본문으로 건너뛰기

CVE 스캔 / 보안 개선 프로세스

GenD 는 GitHub Advanced Security 없이 무료 도구로 의존성·이미지·시크릿 취약점을 탐지·개선한다.

구성 요소

계층도구트리거결과
의존성(SCA)Dependabot주간 + 보안알림 즉시자동 수정 PR
이미지 CVETrivyPR(게이트) + 주간 cron(드리프트)체크 실패 / 추적 이슈
IaCTrivy configPR(보고)Job 로그
시크릿GitleaksPR diff(게이트) + 주간 히스토리체크 실패 / 추적 이슈

:::caution 체크 실패 ≠ 머지 차단 체크가 빨개지는 것과 머지가 막히는 것은 별개다. 실제로 머지를 막으려면 그 잡이 main 브랜치 보호의 required status check 로 등록돼 있어야 한다. 이 문서는 오랫동안 "PR 차단 / 머지 차단" 이라고 적어 왔지만 보안 잡은 하나도 등록돼 있지 않았다 — 즉 exit-code: 1 로 빨개져도 머지 버튼은 열려 있었다 (#2916).

문서에 목록을 박제하면 또 어긋나므로, 현재 등록 상태는 아래로 직접 확인한다:

gh api /repos/genonai/DataX/branches/main/protection \
--jq '{required: .required_status_checks.contexts,
strict: .required_status_checks.strict,
reviews: .required_pull_request_reviews.required_approving_review_count,
enforce_admins: .enforce_admins.enabled}'

