じゃあ、おうちで学べる

本能を呼び覚ますこのコードに、君は抗えるか

OWASP ZAP の finding を Rust/Axum の handler に戻して直す

はじめに

vulnerable-app に ZAP の full scan を回すと、High finding が並びます。XSS、SQL Injection、Path Traversal。alert 名を眺めて、ふと気づく。これは「危険です」の一覧ではない。handler への差し戻し指示書だ。

OWASP ZAP を実行すると、HTML、Markdown、JSON のレポートが出ます。そこには Cross Site Scripting (Reflected)SQL InjectionPath Traversal のような名前が並びます。ただ、alert 名だけを見ても修正は始まりません。必要なのは、finding を実際の handler、SQL、HTML 出力、ヘッダ設定に戻すことです。

OWASP ZAP は OSS の web 脆弱性スキャナで、HTTP リクエストを投げて挙動から問題を推定します。レポートに出る 1 件が alert あるいは finding です。この記事では「finding」を使い、route(URL)と handler(そのルートを処理する関数)に戻す作業を扱います。

この記事では、tools/owasp-zap の Rust / Axum アプリを題材にします。Rust の web framework である Axum は、URL と関数を対応づける形で HTTP を処理します。たとえば /search へのアクセスは search 関数に入ります。

話の中心は「脆弱なサービスを作った」ことではありません。ZAP の結果を読み、修正前後の main.rs を見比べます。どの実装が問題で、どう直したかを確認する流れです。

検証コードはここにあります。

github.com

リポジトリ上では、修正前後を分けています。

役割 パス 用途
修正前 tools/owasp-zap/vulnerable-app/src/main.rs ZAP が finding を出す実装
修正後 tools/owasp-zap/fixed-app/src/main.rs finding を閉じた実装
比較用要約 tools/owasp-zap/reports/zap-findings-summary*.md scan 結果の要約

つまり、ブログでは一部のコードを転記しますが、全体の before/after は GitHub 上の 2 つの app として読めます。

ZAP の baseline scan は passive scan が中心です。一方で full scan は active scan を実行します。実サービスや共有環境で動かす前に、対象と許可範囲を必ず固定してください。

www.zaproxy.org

www.zaproxy.org

このブログが良ければ読者になったりnwiizoXGithubをフォローしてくれると嬉しいです。

この記事で使う形式

本文のコマンドは、リポジトリ root から実行する前提です。 cd tools/owasp-zap した後は、すべて tools/owasp-zap を基準にします。

シェルの NAME=value command は、その command にだけ環境変数を渡す書き方です。 たとえば次の 1 行は、scan 時間の上限を渡して full scan を実行します。

ZAP_MAX_SCAN_MINUTES=3 ZAP_MAX_RULE_MINUTES=1 ./scripts/run-zap.sh full

この記事で使う ZAP wrapper の入口は、次の形式です。

./scripts/run-zap.sh baseline|full

baseline は spider と passive scan を中心にした軽い確認です。 full は active scan なので、攻撃 payload を対象へ送ります。 この違いがあるため、この記事では検証用 app だけを対象にします。

wrapper に渡す主な環境変数は次の通りです。

名前 意味
ZAP_TARGET http://fixed-app:5000 ZAP コンテナから見た scan 対象
REPORT_PREFIX zap-full-fixed 出力ファイル名の prefix
ZAP_MAX_SCAN_MINUTES 3 full scan 全体の上限分数
ZAP_MAX_RULE_MINUTES 1 active scan rule ごとの上限分数

REPORT_PREFIX=zap-full-fixed を指定すると、次の 3 つが作られます。

出力 形式 主な用途
reports/zap-full-fixed.html HTML ブラウザで読む詳細レポート
reports/zap-full-fixed.md Markdown ZAP が出す Markdown レポート
reports/zap-full-fixed.json JSON script、agent、CI が読む元データ

この JSON が一番大事です。 ZAP の JSON は、大きく見ると site -> alerts -> instances という形です。

{
  "site": [
    {
      "alerts": [
        {
          "alert": "Cross Site Scripting (Reflected)",
          "riskdesc": "High (Medium)",
          "confidence": "2",
          "pluginid": "40012",
          "cweid": "79",
          "instances": [
            {
              "method": "GET",
              "uri": "http://vulnerable-app:5000/search?q=...",
              "param": "q",
              "attack": "\"><scrIpt>alert(1);</scRipt>",
              "evidence": "\"><scrIpt>alert(1);</scRipt>"
            }
          ]
        }
      ]
    }
  ]
}

