tech.guitarrapc.cóm

Technical updates

GitHub Actionsで利用するリモートアクションやワークフローのファイルを触る

GitHub Actionsの良いところといえば、GitHub Actions Marketplaceにあるアクションを利用できることです。また、ワークフローもGitHubを参照して再利用できます。 では、YAMLで定義したリモートアクションやワークフローはいつ、どこにダウンロードされているのでしょうか?以前同じような内容の記事を書いたのですが、ちょっと手抜きだったのでもう少し細かく見ます。

リモートアクションがダウンロードされる流れ

リモートアクション1のダウンロードと保持されるパスはGitHub Actions ドキュメントには記載されていません。このため、将来的な挙動は変わる可能性があります。

リモートアクションは次の流れで取り扱われています。少なくともこの挙動は2023年時点から2025年3月現時点まで変わっていないようです。

  1. GitHub Actions YAMLで、リモートアクションを指定する
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    # 例: リモートアクションを指定する
    - uses: actions/checkout@v4
  1. ジョブ実行時に自動実行されるSet up jobでリモートアクションをダウンロード
Prepare workflow directory
Prepare all required actions
Getting action download info
Download action repository 'actions/checkout@v4' (SHA:11bd71901bbe5b1630ceea73d27597364c9af683)
Complete job name: action

alt text

  1. ダウンロードされたリモートアクションは、/home/runner/work/_actions/{OWNER}/{REPO}/{REF}に配置される
# actions/checkout@v4 なら次のパス
/home/runner/work/_actions/actions/checkout/v4

リモートアクションのダウンロードパスを確認する

通常リモートアクションを利用するときは、リモートアクションがaction.ymlで提供するAPIしか触りません。実際それが目的なのでそれでいいです。例えば、actions/checkoutであれば、次のようにwithセクションで指定します。

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    # 例: リモートアクションの提供するAPIを利用する
    - uses: actions/checkout@v4
      with:
        fetch-depth: 0
        sparse-checkout: |
          .github

今回示すダウンロードされたリモートアクションのフォルダはどのようにダウンロードされるか見てみましょう。例えば次のようなワークフローを作成します。

name: remote actions download path
on:
  workflow_dispatch:

jobs:
  action:
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - uses: actions/checkout@v4
      - name: Downloaded actions from the marketplace
        run: ls -l /home/runner/work/_actions
      - name: See actions download path
        run: ls -l /home/runner/work/_actions/actions/checkout/
      - name: See actions download contents
        run: ls -lR /home/runner/work/_actions/actions/checkout/v4
      - name: Cat action's src/main.ts
        run: cat /home/runner/work/_actions/actions/checkout/v4/src/main.ts

実行ログを順に見てみましょう。

まずはDownloaded actions from the marketplaceです。これを見ると、ジョブで利用するリモートアクションが/home/runner/work/_actionsにダウンロードされていることがわかります。複数のリモートアクションを1ジョブで利用していれば、それぞれがダウンロードされていることがわかります。

$ ls -l /home/runner/work/_actions
total 4
drwxr-xr-x 3 runner docker 4096 Mar  4 18:36 actions

次はSee actions download pathで、リモートアクションのREFが何になるのかを確認します。パスを見るとactions/checkout@REFのREFで指定したv4というフォルダが作成されていることがわかります。REFにはブランチも指定できるので、foo/barというブランチを指定した場合は、/home/runner/work/_actions/actions/checkout/foo/barというフォルダが作成されます。

$ ls -l /home/runner/work/_actions/actions/checkout/
total 8
drwxr-xr-x 9 runner docker 4096 Mar  4 18:36 v4
-rw-r--r-- 1 runner docker   19 Mar  4 18:36 v4.completed

実際にダウンロードされたフォルダの中身をSee actions download contentsでみると、このREFのGitHubリポジトリの内容がそのままダウンロードされていることがわかります。この例では、actions/checkout@v4v4ブランチの内容がダウンロードされています。これが私には衝撃でした。リモートアクションはリモートアクションのAPIでしか触れないようにマジックでも使ってるのかと思っていましたが、普通にダウンロードされています。2

$ ls -lR /home/runner/work/_actions/actions/checkout/v4

