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?

gitleaks カスタムルールを各リポジトリに配らない — スキャン時合成という設計

0
Last updated at Posted at 2026-06-14

TL;DR

  • 組織内の全リポジトリに対して、gitleaks で secret scan を回す、という構成を考える。
  • gitleaks デフォルトの generic-api-key ルールは「キーワード + 代入記号 (= : =>)」という形を前提にしているので、言語やフレームワークによって変わる「秘密情報の書き方」(メソッドの引数に直接渡す、定数定義マクロに渡す、など)を構造的に取りこぼす。
  • これを補うカスタムルールを 各リポジトリに commit して配ると、リポジトリごとにルールの内容がばらついていく(更新の PR が全リポジトリに行き渡らない、新規リポジトリが取り込み忘れられる)。
  • なので「配る」のではなく、スキャナが clone した直後に、リポジトリの言語を見て必要なルールを合成 (compose) してから gitleaks に渡すとよい。
  • こうすると、ルールは中央の1箇所にしか無く、常に最新版が全リポジトリに等しく効く。新規リポジトリも自動でカバーされ、リポジトリ固有の許可リスト(allowlist)も尊重される。

組織横断で secret scan を回す

複数の git リポジトリを抱える組織で、「どこかのリポジトリの履歴に API キーやトークンが直書きされていないか」を継続的に監視する構成を考えます。おおよそこうなります。

  1. 日次のスケジュール実行で、組織内の active なリポジトリを列挙する
  2. リポジトリごとに clone して、全履歴 (full history) に対して gitleaks をかける
  3. 検出結果を集約して、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ルールが効かない)ので、「回っているのに肝心のルールが効いていない」状態に注意。

ルールや設定を各リポジトリに配って同期させる構成は、リポジトリ数が増えるほど同期コストが効いてきます。そういう場面では、配らずに「実行時に合成する」という選択肢も検討に値します。

  1. gitleaks の組み込みルールは config/gitleaks.toml で確認できます。 ↩

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?