LLM WikiAccess-protected knowledge portal

WIKI

OpenSSF 악성 패키지(MAL) RAW 수집 — 내부 기술 문서

OpenSSF 악성 패키지 MAL RAW 수집 — 내부 기술 문서 상태 설계 단계 / 미구현 2026 06 15 . 코드는 raw 수집 PoC collect.py 까지만 존재하며, DB 적재·증분·뷰는 본 문서의 설계안이다. 대상 독자 데이터파트 엔지니어 수집기 이해·운영 . 0. 한눈에 대상 OpenSSF ossf/malicious packages — 악성 패키지 advisory를 OSV JSON 포맷 MAL YYYY NNN

경로human/reports/2026-malicious-package-collection.md
카테고리Reports
태그#collection #crawler #malicious #mysql #package #report #reports #security

상태: 설계 단계 / 미구현 (2026-06-15). 코드는 raw 수집 PoC(collect.py)까지만 존재하며, DB 적재·증분·뷰는 본 문서의 설계안이다. 대상 독자: 데이터파트 엔지니어 (수집기 이해·운영).

0. 한눈에

(MAL-YYYY-NNNN)으로 게시하는 공개 저장소.

즉시 조인하지 않고, 독립 RAW 자산으로 전량 수집한다. 인벤토리 매핑은 이후 레이어로 분리(역방향보다 데이터 유실이 없음).

플래그로 소프트삭제하며, 소비/배포는 활성 뷰**만 본다.

1. 소스와 데이터 형태

1.1 생태계 분포 (수집 실측)

ecosystem건수ecosystem건수
npm213,926go18
pypi11,340vscode(+open-vsx)20
rubygems970crates.io9
nuget773maven2
packagist / git1 / 1

→ npm이 절대다수(94%). Ubuntu/RHEL/Debian 등 OS 배포판은 0건 — OpenSSF malicious-packages는 언어 레지스트리 전용이고, OS 배포판 보안 피드는 멀웨어가 아니라 취약점(CVE)이라 여기 포함되지 않는다.

1.2 affected vs database_specific (헷갈리기 쉬움)

영역정체용도
affected[]정규화된 결론 (OSV 표준)매핑/적재는 이걸 사용
database_specific.malicious-packages-origins출처·감사 추적 (OpenSSF 전용)신뢰도(다중 출처)·중복제거·증분
affected[].database_specific해당 패키지 분류 정보cwes(대개 CWE-506), 원본 URL

여러 피드(ghsa-malware, amazon-inspector, osv 등)가 같은 패키지를 신고해도 affected는 하나로 머지되고, 근거는 origins에 N개로 남는다. (예: MAL-2026-887 pyrefly — affected 1개, origins 2개)

1.3 버전 표현: ranges vs versions (수집 실측)

형태건수비율
ranges만 (대부분 introduced:0 = 전 버전)203,35689.6%
versions만 (특정 버전 목록)16,7047.4%
둘 다7,0003.1%

목록 → 충돌 아님, 일관됨.

introduced≠0 1,709건. 이게 "정상 패키지가 특정 버전만 탈취됐다 수정된" 고가치 케이스(예: pyphetools [0.9.120, 0.9.121))다 — 버리면 안 된다.

min~max 단일 구간으로 합치지 말 것 — 정상 버전을 악성으로 오탐하게 된다.

2. RAW 수집 방식

2.1 수집 메커니즘

2.2 적재 스키마

RAW 원본은 git working copy로 보존(repo 자체가 raw 저장소)하므로 DB에는 RAW_DATA나 별도 _RAW 테이블을 두지 않는다. _RAW/V1 2단 분리 없이 단일 TB_COMP_LIB_MAL + 활성 뷰로 간다.

CREATE TABLE TB_COMP_LIB_MAL (
  VULN_ID         VARCHAR(20)  NOT NULL,   -- MAL-YYYY-NNNN
  REPOSITORY       VARCHAR(30)  NOT NULL,   -- NPM / PYPI / COMPOSER / CARGO ... (대문자 정규, §2.4)
  NAME            VARCHAR(330) NOT NULL,   -- 평문 패키지명 (affected.package.name)
  PRODUCT_KEY     VARCHAR(330) NOT NULL,   -- npm=사내 해시키, 그 외=평문
  VULN_RANGE      TEXT         NOT NULL,   -- '|' 구분 토큰(구간/단일버전)의 합집합 (§2.3)
  DEPRECATED      TINYINT      NOT NULL DEFAULT 0,  -- 0=활성, 1=폐기(소프트삭제)
  SOURCE_URL      VARCHAR(400) NULL,
  RECORD_CREATED  TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP,
  RECORD_UPDATED  TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (VULN_ID, REPOSITORY, PRODUCT_KEY),
  KEY idx_active (DEPRECATED, REPOSITORY)
);

