# Software Supply Chain Attacks: How They Work & Defenses

URL: https://www.devopsness.com/blog/software-supply-chain-attacks
Author: Kiril Urbonas
Published: 2026-08-05
Updated: 2026-10-31
Categories: DevOps
Tags: Security, Supply Chain, Appsec, DevSecOps, Dependencies

Summary: A practical tour of how software supply chain attacks reach your build, and the controls that actually stop them.

---
# Software Supply Chain Attacks: How They Work & Defenses

A software supply chain attack compromises something your build trusts (a dependency, a registry account, a base image, a CI pipeline) rather than your own code. The cheapest defenses sit at install time: lockfiles with hashes, install scripts blocked by default, and a reserved package scope. Then remove static CI secrets and sign what you publish.

> [!VERIFIED]
> npm CLI 12.2.1 and 11.21.1, pip 26.2 docs, against docs.npmjs.com (npm ci, trusted publishing), pip's secure-installs guide, and the SLSA v1.2 spec. Checked October 2026.

A modern service ships thousands of transitive packages, base image layers, and CI plugins that you never wrote and rarely read. Any one of them runs with your build's privileges. This post walks the attack types and the defenses that hold up. For proving what shipped, see our companion post on [supply-chain security with SBOMs and attestation](/blog/supply-chain-security-sbom-attestation).

## How the attacks actually reach you

**Typosquatting and slopsquatting.** The oldest trick is registering a package whose name is one keystroke from a popular one (`reqeusts`, `loadash`). Slopsquatting is the newer, nastier variant: AI coding assistants confidently hallucinate package names that do not exist, and attackers pre-register those exact names. A developer pastes the suggestion, `npm install` succeeds, and malware lands. Hallucinated names tend to repeat across prompts, which makes them cheap to farm.

**Dependency confusion.** If your build resolves package names across both a public registry and a private one, an attacker who learns an internal name (say `acme-internal-auth`) can publish a public package with the same name and a higher version. Naive resolvers prefer the public one. Alex Birsan's 2021 research got code running inside dozens of large companies this way without a single stolen credential.

**Hijacked maintainer accounts and malicious updates.** A package can be perfectly safe for years, then turn. The `event-stream` incident is the archetype: a maintainer handed off a popular library, and the new owner slipped in code targeting a specific downstream wallet app. Account takeover through phishing or a leaked token produces the same result, a malicious version published under a trusted name. Worms now automate this: the [Tensorlake npm compromise](/blog/tensorlake-npm-shai-hulud-ai-agent-worm) went after the AI tool configs on developer machines.

**Malicious install scripts.** npm `postinstall` and equivalents run arbitrary code at install time, before any of your tests or review. This is the payload delivery mechanism for most of the attacks above. The dangerous window is between resolving a dependency and running it.

**Build-system and CI/CD compromise.** Your pipeline holds cloud credentials, signing keys, and publish rights, which makes it a higher-value target than any single package. Poisoned pipeline execution, a malicious pull request that alters the build definition, or a stolen long-lived token lets an attacker publish tainted artifacts that look completely legitimate.

**Compromised base images.** A `FROM` line pulls in an OS, its package manager, and whatever the image author included. A backdoored or simply stale base image inherits straight into production.

