애플리케이션 하나를 빌드하면 직접 작성한 코드는 전체의 5~10%에 불과하고 나머지는 오픈소스 의존성이다. 그 의존성은 또 다른 의존성을 끌어오고, 이 전이(transitive) 관계는 수백 단계까지 뻗어나간다. 이 사슬 깊은 곳에서 취약점이 공개되면, 우리는 가장 먼저 “우리 서비스가 그 버전을 쓰고 있는가?”에 답할 수 있어야 한다. 이 질문에 몇 시간 안에 답하지 못하는 조직이 놀랄 만큼 많다.

SBOM(Software Bill of Materials)은 이 질문에 답하기 위한 자재 명세서다. 소프트웨어를 구성하는 모든 컴포넌트와 버전, 라이선스, 출처를 기계가 읽을 수 있는 형식으로 기록한 목록이다. 하지만 SBOM은 만들어두는 것만으로는 아무것도 막지 못한다. 이 글에서는 SBOM을 CI에 통합해 취약점 게이트(vulnerability gate)로 작동시키는 방법, 즉 위험한 의존성이 배포로 넘어가기 전에 파이프라인이 스스로 막아서게 만드는 구성을 다룬다.

SBOM이란 무엇이고 왜 CI에 필요한가

SBOM의 표준 포맷은 두 가지다. SPDX는 라이선스 컴플라이언스에서 출발했고, CycloneDX는 보안 취약점 추적에 초점을 둔다. 보안 관점에서는 CycloneDX가 더 널리 쓰이며, 두 포맷 모두 컴포넌트를 purl 공통 식별자로 표현한다.

SBOM을 CI에 넣는 이유는 단순하다. 로컬이나 배포 후 서버에서 한 번 스캔하는 것으로는 회귀를 막을 수 없다. 오늘 깨끗한 의존성 트리가 내일 누군가의 커밋으로 취약한 라이브러리를 다시 끌어올 수 있다. CI는 모든 변경이 반드시 통과하는 관문이므로, 여기에 게이트를 심어야 위험이 구조적으로 걸러진다.

  • 가시성: 무엇을 쓰는지 매 빌드마다 정확히 기록
  • 탐지: 알려진 취약점(CVE)을 의존성 트리와 대조
  • 차단: 정책 위반 시 파이프라인 실패로 배포 저지
  • 추적: 서명된 SBOM을 아티팩트로 남겨 사후 감사

빌드 단계에서 SBOM 생성하기

SBOM은 소스 기반이미지 기반 두 시점에서 만들 수 있다. 소스 기반은 락 파일(package-lock.json, go.sum 등)을 읽어 선언된 의존성을 뽑고, 이미지 기반은 컨테이너 이미지를 스캔해 OS 패키지까지 포함한 실제 설치 결과를 뽑는다. 정확한 게이트를 만들려면 최종 이미지 기반 SBOM이 진실에 더 가깝다. 락 파일에 없던 베이스 이미지의 시스템 라이브러리가 취약점을 가진 경우가 흔하기 때문이다. 아래는 syft로 SBOM을 생성하는 잡이다. SBOM은 다음 잡으로 넘겨야 하므로 캐시가 아니라 아티팩트로 전달한다.

stages:
  - build
  - sbom
  - scan

build-image:
  stage: build
  script:
    # 최종 이미지를 먼저 빌드 (SBOM은 이 이미지 기준으로 생성)
    - docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" .
    - docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"

generate-sbom:
  stage: sbom
  image:
    name: anchore/syft:latest
    entrypoint: [""]
  script:
    # 이미지에서 OS 패키지 + 언어 의존성까지 모두 추출
    - syft "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" 
        -o cyclonedx-json=sbom.cdx.json 
        -o spdx-json=sbom.spdx.json   # 라이선스 감사용 SPDX도 병행
  artifacts:
    paths: [sbom.cdx.json, sbom.spdx.json]
    expire_in: 90 days   # 감사 추적을 위해 넉넉히 보관

여기서 원칙 하나. SBOM은 스캔하고 배포하는 이미지와 정확히 같은 아티팩트에서 생성해야 한다. 스캔용으로 이미지를 다시 빌드하면 논디터미니스틱한 요소(빌드 시각, 재해결된 의존성) 때문에 SBOM과 실제 배포물이 어긋난다.

취약점 스캔과 매칭

SBOM 자체에는 취약점 정보가 없다. “무엇이 들어있는가”만 기록할 뿐, “그것이 위험한가”는 별도의 취약점 데이터베이스와 대조해야 안다. 이 대조가 grype, trivy 같은 스캐너의 역할이다. 이미 만든 SBOM을 주면 이미지를 재파싱하지 않고 매칭만 수행한다.

