정현진Jeong Hyeonjin

← 프로젝트 목록

공급망 / 2026.06 — 2026.09

TrustGate

npm 패키지를 설치하기 전에 그 릴리스가 원래 저장소의 정상 빌드에서 나왔는지 확인하고, 확인되지 않으면 설치를 막는 도구입니다. 팀장으로 진행을 맡고 GitHub 수집기, 탐지 규칙, 쿨다운 게이트를 만들었습니다.

상태
완료
분야
공급망
기간
2026.06 — 2026.09
역할
팀장
조회
4회
trust boundaryverify
모든 패키지를 경계에서 검증한 뒤에만 설치로 보내는 구조

ANALYSIS

왜 시작했나

프로젝트 전에 최근 공급망 사고를 먼저 조사했습니다.

  • tj-actions/changed-files (CVE-2025-30066): 먼저 오염된 reviewdog 액션(CVE-2025-30154)이 CI 서버의 Runner.Worker 메모리를 /proc로 덤프해
    봇의 PAT를 빼냈습니다. 공격자는 그 토큰으로 dist/index.js에 악성 코드를 넣고, v44·v45 같은 기존 태그를 악성 커밋으로 옮겼습니다.
    이 액션을 쓰던 23,000개가 넘는 저장소가 CI 로그에 secret을 찍었습니다.
  • Miasma: 관리자 계정을 탈취한 뒤 브랜치 보호를 우회해서, commit → PR → review → merge 기록 없이 main에 바로 커밋하고 배포했습니다.

두 사건 모두 패키지 이름과 버전은 정상이었습니다. npm 배포와 GitHub 리뷰는 완전히 별개 시스템이라, 리뷰를 거치지 않은 코드도 npm에 그대로 올라갈 수 있습니다.
그래서 그 릴리스가 평소처럼 PR → 리뷰 → 정상 워크플로 → 서명을 거쳐 나왔는지를 설치 직전에 확인하는 마지막 관문을 만들기로 했습니다.

전체 구조

npm install <패키지>
  └─ safe-npm 래퍼가 install 명령만 가로챔
       ├─ 쿨다운 게이트: 배포된 지 7일 지났나?
       ├─ 계보 수집 (3개 트랙)
       │    A. npm      → 배포 시각, integrity, gitHead, 저장소 주소, attestation 유무
       │    B. GitHub   → 커밋, 연결된 PR, 리뷰 승인, 워크플로 실행
       │    C. Sigstore → 서명 신원(OIDC), 빌드한 저장소·워크플로, SLSA provenance
       ├─ 규칙 엔진: 6개 규칙 × 과거 릴리스 5개와 비교
       └─ PASS면 실제 npm에 위임 / 아니면 차단
  • preinstall/postinstall 훅은 패키지를 내려받은 뒤에 실행돼서 검사 시점으로는 늦습니다. 그래서 npm install 명령 자체를 감싸는 래퍼 방식을 택했습니다.
  • 수집기는 A(npm)가 먼저 돌아 gitHead와 저장소 주소를 뽑고, 그걸로 B와 C를 호출합니다. 한 트랙이 실패해도(API 제한, 404) 프로그램이 죽지 않고 나머지 결과로 리포트를 만듭니다.
  • 판정은 PASS / RISK / UNVERIFIABLE 세 가지이고, PASS가 아니면 설치를 막습니다(fail-closed). npm 래퍼, trustgate install, trustgate scan이 모두 같은 기준을 씁니다.

npm provenance를 대신하는 도구는 아닙니다. provenance가 발급한 증명을 읽어서 차단 여부를 정하고, 증명이 없는 패키지는 과거 기록과 비교해 등급을 매깁니다.

제가 맡은 일

1. 팀장 · 진행 관리

  • 2026-05 기획안 작성, 5월부터 8월까지 회의록 작성
  • 주간 발표 페이지(WBS, 주차별 활동, 질문 정리)와 9월 중간·최종 발표 자료 정리
  • 회의에서 역할 분담 (예: 7/25 회의 — 쿨다운 1명(본인), 변조 패키지 제작·규칙 검증 2명)

