連携フォルダを選び直した朝のことです。ダイアログには、いつも使っている作業フォルダの名前が出ていました。迷う理由はないはずでした。
そこで手が止まりました。
このフォルダの中に何が入っているのか、私は正確には答えられなかったのです。四つのサイトのリポジトリ、運用ドキュメント、画像素材、過去のバックアップ。個人開発で何年も足していけば、当然そうなります。
見せる範囲を決める前に、まず数えることにしました。
先に結論 — 名前で守ろうとすると、4分の3を取り逃します
実際に走らせた結果を先に置きます。対象は日々の作業に使っている連携フォルダそのものです。
| 数え方 | ヒット | うち本当に鍵が入っていた | 取りこぼし |
|---|---|---|---|
| ファイル名で探す | 24 件 | 5 件 | 13 件を見逃す |
| 中身の文字列で探す | 18 件 | 18 件 | — |
私はこの二つを別々に走らせて、結果を突き合わせました。ファイル名に token や secret を含むかどうかで探すと24件が出ましたが、鍵の文字列が実際に入っていたのはそのうち5件でした。残る19件は「トークン数の数え方」を扱った記事ファイルのような、名前だけが引っかかったものです。誤検知が8割近い検査は、続けるうちに誰も見なくなります。
逆に、中身の文字列で探すと18件が出ました。このうち13件は、ファイル名に手がかりがひとつもありません。
名前で守るという発想は、ここで成り立たなくなります。
数えた対象 — 10万件のうち、読まれ得るのは1万4千件
最初に出た数字は 103,267 ファイル / 4.0 GB でした。ただ、この数字はほとんど意味を持ちません。大半は node_modules の中身だからです。
検査の対象からは、次を外しました。
| 除外したディレクトリ | 理由 |
|---|---|
node_modules | 自分が書いたものではなく、鍵を置く場所でもない。件数の9割近くを占める |
.next / dist / build | 生成物。元のソースを見れば足りる |
.git | 履歴の中身は別の検査(コミット済みの鍵の捜索)が必要で、目的が違う |
.wrangler | デプロイツールの作業領域 |
除外後は 14,814 ファイル / 2.1 GB になりました。1割強です。この絞り込みをしないと、検査は毎回数分かかり、結果の一覧も読む気を失う長さになります。
.git を外した点だけは補足させてください。履歴に残った鍵は、作業ツリーから消しても生き続けます。ただ、それは「今このフォルダを見せてよいか」とは別の問いです。ここでは作業ツリーだけを見ています。
ファイル名で探した24件のうち、本物は5件でした
空振りの19件は、すべて記事の原稿ファイルでした。トークン数の見積もりやレート制限を扱った記事のファイル名に token が入っていた、というだけの話です。
本物の5件は、こういう内訳でした。
- 個人アクセストークンを平文で控えたメモ … 1件
- その世代違いのバックアップ … 3件
- Webhook の署名鍵を控えたメモ … 1件
この19件には、見た目以上の代償がついてきます。外れてばかりの検査は、まず流し読みになり、次に実行の間隔が空き、最後は静かに回らなくなります。私も以前に同じような名前ベースの grep を回して「問題なし」と記憶していましたが、今から思えば、記事のファイル名が並んだところで読むのをやめていただけでした。精度は検査の飾りではなく、その検査が忙しい週を生き延びられるかどうかを決めます。
バックアップが3件という行を見て、しばらく手が止まりました。トークンを入れ替えたつもりでいても、古い値は別のファイル名で残り続けます。バックアップは、鍵の寿命を静かに延ばします。
.bak が混ざり込む問題は、コミットの側でも起きます。作業スクリプトが残した控えを git add -A が巻き込む経路については、git add -A が .bak バックアップまで巻き込む に別途まとめています。
中身で探して出てきた18件 — うち13件は名前に痕跡がありません
拡張子で分けると、こうなりました。
| 拡張子 | 件数 | 中身 |
|---|---|---|
.sh | 10 | 決済まわりの設定を作るために書いた使い捨てスクリプト |
.bak_*(日付つき) | 4 | ドキュメントとメモの世代バックアップ |
.txt | 3 | トークンと設定値の控え |
.md | 1 | 過去のつまずき記録。再現手順に実物が貼られていた |
一番多かったのはシェルスクリプトでした。商品と価格を一括登録する、Webhook を作る、説明文をまとめて差し替える。どれも一度動かして目的を果たし、そのまま残っていたものです。書いた時点では「これは手元だけで動かすもの」でしたが、その前提はフォルダを誰かに見せた瞬間に消えます。
そして .md の1件が、個人的にはいちばん堪えました。過去に踏んだ問題の記録に、再現手順として鍵の実物がそのまま貼ってあったのです。ドキュメントは鍵を置く場所ではない、という思い込みが、検査の穴になっていました。
「名前に痕跡がない13件」の正体は、要するに書いた本人が秘密だと思っていなかったファイルです。名前を付けた瞬間には秘密ではなく、後から中身が秘密になった。だから名前では見つかりません。
そのまま走る検査スクリプト
値を一切出力しない形で書いています。出力するのはファイルのパスと件数だけです。
#!/usr/bin/env bash
# 連携フォルダの露出面を数える。鍵の値は表示しない。
set -uo pipefail
ROOT="${1:?usage: scan-exposure.sh <folder>}"
PRUNE=( -name node_modules -o -name .next -o -name .git
-o -name .wrangler -o -name dist -o -name build )
NAME_PAT=( -iname "*token*" -o -iname "*secret*" -o -iname "*credential*"
-o -iname "*.pem" -o -iname "*.key" -o -iname ".env*"
-o -iname "id_rsa*" -o -iname "*api?key*" )
VALUE_RE='ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-ant-[A-Za-z0-9-]{20,}'
VALUE_RE="$VALUE_RE"'|sk_live_[A-Za-z0-9]{20,}|whsec_[A-Za-z0-9]{20,}|AIza[A-Za-z0-9_-]{30,}'
all=$(find "$ROOT" -type f 2>/dev/null | wc -l)
kept=$(find "$ROOT" \( "${PRUNE[@]}" \) -prune -o -type f -print 2>/dev/null | wc -l)
byname=$(find "$ROOT" \( "${PRUNE[@]}" \) -prune -o -type f \( "${NAME_PAT[@]}" \) -print 2>/dev/null | wc -l)
tmp=$(mktemp)
find "$ROOT" \( "${PRUNE[@]}" \) -prune -o -type f -size -2M -print0 2>/dev/null \
| xargs -0 grep -lIE "$VALUE_RE" 2>/dev/null > "$tmp"
printf 'files(all) %s\n' "$all"
printf 'files(scanned) %s\n' "$kept"
printf 'hits(by filename) %s\n' "$byname"
printf 'hits(by content) %s\n' "$(wc -l < "$tmp")"
printf -- '--- by extension ---\n'
sed 's|.*/||; s|^[^.]*$|(no ext)|; s|.*\.|.|' "$tmp" | sort | uniq -c | sort -rn
rm -f "$tmp"手元での実行結果です。
files(all) 103267
files(scanned) 14814
hits(by filename) 24
hits(by content) 18
--- by extension ---
10 .sh
3 .txt
1 .md
1 .bak_20260822
1 .bak_20260602b
1 .bak_2026-07-28
1 .bak_20260602
real 0m32.147s書き方の意図を三点だけ添えます。
grep -l はマッチしたファイル名だけを返し、行の中身は返しません。検査結果そのものが新しい漏洩経路にならないための選択です。-I はバイナリを飛ばします。画像素材が多いフォルダでは、これがないと出力が壊れます。-size -2M は巨大なログや生成物を対象から外すためのもので、鍵の控えが 2 MB を超えることはまずありません。
正規表現に並べたのは、接頭辞が決まっている形式の鍵だけです。接頭辞を持たない値は、この方法では拾えません。それを承知のうえで、拾える範囲を確実に拾う道具として使っています。
32秒という時間は、月に一度回すには十分に軽い水準でした。
数えた後に決めたこと
見つかった18件に対して、私が自分の運用に入れたのは次の三つです。
- 鍵の控えは連携フォルダの外へ出す。 フォルダ単位で渡す道具を使う以上、「このフォルダには秘密を置かない」を先に決めておかないと、境界が毎回揺れます。
- 控えのバックアップに世代を作らない。 3件のバックアップは、入れ替えたはずの値を生かし続けていました。控えは1つだけにして、更新は上書きにしています。
- 検査は名前ではなく中身で、月に一度。 79%が空振りする名前ベースの検査は、続ける価値がありませんでした。
片付けの順番も、最初は間違えました。見つけた瞬間に消したくなりますが、それはほとんど効きません。値はすでに共有されたフォルダの中に置かれていたのですから、ファイルが残っているかどうかとは無関係に、露出そのものは起きた後です。終わらせるのは鍵の入れ替えのほうです。しかも先に消すと、どの鍵がどこにあったかという手がかりまで失われます。入れ替えるべき対象を正しく選ぶには、その手がかりが要ります。ですので今は、入れ替える、移す、消す、の順にして、最後にもう一度検査を回して件数がゼロになったことを確かめています。
数えることは、あくまで入口です。次にあるのは「そもそも読ませない」段で、これは設定の側の仕事になります。OS の側で認証ファイルの読み取りを止める sandbox.credentials の設定と検証手順は、サンドボックスはコードを動かせても、認証ファイルは読ませない に実装込みで置いてあります。数えて実態を掴んだ後に読むと、どの設定がどの件数に効くのかが具体的に見えるはずです。
今日いちばん役に立ったのは、18件という数字そのものよりも、13件が名前では見つからなかったという事実のほうでした。自分の管理を過大評価していた分が、そのまま差になって出ていました。
もし手元に連携フォルダがあるなら、上のスクリプトを一度だけ走らせてみてください。件数がゼロなら、それは安心してよい根拠になります。ゼロでなければ、今日のうちに手を打てる分だけ手を打てます。私自身もまだ整理の途中です。お読みいただきありがとうございました。