跳过正文
  1. 文章列表/

软件供应链安全:从 SBOM 到软件物料清单的攻防实践

Elone Yue
作者
Elone Yue

执行摘要
#

软件供应链攻击已经从理论威胁演变为现实中最具破坏力的攻击向量之一。从 2020 年的 SolarWinds Orion 事件到 2021 年的 Log4j 零日漏洞(CVE-2021-44228),再到 2024 年的 XZ Utils 后门事件(CVE-2024-3094),供应链攻击展现了其"一次入侵,万物遭殃"的放大效应。本文将系统性地从软件物料清单(SBOM)的生成、依赖漏洞检测、CI/CD 集成到策略即代码,提供一套完整的供应链安全工程实践。

软件供应链攻击全景
#

攻击向量分类
#

软件供应链攻击可以根据注入点分为以下几个维度:

                    软件供应链攻击面
    ┌─────────────────────────────────────────────────────────────┐
    │                                                             │
    │  ┌───────────┐    ┌───────────┐    ┌───────────────────┐    │
    │  │ 上游依赖  │    │ 构建系统  │    │ 分发渠道          │    │
    │  │ ┌────────┐│    │ ┌────────┐│    │ ┌────────────────┐│    │
    │  │ │npm包投毒││    │ │CI/CD劫持││    │ │PyPI/镜像篡改   ││    │
    │  │ │Go模块劫持││    │ │依赖混淆│ │    │ │Docker镜像注入  ││    │
    │  │ │Typosquat││    │ │编译器后门││    │ │签名伪造        ││    │
    │  │ └────────┘│    │ └────────┘│    │ └────────────────┘│    │
    │  └───────────┘    └───────────┘    └───────────────────┘    │
    │                                                             │
    │  ┌───────────┐    ┌───────────┐    ┌───────────────────┐    │
    │  │ 开发工具  │    │ 基础设施  │    │ 第三方服务        │    │
    │  │ │IDE插件  ││    │ │云配置   ││    │ │CDN劫持         ││    │
    │  │ │VS扩展   ││    │ │TF模块  ││    │ │JS脚本注入      ││    │
    │  │ │DevOps工具││    │ │容器基座││    │ │监控SDK投毒     ││    │
    │  │ └────────┘│    │ └────────┘│    │ └────────────────┘│    │
    │  └───────────┘    └───────────┘    └───────────────────┘    │
    └─────────────────────────────────────────────────────────────┘

经典案例时间线
#

SolarWinds Orion (2020):

攻击时间线:
2020-03: 攻击者入侵 SolarWinds 开发网络
2020-06: Sunburst 后门植入 Orion 平台构建流程
2020-10: 受影响版本 v2019.4 HF 5 至 v2020.2 HF 1 发布
2020-12: FireEye 和 Microsoft 公开披露
         受影响客户约 18,000 家组织

Log4j 漏洞 (CVE-2021-44228):

2021-11-24: 安全意识研究员 chalk_joe 在 Twitter 首次公开
2021-11-30: Apache 发布 Log4j 2.15.0 修复版本
2021-12:    全球范围内大规模利用,Cloudflare 记录日均 1000+ 次攻击
2022-01:    CVE-2021-44832 和 CVE-2021-45046 相继披露
             影响范围: 全球估计 30,000+ 个 Java 项目直接/间接依赖

XZ Utils 后门 (CVE-2024-3094):

2021-2023: 攻击者以 "Jia Tan" 身份逐步获取 XZ/liblzma 维护者信任
2024-01:    恶意代码合入 v5.6.0 和 v5.6.1 发布版本
2024-03-29: Microsoft 工程师 Andres Freund 发现异常 SSH 延迟
             影响: SSH sshd 通过 liblzma 性能退化约 2-3 倍
             后门: 可绕过 SSH 认证,但需要特定 RSA 密钥配置

SBOM 标准深度解析
#

SPDX(Software Package Data Exchange)
#

SPDX 由 Linux Foundation 维护,是目前最广泛采用的 SBOM 标准。其核心数据模型包含以下关键实体:

  • SPDX Document: 根元素,包含元数据和 SPDX ID
  • Package: 每个软件包的描述(名称、版本、供应商、许可证)
  • File: 构成包的源文件信息
  • Relationship: 包与包、包与文件之间的关系(DEPENDS_ON、CONTAINS 等)

