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
728x90

1. 도입: 카메라가 왜 갑자기 보안 이슈가 되는가

요즘 엔터프라이즈 CCTV는 예전처럼 "화면 찍어서 NVR에 던지는 상자"가 아니다. AXIS가 자사 카메라에서 리눅스 애플리케이션을 직접 돌릴 수 있게 밀고 있는 것처럼, 카메라 자체가 리눅스가 돌아가는 엣지 노드다. 즉 패치 관리, 크리덴셜 관리, 취약점 스캐닝 대상이 되는 서버 한 대라는 얘기다. 그런데 실무에서 우리는 이 기기들을 "그냥 벽에 붙은 카메라" 취급하고 방화벽 뒤에 던져두는 경우가 많다.

이번에 화제가 된 사례는 한화비전(구 삼성테크윈) 카메라 펌웨어에서 GitHub admin 권한 토큰이 발견된 건이다. 그것도 로그인 UI 파일 안에, 그리고 조직의 수백 개 리포지토리에 접근 가능한 관리자 토큰이. 이게 남 일 같지 않은 이유는, 여기서 벌어진 실수가 우리 CI 파이프라인에서도 똑같이 벌어질 수 있는 구조적 실수이기 때문이다. 오늘은 이 사건을 훑으면서 "펌웨어에서 시크릿을 어떻게 찾는지", "왜 이런 게 반복되는지", "그래서 우리 파이프라인은 어떻게 막아야 하는지"까지 실무 관점으로 정리해본다.

2. 핵심: 펌웨어에서 토큰이 나오기까지의 흐름

원문 저자가 밟은 경로를 실무 순서대로 재구성하면 이렇다. 이 흐름 자체가 "서드파티 하드웨어 신뢰성 검증"의 기본기라 알아두면 유용하다.

2-1. 펌웨어 blob 확보 → binwalk로 열어보기

제조사 사이트에서 모델별 펌웨어를 그냥 다운받을 수 있는 경우가 의외로 많다. 받은 이미지를 일단 binwalk로 던져서 안에 뭐가 들었는지 본다.

$ binwalk firmware.bin

DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
0             0x0             POSIX tar archive (GNU)
1245184       0x130000        gzip compressed data
...
$ binwalk -e firmware.bin   # 추출까지

여기서 저자는 내부에 AI 관련 tarball과 fwimage.tgz가 있었는데, 이게 암호화되어 있어서 binwalk가 "encrypted"로 플래그를 띄웠다고 한다. 여기까지가 1차 관문이다.

2-2. 암호화 계층 뚫기

흥미로운 건 복호화 방식이다. 저자에 따르면 fwupgrader 바이너리 안에서:

  • AES 키가 바이너리 내부의 작은 static key table과 XOR 되어 있고, 런타임에 재조립된다.
  • IV는 그냥 평문으로 박혀 있다.
  • fwupgrader가 openssl CLI로 shell out 하는데, 그 명령어 조각들조차 같은 방식으로 XOR 난독화되어 있다.

재구성된 복호화 명령은 이런 형태였다고 한다:

openssl enc -md sha256 -aes-256-cbc -d \
  -K KEY -iv IV -in INPUT -out OUTPUT

여기서 핵심 포인트는 키가 모델 라인 전체에 걸쳐 하드코딩되어 동일하다는 것. 이게 왜 문제냐면, 하드웨어에 키를 구워 넣는(fuse) 방식과 달리 기기를 소유하지 않아도 펌웨어만 있으면 누구나 복호화 가능하다는 뜻이기 때문이다. 소위 "burn key into hardware"가 완벽한 방어는 아니지만, 최소한 "카메라를 물리적으로 가진 사람만" 이라는 진입 장벽이라도 생긴다. 반면 정적 키는 그 장벽이 0이다.

2-3. rootfs 확보 후 trufflehog로 시크릿 스캔

복호화된 rootfs가 손에 들어오면, 그 다음은 우리 실무에서도 매일 쓰는 도구가 나온다. trufflehog다.

$ trufflehog filesystem ./rootfs --only-verified

🐷🔑🐷  TruffleHog. Unearth your secrets. 🐷🔑🐷

Found verified result 🐷🔑
Detector Type: Github
Decoder Type: PLAIN
Raw result: ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
File: ./rootfs/var/www/admin/assets/index-a1b2c3.js
Rotation_guide: https://howtorotate.com/docs/tutorials/github/