scan-vulnerabilities:
  stage: scan
  image:
    name: anchore/grype:latest
    entrypoint: [""]
  script:
    # 앞 단계에서 만든 SBOM을 그대로 입력 → 이미지 재파싱 불필요
    - grype sbom:sbom.cdx.json --output json 
        --file grype-report.json 
        --fail-on high   # high 이상 심각도가 있으면 잡 실패
  artifacts:
    when: always                  # 실패해도 리포트는 남긴다
    paths: [grype-report.json]
    expire_in: 30 days
  dependencies: [generate-sbom]   # SBOM 아티팩트만 내려받음

취약점 매칭은 purl과 CPE를 기준으로 이뤄지며, 여기서 거짓 양성이 자주 발생한다. 벤더가 이름을 바꾼 패키지나 백포트된 패치(버전 번호는 취약해 보이지만 배포판이 이미 패치한 경우)가 대표적 원인이다. 게이트를 무작정 엄격하게 걸면 개발자들은 곧 우회하는 법을 배운다. 정확성과 실용성의 균형이 핵심이다.

정책 게이트: 무엇을 언제 막을 것인가

가장 흔한 실수는 “취약점이 하나라도 있으면 빌드 실패”라는 이분법이다. 대규모 의존성 트리에서 취약점 카운트를 0으로 유지하는 것은 불가능하며, 이런 게이트는 며칠 만에 무력화된다. 실용적인 게이트는 심각도, 수정 가능 여부, 유예 기간 세 축으로 설계한다.

  • 심각도: Critical/High만 우선 차단, Medium 이하는 경고로 기록
  • 수정 가능성: 픽스 버전이 나온(fixed) 취약점만 차단 — 패치가 없는데 막아봐야 할 수 있는 게 없다
  • 유예: 새로 공개된 취약점은 며칠의 대응 기간을 주고 그 후에도 남으면 차단

trivy는 이런 정책을 trivy.yaml.trivyignore로 선언적으로 표현한다. 아래는 수정 버전이 있는 High 이상만 차단하고, 오탐은 만료일과 함께 예외 처리하는 예시다.

# trivy.yaml — 스캔 정책을 코드로 관리
severity:
  - HIGH
  - CRITICAL
vulnerability:
  ignore-unfixed: true   # 패치 없는 취약점은 게이트에서 제외
scan:
  scanners:
    - vuln
    - license   # 라이선스 정책 위반도 함께 검사
exit-code: 1   # 정책 위반 시 non-zero 로 CI 실패 유도
# .trivyignore — 검토를 마친 오탐/수용된 위험만 만료일과 함께 예외 처리
# 만료일을 반드시 붙여, 무기한 방치되는 예외를 방지한다
CVE-2025-12345 exp:2026-09-30   # 백포트 패치 적용 확인, 오탐으로 판정
CVE-2025-22222 exp:2026-08-31   # 인터넷 미노출 내부 배치용, 위험 수용

예외에 만료일을 강제하는 것이 이 설계의 핵심이다. 만료일 없는 예외는 반드시 영원히 남는다. 만료일이 지나면 다시 게이트에 걸리므로 그때 재검토하거나 실제로 수정하게 된다. 예외 목록은 코드 리뷰 대상으로 삼아 누가 왜 위험을 수용했는지 이력을 남긴다.

SBOM 서명과 무결성 검증

SBOM 생성만으로는 충분하지 않다. 배포 시점의 SBOM이 빌드 시점과 같고 변조되지 않았음을 증명해야 진짜 공급망 보안이다. 이를 위해 SBOM을 서명된 어테스테이션(attestation)으로 이미지에 첨부한다. cosign은 이미지 다이제스트에 SBOM을 in-toto 어테스테이션으로 묶고 서명한다.

# SBOM 을 이미지에 어테스테이션으로 첨부하고 키리스(keyless) 서명
# OIDC 신원(CI 러너의 워크로드 아이덴티티)으로 서명 → 개인키 관리 불필요
IMAGE="$CI_REGISTRY_IMAGE@$IMAGE_DIGEST"   # 태그가 아닌 다이제스트로 고정

cosign attest --yes --type cyclonedx 
  --predicate sbom.cdx.json "$IMAGE"

# 배포 게이트에서 서명 + SBOM 어테스테이션 존재를 검증
cosign verify-attestation --type cyclonedx 
  --certificate-oidc-issuer "https://gitlab.example.com" 
  --certificate-identity-regexp "https://gitlab.example.com/.*" "$IMAGE"

여기서 태그가 아니라 다이제스트로 서명하는 것이 결정적이다. 태그는 나중에 다른 이미지를 가리키도록 옮길 수 있어 신뢰의 근거가 되지 못하지만, 다이제스트는 콘텐츠 해시이므로 서명이 정확히 그 바이트를 보증한다.

