Projects / TrustGate
TrustGate
- 유형
- 프로젝트
- 분야
- 공급망
- 상태
- 완료
- 기간
- 2026.06 — 2026.09
- 역할
- 팀장
- 종류
- 프로젝트
npm 패키지를 설치하기 전에 그 릴리스가 원래 저장소의 정상 빌드에서 나왔는지 확인하고, 확인되지 않으면 설치를 막는 도구입니다. 팀장으로 진행을 맡고 GitHub 수집기, 탐지 규칙, 쿨다운 게이트를 만들었습니다.
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인 메인테이너, 태그를 안 다는 패키지 보호)
- 규칙 하나로는 막지 않고, 여러 규칙이 같이 걸릴수록 위험도를 올린다
- 데이터가 없으면 감점하지 않고
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
배운 것
공급망 공격은 코드 한 줄의 결함이 아니라 누가, 어떤 경로로 배포했는지의 문제였습니다.
그래서 탐지 규칙도 절대 기준보다 그 패키지의 평소 모습과 비교하는 방식으로 설계했고,
코드를 읽는 것만으로는 확신할 수 없어서 변조 패키지를 직접 만들어 검증해야 한다는 것도 배웠습니다.