본문으로 건너뛰기
ff1451 logoff1451

staged publishing과 minimumReleaseAge — npm·pnpm의 공급망 방어

계정 탈취부터 자기복제 worm까지, 반복된 npm 공급망 사건과 npm·pnpm의 대응 방식을 정리한다.

7 min read

npm 공급망 공격은 더 이상 “이상한 이름의 패키지를 잘못 설치하는 경우”에만 발생하지 않는다. 2025년 이후 반복된 사건들의 공통점은 오히려 반대다. 개발자들이 이미 쓰고 있던 패키지, 이미 신뢰하던 메인테이너 계정, 이미 자동화해둔 CI 파이프라인이 진입점이 됐다.

이 글에서는 그 배경이 된 사건들을 정리하고, npm과 pnpm이 각각 어떤 방어선을 추가했는지 살펴본다.

1. 반복된 사건들

chalk, debug (2025.09.08)

2025년 9월 8일, 공격자는 npm 지원팀을 사칭한 피싱 메일로 개발자 Qix의 계정을 탈취했다. npmjs.help라는 위장 도메인을 통해 사용자 이름, 비밀번호, 실시간 TOTP 코드까지 수집한 뒤 계정을 완전히 접수했다.

탈취된 계정으로 18개 패키지에 악성 버전이 배포됐는데, chalk, debug를 포함한 이 패키지들의 합산 주간 다운로드 수는 약 26억 회였다. 페이로드는 브라우저 환경에서 암호화폐 거래 주소를 공격자 지갑으로 바꿔치기하는 스크립트였고, 악성 버전은 약 2시간 만에 제거됐다.

이 사건의 문제는 패키지 자체가 악성이었던 것이 아니라, 정상 패키지의 새 버전이 잠깐 악성 버전으로 교체됐다는 점이다. 자동화된 CI나 npm install은 그 2시간 안에 충분히 악성 버전을 받아갈 수 있었다.

Shai-Hulud (2025.09.15)

같은 달 중순, @ctrl/tinycolor가 공급망 공격의 시작점이 됐다. 공격자는 메인테이너 계정을 탈취한 뒤 악성 코드를 담은 새 버전을 배포했는데, 이 코드는 설치 시점에 AWS, GCP, Azure 크리덴셜과 GitHub 토큰, npm 퍼블리시 키를 수집했다. 그리고 탈취한 토큰을 이용해 다른 패키지에 같은 악성 버전을 배포하는 방식으로 스스로 확산했다.

결국 500개 이상의 npm 패키지가 감염됐다. chalk, debug 사건이 “한 계정이 털렸다”는 문제였다면, Shai-Hulud는 “감염된 패키지가 다음 패키지를 감염시키는 구조”였다는 점에서 성격이 달랐다.

axios (2026.03.31)

2026년 3월 31일에는 주간 1억 다운로드 규모의 axios가 공격받았다. 공격자는 소셜 엔지니어링으로 axios 메인테이너의 계정 자격 증명을 탈취한 뒤 axios@1.14.1axios@0.30.4를 배포했고, 두 버전 모두 plain-crypto-js라는 악성 의존성을 주입해 Windows, macOS, Linux를 가리지 않고 원격 접근 트로이목마(RAT)를 설치했다. 악성 버전은 약 3시간 만에 제거됐고, Google은 이 공격을 북한 연계 위협 그룹 UNC1069의 소행으로 지목했다.

세 사건의 구조는 같다. 정상 패키지의 새 버전이 짧은 시간 동안 악성 버전으로 교체됐고, 악성 버전은 빠르게 탐지되어 제거됐지만 그 짧은 시간 안에 자동화된 설치는 이미 일어났다.

2. npm의 대응

staged publishing (2026.05)

GitHub는 2026년 5월 22일 npm의 staged publishing 일반 제공(GA)을 발표했다. 기존에는 CI에서 npm publish를 실행하면 즉시 레지스트리에 공개됐는데, staged publishing을 도입하면 중간에 검토 단계가 생긴다.

기존:  CI → npm publish → 즉시 공개

staged: CI → npm stage publish → staging area → 메인테이너 검토 + 2FA → 공개

npm stage publish는 패키지를 staging area에 올리는 것까지만 한다. 실제 공개는 메인테이너가 tarball을 확인한 뒤 npm stage approve <stage-id>를 실행해야 일어나며, 이 승인 단계에서 2FA가 요구된다.

