최신 트렌드

SBOM과 Sigstore 실전 - 공급망 공격 시대의 A08 방어선 구축

백엔드 개발자 김승원 2026. 4. 23. 12:29

들어가며

지난 #131 OWASP 가이드에서 Dependency-Check와 ZAP으로 A01~A07을 어느 정도 닫았습니다. 남은 것이 A08 - Software & Data Integrity Failures, 즉 공급망(supply chain) 공격입니다.

2024년 xz-utils 백도어, 2025년 Nx npm 대규모 토큰 유출, 그리고 Axios 공급망 공격까지 최근 2년간 굵직한 사고는 대부분 "코드가 문제가 아니라 우리가 당겨온 라이브러리가 문제"였습니다. Dependency-Check로도 안 잡히는 제로데이·타이포스쿼팅이 실제 전선입니다.

막는 방법은 두 축입니다.

  1. SBOM(Software Bill of Materials) - 우리 빌드 산출물에 뭐가 들어갔는지 기계가 읽을 수 있는 형태로 기록
  2. Sigstore(cosign/rekor) - 빌드 산출물과 SBOM에 공급자가 서명하고, 소비자는 검증

오늘은 Spring Boot + GitHub Actions + 사내 레지스트리 환경을 가정해 이 두 가지를 코드 레벨로 구축합니다.

  • 1부 - 공급망 공격이 무엇을 노리는가
  • 2부 - SBOM 포맷 비교와 Gradle 자동 생성
  • 3부 - Syft·Grype로 컨테이너 이미지 SBOM과 취약점 스캔
  • 4부 - Sigstore/cosign으로 이미지·SBOM 서명
  • 5부 - Kubernetes Admission Controller로 서명 없는 이미지 차단
  • 6부 - SLSA 레벨과 빌드 증명(attestation)

1. 공급망 공격이 노리는 것

공격자는 당신 코드를 뚫지 않습니다. 당신이 쓰는 라이브러리의 메인테이너 계정, 또는 빌드 파이프라인을 뚫습니다.

공격 유형 대표 사례 차단 포인트
악성 패키지 공개 typosquatting(requsts) 허용 목록·사내 미러
정상 패키지 탈취 event-stream, ua-parser-js SBOM 버전 고정 + 서명 검증
빌드 시스템 침투 SolarWinds 재현 가능한 빌드 + 증명
레지스트리 중간자 내부 Nexus 무인증 서명 필수화
의존성 혼동 사내 패키지명 탈취 scope/네임스페이스 점유

CVE가 공개되기 전에 당하는 것이 공급망 공격의 특성이라, "CVE 목록과 매칭" 방식은 근본적으로 늦습니다. SBOM·서명은 "뭐가 들어있고 누가 만들었는지"를 항상 알 수 있게 해서, 사고 후 10분 안에 영향 범위를 파악하고 롤백할 수 있게 해줍니다.

2. SBOM 포맷 - CycloneDX vs. SPDX

표준 포맷은 사실상 두 개입니다.

항목 CycloneDX SPDX
주도 OWASP Linux Foundation
초점 보안(취약점·서비스·ML 모델) 라이선스·법적 준수
파일 포맷 JSON, XML, Protobuf JSON, YAML, RDF, tag-value
VEX 지원 기본 내장 확장
대표 도구 CycloneDX CLI, Syft Syft, spdx-tools

보안 중심이면 CycloneDX, 라이선스 컴플라이언스가 중요하면 SPDX, 현실은 둘 다 생성하는 조직이 많습니다. Syft로 한 번에 두 포맷을 뽑을 수 있어 비용이 크지 않습니다.

2-1. Gradle에서 CycloneDX SBOM 생성

// build.gradle.kts
plugins {
    id("org.cyclonedx.bom") version "1.10.0"
}

tasks.cyclonedxBom {
    includeConfigs.set(listOf("runtimeClasspath"))
    skipConfigs.set(listOf("testCompileClasspath"))
    projectType.set("application")
    schemaVersion.set("1.5")
    destination.set(file("build/reports"))
    outputName.set("bom")
    outputFormat.set("json")
    includeLicenseText.set(false)
}
$ ./gradlew cyclonedxBom
# build/reports/bom.json 생성

2-2. 생성된 SBOM 예시(발췌)

