InfoGrab Docs

튜토리얼: GitLab 패키지 레지스트리로 소프트웨어 자재 명세서(SBOM) 생성

요약

이 튜토리얼은 CI/CD 파이프라인으로 CycloneDX 형식의 소프트웨어 자재 명세서(SBOM)를 생성하는 방법을 보여줍니다. 이 튜토리얼을 완료하기 위해 Python 가상 환경을 만들지만, 다른 지원되는 패키지 유형에도 동일한 접근 방식을 적용할 수 있습니다.

이 튜토리얼은 CI/CD 파이프라인으로 CycloneDX 형식의 소프트웨어 자재 명세서(SBOM)를 생성하는 방법을 보여줍니다. 구축할 파이프라인은 그룹의 여러 프로젝트에 걸쳐 패키지를 수집하여 관련 프로젝트의 종속성에 대한 포괄적인 보기를 제공합니다.

이 튜토리얼을 완료하기 위해 Python 가상 환경을 만들지만, 다른 지원되는 패키지 유형에도 동일한 접근 방식을 적용할 수 있습니다.

소프트웨어 자재 명세서란 무엇인가요?#

SBOM은 소프트웨어 제품을 구성하는 모든 소프트웨어 구성 요소의 기계 판독 가능한 인벤토리입니다. SBOM에는 다음이 포함될 수 있습니다:

  • 직접 및 간접 종속성
  • 오픈 소스 구성 요소 및 라이선스
  • 패키지 버전과 출처

소프트웨어 제품 사용에 관심 있는 조직은 채택 전에 제품의 보안 수준을 파악하기 위해 SBOM을 요구할 수 있습니다.

GitLab 패키지 레지스트리에 익숙하다면 SBOM과 종속성 목록의 차이가 궁금할 수 있습니다. 다음 표는 주요 차이점을 강조합니다:

차이점 종속성 목록 SBOM
범위 개별 프로젝트 또는 그룹의 종속성을 표시합니다. 그룹 전체에 게시된 모든 패키지의 인벤토리를 생성합니다.
방향 프로젝트가 의존하는 것을 추적합니다(들어오는 종속성). 그룹이 게시하는 것을 추적합니다(나가는 패키지).
커버리지 package.json 또는 pom.xml과 같은 패키지 매니페스트를 기반으로 합니다. 패키지 레지스트리에 실제로 게시된 아티팩트를 커버합니다.

CycloneDX란 무엇인가요?#

CycloneDX는 SBOM 생성을 위한 가볍고 표준화된 형식입니다. CycloneDX는 조직이 다음을 수행하는 데 도움이 되는 잘 정의된 스키마를 제공합니다:

  • 소프트웨어 구성 요소와 그 관계를 문서화합니다.
  • 소프트웨어 공급망 전반의 취약점을 추적합니다.
  • 오픈 소스 종속성의 라이선스 준수를 확인합니다.
  • 일관되고 기계 판독 가능한 SBOM 형식을 설정합니다.

CycloneDX는 JSON, XML, Protocol Buffer를 포함한 여러 출력 형식을 지원하여 다양한 통합 요구에 대응할 수 있습니다. 이 사양은 기본 구성 요소 식별부터 소프트웨어 출처에 대한 상세 메타데이터까지 모든 것을 포함하도록 포괄적이면서 효율적으로 설계되었습니다.

시작하기 전에#

이 튜토리얼을 완료하려면 다음이 필요합니다:

  • Maintainer 또는 Owner 역할을 가진 그룹.
  • GitLab CI/CD에 대한 액세스.
  • GitLab Self-Managed 인스턴스를 사용하는 경우 구성된 GitLab Runner. GitLab.com을 사용하는 경우 이 요구 사항을 건너뛸 수 있습니다.
  • 선택 사항. 패키지 레지스트리에 대한 요청을 인증하는 그룹 배포 토큰.

단계#

이 튜토리얼을 완료하기 위한 두 가지 단계 세트가 있습니다:

  • CycloneDX 형식으로 SBOM을 생성하는 CI/CD 파이프라인 구성
  • 생성된 SBOM 및 패키지 통계 파일 액세스 및 작업

다음은 수행할 작업의 개요입니다:

  1. 기본 파이프라인 구성 추가.
  2. prepare 단계 구성.
  3. collect 단계 구성.
  4. aggregate 단계 구성.
  5. publish 단계 구성.
  6. 생성된 SBOM 및 통계 파일 액세스.
Note

이 솔루션을 구현하기 전에 다음 사항을 유의하세요:

  • 패키지 종속성은 확인되지 않습니다(직접 패키지만 나열됩니다).
  • 패키지 버전은 포함되지만 취약점에 대해 분석되지 않습니다.

기본 파이프라인 구성 추가#

먼저 파이프라인 전반에서 사용되는 변수와 단계를 정의하는 기본 이미지를 설정합니다.

다음 섹션에서 각 단계의 구성을 추가하여 파이프라인을 구축할 것입니다.

프로젝트에서:

  1. .gitlab-ci.yml 파일을 만듭니다.

  2. 파일에 다음 기본 구성을 추가합니다:

    # Base image for all jobs
    image: alpine:latest
    
    variables:
      SBOM_OUTPUT_DIR: "sbom-output"
      SBOM_FORMAT: "cyclonedx"
      OUTPUT_TYPE: "json"
      GROUP_PATH: ${CI_PROJECT_NAMESPACE}
      AUTH_HEADER: "${GROUP_DEPLOY_TOKEN:+Deploy-Token: $GROUP_DEPLOY_TOKEN}"
    
    before_script:
      - apk add --no-cache curl jq ca-certificates
    
    stages:
      - prepare
      - collect
      - aggregate
      - publish
    

