728x90

지난 분기에 우리 팀에서 실제로 있었던 일이다. 인증 흐름 하나 바꾸는 작업을 RFC로 먼저 쪼갰다. 이슈 6개, PR 6개. 3번째 PR을 머지하고 나서야 "아, 2번에서 만든 인터페이스로는 프론트에서 못 쓴다"는 걸 알았다. 앞의 두 개를 되돌리고 다시 만들었다. 리뷰어 시간까지 합치면 이틀은 날아갔다.

GeekNews에 올라온 "넓게 만들고, 좁게 배포하라(Build Wide, Ship Narrow)"는 딱 이 문제를 건드린다. 핵심 주장은 간단하다. 무엇을 만들지는 구현 전에 정하되, 어떻게 PR로 쪼갤지는 구현이 끝난 뒤에 정하자.

왜 우리는 코드도 없는데 PR 경계를 먼저 정해왔나

전통적 흐름은 RFC 작성 → 작은 이슈 분해 → 순차 구현이었다. 합리적으로 보이지만 근본적 문제가 있다. 아직 코드가 한 줄도 없는, 정보가 가장 부족한 시점에 구조적 경계를 결정해야 한다는 것이다.

  • 어떤 부분이 실제로 분리 가능한지 모른다
  • 각 부분의 복잡도를 추측만 할 수 있다
  • 뒷 단계에서 발견한 문제가 앞 설계를 뒤집을지 알 수 없다

그런데도 왜 미리 쪼갰나? 원문은 이렇게 답한다. 완성된 브랜치를 나중에 작은 리뷰 단위로 다시 푸는 일이 너무 비쌌기 때문이다. 사전 분해는 구현을 쉽게 하려던 게 아니라 "사람이 리뷰할 수 있는 크기"를 확보하려던 현실적 타협이었다.

AI가 바꾼 건 딱 이 비용이다. 원문이 꼽는 싸진 것 세 가지: 구현, 설계 검토, 그리고 완성된 작업의 재분해. 일주일간 얽혀 만든 브랜치를 작은 PR 여러 개로 다시 나누는 게 프롬프트로 처리 가능한 수준이 됐다는 것이다.

반대로 안 싸진 것도 명확하다. 코드 리뷰의 판단 부분(이 코드가 여기 있는 게 맞나, 이 API가 6개월 뒤에도 괜찮나)과 제품 검증이다. 봇이 PR을 승인하는 것과 사람이 코드를 이해하는 것은 다르다는 문장이 이 글의 핵심이라고 본다.

동작 원리: 작업 브랜치는 '버려도 되는 연습장'

전체 흐름은 다섯 단계다.

  1. 설계 검토 — 계획에 실제 결정이 충분히 들어갈 때까지. 새 설계면 명세를 코드보다 먼저 커밋
  2. Build Wide — 브랜치 하나에서 백엔드/프론트 가로질러 끝까지 구현
  3. 데모 먼저 — PR 열기 전에 Slack 영상이나 프리뷰 배포로 제품 피드백
  4. Ship Narrow — 완성된 코드가 드러낸 경계로 작은 PR 재구성
  5. 정리 PR — 기존 코드 삭제는 마지막 별도 PR

여기서 인프라 하는 사람이 가장 거부감 느낄 지점이 2번이다. 원문 저자는 리팩터링 하나에서 하루 동안 수십 개 파일을 고치며 커밋 12개 이상을 만들었다고 한다. Git 이력 깔끔하게 관리하던 사람 입장에선 끔찍하다.

근데 전제가 다르다. 이 작업 브랜치의 이력은 최종 기록으로 남지 않는다. 최종 PR은 전부 main에서 새로 만든다. 작업 브랜치는 버리는 연습장이고, 커밋은 "위험한 시도 전 되돌아갈 지점" 용도다. 구현 중 필요한 이력과 리뷰어가 읽어야 할 이력을 분리하는 것이다.

실제 분해는 이런 식으로 간다. 원문의 5개 PR 사례:

# 작업 브랜치(feat/wide)에서 기능 완성 후, main 기준으로 PR별 worktree 생성
git worktree add ../pr1-backend-a -b pr/backend-endpoint-a main
git worktree add ../pr2-backend-b -b pr/backend-endpoint-b main

