Static-analysis-driven CVE prioritizer. It doesn't just tell you "you have 40 CVEs" — it tells you "3 of these are reachable from your public code, the other 37 are noise." Then it drafts a GitHub issue for the ones that matter.
A dependency scanner tells you every CVE in your dependency tree. In a real project that's dozens to hundreds of alerts, and most of them don't matter: the vulnerable function is never called, the package is a build-time-only dev dependency, or the code path isn't reachable from anything an attacker can touch.
That noise is why security alerts get ignored. This tool cuts the noise by asking a sharper question than "is this CVE present?" — it asks "is this CVE reachable from your code?"
- Parses the target repo — JavaScript, TypeScript/TSX, and Python — with
tree-sitter(real static analysis, not regex) to extract declared dependencies (package.json,requirements.txt,pyproject.toml,Cargo.toml,setup.py/setup.cfg,Pipfile) and how they're actually imported — down to the destructured symbols, soconst { merge } = require('lodash')lines up with the exact API a CVE flags. When a lockfile is present (package-lock.json,yarn.lock,poetry.lock,Pipfile.lock,Cargo.lock) it also resolves exact versions and the transitive dependency tree. - Matches those dependencies against known vulnerabilities (OSV.dev, with an offline snapshot for reproducible runs).
- Classifies reachability: is the vulnerable import connected to a public-facing entrypoint (an HTTP route, a handler), merely imported in a build script, or declared and never imported at all?
- Prioritizes with a Claude agent (function calling) that weighs severity against reachability — or with a deterministic heuristic when offline — and writes a plain-language justification per finding.
- Files a GitHub issue for the findings that survive triage, with the entrypoint path, the advisories, and a concrete upgrade.
- Not a full scanner like Snyk / Dependabot. No SCA at scale, no license compliance, no deep multi-version graph resolution. It reads lockfiles for resolved versions and the transitive tree, but it's a noise reducer that sits on top of a vuln feed and answers "which of these can actually bite me?"
- Not a proof of exploitability. Reachability is a structural heuristic, not taint analysis. "Reachable" means "worth a human's attention," not "confirmed exploit."
- Not a SAST tool for your own code. It triages dependency CVEs, not bugs in your source.
target repo
│
▼
┌──────────────────────────┐ Rust + tree-sitter
│ parser/ (vta-parser) │ • declared deps (package.json / requirements.txt / pyproject.toml / Cargo.toml / setup.* / Pipfile)
│ │ • lockfiles → resolved versions + transitive dependency graph
│ │ • import graph + entrypoint detection + symbols
└──────────────────────────┘ → ParsedProject JSON
│
▼
┌──────────────────────────┐ Python
│ analyzer/ (vta) │ • OSV match (live API or offline snapshot), directs + transitives
│ │ • reachability: BFS from entrypoints; transitives inherit from their introducer
│ │ • triage: Claude agent (function calling) or heuristic
│ │ • GitHub issue drafting / creation
└──────────────────────────┘ → TriageReport JSON
│
├───────────────► GitHub API (files issues for reachable findings)
│
▼
┌──────────────────────────┐ TypeScript
│ viewer/ (vta-report) │ • terminal summary + self-contained HTML dashboard
└──────────────────────────┘
- Rust — the static-analysis engine. Parsing syntax trees across languages,
fast, is a systems job;
tree-sitter's best bindings live here. The parser is a standalone binary that emits JSON and knows nothing about CVEs. - Python — orchestration: the OSV feed, version-range matching, a local CVSS v3.1 calculator, the reachability graph, and the Claude agent.
- TypeScript — the report viewer (terminal + self-contained HTML).
# 1. Build the Rust parser
cargo build --release --manifest-path parser/Cargo.toml
# 2. Install the Python analyzer
cd analyzer && python3 -m venv .venv && . .venv/bin/activate
pip install -e ".[dev]"
# 3. Triage the bundled example app — fully offline, no keys needed
vta triage ../examples/vulnerable-express-app
# Optional: the Claude agent instead of the heuristic (needs ANTHROPIC_API_KEY)
vta triage ../examples/vulnerable-express-app --live
# Optional: draft GitHub issues (dry-run prints them; --live files them)
vta issues ../examples/vulnerable-express-app
vta issues ../examples/vulnerable-express-app --live --repo you/your-repo
# Optional: emit SARIF for GitHub code scanning (the repo's Security tab)
vta triage ../examples/vulnerable-express-app --sarif triage.sarif
# Optional: query the live OSV.dev API instead of the bundled snapshot
vta triage ../examples/vulnerable-express-app --online # needs: pip install '.[live]'
# 4. Render the HTML dashboard
vta triage ../examples/vulnerable-express-app --json > report.json
cd ../viewer && npm ci && npm run build
node dist/index.js ../analyzer/report.json --html report.htmlThe bundled examples/vulnerable-express-app
declares five dependencies, every one pinned to a version with a real CVE.
A severity-sorted scanner would scream about all five. The triage output:
Triage report (heuristic) — examples/vulnerable-express-app
5 vulnerable dependencies -> 2 worth attention, 3 filed as low priority / noise.
[CRITICAL] lodash@4.17.4 (CVE-2018-16487, CVE-2019-10744, CVE-2020-8203, CVE-2021-23337)
lodash@4.17.4 is reachable from a public entrypoint (src/server.js ->
src/routes/users.js) and its vulnerable API (merge) is imported.
fix: Upgrade lodash to >= 4.17.21.
[ MEDIUM] express@4.16.0 (CVE-2024-29041)
express@4.16.0 is reachable from a public entrypoint (src/server.js).
fix: Upgrade express to >= 4.19.2.
[ LOW] minimist@1.2.0 (CVE-2020-7598, CVE-2021-44906)
carries a CRITICAL advisory but is imported only in a build script.
[ noise] ejs@3.1.6 (CVE-2022-29078)
carries a CRITICAL advisory but is a dev-only dependency.
[ noise] moment@2.19.3 (CVE-2022-24785, CVE-2022-31129)
declared but never imported; its advisories cannot be triggered.
The inversion in the middle is the whole point: critical-severity CVEs in
minimist and ejs rank below a moderate one in express, because only
the express one is on a path an attacker can reach. The Python fixture
(examples/vulnerable-flask-app) exercises the
same logic for Flask/PyYAML, including the import yaml → PyYAML name mapping.
Real filed issue: the pipeline opened issue #1 on this repository via the GitHub API — the lodash finding above, with its reachability path, advisories, and suggested upgrade.
A manifest lists your direct dependencies; the real risk often hides one level down, in a package you never chose. When a lockfile is present the parser resolves the full tree and the analyzer extends the reachability model to it with one rule:
A transitive dependency is as reachable as the most reachable direct dependency that introduces it. A vulnerable package pulled in by a reachable direct is treated as reachable; one pulled in only by an unused or dev-only direct is deprioritized the same way its parent is.
This is deliberately conservative — we don't know which sub-module a direct
actually calls, so we never hide a transitive that a reachable direct might reach.
The report labels each finding direct or transitive, names the introducing
chain, and frames the fix through the parent (you can't upgrade a transitive on its
own). The examples/vulnerable-transitive-app
fixture shows it end to end:
[CRITICAL] lodash@4.17.4 (transitive) (CVE-2019-10744, …)
via: data-utils -> lodash
lodash@4.17.4 is reachable from a public entrypoint (src/server.js ->
src/routes/data.js). … It is a transitive dependency, introduced via
data-utils -> lodash.
fix: Upgrade lodash to >= 4.17.21. It is pulled in transitively by
data-utils, so upgrade data-utils (or pin lodash via an override) …
[ MEDIUM] express@4.18.2 (direct) (CVE-2024-29041)
[ noise] ejs@3.1.6 (transitive) (CVE-2022-29078)
via: build-tool -> ejs ← critical, but its introducer is never imported
Supported lockfiles: package-lock.json and yarn.lock (npm), poetry.lock and
Pipfile.lock (PyPI), Cargo.lock (crates.io). All but the flat Pipfile.lock
carry the parent→child edges needed to attribute a transitive to its introducer;
Pipfile.lock still contributes resolved versions.
vta triage --sarif triage.sarif writes a SARIF 2.1.0
log — one result per CVE — that you can upload to
GitHub code scanning,
so the triage verdicts land in the repo's Security tab next to every other
scanner. The load-bearing detail: each alert's security-severity (the number
GitHub sorts by) comes from the triage priority, not the raw CVSS. So the
same inversion the terminal shows carries into the Security tab —
| CVE | package | raw CVSS | SARIF security-severity |
why |
|---|---|---|---|---|
| CVE-2021-44906 | minimist | 9.8 (critical) | 2.0 | critical, but only reachable from a build script |
| CVE-2024-29041 | express | 6.1 (moderate) | 5.5 | moderate, but reachable from a public route |
| CVE-2022-29078 | ejs | 9.8 (critical) | 0.0, suppressed | critical, but a dev-only dependency |
Emitting the raw CVSS here would make GitHub re-sort the noise back to the top —
exactly what the tool exists to prevent — so priority drives the severity and
noise findings ship as SARIF suppressions (auditable, but not active alerts).
Every external integration sits behind an interface with an offline implementation, so the demo and the tests run deterministically with no network and no API keys:
| Integration | Live path | Offline path |
|---|---|---|
| Vulnerability data | OSVClient → OSV.dev API (--online) |
LocalOSVSource → bundled snapshot of real advisories |
| Prioritization | Claude agent (function calling, --live) |
Deterministic severity × reachability rubric |
| GitHub issues | REST API (--live) |
Dry-run prints the drafted issues |
Both triage paths emit the same TriageResult shape, so the report renderer and
the issue builder never know which one ran.
docs/design-decisions.md documents the architecture
tradeoffs, the four tools exposed to the Claude agent, and — most usefully — what
the first design got wrong: the initial "imported anywhere = reachable" model was
scrapped for an entrypoint graph walk, and the import-name/package-name mismatch
(yaml vs PyYAML) only surfaced by running the Flask fixture. The doc is
explicit about what the AI proposed, what was reworked, and what was decided by
hand.
cargo test --manifest-path parser/Cargo.toml # Rust: 38 tests
cd analyzer && ruff check src tests && mypy src && pytest # Python: 71 tests
cd viewer && npm run typecheck && npm test # TypeScript: 9 testsCI runs all three suites on every push, and builds the parser inside the Python job so the cross-language bridge test is exercised, not skipped.