alert は finding の種類です。 instances は、その finding が実際に出た URL、method、parameter の一覧です。 evidence は、ZAP が根拠として拾った文字列です。 この記事では、instances を route と handler に戻していきます。

scripts/summarize-zap-json.mjs は、この JSON を短い Markdown に変換します。 要約 Markdown の形式は、次のように決めています。

## Totals

| Risk | Alerts | Instances |
| --- | ---: | ---: |
| High | 4 | 12 |

## Findings

### High: Cross Site Scripting (Reflected)

- Risk: High (Medium)
- Confidence: 2
- Plugin ID: 40012
- CWE: 79
- Instances: 8
- Example locations:
  - GET http://vulnerable-app:5000/search?q=...; param `q`; evidence `...`

Alerts は finding の種類数です。 Instances は実際に見つかった箇所数です。 つまり、HighAlerts が 4 でも、修正箇所は 4 個とは限りません。 同じ原因が、複数の route や parameter に出ていることがあります。

まず finding を要約する

今回の full scan は次で実行しました。

cd tools/owasp-zap
mkdir -p reports
docker compose up --build -d vulnerable-app
./scripts/wait-for-http.sh "http://127.0.0.1:18080/health"
ZAP_MAX_SCAN_MINUTES=3 ZAP_MAX_RULE_MINUTES=1 ./scripts/run-zap.sh full
node scripts/summarize-zap-json.mjs reports/zap-full.json > reports/zap-findings-summary.md

scripts/summarize-zap-json.mjsreports/zap-full.json から Markdown の要約を作ります。 出力先は、agent や人間が読みやすい reports/zap-findings-summary.md です。 最初に見るべきなのは HTML レポートではなく、この短い要約です。

今回の High finding は次の 4 系統でした。

ZAP finding 入口 見るべきコード
Cross Site Scripting (Reflected) /search, /account, /loans, error page search, update_account, create_loan, server_error
SQL Injection /book?id=... book, query_books
SQL Injection /login login, query_patron
Path Traversal /download?file=... download

この表を作るところが最初の仕事です。ZAP の alert を「危険です」で終わらせず、どの route と関数に戻すかを決めます。この作業を triage(トリアージ)と呼びます。医療のトリアージと同じで、まず件数と場所を俯瞰し、直す順序を決めるためのものです。alert はノイズ、triage は地図です。 知っていることと直せることの間には、思っているより距離があります。その距離を埋めるための地図です。

ZAP を実行して分かったこと

今回の検証で一番大事だったのは、ZAP の実行を「1 回のスキャン」ではなく「比較できる手順」にすることでした。修正前と修正後を比べるには、target、report 名、scan 時間、除外 rule を固定する必要があります。

まず、Docker で動かす時は target URL の視点を間違えやすいです。ブラウザから見る URL は http://127.0.0.1:18080 です。ただし、ZAP コンテナから 127.0.0.1 を見ると、それは ZAP コンテナ自身です。そこで scan target は Compose 内の service 名を使いました。修正前は http://vulnerable-app:5000、修正後は http://fixed-app:5000 です。

cd tools/owasp-zap
mkdir -p reports

docker compose up --build -d vulnerable-app
./scripts/wait-for-http.sh "http://127.0.0.1:18080/health"
ZAP_MAX_SCAN_MINUTES=3 ZAP_MAX_RULE_MINUTES=1 ./scripts/run-zap.sh full

docker compose up --build -d fixed-app
./scripts/wait-for-http.sh "http://127.0.0.1:18081/health"
ZAP_TARGET=http://fixed-app:5000 \
REPORT_PREFIX=zap-full-fixed \
ZAP_MAX_SCAN_MINUTES=3 \
ZAP_MAX_RULE_MINUTES=1 \
./scripts/run-zap.sh full

次に、ZAP の exit code を CI の失敗と混同しないことです。baseline scan の公式ドキュメントでは、0 は成功、1 は FAIL あり、2 は WARN あり、3 はその他の失敗です。脆弱性を見つけるための検証では、12 は「スキャンできたから finding が出た」と読む場面があります。今回の wrapper では 012 を scan completed として扱い、実際の成否は JSON summary で判断します。

