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 uv.lock
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 · 5 analysed · taint reached 3
* 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 urllib3.util.parse_url [urllib3@2.2.3]
net_request confidence 0.8 · 2 argument(s)
args : url
fix : scheme-allowlist, host-allowlist, block-internal-ranges
residual : …, DNS can change between validation and connection, redirects are not re-validated
path_id : P-4a52bb5bc1fa-net_request, P-df57a46469eb-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.
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.
- 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.