요즘 개발 트렌드에서 공급망 보안은 다시 한 번 아주 현실적인 주제가 됐습니다. 2026년 5월 11일 공개된 TanStack npm supply-chain compromise postmortem은 한 사건 이상의 의미가 있습니다. 공개 내용에 따르면 공격자는 pull_request_target 신뢰 경계, GitHub Actions cache poisoning, OIDC token extraction을 연결해 42개 @tanstack/* 패키지에 84개 악성 버전을 publish했습니다. npm token이 직접 탈취된 것이 아니라, 릴리스 워크플로의 신뢰 경계가 이어진 결과 registry 쓰기 권한이 악용됐다는 점이 중요합니다.
이 흐름이 보여주는 변화는 단순합니다. 이제 패키지 릴리스는 “새 버전이 나왔다"가 아니라 보안 이벤트입니다. registry publish, CI cache, trusted publishing, lifecycle script, 설치 호스트의 cloud credential이 한 줄로 연결되면, 새 버전을 받는 자동화가 그대로 사고 전파 자동화가 됩니다. 그래서 저는 앞으로 팀들이 Package Release Quarantine Gate를 더 자주 도입할 거라고 봅니다. 이 글은 Dependency Update Pipeline, AI Code Provenance와 SBOM, Third-party OAuth 공급망, CI/CD 보안과 공급망을 패키지 설치 시점까지 끌어내린 운영 기준입니다.
이 글에서 얻는 것
- 패키지 registry publish를 단순 릴리스가 아니라 신뢰 경계가 바뀌는 이벤트로 볼 수 있습니다.
- 새 버전을 내부 lockfile에 반영하기 전에 어떤 검증을 quarantine gate에 넣을지 기준을 잡을 수 있습니다.
- 빠른 보안 패치와 공급망 사고 차단 사이의 trade-off를 숫자와 risk tier로 운영할 수 있습니다.
핵심 개념/이슈
1) 최신 버전을 빨리 받는 자동화는 양날의 검이다
의존성 업데이트 자동화는 필요합니다. 보안 패치를 늦게 받으면 이미 알려진 취약점에 오래 노출됩니다. 하지만 자동 업데이트가 registry publish 직후 lockfile을 바꾸고, CI가 바로 설치하고, preview 환경이 자동 배포된다면 공격자 입장에서는 전파 경로가 깔끔하게 준비된 셈입니다.
그래서 업데이트 파이프라인에는 속도만이 아니라 짧은 대기와 검증이 들어가야 합니다.
| dependency 유형 | 권장 quarantine window | 검증 강도 |
|---|---|---|
| 빌드 도구, 프레임워크, 패키지 매니저 플러그인 | 30~120분 | provenance, tarball diff, lifecycle script, maintainer/publish anomaly |
| production runtime dependency | 30~60분 | SBOM diff, transitive change, known advisory, smoke test |
| devDependency | 15~60분 | install script, optionalDependencies, git URL dependency 확인 |
| 내부 패키지 | 0~15분 | release receipt, signer, expected workflow 확인 |
| 긴급 CVE 패치 | 별도 break-glass | 대기 단축 가능, 단 검증 로그는 남김 |
핵심은 무조건 늦추자는 것이 아닙니다. registry publish 직후 15분만 지나도 외부 연구자, registry 보안 시스템, 커뮤니티 탐지, vendor advisory가 작동할 시간이 생깁니다. TanStack 사건도 외부 탐지가 약 20분 안에 이뤄졌다는 점이 시사적입니다. 작은 대기 시간이 blast radius를 크게 줄일 수 있습니다.
2) 검증 대상은 소스 저장소가 아니라 tarball이다
공급망 사고에서 자주 놓치는 점은 GitHub repository가 깨끗해 보여도 registry tarball은 다를 수 있다는 것입니다. npm publish에 포함되는 파일, prepare/postinstall script, optionalDependencies, bundled dependency, generated artifact는 소스 diff만으로는 충분히 보이지 않습니다.
quarantine gate는 최소한 아래를 확인해야 합니다.
- 새 tarball에 repository에 없는 대용량 JS, binary, encoded payload가 들어갔는가
preinstall,install,postinstall,preparelifecycle script가 새로 생겼거나 바뀌었는가optionalDependencies,peerDependenciesMeta, git URL dependency가 갑자기 추가됐는가- publish actor, trusted publishing workflow, OIDC subject가 예상 범위인가
- 같은 maintainer가 짧은 시간에 여러 package를 대량 publish했는가
- semver patch인데 파일 수·bundle 크기·권한 요구가 과하게 늘었는가
이 기준은 AI Code Provenance와 SBOM에서 말한 출처 증거와 이어집니다. SBOM은 “무엇을 썼는가"를 보여주고, quarantine gate는 “이 새 버전을 지금 받아도 되는가"를 결정합니다.
3) install host의 비밀값이 blast radius를 결정한다
악성 패키지는 production 서버에 배포되지 않아도 위험합니다. npm install, pnpm install, yarn install이 실행되는 CI runner나 개발자 노트북에 cloud credential, kubeconfig, GitHub token, npm token, SSH private key가 있으면 설치 시점이 곧 침해 시점이 됩니다. 이번 TanStack 포스트모템도 설치 호스트에서 접근 가능한 AWS, GCP, Kubernetes, Vault, GitHub, npm, SSH credential 회전을 권고했습니다.
따라서 dependency 보안은 package scanner만으로 끝나지 않습니다. 설치 환경의 권한을 줄여야 합니다.
- dependency install job에는 production cloud credential을 넣지 않는다.
- npm publish 권한과 dependency install 권한을 같은 runner에 주지 않는다.
- preview CI token은 repo read와 artifact upload 정도로 좁힌다.
- 외부 PR에서 복원한 cache를 release workflow가 그대로 신뢰하지 않는다.
id-token: write는 필요한 job에만 주고, untrusted code 실행 단계와 분리한다.
이 관점은 Third-party OAuth 공급망과 비슷합니다. 토큰은 코드보다 오래 남고, 한 번 새면 blast radius가 저장소 바깥으로 번집니다.
4) release workflow는 성공/실패보다 provenance가 중요해진다
전통적인 CI에서는 workflow가 green이면 안전하다고 봤습니다. 하지만 이제는 “어떤 workflow가 어떤 subject로 어떤 artifact를 publish했는가"가 더 중요합니다. 테스트가 실패했는데 package가 publish됐다면, 그 자체가 강한 이상 신호입니다. 예정된 publish step이 아닌 다른 프로세스가 registry에 직접 POST했다면, 테스트 결과와 무관하게 사고입니다.
그래서 release receipt에는 아래 필드가 필요합니다.
package: "@example/core"
version: "1.24.7"
registry: "npm"
published_at: "2026-05-12T01:00:00Z"
publisher_subject: "repo:org/repo:ref:refs/heads/main:workflow:release.yml"
workflow_run_id: "..."
commit_sha: "..."
tarball_sha256: "..."
sbom_sha256: "..."
lifecycle_scripts_changed: false
quarantine_result: "passed"
approver: "dependency-bot-policy"
이런 receipt가 있어야 나중에 “이 버전은 정상 release lane을 통과했는가"를 빠르게 확인할 수 있습니다. Execution Receipt가 에이전트 실행 증거라면, package release receipt는 의존성 업데이트의 실행 증거입니다.
실무 적용
1) dependency update bot 앞에 hold queue를 둔다
가장 현실적인 시작점은 dependency update bot이 PR을 만들기 전에 hold queue를 거치게 하는 것입니다. 새 버전이 감지되면 바로 lockfile을 열지 않고, package risk tier에 따라 15~120분 대기하면서 검증 결과를 모읍니다.
기본 파이프라인은 이 정도면 충분합니다.
new registry version detected
→ risk tier 계산
→ quarantine window 시작
→ tarball/provenance/lifecycle 검증
→ advisory/community signal 확인
→ internal allow/deny decision
→ lockfile PR 생성
→ CI는 secret-minimized install 환경에서 실행
처음부터 모든 검증을 자동화할 필요는 없습니다. 우선순위는 lifecycle script 변경, tarball diff, git URL dependency, publish actor anomaly입니다. 이 네 가지가 잡히면 큰 사고의 상당 부분을 줄일 수 있습니다.
2) CI cache trust boundary를 분리한다
이번 사고에서 중요한 교훈은 cache가 단순 성능 최적화가 아니라 trust boundary라는 점입니다. 외부 PR이 base repository 권한으로 cache를 저장하고, release workflow가 그 cache를 복원하면, PR 코드가 release runtime에 간접적으로 들어옵니다.
운영 기준은 보수적으로 잡는 편이 좋습니다.
pull_request_target에서는 fork code checkout 후 build/install을 하지 않는다.- 외부 PR cache key와 main/release cache key를 완전히 분리한다.
- release job은 untrusted PR에서 생성된 cache를 복원하지 않는다.
- cache hit 여부를 release receipt에 남긴다.
- cache restore 후 package manager store integrity 검증을 실행한다.
- third-party GitHub Action은 tag보다 full-length commit SHA pin을 우선한다.
이 부분은 CI/CD 보안과 공급망의 실전판입니다. cache는 빠르게 만들지만, 잘못 섞이면 공격자가 만든 파일을 가장 신뢰해야 하는 release job이 실행하게 됩니다.
3) lifecycle script를 allowlist로 바꾼다
npm ecosystem에서는 lifecycle script가 강력합니다. 필요한 경우도 많지만, 공급망 공격에서는 가장 쉬운 실행 지점입니다. 따라서 CI에서는 기본적으로 lifecycle script를 비활성화하거나, 패키지별 allowlist를 두는 방향이 안전합니다.
현실적인 단계는 아래와 같습니다.
- dependency install 단계에서 실행된 lifecycle script 목록을 기록한다.
- 상위 20개 패키지부터 script 필요성을 확인한다.
- 새 script가 생기면 quarantine gate에서 자동 보류한다.
- production build runner에는 install-time secret을 넣지 않는다.
- allowlist 없는 script 실행은 preview CI에서 먼저 차단해 영향도를 본다.
무조건 ignore-scripts를 켜면 일부 패키지가 깨질 수 있습니다. 그래서 바로 전면 차단보다 관측 → allowlist → high-risk 경로 차단 순서가 현실적입니다.
4) 사고 대응은 “설치한 사람” 기준으로 준비한다
공급망 사고가 나면 흔히 “우리 서비스에 배포됐나"만 묻습니다. 하지만 install-time malware라면 질문은 달라져야 합니다. “누가 그 버전을 설치했나”, “그 설치 호스트에 어떤 비밀값이 있었나”, “그 호스트가 publish 권한이나 cloud 권한을 가졌나"를 먼저 봐야 합니다.
대응 runbook의 최소 항목은 다음입니다.
- 영향을 받은 package/version과 설치 시간 범위 확인
- CI logs, dependency cache, local developer install telemetry 조회
- 해당 시간대 install host의 secret inventory 확인
- cloud, GitHub, npm, SSH, Vault, Kubernetes credential 회전 우선순위 결정
- malicious version deprecate/pull 여부 확인
- 내부 lockfile과 artifact cache에서 해당 버전 제거
- 재설치 전 clean runner에서 lockfile 재검증
회전 우선순위는 publish 권한 > cloud admin 권한 > production runtime secret > repo write token > read-only token 순서로 잡는 것이 안전합니다. 모든 secret을 동시에 회전하려 하면 운영이 멈출 수 있으니 blast radius 기준으로 나눠야 합니다.
트레이드오프/주의점
첫째, quarantine gate는 분명 속도를 늦춥니다. 보안 패치가 나온 직후 자동으로 들어오던 팀은 답답하게 느낄 수 있습니다. 그래서 window를 일괄 24시간으로 두는 방식은 추천하지 않습니다. 대부분의 팀에는 15~120분의 짧은 window와 risk tier가 더 낫습니다.
둘째, 검증 자동화가 false positive를 만들 수 있습니다. semver patch에서 파일 수가 늘었다고 모두 공격은 아닙니다. 대형 프레임워크는 정상 릴리스에서도 generated file이 크게 바뀔 수 있습니다. 그래서 차단보다 보류, 보류보다 human review lane으로 설계해야 개발팀이 우회하지 않습니다.
셋째, provenance가 있어도 완전한 안전을 보장하지 않습니다. 정상 workflow가 오염된 cache를 복원하거나, maintainer 계정이 침해되거나, release script 내부가 악성 dependency를 실행하면 provenance는 “어디서 왔는지"는 말해주지만 “안전한지"를 보장하지 않습니다. provenance는 출발점이고, tarball diff와 설치 환경 최소 권한이 같이 필요합니다.
넷째, 개발자 노트북을 빼놓으면 반쪽입니다. CI는 잘 잠갔는데 개발자 로컬에 cloud profile, SSH key, npm token이 모두 있다면 devDependency 사고가 바로 조직 사고가 됩니다. 최소한 high-risk package 설치는 container/devcontainer에서 하고, 로컬 장기 token은 줄이는 방향으로 가야 합니다.
의사결정 우선순위는 설치 호스트 비밀값 축소 > release provenance 확인 > tarball/lifecycle 검증 > quarantine window > 자동 업데이트 속도입니다. 최신 버전을 빨리 받는 능력은 중요하지만, 악성 버전도 똑같이 빨리 받는 구조라면 속도가 아니라 취약점입니다.
체크리스트 또는 연습
체크리스트
- critical dependency 목록과 risk tier가 정해져 있다.
- dependency update bot이 registry publish 직후 바로 lockfile PR을 열지 않고 quarantine window를 거친다.
- 새 tarball의 lifecycle script, optionalDependencies, git URL dependency 변경을 검사한다.
- release receipt에 publisher subject, workflow run, commit SHA, tarball hash가 남는다.
- 외부 PR cache와 main/release cache가 분리되어 있다.
-
pull_request_target에서 fork code를 checkout해 build/install하지 않는다. - CI install job에는 production cloud token, npm publish token, SSH private key가 없다.
- lifecycle script allowlist 또는 최소한 관측 로그가 있다.
- 악성 버전 설치 시 secret rotation 우선순위가 runbook에 있다.
- 내부 artifact cache와 lockfile에서 차단 버전을 제거하는 절차가 있다.
연습
- 현재 프로젝트의 dependency 중 CI에서 설치되는 상위 20개를 뽑고, lifecycle script가 있는 패키지를 표시해 보세요. 이 목록이 곧 첫 allowlist 후보입니다.
- dependency update bot PR에 30분 quarantine window를 붙인다고 가정하고, 긴급 CVE 패치만 통과시키는 break-glass 조건을 3개로 제한해 보세요. 예: public exploit 존재, production reachable, fix version provenance 확인.
pull_request_target, cache restore,id-token: write, npm trusted publishing을 쓰는 workflow를 찾아 신뢰 경계 다이어그램을 그려 보세요. 외부 PR에서 생성된 파일이 release job으로 들어가는 경로가 하나라도 있으면 먼저 끊어야 합니다.
💬 댓글