一个 SPDX JSON 格式的 SBOM 示例:

{
  "spdxVersion": "SPDX-2.3",
  "dataLicense": "CC0-1.0",
  "SPDXID": "SPDXRef-DOCUMENT",
  "name": "TechOrigin-API-Service-1.2.0",
  "documentNamespace": "https://techorigin.dev/sbom/api-service-1.2.0",
  "creationInfo": {
    "created": "2024-06-05T09:00:00Z",
    "creators": [
      "Tool: syft-0.98.0",
      "Organization: TechOrigin"
    ],
    "licenseListVersion": "3.20"
  },
  "packages": [
    {
      "name": "log4j-core",
      "SPDXID": "SPDXRef-Package-log4j-core-2.14.1",
      "versionInfo": "2.14.1",
      "supplier": "ORGANIZATION: Apache Software Foundation",
      "downloadLocation": "https://repo1.maven.org/maven2/org/apache/logging/log4j/log4j-core/2.14.1/",
      "filesAnalyzed": false,
      "licenseConcluded": "Apache-2.0",
      "externalRefs": [
        {
          "referenceCategory": "PACKAGE-MANAGER",
          "referenceType": "purl",
          "referenceLocator": "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1"
        }
      ],
      "checksums": [
        {
          "algorithm": "SHA-256",
          "checksumValue": "c243901dae295b76eb5e081f34c08e6665d4e1e3c7ec7e9b3f1a4c1c5e6d7d8e"
        }
      ]
    },
    {
      "name": "spring-boot-starter-web",
      "SPDXID": "SPDXRef-Package-spring-boot-starter-web-2.7.5",
      "versionInfo": "2.7.5",
      "supplier": "ORGANIZATION: Pivotal Software, Inc.",
      "externalRefs": [
        {
          "referenceCategory": "PACKAGE-MANAGER",
          "referenceType": "purl",
          "referenceLocator": "pkg:maven/org.springframework.boot/spring-boot-starter-web@2.7.5"
        }
      ]
    }
  ],
  "relationships": [
    {
      "spdxElementId": "SPDXRef-DOCUMENT",
      "relatedSpdxElement": "SPDXRef-Package-log4j-core-2.14.1",
      "relationshipType": "DESCRIBES"
    },
    {
      "spdxElementId": "SPDXRef-Package-spring-boot-starter-web-2.7.5",
      "relatedSpdxElement": "SPDXRef-Package-log4j-core-2.14.1",
      "relationshipType": "DEPENDS_ON"
    }
  ]
}

CycloneDX
#

CycloneDX 由 OWASP 推动,其设计理念更侧重于安全用例。与 SPDX 相比,CycloneDX 在以下方面有其独特优势:

  • 内置 vulnerabilitymetadata 组件
  • 对组件依赖图的更精确建模
  • 支持 BOM 等级(BOM Level 1/2/3)

SWID(Software Identification Tags)
#

ISO/IEC 19770-2 标准,由 NIST 推动,主要用于企业软件资产管理。SWID 标签通常由软件供应商在安装包中附带。

多语言 SBOM 生成实战
#

Go 项目 SBOM 生成
#

Go 模块天然支持 SBOM 生成。Go 1.18+ 引入了 debug/buildinfo 包,可以提取完整的依赖信息。

使用 syft(Anchore 开源工具)生成 Go 项目的 SBOM:

# 安装 syft
$ curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin

# 从 Go 模块目录生成 SPDX JSON
$ syft packages dir:. -o spdx-json > sbom.spdx.json

# 从 Go 二进制文件提取 SBOM
$ syft packages ./my-app -o spdx-json > binary-sbom.spdx.json

# 从 Go module 缓存生成
$ syft packages $(go env GOMODCACHE) -o cyclonedx-json > deps-sbom.cdx.json

Go 项目也可以通过标准库编程方式生成 SBOM:

package main

import (
    "debug/buildinfo"
    "encoding/json"
    "fmt"
    "log"
    "os"
)

type SBOMPackage struct {
    Name            string `json:"name"`
    Version         string `json:"version"`
    Sum             string `json:"sum"`
    ReplacementName string `json:"replacement_name,omitempty"`
    ReplacementVer  string `json:"replacement_version,omitempty"`
    Indirect        bool   `json:"indirect"`
}