# 프론트는 실제 의존성이 있으므로 해당 백엔드 PR 위에 스택
git worktree add ../pr3-front-a -b pr/frontend-view-a pr/backend-endpoint-a

# 삭제만 담당하는 마지막 PR
git worktree add ../pr5-cleanup -b pr/remove-legacy-path main

git worktree list
/home/dev/app              a1b2c3d [main]
/home/dev/pr1-backend-a    a1b2c3d [pr/backend-endpoint-a]
/home/dev/pr2-backend-b    a1b2c3d [pr/backend-endpoint-b]
/home/dev/pr3-front-a      e4f5g6h [pr/frontend-view-a]
/home/dev/pr5-cleanup      a1b2c3d [pr/remove-legacy-path]

독립적인 백엔드 엔드포인트 2개는 각각 main에서 직접 분기, 프론트 뷰 2개는 자기가 쓰는 백엔드 위에 스택, 마지막 PR은 기존 경로 삭제만. 원문에 따르면 이 마지막 삭제 PR에서 제거한 코드가 새 기능 전체가 추가한 코드보다 수백 줄 더 많았다고 한다.

이 사례가 말하는 게 뭐냐면, "기존 코드 삭제 PR"은 사전 분해로는 절대 못 만드는 단위라는 것이다. 새 경로를 전부 만들고 동작을 확인해야만 무엇을 지울 수 있는지 알 수 있으니까.

실무 관점: 어디에 쓰고 어디서 터지는가

잘 맞는 작업은 원문 기준으로 세 가지다. 백엔드/프론트를 가로지르는 기능, 끝까지 해보기 전엔 구조를 모르는 리팩터링, 사전에 경계를 정하려면 추측만 가능한 작업.

덜 맞는 작업이 더 중요하다. 인프라 하는 입장에선 여기가 지뢰밭이다.

  • 운영 순서가 제약인 마이그레이션과 스키마 변경 — 어떤 변경을 먼저 적용해야 하는지가 실제 제약이다. 사후에 예쁘게 쪼개봐야 배포 순서는 여전히 강제된다
  • 처음부터 나눌 위치가 명백한 작업 — 굳이 왕복할 이유가 없다
  • 첫 단계만 따로는 절대 배포 못 하는 기능 — 사후 분해로 얻는 건 점진적 리뷰뿐이다

세 번째 항목은 원문도 솔직하게 인정한다. 예시로 든 5개 PR은 글 쓰는 시점에도 전부 열려 있었고, 결국 같은 날 다 머지될 수도 있다고 한다. PR을 잘 나눴다고 자동으로 점진 배포가 되는 게 아니다. "리뷰하기 쉬움"과 "독립적으로 머지할 가치가 있음"은 다른 조건이고, 에이전트가 도와줄 수 있는 건 앞의 것뿐이다.

흔한 함정 1: 편하다고 스택하기

원문 규칙은 명확하다. 실제 의존성이 있을 때만 스택한다. 단순히 편해서 스택하면 아래 PR에 리뷰 피드백이 들어오는 순간 위쪽 브랜치 전부를 연쇄 리베이스해야 한다. 실무에서 이렇게 터진다.

$ git rebase pr/backend-endpoint-a
Auto-merging src/api/client.ts
CONFLICT (content): Merge conflict in src/api/client.ts
error: could not apply e4f5g6h... feat: add frontend view A
hint: Resolve all conflicts manually, mark them as resolved with
hint: "git add/rm <conflicted_files>", then run "git rebase --continue".
Could not apply e4f5g6h... feat: add frontend view A

3~4단 스택에서 이게 나면 아래에서부터 순서대로 다 풀어야 한다. 실제 의존성이 없는데 편의로 쌓아둔 거면 그냥 손해다. 원문은 최초 분해를 수행한 채팅 세션을 유지해두면 그 컨텍스트로 위쪽 브랜치 업데이트도 다시 맡길 수 있다고 하는데, 이건 도구 의존적인 얘기라 각자 환경에서 검증이 필요해 보인다.

흔한 함정 2: 데모를 건너뛰기

