이번 TanStack npm 공격은 비밀번호 유출 사건이 아닙니다. 정상 GitHub Actions 릴리스 파이프라인이 공격자의 배포 도구가 됐고, 악성 패키지는 npm trusted publishing 경로를 통과했습니다. 그래서 지금 봐야 할 것은 뉴스가 아니라 내 저장소의 PR workflow, cache, OIDC 권한, 설치 정책입니다.
2026년 5월 11일, TanStack의 @tanstack/* npm 패키지 일부가 악성 버전으로 배포됐습니다. TanStack 공식 포스트모템에 따르면 42개 패키지에서 84개 악성 버전이 공개됐고, 문제의 배포는 몇 분 안에 일어났습니다.
무서운 부분은 규모보다 방식입니다. 누군가 maintainer의 npm 비밀번호를 훔친 것도 아니고, 오래 살아 있는 npm publish token을 빼낸 것도 아니었습니다. 공격자는 릴리스 workflow가 정상적으로 발급받는 짧은 신뢰 구간을 이용했습니다.
이전의 npm 공급망 공격 사례: Chalk 패키지 해킹과 보안 교훈이 피싱과 계정 탈취의 위험을 보여줬다면, 이번 사건은 한 단계 더 안쪽으로 들어갑니다. 이번에는 사람이 아니라 자동화 파이프라인의 신뢰 경계가 문제였습니다. 공급망 보안의 큰 그림은 소프트웨어 공급망 보안의 중요성과 개발자 도구와도 이어집니다.

핵심은 “trusted publishing이 깨졌다”가 아니다
npm trusted publishing은 대략 이런 흐름으로 동작합니다.
GitHub Actions가 “이 workflow는 이 repository의 이 branch에서 실행 중”이라는 OIDC 기반 증명을 만들고, npm은 그 증명이 사전에 허용한 조건과 맞는지 확인합니다. 조건이 맞으면 npm은 오래 저장되는 publish token 대신 짧게 살아 있는 임시 권한을 줍니다. 이 구조는 npm trusted publishing 문서가 설명하듯 저장된 토큰 유출 위험을 줄이기 위한 장치입니다.
그런데 이번 공격은 trusted publishing 밖에서 가짜 요청을 만든 것이 아니었습니다. 실제 GitHub Actions 안에서 publish 가능한 순간을 만들었습니다.
즉, npm 입장에서는 요청이 정상 workflow에서 온 것처럼 보일 수밖에 없었습니다. 문제는 그 workflow가 실행되기 전, 이미 CI cache 안에 공격자의 코드가 숨어 있었다는 점입니다.
실제 공격 흐름: fork PR 하나가 release cache를 오염시켰다
핵심은 pull_request_target와 공유 cache였습니다.
pull_request_target은 일반 pull_request와 다릅니다. fork에서 온 PR이라도 base repository의 컨텍스트와 권한으로 workflow를 실행할 수 있습니다. GitHub Security Lab의 pwn request 글도 이 이벤트에서 untrusted code를 checkout하고 실행하는 패턴을 대표적인 위험으로 설명합니다.
이번 공격 흐름을 단순화하면 이렇습니다.
- 공격자가 TanStack 저장소를 fork합니다.
- PR을 만들고,
pull_request_targetworkflow가 실행되게 합니다. - 이 workflow가 fork 쪽 코드를 실행하면서 pnpm store cache에 악성 파일을 심습니다.
- 공격자는 PR을 깨끗하게 보이도록 바꾼 뒤 닫습니다.
- 몇 시간 뒤 정상 PR이 main에 merge됩니다.
- release workflow가 같은 cache key를 복원합니다.
- cache 안의 악성 코드가 실행되고, GitHub Actions runner 안의 OIDC 관련 값을 훔쳐 npm에 악성 버전을 publish합니다.
여기서 사람들이 자주 놓치는 질문이 있습니다.
“PR이 merge되지 않았는데 왜 릴리스가 오염됐지?”
답은 cache입니다. PR의 코드는 main에 들어가지 않았지만, CI cache에는 흔적을 남길 수 있었습니다. release workflow는 코드는 main에서 가져왔지만, dependency cache는 이미 오염된 것을 재사용했습니다. 코드 리뷰 화면만 보고는 놓치기 쉬운 지점입니다.
이번 사건의 핵심은 “merge되지 않은 PR도 cache를 통해 release를 오염시킬 수 있다”는 점입니다.

악성 패키지는 어디에 숨어 있었나
포스트모템에서 확인된 악성 패키지의 특징은 꽤 구체적입니다.
일부 패키지에는 optionalDependencies로 다음과 같은 형태의 의존성이 들어갔습니다.
"@tanstack/setup": "github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c"
그리고 패키지 루트에는 router_init.js 같은 큰 JavaScript 파일이 포함됐습니다. 이 코드는 설치 과정에서 실행되어 개발자의 로컬 환경이나 CI 환경에서 secret을 찾으려 했습니다.
이미 설치했는지 빠르게 훑어보려면 프로젝트 루트에서 이런 식으로 먼저 찾을 수 있습니다.
rg -n "router_init\\.js|@tanstack/setup|github:tanstack/router#79ac49" package.json package-lock.json pnpm-lock.yaml yarn.lock node_modules
확인 대상은 단순히 package.json만이 아닙니다.
package-lock.json,pnpm-lock.yaml,yarn.lock에 문제가 된 버전이 들어갔는지node_modules나 package tarball 안에router_init.js가 있는지optionalDependencies에 npm registry가 아닌 GitHub URL, tarball URL이 갑자기 들어왔는지- CI 로그에서 2026년 5월 11일 전후로 예기치 않은 publish, install script, cache restore가 있었는지
특히 optionalDependencies는 “있으면 쓰고 없으면 넘어간다”는 인상 때문에 방심하기 쉽습니다. 하지만 설치 과정에서 외부 Git repository를 끌어오게 만들 수 있다면, 공격자 입장에서는 매우 편한 우회로가 됩니다.
감염이 의심될 때 먼저 할 일
이미 영향을 받은 패키지를 설치했을 가능성이 있다면, 바로 npm install을 다시 돌리거나 대충 node_modules만 지우고 끝내면 안 됩니다.
우선 감염 의심 장비를 네트워크에서 분리하고, CI runner라면 job을 중단한 뒤 해당 runner를 폐기 가능한 상태로 봐야 합니다. 그 다음 lockfile과 설치된 패키지, CI cache, publish 기록을 확인합니다.
실무적으로는 아래 순서가 안전합니다.
| 상황 | 먼저 볼 것 | 이유 |
|---|---|---|
| 로컬 개발 PC에서 설치함 | lockfile, node_modules, editor 설정, shell profile |
install script가 로컬 secret과 개발 도구 설정을 건드렸을 수 있음 |
| CI에서 설치함 | workflow log, cache key, runner image, npm publish 기록 | cache 오염과 자동 publish가 핵심 경로였기 때문 |
| publish 권한이 있는 maintainer 장비 | npm token, GitHub token, SSH key, cloud credential | 2차 전파에 쓰일 수 있는 자산이 많음 |
| monorepo release workflow | release job의 id-token: write, cache restore, dependency install 단계 |
trusted publishing 권한이 발급되는 순간과 install 단계가 가까우면 위험 |
그 다음에는 secret을 회전해야 합니다. npm token만 바꾸는 수준으로 끝내면 부족합니다. GitHub token, npm token, SSH key, cloud credential, Kubernetes/Vault 관련 token까지 “설치 시점에 읽을 수 있었던 것”을 기준으로 봐야 합니다.
다만 순서가 중요합니다. 감염 의심 장비에서 무작정 정리 스크립트를 실행하기보다, 먼저 격리하고 로그와 IOC를 확보한 뒤 깨끗한 환경에서 복구하는 편이 낫습니다.
내 GitHub Actions에서 바로 확인할 위험 신호
이번 사건을 보고 “우리는 TanStack 안 쓰니까 괜찮다”로 끝내면 안 됩니다. 같은 패턴은 다른 npm 프로젝트, Python SDK, 내부 사설 registry에서도 반복될 수 있습니다.
특히 다음 조합은 바로 점검하는 편이 좋습니다.
on: pull_request_target에서 fork PR 코드를 checkout한다.pull_request_targetworkflow에서pnpm install,npm install, build, test를 실행한다.- PR 검증 workflow와 release workflow가 같은
actions/cachekey를 쓴다. - release job이
id-token: write를 넓게 가지고 있다. - publish 직전 job에서 dependency install script를 그대로 실행한다.
- cache를 “성능 최적화” 정도로만 보고 신뢰 경계로 관리하지 않는다.
pull_request_target을 무조건 금지하라는 뜻은 아닙니다. issue label 달기, comment 남기기, contributor 안내처럼 PR metadata만 다루는 작업에는 쓸 수 있습니다. 위험한 것은 그 이벤트에서 fork의 코드를 실행하는 것입니다.
재발 방지를 위한 현실적인 설정
완벽하게 막는 방법은 없습니다. 대신 공격자가 성공하려면 여러 방어선을 동시에 넘어야 하게 만들어야 합니다.
먼저 PR workflow와 release workflow를 분리합니다.
fork PR에서 실행되는 workflow는 읽기 전용으로 두고, untrusted code를 실행해야 한다면 base repository 권한이 없는 pull_request 쪽에서 처리합니다. release workflow는 main push나 protected environment 승인 뒤에만 동작하게 하고, PR 검증 cache와 release cache를 섞지 않는 편이 좋습니다.
다음으로 dependency install 정책을 바꿉니다. pnpm 공급망 보안 문서 기준으로 실전에서 도움이 되는 축은 세 가지입니다.
minimumReleaseAge: 1440
blockExoticSubdeps: true
minimumReleaseAge: 1440은 공개된 지 24시간이 지나지 않은 새 패키지 버전을 바로 설치하지 않게 합니다. 악성 패키지는 공개 직후 몇 시간 안에 탐지되고 내려가는 경우가 많기 때문에, 이 설정 하나만으로 “방금 올라온 독”을 피할 확률이 올라갑니다.
blockExoticSubdeps: true는 registry가 아닌 Git URL, tarball URL 같은 이국적인 하위 의존성을 막는 방향의 방어입니다. 이번처럼 optionalDependencies를 통해 GitHub URL을 끌어오는 패턴을 줄이는 데 도움이 됩니다.
그리고 install script는 기본 허용이 아니라 승인제로 보는 편이 안전합니다.
pnpm approve-builds
이 명령으로 실제 build script 실행을 허용할 패키지를 고르고, 필요한 패키지만 onlyBuiltDependencies 쪽으로 관리하는 방식이 더 낫습니다. npm 생태계의 많은 공격은 postinstall 같은 lifecycle script에서 시작됩니다. 정말 build script가 필요한 패키지만 명시적으로 허용하고, 나머지는 막는 쪽이 관리 비용 대비 효과가 큽니다.
자주 묻는 질문
trusted publishing을 꺼야 하나?
아닙니다. 이 사건의 결론은 “trusted publishing이 위험하다”가 아닙니다.
오래 저장되는 npm publish token을 직접 관리하는 방식보다 trusted publishing이 유리한 부분은 여전히 있습니다. 다만 이 기능은 “어떤 workflow가 publish 요청을 했는가”를 확인하는 데 강하고, workflow 내부에서 실행되는 모든 code path가 안전하다는 보증서는 아닙니다.
실무 기준으로는 trusted publishing은 유지하되, publish job의 id-token: write를 필요한 job에만 주고, release job에서 PR cache를 재사용하지 않게 분리하는 편이 낫습니다.
pnpm만 쓰면 안전한가?
아닙니다. pnpm은 중요한 방어선을 제공하지만 package manager 하나로 끝나는 문제는 아닙니다.
minimumReleaseAge, blockExoticSubdeps, install script 승인 정책은 dependency 설치 단계에서 큰 차이를 만듭니다. 그러나 GitHub Actions workflow가 이미 오염된 cache를 복원하고, publish 권한이 있는 job에서 untrusted code가 실행된다면 pnpm만으로는 충분하지 않습니다.
마무리: 이번 사건은 “설치”보다 “배포 자동화”를 다시 보라는 경고다
TanStack npm 공급망 공격은 npm 생태계가 또 털렸다는 단순한 뉴스가 아닙니다. trusted publishing, OIDC, provenance처럼 비교적 현대적인 방어 장치를 쓰고 있어도, CI/CD 내부의 신뢰 경계가 무너지면 악성 패키지가 정상 배포처럼 보일 수 있다는 사례입니다.
오늘 바로 확인할 것은 세 가지입니다.
pull_request_target에서 fork 코드를 실행하고 있지 않은가?- PR workflow와 release workflow가 같은 cache를 공유하고 있지 않은가?
- dependency 설치 단계에서 새 버전, install script, Git/tarball subdependency를 너무 쉽게 허용하고 있지 않은가?
이 세 가지를 고치는 것만으로도 다음 공급망 공격에서 “바로 감염되는 프로젝트”와 “한 번 더 버티는 프로젝트”가 갈립니다.