← 목록으로

당근이 파이썬 공급망 공격에 대응하는 방법

당근이 파이썬 공급망 공격에 대응하는 방법

요약

들어가며

안녕하세요, 당근 파이썬 챕터에서 활동하고 있는 데니와 엘빈이에요. 당근에는 특정 프로그래밍 언어에 관심 있는 구성원들이 자발적으로 모여 해당 언어의 전사 정책과 생태계 이슈를 논의하는 챕터 그룹이 있어요. Go, Ruby 등 여러 언어를 위한 챕터가 각각 운영되고 있고, 파이썬 챕터도 그 중 하나예요.

이번 글은 파이썬 챕터에서 공급망 보안 이슈를 논의한 후 실제로 사내 인프라에 적용한 과정을 공유해요. 비슷한 고민을 하고 있는 분들께 도움이 됐으면 좋겠어요.

사건의 발단: LiteLLM 공급망 공격

2026년 3월 24일, LLM 프록시 라이브러리로 널리 쓰이는 LiteLLM의 PyPI 패키지공급망 공격(supply chain attack)을 받았어요.

공격이 일어난 과정은 아래와 같아요.

  1. LiteLLM은 보안을 위해 Trivy라는 보안 스캐너를 CI/CD에서 작동시키고 있었어요.
  2. 공격자는 LiteLLM의 CI/CD에서 사용하던 Trivy의 크리덴셜을 탈취했어요.
  3. 탈취한 크리덴셜로 공식 CI/CD 파이프라인을 우회해, 악성 코드가 삽입된 패키지(1.82.7, 1.82.8)를 PyPI에 직접 업로드했어요.
  4. 해당 버전에는 환경 변수, SSH 키, 클라우드 크리덴셜을 외부로 탈취하는 페이로드가 포함돼 있었어요.

악성 패키지는 PyPI에 의해 비교적 빠르게 격리됐지만, 그 짧은 시간 동안에도 상당한 수의 다운로드가 발생했어요. 비슷한 시기에 telnyx 패키지도 같은 방식으로 공격을 받았어요.

왜 쿨다운인가

이런 공급망 공격에는 공통적인 패턴이 있어요. 악성 패키지가 PyPI에 업로드된 직후부터 탐지·격리까지 노출 시간(exposure window)이 짧다는 점이에요.

William Woodruff의 분석(We should all be using dependency cooldowns)에 따르면, 최근 열 건의 주요 공급망 공격 중 여덟 건의 노출 시간이 일주일 미만이었어요. 14일 쿨다운을 적용했다면 그 중 아홉 건을 사전에 차단할 수 있었고요.

아이디어 자체는 단순해요. 패키지가 PyPI에 올라온 직후에는 설치하지 말고, 며칠을 기다리는 거예요. 악성 패키지가 탐지·격리되기까지 시간이 걸리고, 그 전설치하는 게 문제니까요.

일부 도구는 이를 기본적으로 지원하고 있어요.

  • uv: exclude-newer = "P3D" 설정으로 3일 이내 업로드된 패키지 제외
  • pip: -uploaded-prior-to 플래그

당근의 사내 PyPI 프록시

당근에서는 사내 라이브러리를 PyPI 형태로 배포하고 관리하기 위해 AWS CodeArtifact를 사용하고 있어요. 이를 구성원들이 더 쉽게 활용할 수 있도록 사내 PyPI 프록시 서버를 운영하고 있어요.

왜 별도의 프록시가 필요했나

CodeArtifact의 endpoint를 그대로 노출해도 패키지를 받는 데에는 문제가 없어요. 다만 그러려면 모든 환경에서 AWS 자격 증명을 챙긴 다음, 매번 토큰을 발급받아 URL에 끼워 넣는 작업을 거쳐야 해요.

예를 들어 로컬에서 uv를 쓰려면 이런 식의 환경 변수 설정이 필요했어요.

$ export UV_INDEX="<https://aws>:$(
aws codeartifact get-authorization-token \
--domain xxxx-yyyy \
--domain-owner <AWS_ACCOUNT_ID> \
--query authorizationToken \
--output text
)@xxxx-yyyy-<AWS_ACCOUNT_ID>.d.codeartifact.us-west-2.amazonaws.com/pypi/xxxx-yyyy/simple/"