また、baseline と full は役割が違います。baseline は spider と passive scan が中心なので、短時間の smoke check に向いています。full scan は active scan なので、攻撃 payload を送ります。PR ごとに full scan を雑に回すより、baseline を軽い gate に置きます。full scan はローカル、nightly、壊してよい staging に限定します。こうすると CI の時間と active scan のリスクが釣り合います。

実行時間も固定しました。ZAP の active scan は rule や target の反応で長引きます。記事用の再現では、ZAP_MAX_RULE_MINUTESZAP_MAX_SCAN_MINUTES で上限を入れました。時間を固定しないと、前回と今回の差が「コード修正の差」なのか「scan が深く回った差」なのか分かりにくくなります。

ZAP_MAX_SCAN_MINUTES=3 ZAP_MAX_RULE_MINUTES=1 ./scripts/run-zap.sh full

ZAP_TARGET=http://fixed-app:5000 \
REPORT_PREFIX=zap-full-fixed \
ZAP_MAX_SCAN_MINUTES=3 \
ZAP_MAX_RULE_MINUTES=1 \
./scripts/run-zap.sh full

レポート名も分けます。修正前を zap-full.json、修正後を zap-full-fixed.json として残し、それぞれから summary を作ります。HTML レポートは人間向け、JSON は機械処理向け、summary は agent と人間の共有メモです。最初から summary を残すと、後で「何が消えたか」を説明しやすくなります。

node scripts/summarize-zap-json.mjs reports/zap-full.json > reports/zap-findings-summary.md
node scripts/summarize-zap-json.mjs reports/zap-full-fixed.json > reports/zap-findings-summary-fixed.md

最後に、ZAP の rule は実行環境の影響を受けます。手元では、DOM XSS rule が Firefox / Marionette まわりで不安定でした。そのため、zap/full-scan.conf で DOM XSS だけ IGNORE にしました。隠さず書くべき判断です。除外した rule と理由を残しておくと、読者も再現条件を評価できます。

ZAP は「実行すれば真実が出る道具」ではありません。同じコードに同じ ZAP を当てても、結果は揃いません。揃えるのはこちらの仕事です。scan scope、認証状態、ブラウザ依存 rule、timeout、除外設定で結果が変わります。だからこそ、実行条件を固定し、summary を残し、コード上の root cause と照合します。

Reflected XSS は出力境界で直す

Reflected XSS(反射型クロスサイトスクリプティング)は、攻撃者が URL や form に埋めたスクリプトが、サーバのレスポンスにそのまま反射され、被害者のブラウザで実行される攻撃です。たとえば /search?q=<script>alert(1)</script> を踏ませると、検索結果画面で script が動きます。script が動くと、cookie を盗んだり、画面を書き換えたり、フォーム送信を別サイトへ向けたりできます。

ZAP summary には、/search?q=...q parameter が reflected XSS として出ています。対応するコードは vulnerable-app/src/main.rssearch handler です。現在の実装では、query string をそのまま HTML の本文と attribute に埋め込んでいます。

async fn search(Query(params): Query<SearchParams>) -> Response {
    let query = params.q.unwrap_or_default();
    let result = if query.is_empty() {
        "Enter a search term.".to_string()
    } else {
        format!("Results for: {query}")
    };

    let content = format!(
        r#"
        <input name="q" value="{query}" aria-label="Search query">
        <p>{result}</p>
        "#
    );

    layout("Search Catalog", content).into_response()
}

ここで直すべきなのは「script という文字列を拒否する」ことではありません。<script> を弾いても <img onerror=...> のような抜け道がいくらでも作れます。やるべきは、HTML に出す直前で escape することです。escape とは、<" のような HTML で特別な意味を持つ文字を、&lt;&quot; のような「表示はされるが構文には影響しない」形に変換することです。

本文(text node)と attribute では escape の文脈が違います。<p>{値}</p> に入る値は <&lt; にすれば十分です。一方で <input value="{値}"> に入る値は "&quot; にしないと attribute を閉じられてしまいます。Rust なら、たとえば html-escape crate を使い、text node と double quoted attribute を分けます。

修正後の fixed-app/Cargo.toml では、HTML escape 用に html-escape を追加しています。

