Skip to content
Published on

Python PyPI 공급망 공격 방어 전략: 타이포스쿼팅부터 다층 보안까지

Authors
  • Name
    Twitter

들어가며: 왜 지금 PyPI 공급망 보안인가

2025년 하반기부터 2026년 초까지 Python PyPI를 대상으로 한 공급망 공격이 전례 없는 수준으로 급증했습니다. 2025년 7월부터 2026년 1월 사이에만 128개의 팬텀 패키지가 총 121,539회 다운로드되었고, 주간 평균 3,903회의 악성 설치가 발생했습니다. The Hacker News에 따르면 2026년 2월 dYdX 공급망 공격처럼 암호화폐 지갑 탈취와 RAT(원격 접근 트로이목마) 배포를 결합한 고도화된 공격이 등장하고 있습니다.

이 글에서는 실제 발생한 공격 사례를 분석하고, 개발팀이 즉시 적용할 수 있는 다층 방어 전략을 실전 코드와 함께 제시합니다.

PyPI 공급망 공격 유형 분석

공격 유형 비교표

공격 유형설명대표 사례위험도
타이포스쿼팅(Typosquatting)유명 패키지 이름의 오타 변형 등록termncolor(termcolor 위장), sisaws(sisa 위장)높음
의존성 혼동(Dependency Confusion)내부 패키지와 동일한 이름의 공개 패키지 등록기업 내부 패키지명 탈취매우 높음
악성 빌드 스크립트setup.py/pyproject.toml의 빌드 훅에 악성 코드 삽입설치 시 자동 실행되는 백도어높음
계정 탈취(Account Hijacking)패키지 관리자 계정 탈취 후 악성 버전 배포dYdX(2026.02), Ultralytics(2024.12)매우 높음
팬텀 패키지(Phantom Package)유용해 보이는 가짜 패키지를 대량 등록AI/ML 도구 위장 패키지중간
StarJacking인기 GitHub 저장소의 URL을 도용하여 신뢰도 위장PyPI 메타데이터 조작중간

1. 타이포스쿼팅(Typosquatting)

가장 빈번한 공격 유형입니다. 공격자는 requests 대신 reqeusts, colorama 대신 colorizr처럼 유명 패키지와 유사한 이름으로 악성 패키지를 등록합니다. 2025년 7월 발견된 termncolor는 합법적인 termcolor 패키지를 위장했으며, sisawssecmeasure 패키지는 SilentSync RAT를 배포하는 것으로 확인되었습니다.

PyPI는 현재 프로젝트 생성 시 타이포스쿼팅 시도를 자동 탐지하고 플래그를 지정하는 기능을 도입했지만, 모든 변형을 차단하지는 못합니다.

2. 의존성 혼동(Dependency Confusion)

2021년 Alex Birsan이 처음 공개한 이 공격은, 기업이 내부적으로 사용하는 비공개 패키지 이름과 동일한 이름의 패키지를 공개 PyPI에 등록하는 방식입니다. pip는 기본적으로 공개 인덱스의 높은 버전 번호를 우선 설치하므로, 공격자가 9999.0.0 같은 극도로 높은 버전을 등록하면 내부 패키지 대신 악성 패키지가 설치됩니다.

3. 계정 탈취를 통한 정상 패키지 변조

가장 파괴력이 큰 공격 유형입니다. 정상적인 패키지의 관리자 인증 정보를 탈취하여 악성 버전을 배포합니다. 이 경우 패키지 이름 자체는 정상이므로 탐지가 매우 어렵습니다.

실패 사례 분석

사례 1: dYdX 공급망 공격 (2026년 2월)

2026년 1월 28일 공개된 이 사건에서, 공격자는 암호화폐 탈중앙화 거래소 dYdX의 개발자 인증 정보를 탈취하여 npm 패키지(@dydxprotocol/v4-client-js)와 PyPI 패키지(dydx-v4-client)에 악성 버전을 배포했습니다.

