CyberNugget

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 install and pip install can 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=true in .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 no setup.py runs 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 install and pip install can 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.

Deep dive 1

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 verify without 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.
Deep dive 2

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.