이게 실무에서 가장 많이 생략되는 단계인데, 사실 이 방식의 절반이다. 코드 리뷰 단계에서 "UI가 헷갈린다", "이 API 프론트에서 쓰기 불편하다"는 피드백이 나오면 리뷰어 시간과 개발자 시간이 동시에 날아간다. 데모 단계에서 나오면 코드 구조를 크게 바꿔도 부담이 없다.

인프라 관점에서 이건 PR 프리뷰 환경이 있냐 없냐의 문제로 직결된다. 없으면 이 워크플로 자체가 안 돌아간다. CI에서 PR 브랜치별 임시 환경을 띄우는 파이프라인이 선행 조건이라고 보면 된다.

# .github/workflows/preview.yml (핵심만)
on:
  push:
    branches: ['feat/**']   # PR 열기 전 작업 브랜치도 대상
jobs:
  preview:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Deploy preview
        run: |
          NS="pv-$(echo $GITHUB_REF_NAME | tr '/' '-' | cut -c1-40)"
          kubectl create ns "$NS" --dry-run=client -o yaml | kubectl apply -f -
          helm upgrade --install app ./chart -n "$NS" \
            --set image.tag=$GITHUB_SHA --set ingress.host=$NS.preview.example.com
          echo "https://$NS.preview.example.com"

포인트는 on.push.branches에 PR이 아닌 작업 브랜치를 넣었다는 것이다. Build Wide 단계에선 PR이 아직 없으니까. 다만 네임스페이스 무한 증식은 막아야 한다. TTL 붙여서 자동 정리하는 job은 별도로 필요하다.

흔한 함정 3: 분해를 AI에 맡기고 안 읽기

원문이 강조하는 부분인데, PR로 나누는 과정 자체가 작성자 본인의 첫 코드 리뷰다. diff를 머릿속에 담을 수 있는 크기로 하나씩 확인하는 과정이 "AI가 쓴 코드를 그냥 배포한 것"과 "이해하고 배포한 것"을 가른다. 여기서 분해까지 통째로 자동화하고 넘기면 그냥 2,000줄 PR을 다섯 조각 낸 것에 불과하다.

그리고 오해하기 쉬운 게, Build Wide가 "모든 PR이 작아진다"는 뜻이 아니다. 새 데이터 모델, 새 API 표면, 신뢰 경계가 걸린 백엔드 PR은 여전히 무겁고 무거워야 한다. 목표는 균일한 크기가 아니라 리뷰어의 주의를 실제 위험이 있는 곳에 몰아주는 구조다.

대안 — 안 바꿔도 되는 경우

기존 트렁크 기반 개발 + 피처 플래그 조합이 이미 잘 돌고 있고, 도메인이 안정적이라 경계가 자명하다면 굳이 갈아탈 이유가 없다. 이 방식이 값을 하는 건 "해보기 전엔 모르겠는" 작업이다. 반대로 팀에 프리뷰 환경도 없고 리뷰가 며칠씩 밀리는 상황이면, 이 워크플로 도입보다 리뷰 SLA부터 잡는 게 순서다.

정리

한 줄로: 구현 전에 추측으로 하던 PR 경계 결정을, 실제 코드가 존재하는 시점까지 미루는 것. 구조적 판단을 없애는 게 아니라 뒤로 옮기는 거다. 설계를 건너뛰라는 얘기가 절대 아니고, 오히려 원문 저자는 편집기 열기 전에 계획을 세우고 새 설계면 명세를 문서 PR로 먼저 머지해 팀이 이의 제기할 기회를 준다고 한다.

이런 팀에 권한다.

  • AI 코딩 에이전트를 이미 일상적으로 쓰고 있는데 PR이 자꾸 비대해지는 팀
  • 풀스택 기능 하나 만드는 데 이슈를 5~6개로 쪼개다가 중간에 설계가 뒤집힌 경험이 있는 팀
  • PR 프리뷰 환경이 이미 있거나, 만들 여력이 있는 팀

반대로 스키마 마이그레이션이 주 업무거나, 배포 순서가 규제로 정해져 있는 도메인이면 얻는 게 별로 없다. 그리고 도입한다면 프리뷰 환경 → 데모 문화 → 분해 자동화 순서로 가는 걸 권한다. 마지막 것부터 손대면 그냥 큰 PR을 잘게 썬 것으로 끝난다.

