- Seattle Times는 Shai-Hulud 2.0 공격을 우연히 피했지만, 운이 보안 전략이 될 수 없다는 전제에서 클라이언트 측 방어를 도입함
- npm의 Trusted publishing / provenance / 세분화 토큰 같은 개선은 “배포” 측면을 강화하지만, “설치·업데이트” 시점의 악성 코드 실행은 막지 못한다는 공백이 남아있음
- pnpm은 npm 레지스트리를 그대로 사용하면서도 소비(install/update) 단계에서 악성 패키지 실행을 어렵게 만드는 제어를 추가하는 구조임
- 파일럿에서 pnpm의 3가지 제어를 적용해 라이프사이클 스크립트 실행, 최신 릴리즈 즉시 설치, 신뢰도 다운그레이드 같은 벡터를 각각 막도록 설계함
- 예외는 실패가 아니라 설계 일부로 보고, 예외를 문서화하면서도 나머지 레이어가 계속 보호하도록 defense-in-depth 운영을 목표로 함
사건 배경과 전제
- 2025년 11월 자기 복제형 npm 웜이 796개 패키지를 감염시키고 월 1억 3,200만 다운로드 규모에서 확산된 사례가 발생
- 공격은 preinstall 스크립트를 활용해 자격 증명 탈취, 지속성 백도어 설치, 일부 환경에서는 개발 환경 삭제까지 일어남
- 우리 조직에 영향이 없었던 이유는 강한 방어가 아니라, 공격 기간에
npm install/npm update를 실행하지 않은 우연이었음
- 뉴스 조직은 신뢰가 핵심이며, 공급망 침해로 고객 데이터·직원 자격 증명·프로덕션 인프라·소스 코드가 노출될 수 있고 복구와 통지 비용이 큼
팀과 도입 맥락
- Seattle Times는 기본 패키지 매니저로 npm을 오래 사용해 왔고, Yarn 실험도 있었지만 정착하지 못했음
- pnpm을 도입하는 이유는 레지스트리 수준 개선을 보완하는 클라이언트 측 보안 제어 때문
- pnpm은 동일 레지스트리·동일 명령·동일 워크플로우를 사용하는 drop-in replacement라 전환 가능성이 높다고 판단
- 완성된 케이스 스터디가 아니라, 실제 팀이 공급망 보안을 막 시작하며 겪는 문제와 사고 과정을 공유하는 것임
클라이언트 측 제어가 필요한 이유
- npm의 보안 개선으로 계정 탈취 후 악성 패키지 배포가 더 어려워지기는 했음
- 이 개선들이 “배포(publishing)” 측면을 보호하지만, “소비(consuming)” 단계에서 악성 패키지 설치 자체를 막지는 못함
-
npm install/npm update 시 lifecycle scripts(preinstall/postinstall 등) 가 패키지 안전성 평가 이전에 개발자 권한으로 임의 코드를 실행할 수 있는 구조임
- 스크립트가 npm/GitHub/AWS/DB 자격 증명, 소스 코드, 클라우드 인프라, 파일시스템 전체에 접근 가능함
- Shai-Hulud 같은 공격은 이 구조를 악용하며, 유지보수자 계정이 탈취되면 악성 스크립트가 설치 순간 실행되어 커뮤니티가 감지하기 전에 피해가 발생할 수 있음
- npm의 배포 측 개선 + pnpm의 소비 측 제어를 상호 보완적인 방어로 묶어 “defense-in-depth”로 구성
적용한 3가지 레이어
- 파일럿에서는 서로 다른 공격 벡터를 겨냥한 3개 제어를 함께 사용함
- 각 제어에는 현실적 예외를 위한 탈출구가 있으며, 실제 환경에서는 예외가 필요하다는 전제를 두고 설계함
Control 1: Lifecycle Script Management
- pnpm은 기본적으로 lifecycle scripts를 차단하고 설치는 경고와 함께 진행될 수 있음
- 경고가 무시될 수 있다는 우려로
strictDepBuilds: true를 선택해, 스크립트가 있으면 설치가 즉시 실패하도록 강제함
- 설정 예시는
pnpm-workspace.yaml에서 다음 필드로 구성됨
-
strictDepBuilds: true
-
onlyBuiltDependencies: 필요한 빌드 스크립트가 있는 패키지 허용 목록
-
ignoredBuiltDependencies: 불필요한 빌드 스크립트가 있는 패키지 차단(또는 무시) 목록
- “필요한 스크립트”는 네이티브 확장 컴파일, 플랫폼 의존 라이브러리 링크 같은 동작으로 정의됨
- “불필요한 스크립트”는 최적화나 선택적 설정으로, 팀의 사용 방식에서는 기능에 영향을 주지 않는 경우로 정의됨
- 설치 실패를 통해 다음 단계를 강제함
- pnpm이 어떤 패키지에 스크립트가 있는지 명확히 알려줌
- 스크립트 동작을 조사하고 이해
- 허용/차단을 사람 판단으로 의식적으로 결정하고 문서화
- pnpm 팀이 v11에서
strictDepBuilds: true를 기본값으로 고려 중이며 allow/deny 문법의 이름 개선도 검토 중
Control 2: Release Cooldown
- 최근에 배포된 버전은 일정 쿨다운 기간 동안 설치를 막아, 커뮤니티가 악성 패키지를 탐지·제거할 시간을 벌어줌
- 설정 예시는
pnpm-workspace.yaml에서 다음 필드로 구성됨
-
minimumReleaseAge: <duration-in-minutes>
-
minimumReleaseAgeExclude: 긴급 핫픽스 등 예외 허용 목록
- “최신이 최선”이라는 습관을 버리고, 공급망 관점에서는 조금 오래된 것이 더 안전할 수 있다는 사고로 전환이 필요
- 2025년 9월 공격( debug, chalk 포함 16개 패키지)에서 약 2.5시간 내 제거, 2025년 11월 Shai-Hulud 2.0은 약 12시간이 걸렸음
- 조직의 리스크 허용도에 따라 쿨다운은 시간/일/주 단위가 될 수 있으며, 어떤 형태든 해당 공격을 막았을 것
- 조직이 원래도 항상 최신을 쓰지 못하는 현실과 잘 맞아, 쿨다운이 업무를 크게 방해하지 않음
- 보안 패치나 치명적 버그처럼 꼭 필요할 때는 검토 후 예외로 풀 수 있음
Control 3: Trust Policy
- 이전 버전보다 약한 인증으로 배포된 버전이 나타나면 설치를 차단
- 유지보수자 계정이 탈취되어 공식 CI/CD가 아닌 공격자 머신에서 배포되는 경우를 신호로 본다고 설명함
- 설정 예시는
pnpm-workspace.yaml에서 다음 필드로 구성됨
-
trustPolicy: no-downgrade
-
trustPolicyExclude: CI/CD 마이그레이션 등 예외 허용 목록
- npm이 패키지 배포에 대해 3단계 신뢰 수준을 추적한다고 설명함(강→약)
-
Trusted Publisher: GitHub Actions + OIDC + npm provenance 기반
-
Provenance: CI/CD에서 서명된 attestation
-
No Trust Evidence: username/password 또는 토큰 기반 배포
- 새 버전이 이전보다 신뢰 수준이 낮아지면 설치가 실패
- 2025년 8월 s1ngularity 공격에서 공격자가 CI/CD 접근 없이 로컬에서 악성 버전을 배포해 provenance가 없었던 사례에서는 이 제어가 설치를 막았을 것
- 합법적 다운그레이드 가능 사례로 새 유지보수자 합류, CI/CD 마이그레이션, CI/CD 장애 중 수동 핫픽스 등을 들고, 조사 후 예외 목록에 추가
- 이 기능이 2025년 11월 pnpm에 추가된 신규 기능이며, 실제로 합법적 다운그레이드가 얼마나 자주 발생할지는 학습 중
레이어 조합 동작 예시: React 취약점 패치
- 2025년 12월 공개된 React Server Components의 치명적 취약점 패치를 즉시 적용해야 하는 시나리오의 경우
- 일반적으로는 쿨다운이 “방금 배포된 버전 설치”를 막지만, 치명적 보안 패치라 기다릴 수 없음
- 이때
minimumReleaseAgeExclude에 특정 React 버전을 추가하되, 취약점 공지와 패치의 정당성을 검토한 뒤 예외를 적용
- 예외를 적용해도 다른 레이어가 계속 보호함
- React는 일반적으로 lifecycle scripts가 없으므로, 패치 버전에 스크립트가 생기면 즉시 의심 신호가 되며 차단될 수 있음
- 공격자가 자격 증명을 탈취해 로컬에서 “패치”를 배포하면 신뢰 수준 다운그레이드로 차단될 수 있음
- 예외를 “보안 실패”가 아니라, 한 레이어를 우회하더라도 다른 레이어가 남아 단일 실패 지점이 사라지는 설계
파일럿 적용 결과
- 백엔드 서비스 하나에 3개 제어를 모두 적용해 PoC를 진행함
- 조사·이해·접근 정의까지 총 준비 시간이 몇 시간 수준이었음
- pnpm이 lifecycle scripts가 있는 패키지 3개를 식별
-
esbuild: CLI 시작을 밀리초 단위로 최적화하지만, 팀은 JS API만 사용하므로 불필요하다고 판단함
-
@firebase/util: 클라이언트 SDK 자동 구성인데, 팀은 서버 SDK만 사용하므로 불필요하다고 판단함
-
protobufjs: 스키마 호환성 검사인데, 전이 의존성으로만 사용되며 팀 사용 사례에선 불필요하다고 판단함
- 문서 확인과 스크립트 분석(AI로 스크립트 해석 보조 포함)을 거쳐, 세 스크립트 모두 팀 사용 사례에는 필요 없다고 결론 내리고 차단함
- 기능 영향은 없었음
- 마찰(friction)은 의도된 기능이며, 환경에서 실행되는 코드를 묵시적으로 신뢰하지 않게 만드는 강제 장치
- 새 의존성에 스크립트가 있을 경우, 검토 및 문서화에 약 15분 정도 소요될 것으로 예상
운영하면서 얻은 학습
- client-side 레이어 + npm의 publishing-side 개선 조합으로 defense-in-depth가 실제로 작동한다고 체감
- 예외를 적용하더라도 다른 레이어가 남아 있어 예외에 대한 불안이 줄어듦
- “편의 우선”에서 “보안 우선”으로의 정신 모델 전환에 시간이 필요하지만, 익숙해지면 자연스럽게 느껴짐
- 전담 보안팀이 없는 중간 규모 조직에서도 실용적으로 적용 가능
- trust policy는 도입된 지 몇 주밖에 안 된 기능이라, 합법적 다운그레이드 빈도와 운영 감각을 더 학습해야 함
- 가까운 시일 내 다른 코드베이스로 확장할 계획이며, 의존성 그래프가 다른 애플리케이션에서의 데이터가 더 쌓일 것
다른 팀을 위한 적용 팁
- 한 프로젝트에서 먼저 시작해 워크플로우와 마찰 지점을 학습하는 방식을 권장
- lifecycle scripts, release cooldown, trust downgrade 모두에서 예외가 필요할 수 있으므로 처음부터 예외를 전제로 설계할 것
- 경고 기반보다 설치 실패로 강제하는
strictDepBuilds: true를 첫날부터 쓰는 전략을 추천
- 모든 예외를 문서화해 감사 흔적을 남기고, 향후 정리하기 쉽게 만들 것
- 한 레이어 예외가 다른 레이어의 보호를 남긴다는 점을 기억할 것