教程:使用 GitLab 包注册表生成软件物料清单
本教程将向您展示如何通过 CI/CD 管道生成 CycloneDX 格式的软件物料清单(SBOM)。您将构建的管道会收集组内多个项目的包,为您提供相关项目依赖关系的全面视图。
您将创建一个虚拟 Python 环境来完成本教程,但同样的方法也适用于其他支持的包类型。
什么是软件物料清单?
SBOM 是软件产品中所有软件组件的机器可读清单。SBOM 可能包括:
- 直接和间接依赖
- 开源组件和许可证
- 包版本及其来源
对使用软件产品感兴趣的组织可能需要 SBOM 来确定产品在采用前的安全性。
如果您熟悉 GitLab 包注册表,可能会想知道 SBOM 与依赖列表之间的区别。下表突出了关键差异:
| 差异 | 依赖列表 | SBOM |
|---|---|---|
| 范围 | 显示单个项目或组的依赖关系。 | 创建组内所有已发布包的清单。 |
| 方向 | 跟踪项目依赖什么(传入依赖)。 | 跟踪组发布什么(传出包)。 |
| 覆盖范围 | 基于包清单,如 package.json 或 pom.xml。 |
覆盖包注册表中的实际已发布工件。 |
什么是 CycloneDX?
CycloneDX 是一种轻量级、标准化的 SBOM 创建格式。CycloneDX 提供了明确定义的架构,帮助组织:
- 记录软件组件及其关系。
- 跟踪软件供应链中的漏洞。
- 验证开源依赖的许可证合规性。
- 建立一致且机器可读的 SBOM 格式。
CycloneDX 支持多种输出格式,包括 JSON、XML 和 Protocol Buffers,使其能够满足不同的集成需求。该规范设计得全面而高效,涵盖从基本组件标识到软件来源详细信息的各个方面。
开始之前
要完成本教程,您需要:
- 至少拥有 Maintainer 角色的组。
- 访问 GitLab CI/CD。
- 如果您使用的是 GitLab 自托管实例,则需要配置 GitLab Runner。如果您使用的是 GitLab.com,可以跳过此要求。
- 可选。使用 组部署令牌 来验证对包注册表的请求。
步骤
本教程包含两组步骤来完成:
- 配置生成 CycloneDX 格式 SBOM 的 CI/CD 管道
- 访问和使用生成的 SBOM 及包统计文件
以下是您将要操作的概述:
在实施此解决方案之前,请注意:
- 包依赖关系未解析(仅列出直接包)。
- 包版本包含在内,但未进行漏洞分析。
添加基础管道配置
首先,设置定义整个管道中使用的变量和阶段的基础镜像。
在以下部分中,您将通过添加每个阶段的配置来构建管道。
在您的项目中:
-
创建一个
.gitlab-ci.yml文件。 -
在文件中添加以下基础配置:
# 所有作业的基础镜像 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,因为它占用空间小且作业启动快
- 支持组部署令牌进行身份验证
- 安装
curl用于 API 请求,jq用于 JSON 处理,以及ca-certificates来确保安全的 HTTPS 连接 - 将所有输出存储在
sbom-output目录中 - 生成 CycloneDX JSON 格式的 SBOM
配置 prepare 阶段
prepare 阶段设置 Python 环境并安装所需的依赖项。
在您的 .gitlab-ci.yml 文件中,添加以下配置:
# 设置 Python 虚拟环境并安装所需包
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 虚拟环境以实现隔离
- 安装 CycloneDX 库用于 SBOM 生成
- 创建工件的输出目录
- 为后续阶段保留虚拟环境
- 设置一周的工件过期时间以管理存储
配置 collect 阶段
collect 阶段从您组的包注册表中收集包信息。
在您的 .gitlab-ci.yml 文件中,添加以下配置:
# 从 GitLab 注册表收集包信息和版本
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"
# 可选排除列表 - 您可以添加要排除的包类型
# EXCLUDE_TYPES="terraform"
page=1
while true; do
# 获取所有包而不指定类型,支持分页
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 "第 $page 页的 API 响应出错"
break
fi
count=$(echo "$response" | jq '. | length')
if [ "$count" -eq 0 ]; then
break
fi
# 如果设置了 EXCLUDE_TYPES,则过滤包
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
# 将此页结果与现有数据合并
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"
# 如果获得完整页结果,则移至下一页
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 文件中,添加以下配置:
# 通过聚合包数据生成 SBOM
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):
"""通过聚合具有相同名称和类型的包来处理版本信息"""
version_history = {}
package_versions = {} # 按名称和类型分组的包字典
try:
with open(packages_file, 'r') as f:
packages = json.load(f)
if not isinstance(packages, list):
return version_history
# 首先,按名称和类型分组包
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')
})
# 然后处理每个组以创建版本历史
for package_key, versions in package_versions.items():
# 按创建日期排序版本,最新的在前
versions.sort(key=lambda x: x.get('created_at', ''), reverse=True)
# 使用第一个包的 ID 作为键(最新版本)
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"处理版本历史时出错: {e}")
return version_history
def merge_package_data(package_file):
"""合并包数据并生成组件列表"""
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"合并包数据时出错: {e}")
return [], package_stats
return list(merged_components.values()), package_stats
# 主处理
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
# 创建最终 SBOM 文档
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
}
# 将结果写入文件
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文件 - 按名称和类型分组包以识别同一包的不同版本
- 创建符合 CycloneDX 规范的 JSON 格式 SBOM
- 计算包统计信息,包括:
- 按类型统计的包总数
- 每个包的版本历史
- 首次发布和最后更新日期
- 为每个组件生成包 URL (
purl) - 通过适当的异常处理优雅地处理缺失或无效数据
- 同时生成 SBOM 和单独的统计文件
配置 publish 阶段
publish 阶段将生成的 SBOM 和统计文件上传到 GitLab。
在您的 .gitlab-ci.yml 文件中,添加以下配置:
# 将 SBOM 文件发布到 GitLab 包注册表
publish_sbom:
stage: publish
script: |
STATS=$(cat "${SBOM_OUTPUT_DIR}/package_stats.json")
# 上传生成的文件
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"
# 添加包描述
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": "组包注册表 SBOM 生成于 $(date -u)\n统计信息: ${STATS}"
}
EOF
dependencies:
- aggregate_sboms此阶段:
- 将 SBOM 和统计文件发布到您项目的包注册表
- 使用通用包类型进行存储
- 使用提交 SHA 作为包版本以便追踪
- 在包描述中添加生成时间戳和统计信息
访问生成的文件
当管道完成时,它会生成这些文件:
merged_sbom.json: CycloneDX 格式的完整 SBOMpackage_stats.json: 关于您包的统计信息
要访问生成的文件:
- 在您的项目中,选择 Deploy > Package registry。
- 找到名为
sbom的包。 - 下载 SBOM 和统计文件。
使用 SBOM 文件
SBOM 文件遵循 CycloneDX 1.4 JSON 规范,并提供有关您组包注册表中已发布包、包版本和工件的信息。
您还可以将 SBOM 文件用于合规性和审计目的,例如:
- 生成已发布包的报告
- 记录您组包注册表的内容
- 随时间跟踪发布活动
在使用 CycloneDX 文件时,考虑使用以下工具:
使用统计文件
统计文件提供包注册表分析和活动跟踪。
例如,要分析您的包注册表,您可以:
- 查看按类型统计的已发布包总数。
- 查看每个包的版本计数。
- 跟踪首次发布和最后更新日期。
要跟踪包注册表活动,您可以:
- 监控包发布模式。
- 识别最频繁更新的包。
- 随时间跟踪包注册表增长。
您可以使用 jq 等 CLI 工具处理统计文件
以生成可读的 JSON 格式分析或活动信息。
以下代码块列出了几个 jq 命令示例,您可以对统计文件运行这些命令进行一般分析或报告:
# 获取注册表中的总包数
jq '.total_packages' package_stats.json
# 列出包类型及其计数
jq '.package_types' package_stats.json
# 找出发布版本最多的包
jq '.version_history | to_entries | sort_by(.value.version_count) | reverse | .[0:5]' package_stats.json管道调度
如果您频繁更新包注册表,应该相应地更新您的 SBOM。您可以配置管道调度,根据您的发布活动生成更新的 SBOM。
考虑以下建议:
- 每日更新:如果您频繁发布包或需要最新报告,建议使用
- 每周更新:适合大多数具有中等包发布活动的团队
- 每月更新:对于包更新不频繁的组来说足够了
要调度管道:
- 在您的项目中,转到 Build > Pipeline schedules。
- 选择 Create a new pipeline schedule 并填写表单:
- 从 Cron timezone 下拉列表中选择一个时区。
- 选择 Interval Pattern,或使用 cron 语法 添加 Custom 模式。
- 选择管道的分支或标签。
- 在 Variables 下,为调度输入任意数量的 CI/CD 变量。
- 选择 Create pipeline schedule。
故障排除
在完成本教程时,您可能会遇到以下问题。
身份验证错误
如果您遇到身份验证错误:
- 检查您的组部署令牌权限。
- 确保令牌同时具有
read_package_registry和write_package_registry范围。 - 验证令牌是否未过期。
缺少包类型
如果您缺少包类型:
- 确保您的部署令牌有权访问所有包类型。
- 检查包类型是否在您的组设置中启用。
aggregate 阶段的内存问题
如果您遇到内存问题:
- 使用内存更多的 runner。
- 通过过滤包类型一次处理更少的包。
资源建议
为了获得最佳性能:
- 使用至少有 2 GB RAM 的 runner。
- 每 1,000 个包允许 5-10 分钟。
- 对于包含许多包的组,增加作业超时时间。
获取帮助
如果您遇到其他问题:
- 检查作业日志以获取具体的错误消息。
- 直接使用
curl命令验证 API 访问。 - 首先使用较小的包类型子集进行测试。