値を絶対に表示しない .env diff ツールを作った

きっかけ

深夜 3 時。staging は動いている。本番が燃えている。原因は env のどこかだと分かっている。で、やる:

diff .env.staging .env.prod

そして固まる。diff の出力に両ファイルの全キーの全値が含まれている。ターミナルのスクロールバックに残る。パイプしていたら——ログ、チャット、PR コメント——シークレットがそこに座っている。

そこで .env 用の diff を書いた。ルールは 1 つ:値は絶対に表示しない

📦 GitHub: https://github.com/sen-ltd/env-diff

スクリーンショット

作ったもの

TypeScript 約 400 行、ランタイム依存ゼロの CLI。2 つの .env ファイルを比較して、追加・削除・変更されたキーを報告する。値の代わりに SHA-256 のフィンガープリント(8 文字)を表示:

$ env-diff .env.staging .env.prod

~ DATABASE_URL  (8a3f12c7 -> 4e91b2d5)
~ LOG_LEVEL     (06271baf -> 4d1e008c)
+ SENTRY_DSN    (c8a29e4f)

summary: +1 added, -0 removed, ~2 changed, =3 same, 0 excluded

値は出ない。でもどのキーが異なるかは分かる。フィンガープリントが違えば値も違う。

技術的なポイント

ハッシュによる差異証明

「2 つの秘密が異なる」ことを、どちらの秘密も明かさずに伝えるには、値が異なるとき異なり、同じとき同じになる証拠があればよい:

import { createHash } from 'node:crypto';

export function hashValue(value: string): string {
  return createHash('sha256')
    .update(value, 'utf8')
    .digest('hex')
    .slice(0, 8);
}

8 文字(32 ビット)に切り詰める。64 文字の hex はノイズだし、100 キー以下のファイルでの衝突確率は宇宙線の心配が先のレベル。

注意:これは値を秘密にするための仕組みではない。true8080 のような低エントロピーの値は半秒でレインボーテーブルから逆引きできる。約束は「生の値がこのプロセスのメモリから出ない」ことであって、前像耐性ではない。

–show-values はわざと使いにくくした

セキュリティ用語でいう意図的な摩擦。フラグが存在するのは時に必要だから。使いにくいのは、ルーチン化すると破滅的だから。

.env パーサー

dotenv は使わず自作した。約 120 行。処理するケース:

やらないこと: - 変数補完(FOO=$BAR はリテラル $BAR)。dotenv と docker-compose と systemd で挙動が違うので、どれも選ばない - 複数行値。複数行が必要な .env はだいたい base64 にすべき

GitHub Actions アノテーション

--format github で GitHub Actions のワークフローコマンドを出力:

lines.push(
  `::warning title=env-diff::key ${escape(entry.key)} ` +
    `changed ${entry.hashA} -> ${entry.hashB}`,
);

PR の “Files changed” ビューに黄色いアノテーションが表示される。--fail-on-diff と組み合わせれば、.env.example の更新漏れを PR レビュー前にキャッチできる。

おわりに

docker run --rm -v "$PWD":/work ghcr.io/sen-ltd/env-diff /work/a.env /work/b.env

47 件の vitest テスト、MIT ライセンス。

こういう小さなセキュリティ寄りの CLI を作り続ける理由は、デフォルトが製品そのものだから。深夜 3 時に誰もフラグのヘルプを読まない。-v が “verbose” なのか “values” なのか覚えていない。env-diff .env.staging .env.prod と打てば正しいことが起き、シークレットがどこにも漏れない。それ以外は全部、この 1 つの事実の周りを回っている。