배포 일정 앞에서 공지가 왔고, lockfile PR은 빠르게 merge됐다.
그다음 주엔 “그때 뭐가 문제였지?”라는 질문만 남는 경우가 많다.
2026년 5월 Mini Shai-Hulud npm 공급망 사건 때, 많은 FE 팀이 비슷한 하루를 보냈다. 보안 advisory가 오고, 영향 패키지 목록이 공유되고, monorepo마다 lockfile을 손봤다. 다행히 배포 일정까지는 막히지 않았다.
몇 주 뒤 회고 자리에서는 종종 이렇게 말한다.
- “lockfile 지우고 다시 깔라 해서 했어요.”
- “TanStack 버전 올렸어요.”
- “근데 install할 때 뭐가 위험한 거였죠?”
이 글은 긴박한 실황 회고가 아니다. 왜 위험했는지를 차분히 풀고, 빠르게 고친 것과 절차 있게 고치는 것, 재발 방지를 어떻게 나눌지 정리한다. 사실 관계와 대응 프레임은 Tenable, OWASP NPM Cheat Sheet, TanStack postmortem 등 공개 글을 참고했다.
TL;DR
- Mini Shai-Hulud는
@tanstack/*등 믿던 패키지에 악성 버전을 올리고,postinstall같은 lifecycle script로 토큰·시크릿을 노린 공급망 웜이다(CVE-2026-45321). - 위험의 본체는 “이상한 패키지 이름”이 아니라,
npm install한 번이 외부 코드 실행과 캐시 복제를 허용한다는 점이다. - 빨리 merge한 PR만으로는 부족하다. (1) 영향 범위 확인 → (2) pin만 surgical bump → (3) store·mirror 정리 → (4) typecheck로 검증 순서가 재사용 가능한 절차가 된다.
- 재발 방지는 registry 직통 축소, lockfile +
npm ci, 스크립트 allowlist, 신규 버전 cooldown처럼 평소에 깔아 두는 층이다(ArmorCode, OpenReplay).
1. 무슨 일이었나
| 시기 | 요약 |
|---|---|
| 2025-09~ | Shai-Hulud — npm 대량 오염 (Snyk 정리) |
| 2026-05-11 | @tanstack/* 42패키지·84 악성 버전 — 수 분 내 publish (NVD, TanStack postmortem) |
| 이후 | 많은 조직에서 lock bump, cache 정리, ignore-scripts 등 2차 runbook 적용 |
공개 분석이 말하는 공격 체인은 비슷하다. CI pull_request_target·cache poisoning·OIDC 탈취 → trusted identity로 npm publish → install script로 확산(Mend.io, Orca, Socket).
일부 버전에는 SLSA provenance도 붙었다. “출처 증명이 있다”고 해서 안전한 건 아니다. 파이프라인이 이미 오염되면 attestation은 누가 빌드했는가만 말해 준다(Tenable FAQ).
2. 왜 위험했나 — FE가 자주 놓치는 세 가지
“악성 TanStack 버전”은 표면이다. 아래가 구조다.
(1) install = 코드 실행 기회
악성물은 앱을 import하기 전, preinstall / postinstall에서 돌아간다(CSA).
로컬 ~/.npmrc, CI env, GitHub token 같은 개발·배포 경계 안을 노린다.
그래서 “앱 코드엔 TanStack API만 쓴다”는 말과 공급망 위험은 별개다. 설치 한 번이면 충분할 수 있다.
(2) lockfile = 악성 exact version 고정핀
pnpm-lock.yaml / package-lock.json은 semver range가 아니라 특정 tarball을 가리킨다.
- advisory에 있는 버전 번호가 lock에 남아 있으면,
node_modules만 지워도 같은 pin으로 다시 받는다. - 사설 npm mirror는 upstream이 quarantine해도 캐시된 tarball을 잠깐 더 줄 수 있다(Safeguard — pass-through gap).
“registry에서 직접 안 받는다” ≠ “안전하다”. pin · 로컬 store · org mirror는 같이 봐야 한다.
(3) npm audit·스캔만으로는 당일을 막기 어렵다
Marisi Romanillos가 짚듯, audit은 advisory가 올라온 뒤에 강하다. 당일 zero-day malicious tarball에는 평소 깔아 둔 경계(cooldown, proxy policy, frozen lockfile)가 맞다.
3. “후다다닥 고침”과 “절차 있게 고침”
배포 일정이 있으면 빠르게 PR을 merge하는 건 맞다. 문제는 같은 조작을 매번 새로 짜는 것이다.
빠른 대응에서 자주 보이는 패턴
| 패턴 | 단기 | 장기 |
|---|---|---|
| lockfile 통째 삭제 후 재생성 | install은 될 수 있음 | transitive 대량 이동 → 타입·테스트 2차 장애 |
cache clean만 |
한 겹 정리 | pnpm store·mirror는 그대로 |
| “일단 merge” | 배포는 맞춤 | 어떤 버전을 왜 바꿨는지 기록 없음 |
| 영향 없는 레포까지 동일 runbook | 통제감 | 불필요 diff·리뷰 피로 |
긴박함과 무질서는 다르다. 전자는 일정, 후자는 다음 사고 때 또 처음부터다.
절차 있게 고칠 때의 순서
OWASP와 공개 runbook을 FE monorepo에 맞추면 아래 순서를 그대로 재사용할 수 있다.
1. SSOT — advisory의 패키지@버전 목록 (표 한 장)
2. 영향 검색 — lockfile / `pnpm why` / `npm ls` (transitive 포함)
3. 없으면 — lock 무변경, “해당 없음” 기록
4. 있으면 — 해당 exact version만 safe version으로 bump (lock diff 최소)
5. node_modules 삭제 + pnpm store / npm cache 정리
6. (조직 정책) mirror 차단·purge 요청 — pin과 병행
7. frozen install + typecheck + smoke — “설치 성공” ≠ 완료
8. PR 본문 — 패키지, from→to, 검증 명령 3줄
8번이 다음 사고 때 “왜 위험했는지”를 팀이 기억하게 만든다. 스레드 합의를 티켓 한 문단으로에서 말한 “한 문단 기록”과 같은 층이다.
pin · store · mirror
| 겹 | 역할 |
|---|---|
| lockfile | 무엇을 설치할지 — bad pin이면 재감염 |
| 로컬 store | pnpm/npm이 tarball을 어디에 보관하는지 |
| org mirror | 조직이 upstream을 어떻게 복제·캐시하는지 |
runbook에 rm -rf node_modules && npm cache clean만 있으면 store·mirror 겹이 빠지기 쉽다. 절차 문서에는 세 줄 모두 적어 두는 편이 낫다.
4. 재발 방지 — 사고 다음 날부터
“한 번 고치고 끝”이 아니라, 다음 advisory가 왔을 때 1~3절을 다시 읽지 않아도 되게 만드는 층이다. ArmorCode의 defense-in-depth를 FE 팀 단위로 옮기면 아래와 같다.
평소 (레포·로컬)
| 조치 | 왜 |
|---|---|
| lockfile 항상 commit | 공급망 계약서 (Snyk) |
CI는 npm ci / frozen lockfile |
install이 lock을 몰래 바꾸지 않게 |
.npmrc registry 한 줄 문서화 |
public 직통 vs proxy — 온보딩 |
| lifecycle script 기본 차단 + allowlist | Safeguard — install scripts |
| advisory 대응 PR 템플릿 (패키지·버전·검증) | 빠르게 해도 절차는 같음 |
조직 (플랫폼·보안이 있을 때)
| 조치 | 왜 |
|---|---|
| Private registry proxy — public 직접 egress 차단 | dependency confusion·중앙 정책 (OWASP) |
| min-release-age / mirror cooldown (24~72h) | publish 직후 attack window 완충 (OpenReplay) |
| mirror blocklist·악성 feed | pass-through gap 줄이기 (Safeguard 2026, Socket firewall) |
| publish OIDC trusted publishing | 장기 PAT 탈취 면적 축소 |
| runbook에 mirror purge 절차 | 2차 조치 때 FE만 cache clean하지 않게 |
registry.npmjs.org 직접 — 기본값 재검토
예전 기본값:
registry=https://registry.npmjs.org/
npm install → resolve & fetch
2026년에 가깝게 권장되는 방향:
registry=https://npm.internal.example.com/ # proxy
min-release-age=… # 선택 (npm 11+)
ignore-scripts=true + allowlist # 선택
npm ci in CI # 필수에 가깝게
“직접 가져오기”는 편함과 공격 창을 그대로 받기의 교환이다. 재발 방지는 매 사고마다 영웅이 나오는 것이 아니라, 평소 경계가 얇게 겹치는 것이다.
5. 다음 advisory가 왔을 때 — FE 체크리스트
배포 일정이 있어도 순서는 바꾸지 않는 쪽이 낫다.
범위
- SSOT 표와 lockfile exact version 대조
- monorepo workspace·transitive까지
- 해당 없음 레포는 손대지 않기 — diff 최소
수정
- 악성 version만 bump — lock 통째 삭제는 최후
node_modules+ store/cache + (필요 시) mirror
검증
- frozen install
- typecheck · smoke
- PR에 from → to · 검증 명령
사후 (같은 주)
- “왜 위험했는지” wiki·블로그 한 문단
- 깨진
ignore-scriptsallowlist 보완 - cooldown·proxy 미적용이면 티켓 하나
6. AI·에이전트 (짧게)
CSA는 Mini Shai-Hulud가 CI·dev credential을 노린다고 본다. 에이전트가 lock bump PR을 빠르게 만들어 줄 때는, 하네스에 “advisory PR = 버전 diff + 검증 명령 필수” 한 줄을 두면 절차가 지켜진다. 빠름과 무질서를 구분하는 최소 장치다.
7. 마치며
Mini Shai-Hulud 때 많은 팀이 잘 대응했다. 배포도 맞췄다. 그건 인정할 일이다.
다만 “고쳤다”와 “왜 위험했는지 안다”는 다르다. 전자만 반복하면 다음 공지에도 lock 지우기·cache clean만 또 외워 둔다.
- 위험의 중심은 registry에서 tarball을 받는 순간과 install script다.
- 대응의 중심은 속도 + 같은 순서 + 최소 diff + 기록이다.
- 재발 방지는 사고 당일의 영웅이 아니라 평소의 proxy·lockfile·cooldown·allowlist다.
한 줄 결: 빨리 merge한 PR은 그날을 살리고, 절차와 경계는 다음 advisory를 설명 가능하게 만든다.
참고
사건·CVE
- CVE-2026-45321 (NVD)
- TanStack npm supply-chain postmortem
- Mini Shai-Hulud FAQ (Tenable)
- TanStack compromise (Orca)
- 172 packages wave (Mend.io)
- TanStack (Socket)
실무·가이드
- OWASP NPM Security Cheat Sheet
- Snyk — npm security after Shai-Hulud
- OpenReplay — npm security best practices
- ArmorCode — defending npm supply chain
- Marisi Romanillos — mitigating from the trenches
- Safeguard — install scripts
- Safeguard — private registry hardening 2026
- CSA — Shai-Hulud & AI supply chain