「テストは全部緑。ここは大丈夫」——そう信じていた合成テストスイートはすべてパスしていました。それでも実データで計測したら、守りたかった対象の 43% が素通りしていました。
これは私たちが dotfiles を運用する中で実際に起きた話です。秘匿情報を出力からマスクするシェルスクリプトに、ちゃんとテストスイートを書いて、全部パスさせて、安心して運用していた。ところが攻撃者視点で実物の .env ファイル群に対して検証し直したら、高価値な秘匿情報のかなりの部分が漏れていたわけです(テストを書いた本人が一番驚く瞬間です)。
シェルスクリプトは「動いているからヨシ」で放置されがちな領域です。アプリケーションコードには当然のようにテストを書く人でも、dotfiles の hook やビルド検証スクリプトは素通ししていることが多い。そして、いざテストを書いても、bash 特有の罠でテスト自体が静かに無力化されている場合があります。
あなたの手元のシェルスクリプトのテスト、最後に「落ちた」のはいつでしょう。一度も落ちたことがないなら、それは品質が高いのではなく、落ち方を知らないだけかもしれません。
この記事では、dotfiles を運用する中で実際に踏んだ3つの罠と、その対策をコード付きで整理します。
この記事で学べること
set -euo pipefail下で「失敗ケースのテスト」が機能しなくなる仕組みと回避パターン- テストが本番と違う環境を検証してしまう「暗黙の環境依存」の見つけ方
- 合成テストが全部緑でも実データで漏れる問題への対処(実データ検証の組み込み方)
- jq だけで作れるシェルスクリプト用の最小テストハーネス
前提条件
- bash の基本構文(
set -e、コマンド置換、parameter expansion)が読めること - jq がインストール済みであること(テストハーネスの例で使用)
- Nix の例は雰囲気が伝われば十分です(Nix 未使用でも教訓は適用できます)
罠1: set -euo pipefail の下では「失敗のテスト」が書けない
シェルスクリプトのテストでは、まず冒頭に set -euo pipefail を置くのが定石です。未定義変数やパイプの途中の失敗を握りつぶさないための基本装備で、これ自体は正しい。ところがこの設定が、「コマンドが失敗したことを検証するテスト」を静かに殺します。
実例は、AI エージェント用の Docker image に CLI を同梱したときの smoke test です。image 内で claude --version を実行し、失敗したら fail と報告するテストを書いていました。問題のパターンはこれです。
set -euo pipefail
out="$(docker run --rm my-tools:latest claude --version)"
exit_code=$? # 失敗時、この行には到達しない
コマンド置換つきの代入では、置換したコマンドの exit code が代入文全体の exit code になります。つまり claude --version が失敗した瞬間、set -e によってその行でスクリプト全体が exit する。次の行の exit_code=$? は失敗時には一度も実行されません。
結果どうなるかというと、テストの「失敗判定パス」が永遠に通らない。コマンドが成功する限りテストは緑、コマンドが失敗するとテストは fail 報告ではなく途中で死ぬ。どちらに転んでも、書いたはずの失敗レポートは表示されないわけです(緑は安心する色ですよね。安心するだけで、何も保証していないことがありますが)。
修正は、exit code を stdout に埋め込んで、1回の実行で値と終了状態を両方持ち帰る方式にしました。
set -euo pipefail
# コンテナ内で version と exit code をまとめて stdout に出す
out="$(docker run --rm my-tools:latest bash -c \
'claude --version 2>&1; printf "\n__EXIT__=%d" "$?"')"
exit_code="${out##*__EXIT__=}" # 末尾の exit code を取り出す
result="${out%%$'\n'__EXIT__=*}" # 本体の出力を取り出す
if [[ "$exit_code" != "0" ]] || [[ ! "$result" =~ Claude ]]; then
echo "FAIL: claude --version (exit=$exit_code)" >&2
exit 1
fi
コンテナ内の printf は内側のコマンドが失敗しても必ず実行されるので、外側の $(...) は成功扱いになり、set -e に殺されません。exit code は __EXIT__= マーカーごと parameter expansion で切り出します。docker 実行が1回で済むのも地味に効きます(テストケースごとに docker run を2回ずつ走らせると、コーヒーを淹れに行く回数が増えます)。
教訓はシンプルで、テストを書いたら、わざと失敗させて fail パスが動くことを確認する。テストのテストみたいで面倒に聞こえますが、set -e 環境ではこれを省くと「絶対に落ちないテスト」が量産されます。
罠2: テストが「本番と違う環境」を検証している
2つ目の罠は、テストは正しく書けているのに、検証している環境が本番と違うパターンです。
同じ dotfiles リポジトリでの実例です。Docker image に入れるパッケージ構成を nix eval で検証する unit test がありました。当初の実装は <nixpkgs> という channel 参照を使っていたのですが、これは実行するマシンの nix-channel 設定に依存します。一方、本体のビルドは flake.lock で特定バージョンの nixpkgs に pin されている。つまり、テストと本番ビルドが別のバージョンのパッケージセットを見ている可能性があったわけです(健康診断で他人の検体を提出しているようなもので、結果が良好でも自分の健康については何もわかりません)。
修正は、テスト側も flake.lock で pin された nixpkgs を参照すること。builtins.getFlake 経由で flake.inputs.nixpkgs.legacyPackages を辿るように変えて、CI でも開発機でも同じ nixpkgs で評価される状態にしました。
# Before: ホストの channel 設定次第で評価対象が変わる
nix eval --impure --expr 'with import <nixpkgs> {}; ...'
# After: flake.lock で pin された nixpkgs を必ず見る
nix eval --impure --expr \
'(builtins.getFlake (toString ./.)).inputs.nixpkgs.legacyPackages...'
これは Nix 固有の話に見えて、実はどこにでもある構造です。テストだけ別の PATH を見ている、テストだけホストの環境変数を引き継いでいる、テストだけ古い lock ファイルで動いている。「テストが緑」は「テストが見ている環境では緑」でしかありません。テストを書くときは「このテストは本番と同じものを評価しているか」を一度疑う価値があります。
罠3: 合成テストの緑は「現実で守れている」を意味しない
そして冒頭の 43% の話です。これは dotfiles リポジトリの実データ計測で出た数字です。
問題のスクリプトは、Claude Code(Pro $20/月、Max $100-200/月)の PostToolUse hook として動く秘匿情報マスクです。コマンド出力に API キーやパスワードが含まれていたら [REDACTED:...] に置換してから会話の文脈に渡す。想定パターンを網羅した合成テストスイートを用意し、マスク判定はすべてパス、無害な設定値への誤検知もゼロ。それでも、攻撃者視点で実物の .env ファイル群を計測し直したら、高価値な秘匿情報の 43% が素通りしていました。漏れたパターンの内訳と修正、マスクをどのレイヤーに置くかの設計はマスク層の設計記事に書いたので、本記事では「なぜテストは緑のまま見逃したのか」に絞ります。
種明かしをすると、漏れていたのはどれも「実データにしかない形状」でした。合成テストのケースは、結局のところ書いた本人の想定を映す鏡です。実装とテストを同じ頭で書く以上、実装の盲点はそのままテストの盲点になる。ケースを何十件に増やしても増えるのは「想定内の網羅率」だけで、想定の外側は 0 件のままです(数だけは立派に育っていくのが、また厄介なんですよね)。
だから対策は「もっと想定を増やす」ではなく、自分の想定を経由していないデータをテストに混ぜること。やったことは2つです。
1つ目は、同じ実データで攻めと守りの両方を検証する。攻めは漏れ探し——マスクを通した実データに秘匿値が残っていないかを洗う。守りは誤検知探し——マスク対象に salt のような短い単語を足すとき、綴りの近い一般語が誤って潰されないことを実在の設定値で確認する。片方だけだと「漏れは塞いだが正規の出力を壊した」か、その逆に倒れます。この修正で、マスクできた高価値項目は実測でおよそ 1.8 倍に増えました。
2つ目は、見つけた形状を合成スイートに昇格させて回帰させる。実データそのものは secret なのでテストには残せません。代わりに、漏れていた形状は fixture 化して「マスクされるべき」ケースに、誤検知を疑った無害な値(普通の変数名、数値、パス、素の https URL、YAML の地の文)は「素通りすべき」ケースに登録し、拡充したスイートが全ケース緑で通ることを確認してから運用に戻しました。一回きりの実データ検証が保証するのは「今日は守れている」まで。スイートに昇格させて初めて、次にパターンを足すときの回帰の網になります。
jq で作る最小テストハーネス — JSON in / JSON out なら手間は小さい
ここまで読んで「罠が多いな。そこまでして書く価値、実際どうなの?」と感じた方へ。朗報としては、入出力が JSON のスクリプトなら、テストハーネスは jq だけで組めます。
前述のマスク hook は「JSON を stdin で受けて JSON を stdout に返す」インターフェースです。この形ならテストケースは「入力 JSON を組み立てて、出力に期待マーカーが含まれるか見る」だけで書けます。実際のテストスイートで使っている関数の骨格はこうです(簡略版)。
HOOK="./posttool-secret-mask.sh"
FAIL=0
# run_mask_case <名前> <入力テキスト> <期待するマスクマーカー>
run_mask_case() {
local name="$1" text="$2" marker="$3"
local input output
input=$(jq -n --arg text "$text" \
'{tool_name:"Bash", tool_response:{stdout:$text}}')
output=$(echo "$input" | bash "$HOOK" 2>&1 || true)
if echo "$output" | grep -q "$marker"; then
printf 'PASS %s\n' "$name"
else
printf 'FAIL %s\n' "$name"
FAIL=$((FAIL + 1))
fi
}
run_mask_case "AWS Access Key ID" \
"AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE" \
"REDACTED:AWS_ACCESS_KEY_ID"
jq -n --arg で入力を組み立てるのがポイントで、エスケープ問題を jq に丸投げできます。テストフレームワークの導入もビルドも不要、bash と jq があれば動く。「マスクされること」のテストと対で「無害な入力が素通りすること」のテストも同じ形で書けます。
ひとつ、このハーネスを運用して気づいた小ネタを。秘匿情報マスクのテストを書くということは、リポジトリに偽の secret をコミットするということです。Stripe や Slack のトークン prefix をテストデータに生で書くと、GitHub の push protection が「本物の secret では?」と検知して push 自体をブロックしてきます(守ってくれてありがとう、でも今じゃない)。回避策は、prefix を文字列連結で分割しておくことでした。
# push protection の secret scanner 対策: prefix を分割して構築
P_STRIPE_LIVE="sk_li""ve_"
P_SLACK_BOT="xo""xb-"
run_mask_case "Stripe live key" \
"STRIPE_KEY=${P_STRIPE_LIVE}aaaaaaaaaaaaaaaaaaaaaaaa" \
"REDACTED:STRIPE_KEY"
セキュリティツールのテストはセキュリティツールに怒られる、という入れ子構造は、やってみて初めて気づく類いの話です。
注意点・Tips
- fail パスを一度はわざと踏む: 罠1の対策の繰り返しになりますが、これが最優先です。アサーションを一時的に反転させて、ちゃんと FAIL 表示が出て非ゼロで exit することを確認してからコミットする
- 重いテストはフラグで分離する: Docker build を伴う smoke test は、
--skip-build/--skip-dockerフラグを付けて CI とローカルで実行範囲を切り替えられるようにしました。「重いから回さない」が常態化すると、それはもう存在しないテストと同じです - テスト以前に、パースを位置ベースにしない: テストで守る前に壊れにくく書く話として、git 出力を
awk '{print $3}'のような位置ベースで抜くのは避ける。実際、worktree 一覧のマーカー列が空白のとき、awk のデフォルトのフィールド分割が先頭スペースを食ってパスが空文字になり、cd ""で失敗する不具合を踏みました。区切り文字を tab に統一して安定化しています。--porcelainや--formatが使えるなら必ずそちらを使う - hook スクリプトの安全設計とセット: テストで品質を担保したスクリプトをどう多層防御に組み込むかは、hooks の安全設計パターンの記事にまとめています
まとめ
シェルスクリプトのテストが信用できなくなる罠を3つ紹介しました。
set -euo pipefailが失敗テストを殺す — exit code は stdout に埋め込んで持ち帰り、fail パスはわざと踏んで検証する- テストが本番と違う環境を見ている — 暗黙の環境依存(channel、PATH、lock ファイル)を pin する
- 合成テストの緑は現実の保証ではない — 実データで漏れ探しと誤検知探しの両方をやる
共通するのは、テストの緑が保証するのは「自分が書いた想定の中では正しい」ことまで、という点です。想定の外を潰すには、わざと失敗させる・環境を疑う・実データを当てる、という一手間が要ります。逆に言えば、その一手間は jq とシェルだけで始められて、フレームワークも CI の大改修も必要ありません。
dotfiles の中で何年も「動いているからヨシ」になっているスクリプトがあれば、まずは1本、fail パスがちゃんと落ちるテストを書いてみてください。緑の意味が変わります。