type SBOM struct {
    GoVersion string         `json:"go_version"`
    GoArch    string         `json:"go_arch"`
    GoOS      string         `json:"go_os"`
    Path      string         `json:"main_module_path"`
    Deps      []SBOMPackage  `json:"dependencies"`
}

func GenerateSBOM(binaryPath string) (*SBOM, error) {
    info, err := buildinfo.ReadFile(binaryPath)
    if err != nil {
        return nil, fmt.Errorf("failed to read build info: %w", err)
    }

    sbom := &SBOM{
        GoVersion: info.GoVersion,
        GoArch:    info.GoArch,
        GoOS:      info.GoOS,
        Path:      info.Path,
        Deps:      make([]SBOMPackage, 0, len(info.Deps)),
    }

    for _, dep := range info.Deps {
        pkg := SBOMPackage{
            Name:    dep.Path,
            Version: dep.Version,
            Sum:     dep.Sum,
        }
        if dep.Replace != nil {
            pkg.ReplacementName = dep.Replace.Path
            pkg.ReplacementVer = dep.Replace.Version
        }
        sbom.Deps = append(sbom.Deps, pkg)
    }

    return sbom, nil
}

func main() {
    if len(os.Args) < 2 {
        log.Fatal("Usage: sbom-gen <binary-path>")
    }

    sbom, err := GenerateSBOM(os.Args[1])
    if err != nil {
        log.Fatalf("Error: %v", err)
    }

    encoder := json.NewEncoder(os.Stdout)
    encoder.SetIndent("", "  ")
    if err := encoder.Encode(sbom); err != nil {
        log.Fatalf("JSON encode error: %v", err)
    }
}

Python 项目 SBOM 生成
#

Python 生态中,pip-licensespip-auditcyclonedx-bom 是三个关键工具:

# 使用 cyclonedx-bom 生成 CycloneDX 格式
$ pip install cyclonedx-bom
$ cyclonedx-bom -o sbom.cdx.json --format json

# 使用 pip-audit 检查已知漏洞
$ pip install pip-audit
$ pip-audit --format json > vulnerability-report.json

# 使用 pip-licenses 生成许可证清单
$ pip-licenses --format=json --with-authors --with-urls > licenses.json

通过 Python 脚本自动生成 SBOM:

#!/usr/bin/env python3
"""
sbom_generator.py - Generate SBOM for Python projects
Supports requirements.txt, poetry.lock, and pipenv/Pipfile.lock
"""

import json
import os
import sys
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional


class SBOMGenerator:
    """Generate SBOM from Python project dependencies."""

    def __init__(self, project_root: str = "."):
        self.project_root = Path(project_root)
        self.packages: List[Dict] = []

    def scan_pip(self) -> List[Dict]:
        """Scan installed packages via pip list --format=json."""
        result = subprocess.run(
            [sys.executable, "-m", "pip", "list", "--format=json"],
            capture_output=True, text=True, check=True
        )
        pip_packages = json.loads(result.stdout)
        packages = []
        for pkg in pip_packages:
            packages.append({
                "name": pkg["name"],
                "version": pkg["version"],
                "purl": f"pkg:pypi/{pkg['name'].lower()}@{pkg['version']}",
                "type": "library",
                "scope": "required"
            })
        return packages

    def scan_poetry(self) -> List[Dict]:
        """Scan dependencies from poetry.lock file."""
        lock_path = self.project_root / "poetry.lock"
        if not lock_path.exists():
            return []

        # Use toml library to parse poetry.lock
        try:
            import toml
        except ImportError:
            subprocess.run([sys.executable, "-m", "pip", "install", "toml"],
                         capture_output=True)
            import toml

        with open(lock_path) as f:
            lock_data = toml.load(f)

        packages = []
        for pkg in lock_data.get("package", []):
            packages.append({
                "name": pkg["name"],
                "version": pkg["version"],
                "purl": f"pkg:pypi/{pkg['name'].lower()}@{pkg['version']}",
                "type": "library",
                "scope": "required",
                "licenses": [pkg.get("license", "UNKNOWN")],
            })
        return packages

    def scan_requirements(self) -> List[Dict]:
        """Parse requirements.txt for dependencies."""
        req_path = self.project_root / "requirements.txt"
        if not req_path.exists():
            return []

        packages = []
        with open(req_path) as f:
            for line in f:
                line = line.strip()
                if not line or line.startswith("#") or line.startswith("-"):
                    continue
                # Parse package==version, package>=version, etc.
                for sep in ["==", ">=", "<=", "~=", "!="]:
                    if sep in line:
                        name, version = line.split(sep, 1)
                        packages.append({
                            "name": name.strip(),
                            "version": version.strip(),
                            "purl": f"pkg:pypi/{name.strip().lower()}@{version.strip()}",
                            "type": "library",
                        })
                        break
                else:
                    packages.append({
                        "name": line,
                        "version": "unspecified",
                        "purl": f"pkg:pypi/{line.lower()}",
                        "type": "library",
                    })
        return packages

    def generate_cyclonedx(
        self, source: str = "pip", output_path: str = "sbom.cdx.json"
    ) -> str:
        """Generate CycloneDX 1.5 format SBOM."""
        scanners = {
            "pip": self.scan_pip,
            "poetry": self.scan_poetry,
            "requirements": self.scan_requirements,
        }

        scanner = scanners.get(source)
        if not scanner:
            raise ValueError(f"Unknown source: {source}")

        self.packages = scanner()

        bom = {
            "bomFormat": "CycloneDX",
            "specVersion": "1.5",
            "version": 1,
            "metadata": {
                "timestamp": datetime.now(timezone.utc).isoformat(),
                "tools": [
                    {
                        "name": "sbom-generator",
                        "version": "1.0.0",
                        "vendor": "TechOrigin",
                    }
                ],
                "component": {
                    "name": self.project_root.name,
                    "version": "1.0.0",
                    "type": "application",
                },
            },
            "components": self.packages,
            "dependencies": [
                {
                    "ref": f"pkg:pypi/{self.project_root.name.lower()}",
                    "dependsOn": [pkg["purl"] for pkg in self.packages],
                }
            ],
        }

        output = json.dumps(bom, indent=2)
        Path(output_path).write_text(output)
        return output


