- 들어가며 — 직접 설치한 패키지는 14개인데 설치된 것은 1,100개입니다
- 전이 의존성 — 위험의 실제 형태
- 락파일이 보장하는 것과 보장하지 않는 것
- 타이포스쿼팅과 의존성 혼동 — 이름이 공격 표면이다
- postinstall — 설치는 곧 임의 코드 실행이다
- SBOM은 사고 당일 5분을 위한 것이다
- 알림의 신호 대 잡음 — 도달 가능성, 그리고 서명
- 마치며 — 통제 세 가지면 대부분이 정리됩니다
들어가며 — 직접 설치한 패키지는 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
정리하면 락파일은 필요조건이지 충분조건이 아닙니다. 락파일이 하는 일은 "누군가 몰래 바꿨는가"를 잡는 것이고, "게시된 것 자체가 악성인가"는 다른 통제가 답해야 합니다.
타이포스쿼팅과 의존성 혼동 — 이름이 공격 표면이다
패키지 이름은 문자열입니다. 그리고 설치 명령은 사람이 손으로 칩니다.
타이포스쿼팅은 자주 쓰는 이름과 한두 글자 다른 패키지를 미리 올려 두는 방식입니다. requests와 request, python-dateutil과 python-dateutils, crossenv와 cross-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 모듈은 상대적으로 안전합니다. 설치 시점에 실행되는 훅이 없기 때문입니다.
생태계별로 정리하면 이렇습니다.
| 생태계 | 재현 설치 명령 | 설치 시 코드 실행 | 해시 고정 방법 | 내부 인덱스 우선순위 설정 |
|---|---|---|---|---|
| npm | npm ci | preinstall, postinstall | 락파일 integrity | .npmrc 스코프 고정 |
| pnpm | pnpm install --frozen-lockfile | 허용 목록에서만 | 락파일 integrity | .npmrc 스코프 고정 |
| pip | pip install --require-hashes -r | setup.py, 소스 배포본만 | requirements 해시 주석 | index-url 단일화, extra 금지 |
| uv, poetry | uv sync --locked | 빌드 백엔드 실행 | uv.lock, poetry.lock | 인덱스 우선순위 명시 |
| cargo | cargo build --locked | build.rs | Cargo.lock 체크섬 | source replacement 설정 |
| go modules | go build -mod=readonly | 없음 | go.sum, 체크섬 DB | GOPRIVATE, 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분으로 줄여 줍니다.
그리고 락파일에 대한 오해 하나만 정정하고 끝내겠습니다. 락파일의 무결성 해시는 "이 패키지는 안전하다"가 아니라 "이 패키지는 내가 마지막으로 본 것과 같다"는 뜻입니다. 두 문장의 차이가 공급망 보안의 거의 전부입니다.
현재 단락 (1/212)
프로젝트 하나를 열어 실제 숫자를 세어 봅니다.