1
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?

手元の84リポジトリを数えたら、.env は63%が gitignore 済みで .claude/ は18%だった

1
Posted at

.env.gitignore に入れるのは、もう考えなくてもやる。15年かけて反射になった。

.claude/ は違う。まだ誰の反射にもなっていない。

手元のマシンにある git リポジトリを全部数えたら、その差が数字で出た。

数えたもの

~/git を深さ4まで、~ を深さ2まで走査して .git を持つディレクトリを集める。見つけたらその下は掘らない(サブモジュールと node_modules の中の偽リポジトリを避けるため)。84個あった。

各リポジトリで git ls-files を取り、.gitignore を読む。

=== git リポジトリ 84 個(~/git 深さ4 + ~ 深さ2)
  .env を gitignore 済み        53 =  63.1%
  .claude/ が存在する             42 =  50.0%
  .claude/ を git 追跡          27 =  32.1%
  .claude を gitignore 済み     15 =  17.9%
  .env 実体を git 追跡            10 =  11.9%
  .gitignore が無い              9 =  10.7%

.claude/ があるのは半数。そのうち27/42 = 64%が git に入っている。.gitignore.claude を書いているのは18%。

「.claude/ が git に入っている」は事故ではない

ここで止めて「32%が漏らしている」と書くのは誤りだった。.claude/ は共有される前提の場所でもある。CLAUDE.mdagents/commands/ はチームで共有したい。追跡していること自体は正常な運用でありうる。

だから中身を見た。

=== .claude/ の中身
  追跡されている .claude/ 配下ファイル 1,370
    .md                     1332 =  97.2%
    settings.local.json       16 =   1.2%
    .py                        8 =   0.6%
    .gitignore                 4 =   0.3%
    settings.json              3 =   0.2%

97.2%が Markdown。資格情報らしい文字列(sk-ant-AKIAxox[bp]-、秘密鍵ヘッダ)の一致はゼロ件だった。.claude/ を追跡していること自体は、ほぼ無害だった。

仮説は外れた。ただし、外れた先に別のものが落ちていた。

名前が local と言っているファイルが16本コミットされていた

  settings.local.json を追跡 16 本 / 絶対パスを含む 4 / JSON が壊れている 1
    中に入っている許可ルール 611 件 / 1本あたり 中央値 14 最大 248

settings.local.json は Claude Code が「今後確認しない」を押したときに許可ルールを書き足す先で、.local はマシンローカルという意味だ。それが16リポジトリで git に入っていた。合計611件の許可ルールが、コミットされて他人のマシンに配られている。

  • 4本は /Users/<名前> を含む。他人のマシンでは絶対に一致しない。
  • 1本に至っては JSON として壊れている(13行目でカンマが足りない。マージ衝突マーカーは無い)。

壊れた settings.local.json がコミットされて残っているのは、誰も読んでいないからだ。読む消費者がいれば、その時点で落ちて気づく。落ちないまま通ったものは、そのまま残る。

そして許可ルールについては、別の記事で測ったとおり、完全一致で書かれたルールは実行時に一度も一致しない。つまりコミットされた611件の大半は、配った先でも動かない。動かないものが git 履歴に残っているだけになる。

.env のほうは、絞ると小さくなる

比較のために .env も同じ深さまで見た。「追跡されている .env がある」は10リポジトリ(11.9%)。だがこれをそのまま「10件の漏洩」と書くと嘘になる。段階的に絞る。

=== .env をどこまで絞れるか
  追跡されている .env 実体              20本
  中身に実値のある行                   120行
  うちキー名が資格情報を示す行           34行 = 28.3%
  うちプレースホルダ                     5行
  残り(本物の可能性がある行)           29行 / 9 リポジトリ
  • .env.example / .sample / .template / .dist は名前で落とす。テンプレートは漏れではない。
  • 値が12文字未満、true / false / localhost / 環境名だけの行も落とす。
  • 残った120行のうち、キー名が KEY|SECRET|TOKEN|PASSWORD|DSN|AUTH を含むのは34行、28.3%だけだった。
  • そこから your- xxx change_me <...> のようなプレースホルダを5行落として、29行。

最初の「10リポジトリで .env を追跡」から、実際に見るべきなのは29行になった。3分の1以下だ。

キー名の上位はこうなっている(値は一切出さない)。

    JWT_SECRET_KEY                     5
    OPENAI_API_KEY                     4
    APP_KEY                            3
    VITE_PUSHER_APP_KEY                3
    GOOGLE_AI_API_KEY                  2
    JWT_SECRET                         2
    AUTH_SECRET                        2
    SENDGRID_API_KEY                   2

.env.test.env.development が多く、本番の鍵ではないものが混ざっている。それでも29行は目視する価値がある。

