Skip to content

Split View: 의존성 공급망 보안 — 락파일이 막아 주지 않는 것들

✨ Learn with Quiz
|

의존성 공급망 보안 — 락파일이 막아 주지 않는 것들

들어가며 — 직접 설치한 패키지는 14개인데 설치된 것은 1,100개입니다

프로젝트 하나를 열어 실제 숫자를 세어 봅니다.

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

직접 적은 것은 37개인데 설치된 패키지는 1,123개입니다. 그중 486개는 프로덕션 런타임에 함께 배포됩니다. 나머지 637개는 빌드와 테스트 시점에만 쓰이지만, 그 시점의 프로세스는 대개 저장소 전체와 CI 시크릿에 접근할 수 있습니다.

이 숫자가 공급망 보안의 출발점입니다. 우리가 검토하고 선택한 것은 37개이고, 신뢰하기로 한 것은 1,123개입니다. 나머지 1,086개는 이름을 들어 본 적도 없는 경우가 대부분입니다.

여기에 흔한 오해가 하나 붙습니다. 락파일이 있으니 괜찮다는 것입니다. 락파일은 중요한 문제를 하나 풀지만, 그 문제는 보안이 아닙니다. 이 글은 락파일이 무엇을 보장하고 무엇을 보장하지 않는지에서 시작해, 실제로 효과가 있는 통제 몇 가지를 정리합니다.

전이 의존성 — 위험의 실제 형태

전이 의존성이 직접 의존성보다 위험한 이유는 세 가지입니다.

첫째, 권한 경계가 없습니다. 자바스크립트든 파이썬이든 기본 실행 모델에서는 어느 패키지든 파일 시스템, 네트워크, 환경변수에 접근할 수 있습니다. 문자열 패딩 유틸리티도 프로세스 환경 전체를 읽어 외부로 보낼 수 있습니다. 의존성 트리의 어느 잎사귀 하나가 애플리케이션과 동일한 권한을 갖습니다.

둘째, 유지보수 주체가 보이지 않습니다. 어떤 패키지가 어떤 경로로 들어왔는지 확인해 보면 대개 놀랍니다.

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

네 단계를 내려가야 나옵니다. 이 패키지의 유지보수자가 누구인지, 계정에 2단계 인증이 걸려 있는지 우리는 알지 못합니다. 실제 사고 다수가 유지보수자 계정 탈취나 인수인계 과정에서 발생했습니다.

셋째, 갱신 결정권이 없습니다. 취약점이 발견돼도 중간 단계 패키지가 범위를 올려 주기 전까지는 상위에서 손댈 수 없습니다. 강제로 덮어쓰는 방법은 있지만 부작용을 감수해야 합니다.

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

트리 규모를 주기적으로 보는 것만으로도 판단이 달라집니다.

# 프로덕션 트리에서 가장 많은 패키지를 끌어오는 직접 의존성 찾기
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

기능 하나를 위해 200개가 넘는 패키지를 들이는 선택이 합리적인지는 상황에 따라 다릅니다. 다만 그 숫자를 모르고 결정하는 것과 알고 결정하는 것은 다릅니다.

락파일이 보장하는 것과 보장하지 않는 것

락파일 항목을 실제로 읽어 봅니다.

"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는 tarball의 SHA-512 해시입니다. 설치 시 내려받은 바이트가 이 해시와 다르면 설치가 실패합니다. 이것이 보장하는 것은 다음 세 가지입니다.

락파일을 만든 시점과 동일한 바이트를 받는다는 것, 레지스트리나 중간 경로에서 내용이 바뀌면 즉시 실패한다는 것, 그리고 모든 개발자와 CI가 같은 트리를 얻는다는 것입니다. 즉 락파일이 푸는 문제는 재현성입니다.

보장하지 않는 것은 그 바이트가 안전한지 여부입니다. 유지보수자가 악성 코드를 담은 새 버전을 정상적으로 게시하면, 그 tarball의 해시도 정상적으로 계산됩니다. 락파일은 "내가 지난주에 본 것과 같은 것"을 보장할 뿐 "이것이 무해하다"고 말하지 않습니다.

