# 사후 분석: TanStack npm 공급망 침해

> Clean Markdown view of GeekNews topic #29413. Use the original source for factual precision when an external source URL is present.

## Metadata

- GeekNews HTML: [https://news.hada.io/topic?id=29413](https://news.hada.io/topic?id=29413)
- GeekNews Markdown: [https://news.hada.io/topic/29413.md](https://news.hada.io/topic/29413.md)
- Type: GN+
- Author: [neo](https://news.hada.io/@neo)
- Published: 2026-05-12T09:52:26+09:00
- Updated: 2026-05-12T09:52:26+09:00
- Original source: [tanstack.com](https://tanstack.com/blog/npm-supply-chain-compromise-postmortem)
- Points: 1
- Comments: 1

## Topic Body

- 2026-05-11 19:20~19:26 UTC에 공격자가 42개 **@tanstack/** npm 패키지에 걸쳐 악성 버전 84개를 게시함
- 공격 체인은 **pull_request_target** “Pwn Request”, GitHub Actions 캐시 오염, runner 메모리의 OIDC 토큰 추출을 결합함
- npm 토큰과 publish 워크플로는 탈취·손상되지 않았고, 악성코드가 **OIDC trusted publisher** 권한으로 registry에 직접 POST함
- 영향 버전 설치 시 **AWS, GCP, Kubernetes, Vault, GitHub, npm, SSH 자격 증명**이 노출됐을 수 있어 교체가 필요함
- 모든 영향 버전은 deprecated 처리됐고 npm security와 tarball 제거를 진행했으며, 추적 이슈와 GitHub Security Advisory가 공개됨

---

### 사건 개요
- 2026-05-11 19:20~19:26 UTC 사이 공격자가 42개 `@tanstack/*` npm 패키지에 걸쳐 악성 버전 84개를 게시함
- 공격 체인은 **`pull_request_target` “Pwn Request” 패턴**, fork↔base 신뢰 경계를 넘는 GitHub Actions 캐시 오염, GitHub Actions runner 프로세스 메모리에서의 OIDC 토큰 추출을 결합함
- npm 토큰은 탈취되지 않았고, npm publish 워크플로 자체도 손상되지 않은 것으로 확인됨
- 악성 버전은 외부 연구자 `ashishkurmi`가 `stepsecurity`에서 공개적으로 20분 안에 탐지함
- 모든 영향 버전은 deprecated 처리됐고, npm security와 함께 레지스트리에서 tarball 제거를 진행함
- 2026-05-11에 영향 버전을 설치한 사용자는 설치 호스트에서 접근 가능한 **AWS, GCP, Kubernetes, Vault, GitHub, npm, SSH 자격 증명**을 교체해야 함
- 추적 이슈는 [TanStack/router#7383](https://github.com/TanStack/router/issues/7383), GitHub Security Advisory는 [GHSA-g7cv-rxg3-hmpx](https://github.com/TanStack/router/security/advisories/GHSA-g7cv-rxg3-hmpx)임

### 영향 범위
- ## 영향받은 패키지
  - 영향 범위는 42개 패키지와 84개 버전이며, 패키지당 2개 버전이 약 6분 간격으로 게시됨
  - 전체 목록은 추적 이슈에 포함됨
  - 확인된 비영향 제품군은 `@tanstack/query*`, `@tanstack/table*`, `@tanstack/form*`, `@tanstack/virtual*`, `@tanstack/store`, `@tanstack/start` 메타 패키지임
  - `@tanstack/start-*`는 확인된 비영향 목록에 포함되지 않음
- ## 악성코드 동작
  - 개발자 또는 CI 환경이 영향 버전에 대해 `npm install`, `pnpm install`, `yarn install`을 실행하면 npm이 악성 `optionalDependencies` 항목을 해석하고 fork network의 orphan payload commit을 가져옴
  - 이후 `prepare` 라이프사이클 스크립트가 실행되며, 영향 tarball 안에 숨겨진 약 2.3MB 난독화 `router_init.js`가 동작함
  - 악성 스크립트는 AWS IMDS/Secrets Manager, GCP metadata, Kubernetes service-account token, Vault token, `~/.npmrc`, GitHub token, `gh` CLI, `.git-credentials`, SSH private key 등 일반적인 위치에서 자격 증명을 수집함
  - 탈취 데이터는 **Session/Oxen messenger file-upload network**를 통해 유출되며, 대상은 `filev2.getsession.org`, `seed{1,2,3}.getsession.org`임
  - 해당 네트워크는 종단 간 암호화되고 공격자 제어 C2가 없으므로, 네트워크 완화책은 IP/도메인 차단뿐임
  - 자기 전파 로직은 `registry.npmjs.org/-/v1/search?text=maintainer:&lt;user&gt;`로 피해자가 관리하는 다른 패키지를 열거한 뒤 같은 주입 방식으로 다시 게시함
  - payload가 npm install 라이프사이클 일부로 실행되므로, 2026-05-11에 영향 버전을 설치한 호스트는 잠재적으로 손상된 것으로 취급해야 함

### 타임라인
- ## 공격 전: 캐시 오염 단계
  - 2026-05-10 17:16 UTC에 공격자가 TanStack/router fork인 [github.com/zblgg/configuration](https://github.com/zblgg/configuration)을 만들고, fork 목록 검색을 피하려고 이름을 바꿈
  - 2026-05-10 23:29 UTC에 조작된 신원 `claude <claude@users.noreply.github.com>`으로 악성 커밋 `65bf499d16a5e8d25ba95d69ec9790a6dd4a1f14`가 fork에 작성됨
  - 해당 커밋은 약 30,000줄 번들 JS payload인 `packages/history/vite_setup.mjs`를 추가했고, push 이벤트의 CI를 억제하려고 커밋 메시지에 `[skip ci]`를 붙임
  - 2026-05-11 약 10:49 UTC에 `zblgg`가 TanStack/router `main`을 대상으로 “WIP: simplify history build” 제목의 PR #7378을 염
  - `bundle-size.yml`과 `labeler.yml`은 모두 `pull_request_target`으로 PR에 대해 자동 실행됐고, `pull_request_target`은 첫 기여자 승인 게이트를 우회하므로 별도 승인이 필요 없었음
  - `pull_request`를 쓰는 `pr.yml`은 승인 대기 상태로 차단됐고 실행되지 않음
  - 2026-05-11 11:01~11:11 UTC에 `zblgg`가 PR head에 여러 차례 force-push해 추가 `pull_request_target` 실행을 유발함
  - 2026-05-11 11:11 UTC에 악성 커밋 `65bf499d`가 PR head에 올라갔고, `bundle-size.yml`의 `benchmark-pr` job이 `refs/pull/7378/merge`를 체크아웃한 뒤 `pnpm install`과 `pnpm nx run @benchmarks/bundle-size:build`를 실행하면서 `vite_setup.mjs`가 동작함
  - 2026-05-11 11:29 UTC에 `Linux-pnpm-store-6f9233a50def742c09fde54f56553d6b449a535adf87d4083690539f49ae4da11`라는 1.1GB GitHub Actions 캐시가 TanStack/router에 저장됨
  - 이 캐시는 `refs/heads/main` scope에 저장됐고, 다음 `main` push에서 `release.yml`이 조회할 키와 맞도록 구성됨
  - 2026-05-11 11:31 UTC에 공격자가 PR을 당시 `main` HEAD인 `b1c061af`로 되돌려 visible PR을 0-file no-op처럼 만들었고, 같은 분에 PR을 닫고 branch를 삭제했지만 오염된 캐시는 남음
- ## 발화: 게시 단계
  - 2026-05-11 19:15 UTC에 Manuel이 PR #7369를 merge하면서 `main` push가 발생했고, `release.yml` workflow run `25613093674`가 19:15:44에 시작된 뒤 실패함
  - 2026-05-11 19:20:39 UTC에 npm registry가 `@tanstack/history@1.161.9`와 41개 sibling package의 publish를 수신함
  - 전체적으로 42개 패키지에 걸쳐 약 84개 버전이 게시됐지만, 이 정확한 초에 보이는 것은 약 절반이며 나머지는 두 번째 run에서 게시됨
  - publish 인증은 TanStack/router `release.yml@refs/heads/main`에 대한 **OIDC trusted-publisher binding**으로 이뤄졌지만, 테스트 실패로 건너뛴 workflow의 `Publish Packages` step에서 발생한 것은 아님
  - 실제 게시자는 테스트/정리 단계에서 실행된 malware였고, `id-token: write` 권한으로 OIDC 토큰을 mint한 뒤 `registry.npmjs.org`에 직접 POST함
  - 2026-05-11 19:20:47 UTC에 run `25613093674`는 failure 상태로 완료됨
  - 2026-05-11 19:16 UTC에 Manuel이 PR #7382를 merge하면서 두 번째 `main` push가 발생했고, 19:16:22에 workflow run `25691781302`가 시작됨
  - 두 번째 run도 같은 오염 캐시를 restore했고, 2026-05-11 19:26:14 UTC에 `@tanstack/history@1.161.12` 등 패키지당 두 번째 버전 세트가 같은 OIDC 메커니즘으로 게시됨
  - 2026-05-11 19:26:20 UTC에 run `25691781302`도 failure 상태로 완료됨
- ## 탐지와 대응
  - 2026-05-11 약 19:50 UTC에 외부 연구자 `carlini`가 악성 `optionalDependencies` fingerprint와 패키지 목록을 포함한 이슈 #7383을 염
  - 초기 목록은 42개 중 14개였고, 연구자는 npm security에도 직접 알림
  - 2026-05-11 약 20:00 UTC에 Manuel이 #7383에서 사고를 확인하고 대응을 시작함
  - 2026-05-11 약 20:10 UTC에 Manuel이 사용자 머신 손상 가능성에 대비해 다른 팀원의 GitHub push 권한을 제거함
  - 2026-05-11 약 20:30 UTC에 Tanner가 전체 IOC 목록과 registry-side tarball 제거 요청을 [security@npmjs.com](mailto:security@npmjs.com)으로 보냈고, npm을 통해 정식 malware report를 제출함
  - 2026-05-11 약 21:00 UTC에 295개 `@tanstack/*` 패키지 전체 스캔으로 범위가 42개 패키지, 84개 버전으로 확인됨
  - Tanner가 84개 영향 패키지 전체에 대한 npm deprecation을 시작했고, `@tan_stack`과 maintainer들이 Twitter/X, LinkedIn, Bluesky에서 공개 알림을 진행함
  - 2026-05-11 21:30 UTC에 `bundle-size.yml`의 `pull_request_target` 캐시 오염 벡터와 `zblgg/configuration` fork가 식별됨
  - 모든 TanStack/* GitHub repository의 캐시 항목이 API로 제거됨
  - hardening PR이 merge되어 `bundle-size.yml`이 재구성되고, `repository_owner` guard가 추가됐으며, third-party action ref가 SHA로 고정됨
  - 공식 GitHub Security Advisory가 게시됐고 CVE가 요청됨

### 근본 원인
- ## 세 취약점의 결합
  - 공격에는 세 가지 취약점이 모두 필요했고, 어느 하나만으로는 충분하지 않았음
  - fork PR 코드가 base repository cache로 넘어가고, base repository cache가 release workflow runtime으로 넘어가며, release workflow runtime이 npm registry 쓰기 권한으로 이어지는 식으로 각 취약점이 서로의 신뢰 경계를 연결함
- ## `pull_request_target` “Pwn Request” 패턴
  - `bundle-size.yml`은 fork PR에 대해 `pull_request_target`으로 실행됐고, 그 trigger context 안에서 fork의 PR merge ref를 checkout한 뒤 build를 실행함
  - 핵심 구조는 다음과 같음
  ```yaml
  on:
    pull_request_target:
      paths: ['packages/**', 'benchmarks/**']
  
  jobs:
    benchmark-pr:
      steps:
        - uses: actions/checkout@v6.0.2
          with:
            ref: refs/pull/${{ github.event.pull_request.number }}/merge # fork's merged code
  
        - uses: TanStack/config/.github/setup@main # transitively calls actions/cache@v5
  
        - run: pnpm nx run @benchmarks/bundle-size:build # executes fork-controlled code
  ```
  - workflow 작성자는 `comment-pr` job과 `benchmark-pr` job을 분리해 신뢰 경계를 나누려 했고, YAML comment에는 `benchmark-pr`를 “untrusted with read-only permissions”로 유지하려는 의도가 적혀 있었음
  - 그러나 `actions/cache@v5`의 post-job save는 `permissions:`로 막히지 않으며, cache write는 workflow `GITHUB_TOKEN`이 아니라 runner 내부 token을 사용함
  - 따라서 `permissions: contents: read` 설정은 cache mutation을 막지 못함
  - cache scope는 repository 단위이고, base repository cache scope를 사용하는 `pull_request_target` run과 `main` push가 공유함
  - base repository cache scope에서 실행되는 PR은 나중에 `main`의 production workflow가 restore할 cache entry를 오염시킬 수 있음
- ## GitHub Actions 캐시 오염
  - 악성 `vite_setup.mjs`는 정상 `release.yml` workflow가 계산하고 조회할 pnpm-store key에 맞춰 데이터를 쓰도록 설계됨
  - 대상 key는 `Linux-pnpm-store-${hashFiles('**/pnpm-lock.yaml')}` 형식임
  - `benchmark-pr` job 종료 시 `actions/cache@v5` post-step이 오염된 pnpm store를 정확히 그 key로 저장함
  - 이후 `main` push에서 `release.yml`이 실행되자 `Setup Tools` step이 오염된 entry를 설계대로 restore함
  - 이 공격 유형은 2024년 Adnan Khan이 문서화한 GitHub Actions cache poisoning 계열이며, TanStack에만 국한된 버그가 아니라 의식적인 완화가 필요한 GitHub Actions 설계 이슈임
- ## runner 메모리에서 OIDC 토큰 추출
  - `release.yml`은 npm OIDC trusted publishing에 필요해서 정당하게 `id-token: write`를 선언함
  - 오염된 pnpm store가 runner에 restore되면 공격자 제어 binary가 디스크에 존재하게 되고, build step에서 호출됨
  - 해당 binary는 `/proc/*/cmdline`로 GitHub Actions `Runner.Worker` 프로세스를 찾고, `/proc/&lt;pid&gt;/maps`와 `/proc/&lt;pid&gt;/mem`을 읽어 worker 메모리를 dump함
  - 이후 runner가 `id-token: write` 설정에서 lazy mint한 OIDC 토큰을 메모리에서 추출함
  - 추출한 token으로 `registry.npmjs.org`에 직접 POST 요청을 인증해, workflow의 `Publish Packages` step을 완전히 우회함
  - 이 메모리 추출 방식은 2025년 3월 `tj-actions/changed-files` compromise에 쓰인 방식과 같고, attribution comment가 포함된 동일 Python script가 사용됨
  - 공격자는 새로운 기법을 발명한 것이 아니라 공개 연구를 재조합함
- ## 각 요소가 단독으로 충분하지 않은 이유
  - `pull_request_target` 자체는 label이나 comment 같은 신뢰된 작업에는 사용할 수 있음
  - 이미 손상된 dependency 내부에서의 cache poisoning만으로는 별도의 publish vehicle이 필요함
  - OIDC token extraction만으로는 runner에서의 기존 code execution이 필요함

### 탐지와 IOC
- ## 탐지 경로
  - 탐지는 내부가 아니라 외부에서 이뤄짐
  - `carlini`가 publish 후 약 20분 만에 이슈 #7383을 열어 전체 기술 분석을 제공함
  - Tanner는 war room을 시작한 직후 Socket.dev에서 상황을 확인하는 전화를 받음
- ## downstream maintainer와 보안 도구용 fingerprint
  - `@tanstack/*` 패키지 manifest에서 다음 `optionalDependencies` 항목이 핵심 IOC임
  ```json
  "optionalDependencies": {
    "@tanstack/setup": "github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c"
  }
  ```
  - 파일 IOC는 package root의 `router_init.js`이며, 약 2.3MB이고 `"files"`에는 포함되지 않음
  - cache key는 `Linux-pnpm-store-6f9233a50def742c09fde54f56553d6b449a535adf87d4083690539f49ae4da11`임
  - 2단계 payload URL은 `https://litter.catbox.moe/h8nc9u.js`, `https://litter.catbox.moe/7rrc6l.mjs`임
  - 유출 네트워크는 `filev2.getsession.org`, `seed{1,2,3}.getsession.org`임
  - 위조 commit identity는 `claude <claude@users.noreply.github.com>`이며, 실제 Anthropic Claude가 아니라 조작된 GitHub no-reply email임
  - 실제 공격자 계정은 `zblgg` `id 127806521`, `voicproducoes` `id 269549300`임
  - 공격자 fork는 [github.com/zblgg/configuration](https://github.com/zblgg/configuration)이며, TanStack/router fork를 검색 회피 목적으로 rename한 것임
  - fork network 안의 orphan payload commit은 `79ac49eedf774dd4b0cfa308722bc463cfe5885c`임
  - 악성 publish를 수행한 workflow run은 [github.com/TanStack/router/actions/runs/25613093674](https://github.com/TanStack/router/actions/runs/25613093674) attempt 4와 [github.com/TanStack/router/actions/runs/25691781302](https://github.com/TanStack/router/actions/runs/25691781302)임

### 교훈
- ## 잘된 점
  - 외부 연구자들이 사고 후 약 20분 안에 탐지하고 전체 기술 세부사항과 함께 보고함
  - maintainer team이 여러 time zone에 걸쳐 즉시 조율함
  - 탐지 커뮤니티가 몇 시간 안에 명확한 공개 IOC 패턴을 확보함
- ## 개선이 필요했던 점
  - 내부 alerting이 없었고, compromise 사실을 제3자로부터 알게 됨
  - 자체 publish monitoring이 필요하며, 이런 문제를 빠르게 탐지할 수 있는 생태계 보안 연구 기업들과 더 긴밀히 협력하고 feedback loop를 좁힐 계획임
  - `pull_request_target` workflow는 오래전부터 위험한 패턴으로 알려져 있었지만 audit되지 않았음
  - third-party action의 floating ref인 `@v6.0.2`, `@main`은 이번 사건과 별개로 상시 supply-chain risk를 만듦
  - npm의 “dependent가 있으면 unpublish 불가” 정책 때문에 거의 모든 영향 패키지에서 unpublish가 불가능했음
  - registry-side tarball 제거를 npm security에 의존해야 했고, 이로 인해 악성 tarball이 설치 가능한 상태로 남는 시간이 몇 시간 추가됨
  - npm scope의 7명 maintainer 목록은 동일 blast radius에 대해 7개의 별도 credential-theft target을 만든다는 의미가 됨
  - OIDC trusted-publisher binding에는 publish별 review가 없고, 한 번 설정되면 workflow 안의 어떤 code path라도 publish 가능한 token을 mint할 수 있음
  - 필요한 대안은 수동 review가 있는 단기 classic token으로 이동하거나, 예상치 못한 workflow step에서의 publish를 탐지하는 provenance-source-verification을 추가하는 것임
- ## 운이 좋았던 점
  - 공격자가 테스트를 깨뜨리는 payload를 선택해 정상 publish step이 skip됐고, 더 깨끗해 보이는 tarball이 생성되지 않았음
  - 이 때문에 공격이 충분히 요란하게 드러나 빠르게 탐지됨
  - 더 조심스러운 공격자가 테스트를 깨뜨리지 않았다면 몇 시간 더 조용히 publish할 수 있었음
  - 공격자는 attribution comment가 포함된 공개 memory-dump script를 재사용했고, 새로운 코드를 작성하지 않아 IOC matching이 더 빨라짐

### 남은 질문
- `bundle-size.yml`의 `Setup Tools` step이 실제로 `actions/cache@v5`를 호출했는지 확인해야 함
- PR #7378에 대한 `pull_request_target` run 중 하나의 post-job log를 읽어 검증해야 하며, 예시 run id는 `25666610798`임
- force-push로 사라지기 전 최초 PR head commit에 무엇이 있었는지 확인해야 하며, GitHub reflog에 남아 있을 수 있음
- 악성 commit이 fork의 git object store에 들어간 방식이 직접 git push였는지, audit-log entry를 남길 GitHub web UI 생성이었는지 확인해야 함
- `voicproducoes`가 실제 계정인지 sock puppet인지 활동 이력과 대조해야 함
- 6개의 중복 `linux-npm-store-*` entry로 보이는 npm cache도 오염됐는지, 실제 사용됐는지 확인해야 함
- 공격에 Nx Cloud가 필요했는지, GitHub Actions cache만으로도 작동했을지 확인해야 함
- TanStack/router fork network 안에서 orphan payload commit을 포함한 다른 fork를 식별할 수 있는지 확인해야 함
- 다른 fork가 해당 commit을 hosting하고 있다면 `github:tanstack/router#79ac49ee...` 접근성이 유지돼 cleanup이 더 어려워짐
- router, query, table, form, virtual 등 다른 TanStack repo가 같은 `bundle-size.yml` 스타일 패턴을 사용하는지 audit이 필요함
- publish window 동안 영향 버전을 실제로 다운로드한 사용자 수를 npm support에서 받아야 함
- 7명 maintainer의 머신이 별도로 손상됐는지 확인해야 함
- 악성 publish에는 maintainer npm token이 사용되지 않았지만, maintainer machine은 self-propagation logic의 2차 target일 수 있음

### 참고 자료
- [TanStack/router#7383](https://github.com/TanStack/router/issues/7383): 추적 이슈
- [GHSA-g7cv-rxg3-hmpx](https://github.com/TanStack/router/security/advisories/GHSA-g7cv-rxg3-hmpx): GitHub Security Advisory
- [The Monsters in Your Build Cache: Github Actions Cache Poisoning](https://adnanthekhan.com/2024/05/06/the-monsters-in-your-build-cache-github-actions-cache-poisoning/): Adnan Khan의 2024년 GitHub Actions cache poisoning 연구
- [Keeping your GitHub Actions and workflows secure: Preventing pwn requests](https://securitylab.github.com/research/github-actions-preventing-pwn-requests/): GitHub Security Lab의 pwn request 방지 자료
- [Harden-Runner detection: tj-actions/changed-files action is compromised](https://www.stepsecurity.io/blog/harden-runner-detection-tj-actions-changed-files-action-is-compromised): StepSecurity의 2025년 3월 관련 탐지 자료
- [Unpublish](https://docs.npmjs.com/policies/unpublish): npm unpublish 정책
- [Provenance](https://docs.npmjs.com/generating-provenance-statements): npm provenance 문서
- [GHSA-g7cv-rxg3-hmpx](https://github.com/TanStack/router/security/advisories/GHSA-g7cv-rxg3-hmpx): 영향 버전 전체 목록 다루는 GitHub Security Advisory

## Comments



### Comment 57268

- Author: neo
- Created: 2026-05-12T09:52:26+09:00
- Points: 1

###### [Hacker News 의견들](https://news.ycombinator.com/item?id=48100706) 
- 토큰을 폐기할 때 조심해야 함. 페이로드가 **dead-man's switch**를 `~/.local/bin/gh-token-monitor.sh`에 설치하고, Linux에서는 systemd 사용자 서비스로, macOS에서는 `LaunchAgent com.user.gh-token-monitor`로 등록하는 것처럼 보임  
  훔친 토큰으로 60초마다 `api.github.com/user`를 폴링하고, 토큰이 폐기되어 HTTP 40x가 나오면 `rm -rf ~/`를 실행함  
  [https://github.com/TanStack/router/issues/7383#issuecomment-...](<https://github.com/TanStack/router/issues/7383#issuecomment-4425225340>)
  - 현실적으로 **악성코드**를 설치했다면 어차피 컴퓨터를 완전히 초기화해야 함
  - 놀라움. **상호확증파괴** 같은 상황임  
    앞으로 5년간 소프트웨어 세계는 정말 거칠어질 것 같고, **에어갭 시스템**이 크게 중요해질 듯함
  - 원래 항상 **백업**을 설정해뒀어야 하지만, 이 사건 때문에 사람들이 백업을 갖추게 된다면 그나마 다행임

- `@mistralai/mistralai` npm 패키지도 이 **웜**의 일부로 손상됨  
  [https://github.com/mistralai/client-ts/issues/217](<https://github.com/mistralai/client-ts/issues/217>)  
  지금은 npm 레지스트리에서 내려간 상태임

- 불행하지만, 이건 **Trusted Publishing**만으로는 CI에서 안전하게 배포하기에 충분하지 않다는 증거로 보임. CI 파이프라인 내부의 공격자나 훔친 저장소 관리자 권한이 있으면 쉽게 배포할 수 있음  
  새 정보는 아니고, Trusted Publishing이 이를 보장하도록 설계된 것도 아니지만, 로컬 배포와 2단계 인증에서 Trusted Publishing으로 옮기면 CI 침해를 통한 이런 공격 경로가 생김. 로컬에서 작업할 때 npm publish를 막아주던 두 번째 요소가 사라지는 셈임  
  현재 전개상 공격자는 CI/CD 파이프라인을 장악했고, npm publish에 두 번째 요소가 없었기 때문에 OIDC 토큰을 훔쳐 배포를 완료한 것처럼 보임. 흥미롭지만 별개로, 배포 작업 자체는 실패했는데 악성 커밋 안의 페이로드가 워크플로의 OIDC 토큰으로 스스로 배포할 수 있었던 듯함  
  원하는 건 장기 토큰 없는 Trusted Publisher 모델을 유지하되, GitHub 바깥의 두 번째 요소가 남아 있는 CI 배포임. 즉 누군가 npm 쪽에서 2단계 인증으로 아티팩트를 실제 공개 상태로 승격해야 하는 **단계적 배포**가 필요함  
  배포가 GitHub 신뢰 모델 안에서만 가능하다면 저장소 관리자 토큰을 털거나 파이프라인에 악성 코드를 넣은 사람은 누구나 배포를 쉽게 완료할 수 있음. GitHub 컨텍스트 밖의 진짜 두 번째 요소가 있으면 저장소를 망치거나 악성 코드를 심을 수는 있어도, 레지스트리용 두 번째 요소 없이는 배포하지 못함
  - 준인기 패키지 하나를 갖고 있는데, 여전히 **로컬 배포와 2단계 인증**을 쓰고 있음. Trusted Publishing은 너무 복잡해 보이고 계속 해킹당하는 것처럼 보여서, 우리가 안전하게 운용하기엔 너무 복잡한 게 아닌가 싶고 다시 설계부터 해야 할지도 모름
  - 여전히 **Trusted Publishing**은 큰 개선이라고 생각하지만, 릴리스를 진짜 공개 상태로 표시할 때 두 번째 요소를 요구하는 아이디어는 좋음. 그러면 이런 CI 웜을 실행하기가 매우 어려워질 것임
  - YubiKey 같은 걸로 **터치 서명**을 하고 싶음. 클라우드가 대신 자격 증명을 관리하도록 믿는다는 발상 자체가 실수처럼 보임
  - astral 블로그에서 최근 Trusted Publishing을 쓰면서도 **릴리스 게이트**를 어떻게 두는지, 즉 릴리스 워크플로에 수동 승인을 넣는 방식을 보여줬음. 안타깝게도 NPM/PyPI/Rubygems의 Trusted Publishing 문서는 이 가능성을 언급조차 하지 않고, 기본값으로 제공하지도 않음
  - 이런 종류의 **공급망 공격**에 Trusted Publishing이 어떤 차이를 만든다고 사람들이 말하는 이유가 항상 이해되지 않았음

- 사후 분석: [https://tanstack.com/blog/npm-supply-chain-compromise-postmo...](<https://tanstack.com/blog/npm-supply-chain-compromise-postmortem>)
  - TanStack의 사후 분석은 고맙지만, npm 생태계 전체 관점의 **보안 이슈**는 아직 진행 중인 우려가 맞지 않나 싶음  
    TanStack 패키지를 가져오거나 포함했을 수 있는 하위 패키지들을 안전하다고 봐도 된다는 증거가 있는지 궁금함

- **postinstall 스크립트**는 치명적임. 모두 pnpm을 써야 함  
  FORK에 푸시된 “고아” 커밋이 npm 클라이언트에서 이런 일을 유발할 수 있다는 게 말이 안 됨. GitHub에도 책임이 크다고 봄. 악의적인 포크의 커밋이 GitHub의 공유 객체 저장소를 통해 정상 저장소와 구분되지 않는 URI로 접근 가능하다는 건 완전히 미친 구조임
  - 업데이트된 의존성으로 앱을 실행하면 어차피 그 코드가 실행됨. root냐 non-root냐도 중요하지 않고, 중요한 것들은 애플리케이션을 실행하는 **사용자 권한**으로 접근 가능함
  - 이게 어떻게 GitHub의 **P0 장애**가 아닌지 모르겠음. 설명할 수 있는 사람이 있나?  
    처음 읽었을 때는 “fork”라는 말을 잘못 써서 사실 공식 저장소의 브랜치를 뜻하는 줄 알았음. 그게 진짜일 리 없다고 생각했는데, 세상에

- [https://tanstack.com/blog/npm-supply-chain-compromise-postmo...](<https://tanstack.com/blog/npm-supply-chain-compromise-postmortem>)  
  TanStack에서 이 사건에 대한 **사후 분석**을 방금 공개했음

- npm 환경을 안전하게 설정하라는 알림임  
  [https://gajus.com/blog/3-pnpm-settings-to-protect-yourself-f...](<https://gajus.com/blog/3-pnpm-settings-to-protect-yourself-from-supply-chain-attacks>)  
  설정 몇 개만으로 큰 문제를 줄일 수 있음
  - npm v11 이상에서는 `allow-git=none`도 있음: [https://github.blog/changelog/2026-02-18-npm-bulk-trusted-pu...](<https://github.blog/changelog/2026-02-18-npm-bulk-trusted-publishing-config-and-script-security-now-generally-available/>)
  - 이 글은 npm의 **최소 릴리스 나이**에 대해 틀린 것 아닌가 싶음. 1) 설정 이름은 `min-release-age`임. 2) 무슨 이유인지 분 단위가 아니라 일 단위로 만들었음: [https://docs.npmjs.com/cli/v11/using-npm/config#min-release-...](<https://docs.npmjs.com/cli/v11/using-npm/config#min-release-age>)  
    의존성 관리자 공간이 완전히 불필요하게 파편화됐다고 봄
  - 최소 나이를 7일로 설정하면 npm **공급망 취약점**을 “절대” 겪지 않는다는 주장은 과함
  - 모든 의존성을 반드시 **고정**해야 함  
    패키지 버전 의존성이 `^1.0.0`처럼 되어 있거나 심지어 `"*"`라면 더 읽지 말고 즉시 안전한 버전으로 고정해야 함

- Claude로 급하게 만들어서 확산을 줄이는 데 도움을 주려 했음. 당연히 직접 검증해야 하지만, 언급된 **손상 패키지**가 머신에 있는지 스캔해줌: [https://github.com/PaulSinghDev/tanstack-shai-hulud-fix](<https://github.com/PaulSinghDev/tanstack-shai-hulud-fix>)

- 이제는 모두가 각 프로젝트를 **개별 VM**에서 실행해야 하는 단계에 온 것 같음  
  최근의 로컬 권한 상승 취약점을 보면 Docker만으로는 절대 충분하지 않음. 애초에 컨테이너는 주된 보안 경계로 설계된 것도 아님
  - Devcontainers가 이런 “격리된 개발 환경” 개념의 가장 잘 알려진 형태지만, 완전한 VM은 아니고 이번 일에서 완전히 보호해주지도 못함. GitHub 자격 증명이 컨테이너 안으로 자동으로 들어오기 때문임  
    컨테이너 안에서 접근해야 하는 다른 클라우드 서비스가 있다면, 이 자격 증명 탈취기가 그것도 가져갈 것임. 그래도 **피해 반경**은 줄여주니 최소한 개선은 됨
  - QubesOS가 올바른 방향을 잡았음. 루트에 여러 VM을 두고 **겹겹의 보안 계층**을 원하게 됨
  - 컨테이너를 꼭 쓰겠다면 컨테이너마다 VM을 하나씩 두는 방법도 있음. 임의의 Kubernetes 서비스가 아니라 전부 VM에서 돌린 덕분에 최근 몇 주는 꽤 편안했음
  - 다행히 C와 C++처럼 더 안전한 언어 생태계를 쓰는 프로젝트는 이런 문제에서 벗어나 있음 :-)

- 와, 또 다른 거대 패키지임. Axios와 LiteLLM이 손상된 뒤 올렸던 공익 알림을 다시 올림. **수명주기 스크립트** 관련 내용도 적용됨  
  npm/bun/pnpm/uv는 이제 모두 패키지의 최소 릴리스 나이 설정을 지원함. `~/.npmrc`에 `ignore-scripts=true`도 넣어뒀고, 분석상 이것만으로도 취약점을 완화할 수 있었음. bun과 pnpm은 기본적으로 수명주기 스크립트를 실행하지 않음  
  전역 설정으로 최소 릴리스 나이를 7일로 설정하는 방법은 다음과 같음  
  `~/.config/uv/uv.toml`  
  `exclude-newer = "7 days"`  
  `~/.npmrc`  
  `min-release-age=7 # days`  
  `ignore-scripts=true`  
  `~/Library/Preferences/pnpm/rc`  
  `minimum-release-age=10080 # minutes`  
  `~/.bunfig.toml`  
  `[install]`  
  `minimumReleaseAge = 604800 # seconds`  
  전역 설정을 덮어써야 한다면 CLI 플래그를 쓰면 됨  
  `npm install --min-release-age 0`  
  `pnpm add --minimum-release-age 0`  
  `uv add --exclude-newer "0 days"`  
  `bun add --minimum-release-age 0`  
  한 가지 더 덧붙이면, 의존성 대기 시간을 대규모로 도입하면 취약점 발견이 늦어지거나, 의존성 대기 시간이 일종의 무임승차라는 우려가 있는 듯한데 동의하지 않음. 의존성 대기 시간으로 교환하는 것은 **시간 선호**이고, 항상 나보다 시간 선호가 높은 사람들은 존재함  
  0: [https://news.ycombinator.com/item?id=47582220](<https://news.ycombinator.com/item?id=47582220>)  
  1: [https://news.ycombinator.com/item?id=47513932](<https://news.ycombinator.com/item?id=47513932>)
  - 동의함. 지난 두 번의 파도가 오기 전인 3월에 이 설정들을 켜둬서 다행임. 추가로 저장소에 **lockfile**을 커밋해두고 새 의존성을 추가할 때 주의해야 함  
    예기치 않은 변경을 피하려면 `pnpm install --frozen-lockfile`을 쓰면 됨. `min-release-age`를 설정하지 않았다면 간접 의존성을 통해서도 영향을 받은 패키지를 끌어올 수 있다는 점을 기억해야 함. 가능하면 패키지 관리자 버전도 고정하는 게 좋음
