How to Spot and Stop Package Supply Chain Attacks
I think supply chain attacks on packages are one of the sneakiest ways bad actors get into a system. They hide in the code you pull from a public registry, then pop out later when you run the software. It feels like a Trojan horse, but the horse is a tiny JavaScript file or a Python wheel you never looked at.
What a supply chain attack looks like
First, an attacker gets control of a package that many projects depend on. That can happen by compromising the maintainer’s account, by injecting malicious code into a pull request that gets merged, or by publishing a new version that looks legitimate. Once the malicious version is out, anyone who runs npm install or pip install will fetch it automatically.
The code can do many things: steal credentials, open a reverse shell, or drop additional payloads. Because the code runs with the same permissions as the rest of the application, the damage can be severe.
Where the attack usually starts
There are a few common entry points that I see in reports:
- Compromised maintainer credentials – a weak password or a reused credential gets stolen.
- Typosquatting packages – a name that is one character off from a popular library.
- Malicious pull‑request – a contributor adds a backdoor and the maintainer merges it.
- Unverified build artifacts – a CI system pulls a package from a private feed that has been tampered with.
All of these rely on the fact that developers trust the package manager to deliver clean code. The trust model is broken when the source is compromised.
How to detect a problem early
Detection is about watching for changes that don’t belong. Here are some practical steps you can add to your workflow.
- Pin exact versions in lock files and never let a
^or~range slip in. - Enable reproducible builds – use
npm ciorpip install --require-hashesso the exact hash of each artifact is recorded. - Monitor package metadata – set up a script that queries the registry API and alerts when the author field or the published date changes for a locked version.
- Verify signatures – many registries support sigstore or pypi‑sigstore. Reject packages that lack a valid signature.
Below is a tiny Bash snippet that checks the SHA‑256 hash of a downloaded tarball against the value stored in package-lock.json. If the hash differs, the script exits with an error.
#!/usr/bin/env bash
set -euo pipefail
PACKAGE="lodash"
VERSION=$(jq -r ".packages[\"node_modules/$PACKAGE\"].version" package-lock.json)
EXPECTED=$(jq -r ".packages[\"node_modules/$PACKAGE\"].integrity" package-lock.json | cut -d- -f2)
URL="https://registry.npmjs.org/$PACKAGE/-/$PACKAGE-$VERSION.tgz"
TMP=$(mktemp)
curl -sSL "$URL" -o "$TMP"
ACTUAL=$(openssl dgst -sha256 "$TMP" | awk '{print $2}')
if [ "$ACTUAL" != "$EXPECTED" ]; then
echo "Hash mismatch for $PACKAGE@$VERSION"
exit 1
fi
rm "$TMP"
Running this as part of a CI job catches a tampered tarball before it reaches production.
Hardening the build pipeline
Even with detection, you want to make it hard for an attacker to succeed. I recommend a few layers of defense.
- Use a private proxy cache – tools like Verdaccio sit between your CI and the public registry. They can enforce signature checks and block known malicious packages.
- Adopt SLSA (Supply‑Chain Levels for Software Artifacts) – it defines a set of steps that guarantee the provenance of a build. You can start with level 1 by requiring that every build is reproducible and that the source hash is recorded.
- Run static analysis on dependencies – tools such as
npm auditorbanditfor Python can flag known vulnerable functions. Treat the output as a gate, not just a report. - Limit network access in CI – containers that run
npm installshould only be able to talk to the proxy cache, not the internet directly.
Here is a minimal .github/workflows/ci.yml that shows the proxy cache and the hash check together.
name: CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Node
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Configure npm proxy
run: |
npm set registry http://my-proxy.local:4873
- name: Install dependencies
run: npm ci
- name: Verify package hashes
run: ./scripts/verify-hashes.sh
The proxy can be configured to reject any package that does not have a valid sigstore signature. That way a malicious upload never makes it past the cache.
What to do when you see a bad package
If the hash check fails or the signature is missing, stop the pipeline immediately. Then:
- Remove the offending version from your lock file and roll back to a known good version.
- Contact the registry – most public registries have a abuse reporting page. Provide the package name, version, and the hash you observed.
- Audit your code for any usage of the compromised package. Even if the malicious code never executed, it might have left traces.
- Update your security policy to include the new detection rule so the same mistake does not repeat.
In my view, the fastest way to limit impact is to keep the lock file immutable once it is shipped. Treat it like a checksum list – any change triggers a full review.
Final practical tip
One thing I always add to my CI is a simple git diff check that fails if package-lock.json or requirements.txt change without a corresponding commit message that includes the word "dependency". It forces a human to think about why a package version moved.
# In .git/hooks/pre-commit
if git diff --cached --name-only | grep -E 'package-lock\.json|requirements\.txt' > /dev/null; then
if ! git log -1 --pretty=%B | grep -i dependency > /dev/null; then
echo "Dependency file changed without proper note"
exit 1
fi
fi
That tiny rule catches accidental upgrades that could have introduced a malicious version. It is not a silver bullet, but it adds friction for the attacker. The more friction you add, the less likely a supply chain attack will succeed. Use these steps, keep your lock files tight, and watch the registry metadata. If you see a mismatch, stop the build and investigate – that is the fastest way to keep a bad package from reaching users.
Photo by Daniil Komov on Pexels