セキュリティ

Git履歴にコミットされる前にシークレットを阻止する

gitにコミットされたシークレットは、削除された後でも永遠に残ります。ここでは、コミット前にそれをブロックする多層防御と、漏洩した場合にローテーションする方法を紹介します。

この記事は英語の原文をAIモデルが翻訳したものです。表現が原文と異なる場合があります。 英語の原文を読む

APIキー、トークン、パスワード、またはプライベート証明書がコミットに含まれた瞬間、削除では解決できない問題が発生します。Gitの履歴は、設計上、耐久性があります。強制的に削除されたシークレットは、すべてのクローン、すべてのフォーク、すべてのCIキャッシュ、そしてあなたが気付く前にブランチをフェッチしたすべてのエディターに依然として存在します。その値は漏洩したものとして扱わなければならず、これはクリーンアップではなく、ローテーションを意味します。したがって、本当の防御策は、事後に漏洩したシークレットを検出することではありません。それは、シークレットが履歴に入る前にコミットを停止させることです。この投稿では、そのブロックを階層的に構築する方法、なぜ1つの階層だけでは決して十分ではないのか、そして、それでもシークレットがすり抜けてしまった場合に何をすべきかを示します。

コミットされたシークレットを削除しても手遅れである理由

コミットされたシークレットは、公開されたシークレットです。pushしてから気づくまでの間に、その値は読み取りアクセス権を持つ誰にでもプルされ、自動化されたフォークによってミラーリングされ、パブリックホストとプライベートホストの両方をクロールするスキャナーによってインデックス付けされる可能性があります。履歴を書き換えると、ブランチの先端からその値は削除されますが、すでにあなたのマシンから離れたコピーには届きません。

クリーンアップが失敗する第二の理由があります。履歴を書き換えると、編集したコミット以降のすべてのコミットのコミットハッシュが変更されます。これにより、すべての共同作業者はローカルブランチをリセットすることを強制され、オープンなプルリクエストが壊れます。実際の運用コストを支払うことになり、しかもシークレットは依然として漏洩したままです。なぜなら、公開されていた期間中に誰もそれをコピーしなかったことを証明できないからです。

正しいメンタルモデルはシンプルです。シークレットが一度共有履歴に到達すると、その値は死んだも同然です。唯一の安全な対応は、ソースでそれをローテーションし、古いものを無効にすることです。履歴の書き換えを含むその他すべては、その値がすでに無価値になった後に行われる後始末にすぎません。だからこそ、シークレットを履歴から完全に除外できるコミットの境界、つまりアップストリームで努力をすべきなのです。

多層防御の概要

単一のチェックですべてを検出できるわけではありません。ローカルフックは高速ですが、スキップされる可能性があります。サーバースキャンは強制されますが、プッシュ後に実行されます。ファイルルールはあらゆる種類のミスを防ぎますが、意図を読み取ることはできません。各層は他の層が残したギャップをカバーするため、これらを一緒に実行します。

実行場所 検出対象 バイパスのリスク
pre-commitフック 開発者のマシン、コミット前 ステージされたシークレット、履歴に入る前 高、ローカルでありスキップ可能
CIスキャン サーバー、プッシュ後 フックが見逃したもの、またはスキップされたフックが許可したものすべて 低、中央で強制
ignoreおよびallowlistルール 両方 ステージされるべきではないシークレットファイル、および誤検知 該当なし
自動化ガード ボットとスクリプト シークレットをステージするマシンコミット 低、自動化の内部で実行
ローテーション 漏洩後 漏洩した値による損害 該当なし

この表を上から下へ、じょうごのように読んでください。pre-commitフックは、一般的なケースを低コストかつ早期に停止させます。CIスキャンはフックの下にあるネットであり、git commit --no-verifyを実行した開発者や、フックをまったくインストールしなかった開発者のためのものです。ファイルルールは両方のスキャナーが判断しなければならない対象範囲を縮小し、自動化ガードは人間がレビューしないコミットをカバーし、ローテーションは最後の層、つまり実行されることを決して望まない層です。

pre-commitフックでコミットをブロックする

シークレットの混入を防ぐ最もコストのかからない場所は、commitオブジェクトが作成される前の、開発者自身のマシンです。pre-commitフックはステージされた変更に対して実行され、クレデンシャルのようなものを見つけると、ゼロ以外のステータスで終了し、コミットは決して行われません。ステージされたものだけを検査するため、すべてのコミットで実行しても誰も遅延に気づかないほど高速です。