html-escape = "0.2"
use html_escape::{encode_double_quoted_attribute, encode_text};
let query = params.q.unwrap_or_default();
let query_text = encode_text(&query);
let query_attr = encode_double_quoted_attribute(&query);
let result = if query.is_empty() {
    "Enter a search term.".to_string()
} else {
    format!("Results for: {query_text}")
};

let content = format!(
    r#"
    <input name="q" value="{query_attr}" aria-label="Search query">
    <p>{result}</p>
    "#
);

encode_text は text node 用、encode_double_quoted_attribute"..." で囲まれた attribute 用です。同じ値でも、入る場所で必要な escape が違います。これが「出力境界で escape する」の意味です。書いた直後はそれらしく見えますが、全 route で完璧に escape できている自信は、正直なところ毎回ありません。

同じ見方で update_accountcreate_loan も確認します。display_namebook_idborrower がそのまま <p><input value="..."> に入っています。ZAP が複数の XSS を出す時は、同じ root cause が複数 route に散っていることが多いです。個別に blacklist を足すより、HTML を生成する境界で escape する方が安定します。

もう 1 つ見落としやすいのが server_error です。booklogin は SQL error を受けると、error message と SQL を <pre> に出します。

fn server_error(title: &str, error: &str, sql: &str) -> (StatusCode, Html<String>) {
    let content = format!(
        r#"
        <h1>{title}</h1>
        <p class="error">{error}</p>
        <pre>{sql}</pre>
        "#
    );
    (StatusCode::INTERNAL_SERVER_ERROR, layout(title, content))
}

これは XSS と情報漏えいの両方を引き起こしてしまいます。修正では、まず errorsql を画面に出さない。どうしても出すなら escape します。本番では request id だけ返し、詳細は server log へ残す形に寄せます。fixed-app/src/main.rs では、DB error を eprintln! に逃がし、画面には短い reference だけを返しています。

fn server_error(title: &str, request_id: &str) -> (StatusCode, Html<String>) {
    let title = escape_text(title);
    let request_id = escape_text(request_id);
    let content = format!(
        r#"
        <h1>{title}</h1>
        <p class="error">Request failed. Reference: {request_id}</p>
        "#
    );
    (StatusCode::INTERNAL_SERVER_ERROR, layout(title, content))
}

SQL Injection は文字列連結を消す

SQL Injection は、ユーザー入力を SQL 文字列にそのまま埋め込んだとき、入力側に SQL の構文を混ぜられてクエリの意味を変えられる攻撃です。たとえば id=1SELECT ... WHERE id = 1 になりますが、id=1 OR 1=1 を渡されると WHERE id = 1 OR 1=1 になり、全件が返ります。login でも usernamepassword' OR '1'='1 を混ぜられると、認証をすり抜けられます。

/book?id=... の SQL Injection は book handler に戻します。現在のコードは id を SQL 文字列に直結しています。

async fn book(State(state): State<AppState>, Query(params): Query<BookParams>) -> Response {
    let id = params.id.unwrap_or_else(|| "1".to_string());
    let sql = format!("SELECT id, title, author, status FROM books WHERE id = {id}");

    let rows = match query_books(&state, &sql) {
        Ok(rows) => rows,
        Err(error) => {
            return server_error("Database Error", &error.to_string(), &sql).into_response();
        }
    };
}

この修正は 2 段階です。まず id を数値として parse します。数値以外を弾くだけで、1 OR 1=1 は parse 時点で落ちます。次に SQL を placeholder に変えます。placeholder(?1?)は、SQL の文法と値を分けて DB に渡す仕組みです。入力がどんな文字列でも、値として扱われ、SQL 文として解釈されません。これが「prepared statement」と呼ばれるものです。

use rusqlite::{params, Connection};
let id = match params
    .id
    .as_deref()
    .unwrap_or("1")
    .parse::<i64>()
{
    Ok(id) => id,
    Err(_) => return (StatusCode::BAD_REQUEST, "invalid book id").into_response(),
};

let rows = match find_book_by_id(&state, id) {
    Ok(rows) => rows,
    Err(_) => return server_error("Database Error", "book-query").into_response(),
};
fn find_book_by_id(state: &AppState, id: i64) -> rusqlite::Result<Vec<Book>> {
    let conn = state.db.lock().expect("database mutex poisoned");
    let mut statement =
        conn.prepare("SELECT id, title, author, status FROM books WHERE id = ?1")?;
    let rows = statement.query_map(params![id], map_book)?;
    rows.collect()
}