{
  "bomFormat": "CycloneDX",
  "specVersion": "1.5",
  "serialNumber": "urn:uuid:3e671687-...",
  "version": 1,
  "metadata": {
    "timestamp": "2026-04-23T10:15:00Z",
    "tools": [{"name": "cyclonedx-gradle-plugin", "version": "1.10.0"}],
    "component": {
      "type": "application",
      "name": "order-service",
      "version": "2026.04.23-r1",
      "purl": "pkg:maven/com.example/order-service@2026.04.23-r1"
    }
  },
  "components": [
    {
      "type": "library",
      "group": "org.springframework.boot",
      "name": "spring-boot",
      "version": "3.4.2",
      "purl": "pkg:maven/org.springframework.boot/spring-boot@3.4.2",
      "hashes": [{"alg": "SHA-256", "content": "a1b2c3..."}],
      "licenses": [{"license": {"id": "Apache-2.0"}}]
    }
  ]
}

핵심은 purl(Package URL)hash입니다. purl로 범용 식별이 되고, hash로 무결성 검증이 가능합니다.

3. Syft + Grype로 컨테이너 이미지까지 커버

Gradle 플러그인은 자바 의존성만 봅니다. 최종 배포물이 컨테이너라면 베이스 이미지의 OS 패키지도 SBOM에 들어가야 합니다.

3-1. Syft로 이미지 SBOM 추출

$ syft registry.internal/order-service:2026.04.23-r1 \
    -o cyclonedx-json=sbom-image.json \
    -o spdx-json=sbom-image.spdx.json

한 번의 호출로 두 포맷을 동시에 생성합니다. 베이스 이미지(예: eclipse-temurin:21-jre)의 OS 라이브러리까지 모두 잡힙니다.

3-2. Grype로 SBOM 기반 취약점 스캔

$ grype sbom:./sbom-image.json --fail-on high
 NAME               INSTALLED  FIXED-IN  TYPE   VULNERABILITY  SEVERITY
 libc6              2.38-3     -         deb    CVE-2024-XXXX  Medium
 spring-web         6.2.1      6.2.3     java   CVE-2025-XXXX  High  <- 빌드 실패

Dependency-Check와 역할이 다릅니다. Grype는 이미 빌드된 이미지를 검사하고 OS 패키지까지 커버하므로, 개발 단계 Dependency-Check와 배포 단계 Grype를 병행하는 것이 정석입니다.

3-3. GitHub Actions 통합

  sbom:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Generate source SBOM
        run: ./gradlew cyclonedxBom
      - name: Install syft/grype
        run: |
          curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \
            | sh -s -- -b /usr/local/bin
          curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh \
            | sh -s -- -b /usr/local/bin
      - name: Image SBOM
        run: |
          syft registry.internal/order-service:${{ github.sha }} \
            -o cyclonedx-json=sbom-image.json
      - name: Vulnerability scan
        run: grype sbom:./sbom-image.json --fail-on high
      - uses: actions/upload-artifact@v4
        with:
          name: sbom
          path: |
            build/reports/bom.json
            sbom-image.json

4. Sigstore/cosign으로 서명하기

SBOM이 있어도 그 SBOM이 진짜 우리 빌드에서 나온 것인지 검증할 수 없으면 무용지물입니다. Sigstore는 X.509 없이 OIDC 신원 기반으로 서명/검증을 가능하게 해주는 오픈소스 스택입니다.

  • cosign - CLI, 이미지와 파일 서명
  • Fulcio - OIDC 기반 단기 인증서 발급(공개 무료)
  • Rekor - 서명 이력을 위변조 불가 로그에 기록

개인 키 관리가 필요 없는 keyless 서명이 Sigstore의 킬러 피처입니다. GitHub Actions의 OIDC 토큰이 그대로 신원 증명이 됩니다.

4-1. Keyless 서명

  sign:
    needs: sbom
    runs-on: ubuntu-latest
    permissions:
      id-token: write      # OIDC 토큰 요청
      contents: read
      packages: write
    steps:
      - uses: sigstore/cosign-installer@v3
      - name: Login GHCR
        run: echo ${{ secrets.GHCR_TOKEN }} | \
             docker login ghcr.io -u ${{ github.actor }} --password-stdin
      - name: Sign container image
        env:
          COSIGN_EXPERIMENTAL: "1"
        run: |
          cosign sign --yes \
            ghcr.io/myorg/order-service@${{ steps.build.outputs.digest }}
      - name: Attach SBOM as attestation
        run: |
          cosign attest --yes --predicate sbom-image.json \
            --type cyclonedx \
            ghcr.io/myorg/order-service@${{ steps.build.outputs.digest }}