if __name__ == "__main__":
    project = sys.argv[1] if len(sys.argv) > 1 else "."
    source = sys.argv[2] if len(sys.argv) > 2 else "pip"
    output = sys.argv[3] if len(sys.argv) > 3 else "sbom.cdx.json"

    gen = SBOMGenerator(project)
    result = gen.generate_cyclonedx(source=source, output_path=output)
    print(f"SBOM generated: {output} ({len(gen.packages)} packages)")

Node.js 项目 SBOM 生成
#

# 使用 cyclonedx-node 生成
$ npx @cyclonedx/cyclonedx-cli --output-file sbom.cdx.json --format JSON

# 使用 npm ls 生成交付树
$ npm ls --all --json > dependency-tree.json

# 使用 npm audit 检查漏洞
$ npm audit --json > audit-results.json

# 使用 license-checker 生成许可证清单
$ npx license-checker --json --production > licenses.json

依赖漏洞扫描实战
#

Grype
#

Grype 是 Anchore 开发的开源漏洞扫描器,原生支持 SBOM 输入:

# 安装 grype
$ curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin

# 扫描容器镜像
$ grype my-app:latest -o json > grype-report.json

# 扫描目录(项目源码)
$ grype dir:. --config grype.yaml -o table

# 扫描 SBOM(使用已生成的 SBOM 文件)
$ grype sbom:sbom.spdx.json --fail-on high

# 配置忽略规则 (grype.yaml)

Grype 配置文件 grype.yaml:

# grype.yaml
exclude:
  - path: "/test/fixtures/**"
  - vulnerability: "CVE-2023-9999"   # 已评估为风险可接受的已知漏洞

match:
  java:
    search-upstream: true
  golang:
    search-local-copies-of-standard-library: true

db:
  cache-dir: "/tmp/grype-db"
  validate-age: true
  max-allowed-built-age: 120h  # 数据库超过 120 小时提示更新

output-template: |
  ====== 漏洞扫描报告 ======
  项目: {{ .Distro }}
  时间: {{ .Timestamp }}
  严重: {{ .CriticalCount }}
  高危: {{ .HighCount }}
  中危: {{ .MediumCount }}
  低危: {{ .LowCount }}

Trivy
#

Trivy 由 Aqua Security 开发,是功能最全面的漏洞扫描工具之一:

# 安装 trivy
$ wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | sudo apt-key add -
$ echo "deb https://aquasecurity.github.io/trivy-repo/deb $(lsb_release -sc) main" | sudo tee -a /etc/apt/sources.list.d/trivy.list
$ sudo apt update && sudo apt install trivy