3つのシグナルで、実際の漏洩のほとんどを検知できます。1つ目はファイル名です。.env.pem.key.p12.pfxのようなファイルは、リポジトリに含めるべきものではほとんどありません。2つ目は既知のクレデンシャル形式です。多くのプロバイダーは固定のプレフィックスと長さを持つキーを発行し、秘密鍵には認識可能なヘッダー行が含まれます。3つ目は高エントロピーです。tokenpasswordといった名前の変数に割り当てられた、ランダムに見える長い文字列は、既知のどの形式にも一致しない場合でも、強力なヒントとなります。

#!/usr/bin/env bash
# .git/hooks/pre-commit (or the equivalent managed by a hook framework)
set -euo pipefail

# Patterns for well-known credential shapes and private key headers.
SECRET_PATTERNS='(-----BEGIN [A-Z ]*PRIVATE KEY-----)|(secret_[A-Za-z0-9]{24,})|([A-Za-z0-9+/]{40,}={0,2})'

# Inspect only what is staged, and only added or changed lines.
staged=$(git diff --cached --name-only --diff-filter=ACM)
fail=0

for file in $staged; do
  # 1. Reject credential-bearing filenames outright.
  case "$file" in
    *.pem|*.key|*.p12|*.pfx|.env|.env.*)
      echo "blocked: $file looks like a credential file"
      fail=1
      continue
      ;;
  esac

  # 2. Scan the staged content of the file for secret-shaped values.
  if git show ":$file" | grep -nEq "$SECRET_PATTERNS"; then
    echo "blocked: $file contains a value matching a secret pattern"
    fail=1
  fi
done

if [ "$fail" -ne 0 ]; then
  echo "commit rejected. remove the secret, or add an allowlist entry if this is a false positive."
  exit 1
fi

見た目以上に重要な詳細が2つあります。フックは git show ":$file" を読み込みます。これはワーキングコピーではなく、ステージされたバージョンのコンテンツです。これにより、開発者が修正をアンステージしたにもかかわらず、ワーキングファイルはまだクリーンに見えるという競合状態を防ぎます。そして、--diff-filter=ACM でフィルタリングするため、削除はスキャンをトリガーしません。なぜなら、ファイルを削除することでシークレットが履歴に入ることは決してないからです。

エントロピーは、このパターンセットの弱点です。なぜなら、正当なバイナリの base64 ブロブや長いハッシュが、キーとまったく同じように見えることがあるからです。上記の正規表現は、ノイズを抑えるために控えめな長さの下限を使用しています。そして、次のセクションでは、エントロピーを実際に使えるようにする2つのこと、つまり、見逃したものに対するCIネットと、誤ってフラグを立てたものに対する許可リストについて説明します。

CIにおける第2の関門

ローカルフックには致命的な弱点が1つあります。それは開発者のマシン上に存在するため、スキップされたり、アンインストールされたり、あるいは単に新しいクローンで一度もセットアップされなかったりする可能性があります。git commit --no-verify は、1つのフラグでそれをバイパスします。1つのフラグで無効にできる防御策は、制御ではなく、提案にすぎません。したがって、誰もオプトアウトできない場所、つまりCIで、すべてのプッシュに対して同じスキャンを再度実行する必要があります。

CIスキャンは、フックを繰り返すだけではありません。プッシュの完全な差分をスキャンし、フックがなかったマシンで行われたコミットも対象とします。また、初回実行時には履歴を深くスキャンして、この制御が存在する前にコミットされたシークレットを検出できます。サーバー上で実行されるため、その結果は信頼できるものとなります。スキャンが失敗するとマージはブロックされ、ローカルのフラグでそれを通過させることはできません。

# A minimal CI job that fails the pipeline on a detected secret.
scan-secrets:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
      with:
        fetch-depth: 0          # full history, so the scan sees every commit in the push
    - name: scan for secrets
      run: |
        # Scan the range that this push introduced, not just the tip.
        base="${BEFORE_SHA:-HEAD~1}"
        if git diff "$base"..HEAD | grep -nE "$SECRET_PATTERNS"; then
          echo "secret detected in pushed commits. rotate the value and clean history."
          exit 1
        fi