digest 기반 서명이 핵심입니다. 태그는 mutable이라 서명 후 다른 이미지로 바뀔 수 있어, 반드시 @sha256:...으로 고정해야 합니다.

4-2. 소비자 측 검증

$ cosign verify \
    --certificate-identity-regexp \
      "^https://github.com/myorg/.+/.github/workflows/release.yml@.*$" \
    --certificate-oidc-issuer \
      "https://token.actions.githubusercontent.com" \
    ghcr.io/myorg/order-service@sha256:abc123...

"myorg 저장소의 release.yml 워크플로우에서 만들어진 것만 신뢰"라는 정책을 CLI 한 줄로 표현합니다. 키 배포·만료 관리 부담이 없습니다.

4-3. Rekor 투명성 로그

cosign sign이 성공하면 서명 정보가 공개 Rekor 로그에 기록됩니다. 사후에 서명자가 "나는 서명한 적 없다"고 부인할 수 없습니다.

$ rekor-cli search --artifact sbom-image.json
$ rekor-cli get --uuid 24296fb24b8ad77a...
# 시그니처, 인증서, 타임스탬프, 포함 증명 확인

5. Kubernetes에서 서명 검증 강제

빌드에서 서명만 해두고 런타임에서 검증을 안 하면 의미가 없습니다. Admission Controller로 서명 없는 이미지를 배포 단계에서 차단합니다.

5-1. Sigstore Policy Controller 설치

$ helm repo add sigstore https://sigstore.github.io/helm-charts
$ helm install policy-controller sigstore/policy-controller \
    --namespace cosign-system --create-namespace

5-2. ClusterImagePolicy 정의

apiVersion: policy.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
  name: myorg-signed-only
spec:
  images:
    - glob: "ghcr.io/myorg/*"
  authorities:
    - name: github-actions
      keyless:
        url: https://fulcio.sigstore.dev
        identities:
          - issuer: https://token.actions.githubusercontent.com
            subjectRegExp: "^https://github.com/myorg/.+/.github/workflows/release.yml@.*$"
  mode: enforce

이 정책이 적용된 네임스페이스에서는 서명 없는 이미지 Pod 생성이 거부됩니다. Kubernetes 실전 운영의 admission control 지식이 그대로 쓰입니다.

5-3. 네임스페이스 단위 활성화

apiVersion: v1
kind: Namespace
metadata:
  name: production
  labels:
    policy.sigstore.dev/include: "true"

개발 네임스페이스는 제외하고 production·staging에만 적용하면 도입 저항이 줄어듭니다.

6. SLSA 레벨과 빌드 증명

SLSA(Supply-chain Levels for Software Artifacts)는 구글이 주도한 공급망 성숙도 프레임워크입니다. 레벨이 올라갈수록 요구사항이 강해집니다.

레벨 요구사항 현실적 달성도
L1 빌드가 자동화되고 증명(provenance) 생성 GitHub Actions만 있으면 가능
L2 호스팅된 빌드 플랫폼, 인증된 증명 GitHub/GitLab 표준 환경에서 기본
L3 빌드 환경 격리, 위변조 불가 로그 전용 러너 + Rekor
L4 재현 가능한 빌드, 2인 리뷰 극소수 조직

6-1. GitHub의 SLSA Provenance

  provenance:
    permissions:
      id-token: write
      contents: read
      packages: write
    uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v2.0.0
    with:
      image: ghcr.io/myorg/order-service
      digest: ${{ needs.build.outputs.digest }}
      registry-username: ${{ github.actor }}
    secrets:
      registry-password: ${{ secrets.GHCR_TOKEN }}

이 워크플로우가 통과하면 이미지에 빌드 증명(in-toto attestation)이 붙습니다. "누가, 어떤 소스 커밋으로, 어떤 빌더로 만들었는지"를 암호학적으로 증명할 수 있습니다.

6-2. 소비자 측 정책 예시

단순히 서명만 확인하는 게 아니라 "메인 브랜치에서 빌드된 것만 배포"처럼 정책을 강화할 수 있습니다.

apiVersion: policy.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
  name: main-branch-only
