Design notes

Pipeline

lockfiles ──parsers──▶ Inventory (packages + edges + roots)
                           │ enrich: site-packages / node_modules / snapshot / registry
                           ▼
                  policy ─ osv ─ hygiene ──▶ Report ──▶ table | JSON | Markdown
                           │
                           └──▶ sbom (CycloneDX 1.5 / SPDX 2.3) ──▶ schema validation

Everything downstream of the parsers works on one Inventory, so the same checks and exporters apply to every ecosystem.

Inventory and graph

Where the roots come from depends on the format:

Format Roots
uv.lock the workspace package (source = { virtual/editable = "." }) and its dev-dependencies
poetry.lock, pdm.lock, Pipfile.lock the sibling pyproject.toml / Pipfile; without it, packages nobody depends on (scope from category, groups, develop)
requirements*.txt every pin, or - for pip-compile output - pins whose # via comment names only -r ...; # via a, b lines become edges. Files named *dev*/*test*/*lint*/*docs* are dev. -r includes are followed
package-lock.json v2/v3 the root entry (""): dependencies, devDependencies, ... Nested node_modules paths are resolved the way Node does (look in ./node_modules, then each parent's)
yarn.lock v1 / berry sibling package.json (berry also reads the @workspace:. entry); descriptor strings (name@range) are the edge keys
pnpm-lock.yaml importers (or the top level for v5/v6); peer-suffixed keys (1.0.0(react@18), 1.0.0_react@18) are folded to the plain version
go.mod / go.sum require lines without // indirect; replace directives are applied (local-path replaces are dropped). Go lockfiles do not record a graph, so indirect modules have no path
Cargo.lock the workspace members (packages without source); scopes from the sibling Cargo.toml

When several lockfiles are scanned together (a monorepo), their inventories are merged; why shows which project each path starts from.

why

why_paths finds all simple paths from a root to the target. It first computes the set of nodes that can reach the target (reverse BFS) and only walks those, so the search does not explore the whole graph, and it stops at a cap so a dense graph cannot explode. Paths are sorted shortest first:

$ depaudit why semver examples/monorepo
npm:semver 6.3.0 (transitive prod)
  storefront-web -> make-dir 3.1.0 -> semver 6.3.0
npm:semver 7.3.5 (direct prod)
  storefront-web -> semver 7.3.5

License detection

Sources are tried in this order; the first one that yields a value wins and is recorded in license_source:

  1. policy override ([packages.licenses]), applied last so it always wins
  2. the lockfile itself (license in npm lock entries)
  3. installed metadata: METADATA in *.dist-info (License-Expression, then a short License field, then trove classifiers, then a licenses/LICENSE* file), or node_modules/**/package.json (license, licenses[], {type}, falling back to a LICENSE file for SEE LICENSE IN ...)
  4. an offline --metadata snapshot
  5. --online: PyPI JSON / npm registry, cached on disk (--cache-dir, --cache-ttl)

Declared strings are normalised to SPDX where possible (MIT License -> MIT, Apache 2.0 -> Apache-2.0, GPL-2.0+ -> GPL-2.0-or-later, a dual-license classifier pair -> A OR B). Anything that does not normalise stays verbatim and is later treated as an unrecognised license (policy unknown; LicenseRef- in the SBOM). The ambiguous trove classifiers BSD License and Apache Software License map to BSD-3-Clause and Apache-2.0.

License text classifier

depaudit ships fingerprints, not license texts (licdata.py): for each of ~30 SPDX licenses, a handful of distinctive phrases, an optional title, optional excludes (phrases that rule a license out) and, for licenses commonly used as a header (Apache, MPL, GPL family), a notice form.

Classification of a text:

  1. lower-case and tokenise to [a-z0-9]+, so punctuation, line wrapping and case do not matter;
  2. for each license, skip it if an exclude phrase occurs; otherwise frac = fraction of its phrases (and title, which must be in the first 100 tokens) found as contiguous token runs, and coverage = fraction of its fingerprint tokens present in the document (a token-set overlap);
  3. score = 0.7 * frac + 0.3 * coverage if frac >= 0.75, or 0.9 if every notice phrase is present;
  4. the highest score wins.

Worked example: MIT-0 and MIT share the grant and warranty sentences; the only difference is the obligation to keep the copyright notice. MIT-0 therefore has excludes = ["the above copyright notice and this permission notice shall be included"]; a real MIT file contains that sentence, so MIT-0 is skipped and MIT scores 1.0. The same device separates BSD-2-Clause (excludes neither the name of) from BSD-3-Clause, ISC from 0BSD, and the titles separate GPL, LGPL and AGPL (the GPL text mentions the LGPL, so phrases alone would be ambiguous).

Run against /usr/share/common-licenses and ~1700 license files from installed Python packages during development, about 90% of a 600-file sample were classified; the remainder were multi-license bundles, pointer files or unusual wording. A file that mixes several licenses is reported as the best single match.

SPDX expressions

Grammar (case-insensitive operators): expr := and ("OR" and)*, and := term ("AND" term)*, term := "(" expr ")" | id ["+"] ["WITH" exception]. Ids are canonicalised against the bundled SPDX id list, deprecated GPL-family ids are mapped (GPL-2.0 -> GPL-2.0-only, GPL-2.0+ -> GPL-2.0-or-later), and GPL-2.0-with-classpath-exception is rewritten to its WITH form. evaluate() folds a verdict over the tree: OR takes the minimum, AND the maximum.

Vulnerabilities

See version-semantics.md for range evaluation and the upgrade suggestion. Advisories are indexed by (ecosystem, normalised name), so matching a package is a dictionary lookup plus a few version comparisons. Severity: a CVSS v3 vector is scored (cvss.py, spec formulas, roundup) and labelled; otherwise database_specific.severity (MODERATE -> medium); the highest known value wins.

Hygiene

SBOM mapping

depaudit CycloneDX 1.5 SPDX 2.3
package components[] (bom-ref = purl) packages[]
purl purl externalRefs (PACKAGE-MANAGER / purl)
lockfile hash hashes[] (alg SHA-256, ...) checksums[] (SHA256, ...)
license licenses[]: {license.id}, {expression} or {license.name} licenseDeclared; unknown ids become LicenseRef-* with hasExtractedLicensingInfos
direct/transitive edges dependencies[] (root ref depends on the direct packages) DEPENDS_ON relationships
dev scope scope: excluded DEV_DEPENDENCY_OF from the root

Package URLs follow the purl spec: PyPI names normalised, npm scope @ encoded as %40, Go module paths lower-cased, + and ! in versions percent-encoded. Both documents are generated deterministically (the serial number is a UUIDv5 of the contents) except for the timestamp, and are validated against the official JSON schemas bundled in src/depaudit/schemas/ (CycloneDX bom-1.5, SPDX 2.3) before being written; --no-validate skips that.

Hashes: only content hashes that the lockfile provides are emitted (pip --hash, poetry/pdm/uv file hashes, npm integrity, yarn v1 integrity or the #sha1 fragment, Cargo checksum). go.sum's h1: value is a hash of the module's directory tree, not of an archive, so it is not reported as a component hash; yarn berry's checksum is likewise not a plain digest.

diff

audit --format json is the interchange format. diff compares two reports by (ecosystem, name): added and removed packages, version sets that changed, license changes, licenses that are new to the tree, vulnerabilities whose (advisory, package) pair is new or resolved, and new warning/error findings. A finding's identity is (rule, package name, advisory-or-license) - the version is deliberately excluded, so bumping a package that already had a finding does not report it as new. diff exits 1 on a regression: any new vulnerability or new error finding.