근거(수집본 227,060건 실측): 한 advisory의 affected는 항상 1개 패키지이고(다중 0건), 같은 product 중복도 0건 → (advisory, product)가 유일. 한 product에 range가 여러 개인 경우(747건)는 별도 행이 아니라 VULN_RANGE 셀에 합쳐 담는다(§2.3).

raw에 다 있어 필요 시 재추출/추가 가능.

패키지명에서 결정적으로 생성되므로 인벤토리 조회 없이도 계산 가능. (해시 로직 위치는 Open Question)

2.2.1 advisory 메타 테이블 TB_COMP_LIB_MAL_INFO

summary/details/references/aliases는 패키지가 아니라 advisory 단위 정보라 별도 테이블에 둔다. PK는 VULN_ID(advisory 1행).

CREATE TABLE TB_COMP_LIB_MAL_INFO (
  VULN_ID        VARCHAR(20)  NOT NULL,   -- MAL-YYYY-NNNN (MAL과 동일 ID)
  SUMMARY        TEXT         NULL,        -- osv.summary
  DETAILS        TEXT         NULL,        -- osv.details
  MAL_REFERENCES JSON         NULL,        -- osv.references [{type,url}] (REFERENCES는 예약어)
  ALIASES        JSON         NULL,        -- osv.aliases [GHSA-..., RUSTSEC-...]
  DEPRECATED     TINYINT      NOT NULL DEFAULT 0,
  RECORD_CREATED TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP,
  RECORD_UPDATED TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (VULN_ID),
  KEY idx_active (DEPRECATED)
);

CREATE VIEW VIEW_COMP_LIB_MAL_INFO AS
  SELECT VULN_ID, SUMMARY, DETAILS, MAL_REFERENCES, ALIASES,
         RECORD_CREATED, RECORD_UPDATED
  FROM TB_COMP_LIB_MAL_INFO WHERE DEPRECATED = 0;

TB_COMP_LIB_MAL/TB_COMP_LIB_MAL_INFO 양쪽 deprecate→재upsert, full sweep도 양쪽.

2.3 RANGE 변환 규칙 (OSV → 사내 표기)

VULN_RANGE|(파이프) 구분 토큰의 합집합으로 정의한다. 각 토큰은 구간([a,b)) 또는 단일 버전이다. (|는 구간·버전 문자열에 나타나지 않아 구분자로 안전)

