One file, start to finish
Eighteen lines of ordinary Flask, a lockfile, and the whole loop: what VDB finds, what left the machine to find it, the fix, and the signed evidence that the path is closed. Every block below is copied from a real run — including the one that finds nothing.
Nothing exotic
A handler that proxies an avatar, a loader for an uploaded profile, and a health check. No CVE is involved in anything below: both packages are pinned at versions that do exactly what their documentation says.
import requests
import yaml
from flask import request
def fetch_avatar():
"""Proxy an avatar for the profile page."""
url = request.args["avatar_url"]
return requests.get(url).content
def load_profile(blob):
"""Read a profile document a user uploaded."""
return yaml.load(blob)
def health():
return requests.get("https://status.internal.example/health").json()The lockfile is what decides the answer — a version range cannot be analyzed, because the answer differs per version.
requests==2.19.0
urllib3==1.23
PyYAML==5.3.1
idna==2.7
chardet==3.0.4
certifi==2026.7.22Run it
vdb harden app.py --manifest requirements.txt2 call site(s) with a decided dataflow path (4 path(s) in total, independently of whether any CVE exists)
6 dependencies on the graph · 6 analysed · taint reached 4
scope: dataflow into your dependencies, and your own standard-library calls that run
commands, deserialize, decompress or open connections (subprocess, eval/exec, pickle,
zlib/zipfile/tarfile, urlopen). File paths (open, os.path) and regular expressions in
your own code are not examined — use a static analyser such as Bandit or Semgrep for those.
* app.py:9 urllib3.util.parse_url [urllib3@1.23]
net_request confidence 0.8 · 2 argument(s)
args : url
fix : scheme-allowlist, host-allowlist, block-internal-ranges
residual : install-script malware, background data exfiltration, unanalyzed dynamic
loading, dynamic dispatch on the path, DNS can change between validation
and connection, redirects are not re-validated
path_id : P-4927330b4da0-net_request, P-76acd2ecd8ee-net_request
* app.py:14 yaml.load [pyyaml@5.3.1]
cmd_exec confidence 0.8
args : stream
fix : allowlist-check
residual : install-script malware, background data exfiltration, unanalyzed dynamic
loading, dynamic dispatch on the path
path_id : P-4db57f067a93-cmd_exec
global_state confidence 0.8
args : stream
fix : key-allowlist
residual : install-script malware, background data exfiltration, unanalyzed dynamic
loading, dynamic dispatch on the path
path_id : P-6c534d09df6d-global_stateThe report is grouped by call site — the line you will edit — however many arguments or sink kinds meet there. Two things worth reading twice. The network path runs through requests into urllib3 — the package the risk actually lands in is one nobody wrote down. And the fix line names three defenses, not one: a scheme check alone is not an SSRF fix.
What it did not flag: health()calls the same requests.getwith a literal URL and produces no path. That is the only line on this page that proves the tool is deciding rather than pattern-matching imports.
See what left the machine
--emit-ir prints exactly what is sent about your code and makes no network call, so you can read it before you send anything. The only other things sent are the lockfile and a project name for alerts (the folder name unless you pass--project). The file name is F0001, the variables are v1 andv2, and the source isEXT. No source text, no identifiers, no paths, and no literal values — a file-open mode such as"rb" is the one literal sent as written, and there is none in this file.
{
"version": 1,
"language": "python",
"files": [
"F0001"
],
"unparsed": [],
"sources": [
{
"symbol": "v1",
"kind": "input"
},
{
"symbol": "v2",
"kind": "param"
}
],
"flows": [
{
"from": "EXT",
"to": "v1",
"transform": "identity",
"kind": "assign"
}
],
"callsites": [
{
"id": "c1",
"module": "requests",
"api": "get",
"symbol": "f1#requests.get",
"line": 9,
"args": [
{
"index": 0,
"name": "0",
"symbol": "v1",
"tainted": true,
"transform": "identity",
"sanitizers": []
}
]
},
/* c2: yaml.load (line 14), c3: the literal-URL requests.get (line 18) */
],
"masked_literals": 2,
"removed_constants": 0,
"loss": 0.25
}The abstraction still reveals dependency and API names, argument positions and graph edges. Treat those as project metadata when deciding whether to use the hosted service.
Take the fix
--patch prints a wrapper for your call site. Note what it is not: a patched, forked or pinned dependency. You usually cannot fix someone else's package; you can always fix your own boundary.
def vdb_safe_url(value, allowed_hosts=()):
"""Scheme/host allowlist plus internal-range blocking."""
import ipaddress, socket
from urllib.parse import urlparse
u = urlparse(str(value))
if u.scheme not in ("http", "https"):
raise ValueError("scheme not allowed")
if not allowed_hosts:
raise ValueError("a non-empty host allowlist is required")
if u.hostname not in allowed_hosts:
raise ValueError("host not in allowlist")
try:
addresses = {row[4][0] for row in socket.getaddrinfo(
u.hostname or "", u.port or (443 if u.scheme == "https" else 80),
type=socket.SOCK_STREAM)}
except (socket.gaierror, UnicodeError):
raise ValueError("host does not resolve")
def _unwrap(ip):
# An IPv6 address can carry an IPv4 one inside it. Checking only the
# outer form let 6to4 through: 2002:7f00:0001:: is 127.0.0.1 wearing
# an address family that answers is_global with True.
if ip.version == 6:
if ip.ipv4_mapped:
return ip.ipv4_mapped
if ip.sixtofour:
return ip.sixtofour
if ip.teredo:
return ip.teredo[1]
return ip
for addr in addresses:
ip = _unwrap(ipaddress.ip_address(addr))
# is_global is the allowlist, not is_private the blocklist. The old
# flag list missed carrier-grade NAT (100.64.0.0/10) entirely, which
# is routable inside an ISP or a cloud tenancy and is exactly where a
# neighbour's metadata service lives.
if not ip.is_global or ip.is_multicast:
raise ValueError("non-public address blocked")
return value
# At the call site: wrap the argument (the dependency itself is never modified)
# get(vdb_safe_url(v1, allowed_hosts=("api.example.com",)), ...)Applied, the handler becomes one line longer:
def fetch_avatar():
url = request.args["avatar_url"]
return requests.get(vdb_safe_url(url, allowed_hosts=ALLOWED_HOSTS)).contentProve you closed it
vdb harden app.py --manifest requirements.txt \
--verify P-4927330b4da0-net_requestpath P-4927330b4da0-net_request -> CLOSED (sanitizer-applied:block-internal-ranges,host-allowlist,scheme-allowlist)
graph hash : 8e25c9dd6228dd7c...
decided at : 2026-08-28T03:14:48Z
signature : 541aee2e69f232e02d7cc4a7...
note : This authenticates VDB's decision over the submitted abstract IR.
It does not prove that the IR faithfully represents a deployed
build. When closure rests on a wrapper, only an exact
reviewed-wrapper AST is credited.The evidence is bound to the graph hash it was decided against, so an attestation cannot be quietly reused after a dependency moves. Anyone can check the signature at POST /v1/harden/evidence/verifywithout the signing key.
Edit the wrapper and it stops counting. Delete the internal-range loop, keep the scheme and host checks, and you do not get two thirds of a pass:
path P-4927330b4da0-net_request -> OPEN (no-sanitizer-at-boundary)Credit is all-or-nothing on an exact match with the reviewed implementation. A scheme check with the internal ranges left open is the exact shape of a working SSRF, and partial credit is how an attestation that calls it closed gets minted. Function names and the word allowlist are not accepted as proof either.
What this example does not show
- Closure is not proof of safety. A boundary fix constrains what your argument can do. It does not make the dependency trustworthy, and every answer carries a residual-risk block naming what it cannot cover.
- A missed relation is a false negative. Reachability comes from static per-package summaries. Treat the output as evidence for triage order, not proof of non-exploitability.
- Python and PyPI today. That is the declared scope of this feature.
- The signature authenticates a decision, not a deployment. It proves VDB decided this over the submitted abstraction; it does not prove the abstraction matches what you shipped.