Security Scanning in Pipelines
Dependency vulnerability scanning and secret scanning, with real GitHub Actions workflow steps.
Why security scanning belongs in the pipeline
A pipeline that only runs functional tests can still ship a change that introduces a known-vulnerable dependency or accidentally commits a real API key. Both are the kind of mistake that's easy to make and expensive to discover late — a leaked credential can be exploited within minutes of becoming public, and a vulnerable dependency sits in production silently until someone finds and reports it, or someone else exploits it first. Automated security scanning applies the same core CI/CD idea (catch problems as early and as cheaply as possible) to security specifically, running on every push and PR rather than relying on an occasional manual audit.
Two categories cover the majority of what a typical pipeline scans for:
- Dependency vulnerability scanning — checking every third-party package your project depends on against a database of known vulnerabilities (CVEs).
- Secret scanning — checking the diff of every commit/PR for anything that looks like a credential (an API key, a private key, a database password) before it's merged.
Dependency vulnerability scanning
Most package ecosystems ship a built-in audit command that checks installed dependencies against a vulnerability database:
- name: Install dependencies
run: npm ci
- name: Audit dependencies
run: npm audit --audit-level=high
npm audit --audit-level=high exits with a non-zero status (failing the pipeline step) if any dependency has a known vulnerability rated high or critical, while ignoring lower-severity findings that would otherwise generate constant noise. The equivalent exists across ecosystems — composer audit for PHP, pip-audit for Python — and the same pattern applies to all of them: run it as its own step, right after installing dependencies, before tests.
A more thorough option is a dedicated scanner like Trivy, which scans not just application dependencies but container images and infrastructure-as-code files too:
- name: Scan for vulnerabilities
uses: aquasecurity/trivy-action@0.24.0
with:
scan-type: fs
scan-ref: .
severity: HIGH,CRITICAL
exit-code: 1
exit-code: 1 is what actually turns a finding into a failed pipeline step — without it, many scanners report findings but exit successfully regardless, which silently defeats the entire point of running them in CI in the first place.
Secret scanning
Secret scanning inspects a commit or pull request's diff for patterns that look like real credentials — an AWS access key, a private key block, a database connection string with an embedded password — and fails the pipeline (or blocks the push entirely) before it merges. Gitleaks is a widely used open-source scanner for this:
- name: Scan for leaked secrets
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GitHub also offers this natively for public repositories (and for private ones on paid plans) as secret scanning, which checks pushed content against known credential patterns from major providers (AWS, Stripe, GitHub tokens themselves) with no extra workflow configuration needed, and can additionally push-protect — reject the push outright, before it even lands in the repository — rather than only flagging it after the fact.
The key operational difference between these two layers is when they catch a leak: a pipeline step like Gitleaks catches it after the commit already exists in the repository's history (the leak is caught, but the secret was briefly committed); push protection catches it before the push completes at all, so a caught secret never enters history in the first place. Both are worth having — push protection as the first line of defense, a pipeline scan as a second check that also covers anything push protection doesn't (an older commit range in a large PR, for instance).
A complete workflow combining both
# .github/workflows/security.yml
name: Security Scans
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
dependency-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm audit --audit-level=high
secret-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # gitleaks needs full history, not a shallow clone
- name: Scan for leaked secrets
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0 matters specifically for the secret-scanning job: actions/checkout clones a shallow, single-commit checkout by default, which is fine for running tests against the current code but hides the commit history a secret scanner needs to inspect for anything leaked in an earlier, not-yet-merged commit within the same branch.
What to do when a scan finds something
A dependency finding usually means upgrading to a patched version (npm audit fix, or a manual version bump if an automatic fix isn't available) — and if no patched version exists yet, evaluating whether the vulnerable code path is even reachable in your usage before deciding how urgently to act. A leaked secret is a different kind of urgent: the credential must be revoked and rotated immediately, not just removed from the code — a secret that was ever committed, even briefly, should be treated as compromised forever, since it may already have been cloned, cached, or scraped by an automated bot before you noticed. Rewriting git history to remove the commit is good hygiene but does not undo a leak that already happened; rotation is the step that actually closes the exposure.
Common mistakes
- Running a vulnerability scanner without failing the build on its findings — a report nobody reads is barely better than no scan at all.
- Deleting a leaked secret from the latest commit without rotating the actual credential — the old value is still valid and still sitting in git history (and possibly already scraped) even after it's removed from the current code.
- Scanning only on pushes to
maininstead of also on pull requests — this catches a problem after it's already merged instead of before, defeating the "as early as possible" point of putting the scan in CI at all. - Treating a clean scan as a one-time guarantee — new CVEs are published constantly against dependencies you already have installed, which is why the scan needs to run on every single build, not just once at project setup.