이 구성은:

  • 작은 공간과 빠른 작업 시작을 위해 Alpine Linux를 사용합니다
  • 인증을 위한 그룹 배포 토큰을 지원합니다
  • API 요청을 위한 curl, JSON 처리를 위한 jq, 보안 HTTPS 연결을 위한 ca-certificates를 설치합니다
  • 모든 출력을 sbom-output 디렉토리에 저장합니다
  • CycloneDX JSON 형식으로 SBOM을 생성합니다

prepare 단계 구성#

prepare 단계는 Python 환경을 설정하고 필요한 종속성을 설치합니다.

.gitlab-ci.yml 파일에 다음 구성을 추가합니다:

# Set up Python virtual environment and install required packages
prepare_environment:
  stage: prepare
  script: |
    mkdir -p ${SBOM_OUTPUT_DIR}
    apk add --no-cache python3 py3-pip py3-virtualenv
    python3 -m venv venv
    source venv/bin/activate
    pip3 install cyclonedx-bom
  artifacts:
    paths:
      - ${SBOM_OUTPUT_DIR}/
      - venv/
    expire_in: 1 week

이 단계는:

  • 격리를 위한 Python 가상 환경을 만듭니다
  • SBOM 생성을 위한 CycloneDX 라이브러리를 설치합니다
  • 아티팩트를 위한 출력 디렉토리를 만듭니다
  • 이후 단계를 위해 가상 환경을 유지합니다
  • 스토리지 관리를 위해 아티팩트에 1주일 만료를 설정합니다

collect 단계 구성#

collect 단계는 그룹의 패키지 레지스트리에서 패키지 정보를 수집합니다.

.gitlab-ci.yml 파일에 다음 구성을 추가합니다:

# Collect package information and versions from GitLab registry
collect_group_packages:
  stage: collect
  script: |
    echo "[]" > "${SBOM_OUTPUT_DIR}/packages.json"

    GROUP_PATH_ENCODED=$(echo "${GROUP_PATH}" | sed 's|/|%2F|g')
    PACKAGES_URL="${CI_API_V4_URL}/groups/${GROUP_PATH_ENCODED}/packages"

    # Optional exclusion list - you can add package types you want to exclude
    # EXCLUDE_TYPES="terraform"

    page=1
    while true; do
      # Fetch all packages without specifying type, with pagination
      response=$(curl --silent --header "${AUTH_HEADER:-"JOB-TOKEN: $CI_JOB_TOKEN"}" \
                    "${PACKAGES_URL}?per_page=100&page=${page}")

      if ! echo "$response" | jq 'type == "array"' > /dev/null 2>&1; then
        echo "Error in API response for page $page"
        break
      fi

      count=$(echo "$response" | jq '. | length')
      if [ "$count" -eq 0 ]; then
        break
      fi

      # Filter packages if EXCLUDE_TYPES is set
      if [ -n "${EXCLUDE_TYPES:-}" ]; then
        filtered_response=$(echo "$response" | jq --arg types "$EXCLUDE_TYPES" '[.[] | select(.package_type | inside($types | split(" ")) | not)]')
        response="$filtered_response"
        count=$(echo "$response" | jq '. | length')
      fi

      # Merge this page of results with existing data
      jq -s '.[0] + .[1]' "${SBOM_OUTPUT_DIR}/packages.json" <(echo "$response") > "${SBOM_OUTPUT_DIR}/packages.tmp.json"
      mv "${SBOM_OUTPUT_DIR}/packages.tmp.json" "${SBOM_OUTPUT_DIR}/packages.json"

      # Move to next page if we got a full page of results
      if [ "$count" -lt 100 ]; then
        break
      fi

      page=$((page + 1))
    done
  artifacts:
    paths:
      - ${SBOM_OUTPUT_DIR}/
    expire_in: 1 week
  dependencies:
    - prepare_environment

이 단계는:

  • 유형별 별도 호출 대신 단일 API 호출로 모든 패키지 유형을 가져옵니다
  • 원하지 않는 패키지 유형을 필터링하기 위한 선택적 제외 목록을 지원합니다
  • 많은 패키지가 있는 그룹을 처리하기 위한 페이지네이션을 구현합니다(페이지당 100개)
  • 하위 그룹을 올바르게 처리하기 위해 그룹 경로를 URL 인코딩합니다
  • 잘못된 응답을 건너뜀으로써 API 오류를 우아하게 처리합니다

aggregate 단계 구성#

aggregate 단계는 수집된 데이터를 처리하고 SBOM을 생성합니다.

.gitlab-ci.yml 파일에 다음 구성을 추가합니다:

# Generate SBOM by aggregating package data
aggregate_sboms:
  stage: aggregate
  before_script:
    - apk add --no-cache python3 py3-pip py3-virtualenv
    - python3 -m venv venv
    - source venv/bin/activate
    - pip3 install --no-cache-dir cyclonedx-bom
  script: |
    cat > process_sbom.py << 'EOL'
    import json
    import os
    from datetime import datetime

    def analyze_version_history(packages_file):
        """Process version information by aggregating packages with same name and type"""
        version_history = {}
        package_versions = {}  # Dict to group packages by name and type

        try:
            with open(packages_file, 'r') as f:
                packages = json.load(f)
                if not isinstance(packages, list):
                    return version_history

                # First, group packages by name and type
                for package in packages:
                    key = f"{package.get('name')}:{package.get('package_type')}"
                    if key not in package_versions:
                        package_versions[key] = []

                    package_versions[key].append({
                        'id': package.get('id'),
                        'version': package.get('version', 'unknown'),
                        'created_at': package.get('created_at')
                    })

                # Then process each group to create version history
                for package_key, versions in package_versions.items():
                    # Sort versions by creation date, newest first
                    versions.sort(key=lambda x: x.get('created_at', ''), reverse=True)

                    # Use the first package's ID as the key (newest version)
                    if versions:
                        package_id = str(versions[0]['id'])
                        version_history[package_id] = {
                            'versions': [v['version'] for v in versions],
                            'latest_version': versions[0]['version'] if versions else None,
                            'version_count': len(versions),
                            'first_published': min((v.get('created_at') for v in versions if v.get('created_at')), default=None),
                            'last_updated': max((v.get('created_at') for v in versions if v.get('created_at')), default=None)
                        }
        except Exception as e:
            print(f"Error processing version history: {e}")
        return version_history

    def merge_package_data(package_file):
        """Combine package data and generate component list"""
        merged_components = {}
        package_stats = {
            'total_packages': 0,
            'package_types': {}
        }

        try:
            with open(package_file, 'r') as f:
                packages = json.load(f)
                if not isinstance(packages, list):
                    return [], package_stats

                for package in packages:
                    package_stats['total_packages'] += 1
                    pkg_type = package.get('package_type', 'unknown')
                    package_stats['package_types'][pkg_type] = package_stats['package_types'].get(pkg_type, 0) + 1

                    component = {
                        'type': 'library',
                        'name': package['name'],
                        'version': package.get('version', 'unknown'),
                        'purl': f"pkg:gitlab/{package['name']}@{package.get('version', 'unknown')}",
                        'package_type': pkg_type,
                        'properties': [{
                            'name': 'registry_url',
                            'value': package.get('_links', {}).get('web_path', '')
                        }]
                    }

                    key = f"{component['name']}:{component['version']}"
                    if key not in merged_components:
                        merged_components[key] = component
        except Exception as e:
            print(f"Error merging package data: {e}")
            return [], package_stats

        return list(merged_components.values()), package_stats

    # Main processing
    version_history = analyze_version_history(f"{os.environ['SBOM_OUTPUT_DIR']}/packages.json")
    components, stats = merge_package_data(f"{os.environ['SBOM_OUTPUT_DIR']}/packages.json")
    stats['version_history'] = version_history

    # Create final SBOM document
    sbom = {
        "bomFormat": os.environ['SBOM_FORMAT'],
        "specVersion": "1.4",
        "version": 1,
        "metadata": {
            "timestamp": datetime.utcnow().isoformat(),
            "tools": [{
                "vendor": "GitLab",
                "name": "Package Registry SBOM Generator",
                "version": "1.0.0"
            }],
            "properties": [{
                "name": "package_stats",
                "value": json.dumps(stats)
            }]
        },
        "components": components
    }

    # Write results to files
    with open(f"{os.environ['SBOM_OUTPUT_DIR']}/merged_sbom.{os.environ['OUTPUT_TYPE']}", 'w') as f:
        json.dump(sbom, f, indent=2)

    with open(f"{os.environ['SBOM_OUTPUT_DIR']}/package_stats.json", 'w') as f:
        json.dump(stats, f, indent=2)
    EOL

    python3 process_sbom.py
  artifacts:
    paths:
      - ${SBOM_OUTPUT_DIR}/
    expire_in: 1 week
  dependencies:
    - collect_group_packages

이 단계는:

  • packages.json 파일에서 직접 작동하는 최적화된 버전 이력 분석을 사용합니다
  • 동일한 패키지의 다른 버전을 식별하기 위해 이름과 유형별로 패키지를 그룹화합니다
  • JSON 형식으로 CycloneDX 호환 SBOM을 생성합니다
  • 다음을 포함한 패키지 통계를 계산합니다:
    • 유형별 패키지 총 수
    • 각 패키지의 버전 이력
    • 최초 게시 및 마지막 업데이트 날짜
  • 각 구성 요소에 대한 패키지 URL(purl)을 생성합니다
  • 적절한 예외 처리로 누락되거나 잘못된 데이터를 우아하게 처리합니다
  • SBOM과 별도의 통계 파일 모두 생성합니다

publish 단계 구성#

publish 단계는 생성된 SBOM과 통계 파일을 GitLab에 업로드합니다.

.gitlab-ci.yml 파일에 다음 구성을 추가합니다:

# Publish SBOM files to GitLab package registry
publish_sbom:
  stage: publish
  script: |
    STATS=$(cat "${SBOM_OUTPUT_DIR}/package_stats.json")

    # Upload generated files
    curl --header "${AUTH_HEADER:-"JOB-TOKEN: $CI_JOB_TOKEN"}" \
         --upload-file "${SBOM_OUTPUT_DIR}/merged_sbom.${OUTPUT_TYPE}" \
         "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/sbom/${CI_COMMIT_SHA}/merged_sbom.${OUTPUT_TYPE}"

    curl --header "${AUTH_HEADER:-"JOB-TOKEN: $CI_JOB_TOKEN"}" \
         --upload-file "${SBOM_OUTPUT_DIR}/package_stats.json" \
         "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/sbom/${CI_COMMIT_SHA}/package_stats.json"

    # Add package description
    curl --header "${AUTH_HEADER:-"JOB-TOKEN: $CI_JOB_TOKEN"}" \
         --header "Content-Type: application/json" \
         --request PUT \
         --data @- \
         "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/sbom/${CI_COMMIT_SHA}" << EOF
    {
      "description": "Group Package Registry SBOM generated on $(date -u)\nStats: ${STATS}"
    }
    EOF
  dependencies:
    - aggregate_sboms