더 중요한 것은 락파일이 언제 갱신되는가입니다. 여기서 실무 사고가 자주 납니다.

# 락파일을 신뢰하고 그대로 설치한다. package.json과 어긋나면 실패한다.
npm ci

# package.json의 범위를 만족하면 락파일을 갱신한다. 새 버전이 들어올 수 있다.
npm install

CI에서 npm install을 쓰고 있다면 락파일은 사실상 의미가 없습니다. 캐럿 범위가 걸린 의존성은 빌드마다 새 패치 버전을 가져올 수 있고, 그 시점의 최신본이 침해된 버전일 수 있습니다. 다른 생태계도 같은 구분이 있습니다.

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

파이썬의 --require-hashes는 특히 자주 빠집니다. 이 옵션 없이는 requirements.txt에 버전이 고정돼 있어도 해시 검증이 이뤄지지 않습니다.

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

정리하면 락파일은 필요조건이지 충분조건이 아닙니다. 락파일이 하는 일은 "누군가 몰래 바꿨는가"를 잡는 것이고, "게시된 것 자체가 악성인가"는 다른 통제가 답해야 합니다.

타이포스쿼팅과 의존성 혼동 — 이름이 공격 표면이다

패키지 이름은 문자열입니다. 그리고 설치 명령은 사람이 손으로 칩니다.

타이포스쿼팅은 자주 쓰는 이름과 한두 글자 다른 패키지를 미리 올려 두는 방식입니다. requestsrequest, python-dateutilpython-dateutils, crossenvcross-env 같은 쌍이 반복해서 발견됩니다. 문서를 복사하다 오타가 나거나, 이름을 기억에 의존해 칠 때 걸립니다.

더 구조적인 것은 의존성 혼동입니다. 성립 조건이 명확합니다.

내부 전용 패키지의 이름이 공개 레지스트리에서 비어 있고, 빌드 도구가 여러 인덱스를 동시에 조회하면서 버전이 높은 쪽을 고르며, 내부 인덱스에 그 이름이 스코프 없이 등록되어 있는 경우입니다. 공격자는 같은 이름을 공개 레지스트리에 버전 99.0.0으로 올립니다. 다음 빌드에서 사내 CI가 공개본을 내려받아 실행합니다.

파이썬에서 가장 흔한 실수는 이것입니다.

# pip.conf — 위험한 설정
[global]
index-url = https://pypi.org/simple
extra-index-url = https://pypi.internal.acme.com/simple

extra-index-url은 두 인덱스를 모두 조회하고 더 높은 버전을 고릅니다. 우선순위 개념이 없습니다. 내부 인덱스를 단일 진입점으로 두고, 그 인덱스가 공개 패키지를 프록시하도록 구성해야 합니다.

# pip.conf — 안전한 설정
[global]
index-url = https://pypi.internal.acme.com/simple

npm에서는 스코프를 레지스트리에 고정하는 것이 표준 방어입니다.

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

이렇게 하면 @acme로 시작하는 이름은 절대 공개 레지스트리에서 해석되지 않습니다. 여기서 한 가지를 덧붙여야 합니다. 스코프 이름 자체를 공개 레지스트리에 선점해 두는 것이 안전합니다. 설정 파일이 빠진 환경에서 빌드가 돌아도 공격자가 그 스코프에 패키지를 올릴 수 없기 때문입니다.

내부 프록시를 쓴다면 이름 그림자 차단 정책을 켜 두어야 합니다. 내부에 존재하는 이름과 같은 공개 패키지는 프록시가 아예 내려주지 않는 설정입니다.

설치 전에 패키지를 확인하는 습관도 도움이 됩니다.

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'

생성일이 며칠 전이고 다운로드가 폭증하는 패키지, 유지보수자가 최근에 바뀐 패키지는 한 번 더 봅니다.

postinstall — 설치는 곧 임의 코드 실행이다

가장 과소평가되는 지점입니다. npm install은 파일을 내려받아 푸는 명령이 아닙니다. 각 패키지의 설치 스크립트를 실행하는 명령입니다.

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