/home/runner/work/_actions/actions/checkout/v4:
total 332
-rw-r--r-- 1 runner docker   8062 Oct 23 14:24 CHANGELOG.md
-rw-r--r-- 1 runner docker     26 Oct 23 14:24 CODEOWNERS
-rw-r--r-- 1 runner docker   1297 Oct 23 14:24 CONTRIBUTING.md
-rw-r--r-- 1 runner docker   1097 Oct 23 14:24 LICENSE
-rw-r--r-- 1 runner docker   9359 Oct 23 14:24 README.md
drwxr-xr-x 2 runner docker   4096 Mar  4 18:36 __test__
-rw-r--r-- 1 runner docker   4593 Oct 23 14:24 action.yml
drwxr-xr-x 2 runner docker   4096 Mar  4 18:36 adrs
drwxr-xr-x 2 runner docker   4096 Mar  4 18:36 dist
drwxr-xr-x 2 runner docker   4096 Mar  4 18:36 images
-rw-r--r-- 1 runner docker    253 Oct 23 14:24 jest.config.js
-rw-r--r-- 1 runner docker 264924 Oct 23 14:24 package-lock.json
-rw-r--r-- 1 runner docker   1500 Oct 23 14:24 package.json
drwxr-xr-x 3 runner docker   4096 Mar  4 18:36 src
-rw-r--r-- 1 runner docker    334 Oct 23 14:24 tsconfig.json

/home/runner/work/_actions/actions/checkout/v4/__test__:
total 144
-rw-r--r-- 1 runner docker 28440 Oct 23 14:24 git-auth-helper.test.ts
-rw-r--r-- 1 runner docker  9474 Oct 23 14:24 git-command-manager.test.ts
-rw-r--r-- 1 runner docker 14839 Oct 23 14:24 git-directory-helper.test.ts
-rw-r--r-- 1 runner docker  4870 Oct 23 14:24 git-version.test.ts
-rw-r--r-- 1 runner docker  5032 Oct 23 14:24 input-helper.test.ts
-rwxr-xr-x 1 runner docker   210 Oct 23 14:24 modify-work-tree.sh
-rwxr-xr-x 1 runner docker   134 Oct 23 14:24 override-git-version.cmd
-rwxr-xr-x 1 runner docker   180 Oct 23 14:24 override-git-version.sh
-rw-r--r-- 1 runner docker  5982 Oct 23 14:24 ref-helper.test.ts
-rw-r--r-- 1 runner docker  2385 Oct 23 14:24 retry-helper.test.ts
-rw-r--r-- 1 runner docker  3450 Oct 23 14:24 url-helper.test.ts
-rwxr-xr-x 1 runner docker  1195 Oct 23 14:24 verify-basic.sh
-rwxr-xr-x 1 runner docker   353 Oct 23 14:24 verify-clean.sh
-rwxr-xr-x 1 runner docker   445 Oct 23 14:24 verify-fetch-filter.sh
-rwxr-xr-x 1 runner docker   216 Oct 23 14:24 verify-lfs.sh
-rwxr-xr-x 1 runner docker   596 Oct 23 14:24 verify-no-unstaged-changes.sh
-rwxr-xr-x 1 runner docker   258 Oct 23 14:24 verify-side-by-side.sh
-rwxr-xr-x 1 runner docker  1217 Oct 23 14:24 verify-sparse-checkout-non-cone-mode.sh
-rwxr-xr-x 1 runner docker  1352 Oct 23 14:24 verify-sparse-checkout.sh
-rwxr-xr-x 1 runner docker   263 Oct 23 14:24 verify-submodules-false.sh
-rwxr-xr-x 1 runner docker   739 Oct 23 14:24 verify-submodules-recursive.sh
-rwxr-xr-x 1 runner docker   692 Oct 23 14:24 verify-submodules-true.sh

/home/runner/work/_actions/actions/checkout/v4/adrs:
# 省略...

最後に、Cat action's src/main.tsで、リモートアクションの中身を確認します。この例では、actions/checkout@v4v4ブランチのsrc/main.tsを確誋します。

$ cat /home/runner/work/_actions/actions/checkout/v4/src/main.ts
import * as core from '@actions/core'
import * as coreCommand from '@actions/core/lib/command'
import * as gitSourceProvider from './git-source-provider'
import * as inputHelper from './input-helper'
import * as path from 'path'
import * as stateHelper from './state-helper'

