GitHub Actions第三方Action改用完整SHA

结论:凡是工作流会从其他 GitHub 仓库按 ref 拉取的 Action,都应把 @main、@v4、@v4.1.0 改成完整的 40 位 commit SHA,版本号另放在行尾注释里。真正决定执行内容的是 SHA,注释只帮助维护者识别版本;固定之后,再通过 Dependabot 或受控 PR 接收上游更新。

# 修改前
- uses: owner/action@v4

# 修改后,尖括号内容需要替换为真实的完整 SHA
- uses: owner/action@<完整40位commit SHA> # v4.x.y

第三方 Action 会在 Runner 上执行代码,并可能接触当前 job 的 GITHUB_TOKEN、显式传入的 Secrets 和运行环境中的凭据。固定 SHA 能阻止上游移动 Tag 后静默换掉执行内容,但不能把一个已经审核过的提交变成安全提交,也不能替代权限收口。

Hugo van Kemenade 的《Lock Your GitHub Actions》是这次 Hacker News 讨论的延伸阅读。在仓库中,需要决定哪些引用应该锁定,以及锁定后怎样继续更新。

哪些引用需要改,哪些是例外

@main 跟随分支最新提交,@v4 跟随一个可移动的主版本 Tag,@v4.1.0 虽然更具体,但仍然是 Tag。没有额外的不可变约束时,分支和 Tag 都可能被维护者移动或重写;完整 SHA 才直接对应某次提交。

引用方式 实际指向 处理建议
owner/action@main 分支当前提交 改为完整 commit SHA
owner/action@v4 主版本 Tag 改为完整 commit SHA,保留版本注释
owner/action@v4.1.0 精确版本 Tag 仍建议改为完整 commit SHA
owner/action@<40位 SHA> 固定提交 保留,后续通过 PR 更新
uses: ./path/to/action 当前仓库内的本地 Action 不需要上游 SHA
uses: owner/repo/.github/workflows/build.yml@ref 远程可复用工作流 可以锁定到仓库的完整 SHA
uses: docker://image:tag 容器镜像 不适用 Git commit SHA,需要单独的镜像管理策略

工作流通常放在 .github/workflows/,但不能只检查这个目录。Composite Action 的 action.yml 或 action.yaml 里也可能有 uses,工作流本身还同时存在步骤级、job 级和 workflow 级引用。

在仓库根目录执行:

rg --hidden -n --glob '*.yml' --glob '*.yaml' 'uses:'

--hidden 用来包含 .github 目录,*.yml 和 *.yaml 则能覆盖工作流及 Action 清单。检查结果时,要先排除 uses: ./... 和 docker://...,再确认每个远程 owner/repo@ref 的实际来源。

