Posts

NPM OIDC Trusted Publishing의 한계와 그 보안

Miasma 공격 사례와 마주하며 Github Environment 규칙을 추가하고 DX 강화하기

toolingsecurity

올 상반기, ⏱️째깍악어🐊 라이브러리 모노레포 배포에서 npm 토큰을 빼고 OIDC Trusted Publishing을 도입했습니다. 최근 테크 그룹에서 Miasma 이슈를 공유받았고 보안적으로 부족한 지점을 확인했습니다.

며칠 전 테크 그룹 개발자 한 분이 Miasma (wiz.io, 새 창) 사건을 짚어 주셨습니다. 6월 1일 @redhat-cloud-services 네임스페이스의 패키지들이 악성 코드가 실린 채로 npm에 배포된 사건이었는데요. 주간 다운로드가 수만 건에 이르는 패키지들이라서 파장이 꽤나 컸던 모양입니다.

테크 그룹에서 공유된 Miasma 이슈
테크 그룹에서 공유된 Miasma 이슈

중요한 것은 흔히 걱정하는 NPM 토큰 유출로 인해 발생한 사고가 아니었다는 점이었습니다. 탈취된 GitHub 계정으로 짧은 수명의 브랜치에 위조 워크플로우를 올리고 id-token: write로 OIDC 토큰을 받아 Trusted Publishing을 그대로 통과해 배포한 것이었는데요, provenance도 당연히 유효했습니다. 정당한 릴리즈였던 셈인 거죠.

⏱️째깍악어🐊 FE팀은 올 상반기에 마침 같은 방식-OIDC 배포 방식-을 Changesets 기반의 모노레포에 얹었던 상황이었습니다. 적용 때까지만 해도 완벽할 거라 생각했었죠! 이 생각이 얼마나 짧았는지, 이번 글은 올 상반기의 OIDC 작업과 이 사건을 통해 추가로 도입했던 GitHub Environment 승인을 같이 다뤄보려 합니다.

OIDC를 도입했었는데요...

NPM의 Trusted Publishing (docs.npmjs.com, 새 창)은 기존의 액세스 토큰 대신 GitHub Actions가 발급한 짧은 OIDC 토큰으로 신원을 증명하고 배포하는 방식이라고 할 수 있습니다. 토큰의 유효 기간이 짧기 때문에, 또 유효 기간이 배포 시점에서만 발급되기 때문에 NPM의 granular access token의 취약점을 완벽하게 보완하는 셈이죠. granular access token은 유효 기간을 지정해서 사용하니까요!

다시 Trusted Publishing으로 돌아와서, ⏱️째깍악어🐊에서는 changesets/action을 활용하여 모노레포의 원격으로 배포하는 방식을 취하고 있었는데요. 이 방향과 함께 OIDC를 적용하기 위해 많이 헤맸습니다: changesets/action에서는 NPM_TOKEN이 있으면 해당 토큰을 우선적으로 사용해버리기 때문에 OIDC를 쓰려면 토큰을 비워야 했습니다.

permissions:
  contents: write       # version 커밋, GitHub Release
  id-token: write       # OIDC 토큰 발급
  pull-requests: write  # version packages PR

- name: Upgrade npm (npm >= 11.5.1)
  run: npm install -g npm@11.18.0

- name: Create Release Pull Request or Publish to npm
  uses: changesets/action@63a615b9cd06ba9a3e6d13796c7fbcb080a60a0b # v1.8.0
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    NPM_TOKEN: ''

아직 이러한 방식이 Trusted Publishing에 대응하는 changesets/action의 공식적인 방법은 아닌 것으로 보이는데, 임시 방편으로 해당 방법을 채택했습니다(추후 공식적인 어나운스가 나오면 교체하려 합니다!).

여담으로 액션은 태그 대신 커밋 SHA로 고정했습니다. AI 트렌드 이후 보안 이슈가 잦기에 안정성이 보장된 특정 커밋 SHA로 고정해둔 것입니다(실제로 최근 Github에서 보안 강화를 위해 이 방식을 공식적으로 제안하고 있습니다!). 일반적인 태깅 방식을 사용한다면 의도치 않게 악성 코드가 포함된 로직을 호출할 수도 있으니까요!

Miasma 공격 앞에서는 OIDC만으로 부족해!

OIDC를 통해 장기간 보관되는 npm 토큰을 없애고 짧은 수명의 인증 토큰을 사용하게 해줍니다. 자, 그럼 완벽했을까요? 서두에서 설명드린 Miasma 공격 사례처럼, OIDC 방식은 토큰을 발급받을 수 있는 GitHub Actions 실행 자체가 공격자에 의해 만들어지는 상황까지 막아주지는 않았습니다.

즉, 공격자가 저장소에 워크플로우를 추가하거나 수정할 수 있는 권한을 확보한다면 기존의 정상적인 릴리즈 파이프라인을 거치지 않고도 id-token: write 권한을 이용해 OIDC 토큰을 발급받는 실행 경로를 만들 수 있었던 것이죠. npm의 Trusted Publishing은 이 토큰이 신뢰된 GitHub 저장소와 워크플로우에서 발급되었는지는 확인할 수 있지만 그 실행이 실제로 사람이 의도한 정식 릴리즈인지까지 판단하지는 않았던 셈입니다.