spec:
  images: [{glob: "ghcr.io/myorg/*"}]
  authorities:
    - name: slsa
      keyless:
        url: https://fulcio.sigstore.dev
        identities:
          - issuer: https://token.actions.githubusercontent.com
            subjectRegExp: "^https://github.com/myorg/.+@refs/heads/main$"
      attestations:
        - name: slsa-provenance
          predicateType: https://slsa.dev/provenance/v1
          policy:
            type: cue
            data: |
              predicate: {
                buildDefinition: {
                  buildType: "https://github.com/slsa-framework/slsa-github-generator/container@v1"
                }
              }

7. VEX - "이 CVE는 우리와 무관합니다"

SBOM이 자세해질수록 오탐이 폭증합니다. Spring Web에 CVE가 떴는데 우리는 취약한 엔드포인트를 안 쓰는 경우입니다. VEX(Vulnerability Exploitability eXchange)는 이 상황을 기계가 읽을 수 있게 선언하는 포맷입니다.

{
  "@context": "https://openvex.dev/ns/v0.2.0",
  "@id": "https://myorg.example.com/vex/2026-04-23",
  "author": "security@myorg.example.com",
  "timestamp": "2026-04-23T10:00:00Z",
  "statements": [
    {
      "vulnerability": {"name": "CVE-2025-XXXX"},
      "products": [
        {"@id": "pkg:oci/order-service@sha256:abc..."}
      ],
      "status": "not_affected",
      "justification": "vulnerable_code_not_in_execute_path",
      "impact_statement": "해당 DeserializationHandler를 사용하지 않음"
    }
  ]
}

이 VEX 문서도 SBOM과 함께 서명해 레지스트리에 첨부합니다. 취약점 대시보드가 VEX를 인식해 자동으로 Noise를 걸러주어, 보안팀이 진짜 대응해야 할 항목에만 집중할 수 있습니다.

8. 레지스트리 전략

공급망 보안을 강화할수록 의존성을 어디서 받는가가 중요해집니다.

전략 설명 추천 상황
직접 pull Maven Central / npmjs 직접 소규모, 검증 자동화 전제
사내 미러(Nexus/Artifactory) 모든 외부 의존성 프록시 중견 이상 대부분
허용 목록(allowlist) 화이트리스트된 패키지만 통과 금융·공공
서명 필수 레지스트리 cosign 서명 없는 이미지 pull 불가 프로덕션 표준

사내 미러는 오래된 베스트 프랙티스고, 여기에 서명 필수 + SBOM 첨부 필수 레이어를 올리는 것이 2026년 수준입니다.

9. 사고 대응 시나리오