async function run(): Promise<void> {
  try {
    const sourceSettings = await inputHelper.getInputs()

    try {
      // Register problem matcher
      coreCommand.issueCommand(
        'add-matcher',
        {},
        path.join(__dirname, 'problem-matcher.json')
      )

      // Get sources
      await gitSourceProvider.getSource(sourceSettings)
      core.setOutput('ref', sourceSettings.ref)
    } finally {
      // Unregister problem matcher
      coreCommand.issueCommand('remove-matcher', {owner: 'checkout-git'}, '')
    }
  } catch (error) {
    core.setFailed(`${(error as any)?.message ?? error}`)
  }
}

async function cleanup(): Promise<void> {
  try {
    await gitSourceProvider.cleanup(stateHelper.RepositoryPath)
  } catch (error) {
    core.warning(`${(error as any)?.message ?? error}`)
  }
}

// Main
if (!stateHelper.IsPost) {
  run()
}
// Post
else {
  cleanup()
}

挙動を試す

いくつか挙動を試します。

actions/runnerコード上の定義

runnerコード上はテストコードでaction_pathとして渡すときに定義されています。

// これ
inputGitHubContext["action_path"] = new StringContextData("/home/username/Projects/work/runner/_layout/_work/_actions/owner/composite/main");
inputGitHubContext["action"] = new StringContextData("__owner_composite");
inputGitHubContext["api_url"] = new StringContextData("https://api.github.com/custom/path");
inputGitHubContext["env"] = new StringContextData("/home/username/Projects/work/runner/_layout/_work/_temp/_runner_file_commands/set_env_265698aa-7f38-40f5-9316-5c01a3153672");
inputGitHubContext["path"] = new StringContextData("/home/username/Projects/work/runner/_layout/_work/_temp/_runner_file_commands/add_path_265698aa-7f38-40f5-9316-5c01a3153672");
inputGitHubContext["event_path"] = new StringContextData("/home/username/Projects/work/runner/_layout/_work/_temp/_github_workflow/event.json");
inputGitHubContext["repository"] = new StringContextData("owner/repo-name");
inputGitHubContext["run_id"] = new StringContextData("2033211332");
inputGitHubContext["workflow"] = new StringContextData("Name of Workflow");
inputGitHubContext["workspace"] = new StringContextData("/home/username/Projects/work/runner/_layout/_work/step-order/step-order");
inputeRunnerContext["temp"] = new StringContextData("/home/username/Projects/work/runner/_layout/_work/_temp");
inputeRunnerContext["tool_cache"] = new StringContextData("/home/username/Projects/work/runner/_layout/_work/_tool");

GitHub Actions Contextのaction_pathセットされています。ただ、GitHubコンテキストや環境変数には現れないんですよね

// Set GITHUB_ACTION_PATH
step.ExecutionContext.SetGitHubContext("action_path", ActionDirectory);

リモートアクションのREFは動的に指定できない

この流れの中で、いじりたくなったのがダウンロードされるリモートアクションの起点であるuses: actions/checkout@v4の部分です。幸いにもここは静的でないと機能しないように縛りがかかっています。例えばinputsでREFを得て、ワークフローで取得するように書いてもワークフローは不正として実行されません。これは悪意のある攻撃者による動的な攻撃がしにくいことを示すので良い点です。

inputs:
  ref:
    description: 'The ref to checkout. Default is the default branch of the repository.'
    required: false
    default: ${{ github.ref }}

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    # usesは静的でないとエラーになる
    - uses: actions/checkout@${{ inputs.ref }}

リモートアクションのファイルに無制限で触れる

この方法は、リモートアクションのリポジトリにある内容を無制限に触れます。リモートアクションのリポジトリに何か設定ファイルなどをおいて、利用側で読ませるといったこともできます。サブモジュールでもいいじゃないかという指摘もあるしれませんが、サブモジュールは維持・更新重いんですよね。リモートアクションv1のこのファイルはあることが期待できるので読みたいだけ、という時にcurlなどよりも簡単に利用できるので便利です。

無制限にアクセスできるので懸念もあります。例えば、有名なリポジトリに誤認させるオーナー名、リポジトリを用意すれば利用を誤認させられます。action.ymlで直接悪さする処理を書いたり、このファイルパスを利用してさらに仕込みを入れるケースもあり得るでしょう。こういった挙動は十分懸念されるべきです。

従来からいわれている通り3rdパーティのリモートアクションはハッシュタグで指定する安全対策は、シンプルですがハッシュ衝突がない限りは有効な手法と考えられます。

参考


  1. リモートアクションとリモートワークフローの両方を指しますが、簡単のためリモートアクションと記載します。
  2. 同時にこの仕組みは悪意を持った攻撃に対して脆弱性を持ちえるとも考えています。