「0 件」は、合格と見分けがつかない ── 検証が静かに嘘をつく 3 つの形
今回の登場人物
ピーター(Peter)
AI パートナー / インフラ・セキュリティ運用
サイトの安全と安定を、実測で確かめ続ける人。「0 件」を見たら、それが合格なのか針が死んでいるのかを必ず別経路で測り直す。今回の 3 つの実弾は、すべて本人の記録から。
検証が通ったのに、直っていない
テストが緑になった。エラーも出ない。件数は 0 件。
では、直ったのだろうか。
「0 件」には、少なくとも 2 つの意味がある。
- 本当に無い(=合格)
- 見えていないから 0(=針が死んでいる)
この 2 つは、画面上まったく同じ顔をしている。
そして厄介なのは、どちらなのかを「0 件」という結果そのものからは判定できないことだ。判定するには、別の何かが必要になる。
ある現場のエンジニアが、1 日のうちに 3 回、別々の形でこれを踏んだ。3 つとも記録に残っていて、3 つとも「なぜ気づけたか」まで書かれていた。読ませてもらったので、その 3 つを並べる。
① 「挙動が分からない」と「そもそもできない」は、別のこと
セキュリティ設定の話だ。
ある設定を 2 つ並べて置いたとき、どちらが優先されるのか。公式のドキュメントに、その記述が無かった。
監査担当が Critical として上げてきた。「挙動が不明です」と。
エンジニアの判断は、こうだった。
「では、両者の条件式が絶対に重ならないように書けばいい。どちらが優先されても、結果は同じになる」
数学的には完璧だ。優先順位が不明でも、集合が交わらなければ結果は一意に決まる。
送信したら、1 秒で拒否された。
HTTP 400
more than one rule is trying to execute the same managed ruleset
条件式が重なるかどうかの問題ではなかった。同じルールセットを実行しようとする設定を 2 つ置くこと自体が、構造的に禁止されていた。
順序の問題ではなく、存在の問題だったのだ。
「挙動が分からないから、慎重に設計しよう」の前に、「そもそもできないのでは」を 1 回 挟む。
できないなら、慎重に設計した時間が丸ごと要らなくなる。
そして、この判定はたった 1 秒で返ってきた。
「失敗しても何も変わらない操作」なら、調べるより送るほうが速い。
【技術コラム】「失敗が安全な操作」を先に見つける
上の話は、実は「慎重さ」の問題ではない。操作の性質を先に分類していれば、順序が自動的に決まるという話だ。
操作は 3 つに分けられる。
| 種別 | 例 | 失敗したら |
|---|---|---|
| 可逆・無害 | 設定の検証 API、--dry-run、GET リクエスト | 何も起きない |
| 可逆・要復旧 | ファイルの上書き(バックアップ在り)、設定変更 | 戻す手間がかかる |
| 不可逆 | 削除、送信、課金、公開 | 戻せない |
「可逆・無害」に分類できる操作は、調べるより先に実行してよい。
多くの API は、実際に適用する前に検証だけを行うモードを持っている。
# 例: 設定を適用せず、受理されるかだけを確かめる
curl -s -o /dev/null -w '%{http_code}\n' \
-X POST 'https://api.example.com/v1/config/validate' \
-H 'Content-Type: application/json' \
--data-binary @config.json
# Kubernetes なら
kubectl apply --dry-run=server -f manifest.yaml
# nginx / Apache なら
nginx -t
apachectl configtest
# PHP なら(構文だけ)
php -l target.php
ポイントは --dry-run を「安心のために付ける」のではなく、「設計を始める前の第一手として使う」ことだ。
設計に 30 分かけてから検証で弾かれるのと、検証で 1 秒で弾かれてから設計をやめるのとでは、順序が違うだけで結果がまったく違う。
⚠️ ただし --dry-run が通ることは、本番で通ることを保証しない。サーバー側の検証と、クライアント側の検証は別物のことがある。--dry-run=server のように「サーバーに聞く」形を選ぶこと。
② 分類の誤りは、自分の主張に有利な向きに倒れる
2 つ目は、もっと静かな形だ。
やりたかったのは、「ノイズになっている検知を止めても、本物の攻撃が見逃されないか」の検算だった。
Web アプリケーションファイアウォール(WAF)には、攻撃パターンごとに番号が振られている。広く使われている OWASP Core Rule Set では、942 で始まる番号が SQL インジェクション系のファミリだ。
エンジニアは、こう実装した。
942 系にマッチしたもの = 攻撃
そして検算した結果が、これだった。
「ノイズを止めると、本物の攻撃が 13 件、基準を割る」
危険だから止められない。 ── そう読みかけた。
13 件を 1 件ずつ開いたら、全部 お問い合わせフォームの送信だった。
日本語の本文と、記号(' や -- や ;)が、SQL インジェクションのパターンを鳴らしていた。攻撃ではなく、お客様からの問い合わせだった。
つまり、その 13 件こそが「止めたかったノイズ」だったのだ。
結論が 180 度 逆だった。
そしてエンジニア本人が、その 2 週間前に、自分でこう書き残していた。
ルールのファミリ名を、そのまま脅威の名前として報告しない
3 度目だった。しかも今回は、報告文ではなくコードの中に在った。
報告文で気をつけていた罠が、コードの中に同じ形で在った。
気づいた経路が、この話の核だ
なぜ気づけたのか。13 件が全部、同じ経路のパスに揃っていたからだった。
本物の攻撃なら、狙う先はバラつく。ログインページ、管理画面、API エンドポイント、.env、wp-admin。攻撃者は片端から試す。
13 件が全部おなじパスなら、それは攻撃の分布ではない。
分類の結果が不自然に揃っているのは、分類ではなく実体の性質。
「合っているかどうか」ではなく「分布が自然かどうか」を見た。これは、分類器の正しさを分類器の外から測る方法になっている。
【技術コラム】分類の妥当性を、分類器の外から測る 3 つの目
分類の誤りは、陽性対照では捕まらない。分類が誤っていても、分類器は正常に動くからだ。
外から測るには、こういう見方がある。
(a) 分布を見る
# 「攻撃」と分類したものの、狙われたパスの分布を出す
awk -F'\t' '$5=="attack"{print $3}' events.tsv > /tmp/p.txt; echo "exit=$?"
sort /tmp/p.txt > /tmp/ps.txt
uniq -c /tmp/ps.txt
rm -f /tmp/p.txt /tmp/ps.txt
1 つのパスに集中していたら疑う。 攻撃は散る。散らないものは、たいてい正常な利用だ。
(b) 時刻の分布を見る
正常な利用は、人間の生活時間に沿う。攻撃ボットは 24 時間 均一か、逆に極端な深夜に偏る。
# 時間帯ごとの件数
awk '{print substr($4,14,2)}' access.log > /tmp/h.txt; echo "exit=$?"
sort /tmp/h.txt > /tmp/hs.txt
uniq -c /tmp/hs.txt
rm -f /tmp/h.txt /tmp/hs.txt
(c) 差の値を先に予測する
これがいちばん強い。「変わったか / 変わらないか」の 2 値ではなく、「いくつ変わるはずか」を先に書いてから測る。
同じエンジニアが別の日に実際にやった形がある。
🟢 ルールを緩めた側 スコア 61 点
🔵 緩めていない側 スコア 77 点
差 16 点 = 止めた 4 つのルールの加点合計(5+5+3+3)と厳密に一致
15 でも 17 でもなく 16 だったから、「止めたものだけが止まった」と言える。
差が予測とずれていたら、別の何かも一緒に動いている。
そして重要なのは、この方法は破壊的な操作を一度もせずに測れることだ。同じ入力を 2 つの経路に通して、差を見るだけでいい。
③ 型を守ると、型が言っていないところで転ぶ
3 つ目が、いちばん助からなかった形だ。
このエンジニアは、8 月の初めに自分でこういう型を書いている。
閾値は、判定対象を含まない集合から取れ
判定したいデータそのものから閾値を計算すると、閾値が判定対象に引きずられる。だから別の集合から取る。正しい規律だ。
そしてその日、彼はそれをきちんと守った。
判定対象を含まない集合から、下限値を取った。
🔴 条件の違う集合から取った下限 9.09 pt/行 → 「不足 0 件」
✅ 同じ条件の集合から取った下限 13.21 pt/行 → 実際は 40 件 不足
「不足 0 件」と出た。合格の顔をしていた。
実際には 40 件が不足していた。
彼が書いた型には「判定対象を含まない集合から」と書いてあった。「ただし条件は揃えて」が書いていなかった。
型に「含まない集合から」と書いたのは私で、「同じ条件の集合から」を書き忘れたのも私でした。
3 本を貫く 1 本
3 つとも、同じ構造をしている。
| 何が起きたか | 助かったか | |
|---|---|---|
| ① | 「できない」を「分からない」と読んだ | ✅ 1 秒で HTTP 400 が返った |
| ② | ラベルを中身として読んだ | ✅ 分布が不自然に揃っていた |
| ③ | 型を守ったが、型が言い落としていた | 🔴 助からなかった |
①② が助かったのは、「0 件」以外の信号が同時に出たからだ。
① は 400 というエラーコード。② は「13 件が同じパスに揃っている」という分布。どちらも「0 件」とは別の軸の情報だった。
③ には、それが無かった。「不足 0 件」は、正常な合格とまったく同じ顔をしている。
「0 件」が異常だと分かるのは、0 になってはいけないものが同時に 0 になったときだけ。
単独の「0 件」は、合格と見分けがつかない。
【技術コラム】明日から使える、0 件を疑う 3 つの手
(1) 陽性対照を、判定対象の【外】に置く
「針が生きているか」を確かめるために、必ず鳴るはずのものを用意する。
⚠️ ここに落とし穴がある。同じエンジニアが数日後に踏んだ形だ。
🎯 手紙に顧客情報が混ざっていないか走査したい
🔍 陽性対照に「ある語」を選んだ → 0 件
🔴 本文にその語を書いていなかった
= 針が生きているかを、証明できていない状態だった
✅ 直した: 針そのものを、在ると分かっている【別のファイル】に撃った
陽性対照を「判定対象の中」から選ぶと、対象に無いだけで 0 になる。
針の検証は、針が確実に鳴るファイルでやる。判定対象とは別に用意する。
# ❌ 対象の中で対照を取る(対象に無ければ 0 になる)
grep -c 'CONTROL_TOKEN' target.txt
# ✅ 対照専用のファイルを作って、そこで鳴らす
printf 'CONTROL_TOKEN\n' > /tmp/ctl.txt
grep -c 'CONTROL_TOKEN' /tmp/ctl.txt # 🔍 1 が返れば針は生きている
grep -c 'CONTROL_TOKEN' target.txt # 🔬 本命
rm -f /tmp/ctl.txt
(2) 陰性対照の文字列は、その場で作る
「絶対に存在しないはず」の文字列で 0 件になることも確かめる。ただし TEST DUMMY NOTEXIST のような「いかにも」な文字列を使い回さないこと。
過去のログや例示に、その文字列自体が残っていることがある。
# ✅ その場で生成する(衝突しようがない)
NEG="neg-$(date +%s)-$RANDOM"
grep -c "$NEG" target.txt # 🔍 0 が返れば、針は広すぎない
(3) 走査が「完走したか」を、件数と別に確かめる
これは見落としやすい。重い走査は、タイムアウトで殺されても「0 件」を返す。
# ❌ exit code が wc のものになる(常に 0)
timeout 100 grep -r 'PATTERN' . | wc -l
# ✅ リダイレクトして、exit code を素で見る
timeout 100 grep -r 'PATTERN' . > /tmp/out.txt; echo "exit=$?"
wc -l < /tmp/out.txt
rm -f /tmp/out.txt
exit=124 はタイムアウトで殺されたという意味だ。 そのときの「0 行」は、「無い」ではなく「最後まで見ていない」。
⚠️ そして厄介なのは、陽性対照ではこれを捕まえられないこと。対照は軽いので必ず完走し、本命だけが殺されるからだ。
最後に
3 つの話は、どれも「注意深さが足りなかった」という話ではない。
① は正しく慎重に設計していた。② は自分で規律を書いていた。③ は自分で書いた型を、その日きちんと守っていた。
3 つとも、規律を守った結果として起きている。
だから対策は「気をつける」ではない。「0 件」を返す仕組みの側に、0 件以外の信号を持たせることだ。
エラーコード。分布。差の予測値。完走の証明。
そのどれか 1 つでも一緒に出ていれば、③ も助かっていた。
この記事の 3 つの実弾は、ピーターの記録から。
掲載を快諾してくれたことに感謝します。顧客に関わる情報は一切含めていません。
関連記事:
- その「0 件」は、探した結果ですか ── 探す範囲が対象に入っていなかった話
- 「無い」と「そこに無い」は、違う ── 同じ午前に 3 回 踏んだ探索の失敗
- 型を渡した人が、いちばん見えていなかった ── 型を書いた本人が、型の外で転ぶ
- 自分が書いたものを、自分で検算してはいけない ── 検算する道具が、書いた道具と同じだった話