이 단계는:

  • SBOM과 통계 파일을 프로젝트의 패키지 레지스트리에 게시합니다
  • 저장을 위해 일반 패키지 유형을 사용합니다
  • 추적 가능성을 위해 커밋 SHA를 패키지 버전으로 사용합니다
  • 패키지 설명에 생성 타임스탬프와 통계를 추가합니다

생성된 파일 액세스#

파이프라인이 완료되면 다음 파일이 생성됩니다:

  • merged_sbom.json: CycloneDX 형식의 완전한 SBOM
  • package_stats.json: 패키지에 대한 통계

생성된 파일에 액세스하려면:

  1. 프로젝트에서 배포 > 패키지 레지스트리를 선택합니다.
  2. sbom이라는 이름의 패키지를 찾습니다.
  3. SBOM과 통계 파일을 다운로드합니다.

SBOM 파일 사용#

SBOM 파일은 CycloneDX 1.4 JSON 사양을 따르며, 그룹의 패키지 레지스트리에 게시된 패키지, 패키지 버전 및 아티팩트에 대한 세부 정보를 제공합니다.

SBOM 파일을 다음과 같은 규정 준수 및 감사 목적으로 사용할 수도 있습니다:

  • 게시된 패키지 보고서 생성
  • 그룹의 패키지 레지스트리 내용 문서화
  • 시간 경과에 따른 게시 활동 추적

CycloneDX 파일로 작업할 때 다음 도구 사용을 고려하세요:

통계 파일 사용#

통계 파일은 패키지 레지스트리 분석 및 활동 추적을 제공합니다.

예를 들어, 패키지 레지스트리를 분석하려면 다음을 수행할 수 있습니다:

  • 유형별 게시된 패키지의 총 수를 확인합니다.
  • 각 패키지의 버전 수를 확인합니다.
  • 최초 게시 및 마지막 업데이트 날짜를 추적합니다.

패키지 레지스트리 활동을 추적하려면 다음을 수행할 수 있습니다:

  • 패키지 게시 패턴을 모니터링합니다.
  • 가장 자주 업데이트되는 패키지를 식별합니다.
  • 시간 경과에 따른 패키지 레지스트리 성장을 추적합니다.

jq와 같은 CLI 도구를 통계 파일과 함께 사용하여 읽기 쉬운 JSON 형식으로 분석 또는 활동 정보를 생성할 수 있습니다.

다음 코드 블록은 일반적인 분석 또는 보고 목적으로 통계 파일에 대해 실행할 수 있는 몇 가지 jq 명령 예시를 나열합니다:

# Get total package count in registry
jq '.total_packages' package_stats.json

# List package types and their counts
jq '.package_types' package_stats.json

# Find packages with most versions published
jq '.version_history | to_entries | sort_by(.value.version_count) | reverse | .[0:5]' package_stats.json

파이프라인 예약#

패키지 레지스트리를 자주 업데이트하는 경우 그에 따라 SBOM을 업데이트해야 합니다. 게시 활동에 따라 업데이트된 SBOM을 생성하도록 파이프라인 예약을 구성할 수 있습니다.

다음 권장 사항을 고려하세요:

  • 일일 업데이트: 패키지를 자주 게시하거나 최신 보고서가 필요한 경우 권장됩니다
  • 주간 업데이트: 보통의 패키지 게시 활동을 가진 대부분의 팀에 적합합니다
  • 월간 업데이트: 패키지 업데이트가 드문 그룹에 충분합니다

파이프라인을 예약하려면:

  1. 프로젝트에서 빌드 > 파이프라인 일정으로 이동합니다.
  2. 새 파이프라인 일정 생성을 선택하고 양식을 작성합니다:
    • Cron 시간대 드롭다운 목록에서 시간대를 선택합니다.
    • 간격 패턴을 선택하거나 cron 구문을 사용하여 사용자 지정 패턴을 추가합니다.
    • 파이프라인의 브랜치 또는 태그를 선택합니다.
    • 변수 아래에 일정에 CI/CD 변수를 입력합니다.
  3. 파이프라인 일정 생성을 선택합니다.

문제 해결#

이 튜토리얼을 완료하는 동안 다음 문제가 발생할 수 있습니다.

인증 오류#

인증 오류가 발생하는 경우:

  • 그룹 배포 토큰 권한을 확인하세요.
  • 토큰에 read_package_registrywrite_package_registry 범위가 모두 있는지 확인하세요.
  • 토큰이 만료되지 않았는지 확인하세요.

누락된 패키지 유형#

패키지 유형이 누락된 경우:

aggregate 단계의 메모리 문제#

메모리 문제가 발생하는 경우:

  • 더 많은 메모리가 있는 러너를 사용하세요.
  • 패키지 유형을 필터링하여 한 번에 처리하는 패키지를 줄이세요.

리소스 권장 사항#

최적의 성능을 위해:

  • 최소 2GB RAM이 있는 러너를 사용하세요.
  • 패키지 1,000개당 5-10분을 허용하세요.
  • 패키지가 많은 그룹의 경우 작업 타임아웃을 늘리세요.

도움받기#

다른 문제가 발생하는 경우:

  • 특정 오류 메시지에 대한 작업 로그를 확인하세요.
  • curl 명령을 직접 사용하여 API 액세스를 테스트하세요.
  • 먼저 더 작은 패키지 유형 하위 집합으로 테스트하세요.

