Risk that has no CVE yet
Every other dependency scanner starts from a published advisory. This one starts from your call site and asks a different question: can attacker-controlled data reach a dangerous operation somewhere down the transitive chain — whether or not anyone has written it up?
What it returns
import requests, subprocess
from flask import request
def fetch_avatar():
url = request.args["avatar_url"]
return requests.get(url, timeout=5).content
def convert(name):
subprocess.run(f"convert {name} out.png", shell=True)$ vdb harden app.py --manifest requirements.txt
2 call site(s) with a decided dataflow path (3 path(s) in total, independently of whether any CVE exists)
5 dependencies on the graph · 3 analysed · 2 without a summary yet · taint reached 3
Project risk: 0.65–0.75 (interval — analysis incomplete; 3 of 5 dependencies have a summary, 4 undetermined path(s))
low 0.32 code_input request > requests@2.34.2 > net_request in app.py
high 0.25 code_input param > stdlib > cmd_exec in app.py
low 0.16 code_input request > requests@2.34.2 > urllib3@2.7.0 > net_request in app.py
low 0.06 cve PYSEC-2026-4175 > urllib3@2.7.0 > net_request in app.py
low 0.06 cve PYSEC-2026-4177 > urllib3@2.7.0 > net_request in app.py
low 0.06 cve PYSEC-2026-4176 > urllib3@2.7.0 > net_request in app.py
undetermined: 4 path(s) end in a package without a summary — most often idna@3.11 (2), certifi@2026.7.22 (2); queued for building, re-run later for a sharper answer
* app.py:9 subprocess.run [subprocess@stdlib]
cmd_exec confidence 1.0
args : 0
fix : allowlist-check
path_id : P-2ceb4a35350f-cmd_exec
* app.py:6 requests.get [requests@2.34.2]
net_request confidence 0.8 · 2 argument(s)
args : url
fix : scheme-allowlist, host-allowlist, block-internal-ranges
path_id : P-471a3d2cd643-net_request, P-92195bc5283d-net_requestOne path goes through two packages to the place a request is built; the other is your own subprocesscall. The fix is applied at your call site. VDB never asks you to patch, fork, or pin the dependency — you usually cannot fix someone else's package, and you can always fix your own boundary. Above the call sites is the project's risk: each path scored from its entry point to its sink, combined into one number — here an interval, because two of the five packages have no summary yet and the four paths that end in them are undetermined, not dropped.
How risky is the project, and what to fix first
A list of paths is not an order of work. Every decided path is therefore scored from its entry point to its sink, and the scores are combined into one number for the project. Entry points are not only your request handlers: a package name an LLM made up, a package registered three days ago, one that runs a script on install, a tool server that declares shell access, and a published CVE (weighted by whether it is known to be exploited) all enter the same graph on the same scale — so "an agent added a dependency that does not exist"and "an exploited advisory is reachable from your upload handler"appear in one ranking.
| A path's score multiplies | Why |
|---|---|
| the entry point's threat probability | a request argument is attacker-controlled by definition; an environment variable mostly is not; a CVE on the exploited list is 1.0, otherwise its EPSS |
| a confidence per hop | lowered where the engine had to assume propagation through a package without a summary, or through dynamic dispatch |
| a risk factor per package | from a trust score: registration age, release history, a tool server's recent scope expansion — the same flow through a days-old package scores higher |
| the sink's impact | command execution and deserialization 1.0; file paths and network requests 0.8; regular expressions, decompression and global state 0.5 |
Coverage is part of the answer. The project score comes with the share of the dependency graph that was actually analysed. When too many packages have no summary yet, the score is reported as an interval — the lower bound from the decided paths, the upper bound assuming every undetermined path reaches the worst sink — rather than a low number that reads as safe. A path whose boundary already applies every defense its sink requires, or whose closure you verified, scores zero.
It keeps watching. The scored graph is stored. When an advisory is published, a summary is built for a package you depend on, the registry changes, or a tool server's permissions move, the graphs that contain that package are found through an index and re-scored along the paths the event touches. You are mailed only when your project's score moved past a threshold — not for every CVE in every package you happen to include — and only for decided paths, never for ones that rest on an assumption.GET /v1/harden/projects shows the score each project carries now.
What to fix first.vdb harden app.py --manifest uv.lock --remediateapplies each candidate to the graph counterfactually — upgrade a package (re-composed with the new version's summary), harden a call site's boundary, remove a package, confine a sink with an execution policy — re-scores it, and ranks candidates by risk reduction per cost. An upgrade that breaks a parent's declared version constraint costs more; hardening three call sites costs three edits. A greedy combination says which steps, in which order, bring the project under a target risk, and each step's result is re-verified, not assumed. An unpinned requirements.txt is accepted — the unpinned packages count as unanalysed and the first suggestion is to pin them — and--exclude keeps backups and fixtures out of a tree analysis.
$ vdb harden app.py --manifest requirements.txt --remediate Remediation ranking — risk 0.65–0.75, target 0.20 # action Δrisk cost value closes 1 harden app.py:6 0.26 0.20 0.219 2 (worth 0.43) 2 egress-allowlist on net_request sinks 0.17 0.40 0.125 0 3 remove requests 0.19–0.26 0.80 0.106 2 (worth 0.43) 4 harden app.py:9 0.12 0.20 0.097 1 (worth 0.25) 5 upgrade urllib3 2.7.0 → 2.8.0 0.12–0.16 0.40 0.083 7 (worth 0.31) 6 remove urllib3 0.12–0.16 0.80 0.064 7 (worth 0.31) 7 sandbox-profile on cmd_exec sinks 0.06 0.40 0.042 0 8 upgrade certifi 2026.7.22 → 2026.10.19 0.00–0.06 0.40 0.000 0 9 remove certifi 0.00–0.06 0.50 0.000 0 10 upgrade idna 3.11 → 3.15 0.00–0.03 0.40 0.000 0 11 remove idna 0.00–0.03 0.50 0.000 0 Combination: #1 → 0.39–0.55, #2 → 0.32–0.51, #4 → 0.09–0.34 (reaches the target; 3 step(s), cost 0.80) Re-verify after each step: vdb harden … --verify <path_id>
The same file as above. Hardening the request handler leads — it closes both paths through requestsfor the cost of one edit — and the combination line says three steps bring the project under the target. An upgrade's reduction is an interval when the new version's summary cannot decide everything the old one did; a candidate that changes nothing is not listed.
Your source code never leaves your machine
The file is abstracted locally into a call-graph representation. Only that representation and your lockfile are uploaded. The representation is not source text, but it still reveals dependency/API names, argument positions, graph edges, and coarse dataflow facts.
| Sent | Never sent |
|---|---|
| Call sites: module, API name, argument position | Function bodies, control flow |
| Whether each argument is tainted, and how | Identifiers — user_id becomes v3 |
Literal shapes — STR<len:9, [a-z0-9-]>; file-open modes such as "rb" as written | Literal values, comments, docstrings |
Your lockfile — package names, versions, graph edges; a project name for alerts (the folder name unless you pass --project) | File paths |
You do not have to take that on faith. --emit-irprints exactly what is sent about your code and makes no network call at all; the rest of a request is the lockfile and the project name.
Where it runs. On your machine, next to the source: from a terminal, or from an agent that has the project folder (Claude Code, Cursor, Cline, Codex CLI). A chat-only web AI cannot run it — its sandbox has no outbound network and cannot see your files.
Where the command comes from. vdb is installed by thevdb-mcp package (0.2.6 or newer for everything on this page; older clients do not send eval/exec or file modes):uvx --from vdb-mcp vdb harden app.py --manifest uv.lock, orpipx install vdb-mcp once. Where PyPI is unreachable,https://vdb.ai.kr/v1/harden/client.pyz is the same CLI as a single stdlib-only file: check it against /v1/harden/client.sha256 and run python3 client.pyz --doctor. Nothing to install.
When the API cannot be reached, --doctorsays which of your network, your key, or our service is at fault. A sandbox with no outbound DNS is not a dead end: the CLI falls back to a pinned address on its own when — and only when — name resolution fails, still validating the certificate for vdb.ai.kr (override with VDB_API_ADDR).
How a path is decided
- 1Summaries, built once per package version. Each one records where an argument reaches a dangerous operation inside that package, and where it is handed to a specific argument of a specific downstream API. Built offline from the real registry artifact by parsing only — package code is never imported and never executed.
- 2Composition over your resolved graph. Those summaries are chained across the exact versions your lockfile resolves to. A version range cannot be analyzed — the answer differs per version — so an unpinned requirement is accepted but counts as unanalysed, and the first suggestion is to pin it.
- 3Seven sink classes. File path operations, command execution, network requests, deserialization, archive decompression, regex evaluation — only when thepattern is yours, not when a library matches its own pattern against your text — and global-state mutation. Your own calls to
subprocess,eval/exec,pickle, decompression andurlopenare judged directly, with no dependency in between.
Dynamic dispatch, native extensions, a failed signature check. Rather than dropping the path and under-reporting silently, the analysis assumes the argument passes through and prices that assumption into a confidence score you can see. A conservative guess you can inspect beats a silent miss.
Proving you closed it
Finding a path is half of it. After you apply the fix, re-analysis issues signed evidence bound to the hash of the dependency graph it was decided against — so an attestation cannot be quietly reused after your graph moves.
Partial defense is not closure. A network sink requires a scheme allowlist, a host allowlist, and internal-range blocking. Credit is all-or-nothing on an exact match with the wrapper VDB issued, so deleting the internal-range check earns nothing for the two that remain:
-> OPEN (no-sanitizer-at-boundary)
A scheme check with the internal ranges left open is the exact shape of a working SSRF. Partial credit is how an attestation that calls it closed gets minted, so there is no partial credit. Apply a wrapper meant for a different sink and the reason names every defense the path still requires.
VDB binds the evidence to the originally issued path, the original and fixed IR fingerprints, and the graph, and credits a wrapper only when its AST exactly matches the reviewed implementation. The public signature verifier proves that VDB issued the decision over the submitted abstraction; it cannot prove that the abstraction matches a deployed binary.
Turning a finding list into a triage order
A scan on a real project returns hundreds of advisories, nearly all of them about code the application never puts attacker-controlled data into. A queue nobody can work through is a queue everybody ends up ignoring. Point the same analysis at your source tree and it says which ones can be reached:
$ vdb harden ./src --manifest uv.lock --vex 36 advisory finding(s) in uv.lock not_affected 26 under_investigation 10 analyzed 41 file(s) · ecosystems: pypi
The output is an OpenVEX document, so any tool that reads the format can subtract the same set — and it gets a URL, because the result is only useful when a team looks at the same list.
Point it at the tree, not a file. A not_affected claim is only as wide as the code behind it: derive "nothing reaches urllib3" from one file while forty other modules import it and the document is wrong, in a format built to be trusted automatically. Below two analyzed files, every such claim is withheld.
It never says affected. Reaching a package is not reaching the function an advisory names, and stating otherwise would be a guess wearing the clothes of a finding. Reachability comes from static summaries, so a relation the analyzer missed is a false negative — the document says so on itself. Evidence for triage order, not proof of non-exploitability.
What it does not cover
This ships with every answer, not as a footnote. These are never marked defended, whatever fix you apply:
- —Install-script malware. Runs before your code ever calls the package.
- —Background exfiltration. Happens outside your input path, so a call-site boundary never sees it.
- —Unanalyzed dynamic loading. Reflection and dynamic imports cannot be resolved statically.
A boundary fix constrains what your argument can do. It does not make a dependency trustworthy.
What the analysis does not examine
- File paths and regular expressions in your own code(
open,os.path,re). Every function argument is treated as possible outside input, so these would fire on ordinary helpers. Run a static analyser such as Bandit or Semgrep for them; every report says so. - Methods called on a dependency object that is kept in an attribute or a global, or built in a way the client cannot follow —
env.from_string(x)afterenv = Environment()is the usual shape. Most missed template injections look like this. - Outside input that is not a function argument — a value read back from a file, an attribute set elsewhere, a config file's contents. The reverse also holds: your own configuration, passed as an argument, shows up as if it were outside input.
- SQL injection and XXE have no sink class. Packages too large or too native to summarise (torch, for one) are covered only by assumption, and a call into one with nothing summarised behind it can be missed outright.
Measured on 39 published PyPI advisories whose cause was how an application called something: the analysis pointed at the line the advisory blames in 25 — 10 of 20 calls into a dependency, 15 of 19 direct standard-library calls. Read each result as the place to look, not as a verdict.
Getting access
Python and PyPI today, over uv.lock,poetry.lock,Pipfile.lock,requirements.txt, and CycloneDX. The API is live for signed-in users; the local abstraction step ships with the client.