個人開発で運用している Web サイトの設定を、Antigravity のエージェントに整理してもらおうとしたときのことです。依頼文を打つ手前で、ふと手が止まりました。このリポジトリのどこに秘密が置かれているのか、自分の記憶だけで答えようとしていたからです。
.gitignore には .env* と書いてあります。だから大丈夫だろう、と思っていました。そこで、思い込みを確かめるために、点検用のスクリプトを書きました。
最初に結論をお伝えします。.env* は .env 系しか守りません。Cloudflare Workers のローカル用ファイル .dev.vars は、そのパターンの外にあります。 私自身のリポジトリでも、実際にそうなっていました。
点検の対象は三つだけにしました
エージェントに設定を任せる前に、私が知りたかったのは次の三点です。
- コードが読んでいる環境変数の名前を知りたいです(値は読みません)
- 平文でデプロイされる
wrangler.toml の [vars] に、秘密らしいものが紛れていないかを確かめたいです
- ローカル用の秘密ファイルが、git の目から本当に隠れているかを確かめたいです
三つ目が肝心です。.gitignore の書き方を目で追って判断すると、パターンの読み違いがそのまま見落としになります。そこで、判定は git 自身に任せることにしました。git check-ignore は、指定したパスが無視対象かどうかを、ファイルが実在しなくても答えてくれます。
点検スクリプトの全文
Python 3 の標準ライブラリだけで動きます。終了コードは 0 が問題なし、1 が要対応、2 が使い方の誤りです。
#!/usr/bin/env python3
"""Workers 設定の「秘密の置き場」点検。エージェントにリポジトリを渡す前に実行する。
終了コード: 0=問題なし / 1=要対応あり / 2=使い方の誤り
"""
import re, subprocess, sys
from pathlib import Path
repo = Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()
if not (repo / ".git").exists():
print("git リポジトリではありません:", repo); sys.exit(2)
def git(*a):
return subprocess.run(["git", "-C", str(repo), *a], capture_output=True, text=True)
problems, notes = [], []
# 1) コードが読む環境変数の名前を集める(値は読まない)
names = set()
for p in git("ls-files", "*.ts", "*.tsx", "*.js", "*.mjs").stdout.split():
try:
names |= set(re.findall(r"process\.env\.([A-Z][A-Z0-9_]+)", (repo / p).read_text(errors="ignore")))
except OSError:
pass
# 2) wrangler.toml の [vars](平文でデプロイされる側)に秘密らしい名前がないか
cfg = repo / "wrangler.toml"
plain = {}
if cfg.exists():
# 3.10 でも動くよう、[vars] 節だけを素朴に読む(tomllib は 3.11 以降)
sect = re.search(r"^\[vars\]\s*$(.*?)(?=^\[|\Z)", cfg.read_text(), re.M | re.S)
for line in (sect.group(1).splitlines() if sect else []):
m = re.match(r'\s*([A-Za-z0-9_]+)\s*=\s*"(.*)"\s*(#.*)?$', line)
if m:
plain[m.group(1)] = m.group(2)
SECRETISH = re.compile(r"(SECRET|TOKEN|PASSWORD|PRIVATE|API_?KEY|WEBHOOK)", re.I)
for k, v in plain.items():
if SECRETISH.search(k) and not k.startswith("NEXT_PUBLIC_"):
problems.append(f"[vars] に秘密らしい名前: {k}(wrangler secret put へ移す)")
elif str(v).startswith(("sk_", "whsec_", "ghp_")):
problems.append(f"[vars] の {k} が秘密の接頭辞で始まっています")
# 3) 秘密を置くはずの変数が [vars] にないこと=Secret 側にある前提を確認
for n in sorted(names):
if SECRETISH.search(n) and n not in plain:
notes.append(f"{n}: コードは読むが [vars] には無い → Secret 側(ダッシュボード/secret put)にある想定")
# 4) ローカル用ファイルが ignore されているか(git の判定をそのまま使う)
for f in (".env", ".env.local", ".dev.vars", ".dev.vars.production"):
r = git("check-ignore", "-q", f)
if r.returncode != 0:
problems.append(f"{f} が .gitignore の対象外(作った瞬間に git add -A で入る)")
# 5) すでに追跡されていないか
tracked = [p for p in git("ls-files").stdout.split() if re.search(r"(^|/)(\.env|\.dev\.vars)", p)]
for p in tracked:
problems.append(f"追跡済み: {p}")
print(f"コードが読む環境変数: {len(names)} 個 / [vars]: {len(plain)} 個")
for n in notes: print(" ・", n)
for p in problems: print(" ✗", p)
print("結果:", "要対応 %d 件" % len(problems) if problems else "問題なし")
sys.exit(1 if problems else 0)
設計の理由を、順にお伝えします。
値を読まない。 変数の名前だけを集め、.env や .dev.vars の中身は開きません。点検のために秘密を画面やログへ出してしまっては、本末転倒だからです。
[vars] は名前で疑う。 [vars] の値は、設定ファイルにも管理画面にも平文で残ります。SECRET・TOKEN・WEBHOOK などを含む名前が見つかったら、wrangler secret put で Secret 側へ移す合図にしています。NEXT_PUBLIC_ で始まる名前は、もともと公開前提なので外しました。
無視の判定は git に聞く。 check-ignore -q は、無視対象なら 0、そうでなければ 1 を返します。.gitignore の細かな文法を自分で再実装しないので、読み違いが入り込みません。
実際のリポジトリに当てた結果
自分のリポジトリで、何も直さずに実行しました。
コードが読む環境変数: 4 個 / [vars]: 1 個
・ STRIPE_SECRET_KEY: コードは読むが [vars] には無い → Secret 側(ダッシュボード/secret put)にある想定
・ STRIPE_WEBHOOK_SECRET: コードは読むが [vars] には無い → Secret 側(ダッシュボード/secret put)にある想定
✗ .dev.vars が .gitignore の対象外(作った瞬間に git add -A で入る)
✗ .dev.vars.production が .gitignore の対象外(作った瞬間に git add -A で入る)
結果: 要対応 2 件
読み取れたことは三つです。
本番の秘密の置き場は、想定どおり Secret 側にありました。一方で、手元で wrangler dev を動かすために .dev.vars を作った瞬間、git add -A で入ってしまう状態だったのです。リポジトリにはまだ入っていなかったので実害はありませんでしたが、エージェントに「ローカルで動かして確認して」と頼めば、エージェントがそのファイルを作ることは十分ありえます。
仕組みを疑ったのではなく、置き場所の想定を疑いました
意外だったのは、自分の記憶と実際の設定の食い違いです。.env* と書いた時点で、ローカルの秘密ファイルは全部覆ったつもりでいました。ところが wrangler が使うのは .env ではなく .dev.vars で、環境ごとに .dev.vars.<環境名> を使い分けられます。名前の系統が違うので、.env* の傘には入りません。
もう一つ、手元で気づいたことがあります。最初に書いた版は、標準ライブラリの tomllib で wrangler.toml を読んでいました。ところが私の環境の Python 3.10 には tomllib がありません(3.11 以降の標準ライブラリです)。そこで [vars] の節だけを正規表現で読む形に改めました。点検用の道具は、どの環境でも追加なしで動くほうが、結局使われ続けます。
直したあとに、同じ点検で確かめます
.gitignore へ足すのは二行です。
.dev.vars
.dev.vars.*
足したら、同じスクリプトを再実行して、終了コードを確かめます。
python3 secret_surface_check.py . ; echo "exit=$?"
「直したつもり」で終わらせず、点検側が 0 を返すことまで見て初めて、直したと言えます。再実行で ✗ が消え、結果: 問題なし と出れば完了です。
[vars] に秘密を置いてしまう事故を、試して止めます
三つ目のコード例として、わざと壊した小さなリポジトリで動作を確かめた手順を残します。
mkdir fx && cd fx && git init -q
printf '.env*\n' > .gitignore
cat > wrangler.toml <<'TOML'
name = "demo"
[vars]
NEXT_PUBLIC_SITE_URL = "https://example.com"
STRIPE_WEBHOOK_SECRET = "whsec_demo123"
TOML
echo 'const k = process.env.STRIPE_SECRET_KEY;' > a.ts
echo "X=1" > .dev.vars
git add a.ts wrangler.toml .gitignore && git add -f .dev.vars
python3 ../secret_surface_check.py . ; echo "exit=$?"
出力は次のとおりでした。
コードが読む環境変数: 1 個 / [vars]: 2 個
・ STRIPE_SECRET_KEY: コードは読むが [vars] には無い → Secret 側(ダッシュボード/secret put)にある想定
✗ [vars] に秘密らしい名前: STRIPE_WEBHOOK_SECRET(wrangler secret put へ移す)
✗ .dev.vars が .gitignore の対象外(作った瞬間に git add -A で入る)
✗ .dev.vars.production が .gitignore の対象外(作った瞬間に git add -A で入る)
✗ 追跡済み: .dev.vars
結果: 要対応 4 件
exit=1
[vars] の秘密、無視の穴、追跡済みのファイルの三つが、それぞれ別の行で拾われました。ここでの要点は、警告を「ある・ない」で眺めず、終了コードで次の作業を止めることです。エージェントに渡す前の一手順として、exit=0 を確認してから依頼文を打つ流れにします。
コミットの門番にして、エージェントの手も止めます
エージェントに渡す前だけでなく、エージェントが作業したあとにも同じ点検が走るように、pre-commit に置きました。点検が 1 を返すと、コミットそのものが止まります。
#!/bin/sh
# .git/hooks/pre-commit — 秘密の置き場を点検し、要対応があればコミットを止める
python3 "$(git rev-parse --show-toplevel)/tools/secret_surface_check.py" \
"$(git rev-parse --show-toplevel)" || {
echo "pre-commit: 秘密の置き場に要対応があります。上の ✗ を直してから再実行してください。" >&2
exit 1
}
先ほどの使い捨てリポジトリで試すと、[vars] に STRIPE_WEBHOOK_SECRET を書き足した変更は 1 回目のコミットで止まり(終了コード 1)、消した状態では通りました(終了コード 0)。エージェントが善意で [vars] に値を書いても、ここで一度止まります。コードレビューを待たずに、機械が先に気づいてくれる形です。
私の運用手順
実際に回すときの順番を、短く書き留めます。
- 点検スクリプトを実行し、
✗ が 0 件で終わることを確認します(終了コード 0)
✗ があれば、.gitignore と [vars] を直して、もう一度 1 を実行します
- 依頼文のひな形に「中身は開かず、名前だけ扱う」の一文を添えて、エージェントに渡します
- 作業が終わったら、コミット時に
pre-commit が同じ点検を走らせるのを待ちます
今回の私のリポジトリでは、コードが読む 4 個の環境変数のうち 2 個(50%)が秘密でした。割合として多いわけではありませんが、2 個とも課金に直結する鍵です。置き場所の想定が 1 つずれるだけで、守る価値の高いものから先に危うくなる、という実感がありました。
落とし穴
- すでに追跡済みのファイルは、
.gitignore を足しても無視されません。 履歴から消すには別の作業が必要です。上の点検が「追跡済み」を別枠で出すのは、そのためです。
.dev.vars を一度でも push したなら、中身の秘密は作り直します。 履歴を書き換えても、すでに複製された可能性は消せないのです。
- 点検は値を見ないので、本物かダミーかは判断しません。 「名前が秘密らしい」ことしか分かりません、というのが正直なところです。値の検査が必要な場面は、別の記事(
.zip を渡す前の秘密情報検査)で扱っています。
エージェントへの渡し方
点検が通ったあとも、指示のほうに一行添えておくと安心です。私は次の一文を、依頼文のひな形に入れることにしました。
.dev.vars と .env の中身は開かず、変数の名前だけを扱ってください。新しいファイルを作る場合は、先に .gitignore の対象かを git check-ignore で確認してください。
無視ルールの効き方そのものを確かめたいときは、.antigravityignore のパターンが効いていないときの確かめ方が参考になります。エージェントのコード検索が追跡済みファイルしか見ない問題は、エージェントのコード検索が ignore の外を見逃すときにまとめています。リポジトリ全体を固めて渡す場合は、.zip を渡す前の点検も合わせてご覧いただければ幸いです。
判断をひとことで
秘密の置き場は、記憶ではなく git の判定で確かめます。 このスクリプトは 60 行ほどですが、「大丈夫だろう」という感覚を、終了コードに置き換えてくれます。書いてみて気づいたのは、点検の価値は見つかった件数よりも、確かめた、と言い切れることにある、という点でした。同じような設定のリポジトリをお持ちでしたら、依頼文を打つ前に一度、走らせてみてください。