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
- A package is identified by
ecosystem:name@version(PyPI names are PEP 503-normalised). Two lockfiles that pin the same package yield one node whosesourceslists both. - Edges are
parent -> childdependency relations recorded in the lockfile. Roots are the project's direct dependencies with a scope (prodordev). - Scope is derived, not trusted:
finalize()computes the set reachable from prod roots; a package is dev only if it is not reachable from any prod root. A library that is a direct dev dependency and a transitive prod dependency is therefore prod.
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:
- policy override (
[packages.licenses]), applied last so it always wins - the lockfile itself (
licensein npm lock entries) - installed metadata:
METADATAin*.dist-info(License-Expression, then a shortLicensefield, then trove classifiers, then alicenses/LICENSE*file), ornode_modules/**/package.json(license,licenses[],{type}, falling back to a LICENSE file forSEE LICENSE IN ...) - an offline
--metadatasnapshot --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:
- lower-case and tokenise to
[a-z0-9]+, so punctuation, line wrapping and case do not matter; - 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, andcoverage= fraction of its fingerprint tokens present in the document (a token-set overlap); - score =
0.7 * frac + 0.3 * coverageiffrac >= 0.75, or 0.9 if every notice phrase is present; - 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
- Duplicates are grouped per lockfile, so two separate apps in a monorepo that pin different versions of the same package are not a duplicate.
- Loose specifiers come from the parsers: requirement lines without
==, URL requirements, and npm root dependencies that are*,latest, open-ended>=, or git URLs without a commit. - Typosquats: a package is flagged when its name is exactly one edit (insert, delete or substitute) from a name in the
embedded top-1000 PyPI or npm list and is not itself in the list. Names shorter than 4 characters and scoped npm names
are skipped. Example:
reqests(one deletion fromrequests).
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.