Vulnerability Prioritization Needs VEX to Stop Repeat Triage
Vulnerability prioritization fails when the scanner reopens a finding you already disproved. The five VEX justifications and how to record the answer once.

Quick Answer
Vulnerability prioritization fails when an engineer proves a scanner finding is not reachable and the proof is thrown away, so the next scan reopens it. Settle each finding with evidence from the build, the binary and the running process, then record the verdict once as a VEX statement tied to a specific build. Severity scores rank findings, but only recorded reachability stops them coming back.
Vulnerability prioritization is the work of deciding which scanner findings enter remediation now, later or never. We think the hard part for an engineer is rarely the ranking. It is the question underneath it, asked again on every scan. Does this vulnerable code actually run in our service?
Picture a nightly scan that flags a critical flaw in a YAML parser and opens a ticket. The service has no owner in the registry, so the ticket lands on whoever holds the pager. They spend twenty minutes in the build graph and find that the parser only came in through a build-time linting tool, so it never reached the production image. They close the ticket as not applicable, and two nights later the scanner opens it again.
This post covers what a scanner is really claiming, the five standard reasons a finding can be not affected, the commands we would use to gather the evidence, how to record the answer so it survives, where manual checks stop working and which numbers show the cost of triaging the same finding twice.
A Scanner Claims a Package Version, Not a Reachable Flaw
When a scanner flags a CVE it is making a claim about a package version found in a manifest or an image layer. Severity belongs to the code itself, while presence belongs to your build and your runtime. The ticket tells you a piece of code is dangerous. It does not tell you the code is there, loaded or callable.
Several scores sit near this decision, and they answer different questions. We find it useful to keep them apart, because ranking by one of them and calling it prioritization is a common source of wasted work.
| Signal | Question it answers | What it cannot tell you |
|---|---|---|
| CVSS severity | How bad is the flaw in the abstract? | If your service contains it |
| EPSS | How likely is exploitation in the wild? | If the vulnerable code is in your build |
| Reachability evidence | Is the vulnerable code present and executed here? | How likely attackers are to try |
| VEX statement | What did we decide, and why? | Anything about a different build |
FIRST describes EPSS as a model that estimates the probability that a published CVE will be exploited in the wild in the next 30 days. That is useful for ordering a queue. Even a high score does not matter for a package that never reached production, which is why we put the presence question first.
Five Justifications Already Name Every Not Affected Decision
Most engineers triage by intuition, and we think the answers they reach already have standard names. The OpenVEX specification defines four statuses (not_affected, affected, fixed and under_investigation) and five justifications for the first of them.
| Justification label | The engineering question behind it |
|---|---|
| component_not_present | Is this package in the final image, or only in the repository? |
| vulnerable_code_not_present | The library is there, but is the vulnerable function linked in? |
| vulnerable_code_not_in_execute_path | The code is in the binary, but is it ever called? |
| vulnerable_code_cannot_be_controlled_by_adversary | It is called, but can an attacker reach it with their input? |
| inline_mitigations_already_exist | Does something upstream already block this specific exploit? |
The specification is firm that a verdict needs a reason. It says that a statement conveying not_affected "MUST include either a status justification or an impact_statement informing why the product is not affected by the vulnerability." A closed ticket with no reason is exactly the thing a scanner will overwrite.
Vulnerability Prioritization Starts With Evidence per Justification
To move past gut feeling, we use a repeatable check for each of the first three justifications. The commands below are starting points, and each answers one question about one artifact. We run them against the image that actually shipped, not against the repository.
1. Component Not Present
Check if the package exists in the final runtime image rather than in a build stage. A multi-stage build can pull in a vulnerable tool, compile with it and leave it behind, so the manifest and the image disagree.
docker create --name probe IMAGE
docker export probe | tar -t | grep PACKAGE
docker rm probe
If the package appears in the lock file but not in the exported filesystem, the justification is component_not_present. This works for images without a shell, and an SBOM generated from the built image gives the same answer at scale.
2. Vulnerable Code Not Present
A library can be present while the vulnerable function is not linked into the program. For Go services, the toolchain can list the modules a binary contains, and govulncheck can scan the binary itself. Its documentation says that to run it on a compiled binary you pass the path with the "-mode binary" flag, which reads the binary's symbol information.
go version -m ./service
govulncheck -mode binary ./service
If the vulnerable symbol is absent, the code cannot run. The Go team's govulncheck tutorial describes the same idea at source level, scanning dependencies and then "identifying any direct or indirect calls to those vulnerabilities in your code."
3. Vulnerable Code Not in the Execute Path
The package may sit in the container and still never be loaded by the running process. On Linux we can ask the kernel which files a process has mapped.
grep PACKAGE /proc/PID/maps
A shared library that is not mapped is not in the execute path at that moment. We treat this as a snapshot, because a code path that runs only during a monthly job can load the library later.
Record the Answer Against the Build, Not the Service
An answer found at 2 a.m. is worth nothing if it lives in a ticket comment. We would record each determination as a machine-readable statement that names the vulnerability, the product and the justification, and we would let the scanner read it on the next run.
- The vulnerability identifier and the product it applies to.
- The image digest or build hash the verdict covers.
- The status and, for not affected, one of the five justification labels.
- Who made the call and when, so a reviewer can challenge it.
Attach that statement to a specific build, such as an image digest, and never to the service name. A refactor that adds one import can turn an unreachable library into a reachable one overnight, so a new build must reset the verdict. The statement is valid for exactly the artifact it was written about.
This also settles ownership. A finding with no owner in the registry is a routing defect, and the fix is assigning an owner in the metadata. Sending unowned scanner output to the pager is unplanned work, a cost we describe in our capacity math for unplanned work.
Where Manual Reachability Checks Stop Working
These checks have hard limits, and we would not pretend otherwise. Static analysis is easily defeated by dynamic dispatch, reflection and deserialization, and a runtime snapshot only shows what was loaded when you looked. A clean answer from any single check is evidence, not proof.
The work is also repetitive. It spans the build graph, the deployed artifact and the running process, and it has to be repeated for every new build and every new finding. That is the shape of work where we would let an agent gather the evidence and keep the verdict with a person. If you do, we would insist on read-only, self-hosted investigation, because every command above works without write access to production.
The counter-case matters too. For a service that ships dozens of builds a day, hand-written statements per build will not keep up. Generate the statement from the build pipeline and review only the exceptions.
Three Numbers That Show the Cost of Triaging Twice
Leadership rarely sees the interrupt cost of scanner output, so we would track three numbers to make it visible.
1. The Not Affected Rate
The share of tickets closed as not affected. A high rate says the scanner is noisy for your architecture, and it tells you which justification accounts for most of the noise.
2. Time to Determination
The engineer minutes spent proving a flaw is unreachable. Multiply it by the ticket count and the interrupt cost becomes a number a budget owner can read.
3. The Re-Derivation Rate
The share of not affected findings that were already settled in an earlier cycle. This is the purest waste in the process. If the team answers the same reachability question every Tuesday, the problem is persistence, not security.
The same logic applies to alerts. Our notes on reducing on-call alert fatigue and our write-up on how postmortems record the answer and lose the search describe what happens when a team keeps conclusions but discards the evidence that produced them.
Write the Determination Down Once Per Build
Vulnerability prioritization improves when the answer outlives the investigation. Gather evidence from the image, the binary and the process, pick the matching justification, record it against the build digest, and let the next scan read it.
We would start with the service that generates the most repeat tickets and measure the re-derivation rate before and after. If you want agents to gather that evidence while your engineers keep the decision, Operate runs dedicated AI agents that investigate production problems with cited evidence and propose fixes for your team to review.
Frequently Asked Questions
We define it as deciding which scanner findings get remediation work now, later or never, using the risk to your own environment. Severity scores help with ordering, but the deciding question is if the vulnerable code is present and reachable in the build you actually run.
CVSS describes how severe a flaw is in the abstract, while EPSS estimates how likely a published CVE is to be exploited in the wild soon. We use both for ordering a queue, and neither tells us whether the vulnerable code runs inside our service.
We use VEX to mean a machine-readable statement saying if a product is affected by a given vulnerability, with a status and a justification. It lets the person who knows the build record a verdict that scanners and customers can read instead of rediscovering it.
We first check presence in the shipped image, then if the vulnerable code is linked and called, and only then use severity and exploit likelihood to order what remains. Every verdict gets recorded against a build so that the same finding does not return on the next scan.
We treat it as valid only for the exact build it describes. A new image, a new dependency or a configuration change can alter reachability, so the verdict resets with the artifact and should be regenerated by the pipeline rather than carried forward by hand.
About the author
Operate Team
The team behind Operate
Operate Team builds Operate, enterprise AI agents for production operations. Operate answers production questions from Slack and Microsoft Teams, investigates alerts, and reviews performance, security and cost. It finds the root cause with evidence and proposes a fix for an engineer to review, with controlled access to your systems.


