Stop Paying to Triage Vulnerabilities That Were Never Reachable

Every high-CVSS finding a scanner hands you demands the same thing: someone's attention. If you've pointed an AI agent at the queue, it demands the same token spend too. And most of that attention and spend goes to findings that were never reachable in your environment in the first place — a Critical CVE in a build-stage layer that never ships, a dependency imported by a CLI nobody has run since 2023, a package that only executes in a browser where the vulnerable path can't fire. The work was real. The risk was not.

That waste matters more now than it used to. Defenders are racing to automate security work end-to-end, and so are attackers: cheap, capable open-weight models let a small crew scale offensive capability without scaling headcount. In that race, the side that wins is the one that spends its human and AI effort only where it moves an outcome. Nullify's whole premise is decoupling security outcomes from headcount — automation you can control and measure, pointed at the findings that are genuinely exploitable and nothing else. Deciding severity by what's actually deployed is one of the sharpest levers we have for that, because it strips out the wasted effort before a human or an expensive reasoning agent ever picks the finding up.

The reason the waste exists at all is that almost every scanner on the market treats the CVSS score printed on a vulnerability as the final word on how much you should care. A dependency with a 9.8 lands as Critical whether it ships in your internet-facing checkout service, sits in a build-stage layer that never reaches the runtime image, or is imported by a tool nobody runs. The score describes the worst case the vulnerability can produce anywhere in the world. It says nothing about whether that worst case is possible in your environment. The only durable fix is to answer a question the scanner can't: given this finding, is the affected artifact actually deployed, and is it exposed? This post is about how we answer that using the cloud side of the fused code, cloud, and business graph our agents read from, and how the answer moves severity before anyone — human or model — looks at the finding.

Severity is a claim about the worst case; triage should be a claim about your case

We separate two things that most tools blur together. Severity is our worst-case estimate of impact assuming the vulnerability is real and reachable. Priority is what we actually ask you to act on, and priority folds in confidence and context: exploitability, reachability, blast radius, and fix effort. As one of our engineers put it internally, the difference between severity and priority is confidence — we assume the worst case with severity, and nothing gets marked urgent that is low confidence, because we don't want to spend anyone's time fixing something until we're sure it matters.

Cloud context is the machinery that supplies that confidence for deployment and exposure. It's the difference between "this package has a critical CVE" and "this package has a critical CVE, it ships in the browser bundle of a document-upload flow that handles ID documents, and that flow is served behind a public CloudFront distribution." The first is a score you can read off a database. The second is enough to decide what to do about it.

Matching build artifacts to live infrastructure

The core mechanism is deterministic and runs before any LLM sees the finding. Our artifact matcher takes the build artifacts declared in a repository's context — container images, S3 buckets — and finds which compute nodes in the customer's infrastructure graph actually run or access them: Lambda functions, ECS tasks, EC2 instances, Kubernetes pods, and their GCP equivalents — GCE instances, Cloud Run services, and Cloud Functions. Container images are matched across ECR, ACR, GAR/GCR, Docker Hub, GHCR, and Quay; S3 artifacts are matched to bucket nodes and then traced across access edges to whatever compute sits on either side.

Real infrastructure is messy, so the match can't be a naive string equality. The same repository shows up as smithhealth/authn in code and smithhealth-authn in infra; an image is sometimes a first-class node in the graph and sometimes just an ID string referenced by a compute node. The matcher normalizes registry paths, handles both graph shapes, and scores each match by how much it trusts it. Rather than publish the exact scoring table, the useful way to think about the tiers is:

  • Exact match — an identical image URI and tag, or an exact bucket ARN. The artifact in the finding is unambiguously the artifact running in production.
  • Partial match — the same registry and repository, but a different or unknown tag. Very likely the same workload, one deploy out of sync.
  • Name-only match — a heuristic string-similarity match with no registry to anchor it. This is the lowest tier we're willing to trust, and it sits right at the cutoff below which we stop believing the signal at all.

There's a hard floor. Matches below a minimum confidence are dropped before they ever reach the triage agent — weak, speculative links never get dressed up as evidence. When several methods land on the same compute node, the highest-confidence one wins, and results come back sorted by confidence. The agent downstream doesn't get a pile of maybes; it gets a ranked, thresholded set of the strongest links we can defend.

Not being naive about it

