vulnerability-db

SKILL.md

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.

The rule that matters most

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.

Skill: choose the right database

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.

Skill: download before searching

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.

Skill: get structured results

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.

Skill: bulk searches

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.

Skill: manage local data with vdb db

vdb 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.

Skill: read coverage and staleness honestly

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.

Skill: build a local database safely

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.

Skill: reproduce cache bugs without large downloads

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.

Skill: reason about v7 storage

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.

Skill: direct SQLite inspection

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.

Skill: investigate a false positive or false negative

Trace in order:

  1. The source converter (osv.py, aqua.py, gha.py, nvd.py), including whether the record survived its feed’s year floor at all.
  2. VulnerabilityDetail fields: mii, mie, mai, mae, fixed_location, package_type.
  3. to_purl_vers() in utils.py.
  4. CVESource.store5(), for the purl prefix and index row it writes.
  5. The search path and 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.