Help us learn about your current experience with the documentation. Take the survey.

教程:使用 GitLab 包注册表生成软件物料清单

本教程将向您展示如何通过 CI/CD 管道生成 CycloneDX 格式的软件物料清单(SBOM)。您将构建的管道会收集组内多个项目的包,为您提供相关项目依赖关系的全面视图。

您将创建一个虚拟 Python 环境来完成本教程,但同样的方法也适用于其他支持的包类型。

什么是软件物料清单?

SBOM 是软件产品中所有软件组件的机器可读清单。SBOM 可能包括:

  • 直接和间接依赖
  • 开源组件和许可证
  • 包版本及其来源

对使用软件产品感兴趣的组织可能需要 SBOM 来确定产品在采用前的安全性。

如果您熟悉 GitLab 包注册表,可能会想知道 SBOM 与依赖列表之间的区别。下表突出了关键差异:

差异 依赖列表 SBOM
范围 显示单个项目或组的依赖关系。 创建组内所有已发布包的清单。
方向 跟踪项目依赖什么(传入依赖)。 跟踪组发布什么(传出包)。
覆盖范围 基于包清单,如 package.jsonpom.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 及包统计文件

以下是您将要操作的概述:

  1. 添加基础管道配置
  2. 配置 prepare 阶段
  3. 配置 collect 阶段
  4. 配置 aggregate 阶段
  5. 配置 publish 阶段
  6. 访问生成的 SBOM 和统计文件

在实施此解决方案之前,请注意:

  • 包依赖关系未解析(仅列出直接包)。
  • 包版本包含在内,但未进行漏洞分析。

添加基础管道配置

首先,设置定义整个管道中使用的变量和阶段的基础镜像。

在以下部分中,您将通过添加每个阶段的配置来构建管道。

在您的项目中:

  1. 创建一个 .gitlab-ci.yml 文件。

  2. 在文件中添加以下基础配置:

    # 所有作业的基础镜像
    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 格式的完整 SBOM
  • package_stats.json: 关于您包的统计信息

要访问生成的文件:

  1. 在您的项目中,选择 Deploy > Package registry
  2. 找到名为 sbom 的包。
  3. 下载 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。

考虑以下建议:

  • 每日更新:如果您频繁发布包或需要最新报告,建议使用
  • 每周更新:适合大多数具有中等包发布活动的团队
  • 每月更新:对于包更新不频繁的组来说足够了

要调度管道:

  1. 在您的项目中,转到 Build > Pipeline schedules
  2. 选择 Create a new pipeline schedule 并填写表单:
    • Cron timezone 下拉列表中选择一个时区。
    • 选择 Interval Pattern,或使用 cron 语法 添加 Custom 模式。
    • 选择管道的分支或标签。
    • Variables 下,为调度输入任意数量的 CI/CD 变量。
  3. 选择 Create pipeline schedule

故障排除

在完成本教程时,您可能会遇到以下问题。

身份验证错误

如果您遇到身份验证错误:

  • 检查您的组部署令牌权限。
  • 确保令牌同时具有 read_package_registrywrite_package_registry 范围。
  • 验证令牌是否未过期。

缺少包类型

如果您缺少包类型:

aggregate 阶段的内存问题

如果您遇到内存问题:

  • 使用内存更多的 runner。
  • 通过过滤包类型一次处理更少的包。

资源建议

为了获得最佳性能:

  • 使用至少有 2 GB RAM 的 runner。
  • 每 1,000 个包允许 5-10 分钟。
  • 对于包含许多包的组,增加作业超时时间。

获取帮助

如果您遇到其他问题:

  • 检查作业日志以获取具体的错误消息。
  • 直接使用 curl 命令验证 API 访问。
  • 首先使用较小的包类型子集进行测试。