setup.js가 무엇을 하는지는 아무도 검토하지 않습니다. 이 스크립트는 개발자의 노트북에서는 개발자 권한으로, CI에서는 배포 자격증명이 있는 환경에서 실행됩니다. 실제 사고에서 반복되는 패턴이 환경변수 수집과 클라우드 메타데이터 조회입니다.

먼저 현재 트리에 설치 스크립트를 가진 패키지가 몇 개인지 셉니다.

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

수백 개가 아니라 대개 다섯에서 스무 개 정도입니다. 이 정도면 관리가 가능합니다. 그래서 실무 권고는 명확합니다. 스크립트 실행을 기본 차단하고, 정말로 필요한 패키지만 허용합니다.

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

네이티브 바인딩이 필요한 패키지는 빌드가 필요하므로 개별 허용이 필요합니다. pnpm은 이 개념을 설정으로 제공합니다.

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

파이썬도 같은 문제가 있습니다. 소스 배포본을 설치하면 setup.py가 실행됩니다. 휠만 허용하면 이 경로가 사라집니다.

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

Rust의 build.rs도 임의 코드를 실행합니다. 이 관점에서 Go 모듈은 상대적으로 안전합니다. 설치 시점에 실행되는 훅이 없기 때문입니다.

생태계별로 정리하면 이렇습니다.

생태계재현 설치 명령설치 시 코드 실행해시 고정 방법내부 인덱스 우선순위 설정
npmnpm cipreinstall, postinstall락파일 integrity.npmrc 스코프 고정
pnpmpnpm install --frozen-lockfile허용 목록에서만락파일 integrity.npmrc 스코프 고정
pippip install --require-hashes -rsetup.py, 소스 배포본만requirements 해시 주석index-url 단일화, extra 금지
uv, poetryuv sync --locked빌드 백엔드 실행uv.lock, poetry.lock인덱스 우선순위 명시
cargocargo build --lockedbuild.rsCargo.lock 체크섬source replacement 설정
go modulesgo build -mod=readonly없음go.sum, 체크섬 DBGOPRIVATE, GOPROXY 지정

SBOM은 사고 당일 5분을 위한 것이다

SBOM은 규정 준수 문서로 소개되는 경우가 많고, 그래서 만들어 두고 아무도 열어 보지 않는 파일이 됩니다. 실제 가치는 훨씬 구체적입니다. 널리 쓰이는 패키지에서 침해가 공개된 날, "우리가 이걸 쓰나요"라는 질문에 몇 분 안에 답할 수 있느냐입니다.

이 답을 채팅으로 물어 사람이 저장소를 뒤지기 시작하면 반나절이 갑니다. 서비스가 40개면 하루가 넘습니다.

빌드 파이프라인에서 SBOM을 만들어 한곳에 모아 둡니다.

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

그리고 사고 당일에는 이 한 줄이면 됩니다.

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

40개 서비스 중 두 개가 걸렸고, 버전까지 나왔습니다. 이 시점부터는 보안 문제가 아니라 배포 문제로 바뀝니다.

SBOM에 저장된 취약점 판정을 다시 돌리는 것도 빠릅니다.

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

한 가지 주의할 점은 SBOM의 정확도가 생성 시점에 달려 있다는 것입니다. 소스 디렉터리에서 만든 SBOM과 최종 이미지에서 만든 SBOM은 다릅니다. 이미지에는 베이스 이미지의 OS 패키지가 들어 있고, 소스에는 없습니다. 사고 대응에 쓸 것이라면 실제 배포 산출물에서 생성해야 합니다.

알림의 신호 대 잡음 — 도달 가능성, 그리고 서명

의존성 스캐너를 처음 켜면 대개 이런 숫자를 봅니다.

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

156건입니다. 이 상태로 팀에 던지면 아무 일도 일어나지 않습니다. 숫자를 줄이는 것이 아니라 의미 있는 것만 남기는 작업이 먼저입니다.

첫 번째 필터는 배포 대상 여부입니다.

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

