検査は「誤検出」ではなく「空振り」で壊れる
ガードの失敗には 2 種類ある。誤検出(本物でないものを赤くする)と、
空振り(守りたかった状態が壊れているのに緑のまま)だ。
誤検出はうるさいので必ず気づく。放っておくと開発が止まるから、その日のうちに直る。
空振りは違う。「緑だから安全」に化けるので、永久に気づかない。
しかも「テストを足した」という事実が、安全の証拠として誤って読める。
ここ数か月で踏んだ空振りを並べる。全部、書いた本人が「守れている」と思っていたものだ。
位置関係を「文字列の距離」で見ると、別の場所に当たる
「この代入は、必ず権限チェックの内側にあること」を検査したかった。実装はこう書いた。
body.lastIndexOf("scope.level === 'all'", assignmentIndex) > 0
これは同じ関数の別の場所にある var options = scope.level === 'all' ? … : [] に当たった。
つまり、権限チェックの外に置いた代入をそのまま通した。門を実際に外して走らせるまで気づけない。
同じ病気を、別の顔で 3 回繰り返した。
| 書き方 | どう外れるか |
|---|---|
lastIndexOf(目印, 位置) | 手前にある無関係な一致に当たる |
/function f\(\)[\s\S]*?return null;/ | 遅延マッチが手前の早期 return で止まり、目的の catch 節を一度も見ない |
body.slice(body.indexOf(目印), +200) | 文字数の窓が隣の分岐を巻き込む |
3 つとも「関数の中に在るか」を距離で見ており、「どの分岐に在るか」を見ていない。
2 番目は catch 節を return []; に壊しても緑のままだった。
位置関係を見たくなったら、まず範囲を作る。 波かっこの対応で本体を切り出す小関数を書き、
インデックスがその区間に入っているかで判定する。
そしてその小関数自身にも空振り検査を 1 本付ける
(blockAfter("if (a) {x} else {y}") が else を含まないこと)。
そもそも走っていない ── 一番強い空振り
あるアプリの CI が、テストの実行行を 1 本だけ持っていた。
- run: node --test "tests/*.test.mjs"
この glob はサブディレクトリを辿らない。 tests/acceptance/ の 3 本=**148 件が
CI で一度も走っていなかった**。ローカルで実行したときだけ緑になるので、
「通っている」という記憶だけが残る。
他のアプリは引数なしの node --test(再帰探索)だったので、
ワークフローを横並びで読んでも差に気づけない形をしていた。
前節までは「走ったが見ていなかった」。これは「走っていない」だ。
中身をいくら強くしても効かない。
⇒ 書いたテストが CI の実行対象に入っていることを、テストで検査する。tests/** の *.test.mjs を再帰的に数え、ワークフローの glob を集め、
どの glob にも当たらないファイルを落とす。あわせてジョブの起動条件(paths:)に
ソースとテストの両方が載っていることも見る。条件の取りこぼしはジョブごと丸ごとスキップする。
さらにその判定関数 matches(glob, path) は、一度も直接テストされていなかった。return true; に壊しても上位のテストは全て緑だった ── 取りこぼしが 0 件と報告されるだけだからだ。
単体テストを 6 本足し、6 通りに壊して全て赤を確認した。副産物として? がエスケープも変換もされず素通りしていたことが分かった。素の ? は正規表現で
「直前を省略可」なので、当たっていないものを「当たった」と数える=空振りの向きへ倒れる実装ミスだ。
0 件で緑は、合格ではない
シートのセル型を監査するツールが、名前から仕様を引けなかったら continue で飛ばす作りだった。
レポートは 走査したシート: N / 指摘 合計: 0件 としか出さない。
見なかったものは報告に現れないので、0 件を「全部きれい」と読んでしまう。
実際、前回の実行結果「0 件」をそのまま記録として残していた。過去の 0 件は無効だ。
- 走査対象は表で持ち、UNKNOWN を残さない。走査表と除外表の 2 枚だけにして、
どちらにも無い名前は未分類として NG で報告し、指摘としても数える
- 除外には必ず理由を書き、レポートにも出す。「見なかった理由」が読めないと母集団の確認にならない
- 0 件の隣に母集団を置く。
走査 N / 除外 M / 未分類 K = 全 N+M+Kを同じ画面に出して、
はじめて 0 件が読める数字になる。件数だけを出す報告は、範囲が縮んだときに黙って良くなる
フィルタを「正常系の語」で書くと、検査対象が母集団から消える
端末認証を強制する前に、「稼働中かつ未承認の端末」を数えた。稼働判定に
ログイン履歴の result = 'success' を使ったところ、締め出し対象 0 台と出た。
嘘だった。登録処理は未承認端末のログインを pending で記録する。
success で絞ると、まさに数えたかった未承認端末だけが母集団から消える。
実際は 12 台・6 名で、うち 1 名は承認済み 0 台=強制した瞬間に完全にログイン不能になるところだった。
矛盾は同じ表の中に出ていた。端末の「最終アクセス」が今日なのに「最終成功ログイン ―」。
使われている証拠と、使われていない結論が同居していた。
- 異常や保留こそが検査対象なら、状態で絞らず「行があるか」で数える。
絞るなら絞って落ちた件数を必ず出す。result の内訳{success: 1208, pending: 391} を出した瞬間に嘘が割れた
同じ調査でもう 1 つ踏んだ。REST 経由の取得が 1 リクエスト 1000 行で黙って打ち切られる。limit=5000 と書いても 1000 行しか来ず、履歴 1599 行のうち 599 行が静かに欠けていた。
エラーも警告も出ない。⇒ ページングして、取得件数を出力に書く。
API の暗黙上限は空振りの代表的な発生源で、件数を印字しない集計は信じられない。
検査の目を潰すのは、たいてい自分が書いた散文
- 「この文字列がツリーに残っていないこと」を見る掃討テストに
if f.name == Path(__file__).name: continue と書いた結果、
そのテスト自身の docstring に例として書き写した当の文字列を見逃した。
検査対象から自分自身を外すと、自分が生んだコピーが見えなくなる
- 委譲クリックのセレクタ列に属性名が載っているかを見るガードが、実体を消しても緑だった。
原因はセレクタ列の直前に添えた // … は属性名の完全一致なので … という注意書き。
抽出範囲にコメントが入り、コメントの中の文字列が当たっていた
- 逆向きの失敗もある。コメント除去が
//しか落としておらず、撤去理由を書き残した
ブロックコメント内の識別子を「復活している」と誤検出した。ここで/\*[\s\S]*?\*\//g の一括除去に直すと、文字列や正規表現の中の /* を食って本物のコードを消す
=誤検出が空振りへ化ける
「うるさいから緩める」修正は、必ず空振りの方向へ進む。
緩めるときは、緩めた側が空振りにならないかを先に確かめる。
許可リストを「含むか」で書かない
外部への送信先ホストを検査する 1 行を、シェルでこう書いた。
[[ $v != https://*example.com/* ]] && exit 1
実測で、通してはいけない値が 3 つとも通った。
| 値 | なぜ通るか |
|---|---|
https://evil.com/example.com/x | パスに置けば部分一致する |
https://notexample.com/message/1 | 接尾辞として部分一致する |
ホスト名は「含まれるか」ではなく「ホスト部そのものか」 ── 先頭と終端の両方を留める
(^https://([A-Za-z0-9-]+\.)*example\.com(/|\?|#|$))。両端に * を置いた glob は許可リストにならない。
嫌な点は、同じ穴を自分で塞いだ翌日に、別のファイルで開けたことだ。
前日に別の通知先について前方一致を強制し、テストまで書いていた。
「この種の穴を知っている」ことは、次に書くコードで避けられることを意味しない。
否定ベクタが、配列展開で 1 件だけ消えていた
「通してはいけない値」を並べる検査を書いた。
bad=( "短すぎる" "接頭辞が違う" "URLが混ざる" "" )
for v in $bad; do …; done
zsh のクォート無し配列展開は、空文字の要素を黙って落とす。
a=("x" "" "y")
for v in $a → [x] [y] ← 消える
for v in "${a[@]}" → [x] [] [y]
4 件目(空文字)は一度も判定関数に渡っていなかった。将来この検査が空文字を通すように壊れても、
自己テストは緑のままだ。空振りを塞ぐために書いた検査自身に、同じ空振りが入っていた。
気づけた唯一の手がかりは件数だった。宣言は 4 要素なのに、出力の「拒否」は 3 行しか出ていない。
⇒ 否定ベクタを持つ検査は、ベクタ数と出力の判定行数が一致することを人が読める形で出す。
規律
- 欠陥を戻して赤を見るまでガードを信じない。 新しいガードを足したら、必ず一度
「守りたかった状態」を壊して落ちることを確認する
- 前提が変わったらガードごと見直す。 検査を避けるために変数名を変えてはいけない
(危険な代入がガードの目から静かに消える)
- 母集団を明示して数える。
assert checked > 0を置く。0 件で緑は合格ではない - skip は「安全」ではなく「測れていない」。 理由をメッセージに書く
- 位置関係は範囲で見る。 距離・遅延マッチ・窓は全部同じ穴
- 否定ベクタを持たない検査は、検査ではない
最後に一段上げた言い方をしておく。
「テストが緑」は「回した分が緑」でしかない。
母集団(何本あるか)と実行集合(何本回したか)が一致していることを、別に言う必要がある。