The failure mode of any "link findings to infrastructure" system is over-matching — attributing a CVE to whatever happens to share a name. We spent most of the engineering effort here on not doing that.

  • Sidecars and daemons are excluded. A shared base image frequently runs in both your application pods and the observability and platform plumbing around them — Datadog, Prometheus, Grafana, OpenTelemetry, Istio/Envoy, KEDA, Karpenter, cert-manager, the CSI drivers, kube-proxy, aws-node, everything in kube-system and monitoring. If a base-image CVE matched a Datadog sidecar, the severity reasoning would be about the wrong workload. The matcher deliberately attributes the finding to the application pod and drops the sidecar, so infrastructure plumbing never corrupts the verdict.
  • Deployment targets constrain the search. A single-page-app repo that declares it deploys to CloudFront, Lambda, and S3 should not fan out across every ECS service whose name happens to share a prefix. The repo-declared deployment targets are mapped to concrete cloud subtypes and used to bound which compute nodes are even candidates. This one is a scar: it exists because we watched the unconstrained version fan a SPA out across an entire ECS fleet.
  • Name matching is strict. Only exact matches and strict prefix/suffix matches are accepted — never broad substring containment, which is where false positives breed.

And crucially, matches fan in when they should. When one image URI legitimately runs across three replica pods, the matcher returns all three with full confidence and the correct account. That's not noise — that's exactly the deployed, replicated, production case that should raise severity. The system suppresses the sidecar and multiplies the real workload. Both behaviors serve the same goal: severity should track reality.

Spend the expensive reasoning only when the cheap pass can't answer

Here's where efficiency stops being a side effect and becomes the design. Matching an artifact to infrastructure is step one; step two is tracing whether that infrastructure is actually reachable from the internet. That path-tracing is the expensive part, and we don't run it blind.

When the deterministic Go matcher returns a high-confidence, exact link, the orchestrator skips straight to network-path tracing and dedicates almost the entire budget to it — there's no ambiguity to resolve, so nearly all of the spend goes to the one question that remains. When the match is low-confidence or absent, we escalate to LLM-driven discovery, and even then deterministic-hints-first: discovery runs against reused hints — the near-miss matches the deterministic pass already produced, image names, registries, service labels, entrypoint affinities — before the network path is traced. Half the budget goes to finding the right node precisely because we couldn't find it for free.

The same instinct runs all the way through container and dependency triage:

  • For a container finding, the vulnerable artifact is the image, and the scanner already populated its name and full reference. We hand that identity straight to the cloud-context agent instead of asking an LLM to scrape it back out of a prose summary of the code — recovering, expensively, a value we already had structurally.
  • For a container finding there's no user source code to reason about, so the "code context" step that source-code findings run through an LLM is instead built deterministically from fields the scanner already filled in. The upstream LLM was reading the same Dockerfile the scanner had already parsed and emitting a graph node as decoration. We dropped the call.
  • For dependency findings, whether a package has network input is derived by a rules cascade over the repo's declared network exposure and its entrypoint kinds — library imports resolve to no network input, internet-facing services to yes — rather than an LLM round-trip.

That last one carries an explicit, deliberate bias that's worth stating plainly, because it's the whole philosophy in one decision: when context is sparse, we return true. Downstream, that flag decides whether to spend the cloud-context budget at all. Over-running it on a CLI repo is a cost regression; under-running it on an internet-facing service is a correctness regression. We pay the cost. Efficiency is the point, but it never comes ahead of missing something real.

How a match actually changes severity

Once an artifact is matched and a network path is traced, the finding carries real deployment and exposure evidence into the triage classifier. We don't hardcode "unmatched, therefore low" — the mechanism is evidence fed to a judgment, not a switch. The classifier is instructed to trace the attack path from the internet to the vulnerable code or workload, map the network path, identify the blast radius, and document whether the thing is internet-facing or internal only. And it's told, in as many words, not to trust the code alone: an endpoint you assumed was internal might be behind a public load balancer, and a "public" API might actually sit behind a VPN. Check the graph, not the source.

The rule that ties it together is sharp: when cloud context confirms internet exposure and code context shows network input, exploitability defaults to likely unless a mitigating control — a WAF, authentication, input validation — is explicitly identified in the evidence. Exposure raises the floor; only named countervailing controls lower it back. That default then multiplies with impact and fix effort to produce the final priority.

The inverse is just as load-bearing, and this is the part scanners miss. For a container finding, the agent is told: find the workloads that run this image and trace inbound paths to them, and if no running workload matches the image, say so explicitly — absence of a match is itself a strong triage signal. A non-match isn't missing evidence for a high severity; it's present evidence for a low one. In our reachability model, code that is dead, test-only, or behind a disabled feature flag isn't "low risk," it's no risk — and there is a container path where every CVE is unreachable and confined to a build stage that lets us downgrade to Low even when CVSS is 7.0 or above. The score does not decide severity; the deployment does.

We do not let the model take that shortcut cheaply. The container classifier forbids the lazy downgrades outright — "it's named 'builder' so it's not deployed" is rejected, because a name is not evidence; "multi-stage builds isolate everything" is rejected, because that's only true when nothing gets copied out of the build stage into the runtime image. To downgrade on build-stage grounds, the model must cite the specific COPY --from= line it inspected, or it must keep the higher severity and lower its confidence instead. The AI is allowed to reduce severity, but only against evidence it has to name.

