I’ve been waiting years for this category to show up, and OWASP 2025 finally delivered. Software Supply Chain Failures landing at A03 is, in my opinion, the single most important change in the whole list. For most of my career, “application security” meant the code your team wrote. But somewhere along the way, the code your team wrote became the smallest part of what you actually ship.
Pull up any modern app and look at the dependency tree. A fresh Python web project pulls in dozens of direct dependencies and hundreds of transitive ones before you’ve written a single line of business logic. Every one of those packages runs with the same privileges as your code. You audited your own login function six ways from Sunday, and meanwhile there’s a transitive dependency four levels deep that you’ve never heard of, maintained by a person you’ve never met, that can read your environment variables. That’s the problem A03 is trying to make us take seriously.
Quick Answer: What is a Software Supply Chain Failure?
Software Supply Chain Failures occur when an attacker compromises any of the third-party components, build tools, or distribution channels your application depends on, rather than attacking your code directly. Instead of breaking down your front door, they poison something you already trust and let you carry it inside.
Why it’s new in OWASP 2025: The old “A06: Vulnerable and Outdated Components” category only covered known vulnerabilities in dependencies. A03 is broader and scarier - it covers the dependencies, the build pipeline, the package registries, and the developer tooling as one connected attack surface. The data finally caught up to what those of us doing this work have been seeing for years.
Where attacks actually come from:
- Malicious packages published to public registries (PyPI, npm)
- Dependency confusion where a public package shadows your internal one
- Compromised maintainer accounts pushing a backdoored release
- Poisoned build pipelines that inject code during CI/CD
- Typosquatting packages named one keystroke away from a popular library
Why Supply Chain Finally Made the Top 10
When I started doing security work, the dependency conversation was almost entirely about patching - “you’re running an old version of OpenSSL, go update it.” That’s still important, but it’s a tiny slice of the real risk now.
The Trust Problem Nobody Wants to Talk About
Here’s the uncomfortable truth I keep running into during assessments: most teams have no idea what they’re actually running. They can tell me about their own services in detail, but when I ask “what’s the full list of packages that execute in production, including transitive ones, and who maintains them?” I get blank stares.
That’s not a knock on those teams - it’s genuinely hard. The modern dependency graph is enormous, and trust flows transitively through all of it. When you pip install a package, you’re not just trusting that package’s author. You’re trusting every author of every dependency that package pulls in, plus the registry that serves it, plus the build server that produced the wheel. That’s a lot of strangers with a lot of access.
Real Attacks That Changed How I Think
I don’t bring these up to be dramatic. I bring them up because each one taught the industry something specific:
- SolarWinds (2020) - attackers compromised the build system, not the source code. The published, signed binaries were malicious even though the repository looked clean. This is the one that made “trust your build pipeline” a real conversation.
- event-stream (2018) - a popular npm package was handed off to a new “maintainer” who quietly added code to steal cryptocurrency wallets. A textbook maintainer-account problem.
- Dependency confusion (2021) - researcher Alex Birsan showed that publishing public packages with the same names as companies’ internal packages could trick build tools into pulling the attacker’s version. He got code execution inside dozens of major companies just by uploading packages.
- Codecov (2021) - a compromised CI tool exfiltrated environment variables (and therefore secrets) from thousands of build pipelines.
- xz/liblzma (2024) - a multi-year social engineering campaign where an attacker became a trusted maintainer and slipped a backdoor into a compression library that nearly made it into mainstream Linux distributions. This one genuinely scared me, because it was patient, sophisticated, and almost worked.
The pattern across all of these is the same: nobody broke into the victim’s code. They broke into something the victim trusted.
The Anatomy of a Supply Chain Attack
Once you’ve seen a few of these, they start to rhyme. Most follow a recognizable arc:
- Find a trusted input - a popular package, a build tool, a maintainer account, a CI runner.
- Compromise or impersonate it - phish the maintainer, typosquat the name, exploit weak registry controls, or just volunteer to “help maintain” an abandoned project.
- Ship malicious behavior quietly - often in install scripts, build steps, or rarely-read code paths so it survives a casual review.
- Wait for the blast radius - every downstream project that updates picks up the payload automatically.
The reason this works so well is that step 4 is your automation working exactly as designed. Your dependency updater, your CI pipeline, your latest tag - they’re all built to pull the newest thing as fast as possible. Supply chain attacks turn your own efficiency against you.
Practical Prevention That Actually Holds Up
Alright, enough doom. Here’s what I actually recommend, roughly in order of bang-for-buck. None of this is exotic - it’s mostly about removing blind automation and adding verification.
1. Pin Everything and Verify Hashes
The single highest-value change for most teams: stop installing “whatever’s newest” and start installing exactly what you reviewed. In Python, that means pinned versions plus hash checking, so a tampered artifact gets rejected even if the version number matches.
# requirements.txt with hash pinning
# Generated with: pip-compile --generate-hashes requirements.in
#
# pip will REFUSE to install anything whose hash doesn't match,
# which defeats a swapped-out artifact even at the same version.
requests==2.32.3 \
--hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760 \
--hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6
urllib3==2.2.2 \
--hash=sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472
# Install with hash enforcement turned ON
pip install --require-hashes -r requirements.txt
I treat the lockfile (requirements.txt with hashes, poetry.lock, Pipfile.lock, etc.) as a security artifact, not a convenience. It belongs in version control, and changes to it deserve the same review attention as changes to your auth code. When a lockfile changes, something about what you execute changed - that’s worth a human looking at it.
2. Scan Dependencies for Known Vulnerabilities
Pinning protects you from changes; scanning protects you from known-bad versions you’ve already pinned. Run a scanner in CI and fail the build on critical findings. For Python, pip-audit (which queries the OSV and PyPI advisory databases) is my default:
# Audit installed packages or a requirements file
pip-audit -r requirements.txt
# Fail CI on any fixable vulnerability
pip-audit -r requirements.txt --strict
Here’s a small wrapper I’ve used to wire this into CI with a clear pass/fail signal:
import json
import subprocess
import sys
def audit_dependencies(requirements_file: str = "requirements.txt") -> int:
"""Run pip-audit and fail the build on any known vulnerability."""
result = subprocess.run(
["pip-audit", "-r", requirements_file, "-f", "json"],
capture_output=True,
text=True,
)
# pip-audit exits non-zero when it finds vulns; parse either way
try:
report = json.loads(result.stdout or "{}")
except json.JSONDecodeError:
print("Could not parse pip-audit output:")
print(result.stderr)
return 2
findings = [
dep for dep in report.get("dependencies", [])
if dep.get("vulns")
]
if not findings:
print("No known vulnerabilities found.")
return 0
print(f"Found vulnerabilities in {len(findings)} package(s):\n")
for dep in findings:
for vuln in dep["vulns"]:
fix = ", ".join(vuln.get("fix_versions", [])) or "no fix available"
print(f" {dep['name']} {dep['version']}: {vuln['id']} (fix: {fix})")
return 1
if __name__ == "__main__":
sys.exit(audit_dependencies())
The important part isn’t the script - it’s that the result blocks the merge. A scanner whose output everyone ignores is just a slower way of having no scanner.
3. Generate and Keep a Software Bill of Materials (SBOM)
You can’t defend an inventory you don’t have. An SBOM is just a machine-readable list of everything in your build - every package, every version. When the next big vulnerability drops, the difference between “we’re patched in twenty minutes” and “we spent three days finding out if we’re affected” is whether you had an SBOM.
# Generate a CycloneDX SBOM for a Python project
pip install cyclonedx-bom
cyclonedx-py requirements -i requirements.txt -o sbom.json
# Or generate one straight from a container image with syft
syft myapp:latest -o cyclonedx-json > sbom.json
I generate the SBOM as a build artifact on every release and store it alongside the build. It costs almost nothing and turns “are we vulnerable to X?” into a query instead of an investigation.
4. Defend Against Dependency Confusion
This one is sneaky because it exploits how resolvers prioritize sources. If your build can reach both a public registry and your private one, an attacker can publish a public package with the same name as your internal one and a higher version number, and your tooling may happily grab theirs.
Defenses that actually work:
- Use a single trusted index for your internal packages and don’t let pip fall back to PyPI for those names. Avoid
--extra-index-urlfor private packages; it merges sources and creates exactly this ambiguity. - Claim your internal names on the public registry as empty placeholder packages so nobody else can register them.
- Pin to your private index explicitly and verify with hashes (see #1), so a same-name public package can’t satisfy the requirement.
# pip.conf - point at ONE trusted index for internal packages.
# Do NOT add PyPI as an extra-index-url for names you publish privately.
[global]
index-url = https://pypi.internal.example.com/simple/
5. Harden Your Build Pipeline
SolarWinds and Codecov are the reminder that your CI/CD system is part of your supply chain - often the most privileged part, since it holds your signing keys and deploy credentials. A few rules I hold to:
- Pin CI actions/images by digest, not tag. A mutable
@v3tag can be repointed at malicious code; a@sha256:...digest can’t. - Give build jobs least privilege. A job that runs tests does not need production deploy credentials. Scope secrets to the jobs that genuinely need them.
- Treat the runner as untrusted. Anything that runs during
pip install(build hooks,setup.py) executes attacker-controllable code if a dependency is compromised. Network egress controls on build runners limit what a malicious package can phone home with.
# GitHub Actions: pin by commit SHA, not a mutable tag
steps:
- uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2
- uses: actions/setup-python@57ded4d7d5e986d7296eab16560982c6dd7c923b # v4.6.0
6. Slow Down Automatic Updates - On Purpose
I love automated dependency updates for visibility. I’m much more cautious about automated dependency updates that merge themselves. Auto-merging the newest release the moment it’s published is precisely the behavior the event-stream and xz attackers were counting on.
A healthier pattern: let the bot open the PR, but require a human (or at least a cooling-off period and a passing scan) before it lands. The few days of lag is often enough for a malicious release to be caught and pulled before it ever reaches you.
7. Verify Provenance, Not Just Identity
Pinning a hash tells you a package didn’t change between when you reviewed it and when you installed it. It doesn’t tell you the package was built from the source you think it was. That gap is exactly what SolarWinds exploited - the published binary didn’t match the public source, and nobody could tell.
This is where build provenance and signing come in. The ecosystem has finally caught up here, and I now actively prefer dependencies that publish verifiable provenance:
- PyPI trusted publishing + attestations: PyPI lets maintainers publish directly from CI (e.g., GitHub Actions) using short-lived OIDC tokens instead of long-lived API keys, and attach attestations that tie the artifact back to the exact workflow that built it. No leaked token to steal, and a verifiable link from artifact to source.
- Sigstore / cosign: For container images and release artifacts, keyless signing through Sigstore lets you verify who and what produced an artifact without anyone having to manage signing keys. I verify image signatures in CI before a deploy is allowed to proceed.
# Verify a container image signature with cosign before deploying
cosign verify \
--certificate-identity-regexp "https://github.com/myorg/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
myorg/myapp:1.4.2
You don’t have to verify provenance for every transitive dependency on day one. But making it a tiebreaker - preferring the library that signs its releases over the one that doesn’t - slowly moves your whole dependency tree toward things you can actually verify.
What to Do When a Dependency Gets Compromised
It’s going to happen. A package you depend on will eventually have a bad day, and the question that matters is how fast you can answer “are we affected, and how badly?” I’ve been in that war room more than once, and the teams that handle it well all have the same thing in common: they prepared the answers before the incident.
Here’s the playbook I walk teams through:
- Determine exposure immediately. This is where your SBOM earns its keep. Query it: do we ship the affected package, at the affected version, anywhere? Without an SBOM this step alone can take days. With one, it’s a one-line search.
- Pin away from the bad version. Lock to a known-good release (or temporarily remove the dependency) and push it through CI. Because you pin and hash, this is a deliberate, reviewable change rather than a panicked free-for-all.
- Rotate anything the build could have touched. If the compromise had any chance to run in your pipeline, treat your CI secrets, deploy keys, and tokens as exposed. Rotate them. This is the step people skip, and it’s the one that turns a contained incident into a breach.
- Look for what it did, not just that it was there. Check logs and egress for the behavior the malicious version was known to perform - unexpected outbound connections from build runners, new processes, exfiltration attempts.
- Write down the timeline. When did the bad version publish, when could you have pulled it, when did you remediate? That window is your actual exposure, and you’ll want it for any disclosure.
The reason I’m bullish on the earlier prevention steps isn’t that they make incidents impossible - it’s that they make this list fast. An SBOM makes step 1 instant. Pinning makes step 2 safe. Least-privilege build secrets shrink step 3. Each control you put in ahead of time is hours you don’t lose during the fire.
Ecosystem-Specific Notes
The principles are universal, but the tooling differs:
- Python (PyPI): Use
pip --require-hashes,pip-audit, and lockfiles from pip-tools/Poetry/uv. PyPI now supports trusted publishing and attestations - prefer publishers that use them. - JavaScript (npm): Watch out for
postinstallscripts (a favorite payload location), usenpm ciagainst a committed lockfile, and considernpm install --ignore-scriptswhere feasible. - Containers: Pin base images by digest, scan them (Trivy, Grype), and rebuild regularly so you’re not shipping a base layer full of known CVEs.
No matter the ecosystem, the meta-rule holds: know what you run, verify it hasn’t changed, and don’t let automation pull untrusted code with zero friction.
Key Takeaways
After years of watching supply chain attacks go from “interesting research” to “how that company got breached,” here’s what I’d put on a sticky note:
The Mindset Shift
- Your dependencies are your code. They run with your privileges, in your process, against your data. Review the change in a lockfile like you’d review a change to your auth layer.
- Trust is transitive, and that’s the danger. You’re trusting everyone upstream of you, several layers deep, whether you’ve thought about it or not.
- The attacker doesn’t break in - your automation lets them in. Most of these attacks are your own update pipeline working as designed.
What Actually Works
- Pin versions and enforce hashes - reject anything that isn’t exactly what you reviewed.
- Scan in CI and fail the build - known-vulnerable dependencies should never merge silently.
- Keep an SBOM - turn “are we affected?” into a query, not a fire drill.
- Block dependency confusion - one trusted index, claimed names, explicit pins.
- Harden the pipeline - pin actions by digest, least-privilege secrets, treat runners as untrusted.
Software Supply Chain Failures earning its own spot at A03 isn’t OWASP being trendy - it’s the list finally reflecting where real breaches come from. The good news is that the defenses aren’t exotic. They’re mostly about replacing blind trust with verification and adding a little friction in the right places. Start with hash-pinned lockfiles and a scanner that blocks merges, and you’ve already closed the door on the most common attacks.