TL;DR
- 組織内の全リポジトリに対して、gitleaks で secret scan を回す、という構成を考える。
- gitleaks デフォルトの
generic-api-keyルールは「キーワード + 代入記号 (=:=>)」という形を前提にしているので、言語やフレームワークによって変わる「秘密情報の書き方」(メソッドの引数に直接渡す、定数定義マクロに渡す、など)を構造的に取りこぼす。 - これを補うカスタムルールを 各リポジトリに commit して配ると、リポジトリごとにルールの内容がばらついていく(更新の PR が全リポジトリに行き渡らない、新規リポジトリが取り込み忘れられる)。
- なので「配る」のではなく、スキャナが clone した直後に、リポジトリの言語を見て必要なルールを合成 (compose) してから gitleaks に渡すとよい。
- こうすると、ルールは中央の1箇所にしか無く、常に最新版が全リポジトリに等しく効く。新規リポジトリも自動でカバーされ、リポジトリ固有の許可リスト(allowlist)も尊重される。
組織横断で secret scan を回す
複数の git リポジトリを抱える組織で、「どこかのリポジトリの履歴に API キーやトークンが直書きされていないか」を継続的に監視する構成を考えます。おおよそこうなります。
- 日次のスケジュール実行で、組織内の active なリポジトリを列挙する
- リポジトリごとに clone して、全履歴 (full history) に対して gitleaks をかける
- 検出結果を集約して、Issue や Slack に通知する
gitleaks はデフォルトで多数のプロバイダトークン(GitHub / Stripe / Slack / AWS / Google …)の検知ルールを内蔵しています。実際、デフォルト設定には2026年6月時点で200以上のルールが入っており1、useDefault = true を指定するだけでこれらが効きます。これだけでも相当カバーできます。
問題は、デフォルトルールが拾えない領域をどう埋めるかです。
問題:generic-api-key は「代入記号」が無いと取りこぼす
gitleaks の汎用検知ルール generic-api-key は、おおまかに言うと
(api|token|secret|key|...) <代入記号: = : => など> "<ランダム性の高い文字列>"
という形を探します。つまり 「キーワードのすぐ後ろに代入記号が来る」 ことが前提です。
これは gitleaks の不具合ではなく、誤検知を抑えるための意図的なトレードオフです。generic-api-key は「secret らしいキーワード」と「代入らしい文脈」が揃ったときだけ拾うことで誤検知を減らしており、その代わりに、文脈が違う書き方は意図的に取りこぼし得ます。つまりここで埋めたいのは「バグ」ではなく「汎用ルールがカバーしない範囲」です。
ところが言語やフレームワークによっては、秘密情報の渡し方がこの形になりません。例えば:
// Go / メソッドの引数に直接渡す — 代入記号 "=" が無い
client.SetSecretToken("AReal-Looking-Secret-Value")
# Python / キーと値を引数で渡す — 間にあるのは "," 区切り
secrets.set("DB_PASSWORD", "AReal-Looking-Secret-Value")
// PHP / メソッドチェーンで値を渡す書き方
$client->setRefreshToken('AReal-Looking-Secret-Value');
// Java / ビルダーパターン
new ApiClient.Builder().withAccessToken("AReal-Looking-Secret-Value");
いずれも「キーワード」と「値」は近くにあるのに、間にあるのが = ではなく ( や , なので、generic-api-key の形に当てはまらずすり抜けます。フレームワーク固有の SDK を多用しているコードベースほど、この穴は無視できません。
なので、こうした書き方を拾うカスタムルールを足したくなります。問題はその先です。
素朴な解と、その限界:ルールを各リポジトリに配る
gitleaks はリポジトリ直下の .gitleaks.toml を読みます。なので一番素朴なやり方は、カスタムルールを書いた .gitleaks.toml を各リポジトリに commit して回ることです。
# 各リポジトリに置く .gitleaks.toml(案)
[extend]
useDefault = true
[[rules]]
id = "method-arg-secret"
# ... メソッドの引数に渡された秘密情報を拾うカスタムルール ...
これは動きはします。が、組織のリポジトリ数が増えるほど、維持コストが急速に膨らんでいきます。
- 更新が人依存:ルールを更新するたびに全リポジトリへ PR を出してレビュー・マージしてもらう必要がある。1本でも止まると、そのリポジトリだけ古いルールのまま残る。
-
新規リポジトリの取り込み漏れ:新しく作られたリポジトリは、誰かが気づいて
.gitleaks.tomlを置くまで素のデフォルトルールのまま。 - 検知品質がリポジトリごとにばらつく(これが一番の問題):結果として「リポジトリ A は最新ルール / B は半年前 / C は未導入」という状態が生まれる。secret scan は 全リポジトリに同じ網をかけてこそ意味があるのに、肝心の網の目が揃わなくなる。
手間が増えるだけなら我慢もできますが、本当に怖いのは最後の点です。ルールという「中央で一元管理したい資産」を各リポジトリに分散コピーすると、最も避けたい「カバレッジの穴」が組織の中にまだらに生まれる——これが構造的な弱点です。
解決策:スキャン時合成 (compose at scan time)
発想を逆にします。ルールは中央のスキャナだけが持つ。各リポジトリには配らない。 そのかわり、スキャンの直前に合成します。リポジトリごとの流れはこうです。
ポイントは、合成した config をファイルとしてリポジトリに commit しないことです。スキャンのその場で組み立てて gitleaks に食わせ、終わったら捨てる。これで:
- ✅ ルールは中央の1箇所にしかない → 常に最新版が全リポジトリに効く。PR のマージは不要。
- ✅ 新規リポジトリも、言語さえ判定できれば自動でカバーされる。
- ✅ リポジトリ固有の allowlist や
.gitleaksignore(誤検知の抑制)は合成後も尊重される。
実装
仕組みはシンプルで、3つの部品でできます。
1. 検知ルールは「単体で完結しない fragment」にしておく
カスタムルールは、言語/フレームワークごとに分割して持っておきます。重要なのは、これらを単体では gitleaks config として成立しない fragment にしておくことです。[extend] を持たず、[[rules]](と必要なら [[allowlists]])だけを書いておく。
# 言語別 fragment の一例。[extend] は持たない。
#
# メソッド呼び出し (.method() / ->method()) の引数に渡された secret リテラルを拾う。
# generic-api-key は代入記号が前提なので、この形を拾えない。
[[rules]]
id = "method-arg-secret"
description = "メソッド引数に渡された secret リテラル"
regex = '''(?i)(?:\.|->)\s*(?:set|with)\w*(?:token|secret|key|password|auth|credential)\w*\(\s*['"`]([A-Za-z0-9+/=_-]{16,})['"`]'''
entropy = 3.5
[rules.allowlist]
regexTarget = "match"
regexes = [
'''(?i)(test[_-]|dummy|example|placeholder|sample|mock|fake|your[_-]|changeme|foo|bar|baz)''',
]
entropy でランダム性の低い文字列を弾き、[rules.allowlist] で test_ や dummy 等の明らかなダミー値をルール単位で除外しています。
fragment にしておくと、後段で「[extend] をどう前置するか」を合成側が自由に決められます。これが後述の「併用できない問題」の回避にも効いてきます。
2. リポジトリの言語/フレームワークを判定する
clone 済みのディレクトリの中身から、リポジトリの言語やフレームワークを判定し、それに対応するルール fragment を選びます。言語に依存しない汎用ルール(プロバイダ共通のトークン形式など)は、判定結果に関わらず常に載せておくと取りこぼしが減ります。
3. 合成:[extend] を前置して fragment を連結する
最後に1個の config 文字列を組み立てます。ここがキモです。
let header: string;
if (repoConfig) {
// リポジトリに .gitleaks.toml がある → それを path extend で取り込む
header = `[extend]\npath = ${JSON.stringify(repoConfig)}`;
} else {
// 無ければデフォルトルールを直接有効化する
header = `[extend]\nuseDefault = true`;
}
// header の後ろに、選ばれたルール fragment をただ連結する
const blocks = [header];
for (const fragmentPath of selectedFragments) {
blocks.push((await readFile(fragmentPath, 'utf8')).trimEnd());
}
const config = blocks.join('\n\n') + '\n';
- リポジトリに
.gitleaks.tomlがあれば、それを[extend] path = ...で丸ごと取り込みます。リポジトリ側がuseDefault = trueを書いていれば、その経由で gitleaks のデフォルトルールも入ります。リポジトリ固有の allowlist もここで一緒に取り込まれます。 - リポジトリに何も無ければ、
[extend] useDefault = trueを直接置くだけ。 - どちらの場合も、その後ろにルール fragment をテキスト連結して終わり。
.gitleaksignore(fingerprint 単位での誤検知抑制)は、gitleaks が実行時にリポジトリ直下から自動で読むので、合成側では何もしなくても効きます。
できあがる config の例
「リポジトリ固有の .gitleaks.toml がある」ケースで、入力と出力を並べるとこうなります。
入力① リポジトリ側の .gitleaks.toml(リポジトリにそのまま commit されているもの)
[extend]
useDefault = true # ← これが無いとデフォルトルールが落ちる(後述)
[[allowlists]]
description = "テストフィクスチャのダミー値"
paths = ['''tests/fixtures/.*''']
入力② 中央が持つルール fragment(言語判定で選ばれたもの。[extend] は持たない)
[[rules]]
id = "method-arg-secret"
description = "メソッド引数に渡された secret リテラル"
regex = '''(?i)(?:\.|->)\s*(?:set|with)\w*(?:token|secret|key|password|auth|credential)\w*\(\s*['"`]([A-Za-z0-9+/=_-]{16,})['"`]'''
entropy = 3.5
出力 合成された 1 個の config(スキャン時にその場で生成され、gitleaks に渡される)
[extend]
path = "/path/to/repo/.gitleaks.toml"
[[rules]]
id = "method-arg-secret"
description = "メソッド引数に渡された secret リテラル"
regex = '''(?i)(?:\.|->)\s*(?:set|with)\w*(?:token|secret|key|password|auth|credential)\w*\(\s*['"`]([A-Za-z0-9+/=_-]{16,})['"`]'''
entropy = 3.5
ここで効いてくるのが、リポジトリ側の allowlist と(useDefault = true 経由の)デフォルトルールは、合成テキストに直接コピーされるわけではないという点です。出力に並ぶのは [extend] path = ... というヘッダと fragment だけ。リポジトリの allowlist もデフォルトルールも、gitleaks が path を辿って再帰的に読み込みます。だからこそ、次の落とし穴が刺さります。
見落としやすい落とし穴:[extend] の useDefault と path は併用できない
ここは特に気をつけたいポイントです。[extend] には、デフォルトルールを取り込む useDefault = true と、別の config を取り込む path = "..." があり、ここに2つハマりやすい仕様があります(挙動は gitleaks v8.30.1 で確認)。
① 同じ [extend] に両方は書けない。 useDefault = true と path を同時に指定すると、gitleaks は extend.path and extend.useDefault being set というエラーで停止します。なので合成では、リポジトリ固有 config を取り込むなら path、無ければ useDefault と、必ずどちらか一方だけを前置します(前掲のコードがそうしているのはこのため)。
② path で extend しても、デフォルトルールは自動では入らない。 本当に怖いのはこちらです。path で取り込んだ config の中に useDefault = true が無いと、gitleaks の約200個のデフォルトルールは読み込まれません。custom fragment のルールだけが効く状態になり、fragment にマッチする秘密が無ければ検出 0 件に見えますが、実際は主要プロバイダのトークンを丸ごと取りこぼしている。エラーも警告も出ないので、「回っているのに肝心のルールが効いていない」状態に静かに陥ります。
つまり、リポジトリ固有 config を path で取り込む設計では、デフォルトルールを有効にする責任はリポジトリ側の config(useDefault = true)に委ねるしかありません。そこが抜けていないかを合成時にチェックしておくと安全です。
const content = await readFile(repoConfig, 'utf8');
// path extend するのに useDefault=true が無い → デフォルトルールが落ちる懸念
const missingUseDefault = !/useDefault\s*=\s*true/.test(content);
この例は簡略化のため正規表現で見ていますが、コメントアウト行や文字列にマッチして誤判定し得ます。確実を期すなら、TOML として parse して
[extend].useDefaultの値を見るのが安全です。
missingUseDefault が立ったらログに出して気づけるようにしておきます。
関連して、ルール ID をリネームすると、それを参照していた既存の
.gitleaksignoreの fingerprint(<commit>:<file>:<rule-id>:<line>という形式)が無効になり、抑制していたはずの誤検知が復活します。ルール ID は安易に変えないほうがいいです。
トレードオフ
良いことばかりではありません。
- 合成処理が単一障害点になる:ここがバグると全リポジトリのスキャンに影響する。普通のアプリコードと同じくユニットテストを厚くしておきたい(ファイル書き出しと判定ロジックを分離し、純関数寄りに切り出すとテストしやすい)。
- リポジトリ側から「自分にどんなルールが当たっているか」が見えにくい:config がリポジトリに commit されないので、透明性は下がる。ドキュメントや、手元で合成結果を確認できる手段で補うとよい。
- 言語判定に依存する:判定を外すとルールが載らない。判定が外れたときの取りこぼしをどう防ぐか(汎用ルールを常に載せる等)は別途設計が要る。
それでも、「ルールという中央資産を分散コピーしない」というメリットのほうが、組織のリポジトリ数が増えるほど効いてきます。
運用上の注意:スキャナ自身を漏洩源にしない
スキャナは「全リポジトリの全履歴」という、組織で最も秘密情報が集まる対象を毎日触ります。スキャナ自体が漏洩源にならないよう、最低限これは押さえたいところです。
-
通知に生の秘密値を載せない:gitleaks の検出結果には secret 本体やその周辺行が含まれ得ます。Issue や Slack に流す前に
--redactでマスクするか、そもそも secret 本体を通知に含めず、検出箇所のメタ情報(リポジトリ / ファイル / ルール ID)だけ送る。 - 作業ディレクトリは短命に:clone した中身は実行後すぐ破棄する。CI の artifact に残す場合も保存期間を最小限にする。
- トークンの権限を絞る:リポジトリ列挙と clone に必要な read 権限だけの token を使い、書き込みは通知(Issue 起票)に要る範囲にとどめる。
-
allowlist を監査する:リポジトリ固有の
.gitleaks.toml/ allowlist を尊重する設計は便利な反面、過剰に広い allowlist を置けば検知を自己無効化できてしまう。組織横断スキャンでは、極端に広い除外設定を中央側で検出・レビューする仕組みを別に持っておくと安心です。
まとめ
- 組織横断の secret scan で、言語/フレームワーク固有のカスタムルールを 各リポジトリに配ると内容がばらついていく。
- ルールは中央に集約し、スキャン時に clone 済みリポジトリへ合成 (compose) して gitleaks に渡すことで、最新ルールを全リポジトリにばらつき無く効かせられる。
- 合成は「言語を判定 → ルール fragment を選ぶ →
[extend]を前置して連結」だけのシンプルな仕組み。 - gitleaks の
[extend]はuseDefaultとpathを両方書くとエラーになる。一方、pathだけで extend するとデフォルトルールが黙って外れる(取り込み先がuseDefault = trueを持たないと約200ルールが効かない)ので、「回っているのに肝心のルールが効いていない」状態に注意。
ルールや設定を各リポジトリに配って同期させる構成は、リポジトリ数が増えるほど同期コストが効いてきます。そういう場面では、配らずに「実行時に合成する」という選択肢も検討に値します。
-
gitleaks の組み込みルールは
config/gitleaks.tomlで確認できます。 ↩