バックグラウンドエージェントに 6 本のアプリのアップデート作業を任せていたある夜、生成された Pull Request の説明文に Firebase のサーバーキーらしき長い文字列が紛れ込んでいるのを見つけました。エージェントはクラッシュの再現手順を丁寧に書こうとして、デバッグ用に環境変数の中身をそのまま貼り付けていたのです。幸い公開前のプライベートリポジトリでしたが、もしこれが自動マージの設定だったらと考えると、背筋が冷えました。
私は廣川政樹と申します。2014 年から個人でアプリを開発し、壁紙・癒し・引き寄せ系のアプリを中心に累計 5,000 万ダウンロードほどを重ねてきました。最近は Antigravity のエージェントに iOS / Android の更新作業を任せる比率が増えていますが、エージェントは「人間なら直感的に避ける」シークレットの取り扱いを、文脈次第であっさり踏み越えます。ここでは、エージェントの出力からシークレットが漏れる経路を 3 つに分け、それぞれをどう塞ぐかを、実際に運用して計測した数値とともに共有します。
エージェントがシークレットを漏らす3つの出口
シークレット管理というと「どこに保管するか」に意識が向きがちですが、エージェント時代に本当に怖いのは保管場所ではなく出口です。.env を Git 管理から外し、Secret Manager に入れていても、エージェントが実行中にその値を読み取り、別の場所へ書き出してしまえば意味がありません。
私が観測した漏洩の出口は、次の 3 つに整理できました。
コミット差分への混入。エージェントがデバッグ用の一時ファイルや設定スナップショットをそのままコミットに含めてしまうケースです。
実行ログと監査証跡への混入。エージェントが環境変数をダンプしたり、API レスポンスを丸ごとログに流したりするケースです。後から監査ログを残す設計にしているほど、そこに平文の鍵が永続化される危険が高まります。
PR 本文やチャット応答への混入。冒頭の事故がこれです。エージェントが「説明のため」に値を引用してしまう、最も気づきにくい経路です。
この 3 つは性質が違うため、ひとつの対策ですべては塞げません。差分はコミット前に止める、ログは流しながら伏せる、PR とチャットは公開直前に最終チェックする、という具合に層を分けて考える必要があります。
出口1: コミット差分 — pre-commit で止める
最初の防衛線はコミットです。エージェントが git commit を実行する前に、ステージされた差分をスキャンして鍵らしき文字列を弾きます。私は gitleaks をベースに、自分のアプリ事業特有のパターンを足したフックを使っています。
うまくいかない書き方から見ていきましょう。次のように「コミットメッセージだけ」を見るフックは、肝心の差分の中身を素通りさせます。
# アンチパターン: メッセージしか見ておらず差分本体を検査していない
#!/usr/bin/env bash
msg = $( cat " $1 " )
if echo " $msg " | grep -qi "secret" ; then
echo "コミットメッセージに secret が含まれています"
exit 1
fi
exit 0
実際に守りたいのはステージされた差分です。次のように、git diff --cached の中身を gitleaks に渡して検査します。
#!/usr/bin/env bash
# .git/hooks/pre-commit — ステージ済み差分のみを対象に検査する
set -euo pipefail
# gitleaks があれば差分モードで実行(インストール済み環境のみ)
if command -v gitleaks > /dev/null 2>&1 ; then
if ! gitleaks protect --staged --redact --config .gitleaks.toml ; then
echo "❌ シークレットらしき文字列がステージ差分に検出されました"
echo " git restore --staged <file> で取り消してから再コミットしてください"
exit 1
fi
fi
# 自前パターンによる二次チェック(gitleaks 未導入環境のフォールバック)
diff = $( git diff --cached -U0 | grep '^+' || true )
patterns = (
'AKIA[0-9A-Z]{16}' # AWS アクセスキー ID
'AIza[0-9A-Za-z_-]{35}' # Google API キー
'gh[pousr]_[0-9A-Za-z]{36}' # GitHub トークン
'sk_live_[0-9A-Za-z]{24,}' # Stripe 本番シークレット
)
for p in "${ patterns [ @ ]}" ; do
if echo " $diff " | grep -Eq " $p " ; then
echo "❌ 鍵パターン / $p / がステージ差分に一致しました"
exit 1
fi
done
exit 0
ここでの肝は --staged(gitleaks では protect --staged)です。リポジトリ全体の履歴を毎回スキャンすると個人開発の小さなマシンでも数十秒かかりますが、ステージ差分だけなら 6 アプリのどのリポジトリでも 1 秒未満で終わります。エージェントの作業ループに組み込むには、この速さが現実的な分かれ目になります。
.gitleaks.toml には、アプリ事業で実際に使う鍵の種類を allowlist / ルールとして明示しておきます。AdMob のアプリ ID のように「鍵に見えるが公開して問題ない値」を除外しておかないと、後述する誤検知に悩まされます。
出口2: 実行ログと監査証跡 — ストリームを通過させながら伏せる
コミットを守っても、エージェントの実行ログは別の出口です。私はバックグラウンドエージェントの判断を後から再現できるよう監査ログを R2 に残していますが、皮肉なことに「丁寧に残すほど鍵も丁寧に残る」というジレンマがありました。
ここで必要なのは、ログをブロックすることではなく、流しながら鍵だけを伏せ字にするフィルタです。標準出力と標準エラーをパイプで通し、既知のパターンと環境変数の実値をマスクします。
#!/usr/bin/env python3
# redact_stream.py — エージェント出力を1行ずつ受け取り、鍵を伏せて流す
import os
import re
import sys
# 既知の鍵フォーマット(プレフィックス+本体)
PATTERNS = [
re.compile( r "AKIA [ 0-9A-Z ] {16} " ),
re.compile( r "AIza [ 0-9A-Za-z_- ] {35} " ),
re.compile( r "gh [ pousr ] _ [ 0-9A-Za-z ] {36} " ),
re.compile( r "sk_live_ [ 0-9A-Za-z ] {24,} " ),
]
# 環境変数の実値そのものを伏せる(最も確実)
SECRET_ENV_KEYS = ( "FIREBASE_SERVER_KEY" , "ADMOB_API_SECRET" , "STRIPE_SECRET_KEY" )
SECRET_VALUES = [v for k in SECRET_ENV_KEYS if (v := os.environ.get(k)) and len (v) >= 8 ]
def redact (line: str ) -> str :
for value in SECRET_VALUES :
line = line.replace(value, "«REDACTED:env»" )
for pat in PATTERNS :
line = pat.sub( "«REDACTED:pattern»" , line)
return line
for raw in sys.stdin:
sys.stdout.write(redact(raw))
sys.stdout.flush()
ポイントは 2 段構えにすることです。パターン照合だけだと、社内独自フォーマットの鍵や、たまたま桁数の合わない鍵を取りこぼします。そこで「環境変数に入っている実値そのもの」を最優先で置換します。これは取りこぼしが起きにくく、フォーマットが何であっても確実に伏せられます。エージェントを起動する際、agent run ... 2>&1 | python3 redact_stream.py | tee -a audit.log のように噛ませておけば、画面・監査ログの両方が同時に守られます。
ひとつ注意したいのは、flush() を忘れるとログが遅延してリアルタイム監視ができなくなる点です。長時間走るエージェントの様子を別ターミナルで見ているとき、出力が数十行まとめて遅れて出ると、暴走に気づくのが遅れます。私はこれで一度ヒヤッとしたので、必ず行単位で flush するようにしています。
出口3: PR 本文とチャット応答 — CI で最終関門を作る
冒頭の事故が起きた出口です。差分とログを守っても、エージェントが「説明のために」鍵を本文へ書き写すと、その本文は差分検査もログフィルタも通りません。PR 本文やコメントは Git の管理外で生成されることが多いからです。
ここは CI 側に最終関門を置きます。GitHub Actions であれば、PR が開かれた・更新されたタイミングで本文と差分の両方を検査し、検出時はマージをブロックします。
name : secret-guard
on :
pull_request :
types : [ opened , edited , synchronize ]
jobs :
scan :
runs-on : ubuntu-latest
steps :
- uses : actions/checkout@v4
with :
fetch-depth : 0
- name : コード差分の検査
uses : gitleaks/gitleaks-action@v2
env :
GITLEAKS_CONFIG : .gitleaks.toml
- name : PR 本文の検査
env :
BODY : ${{ github.event.pull_request.body }}
run : |
printf '%s' "$BODY" > pr_body.txt
if grep -Eq 'AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{35}|gh[pousr]_[0-9A-Za-z]{36}|sk_live_[0-9A-Za-z]{24,}' pr_body.txt; then
echo "::error::PR 本文に鍵らしき文字列が含まれています"
exit 1
fi
CI を最終関門にする利点は、ローカルのフックを通り抜けても(エージェントが --no-verify を付けてコミットする、という想定外の挙動を含めて)必ずここで止まることです。私はローカルフックと CI を「同じパターン定義ファイルを共有する」形にして、二重メンテにならないよう .gitleaks.toml を単一の真実として扱っています。
誤検知をどう減らすか — allowlist と entropy のさじ加減
多層防御を入れた直後、私のリポジトリでは誤検知が頻発しました。導入初週、6 アプリ合計で 53 件のアラートのうち、本物の漏洩は 0 件、残りはすべて誤検知だったのです。内訳を見ると、AdMob のアプリ ID、テスト用のダミー鍵、長い base64 のアイコンデータ、コミットハッシュなどが「高エントロピー文字列」として引っかかっていました。
誤検知率(false positive rate)でいうと、当初は検出全体の約 12% が「人間が確認して問題なしと判断したもの」で、実作業のたびに手が止まる状態でした。これを下げた手順は次のとおりです。
公開して問題ない値を allowlist に明示する。AdMob のアプリ ID やテスト鍵は、.gitleaks.toml の [allowlist] に正規表現で登録します。
ファイル単位で除外する。アイコンの base64 やスナップショット JSON など、構造上どうしても高エントロピーになるファイルはパス指定で除外します。
エントロピーのしきい値を上げすぎない。検出感度を下げると本物を取りこぼすため、しきい値ではなく allowlist で個別に許可する方針にします。
この 3 つを回したところ、約 2 週間で誤検知率は 0.4% まで下がりました。数値の変化以上に効いたのは「allowlist に追加した理由をコメントで残す」習慣です。なぜこの値を許可したのかが将来の自分(やエージェント)に伝わらないと、allowlist がなし崩しに膨らみ、防御が形骸化します。私は allowlist のひとつひとつに # AdMob app id is public, safe to commit のような一文を必ず添えるようにしています。
漏れてしまった後の動き方 — 検知より復旧が本番
どれだけ層を重ねても、漏洩はゼロにはなりません。むしろ「検知できた=もう書き込まれた後」であることがほとんどです。検知の精度を上げる以上に、漏れた後にどれだけ速く無害化できるかが本番だと考えています。
私が決めている手順は、優先順位を固定することです。
まず鍵をローテーションします。漏れた鍵を無効化し、新しい値を発行することが最優先です。コミットを消すより先に、鍵そのものを使えなくします。
次に伝搬範囲を確認します。その鍵がどのサービスに対する権限を持っていたかを洗い出し、不正利用の痕跡(AdMob や Stripe のダッシュボードの異常なアクティビティ)を確認します。
最後に履歴の除去を行います。git filter-repo などで履歴から消すのはこの段階です。リモートに push 済みの鍵は「もう公開された」前提で扱い、消去を無害化の代わりにしないことが肝心です。
順番を間違えて「まず履歴を消そう」とすると、その間に鍵が使われ続けます。エージェントに自動修正を任せる場合も、この優先順位だけは人間が握っておくべき部分だと感じています。
3層をどう組み合わせるか(運用してみた所感)
3 つの層は、止める場所と速度のトレードオフが異なります。pre-commit は最速で開発体験を損ねませんが、--no-verify で回避され得ます。ログフィルタは流れを止めずに伏せられますが、コミット自体は防げません。CI は確実ですが、止まるのが PR 段階なので手戻りがやや大きくなります。
私の結論は、3 層すべてを薄く入れることでした。どれかひとつに頼ると、その層を抜けた瞬間に無防備になります。重要なのは、3 層が同じパターン定義(.gitleaks.toml)と同じ環境変数リストを共有し、メンテナンスコストを増やさないことです。Dolice Labs の複数サイトと 6 アプリを個人で並行運用している身としては、「守りが厚いこと」よりも「守りが薄くても破綻しないこと」を優先しています。エージェントに作業を委ねるほど、人間が握るべきは個々のチェックではなく、こうした層の設計そのものになっていくのだと思います。
同じように複数のプロジェクトをエージェントと進めている方の、最初の一手の参考になれば幸いです。