ついでに出た数字

  同名リポジトリが複数パスに存在 11 名前 / 実体 23 個

同じ名前のリポジトリが2〜3箇所に clone されている。~/git/tmp/~/git/ に同じものがある、~/Downloads/ にもある、といった具合だ。パスは別なので集計上の重複ではなく、ディスク上に本当に複数ある。

.gitignore を1箇所で直しても、残りのコピーは直らない。棚卸しの対象が実は23個ある、ということでもある。

スクリプト

出力するのは件数・割合・キー名だけで、リポジトリ名・パス・値は一切出さない。

#!/usr/bin/env python3
"""ローカルの全 git リポジトリで .env と .claude/ がどう扱われているかを数える。

出力は件数と割合とキー名だけ。リポジトリ名・パス・値は一切出さない。
"""
import os, re, json, subprocess, collections

SKIP = {"node_modules", ".venv", "venv", ".next", "dist", "build", ".git",
        "__pycache__", "vendor", "target", ".cache", "site-packages", ".terraform"}
# テンプレートとして置かれる .env は漏れではない。名前で落とす。
TEMPLATE = re.compile(r"example|sample|template|\.dist$|dummy|placeholder", re.I)
# 値が資格情報らしいか。短い値・真偽値・数値・環境名は落とす。
NOISE = re.compile(r"^(true|false|null|none|localhost|127\.0\.0\.1|\d+|"
                   r"development|production|staging|test|local|debug|info)$", re.I)
SECRETKEY = re.compile(r"KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL|PRIVATE|DSN|_PW|AUTH", re.I)
PLACE = re.compile(r"your|xxx|change[_-]?me|here|<.*>|\.\.\.|todo|replace|insert|000000|abcdef", re.I)


def find_repos(root, maxdepth):
    """.git を持つディレクトリを探す。見つけたらその下は掘らない。"""
    out, root = [], os.path.expanduser(root)

    def rec(d, depth):
        if depth > maxdepth:
            return
        try:
            entries = list(os.scandir(d))
        except OSError:
            return
        if ".git" in {e.name for e in entries}:
            out.append(d)
            return
        for e in entries:
            if e.is_dir(follow_symlinks=False) and e.name not in SKIP and not e.name.startswith("."):
                rec(e.path, depth + 1)

    rec(root, 0)
    return out


def tracked(d):
    try:
        return [x for x in subprocess.run(["git", "-C", d, "ls-files"],
                capture_output=True, text=True, timeout=30).stdout.split("\n") if x]
    except Exception:
        return []


repos = sorted(set(find_repos("~/git", 4) + find_repos("~", 2)))
c = collections.Counter()
env_files = val_lines = sec_lines = place_lines = 0
claude_files = 0
kinds = collections.Counter()
local_files = local_abs = local_broken = local_rules = 0
per_file = []
keynames = collections.Counter()
leak_repos = set()

for d in repos:
    c["リポジトリ"] += 1
    tr = tracked(d)
    gi = os.path.join(d, ".gitignore")
    gt = open(gi, encoding="utf-8", errors="replace").read() if os.path.isfile(gi) else ""
    if not gt:
        c[".gitignore が無い"] += 1
    if ".env" in gt:
        c[".env を gitignore 済み"] += 1
    if ".claude" in gt:
        c[".claude を gitignore 済み"] += 1
    if os.path.isdir(os.path.join(d, ".claude")):
        c[".claude/ が存在する"] += 1

    tc = [x for x in tr if x.startswith(".claude/")]
    if tc:
        c[".claude/ を git 追跡"] += 1
        claude_files += len(tc)
    for x in tc:
        kinds[os.path.basename(x) if os.path.basename(x).startswith("settings")
              else "." + x.rsplit(".", 1)[-1]] += 1
        if not x.endswith("settings.local.json"):
            continue
        local_files += 1
        try:
            t2 = open(os.path.join(d, x), encoding="utf-8", errors="replace").read()
        except OSError:
            continue
        if re.search(r"/Users/[a-z]+", t2):
            local_abs += 1
        try:
            j = json.loads(t2)
        except Exception:
            local_broken += 1
            continue
        al = (j.get("permissions") or {}).get("allow") or []
        if al:
            local_rules += len(al)
            per_file.append(len(al))

    envs = [x for x in tr if os.path.basename(x).startswith(".env") and not TEMPLATE.search(x)]
    if envs:
        c[".env 実体を git 追跡"] += 1
        env_files += len(envs)
    for x in envs:
        try:
            lines = open(os.path.join(d, x), encoding="utf-8", errors="replace").read().split("\n")
        except OSError:
            continue
        for ln in lines:
            ln = ln.strip()
            if not ln or ln.startswith("#") or "=" not in ln:
                continue
            k, v = ln.split("=", 1)
            v = v.strip().strip("'\"")
            if len(v) < 12 or NOISE.match(v):
                continue
            val_lines += 1
            if not SECRETKEY.search(k):
                continue
            sec_lines += 1
            keynames[k.strip().upper()] += 1
            if PLACE.search(v):
                place_lines += 1
            else:
                leak_repos.add(d)

