Skip to main content
DevOps Insightsguides & answers

CI/CD · troubleshooting

The vulnerability scan blocked the build

A scanner found something and stopped the pipeline, and the question is not how to make it pass. It is whether this finding is reachable, exploitable and fixable, and those are three separate checks.

Reproduced on npm 11.12.1, . Where the text says “reproduced”, the behaviour was run on that version, not taken from documentation.

First published

New to this? Read what is actually happening first, then come back to the steps.

On this page · 10 sections

Before you start

  • The scan output from the failed run, and the exact image digest or lockfile it scanned.
  • The scanner CI runs, with the same flags (--severity, --ignore-unfixed, --exit-code) and the same ignore file, so your results match the gate's.
  • Read access to the manifests, lockfiles and Dockerfile at both the last passing commit and the failing one.
  • Someone with the authority to accept a risk, if the answer is to accept rather than fix. An acceptance needs an owner and an expiry date.

Run this first

step 1 of 4
git diff --stat <last-passing-commit> <failing-commit> -- package-lock.json go.sum requirements.txt Dockerfile

Look for No manifest change in the failing commit

Establishes whether your change caused this at all. A failing scan on an unchanged manifest means a new advisory, which is a different conversation from a dependency you just added.

Work out which cause you have

A few questions to narrow the list. Every answer ends in a command that confirms or rules the cause out — this cannot see your cluster, so nothing here is a certainty until you have checked.

Narrow it down

0 answered · nothing is sent anywhere

Did the failing commit change a dependency manifest or the base image?

If not, an advisory was published rather than anything of yours changing.

Cause space

6 of 6 still possible

  • A new advisory landed against your base imageCommon
  • The vulnerable code is present but never calledCommon
  • It is in a build or test dependency, not in the shipped artifactCommon
  • There is no fixed version yetOccasional
  • It is real, reachable and fixableOccasional
  • The match itself is wrongRare

Nothing ruled out yet. Answer the question above and the branches your answer eliminates will strike through here.

Check it with a tool

Or diagnose it manually

In this order. The first command usually contains the whole answer.

  1. Step 1

    git diff --stat <last-passing-commit> <failing-commit> -- package-lock.json go.sum requirements.txt Dockerfile

    Establishes whether your change caused this at all. A failing scan on an unchanged manifest means a new advisory, which is a different conversation from a dependency you just added.

    Look for No manifest change in the failing commit

  2. Step 2

    trivy image --severity HIGH,CRITICAL --ignore-unfixed <image>

    Removes the two largest noise categories at once — low severity, and anything nobody can fix. What remains is usually small enough to read.

    Look for How many findings survive. This number, not the original total, is the size of the problem

  3. Step 3

    npm ls <package> --omit=dev

    Separates what ships from what builds. A finding present only in the dev tree is a different risk with a different owner.

    Look for Absence from the production dependency tree

  4. Step 4

    grep -rn "<package-name>" --include='*.ts' --include='*.js' --include='*.py' --include='*.go' src/

    Crude reachability. Not conclusive — a transitive call path will not show up — but a package your code never imports is a much weaker finding.

    Look for No import anywhere in your own source

Every cause, and how to fix it

Ordered by how often each one turns out to be the answer.

A new advisory landed against your base image

Common

Nothing in your change caused it. An advisory was published against a package in the base image, and the same commit that passed yesterday fails today. It explains a scan that starts failing with no change on your side, which is why 'what did I change' can be the wrong first question here.

Confirm

git diff --stat <last-passing-commit> <failing-commit> -- package-lock.json go.sum requirements.txt Dockerfile

No dependency or base-image change in the failing commit. If the manifest is untouched, the advisory moved rather than your code

Fix

  • Rebuild against a current base image tag first — most OS-level findings clear without touching your code at all.
  • Rebuild on a schedule rather than only when code changes, so this arrives on a Tuesday rather than during a release.
  • If no fixed base exists yet, record the acceptance with an expiry date rather than an open-ended ignore.
docker build --pull --no-cache -t <image> .

--pull fetches the newest image for the base tag; --no-cache re-runs every step, so package installs pick up patched versions. They do different things, so use both.

The vulnerable code is present but never called

Common

The package is in the image and your application does not import it, or imports it and never calls the affected function. A scanner working from the package inventory cannot tell the difference. This is why severity alone is a poor priority signal.

Confirm

grep -rn "<package-name>" --include='*.ts' --include='*.js' --include='*.py' --include='*.go' src/ || echo 'not imported anywhere in src'

No import at all, which makes it a transitive dependency of something else rather than code you call. Where your ecosystem supports reachability analysis, prefer that over grep