2FA를 퍼블리시 단계가 아닌 승인 단계에 요구하는 것은 의도적인 설계다. CI/CD 자동화 흐름은 사람이 직접 개입해 2FA를 제공하는 단계를 포함하기 어렵다. staged publishing은 자동화된 퍼블리시와 사람의 승인을 분리해서, CI가 내보낸 결과물을 메인테이너가 직접 확인하는 구조를 만든다.

CI 토큰이나 퍼블리시 권한 일부만 탈취된 상황에서는, 공격자가 staging area까지는 올려도 2FA 승인 없이는 실제 공개가 막힌다. 다만 메인테이너의 디바이스나 2FA 수단까지 장악된 경우라면 이 방어선도 우회될 수 있다. npm 문서도 staged publishing이 계정이나 조직을 자동으로 안전하게 만들어주는 것은 아니라고 명시한다.

Trusted Publishing과 staged publishing

Trusted Publishing과 staged publishing은 모두 배포 단계의 보안을 다루지만, 해결하려는 문제가 다르다. Trusted Publishing은 OIDC를 이용해 장기 npm 토큰 없이 CI/CD에서 패키지를 배포하고, “이 패키지가 어떤 저장소와 workflow에서 배포됐는지”를 증명하는 데 초점이 있다. 반면 staged publishing은 배포 결과물이 바로 공개되지 않도록 막고, 사람이 tarball을 확인한 뒤 2FA로 승인하는 절차를 추가한다.

Trusted Publishing이 “누가, 어떤 CI에서 배포했는가”에 대한 신뢰를 제공한다면, staged publishing은 “그 결과물을 공개하기 전에 사람이 확인했는가”에 대한 방어선이다. TanStack 사건처럼 release pipeline 자체가 공격 표면이 될 수 있는 상황에서는 두 개념을 구분해서 이해할 필요가 있다.

min-release-age

npm CLI 11.10.0에는 min-release-age 설정이 추가됐다. .npmrc에 설정하면 공개된 지 지정한 일수 이상의 버전만 설치 후보로 사용한다.

# .npmrc
min-release-age=1

단위는 일(days)이다. 값이 1이면 공개된 지 하루가 넘지 않은 버전은 설치 후보에서 제외된다. axios 악성 버전이 3시간 만에 제거됐다는 점을 감안하면, min-release-age=1만으로도 그 짧은 위험 구간 대부분을 피할 수 있었다.

3. pnpm의 대응

pnpm은 npm보다 앞서 설치 단계 방어를 추가했다. 10.16 릴리즈에서 minimumReleaseAge 설정이 도입됐고, 릴리즈 노트에는 “최근 인기 패키지가 공격받는 사건들이 이어지고 있고, 악성 버전은 보통 빠르게 탐지되어 삭제되니, 새로 공개된 버전의 설치를 지연시키는 설정을 추가한다”고 설명했다.

설정은 pnpm-workspace.yaml에 넣는다.

# pnpm-workspace.yaml
minimumReleaseAge: 1440

단위는 분(minutes)이고, 1440은 24시간이다. pnpm 11부터는 이 값이 기본값이 됐다. 설정 없이 pnpm 11을 쓰면 공개된 지 하루가 안 된 버전은 설치 후보에서 자동으로 제외된다.

특정 패키지를 예외로 두고 싶으면 minimumReleaseAgeExclude를 쓴다.

minimumReleaseAge: 1440
minimumReleaseAgeExclude:
  - some-internal-package

npm의 min-release-age와 단위가 다르다는 점에 주의가 필요하다. npm은 일(days), pnpm은 분(minutes) 기준이다.

lockfile이 있으면 언제 작동할까

minimumReleaseAge는 기존 lockfile에 이미 기록된 버전을 다시 설치할 때가 아니라, 패키지 매니저가 새 버전을 resolve해야 하는 순간에 개입한다. pnpm-lock.yaml에 이미 특정 버전이 고정되어 있고 CI에서 pnpm install --frozen-lockfile을 실행한다면, 새 버전을 고르는 과정이 없으므로 release age gate가 할 일도 없다.

반대로 Renovate나 Dependabot이 업데이트 PR을 만들거나, 개발자가 pnpm add axios@latest를 실행하거나, lockfile을 새로 생성하는 상황에서는 패키지 매니저가 설치 후보 버전을 다시 계산하는데, 이때 공개된 지 충분한 시간이 지나지 않은 버전은 후보에서 제외된다.

따라서 이 기능은 lockfile을 대체하지 않는다. lockfile은 “이미 선택한 버전을 재현”하는 장치이고, release age gate는 “새 버전을 선택하는 순간 너무 이른 버전을 피하는” 장치다.

