Skip to content

필사 모드: Dependency Supply Chain Security — The Things a Lockfile Does Not Block

English
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

Introduction — You Installed 14 Packages Directly and 1,100 Got Installed

Open a single project and count the actual numbers.

jq '.dependencies | length' package.json
jq '.devDependencies | length' package.json
npm ls --all --parseable 2>/dev/null | wc -l
npm ls --omit=dev --all --parseable 2>/dev/null | wc -l
14
23
1123
486

You wrote 37 of them yourself, and 1,123 packages are installed. Of those, 486 ship together into the production runtime. The remaining 637 are used only at build and test time, but the process at that moment usually has access to the entire repository and to the CI secrets.

These numbers are the starting point of supply chain security. What we reviewed and chose is 37; what we decided to trust is 1,123. Most of the remaining 1,086 are names we have never even heard.

One common misconception attaches itself here: that the lockfile makes it fine. A lockfile does solve one important problem, but that problem is not security. This post starts from what a lockfile guarantees and what it does not, then lays out a few controls that actually work.

Transitive Dependencies — The Real Shape of the Risk

There are three reasons transitive dependencies are riskier than direct ones.

First, there is no privilege boundary. In the default execution model of JavaScript or Python, any package can access the file system, the network and the environment variables. A string padding utility can read the entire process environment and send it outside. Any single leaf of the dependency tree holds exactly the same privileges as the application.

Second, you cannot see who maintains it. Check how a given package got in and by what path, and the answer is usually surprising.

npm ls chalk --all
app@1.4.2
└─┬ jest@29.7.0
  └─┬ @jest/core@29.7.0
    └─┬ jest-config@29.7.0
      └─┬ jest-validate@29.7.0
        └── chalk@4.1.2

You have to go down four levels to find it. We do not know who maintains this package, or whether the account has two-factor authentication enabled. Many real incidents originated in a maintainer account takeover or in a handover of ownership.

Third, you do not control the upgrade. Even when a vulnerability is found, you cannot touch it from above until the intermediate package widens its range. There is a way to force an override, but you have to accept the side effects.

{
  "overrides": {
    "semver": "7.6.3"
  }
}

Simply looking at the size of the tree periodically changes your judgment.

# Find the direct dependencies that pull in the most packages in the production tree
for dep in $(jq -r '.dependencies | keys[]' package.json); do
  count=$(npm ls "$dep" --omit=dev --all --parseable 2>/dev/null | wc -l)
  printf '%6d  %s\n' "$count" "$dep"
done | sort -rn | head -5
   214  @aws-sdk/client-s3
   131  express
    88  sharp
    41  pg
    12  zod

Whether it is reasonable to bring in more than 200 packages for a single feature depends on the situation. What differs is deciding without knowing that number versus deciding while knowing it.

What a Lockfile Guarantees and What It Does Not

Let us actually read a lockfile entry.

"node_modules/left-pad": {
  "version": "1.3.0",
  "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz",
  "integrity": "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="
}

integrity is the SHA-512 hash of the tarball. If the bytes downloaded at install time differ from this hash, the install fails. What this guarantees is these three things.

That you receive the same bytes as at the moment the lockfile was created, that it fails immediately if the content changes at the registry or somewhere along the way, and that every developer and every CI run gets the same tree. In other words, the problem a lockfile solves is reproducibility.

What it does not guarantee is whether those bytes are safe. If a maintainer normally publishes a new version containing malicious code, the hash of that tarball is normally computed too. A lockfile only guarantees "the same thing I saw last week"; it never says "this is harmless."

More important is when the lockfile gets updated. This is where practical incidents happen often.

# Trust the lockfile and install exactly it. Fails if it disagrees with package.json.
npm ci

# Update the lockfile as long as the package.json range is satisfied. A new version can come in.
npm install

If you are using npm install in CI, the lockfile is effectively meaningless. A dependency with a caret range can pull a new patch version on every build, and the latest release at that moment could be a compromised version. Other ecosystems have the same distinction.

pnpm install --frozen-lockfile
yarn install --immutable
uv sync --locked
poetry install --sync
cargo build --locked
go build -mod=readonly ./...
pip install --require-hashes -r requirements.txt

The Python --require-hashes is especially often missing. Without this option, hash verification does not happen even when versions are pinned in requirements.txt.

# requirements.txt
requests==2.32.3 \
    --hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6 \
    --hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760

To sum up, a lockfile is a necessary condition, not a sufficient one. What a lockfile does is catch "did someone change this behind our back"; the question "is the published thing itself malicious" has to be answered by other controls.

Typosquatting and Dependency Confusion — The Name Is an Attack Surface

A package name is a string. And the install command is typed by hand.

Typosquatting means publishing, in advance, a package whose name differs from a commonly used one by a character or two. Pairs like requests and request, python-dateutil and python-dateutils, crossenv and cross-env are found over and over. It catches you when you mistype while copying from documentation, or when you type a name from memory.

More structural is dependency confusion. Its preconditions are clear.

