0
0

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でPRごとのセキュリティゲートを作る シークレット・依存関係・SASTとブランチ保護の最小構成

0
Posted at

プルリクエスト(PR)ごとに、シークレットの混入、既知の脆弱性を持つ依存関係、危ない書き方を自動で調べ、通らないとマージできないようにする最小構成を、GitHub Actionsで作りました。人が書いたコードも、AIが書いたコードも、同じ入口を通します。

使うツールは一例です。gitleaks、osv-scanner、Semgrepを選びましたが、同じ役割のツールに置き換えても考え方は変わりません。ワークフロー全文、手元で動かした結果、ブランチ保護の設定、そして拾えないものを順に書きます。気になるところだけ拾ってください。

環境

動作確認は2026年10月17日、macOS(Apple Silicon)です。

役割 ツール バージョン
シークレットスキャン gitleaks 8.30.1
依存関係スキャン osv-scanner 2.7.0
SAST Semgrep 1.181.0
ワークフロー検査 actionlint 1.7.12

ワークフローのYAMLはactionlintで検査し、各コマンドは同じバージョンを手元で動かしました。GitHub上の実行環境(ubuntu-latest)でワークフロー全体を回した結果は、この記事には含まれていません。その点は「拾えないもの、確認していないこと」にも書きます。

この記事をざっくり図解

PR作成 / 更新
  ├─ secret-scan      gitleaks  : このPRのコミットにキーが入っていないか
  ├─ dependency-scan  osv-scanner: ロックファイルに既知の脆弱性がないか
  └─ sast             Semgrep   : 危ない書き方のパターンがないか
           │
           ▼
   ブランチ保護: 3つが緑 かつ レビュー承認 でないとmainにマージできない

1. ワークフロー全文

.github/workflows/pr-security-gate.yml です。

name: pr-security-gate

on:
  pull_request:
    branches: [main]

permissions:
  contents: read

concurrency:
  group: pr-security-gate-${{ github.event.pull_request.number }}
  cancel-in-progress: true

env:
  GITLEAKS_VERSION: "8.30.1"
  GITLEAKS_SHA256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb"
  OSV_SCANNER_VERSION: "2.7.0"
  OSV_SCANNER_SHA256: "160c6253e2922e9e0a6a9771c5c4c40a4b3c6090b34a3cdd5e80c96cc2e4218b"
  SEMGREP_VERSION: "1.181.0"