모든 방어를 뚫고 취약 라이브러리가 들어왔다고 가정해봅시다. SBOM·서명이 있는 조직은 대응 속도가 다릅니다.

  1. 10분 - 새 CVE 뉴스 수신 → grep 수준의 SBOM 쿼리로 영향받는 서비스 전 조직 탐색
    $ for sbom in registry/*.json; do
        jq -r --arg cve "spring-web@6.2.1" \
          '.components[] | select(.purl | contains($cve)) | input_filename' "$sbom"
      done
  2. 30분 - 서명 이력(Rekor)을 역추적하여 언제 이 버전이 배포됐는지 확인
  3. 1시간 - 패치 버전 빌드 + 서명 + SBOM 재생성
  4. 2시간 - 영향받은 네임스페이스 롤아웃. Admission Controller가 신규 서명만 허용
  5. 사후 - VEX 갱신, 공급망 경로 점검

SBOM이 없었다면 1번에서만 하루가 갑니다. log4shell 당시 SBOM 있던 조직과 없던 조직의 대응 격차가 이것이었습니다.

10. Spring Boot 프로젝트 체크리스트

오늘 다룬 내용을 실제 프로젝트에 적용할 때의 단계입니다.

  • [ ] CycloneDX Gradle 플러그인 추가, ./gradlew cyclonedxBom CI 필수화
  • [ ] 배포 이미지에 Syft로 이미지 SBOM 별도 생성
  • [ ] Grype로 빌드 실패 게이트(--fail-on high)
  • [ ] cosign keyless 서명, digest 기반
  • [ ] SBOM을 cosign attest로 이미지에 첨부
  • [ ] 프로덕션 네임스페이스에 Policy Controller 활성화
  • [ ] SLSA L2 이상(generator 워크플로우 채택)
  • [ ] VEX 선언 프로세스 수립, 보안팀 승인 필수
  • [ ] Artifactory/Nexus 미러 + 서명 필수 정책
  • [ ] 월간 SBOM 감사(오래된 버전 고정 리뷰)

11. 흔한 도입 실패 패턴

  • SBOM만 생성하고 아무도 안 읽기 - 빌드 아티팩트에 넣고 끝. 쿼리 파이프라인(예: DependencyTrack)을 붙여 알림으로 이어져야 살아있는 데이터.
  • 태그 기반 서명 - :latest에 서명하면 나중에 다른 이미지가 같은 태그를 재활용해 무력화됩니다. 무조건 digest.
  • cosign 사설 키 분실 - keyless가 나온 이유. 신규 프로젝트는 처음부터 keyless로 시작.
  • VEX 남발 - "이 CVE는 영향 없음" 선언이 보안팀 리뷰 없이 남발되면 구멍 은폐 수단이 됨. 반드시 사유 문서화 + 분기별 재검증.
  • Admission Controller 도입하자마자 전 네임스페이스 enforce - 대형 장애의 지름길. warn 모드로 2주 관찰 후 enforce로 전환.
  • SBOM에 민감정보 유출 - 사내 내부 패키지 경로·서버 URL이 SBOM에 들어갈 수 있음. 외부 공개 전 필터링 규약 필요.

12. 조직 성숙도 로드맵

분기 목표
Q1 소스 SBOM 생성 자동화, Grype 게이트 도입
Q2 cosign keyless 서명 + 이미지 SBOM attestation
Q3 Policy Controller enforce(프로덕션만), SLSA L2 달성
Q4 VEX 프로세스 정착, DependencyTrack 상시 감사

이 과정을 거치면 OWASP Top 10의 A06(취약 컴포넌트)·A08(무결성 실패) 두 항목을 조직 차원에서 구조적으로 닫을 수 있습니다.

마치며

SBOM·Sigstore 기반 공급망 방어의 핵심을 정리합니다.

  • 공급망 공격은 "내 코드"가 아니라 "내가 당겨오는 것"을 노립니다. CVE 매칭 기반 방어는 태생적으로 늦기 때문에, SBOM으로 "지금 뭐가 배포돼 있는가"를 항상 알 수 있게 해야 사고 10분 안에 영향 범위를 파악할 수 있습니다.
  • SBOM은 두 단계로 생성해야 완전합니다. Gradle 플러그인으로 소스 단계 SBOM을, Syft로 최종 이미지 SBOM을 만들고, Grype는 이미지 SBOM을 소비해 취약점을 찾습니다. Dependency-Check는 커밋 단계, Grype는 배포 직전으로 역할이 다릅니다.
  • Sigstore keyless 서명은 사설 키 관리 부담을 없앱니다. GitHub Actions의 OIDC 토큰이 곧 신원이 되고, Rekor 투명성 로그가 부인 방지를 보장합니다. 태그가 아닌 digest로 서명하는 규약만 지키면 됩니다.
  • 서명만으론 부족하고 런타임 검증이 핵심입니다. Sigstore Policy Controller를 Admission 단계에 두어 서명 없는 이미지의 Pod 생성을 거부해야, 비로소 빌드와 런타임 사이의 격차가 사라집니다. 도입은 warn → enforce 단계적으로.
  • SLSA 레벨은 목표가 아니라 로드맵입니다. L2는 GitHub Actions 표준 환경에서 거의 기본이고, L3 이상은 전용 빌더가 필요합니다. 당장은 L2 달성 + 증명 검증부터 시작해 내년에 L3를 목표로 잡는 것이 현실적입니다.
  • VEX로 오탐을 줄여야 보안팀이 지치지 않습니다. SBOM 크기가 커질수록 오탐도 비례합니다. VEX 선언 → 보안팀 리뷰 → 분기 재검증 사이클 없이는 경고 피로로 방어선 자체가 무너집니다.

OWASP 기본기(#131) 위에 SBOM·서명·SLSA 레이어가 올라가면 공급망 축에서는 사실상 최선의 방어가 완성됩니다. 다음 편에서는 이 위에 비밀 관리(Secrets Management) - Vault·AWS Secrets Manager·cloud-native 패턴을 얹어, 애플리케이션이 런타임에 크리덴셜을 어떻게 안전하게 받아가야 하는지를 다룰 예정입니다. 공급망 위 마지막 구멍이 거기입니다.