Fix

  • Record the reachability finding with the evidence, so the next person does not repeat the analysis.
  • Prefer scanners and flags that do reachability analysis where your ecosystem supports it — it removes this category automatically rather than one ticket at a time.
  • Do not mark it fixed. It is accepted, which is a different state and should expire.

It is in a build or test dependency, not in the shipped artifact

Common

A vulnerability in a test framework or a build tool is not in production unless your build environment is itself a target — which is a real concern, but a different one with a different owner. Whether a repository scan conflates them depends on the tool: npm audit includes dev dependencies unless you pass --omit=dev, while Trivy leaves npm, Yarn, pnpm and Bun dev dependencies out unless you pass --include-dev-deps.

Confirm

npm ls <package> --omit=dev   # or: pip show <package> in the shipped image (Required-by) / go mod why -m <module> (counts tests too, so a path it prints may be test-only)

The package absent from the production dependency tree while present in the full one

Fix

  • Scan the built artifact rather than the source tree, so what ships is what is judged.
  • Use a multi-stage build so build-time tooling does not reach the final image.
  • Treat build-environment vulnerabilities as a pipeline-security concern in its own right rather than ignoring them outright.

There is no fixed version yet

Occasional

The advisory is real, the code is reachable, and the maintainer has not released a fix. Assigning this to somebody produces a ticket that cannot be closed, which is how teams learn that security tickets are noise.

Confirm

trivy image --ignore-unfixed <image>   # or the equivalent flag for your scanner

The finding disappearing, which means Trivy's data lists no fix for this package on this platform: its status is affected, will_not_fix, fix_deferred or end_of_life. A newer base image or distribution release may still carry a fix

Fix

  • Accept it explicitly, with an owner, a stated mitigation if one exists, and a date to re-check — not an indefinite ignore.
  • Consider whether a control elsewhere reduces the exposure: a WAF rule, an input constraint, removing the feature that reaches the code.
  • Configure the gate to distinguish fixable from unfixable. Blocking on something nobody can fix is the fastest route to the gate being disabled.

It is real, reachable and fixable

Occasional

The case the gate exists for. Reachable from your code, plausibly reachable from untrusted input, and a fixed version is available. These are the minority of findings and the entire justification for the other four categories of work.

Confirm

npm audit --omit=dev   # or: trivy image --severity HIGH,CRITICAL --ignore-unfixed <image>

A fixed version, against a package your code demonstrably calls on a path reachable from input

Fix

  • Upgrade, test, and ship — and ship it as its own change rather than bundled into a release, so a regression is attributable.
  • Read what changed rather than only bumping the number; a major-version fix can carry behaviour you did not want.
  • If the upgrade is blocked by an incompatibility, that is a scheduling problem to escalate, not a finding to ignore.

The match itself is wrong

Rare

Package naming is inconsistent across ecosystems and advisory databases, so a scanner can match the wrong component, or a version range can be recorded incorrectly upstream. Genuine, and rarer than it feels when you are the one blocked.

Confirm

# Compare the advisory's affected range against the version you actually ship

Your version falling outside the stated range, or the matched package being a different project with a similar name

Fix

  • Report it upstream to the scanner or the advisory source; it is wrong for everyone else too.
  • Suppress it narrowly — that CVE, that package, with a comment explaining why — never by relaxing severity thresholds.
  • Re-check periodically. Advisory data gets corrected.

Understanding it properly

Skip this if you are mid-incident — the working part of the page is above. Worth reading afterwards, because understanding the mechanism is what stops the next one.

In plain terms

A security scanner compared the software packages in your build against public lists of known vulnerabilities, found matches, and stopped the pipeline. A match means a vulnerable version is present, not that your app can be attacked through it. Before changing anything, work out whether the package ships, whether your code uses it, and whether a fixed version exists.

Does it ship, or only build?

Reproduced with npm 11.12.1 (Node.js 25.9.0) on a scratch project with a hand-written node_modules and lockfile, so nothing was installed or downloaded. The package names are made up: parser-lib 3.1.4 stands in for a flagged package that arrives only through the dev dependency test-runner. The project path is replaced with <path>.

Command

npm ls parser-lib
npm ls parser-lib --omit=dev

Output

app@1.0.0 <path>/app
`-- test-runner@2.0.0
  `-- parser-lib@3.1.4