役割分担が要点です。フックは、開発者のマシン上でほぼすべてのシークレットを履歴から除外するための高速なパスです。そこでは、修正にかかるコストはファイルを編集するだけです。CIスキャンは、フックがスキップされたと想定し、シークレットを含むプッシュがマージされることを拒否する強制的なパスです。一方は便利で、もう一方は権威的であり、その両方が必要です。

CIスキャンが作動すると、重要な非対称性が生じます。それが実行される時点では、シークレットはすでにプッシュされており、これはすでに漏洩している可能性があることを意味します。したがって、CIによる検知は、完全に問題を防いだことにはなりません。それは、ローテーション手順を開始し、その値を侵害されたものとして扱い、そして履歴をクリーンアップするための合図です。

誤検知を減らすファイルとパスのルール

スキャナーは、推測する要素が少なくなると精度が向上します。その作業の大部分を担うのが、2つのファイルレベルのルールです。1つ目は、そもそもシークレットファイルのカテゴリ全体をステージングエリアから除外する、厳格な .gitignore です。.env、キーファイル、ローカルの認証情報キャッシュが無視されれば、開発者が誤って git add することはなくなり、スキャナーがその内容を推論する必要もなくなります。

# Never track local secret material.
.env
.env.*
!.env.example
*.pem
*.key
*.p12
*.pfx
credentials.json

!.env.exampleの行は特筆に値します。どの変数が存在するかをプレースホルダー値とともに示す、コミット済みのテンプレートを用意しておくと、スキャナーの許可リストでそのファイルを正確に除外できます。これにより、実際の値を一切含まずに、設定の構成を文書化できます。

2番目のルールはスキャナー自体のための明示的な許可リストです。なぜなら、誤検知は避けられないからです。テストフィクスチャには、意図的に偽のキーが含まれています。ドキュメントにはトークンの例が示されています。ベンダー提供の依存関係には、サンプルの証明書が同梱されています。これらを既知の安全なものとしてマークする方法がなければ、その一つ一つが正当なコミットをブロックしてしまい、開発者はシステム全体を無意味にする--no-verifyに頼るようになります。許可リストは範囲を限定してレビュー可能であるべきで、特定のパスや特定の行に固定し、決して「このパターンをどこでも無視する」といった包括的なものであってはなりません。

# Scanner allowlist: narrow, path-scoped, reviewed in pull requests.
allowlist:
  - path: testdata/fake_keys.txt      # deliberately fake fixtures
  - path: docs/configuration.md       # example token in prose
    reason: "documented placeholder, not a live credential"

ここでの規律は、すべての許可リストのエントリを、理由を付けた小さく明示的な例外とし、他の変更と同様にレビューする、というものです。パターンレベルのミュートは、自分で掘ったことを忘れてしまう穴です。理由が明記されたパスレベルの例外は監査可能であり、プルリクエストをレビューする人は、何が、なぜ免除されたのかを正確に確認できます。

自動コミットの防御

最も危険なコミットは、人間が誰も見ないものです。依存関係更新のプルリクエストを開くボット、設定ファイルを再生成してその結果をコミットするスクリプト、ビルドマニフェストを書き込むリリースジョブ:これらのいずれも、diff内の危険信号に気づく人間がループに介在することなく、シークレットをステージングする可能性があります。自動化は高速に動作し、何もレビューしないため、その独自のパスに直接ブロックを組み込む必要があります。

ルールは、自動コミットパスが人間と同じスキャンを実行し、ヒットした場合はログに記録する警告としてではなく、ハードストップとして扱うことです。ボットのスキャンがコミットしようとしているものの中にシークレットを発見した場合、そのコミットは行われてはなりません。ボットは自身のジョブを大きな音を立てて失敗させるため、静かにシークレットをコミットして次に進むのではなく、人間が調査することになります。これは人間用のフックと同じフェイルクローズの姿勢であり、バックストップとしてのレビュアーが存在しないため、よりリスクが高い場所に適用されます。

これは、エントロピーチェックがその真価を発揮する場面でもあります。コードを書いている人間が、自分でも驚くような形で、誤って本物の40文字のランダムな文字列を貼り付けることはめったにありません。一方、設定ファイルをテンプレート化する自動化は、環境変数から実際の値を取得し、それを追跡対象ファイルに直接書き込む可能性があります。汎用的な高エントロピーチェックは、まさにその種のミス、つまり値が既知のどのプロバイダーのフォーマットにも一致しないが、その形状と変数名から紛れもなくシークレットであるものを検出します。

