Image and dependency scanning
Scanners flag every CVE in your dependencies and base images, but most don't reach exploitable code. Reachability, severity, and fix-availability are how seniors turn a wall of red into a short, ranked list of things that actually matter.
You wire Trivy into CI on a Friday. Monday’s first push fails the build with 312 vulnerabilities, 41 critical. The image is a stock node:20 plus a dozen npm packages you didn’t write. Nobody touched the cryptography this weekend; you just turned on a flashlight that was always pointing at a pile of debt. The temptation is to set --severity HIGH,CRITICAL --exit-code 0 and move on — to keep the light on but unplug the alarm. That’s the wrong reflex, and it’s also the most common one. The real job isn’t finding CVEs; the scanner does that for free. The job is deciding which of the 312 can actually be reached and exploited in your running service — because 280 of them never will be.
By the end of this lesson you’ll know how SCA and image scanners produce that wall of red, and how reachability, fix-availability, and severity turn it into a short ranked list you can act on without going numb to the alerts.
Two scanners, one inventory problem
A container image is a stack of two software supply chains, and you have to scan both. The OS layer is whatever your base image ships — glibc, openssl, zlib, hundreds of Debian or Alpine packages, most of which you never invoke. The application layer is your direct and transitive dependencies: the package-lock.json, go.sum, or requirements.txt that pulls in code you actually call. Both are scanned the same way and it’s worth being precise about how, because the mechanism explains the noise.
A scanner does not analyze your code for bugs. It builds a bill of materials — every package and exact version present — then joins that list against a vulnerability database (the NVD plus distro-specific feeds like Debian Security Tracker, GitHub Advisories, and the language ecosystems). The output is set arithmetic: installed_packages ∩ known_vulnerable_versions. That join is why a fresh node:20 image reports dozens of CVEs on day one — you installed an OS with known-vulnerable packages, and the scanner is reporting a fact, not a judgment. This is what OWASP’s DevSecOps guideline calls Software Composition Analysis (SCA), and it’s the cheapest high-value control you can add to a pipeline. It’s also why turning it on feels like being buried.
Why the wall of red is mostly noise
The set-arithmetic that makes scanning cheap is exactly what makes it noisy. A CVE in installed_packages ∩ known_vulnerable_versions tells you the vulnerable code is present. It does not tell you that the code is loaded, called, or reachable with attacker-controlled input. That gap is the source of nearly all the noise, and it has three distinct shapes a senior learns to recognize.
The three shapes of noise:
- Unreachable code. A CVE in
glibc’s NIS resolver, in a font-parsing path of an image library you only use for thumbnails, or in a CLI subcommand your service never invokes. The vulnerable bytes are on disk; the vulnerable path is dead. Reachability analysis — does any call chain from your entrypoint reach the vulnerable function? — is the single biggest noise reducer, and the newest scanners (and tools like the VEX standard, Vulnerability Exploitability eXchange) exist specifically to record “present but not affected.” - No fix yet (or a
won't-fix). Distros routinely mark low-severity CVEswon't-fixbecause the fix isn’t worth the regression risk. Failing your build on a CVE with no upstream patch just means the build can never go green — you’ve built a ratchet that only tightens. - Severity ≠ exploitability in context. A CVSS 9.8 in a dev-only dependency that’s tree-shaken out of the production bundle is not a 9.8 for you. CVSS scores the vulnerability in the abstract; only you know whether it’s exposed.
| Finding | CVSS | Reachable? | Fix? | Senior action |
|---|---|---|---|---|
| RCE in JSON lib you call on every request | 9.8 | Yes | Patch released | Bump now; this is the alert |
| RCE in a dev-only CLI, tree-shaken out of prod | 9.8 | No | Patch released | Bump opportunistically; don’t gate |
| DoS in glibc NIS path you never invoke | 7.5 | No | won’t-fix | Suppress with VEX + rationale |
| Auth bypass in your web framework | 8.1 | Yes | No patch yet | Compensating control + track |
Where the gate goes, and what it gates on
Knowing the noise shapes lets you place the gate correctly. The wrong design — the one everyone reaches for first — is “fail the build if any CRITICAL exists.” It produces three failure modes in week one: builds that can’t go green (CVEs with no fix), alert fatigue (so the one real RCE scrolls past with the 40 unreachable ones), and the inevitable --exit-code 0 that silently disables the whole control. A scanner that’s been quietly neutered is worse than no scanner, because it lets you believe you’re covered.
The senior gate composes three predicates: severity AND fix-available AND not-suppressed. Fail the build only on a fixable, high-or-critical, unsuppressed finding — because that’s the set where the build failing actually changes someone’s behavior (bump the version). Everything else gets a different workflow: track it, suppress it with a recorded reason, or accept it. The goal is a gate where a red build always means “do this specific thing,” never “go acknowledge 40 things you can’t act on.”
▸Why this works
Why scan the registry continuously, not just at build time? Because the join between your bill-of-materials and the CVE database moves under you. An image that scanned clean on Monday can have three new criticals on Thursday — not because the image changed, but because the database did when a new CVE was published against a package you already shipped. The famous example is Log4Shell: millions of artifacts went from “clean” to “critical RCE” overnight with zero code change. Build-time scanning answers “is this clean now?”; registry/runtime scanning answers “is what we’re running still clean?” — and only the second one catches the CVE disclosed after you deployed.
Cutting the input, not just filtering the output
The cheapest way to win the triage game is to have less to triage. Two structural moves shrink the input before any filtering:
- Minimal base images. A
node:20image is ~1.1 GB and carries the entire Debian userland — a shell,apt, dozens of libraries your app never touches, each a potential CVE. Adistrolessoralpineimage carries only the runtime. Swappingnode:20forgcr.io/distroless/nodejs20routinely drops OS-layer findings from ~150 to single digits, because you deleted the packages, not just suppressed their CVEs. Fewer packages is the only fix that removes the vulnerable code itself rather than arguing about its reachability. - Pin and update deliberately. Lockfiles make scans reproducible (you scan exactly what ships), and a steady dependency-update cadence (Dependabot/Renovate) means you’re applying small patches continuously instead of facing a 200-CVE cliff at the next forced major upgrade.
Your CI scan reports 312 CVEs (41 critical) on a freshly-enabled pipeline. Pick the response a senior ships first.
An image that passed its scan clean on Monday shows 3 new criticals on Thursday with no code or dependency change. What happened?
A CVSS 9.8 RCE is reported in a dev-only build tool that's tree-shaken out of your production bundle. Should it fail the production deploy gate?
Order the triage funnel from raw scanner output to a shippable decision:
- 1 Scanner joins your bill-of-materials against the CVE database → raw findings
- 2 Drop findings whose vulnerable code isn't reachable from your entrypoint
- 3 Separate fixable findings from won't-fix / no-patch-yet
- 4 Rank the fixable+reachable set by severity in your deployment context
- 5 Gate the build on that ranked set; track/suppress the rest with a reason
- 01Explain how an SCA/image scanner produces its findings and why a fresh node:20 image reports dozens of CVEs on day one.
- 02Why is 'fail the build on any CRITICAL' a worse gate than 'fail only on fixable + high/critical + unsuppressed', and how do reachability and registry scanning fit in?
- 03You inherit a pipeline with --severity HIGH,CRITICAL --exit-code 0 (scan reports but never fails) and ~300 standing findings. What's your sequence to make the gate meaningful again without halting delivery?
A container is two supply chains — the OS base image and your application dependencies — and an SCA/image scanner inventories both, then joins that bill of materials against a CVE database. Finding vulnerabilities is therefore free and noisy: a CVE means a vulnerable version is present, not that its code is reachable, exploitable, or even fixable. The senior move is the triage funnel: drop unreachable findings, separate fixable from won’t-fix, rank the rest by exploitability in your deployment, and gate the build only on fixable + high/critical + unsuppressed so a red build always means a concrete action — never “go acknowledge 40 things you can’t fix.” Shrink the input first with a minimal base image and continuous updates, and scan the registry continuously because the CVE database moves under you (Log4Shell flipped millions of unchanged artifacts overnight). The reflex to kill is --exit-code 0: a quietly-neutered scanner is worse than none, because it lets you believe you’re covered.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.