この投稿では、Git 2.54 で導入された config-based hooks を使って、GitHub への意図しない情報漏洩を手元で食い止めるための pre-commit hook の構成を解説します。
この投稿で学べること
- Git 2.54 の config-based hooks の書き方と、従来方式との違い
-
git -c hook.<name>.enabled=false commitによる粒度の細かいバイパス - gitleaks では拾えない「個人情報入りデータダンプ」の止め方
-
git cat-file --batch-checkでステージ済みファイルのサイズを一括取得する方法
きっかけ
Git 2.54 で config-based hooks という機能が入りました。.git/hooks/<name> というスクリプトファイルを置く従来方式の代わりに、gitconfig に hook を1行ずつ書ける仕掛けです。hook.<name>.enabled = false で粒度よくオン・オフできるところが特徴になります。
ちょうど触ってみたかったタイミングで、マネーフォワードが GitHub 経由で個人情報370件を流出させたインシデントのニュースが出ました。
個人情報の取り扱いを伴うサービスの更新作業を行う過程で、個人情報が含まれたファイルが本来の管理手順から外れ誤ってGitHub上に保管されていた
事故の本質は「本来コードリポジトリに入るべきでないファイル種別」、おそらく顧客リストの CSV や Excel が、開発者の作業フォルダ経由で push まで通ってしまったところにあるようです。
組織レベルで本気で止めるなら、GitHub Push Protection、GitHub Advanced Security の secret scanning + custom patterns、CI での定期 gitleaks、そもそも本番データを開発端末に置かせない運用、といったあたりが本丸になります。とはいえ、個人がいま自分の手元でやれることもあります。Git 2.54 の新フックで、最初の一歩だけでも引いておきたいというわけです。
Git 2.54 の config-based hooks とは
config-based hooks とは、Git 2.54 で導入された、gitconfig 上に hook を宣言する新しい方式のことです。書き方はこれだけになります。
[hook "gitleaks"]
event = pre-commit
command = gitleaks git --staged --redact
event と command の2項目を書けば登録完了です。~/.gitconfig に書けばグローバル、リポジトリの .git/config に書けばリポジトリ単位になります。同じイベントに複数の hook を別々に登録できるのもポイントです。従来方式では1スクリプトに分岐をすべて詰めこむ必要がありました。
特に良い点は次の3つです。
-
git hook list <event>で登録されている hook を一覧できます -
hook.<name>.enabled = falseで hook 単位の無効化ができます - 1コミットだけ特定の hook だけを無効化したいときは
git -c hook.<name>.enabled=false commitで済みます
最後の挙動は重要です。これまでの --no-verify だと全部の hook を一気に止めてしまうので、シークレット検出 hook まで黙らせかねないという問題がありました。新方式では「いま邪魔な hook だけスキップ」が標準でできるようになっています。
環境の準備
新機能を使うには Git 2.54 以上が必要です。macOS なら Homebrew で brew install git を実行し、git --version で 2.54 以上が表示されれば準備完了です。
第1層: gitleaks
API 鍵やトークンの混入チェックは gitleaks に任せます。gitleaks とは、コミット前の差分などからシークレットらしき文字列を検出するスキャナのことです。
[hook "gitleaks"]
event = pre-commit
command = gitleaks git --staged --redact
ステージ中の差分だけスキャンするので速いです。手元では数十ms から数百ms で終わります。--redact オプションを付けることで、検出ログに秘密そのものが残らないようにしておきます。
gitleaks では止まらないもの
ところがマネーフォワードの事故で漏れたのは「氏名(アルファベット)+カード下4桁」という組み合わせでした。これは構造的なシークレットではないので、API 鍵パターンを拾う gitleaks にはほぼ引っかかりません。
代わりに、「そもそもデータダンプの形をしたファイルがコミットされない」を直接守るほうがよさそうです。考えられる導線は次の2つになります。
- 拡張子に着目する:
.csv.xlsx.sql.zip.tar.gzなどはソースリポジトリに本来入らないはず - サイズに着目する: 顧客データのような実データは本質的に大きく、100KB を超えるテキストファイルは普通のソースではほぼ無いはず
それぞれを独立した hook にして、独立にバイパスできる構成にしました。スクリプトは Bun(TypeScript)で書いています。
第2層: data-files hook
データダンプらしき拡張子を検知する hook です。スクリプトを ~/.config/git/hooks/data-files に置いて、gitconfig からはこう呼びます。
[hook "data-files"]
event = pre-commit
command = ~/.config/git/hooks/data-files
スクリプト本体は次のとおりです。
#!/usr/bin/env bun
// pre-commit: データダンプの拡張子を止める
// バイパス: git -c hook.data-files.enabled=false commit ...
import { $ } from "bun";
const BLOCKED =
/\.(csv|tsv|xlsx?|sql|dump|bak|sqlite3?|db|parquet|jsonl|ldif|zip|tar|tgz|tbz2|txz|7z|rar|gz|bz2|xz)$/i;
const out = await $`git diff --cached --name-only --diff-filter=AM -z`.text();
const hits = out.split("\0").filter((f) => f && BLOCKED.test(f));
if (hits.length === 0) process.exit(0);
console.error(
Bun.markdown.ansi(`
# data-files hook
データダンプらしき拡張子を検知:
${hits.map((f) => `- \`${f}\``).join("\n")}
中身に個人情報・機密が入っていないか確認し、問題なければ:
\`\`\`
git -c hook.data-files.enabled=false commit ...
\`\`\`
`),
);
process.exit(1);
このスクリプトは、ステージ済みの追加・変更ファイルのうち、.csv .xlsx .sql .zip .tar.gz といった「データダンプによく使われる拡張子」に該当するものをコミット直前で止めます。
なぜ拡張子で見るのが効くかというと、ソースコードリポジトリに本来必要なのはコードと設定ファイルであって、表形式データやアーカイブが混ざること自体が異常だからです。中身まで解析しなくても、拡張子という入口で弾くだけで「顧客リストが紛れこんだ」系の事故は実用的に防げます。誤検知が出たケースこそ、そのファイルが本当にリポジトリに入るべきかを立ち止まって考える機会になります。
git diff --cached --diff-filter=AM でステージ済みの追加・変更だけに絞っているのもポイントです。削除されるファイルは漏洩リスクと無関係なので除外しています。
第3層: large-file hook
ステージ中のファイルのバイトサイズを見て、しきい値を超えるものをブロックする hook です。gitconfig は次のように書きます。
[hook "large-file"]
event = pre-commit
command = ~/.config/git/hooks/large-file
スクリプト本体は次のとおりです。
#!/usr/bin/env bun
// pre-commit: 大きすぎるファイルを止める
// バイパス: git -c hook.large-file.enabled=false commit ...
import { $ } from "bun";
const LIMIT = 100 * 1024;
const out = await $`git diff --cached --name-only --diff-filter=AM -z`.text();
const files = out.split("\0").filter(Boolean);
if (files.length === 0) process.exit(0);
const refs = Buffer.from(files.map((f) => `:${f}`).join("\0"));
const sizes = (
await $`git cat-file ${"--batch-check=%(objectsize)"} -Z < ${refs}`.text()
)
.split("\0")
.filter(Boolean)
.map((s) => Number.parseInt(s, 10));
const hits = files.flatMap((file, i) =>
sizes[i] > LIMIT ? [{ file, size: sizes[i] }] : [],
);
if (hits.length === 0) process.exit(0);
console.error(
Bun.markdown.ansi(`
# large-file hook
${LIMIT / 1024} KB 超を検知:
${hits.map(({ file, size }) => `- \`${file}\` (${(size / 1024).toFixed(1)} KB)`).join("\n")}
中身に個人情報・機密が入っていないか確認し、問題なければ:
\`\`\`
git -c hook.large-file.enabled=false commit ...
\`\`\`
`),
);
process.exit(1);
このスクリプトは、ステージ済みファイルのバイトサイズを git cat-file --batch-check=%(objectsize) で一括取得し、LIMIT を超えたものを検出します。
なぜサイズで見るのが効くかというと、ソースコードや設定ファイルは基本的にそれほど大きくならないからです。手書きのコードで 100KB を超えるテキストはほぼ無く、そのサイズに達している時点で「データかバイナリが紛れこんでいる」と疑ってよい目安になります。第2層の拡張子チェックをすり抜けるパターン、たとえば拡張子なしのダンプや見慣れない拡張子のエクスポートも、サイズという別の角度から拾えるというわけです。
なお、しきい値は運用しながら調整するものです。プロジェクトの性質によっては大きめのフィクスチャや生成物を許容する必要があるので、最初はゆるめに設定して、引っかかった実例を見ながら絞っていくのが現実的です。
バイパスの設計
普段使いで一番効いてくるのは「バイパスをどう設計するか」だと思います。pre-commit hook は本来嫌われがちな存在で、「邪魔だな」と思われた瞬間に --no-verify が常用化して形骸化してしまいます。
新方式の git -c hook.<name>.enabled=false commit ... は粒度が良く、次の2つを同時に満たしてくれるところが優秀です。
- その回だけ無効化する(永続化しない)
- 特定の hook だけ無効化する(他の hook は普通に走る)
エラーメッセージの末尾には毎回コピペできるかたちで、このコマンドを出すようにしてあります。文言も「中身に個人情報・機密が入っていないか確認し、問題なければ」と、押す前に内省させるようにしました。
hook の限界と、本丸
書きながらずっと頭にあったことですが、ローカル hook には根本的な限界があります。
-
git commit --no-verifyで全部スキップできてしまう - 別マシン(他人の端末・CI・別の自分のマシン)では効かない
- そもそも hook を入れていない人を守れない
マネーフォワード規模の事故を本気で止めるなら、サーバ側にゲートを置く必要があります。
| 層 | 仕組み |
|---|---|
| 個人(これから入れる分) | 上記 hook |
| チーム共通 | GitHub Push Protection で push 時に PII やシークレットをサーバ側で強制ブロック |
| 組織 | GitHub Advanced Security の Secret Scanning + Custom patterns |
| 検知 | 定期 gitleaks / trufflehog を CI で全履歴スキャン |
| 物理的隔離 | 本番データは開発端末にダウンロードしない運用、マスキング済みの DB で開発 |
ニュースの「本来の管理手順から外れ誤って」という言い回しは、手順は整備されていたがハマる導線があったことを示唆しているようです。手順書だけでは事故は止まらないということで、仕組みで止める必要があるというわけです。
まとめ
- Git 2.54 の config-based hooks は、
eventとcommandを gitconfig に書くだけで登録完了になります。シェルスクリプトを置く必要はありません -
git -c hook.<name>.enabled=false commitで、1コミットだけ・特定 hook だけを無効化できます。--no-verifyを雑に使う必要がなくなります - gitleaks は API 鍵には強いですが、PII(顧客名一覧のようなもの)は守れません。ファイル種別とサイズに着目した別の hook を組み合わせます
- バイパスの文言は「ショートカット」ではなく「中身を確認したうえで」へ。一拍置かせる設計が大事です
- ただしこれは個人の防衛線にすぎません。本丸はサーバ側ゲート(GitHub Push Protection など)とデータ取扱フローの設計です
所感
最近のセキュリティインシデントは、誰かを責めるよりも、自分の作業フローを見つめなおすきっかけにしたいところです。Git 2.54 を機に、その「最初の一歩」を引いてみました。
最後までお読みくださりありがとうございました。Twitter では、Qiita に書かない技術ネタなどもツイートしているので、よかったらフォローしてもらえると嬉しいです
→Twitter@suin
