npm dist-tag 권한, 이제 OIDC로 — 트러스티드 퍼블리싱으로 토큰 없이 latest 옮기는 GitHub Actions 설정
npm 트러스티드 퍼블리싱(trusted publishing)으로 dist-tag까지 관리할 수 있게 됐다. GitHub가 9월 30일 체인지로그에서 밝힌 내용으로, 각 트러스티드 퍼블리싱 설정에 Allow npm dist-tag라는 선택 권한이 생겼다. 이걸 켠 워크플로는 장기 액세스 토큰 없이 짧게 살다 사라지는 OIDC 자격증명만으로 latest를 새 버전으로 올리거나 next·beta 포인터를 옮길 수 있다. 기본값은 꺼짐이다. 새 설정이든 기존 설정이든 마찬가지라, 아무것도 안 건드리면 지금 권한 그대로다.
이게 왜 소식거리냐면, 그동안 OIDC로 배포를 전부 옮긴 팀도 dist-tag 때문에 토큰 하나를 버리지 못했기 때문이다. 배포 뒤에 태그를 옮기거나 문제가 생겨 latest를 이전 버전으로 되돌리려면 결국 그래뉼러 액세스 토큰이 필요했다. 이번 변경으로 그 마지막 토큰을 치울 길이 열렸다. 스테이징 전용 토큰 자체는 앞서 다른 글에서 다뤘으니, 이 글은 OIDC 쪽 dist-tag 권한과 그걸 안전하게 나누는 방법에 집중한다.
트러스티드 퍼블리싱과 dist-tag, 먼저 개념부터
트러스티드 퍼블리싱은 CI가 npm 토큰을 비밀값으로 들고 있지 않고, 실행될 때마다 CI 제공자에게서 OIDC 신원 토큰을 받아 npm에 "나는 이 저장소의 이 워크플로다"라고 증명하는 방식이다. OIDC(OpenID Connect)는 그 신원 증명에 쓰는 표준 규격이다. npm은 패키지 설정에 미리 등록해 둔 저장소·워크플로 파일 이름과 토큰 내용이 맞을 때만 작업을 허락한다. 토큰은 그 실행 동안만 유효하니 저장소 비밀값에서 털릴 장기 토큰이 아예 없다. npm 문서는 이 기능이 OpenSSF의 트러스티드 퍼블리셔 표준을 구현한 것이라고 설명한다.
dist-tag는 패키지 버전에 붙이는 이름표다. npm install axios처럼 버전 없이 설치하면 npm은 latest 태그가 가리키는 버전을 받는다. npm help dist-tag 설명에 따르면 npm publish도 --tag를 따로 주지 않으면 방금 올린 버전에 latest를 붙인다. latest 말고는 npm이 특별 취급하는 태그가 없고, next·beta·canary는 프로젝트마다 관례로 쓰는 이름일 뿐이다.
실제 패키지를 보면 감이 온다. 10월 5일 macOS(npm 10.9.2, Node.js 22.16.0)에서 읽기 전용 명령으로 몇 개를 조회했다.
$ npm view axios dist-tags
{ next: '1.7.0-beta.2', 'old-version': '0.30.0', latest: '1.20.0', v0x: '0.34.0' }
$ npm view typescript dist-tags --json
{ "dev": "3.9.4", "tag-for-publishing-older-releases": "4.1.6",
"insiders": "4.6.2-insiders.20220225", "beta": "6.0.0-beta",
"rc": "7.0.1-rc", "latest": "7.0.2", "next": "7.1.0-dev.20261004.1" }
TypeScript는 next를 매일 밤 빌드에 쓰고, React는 canary·experimental을 따로 운영한다. Next.js는 next-14, backport처럼 구버전 줄기마다 태그를 둔다. 태그 하나가 수많은 사용자의 설치 대상을 정한다는 뜻이다. 레지스트리 API로도 같은 값을 볼 수 있다. curl -s https://registry.npmjs.org/-/package/axios/dist-tags를 호출하면 위와 같은 JSON이 그대로 돌아온다.
latest 포인터를 따로 지켜야 하는 이유
올해 npm에서 실제로 일어난 사고 두 건이 이 권한 분리가 왜 필요한지 보여 준다.
첫째는 3월 31일 axios 사고다. axios 저장소에 올라온 사후 분석에 따르면 공격자는 리드 메인테이너 PC를 장악해 npm 계정 자격증명을 얻었고, 악성 버전 1.14.1과 0.30.4를 올렸다. 두 버전은 원격 접속 트로이목마를 까는 의존성(plain-crypto-js)을 몰래 끼워 넣었고, 약 3시간 동안 살아 있었다. 보안 업체 StepSecurity 분석을 보면 이 기간 latest가 악성 1.14.1을 가리켰고, 삭제 뒤에야 1.14.0으로 돌아왔다. 피해 범위는 설치 시점에 달렸다. 메인테이너 안내 기준으로, 깨끗한 버전에 이미 고정돼 있었고 3월 31일 UTC 00:21~03:15 사이에 새로 설치하지 않았다면 영향이 없다. 그 사이 새로 설치하면서 악성 버전이 풀려 들어간 환경이 문제였고, 잠금 파일(package-lock.json·yarn.lock)에 1.14.1·0.30.4나 plain-crypto-js가 찍혀 있으면 감염된 것으로 보라고 한다. 이 분석은 정상 배포에는 trustedPublisher 기록이 남는데 악성 배포에는 그게 없었다는 점, 즉 OIDC 파이프라인을 우회해 장기 토큰으로 직접 올렸다는 점도 짚는다.
지금 axios는 어떨까. 최신 버전 메타데이터를 직접 조회해 봤다.
$ npm view [email protected] _npmUser --json
{
"name": "GitHub Actions",
"email": "[email protected]",
"approver": { "name": "jasonsaayman", ... },
"trustedPublisher": { "id": "github", "oidcConfigId": "oidc:9acc5c94-..." }
}
트러스티드 퍼블리셔로 올라왔고 approver 항목까지 붙어 있다. CI가 올린 버전을 사람이 승인하는 스테이징 흐름으로 바뀐 흔적이다. 출처 증명(dist.attestations)도 SLSA provenance v1 형식으로 달려 있었다.
둘째는 5월 11일 TanStack 사고다. TanStack의 사후 분석에 따르면 6분 사이에 42개 @tanstack/* 패키지에서 악성 버전 84개가 올라갔다. 이번엔 토큰을 훔친 게 아니었다. 포크 PR에서도 도는 pull_request_target 워크플로, GitHub Actions 캐시 오염, 러너 프로세스 메모리에서 OIDC 토큰을 빼내는 수법을 엮어 정상 배포 파이프라인의 신원으로 올렸다. 악성 버전 84개는 첫 배포 뒤 1시간 43분 안에 모두 폐기(deprecate) 처리됐다.
두 사건을 나란히 놓고 보면 결론이 하나 나온다. OIDC는 장기 토큰 유출을 막아 주지만, 워크플로 자체가 뚫리면 그 워크플로가 가진 권한은 공격자 것이 된다. 그래서 "어느 워크플로가 무엇을 할 수 있나"를 잘게 나누는 게 중요하다. dist-tag 이동은 새 버전을 만들지 않는다. 출처 증명이 새로 생기지도 않고 악성 코드 스캔을 거치지도 않는다. latest를 오래된 취약 버전으로 돌려 놓는 것만으로도 피해를 줄 수 있으니, 이 권한을 배포 권한과 따로 켜고 끄게 한 건 합리적인 설계다.
Allow npm dist-tag 설정 방법
먼저 요구 조건부터. npm 문서 기준이다.
| 항목 | 조건 |
|---|---|
| 트러스티드 퍼블리싱 자체 | npm CLI 11.5.1 이상, Node.js 22.14.0 이상 |
| OIDC로 dist-tag 관리 | npm CLI 11.21.0 이상 또는 12.2.0 이상 |
| 지원 CI | GitHub Actions(GitHub 호스팅 러너), GitLab.com 공유 러너, CircleCI 클라우드 — 셀프 호스팅 러너는 미지원 |
| 패키지당 설정 수 | 최대 10개 |
| 워크플로 권한 | id-token: write 필수 |
npm view npm dist-tags로 확인하니 10월 5일 현재 latest는 12.2.0, 11 줄기 최신(next-11)은 11.21.0이었다. 딱 문서의 최소 버전이 각 줄기 최신판이라, CI 이미지에 기본으로 깔린 npm으로는 부족할 가능성이 높다. 이 글을 쓴 맥의 npm도 10.9.2였다.
설정 순서는 이렇다.
- npmjs.com에서 패키지 설정의 트러스티드 퍼블리셔 항목을 연다.
- 태그 관리를 맡길 워크플로를 등록한다. GitHub Actions라면 조직(또는 사용자) 이름, 저장소 이름, 워크플로 파일 이름이 필수이고 환경(environment) 이름은 선택이다.
- 그 설정에서 Allow npm dist-tag를 켠다. 직접 배포(
npm publish) 허용 여부와는 별개로 고를 수 있다.npm stage publish는 모든 설정에 항상 허용된다. - 워크플로 안에서
npm dist-tag add·rm·ls를 그냥 실행한다. 문서에 따르면 별도 플래그 없이 npm CLI가 OIDC 신원으로 알아서 인증한다. - 태그 관리용으로 남겨 뒀던 그래뉼러 토큰을 폐기한다.
하나 걸리는 점이 있다. npm 문서는 기존 연결의 제공자와 필수 항목은 만든 뒤 바꿀 수 없고, 바꾸려면 지우고 새로 만들어야 한다고 적는다. 반면 체인지로그는 기존 설정에서도 이 권한을 켤 수 있다고 안내한다. 저장소·워크플로 이름 같은 필수 항목은 고정이고 허용 작업은 별개로 고르는 구조로 읽힌다. 화면에서 토글이 안 보이면 설정을 새로 만드는 쪽이 빠르다.
태그 승격만 하는 워크플로 예시
권한을 나누려면 배포 워크플로와 태그 워크플로를 파일부터 따로 두는 게 깔끔하다. 아래는 사람이 버전과 태그를 골라 수동으로 돌리는 승격 전용 워크플로다. npm 쪽에는 promote.yml만 등록한 설정을 따로 만들고 Allow npm dist-tag만 켠다. 직접 배포는 끈다.
# .github/workflows/promote.yml
name: Promote dist-tag
on:
workflow_dispatch:
inputs:
version:
description: "태그를 붙일 버전 (예: 2.1.0)"
required: true
tag:
description: "옮길 태그"
required: true
default: latest
type: choice
options: [latest, next, beta]
permissions:
id-token: write # OIDC 토큰 발급에 필수
contents: read
jobs:
promote:
runs-on: ubuntu-latest # 셀프 호스팅 러너는 미지원
environment: npm-promote # 저장소 설정에서 승인자 지정
steps:
- uses: actions/setup-node@v6
with:
node-version: '24'
registry-url: 'https://registry.npmjs.org'
- run: npm install -g npm@^11.21.0 # OIDC dist-tag 최소 버전
- name: Move dist-tag
env:
PKG: my-package
VERSION: ${{ inputs.version }}
TAG: ${{ inputs.tag }}
run: |
npm view "$PKG@$VERSION" version # 존재하는 버전인지 먼저 확인
npm dist-tag add "$PKG@$VERSION" "$TAG"
npm dist-tag ls "$PKG"
몇 가지는 의도적으로 넣었다. 입력값을 run에 ${{ }}로 바로 꽂지 않고 환경변수로 넘긴 건 셸 명령 주입을 피하려는 GitHub Actions의 흔한 습관이다. 저장소를 체크아웃하지 않는 것도 같은 이유다. 이 잡은 남의 코드를 돌릴 일이 없으니 공격면을 줄이는 편이 낫다. 롤백도 같은 파일로 된다. version에 직전 정상 버전을 넣고 tag를 latest로 고르면 끝이다. npm-promote 같은 환경 이름은 예시이고, 실제 패키지 이름도 바꿔 써야 한다. 이 워크플로는 우리 쪽에 등록된 npm 패키지가 없어 직접 돌려 보지는 못했다.
우리 팀에 적용한다면
개인적으로 이번 변경에서 제일 중요한 문장은 체인지로그 한구석에 있는 이 대목이라고 본다. dist-tag 작업은 들어온 OIDC 토큰이 권한이 켜진 설정 중 어느 하나와만 맞아도 허용된다. 설정을 여러 개 두더라도 권한을 켠 설정이 둘이면 그 둘 중 아무 워크플로나 태그를 옮길 수 있다는 뜻이다. 편하다고 기존 배포 워크플로 설정에 Allow npm dist-tag를 같이 켜 두면, TanStack처럼 배포 워크플로가 뚫렸을 때 latest까지 같이 넘어간다. 권한은 승격 전용 설정 하나에만 켜는 게 맞다.
배포 쪽은 GitHub가 9월 3일 여러 설정 지원을 발표하면서 설정을 스테이징 전용으로 두라고 권장했다. 그 흐름과 묶으면 역할이 이렇게 갈린다. 배포 워크플로는 스테이징만 하고, 사람이 2FA로 승인해 버전이 공개되고, 태그는 승인자가 붙은 별도 워크플로가 옮긴다. 이렇게 나누면 배포 워크플로가 뚫려도 새 버전이 곧장 공개되지는 않는다. 태그 승격 워크플로는 사정이 다르다. 이것만 장악해도 새 버전 없이 latest를 이미 공개된 다른 버전으로 옮길 수 있고, 그러면 사용자가 받는 버전이 바뀐다. 승격 워크플로에 승인자를 붙이는 이유다.
놓치기 쉬운 점도 몇 가지 정리해 둔다.
- 토큰을 남겨 두면 의미가 반감된다. axios 사고의 악성 배포는 OIDC를 우회한 장기 토큰에서 나왔다. npm 문서도 트러스티드 퍼블리싱을 켠 뒤 패키지의 배포 접근 설정에서 "2FA 필수 및 토큰 금지"를 고르라고 권한다.
- 트러스티드 퍼블리싱 설정 자체를 점검한다. 저장소 이름만 맞으면 되는 게 아니라 워크플로 파일 이름까지 묶이니, 승격 워크플로 파일을 다른 사람이 수정할 수 없도록 브랜치 보호와 CODEOWNERS로 막아 두면 좋다.
pull_request_target과 캐시를 의심한다. TanStack은 결국 포크 PR이 쓴 캐시를 배포 잡이 복원하면서 뚫렸다. 배포·승격 잡에서는 캐시를 쓰지 않는 쪽이 안전하다. npm 문서의 예시 워크플로가package-manager-cache: false를 넣어 둔 것도 같은 맥락으로 읽힌다.- CI의 npm 버전. 11.21.0 미만이면 OIDC로 dist-tag를 못 쓴다. 워크플로 첫 줄에
npm --version을 찍어 두면 실패 원인을 빨리 찾는다.
태그 하나 옮기는 게 사소해 보여도, 실제로 수백만 명이 받는 버전을 정하는 스위치는 새 버전이 아니라 그 포인터 쪽이다. 배포 토큰을 다 치웠는데 태그용 토큰 하나가 저장소 비밀값에 남아 있다면, 이번이 그걸 정리할 때다.