공격 특징:

  • PyPI 패키지에는 100회 반복 난독화된 악성 코드가 삽입됨
  • npm과 PyPI 양쪽에 동시 배포하는 크로스 에코시스템 공격
  • 암호화폐 지갑 인증 정보 탈취 + RAT(원격 접근 트로이목마) 동시 배포
  • 패키지 관리 인프라에 직접 접근하여 정상 빌드 프로세스를 우회

교훈: dYdX는 사용자에게 감염된 머신을 격리하고, 클린 시스템에서 새 지갑으로 자금을 이동하며, 모든 API 키와 인증 정보를 교체할 것을 권고했습니다. 이 사례는 2FA(이중 인증)와 Trusted Publisher 설정의 중요성을 보여줍니다.

사례 2: Ultralytics 공급망 공격 (2024년 12월)

세계 최고의 컴퓨터 비전 AI 라이브러리인 Ultralytics(YOLO)가 GitHub Actions 워크플로우 침해를 통해 공격당했습니다.

공격 타임라인:

  • 1차 공격(12월 4-5일): 악성 버전 8.3.41, 8.3.42 배포 (약 12시간 노출)
  • 2차 공격(12월 7일): GitHub Actions를 우회하여 직접 PyPI에 버전 8.3.45, 8.3.46 배포

공격 메커니즘:

  • 공격자가 git 브랜치 이름을 악용하여 GitHub Actions CI/CD 파이프라인의 인증 정보를 탈취
  • 탈취한 PyPI API 토큰으로 XMRig(Monero 암호화폐 채굴기)가 포함된 악성 버전 배포
  • 이 공격 기법(Pwn Request)은 2021년부터 알려진 방식이었으나 방어되지 않았음

교훈: PyPI API 토큰의 범위를 특정 프로젝트와 버전으로 제한하고, GitHub Actions 워크플로우에서 외부 입력(브랜치 이름, PR 제목 등)을 검증해야 합니다. 또한 Trusted Publisher를 사용하면 토큰 탈취 자체를 방지할 수 있습니다.

다층 방어 전략

┌──────────────────────────────────────────────────────────────┐
PyPI 공급망 보안 다층 방어                     │
├──────────┬──────────────┬──────────────┬─────────────────────┤
Layer 1Layer 2Layer 3Layer 4│          │              │              │                     │
│ 의존성   │ 취약점       │ 빌드 환경    │ 런타임              │
│ 관리     │ 스캐닝       │ 보안         │ 모니터링            │
│          │              │              │                     │
Lockfile │ pip-audit    │ TrustedSBOMPinning  │ safety       │ Publisher    │ 추적                │
│          │              │              │                     │
HashGitHubPEP 740     │ 의존성              │
│ 검증     │ DependabotAttestation  │ 감사               │
│          │              │              │                     │
PrivateSnyk /       │ 2FA /        │ 이상 탐지           │
IndexSocket.devOIDC         │                     │
└──────────┴──────────────┴──────────────┴─────────────────────┘

Layer 1: 의존성 관리 강화

Lockfile Pinning과 해시 검증

의존성을 정확한 버전과 해시로 고정하면, 패키지가 변조되었을 때 설치를 차단할 수 있습니다.

# pyproject.toml - uv/pip 호환 의존성 관리
[project]
name = "my-secure-app"
requires-python = ">=3.11"
dependencies = [
    "requests==2.31.0",
    "cryptography==42.0.5",
    "pydantic==2.6.1",
]

[tool.uv]
# 프라이빗 인덱스 우선 설정 (의존성 혼동 방어)
index-url = "https://my-company.jfrog.io/pypi/simple/"
extra-index-url = "https://pypi.org/simple/"

[tool.uv.pip]
# 해시 검증 필수화
require-hashes = true
# requirements.txt - 해시 고정 예시
requests==2.31.0 \
    --hash=sha256:942c5a758f98d790eaed1a29cb6eefc7f0edf3fcb0fce8aea3fbd5951d bdf0f4
cryptography==42.0.5 \
    --hash=sha256:6e2b11c55d260d03a8cf29ac9b5e0608c3cb2b6f56af2f20f2132764710 68e5c
