npm 공급망 공격의 기본 구조는 단순하다. 널리 쓰이는 패키지의 게시 권한을 탈취해 악성 버전을 올린다. 그 패키지가 설치되는 순간, preinstall·postinstall 훅이 애플리케이션 코드보다 먼저 실행된다. 개발자 권한과 CI 러너 권한으로.
방어의 핵심은 세 층이다. 설치 시 코드 실행을 막고, 무엇이 설치되는지 고정하고, 설치 환경에 훔칠 것을 두지 않는 것. 셋 다 오늘 시작할 수 있다.
이 주제가 남 얘기처럼 느껴진다면 질문 하나만 해 보자. 지금 내 노트북에서 npm install을 실행하면 몇 개의 스크립트가 실행되는가? 그 스크립트들이 접근할 수 있는 건 무엇인가? SSH 키, 클라우드 자격증명, 브라우저 세션, 사내 네트워크. 대부분의 개발 환경은 프로덕션 서버보다 권한이 넓고 감시는 적다.
공격 경로 이해
1단계 — 설치 시 코드 실행 차단
가장 효과가 큰 조치다. 설치 훅을 끄면 악성 패키지가 설치만으로 코드를 실행할 수 없게 된다. 문제는 일부 패키지가 훅에 의존한다는 것인데, 생각보다 적다.
# 훅 없이 설치
npm ci --ignore-scripts
# .npmrc 에 기본값으로 박아 두기
echo "ignore-scripts=true" >> .npmrc
# pnpm: 설치 스크립트 허용 목록 방식
# pnpm.onlyBuiltDependencies 에 꼭 필요한 패키지만 나열
# package.json
# {
# "pnpm": {
# "onlyBuiltDependencies": ["esbuild", "sharp"]
# }
# }
# 어떤 패키지가 훅을 가지고 있는지 먼저 파악
npm ls --all --json 2>/dev/null | grep -c '"scripts"' || true
네이티브 빌드가 필요한 패키지(이미지 처리, 암호화 등)는 예외가 필요하다. 허용 목록 방식이 있는 패키지 매니저를 쓰면 예외를 명시적으로 관리할 수 있다. 예외 목록이 길어지면 그 자체가 리뷰 대상이 된다.
2단계 — 무엇이 설치되는지 고정
npm ci(또는 --frozen-lockfile)만 사용마지막 항목에 대해 나는 팀 규칙을 하나 뒀다. 새 의존성을 추가하는 PR에는 "왜 이 패키지인가"를 한 줄 적는다. 형식적으로 보이지만, 그 한 줄을 쓰기 귀찮아서 직접 구현하게 되는 경우가 생각보다 많다. 의존성 수 자체가 공격면이다.
3단계 — 출처 증명(provenance) 활용
최근 레지스트리는 패키지가 어느 저장소의 어느 커밋에서, 어떤 CI 워크플로로 빌드됐는지를 증명하는 메타데이터를 지원한다. 이를 확인하면 "메인테이너 계정만 탈취해 로컬에서 올린" 패키지를 구분할 수 있다.
# 패키지의 provenance 확인
npm view <package> --json | grep -i provenance
# 설치 시 서명·출처 검증
npm audit signatures
# 내 패키지를 게시할 때 provenance 첨부 (CI에서)
# npm publish --provenance --access public
# - OIDC 기반으로 CI 신원을 증명
# - 로컬에서 수동 게시하면 provenance 가 붙지 않는다
조직 차원에서는 "provenance 없는 패키지는 새로 추가하지 않는다" 같은 정책을 점진적으로 도입할 수 있다. 기존 의존성 전부에 적용하기는 아직 무리지만, 신규 추가분부터 적용하면 시간이 지나며 비율이 올라간다.
4단계 — 설치 환경에 훔칠 것을 두지 않기
훅을 막고 락파일을 고정해도 완벽하지 않다. 그래서 마지막 방어선은 탈취당해도 잃을 게 적게 만드는 것이다.
| 위험 요소 | 나쁜 방식 | 나은 방식 |
|---|---|---|
| CI 자격증명 | 장기 액세스 키를 시크릿에 저장 | OIDC로 발급받는 단기 토큰 |
| 레지스트리 토큰 | 만료 없는 자동화 토큰 | 짧은 만료 + 게시 전용 스코프 |
| 빌드 환경 | 모든 시크릿이 한 잡에 노출 | 잡 분리 — 빌드와 배포를 다른 잡으로 |
| 네트워크 | 무제한 아웃바운드 | 필요한 도메인만 허용(이그레스 제어) |
| 개발자 PC | 클라우드 관리자 키 상주 | SSO 기반 임시 자격증명, 권한 최소화 |
# GitHub Actions 예: 빌드 잡에는 시크릿을 주지 않는다
jobs:
build:
permissions:
contents: read # 최소 권한
steps:
- uses: actions/checkout@v4
- run: npm ci --ignore-scripts
- run: npm run build
- uses: actions/upload-artifact@v4
with: { name: dist, path: dist/ }
publish:
needs: build
permissions:
id-token: write # OIDC 로 단기 자격증명 발급
contents: read
steps:
- uses: actions/download-artifact@v4
with: { name: dist }
# 여기서만 배포 자격증명을 사용한다
5단계 — 탐지
막는 것만큼 중요한 게 빨리 아는 것이다. 침해 사례들의 공통점은 발견이 늦었다는 것이다. 몇 달 뒤 외부 제보로 아는 경우가 흔하다.
- 레지스트리 토큰의 사용 위치·시각을 주기적으로 검토한다.
- 저장소 대량 클론이나 비정상 시간대 접근에 알림을 건다.
- CI 잡의 아웃바운드 연결 대상을 로깅한다 — 빌드가 낯선 도메인에 접속하면 신호다.
- 의존성 변경 알림을 받는다 (락파일 diff를 리뷰 필수로).
- 조직 구성원의 퇴사·역할 변경 시 발급한 모든 토큰을 회수한다.
현실적인 도입 순서
npm ci만 쓰도록 바꾼다. 비용 0, 효과 즉시.전부 하려 들면 아무것도 안 된다. 1번과 3번만 해도 실제 피해 시나리오의 상당수가 막힌다. 나머지는 여유가 생길 때 하나씩 붙이면 된다.
자주 묻는 질문
설치 스크립트를 꺼도 괜찮나요?
대부분의 패키지는 문제없이 동작합니다. 네이티브 바이너리를 빌드하거나 내려받는 일부 패키지만 예외가 필요하며, 허용 목록으로 관리할 수 있습니다. 예외 목록이 짧게 유지되는지 주기적으로 확인하세요.
락파일만 있으면 안전한가요?
설치 결과를 고정해 주지만, 악성 버전이 락파일에 들어온 이후에는 소용이 없습니다. 의존성 변경을 리뷰 대상으로 만들고, 자동 업데이트의 자동 병합을 피하는 정책이 함께 필요합니다.
provenance가 있으면 안전한 패키지인가요?
해당 버전이 특정 저장소와 CI 워크플로에서 빌드됐음을 증명할 뿐, 코드가 안전하다는 보증은 아닙니다. 다만 계정만 탈취해 로컬에서 올린 패키지를 구분하는 데는 효과적이어서, 신뢰 판단의 유용한 신호가 됩니다.
CI에서 장기 토큰을 없애려면 어떻게 하나요?
OIDC를 지원하는 클라우드·레지스트리라면 CI가 워크플로 신원을 증명해 단기 자격증명을 발급받도록 구성할 수 있습니다. 빌드 잡과 배포 잡을 분리해 시크릿이 필요한 범위를 최소화하는 것도 함께 적용하세요.
전이 의존성은 어떻게 관리하나요?
전부 검토하는 것은 불가능하므로 구조적 방어에 의존해야 합니다. 설치 훅 차단, 락파일 고정, CI 권한 최소화가 핵심이며, 여기에 의존성 수 자체를 줄이는 문화(새 패키지 추가 시 필요성 검토)가 더해지면 공격면이 눈에 띄게 줄어듭니다.
침해를 당했는지 어떻게 확인하나요?
레지스트리 토큰의 사용 이력, 저장소 클론·접근 로그, CI 잡의 외부 연결 기록을 확인하세요. 의심되면 모든 토큰을 즉시 회전하고, 영향 기간에 게시된 자사 패키지와 커밋에 이상이 없는지 감사해야 합니다.

댓글 0