ポイントは、query_books(state, sql: &str) のような「任意 SQL を受け取る helper」を残さないことです。helper は便利ですが、呼び出し側が文字列連結を始める余地を残します。用途ごとに find_book_by_id のような関数に分けます。型と placeholder が近くに並び、呼び出し側が SQL を組み立てなくてよい形になります。

/login も同じです。修正前の login handler は username と password を SQL に埋め込んでいます。

let sql = format!(
    "SELECT username, role FROM patrons WHERE username = '{}' AND password = '{}'",
    form.username, form.password
);

ここも placeholder にします。

fn find_patron(
    state: &AppState,
    username: &str,
    password: &str,
) -> rusqlite::Result<Option<Patron>> {
    let conn = state.db.lock().expect("database mutex poisoned");
    let mut statement = conn.prepare(
        "SELECT username, role FROM patrons WHERE username = ?1 AND password = ?2",
    )?;
    let mut rows = statement.query_map(params![username, password], |row| {
        Ok(Patron {
            username: row.get(0)?,
            role: row.get(1)?,
        })
    })?;

    rows.next().transpose()
}

password の保存方式は別問題です。このサンプルは plain text password なので、本来は password hash も直すべきです。ただし、password hash への修正は本記事のスコープ外とします。ZAP の SQL Injection finding を閉じる修正としては、まず SQL 文字列連結を消すことが第一です。

Path Traversal は allow list で閉じる

Path Traversal は、ユーザーが指定したファイル名に ../ を混ぜられたとき、意図したディレクトリの外のファイルが読まれてしまう攻撃です。files/ 配下だけを配布するつもりでも、../secret-config.txt のような値を渡されると、親ディレクトリの設定ファイルや秘密情報が漏れます。

Path Traversal は /download?file=... から出ています。対応する download handler は、受け取った filedownload_dir に join しています。

let file_name = params
    .file
    .unwrap_or_else(|| "borrowing-policy.txt".to_string());
let target = state.download_dir.join(&file_name);
let bytes = tokio::fs::read(&target).await?;

join は sandbox ではありません。../secret-config.txt のような値を受けると、意図したディレクトリの外に出ます。今回のように配布ファイルが少ないなら、最初は allow list が一番読みやすい修正です。allow list は「受け付ける値を事前に列挙しておく」方針で、危険な文字列を弾く blacklist より抜け漏れが少なくなります。../ を弾くだけの実装は ..%2f....// のような変形で抜けられるため、許可したものだけを通すほうが安全です。

let requested = params
    .file
    .as_deref()
    .unwrap_or("borrowing-policy.txt");

let allowed_file = match requested {
    "borrowing-policy.txt" => "borrowing-policy.txt",
    _ => return (StatusCode::NOT_FOUND, "file not found").into_response(),
};

let target = state.download_dir.join(allowed_file);
let bytes = match tokio::fs::read(&target).await {
    Ok(bytes) => bytes,
    Err(_) => return (StatusCode::NOT_FOUND, "file not found").into_response(),
};

動的にファイルを扱うなら、root と target を canonicalize して starts_with を確認します。ただし、今回の図書館ポリシー配布では allow list のほうが明快です。ZAP の finding に対して、必要以上に汎用的なファイルサーバを作らないことも修正方針の一部です。

Medium finding には、CSP 不在、clickjacking 対策不足、cookie flag 不足が出ます。これは個別 handler の問題ではなく、レスポンス共通処理の問題です。

現在の cookie は login handler でこう作っています。

let cookie = format!(
    "library_session={}-{}-session; Path=/",
    patron.username, patron.role
);

最低限、HttpOnlySameSite を付けます。HTTPS 前提なら Secure も付けます。各 flag の意味はこうです。

  • HttpOnly: JavaScript から document.cookie で読めなくなる。XSS が残っていても session token を直接盗まれにくくなる
  • SameSite=Lax: 別サイトからの POST で cookie が送られなくなる。CSRF の影響を小さくする
  • Secure: HTTPS 以外のリクエストでは送られなくなる。中間者に cookie を盗まれないための基本
let cookie = format!(
    "library_session={}-{}-session; Path=/; HttpOnly; SameSite=Lax",
    patron.username, patron.role
);