n = c["リポジトリ"]
print(f"=== git リポジトリ {n} 個(~/git 深さ4 + ~ 深さ2)")
for k, v in c.most_common():
    if k != "リポジトリ":
        print(f"  {k:24} {v:4} = {v / n * 100:5.1f}%")

print(f"\n=== .env をどこまで絞れるか")
print(f"  追跡されている .env 実体            {env_files:4}")
print(f"  中身に実値のある行                  {val_lines:4}")
print(f"  うちキー名が資格情報を示す行         {sec_lines:4}行 = {sec_lines / val_lines * 100:.1f}%")
print(f"  うちプレースホルダ                  {place_lines:4}")
print(f"  残り(本物の可能性がある行)         {sec_lines - place_lines:4}行 / {len(leak_repos)} リポジトリ")
print("  キー名 上位:")
for k, v in keynames.most_common(8):
    print(f"    {k[:34]:34} {v}")

print(f"\n=== .claude/ の中身")
print(f"  追跡されている .claude/ 配下ファイル {claude_files:,}")
for k, v in kinds.most_common(5):
    print(f"    {k:22} {v:5} = {v / claude_files * 100:5.1f}%")
med = sorted(per_file)[len(per_file) // 2] if per_file else 0
print(f"  settings.local.json を追跡 {local_files}"
      f" / 絶対パスを含む {local_abs} / JSON が壊れている {local_broken}")
print(f"    中に入っている許可ルール {local_rules} 件 / 1本あたり 中央値 {med} 最大 {max(per_file) if per_file else 0}")
dupe = collections.Counter(os.path.basename(r) for r in repos)
d2 = {k: v for k, v in dupe.items() if v > 1}
print(f"  同名リポジトリが複数パスに存在 {len(d2)} 名前 / 実体 {sum(d2.values())}")

自分の環境で数える

上のスクリプトを保存して python3 で走らせると、自分のマシンの全リポジトリで同じ集計が出る。走査対象は ~/git 深さ5、~ 深さ3までなので、リポジトリを別の場所に置いているなら先頭の探索パスだけ直せばいい。

見るべき数字は1つに絞れる。settings.local.json を追跡 N 本の N だ。

ここが0でなければ、他人のマシンで意味を持たない許可ルールを配っている。1本でもあれば対処は同じで、.gitignore に1行足して git rm --cached する。件数は関係ない。

.env 側は、絞り込み前後の差を見るほうが早い。手元では素朴なパターン一致で拾った候補のうち、テンプレートと短い値を落とすと29行まで減った。この差が大きいほど、素朴なスキャンをそのまま信じてはいけない環境だということになる

この計測の限界

  • 深さで打ち切っているので、~/git 深さ5以上や ~ 深さ3以上のリポジトリは見ていない。84個は「全部」ではなく「この範囲の全部」。
  • git ls-files は現在の HEAD の追跡状況しか見ない。過去にコミットされて後から削除された .env は数えていない。履歴に残っているものはこの数字に入らないので、実際の露出はこれより大きい。
  • キー名による分類は名前だけの判断で、CONFIG_URL に資格情報が入っていれば見落とす。29行は下限。
  • 資格情報の一致がゼロだった .claude/ も、パターン6種類で見ただけ。独自形式の鍵は捕まらない。

結論

.env は63%が gitignore されていて、.claude/ は18%。差は45ポイントある。

ただし .claude/ を追跡すること自体は問題ではなく、97.2%は Markdown で、資格情報の混入はゼロだった。問題は1.2%の settings.local.json のほうにあって、そこには他人のマシンで意味を持たない611件の許可ルールと、壊れたまま気づかれていない JSON が1本入っていた。

.gitignore に足すのは1行で済む。

.claude/settings.local.json

関連

.claude/ を追跡するかどうかは、規約を誰と共有するかの設計そのものです。個人の設定とチームの規約が同じディレクトリに混ざっていると、どちらも効かなくなります。

層の切り方と、効かなくなる4つのパターンをまとめた本があります。

AIコンテキスト設計ガイド ── 規約を配ったのに守られない理由(2,000円)

第3章「規約が効かなくなる4つのパターン」まで無料で読めます。
/compact の後もルートの CLAUDE.md は再注入されるが、サブディレクトリと paths: 付きは再注入されない ── 長いセッションで静かに落ちるのはどちらか、という章です。

1
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
1
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?