VEX: 오탐 판단을 구조화하기

게이트를 운영하다 보면 스캐너가 보고한 취약점이 실제로는 악용 불가능(not exploitable)한 경우를 끝없이 마주친다. 취약한 함수가 코드 경로상 호출되지 않거나 문제의 기능이 비활성화된 경우다. 이런 판단을 개인의 머릿속이나 흩어진 티켓에 남기면 휘발된다. VEX(Vulnerability Exploitability eXchange)는 “이 취약점이 이 제품에서 실제로 악용 가능한가”에 대한 판단을 표준 문서로 기록하는 형식이다. 스캐너가 VEX를 참조하면 이미 not_affected로 판정한 취약점은 자동으로 걸러진다. 자유 텍스트 주석보다 기계 판독이 가능하고 판정 근거(justification)가 표준 어휘로 남아 감사에 유리하다.

{
  "@context": "https://openvex.dev/ns/v0.2.0",
  "author": "[email protected]",
  "statements": [
    {
      "vulnerability": { "name": "CVE-2025-30777" },
      "products": [ { "@id": "pkg:oci/api-gateway@sha256:9f2c..." } ],
      "status": "not_affected",
      "justification": "vulnerable_code_not_in_execute_path",
      "impact_statement": "취약한 XML 파서 경로 미사용, 입력은 JSON 전용"
    }
  ]
}

파이프라인 전체를 하나로 엮기

지금까지의 조각을 조립하면 흐름은 이렇다. 빌드 → SBOM 생성 → 서명 → 스캔 → 통과 시에만 배포. 배포 잡을 게이트 잡에 needs로 묶는 것이 핵심이다.

vuln-gate:
  stage: gate
  script:
    # SBOM 기반 스캔 + VEX 적용 → 정책 위반 시 exit 1 로 배포 차단
    - trivy sbom sbom.cdx.json --vex vex/ --config trivy.yaml
  dependencies: [generate-sbom]

deploy-prod:
  stage: deploy
  needs: [vuln-gate]                       # 게이트 성공해야만 실행
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'    # 보호 브랜치만 배포
  script:
    - cosign verify-attestation --type cyclonedx ... "$IMAGE"  # 배포 직전 재검증
    - ./deploy.sh "$IMAGE"

파이프라인 게이트만으로 끝내지 않는다면 어드미션 컨트롤러를 클러스터에 두어 서명·SBOM 검증을 통과하지 못한 이미지의 실행 자체를 거부할 수 있다. CI 게이트는 파이프라인을 건드리면 우회 가능하지만, 클러스터 레벨 정책은 최후의 방어선이다.

마무리

SBOM은 문서가 아니라 운영 도구일 때 가치가 있다. 핵심은 SBOM을 CI의 흐름에 심어, 매 빌드마다 자동으로 생성·스캔·검증되고 정책 위반 시 배포를 스스로 저지하는 살아있는 게이트로 만드는 것이다. 최종 이미지 기반으로 SBOM을 뽑고, 다이제스트로 서명하며, 심각도·수정 가능성·유예 기간으로 현실적 정책을 설계한다.

다만 모든 게이트에는 비용이 따른다. 너무 엄격한 정책은 개발자가 우회하는 문화를 만들어 게이트가 형식만 남고, 예외를 만료일 없이 방치하면 게이트는 이름뿐인 장식이 된다. 정확한 SBOM, 현실적인 정책, 만료되는 예외, 판단을 기록하는 VEX. 이 넷이 함께 돌아갈 때 게이트는 개발 속도를 해치지 않으면서 위험을 걸러내는 장치가 된다.

자주 묻는 질문

Q. SBOM은 소스에서 만들어야 하나요, 이미지에서 만들어야 하나요?
A. 게이트 목적이라면 최종 컨테이너 이미지에서 생성하는 것을 권장합니다. 락 파일 기반 SBOM은 직접 의존성은 잘 잡지만 베이스 이미지의 OS 패키지나 빌드 중 추가되는 시스템 라이브러리를 놓칩니다. 실제 취약점은 이 시스템 계층에서 자주 나오므로 배포 실물과 일치하는 이미지 기반이 정확합니다.

Q. cosign 키리스 서명에서 개인키 없이 어떻게 신뢰가 성립하나요?
A. CI 러너의 OIDC 신원(워크로드 아이덴티티)으로 단기 인증서를 발급받아 서명하고, 그 기록을 투명성 로그에 남깁니다. 검증 측은 “어떤 OIDC 발급자의 어떤 신원이 서명했는가”를 확인합니다. 신뢰의 근거가 장기 개인키가 아니라 검증 가능한 신원으로 옮겨가므로 키 유출·관리 부담이 사라집니다.