1. 「問題なし」で終わらせない
セキュリティレビューでは、ある入力を与えたときに、想定していないHTML解釈が起きなかったり、SQLらしい文字列がデータとして扱われたり、公開レスポンスに内部情報が現れなかったりすることがあります。
その確認は重要です。ただし、確認した結果をレビュー記録に残すだけでは、次の変更で同じ前提が静かに崩れたときに気づけないかもしれません。
Network CheckのSecurity Hardening作業では、XSS、SQL Injection、Command Injection、Error Information Leakageという4つの境界を確認しました。ここで扱うのは、Network Check全体が安全だという結論ではありません。確認した経路・入力・結果を、あとからもう一度確認できるregression testへ変換した、という小さなケーススタディです。
本稿で扱うのは、今回のセキュリティレビューで確認した防御策の一部です。Network Checkの防御策を網羅したものではありません。
流れは次のように整理できます。
レビューで得た一時点の観測
↓
再現可能なassertion
↓
将来の変更を検知するregression guard
この3つは同じものではありません。regression guardが提供するのは、テストとして書いた条件を再確認する手段です。脆弱性が存在しないことの証明でも、アプリケーション全体のcoverageでも、将来にわたる安全の保証でもありません。
2. 確認した4つの境界
4領域は同じ強さの結論になっていません。どの経路を見て、どんな防御を確認し、何をtestに残したかを分けて記録する必要があります。
| 領域 | 確認した経路 | 確認した防御 | 回帰assertion | 境界 |
|---|---|---|---|---|
| XSS | indexのheader表示、security-header template、Multi Check renderer | template escaping、textContent、createElement | markerのescaped output、unsafe sinkのsource contract | selected pathsのみ。全routeとbrowser実行は未確認 |
| SQL Injection | usage_metrics.pyのSQLite path | ? placeholderによるbound parameter | SQL-lookingな値をtemporary DBでdataとして保持し、tableの存続を確認 | usage-metricsとtemporary DBのみ |
| Command Injection | HTTP/2のcurl subprocess path | input validation、destination guard、list-form argv、shell回避 | metacharacter入力の拒否とargv境界をassert | HTTP/2 pathのみ |
| Error Information Leakage | selected check関数のerror resultと、metrics pageのHTTP response | Security Hardening作業で変更したgeneralized message、marker/path/DSN-like valueの非露出 | selected result / responseへのnegative assertion | PARTIAL。destination_guard.pyのpublic reachabilityはUNKNOWN |
XSS、SQL Injection、Command Injectionで確認した防御は、reviewed pathについてはSecurity Hardening作業より前のrevisionにすでに存在し、同じ作業ではそれを確かめるregression testが追加されました。Error Information Leakageは性質が異なり、selected error pathのmessageそのものを同じ作業の中でgeneralizedな内容へ変更しています。
この表の「確認した」は、この記事のための証拠確認でsourceやtestのコードを読んだ結果を指します。testやruntimeは、この証拠確認では実行していません。
3. XSS — escapeとDOM sinkをテストに残す
入力、表示、期待結果を分ける
最初のregression testは、scriptのように見えるmarkerを、index表示に使われるUser-AgentとAccept-Languageへ入れるものでした。testはレスポンスが200であることに加え、rawなmarkerが含まれず、HTMLとしてescapeされた表現が含まれることをassertします。
ここで固定された契約は、「すべての入力が常に安全」というものではありません。このtestが対象にしたheader値について、indexのtemplate rendering pathのresponse textにraw markerが存在せず、escaped representationが存在することです。browserがそのresponseをどう解釈するかは実行していません。
security-headerのtemplate pathにも、別のmarker testがあります。mockしたcheck関数が返すresultにmarkerを含め、/security-headersへのPOST responseでrawな表現が現れず、escaped representationが現れることを確認します。markerはURL入力としてPOSTしているのではなく、mockの戻り値に含まれています。このtestではcheck関数をmockしているため、遠隔のheader取得から表示までの全経路を一度に検証しているわけではありません。表示側のescape境界を切り出して確認しています。
DOM sinkの確認
Multi Checkの動的な結果表示では、static/multi_check.jsのrendererがdocument.createElementで要素を作り、textContentへ値を入れる構造になっています。source regression testは、static/multi_check.jsのsourceにinnerHTMLまたはinsertAdjacentHTMLが含まれることを拒否し、textContentとdocument.createElementが存在することを要求します。
これはbrowserを起動して全画面を操作するtestではありません。sourceに対するcontractです。templateとstaticのreviewed pathに明示的な|safeやMarkup(...)がないというsource observationも、同じようにそのrevisionの範囲に限られます。
つまり、XSSについて残したのは次のような限定されたassertionです。
selected input
→ selected rendering path
→ escaped output / textContent
→ regression assertion
これらのtest結果を、アプリケーション全体のXSS不存在へ一般化することはできません。すべてのtemplate、すべてのDOM sink、すべてのbrowser挙動を確認したわけではないからです。
4. SQL Injection — SQLらしい文字列を「データ」のまま扱う
SQL Injectionの確認対象は、network_check/usage_metrics.pyのSQLite pathです。record_usage_eventでは、日付、event type、target ID、count、更新時刻などの値を、SQL文字列へ連結するのではなく、? placeholderの対応値として渡します。summaryやdashboardのreviewed queryでも、変数として扱う値はbound parameterになっています。
regression testでは、SQL文の一部に見える文字列をtarget_idやevent_typeとしてtemporary SQLite DBへ渡します。testは、その文字列がSQL syntaxとして実行されるのではなく、値として保持されることを確認します。別のtestでは、malicious-lookingな値を記録したあとも、対象のmetrics tableをqueryできることをassertします。
ここで保存した契約は、次の形です。
reviewed usage-metrics value
→ bound parameter
→ temporary SQLiteへdataとして保存
→ value保持 / tested table存続
このtestは、SQL Injectionが不可能だと証明するものではありません。production database、別のstorage、将来追加されるquery、またはpublic requestからdatabaseまでの全経路を対象にしていません。parameter bindingというreviewed pathの前提を、悪性に見える値でも壊さないことを回帰条件にしたものです。
5. Command Injection — shellを使わないだけではなく、境界をテストする
Command Injectionについては、確認対象をHTTP/2 checkのcurl subprocess pathに限定します。このpathでは、ひとつの防御を「安全なcommand」と呼ぶのではなく、複数の境界を分けて確認します。
- 入力形式を検証する。
- 接続先をdestination guardで確認する。
- subprocessへ渡す値をlist-form argvの境界に置く。
- reviewed pathで
shell=Trueを使わない。 - これらの前提をregression assertionにする。
domain形式のvalidationを通らないshell metacharacterを含む入力を、regression testの入力として使います。期待結果は、generalizedなinvalid-domain errorが返り、destination guardもsubprocessも呼ばれないことです。これは第三者のtargetへ行うexploit手順ではなく、Network Check自身の入力境界を確認するための非破壊的なtestです。
validなdomainのtestでは、mockしたsubprocess.runの呼び出しを調べ、引数がlistであること、shell keywordを使っていないこと、必要な--resolveの値がargv内にあることをassertします。curlそのものは実行しておらず、この記事のための証拠確認でもcurlは実行していません。source reviewでも、検索したapplication scopeのsubprocess pathはHTTP/2のsubprocess.runに限られ、Popen、os.system、shell=Trueは確認されませんでした。
ただし、input validation、destination guard、argv separation、shell avoidanceは同じ意味ではありません。destination guardは接続先の境界であり、shell parsingの代わりではありません。list-form argvはcommandの引数境界を保ちますが、入力検証や接続先の検証を置き換えません。
したがって、このtestから言えるのは、reviewed HTTP/2 curl pathのこれらの境界を選択した入力で再確認できる、ということです。Network Checkのすべてのsubprocess、外部script、将来のcommand pathについて一般化することはできません。
6. Error Information Leakage — selected resultにmarkerが出ないことを確認する
この領域は、ほかの3つと成り立ちが異なります。selected error pathのmessageを、Security Hardening作業の中でgeneralizedな内容へ変更し、その変更後の挙動をregression testに固定しました。前からあった防御をそのまま固定したケースではありません。
変更後のsourceでは、security headersのtransport failure、HTTP/2のsubprocess failure、TLSのsocket failure、DNS timing・MX・CAA・PTRのselected resolver failure、metricsのdashboard failureといった、reviewedした例外branchがfunctionごとのgeneralized messageを返します。ただし、HTTP/2、TLS、security headersには、destination guardが返すerrorを結果へそのまま渡す別の経路があり、その最終的なpublic exposureは未解決です(後述)。
testでは、内部用marker、fake filesystem path、private-address marker、fake DSNのような文字列を、mockした処理の例外などに含めます。security headers、HTTP/2、DNS timing、MX、TLS、CAA、PTRのtestは、check関数が返すerror resultにmarkerが含まれないことをassertします。これらはcheck関数単位のtestで、そのresultを描画したHTTP responseまでは確認していません。rendered HTTP responseを確認しているのはmetricsの2つのtestで、TestClientで/usage-metricsを取得し、response textにmarkerやDB pathが含まれないことをassertします。あわせて、usage_metrics_status()はDBの存在状態だけを返し、DB filesystem pathをtemplateへ渡さない構造になっています。
ただし、この領域はPARTIALです。すべてのpublic routeとすべてのerror branchに同じmarker testがあるわけではありません。たとえば、MXのmarker pathはtestされていますが、SPFとDMARCはsource上のgeneralized messageを確認した範囲で、同じ直接marker testまでは行っていません。rendered routeの全組み合わせをbrowserで実行したわけでもありません。
この節で固定した条件は、次の形に限られます。
Security Hardening作業で変更したselected error behavior
→ markerを含む失敗 / 入力
→ selected check-function result、またはmetrics pageのresponseにmarkerが現れない
→ regression assertion
これはerror surface全体のcoverageではありません。
destination_guard.pyをUNKNOWNのまま残す
source reviewでは、destination_guard.pyの名前解決処理がsocket.gaierrorを捕捉し、そのexception valueをerror文字列へ補間するpathが確認されました。source上は、その値がguardから呼び出し側へ返り、HTTP/2、TLS、security headersのresultに入り、対応するtemplateがresult.errorを描画する経路が観測されます。
一方で、この記事のために確認した証拠には、markerを含むresolver errorを実際に発生させ、最終的なpublic HTMLまたはAPI responseへ何が出るかをassertするtestがありません。runtime/public requestも実行していません。実際のresolver errorにどのような情報が含まれるかも確認していません。
したがって、この記事での分類は次の通りです。
destination_guard.py resolver-error path
classification: UNKNOWN
description: potential but unproven public exposure
これは脆弱性や情報漏えいと断定するものでも、安全または到達不能と断定するものでもありません。確認できていない経路を確認済みのtest結果へ混ぜないための、記事上の境界です。
7. 共通して行ったこと — 防御の確認からregression testへ
4領域の技術的な内容は異なります。HTML escaping、SQLite parameter binding、subprocess argv、public error messageは、それぞれ別の層の問題です。
それでも、作業を保存する方法には共通点があります。
確認した防御(Error Information Leakageでは同じ作業で変更した挙動)
→ adversarial / negative input
→ 観測可能な期待結果
→ regression assertion
XSSではescaped representationやtext sink、SQL Injectionではdata保持とtable存続、Command Injectionではsubprocess未呼出しとargv境界、Error Information Leakageではmarker非露出が期待結果になります。
この共通点は、すべてのセキュリティレビューに適用できる普遍的な方法だという主張ではありません。Security Hardening作業でレビューした経路について、「どの前提を次回も見たいのか」をtestの形へ落としたという、ケーススタディ上の共通パターンです。
8. 回帰テストにすると何が変わるか
ここからは主にINFERENCEです。
レビューの記録は、「このrevisionを、この入力と経路で見たときは、こう観測した」というpoint-in-timeの情報です。regression testへ変換すると、将来の変更によってtestに書いた条件が破られた場合、testを再実行したときの失敗として検知できます。testに書いていない挙動の変化まで検知できるわけではありません。
つまり、レビュー結果を次のような条件へ近づけられます。
レビュー時の観測:
selected markerはresult / responseに現れなかった
将来の検知条件:
同じselected pathでmarkerが現れたらtestが失敗する
この変換は、「安全になった」という結論を強めるものではありません。確認対象を再実行可能にし、前提の変化を見つける機会を残すものです。実装が変われば、testが守っている条件も、testが対象にしていない境界も、改めて見直す必要があります。
なお、Network CheckのSecurity Hardening作業では、作業途中のchangelogに70/70 PASSが記録され、最終的なSecurity Hardening commitには73/73 PASSが記録されています(changelog側も後に73/73へ訂正されています)。どちらもrepositoryに残るREPORTEDな履歴で、この記事のための証拠確認で実行した結果ではありません。現在のtest suiteは、この記事のためには実行していません。
9. テストで確認できること、できないこと
regression testを追加したことは、レビューを記録するうえで有用です。しかし、testの存在とpassだけから、より広い結論を導くことはできません。
- ここで確認したのはselected pathsです。すべてのroute、template、DOM sink、database path、subprocess pathではありません。
- 入力もselected payloadsに限られます。すべての攻撃variantや入力組み合わせを列挙したものではありません。
- testしていない経路は、安全とも危険とも確認できておらず、UNKNOWNのままです。
- sourceの構造とruntimeの挙動は同じではありません。この記事のための証拠確認では、runtimeやpublic requestを実行していません。browserでの実行も行っていません。
- 一部のtestはcheck関数やsubprocessをmockして境界を切り出しています。mockの外側にある実際の取得処理やcurlの実行は、これらのtestの対象外です。
- dependencyやframeworkの変更によって、同じsource-levelの意図でも挙動が変わる可能性があります。
- Error Information Leakageでは、generalized messageとmarker非露出を確認したselected pathがある一方、SPF/DMARCの直接marker testや他のpublic routeは未確認です。
destination_guard.pyのresolver exceptionが最終public responseへ到達するかは、なおUNKNOWNです。70/70と73/73はrepositoryが報告した履歴であり、この記事のために実行したtest結果ではありません。
したがって、passing testsは脆弱性の不存在を証明しません。Network Check全体が安全であること、全exceptionが一般化されていること、すべてのrouteがcoveredであることも証明しません。
10. まとめ
この記事で示したいのは、単に「問題が見つからなかった」という記録ではありません。
確認した範囲では、header/templateのescape、Multi Checkのtext sink、usage-metricsのparameter binding、HTTP/2のargv境界という、作業前からあった前提がありました。Error Information Leakageでは、selected error pathのmessageを同じ作業で変更し、その変更後のmarker非露出を対象にしました。それぞれに対して、異常または悪性に見える入力を使い、期待する観測結果をregression assertionとして残しました。
その結果、レビュー時点の観測を、将来の変更後にも再確認できる条件へ変換できました。これは、防御を「問題なし」という一時点の判断だけで終わらせないための、具体的な記録方法です。
ただし、その条件が守るのは、テストとして書いた範囲だけです。未確認の経路とdestination_guard.pyのUNKNOWNは、結論から隠さず、次のレビューで確認すべき境界として残ります。