Software Supply Chain Security
On this page
- The Code You Trusted Came From Somewhere
- How Supply Chains Get Attacked
- Dependency Confusion in Detail
- Know What You Ship: the SBOM
- A Worked Example: Checking Dependencies Against OSV
- Make Installs Deterministic
- Verify Provenance and Signatures
- Harden the Pipeline
- Updating Without Being the First Victim
- Practice
The Code You Trusted Came From Somewhere#
Most of the code in a modern application was not written by its team. It arrives from package registries, base container images, build plugins and CI actions, and each of those pulls in more dependencies of its own. An attacker who compromises one popular link in that chain reaches every application downstream at once, without touching any of their source code.
Software supply-chain security means knowing exactly what goes into your builds, verifying that it is what it claims to be, controlling how it gets updated, and protecting the build system itself.
How Supply Chains Get Attacked#
| Attack | How it works | Main defence |
|---|---|---|
| Typosquatting | Publish reqeusts or python-dateutils and wait for a typo |
Review new dependencies, lock files, allowlists |
| Dependency confusion | Publish a public package with the same name as your private one and a higher version | Scoped names, single trusted index, name reservation |
| Maintainer account takeover | Steal a maintainer's registry credentials, publish a malicious version | Pinning, delayed adoption, provenance checks |
| Malicious maintainer | A trusted contributor or new owner adds a backdoor | Review diffs of updates, reproducible builds |
| Install-time scripts | postinstall (npm) or setup.py code runs on the developer or CI machine during install |
Disable scripts where possible, isolate builds |
| Build system compromise | Attacker modifies the build server so the output differs from the source | Hardened, ephemeral builders, signed provenance |
| CI action or plugin hijack | A third-party action's tag is repointed to malicious code | Pin by commit SHA, restrict token permissions |
| Poisoned base image | A container base image contains malware or old vulnerable packages | Trusted, digest-pinned, scanned images |
The common thread: every artifact you ingest is code, and it runs with someone's privileges. Install scripts run with the developer's; CI actions run with the pipeline's, which often include production deploy credentials.
Dependency Confusion in Detail#
This one catches mature teams because the tooling does the wrong thing by default. Suppose your internal index hosts acme-billing version 1.4. With pip:
# DANGEROUS: pip considers BOTH indexes and installs the highest version it finds
pip install --extra-index-url https://pypi.internal.acme/simple acme-billing
If an attacker publishes acme-billing 99.0 on the public index, pip installs the attacker's package. Fixes:
- Use a single index (
--index-url) that proxies the public registry and serves your private packages, with the proxy configured so internal names can never be satisfied from upstream. - In npm, put internal packages under a scope (
@acme/billing) and map the scope to your registry in.npmrc. - Register your internal names on the public registry as empty placeholders.
Know What You Ship: the SBOM#
A Software Bill of Materials is a machine-readable inventory of every component in a build: names, versions, package URLs, hashes and licences. The two common formats are CycloneDX and SPDX. Generate it at build time from the actual artifact, not from a hand-maintained list:
# Generate an SBOM for a container image (syft), then scan it (grype)
syft ghcr.io/acme/billing@sha256:4f1c... -o cyclonedx-json > sbom.json
grype sbom:./sbom.json --fail-on high
# Python project: audit installed or locked dependencies
pip-audit -r requirements.txt
The SBOM earns its keep when a new vulnerability is announced. Instead of asking every team "do you use library X?", you query stored SBOMs and get the list of affected releases in minutes.
A Worked Example: Checking Dependencies Against OSV#
The OSV database aggregates advisories for PyPI, npm, Go, Maven and other ecosystems, with a free query API. A short script shows what vulnerability scanners do under the hood:
import re
import requests
def parse_requirements(path):
for line in open(path, encoding="utf-8"):
m = re.match(r"^([A-Za-z0-9_.\-]+)==([^\s;#]+)", line.strip())
if m:
yield m.group(1), m.group(2)
def osv_query(name, version, ecosystem="PyPI"):
r = requests.post("https://api.osv.dev/v1/query", timeout=15, json={
"package": {"name": name, "ecosystem": ecosystem},
"version": version,
})
r.raise_for_status()
return r.json().get("vulns", [])
for name, version in parse_requirements("requirements.txt"):
vulns = osv_query(name, version)
if vulns:
ids = ", ".join(v["id"] for v in vulns[:5])
print(f"{name}=={version}: {len(vulns)} advisories ({ids})")
For an old pinned dependency you get lines like somepackage==1.2.0: 3 advisories (GHSA-..., PYSEC-...). Each advisory record includes the affected version ranges and the fixed version. Production tools add what this sketch lacks: transitive dependencies, batching, caching and severity scoring. Only pinned (==) lines are checked here, which is itself a lesson: you cannot audit a range like >=2.0 because you do not know what is installed.
Make Installs Deterministic#
Lock files freeze the exact resolved version of every direct and transitive dependency. Hashes go further: the installer refuses any file whose digest does not match, so a replaced file on the registry or a tampered mirror fails loudly.
# pip-tools: compile a fully pinned lock file with hashes
pip-compile --generate-hashes requirements.in -o requirements.txt
pip install --require-hashes -r requirements.txt
# npm: install exactly what package-lock.json says, fail if it is out of sync
npm ci
npm ci --ignore-scripts # also skip install scripts where your deps allow it
A hash mismatch error from pip ("THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE") should be investigated, not silenced by regenerating the lock file. It means the bytes changed for a version you already trusted. Hashing is explained in hashing.
Container images get the same treatment: reference base images by digest (python:3.12-slim@sha256:...) rather than a mutable tag.
Verify Provenance and Signatures#
Locking tells you the bytes have not changed since you locked them. Provenance tells you where they came from in the first place:
- Signed artifacts: Sigstore's
cosignsigns and verifies container images, often with short-lived certificates tied to a CI identity rather than a long-lived key. Verify at deploy time, for example with a Kubernetes admission policy. See digital signatures. - Build provenance: the SLSA framework defines levels of assurance that an artifact was built from a specific source commit by a specific, hardened build system. Several registries now show provenance attestations for published packages (npm supports publishing with
--provenancefrom supported CI systems). - Reproducible builds: if two independent builders produce bit-identical output from the same source, a compromised builder is detectable.
Harden the Pipeline#
CI is a high-value target because it holds deploy credentials and runs third-party code:
permissions:
contents: read # default token can only read the repo
jobs:
build:
runs-on: ubuntu-latest
steps:
# pinned to a full commit SHA, not a movable tag like @v4
- uses: actions/checkout@<full-40-character-commit-sha>
- run: npm ci --ignore-scripts
- Pin third-party actions by full commit SHA; a tag can be moved to malicious code, a SHA cannot. Tools like Dependabot can still update SHA pins for you.
- Give the workflow token minimal permissions and grant more per job only where needed.
- Never expose secrets to builds triggered by pull requests from forks.
- Use ephemeral build runners so one poisoned build cannot persist into the next.
- Use OIDC federation for cloud deploys instead of stored keys (see secrets management).
Updating Without Being the First Victim#
There is a tension: stale dependencies accumulate known vulnerabilities, but instantly adopting every new release means you install a hijacked version within minutes of publication. A workable policy:
- Automate update pull requests (Dependabot, Renovate) so updating is cheap.
- For routine updates, wait a short period after release before merging; most malicious versions are detected and pulled by registries quickly.
- For security fixes, update fast, under an SLA by severity.
- Review the diff of dependency updates for high-risk packages, especially new install scripts, new network calls or obfuscated code.
- Treat an unexpected new dependency or a sudden maintainer change on a critical package as a signal worth investigating.
Practice#
Take a project you own and generate an SBOM with syft dir:., then run grype or pip-audit against it. Count how many packages appear that you did not list directly. Next, switch the project to hash-locked installs and confirm that editing one hash in requirements.txt makes the install fail. Finally, grep your CI workflows for uses: lines not pinned to a SHA. Malware delivered this way is described in malware types.
Check your understanding
4 quick questions on this lesson. Get them all right and the lesson is marked complete.