이 엔지니어를 위한 이야기 공장 AI 기술에 인문학의 온기를

패키지를 사흘 묵혔다 쓰는 게 방어가 되나?

패키지를 사흘 묵혔다 쓰는 게 방어가 되나?

깃허브가 10월 20일 패키지 방화벽(package firewall)을 퍼블릭 프리뷰로 열었습니다. 빌드 머신이나 코딩 에이전트와 npm 레지스트리 사이에 서서, 패키지를 내려받기 전에 한 번 거르는 물건입니다. 지금은 npm만 지원하고, 깃허브 엔터프라이즈 클라우드 고객이 대상이고, 프리뷰 동안은 무료입니다.

설정할 수 있는 항목 중에 쿨다운(cooldown)이 있습니다. 갓 올라온 버전을 정해 둔 기간 동안 받지 않는 기능이고요.

패치는 빨리 받으라고 이십 년 넘게 배웠는데, 이제는 새 버전을 며칠 묵혀 두는 것이 보안 기능이 됐습니다.

왜 이렇게 됐을까요?

올해 5월 19일 npm에서 벌어진 일을 보면 이해가 빠릅니다. 공격자가 패키지 547개를 관리하는 계정 하나를 손에 넣었고, 20여 분 동안 314개 패키지에 637개의 악성 버전을 올렸습니다. 사람이 하나씩 올린 게 아니라 자동화된 작업이었습니다.

올라간 코드는 preinstall 훅으로 실행됩니다. 여기를 잠깐 짚고 가죠. npm 11까지는 의존성을 내려받고 나면, 패키지가 자기 설정 파일에 적어 둔 설치 스크립트를 자동으로 실행해 줬습니다. 네이티브 모듈을 빌드하거나 바이너리를 내려받으라고 만들어 둔 자리인데요. 문제는 그게 여러분이 그 코드를 읽어 보기 전에, 테스트를 돌려 보기도 전에 먼저 실행된다는 점이었고요. (npm install 로그를 끝까지 읽어 본 적이 언제였는지 떠올려 보면 더 그렇죠.) 이번 것은 클라우드와 쿠버네티스 자격증명, SSH 키, 깃허브 토큰을 긁어 가고, 같은 머신의 다른 프로젝트에 자기를 복사해 넣었습니다. 코딩 도구 설정에도 훅을 심어서 다음 세션에 다시 깨어나게 해 뒀고요.

그런데 이런 사건은 결국 전부 발각됩니다. 보안 회사가 찾아내고, 레지스트리가 내리고, 권고 데이터베이스에 올라갑니다. 다만 그게 몇 시간에서 며칠 걸립니다. 3월 axios 침해 때는 악성 버전이 세 시간쯤 살아 있었습니다.

그러니까 공격자에게 돈이 되는 구간은 게시된 순간부터 탐지될 때까지입니다. 20분 남짓에 637개를 뿌리는 이유가 여기 있죠. 그 구간에 누가 설치해 주느냐가 전부인데, 지금 그 자리에 앉아 있는 것이 CI 러너와 코딩 에이전트입니다. 깃허브도 체인지로그에 그렇게 적었습니다. 에이전트와 CI 러너는 사람이 끼어들지 않은 채로 의존성을 설치하기 때문에 특히 취약하다고요.

쿨다운은 탐지를 빠르게 만들어 주지 않습니다. 내가 설치하는 시점을 탐지 뒤로 미룰 뿐입니다. 사흘을 기다리면 그 사흘 사이에 누군가가 먼저 발견해 주고, 나는 이미 정리된 목록을 받게 되죠.

이 발상은 깃허브만의 것이 아닙니다. Dependabot은 7월부터 기본 동작이 사흘 지난 버전만 후보로 올리는 쪽으로 바뀌었고(보안 업데이트는 예외라 바로 올라옵니다), npm에는 min-release-age가, pnpm에는 minimumReleaseAge가 생겼습니다. PyPI는 7월부터 14일보다 오래된 릴리스에 새 파일을 올리는 것 자체를 거부합니다. 각자 다른 회사가 같은 해에 같은 결론에 도달한 셈입니다.

그럼 쓰고 있던 취약점 스캐너로는 왜 안 될까요? 못 잡는다기보다, 보는 시점이 다릅니다. 스캐너는 커밋된 락파일을 읽습니다. 락파일은 '무엇을 쓸지'를 고정해 주죠. 그런데 악성 버전은 설치되는 순간에 이미 실행된 뒤입니다. 새 의존성을 추가할 때, 락파일을 갱신하며 범위 지정(^)을 다시 해석할 때, 간접 의존성이 따라 들어올 때 — 레지스트리로 나가는 길은 여전히 열려 있고, 그 길목에 사람이 없습니다. 설치 시점이 새로운 경계선이 된 건 그래서입니다.

그래서 오늘 확인해 볼 만한 것은 세 가지쯤 됩니다. 우리 CI 러너와 코딩 에이전트가 레지스트리로 직접 나가고 있는지(방화벽이든 사내 프록시든, 우회하는 경로가 하나라도 있으면 정책은 소용이 없습니다). 쿨다운 일수를 팀의 정책으로 정해 두었는지. 그리고 설치 스크립트가 꺼져 있는지. 마지막 것은 쓰고 있는 npm 버전부터 보면 되는데요. 12부터는 기본으로 꺼져 있고, 그 전 버전이라면 .npmrc에 ignore-scripts=true 한 줄입니다. 5월 사건의 실행 경로가 정확히 거기였습니다.

한 가지 마음에 걸리는 게 있습니다. 시간을 방어선으로 쓴다는 건 탐지가 따라잡을 시간을 산다는 뜻인데, 그러면 탐지가 따라잡지 못하는 공격에는 약하다는 말도 됩니다. npm이 7월부터 게시 시점에 멀웨어 검사를 돌리며 새 패키지를 보통 5분, 길면 15분 넘게 붙잡아 두기 시작했는데요. 9월에 5월의 그 페이로드가 해시까지 똑같은 채로 111일 만에 다시 올라왔고, 검사를 그대로 통과했습니다. 번 시간만큼 눈이 밝아지는 건 아니라는 뜻이죠. 쿨다운이 보편화될수록 공격자가 더 긴 시간차를 두는 쪽으로 옮겨 갈 유인도 커진다는 가정입니다. 반드시 그렇게 된다는 예측은 아니지만, 적어도 사흘이라는 숫자가 영원히 충분할 리는 없겠죠.

그리고 솔직히 말하면, 쿨다운은 구조적으로 남이 먼저 설치해서 먼저 당하기를 기다리는 전략이기도 합니다. 모두가 사흘을 걸어 두면 첫 사흘을 걷는 사람은 누구인가, 하는 질문이 남습니다. 답은 아마 쿨다운을 걸 여유가 없는 쪽이겠죠.

기다리는 것이 방어가 되는 세상이 조금 이상하긴 한데, 설치 버튼을 누르는 주체가 사람이 아니게 된 이상 자연스러운 수순 같기도 합니다. 당장은 우리 파이프라인이 레지스트리로 몇 군데서 나가고 있는지부터 세어 봐야겠네요.


커버 이미지: 데이터센터의 서버 랙 — Wikimedia Commons, Victorgrigas

#공급망보안 #npm #깃허브 #CI #AI코딩에이전트 #개발자도구