A container image scan comes back with 340 findings. Two hundred of them are CVEs in packages that no process in the running container ever imports, calls, or loads. This is not a scanner defect: it is the direct result of how package-manifest matching works, and it hands security engineers the recurring job of separating what is installed from what is exploitable. That separation is mechanical, not a matter of opinion, once you know which evidence actually settles the question and which evidence just restates the alert.
How package-manifest matching produces findings for code that never runs
Almost every container scanner, whether it is Trivy, Grype, Snyk, or a proprietary engine, works the same way at its core. It reads a package manifest, such as the dpkg or rpm status database inside the image, an apk world file, or an application-level lockfile like package-lock.json, requirements.txt, or go.sum. It extracts a name and a version for every entry, then matches that pair against a vulnerability feed such as the NVD, OSV, or a language-specific advisory database. If the name and version fall inside a reported vulnerable range, the scanner emits a finding.
Nothing in that pipeline touches your application code. A CVE in a BIGNUM parsing routine inside OpenSSL is reported the moment OpenSSL is present at a vulnerable version, regardless of whether your process ever calls that routine. A deserialization flaw in a Python library is reported even if your code only imports the library for one unrelated helper function. The scanner answers “is this version present,” not “is this code path reachable,” and only one of those questions is what an attacker needs answered.
This is why a base image with a large package set produces disproportionately more findings than a minimal one: more packages means more name-version pairs to match, independent of how much of that surface the application exercises.
Distribution backports: why the version number lies about the fix
A second, more subtle source of noise comes from how Linux distributions patch security issues. Debian, Ubuntu, and RHEL do not usually upgrade a package to the latest upstream release when a CVE is fixed. Instead, they backport the specific patch into the version they already ship and append a distro-specific suffix, so openssl 1.1.1n-0+deb11u4 can already contain the fix that upstream shipped in 1.1.1p, while its version string still reads as 1.1.1n.
A scanner that compares the raw semantic version against an NVD record saying “fixed in 1.1.1p” will flag that package as vulnerable, even though the patch is already applied. This is arguably the largest single source of false positives in container scanning for Debian and RHEL based images, and it has nothing to do with reachability: the code path may well be reachable, it simply is not vulnerable anymore.
The fix is to use distro-aware vulnerability data rather than raw NVD version ranges. Grype and Trivy both consume the Debian Security Tracker, Ubuntu’s USN feed, and RHEL’s OVAL data specifically to catch this case; when they report a package as fixed, they are comparing against the backport-aware advisory, not the upstream version number. If you are triaging a finding manually, check changelog.Debian.gz or run dpkg -s <package> and look for the CVE identifier in the changelog before assuming the version string tells the whole story. Consolidating multiple scanners that each ingest a different advisory source, the kind of work covered under scanner integrations, reduces how often this specific class of false positive reaches a human.
Unreachable code, unused binaries, and dev dependencies shipped by accident
Beyond backports, a large share of container findings point at code that is technically inside the image but never executes. Three patterns account for most of it.
- Full base images instead of minimal ones. A general-purpose base image such as python:3.11 ships perl, tar, curl, and a shell, none of which your entrypoint ever invokes. Their CVEs are real, but only if something inside the container actually runs them. Switching to a slim or distroless variant does not fix the underlying vulnerabilities elsewhere, but it removes an entire category of findings that were never reachable in the first place.
- Dev dependencies leaking into production images. A Dockerfile that runs COPY . . and npm install without a multi-stage build, or without npm ci –omit=dev, bundles test frameworks, linters, and build tools into the final image. These packages are never imported by the running process, but they still show up in the SBOM and get matched against the CVE feed.
- Binaries installed but never invoked. A package might be a transitive dependency of a package manager itself, present on disk purely as installation scaffolding, with no cron job, entrypoint script, or application code that ever calls it.
Confirm whether a binary is dead weight by generating an SBOM with Syft, then checking layer history with a tool like dive to see whether the file was ever referenced after the base layer. If the entrypoint script and any supervisor configuration never reference the binary’s path, and no cron entry calls it, that is a strong first signal, not final proof.
Evidence that actually settles the argument: call paths, loaded libraries, config
A developer’s objection that “we don’t use that part of the library” is only as good as the evidence behind it. Four kinds of evidence hold up under audit scrutiny, roughly in order of strength.
- Static reachability analysis. Language-specific tools tell you whether your code, including its transitive call graph, actually reaches the vulnerable function. Go’s govulncheck ./… is the clearest example: it reports only the vulnerabilities whose vulnerable symbols are actually called by your code, separate from the full CVE list your go.sum would otherwise show. Semgrep and Snyk’s reachability analysis do a similar job for other ecosystems, with lower precision for highly dynamic languages.
- Runtime library and process evidence. Attaching strace or checking ldd against a running container shows which shared libraries are actually mapped into the process’s memory. An eBPF-based runtime agent such as Falco can confirm, over a real observation window like thirty days of production traffic, that a binary was never executed and a file was never opened.
- Configuration that disables the vulnerable path. Some CVEs are closed by configuration rather than code. The infamous Log4Shell JNDI lookup is neutralized by log4j2.formatMsgNoLookups=true even on an otherwise vulnerable jar version. If that flag is set and verifiable in the deployed configuration, the reachability question is answered by config, not by code review.
- Network exposure evidence. A vulnerable service that only binds to loopback, or that sits behind a network policy blocking all ingress except from one internal caller, has a materially different exploitability profile than the same service exposed to the internet, even though the CVE and CVSS score are identical in both cases.
Correlating these four evidence types by hand, for every finding, does not scale past a handful of images. Platforms that automate this correlation, such as SITEY, pull the SBOM, the reachability result, and the running configuration together before a finding ever reaches a human triage queue, which is the specific job of an AI triage stage rather than a scoring formula applied to the raw CVE list.
Recording a justified suppression that an auditor will accept
Once you have real evidence, record the suppression in a form that survives an ISO 27001, PCI DSS, or SOC 2 audit, not a Slack message referencing a ticket number. The industry has largely converged on VEX, the Vulnerability Exploitability eXchange format, expressed as a CycloneDX VEX document or the standalone OpenVEX schema. A VEX statement carries a status of not_affected, a machine-readable justification code such as vulnerable_code_not_in_execute_path or vulnerable_code_not_present, and a free-text impact statement naming the specific evidence.
At minimum, an auditor will want to see the following fields attached to the suppressed finding, not stored separately from it:
| Field | Example |
|---|---|
| CVE identifier | CVE-2023-XXXXX |
| Affected component and version | libexpat 2.4.7-1 |
| Justification code | vulnerable_code_not_in_execute_path |
| Evidence reference | govulncheck run, build #4821, link to log |
| Approver | Name and role of the engineer who signed off |
| Review or expiration date | 90 days from approval |
ISO 27001 Annex A control 8.8 expects a documented vulnerability management process with records of how exceptions were evaluated, and PCI DSS requires that compensating controls be reviewed and re-justified on a defined schedule rather than approved once and forgotten. Keeping this record attached to the vulnerability’s own lifecycle, the same object an engineer sees when the finding was first opened, is what turns a suppression from an informal favor into something covered under vulnerability lifecycle tracking rather than a side spreadsheet nobody remembers to update.
Keeping suppressions alive without hiding a future real exploit
A suppression that never expires is how a genuinely exploitable CVE ends up silently ignored six months later. Three practices keep it honest.
First, scope the suppression to the exact package version and, where possible, the image digest, not to the bare CVE identifier. If the CVE ID alone is suppressed, upgrading the package to a version where the vulnerable function suddenly is reachable, because a new feature now calls it, will not surface a fresh alert. Scoping to version and digest forces the suppression to be re-evaluated the moment the underlying artifact changes.
Second, put a hard expiration on every suppression, commonly 90 days, and treat an expired suppression as an open finding again rather than a silent pass. This forces re-verification against the current build on a fixed cadence instead of relying on someone remembering to revisit it.
Third, re-run the reachability check on every build, not just at the time the suppression was written. A function that is unreachable today can become reachable next sprint when a different engineer adds a code path that happens to call it. The mechanism that catches this drift is periodic re-verification: re-running the same reachability and configuration checks against the current build and re-testing the specific finding, the way an automated retest phase does in platforms such as SITEY, rather than trusting that a waiver written three months ago still describes today’s binary.
Handled this way, a suppression is not an act of trust in the developer who filed it. It is a claim backed by evidence, scoped narrowly enough to break automatically when the facts change, and reviewed on a cadence an auditor can verify without asking you to explain it from memory. That combination is what actually answers “this doesn’t affect us,” instead of just asserting it. Coverage across frameworks such as ISO 27001, PCI DSS, SOC 2, GDPR, KVKK, and BDDK depends on exactly this kind of durable, evidence-backed record, which is the underlying reason compliance mapping is treated as a first-class output of the vulnerability pipeline rather than a report generated at audit time.
About SITEY
SITEY is an autonomous vulnerability management platform. It discovers, validates, prioritizes, remediates and re-tests vulnerabilities through an eight-phase automated pipeline, unifying output from 17 integrated scanners. SITEY is self-hosted: it runs in your own infrastructure and your findings are stored there. Outbound connections are limited to licence activation and the optional services you enable, such as an AI provider, CVE enrichment and patch catalogues. Pricing is 599 USD per month or 5,999 USD for a perpetual lifetime license. See pricing or how the platform works.