pydantic==2.6.1 \
    --hash=sha256:4fd5c182a2488dc63e6d32737ff19937888001e2a6d86e94b3f233104a5 d1fa9

프라이빗 인덱스 우선 설정 (의존성 혼동 방어)

# pip.conf - 프라이빗 인덱스 우선 설정
[global]
index-url = https://my-company.jfrog.io/pypi/simple/
extra-index-url = https://pypi.org/simple/

[install]
# 해시 검증을 기본 활성화
require-hashes = true

uv를 사용하는 경우 더 강력한 의존성 혼동 방어가 가능합니다.

# pyproject.toml - uv의 인덱스 전략 설정
[tool.uv]
# "first-match" 전략: 첫 번째 인덱스에서 패키지를 찾으면 다른 인덱스 검색 안 함
index-strategy = "first-match"

[[tool.uv.index]]
name = "internal"
url = "https://my-company.jfrog.io/pypi/simple/"
default = true

[[tool.uv.index]]
name = "pypi"
url = "https://pypi.org/simple/"

Layer 2: 취약점 스캐닝

pip-audit로 알려진 취약점 검사

pip-audit은 Google이 후원하고 Trail of Bits가 개발한 오픈소스 도구로, PyPI JSON API를 통해 Python Packaging Advisory Database의 취약점 정보를 조회합니다.

# pip-audit 설치 및 실행
pip install pip-audit

# 현재 환경 스캐닝
pip-audit

# requirements.txt 기반 스캐닝
pip-audit -r requirements.txt

# 취약점 자동 수정 (안전한 최신 버전으로 업그레이드)
pip-audit --fix

# JSON 형식 출력 (CI/CD 파이프라인 연동용)
pip-audit -f json -o audit-report.json

# 특정 취약점 무시 (오탐 또는 비적용 사례)
pip-audit --ignore-vuln PYSEC-2024-XXXX

Safety CLI로 악성 패키지 탐지

Safety는 취약점 검사 외에 악성 패키지 탐지 기능도 제공합니다.

# Safety 설치 및 실행
pip install safety

# 현재 환경 스캐닝
safety check

# requirements.txt 기반 스캐닝
safety check -r requirements.txt

# JSON 출력 형식
safety check --output json

# 전체 프로젝트 디렉토리 스캐닝 (악성 패키지 탐지 포함)
safety scan --target ./my-project/

취약점 스캐닝 도구 비교

기능pip-auditSafety CLISnyk
취약점 DBPyPI Advisory DB (OSV)SafetyDB (PyUp)Snyk Vulnerability DB
악성 패키지 탐지미지원지원지원
자동 수정지원 (--fix)미지원지원
라이선스 검사미지원유료 버전 지원지원
CVSS 점수미지원유료 버전 지원지원
CI/CD 통합GitHub Actions 제공GitHub Actions 제공네이티브 통합
비용무료 (Apache 2.0)무료/유료무료/유료
추천 용도CI/CD 자동 검사개발 환경 보안엔터프라이즈

Layer 3: 빌드 환경 보안 - Trusted Publisher와 PEP 740

Trusted Publisher 설정

PyPI Trusted Publisher는 OpenID Connect(OIDC)를 사용하여 GitHub Actions 등 CI/CD 플랫폼에서 토큰 없이 안전하게 패키지를 배포할 수 있게 합니다. API 토큰이 존재하지 않으므로 탈취 자체가 불가능합니다.

# .github/workflows/publish.yml - Trusted Publisher 기반 배포
name: Publish to PyPI

on:
  release:
    types: [published]

permissions:
  id-token: write # OIDC 토큰 발급에 필요
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: Install build dependencies
        run: pip install build

      - name: Build package
        run: python -m build

      - name: Publish to PyPI
        uses: pypa/gh-action-pypi-publish@release/v1
        # Trusted Publisher 사용 시 password/token 불필요
        # PyPI에서 GitHub 저장소를 Trusted Publisher로 등록해야 함
        with:
          attestations: true # PEP 740 디지털 어테스테이션 자동 생성

  verify:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Verify attestation
        run: |
          pip install pypi-attestations
          python -m pypi_attestations verify my-package