저자는 이 토큰이 약 30개 파일에 중복되어 있었고, 확인해보니 조직 내 수백 개 리포지토리에 admin 권한을 가진 토큰이었다고 한다. 즉 이 토큰 하나면 그 조직 GitHub의 사실상 전권을 쥘 수 있었다는 뜻이다.

2-4. 왜 30개 파일에 중복됐나 — 진짜 범인

이 부분이 이 글에서 가장 실무적으로 중요하다. 저자가 파악한 원인은 Vite 빌드 설정 실수였다. UI를 Vite로 빌드하는데, 어떤 변수에 process.env 전체를 통째로 주입하도록 설정되어 있었던 것.

var W = {
  DATAPORT: 9090,
  GIT_LFS_SKIP_SMUDGE: 1,
  npm_command: "run-script",
  KUBERNETES_SERVICE_PORT_HTTPS: 443,
  GITHUB_NPM_TOKEN: "ghp_...REDACTED...",
  npm_config_userconfig: "/home/docker/.npmrc",
  // ...
}

결과적으로 CI 잡의 환경변수 전체가 프론트엔드 번들 파일에 그대로 박혀서 출하된 것이다. 카메라 admin UI에 접속하는 사람은 이 토큰을 네트워크 너머로 전송받았을 가능성이 있다는 뜻. 덤으로 미국 국방부(DoD)에 할당된 IP 대역이 내부 서비스 env에 박혀 있는 것도 발견됐는데(한화가 방산 계열사를 두고 있는 점과 엮여 흥미로운 추측을 낳긴 했지만) 이 부분은 저자도 "SPECULATION"이라고 명시했으니 우리도 추측으로만 남겨두자.

참고로 대응은 빨랐다. 저자가 신고 메일을 보냈고 한화 측은 12시간 내에 토큰을 폐기했다고 한다. 사고 자체는 나쁘지만 대응 속도는 모범적이었다.

3. 실무 관점: 왜 반복되고, 우리는 어떻게 막나

3-1. 이 실수가 반복되는 근본 패턴

"토큰을 코드에 하드코딩하지 마라"는 다 안다. 그런데도 계속 터진다. 이유는 개발자가 손으로 넣는 하드코딩이 아니라 빌드 도구가 자동으로 환경변수를 삼켜버리는 구조이기 때문이다. 이번 케이스가 정확히 그렇다. 흔한 함정 몇 가지:

  • 프론트엔드 번들에 env 통주입: Vite/Webpack에서 define이나 DefinePluginprocess.env를 통째로 넣는 실수. Vite는 원래 VITE_ 프리픽스 붙은 것만 클라이언트에 노출하는데, 이걸 우회해서 전체를 넣으면 서버 시크릿까지 클라이언트로 샌다.
  • CI 러너 env에 장기 토큰 상주: GitHub Actions/GitLab CI 러너 환경에 오래 사는 PAT를 넣어두면, 빌드 산출물 어딘가로 새기 쉽다.
  • admin 권한 토큰 사용: npm private 패키지 하나 받자고 조직 admin 권한 PAT를 쓰는 건 최악. 필요한 최소 권한(read:packages 정도)만 줘야 한다.

3-2. 그래서 우리 CI에 시크릿 스캐너를 붙인다

가장 먼저 할 일은 "새어나가기 전에 잡는" 파이프라인 게이트다. GitHub Actions 예시:

name: secret-scan
on: [push, pull_request]

jobs:
  trufflehog:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # 전체 히스토리 스캔
      - name: TruffleHog
        uses: trufflesecurity/trufflehog@main
        with:
          extra_args: --only-verified

커밋 들어가기 전 로컬에서 막고 싶으면 pre-commit 훅으로 gitleaks를 건다:

$ gitleaks detect --source . -v

    ○
    │╲
    │ ○
    ○ ░
    ░    gitleaks

Finding:     GITHUB_NPM_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx
Secret:      ghp_xxxxxxxxxxxxxxxxxxxx
RuleID:      github-pat
File:        web/.env.production
Commit:      a1b2c3d
Author:      dev@example.com

WARN[0000] leaks found: 1

3-3. 흔한 함정 — 실제로 만나는 에러

스캐너를 붙이면 처음엔 오탐과 씨름한다. 특히 CI에서 히스토리가 얕게 clone되면 이런 에러를 만난다:

Error: TruffleHog scan failed: unable to scan commits:
git rev-parse HEAD~1: fatal: ambiguous argument 'HEAD~1':
unknown revision or path not in the working tree