참고 자료

728x90
728x90

어제 팀 슬랙에 이런 질문이 올라왔다. "AI가 pip install 하라고 알려준 패키지가 있는데, 검색해도 GitHub이 안 나와요. 이거 써도 되나요?" 나는 순간 등골이 서늘했다. 이게 정확히 요즘 뜨는 slopsquatting 공격이 노리는 그 순간이기 때문이다.

이 글에서는 slopsquatting이 뭔지, 왜 우리가 평소 쓰던 방어책이 이걸 못 막는지, 그리고 CI/CD 파이프라인에 실제로 어떤 게이트를 걸어야 하는지를 실무자 입장에서 정리한다.

1. 왜 지금 이게 문제인가

Typosquatting은 오래된 수법이다. express 옆에 expres를, python-dateutil 옆에 python-dateutl을 등록해두고 누군가 오타를 내길 기다린다. 성공률은 낮다. 대부분은 철자를 제대로 친다. 공격자는 실수하는 극소수를 낚는 낚시질을 하는 셈이다.

Slopsquatting은 이 "인간의 실수"라는 전제를 완전히 없앤다. Python Software Foundation의 Seth Larson이 2025년에 이름 붙인 개념인데, 핵심 차이는 이렇다.

  • Typosquatting: 당신의 오타에 베팅한다. 공격자가 당신 손가락이 어디로 미끄러질지 추측한다.
  • Slopsquatting: 당신의 AI 어시스턴트에 베팅한다. 공격자는 추측하지 않는다. 모델이 실제로 뱉는 출력을 대규모로 관찰하고, 반복해서 나오는 이름을 등록한다.

여기서 소름 돋는 부분은, 개발자는 이름을 정확하게 친다는 거다. 오타가 아니라, AI가 지어낸 이름을 그대로 복사해서 붙여넣기 때문이다. 실수는 이미 상류(모델)에서 발생했고, 사람은 그걸 충실히 재현할 뿐이다.

2. 동작 원리: AI가 없는 패키지를 추천하는 순간

이런 상황을 상상해보자. requests로 OAuth2 토큰을 깔끔하게 붙이는 방법을 물었더니 AI가 이렇게 답한다.

pip install requests-oauth2-helper

이름이 완벽하다. 케이싱도 관용적이고, 하이픈 위치도 PyPI 생태계 관례에 맞고, 실제로 있을 법한 헬퍼 패키지 오십 개와 똑같은 느낌이 난다. 그래서 그냥 실행한다. 테스트 통과. 다음 작업으로 넘어간다.

문제는 requests-oauth2-helper가 모델이 학습하던 시점에 존재하지 않았다는 것이다. 모델이 지어냈다. 그리고 공격자가 주의 깊게 지켜보고 있었다면, 그 이름은 더 이상 빈자리가 아니다.

왜 이게 실제 위협인가: 환각은 자주, 그리고 반복해서 발생한다

USENIX Security 2025의 We Have a Package for You! 연구가 이 공격의 경제성을 뒤집는 숫자를 내놨다. 16개 LLM으로 Python/JavaScript 코드 샘플 57만 6천 개를 생성해 추천된 패키지를 전부 검증했더니:

  • 추천된 패키지의 19.7%가 존재하지 않았다. 다섯 개 중 하나꼴이다.
  • 구별되는 환각 패키지 이름이 205,474개 로깅됐다.
  • 상용 모델은 평균 최소 5.2%, 오픈소스 모델은 최소 21.7%. 깨끗한 모델은 없었다.

여기까지면 그래도 관리 가능하다. 환각이 매번 다른 눈송이라면 공격자는 20만 개를 다 등록할 수도 없고, 어떤 게 다시 나올지도 모르니까. 그런데 진짜 무서운 발견은 재현성이다.

  • 가짜 패키지를 만들어낸 프롬프트 500개를 각각 10번씩 더 돌렸더니, 43%가 매번 똑같이 다시 나왔다.
  • 58%는 두 번 이상 등장했다.
  • 39%만 다시는 안 나타났다.