PEP 740 디지털 어테스테이션

PEP 740은 PyPI 패키지에 대한 암호학적 검증 가능한 증명(Attestation)을 정의합니다. Sigstore 기반의 키리스(keyless) 서명을 사용하며, 패키지가 어떤 소스 저장소에서 빌드되었는지 검증할 수 있습니다.

# 어테스테이션 검증 (소비자 측)
pip install pypi-attestations

# 특정 패키지의 어테스테이션 확인
python -c "
import requests
resp = requests.get(
    'https://pypi.org/integrity/requests/2.31.0/'
)
attestations = resp.json()
print(f'Attestation count: {len(attestations)}')
for att in attestations:
    print(f'  Publisher: {att.get(\"publisher\", \"unknown\")}')
"

Layer 4: CI/CD 보안 파이프라인 구축

GitHub Actions 종합 보안 파이프라인

# .github/workflows/security.yml
name: Python Supply Chain Security

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  schedule:
    # 매일 오전 9시(KST) 정기 스캐닝
    - cron: '0 0 * * *'

permissions:
  contents: read
  security-events: write

jobs:
  dependency-audit:
    name: Dependency Audit
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: Install dependencies
        run: |
          pip install pip-audit safety cyclonedx-bom

      - name: Run pip-audit
        run: |
          pip-audit -r requirements.txt \
            -f json \
            -o pip-audit-report.json \
            --desc on
        continue-on-error: false

      - name: Run Safety check
        run: |
          safety check -r requirements.txt \
            --output json \
            > safety-report.json
        continue-on-error: true

      - name: Check for critical vulnerabilities
        run: |
          python3 -c "
          import json, sys
          with open('pip-audit-report.json') as f:
              report = json.load(f)
          vulns = report.get('dependencies', [])
          critical = [v for v in vulns if v.get('vulns')]
          if critical:
              print(f'CRITICAL: {len(critical)} vulnerable packages found')
              for pkg in critical:
                  name = pkg['name']
                  version = pkg['version']
                  for vuln in pkg['vulns']:
                      vid = vuln['id']
                      fix = vuln.get('fix_versions', ['N/A'])
                      print(f'  - {name}=={version}: {vid} (fix: {fix})')
              sys.exit(1)
          print('No vulnerabilities found')
          "

      - name: Upload audit reports
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: security-reports
          path: |
            pip-audit-report.json
            safety-report.json

  sbom-generation:
    name: Generate SBOM
    runs-on: ubuntu-latest
    needs: dependency-audit
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: Install dependencies
        run: |
          pip install -r requirements.txt
          pip install cyclonedx-bom

      - name: Generate CycloneDX SBOM
        run: |
          cyclonedx-py environment \
            --output sbom.json \
            --output-format json \
            --schema-version 1.5

      - name: Validate SBOM
        run: |
          python3 -c "
          import json
          with open('sbom.json') as f:
              sbom = json.load(f)
          components = sbom.get('components', [])
          print(f'SBOM generated: {len(components)} components')
          print(f'Format: CycloneDX {sbom.get(\"specVersion\", \"unknown\")}')
          for comp in components[:5]:
              name = comp.get('name', 'unknown')
              version = comp.get('version', 'unknown')
              print(f'  - {name}@{version}')
          if len(components) > 5:
              print(f'  ... and {len(components) - 5} more')
          "

      - name: Upload SBOM
        uses: actions/upload-artifact@v4
        with:
          name: sbom
          path: sbom.json

  lockfile-integrity:
    name: Lockfile Integrity Check
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: Verify hash-pinned dependencies
        run: |
          pip install --require-hashes \
            -r requirements.txt \
            --dry-run \
            --no-deps
        continue-on-error: false

      - name: Check for unpinned dependencies
        run: |
          python3 -c "
          import re, sys
          unpinned = []
          with open('requirements.txt') as f:
              for line in f:
                  line = line.strip()
                  if line and not line.startswith('#'):
                      if '==' not in line and '--hash' not in line:
                          unpinned.append(line)
          if unpinned:
              print('WARNING: Unpinned dependencies found:')
              for dep in unpinned:
                  print(f'  - {dep}')
              sys.exit(1)
          print('All dependencies are version-pinned')
          "