# 扫描文件系统
$ trivy fs --security-checks vuln,config,secret --format json -o trivy-fs-report.json .

# 扫描容器镜像
$ trivy image --severity HIGH,CRITICAL --format table my-app:latest

# 扫描 Git 仓库
$ trivy repo https://github.com/org/repo.git --format sarif -o results.sarif

# 扫描 SBOM
$ trivy sbom sbom.cdx.json --exit-code 1 --severity HIGH,CRITICAL

# 扫描 IaC 配置
$ trivy config --format json deployment.yaml terraform/

综合扫描脚本
#

将 Grype 和 Trivy 整合到一个自动化扫描流程中:

#!/usr/bin/env python3
"""
supply_chain_scanner.py - Unified supply chain vulnerability scanner
Integrates Grype and Trivy for comprehensive dependency scanning
"""

import json
import subprocess
import sys
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Dict, List, Optional


class Severity(Enum):
    CRITICAL = "CRITICAL"
    HIGH = "HIGH"
    MEDIUM = "MEDIUM"
    LOW = "LOW"
    UNKNOWN = "UNKNOWN"


@dataclass
class Vulnerability:
    id: str                    # CVE-2024-XXXX
    severity: Severity
    package: str
    installed_version: str
    fixed_version: Optional[str]
    title: str
    description: str
    urls: List[str] = field(default_factory=list)
    scanner: str = ""


class SupplyChainScanner:
    """Unified scanner for supply chain vulnerability detection."""

    def __init__(self, target: str, target_type: str = "dir"):
        self.target = target
        self.target_type = target_type
        self.vulnerabilities: List[Vulnerability] = []

    def run_command(self, cmd: List[str]) -> str:
        """Run command and return stdout."""
        result = subprocess.run(
            cmd, capture_output=True, text=True,
            timeout=300  # 5-minute timeout
        )
        if result.returncode not in (0, 1):  # 1 = vulnerabilities found
            print(f"Warning: {' '.join(cmd)} exited with {result.returncode}")
        return result.stdout

    def scan_with_grype(self) -> List[Vulnerability]:
        """Run Grype scan."""
        cmd = ["grype", self.target, "--scope", "squashed", "-o", "json"]
        output = self.run_command(cmd)
        if not output:
            return []

        data = json.loads(output)
        vulns = []
        for match in data.get("matches", []):
            vuln = match.get("vulnerability", {})
            severity_str = vuln.get("severity", "UNKNOWN")
            try:
                severity = Severity(severity_str)
            except ValueError:
                severity = Severity.UNKNOWN

            vulns.append(Vulnerability(
                id=vuln.get("id", ""),
                severity=severity,
                package=match.get("artifact", {}).get("name", ""),
                installed_version=match.get("artifact", {}).get("version", ""),
                fixed_version=vuln.get("fix", {}).get("versions", [None])[0]
                if vuln.get("fix", {}).get("versions") else None,
                title=vuln.get("description", "")[:200],
                description=vuln.get("description", ""),
                urls=vuln.get("references", []),
                scanner="grype"
            ))
        self.vulnerabilities.extend(vulns)
        return vulns

    def scan_with_trivy(self) -> List[Vulnerability]:
        """Run Trivy scan."""
        if self.target_type == "image":
            cmd = ["trivy", "image", "--format", "json", self.target]
        elif self.target_type == "sbom":
            cmd = ["trivy", "sbom", "--format", "json", self.target]
        else:
            cmd = ["trivy", "fs", "--format", "json", self.target]

        output = self.run_command(cmd)
        if not output:
            return []

        data = json.loads(output)
        vulns = []
        for result in data.get("Results", []):
            for vuln in result.get("Vulnerabilities", []):
                severity_str = vuln.get("Severity", "UNKNOWN")
                try:
                    severity = Severity(severity_str)
                except ValueError:
                    severity = Severity.UNKNOWN

                vulns.append(Vulnerability(
                    id=vuln.get("VulnerabilityID", ""),
                    severity=severity,
                    package=result.get("Title", result.get("Target", "")),
                    installed_version=vuln.get("InstalledVersion", ""),
                    fixed_version=vuln.get("FixedVersion"),
                    title=vuln.get("Title", ""),
                    description=vuln.get("Description", ""),
                    urls=vuln.get("References", []),
                    scanner="trivy"
                ))
        self.vulnerabilities.extend(vulns)
        return vulns

    def deduplicate(self) -> List[Vulnerability]:
        """Deduplicate vulnerabilities across scanners."""
        seen = set()
        unique = []
        for v in self.vulnerabilities:
            key = (v.id, v.package)
            if key not in seen:
                seen.add(key)
                unique.append(v)
        return unique

    def generate_report(self, output_path: str = "scan-report.json") -> Dict:
        """Generate comprehensive scan report."""
        unique = self.deduplicate()

        severity_counts = {s: 0 for s in Severity}
        for v in unique:
            severity_counts[v.severity] += 1

        report = {
            "scan_target": self.target,
            "total_vulnerabilities": len(unique),
            "severity_summary": {
                k.value: v for k, v in severity_counts.items()
            },
            "vulnerabilities": [
                {
                    "cve": v.id,
                    "severity": v.severity.value,
                    "package": v.package,
                    "installed": v.installed_version,
                    "fixed_in": v.fixed_version or "unfixed",
                    "description": v.title,
                    "scanner_source": v.scanner,
                }
                for v in sorted(unique, key=lambda x: list(Severity).index(x.severity))
            ],
        }

        Path(output_path).write_text(json.dumps(report, indent=2))
        return report


