Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

47 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Vulnerability Triage Agent

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.

CI version license


The problem

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?"

What it does

  1. 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, so const { 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.
  2. Matches those dependencies against known vulnerabilities (OSV.dev, with an offline snapshot for reproducible runs).
  3. 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?
  4. 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.
  5. Files a GitHub issue for the findings that survive triage, with the entrypoint path, the advisories, and a concrete upgrade.

What it is NOT

  • 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.

Architecture

  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
└──────────────────────────┘

Why three languages

  • 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).

Quickstart

# 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.html

Demo: input → output

The 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 yamlPyYAML 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.

Transitive dependencies

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.

GitHub code scanning (SARIF)

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).

Offline-first design

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.

Design decisions

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.

Development

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 tests

CI 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.

License

MIT