security header は、各 handler に散らすより response helper に寄せます。ここで入れる header の役割はこうです。

  • X-Content-Type-Options: nosniff: ブラウザが content type を推測しない。text を script として解釈させない
  • X-Frame-Options: DENY: 自サイトを iframe で他サイトに埋め込ませない。clickjacking(透明な iframe に被せて意図しないクリックをさせる攻撃)対策
  • Content-Security-Policy: default-src 'self': 自ドメインの資源だけ読み込みを許可。XSS の影響範囲を狭める第二の壁
  • Permissions-Policy: camera や geolocation などの browser API を明示的に閉じる
  • Cross-Origin-Resource-Policy: cross-origin 読み込みの扱いを明示する
async fn add_security_headers(mut response: Response) -> Response {
    let headers = response.headers_mut();
    headers.insert(
        HeaderName::from_static("content-security-policy"),
        HeaderValue::from_static(
            "default-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
        ),
    );
    headers.insert(
        HeaderName::from_static("x-content-type-options"),
        HeaderValue::from_static("nosniff"),
    );
    headers.insert(
        HeaderName::from_static("x-frame-options"),
        HeaderValue::from_static("DENY"),
    );
    headers.insert(
        HeaderName::from_static("permissions-policy"),
        HeaderValue::from_static("camera=(), geolocation=(), microphone=()"),
    );
    headers.insert(
        HeaderName::from_static("cross-origin-resource-policy"),
        HeaderValue::from_static("same-origin"),
    );
    response
}

fixed-app では Axum の middleware::map_response を使い、すべてのレスポンスへ共通 header を差し込んでいます。layout ごとに header を足すと漏れるため、router の外側でまとめるほうが安全です。

CSRF(Cross-Site Request Forgery)は、ログイン済みユーザーのブラウザから、攻撃者のサイト経由で対象サイトへ state-changing なリクエストを送らせる攻撃です。ユーザーが意図しない投稿・送金・設定変更を踏ませられます。対策は、server が発行したランダムな token を form に埋め、POST 時に session の token と一致するかを検証することです。

CSRF token 不在は、単に hidden input を足せば終わりではありません。server-side session、token 生成、token 保存、POST 時の検証が必要です。今回のコードでは /account/loans が state-changing form なので、cookie を実セッション化した後で CSRF token を入れる順序になります。

誰でも使える skill として整理する

Codex や Claude Code と連携するなら、agent に full scan を自由に実行させるより、ZAP の結果をコードに戻す triage を任せるほうが安全です。この考え方は Rust / Axum だけのものではありません。Rails、Django、Express、Go、Spring でも同じです。

skill に閉じ込めるべき学びは、ツール固有のコマンドではありません。ZAP の finding をコード修正へ変換する型です。

finding -> route -> handler -> vulnerable pattern -> fix pattern -> regression

この型にすると、場所を選ばず使えます。入力は ZAP の JSON か要約、対象コード、許可済みの target URL です。出力は「どの finding をどの関数で直すか」と「再検証コマンド」です。

Codex 側では、公式の設定リファレンスに skills.config があり、pathSKILL.md を含む skill フォルダを指定できます。

developers.openai.com

Codex で使うなら、たとえば ~/.codex/config.toml か project local の .codex/config.toml にこう置けます。

[[skills.config]]
path = "/path/to/zap-triage"
enabled = true

Claude Code 側も、SKILL.md を持つ skill を /skill-name で呼び出せます。

code.claude.com

Claude Code で使うなら、project ごとの .claude/skills/zap-triage/SKILL.md に置きます。個人用なら ~/.claude/skills/zap-triage/SKILL.md です。どちらでも、skill の中身は同じでよいです。

今回のような作業なら、skill の指示は抽象的で十分です。tools/owasp-zap/skills/zap-triage/SKILL.md に置いた skill も、この形に寄せています。

# ZAP triage

1. ZAP report、application root、target URL、許可範囲を確認する
2. alert、risk、route、parameter、evidence を要約する
3. High / Medium finding を handler や controller に対応づける
4. 脆弱な実装パターンを特定する
5. 最小の修正パターンを提案する
6. 再検証コマンドを付ける
7. 許可されていない target へ active scan を実行しない

