我在 GitHub Actions 中运行的是谁的代码?

一周前,有人向 tj-actions/changed-files GitHub Action 添加了恶意代码。 如果您使用了被入侵的 action,它会将 secrets 泄露到您的构建日志中。 这些构建日志对于公共仓库是公开的,所以任何人都可以看到您的 secrets。 太可怕了!

可变引用 vs 不可变引用

这种攻击之所以成为可能,是因为在 GitHub Actions workflow 中引用 tags 是一种常见做法,例如:

jobs:
 changed_files:
  ...
  steps:
   - name: Get changed files
    id: changed-files
    uses: tj-actions/changed-files@v2
   ...

乍一看,这似乎是对已发布的 "version 2" 的 action 的不可变引用,但实际上这是一个可变的 Git tag。 如果有人更改了 tj-actions/changed-files 仓库中的 v2 tag,使其指向不同的 commit,那么下次运行此 action 时,将会运行不同的代码。

如果您指定一个 Git commit ID(例如 a5b3abf),这是一个不可变的引用,每次都将运行相同的代码。

Tags vs commit IDs 是便利性和安全性之间的一种权衡。 指定确切的 commit ID 意味着代码不会意外更改,但 tags 更易于阅读和比较。

我是否有任何可变引用?

我并不担心这次特定的攻击,因为我没有使用 tj-actions,但我很好奇我正在使用的其他 GitHub Actions 是什么。 我在所有仓库的本地克隆所在的文件夹中运行了一个简短的 shell 脚本:

find . -path '*/.github/workflows/*' -type f -name '*.yml' -print0 \
 | xargs -0 grep --no-filename "uses:" \
 | sed 's/\- uses:/uses:/g' \
 | tr '"' ' ' \
 | awk '{print $2}' \
 | sed 's/\r//g' \
 | sort \
 | uniq --count \
 | sort --numeric-sort

这会打印出我正在使用的所有 action 的计数。 这是输出的片段:

 1 hashicorp/setup-terraform@v3
 2 dtolnay/rust-toolchain@v1
 2 taiki-e/create-gh-release-action@v1
 2 taiki-e/upload-rust-binary-action@v1
 4 actions/setup-python@v4
 6 actions/cache@v4
 9 ruby/setup-ruby@v1
31 actions/setup-python@v5
58 actions/checkout@v4

我浏览了整个列表,并考虑了我对每个 action 及其作者的信任程度。

我对我的列表感觉很好。 我的大多数 action 来自大型组织,其余的是一些特定于我的 Rust 命令行工具的 action,这些工具是非关键的玩具,GitHub 仓库被入侵的影响相对较小。

此脚本的工作原理

这是 Unix pipelines 的一个经典用法,我将一堆内置的文本处理工具链接在一起。 让我们逐步了解它是如何工作的。

find . -path '*/.github/workflows/*' -type f -name '*.yml' -print0

这会查找任何 GitHub Actions workflow 文件——任何名称以 .yml 结尾的文件,位于像 .github/workflows/ 这样的文件夹中。 它会打印一个文件名列表,例如: ./alexwlchan.net/.github/workflows/build_site.yml ./books.alexwlchan.net/.github/workflows/build_site.yml ./concurrently/.github/workflows/main.yml

它会使用一个空字节 (\0) 将它们分隔开来打印,这使得在下一步中可以分割文件名。 默认情况下它使用换行符,但是空字节更安全一些,以防您有包含换行符的文件名。

我知道我总是使用 .yml 作为文件扩展名,但是如果您有时使用 .yaml,您可以将 -name '*.yml' 替换为 \( -name '*.yml' -o -name '*.yaml' \)

我有很多本地仓库是开源项目的克隆,而不是我的代码,所以我不太关心它们正在使用的 GitHub Actions。 我通过添加额外的 -path 规则来排除它们,例如 -not -path './cpython/*'.

xargs -0 grep --no-filename "uses:"

然后我们使用 xargs 逐个遍历文件名。 -0 标志告诉它以空字节分割,然后它运行 grep 来查找包含 "uses:" 的行——这是在您的 workflow 文件中使用 action 的方式。

--no-filename 选项意味着这只打印匹配的行,而不打印它来自的文件的名称。 不是我所有的文件都以一致的方式格式化或缩进,所以输出非常混乱:

- uses: actions/checkout@v4 uses: "actions/cache@v4" uses: ruby/setup-ruby@v1

sed 's/\- uses:/uses:/g' \

有时有一个前导连字符,有时没有——这取决于 uses: 是否是 YAML 字典中的第一个 key。 这个 sed 命令将 "- uses:" 替换为 "uses:",开始整理数据。

uses: actions/checkout@v4 uses: actions/cache@v4 uses: ruby/setup-ruby@v1

我知道 sed 是一个非常强大的工具,可以对文本进行更改,但我只知道几个简单的命令,比如这个替换文本的模式:sed 's/old/new/g'

tr '"' ' '

有时 action 的名称被引用,有时没有。 这个命令从输出中删除任何双引号。

uses: actions/checkout@v4 uses: actions/cache@v4 uses: ruby/setup-ruby@v1

现在我正在写这篇文章,我想到我也可以使用 sed 来进行这个替换。 我选择使用 tr,因为我已经使用它很长时间了,并且对于进行单字符替换来说,语法更简单:tr '<oldchar>' '<newchar>'

awk '{print $2}'

这会在空格上分割字符串,并打印第二个 token,即 action 的名称:

actions/checkout@v4 actions/cache@v4 ruby/setup-ruby@v1

awk 是另一个强大的文本实用程序,我从未正确地学习过——我只知道如何在字符串中打印第 n 个单词。 它有很多我从未尝试过的模式匹配功能。

sed 's/\r//g'

我有一些 workflow 文件使用回车符 (\r),并且这些文件包含在 awk 输出中。 这个命令去掉了它们,这使得数据对于最后一步更加一致。

sort | uniq --count | sort --numeric-sort

这会对行进行排序,以便相同的行相邻,然后它会分组并计算行数,最后重新排序,将最频繁的行放在底部。

我将此设置为一个名为 tally 的 shell alias。

6 actions/cache@v4 9 ruby/setup-ruby@v1 59 actions/checkout@v4

这种循序渐进的方法是我构建 Unix 文本 pipelines 的方式:我可以一次编写一个步骤,并逐步改进和调整输出,直到得到我想要的结果。 有很多方法可以做到这一点,并且因为这是一个我将使用一次然后丢弃的脚本,我不必太担心以“最纯粹”的方式来做——只要它能得到正确的结果,那就足够了。

如果您使用 GitHub Actions,您可能需要使用此脚本来检查您自己的 action,并查看您正在使用的 action。 但更重要的是,我建议您熟悉 Unix 文本处理工具和 pipelines——即使在人工智能时代,它们仍然是组合一次性脚本来处理数据的强大而灵活的方式。

这个网站由 Alex Chan (they/she) 用 ❤︎ 创建。 它以 CC BY 4.0 许可发布。

有疑问、意见或想说声谢谢? alex@alexwlchan.net

想要一份关于我正在做的事情的每月摘要?