404も含め、すべてを通過させたセーフティゲート
コンテンツフィルターが何ヶ月もの間、「問題なし」と報告していました。それは何も読み込んでいませんでした。チェックが静かに承認へと反転してしまう4つの方法と、障害を確実に検知する方法。
ある公開パイプラインには、デプロイのたびに実行されるチェックがありました。公開されたページを取得し、禁止用語をgrepで検索し、何かがマッチすればその投稿を取り下げる、というものです。それは毎回クリーンだと報告していました。また、その稼働期間中ずっと、それ以外のことを報告することは不可能でした。ページの取得が何も通知せずに空を返していたため、空に対するgrepは何もマッチせず、マッチなしは「安全」を意味するように設定されていたのです。
バグはフェッチではありません。バグは、「問題は見つからなかった」と「確認できなかった」が同じ出力を生成し、そのうちの1つだけが真実だったということです。
反転するゲートの形状
チェックには3つの可能な結果がありますが、ほとんどの実装では2つしかエンコードされません。問題を見つけるか、問題を見つけないか、あるいは確認に失敗するかです。3つ目は安全性が損なわれる部分です。なぜなら、チェックを記述する最も簡単な方法では、「確認に失敗した」が「問題は見つからなかった」と区別できなくなるからです。
こうしたチェックの典型的な出発点である、シェル版を考えてみましょう:
curl -s "$URL" | grep -iEf patterns.txt && echo "BLOCKED"
その行におけるあらゆる失敗は、何も出力しないという結果に終わります。404エラーが発生すると、-sは進捗表示を抑制するだけなので、curlは空のボディを返し、終了コード0で終了します。DNSの失敗も空のボディを返します。パターンファイルが空の場合、grepは何もマッチしません。パイプラインの終了ステータスは最後のコマンドのみを反映するため、curlが完全に失敗しても、grepは「マッチなし」と報告することになります。4つの異なる不具合、1つの見分けのつかない結果、そしてその結果が投稿を通過させてしまうのです。
この形状はコンテンツフィルターに特有のものではありません。接続エラーを「エラー報告なし」として扱うヘルスチェック。何も一致しないglobに対して呼び出されるリンター。ルールセットのダウンロードに失敗したセキュリティスキャナー。いずれの場合も、機構は正常で、レポートはグリーンであり、そのグリーンはダッシュボードが示唆することの逆を意味します。
同じチェックが失敗した4つの理由
問題のチェックは、4つの独立した方法で失敗しました。それぞれが単独で十分な原因となり、またそれぞれが単独では気づきにくかったため、この点をはっきりと述べる価値があります。
チェックが別のドキュメントを読み込んでいた
元のチェックは、HTMLをmarkdownに変換してから返すヘルパーを介してページを取得していました。これは読み取りには便利でしたが、パターンはマークアップに対して書かれていたため、照合にとっては致命的でした。属性、メタタグ、構造化データブロックは、この変換を生き残りません。チェックは実行されましたが何も見つからず、意図しない識別子が含まれる可能性が最も高いフィールドを一度も見ていませんでした。
この教訓は、この1つのヘルパーにとどまらず一般化できます。フィルターは、ある表現に対して書かれます。ソースとフィルターの間で何かがその表現を書き換えると、フィルターは本番環境のどこにも存在しないドキュメントをチェックすることになります。ユーザーが受け取るバイト列をそのまま取得してください。
ノイズが大きすぎて誰もシグナルを読んでいなかった
同じチェックが、すべてのページで7つか8つの一致を報告しました。それは常に同じもので、広告スクリプトに埋め込まれたパブリッシャーIDであり、サイトのすべてのページに設計上存在するものでした。
実行のたびに「オオカミが来た」と叫ぶチェックは、読まれなくなります。さらに悪いことに、それはオペレーターに包括的な抑制を追加するように仕向け、包括的な抑制は、本当の一致が葬り去られる場所です。
本能的に、検索範囲を記事の本文に絞りたくなります。それを測定したところ、より危険な方向で間違っていることがわかりました。広告は記事要素の内部でもレンダリングされるため、範囲を絞っても偽陽性の半分しか減りませんでした。その一方で、タイトル、メタディスクリプション、構造化データを新たに除外してしまいました。これらは著者の言葉から派生したものであり、まさに識別子が入り込む可能性のある場所です。
修正策は、スコープを縮小するのではなく、ノイズを除去することです。script、style、iframe、noscriptブロックを取り除き、広告コンテナタグはテキストを削除せずに展開し、構造化データはスクリプト除去の前に抽出し、後で再び追加することで保持します。同じページで測定した結果:修正前は7件の一致、スコープを絞った場合は4件、ノイズ除去と完全なカバレッジでは0件でした。
パターンファイルが空であることを許容していた
拒否リストの読み込みに失敗すると、パターンセットは空になり、空のパターンセットは何も一致しません。これは、本当にクリーンなページと同じ「クリーン」です。
この問題は安価に解決できますが、ほとんど行われていません:
PAT="$(grep -vE '^\s*#|^\s*$' denylist.txt)"
[ -n "$PAT" ] || { echo "empty denylist, cannot verify"; exit 1; }
チェックが依存するすべての入力は、同様に扱われるべきです。「不正」を定義するものが失われる可能性がある場合、その欠如は判定ではなく、エラーでなければなりません。
ゲートが失敗する可能性を誰もテストしなかった
最も根深い問題は、これが何一つ仮説ではなかったということです。存在しないURLにチェックを向け、問題なしと報告されることを観察することで、約1分で発見可能でした。
ゲートは、成功する方向でテストされます。誰かが投稿を書き、チェックを実行し、それが成功するのを見て、シップします。失敗する方向はめったに実行されません。なぜなら、本物の違反を生成することは、手間がかかり、少し危険だと感じられるからです。そのため、重要なブランチ、つまりリリースを停止させなければならないブランチが、決して実行されない唯一のブランチとなるのです。
これが不注意ではなく体系的なのには理由があります。テストのために本物の違反を生成するということは、本物のシークレットを含むテキストを書くことを意味し、それをコミットしたい人はいません。壊れたターゲットにチェックを向けることは、意図的に何かを壊すことを意味します。どちらも問題を作り出しているように感じられ、そしてチェックは機能しているように見えるため、その作業は永遠に先延ばしにされます。
2つの習慣がこれを恒久的に解決し、どちらもその不快感を回避します。
CIで既知の不正なフィクスチャに対してゲートをテストします。拒否リストから1つの用語を含む小さなファイルに、それに対してチェックが非ゼロで終了するというアサーションを加えます。フィクスチャは決してシップされず、実際のページに触れることもなく、誰かがパターンの読み込みや終了コードを壊した場合に大きな音で失敗します。本物の用語をコミットすることに抵抗がある場合は、純粋にこの目的のために拒否リストに存在するセンチネルエントリを使用してください。
既知の到達不能なターゲットに対してゲートをテストします。404になることが保証されているURLに向け、問題なしではなく、失敗を報告することをアサートします。その1つのアサーションがあれば、初日に元のバグを捕捉できたでしょうし、それはたった1行で済みます。
どちらのテストにも、言及に値する共通の特性があります。それらは、実際の内容なしにゲートを実行するということです。失敗方向のテストがスキップされる理由は、通常、人々が現実的な違反が必要だと想像するためですが、フィクスチャと不正なURLは、その要件を完全に取り除きます。
失敗を明確にする
チェックを書き直して3つの結果を分離することは、主にパイプラインがそれらを縮退させてしまうのを許さないということです:
BAD=0
for L in "${LANGS[@]}"; do
HTML="$(curl -fsSL "$BASE/$L/$SLUG")" \
|| { echo "fetch failed: $L" >&2; BAD=1; continue; }
[ -n "$HTML" ] || { echo "empty body: $L" >&2; BAD=1; continue; }
CLEANED="$(printf '%s' "$HTML" | strip_noise)" \
|| { echo "cleanup failed: $L" >&2; BAD=1; continue; }
if printf '%s\n' "$CLEANED" | grep -qiEf <(printf '%s\n' "$PAT"); then
echo "match: $L" >&2; BAD=1
fi
done
[ "$BAD" -eq 0 ] || retract
失敗する可能性のある各ステップは、実際の一致が割り当てるのと同じ失敗変数を設定します。curl -fは、HTTPエラーを空のボディではなく終了コードにします。各ステージは独自のコマンド置換で実行されるため、失敗はパイプラインの終了ステータスに飲み込まれるのではなく、キャッチ可能です。「何かがうまくいかなかった」状態から「クリーン」な状態に至る経路は存在しません。
これらから導き出される一般的なルールは、チェックは「害の証拠は見つからなかった」ではなく、「安全であることが検証された」を返すべきだということです。これらは似ているように聞こえますが、メカニズムが壊れたときには正反対の動作をします。前者は、チェックが実際に実行され、正しいドキュメントを調べ、比較対象となる「悪い」の定義を持っていたことを要求します。後者は、空の文字列が無料で、そして永遠に与えてくれるものです。
フェイルクローズに関する注意点が1つあります。それは無料ではないということです。すべての一時的なエラーでブロックするゲートは、ネットワークの一時的な障害でもブロックし、ブロッキングのコストが高い場合、チームはそれを回避しようとします。2つのことで、それを管理しやすくできます。失敗を宣言する前に、フェッチなどの一時的な部分を再試行することで、1回の接続切断がリリースを停止させないようにします。そして、作業を保存し、どのステージが失敗したかを正確に報告することで、ブロックされた状態からの回復を安価にします。フェイルクローズは、誤ったブロックのコストが1分間の混乱である場合には持続可能であり、1時間の再構築である場合には持続不可能です。
今日から実行できる簡単な監査
あなたが所有する自動化ゲートのいずれかについて、4つの質問を自問してみてください。
検査対象が見つからない、または到達できない場合、それはどうなりますか?もし答えが「成功 (passes)」なら、あなたはこのバグを抱えています。
検査しているのは、ユーザーが受け取るのと同じ表現ですか?それとも、ヘルパーが途中で再フォーマットしたものですか?
その設定、拒否リスト (denylist)、またはルールセットの読み込みに失敗した場合はどうなりますか?空のルールはエラーでなければなりません。
それが最後に意図的に失敗したのはいつですか?もし答えが「一度もない (never)」なら、あなたはそれが失敗できることさえ知らないのです。
この話に出てくるパイプラインは、今では4つの項目すべてにおいて安全な方向に倒れるようになり、その修正にかかった時間は半日足らずでした。高くついたのは修正作業ではありませんでした。高くついたのは、何の意味もなかった数ヶ月間の緑のチェックマークだったのです。