{"id":345,"date":"2026-10-03T10:14:00","date_gmt":"2026-10-03T17:14:00","guid":{"rendered":"https:\/\/blog.canutethegreat.com\/index.php\/2026\/10\/03\/package-registry-supply-chain-attacks-slsa\/"},"modified":"2026-10-03T10:14:00","modified_gmt":"2026-10-03T17:14:00","slug":"package-registry-supply-chain-attacks-slsa","status":"publish","type":"post","link":"https:\/\/blog.canutethegreat.com\/index.php\/2026\/10\/03\/package-registry-supply-chain-attacks-slsa\/","title":{"rendered":"Supply chain attacks on package registries: typosquatting, dependency confusion and what SLSA actually asks for"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Every <code>npm install<\/code> or <code>pip install<\/code> is a decision to run someone else&#8217;s code with your build&#8217;s permissions. Most of the time that&#8217;s a good trade. The attacks I want to cover here don&#8217;t exploit a bug in your code at all. They exploit how package managers decide which package to fetch, and how little most organisations know about where their artifacts came from.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Typosquatting is boring and still works<\/h2>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1360\" height=\"700\" src=\"https:\/\/blog.canutethegreat.com\/wp-content\/uploads\/2026\/10\/package-registry-supply-chain-attacks-slsa-diagram.png\" alt=\"Pipeline from source repo to build platform, publish, package registry and install, with the attack that lands at each stage above it and the matching control, such as SLSA provenance, trusted publishing, scoped names and lockfile hashes, below it\" class=\"wp-image-344\" srcset=\"https:\/\/blog.canutethegreat.com\/wp-content\/uploads\/2026\/10\/package-registry-supply-chain-attacks-slsa-diagram.png 1360w, https:\/\/blog.canutethegreat.com\/wp-content\/uploads\/2026\/10\/package-registry-supply-chain-attacks-slsa-diagram-300x154.png 300w, https:\/\/blog.canutethegreat.com\/wp-content\/uploads\/2026\/10\/package-registry-supply-chain-attacks-slsa-diagram-1024x527.png 1024w, https:\/\/blog.canutethegreat.com\/wp-content\/uploads\/2026\/10\/package-registry-supply-chain-attacks-slsa-diagram-768x395.png 768w\" sizes=\"auto, (max-width: 1360px) 100vw, 1360px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The oldest trick is publishing a package with a name one keystroke away from a popular one. In 2017 an npm package called <code>crossenv<\/code> (the real one is <code>cross-env<\/code>) copied the legitimate package&#8217;s behaviour and also sent the contents of the environment to a remote server. Environment variables on a CI runner are often the most valuable thing on the box: registry tokens, cloud credentials, signing keys. The same year Slovakia&#8217;s national security authority reported a batch of typosquats on PyPI with names like <code>urlib3<\/code>, and PyPI has been removing waves of them ever since.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The registry operators catch a lot of these now, but not before someone installs them. The defences on your side are unglamorous. Don&#8217;t let people add dependencies by typing names into a terminal on a machine that holds credentials. Review dependency additions in pull requests the same way you review code, and look at the package&#8217;s age, download count and repository link before approving it. A two week old package with a name almost matching a library with millions of weekly downloads should not get through review.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Dependency confusion<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Alex Birsan&#8217;s February 2021 research turned a quirk of package resolution into a way to run code inside Apple, Microsoft, PayPal and over thirty other companies, earning more than $130,000 in bug bounties. The idea is simple. Companies use internal package names that never exist on the public registry. Those names leak through public manifests, JavaScript bundles and error messages. If an attacker publishes a public package with the same name and a higher version number, many resolvers happily prefer it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The classic pip mistake looks like this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>pip install --index-url https:\/\/pypi.internal.example\/simple \\\n            --extra-index-url https:\/\/pypi.org\/simple \\\n            acme-billing-utils<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>--extra-index-url<\/code> doesn&#8217;t mean &#8220;fall back to PyPI if the internal index doesn&#8217;t have it&#8221;. pip collects candidates from every index and picks the best version across all of them. If someone publishes <code>acme-billing-utils<\/code> 99.0.0 on PyPI, that&#8217;s what gets installed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This wasn&#8217;t a one-off. In December 2022 the PyTorch project disclosed that its nightly builds installed between December 25 and 30 had pulled a malicious <code>torchtriton<\/code> package from PyPI, because the legitimate one lived on PyTorch&#8217;s own index and PyPI took precedence. The malicious version collected system information and files from the machines that installed it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The structural fix is to have exactly one place your builds resolve from. Run a proxying repository (Artifactory, Nexus, a pull-through cache) that serves internal packages and mirrors public ones, and configure it so an internal name can never be satisfied from upstream. For npm, put internal packages under a scope you own and pin that scope to your registry:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># .npmrc\n@acme:registry=https:\/\/npm.internal.example\/\nregistry=https:\/\/registry.npmjs.org\/<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Registering your internal names (or scope) on the public registry as empty placeholders is a reasonable extra layer, but I wouldn&#8217;t rely on it alone.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Lockfiles and hashes<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A lockfile pins exact versions, and the integrity hashes in it pin exact bytes. That turns &#8220;install whatever matches ^4.2&#8221; into &#8220;install this tarball and fail if it&#8217;s different&#8221;. Use <code>npm ci<\/code> rather than <code>npm install<\/code> in CI, since <code>ci<\/code> refuses to proceed if <code>package-lock.json<\/code> and <code>package.json<\/code> disagree. For Python, pip&#8217;s hash checking mode does the same thing:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>pip install --require-hashes -r requirements.txt<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">with each line carrying <code>--hash=sha256:...<\/code>, which tools like <code>pip-compile --generate-hashes<\/code> produce. Hashes don&#8217;t protect you on the day you add or upgrade a dependency. They do stop a later build from silently picking up something different.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Compromised maintainers<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Some attacks don&#8217;t need a lookalike name. In 2018 the maintainer of <code>event-stream<\/code> handed the project to a volunteer, who added a dependency called <code>flatmap-stream<\/code> carrying code aimed at stealing from a specific cryptocurrency wallet app. In October 2021 the npm account behind <code>ua-parser-js<\/code> was hijacked and versions shipping a cryptominer and credential stealer were published. And in 2024, CVE-2024-3094 showed a backdoor in xz utils that had been inserted after a multi-year effort to gain maintainer trust, caught largely because one engineer noticed SSH logins getting slower.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Nothing on the consumer side fully prevents this, but you can shrink the window. Delay adopting brand new versions by a few days unless they fix something you need, because most malicious releases are spotted and pulled quickly. Watch for install scripts (<code>preinstall<\/code>, <code>postinstall<\/code>) appearing in packages that never had them, and consider <code>npm config set ignore-scripts true<\/code> for builds that don&#8217;t need them.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What SLSA requires<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">SLSA (Supply-chain Levels for Software Artifacts) is easy to misread as a scanning standard. It&#8217;s not. Version 1.0&#8217;s Build track is about provenance: a verifiable statement of which source, which build platform and which process produced an artifact.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The levels build on each other. Build L1 means provenance exists and describes how the artifact was built. Build L2 means the build ran on a hosted platform that generates and signs that provenance itself, so a developer laptop can&#8217;t produce it. Build L3 means the platform is hardened: builds are isolated from each other, and the signing material is out of reach of the user-defined build steps, so even a malicious build script can&#8217;t forge provenance.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">What SLSA does not do is tell you whether a dependency is trustworthy. It lets a consumer check that a package came from the repository and workflow it claims to, which is exactly what you want to know after an account takeover. On the publishing side, PyPI&#8217;s Trusted Publishing and npm&#8217;s provenance support both bind packages to a specific CI workflow using short lived OIDC credentials instead of long lived API tokens. On the consuming side you verify:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>npm audit signatures\n\nslsa-verifier verify-artifact .\/mytool-linux-amd64 \\\n  --provenance-path .\/mytool-linux-amd64.intoto.jsonl \\\n  --source-uri github.com\/example\/mytool \\\n  --source-tag v1.4.2<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">I&#8217;d prioritise it this way. Get to one resolution source and committed lockfiles with hashes first, because that kills dependency confusion and drift. Move your own publishing to trusted publishing so there&#8217;s no static token to steal. Then start verifying provenance for the dependencies that matter most, beginning with anything that runs in your build or release pipeline.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The part people skip<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Your build environment is part of the supply chain. A CI job that installs packages while holding production deploy credentials gives every one of those packages production access. Split dependency installation from credentialed steps where you can, give build jobs only the secrets they need, and treat a compromised dependency the same as a compromised server: rotate what it could reach, don&#8217;t just bump the version.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Every npm install or pip install is a decision to run someone else&#8217;s code with your build&#8217;s permissions. Most of the time that&#8217;s a good trade. The attacks I want to cover here don&#8217;t exploit a bug in your code at all. They exploit how package managers decide which package to fetch, and how little [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":346,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[21,26],"tags":[69,70,71,68,67],"class_list":["post-345","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-security","category-software-engineering","tag-dependency-confusion","tag-npm","tag-pypi","tag-slsa","tag-supply-chain-security"],"_links":{"self":[{"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/posts\/345","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/comments?post=345"}],"version-history":[{"count":0,"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/posts\/345\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/media\/346"}],"wp:attachment":[{"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/media?parent=345"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/categories?post=345"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/tags?post=345"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}