This is also why we insist on matching against the graph rather than reading intent out of a repo. Even when the repo declares it deploys to CloudFront, Lambda, and S3, the classifier only fully commits when it can cite a concrete resource node — an actual Lambda ARN in the infrastructure graph — rather than a string description of a deployment shape. It's worth being honest about the history here: we didn't always have the graph maturity to make this claim, and there was a time we pulled the "internet exposed" and "runs in production" fields from the product rather than assert something the data couldn't back. The current results are the payoff of closing that gap. The capability comes from the graph, not from a README.

What this looks like in practice

The pattern shows up as both downgrades and escalations, which is the tell that it's tracking reality rather than just deflating queues.

On a customer-facing UI repo at a financial-services company, a set of dependency findings that had all landed at the top of the queue got re-triaged with deployment context. An axios finding at Critical was downgraded to Negligible because the app is a React single-page app served through a CDN — the code runs in the browser, not in a Node server where the vulnerable path applies. A Babel helpers package dropped because it's build-time only. But form-data and follow-redirects were kept elevated, because they genuinely ship into the browser bundle behind a document-upload flow handling identity documents and bank statements. Across dozens of dependency findings on that repo, only a small handful came out as Important or Urgent — the rest were re-scored down to what the deployment actually justified. The triage lead's summary of why: CVE severity assumes the worst-case deployment context; before, triage didn't know how or where the package was deployed, and now it knows the exact path.

At a workforce platform, re-triaging a batch of source-code findings against the full infrastructure path — load balancer to target group to ECS service to image to code — cut roughly a third of the noise, confirmed a set of outright false positives, and downgraded several more to negligible. It also escalated a handful to Urgent, because tracing the path proved they were reachable through internal load balancers serving production traffic. Cloud context isn't a discount engine; it moves severity in whichever direction the deployment demands.

And at the container layer specifically, a scan of a Debian build-stage image flagged well over a thousand CVEs as Critical — none of which crossed into the runtime image, because the vulnerable packages lived in a build stage that a multi-stage Dockerfile never copied forward. The severity was overstated for exactly one reason: naive scanning scored the image without asking what actually gets deployed.

Where the line is

We're deliberate about the scope of the claim, because overclaiming it is how this capability gets a bad name. Deployment and reachability gating applies to CVE, dependency, and container-style findings — things whose exploitability genuinely depends on whether and how they're deployed. It does not apply to secrets. A leaked credential is exploitable whether or not the code around it ever shipped, so secrets are explicitly carved out of the reachability gate. "Is it deployed?" is the right question for a CVE and the wrong one for a live key.

It's also worth distinguishing this from the other reachability question we answer for dependency findings: whether the vulnerable function inside a package is ever called by the application's own code, independent of where the artifact runs. That's a code-level analysis with its own confidence rules and its own guardrails against false negatives — the subject of our post on per-CVE reachability — and it composes with, rather than replaces, the deployment question this post is about.

You can see all of it, and you can overrule all of it

None of this is a black box, and none of it is a locked door.

Every triaged finding carries an audit trail. Per finding, we record whether cloud context was fetched, absent, or skipped; which cloud accounts' graphs were actually consulted; the context versions in play; the duration; and a trace URL that replays the full agent reasoning. If you have no cloud integration installed, the system doesn't guess — it records the finding's cloud-context status as absent and continues. The degradation is logged, not hidden. Alongside that, each finding reports how many artifacts in the repo matched to live infrastructure versus how many had no deployment match at all, so "31 of 40 build artifacts are deployed, 9 aren't" is a number you can read, not a claim you have to trust. Severity arrives with the evidence pills — the specific resource nodes and code locations — that justified it, and validators enforce that those citations are actually present.

And because every finding lands on disk as a file you can edit between phases, a security engineer can override any AI-derived field, including a cloud-context-driven severity change, and the next phase re-validates the edit rather than blindly accepting it. You can select findings by severity or priority, step through them interactively, filter, deselect, mark fixed with an audit reason, or hand-edit the JSON directly. The default is autonomous — the system triages the queue down to what's reachable without asking you to babysit it. But the lever is always there when you want it. You are not being asked to take an opaque engine on faith, and you are not being handed back a review queue that returns all the work to you: the queue runs itself down to what's reachable, and the override stays within reach the moment you want to reach for it.

That combination — deterministic matching first, expensive reasoning only when it's needed, severity decided by what's deployed rather than by a database, and a full audit trail with a human lever over all of it — is the point. The outcome we're after is the same one every AppSec team wants: the real vulnerabilities found, triaged correctly, and driven to closure, for the least human time and the least AI spend. In a year when both sides are automating as fast as they can, that efficiency is not a nicety — it's how a team that isn't growing its headcount keeps pace with attackers who aren't growing theirs either.

Book a demo