이게 경제성을 완전히 바꾼다. 공격자는 전부 등록할 필요가 없다. 모델 출력을 채굴해서 반복적으로 나오는 이름만 골라 등록하면 된다. 재현성 자체가 정찰(reconnaissance)이 되는 셈이다. 모델이 공격자에게 "미래의 개발자가 어떤 가짜 이름을 받게 될지"를 반복해서 알려준다.

게다가 이름이 진짜 패키지처럼 안 생겨도 된다. Levenshtein distance로 분석했더니 환각 이름 중 단순 오타 형태는 13%뿐이었다. 약 38%는 중간 정도 유사, 나머지 절반 가까이는 완전히 지어냈지만 문맥상 그럴듯한 이름이었다. 이 마지막 그룹이 기존 typosquat 탐지를 그대로 통과한다.

킬 체인 4단계

  1. 모델이 import를 제안한다. 문제 모양에 맞는 의존성 이름을 뱉는다. 지어낸 이름이지만 문법은 완벽하다.
  2. 이름이 그럴듯해서 신뢰한다. 이게 하중을 지탱하는 단계다. 기술이 아니라 심리 문제다. 자신감 넘치는 시니어의 PR을 통과시키듯, 자신감 넘치는 AI 출력도 경계를 뚫는다.
  3. install이 코드를 실행한다. npm과 pip은 설치만으로 스크립트가 자동 실행된다. postinstall 훅, setup.py 빌드 스텝. import하거나 함수를 호출할 필요도 없다. install이 끝났으면 코드는 이미 돌았다.
  4. 크리덴셜이 빠져나간다. 페이로드가 빌드 에이전트에 늘 굴러다니는 것들(환경변수, ~/.aws/credentials, ~/.npmrc 토큰, GITHUB_TOKEN, .env)을 읽어 공격자 엔드포인트로 POST한다. 밖에서 보면 그냥 install 중 메타데이터 받아오는 것처럼 보인다.

이건 이론이 아니다: huggingface-cli 사례

Lasso Security의 Bar Lanyado가 모델들이 huggingface-cli라는 Python 패키지를 반복적으로 환각하는 걸 발견했다. 그래서 실험 삼아 그 이름으로 빈 패키지를 PyPI에 등록했다. 3개월간 실제 다운로드 15,000건 이상이 발생했고, Alibaba의 GraphTranslator 프로젝트 README가 pip install huggingface-cli를 추천하기까지 했다. 진짜 도구는 pip install -U "huggingface_hub[cli]"로 설치하는데도 말이다. Lanyado의 패키지는 일부러 무해했다. slopsquatter의 것은 아닐 거다.

3. 실무 관점: 생태계별 위험도와 흔한 함정

"JavaScript, PHP, Go 다 위험함"으로 뭉뚱그리면 방어 예산을 어디 쓸지 모른다. 생태계마다 공격자에게 주는 밧줄 길이가 다르다.

npm — 가장 위험

lifecycle 스크립트(preinstall, install, postinstall)가 npm install 시 자동 실행된다. 고전적인 벡터다.

{
  "name": "requests-oauth2-helper",
  "version": "1.0.3",
  "scripts": {
    "postinstall": "node ./collect.js"
  }
}

이 때문에 생태계가 움직이기 시작했다. pnpm v10은 2025년 초 의존성 lifecycle 스크립트를 기본 비활성화했고, npm도 v12에서 자동 스크립트 실행을 기본 끄기로 했다(2026년 6월 발표). 해당 버전으로 올리기 전까지 postinstall은 CI를 겨눈 장전된 총이다.

pip — 그 다음

source distribution은 설치 시 setup.py를 실행해 메타데이터/빌드를 처리한다. 즉 pip install만으로 임의 코드가 돈다. 반면 wheel(.whl)은 설치 시점 코드를 그렇게 돌리지 않는다. 그래서 --only-binary가 실제 하드닝 레버가 된다.

# source 빌드 거부, 사전 빌드된 wheel만 허용
pip install --only-binary :all: requests-oauth2-helper

Composer — 설계상 가장 안전