同じ LLM でも、渡す入力で役割が変わります。active scan を握らせれば「攻撃する係」、finding を handler に戻させれば「コードレビュー係」です。agent に権限を渡すとは、職種を渡すということです。 攻撃する係を雇った覚えがないのに、active scan の鍵を握らせていないか。脆弱性スキャンを任せる前に、自分が何係を雇っているかを決めておくほうが安全です。範囲を決めずに権限だけ渡すと、何が起きても「agent のせいではない」「自分のせいでもない」という真空地帯ができます。これは一番やってはいけないことです。

修正は finding ごとに小さく切ります。XSS の escape、SQL の placeholder、download の allow list、header の共通化を別々に確認します。

この skill は、次のように呼べば別プロジェクトでも使えます。

Use zap-triage.
Report: reports/zap-full.json
Application root: .
Target URL: https://staging.example.com
Task: map High and Medium findings to source code and propose minimal remediations.
Do not run active scans unless I explicitly ask.

この skill を他の現場で使う時に、固定しておくべき入力は 4 つです。

入力 意味
Report ZAP の JSON、Markdown、または要約
Application root ソースコードの root
Target URL 許可済みの scan 対象
Scope active scan をしてよい範囲

逆に、skill に持たせないものもあります。特定プロジェクトの port、Docker service 名、修正先ファイル名、ZAP の実行許可です。これらを skill の中に書き込むと、別プロジェクトで使えません。skill は「どこをスキャンするか」を決めるものではなく、「出た finding をどう読むか」を決めるものです。

SKILL.md の中身は、3 層に分けると再利用しやすくなります。

Safety Boundary -> Workflow -> Output Format

Safety Boundary には、許可されていない active scan をしないことを書きます。scope を広げないこと、巨大な HTML report を最初から全部読まないこともここです。Workflow には、summary 作成、route 分類、handler への対応づけ、脆弱な実装パターンの特定を書きます。Output Format には、confirmed、needs reproduction、false positive、scan setup issue の分類を書きます。

こうすると、skill は知識メモではなく作業手順になります。別の言語や framework でも、XSS は出力境界、SQL Injection は query 境界へ戻せます。Path Traversal は filesystem 境界、header は response 境界です。ここが、ZAP triage skill として一番持ち帰りやすい学びです。

再検証までを修正に含める

修正したら、同じ入口で再確認します。手元では次のような順番にします。 ここでも成果物の形式を分けます。 Rust のコマンドは code 側の品質確認です。 Playwright CLI はブラウザ表示を PNG として残す確認です。 ZAP は security finding の再確認です。

cd tools/owasp-zap/fixed-app
cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all

cd ..
docker compose up --build -d fixed-app
./scripts/wait-for-http.sh "http://127.0.0.1:18081/health"
npx --yes playwright screenshot --wait-for-timeout=1000 "http://127.0.0.1:18081/book?id=1%20OR%201=1" reports/playwright-fixed-sqli.png
ZAP_TARGET=http://fixed-app:5000 \
REPORT_PREFIX=zap-full-fixed \
ZAP_MAX_SCAN_MINUTES=3 \
ZAP_MAX_RULE_MINUTES=1 \
./scripts/run-zap.sh full
node scripts/summarize-zap-json.mjs reports/zap-full-fixed.json > reports/zap-findings-summary-fixed.md

reports/playwright-fixed-sqli.png は、SQL Injection payload を入れた URL の画面証跡です。 PNG は ZAP レポートではありません。 人間が「修正後にどう見えるか」を確認するために残します。

XSS なら reports/zap-findings-summary-fixed.md から Cross Site Scripting (Reflected) が消えたかを見ます。SQL Injection なら /book?id=1%20OR%201=1 が 400 になることを Playwright か curl で見ます。Path Traversal なら /download?file=../secret-config.txt が 404 になることを確認します。

実際の再実行では、修正前後でこう変わりました。

finding 修正前 vulnerable-app 修正後 fixed-app
Cross Site Scripting (Reflected) High 8 件 PASS
SQL Injection High 3 件 PASS
Path Traversal High 1 件 PASS
CSP header missing Medium 9 件 PASS
X-Content-Type-Options missing Medium 8 件 PASS
Anti-CSRF token absence Medium 6 件 PASS

修正後にも Anti-CSRF Tokens Check は残りました。これは、サンプルの token が実セッション管理まで持っていないためです。ZAP の High finding を閉じる目的では改善を確認できました。本番相当の CSRF 対策にするなら、server-side session と token store を実装する必要があります。

