VDB
Sign up
Worked example

One file, start to finish

Eighteen lines of ordinary Flask, a lockfile, and the whole loop: what VDB finds, how much risk that is, what left the machine to find it, the fix, the signed evidence that the path is closed, and the order to do the rest in. Every block below is copied from a real run — including the one that finds nothing.

The file

Nothing exotic

A handler that proxies an avatar, a loader for an uploaded profile, and a health check. The dataflow decision needs no CVE: both packages do exactly what their documentation says at these versions. (The pins are old, and the risk score further down does count the advisories they carry — that is a different question.)

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. An unpinned requirement is still accepted; it just counts as unanalysed, and the first suggestion is to pin it.

requests==2.19.0
urllib3==1.23
PyYAML==5.3.1
idna==2.7
certifi==2026.7.22
Step 1

Run it

vdb harden app.py --manifest requirements.txt
2 call site(s) with a decided dataflow path (4 path(s) in total, independently of whether any CVE exists)
5 dependencies on the graph · 3 analysed · 2 without a summary yet · 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.

Project risk: 0.96–0.97  (interval — analysis incomplete; 3 of 5 dependencies have a summary, 6 undetermined path(s))
  low        0.32      code_input request > requests@2.19.0 > net_request  in app.py
  high       0.20      code_input param > pyyaml@5.3.1 > cmd_exec  in app.py
  low        0.16      code_input request > requests@2.19.0 > urllib3@1.23 > net_request  in app.py
  low        0.10      code_input param > pyyaml@5.3.1 > global_state  in app.py
  high       0.08      cve PYSEC-2021-142 > pyyaml@5.3.1 > cmd_exec  in app.py
  low        0.06      cve PYSEC-2026-1873 > requests@2.19.0 > net_request  in app.py
  low        0.06      cve PYSEC-2019-133 > urllib3@1.23 > net_request  in app.py
  low        0.06      cve PYSEC-2026-2275 > requests@2.19.0 > net_request  in app.py
  undetermined: 6 path(s) end in a package without a summary — most often idna@2.7 (4), certifi@2026.7.22 (2); queued for building, re-run later for a sharper answer
  … 150 scored paths in total (--json for all)

* 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_state

Two blocks. The first is the project's risk: every path from an entry point to a sink scored on one scale — your request handler, the advisories the old pins carry, a function parameter of unknown provenance — and combined into one number, with the share of the dependency graph that was actually analysed. Two of five packages have no summary yet, so the number is an interval, and the six paths that end in them are listed as undetermined rather than dropped.

The second block 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.

Step 2

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.

Step 3

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)).content
Step 4

Prove you closed it

vdb harden app.py --manifest requirements.txt \
  --verify P-4927330b4da0-net_request
path 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.

Step 5

Decide what to fix first

With 150 scored paths, the order of work is the actual question. --remediate applies each candidate to the attack graph counterfactually — upgrade a package (re-composed with the new version's summary), harden a call site, remove a package, confine a sink with an execution policy — re-scores it, and ranks the candidates by risk reduction over (1 + cost):

vdb harden app.py --manifest requirements.txt --remediate --target 0.2
Remediation ranking — risk 0.96–0.97, target 0.20
   #  action                                                   Δrisk  cost  value  closes
   1  upgrade requests 2.19.0 → 2.33.0                     0.13–0.23  0.40  0.096  66 (worth 0.88)
   2  remove urllib3                                       0.13–0.21  0.80  0.075  107 (worth 0.83)
   3  upgrade urllib3 1.23 → 2.8.0                         0.13–0.21  1.00  0.067  107 (worth 0.83)
   4  egress-allowlist on net_request sinks                     0.07  0.40  0.052  0
   5  remove requests                                      0.07–0.10  0.80  0.038  42 (worth 0.72)
   6  harden app.py:9                                           0.03  0.20  0.026  2 (worth 0.43)
   7  harden app.py:14                                          0.02  0.20  0.013  2 (worth 0.28)
   8  upgrade pyyaml 5.3.1 → 5.4                           0.02–0.03  0.40  0.012  6 (worth 0.39)
   9  sandbox-profile on cmd_exec sinks                         0.01  0.40  0.010  0
  10  remove pyyaml                                        0.02–0.03  0.80  0.009  6 (worth 0.39)
  11  filesystem-write-limit on path_op sinks                   0.01  0.30  0.005  0
  12  expansion-cap on decompress sinks                         0.00  0.30  0.001  0
  … 4 more candidate(s) (--json for all)
  Combination: #1 → 0.72–0.84, #2 → 0.39–0.64, #7 → 0.15–0.50  (reaches the target; 3 step(s), cost 1.40)
  Re-verify after each step: vdb harden … --verify <path_id>

Upgrading requests leads: it closes 66 paths at once, and its reduction is an interval because the target version's summary cannot decide everything the old one did. Hardening the two call sites — Step 3 — sits lower here, because the advisories on the old pins stay reachable through other paths. The combination line is the plan: three steps bring the project under the target, each one re-verified rather than assumed.

The scored graph is stored. When an advisory is published for one of these packages, a summary is built for one that had none, or the registry changes, it is re-scored along the paths the event touches, and you are mailed only when the project's score moved — not for every CVE in every package you include. GET /v1/harden/projectsshows the score each project carries now.

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.
How the decision is made →API reference →Get an API key →