L55 · Dependency and supply chain risk
Module 8: Secure SDLC and Supply Chain · ~20 min · Lesson 55 of 71
🎯 What you'll learn
- How attackers get their package into your build: typosquatting, dependency confusion, and hijacked maintainers
- Why
npm installandpip installcan run someone else's code on your laptop and in CI - What lockfiles and integrity hashes guarantee, and what they don't
- How SBOMs, SLSA levels and OpenSSF Scorecard help you know and prove what you ship
🔥 Why this matters
In February 2021, researcher Alex Birsan published "Dependency Confusion". He had found the names of internal packages at Apple, Microsoft, PayPal and others, published public packages with the same names and higher version numbers, and watched their build machines download and run his code. He reported it to more than 35 companies and collected over $130,000 in bug bounties. He never broke in: their own package managers fetched his code.
1. Your code is mostly other people's code
A Spring Boot service that declares 20 dependencies often ships over 100 jars. Every one of them, and every system that built and delivered them, is part of your software supply chain: everything that touches your code between someone's keyboard and your production pod.
UPSTREAM PUBLIC REGISTRY YOUR PROXY YOUR CI
┌────────────┐ 1 ┌──────────────┐ 2 ┌──────────────┐ 3 ┌─────────┐
│ maintainer │──▶ │ npmjs, PyPI │──▶ │ Artifactory │──▶ │ build + │
│ repo + CI │ │ Maven Central│ │ CodeArtifact │ │ install │
└────────────┘ └──────────────┘ └──────────────┘ └────┬────┘
4 ▼
attack points: ┌────────────┐
1 stolen maintainer token, hijacked build (L56) │ ECR image │
2 typosquat or look-alike package published │ → EKS pod │
3 proxy serves public package for internal name └────────────┘
4 install script runs on the CI runner (L57)
SCA tools (L54) find known vulnerabilities in packages you meant to use. This lesson is about the other half: packages that are malicious on purpose, or that aren't the package you meant.
2. Getting the wrong package
Typosquatting is publishing a malicious package whose name is one slip away from a popular one:
reqeusts for requests, lodahs for lodash. A real case: in 2017 the npm package crossenv
(the real one is cross-env) sent the installer's environment variables to its author. AI coding
assistants add a twist: they sometimes invent package names, which attackers then register.
Dependency confusion is subtler. Your team publishes examplefin-ledger-utils 1.4.2 to the
internal registry. An attacker learns the name (from a leaked package.json, an error page, a
public repo) and publishes examplefin-ledger-utils 99.0.0 on the public registry. If your build
can see both and picks "the highest version", it picks the attacker's.
build machine: pip install examplefin-ledger-utils
│
│ pip.conf: index-url = internal, extra-index-url = pypi.org
├──────────────▶ internal index: 1.4.2 (yours)
├──────────────▶ pypi.org: 99.0.0 (attacker's)
│
▼ pip treats all indexes as equal: highest version wins
downloads 99.0.0 → setup.py runs → env vars, AWS creds sent out
npm (unscoped internal names), Maven (an extra public repository) and any proxy that merges internal and public repos without a priority rule have the same bug. The fixes are in the worked example.
A third route skips naming tricks: hijack a real package by phishing a maintainer or stealing
their token. In September 2025 a phished maintainer's account pushed malware into chalk, debug
and other popular npm packages, and the self-spreading "Shai-Hulud" worm stole npm tokens and
republished itself into its victims' packages (scale estimates vary).
3. Installing is running code
A lifecycle script is code a package runs automatically during install. npm runs a dependency's
preinstall, install and postinstall scripts. pip runs setup.py when it builds from a source
distribution (sdist) instead of a prebuilt wheel. Maven has no install hooks, but build plugins
and annotation processors run arbitrary code at build time.
{
"name": "lodahs",
"version": "4.17.21",
"scripts": { "postinstall": "node setup.js" }
}
Here setup.js quietly posts process.env to x.example.net. On a developer laptop that leaks
~/.aws and ~/.npmrc tokens. On a CI runner it leaks deploy credentials (L57). Ways to cut it
back:
- npm:
ignore-scripts=truein.npmrc, then allow the few packages that need a build step (pnpm v10 and later doesn't run dependencies' scripts unless allowed; check your version). - pip:
pip install --only-binary :all:refuses sdists, so nosetup.pyruns at install. - Everywhere: install in a throwaway container, not on a laptop full of credentials.
4. Lockfiles and integrity hashes
A lockfile records the exact version and a hash of every package, direct and transitive, from the last resolution. The integrity hash is checked at install: if the bytes differ, install fails.
"node_modules/left-padder": {
"version": "2.1.0",
"resolved": "https://artifacts.example.com/npm/left-padder-2.1.0.tgz",
"integrity": "sha512-q8Xv3Ex...k2A=="
}
| Ecosystem | Lock and hash | Enforce in CI |
|---|---|---|
| npm | package-lock.json, integrity (sha512) |
npm ci (fails if lock and package.json disagree) |
| Poetry | poetry.lock, sha256 per file |
poetry install (or poetry check --lock, Poetry 1.6+) |
| pip | requirements.txt from pip-compile --generate-hashes |
pip install --require-hashes -r ... |
| Maven | no lockfile; pin exact versions, no ranges | checksumPolicy=fail or mvn -C |
Maven's .sha1/.sha512 files come from the same server as the jar, so they catch corruption, not
a malicious server. Maven 3.9+ can check against trusted checksums you commit; Gradle has
verification-metadata.xml (check current docs, both are version-specific).
The limit: a lockfile freezes what you got. Lock a malicious version and it faithfully reinstalls it. It defends against change, not a bad first choice. L14's deep dive pins Docker bases and GitHub Actions the same way.
5. Know what you ship, and prove how it was built
An SBOM (software bill of materials, L06) lists every component in a build. The two formats are
CycloneDX (OWASP) and SPDX (Linux Foundation, ISO/IEC 5962). Generate it from the final
image (syft <image>@sha256:... -o cyclonedx-json), which holds things the manifest doesn't
(L36's shaded jars), and store it next to the image.
SLSA (Supply-chain Levels for Software Artifacts) grades how trustworthy a build is. Its v1.0 Build track:
| Level | Requirement | Stops |
|---|---|---|
| L1 | Provenance exists: a record of what was built, from which source, by which builder | "Where did this come from?" guessing |
| L2 | Built on a hosted platform that signs the provenance | Tampering after the build |
| L3 | Hardened builder: isolated runs, signing keys unreachable from build steps | A build step forging its own provenance |
Provenance is that signed record (first deep dive). SLSA doesn't judge code quality: a perfectly SLSA L3 build of malicious source is still malicious (L56's xz case).
OpenSSF Scorecard scores a project's habits 0–10 per check: code review, branch protection, pinned dependencies, dangerous workflows, signed releases. Use it when choosing a dependency (second deep dive) and on your own repos.
🧪 Worked example: closing dependency confusion in npm, pip and Maven
The rule everywhere: one door in. Builds talk only to your proxy, which never fetches a public package with an internal name.
npm: use a scope. Name internal packages @examplefin/ledger-utils, and route the scope:
# .npmrc (repo root, committed)
registry=https://artifacts.example.com/npm/npm-all/
@examplefin:registry=https://artifacts.example.com/npm/npm-internal/
ignore-scripts=true
npm-all merges internal and npmjs; npm-internal has no public upstream, so @examplefin/* can
never come from npmjs. Also register the examplefin org on npmjs.com so nobody else can use it.
pip: one index, never extra-index-url. Python has no scopes, so the proxy enforces priority:
# pip.conf
[global]
index-url = https://artifacts.example.com/api/pypi/pypi-all/simple
# no extra-index-url: that's what made pip compare versions
In Artifactory, exclude your internal prefix (examplefin-*) from the remote PyPI repo, or turn on
priority resolution for the local repo. In CodeArtifact, block upstream for internal names:
aws codeartifact put-package-origin-configuration \
--domain examplefin --repository internal --format pypi \
--package examplefin-ledger-utils \
--restrictions publish=ALLOW,upstream=BLOCK
Per AWS docs, CodeArtifact applies this by default once you publish a version of a package directly, but set it explicitly for names you've reserved and not yet published.
Maven: mirror everything. Central makes publishers prove they own a groupId's domain, which
helps, but any extra repository in a pom.xml is a second door:
<!-- ~/.m2/settings.xml and the CI image -->
<mirrors>
<mirror>
<id>corp</id>
<mirrorOf>*</mirrorOf> <!-- every repo goes through the proxy -->
<url>https://artifacts.example.com/maven-all/</url>
</mirror>
</mirrors>
Then commit lockfiles with hashes, and run npm ci / pip install --require-hashes in CI.
🛠 Try it (optional, ~5 min)
In an empty folder on your machine, install one well-known package without scripts, then check its lock entry and registry signatures:
mkdir -p ~/l55-try && cd ~/l55-try && npm init -y >/dev/null
npm install --ignore-scripts express
grep -c '"integrity"' package-lock.json # one hash per package
grep -l '"postinstall"' node_modules/*/package.json # who'd run code?
npm audit signatures
# audited 66 packages in 1s (counts vary by version)
# 66 packages have verified registry signatures
Note how many packages one dependency pulled in. Delete ~/l55-try after.
🧠 Key takeaways
- Most of your shipped code is dependencies; each one, and its maintainer's accounts, is in your trust boundary.
- Dependency confusion is a configuration bug: one index, scoped names, upstream blocked for internal names.
npm installandpip installcan run arbitrary code; turn scripts off and install away from credentials.- Lockfiles stop silent change, not bad first choices.
- SBOMs say what you ship; SLSA provenance says how it was built; Scorecard hints at how a project is run.
📖 Words to know
| Term | Meaning |
|---|---|
| Software supply chain | Every person, system and artifact between source code and what runs in production |
| Typosquatting | Publishing a malicious package under a name that's a likely misspelling of a popular one |
| Dependency confusion | Tricking a build into fetching a public package that has the same name as an internal one |
| Lifecycle script | Code a package runs automatically during install (npm postinstall, pip setup.py) |
| Lockfile | A file recording the exact version and hash of every resolved dependency |
| SBOM | Software bill of materials: a machine-readable list of components (CycloneDX, SPDX) |
| Provenance | A signed record of how, where and from what source an artifact was built |
| SLSA | A framework of levels for build integrity, from "provenance exists" to "hardened builder" |
| OpenSSF Scorecard | Automated checks that score an open-source project's security practices |
⏭ Next time
L56 looks at two supply chain attacks that beat all of this: SolarWinds, where attackers hid inside the vendor's build server, and xz-utils, where one patient "maintainer" spent years earning trust.
Build provenance: reading and verifying a SLSA attestation
L49's deep dive showed how keyless signing proves who signed an image. Provenance is what they signed: a statement about how the artifact was built. A signature over a vague statement proves little, so the value is in the fields, and in checking them.
What's inside
Provenance is usually an in-toto attestation: an envelope with a subject (the artifact's digest) and a predicate (facts about it). Trimmed SLSA v1 provenance, made-up values:
{
"_type": "https://in-toto.io/Statement/v1",
"subject": [{ "name": "statements-svc",
"digest": { "sha256": "9f74e2...c1" } }],
"predicateType": "https://slsa.dev/provenance/v1",
"predicate": {
"buildDefinition": {
"externalParameters": { "workflow": {
"repository": "https://github.com/examplefin/statements-svc",
"ref": "refs/tags/v1.4.2",
"path": ".github/workflows/release.yml" } },
"resolvedDependencies": [{
"uri": "git+https://github.com/examplefin/statements-svc",
"digest": { "gitCommit": "a1b2c3d4..." } }] },
"runDetails": { "builder": {
"id": "https://github.com/actions/runner/github-hosted" } }
}
}
The four questions to ask
artifact you're about to deploy
│ 1 does its sha256 equal subject.digest? no → stop
▼
signature on the attestation
│ 2 signed by a builder you trust (builder.id)? no → stop
▼
source
│ 3 your repo, from a protected ref (tag/main),
│ not a fork or feature branch? no → stop
▼
how
│ 4 the expected workflow file? no → stop
▼
deploy
Most real failures are skipped questions: verifying the signature but not the repository, or accepting any branch. An attacker who can push a branch can often run your trusted builder on their code; the provenance will then honestly say so, but only if someone reads it.
Worked example: tools that do the checking
GitHub artifact attestations (built with actions/attest-build-provenance) and the CLI:
gh attestation verify oci://123456789012.dkr.ecr.us-east-1\
.amazonaws.com/statements-svc@sha256:9f74e2...c1 \
--owner examplefin \
--signer-workflow examplefin/statements-svc/.github/workflows/release.yml \
--source-ref refs/tags/v1.4.2
# ✓ Verification succeeded!
Without the --signer-workflow and --source-ref flags, any workflow in the org would pass. For
open-source packages, npm publish --provenance attaches provenance to npm packages, and
npm audit signatures checks it on install. slsa-verifier does the same for artifacts built with
the SLSA GitHub generators. (Flags change between versions; check --help.)
In a cluster, the same four checks go into an admission policy (Kyverno or a Sigstore policy controller, L51) so a pod without matching provenance never starts. Wiring the CI side is L57.
🧠 Extra takeaways
- Provenance is a signed statement of source, builder and workflow; signing alone proves none of them.
- Check digest, builder, source repo and ref, and workflow, every time.
gh attestation verifywithout workflow and ref constraints is a weak check.- Provenance can't tell you the source code was good; it tells you which code it was.
Vetting a new dependency in ten minutes
A teammate's PR adds fast-json-mask to mask account numbers in API logs. Before you approve,
you're deciding to run a stranger's code in production with access to customer data. Ten minutes
of checks catch most bad picks.
The checklist
| Check | Where | Worry if |
|---|---|---|
| Is the name right? | Registry page, project's own docs | One letter off a popular package, no repo link |
| Who maintains it? | Registry, GitHub | One maintainer, or a new maintainer added recently |
| How old is this version? | Registry publish date | Released hours ago |
| Install scripts? | package.json scripts, sdist-only on PyPI |
postinstall on a pure-JS library |
| What does it pull in? | npm view <pkg> dependencies, deps.dev |
40 transitive deps for a small job |
| Project health | OpenSSF Scorecard, deps.dev | No code review, unpinned workflows, unmaintained |
| Known vulns, license | SCA (L54), deps.dev | Open criticals, a license legal won't accept |
Scorecard in practice
Scorecard results for many public GitHub projects are published, so you can read them without running anything:
curl -s https://api.securityscorecards.dev/projects/github.com/\
expressjs/express | jq '{score, checks: [.checks[]
| select(.score >= 0 and .score < 5) | {name, score}]}'
# {"score": 6.8, "checks": [
# {"name": "Pinned-Dependencies", "score": 3},
# {"name": "Signed-Releases", "score": 0} ...]}
(Output illustrative; scores change.) Read low scores as questions, not verdicts: many healthy
projects don't sign releases. Dangerous-Workflow, Code-Review and Maintained low scores
deserve a closer look. The scorecard CLI does the same for your own repos.
Worked example: a cooldown
Many hijacked versions (like the September 2025 npm incidents) were spotted and removed within
hours or days. Waiting a few days before adopting any new version avoids most of them. In
renovate.json (older Renovate versions called this stabilityDays):
{ "minimumReleaseAge": "3 days" }
Make sure security fixes are exempt (check your tool's docs), so urgent patches aren't delayed.
Dependabot (cooldown), pnpm (minimumReleaseAge) and uv (exclude-newer) offer similar settings
in recent versions; check which yours supports. For fast-json-mask: one maintainer, published last week, a postinstall
script, and masking is 15 lines of your own code. Write the 15 lines.
🧠 Extra takeaways
- Adding a dependency is a trust decision; ten minutes of checks is cheap.
- Treat a fresh version, a new maintainer and a surprise install script as warning signs.
- Scorecard scores are prompts for questions, not pass/fail.
- A cooldown of a few days avoids most hijacked releases at almost no cost.
The quiz for this lesson and the Tests section need JavaScript.