2. 공통 스키마 설계 (06-21)

세 수집기가 각자 다른 형식으로 결과를 내지 않도록, 개발 전에 하나의 JSON 스키마를 정했습니다.

영역 채우는 수집기 내용
package, artifact npm 이름·버전·배포 시각, integrity, gitHead, attestation
workflow, commit, pull_request, reviewers GitHub 워크플로 실행, 커밋, PR, 리뷰 승인
oidc Sigstore issuer, subject, 저장소, 워크플로
lineage 공통 artifact→workflow→commit→PR→reviewer, workflow→OIDC 연결마다 결과
baseline, result 공통 과거 5개 버전과 비교, 신뢰 점수와 최종 판정

3. GitHub 수집기 (06-27)

  • PyGithub로 태그 → 커밋(sha, 작성자, 시각) → 연결된 PR(번호, merge 여부·시각) → 리뷰어 승인 → 워크플로 실행(id, 이름, run_id, 실행 주체)을 따라가며 수집
  • 토큰은 계정 전체에 접근하는 classic 대신, 특정 저장소에만 접근하고 만료가 있는 Fine-grained 토큰을 환경변수로 사용
  • 요청 전에 남은 Rate Limit을 확인해 0이면 초기화 시각을 안내하고, 10회 이하면 경고

4. 탐지 규칙 설계 (07-18)

Unreviewed, Tag/Identity Drift, Unexpected Builder 세 규칙을 만들고, 팀원 규칙까지 6개를 한 문서로 정리했습니다.

규칙 보는 것
Orphan Release 커밋에 PR이 아예 없음
Unreviewed PR은 있는데 사람 승인 없음, self-merge, 봇·외부인 승인만 있음
Workflow Drift 워크플로 파일이 릴리스 직전에 바뀜
OIDC Mismatch 서명 신원·저장소가 패키지가 주장하는 출처와 다름
Unexpected Builder provenance의 빌더나 attestation이 과거와 달라짐
Tag/Identity Drift 배포 계정이 새로 바뀜, 평소 있던 gitHead·태그가 이번만 없음

오탐을 줄이려고 세 가지 원칙을 정했습니다.

  1. 절대 기준이 아니라 그 패키지 자신의 과거 릴리스와 비교한다 (1인 메인테이너, 태그를 안 다는 패키지 보호)
  2. 규칙 하나로는 막지 않고, 여러 규칙이 같이 걸릴수록 위험도를 올린다
  3. 데이터가 없으면 감점하지 않고 UNVERIFIABLE로 둔다

예를 들어 Tag/Identity Drift의 원래 항목은 "태그 부재"였는데, 태그를 원래 안 다는 패키지가 걸리지 않도록 "평소엔 다는데 이번만 없음"으로 바꿨습니다.

5. 쿨다운 게이트 (08-01)

악성 릴리스는 대부분 게시 후 며칠 안에 발견돼 내려갑니다. 그래서 배포된 지 7일이 안 된 버전은 바로 설치하지 않는 관찰 기간을 두었습니다.

쿨다운 검증 결과 조치
지남 (7일+) 신버전 PASS 신버전 설치
지남 (7일+) 신버전 FAIL 검증을 통과하는 하위 버전으로 내려감 (최대 5개 시도)
안 지남 구버전 FAIL 전체 차단 (믿을 기준선이 없음)
안 지남 구·신 둘 다 PASS 조기 승인
안 지남 그 외 구버전 유지, 7일 뒤 재평가
  • 기준 시각은 npm registry 게시 시각만 씁니다. git 커밋 시각은 커밋하는 쪽에서 정할 수 있어 위조가 가능합니다.
  • 배포일을 못 가져오면 지난 걸로 치지 않고 보류합니다.
  • cooldown.py(경과 판정)와 cooldown_gate.py(전체 분기·하위 버전 폴백)로 나눴습니다. 구버전은 package-lock.json에서 읽습니다.
  • safe_npm.py의 check_package에서 계보 수집 직전에 부르게 해서, 보류할 때는 무거운 계보 수집을 아예 하지 않습니다.