シークレットが漏洩した場合は、まずローテーションする

遅かれ早かれシークレットは漏洩し、その対応の順序が被害の大きさを決定します。本能的に、その値を消すためにすぐに履歴を消去しようとします。その本能は間違いです。なぜなら、その値はすでに外部に漏れており、履歴を書き換えても、あなたのマシンから出て行ったコピーには何の効果もないからです。最初の行動は常にローテーションです。

  1. 他の何よりも先に、漏洩した値をその発行元でローテーションまたは無効化します。プロバイダーで代替の値を生成し、古いものを無効にします。シークレットが公開された瞬間、その値は死んだも同然ですので、まずそれを無効にしてください。
  2. 古い値がもはや機能しないことを確認します。それを使って認証を試み、呼び出しが拒否されることを確認します。失敗するのを確認するまで、実際には無効化できていません。
  3. 通常のシークレットストアを通じて、サービスが必要とするあらゆる場所に新しい値をデプロイし、システムが代替の値で稼働し続けるようにします。
  4. この段階になって初めて履歴をクリーンアップします。クローン、フォーク、キャッシュにはまだ古い値が保持されていることを理解した上で、シークレットを含んでいたコミットを書き換え、force-pushします。このステップは封じ込めではなく、衛生管理です。
  5. 漏洩から無効化までの間に古い値が使用された形跡がないか、アクセスログを確認します。もしそのクレデンシャルが実データへのアクセスを許可するものであれば、それが使用された可能性があると仮定し、それに応じて調査します。

この順序こそが、この教訓のすべてです。ローテーションはインシデントを封じ込めます。履歴のクリーンアップは、その後の後始末にすぎません。最初に履歴を書き換え、次にローテーションを行うチームは、何も変えることのないステップに最も迅速に対応すべき時間を費やしてしまいます。その間、有効なまま漏洩したクレデンシャルは、それをすでにコピーした誰にとっても機能し続けます。

偽陽性のトレードオフ、そしてフェイルクローズが勝利する理由

すべてのシークレットスキャナーは、同じ緊張関係に直面します。パターンを厳しくすれば、本物のシークレットを見逃します。パターンを緩めれば、誤検知によって正当なコミットをブロックしてしまいます。両方を排除する設定は存在しないため、どちらのエラーを許容するかを選択しなければならず、その選択は対称的ではありません。

偽陽性は迷惑なものです。開発者はブロックされたコミットを見て、フラグが立てられた行を確認し、それがテストフィクスチャやドキュメント化されたプレースホルダーであることを確認して、限定的な許可リストのエントリを追加します。コストは数分とレビューされた例外です。偽陰性は侵害です。本物の認証情報がすり抜け、共有履歴に到達し、プレッシャーの中でシークレットのローテーションとアクセスログの読み取りを行うことになります。この2つの結果は同列に語れるものではなく、だからこそスキャナーはフェイルクローズすべきなのです。つまり、疑わしい場合はブロックし、人間に例外を確認させるのです。

その選択が機能し続けるのは、偽陽性の対応が本当に安価である場合に限られます。だからこそ、許可リストが非常に重要なのです。誤検知の解除が苦痛であれば、開発者は--no-verifyを使ってスキャナーを迂回し、バイパスされた制御は何も保護しません。したがって、パターンを本物のシークレットを捕捉できるほど積極的に保ち、例外処理を迅速、限定的、かつレビュー可能にすることに投資します。検知時にはフェイルクローズし、例外処理は安価に保つことで、開発者が抵抗するのではなく使い続けるシステムを手に入れることができます。

これらの層を組み合わせると、その全体像は明確になります。プリコミットフックは、開発者のマシン上でシークレットが履歴に残らないようにします。CIスキャンは、どのフラグもスキップできない場所で同じルールを強制します。ファイルと許可リストのルールは、当て推量を減らし、偽陽性のコストを低く抑えます。自動化ガードは、誰もレビューしないコミットをカバーします。そして、ローテーションは、いつか何かがすり抜けてしまう日に備えています。なぜなら、設計全体の背後にある正直な前提は、最終的には何かがすり抜けてしまうということであり、コミットされたシークレットは、それが着地した瞬間に死んだも同然だからです。