A practical guide for AI agents that need to use vdb. If you are changing the
code rather than using it, read AGENTS.md instead.
Coming from VDB 6.x, read MIGRATING_TO_V7.md before acting on any 6.x instructions you find elsewhere. Image URLs, environment variables and the comparison API changed, v7 refuses to read v6 databases, and 6.x code samples contain one API call that no longer exists.
An empty result is not a clean result until you have checked that the database exists, covers the purl types you asked about, and is fresh enough to matter. VDB reports all three, and the reports exist because a confident empty answer is the most dangerous output a vulnerability scanner can produce.
Concretely, before you tell a user “no vulnerabilities found”:
flowchart TD
A[Search returned no findings] --> B{Exit code 0?}
B -- "no, exit 1 + 'empty'" --> X[Scanner down.<br/>Report a failure, not a clean scan.]
B -- yes --> C{Shard store?}
C -- "no, full database" --> D{Metadata search?}
C -- yes --> E{Every requested purl<br/>type covered?}
E -- "no, coverage_gap" --> Y[NOT CHECKED.<br/>Name the uncovered types.]
E -- yes --> F{Any answering<br/>shard stale?}
F -- yes --> Z[Answer may be old.<br/>Name the stale shard.]
F -- no --> D
D -- "yes, on a database<br/>without metadata" --> W[Tells you nothing.<br/>Build with --include-metadata.]
D -- no --> OK[Clean result.<br/>Safe to report.]
Only the OK branch is a clean result. Every other leaf is a statement about
the database rather than about the software being scanned.
v7 publishes vdb7-full, vdb7-app-only, three app-scope variants, and
per-type shards.
| Need | Artifact |
|---|---|
| Standard application dependency scanning | vdb7-app-only |
| Container or Linux OS package scanning | vdb7-full |
| CVE ID, alias, CPE, reference or full-text search | vdb7-full only; these raise on a shard |
| Single-ecosystem workload (npm only, maven only) | the matching type shard |
| Metadata, text, alias, reference or symbol search | vdb7-app-extended or vdb7-app-10y-extended |
| Advisories older than 2020 | vdb7-app-10y / vdb7-app-10y-extended (NVD from 2016) |
vdb7-app-only is the app-scope database. vdb7-app is a different
artifact: the app group shard, completeness=partial, for the shard store
only. vdb db refresh full rejects it by design.
Five shards are structural (deb, rpm, apk, app, and cpe as the
complement) and the rest are per-type. There are 43 possible shard names and a
build emits only the ones it has rows for, which was 26 on a recent app+OS
build. Do not assume a fixed shard list; a shard’s vdb.meta
(artifact.types, siblings.available) is the authority, and vdb db status
reports what is actually local.
Prefer an organization-controlled mirror or an internally produced artifact when the user has one. Use the AppThreat-hosted defaults for bootstrap, local testing, or when no internal URL was given.
Default v7 databases keep cve_metadata and cve_metadata_text empty to
hold the index size down; the extended flavors (vdb7-app-extended,
vdb7-app-10y-extended) are the pre-built exceptions. When a task needs
search_full_text(), search_by_alias(), search_by_reference(),
search_by_package_name(), search_by_symbol(), or a metadata-dependent
filter (source, severity, dates, malware flags), download the extended flavor
with vdb db refresh full --flavor app-extended, build locally with
--include-metadata, or point VDB_DATABASE_URL at an internal metadata
artifact. On a default database those searches fail safe by returning no
metadata matches, so an empty result there tells you nothing about the package.
The files are data.vdb7 and data.index.vdb7 under VDB_HOME. v7 does not
read or migrate .vdb6, and a search against a zero-row database exits 1 with
an empty-database message rather than printing “No results found!”.
vdb db refresh full --app-only # app-only
vdb db refresh full # app+OS
vdb db refresh full --flavor app-extended # app-only + metadata tables
Both stage and validate the download before it replaces the local database. For type shards:
vdb db refresh npm pypi # named shards into the store
The -xz image suffix is the safe choice: it always unpacks. A -zst
reference works only when a zstd decompressor is present (Python 3.14+, or a
zstd binary on PATH); without one the refresh fails before downloading and
names both remedies. vdb db refresh --compression zst selects zst
explicitly.
In Python:
import os
from vdb.lib import config, db, search
from vdb.lib.orasclient import download_image
DB_URL = os.getenv("VDB_APP_ONLY_DATABASE_URL", config.VDB_APP_ONLY_DATABASE_URL)
if db.needs_update(days=1, default_status=True):
download_image(DB_URL, config.DATA_DIR)
results = search.search_by_any("pkg:pypi/requests@2.31.0", with_data=True)
Set VDB_HOME before importing vdb.lib.config. Path and build-scope
variables are read at import time.
The CLI prints human-readable tables. There is no --json flag on the search
commands, so do not look for one. Use the Python API for machine-readable
output.
import json
from vdb.lib import search
output = []
for item in search.search_by_any("pkg:npm/lodash@4.17.20", with_data=True):
row = {
"cve_id": item["cve_id"],
"package": item["name"],
"purl_prefix": item["purl_prefix"],
"vers": item["vers"],
"fix_version": item.get("fix_version"),
"severity": item.get("severity"),
}
if item.get("source_data"):
row["cve_data"] = item["source_data"]
output.append(row)
print(json.dumps(output, indent=2))
source_data is a plain dict, ready for json.dumps(). It is not a Pydantic
model and has no .model_dump(). Older snippets call
item["source_data"].model_dump(mode="json"); that raises AttributeError on
v7. This is the single most common wrong line in 6.x-era sample code.
from vdb.lib import search
packages = [
{"purl": "pkg:pypi/requests@2.31.0"},
{"purl": "pkg:npm/react@18.2.0"},
{"url": "https://github.com/pallets/flask"},
]
for batch in search.search_packages_batched(packages, batch_size=100, with_data=False):
for item in batch:
print(item["locator"], item["result_count"], item.get("max_severity"))
For a CycloneDX BOM, search_bom_summary and search_bom_detailed take the
same filters, and search_by_cdx_bom yields batches so peak memory stays
bounded on a large SBOM.
vdb dbvdb db status # main DB and per-shard build id, age, size, coverage
vdb db status --json # the same report, machine-readable
vdb db refresh # sync what I have: everything already in the store
vdb db refresh npm pypi # a named set of shards (names or purl types)
vdb db refresh --all # every default shard
vdb db refresh full # the full image, staged and validated
Call vdb db status before reporting scan results, and again after any refresh.
The plain and JSON reports expose the same facts: per-shard build_id, build
time and age_days, sizes, mixed_builds, missing_group_shards, coverage
(full, or partial (missing: ...)), and a per-shard stale flag.
Refreshes are safe to interrupt. The full image is downloaded to a staging
directory, validated (both .vdb7 files present, index non-empty, not a
single-shard artifact) and swapped in only after validation, with the current
files stashed away first. Each shard lands with an atomic rename. A failed or
empty fetch is an error and never replaces a working database.
When the connected main database is a shard (vdb.meta says
completeness: "partial"), purl searches fan out across the local shard store.
Two conditions that look similar and are not:
Coverage gap. A requested purl type has no local shard. Single-purl
searches raise ShardCoverageError. Batch searches mark the affected
components coverage_gap: true, expose requested, covered and uncovered types
through the .coverage companion on the result list and
vdb.lib.search.last_shard_coverage, and warn on stderr.
Staleness. A shard more than 7 days older than its newest sibling. Reported
per shard by vdb db status and warned at search time. A shard with no
parseable build time is never claimed stale, though its build_id is still
shown.
Rules for an agent:
An empty result on a shard store is not “no vulnerabilities” until coverage
shows every requested type was covered. A coverage_gap means not checked.
A stale shard still answers, and the answer may be old. Name the stale shard when you report results.
Never conclude “clean” from a scan whose vdb db status shows
missing_group_shards.
Never enable VDB_AUTO_FETCH on a user’s behalf in air-gapped CI. On-demand
network fetch during a scan is opt-in for a reason.
Use app-only builds unless OS package scanning is required.
export VDB_HOME=/tmp/vdb-home
export VDB_CACHE=/tmp/vdb-cache
export VDB_TEMP_DIR=/tmp/vdb-home/tmp
export NVD_START_YEAR=2024
export GITHUB_PAGE_COUNT=1
mkdir -p "$VDB_HOME" "$VDB_CACHE" "$VDB_TEMP_DIR"
vdb --cache
NVD_START_YEAR bounds NVD-style CVE data and the Linux distro feeds only.
Application ecosystem advisories are not year-filtered, because a distro fix
moves the whole release forward while a lockfile does not, so an old library
advisory is exactly what a scan needs. Set
VDB_APP_ECOSYSTEM_START_YEAR if you specifically want the smaller artifact.
Do not set OSV_EXCLUDE_MALWARE=true to save space on a build you will scan
with. It removes the malicious-package advisories, and a missed malicious
package is an installed backdoor rather than a stale finding.
For app+OS builds, and for metadata search support:
vdb --cache-os
vdb --cache --include-metadata
vdb --cache-os --include-metadata
Builds print minimal progress. Use --quiet or VDB_QUIET=true to suppress
the logo, logs and progress. Tune cadence with VDB_PROGRESS_INTERVAL.
To restrict OS data, use the VDB_IGNORE_* and VDB_INCLUDE_* flags from
vdb/lib/config.py:
export VDB_IGNORE_DEBIAN=true
export VDB_IGNORE_ALPINE=true
vdb --cache-os
Set VDB_TEMP_DIR to a partition with room. An app+OS build needs significant
temporary space for index creation and VACUUM, and a small /tmp is the most
common build failure.
AquaSource reads $VDB_CACHE/vuln-list.zip when it exists. Build a small zip
with a few representative paths and monkeypatch config.CACHE_DIR at it in
tests. This avoids cloning or downloading the full vuln-list repository.
data.vdb7 stores each CVE source blob once in cve_source_data, with package
locator rows in cve_data referencing them by hash. data.index.vdb7 stores
package and version rows in cve_index, plus optional metadata in
cve_metadata (compact, package-level) and cve_metadata_text (long text, one
row per CVE).
The split follows access patterns: the index is small, random-access and hot; the data file is large, sequential and cold. Resolve against the index first, hydrate only what matched.
Databases are built from scratch by the release workflows. Schema migrations
and legacy inline cve_data.source_data write paths are deliberately not
required. v7 reads .vdb7 only.
The .vdb7 files are ordinary SQLite databases.
sqlite3 "$VDB_HOME/data.index.vdb7" ".schema cve_index"
sqlite3 "$VDB_HOME/data.index.vdb7" "SELECT count(*) FROM cve_index;"
sqlite3 "$VDB_HOME/data.index.vdb7" "SELECT count(*) FROM cve_metadata;"
sqlite3 "$VDB_HOME/data.vdb7" "SELECT count(*) FROM cve_data;"
sqlite3 "$VDB_HOME/data.vdb7" "SELECT count(*) FROM cve_source_data;"
A healthy build has many more cve_data rows than cve_source_data rows,
because a CVE affecting many packages shares one blob. In a default database
cve_metadata and cve_metadata_text have zero rows; in an
--include-metadata build they are populated.
Open a database you did not build read-only:
sqlite3 "file:$VDB_HOME/data.index.vdb7?mode=ro" "SELECT count(*) FROM cve_index;"
Two traps when querying by hand. Locators are lowercased, so lowercase your
query. And a bare SELECT ... FROM cve_index scans in name order via a
covering index rather than by CVE id, so ordering by cve_id costs nothing but
assuming an order costs correctness.
Direct SQL sees none of the coverage, staleness or empty-database guards the library applies. On a shard, a purl of an uncovered type returns no rows, and that is indistinguishable in SQL from clean. Prefer the library APIs when the answer will be reported to someone.
Trace in order:
osv.py, aqua.py, gha.py, nvd.py), including
whether the record survived its feed’s year floor at all.VulnerabilityDetail fields: mii, mie, mai, mae, fixed_location,
package_type.to_purl_vers() in utils.py.CVESource.store5(), for the purl prefix and index row it writes.vdb.lib.vers.vers_compare().For a false negative, check step 4 against step 5 specifically. The most common cause is a stored locator the lookup never proposes: both sides build a purl prefix independently, and when they disagree the rows exist and are unreachable, which looks exactly like “not vulnerable”.
Add the regression test at the lowest layer that demonstrates the bug.