6. 대시보드 구조 정리 (08-09)

  • 파일을 옮기자 parents[1]처럼 깊이를 고정해 계산하던 경로가 조용히 깨졌습니다. paths.py를 만들어 경로와 .env 로딩을 한곳에서 관리했습니다.
  • Windows에서 .woff2, .js가 application/octet-stream, .css가 text/plain으로 나가는 문제를 mimetypes.add_type()으로 고쳤습니다.
  • 따로 돌던 webapp/server.py를 python -m rootkeepers.dashboard 하나로 합쳐 Docker 실행을 단순하게 했습니다.

검증

팀에서 로컬 프라이빗 레지스트리(Verdaccio)와 개인 GitHub 저장소에 변조 패키지를 직접 만들어 6개 규칙을 하나씩 돌렸습니다. 공개 npm에는 아무것도 올리지 않았습니다.

규칙 결과
Orphan Release RISK 100점
Unreviewed RISK 70점
Workflow Drift WARN 50점
OIDC Mismatch RISK 100점
Unexpected Builder WARN 40점
Tag/Identity Drift WARN 40점
  • gitHead 없음 + attestation 사라짐 + 새 배포자처럼 여러 규칙이 겹치는 경우 실제로 최종 RISK로 차단되는 것을 확인했습니다.
  • 검증 중 Orphan Release가 "커밋이 없음"과 "조회 실패"를 구분하지 못해, attestation만 있으면 ua-parser-js 사고 같은 패턴이 PASS될 수 있는 버그를 찾아 고쳤습니다.
  • main 브랜치에 바로 푸시해 릴리스하는 정상 관행까지 감점하던 문제를 고친 뒤, 정상 패키지 300개에서 억울한 감점이 사라지고 대부분 PASS가 나오는 것을 확인했습니다.

FINDINGS

한계

  • 현재 Sigstore 서명을 암호학적으로 검증하지 않고 필드만 읽습니다. 저희가 자체서명 인증서로 가짜 attestation을 만들어 테스트할 수 있었던 이유이기도 하지만, 공격자도 같은 방식으로 속일 수 있어 따로 보완이 필요합니다.
  • 모노레포 패키지는 npm에 gitHead가 없어 GitHub 트랙이 건너뛰어졌습니다. attestation 안의 커밋 해시를 대신 쓰도록 수집 순서를 A→B→C에서 A→C→B로 바꾸는 방안을 회의에서 정했습니다.

ANALYSIS

팀에서 함께 만든 것

trustgate doctor     # 환경 진단
trustgate up         # 대시보드 실행
trustgate scan       # 패키지 검사
trustgate install    # 검사 후 설치
trustgate monitor    # 설치 버전 취약점 확인
  • 패키지 상세 화면에서 OSV 취약점 조회와 Semgrep 정적 분석 결과를 묶어 위험 요약을 보여줍니다. 설치 여부는 AI가 아니라 규칙이 정합니다.
  • 개발 중 판정 결과를 틀리게 만들던 수집기 버그 세 건도 팀에서 수정했습니다.

FINDINGS

배운 것

공급망 공격은 코드 한 줄의 결함이 아니라 누가, 어떤 경로로 배포했는지의 문제였습니다.
그래서 탐지 규칙도 절대 기준보다 그 패키지의 평소 모습과 비교하는 방식으로 설계했고,
코드를 읽는 것만으로는 확신할 수 없어서 변조 패키지를 직접 만들어 검증해야 한다는 것도 배웠습니다.

STACK

  • Python
  • Docker
  • Sigstore
  • OSV
  • Semgrep
←

백링크

이 프로젝트에 연결된 기록 1개
→

다른 프로젝트

단어 981글자 3,455백링크 1