튜토리얼: GitLab 패키지 레지스트리로 소프트웨어 자재 명세서(SBOM) 생성

원문 보기
요약

이 튜토리얼은 CI/CD 파이프라인으로 CycloneDX 형식의 소프트웨어 자재 명세서(SBOM)를 생성하는 방법을 보여줍니다. 이 튜토리얼을 완료하기 위해 Python 가상 환경을 만들지만, 다른 지원되는 패키지 유형에도 동일한 접근 방식을 적용할 수 있습니다.

이 튜토리얼은 CI/CD 파이프라인으로 CycloneDX 형식의 소프트웨어 자재 명세서(SBOM)를 생성하는 방법을 보여줍니다. 구축할 파이프라인은 그룹의 여러 프로젝트에 걸쳐 패키지를 수집하여 관련 프로젝트의 종속성에 대한 포괄적인 보기를 제공합니다.

이 튜토리얼을 완료하기 위해 Python 가상 환경을 만들지만, 다른 지원되는 패키지 유형에도 동일한 접근 방식을 적용할 수 있습니다.

소프트웨어 자재 명세서란 무엇인가요?#

SBOM은 소프트웨어 제품을 구성하는 모든 소프트웨어 구성 요소의 기계 판독 가능한 인벤토리입니다. SBOM에는 다음이 포함될 수 있습니다:

  • 직접 및 간접 종속성
  • 오픈 소스 구성 요소 및 라이선스
  • 패키지 버전과 출처

소프트웨어 제품 사용에 관심 있는 조직은 채택 전에 제품의 보안 수준을 파악하기 위해 SBOM을 요구할 수 있습니다.

GitLab 패키지 레지스트리에 익숙하다면 SBOM과 종속성 목록의 차이가 궁금할 수 있습니다. 다음 표는 주요 차이점을 강조합니다:

차이점 종속성 목록 SBOM
범위 개별 프로젝트 또는 그룹의 종속성을 표시합니다. 그룹 전체에 게시된 모든 패키지의 인벤토리를 생성합니다.
방향 프로젝트가 의존하는 것을 추적합니다(들어오는 종속성). 그룹이 게시하는 것을 추적합니다(나가는 패키지).
커버리지 package.json 또는 pom.xml과 같은 패키지 매니페스트를 기반으로 합니다. 패키지 레지스트리에 실제로 게시된 아티팩트를 커버합니다.

CycloneDX란 무엇인가요?#

CycloneDX는 SBOM 생성을 위한 가볍고 표준화된 형식입니다. CycloneDX는 조직이 다음을 수행하는 데 도움이 되는 잘 정의된 스키마를 제공합니다:

  • 소프트웨어 구성 요소와 그 관계를 문서화합니다.
  • 소프트웨어 공급망 전반의 취약점을 추적합니다.
  • 오픈 소스 종속성의 라이선스 준수를 확인합니다.
  • 일관되고 기계 판독 가능한 SBOM 형식을 설정합니다.

CycloneDX는 JSON, XML, Protocol Buffer를 포함한 여러 출력 형식을 지원하여 다양한 통합 요구에 대응할 수 있습니다. 이 사양은 기본 구성 요소 식별부터 소프트웨어 출처에 대한 상세 메타데이터까지 모든 것을 포함하도록 포괄적이면서 효율적으로 설계되었습니다.

시작하기 전에#

이 튜토리얼을 완료하려면 다음이 필요합니다:

  • Maintainer 또는 Owner 역할을 가진 그룹.
  • GitLab CI/CD에 대한 액세스.
  • GitLab Self-Managed 인스턴스를 사용하는 경우 구성된 GitLab Runner. GitLab.com을 사용하는 경우 이 요구 사항을 건너뛸 수 있습니다.
  • 선택 사항. 패키지 레지스트리에 대한 요청을 인증하는 그룹 배포 토큰.

단계#

이 튜토리얼을 완료하기 위한 두 가지 단계 세트가 있습니다:

  • CycloneDX 형식으로 SBOM을 생성하는 CI/CD 파이프라인 구성
  • 생성된 SBOM 및 패키지 통계 파일 액세스 및 작업

다음은 수행할 작업의 개요입니다:

  1. 기본 파이프라인 구성 추가.
  2. prepare 단계 구성.
  3. collect 단계 구성.
  4. aggregate 단계 구성.
  5. publish 단계 구성.
  6. 생성된 SBOM 및 통계 파일 액세스.
Note

이 솔루션을 구현하기 전에 다음 사항을 유의하세요:

  • 패키지 종속성은 확인되지 않습니다(직접 패키지만 나열됩니다).
  • 패키지 버전은 포함되지만 취약점에 대해 분석되지 않습니다.

기본 파이프라인 구성 추가#

먼저 파이프라인 전반에서 사용되는 변수와 단계를 정의하는 기본 이미지를 설정합니다.

다음 섹션에서 각 단계의 구성을 추가하여 파이프라인을 구축할 것입니다.

프로젝트에서:

  1. .gitlab-ci.yml 파일을 만듭니다.

  2. 파일에 다음 기본 구성을 추가합니다:

    # Base image for all jobs
    image: alpine:latest
    
    variables:
      SBOM_OUTPUT_DIR: "sbom-output"
      SBOM_FORMAT: "cyclonedx"
      OUTPUT_TYPE: "json"
      GROUP_PATH: ${CI_PROJECT_NAMESPACE}
      AUTH_HEADER: "${GROUP_DEPLOY_TOKEN:+Deploy-Token: $GROUP_DEPLOY_TOKEN}"
    
    before_script:
      - apk add --no-cache curl jq ca-certificates
    
    stages:
      - prepare
      - collect
      - aggregate
      - publish
    