The name of an internal-only package is unclaimed on the public registry, the build tool queries several indexes at once and picks whichever has the higher version, and that name is registered in the internal index without a scope. The attacker publishes the same name on the public registry as version 99.0.0. On the next build, the company CI downloads and runs the public copy.

The most common mistake in Python is this.

# pip.conf — the dangerous configuration
[global]
index-url = https://pypi.org/simple
extra-index-url = https://pypi.internal.acme.com/simple

extra-index-url queries both indexes and picks the higher version. There is no notion of priority. You have to make the internal index the single entry point and configure it to proxy public packages.

# pip.conf — the safe configuration
[global]
index-url = https://pypi.internal.acme.com/simple

In npm, pinning a scope to a registry is the standard defense.

# .npmrc
@acme:registry=https://npm.internal.acme.com/
//npm.internal.acme.com/:_authToken=${NPM_TOKEN}
registry=https://registry.npmjs.org/

With this, a name starting with @acme is never resolved from the public registry. One more thing must be added here. It is safer to claim the scope name itself on the public registry too. That way, even if a build runs in an environment where the configuration file is missing, an attacker cannot publish a package under that scope.

If you use an internal proxy, turn on the name shadowing block policy. That is the setting where the proxy refuses to serve a public package whose name also exists internally.

The habit of checking a package before installing it helps as well.

npm view left-pad time.created time.modified maintainers dist.tarball
time.created = '2014-03-20T21:08:33.000Z'
time.modified = '2018-03-05T21:16:27.081Z'
maintainers = [ 'azer <azer@roadbeats.com>' ]
dist.tarball = 'https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz'

Take a second look at a package created a few days ago whose downloads are spiking, or one whose maintainer changed recently.

postinstall — Installing Is Arbitrary Code Execution

This is the most underestimated point. npm install is not a command that downloads and unpacks files. It is a command that runs the install script of each package.

{
  "name": "acme-logger",
  "version": "99.0.0",
  "scripts": {
    "postinstall": "node ./scripts/setup.js"
  }
}

Nobody reviews what setup.js does. This script runs with the developer privileges on the developer laptop, and in CI it runs in an environment holding the deploy credentials. The patterns that recur in real incidents are collecting environment variables and querying cloud metadata.

Start by counting how many packages in the current tree have install scripts.

find node_modules -maxdepth 4 -name package.json -not -path '*/node_modules/*/node_modules/*' \
  -exec jq -r 'select(.scripts.preinstall or .scripts.install or .scripts.postinstall) | .name' {} + \
  2>/dev/null | sort -u
@parcel/watcher
bcrypt
esbuild
lefthook
sharp

Not hundreds, but usually somewhere between five and twenty. At that size it is manageable. So the practical recommendation is clear. Block script execution by default and allow only the packages that genuinely need it.

# .npmrc
ignore-scripts=true
npm ci --ignore-scripts

Packages that need native bindings do need to build, so they need individual approval. pnpm offers this concept as a setting.

# pnpm-workspace.yaml
onlyBuiltDependencies:
  - bcrypt
  - esbuild
  - sharp

Python has the same problem. Installing a source distribution runs setup.py. Allowing only wheels removes this path.

pip install --only-binary=:all: -r requirements.txt

The Rust build.rs also executes arbitrary code. From this perspective Go modules are relatively safe, because there is no hook that runs at install time.

Organized per ecosystem, it looks like this.

EcosystemReproducible install commandCode execution at installHash pinning methodInternal index priority setting
npmnpm cipreinstall, postinstalllockfile integrity.npmrc scope pinning
pnpmpnpm install --frozen-lockfileonly from an allowlistlockfile integrity.npmrc scope pinning
pippip install --require-hashes -rsetup.py, sdists onlyrequirements hash commentssingle index-url, no extra
uv, poetryuv sync --lockedbuild backend executionuv.lock, poetry.lockexplicit index priority
cargocargo build --lockedbuild.rsCargo.lock checksumssource replacement configuration
go modulesgo build -mod=readonlynonego.sum, checksum DBGOPRIVATE, GOPROXY settings

An SBOM Exists for Five Minutes on Incident Day

An SBOM is often introduced as a compliance document, and so it becomes a file that gets generated and never opened. Its real value is far more concrete. On the day a compromise in a widely used package becomes public, can you answer the question "do we use this?" within a few minutes.

If you ask for that answer in chat and a human starts digging through repositories, half a day goes by. With 40 services it takes more than a day.

Generate the SBOM in the build pipeline and collect them all in one place.

syft packages dir:. -o cyclonedx-json > "sboms/${SERVICE_NAME}.cdx.json"
syft packages registry.acme.com/payments:1.4.2 -o cyclonedx-json > sboms/payments-image.cdx.json

And on incident day, this one snippet is enough.