if __name__ == "__main__":
    target = sys.argv[1] if len(sys.argv) > 1 else "."
    scanner = SupplyChainScanner(target)

    print(f"[*] Scanning {target} with Grype...")
    scanner.scan_with_grype()
    print(f"[*] Scanning {target} with Trivy...")
    scanner.scan_with_trivy()

    report = scanner.generate_report()
    print(f"\n[+] Scan complete: {report['total_vulnerabilities']} unique vulnerabilities found")
    for sev, count in report["severity_summary"].items():
        print(f"    {sev}: {count}")

GitHub Actions: 自动化 SBOM 生成与扫描
#

将 SBOM 生成和漏洞扫描集成到 CI/CD 管道中是实现供应链安全自动化的关键步骤。

# .github/workflows/supply-chain-security.yml
name: Supply Chain Security Scan

on:
  push:
    branches: [main, master]
  pull_request:
    branches: [main, master]
  schedule:
    # 每周日凌晨 2 点运行全量扫描
    - cron: '0 2 * * 0'
  workflow_dispatch:

permissions:
  contents: write
  security-events: write
  packages: read

jobs:
  sbom-generation:
    name: Generate SBOM
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Go
        uses: actions/setup-go@v5
        with:
          go-version: '1.22'

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: Install syft
        run: |
          curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
          syft version

      - name: Generate SBOM for Go modules
        run: |
          syft packages dir:. -o spdx-json > sbom-go.spdx.json
          echo "Go SBOM generated"

      - name: Generate SBOM for Python deps
        run: |
          pip install cyclonedx-bom
          cyclonedx-bom -o sbom-python.cdx.json --format json
          echo "Python SBOM generated"

      - name: Generate SBOM for Node.js deps
        if: hashFiles('package.json') != ''
        run: |
          npx @cyclonedx/cyclonedx-npm --output-file sbom-node.cdx.json
          echo "Node.js SBOM generated"

      - name: Merge SBOMs
        run: |
          # Create a combined SBOM referencing all language-specific ones
          cat > sbom-combined.cdx.json << SBOM_EOF
          {
            "bomFormat": "CycloneDX",
            "specVersion": "1.5",
            "version": 1,
            "metadata": {
              "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
              "tools": [{"name": "TechOrigin-SBOM-Merger", "version": "1.0.0"}]
            },
            "components": [],
            "compositions": [
              {
                "ref": "main",
                "dependsOn": ["go-sbom", "python-sbom", "node-sbom"],
                "assemblies": ["go-sbom", "python-sbom", "node-sbom"]
              }
            ]
          }
          SBOM_EOF

      - name: Upload SBOM artifacts
        uses: actions/upload-artifact@v4
        with:
          name: sbom-files
          path: sbom-*.json
          retention-days: 90

  vulnerability-scan:
    name: Vulnerability Scanning
    needs: sbom-generation
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Download SBOM
        uses: actions/download-artifact@v4
        with:
          name: sbom-files

      - name: Install Grype
        run: |
          curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin

      - name: Install Trivy
        uses: aquasecurity/trivy-action@master
        with:
          trivy-config: trivy.yaml

      - name: Run Grype SBOM Scan
        run: |
          grype sbom:sbom-go.spdx.json \
            --fail-on high \
            --output sarif \
            -o grype-results.sarif \
            || true

      - name: Run Trivy Scan
        uses: aquasecurity/trivy-action@master
        with:
          scan-type: 'fs'
          format: 'sarif'
          output: 'trivy-results.sarif'
          severity: 'CRITICAL,HIGH'
          exit-code: '0'

      - name: Upload SARIF to GitHub Security
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: trivy-results.sarif
          category: trivy

      - name: Run pip-audit
        if: hashFiles('requirements.txt') != '' || hashFiles('poetry.lock') != ''
        run: |
          pip install pip-audit
          pip-audit --format json -o pip-audit-report.json || true

      - name: Generate summary
        if: always()
        run: |
          echo "## Supply Chain Security Report" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo "| Tool | Status | Output |" >> $GITHUB_STEP_SUMMARY
          echo "|------|--------|--------|" >> $GITHUB_STEP_SUMMARY
          echo "| Grype | Complete | grype-results.sarif |" >> $GITHUB_STEP_SUMMARY
          echo "| Trivy | Complete | trivy-results.sarif |" >> $GITHUB_STEP_SUMMARY
          echo "| pip-audit | Complete | pip-audit-report.json |" >> $GITHUB_STEP_SUMMARY

  policy-check:
    name: Policy as Code Check
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Install Conftest
        run: |
          conftest_version="0.53.0"
          wget -q "https://github.com/open-policy-agent/conftest/releases/download/v${conftest_version}/conftest_${conftest_version}_Linux_x86_64.tar.gz"
          tar xzf "conftest_${conftest_version}_Linux_x86_64.tar.gz"
          sudo mv conftest /usr/local/bin/

      - name: Run OPA Policy Checks
        run: |
          conftest test -p policies/ sbom-combined.cdx.json

