Git, CI/CD & GitOpsLXXI · CI/CD Threat ModellingAttack
The dependency-compromise attack — the upstream-package scenario
What you'll learn
- Describe the dependency-compromise attack: a malicious package, a typosquatted name, or a compromised upstream maintainer
- Distinguish direct dependencies, transitive dependencies, and base images as separate attack surfaces with separate controls
- Identify the detection signals: a new transitive dependency, an unexpected version bump, an SBOM diff against the baseline
- Apply controls — lockfile pinning, SHA-pinned actions, SBOM verification, registry allowlists — that make a successful dependency-compromise attack expensive
Prerequisites
Practice
Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x
The dependency-compromise attack is the scenario where the attacker is upstream of the team. The team pulls a package, a base image, or a third-party action from a registry; the attacker controls what the registry serves. The team’s build is honest; the team’s source is clean; the team’s runner is uncompromised; the artifact is built from a backdoor the team did not write and did not review. The attacker who compromises an upstream the team pulls from compromises the team’s build without ever touching the team.
Four shapes of dependency compromise
The dependency-compromise attack takes four shapes. Each shape exploits a different trust delegation the team has made.
- Typosquatting. The attacker publishes a package with
a name one character away from a popular dependency —
lodahsinstead oflodash,reqestsinstead ofrequests. The team’s developer mistypes the name, or a copy-paste error introduces the wrong name. The malicious package is fetched and executed during the build. - Maintainer-account compromise. The attacker compromises the account of a legitimate package maintainer — through a phished credential, a leaked token, or a vulnerable package-manager workflow. The attacker publishes a new version of the legitimate package, or pushes a malicious commit to a tag. The team’s build pulls the malicious version because the package is pinned by version range, not by hash.
- Mirror or registry compromise. The attacker compromises the package registry itself — a typosquatted package on a public registry, a poisoned mirror on a private registry, or a malicious package injected into a CDN-served tarball. The team’s build pulls what the mirror serves.
- Base-image or toolchain compromise. The attacker
compromises a base image the team uses (
node:18,python:3.12,golang:1.22), or a toolchain the team installs during the build. The base image’s Dockerfile includes a malicious layer, or the toolchain’s installer pulls a backdoored binary. The team’s build starts from a poisoned base.
flowchart LR
A["Dependency compromise"] --> B["Typosquat"]
A --> C["Maintainer takeover"]
A --> D["Mirror / registry"]
A --> E["Base image / toolchain"]
B --> F["Build pulls malicious pkg"]
C --> F
D --> F
E --> F
F --> G["Backdoor in artifact"]
The four shapes converge on the same outcome: the team’s artifact contains code the team did not write.
The attack in three steps
The dependency-compromise attack has three steps. Each step exploits a different trust delegation.
- Publish the malicious package. The attacker creates or compromises the package, tags it with a version that fits the team’s version range, and waits for the team’s next build to resolve it. The publication is on the legitimate path — the registry serves what it is asked to serve.
- Trigger the team’s resolver. The team’s build runs; the resolver evaluates the version range against the registry; the malicious version satisfies the range and is downloaded. The resolver does not know the package is malicious; the resolver knows the version exists.
- Execute the package’s install hook. Modern package
managers run install scripts (
npm installrunspreinstall/postinstall;pip installrunssetup.py;bundle installruns Ruby code;cargo buildrunsbuild.rs). The install hook executes with the build’s privileges, reads the build’s secrets, and exfiltrates or writes.
flowchart LR
A["Publish malicious version"] --> B["Team's resolver pulls it"]
B --> C["Install hook runs"]
C --> D["Reads secrets / writes artifact"]
C --> E["Exfiltrates to attacker"]
C --> F["Backdoor in next build"]
The install hook is the attacker’s execution environment. The hook runs in the build’s process; the hook has the build’s secrets; the hook can write to the build’s filesystem. The hook is the most privileged component in the supply chain, and it is the component the team has the least visibility into.
Real-world incidents
The dependency-compromise attack is not theoretical. Three incidents illustrate the four shapes:
- event-stream (2018). A popular npm package was
transferred to a new maintainer. The new maintainer
added a dependency (
flatmap-stream) that contained a payload targeting a specific cryptocurrency wallet. The payload ran in every downstream build. The attack exploited the absence of dependency review after a maintainer change. - ua-parser-js (2021). A popular npm package was compromised through a maintainer phish. The attacker published versions containing a cryptocurrency stealer and a credential exfiltrator. The attack exploited the use of version-range pinning instead of hash pinning.
- xz-utils (2024). A backdoor was merged into the liblzma library; the backdoor activated only when the library was linked by a specific toolchain. The attack exploited the assumption that a long-standing maintainer is a trusted boundary. The backdoor was discovered before it shipped to most production systems, but the campaign had been running for two years.
The three incidents share a pattern: the attacker chose the dependency tree as the entry point because the dependency tree is the surface the team has trusted upstream.
Detection signals
The signals that distinguish a dependency-compromise attack from a clean build are not in the build log; they are in the dependency tree.
- A new transitive dependency. The build’s SBOM lists every package the build pulled; the SBOM diff against the previous build flags every package that is new. The flag is the attacker’s first visible artefact.
- An unexpected version bump. A package the team has not touched in months is now at a new version; the version’s release notes are empty; the version’s publish time is recent. The signal is in the registry’s metadata.
- An SBOM diff against the baseline. The team’s SBOM is compared against a known-good baseline; a divergence is an alert. The defence is the comparison, not the SBOM itself.
The Trivy filesystem scanner produces an SBOM and a vulnerability report for every build:
trivy fs --security-checks vuln,secret .
The --security-checks vuln,secret flag tells Trivy to
report both vulnerabilities and secrets; the output is a
per-build artefact the team can compare against the
baseline. A new vulnerability with no upgrade path is a
signal that the dependency tree has changed in a way the
team did not expect.
Controls that make a dependency-compromise attack expensive
The controls that close the dependency-compromise attack fall into four groups.
- Lockfile pinning with hash verification. Every
package is pinned to an exact version, with the
cryptographic hash recorded. The build refuses to
install a package whose hash does not match. The
npm install --package-lock-only,pip install --require-hashes, andcargo install --lockedpatterns are the reference implementations. - SHA-pinned third-party actions. Every action used in a workflow is pinned to a full 40-character commit SHA. The build refuses to resolve a tag, a branch, or a major-version reference. The pattern is covered in Part LXVI.
- Registry allowlists. The build is configured to pull only from registries the team controls or has vetted. A typo in a package name that resolves to a different registry is rejected at the network layer.
- SBOM baseline and diff. Every build produces an SBOM; the SBOM is compared against a known-good baseline; a divergence is an alert. The SBOM is the audit artefact; the diff is the detection signal.
The four groups together close the four shapes of the attack: typosquatting (registry allowlist + hash pinning), maintainer-account compromise (hash pinning + SHA pinning), mirror compromise (hash pinning + registry allowlist), and base-image compromise (SHA-pinned base image + SBOM diff).
Production discipline
- Pin every dependency by hash, not by version range. The lockfile records the cryptographic hash of every package; the build refuses a mismatch.
- Pin every third-party action by SHA, not by tag. The workflow references the commit; the build refuses a different commit.
- Allowlist the registries the build pulls from. The network layer rejects any package resolution to a registry not on the list.
- Diff every SBOM against the baseline. A new transitive dependency is an alert; a version bump is an alert; the alert is on the build, not on the audit.
Cross-course references
- Git, CI/CD & GitOps — Part LXVI (Third-party Actions) covers the SHA-pinning pattern in detail.
- Git, CI/CD & GitOps — Part LXVII (Pinning Discipline) covers the lockfile and hash-pinning patterns.
- Git, CI/CD & GitOps — Part LXVIII (Identity and Token Hygiene) covers the maintainer-account compromise pattern.
- Container Security for Production Sysadmins — Part II (Base Image Trust) covers the base-image compromise pattern.
Quiz
Knowledge check · 4 questions
Q1. Why is a lockfile that pins by version range a weaker guarantee than a lockfile that pins by cryptographic hash?
Q2. A team that uses lockfiles for every dependency but does not record hashes is a team that has structurally defended against every shape of dependency-compromise attack.
Q3. Name the four shapes of dependency-compromise attack and the control that closes each shape.
Q4. Identify the attack shape, the entry point, and the controls that would have closed the gap.
Team T's Node.js application declares `lodash` as a direct dependency in package.json with the version range `^4.17.0`. The lockfile (package-lock.json) is committed to the repository. The build runs `npm ci`. An attacker compromises the npm account of a popular transitive dependency (`lodash.get`) and publishes version 4.4.0 with a `postinstall` script that reads `process.env.GITHUB_TOKEN` and posts it to an external server. The next build pulls the malicious version because the lockfile's resolved range includes 4.4.0. The malicious script runs in the build's environment and exfiltrates the registry token.
Passing score: 75%. Answers are checked in this browser.