Every npm install or pip install runs code chosen by a public registry, and attackers have learned to make that choice for you. Typosquats, dependency confusion, hijacked maintainer accounts and self-spreading worms such as Shai-Hulud exploit the same gap: package managers trust names, versions and install scripts far more than they should. This guide explains each technique at a defensive level, the registry, resolver and install-time settings that stop them, how to detect a bad package that got through, and how to respond when a laptop or CI runner has executed one. It is written for application security, platform and build teams, and the security operations staff who own the response.
Key takeaways
- Control where names resolve. Dependency confusion needs a resolver that can see an internal and a public name at once; scoped packages, a single internal index and reserved public names close it.
- Install time is execution time. npm lifecycle scripts and Python source builds run with the user's or runner's privileges, so block them by default and allow only the packages that need them.
- Slow down new versions. A minimum release age of a few days, plus committed lockfiles with integrity hashes, keeps freshly hijacked releases out of your builds while they are being found.
- Provenance proves origin, not innocence. Trusted publishing and Sigstore-backed attestations make account takeover harder and tampering visible, but a legitimate pipeline can still publish malicious code.
- Every malicious install is a credential incident. Modern payloads steal npm, GitHub, cloud and SSH credentials first, so response means rotating the non-human identities the machine could reach.
How malicious packages reach your builds
MITRE ATT&CK tracks this as T1195.001 Compromise Software Dependencies and Development Tools (Initial Access). The techniques fall into two families: tricking you into installing the wrong package, and poisoning the right one.
| Technique | How it works | Example | Primary control |
|---|---|---|---|
| Typosquatting and brandjacking | A name one keystroke or separator away from a popular package, often with a copied README | Routine on npm and PyPI | Proxy policy or allowlist; name similarity checks |
| Dependency confusion | Your internal package name published publicly with a higher version; a resolver that sees both picks the public one | Birsan 2021; PyTorch torchtriton 2022 | Scopes, single internal index, reserved names |
| Slopsquatting | Attackers register package names that AI coding assistants invent | 205,474 unique hallucinated names across 16 models (Spracklen et al.) | Add dependencies only through a proxy that rejects unknown or brand-new names |
| Starjacking | A package links to a popular, unrelated repository so the registry shows borrowed stars | Non-unique repository links on 3.03% of PyPI and 7.23% of npm packages (Checkmarx, 2022) | Ignore popularity metrics; check the repository builds the package |
| Maintainer account takeover | Phished, reused or leaked publishing credentials push a new version of a trusted package | ua-parser-js 2021, axios 2026 | Trusted publishing, phishing-resistant MFA, cooldowns |
| Malicious install scripts | A preinstall or postinstall hook or Python build step runs the payload before any code is imported | eslint-scope 2018 | Scripts off by default with an allowlist; wheels only |
| Self-propagating worms | Stolen publishing tokens are used to republish every package the victim can reach | Shai-Hulud 2025, Mini Shai-Hulud and ChainDrop 2026 | Short-lived publishing credentials, cooldowns, fast revocation |
CISA's September 2025 alert describes Shai-Hulud scanning environments for GitHub personal access tokens and AWS, Google Cloud and Azure keys, publishing them to a public GitHub repository named "Shai-Hulud", then logging into npm as the victim to inject code into other packages and publish them. GitHub removed more than 500 compromised packages in that wave. See the Shai-Hulud first wave and the later Shai-Hulud campaign that reached 25,000+ GitHub repositories.
Dependency confusion: why it works and how to close it
Alex Birsan's February 2021 research showed that when a package exists on both an internal and a public index, pip run with --extra-index-url installs whichever has the higher version. He found the weakness in more than 35 organisations across npm, PyPI and RubyGems, including Apple, Microsoft, PayPal and Shopify. Internal names leaked through manifests, JavaScript bundles and public repositories, so assume yours are known.
In the PyTorch torchtriton attack, PyTorch's advisory explains that "Since the PyPI index takes precedence", a malicious torchtriton on PyPI replaced the nightly index copy between 25 and 30 December 2022. Its binary read environment variables, $HOME/.ssh/*, .gitconfig and the first 1,000 files in the home directory and exfiltrated them over DNS. PyTorch renamed the dependency pytorch-triton and registered a placeholder on PyPI.
- npm: publish internal packages under an organisation scope (
@yourorg/name), map the scope to the internal registry in a committed.npmrc(@yourorg:registry=...) and register the scope publicly so nobody else owns it. - Python: never mix an internal index with
--extra-index-urlpointing at PyPI. Use one--index-urlfor an internal proxy that serves internal names first and refuses public lookups for them. - NuGet, Maven and Gradle: reserve your package ID prefix on nuget.org, use group IDs under a domain you own, and resolve through one repository manager with upstream sources rather than several feeds side by side.
- Everywhere: publish placeholders for internal names that must stay unscoped, and alert when a public package appears matching an internal name.
Registry architecture: proxies, namespaces and allowlists
The most effective structural control is that builds and developer machines resolve packages from one internal registry, never directly from the internet. That repository manager becomes where you enforce policy, cache known-good versions and see what was installed where.
| Model | Behaviour | Strength | Trade-off |
|---|---|---|---|
| Open proxy | Proxies and caches any public package on demand | Single choke point, logs, cache for incident lookup | Lets a new malicious package through on first request |
| Proxy with policy (dependency firewall) | Adds blocking rules: malicious package feeds, minimum age, install script presence, name similarity | Stops most opportunistic attacks with little developer friction | Needs a maintained block feed and an exception process |
| Curated allowlist | Only approved packages and versions are available | Strongest against typosquats and slopsquats | Review queue becomes a bottleneck and invites workarounds |
| Vendored or frozen mirror | Dependencies copied into the repository or a snapshot | Reproducible, offline builds | Security fixes lag unless updates are scheduled |
Decision guidance: a small team without a repository manager still gets most of the benefit from scoped names, committed lockfiles, a release cooldown and scripts disabled by default, adding a hosted proxy later. Larger organisations should run a proxy with policy for developers and a stricter allowlist for production pipelines, because a build runner usually holds more valuable credentials than a laptop. Either way, block CI egress to public registries so a stray configuration file cannot bypass the proxy, and make sure you can purge a version from the cache: CISA told Shai-Hulud responders to search artifact repositories for cached copies.
Install-time controls: lockfiles, hashes, cooldowns and scripts
Hijacked releases are often reported within days. CISA's Shai-Hulud advice was to pin to versions published before 16 September 2025, the day after the worm was first reported, which a modest cooldown would already have enforced.
| Control | npm | pnpm | pip |
|---|---|---|---|
| Lockfile with integrity | package-lock.json integrity hashes; npm ci in CI | Committed pnpm-lock.yaml, frozen in CI | Pinned requirements with --hash; --require-hashes forces hash-checking mode |
| Release cooldown | min-release-age (days) or before (date) | minimumReleaseAge in minutes (documented default 1440; 10080 for a week), with minimumReleaseAgeExclude | Package age rule at the proxy |
| Install scripts | ignore-scripts=true in .npmrc | Dependency postinstall off by default since v10; approve packages with allowBuilds; avoid dangerouslyAllowAllBuilds | --only-binary :all: refuses source distributions |
| Trust checks | npm audit signatures (npm 8.15.0+) verifies registry signatures and attestations | trustPolicy: no-downgrade; blockExoticSubdeps blocks git and tarball transitive dependencies | Verify attestations where projects publish them |
- Cooldown length: three to seven days is a practical range. Keep a logged exception path so a cooldown never delays a fix for an actively exploited flaw.
- Update bots: make Dependabot or Renovate respect the same minimum age and never auto-merge widely used packages. The axios npm compromise spread in minutes partly through auto-merging bots.
- Script exceptions:
ignore-scriptsbreaks packages that compile native code, so turn scripts off globally and approve the few that need them, rather than leaving them on everywhere. - Lockfile review: a pull request that adds a new transitive dependency, git URL or tarball URL deserves a human look.
Trust signals: trusted publishing, provenance and their limits
For packages you publish, the fix for stolen tokens is to stop having long-lived ones. npm trusted publishing uses OpenID Connect from GitHub Actions, GitLab CI/CD and CircleCI cloud runners, needs npm CLI 11.5.1 and Node 22.14.0 or later, and adds provenance automatically for GitHub and GitLab publishes. npm then recommends "Require two-factor authentication and disallow tokens" on the package. PyPI's equivalent mints a project-scoped token "only valid for 15 minutes from time of creation". After Shai-Hulud, GitHub announced npm publishing would narrow to local publishing with mandatory 2FA, granular tokens limited to seven days, and trusted publishing, deprecating classic tokens and TOTP in favour of FIDO. Leftover tokens keep causing incidents: see the Ultralytics PyPI compromise and the LiteLLM PyPI breach.
For packages you consume, provenance shows which repository and workflow built a version, recorded in Sigstore's public transparency log. npm's documentation is explicit that it "does not guarantee the package has no malicious code".
- Watch for downgrades: a package that always shipped with provenance and suddenly publishes without it is a strong takeover signal; pnpm's
trustPolicyautomates the check. - Ignore vanity metrics: registries do not verify stars, downloads or linked repositories, which is what starjacking abuses.
- Check AI suggestions: across 576,000 generated code samples, Spracklen et al. found average hallucinated package rates of at least 5.2% for commercial and 21.7% for open source models. Developers and coding agents should add dependencies only through the proxy. See the AI Coding Agents Security Guide.
See the SLSA and Build Provenance Guide for producing and verifying attestations.
Detecting malicious packages that got through
- Malicious package feeds: the OpenSSF malicious-packages repository publishes reports in OSV format (MAL- identifiers) and works closely with OpenSSF Package Analysis. Feed it into proxy blocking and SCA so a reported package is blocked and past installs are flagged.
- Queryable inventory: CISA's first Shai-Hulud instruction was to check lockfiles, including nested dependencies. Keep SBOMs or lockfile snapshots per build so you can answer "who installed version X, and when" in minutes. See the Open Source Security Guide.
- Runner and endpoint telemetry: alert when
node,npm,pythonorpipspawn shells, read~/.npmrc,~/.aws/credentialsor~/.ssh, or connect anywhere other than the proxy. CISA advised blocking outbound traffic to webhook.site domains. - Source control signals: new public repositories, new workflow files and unusual secret reads. The Nx s1ngularity attack even drove locally installed AI command-line tools to hunt for secrets.
Metrics that show it is working
- Proxy coverage: share of package downloads from CI and developer machines served by the internal registry (target 100% for CI).
- Exposed internal names: unscoped internal names without a public placeholder (target zero).
- Cooldown compliance: share of production builds where every resolved version is older than the minimum age, excluding logged exceptions.
- Time to block: hours from a MAL- report to the package being blocked and affected builds identified.
- Publishing tokens: long-lived registry tokens remaining for packages you publish (target zero).
Where identity fits: credentials on developer machines and CI
The package is the delivery; the objective is the publishing tokens, GitHub personal access tokens, cloud keys, SSH keys and AI API keys in environment variables, dotfiles and CI secrets. The eslint-scope compromise stole npm tokens in 2018, and the ChainDrop npm worm took CI and cloud credentials across 400+ packages in hours. Reduce what an install can steal: keep publishing tokens off laptops, use workload identity federation instead of static cloud keys, and keep secrets out of the build steps that install dependencies. See the CI/CD Pipeline Identity Security Guide and the Secrets Management Guide.
Response when a malicious package was installed
- Scope: query lockfiles, SBOMs and proxy logs for the affected versions, including transitive use, and list every laptop, runner and image that installed them.
- Contain: block the versions at the proxy, purge caches, pin to the last good release, rebuild images and isolate machines that ran the payload.
- Rotate: treat every credential readable on those machines as stolen, starting with anything holding publish or write rights. Follow the Leaked Credential Response Playbook.
- Hunt: review audit logs for use of those credentials since first install: new package versions, repositories, workflows or cloud API calls.
- Harden: close the gap that let it in and record the incident against the metrics above.
Implementing the controls
- Inventory: list ecosystems, registry configuration files (
.npmrc,pip.conf,nuget.config,settings.xml) and internal package names. Every--extra-index-urland unscoped internal name is confusion exposure. - Protect names: scope internal npm packages, reserve scopes and prefixes publicly, and publish placeholders.
- Proxy in observe mode: route CI through the internal registry with logging only for two to four weeks and review what policy would have blocked.
- Pilot ring: enable blocking, a three-day cooldown and scripts off by default for one or two teams; build the script allowlist and the security-fix exception process.
- Enforce: require lockfiles and strict installs, block CI egress to public registries, then extend to developer machines through managed configuration. Move your own packages to trusted publishing.
- Rollback: keep proxy policy in version control so a bad rule reverts in one change, and use expiring, logged per-project exceptions instead of switching policy off.
Common failure patterns
- Optional proxy: some pipelines still reach public registries directly, so policy protects only the builds that needed it least.
- Package removed, credentials not rotated: stolen publish tokens are what let worms keep republishing, as with Mini Shai-Hulud and CanisterSprawl.
- Former maintainers keep publish rights: the Ledger Connect Kit compromise came through a former employee's unrevoked npm access.
- Extensions and AI tooling out of scope: GlassWorm spread through VS Code extensions, and the TrapDoor campaign aimed malicious packages at AI coding assistants.
- Trusting popularity: high download counts persuade reviewers to skip checks, while worms target the most depended-on packages.
Framework mapping
| Framework | Reference | Relevance |
|---|---|---|
| MITRE ATT&CK | T1195.001; M1013, M1033, M1051, M1016 | Compromised dependencies; developer guidance, limit software installation, controlled updates, scanning |
| NIST SSDF (SP 800-218) | PW.4 | Acquire and maintain well-secured third-party components |
| NIST CSF 2.0 | GV.SC, ID.RA-09 | Supply chain risk management; software authenticity and integrity assessed before use |
| NIST SP 800-53 Rev. 5 | SR-3, SR-4, SR-11, CM-7(5), SI-7 | Supply chain controls, provenance, component authenticity, software allowlisting, integrity |
| CIS Controls v8.1 | 2.5, 16.4, 16.5 | Allowlist authorised software; inventory and trusted third-party components |
| ISO/IEC 27001:2022 | A.5.21, A.8.19, A.8.28 | ICT supply chain; software installation; secure coding with external libraries |
Practitioner checklist
- Route all CI package resolution through one internal registry and block CI egress to public registries.
- Remove every
--extra-index-urlthat mixes internal and public Python indexes. - Scope internal npm packages, reserve scopes and prefixes publicly, and publish placeholders.
- Commit lockfiles with integrity hashes and use
npm ci, frozen lockfiles or--require-hashes. - Apply a three to seven day minimum release age in package managers, the proxy and update bots.
- Disable dependency install scripts by default and keep a reviewed allowlist.
- Feed OpenSSF malicious package data into proxy blocking and SCA alerts.
- Move your packages to trusted publishing, disallow token publishing and require phishing-resistant MFA for maintainers.
- Rehearse the response: scope, contain, rotate every reachable credential, hunt, harden.
Standards and references
- MITRE ATT&CK: T1195.001 Compromise Software Dependencies and Development Tools
- CISA: Widespread Supply Chain Compromise Impacting npm Ecosystem
- OpenSSF: Malicious Packages repository
- npm Docs: Trusted publishing for npm packages
- PyPI Docs: Publishing with a Trusted Publisher
- pip documentation: Secure installs
- PyTorch: Compromised PyTorch-nightly dependency chain
- Alex Birsan: Dependency Confusion
Related NHI Mgmt Group resources: Open Source Security Guide · SLSA and Build Provenance Guide · CI/CD Pipeline Identity Security Guide · Secrets Management Guide · Leaked Credential Response Playbook