이 구성은:

  • 작은 공간과 빠른 작업 시작을 위해 Alpine Linux를 사용합니다
  • 인증을 위한 그룹 배포 토큰을 지원합니다
  • API 요청을 위한 curl, JSON 처리를 위한 jq, 보안 HTTPS 연결을 위한 ca-certificates를 설치합니다
  • 모든 출력을 sbom-output 디렉토리에 저장합니다
  • CycloneDX JSON 형식으로 SBOM을 생성합니다

prepare 단계 구성#

prepare 단계는 Python 환경을 설정하고 필요한 종속성을 설치합니다.

.gitlab-ci.yml 파일에 다음 구성을 추가합니다:

# Set up Python virtual environment and install required packages
prepare_environment:
  stage: prepare
  script: |
    mkdir -p ${SBOM_OUTPUT_DIR}
    apk add --no-cache python3 py3-pip py3-virtualenv
    python3 -m venv venv
    source venv/bin/activate
    pip3 install cyclonedx-bom
  artifacts:
    paths:
      - ${SBOM_OUTPUT_DIR}/
      - venv/
    expire_in: 1 week

이 단계는:

  • 격리를 위한 Python 가상 환경을 만듭니다
  • SBOM 생성을 위한 CycloneDX 라이브러리를 설치합니다
  • 아티팩트를 위한 출력 디렉토리를 만듭니다
  • 이후 단계를 위해 가상 환경을 유지합니다
  • 스토리지 관리를 위해 아티팩트에 1주일 만료를 설정합니다

collect 단계 구성#

collect 단계는 그룹의 패키지 레지스트리에서 패키지 정보를 수집합니다.

.gitlab-ci.yml 파일에 다음 구성을 추가합니다:

# Collect package information and versions from GitLab registry
collect_group_packages:
  stage: collect
  script: |
    echo "[]" > "${SBOM_OUTPUT_DIR}/packages.json"

    GROUP_PATH_ENCODED=$(echo "${GROUP_PATH}" | sed 's|/|%2F|g')
    PACKAGES_URL="${CI_API_V4_URL}/groups/${GROUP_PATH_ENCODED}/packages"

    # Optional exclusion list - you can add package types you want to exclude
    # EXCLUDE_TYPES="terraform"

    page=1
    while true; do
      # Fetch all packages without specifying type, with pagination
      response=$(curl --silent --header "${AUTH_HEADER:-"JOB-TOKEN: $CI_JOB_TOKEN"}" \
                    "${PACKAGES_URL}?per_page=100&page=${page}")

      if ! echo "$response" | jq 'type == "array"' > /dev/null 2>&1; then
        echo "Error in API response for page $page"
        break
      fi

      count=$(echo "$response" | jq '. | length')
      if [ "$count" -eq 0 ]; then
        break
      fi

      # Filter packages if EXCLUDE_TYPES is set
      if [ -n "${EXCLUDE_TYPES:-}" ]; then
        filtered_response=$(echo "$response" | jq --arg types "$EXCLUDE_TYPES" '[.[] | select(.package_type | inside($types | split(" ")) | not)]')
        response="$filtered_response"
        count=$(echo "$response" | jq '. | length')
      fi

      # Merge this page of results with existing data
      jq -s '.[0] + .[1]' "${SBOM_OUTPUT_DIR}/packages.json" <(echo "$response") > "${SBOM_OUTPUT_DIR}/packages.tmp.json"
      mv "${SBOM_OUTPUT_DIR}/packages.tmp.json" "${SBOM_OUTPUT_DIR}/packages.json"

      # Move to next page if we got a full page of results
      if [ "$count" -lt 100 ]; then
        break
      fi

      page=$((page + 1))
    done
  artifacts:
    paths:
      - ${SBOM_OUTPUT_DIR}/
    expire_in: 1 week
  dependencies:
    - prepare_environment

이 단계는:

  • 유형별 별도 호출 대신 단일 API 호출로 모든 패키지 유형을 가져옵니다
  • 원하지 않는 패키지 유형을 필터링하기 위한 선택적 제외 목록을 지원합니다
  • 많은 패키지가 있는 그룹을 처리하기 위한 페이지네이션을 구현합니다(페이지당 100개)
  • 하위 그룹을 올바르게 처리하기 위해 그룹 경로를 URL 인코딩합니다
  • 잘못된 응답을 건너뜀으로써 API 오류를 우아하게 처리합니다

aggregate 단계 구성#

aggregate 단계는 수집된 데이터를 처리하고 SBOM을 생성합니다.

.gitlab-ci.yml 파일에 다음 구성을 추가합니다:

# Generate SBOM by aggregating package data
aggregate_sboms:
  stage: aggregate
  before_script:
    - apk add --no-cache python3 py3-pip py3-virtualenv
    - python3 -m venv venv
    - source venv/bin/activate
    - pip3 install --no-cache-dir cyclonedx-bom
  script: |
    cat > process_sbom.py << 'EOL'
    import json
    import os
    from datetime import datetime

    def analyze_version_history(packages_file):
        """Process version information by aggregating packages with same name and type"""
        version_history = {}
        package_versions = {}  # Dict to group packages by name and type

        try:
            with open(packages_file, 'r') as f:
                packages = json.load(f)
                if not isinstance(packages, list):
                    return version_history

                # First, group packages by name and type
                for package in packages:
                    key = f"{package.get('name')}:{package.get('package_type')}"
                    if key not in package_versions:
                        package_versions[key] = []

                    package_versions[key].append({
                        'id': package.get('id'),
                        'version': package.get('version', 'unknown'),
                        'created_at': package.get('created_at')
                    })

                # Then process each group to create version history
                for package_key, versions in package_versions.items():
                    # Sort versions by creation date, newest first
                    versions.sort(key=lambda x: x.get('created_at', ''), reverse=True)

                    # Use the first package's ID as the key (newest version)
                    if versions:
                        package_id = str(versions[0]['id'])
                        version_history[package_id] = {
                            'versions': [v['version'] for v in versions],
                            'latest_version': versions[0]['version'] if versions else None,
                            'version_count': len(versions),
                            'first_published': min((v.get('created_at') for v in versions if v.get('created_at')), default=None),
                            'last_updated': max((v.get('created_at') for v in versions if v.get('created_at')), default=None)
                        }
        except Exception as e:
            print(f"Error processing version history: {e}")
        return version_history

    def merge_package_data(package_file):
        """Combine package data and generate component list"""
        merged_components = {}
        package_stats = {
            'total_packages': 0,
            'package_types': {}
        }

        try:
            with open(package_file, 'r') as f:
                packages = json.load(f)
                if not isinstance(packages, list):
                    return [], package_stats

                for package in packages:
                    package_stats['total_packages'] += 1
                    pkg_type = package.get('package_type', 'unknown')
                    package_stats['package_types'][pkg_type] = package_stats['package_types'].get(pkg_type, 0) + 1

                    component = {
                        'type': 'library',
                        'name': package['name'],
                        'version': package.get('version', 'unknown'),
                        'purl': f"pkg:gitlab/{package['name']}@{package.get('version', 'unknown')}",
                        'package_type': pkg_type,
                        'properties': [{
                            'name': 'registry_url',
                            'value': package.get('_links', {}).get('web_path', '')
                        }]
                    }

                    key = f"{component['name']}:{component['version']}"
                    if key not in merged_components:
                        merged_components[key] = component
        except Exception as e:
            print(f"Error merging package data: {e}")
            return [], package_stats

        return list(merged_components.values()), package_stats

    # Main processing
    version_history = analyze_version_history(f"{os.environ['SBOM_OUTPUT_DIR']}/packages.json")
    components, stats = merge_package_data(f"{os.environ['SBOM_OUTPUT_DIR']}/packages.json")
    stats['version_history'] = version_history

    # Create final SBOM document
    sbom = {
        "bomFormat": os.environ['SBOM_FORMAT'],
        "specVersion": "1.4",
        "version": 1,
        "metadata": {
            "timestamp": datetime.utcnow().isoformat(),
            "tools": [{
                "vendor": "GitLab",
                "name": "Package Registry SBOM Generator",
                "version": "1.0.0"
            }],
            "properties": [{
                "name": "package_stats",
                "value": json.dumps(stats)
            }]
        },
        "components": components
    }

    # Write results to files
    with open(f"{os.environ['SBOM_OUTPUT_DIR']}/merged_sbom.{os.environ['OUTPUT_TYPE']}", 'w') as f:
        json.dump(sbom, f, indent=2)

    with open(f"{os.environ['SBOM_OUTPUT_DIR']}/package_stats.json", 'w') as f:
        json.dump(stats, f, indent=2)
    EOL

    python3 process_sbom.py
  artifacts:
    paths:
      - ${SBOM_OUTPUT_DIR}/
    expire_in: 1 week
  dependencies:
    - collect_group_packages

이 단계는:

  • packages.json 파일에서 직접 작동하는 최적화된 버전 이력 분석을 사용합니다
  • 동일한 패키지의 다른 버전을 식별하기 위해 이름과 유형별로 패키지를 그룹화합니다
  • JSON 형식으로 CycloneDX 호환 SBOM을 생성합니다
  • 다음을 포함한 패키지 통계를 계산합니다:
    • 유형별 패키지 총 수
    • 각 패키지의 버전 이력
    • 최초 게시 및 마지막 업데이트 날짜
  • 각 구성 요소에 대한 패키지 URL(purl)을 생성합니다
  • 적절한 예외 처리로 누락되거나 잘못된 데이터를 우아하게 처리합니다
  • SBOM과 별도의 통계 파일 모두 생성합니다

publish 단계 구성#

publish 단계는 생성된 SBOM과 통계 파일을 GitLab에 업로드합니다.

.gitlab-ci.yml 파일에 다음 구성을 추가합니다:

# Publish SBOM files to GitLab package registry
publish_sbom:
  stage: publish
  script: |
    STATS=$(cat "${SBOM_OUTPUT_DIR}/package_stats.json")

    # Upload generated files
    curl --header "${AUTH_HEADER:-"JOB-TOKEN: $CI_JOB_TOKEN"}" \
         --upload-file "${SBOM_OUTPUT_DIR}/merged_sbom.${OUTPUT_TYPE}" \
         "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/sbom/${CI_COMMIT_SHA}/merged_sbom.${OUTPUT_TYPE}"

    curl --header "${AUTH_HEADER:-"JOB-TOKEN: $CI_JOB_TOKEN"}" \
         --upload-file "${SBOM_OUTPUT_DIR}/package_stats.json" \
         "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/sbom/${CI_COMMIT_SHA}/package_stats.json"

    # Add package description
    curl --header "${AUTH_HEADER:-"JOB-TOKEN: $CI_JOB_TOKEN"}" \
         --header "Content-Type: application/json" \
         --request PUT \
         --data @- \
         "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/sbom/${CI_COMMIT_SHA}" << EOF
    {
      "description": "Group Package Registry SBOM generated on $(date -u)\nStats: ${STATS}"
    }
    EOF
  dependencies:
    - aggregate_sboms