PKG=event-stream
for f in sboms/*.cdx.json; do
  jq -r --arg p "$PKG" \
    '.components[]? | select(.name==$p) | "\(input_filename)  \(.name)@\(.version)"' "$f"
done
sboms/checkout-api.cdx.json  event-stream@3.3.6
sboms/notification-worker.cdx.json  event-stream@4.0.1

Two out of 40 services matched, versions included. From this point on it stops being a security problem and becomes a deployment problem.

Re-running the vulnerability verdict against a stored SBOM is fast too.

grype sbom:sboms/checkout-api.cdx.json --fail-on high
NAME          INSTALLED  FIXED-IN  TYPE  VULNERABILITY   SEVERITY
event-stream  3.3.6      4.0.1     npm   GHSA-mh6f-8j2x  Critical
semver        5.7.1      7.5.2     npm   GHSA-c2qf-rxjj  High

One caveat is that the accuracy of an SBOM depends on when it was generated. An SBOM made from the source directory and one made from the final image are different. The image contains the OS packages of the base image; the source does not. If it is going to be used for incident response, it has to be generated from the actual deployed artifact.

Signal to Noise in Alerts — Reachability, and Signing

Turn on a dependency scanner for the first time and you usually see numbers like these.

npm audit --json | jq '.metadata.vulnerabilities'
{
  "info": 0,
  "low": 41,
  "moderate": 88,
  "high": 23,
  "critical": 4,
  "total": 156
}

156 findings. Throw that at a team in this state and nothing happens. The first job is not to shrink the number but to keep only what is meaningful.

The first filter is whether it ships.

npm audit --omit=dev --json | jq '.metadata.vulnerabilities'
{
  "info": 0,
  "low": 3,
  "moderate": 7,
  "high": 2,
  "critical": 0,
  "total": 12
}

144 of the 156 were development dependencies. That does not mean vulnerabilities in development dependencies are meaningless. The build environment holds the secrets, so it can be more dangerous. What differs is the response mode and the urgency. Put remote code execution in the production runtime and a regular expression denial of service in the test runner into the same queue and neither gets handled.

The second filter is reachability. Being installed and having the vulnerable code on an execution path are different things. The Go tooling shows this concept most clearly.

govulncheck ./...
Vulnerability #1: GO-2024-2687
    Resource exhaustion in HTTP/2 CONTINUATION frame handling
  Found in: golang.org/x/net/http2@v0.21.0
  Fixed in: golang.org/x/net/http2@v0.23.0
  Example traces found:
    #1: internal/gateway/server.go:88:21: gateway.Serve calls http2.Server.ServeConn

=== Informational ===

Vulnerability #2: GO-2024-2611
  Found in: golang.org/x/net/html@v0.21.0
  Fixed in: golang.org/x/net/html@v0.23.0
  No traces found.

Your code is affected by 1 vulnerability from 1 module.

Two are installed and only one is actually called. The response order sets itself. Similar analyses are growing in other ecosystems too, and the format for sharing the verdicts as a document is VEX.

Here we should point out a common mistake in the opposite direction: upgrading everything to the latest just to make the alerts go away. A large batch upgrade brings in regression risk all at once, and when it fails it is hard to pinpoint the cause. On top of that, when the compromised version is the latest one, upgrading is infection. In several real incidents the victims were the teams that diligently applied automatic updates.

A realistic policy looks like this. Handle items reachable in the production runtime immediately, and the rest in a regular batch. Put in a grace window so that new versions are only accepted once a certain period has passed since publication. This single setting lets you avoid most of the compromises that are discovered right after publication.

{
  "packageRules": [
    {
      "matchDepTypes": ["dependencies"],
      "minimumReleaseAge": "5 days"
    }
  ]
}

The last layer is signing and provenance. This is the step that confirms a package really was built from the source it claims.

npm audit signatures
audited 1123 packages in 3s
1119 packages have verified registry signatures
4 packages have missing registry signatures

If you are the one publishing, you can attach provenance.

npm publish --provenance --access public

For container images and release artifacts, signing and verification are used together.

cosign sign --yes registry.acme.com/payments:1.4.2
cosign verify registry.acme.com/payments:1.4.2 \
  --certificate-identity-regexp 'https://github.com/acme/payments/.github/workflows/.*' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

A signature proves not "these bytes are safe" but "these bytes came out of this pipeline." By itself it does not stop malicious code, but it makes it traceable where and what came in during an incident, and it closes the path by which a registry compromise swaps out the artifact.

Wrapping Up — Three Controls Cover Most of It

The dependency supply chain is an area you cannot fully block. The code you have to trust runs into the thousands and most of it is unreadable. So the goal is not blocking but reducing exposure and shortening response time.

The three controls with the best effect-to-cost ratio are these.

Use only reproducible install commands in CI. npm ci, --frozen-lockfile, --locked, --require-hashes. If this is missing, every other control becomes meaningless.

Block install scripts by default and allow only the packages that need them. The list is usually around ten, and this one measure almost eliminates the arbitrary code execution path at install time.

Generate SBOMs from the deployed artifacts and collect them in one place. There is no use for them on an ordinary day, and on incident day they turn half a day into five minutes.

And let me close by correcting one misconception about lockfiles. The integrity hash in a lockfile does not mean "this package is safe" but "this package is the same as the one I last saw." The difference between those two sentences is very nearly the whole of supply chain security.

현재 단락 (1/212)

Open a single project and count the actual numbers.

작성 글자: 0원문 글자: 15,176작성 단락: 0/212