Dependency & License Risk Management
Software Composition Analysis in Depth spent its entire chapter on one question: does a dependency carry a known vulnerability. That question has a good answer today, thanks to CVSS, EPSS, and three named scanners. It is not the only question a dependency can fail. A package can be perfectly CVE-free and still get your company sued, because a strong-copyleft license buried four hops down the tree obligates you to open-source code you never intended to release. A package can be perfectly CVE-free and still be actively malicious, because it was published an hour ago under the exact name your build expects, or one keystroke away from the name a developer meant to type. And a package can be perfectly clean today and catastrophically backdoored tomorrow, because the one person who has quietly maintained it for a decade just handed the keys to someone who spent two years earning that trust for exactly this moment. None of that shows up in a CVE feed. This page is the other half of SCA — the half a legal team cares about and a vulnerability scanner cannot see.
Imagine borrowing ingredients from neighbors to bake a cake. Most neighbors just want their bowl back — that's a permissive license. One neighbor says "fine, but if you use my secret spice, you have to publish your whole recipe" — that's copyleft, and depending on which neighbor, it might only cover the spice itself or the entire cake. A vulnerability scanner checks whether an ingredient got recalled for being unsafe. It has nothing to say about a contract stapled to the flour bag, a stranger who slipped a fake tin onto the shelf with a label one letter off from the real one, or the fact that the kind old man who's run the corner store for ten years quietly sold it last month to someone nobody's actually met.
Two risks a CVE feed will never surface
☺ Like you're 10: A scanner checks a library against a list of known-bad things. Nothing on that list says "this obligates your lawyers" or "this name is a lie" — those aren't vulnerabilities, so they're invisible to a vulnerability scanner by design, not by oversight.
A CVE is, definitionally, a publicly disclosed, catalogued security flaw. That definition draws a hard boundary around what a SAST, SCA, or container scanner can ever flag, and two entire categories of real risk fall outside it every time:
- License risk is a legal and business question, not a security one. A dependency can be flawlessly written, actively maintained, and free of every CVE that's ever been filed against it, and still carry an obligation — publish your source, don't offer it as a hosted service, pay a fee past a certain revenue threshold — that a scanner tuned for vulnerabilities has no field to record it in.
- Supply-chain trust risk is a question about the package's provenance and stewardship, not its code. Is this the package you meant to install, or one with a confusingly similar name? Has this specific version existed long enough for anyone to have looked at it? Is the person who can publish a new release to millions of installs the same trustworthy maintainer who wrote the original project, or someone who received commit access eighteen months ago and has been quietly building a reputation ever since?
Both categories share a structural property that makes them dangerous in a way CVEs aren't: a CVE gets fixed, and the fix is itself the signal that tells you to act. License risk and trust risk don't announce themselves. Nobody files a disclosure the day a maintainer account gets socially engineered — you find out, if you're lucky, before it's exploited rather than after.
The open-source license spectrum: how much does borrowing this code cost you?
☺ Like you're 10: Licenses aren't all-or-nothing "free" or "not free" — they sit on a spectrum from "just say thanks" to "anyone who touches this over the network has to open their whole codebase."
Every open-source license grants permission to use, modify, and redistribute code, and every one attaches conditions to that permission. What separates the four broad families below is how much of your own code the conditions reach, expressed as a standard SPDX license identifier — the short, machine-readable string (MIT, GPL-3.0-only, Apache-2.0) that every modern license scanner, package registry, and SBOM uses instead of parsing free-text license files.
| Family | Examples (SPDX id) | What actually triggers the obligation |
|---|---|---|
| Permissive | MIT, BSD-3-Clause, Apache-2.0, ISC | Nothing beyond keeping the copyright/license notice attached. Apache-2.0 additionally grants an explicit patent license — worth more than it looks in litigation. |
| Weak / file-level copyleft | LGPL-2.1-only, LGPL-3.0-only, MPL-2.0 | Modifying the licensed library's own files. Linking against it — even statically for MPL — doesn't pull your surrounding code into the obligation. |
| Strong copyleft | GPL-2.0-only, GPL-3.0-only | Distributing a combined work that links against GPL code. Internal-only use with no distribution never triggers it — the loophole the next family closes. |
| Network copyleft | AGPL-3.0-only | Users interacting with a modified copy over a network. No binary needs to leave your servers — running it as a SaaS is enough. |
How copyleft actually bites: linking, distribution, and the SaaS loophole
☺ Like you're 10: "I only ran it, I didn't hand anyone a copy" used to be a real escape hatch for a company running modified GPL code as a web service. AGPL was written specifically to close that door.
The word doing all the work in GPL's obligation is distribute. The Free Software Foundation's own position is that static linking — compiling GPL code directly into your binary — always creates a single combined "derivative work," so shipping that binary to anyone triggers the obligation to release your own source under GPL too. Dynamic linking is legally murkier and has been argued both ways for decades; the FSF's stated view is that it still counts, while a meaningful part of the industry treats a dynamically-linked shared library as separate enough to escape the obligation. LGPL exists precisely to remove that ambiguity for library authors who want wide adoption: it explicitly carves out an exception for dynamic linking, so a proprietary application can link against an LGPL library without the application itself becoming LGPL — only changes to the library's own source stay open. That's why LGPL and MPL are the licenses you'll find under most commercially-friendly infrastructure libraries, and GPL is the one that shows up on end-user applications where the authors want copyleft enforced hard.
Either reading of "distribute" has always had one gap: a company that takes GPL code, modifies it, and runs it as an internal tool or a hosted web service never hands a binary to anyone. Users interact with it over HTTP; no "distribution" in the traditional sense ever happens, so GPL's disclosure obligation never fires — a real and widely exploited loophole through the SaaS-heavy 2000s. The GNU Affero General Public License (AGPL-3.0) was written specifically to close it: AGPL's Section 13 adds an obligation that fires the moment users interact with the modified program over a network, binary or no binary. This is exactly why AGPL is treated as radioactive by most corporate open-source policies, and why a legal or platform team will often ban it outright rather than evaluate it case by case — it's the one license family where "we only run it, we never shipped it" stops being a defense.
"GPL taint" is the scenario that turns a routine dependency bump into a fire drill: one strong-copyleft package, three or four hops down a transitive tree nobody reads that deep into, gets pulled in by something you added for an unrelated reason. Nobody notices until a legal review — often triggered by an acquisition, an enterprise customer's due-diligence questionnaire, or a partner integration — asks for a full license inventory, and by then the offending dependency has been in production for a year and untangling it means re-architecting around a replacement library under deadline pressure. The fix is catching it in CI, at merge time, the same way a CVE gate catches a vulnerable version — see the next two sections.
License scanning and policy as code
☺ Like you're 10: Instead of a lawyer manually reading every dependency's license file, you write the allowed and forbidden list once, and a machine checks every single build against it, forever.
License scanning starts from the same dependency graph an SCA vulnerability scan already resolves — the manifest and lockfile covered in Software Composition Analysis in Depth — but reads a different field off each package's metadata. The tooling landscape splits into a few real options: FOSSA and Snyk's license-compliance product are commercial SaaS platforms built around policy dashboards and legal-team workflows; ScanCode Toolkit and the OSS Review Toolkit (ORT) are open-source and do the deeper, slower work of scanning actual file contents for embedded license text rather than trusting only a package's declared metadata (a package's package.json can claim MIT while a vendored file three directories in still carries a GPL header — metadata-only scanning misses that entirely); and ecosystem-native CLIs like license-checker (npm) and pip-licenses (Python) are fast enough to run as a plain CI step with no external service at all.
# Fast, ecosystem-native — good enough for a CI gate on every PR npx license-checker --summary --excludePrivatePackages npx license-checker --failOn 'GPL-2.0;GPL-3.0;AGPL-3.0;SSPL-1.0' pip-licenses --format=json --with-urls --fail-on='GPL-3.0;AGPL-3.0' # Deeper, file-content-level scanning — catches a vendored GPL header # that a package's own declared metadata doesn't mention scancode --license --json-pp report.json ./vendor/
Licenses combine, and SPDX license expressions are the standard syntax for saying so precisely: Apache-2.0 OR MIT means the consumer can pick either; GPL-2.0-only WITH Classpath-exception-2.0 — the license OpenJDK itself ships under — means GPL's obligations apply with one named carve-out, in that case explicitly permitting proprietary code to link against the JDK without becoming GPL. A scanner that only understands single identifiers and not the boolean grammar will misclassify both of those as plain GPL, which is a common source of false-positive panic in a newly wired-up gate.
The actual enforcement point is the same mechanism What is DevSecOps? introduced for infrastructure policy: a rule expressed as code, versioned in git, run automatically, and merge-blocking rather than advisory.
package license.policy
# Deny the build if any resolved dependency carries a license
# our legal team has explicitly disallowed without a signed exception.
default allow = false
denied_licenses := {"GPL-2.0-only", "GPL-3.0-only", "AGPL-3.0-only", "SSPL-1.0"}
violations[pkg] {
pkg := input.dependencies[_]
pkg.license == denied_licenses[_]
not exception_on_file(pkg.name)
}
exception_on_file(name) {
input.approved_exceptions[_] == name # legal signed off, tracked in git
}
allow {
count(violations) == 0
}License policy as code is the exact same mechanism as a security gate, aimed at a different question. The OPA policy above is structurally identical to the Terraform-plan gate from the DevSecOps foundations page — same engine, same "merge-blocking, not a suggestion" enforcement model. The moment a legal question can be reduced to "is this string in a denylist," it stops needing a lawyer to catch it at merge time and only needs one to write the denylist in the first place.
Dependency confusion: exploiting the public/private namespace
☺ Like you're 10: If your company has a private tool called "acme-utils" that only lives on your own shelf, and a stranger puts a public box labeled "acme-utils" on the public shelf with a bigger number on it, some delivery robots will grab the stranger's box because the number looks newer — without ever checking which shelf it's actually supposed to come from.
In February 2021, security researcher Alex Birsan published "Dependency Confusion: How I Hacked Into Apple, Microsoft and Dozens of Other Companies," documenting an attack technique that earned him over $130,000 in bug bounties across more than 35 organizations, including PayPal, Microsoft, Apple, Netflix, Uber, Yelp, Tesla, and Shopify. The mechanism doesn't require breaching anything — it exploits how package managers resolve a bare name against whichever registries are configured, when a company's internal package name has never been claimed on the public registry.
Package managers like npm and pip commonly get configured with more than one source: an internal registry (Artifactory, Nexus, a private PyPI mirror) for proprietary packages, alongside the public registry for everything else. Birsan's insight was that many real-world configurations don't strictly prefer the internal source — they check version numbers across every configured registry and install whichever is highest, regardless of which registry it came from. If an internal package called acme-internal-utils sits at version 2.1.0 on a private registry that's never been mirrored publicly, an attacker only has to learn that name — often visible in a leaked package.json, a public error stack trace, or an open-source repo that references an internal build tool — and publish a package under the identical name at version 9.9.9 on the public npm or PyPI registry. The next CI build that resolves dependencies across both sources installs the attacker's package instead, and its install-time script (npm's postinstall, a Python setup.py) runs arbitrary code inside the build the moment it's installed — no exploit of any vulnerability required, just an ambiguous name.
The mitigation is namespace control, applied at three layers:
- Claim your namespace publicly. Register your organization's package names on the public registry — even as empty placeholder publishes — so an attacker can never claim them. This is the cheapest fix and the one most often skipped, because it feels like doing the attacker's homework for them.
- Scope, and pin the scope's registry. npm's
@acme/-scoped packages can be mapped to your internal registry explicitly, so the public registry is never even queried for anything under that scope. - Collapse to a single index for internal resolution. pip's own documentation is explicit that combining
--index-urlwith--extra-index-urllets a build search every listed index and take the highest version across all of them — precisely the ambiguity Birsan exploited. Point--index-urlat a single internal proxy configured to serve both your private packages and a vetted mirror of public ones, instead of layering multiple indexes at resolve time.
# .npmrc — scope pinned to the internal registry; the public registry # is structurally never consulted for anything under @acme @acme:registry=https://npm.acme.internal/ # pip.conf — a single index, not index + extra-index, removes the # "highest version wins across sources" ambiguity entirely [global] index-url = https://pypi.acme.internal/simple/
Scoping alone is not a fix — it's a fix only once the scope's registry mapping is actually pinned in every build environment that resolves it. A developer's laptop with a global .npmrc that never got the @acme:registry= line still falls back to the public registry for that scope, silently, with no error. Audit every environment that runs npm install or pip install against your internal namespace — CI runners, build containers, and every engineer's machine — not just the one you tested against.
Typosquatting: exploiting a keystroke, not a namespace
☺ Like you're 10: Dependency confusion tricks the computer's resolution logic. Typosquatting tricks the human typing the command — it just needs you to fat-finger one letter.
Where dependency confusion exploits a resolver's logic, typosquatting exploits the person typing the install command, or copy-pasting a name from a tutorial that got it slightly wrong. An attacker publishes a package under a name that differs from a popular one by a single character, a transposed pair, a missing hyphen, or a homoglyph — a character that looks identical to another in most fonts — and waits for the inevitable typo to install it instead. Real, documented cases from npm and PyPI include crossenv (2017, npm — a typosquat of the legitimate cross-env that exfiltrated environment variables, including AWS and npm credentials, during install), colourama (2018, PyPI — a typosquat of colorama carrying a Windows remote-access trojan and a Discord token stealer), and python3-dateutil paired with a homoglyph package called jeIlyfish (2019, PyPI — a capital "I" standing in for a lowercase "l" in jellyfish, visually near-identical in most fonts, both exfiltrating SSH and GPG keys during install). None of these required a single line of legitimate code to be broken into — the entire attack is a name.
Combosquatting is the same idea with common qualifiers instead of typos: reactjs or react-native-js riding on the reputation of react, betting that a developer skimming search results reads "close enough" and moves on. Detecting either variant reliably is a string-distance problem more than a code-review problem: tools like Socket and Phylum, and the OpenSSF's own Package Analysis project, run automated Levenshtein-distance and homoglyph checks against the top N most-downloaded packages in each ecosystem, flagging any new publish that lands suspiciously close to an established name — the same category of check that has led npm and PyPI to proactively pull thousands of typosquat packages over the years, often before meaningful download counts accrue.
Maintainer trust signals a scanner has no field for
☺ Like you're 10: A CVE feed can only warn you about a problem after someone has found and reported it. It has nothing to say about a package that's brand new, or a trustworthy maintainer who just handed the keys to someone else.
Every signal in this section shares one property: none of them is a vulnerability, so none of them will ever appear in a CVE database, yet each one has preceded a real, damaging supply-chain compromise.
- Package age. A CVE requires a disclosure process that takes time to run — responsible disclosure windows, CNA assignment, publication. A malicious or compromised package published yesterday is, by construction, CVE-blind for as long as it takes anyone to notice and report it. Flagging any newly added direct dependency younger than some threshold — 90 days is a common starting point — for a manual review, rather than an automatic merge, closes a window a vulnerability scanner cannot see into.
- Download counts, and sudden cliffs in them. A package with ten downloads a week that suddenly jumps to ten thousand is worth a second look — inflated download counts are a known technique for making a malicious package look established before it's ever been reviewed by a human.
- Single-maintainer / bus-factor risk. Research repeatedly finds that a majority of packages across npm and PyPI have exactly one person with publish rights. One compromised account, one coerced maintainer, or one person simply agreeing to hand off a project they no longer have time for is a single point of failure with no organizational backstop behind it — see the case below.
- Install-time script behavior. npm's
postinstallhook and Python'ssetup.pyboth run arbitrary code the moment a package is installed, before a single line of the package is ever imported or executed by your application. This is the actual delivery mechanism behind nearly every incident named on this page — the payload doesn't wait for someone to call a malicious function, it runs automatically duringnpm install.
| Incident | Year | What actually happened |
|---|---|---|
| event-stream (npm) | 2018 | The original, trusted maintainer handed publish rights to a stranger who volunteered to help. The new "maintainer" added a malicious dependency, flatmap-stream, targeting one specific Bitcoin wallet app's users — no account was hacked, the access was simply given away. |
| ua-parser-js (npm) | 2021 | A popular package (millions of weekly downloads) had its maintainer's npm account compromised. Malicious versions shipped a cryptominer and a credential stealer in a postinstall script, running before most CI pipelines even reached a build step. |
| node-ipc "protestware" | 2022 | The legitimate maintainer, not an attacker, added code that wiped files on machines geolocated to Russia or Belarus, in protest of the invasion of Ukraine. Trust was never breached — it was weaponized by the person who legitimately held it. |
| xz-utils backdoor (CVE-2024-3094) | 2024 | A contributor using the "Jia Tan" identity spent roughly two years building commit history and community trust before being granted co-maintainer status on a compression library linked into OpenSSH on most Linux distributions via liblzma. The backdoor was hidden in obfuscated build-script macros in the release tarball, not visible in the reviewable git history, and would have granted an authenticated SSH bypass. It was caught by chance — a single engineer, Andres Freund, noticed a small, unexplained latency regression during routine performance work. |
The xz-utils case is the sharpest illustration of this whole page's thesis. A CVE scanner checking xz-utils 5.6.0 and 5.6.1 against a vulnerability database found nothing, because no CVE existed until after Freund's discovery. A naive trust heuristic — "how long has this project existed, how active is its maintenance" — would have scored xz-utils highly, because Jia Tan's two years of legitimate-looking commits were the entire point of the operation. The backdoor didn't exploit a flaw in the code review process; it exploited the fact that sustained, patient, apparently-genuine contribution is indistinguishable from the real thing until it isn't.
Automated tooling closes part of this gap without requiring a human to research every dependency by hand. The OpenSSF Scorecard project runs roughly eighteen automated checks against a repository — Maintained, Code-Review, Branch-Protection, Dangerous-Workflow, Binary-Artifacts, Signed-Releases, Pinned-Dependencies, and more — and rolls them into a 0–10 score. deps.dev, Google's Open Source Insights project, hosts precomputed Scorecard results and full dependency graphs for a large share of the npm, PyPI, Go, Maven, and Cargo ecosystems, so most dependencies can be checked without running the scan yourself.
# Run Scorecard against a specific repo you're evaluating as a new dependency scorecard --repo=github.com/example-org/example-lib # Or query deps.dev's precomputed result instead of running your own scan curl -s "https://api.deps.dev/v3alpha/projects/github.com%2Fexample-org%2Fexample-lib:scorecard" | jq . # Flag anything below a threshold for manual review rather than auto-merge scorecard --repo=github.com/example-org/example-lib --format=json \ | jq 'if .score < 5 then "REVIEW REQUIRED" else "OK" end'
Pick any dependency already in one of your own projects — ideally one you've never personally evaluated, just something a teammate added a year ago. Run npm view <package> time.created (or check PyPI's release history page) to see exactly how old the version you're pinned to actually is, then look up its OpenSSF Scorecard result on deps.dev. If the score is low, or the maintainer list is exactly one name, you've just done — by hand, in fifteen minutes — the review a merge gate should be doing automatically on every new dependency your team adds.
Wiring it into one merge-blocking gate
☺ Like you're 10: Four separate checks, one door — nothing gets through unless it clears the license list, comes from the registry it's supposed to, isn't a lookalike name, and has a maintainer track record worth trusting.
None of the checks in this page are useful sitting in a dashboard nobody reads on a schedule nobody keeps. The pattern that actually holds is the same one the secure SDLC establishes for every other security control: a required, merge-blocking status check, not an advisory report.
| Risk | Signal to check | Where it runs |
|---|---|---|
| License obligation | SPDX id against an allow/deny policy | PR-time, via license-checker/pip-licenses or a Rego gate |
| Dependency confusion | Registry the package actually resolved from | Build-time, enforced by scope pinning + single-index config |
| Typosquatting | String distance to top-N package names | Publish-time (registry) and PR-time (Socket/Phylum-class tooling) |
| Maintainer trust | Package age, Scorecard score, maintainer count | PR-time, on any newly added direct dependency |
Feed the results into the same finding-management system that already tracks CVE findings — vulnerability management & triage covers DefectDojo for exactly this purpose — so a license violation and a critical CVE compete for the same team's attention through the same triage process, instead of license risk living in a spreadsheet nobody on the security team ever opens. If you want to build one of these gates end to end against a real pipeline, the capstone's Add an SCA Gate lab and the standalone Vulnerable Dependency Fire Drill drill both give you a pipeline to break and then actually fix.
If you're studying toward a certification that weights this specific area, CSSLP's Secure Software Supply Chain domain covers vendor, license, and third-party component risk in real depth — as with every certification named in this course, verify the current exam outline on ISC2's own page before planning study time around it, since domain weights shift between exam versions.
Benny the Beaver: Added a new library for the report generator. Scan's green, no CVEs. Merging.
Nutty the Squirrel: Hold on — I pulled its license. It's AGPL-3.0. We're a hosted SaaS. That's not a "keep the notice" license, that's "open your whole codebase the moment a customer's browser talks to it."
Benny the Beaver: The scanner didn't flag anything!
Timmy the Turtle: Because it's not a vulnerability. It's a contract. Different gate, same door — and this one was never wired in.
Pip the Hummingbird: While you're both here — I checked who publishes it. One maintainer, account created eight months ago, no Scorecard history worth mentioning. Even if the license were fine, I wouldn't trust this one yet.
Foxy: So a clean CVE scan told us precisely nothing about either problem?
Pip the Hummingbird: Precisely nothing. A CVE feed only knows about flaws someone already found and filed. It has no opinion on a contract, a lookalike name, or a stranger's two years of patient, trustworthy-looking commits. Ask Jia Tan how well that last one worked.
1. Why can a package be completely free of CVEs and still be a serious risk — name the two risk categories this page covers that a vulnerability scanner structurally cannot see. 2. What's the difference between GPL and AGPL, in terms of what specifically triggers the disclosure obligation? 3. Walk through the dependency confusion attack in one or two sentences — what does the attacker actually do, and why does an unscoped, multi-registry build fall for it? 4. Name one real incident where the maintainer of a package was never compromised at all, and explain what happened instead.
Check your answers
- License risk (a legal obligation attached to using the code, unrelated to code quality) and supply-chain trust risk (whether the package's name, registry, and maintainer are actually what they claim to be). Both are structurally invisible to a CVE scanner because neither is a disclosed vulnerability — a CVE only exists once someone has found and filed one.
- GPL's obligation triggers on distribution of a combined work — if you never hand a binary to anyone, e.g. you only run it internally or as a web service, GPL's disclosure requirement never fires. AGPL adds Section 13, which triggers the same obligation the moment users interact with the modified program over a network, closing that SaaS loophole entirely — no distribution required.
- The attacker learns (or guesses) the name of an internal, never-publicly-published package, and publishes a package under that exact name on the public registry at a deliberately high version number. A build resolving across both an internal registry and the public one, without an explicit registry mapping pinning that name to the internal source, picks the highest version across every configured source — landing on the attacker's public package, whose install script then runs as the build.
- The
event-streamincident (2018): the original maintainer wasn't hacked — they voluntarily handed publish rights to a new contributor who then added a malicious dependency. Thenode-ipc"protestware" incident (2022) is the other example: the legitimate maintainer themselves added destructive code, with no account compromise at all — the trust was real and simply turned against its users.
You now have the full non-CVE half of software composition analysis: license obligations that scale from a copyright notice to your entire codebase, two attacks that exploit a name instead of a flaw, and the maintainer-trust signals that would have flagged xz-utils years before Andres Freund got lucky. Pair this with Software Composition Analysis in Depth for the CVE half and Software Bills of Materials for the inventory both halves query against, and see container & supply-chain security for how signing and SLSA provenance close the trust gap this page leaves at the source level.