☰
jena.run
/
♡

package-lock.json만 믿고 있었다면

Shai-Hulud npm 공급망 공격이 무서운 이유

package-lock.json만 믿고 있었다면: Shai-Hulud npm 공급망 공격이 무서운 이유

얼마 전 Reddit의 r/devsecops를 보다가 꽤 신경 쓰이는 글 하나를 발견했다.

2026년 8월 초, keyv와 cacheable을 시작으로 npm 생태계에서 다시 대규모 공급망 공격이 발생했다는 내용이었다.

글의 요지는 대충 이랬다.

우리는 아직도 CI에서 별 의심 없이 npm install을 하고 있다.

그리고 이번에는 그 말이 단순한 보안 업계의 겁주기가 아니었다.

실제로 2026년 8월 4일, 널리 사용되는 keyv, cacheable 계열 패키지의 정상 배포 경로가 공격자에게 악용됐고, Shai-Hulud 계열의 자기복제형 악성코드가 수백 개 npm 패키지로 확산됐다. 조사 시점에 따라 숫자는 조금씩 달랐지만 JFrog는 400개 이상의 패키지와 1,700개 이상의 버전을 확인했고, 싱가포르 사이버보안청(CSA)은 1,300개가 넘는 악성 패키지 버전과 월간 다운로드 규모 약 20억 회에 해당하는 생태계가 노출됐다고 설명했다.

문제는 여기서부터다.

공격받은 패키지는 이름부터 수상한 free-bitcoin-generator-2026 같은 패키지가 아니었다.

우리가 평범하게 dependency tree 어딘가에서 만날 수 있는 정상 오픈소스 패키지였다.


1. 이번 공격은 어떻게 시작됐나

공격자는 keyv 및 관련 패키지를 관리하는 GitHub 계정을 장악한 뒤 정상 저장소와 릴리스 과정에 악성 코드를 끼워 넣었다.

대표적으로 악성 keyv@6.0.0에는 설치 시 실행되는 preinstall 코드가 포함됐다. 즉 애플리케이션에서 import keyv를 실행할 필요조차 없었다.

의존성을 설치하는 순간 공격이 시작될 수 있었다.

npm install

개발자에게는 그저 평범한 명령어지만, 패키지 입장에서는 설치 과정에서 lifecycle script를 실행할 수 있는 기회이기도 하다.

악성 payload는 개발자 PC와 CI/CD 환경에서 각종 credential을 수집했다.

GitHub token, npm token뿐 아니라 클라우드 credential, SSH key, Kubernetes 및 Vault 관련 secret 등 개발자가 업무 환경에서 가지고 있을 법한 인증정보가 대상이었다. 이후 확보한 npm publish 권한을 이용해 해당 개발자가 관리하던 다른 패키지까지 다시 감염시키면서 스스로 확산했다.

그래서 이 공격을 단순한 credential stealer가 아니라 worm, 즉 자기복제형 공급망 공격이라고 부르는 것이다.

흐름을 단순화하면 이렇다.

Maintainer 계정 탈취
        ↓
정상 GitHub Repository 변조
        ↓
악성 npm package 배포
        ↓
개발자 / CI에서 dependency 설치
        ↓
credential 탈취
        ↓
npm publish token 확보
        ↓
피해자가 관리하는 다른 package 감염
        ↓
다른 개발자가 다시 설치
        ↓
반복

공급망 공격이 무서운 이유가 정확히 여기 있다.

공격자는 모든 개발자를 직접 해킹할 필요가 없다.

개발자들이 이미 신뢰하고 있는 한 지점을 해킹하면 된다.


2. 더 골치 아픈 점: 내가 keyv를 설치하지 않았어도 된다

"나는 keyv 안 쓰는데?"

이게 공급망 사고 때 가장 위험한 생각 중 하나다.

JavaScript 프로젝트에는 직접 설치한 dependency보다 transitive dependency가 훨씬 많다.

예를 들어 어떤 프로젝트에서는 이런 dependency chain이 만들어질 수 있다.

