OWASP Top 10:2025 Course — Module 3 of 10. Course home · Previous: Module 2 — Security Misconfiguration · Next: Module 4 — Cryptographic Failures
Learn first: what is a Software Supply Chain Failure
A03:2025 covers trusting third-party code without verifying what it actually does. The textbook attack: a dependency (or a dependency of a dependency) runs a script automatically when it’s installed — npm‘s postinstall hook — and that script quietly reads environment variables or files and sends them somewhere. npm install still prints 0 vulnerabilities, because vulnerability scanners only know about published CVEs, not a brand-new malicious package behaving exactly as its package.json told it to.
npm install
│
├─▶ installs shopcart-api's real deps
└─▶ installs "malicious-logger"
│
└─▶ runs postinstall: install-hook.js
│
├─ reads process.env.ROGUE_DEMO_API_KEY
├─ writes it to .loot/exfil-*.json
└─ POSTs it to an attacker server
This mirrors real incidents like the 2025 "Shai-Hulud" npm worm, which harvested secrets at install time the same way.
Scenario briefing
You maintain shopcart-api, a small internal service. A teammate added a new logging dependency last sprint to "clean up our console output." You’re doing a routine supply-chain audit before the next release.
Lab setup
- Download: Download the lab (zip)
- Prerequisites: Docker Engine 20+, Docker Compose v2 (no local Node.js install needed — it’s all in the container).
- Start the attacker’s collection endpoint:
cd owasp-a03-supply-chain-lab docker compose up -d attacker - Check it worked:
docker compose logs attackershould showlistening on http://0.0.0.0:4444/collect.
Missions
Mission 1 — trigger the attack. Install the victim project and watch what happens.
Show solution
Solution — Mission 1
docker compose run --rm victim
npm install reports 0 vulnerabilities — completely normal-looking. But the attacker container’s log shows a POST arriving with the harvested secret:
docker compose logs attacker
[attacker-server] received exfiltrated data:
{"harvestedAt":"...","variable":"ROGUE_DEMO_API_KEY","value":"FLAG{a03-postinstall-exfiltrated-the-key}"}
Mission 2 — generate an SBOM. Spot the dependency that resolves from a local path instead of the npm registry.
Show solution
Solution — Mission 2
docker compose run --rm victim sh -c "cd app && npm sbom --sbom-format cyclonedx | grep -B2 -A6 malicious-logger"
Look for "cdx:npm:package:path" pointing at ../malicious-logger — a production dependency resolved from a local filesystem path, not a published registry version, is a red flag a dependency-review step should catch automatically.
Mission 3 — find the hook. Locate the install-time script inside node_modules and read what it does.
Show solution
Solution — Mission 3
docker compose run --rm victim sh -c "cd app && grep -r postinstall node_modules/*/package.json && cat node_modules/malicious-logger/install-hook.js"
package.json declares "postinstall": "node install-hook.js". The script reads ROGUE_DEMO_API_KEY, writes a .loot/ file, and POSTs it to 127.0.0.1:4444. It contains FLAG{a03-found-the-postinstall-hook} right above the exfiltration code — read it carefully, that’s the actual malicious logic hiding in a package that advertises itself as "a tiny console logging helper."
Mission 4 — prevent it. Stop install scripts from running for untrusted packages, then verify.
Show solution
Solution — Mission 4
echo "ignore-scripts=true" > workspace/app/.npmrc
python3 lab.py verify
With ignore-scripts=true, npm install still installs malicious-logger as a declared dependency (it’s a real file: dependency the app needs to run), but the postinstall hook never executes — no .loot/ file, no network call, and the app still starts normally. In CI, use npm ci --ignore-scripts and allowlist only the handful of packages that legitimately need a native build step.
Hints
docker compose run --rm victimis how you re-run the install-and-start flow each time — it’s a fresh container every run.- The SBOM command needs to run inside the victim’s
app/directory; it readspackage-lock.jsonthere. grep -r postinstall node_modules/*/package.jsonis the single command a reviewer should run on every new dependency before merging.
Fix it + re-test
The real fix is process, not code: disable arbitrary script execution at install time by default (ignore-scripts=true or npm ci --ignore-scripts), review any package that needs scripts re-enabled, and check SBOMs for dependencies resolved from local paths or unexpected registries in CI, not just at release time.
Detect it
Generate and diff SBOMs on every dependency change (npm sbom), flag any new postinstall/preinstall script in a PR, and monitor outbound connections from your build/CI environment — a build box making a request to an unfamiliar host during npm install is exactly what this attack looks like on the wire.
Quick self-check
- Why did
npm auditreport zero vulnerabilities even though the package is actively malicious? - What’s the practical difference between "reviewed this package’s code" and "it has a lot of GitHub stars"?
- Why does
ignore-scripts=truenot break installingmalicious-loggeras a dependency?
Show answers
Answers
npm auditonly matches against a database of known, disclosed vulnerabilities (CVEs) — a brand-new or private malicious package has no CVE, so there’s nothing to match against.- Popularity is a social signal, not a technical one — it tells you how many people installed it, not what the install script does; only reading the code (or at minimum the
scriptsblock inpackage.json) tells you that. ignore-scriptsonly suppresses thepreinstall/install/postinstallscript execution step; the package’s files are still copied intonode_modulesand its exported functions (likelog()) still work normally.