provenance 역시 마찬가지입니다. provenance가 유효하다는 것은 해당 패키지가 특정 빌드/워크플로우에서 생성되었다는 출처 정보가 검증된다는 의미이지 그 빌드가 사람이 의도한 정식 릴리즈였다는 의미는 아닙니다. 결국 provenance 딱지가 붙었다는 것이 보안적으로 엄격하게 검증된 상황인 셈은 아닌 것이죠.

changesets/action을 사용하니 개발자가 충분히 배포 상황을 제어할 수 있지 않냐구요? 그렇지도 않은 것 같았습니다. 저희가 의도한 Changesets의 릴리즈 흐름은 changeset 배포 md 파일(메타데이터)을 추가하고, version PR이 자동으로 생성, 이를 머지한 뒤 main에서 정식 release.yml이 실행되는 과정을 전제로 합니다. 하지만 공격자가 별도의 워크플로우를 실행할 수 있다면 이 과정을 통째로 정식적으로 밟아 npm publish까지 도달하는 경로를 충분히 만들 수 있었습니다. 단순하게 탈취한 Github 계정으로 로그인하면 되니까요.

Required Reviewers의 도입과 함께 고려한 DX

그래서 이번 사태를 보면서 테크 그룹에 개선안을 공유드렸습니다. 결국에는 실제 npm 배포 직전에 사람의 상호 승인이 필요하다는 판단이었습니다. 그리고 GitHub Environment의 Required reviewers로 상호 승인 방식을 걸 수 있는 상황으로 보였습니다.

GitHub Environment의 Required reviewers 설정
GitHub Environment의 Required reviewers 설정

해당 방식을 적용하여 라이브러리를 배포하기 위해서는 작업자와 최소 1명 이상의 승인자가 필요한 셈이 되었습니다. 계정을 탈취하거나 다른 파이프라인을 통해 이를 우회하더라도 결국 혼자서는 배포가 불가능한 것입니다. 아래처럼 워크플로우가 곧바로 실행되지 않고 제3자가 Approve workflows to run 버튼을 눌러야 워크플로우가 실행될 뿐이죠.

배포 Job이 승인 대기 중인 GitHub Actions
배포 Job이 승인 대기 중인 GitHub Actions

다만 changesets/action은 version PR 생성과 publish를 한 Job에서 처리하기 때문에 Github Environment를 Job 전체에 붙이면 배포가 아닌 version PR을 만들 때도 승인을 받아야 해서 DX가 꽤 나빠질 수 밖에 없었습니다. 이미 npm 배포 직전에 다른 개발자의 승인이 필요하다는 것조차도 DX 측면에서 나쁜 상황인데 더 나빠진다니...😭​

그래서 Job을 나누기로 결정했습니다. publish Job에만 environment: production을 붙이고, id-token: write도 publish Job에만 두었습니다. 승인이 필요한 배포 시점을 분리하려는 것이 목적인 셈이었죠.

production Environment에는 Required reviewers와 배포 브랜치 제한을 같이 걸었습니다. npm 패키지 설정의 Trusted Publisher에도 Environment production을 넣었습니다. 워크플로우에 environment: production이 없으면 npm이 publish를 거부하고, Environment를 쓰는 워크플로우라도 GitHub가 브랜치를 확인하는 방식인 것입니다.

모노레포 보안 개선 제안
모노레포 보안 개선 제안

마무리하며

최근 많은 서비스에서 보안 이슈들이 발생하고 있는데요, 에이전틱 코딩 트렌드로 인해 매뉴얼한 사람의 검증이 느슨해진 탓이 아닐까 생각해 봅니다. 이번 Miasma처럼 공급망을 노린 공격이든, 최근 많은 기업들에서 개인 정보 유출 사고든 결국 공통점은 기술적인 것만으로는 완벽하게 막기 어려운 경로가 따로 있다는 점입니다. 결국 최종 검수자인 인간이 필요한 것이죠. 저도 물론 코드를 짜면서 AI 활용을 아주 안하는 것은 아니지만 (1) 맥락을 이해하고 의도를 명확하게 하기 위해 대다수의 작업에서는 여전히 손코딩과 실제 코딩을 하고 있으며, (2) AI를 활용해 짠 모든 코드는 독해하고 있습니다. 이번 보안 작업에서도 결국 사람의 승인을 최종 마지노선으로 둔 것처럼 저는 앞으로도 제 힘으로 코드를 통제하는 이러한 방식을 고수할 생각입니다.

FE 개발자라는 핑계로 보안에 대해서 많이 고민하고 있지 않았던 과거를 반성하면서, 또 앞으로는 개별 개발자의 업무 범위와 영향력이 확장될 것이기 떄문에 조금 더 배워야겠다고 생각하면서 글을 마칩니다. 긴 글 읽어주셔서 감사합니다.

Dongyeon.log