Composer는 root 패키지의 composer.json에 정의된 스크립트만 실행한다. 의존성 자신의 scripts 블록은 무시된다. 다만 플러그인이 install 이벤트를 후킹할 수 있어서, 신뢰 안 되는 트리엔 composer install --no-plugins --no-scripts를 쓴다.

Go — 나중에, 하지만 영원히 기억한다

Go엔 install 스크립트가 없다. 악성 코드는 프로그램이 실제로 실행될 때, init() 함수나 go test 시점에 돈다. 대신 module proxy가 고약한 트위스트를 준다. Socket이 발견한 BoltDB의 백도어 typosquat(github.com/boltdb-go/bolt)은 2021년 11월 업로드돼 Go 모듈 미러에 캐시됐고, 이후 Git 태그를 깨끗한 코드로 바꿔치기했는데도 프록시가 캐시된 악성 버전을 계속 서빙했다. 3년 넘게 탐지되지 않았다. 재현 가능한 빌드를 위한 캐싱이 오염된 버전도 오래 살아남게 만든 것이다.

왜 평소 방어책이 이걸 못 잡나

이 부분이 진짜 불편하다. 우리가 이미 돌리는 통제들은 대부분 다른 위협 모델용으로 만들어졌다.

  • Lockfile은 첫 설치 이후에만 돕는다. package-lock.json이나 composer.lock은 이미 검증하고 락한 패키지 버전을 고정한다. 하지만 slopsquatting은 완전히 새 이름의 첫 설치를 노린다. lockfile엔 아직 아무것도 없다. AI가 30초 전에 추천했으니까. lockfile은 처음 받아온 악성 버전을 충실히 기록하고 이제 그걸 핀으로 박아버린다. Lockfile은 연속성을 보호하지 첫 접촉을 못 막는다.
  • 스캐너는 known-bad를 찾는데 이건 never-seen이다. 어제 등록된, 이번 분기부터 반복되기 시작한 환각을 노린 패키지엔 CVE도, 권고문도, 평판도, 이력도 없다. 부재로 인해 "깨끗해" 보인다.
  • Typosquat 탐지는 edit distance로 잡는데 절반은 아무것도 안 닮았다. 앞서 말한 대로 환각 이름의 절반 가까이가 원본과 매우 다르다. 문자열 유사도 필터를 그대로 통과한다.

흔한 함정: 실제로 만나는 에러

AI가 준 이름을 실행했는데 진짜로 없는 패키지면 그나마 다행이다. 이런 에러가 뜬다.

$ pip install requests-oauth2-helper
ERROR: Could not find a version that satisfies the requirement requests-oauth2-helper (from versions: none)
ERROR: No matching distribution found for requests-oauth2-helper

npm이면 이렇게 나온다.

$ npm install requests-oauth2-helper
npm error code E404
npm error 404 Not Found - GET https://registry.npmjs.org/requests-oauth2-helper - Not found
npm error 404
npm error 404  'requests-oauth2-helper@*' is not in this registry.

여기서 진짜 함정. 이 404가 뜨면 대부분의 개발자는 "아 이건 아직 없나 보네" 하고 다른 이름을 찾는다. 문제는 공격자가 이미 그 이름을 등록해둔 경우다. 그러면 404가 아니라 설치가 정상적으로 성공한다. 즉 "설치가 됐다 = 안전한 진짜 패키지다"라는 등식이 성립하지 않는다. 설치 성공은 아무것도 보장하지 않는다. 이 심리적 함정을 파이프라인 게이트로 막아야 하는 이유가 여기 있다.

4. 방어: 설치 전에 패키지 실재성을 검증하는 게이트

핵심 아이디어는 단순하다. install이 코드를 실행하기 전에, 이 패키지가 애초에 사람이 선택할 만한 실재하는 물건인지 먼저 확인한다. 아래는 PyPI JSON API로 패키지 존재/나이/다운로드를 사전 검증하는 스크립트다.

#!/usr/bin/env bash
# check-pkg.sh — 설치 전 PyPI 패키지 실재성 검증
set -euo pipefail

PKG="$1"
API="https://pypi.org/pypi/${PKG}/json"

HTTP=$(curl -s -o /tmp/pkg.json -w "%{http_code}" "$API")