ZAP の finding は修正の入口です。出口は、コード上の root cause が消え、同じ再現手順で挙動が変わり、ZAP の summary から該当 finding が消えることです。

生成 AI 時代に ZAP を回す理由

生成 AI がコードを書く割合は増えています。agent が route、handler、SQL、HTML を量産します。これ自体は早くて便利です。ただし、agent のコードは静的解析を通っていても、出力境界の escape、SQL の placeholder、ファイル名の正規化で抜けがちです。全件を人間のレビューだけで拾いきるのは、難しくなっています。

このような世界観では、OWASP ZAP のような DAST の立ち位置が上がります。baseline scan を CI の軽い gate として置きます。full scan はローカルや壊してよい staging で回します。これで「最低限のセキュリティ」を自動で担保できます。走らせるコストは小さく、finding を放置するコストは大きくなります。

生成 AI 時代の DAST は、遅い人間レビューの代替ではありません。人間が見切れない量を、攻撃面のまま実行して確かめる道具です。

agent にコードを書かせる環境で、DAST を通さない理由はほとんどありません。無料、OSS、スクリプト化しやすく、レポートも機械処理しやすい。「最低限」を入れておくかどうかで、後の冷や汗の量は変わります。

手前味噌ですが、私の所属する組織には Securify という Web セキュリティのプラットフォームもあります。ASM、脆弱性診断、CSPM、SBOM、Web アプリ診断を 1 つにまとめた形で、ZAP のような OSS DAST とは、役割の重なる部分と違う部分があります。

Securify は複数のスキャナや診断から出てくる脆弱性や設定ミスを単一のダッシュボードに集めて一元管理します。資産の重要度や外部公開状況をもとに、AI が対応優先度を自動でスコアリングするので、「どの finding から直すか」をチームで決める手間が小さくなります。専門知識がなくても運用に乗せやすく、持続的にセキュリティを回し続けるための土台として作られています。

宣伝が続いて恐縮ですが、ZAP の triage skill は「1 つの scan を handler に戻す」作業を支えます。Securify のようなプラットフォームは「複数の scan と資産情報をまたいで、組織全体の優先度を決める」作業を支えます。同じセキュリティ運用でも、扱う粒度と時間軸が違います。宣伝までに置いておきます。

www.securify.jp

この記事が少しでも伸びたら、ZAP の様々な使い方(認証付きの context 設定、automation framework、CI への組み込みなど)の続編を書く予定です。

最初の想定から外れた話

正直に告白します。この記事は最初、非エンジニアのバイブコーディング向けに書き始めました。「AI に雑なコードを書かせても、ZAP を回せば最低限のチェックになりますよ」くらいの軽い紹介のつもりでした。ところが書いているうちに熱が入ります。Rust の handler、SQL の placeholder、HTML escape の文脈、skill の抽象設計まで踏み込みました。読み返すと、最初の想定読者には全く似つかわしくない記事になっています。

まあ、書き始めの狙いと書き上がりのトーンがずれるのは、いつものことです。そのまま出します。

まとめ

ZAP の記事で大事なのは、alert 名を並べることではありません。alert を route に戻し、handler に戻し、修正方針に戻すことです。

今回なら、reflected XSS は searchserver_error の HTML 出力境界に戻します。SQL Injection は booklogin の文字列連結 SQL に戻します。Path Traversal は downloadjoin に戻します。Medium finding は cookie と security header の共通処理に戻します。

この形にすると、ZAP と agent skill の連携も自然になります。ZAP は証拠を集める。agent は証拠をコードに対応づける。人間は修正範囲を選び、再検証で閉じる。この 3 つのうち 1 つでも欠けると、残るのは「ZAP を回した履歴」だけで、直したことにはなりません。

冒頭で、alert 名だけを見ても修正は始まらないと書きました。今はこう言えます。ZAP は finding を出す道具ではない。handler を書き直すための道具だ。 使い方は、alert 名を眺める作業ではなく、alert を関数名へ変換する作業です。

この変換がいつも綺麗に終わるかというと、そんなことはありません。次の finding が出た時、落ち着いて同じ手順を踏めるかは、その時になってみないと分かりません。それでも、alert 名を眺める時間が以前より短くなるなら、この手順は役に立っています。たぶん。