eslint
└── file-entry-cache
    └── flat-cache
        └── keyv

나는 ESLint를 설치했을 뿐인데 dependency tree 몇 단계 아래에서 keyv가 따라올 수 있다는 얘기다.

Reddit에서도 이번 사고 이후 자신이 직접 사용하지 않는 패키지까지 확인해야 한다는 이야기가 나온 이유다. 실제 피해 여부 역시 패키지 이름만이 아니라 정확히 어떤 버전이 dependency tree에 존재했는지 확인해야 했다.

확인 자체는 간단하다.

npm ls keyv
npm ls cacheable
npm ls flat-cache
npm ls file-entry-cache

또는 lockfile에서 검색할 수도 있다.

grep -n '"keyv"' package-lock.json

물론 이것만으로 이번 캠페인 전체를 검사할 수 있다는 뜻은 아니다. 실제로는 공식 IOC와 당시 공개된 affected package 목록을 기준으로 검사해야 한다.


3. 그러면 package-lock.json이 있으면 안전한 거 아닌가?

여기서 꽤 중요한 오해가 하나 생긴다.

결론부터 말하면:

lockfile은 중요하다. 하지만 supply-chain security 그 자체는 아니다.

package-lock.json이 있고 CI에서 npm ci를 사용한다면 이미 고정된 dependency tree가 갑자기 오늘 업로드된 최신 악성 버전으로 바뀌는 위험은 상당히 줄어든다.

이건 분명 좋은 방어다.

하지만 이런 상황을 생각해보자.

1. 정상 버전 5.9.0 사용 중
2. 공격자가 6.0.0 배포
3. Renovate / Dependabot / 개발자가 6.0.0 update
4. CI 통과
5. package-lock.json에 6.0.0 기록
6. merge

이제부터 lockfile은 무엇을 해줄까?

악성 버전을 아주 성실하고 재현 가능하게 설치해준다.

npm ci

할 때마다 똑같은 악성 dependency tree를 정확하게 재현한다.

lockfile의 역할은 기본적으로 dependency resolution을 고정하는 것이지,

"이 코드가 안전하다."

를 증명하는 것이 아니다.

integrity hash도 마찬가지다.

해시는

"내가 받은 파일이 registry에 올라온 파일과 동일하다."

를 확인하는 데 유용하다.

하지만 registry에 올라온 파일 자체가 악성이라면?

악성코드의 SHA-512를 정확하게 검증해주는 셈이다.


4. 심지어 provenance도 있었다

개인적으로 이번 사건에서 가장 흥미로운 부분이다.

일반적으로 공급망 보안을 강화할 때 package provenance나 trusted publishing을 사용한다.

아이디어 자체는 합리적이다.

패키지가

개발자 노트북
→ npm token
→ npm publish

처럼 개인 credential을 거쳐 올라가는 대신,

GitHub Repository
→ GitHub Actions
→ OIDC
→ npm Trusted Publishing

같은 검증 가능한 배포 경로를 사용하게 만드는 것이다.

그런데 이번 keyv 관련 악성 릴리스 일부는 정상 GitHub Actions 기반 trusted publishing을 거쳐 유효한 provenance까지 가지고 있었다.

배포 시스템이 뚫린 것이 아니라,

배포 시스템이 실행하기 전에 source가 이미 오염돼 있었기 때문이다.

이 사건이 보여준 중요한 차이가 있다.

Provenance가 증명하는 것

"This artifact came from this repository/workflow."

하지만 우리가 정말 알고 싶은 것은 이것이다.

"This artifact is safe."

둘은 같은 문장이 아니다.

서명과 provenance가 정상이라는 건 배포 경로의 신뢰성을 증명할 수는 있어도, 그 경로로 흘러간 코드 자체가 선량하다는 것까지 증명하지는 못한다.


5. Claude Code와 VS Code까지 공격 대상이 됐다

이번 변종에서 특히 2026년다운 부분이 하나 있다.

