Read OSS

自动化供应链安全:依赖更新器与版本固定系统

高级

前置知识

  • 第 1 篇:架构概述(了解 versions.json 的背景)
  • 语义化版本(semver)基础知识
  • 熟悉 GitHub API

自动化供应链安全:依赖更新器与版本固定系统

在前三篇文章中,我们将 versions.json 视为既定事实——Dockerfile 从中读取信息来克隆并验证上游依赖。但这些版本信息需要有人(或某个工具)来持续维护。如果手动操作,就意味着要监控四个上游仓库的新版本发布、解析不同格式的 tag、确保更新不会导致版本降级、同步更新 versions.jsonversions.env,还要创建带有有意义 diff 链接的 Pull Request。

dependency_updater 是一个 Go CLI 工具,将上述整个流程全部自动化。它通过 GitHub Actions 每天运行,使用分页查询 GitHub Tags API,按追踪模式过滤版本,强制执行防降级保护,并自动生成 PR。仅版本解析逻辑本身就需要处理四种不同的 tag 格式——这充分说明了上游项目在版本规范上的多样性。

versions.json:唯一可信来源

让我们仔细看看 versions.json 的结构。四个依赖项各自遵循一套明确定义的 schema:

{
  "op_node": {
    "tag": "op-node/v1.16.11",
    "commit": "cba7aba0c98aae22720b21c3a023990a486cb6e0",
    "tagPrefix": "op-node",
    "owner": "ethereum-optimism",
    "repo": "optimism",
    "tracking": "release"
  }
}
字段 用途
tag 克隆时使用的 Git tag——人类可读的版本标识符
commit 该 tag 对应的预期 SHA——用于供应链验证
tagPrefix 单体仓库 tag 的可选前缀(例如 op-node/v1.16.11 中的 op-node
owner / repo GitHub 组织名和仓库名
tracking 版本选择策略:releasetagbranch

tracking 字段决定了更新器认为哪些上游版本是有效的:

  • release:仅限稳定版本(不含预发布后缀)。当前四个依赖均使用此模式。
  • tag:稳定版本加上 Release Candidate(-rc1-rc.2)。适用于跟踪预发布版本链。
  • branch:跟踪指定分支上的最新提交,完全忽略 tag。通过 branch 字段指定要跟踪的分支。
flowchart TD
    T{"tracking mode"} -->|release| R["Only stable versions<br/>v0.7.0 ✅<br/>v0.7.1-rc1 ❌<br/>v0.7.1-synctest.0 ❌"]
    T -->|tag| RC["Stable + RC versions<br/>v0.7.0 ✅<br/>v0.7.1-rc1 ✅<br/>v0.7.1-synctest.0 ❌"]
    T -->|branch| BR["Latest commit on branch<br/>No tag filtering<br/>Tracks HEAD of branch"]

跨四种格式的版本解析

四个上游依赖使用四种不同的版本规范。version.go 通过一套标准化流水线统一处理。

依赖项 示例 Tag 格式
base_reth_node v0.7.0 标准 v 前缀 semver
nethermind 1.36.2 裸 semver(无 v 前缀)
op_geth v1.101702.0 v 前缀,但次版本号较为特殊
op_node op-node/v1.16.11 单体仓库前缀 tag

ParseVersion 函数通过三个步骤来处理这些差异:

flowchart LR
    A["Input:<br/>op-node/v1.16.11-rc1"] -->|"Step 1: Strip prefix"| B["v1.16.11-rc1"]
    B -->|"Step 2: Normalize RC"| C["v1.16.11-rc.1"]
    C -->|"Step 3: Parse semver"| D["Version{1, 16, 11, rc.1}"]

第一步:去除 tag 前缀。 如果配置了 tagPrefix,则连同后面的斜杠一起移除。op-node/v1.16.11 变为 v1.16.11

第二步:规范化 RC 格式。 normalizeRCFormat 函数使用正则表达式,将所有 RC 格式变体统一转换为 semver 兼容形式:

var rcPattern = regexp.MustCompile(`(?i)-rc[.-]?(\d+)`)

func normalizeRCFormat(version string) string {
    return rcPattern.ReplaceAllString(version, "-rc.$1")
}

这一个正则表达式就能处理 -rc1-rc.1-rc-1-RC1,统一转换为 -rc.1(?i) 标志使其大小写不敏感。version_test.go 中的测试覆盖了所有这些变体。

第三步:使用 Masterminds/semver 解析。 标准化后的字符串传递给 Masterminds 库的 semver.NewVersion() 函数,该函数会自动处理 v 前缀并生成结构化的版本对象。

提示: op-geth 的 v1.101702.0 格式看起来奇怪,但它是合法的 semver。次版本号 101702 编码了 Optimism 协议版本信息。semver 库将其视为普通整数处理,无需任何特殊逻辑。

版本比较与防降级保护

版本解析完成后,更新器需要判断新版本是否真的是一次升级。ValidateVersionUpgrade 函数强制执行严格的单向前进规则:

func ValidateVersionUpgrade(currentTag, newTag, tagPrefix string) error {
    if currentTag == "" {
        _, err := ParseVersion(newTag, tagPrefix)
        return err
    }
    currentVersion, err := ParseVersion(currentTag, tagPrefix)
    if err != nil {
        _, newErr := ParseVersion(newTag, tagPrefix)
        return newErr
    }
    newVersion, err := ParseVersion(newTag, tagPrefix)
    if err != nil {
        return fmt.Errorf("new version %q is not a valid semver: %w", newTag, err)
    }
    if newVersion.LessThan(currentVersion) {
        return fmt.Errorf("version downgrade detected: %s -> %s", currentTag, newTag)
    }
    return nil
}

防降级逻辑简洁,但对边界情况考虑周全:

  • 当前版本为空:任何合法的新版本均可接受(用于首次初始化)
  • 当前版本无法解析:只要新 tag 是合法的 semver,就允许更新
  • RC 版本排序:RC 版本在排序上低于对应的稳定版本(v0.3.0-rc2 < v0.3.0
flowchart TD
    A["v0.2.2 (current)"] --> B{"Is v0.3.0-rc1 an upgrade?"}
    B -->|"0.3.0-rc1 > 0.2.2 ✅"| C["Valid upgrade"]
    C --> D{"Is v0.3.0-rc2 an upgrade?"}
    D -->|"0.3.0-rc2 > 0.3.0-rc1 ✅"| E["Valid upgrade"]
    E --> F{"Is v0.3.0 an upgrade?"}
    F -->|"0.3.0 > 0.3.0-rc2 ✅"| G["Valid: RC → stable"]
    G --> H{"Is v0.3.0-rc2 an upgrade?"}
    H -->|"0.3.0-rc2 < 0.3.0 ❌"| I["BLOCKED: downgrade!"]
    style I fill:#ff6666

version_test.go 第 71-122 行 的测试套件非常完整,覆盖了升级路径、降级场景、无法解析版本的边界情况,乃至跨前缀验证(防止将 rollup-boost/v0.7.11 "升级"为 websocket-proxy/v0.0.2)。

更新流程:从 GitHub API 到 Pull Request

主要的更新逻辑位于 dependency_updater.go。每个依赖项的处理流程如下:

flowchart TD
    A["Read versions.json"] --> B["For each dependency"]
    B --> C{"tracking mode?"}
    C -->|"release/tag"| D["Paginate GitHub Tags API"]
    C -->|"branch"| E["Get latest commit on branch"]
    D --> F["Filter by tagPrefix"]
    F --> G["Filter by tracking mode<br/>(release-only or release+RC)"]
    G --> H["Validate: is this an upgrade?"]
    H --> I["Find maximum valid version"]
    I --> J{"Version changed?"}
    J -->|Yes| K["Update versions.json"]
    J -->|No| L["Skip dependency"]
    E --> M{"Commit changed?"}
    M -->|Yes| K
    M -->|No| L
    K --> N["Regenerate versions.env"]
    N --> O["Create commit with diff URL"]

getVersionAndCommit 中基于 tag 的更新逻辑设计得相当精巧。它不会在找到第一个有效升级版本时立即返回,而是收集所有页面中的全部有效 tag,再从中找出最大版本:

// Collect all valid tags across all pages, then find the max version
var validTags []*github.RepositoryTag

for {
    tags, resp, err := client.Repositories.ListTags(ctx, owner, repo, options)
    // ... filter by prefix, tracking mode, upgrade validation ...
    if resp.NextPage == 0 {
        break
    }
    options.Page = resp.NextPage
}

// Find the maximum version among valid tags
for _, tag := range validTags {
    if selectedTag == nil {
        selectedTag = tag
        continue
    }
    cmp, _ := CompareVersions(*tag.Name, *selectedTag.Name, tagPrefix)
    if cmp > 0 {
        selectedTag = tag
    }
}

这种"先分页收集、再取最大值"的方式至关重要,原因在于 GitHub Tags API 并不按语义化版本顺序返回 tag,而是按时间或字典序排列。如果只取第一页结果,很可能会遗漏最高版本。

该函数还会为提交信息生成一个 diff URL:https://github.com/{owner}/{repo}/compare/{old_tag}...{new_tag},让 PR 审阅者可以直接查看上游的变更内容。

对于 branch 追踪模式(第 284-307 行),逻辑更简单:查询指定分支上的最新提交,比较 SHA 哈希值。如果提交发生了变化,则生成新旧提交之间的 diff URL。

所有 API 调用都使用 Optimism SDK 中的 retry.Do0 进行了封装(第 100 行),最多重试 3 次,固定间隔 1 秒,以应对 GitHub API 的瞬时故障。

versions.env 生成

更新完 versions.json 后,更新器通过 createVersionsEnv 函数重新生成 versions.env

func createVersionsEnv(repoPath string, dependencies Dependencies) error {
    envLines := []string{}
    for dependency := range dependencies {
        repoUrl := generateGithubRepoUrl(dependencies, dependency) + ".git"
        dependencyPrefix := strings.ToUpper(dependency)
        envLines = append(envLines, fmt.Sprintf("export %s_%s=%s",
            dependencyPrefix, "TAG", dependencies[dependency].Tag))
        envLines = append(envLines, fmt.Sprintf("export %s_%s=%s",
            dependencyPrefix, "COMMIT", dependencies[dependency].Commit))
        envLines = append(envLines, fmt.Sprintf("export %s_%s=%s",
            dependencyPrefix, "REPO", repoUrl))
    }
    slices.Sort(envLines)
    // ... write to file
}

命名规则是确定性的:依赖项的键名转为大写(op_gethOP_GETH),再加上 _TAG_COMMIT_REPO 后缀。写入前按字母顺序排序,这样当只有一个依赖项更新时,diff 会保持整洁。

flowchart LR
    VJ["versions.json<br/>op_geth.tag = v1.101702.0<br/>op_geth.commit = d0734fd..."] -->|createVersionsEnv| VE["versions.env<br/>export OP_GETH_TAG=v1.101702.0<br/>export OP_GETH_COMMIT=d0734fd...<br/>export OP_GETH_REPO=https://..."]
    VE -->|"COPY into Dockerfile"| DF["RUN . /tmp/versions.env && ..."]

提示: versions.jsonversions.env 都会提交到仓库中。.env 文件是从 .json 文件派生出来的产物。如果两者不同步,运行更新器即可重新对齐。永远不要手动编辑 versions.env——始终修改 versions.json,然后重新生成。

Docker 构建中的提交哈希验证

每个 tag 旁边存储的提交哈希不只是参考信息,而是一项主动的安全控制。正如我们在第 3 篇中看到的,每个 Dockerfile 都会验证克隆仓库的 HEAD 是否与预期提交匹配。让我们梳理完整的验证链路:

sequenceDiagram
    participant GH as GitHub Tags API
    participant UP as dependency_updater
    participant VJ as versions.json
    participant DF as Dockerfile (build time)
    participant REPO as Upstream Repo

    UP->>GH: ListTags(owner, repo)
    GH-->>UP: tags with commit SHAs
    UP->>UP: Find max valid version
    UP->>VJ: Write tag + commit SHA
    Note over DF: At build time...
    DF->>REPO: git clone --branch TAG
    DF->>DF: git rev-parse HEAD
    DF->>DF: Compare HEAD == expected COMMIT
    alt Match
        DF->>DF: Proceed to build
    else Mismatch
        DF->>DF: BUILD FAILS
    end

更新器查询 GitHub Tags API 时,每个 tag 对象都包含它所指向的提交 SHA。更新器将该 SHA 写入 versions.json。构建时,Dockerfile 按 tag 克隆仓库,再将 HEAD 与存储的 SHA 进行比对。若不匹配,构建立即失败。

这一机制防范的是一种具体威胁:上游维护者(或遭到入侵的账户)可能将某个 tag 移动到指向另一个提交。若没有 SHA 校验,Dockerfile 会直接构建 tag 当前指向的任意代码。有了这层检查,构建会立刻失败,提醒运维人员注意异常。

通过 GitHub Actions 实现每日自动化

整个更新流水线通过 update-dependencies.yml 全自动运行:

on:
  schedule:
    - cron: '0 13 * * *'  # Daily at 1 PM UTC
  workflow_dispatch:

工作流程很直接:检出仓库、构建 Go 更新器、运行它,并在版本有变化时创建 PR:

flowchart TD
    A["Daily cron at 1 PM UTC"] --> B["Checkout repository"]
    B --> C["cd dependency_updater && go build"]
    C --> D["Run updater with --github-action"]
    D --> E{"Any versions updated?"}
    E -->|Yes| F["Updater writes TITLE and DESC<br/>to GITHUB_OUTPUT"]
    F --> G["peter-evans/create-pull-request<br/>creates PR with diff links"]
    E -->|No| H["Workflow exits cleanly"]

在 GitHub Actions 模式下(--github-action true),更新器使用 GitHub 的多行输出语法将提交标题和描述写入 GITHUB_OUTPUT第 385-407 行)。随后,peter-evans/create-pull-request action 使用更新器的输出创建 PR:

- name: create pull request
  if: ${{ steps.run_dependency_updater.outputs.TITLE != '' }}
  uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
  with:
    title: ${{ steps.run_dependency_updater.outputs.TITLE }}
    body: "${{ steps.run_dependency_updater.outputs.DESC }}"
    branch: run-dependency-updater
    delete-branch: true

PR 标题遵循 chore: updated op-geth, op-node 的格式,正文包含每个更新依赖项的 diff 链接。delete-branch: true 会在合并后自动清理分支。

注意 if 条件:如果更新器的 TITLE 输出为空,则不会创建 PR。这种情况发生在所有依赖项都已是最新版本时。

完整的供应链流水线

退一步来看,完整的供应链安全流水线如下:

  1. 每日触发:GitHub Actions 运行依赖更新器
  2. 更新器:查询 GitHub API → 查找最新版本 → 验证升级合法性 → 捕获提交 SHA → 更新文件 → 创建 PR
  3. PR 审查:人工审阅 diff 链接并批准
  4. 合并:更新后的 versions.jsonversions.env 合入 main 分支
  5. CI 构建:Docker 构建在固定 tag 处克隆并验证提交哈希
  6. 运行时:容器运行经过验证的二进制文件

这条链路的每个环节都包含验证步骤。更新器确保版本不会降级;Dockerfile 验证提交哈希是否匹配;CI 流水线使用固定 action SHA(而非版本 tag)来保障自身的供应链安全。整个系统以每日为周期运行,确保 Base 节点始终与上游保持同步。

下一步

依赖更新器负责创建 PR,但这些 PR 在合并前需要经过验证。在第 5 篇中,我们将深入探讨 CI/CD 流水线——它如何为三种执行客户端跨两种 CPU 架构构建 Docker 镜像,并采用"构建 → 上传摘要 → 合并 manifest"的精妙模式。我们还会了解 asm-keccak 等平台专属优化是如何按条件应用的,以及流水线如何通过固定 action SHA 和 harden-runner 实现纵深防御。