jobs:
  secret-scan:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    # リポジトリ変数 GATE_REPORT_ONLY が true の間は、失敗してもPRを止めない
    continue-on-error: ${{ vars.GATE_REPORT_ONLY == 'true' }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          fetch-depth: 0
          persist-credentials: false
      - name: Install gitleaks
        run: |
          curl -sSfL -o gitleaks.tar.gz \
            "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
          echo "${GITLEAKS_SHA256}  gitleaks.tar.gz" | sha256sum -c -
          tar -xzf gitleaks.tar.gz gitleaks
      - name: Scan commits in this PR
        env:
          BASE_SHA: ${{ github.event.pull_request.base.sha }}
          HEAD_SHA: ${{ github.event.pull_request.head.sha }}
        run: ./gitleaks git --no-banner --redact --log-opts="${BASE_SHA}..${HEAD_SHA}" .

  dependency-scan:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    continue-on-error: ${{ vars.GATE_REPORT_ONLY == 'true' }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - name: Install osv-scanner
        run: |
          curl -sSfL -o osv-scanner \
            "https://github.com/google/osv-scanner/releases/download/v${OSV_SCANNER_VERSION}/osv-scanner_linux_amd64"
          echo "${OSV_SCANNER_SHA256}  osv-scanner" | sha256sum -c -
          chmod +x osv-scanner
      - name: Scan lockfiles
        run: ./osv-scanner scan source --recursive .

  sast:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    continue-on-error: ${{ vars.GATE_REPORT_ONLY == 'true' }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - name: Install semgrep
        run: |
          python3 -m venv "${RUNNER_TEMP}/venv"
          "${RUNNER_TEMP}/venv/bin/pip" install --quiet "semgrep==${SEMGREP_VERSION}"
      - name: Run semgrep
        run: |
          "${RUNNER_TEMP}/venv/bin/semgrep" scan \
            --config p/default --error --metrics=off --disable-version-check .

2. 設計の理由

ワークフローを書くときに、迷った点とその理由を書いておきます。

gitleaksは公式のActionではなくバイナリを使っています。 gitleaks-actionのREADMEには、組織(Organization)のリポジトリではライセンスキー(無料で取得できる)が必要で、個人アカウントでは不要、と書かれています(2026年10月17日確認)。キーを secrets に登録する手間を避けて、CLIを直接動かしています。ここは好みと運用の問題で、Actionを使う選択もあります。

ダウンロードしたバイナリはSHA-256で検証しています。 gitleaksの値は公式の gitleaks_8.30.1_checksums.txt と、osv-scannerの値は osv-scanner_SHA256SUMS と照合しました。ダウンロード元が書き換えられていた場合に、実行前に止まります。反面、新しい版に上げるたびにハッシュも更新が必要です。

actions/checkout はタグではなくコミットSHAで固定しています。 タグは付け替えられる可能性があるためです(v7.0.1 は 3d3c42e5… を指すことを git ls-remote で確認しました)。コメントで版を残しておくと、更新時に分かりやすいです。

そのほかの小さな設定です。

  • permissions: contents: read で、ジョブに渡るトークンの権限を最小にしています。
  • persist-credentials: false で、チェックアウト後の認証情報がディスクに残らないようにしています。
  • run: の中で ${{ ... }} を直接展開せず、env: 経由で渡しています。PRのタイトルやブランチ名のような、外から入る文字列をシェルに直接埋め込まないためです。ここで使っているのはSHAだけですが、同じ書き方に揃えています。
  • トリガーは pull_request です。forkからのPRにはシークレットが渡りません。このワークフローはシークレットを使わないので、それで足ります。
  • fetch-depth: 0 は、PRのコミット範囲(base..head)をgitleaksに渡すため、履歴が必要だからです。大きなリポジトリでは時間がかかります。

3つを別々のジョブに分けたのは、ブランチ保護で「必須のチェック」として個別に指定するためです。 ジョブ名(secret-scan、dependency-scan、sast)が、そのままチェック名になります。

3. 手元で動かした結果

ワークフローの各コマンドを、同じバージョンで手元の小さなリポジトリに対して実行しました。リポジトリには、GitHubトークンの形をした架空の文字列、requests==2.19.0 を書いた requirements.txt、SQLを文字列連結で組み立てる関数と shell=True の subprocess.run を含む app.py を入れてあります。

gitleaks

gitleaks git --no-banner --redact -v --log-opts="HEAD~1..HEAD" .
Finding:     GITHUB_TOKEN = "REDACTED
Secret:      REDACTED
RuleID:      github-pat
File:        app.py
Line:        6
Fingerprint: <commit>:app.py:github-pat:6

1 commits scanned.
leaks found: 1

終了コードは1です。--log-opts に渡した範囲のコミットだけを調べているので、PRで追加した分だけが対象になります。

試すときの罠が一つありました。AWSの公式ドキュメントにある例のキー(末尾が EXAMPLE のもの)を入れても、no leaks found でした。サンプル値は検出から外されているようです。動作確認は、サンプルではなく、実際のトークンの形をした架空の値で行ってください。

osv-scanner

osv-scanner scan source --recursive .
Total 3 packages affected by 19 known vulnerabilities (0 Critical, 8 High, 11 Medium, 0 Low, 0 Unknown) from 1 ecosystem.
19 vulnerabilities can be fixed.

requests==2.19.0 の1行から、直接・間接の依存を含めて19件が出ました(終了コード1)。ロックファイルがなければ、この種のスキャンは何も見つけません。Pythonなら requirements.txt だけでも読めましたが、バージョンが固定(==)されている前提です。

Semgrep

semgrep scan --config p/default --error --metrics=off --disable-version-check .
Findings: 2 (2 blocking)
python.sqlalchemy.security.sqlalchemy-execute-raw-query.sqlalchemy-execute-raw-query
    3┆ return conn.execute("SELECT * FROM users WHERE name = '" + name + "'").fetchall()
python.lang.security.audit.subprocess-shell-true.subprocess-shell-true
    5┆ return subprocess.run(cmd, shell=True)

終了コードは1です。ルール名はSQLAlchemy向けですが、sqlite3 風の conn.execute に文字列連結を渡す形でも検出されました。ちなみに --config p/python では、SQLの連結は検出されず、shell=True の1件だけでした。ルールセットの選び方で、拾える範囲が変わります。

4. ブランチ保護

ワークフローが緑でも、マージできる状態では意味がありません。保護の設定で、3つのジョブを必須にします。GitHubのREST APIでは、次のJSONを PUT /repos/{owner}/{repo}/branches/{branch}/protection に送ります。required_status_checks、enforce_admins、required_pull_request_reviews、restrictions の4つが必須で、使わないものは null を渡します(GitHub Docsより)。

{
  "required_status_checks": {
    "strict": true,
    "checks": [
      { "context": "secret-scan" },
      { "context": "dependency-scan" },
      { "context": "sast" }
    ]
  },
  "enforce_admins": true,
  "required_pull_request_reviews": {
    "required_approving_review_count": 1,
    "dismiss_stale_reviews": true,
    "require_code_owner_reviews": false
  },
  "restrictions": null
}
gh api -X PUT repos/OWNER/REPO/branches/main/protection --input branch-protection.json
  • strict: true は、mainに追従していないブランチをマージ不可にします。チェックがmainの最新に対して通った状態でマージされます。
  • enforce_admins: true は、管理者も同じルールに従わせます。緊急時に迂回したいなら、外す判断もありますが、そのときは記録を残す運用が要ります。
  • dismiss_stale_reviews: true は、承認後に新しいコミットが積まれたら承認を取り消します。承認後に中身が変わっても通る、を防ぎます。
  • checks の context は、ワークフローのジョブ名と一致させます。名前がずれると、存在しないチェックを待ち続けてマージできなくなるので、適用前に一度PRを作って、チェック名を目で確かめるのが安全です。

ブランチ保護が使えるプランや機能は、リポジトリの公開/非公開、プランで異なります。適用前に、GitHub Docsで自分のプランを確認してください。この記事では実際のリポジトリへの適用はしていません。

5. 最初は「報告のみ」で動かす

既存のコードに指摘が大量に出る状態で、いきなり必須にすると、チェックそのものが外されがちです。ワークフローに入れた continue-on-error: ${{ vars.GATE_REPORT_ONLY == 'true' }} が、そのための仕掛けです。

  1. リポジトリの変数 GATE_REPORT_ONLY を true にする。指摘は出るがPRは止まらない
  2. 数週間、指摘を見て、誤検知を .gitleaksignore やSemgrepの除外で整理する
  3. GATE_REPORT_ONLY を消して、必須のチェックにする

continue-on-error を true にしたジョブは、失敗しても「成功」として扱われる、というGitHub Actionsの仕様のため、PRの画面は緑に見えます。結果はジョブのログで確認する運用になります(この表示はGitHub上では確かめていません)。見落としを避けるには、いつまでに必須にするか、期限を先に決めておくのがよいです。

拾えないもの、確認していないこと

  • 業務ロジックの誤りや認可漏れは拾えません。 3つとも、既知のパターンや既知の脆弱性を見つけるものです。「他人のIDで取得できる」は、どのツールも通します。人のレビューが要る部分です。
  • シークレットスキャンは、パターンに合うものだけを見つけます。 パターンに合わない独自形式の鍵や、すでに履歴に入った古いコミットは別の話です。ここでは、PRで追加されたコミットの範囲だけを見ています。キーが入ったときは、スキャンの前に、失効(ローテーション)が必要です。
  • 依存関係のスキャンは、ロックファイルがある言語で効きます。 バージョンが固定されていなければ、結果は不安定です。
  • SASTは、ルールにあるものしか拾いません。 上のとおり、設定ひとつで検出が変わりました。誤検知(と見逃し)は前提として運用します。
  • GitHub上での実行は確認していません。 手元で各コマンドを動かし、YAMLを検査しただけです。ubuntu-latest の環境差や、continue-on-error の表示は、実際に回して確認してください。
  • 更新の手間があります。 固定した版とハッシュは、放っておくと古くなります。GitHub Actionsのバージョン更新はDependabotで自動化できます。バイナリの版とハッシュは手動です。
  • コードをどこに置くかで、使える機能が変わります。 この記事のツールは、GitHubの追加契約なしで動くものを選びました。GitHub純正の依存関係レビューやコードスキャンは、非公開リポジトリでは追加の契約が必要な場合があります。

最後に

全部を入れなくても、まずは secret-scan だけを「報告のみ」で入れてみてください。実際に自分のリポジトリの過去のコミットに対して動かすと、思ったより反応するものが多いと思います。そこから、必須にするタイミングを決められます。

参考文献

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?