app@1.0.0 <path>/app
`-- (empty)

How to read it

  • The first block, from npm ls parser-lib, shows the only path to the flagged package: it comes in through test-runner, a dev dependency.
  • The second block, with --omit=dev, is (empty), and npm exited with status 1. A production install does not contain the package.
  • That moves the finding from production to your build and test environment. It does not make it disappear, since CI still runs test-runner.
  • Scanners differ on this: Trivy leaves npm, Yarn, pnpm and Bun dev dependencies out unless you pass --include-dev-deps, while npm audit includes them unless you pass --omit=dev.

Words used on this page

Advisory (CVE)
A published record of a vulnerability: which package, which versions are affected, and which version fixes it.
Severity
The rating on a finding, from LOW to CRITICAL. Trivy takes it from the vendor's advisory where one exists, because vendor severity is more accurate, and falls back to NVD.
Status
Whether a fix exists for this package on this platform. Trivy records fixed, affected (no patch yet), will_not_fix, fix_deferred and end_of_life, among others.
Reachability
Whether your code actually calls the vulnerable function. Most scanners cannot tell; Go's govulncheck reports only vulnerabilities in functions your code calls.
Dev dependency
A package needed only to build or test, listed under devDependencies. npm ls <package> --omit=dev shows whether it is in the production tree.
Base image
The FROM image your Dockerfile builds on. Its operating-system packages are scanned with yours, so a new advisory against it can fail a build you did not change.
Ignore file
A list of accepted findings, such as .trivyignore.yaml, which can limit an entry to one package or path, record a reason, and stop applying after an expired_at date.
Exit code gate
What actually blocks the build. Trivy exits 0 even when it finds issues unless you pass --exit-code, and npm audit exits non-zero according to audit-level.
What is actually happening

A scanner compares the components in your artifact against advisory databases and reports matches. Most scanners stop there. They do not know whether your code calls the vulnerable function (Go's govulncheck is an exception that follows the call graph), whether untrusted input can reach it, or whether the fix exists — so a finding is a starting point rather than a verdict.

Two responses are common and both are bad. Bumping versions until the scan passes fixes whatever happened to be flagged and teaches nobody anything. Adding an ignore entry, or disabling the gate, removes the signal along with the noise — and it is almost always done under release pressure, which is precisely when the judgement is worst.

The useful move is a sequence: is it reachable, is it exploitable, is there a fix. Many findings can fail the first test, which is why a long scan report may contain only a few worth acting on.

One thing worth saying plainly: a blocked build is the control working. The problem is almost never that the gate fired — it is that the gate was configured to fire on more than anyone can triage.

How to tell this is your problem
WhereWhat you see
The pipelineA scan step exiting non-zero with a list of CVEs and severities
The reportFar more findings than the change introduced — most come from the base image
TimingOften on a build with no dependency change, because a new advisory was published

How to know it is actually fixed

  • The scan passes without any new suppression having been added.
  • Where something was accepted rather than fixed, it has an owner, a reason and an expiry date recorded.
  • Re-run against a deliberately vulnerable image to confirm the gate still fires. A gate that passes everything is indistinguishable from a gate that is off.
  • The fix shipped as its own change, so a regression can be attributed to it.

Stopping it happening again

  • Scan the built artifact rather than the source tree, so the judgement is about what ships.
  • Rebuild on a schedule, not only on code change. Most base-image findings clear on a rebuild and arrive far more calmly on a Tuesday.
  • Gate on fixable findings only. Blocking on something with no available fix guarantees the gate gets disabled.
  • Run a new scanner in warn mode for a few weeks before it can block anything, so you learn its false-positive rate on your own codebase first.
  • Use a minimal base image — fewer packages means fewer advisories to triage, which is the cost that actually recurs.
  • Give acceptances an expiry. An indefinite ignore is a decision nobody will revisit.

Security considerations

  • Trivy exits 0 even when it finds issues; the build blocks only because someone added --exit-code. Removing that flag, or the step, turns the gate off without any error, so re-test the gate against an image you know is vulnerable.
  • --ignore-unfixed hides more than 'no patch yet'. It is shorthand for ignoring affected, will_not_fix, fix_deferred and end_of_life, so findings the vendor does not intend to fix also vanish from the gate. Keep them in a report that does not block.
  • Suppress one finding at a time. .trivyignore.yaml can limit an entry to a package URL or a path, record a statement, and stop applying after expired_at; use that instead of raising the --severity threshold.
  • Read the diff before running npm audit fix --force. npm documents that it installs versions outside your declared ranges, including SemVer-major changes, which can change behaviour while clearing the scan.
  • If you pin your base image by digest, docker build --pull will not move you to a patched image. Docker notes that a digest pin keeps the same image even when the publisher replaces the tag, so update the pin on purpose.

Sources

Behaviour described here is drawn from official documentation. Where a figure could not be confirmed on an official page it is attributed in the text rather than stated as canonical.

Did this get you to an answer?

No text box on purpose — please do not paste production logs anywhere

Something wrong or out of date? Report it