gitleaks は、API キーやアクセストークンなどのシークレットがリポジトリへ混入していないかを検査するツールです。CI やコミット前のフックでは、通常、gitleaks の終了コードが 0 なら「問題なし」と判断して後続処理を続けます。
ところが gitleaks 8.27.0 以降の gitleaks git には、内部で実行している git が stderr へ出力したとき、検査を途中で打ち切ったにもかかわらず no leaks found と表示し、終了コード 0 で終わる場合があります。実際にはシークレットを含むリポジトリでも発生します。
この問題は 8.27.0 から、この記事を書いている時点の最新リリースである 8.30.1 まで再現しました。8.26.0 では同じ条件でも終了コード 1 になります。関連する問題は issue #2129 や issue #1981 で報告されていますが、いずれも未解決です。
8.30.1 を使い続けるのであれば、少なくとも終了コードだけを信用するのは避けたほうがよさそうです。私が確認した範囲では、検査が git のエラーで打ち切られた場合、gitleaks の出力に ERR が含まれます。そのため「終了コードが 0 でも出力に ERR があれば失敗とする」という回避策で、今回の見逃しを検出できます。
動作確認環境は macOS 14.2.1、Git 2.55.0 です。以下で特にバージョンを書いていない結果は gitleaks 8.30.1 のものです。
まず、シークレットが見逃される状態を再現する
シークレットを含むコミットが 1 件あるリポジトリを用意します。たとえば ghp_ に続けて英数字 36 文字を並べたダミー文字列をコミットすると、gitleaks は GitHub のトークンとして検出します。
通常どおり実行すると、シークレットが検出され、終了コードは 1 になります。
$ gitleaks git --no-banner --redact
INF 31 commits scanned.
WRN leaks found: 1
$ echo $?
1
次に、同じリポジトリで GIT_TRACE を有効にします。GIT_TRACE を有効にすると git がトレース情報を stderr へ出力します。ここでは、実際の運用環境で発生し得る stderr 出力を意図的に再現するために使っています。
$ GIT_TRACE=1 gitleaks git --no-banner --redact
ERR error="stderr is not empty"
INF 0 commits scanned.
INF no leaks found
$ echo $?
0
シークレットを含む同じリポジトリを検査しているにもかかわらず、今度は no leaks found と表示され、終了コードも 0 になりました。検査そのものが正常に完了したわけではなく、git の stderr を検知した時点で途中終了しています。
この状態で 8 回実行したところ、8 回ともシークレットを見逃しました。ただし commits scanned は毎回同じではなく、0 または 1 になりました。
コミット前のフックで利用する gitleaks git --pre-commit --staged でも同様です。内部で実行する git コマンドが git log から git diff に変わりますが、問題が起きる処理経路自体は同じです。
GIT_TRACE だけの特殊な問題ではない
GIT_TRACE は再現を簡単にするために使っただけです。実際には、git が stderr へ出力するさまざまな状況で同じ問題が起こります。
代表的なのが safe.directory のエラーです。リポジトリの所有者とコマンドを実行するユーザーが異なる場合、git は安全ではないリポジトリとして処理を拒否することがあります。issue のコメントには、GitHub Actions のジョブコンテナーで gitleaks v8.29.0 を使った際に、この状態になった例も報告されています。
git-crypt のような差分フィルターも影響します。今回の問題が報告された issue では、git-crypt が stderr へ警告を出すケースが最初の例として挙げられています。
存在しないコミットを --log-opts に渡した場合も同じです。たとえば --log-opts="deadbeef..HEAD" のように解決できないコミット範囲を指定すると、git 自体は失敗しているにもかかわらず、gitleaks は終了コード 0 で終わります。似た問題は以前 issue #1450 として報告され、PR #1461 で修正されていましたが、8.27.0 以降では再び同じような動作になります。
さらに、git リポジトリではないディレクトリで gitleaks git を実行した場合も終了コード 0 になります。
私が確認したこれらのケースでは、いずれも gitleaks のログに ERR が出力されます。
safe.directory の問題は、Git の内部テスト用環境変数 GIT_TEST_ASSUME_DIFFERENT_OWNER=1 を使うと簡単に再現できます。
$ GIT_TEST_ASSUME_DIFFERENT_OWNER=1 gitleaks git --no-banner --redact
ERR [git] fatal: detected dubious ownership in repository at '/path/to/repo'
ERR error="stderr is not empty"
INF 0 commits scanned.
INF no leaks found
$ echo $?
0
最初には fatal: detected dubious ownership と出ています。それでも最後は no leaks found となり、終了コードは 0 です。CI が終了コードだけを見ていれば、この検査を成功として扱ってしまいます。
なお、git が stderr に何か書けば必ず検査が停止するわけではありません。rename 検出の上限や自動 gc の案内など、gitleaks が既知のものとして扱っている数種類のメッセージについては警告として処理され、検査が続行されます。
なぜ終了コード 0 になってしまうのか
v8.27.0 のソースを追うと、git で発生したエラーが最終的な終了コードまで伝播していないことが分かります。行番号は変わりますが、8.30.1 でも同じ流れです。
まず sources/git.go の listenForStdErr が git の stderr を読みます。既知のメッセージ以外が出力されると、stderr is not empty というエラーを生成します。
同じファイルの Fragments はこのエラーを受け取ると、yield(Fragment{}, err) でコールバックへ渡します。それと同時に差分を読み取るループを抜けるため、その後のコミットや差分は検査されません。
次に detect/detect.go の DetectSource がこのエラーを受け取ります。ここではエラーをログへ出力するものの、コールバックからは nil を返します。コードには Log the error and move on to the next fragment というコメントがありますが、実際には Fragments 側のループがすでに終了しているため、次の fragment の検査には進みません。
そして DetectSource 自体もエラーを返しません。その結果、cmd/root.go の findingSummaryAndExit には、検査中にエラーが起きたという情報が届きません。正常に最後まで検査したものとして no leaks found が表示され、終了コードも 0 になります。
findingSummaryAndExit 自体には、検査中にエラーがあった場合に partial scan completed と表示して終了コード 1 で終わる処理があります。問題は、その処理までエラーが届いていないことです。
gitleaks の README にある Exit Codes には 1 - leaks or error encountered と書かれています。つまり本来は、シークレットを検出した場合だけでなくエラーが発生した場合も 1 になる仕様と読めます。今回の動作は、この説明とも一致していません。
8.27.0 で入ったレグレッションと考えられる
どのバージョンから問題が発生するのかを確認するため、公式リリースのバイナリを使って、シークレットを含む同じリポジトリを検査しました。
結果は次のとおりです。GIT_TRACE=1 の列については各バージョンで 5 回ずつ実行しましたが、同じバージョンでは 5 回とも同じ終了コードになりました。
| バージョン | 通常の実行 | GIT_TRACE=1 |
safe.directory の拒否 |
存在しないコミット | git リポジトリ以外 |
|---|---|---|---|---|---|
| 8.24.0 | 1 | 1 | 1 | 1 | 1 |
| 8.25.1 | 1 | 1 | 1 | 1 | 1 |
| 8.26.0 | 1 | 1 | 1 | 1 | 1 |
| 8.27.0 | 1 | 0 | 0 | 0 | 0 |
| 8.27.1 | 1 | 0 | 0 | 0 | 0 |
| 8.27.2 | 1 | 0 | 0 | 0 | 0 |
| 8.28.0 | 1 | 0 | 0 | 0 | 0 |
| 8.29.0 | 1 | 0 | 0 | 0 | 0 |
| 8.29.1 | 1 | 0 | 0 | 0 | 0 |
| 8.30.0 | 1 | 0 | 0 | 0 | 0 |
| 8.30.1 | 1 | 0 | 0 | 0 | 0 |
8.26.0 までは git のエラーで検査が中断すると no leaks found in partial scan と表示され、シークレットを検出できていなくても終了コード 1 になります。8.27.0 から挙動が変わっています。
--pre-commit --staged についても同様でした。GIT_TRACE=1 を付けると、8.25.1 と 8.26.0 は終了コード 1、8.27.0 と 8.30.1 は 0 になります。
ソースを見ると、v8.26.0 の cmd/git.go は DetectGit を呼び、stderr で発生したエラーを呼び出し元まで返していました。一方、v8.27.0 の cmd/git.go では DetectSource を呼ぶ構造へ変更されています。
この変更は、v8.27.0 に含まれる PR #1872 のアーカイブ検査対応で入っています。issue #1450 のコメントでも、コントリビューターがこの PR によるレグレッションの可能性を指摘しています。
ただし、私は PR #1872 の直前と直後のコミットを個別にビルドして比較したわけではありません。そのため「PR #1872 が原因」と断定しているのではなく、8.26.0 と 8.27.0 のリリース単位の実測結果と、両バージョン間のソースコードの差分から、その可能性が高いと判断しています。
最新版に更新しても直らない
この記事を書いている時点の最新リリースである 8.30.1 でも問題は再現します。そのため、単純に gitleaks を最新版へ更新するだけでは解決しません。
master にも、この問題を解消する修正はまだ入っていません。v8.30.1 以降に master へ入った変更を確認すると、.github/ の設定、README、SECURITY.md、go.mod、go.sum などが中心で、今回の処理に関係する sources/git.go、detect/detect.go、cmd/root.go、cmd/git.go は同じ内容です。
bug ラベルが付いている issue #1981 にも、master で再現するという報告があります。
今回の問題に関連する修正 PR は複数ありますが、確認した時点ではいずれも未マージです。
- PR #2080: git の stderr のエラーを伝播する
- PR #2203: stderr により残りのコミットが検査されない問題を修正する
- PR #2252: 失敗した検査を 0 以外で終了させる
-
PR #2253: 読み取りエラーを
DetectSourceから返す - PR #2292: git コマンドの失敗をエラーとして返す
また、master の README 冒頭には、次の記述があります。
Gitleaks is feature complete. I'm not merging new features into Gitleaks. Future releases will be security patches only. I'm shifting my focus to Betterleaks
今後の gitleaks のリリースはセキュリティパッチのみとされています。今回の問題がその対象として修正されるのか、いつリリースされるのかは現時点では分かりません。
8.26.0 や Betterleaks に替える選択肢
8.30.1 をそのまま使わないのであれば、確認した範囲では gitleaks 8.26.0 と Betterleaks 1.9.0 が候補になります。
どちらも、先ほどの表と同じ 5 種類の実行ではすべて終了コード 1 になりました。一方、シークレットを含まない正常なコミット範囲を検査した場合は 0 になるため、単に常に失敗しているわけではありません。
ただし、8.26.0 へ戻すと 8.27.0 以降に入ったルールの変更や修正を利用できません。そのため、長期的に 8.26.0 へ固定するのはあまり勧めません。
Betterleaks 1.9.0 は、今回確認したコマンドについては gitleaks と同じ引数で実行できました。ただし、検出ルールが gitleaks と完全に同じかどうかまでは調べていません。また、開発中の 2.0.0 の候補版についても試していません。
Betterleaks にも、設定ファイルの読み込みに失敗した場合に終了コード 0 で終わるという未解決の issue #383 があります。こちらは issue の内容を確認しただけで、私は再現テストをしていません。
そのため、現時点での選択肢としては「gitleaks 8.30.1 に後述の回避策を組み合わせる」か、「Betterleaks 1.9.0 への移行を検討する」のどちらかが現実的だと考えています。
テストで一度シークレットを検出できても安全とは限らない
この問題を確認するときに注意したいのが、「stderr が出てもシークレットを検出できることがある」という点です。
Fragments は Go の select を使い、stderr のエラーと git の差分を同時に待っています。差分を先に処理できた場合は、検査が打ち切られる前にシークレットを見つけ、終了コード 1 になることがあります。
先ほどの再現で commits scanned の値が実行ごとに 0 または 1 と変わったのも、stderr のエラーを処理するまでに読み取れた差分の量が変化したためだと考えられます。
macOS では、stderr へ警告を出す差分フィルターを設定して 3 回実行したところ、3 回とも ERR を出しながらシークレット自体は検出しました。また GitHub Actions の Windows ランナーでは、GIT_TRACE=1 を付けた --pre-commit --staged でもシークレットを検出しました。一方、macOS で同じ条件を 3 回試すと、3 回ともシークレットを見逃しました。
この差が本当に処理順序によるものかまでは確認していません。ただし少なくとも、「テスト用のシークレットを入れてみたら検出できた」という一度の確認だけで、この問題の影響を受けないと判断するのは危険です。
JSON レポートを見ても、正常終了と区別できない
CI で gitleaks の標準出力ではなく、JSON レポートだけを利用している場合にも注意が必要です。
--report-format json --report-path を指定すると、検査が途中で打ち切られた場合でもレポートには空の配列 [] が出力されます。つまり、レポートだけを見ると「最後まで検査してシークレットが 0 件だった」のか、「途中で失敗して何も検査できなかった」のかを区別できません。
commits scanned の値も判定には向きません。今回の再現では 1 commits scanned と表示された実行でもシークレットを見逃しました。スキャン件数が 0 より大きいことを確認しても、検査が最後まで完了した保証にはなりません。
8.30.1 を使い続ける場合の回避策
gitleaks 8.30.1 を使い続けるのであれば、終了コードに加えてログ中の ERR を確認する方法が簡単です。
「gitleaks 自体の終了コードが 0 だったにもかかわらず、出力に ERR が含まれていたら検査失敗とする」という条件を追加します。
scan() {
status=0
output=$(gitleaks git --redact --no-banner "$@" 2>&1) || status=$?
printf '%s\n' "$output"
if [ "$status" -eq 0 ] && printf '%s\n' "$output" | grep -q 'ERR'; then
echo "git のエラーで検査が完了しませんでした" >&2
return 2
fi
return "$status"
}
status=0 と || status=$? の形にしているのは、呼び出し元で set -e が有効になっていても、gitleaks が終了コード 1 を返した時点でスクリプト全体が終了しないようにするためです。
この関数を使った場合、私の環境では、シークレットを含む正常な検査は 1、シークレットを含まない正常な検査は 0、GIT_TRACE=1、GIT_TEST_ASSUME_DIFFERENT_OWNER=1、--log-opts="deadbeef..HEAD" の各ケースは 2 になりました。
この方法には欠点もあります。gitleaks のログ形式に依存するため、--log-level fatal のようにログレベルを変更して ERR を出力しなくすると検出できません。また、gitleaks の将来バージョンでログの形式が変わる可能性もあります。バージョンを更新するときは、正常系と異常系の両方について、期待する終了コードになることを改めて確認したほうがよいでしょう。
さらに、git が出した一時的な警告であっても、gitleaks が ERR として扱えば CI を失敗させることになります。ただしシークレット検査では、「本当は検査できていないのに成功扱いになる」ほうがリスクが大きいため、この回避策では意図的に fail closed、つまり疑わしい場合は失敗として扱っています。
なお、8.27.0 より前の gitleaks と Betterleaks 1.9.0 では、今回確認したケースについてこの回避策は必要ありません。
gitleaks stdin を使う回避策には別の問題がある
関連 issue では、git の出力を gitleaks stdin へ渡す方法も回避策として紹介されています。
git log -p --all 2>/dev/null | gitleaks stdin
macOS で試したところ、この方法でもシークレット自体は検出できました。ただし、そのまま CI の代替手段として使うにはいくつか注意点があります。
まず、上のコマンドでは git の stderr を /dev/null へ捨てています。そのため git log 自体が失敗しても、gitleaks 側からは分かりません。実際、存在しないコミットを指定した場合や safe.directory で拒否された場合も、最終的には no leaks found と表示され、終了コード 0 になりました。
シェルの pipefail を有効にすれば、パイプの前段にある git の失敗を拾えます。私の確認では、先ほどの 2 ケースはいずれも終了コード 128 になりました。
もう一つの違いは検査対象です。gitleaks git は基本的にコミットで追加された行を検査しますが、git log -p の結果を gitleaks stdin に流すと、差分から削除された行も検査対象になります。そのため、すでに削除済みのシークレットまで検出し、通常の gitleaks git とは異なる結果になる可能性があります。
この方法を採用するのであれば、単に 2>/dev/null で stderr を捨てるだけではなく、少なくとも pipefail を含めて git 自体の失敗を検出できる構成にする必要があります。
まとめ
gitleaks 8.27.0 以降の gitleaks git では、内部で実行する git が stderr へ想定外の出力をすると、検査を途中で打ち切ることがあります。問題なのは、その状態でも no leaks found と表示され、終了コード 0 になることです。
私が確認した範囲では、この問題は 8.27.0 で発生するようになり、最新の 8.30.1 でも再現します。GIT_TRACE のような人工的な条件だけではなく、safe.directory、git-crypt、存在しないコミットの指定、git リポジトリ外での実行などでも発生します。
さらに、タイミングによっては stderr のエラーが発生していてもシークレットを検出できる場合があるため、「テスト用シークレットを一度検出できたから問題ない」とも言えません。JSON レポートや commits scanned の値だけで、検査が正常に完了したかどうかを判断するのも困難です。
8.30.1 を使い続けるのであれば、終了コードだけではなく出力の ERR も確認し、検査中にエラーが発生していた場合は CI を失敗させるのが簡単な回避策です。ツールを変更できるのであれば Betterleaks 1.9.0 も候補になります。一方、gitleaks 8.26.0 へ戻す方法は今回の問題を避けられるものの、その後のルールや修正を取り込めないため、長期的な固定先としては選びにくいでしょう。
シークレットスキャンは「検出できたか」だけでなく、「最後まで正常に検査できたか」を確認する必要があります。gitleaks 8.27.0 以降を CI や pre-commit hook で利用している場合は、一度、git 側でエラーを発生させたときの終了コードまで確認しておくことを勧めます。
参考情報
- gitleaks issue #2129: git の stderr で検査を打ち切り exit 0 で終わる問題
- gitleaks issue #1981: git のエラーが無視される問題
- gitleaks issue #1464: 不正なフラグの組み合わせで exit 0 を返す問題
- gitleaks issue #1450: 不正な
--log-optsが無視される問題 - gitleaks PR #1461:
DetectGitの失敗を 0 以外で終了させる修正 - gitleaks PR #1872: アーカイブ検査の追加
- gitleaks PR #2080: git の stderr のエラーを伝播する修正
- gitleaks PR #2203: stderr で残りのコミットが検査されない問題の修正
- gitleaks PR #2252: 失敗した検査を 0 以外で終了させる修正
- gitleaks PR #2253: 読み取りエラーを
DetectSourceから返す修正 - gitleaks PR #2292: git コマンドの失敗をエラーとして返す修正
- gitleaks v8.27.0
sources/git.go:listenForStdErr - gitleaks v8.27.0
sources/git.go:Fragmentsのエラー処理 - gitleaks v8.27.0
detect/detect.go:DetectSourceのコールバック - gitleaks v8.27.0
cmd/root.go:findingSummaryAndExit - gitleaks v8.27.0
cmd/git.go:DetectSourceの呼び出し - gitleaks v8.26.0
cmd/git.go:DetectGitの呼び出し - gitleaks v8.26.0
detect/git.go: stderr のエラーを返す処理 - gitleaks
masterREADME - gitleaks v8.27.0 README: Exit Codes
- Git:
GIT_TRACE - Git:
safe.directory - git-crypt
- Betterleaks
- Betterleaks issue #383