Skip to main content
RunBook Academy

← All break/fix scenarios in Git, CI/CD & GitOps

advancedsupply-chain-compromise~60 min

Compromised dependency postmortem

Reported symptoms

  • Outbound DNS queries from CI runners to a newly-registered domain with no prior history
  • A transitive dependency, installed via `npm install` on a pinned-but-not-hashed lockfile, made unexpected network calls during install
  • The malicious package was published under a legitimate name after a maintainer account takeover or a namespace transfer
  • Build time for one job spiked from 90 seconds to 14 minutes because the payload did a slow loop
  • `npm audit` / `pip-audit` did not flag the package because the advisory database had not yet been updated
  • SBOMs generated post-incident show the package as a transitive of a pinned direct dependency
  • CloudTrail / IAM credential access logs show anomalous `sts:GetCallerIdentity` calls from CI OIDC tokens within the window

Evidence

  • · `npm ls <package>` and `pnpm why <package>` show the malicious package as a transitive of a direct dependency that was pinned only by version range, not by exact version or hash
  • · `diff <previous-package-lock.json> <current-package-lock.json>` shows the malicious version appearing where the previous version was used
  • · The package's `package.json` shows a `postinstall` script (or equivalent for the ecosystem) that runs at install time and contains obfuscated JavaScript
  • · Decoding the obfuscated script reveals a `fetch()` or `https.request()` to an IP address that was registered within the past 30 days
  • · `os.environ` or `process.env` is enumerated in the script and a subset is sent in the request body or query string
  • · The CI runner network policy was permissive (egress allowed to any IP), so the request was not blocked
  • · The package's maintainer GitHub account shows a recent email change or repository transfer to an unfamiliar account
  • · `slsa-verifier` on the build provenance either was not run, or was run but did not enforce signature/attestation policies
Diagnosis and resolutionclick to reveal

Root cause

The dependency was pinned by version range in the manifest, and the lockfile was generated with a resolver that accepts any version satisfying the range. When the maintainer''s account was compromised and a new, malicious version was published under the same name and version range, the next `npm install` (without `--ignore-scripts` or a hash check) ran the package''s `postinstall` script, which executed arbitrary code on the CI runner. The runner had access to OIDC-issued cloud credentials and to the registry credentials used in the build, which the payload exfiltrated. The structural failure is the absence of three controls: exact-version or hash-pinning of every dependency (transitive or direct), `--ignore-scripts` for any dependency whose install-time code is not part of the build contract, and an egress network policy that would have made the exfiltration request fail at the network layer regardless of code execution.

Remediation

Quarantine first. Stop all CI jobs that use the compromised dependency; the runners may themselves be compromised and any credentials they held are suspect. Treat the OIDC tokens issued to those runners during the window as compromised: rotate any cloud IAM role trust policies that the runner''s OIDC subject could have used, audit CloudTrail for anomalous API calls, and revoke any registry tokens or deploy credentials that the runner could have accessed. Then fix the dependency: pin to the last known-good version in the lockfile (`npm ci --ignore-scripts` to install without running the malicious script), or replace the package entirely if the maintainer''s account cannot be recovered. Run `git filter-repo` or `npm cache clean --force` to clear poisoned package caches. Generate a fresh SBOM and diff against the pre-incident SBOM to identify every consumer of the package; communicate the compromise to every downstream team that may have pulled the same version.

Verification

The compromised version is no longer reachable in any lockfile. SBOM generation includes every transitive dependency and the diff against the pre-incident SBOM is empty. `--ignore-scripts` is the install default for any dependency that does not have a documented install-time requirement. Network egress from CI runners is denied by default; explicit allowlist per registry/dependency host. CloudTrail shows no further anomalous API calls from the OIDC subject after the quarantine timestamp. The package''s maintainer account is either recovered (and a new version published with a security advisory), or the package is replaced by a different one.

Prevention

Treat install-time code execution as a privilege. Pin every dependency — direct and transitive — by exact version and by hash, not by version range: `npm ci` with `package-lock.json` committed, `--require-hashes` for `pip`, `go.sum` for Go, lockfile-plus-checksum for every ecosystem. Default to `--ignore-scripts` for any package whose `preinstall` / `postinstall` is not essential to the build, and audit the packages that do need install scripts. Use a private package proxy (Nexus, Artifactory, Cloudsmith, GitHub Packages with an internal mirror) so the resolver cannot reach a public registry that has just been poisoned. Generate SBOMs on every build (CycloneDX or SPDX), store them, and alert on new transitive packages appearing in the SBOM diff. Sign internal packages with Sigstore Cosign and require SLSA Build Level 3 provenance on every external dependency that supports it. Configure CI runners with default-deny egress and an explicit allowlist of registry hosts, so an exfiltration request fails at the network layer regardless of code execution.

A dependency compromise succeeds because version-range resolution lets a new, malicious version into the lockfile, install-time code execution runs it, and permissive egress lets it phone home. Each of those three is a structural control that must be in place before the next compromise attempt; none of them is a habit.