낯선 레포에서 위험한 건 코드가 아니라 거기 딸려오는 의존성입니다. 2026년 7월 8일 정식 릴리스된 npm 12도 의존성의 preinstall·install·postinstall과 암묵적 node-gyp 빌드를 기본 차단으로 돌렸는데, 같은 결의 공급망 공격이 배경이고요. 면접 과제 레포를 열어야 한다면 순서는 이겁니다. VS Code 제한 모드로 먼저 열고 → 컨테이너 안에서만 설치하고 → npm approve-scripts로 필요한 것만 켠다.
어제까지 멀쩡히 돌던 프로젝트가 오늘 갑자기 "이 패키지의 스크립트는 실행하지 않았다"는 잔소리를 뱉으면, 범인은 십중팔구 npm 메이저 업입니다. 저도 소식 보자마자 사내 API 서버 2대부터 확인하러 갔고요. 근데 이 변경, 빌드 깨져서 짜증나는 것치고는 배경이 꽤 무겁습니다. 발단이 채용 면접을 가장한 공급망 공격이거든요.

1. 어제까진 되던 npm install이 오늘 멈춘 이유
npm 12는 2026년 7월 8일 정식 릴리스되면서 latest 태그를 가져갔습니다. 바뀐 핵심은 의존성이 선언한 라이프사이클 스크립트가 더 이상 그냥 돌지 않는다는 것. 명시적 허용(allowScripts)이 없으면 preinstall도, install도, postinstall도 건너뜁니다. 여기에 패키지 루트에 binding.gyp만 있으면 알아서 돌던 암묵적 node-gyp 빌드까지 같이 묶였어요. 스크립트를 한 줄도 선언하지 않은 패키지가 컴파일 단계에서 코드를 실행하던 경로였으니, 개인적으로는 이쪽이 더 서늘합니다.
닫힌 문은 스크립트만이 아닙니다. Git 의존성과 원격 URL 의존성도 같이 잠겼어요.
| 바뀐 항목 | npm 12 기본값 | 메모 |
|---|---|---|
| 의존성 라이프사이클 스크립트 | 실행 안 함 | preinstall · install · postinstall |
| 암묵적 node-gyp 빌드 | 실행 안 함 | binding.gyp만 있어도 돌던 경로 |
Git 의존성 --allow-git |
none | 직접·전이 의존성 모두. npm 11.10.0+에서 미리 사용 가능했음 |
원격 URL 의존성 --allow-remote |
none | https tarball 등. npm 11.15.0+에서 미리 사용 가능했음 |
플래그 두 개가 마이너 버전에서 미리 열려 있었다는 게 포인트예요. 어느 날 뚝 떨어진 결정이 아니라, 예고하고 옵션으로 굴려보게 한 다음 기본값만 뒤집은 겁니다. 참고로 npm 공식 문서 기준 npm install에서 자동으로 도는 스크립트는 preinstall → install → postinstall → prepublish → preprepare → prepare → postprepare 순서인데, 이 중 앞의 셋이 의존성 쪽에서 막힌 거고요. 내 프로젝트 자신의 스크립트와 남의 패키지 스크립트를 구분해서 본다는 뜻입니다.
npm 11.16.0 이상으로 먼저 올려두면, 평소 하던 설치에서 "v12가 되면 이것들이 막힌다"를 경고 형태로 미리 보여줍니다. CI를 한 번에 v12로 밀어버리기 전에 이 경고 목록부터 뽑아보는 게 훨씬 덜 아픕니다.
2. 왜 하필 지금이냐면 — 면접 과제로 위장한 레포
ReversingLabs가 2026년 2월에 공개한 'Graphalgo' 캠페인을 보면 감이 옵니다. 수법은 이래요. 리크루터가 접근해서 "간단한 과제 하나만 풀어달라"며 저장소 링크를 건넵니다. 받아서 코드를 읽어보면 이상한 게 없어요. 진짜로 없습니다. 악성 코드는 저장소가 아니라 npm·PyPI 같은 정상 레지스트리에 올라간 악성 의존성 쪽에 심겨 있거든요.
그래서 소스만 훑는 코드 리뷰로는 안 걸립니다. 걸리려면 의존성 목록부터 봐야 해요.
감염 시점도 코드를 읽는 순간이 아니라 과제를 돌려보는 순간이에요. 악성 의존성이 설치에 슬쩍 끼어 들어오고, 시키는 대로 예제를 한 번 실행하면 — ReversingLabs 분석으로는 그래프 클래스의 생성자가 호출되는 시점에 — 그때 페이로드가 깨어납니다. 확인된 악성 패키지는 192개, 활동은 최소 2025년 5월까지 거슬러 올라가며, 북한 Lazarus 그룹 연계로 평가됩니다(중~높은 신뢰도). 표적은 암호화폐·블록체인 기업에 지원한 자바스크립트·파이썬 개발자였고요. 이직 준비하면서 과제 레포 몇 개 받아본 사람이라면 남 일 같지가 않을 겁니다.
마이크로소프트 보안 블로그도 2026년 3월 11일 같은 계열을 'Contagious Interview'라는 이름으로 다뤘습니다. 공격자는 암호화폐 거래·AI 솔루션 기업 리크루터를 사칭해 가짜 기술면접을 진행한 뒤, 악성 npm 패키지 실행 또는 VS Code 태스크 실행을 유도해 백도어를 심어요. 배포된 악성코드 계열로는 OtterCookie(JS 백도어), Invisible Ferret(파이썬 백도어), FlexibleFerret, BeaverTail(인포스틸러/로더)이 거론됩니다. 저장소는 GitHub·GitLab·Bitbucket 어디든 올라오고요. "깃허브 링크니까 괜찮겠지"가 안 통하는 이유죠.
마이크로소프트가 내놓은 방어책의 첫 항목이 "코딩 테스트는 격리된 환경에서 실행하라"입니다. 이어지는 항목에도 리크루터가 준 저장소를 실행 전에 검토하라, 스크립팅 런타임을 제한하라가 들어갑니다. 벤더 공식 권고 자체가 "본체에서 열지 마라"인 셈이에요.
3. npm 12만 믿고 있으면 안 되는 구멍
여기서 김이 좀 새는데, npm 12를 깔아도 다 막히진 않습니다. 당장 앞에서 본 Graphalgo부터가 그래요. 페이로드가 설치 스크립트가 아니라 과제 코드를 실행할 때 도는 구조라, npm 12의 스크립트 차단으로는 이 경로를 애초에 못 막습니다. 그리고 위 문단에 힌트가 하나 더 있었어요. "악성 npm 패키지 실행 또는 VS Code 태스크 실행" — 뒤쪽 절반은 npm과 아무 상관이 없죠. 프로젝트 폴더에 .vscode 설정을 심어두고 에디터가 알아서 실행하게 만드는 경로라서, 패키지 매니저가 아무리 조여도 손이 닿지 않습니다.
2026년 7월엔 더 얄궂은 수법도 확인됐어요. Elastic Security Labs가 REF9403으로 추적하는 같은 캠페인이 국기 아이콘 SVG 파일(AE.svg, AF.svg 같은 것들)의 HTML 주석에 base64 조각을 잘게 흩뿌려 페이로드를 숨겼습니다. 국가 선택 드롭다운에나 쓰는, 아무도 열어보지 않는 그 파일들이요. 최종 페이로드는 브라우저 자격증명 탈취·파일 탈취·RAT·클립보드 탈취로 이어지는 다단계 구성이고요. 표적은 Slack·Discord·LinkedIn 같은 공개 개발자 커뮤니티에서 구직에 관심을 보인 개발자들이었는데, Elastic 자사 커뮤니티 Slack 구성원한테까지 접근이 들어왔다고 합니다.
이 대목 읽고 등골이 좀 서늘했어요.
그러니까 npm 12는 가장 넓은 통로 하나를 닫은 것이지, 남의 레포를 안전하게 만들어주는 물건이 아닙니다. 나머지는 "무엇을 설치하느냐"가 아니라 "어디서 여느냐"의 문제로 넘어가요. 그래서 아래 순서가 필요합니다.
4. 낯선 레포, 저는 이 순서로 엽니다
1단계 — 새 창에서, 제한 모드로 먼저 연다
VS Code에는 Workspace Trust의 제한 모드(Restricted Mode)가 있습니다. 이 모드에서는 태스크·디버깅·터미널(열기 자체가 기본 차단)·워크스페이스 설정·AI 에이전트, 그리고 Workspace Trust를 지원하지 않는 확장이 통째로 비활성화돼요. 공식 문서도 "의심스러우면 폴더를 제한 모드로 두라, 신뢰는 나중에 켜도 된다"고 권고하고, 신뢰할 수 없는 파일은 새 창에서 제한 모드로 여는 것을 안전한 선택지로 제시합니다. 3번에서 얘기한 VS Code 태스크 경로가 정확히 여기서 막히고요.
저는 Claude Code 자동 실행 권한을 보수적으로 줄여둔 뒤로 이 감각이 몸에 좀 붙었는데요. 뭘 실행할지 내가 고르지 않은 상태에서 무언가 알아서 돌아가는 구성이 제일 무섭더라고요. 에디터도 똑같습니다.
2단계 — 설치와 실행은 일회용 환경 안에서만
제한 모드는 에디터 안에서 알아서 돌아가는 것들을 막아줄 뿐이라, 바깥 터미널을 열어 내가 손으로 npm install을 치면 그건 그냥 돕니다. 그래서 설치·빌드·실행은 버려도 되는 공간 안에서 해야 해요. 데브컨테이너든, 맨 도커 컨테이너든, 스냅샷 찍어둔 VM이든 상관없습니다. 중요한 건 그 공간에 브라우저 프로필·SSH 키·클라우드 자격증명·지갑이 없다는 것. 위에서 본 페이로드들이 노리는 게 정확히 그 목록이니까요.
- 과제 레포 전용 컨테이너를 하나 띄우고, 끝나면 통째로 버린다
- 호스트 홈 디렉토리를 통으로 마운트하지 않는다 (레포 폴더만)
- SSH 에이전트 포워딩·클라우드 CLI 프로필은 넣지 않는다
- 과제 제출은 컨테이너 밖에서, 코드만 복사해 나온다
GitHub Codespaces와 도커 컨테이너 연결은 VS Code가 관리형 환경으로 보고 자동 신뢰합니다. 격리라는 목적에는 맞지만, "컨테이너니까 제한 모드도 켜져 있겠지"라고 기대하면 안 돼요. 격리는 컨테이너가, 자동 실행 차단은 제한 모드가 — 역할이 다릅니다. 그래서 순서가 1단계 먼저인 겁니다.
5. 차단된 스크립트, 뭘 켜고 뭘 버릴까
격리 환경까지 들어왔으면 이제 설치를 해봅니다. npm 12에서 차단된 스크립트를 다루는 명령은 npm approve-scripts예요. 이름 그대로 승인 절차인데, 읽기 전용으로 목록만 보는 모드가 따로 있다는 게 실무에서 훨씬 유용합니다.
3단계 — 먼저 목록만 읽는다
npm approve-scripts --allow-scripts-pending는 package.json을 건드리지 않는 읽기 전용 모드입니다. "아직 allowScripts 정책에 포함되지 않은" 패키지만 쭉 나열해 줘요. 여기서 이름을 하나하나 봅니다. 네이티브 빌드가 필요한 게 뻔한 패키지가 올라오면 납득이 가고, 처음 보는 유틸리티가 postinstall을 요구하면 그때부터 의심해도 늦지 않죠. 면접 과제 레포에서는 이 목록이 사실상 "이 레포가 내 머신에서 뭘 실행하려 했는지" 명세서가 됩니다. 저라면 이 화면을 캡처해두고 나서 다음 단계로 갑니다.
4단계 — 필요한 것만 승인하고 버전째 커밋
승인은 npm approve-scripts <pkg>로 개별로, 또는 --all로 한꺼번에 할 수 있습니다. 결과는 package.json의 allowScripts 필드에 기록되고, 기본값이 pkg@1.2.3처럼 버전까지 고정돼요(--no-allow-scripts-pin으로 해제 가능). 이 기본값이 은근히 중요한데, 지금 승인한 건 그 버전이지 그 패키지의 미래 전부가 아니라는 뜻이니까요. 낯선 레포에서 --all은 결국 예전 npm으로 되돌리는 스위치라, 쓸 거면 사내 모노레포처럼 출처가 확실한 곳에서만 쓰는 게 맞습니다.
(여담인데) 저는 매직넘버를 상수로 빼두는 버릇이 있어서 그런지, 이 버전 고정이 유독 반갑더라고요. "저 패키지가 언젠가 2.0에서 postinstall에 뭘 넣더라도 나는 1.2.3만 승인했다"가 파일로 남는 거잖아요. 리뷰어도 diff에서 바로 보이고요.
6. 그래서 우리 회사 서버랑 CI는 어떻게 했냐면
개인 프로젝트는 nvm으로 갈아끼우면 그만인데, 회사 쪽은 얘기가 다릅니다. 사내 API 서버 2대는 도커 베이스 이미지 태그로 런타임을 고정해두는 구조라, 이미지 태그를 올리는 순간 npm 메이저도 슬쩍 따라 올라와요. 그날 CI가 "네이티브 모듈 빌드에 실패했다"고 뻗는 그림, 상상하기 어렵지 않죠. 그래서 저는 순서를 뒤집어 잡았습니다.
먼저 npm 11.16.0 이상으로 올려 경고만 받아보고, 그 목록으로 allowScripts를 채워 커밋한 다음, 마지막에 메이저를 올리는 순서예요. 이러면 "무엇이 막히는가"를 확인하는 작업과 "메이저를 올리는" 작업이 분리돼서, 깨졌을 때 원인이 하나로 좁혀집니다. 3년째 도커로 굴리고 있는 팀 Gitea의 Actions 러너도 같은 순서로 갈 생각이고요.
지난번 GitHub 셀프호스티드 러너 최소 버전 강제 글에서도 비슷한 얘길 했는데, 방치된 빌드 환경은 꼭 이런 식으로 존재감을 드러냅니다. 한 번 깔고 잊은 것들이 어느 날 조용히 멈추는 거죠. 이번 npm 기본값 변경도 결이 똑같아서, 저는 그냥 "우리 팀에서 npm 메이저를 누가 언제 올리는가"를 명시해두는 계기로 쓰기로 했습니다.
이 변경, 저는 반갑습니다. 짜증나는 건 딱 첫 주뿐이고 그 뒤로는 "내 프로젝트가 남의 코드를 얼마나 실행하고 있었나"가 파일에 적혀 남으니까요. 그리고 면접 과제 레포는 회사 이름이 아무리 그럴듯해도 본체에서 열지 않습니다. 컨테이너 하나 띄우는 데 30초면 되는데, 브라우저 세션이랑 SSH 키를 걸고 도박할 이유가 없잖아요. 혹시 최근에 받아둔 과제 레포가 있다면, 오늘 npm approve-scripts --allow-scripts-pending 한 번만 돌려보세요. 목록이 생각보다 길어서 놀랄 수도 있습니다.
이 포스팅에는 쿠팡 파트너스 활동의 일환으로 일정 수수료를 제공받을 수 있습니다.
'Tech > Dev Notes' 카테고리의 다른 글
| 홈서버 서비스 늘 때마다 포트부터 열었나요? 리버스 프록시로 도메인 하나에 정리하는 법 (0) | 2026.08.04 |
|---|---|
| Claude가 CTF 평가 중 진짜 서버를 뚫었다고요? — Anthropic이 밝힌 사이버보안 사고 3건 (0) | 2026.07.31 |
| tmux 완벽 가이드 - 설치부터 .tmux.conf 설정, 세션 관리, 단축키까지 (1) | 2026.07.17 |
| 깃허브 떠난다는 글이 오늘도 1등 — 3년째 팀 Gitea 굴려보니, 광고가 말 안 하는 비용이 있더라고요 (0) | 2026.07.10 |
| 짝수 LTS만 골라 올리던 습관이 사라집니다 — Node.js 릴리스, 27부터 매년 4월 딱 하나·전부 LTS로 (0) | 2026.07.10 |