“第三方”也需要在仓库政策中定义清楚。actions/* 这类由 GitHub 维护的仓库是否纳入范围,可以由组织规则决定;如果没有明确豁免,把所有远程 Action 一并固定更简单,也更容易用扫描器统一检查。一个公开仓库中已合并的改造记录同时固定了 actions/checkout、actions/setup-python、actions/upload-artifact、actions/rust-cache 和发布 Action,而不是只处理社区项目。

怎样找到并替换正确 SHA

先建立“owner/repo + 当前 ref + 目标 SHA”的映射,不要对 @v4 做全局替换。不同仓库里的 @v4 指向不同提交;同一个仓库里的 actions/checkout@v4 和 actions/setup-python@v4 也不是同一个 SHA。

下面的命令适用于 Bash。先把 OWNER/REPO 换成目标仓库,v4 换成实际引用:

sha="$(gh api "repos/OWNER/REPO/commits/v4" --jq '.sha')"
[[ "$sha" =~ ^[0-9a-f]{40}$ ]] || exit 1

GitHub API 的 commits 接口会把这个引用解析到当前对应的提交,gh api 的调用形式可按 GitHub CLI 手册核对。 如果只知道主版本 Tag,还要找出它当时对应哪个精确发布版本:

gh api --paginate "repos/OWNER/REPO/tags?per_page=100" \
  --jq '.[] | [.name, .commit.sha] | @tsv'

再用 Git 独立解析一次远程引用:

git ls-remote https://github.com/OWNER/REPO.git 'refs/tags/v4*'

这里的宽匹配可能同时列出 v4、v4.1.0 等多个 Tag。定位到准备采用的精确版本后,可以缩小范围:

git ls-remote https://github.com/OWNER/REPO.git \
  'refs/tags/v4.1.0' 'refs/tags/v4.1.0^{}'

Annotated Tag 有一个容易踩的坑:普通 Tag 行指向 Tag 对象,而带 ^{} 的行才是剥离 Tag 对象后得到的 commit。两者的 SHA 都是 40 位十六进制值,只检查长度无法区分。对于 Annotated Tag,应锁定 ^{} 对应的 commit;Lightweight Tag 没有额外的 peeled 条目,唯一的 Tag 行就是目标 commit。

因此,GitHub API 得到的 commit SHA 应与 git ls-remote 中对应的 peeled commit 比较,而不是与 Annotated Tag 对象本身比较。如果结果不一致,先检查两次查询是否用了相同 Tag,以及查询期间 Tag 是否发生了移动:

gh api "repos/OWNER/REPO/commits/$sha" --jq '.sha'

API 和 ls-remote 一致,只能证明两条查询解析到了同一个提交;还必须确认它确实对应计划采用的发布版本,而不是上游仓库的任意最新提交。确认后再改写工作流:

- uses: owner/action@<完整40位commit SHA> # v4.x.y

版本注释最好写精确的语义版本,例如 v4.1.0,而不只写 v4。注释不会影响执行;如果以后因为排版原因删掉它,Runner 仍然只会执行 SHA 指定的提交。

还有一个常见混淆:Action 上游仓库的 commit SHA,与当前项目自己的 github.sha 完全不同。在 pull_request 事件中,github.sha 通常是 PR 的合并提交,github.event.pull_request.head.sha 才是 PR 源分支提交;两者都不能代替第三方 Action 的上游 SHA。

批量迁移时,可以让映射文件按完整的 owner/repo@ref 匹配。下面是制表符分隔的 TSV 模板,需要先把占位内容替换为真实值:

action_ref  sha comment
actions/checkout@v4 <40位SHA>    v4.x.y
actions/setup-python@v5 <40位SHA>    v5.x.y

下面的 Python 3 脚本只处理已经存在于映射表中的完整 action_ref,不会把所有 @v4 替换成同一个 SHA。它处理已纳入 Git 跟踪的常规 YAML 写法;带引号或特殊格式的行仍应结合 git diff 复核。

python3 - <<'PY'
import csv
import pathlib
import re
import subprocess

mapping = {}

with open("action-sha.tsv", encoding="utf-8") as f:
    for row in csv.reader(f, delimiter="\t"):
        if not row or row[0].startswith("#") or row[0] == "action_ref":
            continue
        action_ref, sha = row[:2]
        label = row[2] if len(row) > 2 else ""
        if not re.fullmatch(r"[0-9a-f]{40}", sha):
            raise SystemExit(f"invalid SHA: {sha}")
        mapping[action_ref] = (sha, label)

pattern = re.compile(
    r"^(?P<head>\s*(?:-\s+)?uses:\s*)"
    r"(?P<quote>['\"]?)"
    r"(?P<action>[^@\s'\"#]+)"
    r"@(?P<ref>[^\s#'\"]+)"
    r"(?P=quote)"
    r"(?P<comment>[ \t]*#.*)?$",
    re.MULTILINE,
)

files = subprocess.check_output(
    ["git", "ls-files", "--", "*.yml", "*.yaml"],
    text=True,
).splitlines()

for name in files:
    path = pathlib.Path(name)
    text = path.read_text(encoding="utf-8")

    def replace(match):
        key = f"{match.group('action')}@{match.group('ref')}"
        if key not in mapping:
            return match.group(0)
        sha, label = mapping[key]
        comment = f"  # {label}" if label else ""
        quote = match.group("quote")
        return (
            f"{match.group('head')}{quote}"
            f"{match.group('action')}@{sha}{quote}{comment}"
        )

    updated = pattern.sub(replace, text)
    if updated != text:
        path.write_text(updated, encoding="utf-8")
PY

git diff --check
git diff

不同 Action 必须使用各自的 SHA。公开改造记录中的快照如下;这些值用于说明迁移方式,不代表当前上游最新版本,实际项目仍应重新解析目标版本。

Action 原引用 改造记录中的 SHA
actions/checkout @v4 11d5960a326750d5838078e36cf38b85af677262
actions/setup-python @v5 a26af69be951a213d495a4c3e4e4022e16d87065
actions/upload-artifact @v4 ea165f8d65b6e75b540449e92b4886f43607fa02
dtolnay/rust-toolchain @stable 686976e191b89faba57d3206551f0f330d8cb249
Swatinem/rust-cache @v2 49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c
softprops/action-gh-release @v2 3bb12739c298aeb8a4eeaf626c5b8d85266b0e65

怎样持续升级并限制权限

SHA 固定解决的是“执行哪个提交”,不是“以后不再更新”。Dependabot 可以继续检查 github-actions 生态并提交 SHA 升级 PR,让更新继续走代码评审流程。

version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/.github/workflows"
    schedule:
      interval: "weekly"

升级 PR 到达后,应核对新旧 SHA、对应版本、上游发布说明和 Action 行为变化。Dependabot 的检查通过只能说明依赖关系发生变化,不能替代对权限扩大、新增网络请求或发布行为变化的审查。版本注释也应与新 SHA 对应的发布版本保持一致。

Dependabot 负责发现升级,Zizmor 一类审计工具负责检查仓库里是否重新出现浮动引用,两者的用途不同。 可以按当前 Zizmor 官方文档确认安装和扫描参数,常见的仓库根目录扫描形式是:

zizmor .

具体规则名称、输出和退出行为可能随版本变化,不宜写死到 CI 脚本里。如果 Zizmor 本身也通过 GitHub Action 运行,这个用于审计的 Action 同样需要固定到完整 SHA。

权限必须同时收口:先为工作流声明普通任务所需的最小权限,只有发布等特定 job 才提升:

permissions:
  contents: read

jobs:
  publish:
    permissions:
      contents: write
    steps:
      - uses: owner/action@<完整40位commit SHA> # v4.x.y

完全不需要 GITHUB_TOKEN 的 job 可以使用 permissions: {};需要向云平台换取 OIDC 凭据时,才在对应 job 增加 id-token: write。不要依赖仓库或组织当前设置的默认权限。

checkout 完成代码拉取后,如果后续步骤不再使用 Git 凭据,可以关闭凭据持久化:

- uses: actions/checkout@<完整40位commit SHA> # v4.x.y
  with:
    persist-credentials: false

这项设置只处理 checkout 写入本地 Git 配置的凭据,不等于清空 job 中的其他 Secrets。部署密码、令牌和云密钥仍应放在 GitHub Secrets 中,不能写进 YAML 或提交到仓库。

如果可疑的浮动 Action 已经运行过,之后再锁定 SHA 不能撤销已经发生的暴露。此时应先暂停相关流程,轮换可能泄露的长期凭据,检查历史运行日志、仓库提交和发布等变更,再扫描所有工作流,确认是否还有其他浮动引用。是否需要处理 GITHUB_TOKEN,则要根据当时的 event、job 权限和 Secrets 注入范围判断。

完整 SHA 只固定了直接引用的 Action 提交。它不会自动修补已锁定提交中的漏洞,也不能保证 Composite Action 内部调用的其他 Action、运行时下载的依赖或 Docker 镜像同样不可变;脚本注入和 Secrets 暴露也需要单独治理。 因此,SHA 固定应与版本注释、持续升级、CI 审计、最小权限和异常处置一起维护,而不是作为单独的一条 YAML 规则。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容