构建产物与加密资源管理
#

在持续集成和部署流程中,构建产物(编译后的二进制、SBOM 文件、签名证书等)通常包含敏感信息,尤其在金融、政务等受监管行业中,这些产物必须进行加密存储。当团队需要访问加密的构建产物或共享密钥时,可靠的密码恢复机制对保障业务连续性至关重要。如果凭证丢失导致无法解密关键构建产物,项目的发布流程可能被中断。在此类场景下,像猫密网(Catpasswd)这样的工具可以帮助恢复加密构建产物和共享资源的访问密码,确保在凭证遗失时仍能维持业务的正常运转。这属于企业风险管理中的基本兜底措施。

in-toto 与 Sigstore:供应链证明链
#

in-toto 框架
#

in-toto 是 CNCF 下的供应链安全框架,通过声明性策略和签名证明(attestations)确保软件从源码到部署的完整性:

in-toto 证明链:

  源码仓库          CI/CD 系统          制品仓库          部署
  ┌──────┐        ┌──────────┐       ┌──────────┐     ┌──────────┐
  │      │  commit │          │  build │          │ sign │          │
  │ dev  │───────▶│ builder  │──────▶│ registry  │─────▶│ k8s      │
  │      │        │          │       │          │      │ cluster  │
  └──────┘        │ + link   │       │ + SBOM   │      │ + policy │
                  │  record  │       │          │      │  check   │
                  └──────────┘       └──────────┘      └──────────┘

in-toto link 记录示例:

{
  "_type": "link",
  "name": "build-step",
  "byproducts": {
    "build_log": ["Step 1/5: FROM golang:1.22-alpine...", "..."]
  },
  "command": ["go", "build", "-o", "app", "."],
  "environment": {
    "GOOS": "linux",
    "GOARCH": "amd64",
    "CGO_ENABLED": "0"
  },
  "materials": [
    {
      "main.go": {
        "sha256": "a1b2c3d4e5f6..."
      }
    }
  ],
  "products": [
    {
      "app": {
        "sha256": "f1e2d3c4b5a6..."
      }
    }
  ],
  "signatures": [
    {
      "keyid": "rsa:abcdef123456",
      "sig": "MEUCIQD..."
    }
  ]
}

Sigstore / cosign
#

Sigstore 提供了无需管理 PKI 的容器镜像签名方案:

# 安装 cosign
$ go install github.com/sigstore/cosign/v2/cmd/cosign@latest

# 使用 keyless 签名(通过 OIDC 认证)
$ cosign sign --yes myregistry.io/my-app:v1.2.0
  Tlog entry created at index: 12345678

# 验证签名
$ cosign verify myregistry.io/my-app:v1.2.0 \
    --certificate-identity=builder@techorigin.dev \
    --certificate-oidc-issuer=https://accounts.google.com

# 附加 SBOM 到镜像
$ cosign attach sbom \
    --sbom sbom.spdx.json \
    myregistry.io/my-app:v1.2.0

# 附加 in-toto attestation
$ cosign attach attestation \
    --predicate provenance.json \
    myregistry.io/my-app:v1.2.0

# 验证 attestation
$ cosign verify-attestation \
    --type slsaprovenance1 \
    myregistry.io/my-app:v1.2.0

策略即代码:OPA / Gatekeeper
#

使用 OPA(Open Policy Agent)和 Gatekeeper 对供应链安全实施强制性策略控制。

OPA 策略规则
#

# policies/sbom_policy.rego
package sbom

import rego.v1

deny[msg] {
    input.components[_].name == "log4j-core"
    version := input.components[_].version
    not is_safe_version(version)
    msg := sprintf(
        "CRITICAL: log4j-core version %s is vulnerable (CVE-2021-44228, CVE-2021-45046, CVE-2021-44832). Upgrade to 2.17.1+",
        [version]
    )
}

is_safe_version(v) {
    # Log4j 2.17.1+ is safe
    semver := split(v, ".")
    major := to_number(semver[0])
    minor := to_number(semver[1])
    patch := to_number(semver[2])
    major >= 2
    minor > 17
}

is_safe_version(v) {
    semver := split(v, ".")
    major := to_number(semver[0])
    minor := to_number(semver[1])
    patch := to_number(semver[2])
    major >= 2
    minor == 17
    patch >= 1
}

# 禁止使用未授权许可证的依赖
deny[msg] {
    component := input.components[_]
    not is_approved_license(component.licenses[_])
    msg := sprintf(
        "Policy violation: %s@%s uses unapproved license. Approved: Apache-2.0, MIT, BSD-2-Clause, BSD-3-Clause, ISC",
        [component.name, component.version]
    )
}

is_approved_license(lic) {
    lic in ["Apache-2.0", "MIT", "BSD-2-Clause", "BSD-3-Clause", "ISC", "MPL-2.0"]
}

# 要求所有组件都有 PURL
warn[msg] {
    component := input.components[_]
    not component.purl
    msg := sprintf(
        "Warning: component %s@%s has no PURL. SBOM completeness is reduced.",
        [component.name, component.version]
    )
}

Gatekeeper Constraint Template
#

# constrainttemplate.yaml
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
  name: k8sbomrequired
spec:
  crd:
    spec:
      names:
        kind: K8SBomRequired
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |
        package k8sbomrequired

        deny[msg] {
          not input.review.object.metadata.annotations["sbom.techorigin.dev/verified"]
          msg := "SBOM verification annotation is required for all deployments"
        }

        deny[msg] {
          input.review.object.metadata.annotations["sbom.techorigin.dev/verified"] == "false"
          msg := "Deployments must have a verified SBOM before deployment"
        }
# constraint.yaml
apiVersion: constraints.gatekeeper.sh/v1
kind: K8SBomRequired
metadata:
  name: require-sbom-verification
spec:
  match:
    kinds:
      - apiGroups: ["apps"]
        kinds: ["Deployment"]
    namespaces: ["production", "staging"]

防御策略总结
#

防御层次 工具/技术 检测目标
SBOM 生成 syft, cyclonedx-bom 完整的依赖清单
漏洞扫描 grype, trivy, pip-audit 已知 CVE
签名验证 cosign, sigstore 镜像篡改
供应链证明 in-toto, slsa 构建过程完整性
策略控制 OPA, Gatekeeper 部署合规
密钥管理 gitleaks, trufflehog 密钥泄露

供应链安全不是一次性的任务,而是一个持续的过程。通过 SBOM 建立依赖可见性,通过自动化扫描实现风险检测,通过 in-toto 和 Sigstore 构建信任链,通过 OPA 实施强制策略——这四层防御体系构成了现代软件供应链安全的工程基石。