The pattern behind the headline cases (the 2024 `xz` backdoor's patient, social-engineered maintainer takeover included) is consistent: attackers move upstream, where one compromise fans out to every consumer. AI is scaling both ends of this. It generates plausible package names to squat and lowers the effort to produce convincing malicious packages and pull requests at volume.

## Defenses, layered across install, build and publish

No single control covers this. You need layers.

**Pin and lock with hashes.** A lockfile that records exact versions and cryptographic hashes means a swapped artifact fails the install rather than executing. Install from the lockfile, not from loose ranges:

```bash
# Node: install exactly what the lockfile pins, and skip lifecycle scripts
$ npm ci --ignore-scripts

# Python: fail if a resolved artifact's hash is not in the lockfile
$ pip install --require-hashes -r requirements.lock
```

```text
# requirements.lock (hashes make substitution detectable)
requests==2.34.2 \
  --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0
```

pip turns hash-checking on for the whole file as soon as any line carries a `--hash`, and then every dependency must be pinned with `==` and hashed. `--require-hashes` forces the mode even when nobody remembered to add hashes, which is why it belongs in deploy scripts. Do the same for infrastructure code: a [Terraform lock file](/blog/terraform-lock-file-supply-chain-attack) is the provider-side equivalent.

**Block dependency confusion with namespaces.** Route installs through a private registry, and reserve your organization's scope so nobody can publish under it publicly. Pin the scope to your internal registry only:

```ini
# .npmrc (@acme resolves ONLY from the internal registry)
@acme:registry=https://npm.internal.acme.com/
registry=https://registry.npmjs.org/
```

**Disable install scripts and review new dependencies.** On npm 11 and earlier, `ignore-scripts` defaults to `false`, so set it explicitly. npm 12 changes the default posture: dependency install scripts that no one has reviewed are blocked with a warning, and the policy lives in an `allowScripts` field in `package.json` or `.npmrc`; set `strict-allow-scripts` to turn the warning into a hard failure. Either way, allowlist the few packages that genuinely need lifecycle hooks, and treat a brand-new dependency or a jump to a fresh maintainer version as a change that gets human eyes, not an automatic merge.

**Scan continuously.** Software composition analysis flags known-vulnerable and known-malicious packages before they merge and as new advisories land. Wire it into the pipeline and pull requests, covered in our [dependency and SCA scanning](/blog/dependency-scanning-sca-guide) guide.

**Least-privilege CI with short-lived tokens.** Replace static registry and cloud secrets with short-lived OIDC tokens minted per job. npm's trusted publishing does exactly this for package releases from GitHub Actions, GitLab CI/CD and CircleCI cloud runners (npm CLI 11.5.1 or later; self-hosted runners aren't supported yet). Scope each job to exactly what it needs, and never expose publish credentials to pull requests from forks. A stolen token that expires in minutes is a far smaller prize.

**Sign and verify artifacts, generate provenance.** Sign what you publish and verify signatures on what you consume, so a package that did not come from your build is rejected. Generate an SBOM and SLSA build provenance so you can answer "what is in this, and where did it come from" during an incident.

## The order we'd do it in

Sequence the work by payoff per hour:

1. Lockfiles with hashes plus install scripts blocked by default. Cheap, and it neutralizes the most common install-time payloads.
2. Private registry with scope reservation. Closes dependency confusion outright.
3. SCA scanning in CI and on pull requests, blocking on known-malicious.
4. OIDC short-lived tokens and trusted publishing; delete long-lived publish secrets.
5. Artifact signing plus verification, then SBOM and provenance for response.

## Questions people ask

### What is a software supply chain attack?

It is an attack on something your software depends on rather than on your code directly: an open-source package, a maintainer's registry account, a base container image, a build plugin, or the CI system that publishes your artifacts. Because every downstream consumer trusts that component, one compromise spreads to all of them. Typosquatting, dependency confusion, malicious install scripts, and stolen CI tokens are the common entry points.

### How do you prevent dependency confusion?

Make sure internal package names can only resolve from your private registry. In npm, reserve an organization scope such as `@acme` on the public registry and map that scope to your internal registry in `.npmrc`. In Python, point installs at one index that proxies the public one instead of mixing `--extra-index-url` sources. Then install from a hash-pinned lockfile so a higher-versioned public impostor cannot substitute silently.

### Does npm ci --ignore-scripts break anything?

Sometimes. Packages that compile native code or download binaries in `postinstall` (image processing, database drivers, some CLIs) will install but not work. The fix is an allowlist, not dropping the flag: on npm 12, approve those packages in the `allowScripts` policy; on older npm, keep `--ignore-scripts` and run `npm rebuild <package>` for the few that need it. Explicitly invoked scripts such as `npm test` still run.

## The call we'd make

Start at the install boundary, because that is where the highest-volume, lowest-effort attacks land, and it is the cheapest thing to fix this week. Lockfile hashes, blocked scripts, and a reserved namespace shut down typosquatting, slopsquatting, dependency confusion, and most malicious `postinstall` payloads with configuration you already control.

Then move up to the build. Kill static publish secrets in favor of OIDC, and treat CI permissions as the crown jewels they are. Signing, SBOMs, and provenance come last not because they are optional, but because they pay off most once the noisy front-door attacks are already closed. Fold all of it into your broader [application security best practices](/blog/application-security-best-practices) rather than running it as a side project. The attackers are automating; your defenses have to be defaults, not heroics.

## Sources

- [npm Docs: npm ci (v12), including ignore-scripts and allow-scripts](https://docs.npmjs.com/cli/v12/commands/npm-ci/)
- [npm Docs: Trusted publishing for npm packages](https://docs.npmjs.com/trusted-publishers/)
- [pip documentation: Secure installs (hash-checking mode)](https://pip.pypa.io/en/stable/topics/secure-installs/)
- [SLSA specification v1.2](https://slsa.dev/spec/v1.2/)