등록 시 주의 (#2916 에서 실측한 함정):

  1. PUT .../protection 은 전체 치환이다. contexts 에 기존 Test API (apps/api), Test UI (ui/) 를 함께 넣지 않으면 그 두 게이트가 사라진다.
  2. 잡 이름은 check-runs API 원문으로 확인한다. YAML 의 미인용 스칼라에 # 이 들어가면 주석으로 먹혀 잡 이름이 잘린다 — 눈으로 옮겨 적으면 오타가 난다. gh api /repos/genonai/DataX/commits/<sha>/check-runs --jq '.check_runs[].name'
  3. paths 필터가 있는 워크플로우의 잡을 등록하면 안 된다. 그 경로를 건드리지 않은 PR 에서는 워크플로우가 발동하지 않아 체크가 생성되지 않고, 영원히 "Expected — waiting for status" 로 머지 불가가 된다. security-scan.yml 은 이 때문에 PR 트리거에서 paths 를 제거하고 잡 안에서 dorny/paths-filter 로 가르도록 바꿨다 — 무관한 PR 에서도 잡은 생성되고 success 를 보고한다.
  4. Semgrep OSS SAST 는 등록 대상이 아니다. semgrep scan ... || true 라 findings 가 있어도 항상 success 이므로 게이트로서 의미가 없다. :::

트리거별 동작

  • PR: 변경 표면에서 신규 CRITICAL/HIGH(fixed) 또는 시크릿 발견 시 체크 실패. (그 실패가 머지를 막는지는 위 required 등록 여부에 달려 있다)
  • 주간 cron(월 09:00 KST): main 재빌드 후 갱신된 CVE DB 로 재스캔 → 신규 취약점을 dedup 이슈로 발행 → Claude autofix 파이프라인 + Slack 통지.
  • Dependabot: 자체 스케줄로 버전/보안 업데이트 PR 생성 → 봇 리뷰.

워크플로우 / 설정 파일

파일역할
.github/dependabot.yml의존성 업데이트 대상 정의(pip/npm/docker/actions)
.github/workflows/security-scan.ymlTrivy 이미지/IaC 게이트 + 주간 드리프트 job
infra/scripts/trivy-drift-issue.sh드리프트 결과 → dedup 이슈 발행
.github/workflows/secret-scan.ymlGitleaks PR 게이트 + 주간 히스토리
.gitleaks.tomlGitleaks 규칙 확장 + allowlist

대응 절차

  1. Dependabot PR: CI 통과 확인 후 머지(보안 업데이트 우선).
  2. security-scan-drift 이슈: 표의 Fixed 버전으로 base 이미지/패키지 bump → PR.
  3. security-secret-leak 이슈: 즉시 해당 자격증명 회전/회수, 히스토리 제거 검토, 오탐이면 .gitleaks.toml allowlist 에 사유와 함께 등록.

Dependabot PR 처리 정책

PR 의 빨간 security 라벨은 전 dependency PR 에 붙으므로 실제 취약점 여부는 라벨이 아니라 Dependabot alert(Security 탭)로 판단한다. 유형별:

유형처리
보안 alert 해소 PR (실 취약점)심각도순(CRITICAL>HIGH>MEDIUM) 즉시 머지
minor/patch 그룹CI green 시 머지
메이저 version-bump (비-보안)보류 — 적응 예정이면 open 백로그, 당분간 안 쓰면 @dependabot ignore this major version
range-wideners (<3.0<4.0 캡 완화)노이즈 — 새 메이저 채택 시점에만, 아니면 ignore
런타임 민감 lib (pdfjs·뷰어·router)semver 무관 브라우저 스모크 후 머지

메이저 PR을 "사라지게" 하는 법: @dependabot ignore this major version (닫힘 + 해당 메이저 재제안 차단, 현재 메이저 보안패치는 계속 수신). plain close 는 다음 릴리스에 재생성되므로 금지. ignore this dependency(보안패치까지 차단)·github-actions 그룹 ignore(액션 freshness 차단)는 피한다.

transitive(간접) 취약점은 Dependabot 이 자동 PR 을 안 여는 경우가 많다 → 수동: uipnpm.overrides 범위한정("pkg@major": ">=patched"), docs-sitenpm audit fix(비파괴).

인프라 메이저(trino-spi/flink/postgres/HikariCP/mlflow 등)는 open 백로그로 유지dependency-major-review.yml(분기 cron)이 자동 발행하는 재검토 이슈가 이 목록을 추적한다. 닫으면 추적을 잃는다.

억제(suppression) 정책

  • .trivyignore / .gitleaks.toml allowlist 항목은 사유 주석 + 만료일 필수, 가능한 추적 이슈 연결.

    • 예: 로컬 개발 전용 Harbor 기본 자격증명은 추적 이슈로 연결된 allowlist 로 관리한다(진짜 시크릿은 allowlist 금지 — 회수/회전).
  • 만료일 형식은 expires YYYY-MM-DD 이고 scripts/lint_suppression_expiry.py 가 CI 에서 강제한다. 만료일이 없거나 지났으면 실패한다.

    • 시간제한 수용이 아니라 구조적 규칙인 항목(예: "값 전체가 ${ENV_VAR} 면 시크릿이 아니다")은 permanent: <사유> 로 표기한다. 사유 문구가 비면 인정되지 않고, 통과 시에도 개수가 출력되어 조용한 탈출구가 되지 않는다.

    :::note 왜 사유 주석만으로는 부족한가 이전 억제 2건은 "starlette<1.0 핀이 풀리면 함께 제거할 것" 이라는 해제 조건을 달고 있었다. 그 핀은 2026-06-21 에 풀렸는데 억제는 45일 넘게 남아 있었다. 게다가 해제 트리거가 가리킨 파일에는 이미 핀이 없었고, 실제 starlette<1.0 은 다른 서비스(apps/codexec)로 옮겨가 있었다 — 조건이 엉뚱한 곳을 가리켜도 아무도 모른다. 조건은 사람만 확인할 수 있지만 날짜는 파서가 강제할 수 있다. (#2917) :::

  • 이미지별 게이트 방식은 베이스라인 부채에 따라 두 가지다.

    • 절대 게이트(exit-code:1)gend-api / streaming / codexec / egress-proxy. 베이스라인이 0 이라 CRITICAL/HIGH 가 하나라도 있으면 실패.
    • 부채 대장 게이트singleuser / codeserver / IaC(infra/). 베이스라인이 커서 0 으로 만들 수 없는 대상이다. exit-code 는 0 으로 두어 표 출력(부채 가시성)을 유지하고, scripts/check_cve_baseline.pyinfra/security/cve-baseline-*.txt 와 대조해 신규·증가에만 실패한다. 자세한 것은 부채 대장 README.
    • spark-notebook 은 visibility-only 로 남아 있다 — prod 미배포로 확인되어 은퇴 검토 중(#3063).

    :::warning 잡이 success 인 것은 무결의 증거가 아니다 exit-code:0 이면 findings 가 몇 건이든 항상 success 다. 그래서 이 저장소는 "게이트가 켜져 있나" 가 아니라 "무엇에 실패하나" 로 판단한다. 부채 대장 게이트는 그 답이 "대장에 없는 신규 findings" 다. :::

  • 분기별로 allowlist·SHA pin·룰셋을 리뷰한다.

SLA

  • CRITICAL: 발견 후 영업일 기준 3일 내 triage 및 수정 PR 착수.
  • HIGH: 1주 내 triage.

활성화 선행조건 (운영자 1회 설정)

레포 Settings > Code security and analysis 에서 무료 항목 활성화 (또는 gh api -X PUT repos/<owner>/<repo>/vulnerability-alerts + .../automated-security-fixes):

  • Dependabot alerts
  • Dependabot security updates

커버리지 점검 (정기)

dependabot.yml 이 커버하지 않는 manifest 의 취약점은 자동 수정 PR 이 생성되지 않는다(HIGH 도 조용히 노출됨).

커버리지는 이제 CI 가 강제한다scripts/lint_dependabot_coverage.py 가 저장소의 실제 매니페스트와 dependabot.yml 선언을 대조해 갭이 있으면 머지를 막는다 (#2918). 매니페스트 판정은 목록이 아니라 규칙이다:

  • pip[project] 테이블이 있는 pyproject.toml. 루트 pyproject.toml 처럼 도구 설정(ruff) 전용이면 대상이 아니다.
  • docker — 리터럴 base image 를 하나라도 쓰는 Dockerfile*. FROM ${GEND_API_IMAGE} 처럼 모든 FROM 이 build ARG 참조뿐이면(예: infra/seed) Dependabot 이 갱신할 대상이 없으므로 제외된다.
  • 판단이 필요한 예외는 dependabot.yml 안에 # dependabot-coverage-exempt: /dir — <사유> 로 남긴다. 더 이상 매니페스트가 아니거나 이미 커버된 경로를 가리키는 죽은 예외도 lint 가 잡는다.

:::caution 커버리지 갭은 "알림 0건" 으로 보인다 갭은 "문제 없음" 과 구분되지 않는 형태로 나타난다. 실제로 apps/codexecapps/egress-proxy 는 pyproject + Dockerfile 을 갖고 배포되는 서비스인데 pip·docker 어느 생태계에도 없었고, 하필 apps/codexec.trivyignore 로 억제된 Starlette CVE 2건의 실거주지였다 — 억제로 게이트에서 빠지고 미등록으로 알림도 없어 탐지 경로가 0 이었다. 아래 스니펫은 "열린 alert" 를 세므로 없는 알림은 못 센다 — 갭 점검은 반드시 lint 로 한다. :::

# 열린 alert 의 manifest_path × ecosystem × severity (커버되는 범위 안에서의 현황)
gh api "repos/<owner>/<repo>/dependabot/alerts?state=open&per_page=100" \
--jq '.[] | "\(.security_advisory.severity) \(.dependency.package.ecosystem) \(.dependency.manifest_path)"' | sort | uniq -c

# 커버리지 갭 자체는 이쪽으로 (CI 와 동일 판정)
python3 scripts/lint_dependabot_coverage.py

긴급도 기준 = 권고 심각도·도달성·노출면 (버전 크기 아님). HIGH/CRITICAL advisory 해소 PR 은 즉시 머지 대상, 일상 version-bump 은 낮은 우선순위.

간접(transitive) 의존성 — 파이썬은 uv.lock 기반

간접 의존성 취약점은 version-update 로 안 잡히고 락파일이 있어야 security-update 가 처리한다. JS 는 pnpm-lock.yaml / package-lock.json, 파이썬은 uv.lock 이 그 역할을 한다 (9개 프로젝트 전부, #2941).

dependabot.yml 에서 파이썬은 package-ecosystem: "uv" 로 선언한다 — pip 과 별개 값이며, pip 으로 두면 uv.lock 이 갱신되지 않는다. 두 생태계에 같은 디렉터리를 동시에 선언하면 중복 PR 이 열리므로 uv 단독으로 둔다.

:::caution 락이 없을 때 무엇이 안 보였나 (2026-08-06 실측) GitHub dependency graph 는 락이 없으면 파이썬 패키지의 버전을 기록하지 못한다. SBOM API 실측: pypi 134종 전부 versionInfo 없음. 같은 시점 npm 2322/2322 · maven 7/7 · github-actions 26/26 은 전부 버전 보유.

그 결과 전이 의존성은 그래프에 아예 등장하지 않았다 — 예: pyarrow 19.0.1 (HIGH, mlflow 경유)이 SBOM 에 0건. 탐지 경로가 문자 그대로 없었다.

그리고 "pip alert 가 적다" 는 안전의 근거가 아니다. 전 기간 alert 220건 중 pip 은 10건뿐이고 전부 mlflow 한 패키지인데, 그 10건조차 매니페스트가 한 번도 바뀌지 않은 채 state=fixed 로 닫혔다. 열린 pip alert 는 현재 0건이지만 동일 advisory DB(GitHub Advisory) 기준 mlflow 2.22.5 에는 미해소 advisory 22건 (CRITICAL 7) 이 있다. 대시보드의 "열린 알림 0" 을 파이썬이 깨끗하다는 근거로 쓰지 말 것. :::

:::note 락이 닫지 못하는 것 락은 가시화 장치지 그 자체로 취약점을 고치지 않는다. 상한 핀이 모든 픽스 버전을 배제하면 락을 붙여도 해소되지 않는다 — mlflow>=2.19,<3.0 이 정확히 그 경우이고, 실제 해소는 상한 해제(별도 이슈)로만 가능하다.

또한 pyproject.toml 이 없는 파이썬 설치 표면(인프라 Dockerfile 10곳, docker/spark-notebookpyarrow==17.0.0 등)은 락이 덮지 않는다. 그쪽은 이미지 Trivy 스캔이 유일한 방어선이다. :::

워크플로우 / 설정 파일 (가드 스크립트)

파일역할
infra/scripts/build-scan-image.sh스캔 잡과 주간 드리프트가 공유하는 이미지 빌드 정의 단일 소스. 두 곳에 복붙하면 한쪽만 갱신되는 회귀가 난다(#1919 에서 codeserver build-arg 누락으로 실제 발생)
scripts/lint_suppression_expiry.py억제 항목 만료일 누락/경과 차단 (#2917)
scripts/lint_dependabot_coverage.pydependabot 커버리지 갭 차단 (#2918)
scripts/lint_manifest_env_secrets.py매니페스트 평문 자격증명 차단 (#3088) — 두 축: ① env[].value ② YAML/SQL/values/properties 의 <자격증명키> = <값>. 예외는 이슈·만료일·건수를 전부 요구한다
scripts/check_cve_baseline.py부채 대장 대조 — 신규/증가 exit 1, 해소/감소 exit 2, 대장 부재 exit 3 (#1920)
infra/security/cve-baseline-*.txt부채 대장. 억제가 아니라 대장 — 항목이 스캔 출력에 그대로 남는다
scripts/test_cross_pr_consistency.py::test_jupyterhub_cve_overlay_pins_are_in_sync두 singleuser 이미지의 CVE 정정 overlay 핀 동기화 강제 (#1920)

매니페스트 평문 자격증명 (#3088)

Trivy 도 Gitleaks 도 k8s 매니페스트의 자격증명 리터럴을 보지 못한다:

스캐너사각지대
Trivy KSV-0109ConfigMap 만 검사 — Deployment/Job 의 env[].value 는 대상 밖
Gitleaksk8s env(name:/value: 두 줄) 패턴을 안 본다
test_manifest_secret_isolation.py (#2991)kind: Secret 문서만 검사

그 사이로 prod SeaweedFS 자격증명과 LDAP root 비밀번호가 리포에 평문으로 들어와 있었다. scripts/lint_manifest_env_secrets.py두 축으로 이 형태를 막는다.

:::warning 한 축만 막으면 "고쳤다" 는 착각이 생긴다 축 1(env[].value)만 넣고 머지한 뒤 검증하니, 같은 자격증명이 ConfigMap 안 SQL DDL 과 Helm values 의 ini 문자열에 그대로 남아 있었다. 축 2 는 그 경험에서 나왔다. :::

예외 등재 형식 — 자유 서술 사유를 받지 않는다

EXEMPTIONS 항목은 issue · expires · reason · count전부 요구한다.

  • expires — 지나면 CI 가 실패한다. .trivyignore 의 만료 규칙과 같은 원리.
  • count지금 있는 만큼을 고정한다. 없으면 같은 파일에 같은 키를 더 넣어도 통과한다.
  • reason — ★ "dev 전용" 이라고 쓰려면 그것이 검증된 사실이어야 한다.

:::danger 사유가 틀리면 가드가 위험을 인증한다 #2991 의 allowlist 는 infra/s3/credentials.yaml 을 "Kind 로컬 dev 클러스터 well-known 자격증명" 이라는 사유로 승인했는데, 실측하니 prod 실값과 sha256 이 일치했다(#3088). 값이 안 바뀌므로 stale 검사도 못 잡는다 — 그래서 만료일로 재검토를 강제한다.

사유는 이후 사실로 교체했고(#3088), 재발을 막는 가드 2종을 넣었다:

  • test_allowlist_reason_does_not_claim_dev_only_without_qualification — "dev 전용" 을 근거 없이 주장하는 사유를 차단한다. 그렇게 쓰려면 prod 와의 관계 (sha256 대조 결과)를 사유에 명시해야 한다.
  • test_prod_matching_entries_are_marked_as_such — prod 실값으로 확정된 항목이 사유에 그렇게 적혀 있는지 고정한다.

기계가 prod 를 조회할 수는 없으므로 주장의 형식을 강제하는 것이 최선이다. :::

부채 대장 갱신 (운영 절차)

대장은 CI 가 만든다. 로컬 trivy 는 DB 스냅샷이 달라 같은 이미지에서도 다른 findings 를 내므로, 로컬로 만든 대장을 커밋하면 첫 CI 에서 신규·해소가 뒤섞인 채 터진다.

gh workflow run security-scan.yml --ref <브랜치> -f update_baseline=true
gh run watch <RUN_ID> --exit-status
# ★ gh run download 는 같은 이름의 파일이 있으면 **덮어쓰지 않고 실패**한다.
# 지우고 받을 것 — 안 그러면 옛 대장을 새 것으로 착각한다.
rm -f infra/security/cve-baseline-*.txt
for n in singleuser codeserver iac; do
gh run download <RUN_ID> -n "cve-baseline-$n" -D infra/security/
done

부채를 늘려서 갱신하는 것은 마지막 수단이며, 왜 수정 대신 수용인지 PR 본문에 근거를 남긴다.

주간 드리프트와 대장의 관계 (#3073)

주간 trivy-drift 잡도 같은 대장을 참조한다 — 대장에 있는 findings 는 이슈로 만들지 않고, 대장에 없는 신규만 보고한다. 이슈 본문에 신규 CRITICAL/HIGH기지 부채 N건 이 분리 표기되므로 한 줄로 판단할 수 있다.

  • 대장이 없는 이미지(gend-api / streaming / codexec / egress-proxy)는 빈 대장으로 본다 → findings 가 곧 신규다. 이들은 절대 게이트라 베이스라인이 0 이므로 정확하다.
  • 대조가 실패하면 drift 는 exit 1 로 죽는다. "대장을 못 읽음" 을 "신규 0" 으로 오독해 주간 알림이 통째로 무음이 되는 것을 막기 위해서다.

:::warning 왜 이 배선이 필요했나 배선 전에는 drift 가 기지 부채를 매주 재보고했다. 실측(2026-08-07)으로 8/10 첫 실행에 codeserver 68건 · singleuser 17건이 100% 대장에 있는 값으로 이슈가 될 예정이었다. 알림이 전부 기지 부채면 진짜 신규가 그 안에 묻힌다. :::

향후 확장 (Phase 2)

  • singleuser / codeserver 를 절대 게이트로 승격 — 선행 조건은 mlflow 2→3 동반 업그레이드(클라이언트 15건 + 그 캡에 막힌 pyarrow), code-server 4.95.3 업그레이드(번들 node_modules ~38건), jupyterhub upstream 의 underscore.
  • IaC 절대 게이트 승격 — KSV-0014(readOnlyRootFilesystem) 계열 76건 해소 선행.
  • spark-notebook 은퇴 판단 (#3063) — 유지 시 Spark/Hadoop JAR 138건 에픽.
  • 파이썬 락파일 도입(전이 의존성 가시화) — #2941 에서 완료.
  • 이미지 서명(cosign) — 에어갭 반입 무결성과 함께 검토.