---
title: "GitHub Actions の workflow を 4 項目で安全側に倒した"
description: "org 配下の workflow を `defaults.run.shell: bash`、`actions/checkout` の `persist-credentials: false`、サードパーティアクションの SHA pin、`permissions` 最小化の4項目で揃えた経緯と、`git push` を含む `release.yml` や `submodules: true` を併用する workflow で「適用しない」を選んだ判断軸を記録します。"
category: "DevOps"
tags: ["GitHub Actions","CI/CD","Security","DevOps"]
publishedAt: "2026-06-17"
lastmod: "2026-08-05"
---

ラビー合同会社のorg配下でworkflowを持つリポジトリに対して、[GitHub Actions](https://docs.github.com/en/actions) のセキュリティ設定を4項目に揃えました。workflow level での `defaults.run.shell: bash` 宣言、[actions/checkout](https://github.com/actions/checkout) への `persist-credentials: false`、サードパーティアクションの commit SHA pin、`permissions` の最小化です。4項目をテンプレートに落とすと最小例はこの形になります。

```yaml
permissions:
  contents: read

defaults:
  run:
    shell: bash

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false
```

ベストプラクティスを当てれば動かなくなる workflow もあります。`git push` を含む[SwiftMilkdown](https://github.com/LabeeHive/SwiftMilkdown) の `release.yml` や、`actions/checkout` で `submodules: true` を併用しているworkflowが該当します。どこで線を引いたかも合わせて記録します。

::card[https://github.com/LabeeHive]

## 対象リポジトリと進め方

ラビー合同会社のorgには、CIだけのworkflowもあれば、[Cloudflare Workers](https://developers.cloudflare.com/workers/) や Cloudflare Pages へのデプロイ、Swiftパッケージのリリースなど、用途の違うworkflowを持つリポジトリが並んでいます。今回はそのworkflow群に対して、4項目を順に当てていきました。

旧運用のメモには「deploy/release専用ワークフローは対象外」というルールが残っていましたが、適用直前にこのルールは撤回しました。**deploy/release こそクレデンシャルを扱うため、`persist-credentials: false` の効果が大きい**からです。CI workflow はテストを動かすだけでGitHubの操作は限定的ですが、release系のworkflowはAPIトークンや署名鍵を扱う上にGitHub操作も多く、デフォルト設定のままだとトークンが各stepから見えるワークスペースに置かれ続けます。守る価値が大きいのは後者の方です。

アーカイブ済みのリポジトリはworkflowが残っていても対象外としました。動いていないものに手を入れる意味はありません。

## `defaults.run.shell: bash` を workflow level で宣言する

1項目目は、workflowのトップレベルで `defaults.run.shell` を `bash` に固定することです。

```yaml
defaults:
  run:
    shell: bash
```

GitHub Actionsの `run:` ステップは、ランナーOSに応じて既定シェルが変わります。Linux/macOSは `bash`（`--noprofile --norc -eo pipefail {0}`）、Windowsは `pwsh` です。workflow levelで `bash` を明示すれば、ステップごとに `shell: bash` を書く重複が消えます。10ステップあるworkflowで毎回 `shell: bash` と並べるのは現実的ではありません。

もう1つの効果は、ランナーOSが将来変わったり、matrixで `windows-latest` を混ぜたりしたときに**暗黙の挙動差を抑えられる**ことです。`bash` の既定オプションには `-eo pipefail` が含まれており、パイプの途中で失敗したコマンドがあればステップ全体が落ちます。`set -e` を忘れた `sh` 系のシェルだと、エラーを握りつぶしたままステップが成功してしまうことがあります。CI が通っているのに途中で失敗していた、というのは一番見つけにくい類のバグです。

うちのorgのworkflowはほぼ `ubuntu-latest` で動いていますが、それでも明示しておく価値があると判断しました。書いてあるworkflowを見て「このスクリプトは `pipefail` 前提だ」とすぐ分かること自体がドキュメントになります。

## `persist-credentials: false` で GITHUB_TOKEN を残さない

2項目目が一番効果の大きい変更で、`actions/checkout` の `persist-credentials` を `false` にすることです。

```yaml
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
  with:
    persist-credentials: false
```

`actions/checkout` の `persist-credentials` 入力の既定値は `true` で、checkoutが終わるとワークスペース上のgit設定にGITHUB_TOKEN（もしくはSSH鍵）が残ります。これは、後続のステップで `git push` や `git fetch` を認証なしに書きたいケースのための仕組みです。v6では `$RUNNER_TEMP` 配下に別ファイルとして保存される設計に変わり、`.git/config` への直接埋め込みは避けられるようになりましたが、それでも**ワークスペースを触れるステップ全てがトークンへ到達可能**という構造は変わりません。

ここで気になるのは、後続のステップで動かすツールやスクリプトです。`npm install`、`pip install`、`pnpm install` といったパッケージインストールは、依存パッケージのインストールスクリプトを実行します。そのなかでgit configを読むものがあれば、トークンが流れる経路ができます。サプライチェーン攻撃の文脈では、悪意のあるパッケージが `.git/config` や `$RUNNER_TEMP` のクレデンシャルを読み出して外部に送る、というシナリオが現実的です。`persist-credentials: false` にしておけば、checkout後のgit設定にトークンは残らず、後続のステップからは見えなくなります。

CI workflowで `git push` が必要なケースは稀です。テスト、lint、型チェック、ビルドが主な仕事で、リポジトリへの書き込みは発生しません。「とりあえず false」を既定にして、必要なケースだけ true に戻す方が健全な運用です。

### `git push` を含む release.yml では false にできない

例外は1つあります。[SwiftMilkdown](https://github.com/LabeeHive/SwiftMilkdown) の `release.yml` は、Swiftパッケージのバージョンタグを切ってリリースする際に workflow 内で `git push` を実行します。タグ付与とコミット追加が両方発生するため、`persist-credentials: false` にすると **`git push` で認証エラー**になります。意図的に未適用にして、PR 本文に「git push のため適用不可」と明記しました。

代替案としては、checkoutを2回呼んで「ビルドだけは `persist-credentials: false`、push する直前のステップだけ `persist-credentials: true`」と分ける書き方があります。SwiftMilkdown の場合、release workflow は短く、依存パッケージのインストールも限定的なため、現状はそのまま既定動作を許容しています。判断のラインは「サプライチェーン由来でトークンが盗まれるリスクと、checkoutを2回呼ぶ複雑さのどちらが大きいか」です。

### `submodules: true` を併用する workflow

もう1つ気にしたのが、`actions/checkout` に `submodules: true` を併用するworkflowです。submodule が public なら `persist-credentials: false` でもチェックアウトは成功しますが、private な submodule では認証ができず失敗します。適用時点では現状の submodule が public だったので問題は出ませんでしたが、将来 private に切り替えたときに気づけるよう、PR 本文に「private submodule に切り替えた場合は CI が失敗する」と注記しました。動いた直後ではなく、後から仕様変更が起きたときに気づける形で残しておく方が安全です。

## SHA pin でサプライチェーンを切る

3項目目は、サードパーティアクションを commit SHA で pin することです。

```yaml
# 望ましい
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

# 望ましくない
- uses: actions/checkout@v6
```

タグ参照は書き換え可能で、`v6` のような major タグはマイナーバージョンが上がるたびに最新コミットを指すように更新されます。アクション作者にとっては便利な仕組みですが、利用者からすると **今日 `v6` で動いていたコードが明日には別のコードを実行する** ことを意味します。アクションのメンテナーアカウントが乗っ取られたり、メンテナーが悪意あるコードを混ぜたりすれば、それを参照する全リポジトリのCIが影響を受けます。

commit SHA で pin すれば、その時点のソースコードに対する不変の参照になります。後でアップデートしたいときは [Renovate](https://docs.renovatebot.com/) や [Dependabot](https://docs.github.com/en/code-security/dependabot) が pin を新しい SHA へ書き換える PR を作るので、人がレビューしてからマージできます。コメントとして `# v6.0.2` のように正確なバージョンを残しておけば、人間が読むときの可読性も保てます。

うちのorgでは過去のRenovate運用ですでにSHA pinが適用済みのリポジトリが大半でした。今回の作業は「全アクションがSHA参照になっているか」の確認と、漏れていた数件の修正で済んでいます。新規のworkflowを書くときに `actions/checkout@v6` のような書き方で済ませてしまわないよう、レビュー観点として明示しておきます。

## `permissions` を `contents: read` から始める

4項目目は、workflow全体の `permissions:` を明示し、必要なスコープだけを付けることです。

```yaml
permissions:
  contents: read
```

GitHub Actionsの `GITHUB_TOKEN` には、組織やリポジトリの既定設定に応じて広いスコープが付与されることがあります。`permissions:` を書かないまま動いている workflow は、知らないうちに `contents: write` や `pull-requests: write` を握っている可能性があります。CIで動くテストスクリプトに、リポジトリへ書き込む権限まで持たせる必要はありません。

うちのorgでは、まずworkflowのトップレベルで `contents: read` を既定値として宣言し、jobごとに必要なスコープだけを上書きする方針にしました。GitHub Pagesへのデプロイには `pages: write` と `id-token: write` が必要なので、そのjobだけ追加します。`packages: write` が必要なリリースjobには、そこだけ追加します。

```yaml
permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

  deploy-pages:
    needs: build
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pages: write
      id-token: write
    steps:
      - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5
```

この方針には**事故が起きたときの被害範囲を狭められる**効果があります。CIのスクリプトが何らかの理由で悪意ある操作を試みたとしても、`contents: read` しか持っていなければissueをcloseしたりreleaseを消したりはできません。同時に、workflowを読むだけで「この workflow は何を触る権限があるか」がすぐ分かるので、コードレビューでも `permissions` の宣言から差分の確認観点を絞り込めます。

::card[https://github.com/LabeeHive/.github]

## SHA pin は Renovate とセットで運用する

SHA pin の最大の懸念は更新作業のコストです。タグ参照なら `v6` のまま放っておけば最新が自動で当たりますが、SHA pin は手作業で書き換えない限り古いまま固まります。これを解消するのが [Renovate](https://docs.renovatebot.com/) や [Dependabot](https://docs.github.com/en/code-security/dependabot) との組み合わせです。

::card[https://labee.jp/posts/renovate-cloudflare-workers-grouping]

うちのorgのRenovate設定では、`actions/*` 系のSHA pinをまとめて追従させています。新しいバージョンがリリースされると、SHAを新しい値に書き換えるPRがBotから飛んできて、CIが通れば自動マージされます。pin自体は手作業で更新する仕組みではなく、Botが新しいSHAへの追従PRを出し続ける運用です。`actions/checkout` のように頻繁にアップデートされるアクションでも、SHA pinが運用上の足かせにはなっていません。

## 機械的にチェック・適用するツールを使う

4項目を手作業で見て回るのは規模が大きくなると現実的ではありません。次の運用ではいくつかのツールを組み合わせて、新規workflowと既存workflowの両方を機械的に検査する形に寄せます。1ツールで4項目すべてをカバーできるものは見つからなかったので、役割を分けて組む前提です。

### pinact — SHA pin の自動適用と検証

[suzuki-shunsuke/pinact](https://github.com/suzuki-shunsuke/pinact) は GitHub Actions と Reusable Workflow の `uses:` 行を commit SHA に書き換える CLI です。`actions/checkout@v6` のような参照を `actions/checkout@<sha> # v6.0.2` の形に変換し、コメントとして元のバージョンを残します。記事中で言及した可読性とのバランスにそのまま合致する書式です。

```bash
# .github/workflows/*.yml と action.yml を一括で pin
pinact run

# 既存の pin を最新 SHA へ追従
pinact run -update

# CI 用にチェックのみ (差分があれば exit code 非ゼロ)
pinact run -check
```

公式の GitHub Action ラッパー [suzuki-shunsuke/pinact-action](https://github.com/suzuki-shunsuke/pinact-action) も用意されています。Renovate と併用する場合の役割分担は、**初回の SHA 化を pinact で当て、その後の追従を Renovate に任せる**形が分かりやすいです。

### ratchet — pin / unpin の双方向変換

[sethvargo/ratchet](https://github.com/sethvargo/ratchet) も SHA pin ツールです。`pin` / `unpin` / `update` / `lint` のサブコマンドを持ち、`lint` は CI で「全 workflow が pin されているか」をチェックして失敗させる用途に使えます。pinact との違いは、`unpin` でタグ参照に戻す逆変換を持つ点と、GitHub Actions 以外の CircleCI などにも対応している点です。

```bash
ratchet pin workflow.yml
ratchet lint workflow.yml   # CI 検査用
```

書式の好み (`<sha> # version` コメントの扱い) と運用フローで選ぶことになりそうです。

### zizmor — workflow 全体の静的解析

[zizmorcore/zizmor](https://github.com/zizmorcore/zizmor) は GitHub Actions の静的解析ツールで、記事で触れた3項目が個別のルールとしてカバーされています。

- `artipacked`: `actions/checkout` の `persist-credentials` 既定値を検出する。`--fix` で自動修正にも対応
- `unpinned-uses`: SHA pin されていない `uses:` を検出する。`--fix` で SHA に書き換え、ポリシー設定で「`actions/*` はタグ pin 許可、サードパーティは SHA pin 必須」のような切り分けもできる
- `excessive-permissions` / `undocumented-permissions`: workflow / job レベルの `permissions:` 宣言を検査する。トップレベルで `permissions: {}` を既定にして job ごとに付け足す書き方を推奨する形になっている

```bash
# .github/workflows/ 以下を一括で監査
zizmor .

# 自動修正
zizmor --fix .

# CI でレポートを SARIF として出す
zizmor --format sarif .
```

`defaults.run.shell` のチェックは zizmor のルールには含まれていません。この項目だけは、PR レビューでカバーするか、後述の actionlint や独自スクリプトに任せる前提になります。

### actionlint — 構文と式の検査

[rhysd/actionlint](https://github.com/rhysd/actionlint) は workflow の構文・式・shell スクリプト (shellcheck 連携) を検査するツールで、上記4項目のセキュリティ観点を直接当てるツールではありません。ただし `${{ }}` の型チェック、ジョブ依存関係、cron 式の妥当性など workflow を書き間違えたときの早期発見には有効で、zizmor とは補完関係になります。

```bash
actionlint
```

### Harden-Runner — runtime 側からの検知

[step-security/harden-runner](https://github.com/step-security/harden-runner) は静的解析ではなく、runner の実行時にネットワーク egress とプロセス活動を監視する step を差し込む形のツールです。`GITHUB_TOKEN` がどのスコープを実際に使ったかを記録して **「この job に必要な最小権限」のレコメンドを返す**機能があるので、4項目目の `permissions:` 最小化を後から詰めるときには静的解析より精度が出る場面があります。

```yaml
steps:
  - name: Harden Runner
    uses: step-security/harden-runner@<sha> # v2.x.x
    with:
      egress-policy: audit
```

うちのorgで今すぐ全リポジトリに入れる判断はしていませんが、CIから外部にデータを送るリスクが高いリポジトリには検討対象に入れたいツールです。

### 組み合わせ案

5つのツールが4項目のどこを見てくれるかを並べると、埋まらない列がはっきりします。

| ツール | `defaults.run.shell` | `persist-credentials` | SHA pin | `permissions` 最小化 |
|---|---|---|---|---|
| pinact | — | — | ○ | — |
| ratchet | — | — | ○ | — |
| zizmor | — | ○ | ○ | ○ |
| actionlint | — | — | — | — |
| Harden-Runner | — | — | — | ○ |

`defaults.run.shell` の列だけが空のままです。actionlint は shellcheck 連携を持っていますが、検査するのは `run:` の中身であって、shell の宣言そのものではありません。

現時点で検討している組み合わせは次の通りです。決定はまだ先送りです。

- **SHA pin の追従**: pinact + Renovate（初回適用は pinact、継続追従は Renovate）
- **PR 単位の静的検査**: zizmor を CI のPRチェックに追加し、`artipacked` `unpinned-uses` `excessive-permissions` で4項目のうち3つを機械的に押さえる
- **runtime 側の検査**: Harden-Runner はリリース workflow など影響が大きい workflow から段階的に導入

`defaults.run.shell` を機械的に当てるツールは見つけられていません。この1項目は PR レビュー観点として残し、新規 workflow の追加時に確認する運用にする想定です。

## 4 項目を機械的に当てない

org 全体へ適用してみて一番効いたのは、4項目をすべて機械的に当てるのは間違いだった、という気づきです。SwiftMilkdown の release workflow のように、ベストプラクティスを当てると壊れるケースがあります。「全リポジトリに同じパッチを当てる」ではなく、各workflowが何をしているかを読んでから判断するフローを残しました。一括適用のPRテンプレートに「適用しないステップがある場合は理由を本文に書く」という項目を加えています。

4項目はどれもworkflowの処理そのものを変えるものではありません。それでも、書いてあるかどうかでセキュリティの設計が変わります。新規のworkflowを書くときはまずこの4項目を満たしているかを確認し、満たせない場合は理由をPR本文で残す、という運用に揃えました。