✓ Human-authored analysis; AI used for formatting and proofreading.
Vulnerability management is the fourth domain where the same structural gap produces the same structural failure.
The principle says: don't ship vulnerable code. The implementation: scan every container image, every dependency tree, every base layer for known CVEs. Report everything. Demand developers patch everything. Treat every "Critical" finding as an emergency. The scanner found a vulnerability in libxml2. Is the application actually calling libxml2? Nobody knows. Patch it anyway. The scanner found a vulnerability in grep. Is grep even invoked at runtime? Nobody knows. Patch it anyway. The scanner found 247 CVEs in the base image. How many are reachable from the application's code paths? Nobody knows. Patch all 247.
If you've read the previous articles in this series — Least Privilege, Microsegmentation, DLP — the pattern is familiar. The missing artifact is different. The structural failure is identical.
The Principle and Its Hidden Assumption
"Don't ship vulnerable code" assumes you know which code is vulnerable and which vulnerable code matters. Scanners answer the first question well. Nobody answers the second.
A container image contains the application code, its direct dependencies, their transitive dependencies, the language runtime, the base OS packages, and every utility that was included in the base image. A typical production container has 200-400 packages. The application's code calls functions in perhaps 10-30 of them directly. Another 20-50 are transitive dependencies called by the direct dependencies. The remaining 100-300 packages are base image artifacts: shells, text utilities, package managers, debugging tools, and libraries included because the base image was built for general purpose use, not for this specific application.
A vulnerability scanner reports CVEs for all of them equally. A critical vulnerability in a library the application calls on every request and a critical vulnerability in a text processing utility the application never invokes both appear as "Critical" in the same report. The CVSS score is the same. The remediation priority is the same. The risk is not even in the same category. One is exploitable on every request, the other is exploitable only if an attacker can somehow invoke a binary that the application never calls, in a container that may not even have a shell.
The scanner can't distinguish between them because the scanner doesn't know which code the application executes. That knowledge — the reachability map is the missing specification.
The Three Mismatches
Mismatch 1: Granularity
Scanners operate at package granularity. Risk operates at function granularity.
A CVE is filed against libpng version 1.6.39: a heap buffer overflow in the png_read_IDAT_data function. The scanner reports: "Critical — CVE-2023-XXXXX in libpng 1.6.39." But the application uses libpng only through a high-level wrapper that calls png_read_info and png_get_image_width. It never calls png_read_IDAT_data. The vulnerable function is never invoked. The vulnerability exists in the package. It doesn't exist in the application's execution.
The gap between package-level scanning and function-level risk is enormous. A study by Endor Labs found that fewer than 9.5% of CVEs in open-source dependencies are reachable from the application's code. The industry treats 100% of CVEs as actionable. Fewer than 10% are actionable. The 90% that aren't consume developer time, create patch fatigue, and train developers to ignore security findings because most of them don't matter.
Mismatch 2: Time
Vulnerability databases update continuously. Reachability context doesn't exist at all.
A new CVE is published on Tuesday. The scanner runs on Wednesday and flags every container image that includes the affected package. Development teams receive tickets on Thursday. By Friday, they're evaluating whether to patch without any information about whether the vulnerability is reachable from their application's code paths.
The evaluation is manual. A developer reads the CVE description, identifies the vulnerable function, traces through the application's code to determine whether any execution path reaches that function, evaluates whether the preconditions for exploitation are met in their specific environment, and decides whether to patch or accept the risk. This analysis takes hours per CVE. There are 247 CVEs in the report. The developer has other work.
So they take the same shortcut everyone takes. Update the package to the latest version without evaluating reachability. If the update breaks nothing, ship it. If it breaks something, file a ticket to investigate later. The patching is version bumping without understanding. Upgrading a library to close a vulnerability that may not have been exploitable, while potentially introducing regressions in functionality the application uses.
The temporal mismatch: CVE publications are instantaneous and continuous. Reachability analysis is manual and never done. The vulnerability is flagged in minutes. The context needed to prioritize it doesn't exist.
Mismatch 3: Composition
Individual packages are scanned in isolation. Exploitation chains across packages are not evaluated.
A vulnerability in libcurl allows an attacker to inject headers. A vulnerability in openssl allows a specific malformed certificate to cause a buffer overflow. Neither is exploitable alone from the application's code path. But if the application calls libcurl with user-controlled URLs, and libcurl calls openssl for TLS, the two vulnerabilities chain: the injected header triggers a TLS renegotiation with a malformed certificate, which triggers the buffer overflow.
The scanner reports two separate CVEs with separate CVSS scores. The combination is the actual risk. It is invisible because scanners evaluate packages independently, not as a call graph. The compound exploitation path exists in the function call chain, not in any individual package. This is the same composition mismatch as IAM trust chains, network paths, and data lineage. Per-component evaluation misses the cross-component path.
The Symptom Treatment Industry
The vulnerability management industry has built the same symptom-treatment ecosystem as the other three domains:
SCA scanners (Snyk, Grype, Trivy, Dependabot) scan dependency manifests and container layers for known CVEs. They report every CVE in every package regardless of reachability. A typical scan of a production container image produces 50-300 findings. Teams triage by CVSS score. But CVSS measures theoretical severity, not contextual exploitability. A CVSS 9.8 in an unreachable library is less risky than a CVSS 5.3 in a library called on every request. The newer EPSS (Exploit Prediction Scoring System) attempts to address this by predicting how likely a CVE is to be exploited in the wild. A step toward risk-driven prioritization. But EPSS predicts exploitation likelihood globally, not reachability in your application. A CVE with high EPSS but zero reachability in your call graph is still not your problem. CVSS without reachability is severity without context. EPSS without reachability is probability without relevance. Both are workarounds for the missing call graph.
Vulnerability management platforms (Qualys, Tenable, Rapid7) aggregate scanner findings across the estate. They add dashboards, trends, SLAs, and integration with ticketing systems. The findings flow from scanner to platform to JIRA ticket to developer. But the fundamental quality problem is unchanged. The findings are package-level, not function-level. The developer receives the ticket, sees "Critical CVE in libxml2," and must manually determine reachability. The platform made the workflow faster. It didn't make the signal better.
Automated dependency updates (Dependabot, Renovate) create pull requests to bump vulnerable packages to patched versions. This solves the mechanical problem of creating the update. It doesn't solve the prioritization problem. Every CVE gets a PR, regardless of reachability. The developer reviews 20 automated PRs per week. Most of them patch unreachable vulnerabilities. The developer approves them all without review because reviewing each one takes longer than accepting the risk. Automation without prioritization produces faster noise.
VEX (Vulnerability Exploitability eXchange) is the industry's attempt to add exploitability context to CVE reports. A VEX document states whether a vulnerability is exploitable, not exploitable, under investigation, or fixed. This is the right direction. But VEX documents are authored manually by maintainers or vendors, one CVE at a time. They don't scale to 247 CVEs per container image. And they describe exploitability in general, not exploitability in your application's specific call graph. A VEX statement says "this vulnerability is exploitable if the application calls function X." Whether your application calls function X remains your problem to determine.
The Missing Artifact
The artifact that would make vulnerability management actionable is a reachability map. A machine-readable representation of which packages, libraries, and functions the application calls at runtime.
SBOM answers the question: "which packages are in the container".
Scanner output answers the question: "which packages have known CVEs".
Reachability map answers the question: "which packages are invoked by the application's execution paths?". It doesn't exist as a standard, enforced artifact.
REACHABILITY MAP (the missing artifact):
application: order-service
reachable:
- package: express@4.18.2
functions_called: [createServer, Router, json, urlencoded]
call_depth: direct
- package: pg@8.11.3
functions_called: [Pool.connect, Pool.query, Client.end]
call_depth: direct
- package: jsonwebtoken@9.0.1
functions_called: [verify, decode]
call_depth: direct
- package: openssl@3.0.12
functions_called: [TLS_client_method, SSL_CTX_new]
call_depth: transitive (via pg)
present_but_unreachable:
- package: libxml2@2.10.3
reason: "Included in base image, not imported by application"
- package: curl@8.1.2
reason: "Base image utility, no application code path invokes it"
- package: imagemagick@7.1.1
reason: "Transitive dependency of abandoned dev tool, never loaded"
If this map existed, a CVE in libxml2 would be automatically deprioritized — "present but unreachable, base image artifact, no application code path." A CVE in pg would be automatically escalated — "direct dependency, called on every database query, functions Pool.connect and Pool.query are in the vulnerable path." The scanner result plus the reachability map produces prioritization that reflects actual risk, not theoretical severity.
Chesterton's Fence
Every package in the container image is a Chesterton's Fence. Is imagemagick in the container because the application processes images, or because a developer added it two years ago for a feature that was never shipped, or because the base image includes it? Nobody knows. Nobody dares remove it because nobody knows if something depends on it. The package stays. The CVE stays. The finding stays in the queue. The developer ignores it alongside 246 others.
The reachability map is Chesterton's Fence made auditable. A package in the present_but_unreachable list can be removed with confidence. The map proves no execution path invokes it. A package in the reachable list with documented call paths can be evaluated: "Is this CVE in a function we call?" The map doesn't just prioritize vulnerabilities. It enables the removal of packages that should never have been in the image — reducing the attack surface.
Stewart Brand's Shearing Layers
The same rate-of-change separation applies:
Application code (which libraries the application imports and calls)
→ changes weekly — new features, refactored modules
Container image (which packages are included in the image layers)
→ changes at build time — base image updates, dependency locks
Vulnerability database (which CVEs exist for which package versions)
→ changes daily — new disclosures, severity updates
The vulnerability database changes daily. The container image changes at build time. The application's call graph. The link between them is never recorded. The scanner compares the container's packages against the vulnerability database and reports every intersection. Without the call graph, every intersection is treated equally. A daily-changing signal (new CVEs) is evaluated against a build-time artifact (the container) without the linking context (which packages are actually used). The result is noise at the rate of CVE publications, which at 40,000+ CVEs per year, is overwhelming.
Declared reachability separates these layers:
Layer 1: CALL GRAPH (changes when application code changes)
"order-service calls pg, express, jsonwebtoken"
→ updated automatically by static analysis at build time
→ reflects what the application actually imports and invokes
Layer 2: PRIORITIZED FINDINGS (changes when CVE database or call graph changes)
CVEs filtered through reachability — only findings for packages
in the call graph are escalated
→ computed, not manually triaged
→ a CVE in an unreachable package is informational, not critical
Layer 3: DEPLOYED IMAGE (verified against Layer 2)
The actual container image in production
→ verified to contain only packages in the call graph plus declared exceptions
→ any unreachable package without an exception = finding: "remove from image"
The Industry is Partially Waking Up
Unlike the IAM, network, and data domains, the vulnerability management domain has begun to address the missing specification. The adoption remains early.
Reachability analysis tools (Endor Labs, Semgrep Supply Chain, Snyk with DeepCode) perform static analysis on the application's source code to determine which functions in which dependencies are called. When a CVE is filed against a specific function, these tools can determine whether the application's code path reaches that function. This is the correct architecture. The scanner result plus the call graph produces prioritized, actionable findings.
The limitation: static reachability analysis is conservative. It overapproximates — if there's any code path that could reach the vulnerable function, it reports it as reachable, even if that path requires conditions that never occur at runtime. Dynamic analysis (profiling execution) is more precise but requires instrumentation and can't cover all paths. There is also a blind spot neither approach fully resolves: reflection and dynamic loading.
In languages like Java and Python, an application can invoke a library at runtime through Class.forName() or importlib.import_module() — calls that a static analyzer cannot trace because the target is a string resolved at runtime. This is the vulnerability equivalent of the dynamic multi-tenant resource problem from the data article. The call graph has the same limitation as the data schema: static analysis covers the majority of paths, but runtime-resolved paths escape it. Both static and dynamic analysis are still better than no reachability analysis at all.
SBOMs (Software Bill of Materials) are becoming mandated. The US Executive Order 14028 requires them for software sold to the federal government. SBOMs list what's in the container. They don't list what's used. An SBOM is the ingredient list on a food package. The reachability map is the recipe. It tells you which ingredients are combined. An SBOM without reachability tells you libxml2 is present. It doesn't tell you whether the application would work identically without it.
Distroless and minimal base images (Google Distroless, Alpine, Chainguard) attack the problem from the opposite direction. Instead of analyzing which packages are reachable, remove everything that isn't the application. A distroless image contains the application binary and its runtime dependencies. No shell, no package manager, no text utilities, no debugging tools. The attack surface is by exclusion. This doesn't solve reachability for the remaining dependencies. But it eliminates the 100-300 base image packages that are never invoked. The CVE count drops from 247 to 30. The remaining 30 are more likely to be reachable.
One nuance reinforces why distroless matters: some libraries are reachable even if the application code never explicitly calls them. Libraries like libc and openssl are used by the language runtime itself — to start the process, to resolve DNS, to establish TLS connections. The application's call graph doesn't show these calls because they happen below the application layer, in the runtime or the OS. A CVE in libc is reachable by definition in any container that runs a process. The static call graph won't show it, but the exploit path exists. This is the one category where package presence is a sufficient signal for reachability, and it's precisely why distroless images are so powerful: they minimize the set of implicitly-reachable OS-level libraries to the absolute minimum the runtime requires.
The Identical Pattern
Four articles, four domains, one structural gap:
| Domain | Principle | Missing artifact | Symptom treatment |
|---|---|---|---|
| IAM | Least privilege | Intent specification | Granted-vs-used analyzers |
| Network | Microsegmentation | Application dependency map | Flow log analyzers |
| Data | Data protection | Typed data schema | DLP content scanners |
| Vulnerability | Patch all CVEs | Reachability map / call graph | Package-level SCA scanners |
Each principle assumes a specification exists. Each tool measures compliance against a proxy (usage history, traffic patterns, content patterns, package presence) because the specification doesn't exist. Each tool generates findings nobody acts on because the proxy conflates present with relevant. The fix is the same: declare what's used, derive prioritization from the declaration, and verify the deployed artifact against the derived expectations.
The Path Forward
Start with the containers that matter most. The internet-facing services, sensitive data processing, the ones with the highest CVE counts. Generate the reachability map through static analysis at build time. Filter scanner findings through the map. Escalate reachable CVEs. Deprioritize unreachable ones. Remove packages that are present but never invoked.
The reachability map doesn't need to cover every container on day one. It needs to cover the containers an attacker would target. Each map is a ratchet. Once reachability is known, prioritization is automatic, and the developer's time is spent on the 9.5% that matters rather than the 100% that was reported.
The call graph can be generated automatically. Static analysis tools trace imports, function calls, and dependency chains. Unlike the IAM intent specification or the application dependency map, which require a human to declare intent, the reachability map can be computed from the code itself. This is the one domain in the series where the missing artifact is derivable rather than declarable. The code is the specification of what's used. The tools to extract reachability from code exist. The industry just hasn't connected them to the vulnerability management pipeline as a standard practice.
This makes vulnerability management the easiest of the four problems to solve technically. The artifact can be computed. It may also be the hardest to solve culturally. The entire compliance and audit ecosystem is built around "patch all Critical CVEs within 30 days." SLAs are measured by CVE count, not by reachable CVE count. Regulators check whether you patched, not whether the vulnerability was exploitable. Telling an auditor "we didn't patch this Critical CVE because our call graph shows it's unreachable" requires a level of technical confidence and auditor sophistication that most organizations don't have. The industry is doing compliance-driven security (patching to make a report green) rather than risk-driven security (patching to break an exploit chain). The call graph is the bridge between those two worlds.
Vulnerability management is the symptom of not knowing which code matters. Know which code the application executes, and vulnerability management becomes targeted remediation of real risks. Without that knowledge, it remains what it is today: patch everything, hope for the best, and train developers to ignore security findings because 90% of them don't apply.
Top comments (0)