if [ "$HTTP" = "404" ]; then
  echo "❌ '${PKG}' : PyPI에 존재하지 않음. AI 환각 가능성 높음. 설치 중단."
  exit 1
fi

if [ "$HTTP" != "200" ]; then
  echo "⚠️  API 조회 실패 (HTTP ${HTTP}). 수동 확인 필요."
  exit 2
fi

# 최초 릴리스 시점과 프로젝트 URL 확인
UPLOAD=$(python3 -c "import json;d=json.load(open('/tmp/pkg.json'));r=d['releases'];print(min((f['upload_time'] for v in r.values() for f in v), default='unknown'))")
HOME=$(python3 -c "import json;d=json.load(open('/tmp/pkg.json'));print(d['info'].get('home_page') or d['info'].get('project_url') or 'none')")

echo "✅ '${PKG}' 존재함"
echo "   최초 업로드: ${UPLOAD}"
echo "   프로젝트 URL: ${HOME}"
echo "   ⚠️ 등록일이 최근(수일~수주)이고 URL이 없으면 slopsquat 의심."

실행 결과는 이렇게 나온다.

$ ./check-pkg.sh requests-oauth2-helper
❌ 'requests-oauth2-helper' : PyPI에 존재하지 않음. AI 환각 가능성 높음. 설치 중단.

$ ./check-pkg.sh requests
✅ 'requests' 존재함
   최초 업로드: 2011-02-14T15:33:52
   프로젝트 URL: https://requests.readthedocs.io
   ⚠️ 등록일이 최근(수일~수주)이고 URL이 없으면 slopsquat 의심.

포인트는 단순 존재 여부만 보지 않는다는 거다. huggingface-cli 사례처럼 공격자가 이미 등록했다면 존재는 한다. 그래서 "최초 등록일이 최근이고, 프로젝트 URL/GitHub이 없고, 다운로드 이력이 얕다"는 신호를 함께 본다.

CI/CD 파이프라인에 게이트 걸기

이 검증을 requirements.txt 전체에 돌리고, 하나라도 걸리면 빌드를 실패시킨다. GitHub Actions 예시다.

name: dep-sanity
on: [pull_request]

jobs:
  verify-packages:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Verify each requirement exists on PyPI
        run: |
          fail=0
          while read -r line; do
            # 주석/빈 줄 스킵, 버전 지정자 제거
            pkg=$(echo "$line" | sed 's/[<>=!~;].*//' | tr -d '[:space:]')
            [ -z "$pkg" ] && continue
            [[ "$pkg" == \#* ]] && continue
            code=$(curl -s -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/${pkg}/json")
            if [ "$code" = "404" ]; then
              echo "::error::존재하지 않는 패키지: ${pkg}"
              fail=1
            else
              echo "ok: ${pkg}"
            fi
          done < requirements.txt
          exit $fail

여기에 더해 실무에서 같이 거는 레버들:

  • pip은 --only-binary :all:로 source 빌드(=install 시 코드 실행)를 원천 차단한다. wheel 없는 패키지는 CI에서 빌드 실패시키고 사람이 검토하게 한다.
  • npm은 npm ci --ignore-scripts로 lifecycle 스크립트를 끄고 설치한다. 정말 postinstall이 필요한 신뢰 패키지만 allowlist로 관리한다.
  • 내부 프록시/미러를 둔다. 개발자가 public registry에 직접 붙지 않게 하고, 승인된 패키지만 통과시킨다. 첫 접촉 자체를 게이트 뒤로 밀어넣는 방식이다.
  • lockfile + hash 검증은 첫 접촉을 못 막지만, 한번 검증한 뒤엔 반드시 걸어라. 연속성 보호는 여전히 유효하다.

트레이드오프

PyPI/npm API를 PR마다 호출하면 rate limit과 빌드 지연이 생긴다. 큰 의존성 트리에선 캐싱이 필수다. 그리고 이 게이트는 "존재 여부"까지만 자동화할 수 있고, "이 패키지가 정말 내가 의도한 그 물건인가"라는 최종 판단은 결국 사람 몫으로 남는다. 자동 게이트를 만능으로 착각하면 안 된다.

728x90

+ Recent posts