ㅂㄹㄱ

← 목록으로

npm publish 파이프라인 점검기 — CI가 한 번도 안 돌고 있었다

it-trend/security 6분


이 글은 Mini Shai-Hulud npm 공급망 웜 분석의 후속이다.
남의 사고를 정리했으니 내 파이프라인도 같은 기준으로 봤다.

들어가며

자가전파 npm 웜을 분석하는 글을 쓰고 나서 찜찜함이 남았다. 글에서는 "npm 토큰이 웜의 1차 표적"이라고 썼는데, 정작 내 저장소들이 어떻게 배포되고 있는지는 자신 있게 답할 수 없었다.

그래서 계정 전체를 훑었다. 결과가 예상과 달랐다. 하드닝할 게 있을 줄 알았는데, 하드닝할 대상이 애초에 동작하지 않고 있었다.


무엇을 점검했나

두 계정의 저장소 117개에서 npm 배포 경로를 가진 워크플로를 추렸다. 판별 기준은 두 가지다.

  • npm publish·NPM_TOKEN·changesets/action 중 하나라도 등장하는 워크플로
  • id-token: write 권한을 요청하는 워크플로 (OIDC 발급 대상)

결과는 이렇다.

항목
npm 배포 경로를 가진 워크플로4개
id-token: write를 쓰는 워크플로3개
관련 저장소2개

수가 적어서 다행이라고 생각했다. 하나씩 열어보기 전까지는 그랬다.


첫 번째 발견 — publish job이 한 번도 안 돌았다

whois2rdap의 CI 워크플로는 이렇게 생겼다.

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    # …

  publish:
    needs: test
    if: startsWith(github.ref, 'refs/tags/v')
    permissions:
      contents: read
      id-token: write
    steps:
      - run: npm publish --provenance
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

id-token: write도 있고 --provenance도 붙어 있다. 얼핏 잘 짜인 것처럼 보인다.

문제는 트리거다. on: push: 아래에 branches만 있고 tags가 없다. 이러면 태그 push는 워크플로를 시작시키지 않는다. 브랜치 필터와 태그 필터는 별개이기 때문이다.

job 안의 if: startsWith(github.ref, 'refs/tags/v')는 워크플로가 시작돼야 평가된다. 시작 자체가 안 되니 이 조건은 평가될 기회가 없다. publish job은 문법적으로 존재하지만 실행 경로가 없는 코드였다.

실행 이력으로 확인했다.

gh run list --repo mack-erel/whois2rdap --limit 8 \
  --json event,conclusion,createdAt

7건 전부 event: push였다. 태그로 트리거된 실행은 0건이다. 저장소에는 v0.1.0·v0.1.1·v0.1.2 태그가 멀쩡히 달려 있는데도 그렇다.


두 번째 발견 — 그럼 npm의 세 버전은 어디서 왔나

레지스트리를 조회했다.

curl -s https://registry.npmjs.org/whois2rdap | jq '.versions | keys'

0.1.0·0.1.1·0.1.2 세 버전이 정상 배포돼 있었다. CI가 안 돌았는데 패키지는 올라가 있다.

각 버전의 메타데이터를 보면 답이 나온다.

버전attestations배포 npm CLI배포 시각
0.1.0없음11.6.22026-05-16 13
0.1.1없음11.17.02026-07-08 09
0.1.2없음11.17.02026-07-08 09

세 가지가 한꺼번에 드러난다.

첫째, dist.attestations가 전부 비어 있다. provenance가 붙지 않았다는 뜻이다. --provenance 플래그는 워크플로 안에 적혀 있었지만 그 워크플로가 안 돌았으니 당연하다.

둘째, 배포에 쓰인 npm CLI 버전이 제각각이다. CI 러너였다면 워크플로에 고정한 Node 20 이미지의 npm 버전으로 일정했을 것이다. 로컬 머신에서 그때그때 깔려 있던 버전으로 나갔다는 신호다.

셋째, 0.1.1과 0.1.2 사이가 43분이다. 급하게 재배포한 흔적이다.

결정적으로, 저장소 시크릿 목록이 비어 있었다.

gh secret list --repo mack-erel/whois2rdap
# (출력 없음)

NPM_TOKEN이 등록조차 되어 있지 않다. 워크플로가 어쩌다 실행됐더라도 인증에서 실패했을 것이다.

정리하면 이렇다. CI에 배포 파이프라인이 있다고 믿고 있었지만 실제 배포는 전부 내 노트북에서 내 개인 npm 토큰으로 이뤄졌다.


왜 이게 정확히 웜의 표적인가

Mini Shai-Hulud 분석에서 정리한 감염 고리를 다시 보면 이 상태가 왜 나쁜지 분명해진다.

이 고리가 성립하려면 개발 머신에 장기 npm 토큰이 상주해야 한다. 로컬 수동 배포는 그 조건을 정확히 충족한다. npm install 한 번 잘못하면 배포 권한이 함께 나간다.

CI에서 배포하면 이 고리가 끊긴다. 토큰이 러너에만 존재하고 작업이 끝나면 사라지기 때문이다. Trusted Publishing까지 가면 토큰 자체가 없어진다.