공격자는 단순히 .npmrc나 .env만 훑고 끝내지 않았다.

일부 보안업체 분석에 따르면 Claude Code 설정과 VS Code task를 이용한 persistence도 포함됐다. 개발자가 프로젝트를 다시 열거나 AI 개발환경을 사용하는 것 자체가 공격자의 실행 지점이 될 수 있도록 설계된 것이다.

몇 년 전까지만 해도 개발자 PC에서 가장 민감한 파일을 생각하면 이런 것들이었다.

~/.ssh/
~/.aws/
.env
.npmrc

이제는 여기에 하나를 더 추가해야 할 것 같다.

AI coding agent configuration

Claude Code, Copilot, Cursor류의 agent가 점점 더 많은 파일과 shell command, GitHub repository에 접근할 수 있게 되면서 AI 개발환경 역시 새로운 supply-chain attack surface가 되고 있다.


6. Reddit에서 나온 질문: 우리는 왜 아직도 CI에서 모든 script를 실행할까?

이번 사건 이후 r/devsecops에서 꽤 공감됐던 문제 제기가 있었다.

많은 팀이 여전히 CI에서 새 dependency를 받아오면서 install script 실행을 기본 허용하고 있다는 것이다.

보통 CI 설정은 이런 식이다.

- run: npm ci
- run: npm test
- run: npm run build

문제는 CI runner가 생각보다 굉장히 좋은 공격 대상이라는 점이다.

CI 안에는 흔히 다음과 같은 것들이 존재한다.

GitHub token
AWS credential
Docker registry credential
npm token
deployment secret
SSH key

그리고 우리가 실행하는 dependency에는 경우에 따라 이런 권한이 있다.

install되는 순간 JavaScript 실행

둘을 같은 환경에 넣어두고 있었던 것이다.


7. 최소한 install script 정책은 다시 볼 필요가 있다

npm에는 dependency 설치 과정의 script 실행을 막는 옵션이 있다.

npm ci --ignore-scripts

ignore-scripts=true로 설정하면 package.json에 정의된 install lifecycle script의 자동 실행을 차단할 수 있다. 최근 npm은 필요한 package의 install script만 허용하는 allowScripts 계열 정책도 제공한다.

물론 현실적으로 모든 프로젝트가 이렇게 간단하게 끝나지는 않는다.

native module이나 binary download 등의 이유로 실제 install script가 필요한 dependency도 존재하기 때문이다.

그래서 더 현실적인 방향은

모든 script 허용

에서

필요한 script만 허용

으로 모델을 바꾸는 것이다.

Security에서 allowlist가 자주 등장하는 이유와 같다.


8. 새 버전을 바로 설치하지 않는 것도 방어다

조금 재미있는 방어법도 있다.

패키지가 배포된 직후 일정 기간 동안 설치 후보에서 제외시키는 것이다.

현재 npm에는 min-release-age 설정이 존재한다.

예를 들어:

# .npmrc
min-release-age=7

이면 새로 발표된 버전이 최소 7일이 지나기 전에는 dependency resolution 후보에 들어가지 않도록 할 수 있다. npm 공식 문서에서도 이를 지원한다.

왜 이게 효과가 있을까?

공급망 malware 중 상당수는 이런 싸움이기 때문이다.

악성 package publish
       ↓
개발자 install
       ↓
보안업체 발견
       ↓
registry에서 삭제

공격자가 패키지를 배포하고 탐지되기까지 2시간이 걸린다면,

나는 그 2시간 동안 새 버전을 설치하지 않으면 된다.

물론 zero-day나 오래 잠복하는 악성 버전에는 효과가 없다.

그래도 dependency update에 반드시 실시간성이 필요한 서비스가 아니라면 꽤 값싼 방어층이다.


9. 이미 설치했다면 node_modules만 지우면 될까?

아니다.

이 부분은 특히 중요하다.

GitHub Advisory Database는 악성 keyv@6.0.0 및 관련 패키지가 설치된 시스템을 완전히 compromise된 것으로 취급할 것을 권고했다.

