-
Scan the FULL transitive tree, not just direct deps — and pin the result to a manifest. Direct deps are a tiny minority; copyleft almost always rides in transitively. Pick the resolver for the ecosystem and emit machine-readable output:
npx license-checker-rseki --production --json --out licenses.json
pip-licenses --format=json --with-license-file --with-urls > licenses.json
cargo install cargo-deny && cargo deny list -f json > licenses.json
go install github.com/google/go-licenses@latest && go-licenses report ./... > licenses.csv
pipx run scancode-toolkit scancode --license --json-pp scancode.json <vendored_dir>
Scope to what you distribute: prod/runtime deps only. devDependencies, test, and build-only tooling are generally not distributed — exclude them (--production, --omit=dev) or you'll drown in false GPL hits from linters. When package metadata is missing or wrong, scancode reading the real LICENSE/headers is the tiebreaker, not the package.json license field.
-
Classify each license by risk against YOUR distribution model — this is the whole audit. The same license is fine or fatal depending on how you ship. Decide the model first, then read the table left-to-right:
| License class | Examples | SaaS (network only) | Distributed binary / app | Library you publish |
|---|
| Permissive | MIT, BSD-2/3, ISC, Apache-2.0, Unlicense, 0BSD | ✅ allow | ✅ allow (must keep NOTICE) | ✅ allow |
| Weak copyleft (file) | MPL-2.0, EPL-2.0, CDDL | ✅ allow | ⚠️ allow if unmodified & file-isolated | ⚠️ review |
| Weak copyleft (lib) | LGPL-2.1/3.0 | ✅ allow | ⚠️ dynamic link only; static link triggers relink obligation | ⚠️ review |
| Strong copyleft | GPL-2.0, GPL-3.0 | ✅ allow (no conveying) | ❌ deny — forces whole-program source disclosure | ❌ deny |
| Network copyleft | AGPL-3.0 | ❌ deny — network use = conveying, source must be offered to users | ❌ deny | ❌ deny |
| Notice-heavy / patent | Apache-2.0, BSD-4-Clause | ✅ (track NOTICE/patent grant) | ⚠️ BSD-4 advertising clause incompatible w/ GPL | ⚠️ review |
| Non-OSS / source-available | SSPL, BUSL-1.1, Elastic-2.0, CC-BY-NC, "Commons Clause" | ❌ deny (not OSI; usage-restricted) | ❌ deny | ❌ deny |
| Public domain / unclear | WTFPL, "UNLICENSED", no license | ❌ deny pending manual review | ❌ deny pending review | ❌ deny |
The trap most teams miss: AGPL bites SaaS (where GPL does not, because you never "convey" a binary), and LGPL/GPL bite distributed binaries (where they're harmless on a server). Set the model once; don't hand-wave it as "it depends."
-
Encode the policy as allow / deny / review with SPDX IDs and gate CI on it. A human-readable table isn't enforcement. Use one tool to both classify and fail the build. cargo-deny-style config (mirror the shape in license-checker --failOn or an oss-review-toolkit/fossa/trivy policy):
[licenses]
allow = ["MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "ISC", "MPL-2.0"]
confidence-threshold = 0.9
[[licenses.exceptions]]
crate = "ring"
allow = ["OpenSSL"]
cargo deny check licenses
npx license-checker-rseki --production --onlyAllow \
"MIT;Apache-2.0;BSD-2-Clause;BSD-3-Clause;ISC;MPL-2.0" --excludePrivatePackages
Default posture is allowlist, deny-by-default: an unknown/unparseable license fails closed, so a newly added denied or no-license dep cannot merge silently. Route genuinely-ambiguous cases to a review bucket that fails CI with a "needs legal sign-off" message rather than auto-allowing.
-
Resolve dual-licensed and missing-license deps explicitly — never let the tool pick for you. SPDX OR means you choose (e.g. (MIT OR Apache-2.0) → pick the one in your allowlist and record the choice). SPDX AND means both apply (you must satisfy every obligation). For a dep with no license, default-deny: open an issue, contact the maintainer, or remove it — "no license" means all-rights-reserved, not free. Pin every resolution and exception in deny.toml/policy with a one-line rationale so the next audit doesn't relitigate it.
-
Generate NOTICE / THIRD-PARTY-LICENSES attribution from the same scan. Permissive licenses (MIT/BSD/Apache) require you to reproduce their copyright + license text in distributed artifacts; Apache-2.0 also requires propagating any upstream NOTICE. Auto-generate, don't hand-maintain:
npx oss-attribution-generator ./
pip-licenses --format=plain-vertical --with-license-file --no-license-path \
--output-file THIRD-PARTY-LICENSES.txt
go-licenses save ./... --save_path=THIRD-PARTY-LICENSES/
Ship THIRD-PARTY-LICENSES.txt (or NOTICE) inside the artifact — in the package tarball, the container image, the app's "Licenses" screen, or /licenses. Regenerate it in CI from the locked tree and diff against the committed copy so a new dep can't slip in without its attribution.
-
Wire both gates into one CI job. Policy check (step 3) + attribution drift check (step 5) run on every PR and on the release tag. Fail the build if a denied/unknown license appears OR the generated NOTICE differs from the committed one. This is what makes the audit durable instead of a one-time spreadsheet.
Done = the scan covers the full transitive prod tree, CI blocks a newly added denied-license and an unknown-license dep (verified by positive controls), every distributed dependency is listed with its license in the committed NOTICE, and the NOTICE-drift gate fails when that list goes stale.