provenance가 없다는 것도 별개 문제다. 사용자가 "이 패키지가 정말 그 저장소의 그 커밋에서 빌드됐는지" 검증할 방법이 없다. Mini Shai-Hulud가 SLSA 증명을 위조해서 문제가 됐는데, 내 패키지는 증명 자체가 없었다.


대조군 — 같은 계정의 다른 저장소는 제대로 되어 있었다

흥미롭게도 같은 계정의 다른 저장소 하나는 이미 잘 되어 있었다. 사내용 위젯 SDK를 배포하는 워크플로인데, 구조가 이렇다.

요소적용 방식
트리거workflow_dispatch만. 자동 배포 없음
기본값dry_run: true — 실수로 눌러도 배포 안 됨
인증Trusted Publishing. NODE_AUTH_TOKEN 없음
권한validate job은 contents: read, publish job에만 id-token: write
게이트environment: npm_packages
사전 검증preflight 스크립트 결과를 아티팩트로 30일 보관
배포 전테스트 + npm pack --dry-run

같은 사람이 만든 두 저장소인데 수준 차이가 크다. 차이가 생긴 이유는 단순하다. 위젯 SDK는 팀이 함께 쓰는 패키지라 신경 써서 만들었고, whois2rdap은 혼자 쓰는 도구라 "일단 돌아가게" 해놓고 잊었다.

개인 프로젝트일수록 파이프라인이 방치된다. 그런데 npm에 올라간 패키지는 개인 프로젝트든 아니든 남이 설치한다.


조치 1 — Trusted Publishing으로 전환

이미 잘 되어 있는 쪽의 패턴을 그대로 가져오면 된다. 핵심은 NODE_AUTH_TOKEN을 지우는 것이다.

on:
  push:
    tags: ['v*']        # ← 태그 트리거를 명시. 이게 빠져 있었다

jobs:
  publish:
    needs: test
    runs-on: ubuntu-latest
    environment: npm_publish
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          registry-url: https://registry.npmjs.org
      - run: npm install -g npm@^11.5.1   # Trusted Publishing 지원 CLI
      - run: npm ci
      - run: npm publish --access public
        # NODE_AUTH_TOKEN 없음 — OIDC로 인증한다

npm 쪽에서도 한 번 설정해야 한다. 패키지 설정의 Trusted Publisher에 저장소와 워크플로 파일명을 등록하면, 그 워크플로에서 온 OIDC 토큰만 배포를 허용한다.

전환이 끝나면 기존 개인 토큰을 폐기한다. 이게 실질적인 이득이다. 남아 있으면 CI를 아무리 잘 만들어도 우회 경로로 남는다.


조치 2 — 액션을 SHA로 고정

actions/checkout@v4는 가변 참조다. 태그가 가리키는 커밋은 언제든 바뀐다. 액션 저장소가 탈취되면 태그 재지정만으로 악성 코드가 들어온다.

# 전
- uses: actions/checkout@v4

# 후
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0

주석으로 버전을 남겨두면 사람이 읽기도 편하다. Dependabot이 SHA와 주석을 함께 갱신해준다.


조치 3 — environment로 게이트를 건다

environment:를 지정하면 배포 job에 승인 절차를 붙일 수 있다. 저장소 write 권한만 있으면 태그를 밀어 배포가 나가는 상태보다 낫다.

  • required reviewer — 배포 전 승인 필요
  • deployment branch 제한 — 특정 브랜치·태그에서만 배포 허용
  • 환경 전용 시크릿 — 다른 job에서는 접근 불가

혼자 쓰는 저장소라 승인자를 둘 이유가 없어 보여도, 배포가 자동으로 나가지 않게 한 단계 세워두는 것 자체가 의미가 있다.


정리

점검 전과 후를 비교하면 이렇다.

항목점검 전 (실제 상태)조치안
배포 주체개인 노트북GitHub Actions 러너
인증로컬 상주 개인 npm 토큰OIDC 단명 토큰 (Trusted Publishing)
provenance없음자동 첨부
태그 트리거동작 안 함tags: ['v*'] 명시
액션 참조가변 태그커밋 SHA 고정
배포 게이트없음environment

이번 점검에서 제일 크게 배운 건 워크플로가 존재한다는 것과 실행된다는 것은 다르다는 사실이다. 파일을 읽고 만족하지 말고 실행 이력을 봐야 한다. gh run list로 이벤트 종류만 훑어도 몇 초면 끝난다.

배포 경로를 확인하는 요령도 하나 얻었다. 레지스트리 메타데이터에서 _npmVersion이 들쭉날쭉하거나 dist.attestations가 비어 있으면 CI 배포가 아니다. 남의 패키지를 평가할 때도 같은 기준이 통한다.

이런 방치가 하필 개인 프로젝트에서 생긴다는 것도 짚어둘 만하다. 팀 프로젝트는 리뷰를 거치며 자연스럽게 다듬어지는데 혼자 만든 도구는 그럴 계기가 없다. npm에 올라간 이상 남이 설치한다는 사실은 똑같은데도 그렇다.


참고자료


태그 ci-cd devsecops github-actions npm npm-publish npm-token oidc provenance security sigstore supply-chain trusted-publishing

공유 X 링크드인

FAQ