단순히 package를 제거했다고 모든 악성코드가 제거됐다고 보장할 수 없기 때문이다.

즉 사고 대응은

rm -rf node_modules
npm install

수준에서 끝날 문제가 아니다.

개발 PC나 CI runner에서 실제 악성 버전이 실행됐다면,

  • GitHub credential

  • npm credential

  • cloud credential

  • SSH key

  • CI/CD secret

등 노출 가능성을 전제로 incident response를 해야 한다.

그리고 credential rotation 역시 감염된 머신이 아니라 신뢰할 수 있는 깨끗한 환경에서 수행하는 것이 안전하다.


10. 결국 우리가 신뢰했던 것은 패키지가 아니라 '신뢰의 연쇄'였다

npm에서

npm install some-package

를 입력하는 행위는 사실 한 사람을 믿는 것이 아니다.

우리는 동시에 꽤 많은 것을 믿는다.

Maintainer
    ↓
GitHub account
    ↓
Repository
    ↓
Pull Request
    ↓
GitHub Actions
    ↓
OIDC / Publish credential
    ↓
npm Registry
    ↓
Dependency resolver
    ↓
Lifecycle script
    ↓
CI runner

이 중 하나만 공격자가 장악해도 문제가 시작될 수 있다.

그리고 현대 JavaScript 개발에서는 dependency가 수백 개에서 수천 개까지 늘어난다.

결국 supply-chain security의 핵심은

"신뢰하지 않는다."

가 아니라

"우리가 정확히 무엇을 신뢰하고 있는지 알고 있는가?"

에 더 가깝다고 생각한다.


내가 이번 사건 이후 CI에서 확인할 것들

개인적으로는 최소한 아래 정도는 다시 점검할 것 같다.

[ ] CI에서는 npm install 대신 npm ci 사용
[ ] lockfile 반드시 commit
[ ] dependency update PR의 transitive dependency 변경 확인
[ ] install lifecycle script 최소화
[ ] 가능한 환경에서 --ignore-scripts 적용
[ ] 필요한 install script는 allowlist 방식으로 관리
[ ] 신규 package version에 release-age 정책 적용 검토
[ ] CI credential 최소 권한화
[ ] npm / GitHub publish credential 최소 권한화
[ ] dependency update와 production deployment 분리
[ ] 개발 PC와 CI에서 secret 노출 범위 최소화
[ ] AI coding agent configuration도 보안 검사 대상에 포함

그리고 한 가지.

npm audit

만 통과했다고 안심하지 않는 것.

이번 사건은 전형적인 CVE 하나가 생겨서 특정 함수를 호출할 때 exploit되는 종류의 취약점이 아니었다.

패키지 그 자체가 malware였다.


마무리

오픈소스를 쓰지 않는 현대 개발은 사실상 불가능하다.

그러니 해결책은 dependency를 없애는 것이 아니다.

대신 dependency를 실행 가능한 외부 코드로 취급해야 한다.

우리는 서버에서 인터넷에서 받은 임의의 shell script를 바로 실행하라고 하면 당연히 경계한다.

그런데 이상하게도

npm install

앞에서는 그 경계심이 많이 사라진다.

이번 Shai-Hulud 사건은 그 차이가 사실 존재하지 않는다는 것을 아주 크게 보여줬다.

package-lock.json도 필요하다.

Provenance도 필요하다.

2FA도 필요하다.

Trusted Publishing도 필요하다.

하지만 어느 하나도 단독으로

safe = true

를 만들어주지는 않는다.

결국 공급망 보안은 하나의 완벽한 방어책이 아니라,

공격자가 통과해야 하는 신뢰 경계를 최대한 많이 만드는 작업에 가깝다.

그리고 앞으로는 npm install 역시 우리가 생각했던 것보다 훨씬 더 보안에 민감한 명령어로 봐야 할 것 같다.

끝까지 읽었네… 메밀 승인 🐾
+