156건 중 144건이 개발 의존성이었습니다. 개발 의존성 취약점이 무의미하다는 뜻은 아닙니다. 빌드 환경은 시크릿을 쥐고 있으므로 오히려 위험할 수 있습니다. 다만 대응 방식과 긴급도가 다릅니다. 프로덕션 런타임의 원격 코드 실행과 테스트 러너의 정규식 서비스 거부를 같은 대기열에 두면 둘 다 처리되지 않습니다.

두 번째 필터가 도달 가능성입니다. 설치되어 있다는 것과 취약한 코드가 실행 경로에 있다는 것은 다릅니다. Go의 도구가 이 개념을 가장 명확하게 보여 줍니다.

govulncheck ./...
Vulnerability #1: GO-2024-2687
    HTTP/2 CONTINUATION 프레임 처리에서 자원 소진
  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.

두 건이 설치돼 있고 한 건만 실제로 호출됩니다. 대응 순서가 자동으로 정해집니다. 다른 생태계에서도 비슷한 분석이 늘고 있고, 판정 결과를 문서로 공유하는 형식이 VEX입니다.

여기서 흔한 반대 방향의 실수를 짚어야 합니다. 알림을 없애려고 전부 최신으로 올리는 것입니다. 대규모 일괄 업그레이드는 회귀 위험을 한꺼번에 들여오고, 실패했을 때 원인 지목이 어렵습니다. 게다가 침해된 버전이 최신인 경우 업그레이드가 곧 감염입니다. 실제로 여러 사고에서 피해자는 자동 업데이트를 성실히 적용한 팀이었습니다.

현실적인 정책은 이렇습니다. 프로덕션 런타임에 도달 가능한 항목은 즉시, 나머지는 정기 배치로 처리합니다. 새 버전은 게시 후 일정 기간이 지난 것만 받도록 유예 창을 둡니다. 이 한 가지 설정이 게시 직후 발견되는 침해 사고 대부분을 피하게 해 줍니다.

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

마지막 층이 서명과 출처 증명입니다. 패키지가 주장하는 소스에서 실제로 만들어졌는지 확인하는 단계입니다.

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

게시하는 쪽이라면 출처 증명을 붙일 수 있습니다.

npm publish --provenance --access public

컨테이너 이미지와 릴리스 아티팩트는 서명과 검증을 함께 씁니다.

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

서명은 "이 바이트가 안전하다"가 아니라 "이 바이트가 이 파이프라인에서 나왔다"를 증명합니다. 그 자체로 악성 코드를 막지는 않지만, 사고 시 어디서 무엇이 들어왔는지 추적 가능하게 만들고, 레지스트리 침해로 산출물이 바꿔치기되는 경로를 닫습니다.

마치며 — 통제 세 가지면 대부분이 정리됩니다

의존성 공급망은 완전히 막을 수 없는 영역입니다. 신뢰해야 할 코드가 천 개 단위이고 그중 대부분을 읽을 수 없기 때문입니다. 그래서 목표는 차단이 아니라 노출 축소와 대응 속도입니다.

효과 대비 비용이 가장 좋은 통제는 세 가지입니다.

CI에서 재현 설치 명령만 쓰는 것입니다. npm ci, --frozen-lockfile, --locked, --require-hashes. 이것이 빠져 있으면 나머지 통제가 모두 무의미해집니다.

설치 스크립트를 기본 차단하고 필요한 패키지만 허용하는 것입니다. 대상은 대개 열 개 안팎이고, 이 조치 하나가 설치 시점 임의 코드 실행 경로를 거의 없앱니다.

배포 산출물에서 SBOM을 만들어 한곳에 모으는 것입니다. 평소에는 쓸 일이 없다가 사고 당일 반나절을 5분으로 줄여 줍니다.

그리고 락파일에 대한 오해 하나만 정정하고 끝내겠습니다. 락파일의 무결성 해시는 "이 패키지는 안전하다"가 아니라 "이 패키지는 내가 마지막으로 본 것과 같다"는 뜻입니다. 두 문장의 차이가 공급망 보안의 거의 전부입니다.

Dependency Supply Chain Security — The Things a Lockfile Does Not Block

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.