원인은 대부분 actions/checkout의 기본 fetch-depth: 1 때문이다. diff 기반 스캔을 하려는데 이전 커밋이 없어서 터지는 것. 위 예시처럼 fetch-depth: 0으로 전체 히스토리를 가져오면 해결된다. 다만 리포가 크면 clone 시간이 늘어나니, PR 스캔은 base...head 범위만 스캔하도록 좁히는 게 낫다.

또 하나 자주 보는 오탐 관련 이슈:

gitleaks detect --config .gitleaks.toml
WARN leaks found: 47

테스트 픽스처나 예제 코드의 더미 값까지 다 잡혀서 47개가 뜨는 경우다. 이때 CI를 통째로 빨갛게 만들지 말고, .gitleaks.toml[allowlist]에 파일 경로/정규식을 명시적으로 등록해서 걸러야 한다. 무작정 스캐너를 끄면 진짜 시크릿도 같이 놓친다.

3-4. 공급망 관점 — 서드파티 하드웨어 도입 시 체크

이번 사건의 진짜 교훈은 "우리가 산 카메라도 이럴 수 있다"는 것이다. IoT/OT 기기를 도입할 때 실무에서 확인할 것:

  • 펌웨어가 공개 다운로드 가능한가? 가능하다면 우리도 binwalk + trufflehog로 한 번 열어보는 게 좋다(계약/법적 검토는 별도).
  • 기기가 나가는 아웃바운드 트래픽 통제: 카메라는 원칙적으로 내부 세그먼트에 격리하고, 필요한 목적지 외에는 egress를 막는다. 이번처럼 admin UI가 토큰을 브라우저로 뿌려도, 세그먼트 격리가 되어 있으면 피해 범위가 줄어든다.
  • 펌웨어 업데이트 검증: 서명 검증 없이 업데이트를 받으면, 하드코딩 키를 아는 공격자가 악성 펌웨어를 밀어넣을 여지가 생긴다.

3-5. 재발 방지 아키텍처 — 시크릿을 코드/이미지 밖으로

근본 해법은 "빌드 산출물에 시크릿이 아예 존재하지 않게" 만드는 것이다. 트레이드오프를 곁들여 비교하면:

  • SOPS + age/KMS: 시크릿을 암호화해서 git에 커밋. 관리 단순, GitOps와 궁합 좋음. 단, 복호화 키 관리가 여전히 숙제다.
  • HashiCorp Vault: 동적 시크릿·짧은 TTL 발급 가능. 이번 사건처럼 장기 admin 토큰을 아예 없앨 수 있다. 단, Vault 자체 운영 부담이 크다.
  • External Secrets Operator(ESO): 쿠버네티스 환경이면 AWS Secrets Manager/Vault의 시크릿을 K8s Secret으로 동기화. CI/런타임이 클러스터 위라면 가장 매� 끄럽다.

Kubernetes 환경이라면 ESO로 이렇게 선언한다:

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: github-npm-token
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: vault-backend
    kind: SecretStore
  target:
    name: github-npm-token
  data:
    - secretKey: GITHUB_NPM_TOKEN
      remoteRef:
        key: ci/npm
        property: token

핵심은 토큰이 Vault에만 있고, 필요한 순간 짧은 수명으로만 주입된다는 점이다. 빌드 산출물이나 이미지 레이어에는 절대 남지 않는다. 그리고 원칙적으로 발급하는 토큰 권한을 최소화해야 한다. npm 패키지 읽기용이면 admin이 아니라 read 스코프면 충분하다.

4. 정리: 한 줄 요약과 적용 대상

한 줄 요약: 이번 사건은 "개발자가 토큰을 하드코딩한" 사건이 아니라 "빌드 도구가 CI 환경변수를 통째로 산출물에 삼킨" 구조적 사고다. 그래서 사람 교육만으로는 안 막히고, 파이프라인 게이트(스캐너) + 시크릿 외부화(Vault/ESO/SOPS) + 최소 권한 세 가지가 동시에 필요하다.

  • 지금 당장 할 것: gitleaks/trufflehog를 CI에 게이트로 붙이고, 프론트엔드 빌드에서 process.env 통주입이 없는지 확인한다.
  • IoT/OT 기기 담당자: 도입 전 펌웨어를 한 번 열어보고, 기기 egress를 세그먼트로 격리한다.
  • 플랫폼/DevOps 팀: 장기 PAT를 Vault 동적 시크릿이나 ESO로 대체하고, 토큰 권한을 최소 스코프로 재발급한다.

스캐너는 최후의 방어선이지 첫 방어선이 아니다. 첫 방어선은 "시크릿이 애초에 이미지·번들에 들어갈 경로 자체를 없애는" 아키텍처다.

참고 자료

728x90

+ Recent posts