튜토리얼: 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 및 패키지 통계 파일 액세스 및 작업
다음은 수행할 작업의 개요입니다:
이 솔루션을 구현하기 전에 다음 사항을 유의하세요:
- 패키지 종속성은 확인되지 않습니다(직접 패키지만 나열됩니다).
- 패키지 버전은 포함되지만 취약점에 대해 분석되지 않습니다.
기본 파이프라인 구성 추가#
먼저 파이프라인 전반에서 사용되는 변수와 단계를 정의하는 기본 이미지를 설정합니다.
다음 섹션에서 각 단계의 구성을 추가하여 파이프라인을 구축할 것입니다.
프로젝트에서:
-
.gitlab-ci.yml파일을 만듭니다. -
파일에 다음 기본 구성을 추가합니다:
# 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 형식의 완전한 SBOMpackage_stats.json: 패키지에 대한 통계
생성된 파일에 액세스하려면:
- 프로젝트에서 배포 > 패키지 레지스트리를 선택합니다.
sbom이라는 이름의 패키지를 찾습니다.- 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을 생성하도록 파이프라인 예약을 구성할 수 있습니다.
다음 권장 사항을 고려하세요:
- 일일 업데이트: 패키지를 자주 게시하거나 최신 보고서가 필요한 경우 권장됩니다
- 주간 업데이트: 보통의 패키지 게시 활동을 가진 대부분의 팀에 적합합니다
- 월간 업데이트: 패키지 업데이트가 드문 그룹에 충분합니다
파이프라인을 예약하려면:
- 프로젝트에서 빌드 > 파이프라인 일정으로 이동합니다.
- 새 파이프라인 일정 생성을 선택하고 양식을 작성합니다:
- Cron 시간대 드롭다운 목록에서 시간대를 선택합니다.
- 간격 패턴을 선택하거나 cron 구문을 사용하여 사용자 지정 패턴을 추가합니다.
- 파이프라인의 브랜치 또는 태그를 선택합니다.
- 변수 아래에 일정에 CI/CD 변수를 입력합니다.
- 파이프라인 일정 생성을 선택합니다.
문제 해결#
이 튜토리얼을 완료하는 동안 다음 문제가 발생할 수 있습니다.
인증 오류#
인증 오류가 발생하는 경우:
- 그룹 배포 토큰 권한을 확인하세요.
- 토큰에
read_package_registry및write_package_registry범위가 모두 있는지 확인하세요. - 토큰이 만료되지 않았는지 확인하세요.
누락된 패키지 유형#
패키지 유형이 누락된 경우:
- 배포 토큰이 액세스 권한이 있는지 확인하세요.
- 패키지 유형이 그룹 설정에서 활성화되어 있는지 확인하세요.
aggregate 단계의 메모리 문제#
메모리 문제가 발생하는 경우:
- 더 많은 메모리가 있는 러너를 사용하세요.
- 패키지 유형을 필터링하여 한 번에 처리하는 패키지를 줄이세요.
리소스 권장 사항#
최적의 성능을 위해:
- 최소 2GB RAM이 있는 러너를 사용하세요.
- 패키지 1,000개당 5-10분을 허용하세요.
- 패키지가 많은 그룹의 경우 작업 타임아웃을 늘리세요.
도움받기#
다른 문제가 발생하는 경우:
- 특정 오류 메시지에 대한 작업 로그를 확인하세요.
curl명령을 직접 사용하여 API 액세스를 테스트하세요.- 먼저 더 작은 패키지 유형 하위 집합으로 테스트하세요.