SBOM 생성과 관리

SBOM(Software Bill of Materials)은 소프트웨어에 포함된 모든 구성 요소를 문서화한 목록입니다. 미국 행정명령 14028호 이후 공급망 투명성의 핵심 요소가 되었습니다.

# CycloneDX로 Python 프로젝트 SBOM 생성
pip install cyclonedx-bom

# 현재 가상 환경 기반 SBOM 생성
cyclonedx-py environment \
  --output sbom.json \
  --output-format json \
  --schema-version 1.5

# requirements.txt 기반 SBOM 생성
cyclonedx-py requirements \
  --input-file requirements.txt \
  --output sbom-requirements.json \
  --output-format json

# SPDX 형식으로도 생성 가능
pip install spdx-tools
# sbom_validator.py - SBOM 검증 및 분석 스크립트
import json
import sys
from datetime import datetime


def validate_sbom(sbom_path: str) -> dict:
    """SBOM 파일을 검증하고 요약 리포트를 생성합니다."""
    with open(sbom_path) as f:
        sbom = json.load(f)

    components = sbom.get("components", [])
    metadata = sbom.get("metadata", {})

    report = {
        "timestamp": datetime.now().isoformat(),
        "spec_version": sbom.get("specVersion", "unknown"),
        "total_components": len(components),
        "components_without_version": [],
        "components_without_license": [],
        "components_without_purl": [],
    }

    for comp in components:
        name = comp.get("name", "unknown")
        if not comp.get("version"):
            report["components_without_version"].append(name)
        if not comp.get("licenses"):
            report["components_without_license"].append(name)
        if not comp.get("purl"):
            report["components_without_purl"].append(name)

    # 검증 결과 출력
    print(f"SBOM Validation Report")
    print(f"=" * 50)
    print(f"Spec Version: {report['spec_version']}")
    print(f"Total Components: {report['total_components']}")
    print(f"Missing Versions: {len(report['components_without_version'])}")
    print(f"Missing Licenses: {len(report['components_without_license'])}")
    print(f"Missing PURLs: {len(report['components_without_purl'])}")

    # 품질 점수 계산
    total = report["total_components"]
    if total > 0:
        quality_score = (
            1
            - (
                len(report["components_without_version"])
                + len(report["components_without_license"])
                + len(report["components_without_purl"])
            )
            / (total * 3)
        ) * 100
        print(f"Quality Score: {quality_score:.1f}%")
        report["quality_score"] = quality_score

    return report


if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("Usage: python sbom_validator.py sbom.json")
        sys.exit(1)
    validate_sbom(sys.argv[1])

pyproject.toml 보안 설정 모범 사례