입력변환비고
introduced:0(,)89%
introduced:X (상한 없음)[X,)1,709건
introduced:A + fixed:B[A,B)33건 — 처리 필수
introduced:A + last_affected:B[A,B]128건
versions:[v1,v2,…] (불연속)`v1\v2\v3`min~max 합치기 금지, `\`로 나열
range 여러 개 (한 product)union 단순화747건. (,) 있으면 (,), 없으면 disjoint를 `[a,b)\[c,d)`

대개 introduced:0을 포함 → (,) 하나로 접힌다. 그냥 이어붙이지 말고 union할 것.

2.4 ecosystem 정규화 (OSV → 내부 코드)

OSV ecosystem 명칭은 우리 내부 명칭과 다르다(예: PackagistCOMPOSER, crates.ioCARGO). 게다가 내부 명칭 자체가 테이블마다 케이싱이 일관되지 않다 (PyPI: product PYPI / vuln pypi, Rust: 테이블명 CARGO / product REPOSITORY Cargo). 따라서 MAL REPOSITORY대문자 정규 코드(vuln 테이블 suffix)로 통일하고, 조인 시 UPPER()로 케이싱 차이를 흡수한다.

OSV ecosystem (raw)내부 REPOSITORY(정규)LANGUAGE조인 시 실제 REPOSITORY
npmNPMjavascriptNPM
PyPIPYPIpythonproduct PYPI / vuln pypi ⚠️
RubyGemsRUBYGEMSrubyRUBYGEMS
NuGetNUGETdotnetNUGET
GoGOLANGgoGolang ⚠️
crates.ioCARGOrustproduct Cargo ⚠️ (테이블명 CARGO)
MavenMAVENjavaMAVEN
PackagistCOMPOSERphpCOMPOSER
vscode, vscode:open-vsx.org— (매핑 없음)VS Code 확장, 미수집
git— (매핑 없음)GIT range, 패키지 아님

규칙:

  1. REPOSITORY은 위 대문자 정규 코드로 적재하고 LANGUAGE도 동반 저장한다.
  2. 케이싱 불일치는 우리 데이터 문제이므로 조인은 UPPER(REPOSITORY) 비교로 흡수한다.
  3. vscode / vscode:open-vsx.org / git은 **매핑 대상이 없으므로 적재 스킵 + 로그로

명시**한다(조용히 버리지 않음). MAL 합계 21건.

구현 참고용 매핑(예시):

OSV_TO_INTERNAL = {
    "npm": ("NPM", "javascript"),
    "PyPI": ("PYPI", "python"),
    "RubyGems": ("RUBYGEMS", "ruby"),
    "NuGet": ("NUGET", "dotnet"),
    "Go": ("GOLANG", "go"),
    "crates.io": ("CARGO", "rust"),
    "Maven": ("MAVEN", "java"),
    "Packagist": ("COMPOSER", "php"),
    # 매핑 없음 → 스킵 + 로그: "vscode", "vscode:open-vsx.org", "git"
}

3. 증분 처리 (핵심 설계)

3.1 흐름

  1. git pull 후, 직전 head_commit 대비 변경 파일 추출:

git diff --name-only <prev>..<head> -- osv/malicious/.

  1. 변경된 각 MAL-*.json 재파싱 → **현재 advisory가 가리키는

(PRODUCT_KEY, REPOSITORY, VULN_RANGE) 행 집합**을 계산.

  1. 같은 VULN_ID의 기존 행과 비교(reconcile):

- 지금 있고 과거 없음 → INSERT (DEPRECATED=0). - 과거·현재 모두 있음 → 유지(DEPRECATED=0, RECORD_UPDATED 갱신). - 과거 있고 지금 없음 → DEPRECATED=1 (소프트삭제, 물리 삭제 X).

  1. advisory 파일 자체가 repo에서 삭제됨 → 해당 VULN_ID 행 전부 DEPRECATED=1.
  2. 처리 완료 후 head_commit을 상태로 저장 → 멱등(같은 commit 재처리 시 무변화).

3.2 왜 "패키지가 바뀌면" 폐기가 필요한가

advisory는 사후 수정된다. 예: MAL-xxxx-xx가 처음엔 패키지 A를 악성으로 지목했다가, 재분석 후 패키지 B로 정정될 수 있다. 그러면:

3.3 왜 물리 삭제하지 않는가

수집 DB의 데이터는 배포(distribution) DB로 동기화되어 흘러간다. 행을 물리 삭제하면 배포 동기화에서 누락·정합 문제가 생긴다. 그래서 A 행을 지우는 대신 DEPRECATED=1로 상태만 바꿔 폐기로 표시한다. 삭제가 아니라 상태 전이이므로 sync가 안전하게 전파한다.

3.4 증분 기준점(prev) 복구

prev(직전 head_commit)는 두 가지로 못 쓸 수 있다: ① 최초 실행이라 NULL, ② 존재했지만 upstream history rewrite/force-push/squash 또는 shallow clone으로 그 commit이 사라짐. 후자에서 git diff prev..HEAD가 실패하거나 엉뚱해진다.

기준점 유효성은 "존재"가 아니라 "HEAD의 조상인가"로 판정한다:

git cat-file -e "$prev^{commit}" 2>/dev/null \
  && git merge-base --is-ancestor "$prev" HEAD     # 둘 다 통과해야 정상 diff

commit_time 폴백 (prev가 무효일 때): stats에 저장한 commit_time(committer date) 이하(≤)의 가장 최근 commit을 base로 잡는다 — 반드시 내림(round down).

base=$(git rev-list -1 --before="$commit_time" origin/HEAD)
git diff --name-only "$base"..HEAD -- osv/malicious/

멱등이라 무해). 올려잡으면 사이 변경분을 놓쳐 데이터 유실 → 절대 금지.

의사결정 흐름

prev == NULL                  → 전체 스캔 INSERT (최초)
prev 가 HEAD의 조상           → git diff prev..HEAD        (빠른 경로)
prev 무효 & commit_time 있음  → base=(≤commit_time 최신); diff base..HEAD
그 외 (둘 다 실패)            → full reconcile (§3.5)

3.5 full reconcile (최종 안전망)

"무엇이 활성이어야 하는가"의 진짜 기준은 diff가 아니라 현재 HEAD 체크아웃 트리다. diff는 "어디만 볼지" 최적화일 뿐이다. 따라서 기준점을 못 찾는 최악의 경우엔 전량 대조한다:

- repo엔 있고 DB엔 없음 → INSERT (DEPRECATED=0) - DB엔 있고 repo엔 없음 → DEPRECATED=1

3.6 증분 상태(stats) 메타

기존 TB_CRAWLER_STATUS 컨벤션을 따라 TYPE='OPENSSF_MAL' 행에 메타를 둔다.

author date 아님.

4. DEPRECATED 컬럼 + 활성 뷰

-- 활성(미폐기) 악성 매핑만 노출
CREATE VIEW VIEW_COMP_LIB_MAL AS
SELECT VULN_ID, REPOSITORY, NAME, PRODUCT_KEY, VULN_RANGE, SOURCE_URL,
       RECORD_CREATED, RECORD_UPDATED
FROM TB_COMP_LIB_MAL
WHERE DEPRECATED = 0;

→ 폐기 이력은 원본 테이블에 남아 감사/복원이 가능하고, 다운스트림은 "현재 유효한 악성 매핑"만 단순 조회한다.

4.5 동작 시퀀스

초기 수집 (최초 실행 또는 full_reconcile=true)

sequenceDiagram
    participant DAG as Airflow DAG
    participant Pod as K8s Pod
    participant PVC as PVC /resources
    participant GH as OpenSSF repo
    participant ST as TB_CRAWLER_STATUS
    participant MAL as TB_COMP_LIB_MAL
    participant V as VIEW_COMP_LIB_MAL

    DAG->>DAG: check_running_instances
    DAG->>DAG: push_db_url_to_xcom, db_url
    DAG->>Pod: run scraper openssf_mal
    Pod->>MAL: initialize, CREATE TABLE/VIEW
    Pod->>PVC: ensure_repo, .git 없음
    Pod->>GH: git clone 약 896MB
    GH-->>PVC: working copy = raw 저장소
    Pod->>Pod: head_commit, head_time, run_ts
    Pod->>ST: load prev state TYPE=OPENSSF_MAL
    ST-->>Pod: 없음 → FULL 모드
    Pod->>PVC: list_all_files 약 227k
    loop batch 2000 scrape_data
        Pod->>PVC: 파일 read + 파싱
        Note over Pod: REPOSITORY 정규화, npm AES, RANGE 합집합
        Pod->>MAL: UPSERT DEPRECATED=0, RECORD_UPDATED=run_ts
    end
    Pod->>MAL: SWEEP, RECORD_UPDATED 이전 행 DEPRECATED=1 (최초 0건)
    Pod->>ST: UPSERT commit_id, commit_time
    V-->>V: 소비/배포는 DEPRECATED=0 만 조회

증분 (이후 매일 실행)

sequenceDiagram
    participant DAG as Airflow DAG
    participant Pod as K8s Pod
    participant PVC as PVC /resources
    participant GH as OpenSSF repo
    participant ST as TB_CRAWLER_STATUS
    participant MAL as TB_COMP_LIB_MAL

    DAG->>Pod: run, PVC 재마운트 repo 유지
    Pod->>PVC: ensure_repo, .git 있음
    Pod->>GH: git fetch + reset --hard origin/HEAD
    GH-->>PVC: 변경분 반영, 새 HEAD
    Pod->>ST: load prev state, prev commit_id/time
    alt prev 가 HEAD의 조상 is-ancestor
        Pod->>PVC: git diff prev..HEAD, 변경 파일만
    else prev 무효 그리고 commit_time 있음
        Pod->>PVC: base = commit_time 이하 최신, diff base..HEAD
    else 둘 다 실패 또는 full_reconcile
        Pod->>PVC: list_all_files 전체, FULL
    end
    loop 변경 advisory batch scrape_data
        Pod->>MAL: 변경 VULN_ID DEPRECATED=1 일괄 폐기
        Pod->>PVC: 현재 파일 파싱
        Pod->>MAL: 현재 행 UPSERT DEPRECATED=0 재활성/정정
        Note over MAL: A에서 B로 정정 시 A 폐기 유지, B 활성<br/>삭제 advisory도 폐기 유지, 물리삭제 안 함
    end
    Pod->>ST: UPSERT commit_id, commit_time, HEAD로 갱신

핵심: PVC가 증분의 전제(clone 1회 후 pull), 폐기는 삭제가 아님(상태 전이로 배포 sync 보호), FULL은 전역 sweep, 증분은 변경 VULN_ID만 폐기 후 재upsert.

5. 운영 / 다음 단계

Open Questions

base64(AES-CBC(pkg_name)), key=IV=b'NPMNPMNPMNPMNPMN', ai/labradorlabs/util/aesCbc.py(deterministic). npm만 이 변환 적용.

용어


근거: ~/labrador/tool/openssf-malicious(collect.py, manifest.json), 설계 결정 ai/decisions/2026-06-15-malicious-package-openssf-collection.md.