긴급 보안 패치와의 충돌

release age gate에는 딜레마도 있다. 심각한 CVE가 공개되고 패치 버전이 막 배포된 상황이라면, minimumReleaseAge: 1440은 그 패치 버전도 하루 동안 설치 후보에서 제외한다. 이때는 minimumReleaseAgeExclude로 해당 패키지를 우회시킬 수 있다.

하지만 예외를 남발하면 방어선의 의미가 약해진다. 공격자가 “보안 패치처럼 보이는 긴급 릴리스”를 가장해 악성 버전을 배포할 수도 있기 때문이다. 결국 release age gate는 자동으로 모든 결정을 대신하는 기능이 아니라, 기본적으로는 기다리고 정말 긴급한 경우에만 사람이 근거를 확인한 뒤 예외를 적용하게 만드는 운영 정책에 가깝다.

다만 이 기능 자체의 한계도 있다. 오래전에 배포된 악성 패키지, 이름을 속인 타이포스쿼팅 패키지, 메인테이너가 오랫동안 악성 코드를 숨겨둔 경우에는 효과가 제한적이다. “방금 배포된 악성 버전”에 대한 시간 지연 방어선이지, 패키지의 안전성을 검증하는 기능은 아니다.

4. 두 방향의 차이

staged publishing과 minimumReleaseAge는 같은 위협에 대응하지만 방어 위치가 다르다.

npm staged publishing minimumReleaseAge / min-release-age
방어 위치 배포 단계 설치 단계
대상 패키지 메인테이너·라이브러리 팀 패키지 사용자·프로젝트
핵심 자동화된 결과물을 사람이 한 번 더 검토 새 버전을 일정 시간 동안 설치 안 함

staged publishing은 배포하는 쪽이 도입해야 효과가 있다. 라이브러리를 직접 만들지 않는 개발자라면 직접 설정할 일이 없다. minimumReleaseAge와 min-release-age는 소비자 쪽에서 설정하는 것이라, 어떤 패키지를 쓰든 적용된다.

TanStack 사건(2026.05)은 staged publishing만으로 모든 공급망 공격을 막을 수 없다는 점을 보여주는 사례다. 이 사건에서는 계정이 탈취된 것이 아니라 GitHub Actions 파이프라인 자체가 침해됐고, 공격자가 CI/CD 흐름 안에서 악성 버전을 퍼블리시했다. 악성 버전은 외부 연구자에 의해 약 20~26분 만에 탐지됐는데, release pipeline 자체가 공격 표면이 될 수 있다는 점이 드러난 사례였다.

반면 minimumReleaseAge는 공격자가 계정을 탈취했는지, CI를 침해했는지와 무관하게 “새로 배포된 버전”이라는 조건에 대해 작동한다. TanStack 악성 버전이 약 20~26분 만에 탐지됐다는 점을 감안하면, 기본값 1440분이 설정돼 있었다면 설치가 막혔을 것이다.

두 방어선이 충돌하지 않으니, 라이브러리를 배포하는 팀이라면 staged publishing을 도입하고, 프로젝트에서 의존성을 설치하는 쪽은 minimumReleaseAge나 min-release-age를 설정하는 방식으로 함께 쓸 수 있다.

5. 생태계 전체의 수렴

흥미로운 점은 release age gate가 npm·pnpm만의 선택이 아니라는 것이다. Yarn 4.10은 npmMinimalAgeGate라는 이름으로 같은 기능을 추가했고, Bun도 install.minimumReleaseAge를 통해 설정할 수 있다. 더 앞선 계층에서는 Renovate가 이미 수년 전부터 minimumReleaseAge를 제공해왔는데, 새 릴리스가 지정한 시간만큼 지나기 전까지 PR 생성이나 automerge를 지연시킨다.

도구 설정명 단위
npm min-release-age days
pnpm minimumReleaseAge minutes
Yarn npmMinimalAgeGate minutes
Bun install.minimumReleaseAge seconds
Renovate minimumReleaseAge duration string (3 days 등)

설정명도 단위도 제각각이지만, “최신 버전을 즉시 받아들이는 기존 관성에서 벗어나 일정한 관찰 시간을 두자”는 방향은 같다. 패키지 매니저와 업데이트 자동화 도구가 동시에 같은 방향으로 수렴하고 있다는 점은, 이 접근이 특정 도구의 실험이 아니라 생태계 전반의 보안 관행으로 자리잡아가고 있다는 신호로 읽힌다.

참고 자료