Skip to main content

Ranking dependency alerts by real risk

· 8 min read
Vulkro
Security research

Open the dependency report for any mature JavaScript or Python project and you will usually find dozens of known vulnerabilities. Sorted by severity, they read as a wall of Highs and Criticals. A team that tries to fix them top to bottom spends its week on a prototype pollution bug in a test helper while an actively exploited library sits further down the list.

Severity is a property of the vulnerability. Risk is a property of the vulnerability in your project: whether the package ships, whether attackers are using the bug, and whether your code ever reaches the vulnerable part. A dependency report should be ordered by those three things.

This post covers how Vulkro now uses each of them, what each one changes in the default view and the build gate, and where nothing is hidden.

Signal one: does it ship?​

Most projects declare two kinds of dependency. Production dependencies run in the deployed application. Development dependencies run on a developer's laptop or in CI: test runners, linters, bundler plugins, type generators. A vulnerable version of the second kind is still worth upgrading, but it does not reach your production runtime.

{
"dependencies": {
"express": "4.21.2"
},
"devDependencies": {
"some-test-helper": "1.2.0"
}
}

If some-test-helper has an advisory, a report that puts it next to a vulnerable express overstates the risk to the running service.

Vulkro now holds back CVE findings for packages declared only as development dependencies: devDependencies in npm, [dev-packages] in a Pipfile, a dev group in Python project files. Such a finding is held at Low confidence, which takes it out of the default view and out of the default failure policy, so it no longer fails a --fail-on critical,high build.

It is not dropped, and the line is drawn conservatively:

  • It keeps its true severity and stays visible with --all-confidence, in JSON and in SARIF, tagged (dev dependency).
  • A package that is a production dependency in even one manifest of the project is never held back.
  • VULKRO_SCA_DEV_DEP_DEMOTE=0 keeps dev-only CVEs at full confidence, for a pipeline that depends on the old exit codes while it re-baselines.

One caution worth stating. Development dependencies are not harmless in general: the npm worms of 2026 ran their payloads at install time, and a dev dependency installs on every developer machine and CI runner. That is a malicious-package problem, handled by different checks. This change is about known vulnerabilities in code that does not ship.

Signal two: is anyone exploiting it?​

Two public data sets answer this, and they answer different questions.

The CISA Known Exploited Vulnerabilities catalog is a list of vulnerabilities with evidence of exploitation in the wild. CISA describes it as "the authoritative source of vulnerabilities that have been exploited in the wild," and says organisations should use it as an input to their vulnerability management prioritisation. A KEV listing is a statement of fact about the past: someone has used this.

EPSS, the Exploit Prediction Scoring System run by FIRST, is a forecast. It estimates the probability that a published CVE will be exploited in the wild in the next 30 days, and FIRST publishes a fresh score for every CVE daily. Its FAQ is careful about what that number is not. EPSS is the threat-likelihood part of a risk calculation only. It does not measure impact or know anything about your environment, and FIRST warns against multiplying an EPSS probability by a CVSS score, because the result has no interpretable meaning.

Vulkro's local vulnerability bundle includes both data sets alongside the advisories, so every dependency finding can carry them without a network call at scan time. According to the dependencies and CVEs reference:

SignalEffect on the finding
Listed in CISA KEVSeverity raised to Critical, tagged [CISA KEV - actively exploited, added <date>]
EPSS 0.9 or higherSeverity raised to at least High, tagged [EPSS NN%]
EPSS 0.5 to 0.9Tagged only

The bar for changing severity is deliberately high. A KEV listing is evidence, so it moves the finding to the top. A very high EPSS probability lifts a finding to at least High. Everything in the middle is shown as context and left to the ranking.

Signal three: does your code reach it?​

A vulnerable package that your project installs but never calls is a smaller problem than one on your request path. Vulkro computes two kinds of reachability for dependency findings:

  • A call-shape tag for npm and PyPI. A finding is tagged [reachable] when a known call shape for the vulnerable package appears in your source, otherwise [unreachable]. An unreachable finding drops one severity tier. It is never removed.
  • A call-graph verdict. Findings with known vulnerable symbols get a reachability verdict of reachable, unreachable or unknown, with the entry-point call chain. For Go, each advisory is checked against the exact function it names, which we described in imported is not reachable. This verdict is a label and does not move severity.

Both are approximations. They join names, so dynamic dispatch, reflection and import aliases can fool them in either direction. That is why an unreachable finding is lowered rather than deleted. It is also why making unreachable findings stop failing the build is an explicit flag, --fail-on-reachable-only, and not a default.

Putting the signals in one order​

Each finding gets a deterministic risk score from 0 to 100, and vulkro explain --risk-model prints the exact formula. It is a sum of named parts:

risk_score = clamp(0, 100, base + exploit + reach + conf + priority)

base (severity) Critical 50 High 38 Medium 22 Low 8 Info 2
exploit (grade) Exploitable +30 Likely +16 Theoretical +2
reach (verdict) Reachable +10 Unknown +2 Unreachable -12
conf (confidence) Verified +8 High +5 Medium +1 Low -6
priority (SCA) CISA KEV +12; else EPSS +round(pct/10) when pct >= 50

Every part is a signal the scan has already computed, so nothing is fetched and the same scan always ranks the same way. Note what the formula does not do. It never multiplies EPSS by a severity score. Exploitation data adds a bounded number of points, which keeps the EPSS FAQ's warning intact.

The score orders things. It never changes what was detected. On an interactive terminal, vulkro scan ends with a "Fix these first" list of the top ten. --top N changes the count and --top 0 turns it off. Every machine format carries risk_score and risk_rank on each finding, so a CI job or a dashboard can sort the same way.

Before and after​

Picture the same project's dependency report twice.

Before: three Criticals and eleven Highs. Two of the Criticals are in a test runner and a documentation generator. The third is in a library your request handlers call, with a KEV listing that was easy to miss because nothing set it apart.

After: the KEV-listed library sits at the top as Critical, with its tag and the call chain into your handler. The test-runner and doc-generator CVEs are out of the default view and the default gate, still listed under --all-confidence and in SARIF as dev dependencies. A High in a package your code imports but never calls carries an [unreachable] tag one tier lower, ranked below the reachable ones.

Nothing was deleted. The report just stopped asking you to treat every advisory as equally urgent.

What this does not decide for you​

These signals rank work. They do not close it. An unreachable verdict can be wrong, a dev dependency can become a production one in the next pull request, and an EPSS score of 2% is a probability, not a promise. Keep upgrading everything on a schedule. Use the ranking to decide what goes first, and what can wait for the routine bump.

Try it on your lockfile​

Run vulkro update to refresh the local bundle, then vulkro scan on a project with a lockfile. Add --all-confidence to see the dev-only findings that were held back, and --format json or --format sarif to read risk_score, the KEV and EPSS tags and the reachability verdicts. The dependencies and CVEs reference and the scan reference cover the flags. To run it on your own project, start here.

Sources​