이 단계는:

  • SBOM과 통계 파일을 프로젝트의 패키지 레지스트리에 게시합니다
  • 저장을 위해 일반 패키지 유형을 사용합니다
  • 추적 가능성을 위해 커밋 SHA를 패키지 버전으로 사용합니다
  • 패키지 설명에 생성 타임스탬프와 통계를 추가합니다

생성된 파일 액세스#

파이프라인이 완료되면 다음 파일이 생성됩니다:

  • merged_sbom.json: CycloneDX 형식의 완전한 SBOM
  • package_stats.json: 패키지에 대한 통계

생성된 파일에 액세스하려면:

  1. 프로젝트에서 배포 > 패키지 레지스트리를 선택합니다.
  2. sbom이라는 이름의 패키지를 찾습니다.
  3. SBOM과 통계 파일을 다운로드합니다.

SBOM 파일 사용#

SBOM 파일은 CycloneDX 1.4 JSON 사양을 따르며, 그룹의 패키지 레지스트리에 게시된 패키지, 패키지 버전 및 아티팩트에 대한 세부 정보를 제공합니다.

SBOM 파일을 다음과 같은 규정 준수 및 감사 목적으로 사용할 수도 있습니다:

  • 게시된 패키지 보고서 생성
  • 그룹의 패키지 레지스트리 내용 문서화
  • 시간 경과에 따른 게시 활동 추적

CycloneDX 파일로 작업할 때 다음 도구 사용을 고려하세요:

통계 파일 사용#

통계 파일은 패키지 레지스트리 분석 및 활동 추적을 제공합니다.

예를 들어, 패키지 레지스트리를 분석하려면 다음을 수행할 수 있습니다:

  • 유형별 게시된 패키지의 총 수를 확인합니다.
  • 각 패키지의 버전 수를 확인합니다.
  • 최초 게시 및 마지막 업데이트 날짜를 추적합니다.

패키지 레지스트리 활동을 추적하려면 다음을 수행할 수 있습니다:

  • 패키지 게시 패턴을 모니터링합니다.
  • 가장 자주 업데이트되는 패키지를 식별합니다.
  • 시간 경과에 따른 패키지 레지스트리 성장을 추적합니다.

jq와 같은 CLI 도구를 통계 파일과 함께 사용하여 읽기 쉬운 JSON 형식으로 분석 또는 활동 정보를 생성할 수 있습니다.

다음 코드 블록은 일반적인 분석 또는 보고 목적으로 통계 파일에 대해 실행할 수 있는 몇 가지 jq 명령 예시를 나열합니다:

# Get total package count in registry
jq '.total_packages' package_stats.json

# List package types and their counts
jq '.package_types' package_stats.json

# Find packages with most versions published
jq '.version_history | to_entries | sort_by(.value.version_count) | reverse | .[0:5]' package_stats.json

파이프라인 예약#

패키지 레지스트리를 자주 업데이트하는 경우 그에 따라 SBOM을 업데이트해야 합니다. 게시 활동에 따라 업데이트된 SBOM을 생성하도록 파이프라인 예약을 구성할 수 있습니다.

다음 권장 사항을 고려하세요:

  • 일일 업데이트: 패키지를 자주 게시하거나 최신 보고서가 필요한 경우 권장됩니다
  • 주간 업데이트: 보통의 패키지 게시 활동을 가진 대부분의 팀에 적합합니다
  • 월간 업데이트: 패키지 업데이트가 드문 그룹에 충분합니다

파이프라인을 예약하려면:

  1. 프로젝트에서 빌드 > 파이프라인 일정으로 이동합니다.
  2. 새 파이프라인 일정 생성을 선택하고 양식을 작성합니다:
    • Cron 시간대 드롭다운 목록에서 시간대를 선택합니다.
    • 간격 패턴을 선택하거나 cron 구문을 사용하여 사용자 지정 패턴을 추가합니다.
    • 파이프라인의 브랜치 또는 태그를 선택합니다.
    • 변수 아래에 일정에 CI/CD 변수를 입력합니다.
  3. 파이프라인 일정 생성을 선택합니다.

문제 해결#

이 튜토리얼을 완료하는 동안 다음 문제가 발생할 수 있습니다.

인증 오류#

인증 오류가 발생하는 경우:

  • 그룹 배포 토큰 권한을 확인하세요.
  • 토큰에 read_package_registrywrite_package_registry 범위가 모두 있는지 확인하세요.
  • 토큰이 만료되지 않았는지 확인하세요.

누락된 패키지 유형#

패키지 유형이 누락된 경우:

aggregate 단계의 메모리 문제#

메모리 문제가 발생하는 경우:

  • 더 많은 메모리가 있는 러너를 사용하세요.
  • 패키지 유형을 필터링하여 한 번에 처리하는 패키지를 줄이세요.

리소스 권장 사항#

최적의 성능을 위해:

  • 최소 2GB RAM이 있는 러너를 사용하세요.
  • 패키지 1,000개당 5-10분을 허용하세요.
  • 패키지가 많은 그룹의 경우 작업 타임아웃을 늘리세요.

도움받기#

다른 문제가 발생하는 경우:

  • 특정 오류 메시지에 대한 작업 로그를 확인하세요.
  • curl 명령을 직접 사용하여 API 액세스를 테스트하세요.
  • 먼저 더 작은 패키지 유형 하위 집합으로 테스트하세요.