Security scanners in CI: what should break the build
Adding scanners takes an afternoon. The gating policy is the hard part: block on everything and someone comments the gate out, block on nothing and the findings pile up unread.
Putting SAST, dependency scanning, secret detection, IaC scanning and a DAST baseline into a pipeline is a short task. The hard part arrives a week later, when the pipeline is red on every build because it is blocking on four hundred findings that predate the scanner, and a developer with a deadline does the rational thing and comments the gate out. The opposite failure is quieter and just as common: the scanner runs, blocks nothing, writes its findings to a log, and nobody has opened that log since the day it was set up.
Both failures come from the same missing decision. Before you add a scanner, decide what it is allowed to break the build over, who triages what it finds, and how long a fix has before it becomes a problem. Everything after that is configuration.
In short
Decide the gating policy before you install the tool: what blocks, what warns, who owns triage, how fast fixes are due.
Secrets always block, and the fix is rotation, because the secret is burned the moment it reaches version control.
Gate on new findings, not the whole backlog, so the pipeline stops getting worse without failing on old code.
For dependencies, combine severity with KEV, EPSS and reachability. Severity alone blocks on noise.
Pin third-party actions to a full commit SHA; a tag can be moved to malicious code, as happened in March 2025.
Every suppression needs a written reason and an expiry date, or it becomes a permanent hiding place.
The decision that comes before the tool
A gate is a policy enforced by software, and most teams install the software and skip the policy. Write it down first, in a table your developers can read, so that when a build goes red everyone already knows whether it is supposed to and what to do next. The policy has four columns that matter: what each scanner blocks on, what it only warns on, who owns the triage, and how long a confirmed finding has before it is a breach of your own rules. Here is the shape we deploy, tuned per client, as a starting point.
Scanner
Blocks the build on
Warns only on
Owner
Fix time
Secret scanning (Gitleaks)
any live secret found
historical secrets already rotated
AppSec on-call
rotate same day
Dependency (Trivy)
HIGH or CRITICAL that is reachable and KEV-listed or high EPSS
everything else
service team lead
critical in days, high in 1 to 2 weeks
SAST (Semgrep, CodeQL)
new high-confidence findings on changed code
low-confidence, backlog
service team lead
high in 1 to 2 weeks
IaC (Trivy, Checkov)
HIGH or CRITICAL misconfig exposing data or access
the rest
platform team
high in 1 to 2 weeks
DAST (ZAP baseline)
nothing at first, then specific trusted checks
all findings
AppSec
triage weekly
The table is the contract. Without it, every red build becomes a negotiation, and negotiations under deadline pressure end with the gate turned off.
Secrets always block, and rotation is the fix
A committed secret is the one category where blocking is unambiguous. A live API key or database password in the repository is an active exposure, so the build stops.
The part teams get wrong is what counts as fixing it. Removing the secret from the latest commit does nothing, because it is still in the git history, and anyone who cloned the repository or has the CI cache still holds it. The secret is burned the moment it lands in version control. The fix is to rotate it: invalidate the exposed credential, issue a new one, store it properly, and then clean the history as a secondary step. Treat the scanner as the thing that tells you to rotate, not the thing that makes the exposure go away.
The reason secrets get the strictest rule is the blast radius. A committed database password is a credential that may already be in a fork, a CI cache, a developer’s local clone, and the logs of every build since it landed, which is why it is never a finding you can simply schedule. Scanners like Gitleaks also run in a historical mode across the whole repository, which is worth doing once when you first adopt the tool, because the secret you most need to rotate is often one that was committed, noticed, and “removed” in a later commit months ago while staying in the history the whole time.
The GitHub Action for Gitleaks needs a GITHUB_TOKEN, and organization repositories also need a GITLEAKS_LICENSE secret. Gitleaks runs fast enough to gate on every push, and a pre-commit hook on top catches most secrets before they ever reach the server, which is cheaper for everyone than catching them in CI.
Gate on new findings, not the whole backlog
The single change that makes scanning sustainable is gating on new findings only. Establish a baseline of what already exists on the main branch, and let the gate block a pull request only when it introduces something that was not there before. The old backlog becomes a separate, planned cleanup rather than a wall that fails every build.
This is fair to developers, who should not be blocked by a vulnerability someone introduced two years ago in code they never touched, and it is honest about risk, because the thing you most want to prevent is the pipeline getting worse. A team that stops the bleeding and then burns down the backlog deliberately ends up far ahead of one that tried to block on everything on day one and turned the gate off by Friday.
One caution on baselining: baseline by fingerprint, not by count. A gate that simply allows “the current number of findings” can be fooled, because a developer could remove one old finding and add a new one and stay under the total. Fingerprint each existing finding by its rule and location so the gate blocks a genuinely new one and ignores a genuinely old one, regardless of how the totals move.
Dependencies need a decision rule beyond severity
The naive dependency gate blocks on any high or critical CVE, and it drowns you, because a large modern application pulls in hundreds of transitive packages and some of them always have an open advisory. Severity on its own is too blunt to gate on. You need a rule that weighs severity against real exploitability, and three public signals make that possible.
CISA’s Known Exploited Vulnerabilities catalog lists vulnerabilities known to be exploited in the wild right now, which is the strongest signal that a finding is not theoretical. FIRST’s EPSS estimates the probability that a vulnerability will be exploited in the next thirty days, which lets you rank the long tail of things that are not yet on KEV. And reachability, whether the vulnerable code path is actually called by your application, is the signal that cuts the list most, because a critical CVE in a function your code never touches is a lower real risk than a medium one sitting on your authentication path.
01Severity HIGH or CRITICAL?if not, send it to the backlog and move on
02On CISA KEV, or high EPSS?known-exploited or likely-to-be-exploited raises the priority
03Is the vulnerable code reachable?if your app never calls that path, the real risk drops
04Is a fix available?no patch yet means watch it, do not block on it
05Decisionblock only when it is severe, exploitable, reachable and fixable
Deciding whether a dependency finding breaks the build
Reachability is the hardest of the three signals to automate, and it is worth knowing what your scanner actually does. Some tools only match a package name and version against an advisory, which tells you a vulnerable package is present, not that you call the vulnerable function. Others do call-graph analysis and can say whether the affected symbol is reachable from your code, which is the signal you want. If your scanner cannot do reachability, approximate it in triage: a vulnerability in a dev dependency that never ships, or in a code path behind a feature you do not enable, is a lower real risk than the same severity on your request-handling path, and your gate should reflect that judgment even when the tool cannot make it for you.
A workable policy blocks on vulnerabilities that are high or critical and carry an exploitability signal, KEV-listed or a high EPSS score, and reports the rest for planned updating. Pair the scanner with automated update pull requests from Dependabot or Renovate so the backlog moves on its own instead of waiting for a human to notice.
SAST and IaC, tuned with suppressions that expire
Static analysis is only as good as its tuning. Run a generic ruleset against a real codebase and you get noise, and noise teaches people to approve findings without reading them. Tune the rules to your stack with Semgrep or CodeQL so the findings that fire are the ones that matter in your language and framework, and gate only on new high-confidence results on changed code.
You will still get false positives, and you need a way to dismiss them that does not become a silent escape hatch. “Dismissed by someone, at some point, for some reason” is how real issues hide in plain sight.
Every suppression carries a written reason as well as the approver’s name.
Every suppression has an expiry date and comes back for review when it lapses.
Suppressions live in the repository, in version control, next to the code they excuse.
A monthly job lists suppressions expiring soon, so nothing is buried forever.
Infrastructure-as-code scanning follows the same shape. Gate on the high-severity misconfigurations that expose data or open access, warn on the rest, and keep the suppressions accountable. Here is the general shape in GitHub Actions, with secret, dependency and IaC scanning gating on the levels that matter.
name: security
on: [pull_request]
jobs:
secrets:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # full history, so nothing is missed
- name: Gitleaks
uses: gitleaks/gitleaks-action@<commit-sha> # v2, pinned; a finding fails the job
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} # required for organization repos
deps-and-iac:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Trivy (dependencies, IaC, secrets)
uses: aquasecurity/trivy-action@<commit-sha> # pinned to a reviewed commit
with:
scan-type: fs
scanners: vuln,misconfig,secret
severity: HIGH,CRITICAL # gate only on these
exit-code: '1' # fail the build when found
ignore-unfixed: true # do not block on CVEs with no fix yet
ignore-unfixed: true is a deliberate kindness. There is no point failing a build over a vulnerability that has no patch available, because the developer cannot act on it. It goes on the backlog to watch, not on the gate.
Be clear with yourself about what this snippet does and does not do. It gates on severity alone, which is where most teams start, and it will still fail on some HIGH findings that the decision rule above would let through. Trivy does not read the KEV catalog or EPSS scores for you, so the rule has to sit on top: either a small step after the scan that reads Trivy’s JSON output and checks each CVE against the KEV list and the EPSS feed before deciding to fail, or a human applying the rule in triage until that step exists. Start with the plain severity gate and warn-only mode, then tighten it once the extra step is in place.
Pin your actions to a commit SHA
Notice the <commit-sha> placeholders. Pin third-party actions to a full commit SHA, never a version tag, because a tag can be moved to point at different code. In March 2025 that is exactly what happened to the widely used tj-actions/changed-files action: its tags were repointed to a malicious commit that printed CI secrets into build logs, tracked as CVE-2025-30066. Any workflow that referenced the action by tag picked up the malicious code on its next run.
Your scanners run with access to your source and your tokens, so they deserve at least as much scrutiny as any other dependency. The Trivy action and every other third-party action should be pinned the same way. Dependabot can keep pinned SHAs current by opening a pull request when a new release ships, so pinning costs you nothing in freshness: each update arrives as a change someone reviewed, instead of code that changed under you overnight.
A triage flow and a named owner
The policy only holds if a named person owns triage, because scanners produce findings continuously and findings that nobody is responsible for reading pile up until the whole system is background noise. Give triage an owner per scanner, as in the policy table, and run every finding through the same short path.
01Confirmis it a true positive, and does it affect code you ship?
02Classifymap it to severity, and for dependencies apply the KEV, EPSS and reachability rule
03Decideblock and fix now, backlog with a due date, or suppress with a reason and an expiry
04Fix or routeopen the fix, or the suppression, so the decision is recorded in the repo
05Verifyre-run the scanner on the fix and close the finding only when it is gone
Triaging a finding instead of letting it rot in a log
Give each severity a clock, as the policy table does: criticals in days, highs in a week or two, the rest on the normal backlog. A named owner and a deadline are the two things that separate a working gate from a very thorough way of generating logs nobody reads.
Wire the findings into one place rather than five separate tool outputs. Most of these scanners emit SARIF, and GitHub’s code scanning view ingests it, so a developer sees SAST, dependency and IaC findings as annotations on the pull request diff instead of hunting through job logs. Triage then happens where code review already happens, which is the only place developers reliably look. A finding that appears as a comment on the exact line gets acted on; the same finding buried in a two-thousand-line build log does not.
For teams building banking and government platforms here, the gate doubles as an evidence source. NCA and SAMA assessments commonly ask for evidence that security testing runs and that findings are tracked through to closure, and a pipeline that records what it blocked, who triaged it, and when it was fixed produces that evidence as a by-product. The suppression log with reasons and expiry dates, kept in version control, is exactly the kind of artifact an assessor samples. Build the gate well and you avoid running a second project later to prove you did.
Rollout over four weeks
Turning all of this on at once is how you get the four-hundred-finding red build. Stage it.
01Week 1Secret scanning only, blocking, on every push
02Week 2Add dependency and IaC scanning in warn-only mode; baseline the existing backlog
03Week 3Turn on blocking for new HIGH and CRITICAL findings; keep the backlog separate
04Week 4Add a DAST baseline non-blocking; publish owners and fix-time rules
A four-week rollout that survives contact with developers
Start with secret scanning because it is the highest-value, lowest-false-positive check you can add, it is uncontroversial to block on, and it gets the team used to the pipeline stopping them for a reason that is always real. A dynamic scan, an OWASP ZAP baseline against a staging deployment, catches a different class of issue than the static tools, the things that only appear in the running application, and it belongs in week four precisely because its first findings need human judgement before you let them fail anyone’s build. Promote specific DAST checks to blocking once you trust them, and the gate your team ends the month with is one they keep on.