[project]
name = "my-secure-app"
version = "1.0.0"
requires-python = ">=3.11"
dependencies = [
    "requests>=2.31.0,<3.0",
    "cryptography>=42.0.0,<43.0",
    "pydantic>=2.6.0,<3.0",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

# 보안 관련 도구 설정
[tool.pip-audit]
# pip-audit 설정
desc = "on"
progress-spinner = "on"
output = "json"

[tool.safety]
# Safety CLI 설정
output = "json"
continue-on-error = false

[tool.ruff]
# 보안 관련 린트 규칙 활성화
select = [
    "S",     # flake8-bandit (보안 취약점 탐지)
    "B",     # flake8-bugbear
]

[tool.bandit]
# Bandit 정적 보안 분석 설정
exclude_dirs = ["tests", "venv"]
skips = []

추가 방어 기법

setup.py 빌드 스크립트 검사

악성 패키지의 상당수가 setup.pyinstall 훅에 악성 코드를 삽입합니다. 패키지 설치 전에 setup.py 내용을 검사하는 습관이 필요합니다.

# 패키지 설치 전 소스 코드 검사
pip download --no-binary :all: --no-deps suspect-package
# 다운로드된 소스를 압축 해제 후 setup.py 검사

# 또는 pip의 --no-build-isolation 옵션으로 빌드 스크립트 실행을 제한
pip install --no-build-isolation --only-binary :all: package-name

GitHub Dependabot 설정

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: 'pip'
    directory: '/'
    schedule:
      interval: 'daily'
    reviewers:
      - 'security-team'
    labels:
      - 'dependencies'
      - 'security'
    open-pull-requests-limit: 10
    # 보안 업데이트만 자동 PR 생성
    allow:
      - dependency-type: 'direct'
    # 메이저 버전 업데이트는 수동 검토
    ignore:
      - dependency-name: '*'
        update-types: ['version-update:semver-major']

pre-commit 훅으로 로컬 검사

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/pypa/pip-audit
    rev: v2.7.3
    hooks:
      - id: pip-audit
        args: ['-r', 'requirements.txt']

  - repo: https://github.com/PyCQA/bandit
    rev: 1.7.8
    hooks:
      - id: bandit
        args: ['-r', 'src/', '-ll']

  - repo: https://github.com/Yelp/detect-secrets
    rev: v1.4.0
    hooks:
      - id: detect-secrets
        args: ['--baseline', '.secrets.baseline']

보안 체크리스트

프로젝트에 즉시 적용할 수 있는 공급망 보안 체크리스트입니다.

의존성 관리

  • 모든 의존성이 정확한 버전으로 고정되어 있는가 (== 사용)
  • requirements.txt 또는 lockfile에 해시가 포함되어 있는가 (--hash)
  • 프라이빗 패키지에 대해 내부 인덱스가 우선 설정되어 있는가
  • 사용하지 않는 의존성이 제거되어 있는가
  • 의존성 업데이트 주기가 정의되어 있는가 (Dependabot/Renovate)

CI/CD 보안

  • pip-audit 또는 Safety가 CI 파이프라인에 통합되어 있는가
  • CRITICAL/HIGH 취약점 발견 시 빌드가 실패하도록 설정되어 있는가
  • SBOM이 빌드마다 자동 생성되는가
  • GitHub Actions에서 Trusted Publisher를 사용하고 있는가
  • PyPI API 토큰의 범위가 최소 권한으로 제한되어 있는가

계정 보안

  • PyPI 계정에 2FA(이중 인증)가 활성화되어 있는가
  • PyPI API 토큰이 프로젝트별로 분리되어 있는가
  • API 토큰이 코드 저장소에 하드코딩되어 있지 않은가
  • GitHub Actions secrets가 안전하게 관리되고 있는가

모니터링

  • 의존성 변경 사항에 대한 알림이 설정되어 있는가
  • SBOM 기반 취약점 모니터링이 운영되고 있는가
  • 새로운 CVE 발표 시 영향 분석 프로세스가 있는가

결론

PyPI 공급망 공격은 단일 도구나 단일 정책으로 막을 수 없습니다. 의존성 잠금(Lockfile Pinning + Hash Verification), 취약점 스캐닝(pip-audit + Safety), 빌드 환경 보안(Trusted Publisher + PEP 740 Attestation), 런타임 모니터링(SBOM 추적)을 결합한 다층 방어 전략이 필요합니다.

특히 2026년 현재 주목해야 할 세 가지 핵심 조치는 다음과 같습니다.

  1. Trusted Publisher 전환: PyPI API 토큰 대신 OIDC 기반 Trusted Publisher를 사용하여 인증 정보 탈취 위험을 근본적으로 제거
  2. PEP 740 어테스테이션 활용: 패키지의 출처를 암호학적으로 검증하여 변조 여부를 확인
  3. SBOM 자동화: 빌드마다 SBOM을 생성하고 지속적으로 취약점 모니터링을 수행

공급망 보안은 한 번 설정하고 끝나는 것이 아니라, 지속적으로 업데이트하고 감시해야 하는 운영 프로세스입니다. 오늘 당장 위의 체크리스트를 기반으로 팀의 보안 상태를 점검해 보시기 바랍니다.

참고 자료