파이썬 패키지를 설치하고 싶었을 뿐인데 awscli를 설치해야 하고, 셸에서 AWS 자격 증명이 유효해야 하고, get-authorization-token으로 받은 토큰을 URL에 직접 넣어야 했어요. 토큰은 일정 시간 뒤 만료되기 때문에 작업이 길어지면 같은 명령을 다시 실행해야 했고요.

Dockerfile은 한층 더 복잡해졌어요. 빌드 단계에 awscli를 설치하고, 빌드 시점에 AWS 자격 증명을 ARG로 주입한 뒤, 그 자격 증명으로 토큰을 발급해 의존성 설치 명령 안에 끼워 넣어야 했어요.

# 빌드 시점에 자격 증명 주입, 보안상 권장되지 않음
ARG AWS_ACCESS_KEY_ID
ARG AWS_SECRET_ACCESS_KEY
RUN apt-get update \
&& apt-get install -y --no-install-recommends git \
&& rm -rf /var/lib/apt/lists/* \
&& uv pip install awscli
RUN UV_INDEX="<https://aws>:$(aws codeartifact get-authorization-token \
--domain xxxx-yyyy \
--domain-owner <AWS_ACCOUNT_ID> \
--query authorizationToken \
--output text)@xxxx-yyyy-<AWS_ACCOUNT_ID>.d.codeartifact.us-west-2.amazonaws.com/pypi/xxxx-yyyy/simple/" \
uv pip install .

CI 파이프라인은 별도로 OIDC나 시크릿을 통해 AWS 자격 증명을 주고받는 절차가 필요했고요. 환경마다 인증 방식이 달랐기 때문에 가이드 문서가 길어졌고, 새로 합류한 동료가 어딘가의 설정을 빠뜨리면 그대로 설치 실패로 이어졌어요.

따라서 매니지드 서비스인 CodeArtifact의 장점은 살리되, 사용자 입장에서는 평소 PyPI를 쓰던 경험을 그대로를 유지하고 싶었어요.

얇은 프록시 레이어로 풀기

해결책은 단순해요. CodeArtifact 앞에 얇은 프록시 레이어를 하나 두고, 클라이언트의 요청을 받아 CodeArtifact endpoint로 그대로 흘려보내는 거예요. 사용자 쪽 설정은 이 한 블록으로 끝나요.

# pyproject.toml
[[tool.uv.index]]
name = "karrot-pypi-proxy"
url = "https://<proxy-host>/simple"
default = true

어떤 환경(로컬, CI, Dockerfile)에서도 같은 설정 한 줄이면 되고, AWS 자격 증명을 어디에도 끼워 넣을 필요가 없어요. PEP 503의 Simple Repository API 경로(/simple/{package}/, /simple/{package}/{version}/{file} 등)를 그대로 라우팅하기 때문에, pip나 uv 같은 클라이언트 도구는 일반 PyPI Index와 통신하고 있다고 생각해요.

요청이 들어오면 프록시는 CodeArtifact용 인증 헤더를 새로 붙여 전달해요. wheel 파일 같은 큰 바이너리를 메모리에 모두 올리지 않고 내려보내기 위해서, 응답은 스트림으로 받고 청크 단위(8KB)로 내려주도록 구현했어요.

AWS 토큰을 프록시 안에서 관리하기

CodeArtifact의 인증 토큰은 한 번 발급받으면 일정 시간 뒤에 만료돼요. 사용자가 매번 토큰을 직접 다루지 않게 하려면 프록시가 이 라이프사이클을 책임져야 해요. 그래서 애플리케이션이 시작할 때 백그라운드 데몬 스레드를 하나 띄워두고, 이 스레드가 주기적으로 만료 시각을 확인하면서 토큰을 자동으로 갱신하도록 구현했어요. 발급된 토큰은 threading.Lock으로 보호되고, 모든 요청 핸들러가 함께 참조해요.

운영상 두 가지 디테일을 더했어요. 하나는 토큰을 boto3가 허용하는 가장 짧은 수명인 900초(15분) 로 발급받고, 만료 300초 전에 미리 재발급한다는 점이에요. 짧게 발급해두면 토큰이 어디선가 새어 나가더라도 노출 시간을 줄일 수 있어요. 다른 하나는 갱신 시점에 ±60초의 jitter를 더한다는 점이에요. 여러 인스턴스가 동시에 같은 시각에 갱신을 시도해 CodeArtifact API에 트래픽이 과중되는 일을 피하려고 했어요.

서버가 정상 동작 중이라면 토큰은 항상 살아 있다고 봐도 되도록, /health 엔드포인트는 단순히 프로세스가 살아있는지가 아니라 현재 토큰의 유효성까지 함께 점검해요. 어쩌다 갱신이 지속적으로 실패하면 헬스 체크가 503을 내려주고 재시작되도록 보완했어요.

자연스럽게 만들어진 단일 진입점

이 모든 처리를 프록시 안에서 끝내기 때문에, 사용자는 환경에 관계없이 평소처럼 pip install이나 uv sync만 하면 돼요. 그러면서 부수 효과로 사내 모든 파이썬 패키지 설치 트래픽이 이 프록시 한 곳을 거쳐 흐르는 구조가 자연스럽게 만들어졌어요. Prometheus 메트릭으로 어떤 패키지가 얼마나 다운로드되는지를 한눈에 관측할 수 있었고요. 더욱이 이 단일 진입점이 이번에 쿨다운 정책을 도입할 수 있었던 출발점이 됐어요.

앞서 언급했듯 uv나 pip 같은 클라이언트 도구에서도 쿨다운을 설정할 수 있어요. 하지만 사내 모든 구성원의 로컬 환경과 CI 설정을 일일이 강제하는 건 현실적으로 쉽지 않아요. 누군가 설정을 빠뜨리거나, 새로운 프로젝트를 시작하면서 적용을 잊는 경우가 생길 수밖에 없거든요.

반면 프록시는 달라요. 모든 파이썬 패키지 요청이 이 프록시를 통해 흐르는 구조이기 때문에, 프록시 레이어에 쿨다운 정책을 한 번만 적용해두면 클라이언트 도구나 개인 설정에 무관하게 전사적으로 일관된 보안 정책이 적용돼요.

도전: PEP 503과 PEP 691 사이

그런데 막상 쿨다운 정책을 구현하려고 보니 흥미로운 문제가 생겼어요.

PEP 503: HTML 기반 Simple Repository API

pip, uv 같은 패키지 관리자는 PEP 503이 정의한 Simple Repository API 규격에 따라 패키지 목록을 요청해요. 이 API는 패키지별로 /simple/{package}/ URL에 HTML 페이지를 반환하고, 각 파일은 <a> 태그로 나열돼요.

<!-- /simple/litellm/ -->
<!DOCTYPE html>
<html>
<body>
<a href="...">litellm-1.82.6-py3-none-any.whl</a>
<a href="...">litellm-1.82.7-py3-none-any.whl</a> <!-- 악성 버전 -->
<a href="...">litellm-1.82.8-py3-none-any.whl</a> <!-- 악성 버전 -->
<a href="...">litellm-1.83.0-py3-none-any.whl</a>
</body>
</html>

문제는 이 HTML에 업로드 시각 정보가 없다는 점이에요. 어떤 파일이 쿨다운 기간 안에 업로드된 것인지 판단할 수가 없어요.

PEP 691: JSON 기반 Simple API

PEP 691은 PEP 503과 동일한 내용의 Simple API를 JSON 형식으로 제공하는 스펙이에요. Accept: application/vnd.pypi.simple.v1+json 헤더를 함께 보내면 JSON 응답을 받을 수 있어요.

{
"name": "litellm",
"files": [
{
"filename": "litellm-1.82.6-py3-none-any.whl",
"upload-time": "2026-03-22T06:35:56.795579Z"
},
{
"filename": "litellm-1.82.7-py3-none-any.whl",
"upload-time": "2026-03-24T14:21:00Z" // 악성 버전 (격리됨, 업로드 시각 추정)
},
{
"filename": "litellm-1.82.8-py3-none-any.whl",
"upload-time": "2026-03-24T14:35:00Z" // 악성 버전 (격리됨, 업로드 시각 추정)
},
{
"filename": "litellm-1.83.0-py3-none-any.whl",
"upload-time": "2026-03-31T05:08:21.987889Z"
}
]
}

JSON 응답에는 각 파일의 upload-time 필드가 포함돼요. 이 필드를 활용하면 쿨다운 기준을 정확하게 계산할 수 있어요.

두 가지 스펙을 함께 활용하기

이 문제를 해결하기 위해 클라이언트(pip, uv)에는 PEP 503 HTML 응답을 제공하되, 필터링 판단은 PEP 691 JSON API로 한다는 구조를 선택했어요. 전체 흐름은 아래와 같아요.

구현 살펴보기

쿨다운 필터 적용

패키지 목록 요청이 들어오면 프록시는 두 가지 일을 동시에 해요. CodeArtifact에서 PEP 503 HTML 응답을 받아두고, pypi.org에도 동일한 패키지의 PEP 691 JSON을 요청해요. JSON 응답에서 upload-time을 기준으로 쿨다운 기간 안에 업로드된 파일을 추려낸 뒤, CodeArtifact HTML에서 해당 파일의 링크를 제거해요. 클라이언트에게는 필터링된 HTML이 최종 응답으로 전달돼요.

Central Dogma로 동적 제어

쿨다운 정책은 사내 설정 관리 시스템인 Central Dogma를 통해 동적으로 관리해요. 서버를 재배포하지 않고도 쿨다운을 활성화/비활성화하거나, 적용 일수를 조정하거나, 특정 패키지를 예외 처리할 수 있어요.

모든 패키지에 쿨다운을 적용하는 건 현실적이지 않아요. 당근이 직접 관리하는 사내 라이브러리나, 오랜 기간 검증된 신뢰도 높은 라이브러리는 굳이 며칠을 기다릴 이유가 없거든요. 그래서 exclude 목록을 두고, 여기에 등록된 패키지는 쿨다운 없이 최신 버전을 바로 받을 수 있도록 했어요. 이 설정 역시 Central Dogma를 통해 실시간으로 반영돼요.

{
"cooldown": {
"enabled": true,
"duration_days": "x", // 보안상 이유로 공개하지 않아요
"exclude": [
"karrot-internal-package", // 사내 라이브러리
"boto3" // 신뢰도 높은 외부 라이브러리
]
}
}

쿨다운만으로 충분할까요?

쿨다운은 공급망 공격에 대한 중요한 방어 레이어예요. 다만 몇 가지 한계도 함께 인식하고 있어요.

  • 보안 패치 지연: 취약점을 수정한 버전도 쿨다운 대상이 될 수 있어요. 그래서 긴급 업데이트가 필요한 패키지는 exclude 목록에 빠르게 추가할 수 있는 프로세스가 함께 필요해요.
  • 기존 악성 패키지 미탐지: 쿨다운 이전에 이미 업로드된 패키지는 차단할 수 없어요. 의존성 감사 도구를 병행하는 게 중요한 이유예요.

마치며

쿨다운 정책을 도입하고 나서 유사한 공급망 공격이 실제로 또 발생했어요. 2026년 4월 30일, 머신러닝 프레임워크로 널리 쓰이는 PyTorch Lightning(pytorch-lightning 2.6.2, 2.6.3)이 같은 공격 캠페인(Mini Shai-Hulud)의 일부로 타깃이 됐어요. 당근에서는 사내 쿨다운 정책 덕분에 해당 버전이 프록시를 통해서 다운받지 않게 되었어요.

공급망 공격은 점점 정교해지고 있어요. 오픈소스 생태계를 신뢰하는 만큼, 그 위협에도 능동적으로 대응할 필요가 있어요. 당근의 파이썬 챕터는 이번 LiteLLM 사태를 계기로 사내 PyPI 프록시에 쿨다운 정책을 도입했어요.

“무엇을 설치할지”와 함께 “언제 설치할지”를 고민하는 것, 그것이 파이썬 생태계를 더 안전하게 사용하는 중요한 첫걸음이라고 생각해요.

파이썬 챕터는 앞으로도 당근의 파이썬 생태계를 더 안전하고 생산적으로 만들기 위한 논의를 이어나갈 거예요.

파이썬 챕터 멤버들과 함께 📸

참고 자료

당근이 파이썬 공급망 공격에 대응하는 방법 was originally published in 당근 테크 블로그 on Medium, where people are continuing the conversation by highlighting and responding to this story.

← 목록으로