
OWASP ZAPとは、動いているWebアプリに実際のHTTPリクエストを送って外側から欠陥を探す、無償のオープンソース診断ツール(本稿はDocker版の2.17.0で検証)です。ソースコードやコンテナイメージを静的に照合するツールではなく、ブラウザと同じように画面を巡回してレスポンスを見るDAST(動的診断)に分類されます。ライセンスはApache 2.0です。プロジェクトはすでにOWASPの傘下を離れており、検証した2.17.0のレポートにも「ZAP by Checkmarx」と表示されますが(執筆時点)、検索では今も「OWASP ZAP」の名前が使われ、コマンドや使い方も従来のままです。
この記事は、自社が運用するWebアプリを自分の手元で診断したいインフラ担当・情シス向けです。読み終えると、公式Dockerイメージでzap-baseline.pyとzap-full-scan.pyを回し、生成されたレポートからアラートを risk とプラグインIDで読み分け、CSPやSQLインジェクションといった実際の指摘をnginxやアプリ側で直し、終了コードでCIの合否を決めるところまで到達できます。診断対象は、自分で起動した使い捨てコンテナ(OWASP Juice Shopと自作の脆弱なFlaskアプリ)だけに限定します。
この記事でわかること
- 公式イメージ
zaproxy/zap-stableでベースラインスキャンを回す最小コマンドと、レポート3種(HTML・JSON・Markdown)の役割 - 実際に出たアラート(CSP未設定・CORS設定ミス・反射型XSS・SQLインジェクション)の意味と、nginxやアプリ側での直し方
- 終了コード(0/1/2/3)とルールファイル
-c・-I・-jの使い分けで、CIの合否判定を作る方法 - 権限エラー・接続拒否・ルールファイルの書式・JVMメモリなど、実行時に踏んだ4つのつまずきと回避策
- 自分の管理下のシステムだけに診断を限定する、スコープの決め方
ZAPは何をするツールか:DASTという立ち位置
ZAPはHTTPプロキシとして動き、対象URLをクローラーで巡回して集めたリクエストに、正常系の観測(パッシブスキャン)と攻撃文字列の注入(アクティブスキャン)を行います。前者はレスポンスヘッダーやCookie属性を見るだけで対象に負荷をかけず、後者は'や<script>のような値を実際に送り込んで挙動の変化を観測します。同じ「脆弱性」という言葉でも、コンテナイメージのCVEを照合するTrivyや、開いているポートを調べるnmapとは対象がまったく違います。ZAPが見るのは、アプリが返すレスポンスに現れる作り込みの欠陥です。
手元で回す方法は主に3つあります。デスクトップGUI、Dockerイメージ、そしてGUIを埋め込んだ週次スキャン用のパッケージスキャンです。サーバー上での定期実行やCI連携を考えるなら、GUIを持たないDockerのスキャンスクリプトが扱いやすく、この記事もそれで統一します。イメージは1.2GB(圧縮時)ほどあり、内部にはOpenJDK 17・Python 3・Firefox ESR・jqまで同梱されているので、ホスト側にJavaを入れる必要はありません。
診断してよいのは自分の管理下のシステムだけ
アクティブスキャンは攻撃と同じリクエストを送ります。日本の法律に「脆弱性診断の禁止」を定めた条文はありませんが、他人が運用するサイトへ許可なくこれを行うと、不正アクセス禁止法や業務妨害の問題になり得ます。判断は単純で、管理権限を持つ資産と、書面で許可を得た範囲以外にはZAPを向けません。SaaSやレンタルサーバー上のアプリは、IPやドメインが自社名義でも設備は事業者のものなので、事前に規約と申請手順を確認します。スコープの決め方はnmapの記事と同じ考え方で、詳細はそちらに譲ります。
この記事の検証は、外部に一切公開しない構成で行いました。診断対象は自分で起動したDockerコンテナだけを専用ネットワークに置き、ホストのポートには何も公開していません。ZAPコンテナも同じネットワークに参加させ、コンテナ名(juice)で名前解決して到達します。この形なら、スキャンパケットがネットワーク外へ出ることはありません。
本番のWebサーバーへいきなりアクティブスキャンを当てないでください。攻撃文字列がフォーム送信やDB書き込みを誘発し、ダミー注文やスパムメール送信を大量に発生させることがあります。まずはステージング環境か、本番のコピーを起動した使い捨てコンテナで試し、影響範囲を見てから本番のパッシブスキャンへ進みます。
診断用の的とネットワークを用意する
練習台には、意図的に脆弱性を残したWebアプリOWASP Juice Shop(bkimminich/juice-shop、検証時はv20.2.0)を使います。まず専用ネットワークを作り、そこに的だけを起動します。ホストのポートは公開しません(-pを付けない)。
# 専用ネットワークを作る(既にあれば無視される)
docker network create zap-net
# 的(Juice Shop)を起動。--network-alias で "juice" という名前で引ける
docker run -d --name zap-juice --network zap-net --network-alias juice \
--memory 1200m bkimminich/juice-shop
# 起動を待つ("Server listening on port 3000" が出れば準備完了)
docker logs zap-juice 2>&1 | tail -3ここで的のポート3000はネットワークzap-netの中だけに存在し、ホストや外部からは見えません。同じネットワークに後からZAPコンテナを参加させると、http://juice:3000で到達できます。的をlocalhostで参照しないのが要点で、ZAPコンテナにとってのlocalhostはZAP自身を指すため、的には届きません(つまずきポイントで実際のエラーを示します)。
zap-baseline.pyでベースラインスキャンを回す
ベースラインスキャンは、対象を1分間クロールしてパッシブスキャンだけを実行します。攻撃文字列は送らないので、比較的安全に全体像をつかめます。レポートを取り出すため、ホストの作業ディレクトリをコンテナの/zap/wrkにマウントします。
mkdir -p ~/zap-wrk && cd ~/zap-wrk
docker run --rm --network zap-net -u root \
-v "$PWD:/zap/wrk:rw" \
zaproxy/zap-stable \
zap-baseline.py -t http://juice:3000 \
-r report.html -J report.json -w report.md -I -s各オプションの意味は次のとおりです。-tが対象URL、-rがHTMLレポート、-JがJSON、-wがMarkdown、-Iは警告(WARN)が出ても終了コードを失敗にしない指定、-sは問題なしのルール(PASS)の行と指摘ごとの例示URLを省いて出力を短くする指定です。-u rootを付けているのは、マウントした作業ディレクトリの所有者がホスト側ユーザーで、イメージ内の既定ユーザー(uid 1000のzap)では書き込めずレポート生成が失敗するためです。この検証環境では60〜70秒で完了し、Juice Shopに対して88件のURLを巡回しました(-sを外すと「Total of 88 URLs」の行と、PASSの59行も表示されます)。-s付きの標準出力は次のとおりです。
Using the Automation Framework
WARN-NEW: Content Security Policy (CSP) Header Not Set [10038] x 5
WARN-NEW: Non-Storable Content [10049] x 8
WARN-NEW: Deprecated Feature Policy Header Set [10063] x 5
WARN-NEW: Timestamp Disclosure - Unix [10096] x 5
WARN-NEW: Cross-Domain Misconfiguration [10098] x 5
WARN-NEW: Modern Web Application [10109] x 5
WARN-NEW: Dangerous JS Functions [10110] x 1
WARN-NEW: Cross-Origin-Embedder-Policy Header Missing or Invalid [90004] x 10
FAIL-NEW: 0 FAIL-INPROG: 0 WARN-NEW: 8 WARN-INPROG: 0 INFO: 0 IGNORE: 0 PASS: 59末尾の集計行が、CIで使う判定材料です。PASSはルールが問題を見つけなかった件数、WARNは指摘、FAILは「必ず失敗にする」と設定したルールの一致数を表します。x 5などの数字は検出箇所(URL×パラメータ)の件数で、クロールが乱数を含むため実行のたびに多少ぶれます。件数の絶対値より、どのルールが出ているかを見ます。
レポートの読み方:risk・count・プラグインIDの3点で見る
HTMLレポートは人が読む用、Markdownはチケットやレビューへの貼り付け用、JSONは機械処理用です。CIやスクリプトからはJSONをjqで抜き出すのが速く、risk(危険度)と名前の一覧はこの1行で出せます。
jq -r '.site[0].alerts[] | "\(.riskdesc): \(.name)"' report.jsonMedium (High): Content Security Policy (CSP) Header Not Set
Medium (Medium): Cross-Domain Misconfiguration
Low (Medium): Cross-Origin-Embedder-Policy Header Missing or Invalid
Low (Medium): Cross-Origin-Opener-Policy Header Missing or Invalid
Low (Low): Dangerous JS Functions
Low (Medium): Deprecated Feature Policy Header Set
Low (Low): Timestamp Disclosure - Unix
Informational (Medium): Modern Web Application
Informational (Medium): Non-Storable Content
Informational (Medium): Storable and Cacheable Content
Informational (Medium): Storable but Non-Cacheable Contentriskdescは「危険度(確信度)」の形です。Medium (High)なら影響は中程度・検出の確からしさは高い、という読み方をします。確信度がLowのものは誤検知の可能性を含むので、まずMedium (High)やHigh (High)から潰し、確信度の低いものは手で裏取りします。角括弧の数字(10038など)はプラグインIDで、ルールファイルでの制御や公式のルール解説を引く鍵になります。このベースラインの集計は、Medium 2件・Low 5件・Informational 4件、Highは0件でした。パッシブスキャンだけではXSSやSQLインジェクションのような入力起因の欠陥は出ず、多くがヘッダーの不足であることが読み取れます。
主要なアラートの意味と、直す場所
上位に出た指摘は、多くがレスポンスヘッダーで解決できます。プロキシ(nginx)を前段に置いているなら、アプリを触らずにヘッダーを付与・除去して対処するのが手早い方法です。
| ID | アラート | 意味 | 直す場所 |
|---|---|---|---|
| 10038 | CSP Header Not Set | Content-Security-Policyが無く、XSS時の被害を抑える最後の砦が効かない | nginxでadd_header Content-Security-Policy |
| 10098 | Cross-Domain Misconfiguration | Access-Control-Allow-Origin: *で全オリジンにCORSを許可している | アプリのCORS設定を絞る/不要なら削除 |
| 90004 | COEP / COOP Missing | クロスオリジン分離のヘッダーが無い | nginxでCross-Origin-*-Policyを付与 |
| 10063 | Deprecated Feature Policy | 廃止されたFeature-Policyを送っている | Permissions-Policyへ置き換え |
| 10096 | Timestamp Disclosure | レスポンス中のUnix時刻が時刻情報の漏えいと判定された | 多くは誤検知。中身を確認しルールで抑制 |
10098の証跡(evidence)には実際にAccess-Control-Allow-Origin: *が記録されていました。これは「どのサイトからでもこのAPIを読める」設定で、認証付きのデータを扱うなら明確な問題です。一方10096のTimestamp Disclosureは、CSSに埋め込まれた数値(例:1666666667)を時刻とみなした誤検知でした。ZAPの指摘を機械的に全部直そうとすると、こうした無害な項目に工数を奪われます。危険度と確信度、そして証跡の中身を突き合わせて、対応するものを選びます。
nginxを前段に置いてヘッダーを付与すると、ベースラインの結果は目に見えて変わります。同じJuice Shopを、CSPと各種セキュリティヘッダーを足したnginx経由でスキャンし直したところ、10038・10063・10098・90004の指摘が消え、代わりにCSP: script-src unsafe-inline [10055]という、より踏み込んだCSPの弱点だけが残りました。ヘッダーを付けるだけでなく、unsafe-inlineを外す方向に締めていく次の一手が、この指摘から見えます。ヘッダーの書き方はリバースプロキシとWordPressハードニングの記事で扱っています。
nginxではadd_headerをlocationブロック内に書くと、その階層のserverで定義したadd_headerがすべて無効になります(継承ではなく上書き)。ヘッダーが一部しか付かないときは、まずこの入れ子を疑います。また4xx・5xx応答にもヘッダーを付けるには、各add_headerにalwaysを付けます。
能動スキャンでXSSとSQLインジェクションを見つける
入力起因の欠陥を出すには、攻撃文字列を送るアクティブスキャン(zap-full-scan.py)が要ります。挙動を確かめるため、わざと穴を残した最小のFlaskアプリを的にしました。文字列連結でSQLを組み立て、入力をエスケープせずHTMLへ埋め込んでいます。
@app.get("/search")
def search():
q = request.args.get("q", "")
# 穴1: 文字列連結でSQLを組み立てている(SQLインジェクション)
sql = f"SELECT name, price FROM products WHERE name LIKE '%{q}%'"
rows = conn.execute(sql).fetchall()
items = "".join(f"<li>{n}: {p} yen</li>" for n, p in rows)
# 穴2: 入力をエスケープせずにHTMLへ埋め込んでいる(反射型XSS)
return f"<h1>results for {q}</h1><ul>{items}</ul>"docker run --rm --network zap-net -u root -v "$PWD:/zap/wrk:rw" \
zaproxy/zap-stable \
zap-full-scan.py -t http://vuln:5000 -J full.json -IURL数がわずか6件のこのアプリに対して、フルスキャンは約37秒で終わり、次のHigh指摘を検出しました。
WARN-NEW: Cross Site Scripting (Reflected) [40012] x 2
WARN-NEW: SQL Injection [40018] x 1
WARN-NEW: Source Code Disclosure - File Inclusion [43] x 2
# 40012 の実際の攻撃値(JSONレポートより)
GET /hello?name=</p><scrIpt>alert(1);</scRipt><p>
GET /search?q=</h1><scrIpt>alert(1);</scRipt><h1>
# 40018 は q=' を送り込み、500応答が返ることで検出
GET /search?q=%27 -> HTTP/1.1 500 INTERNAL SERVER ERRORXSSは、送り込んだ<script>がそのままレスポンスに反射していることをZAPが確認して検出します。SQLインジェクションは、シングルクォート1文字で500エラーになる(=SQL文が壊れた)挙動から推定します。ここで[43] Source Code DisclosureもHighで出ましたが、これは正常なパラメータ名(search)とランダム名でレスポンスの差が大きいことを根拠にした誤検知でした。Highでも中身を見て切り分けます。
次に、SQLをプレースホルダに、出力をmarkupsafe.escapeに直した版へ差し替えて再スキャンすると、40012(XSS)と40018(SQLi)の両方が消えました。修正が効いたことをスキャンで裏取りできるのが、DASTを回す実利です。
from markupsafe import escape
# 修正1: プレースホルダで値を渡す(文字列連結をやめる)
sql = "SELECT name, price FROM products WHERE name LIKE ?"
rows = conn.execute(sql, (f"%{q}%",)).fetchall()
# 修正2: 出力をエスケープしてからHTMLへ埋め込む
return f"<h1>results for {escape(q)}</h1><ul>{items}</ul>"CIに載せる:終了コードとルールファイル
ZAPのスキャンスクリプトは、結果を終了コードで返します。パイプラインの合否判定は、この値を読むだけで作れます。
| 終了コード | 意味 | いつ返るか |
|---|---|---|
| 0 | 成功 | FAILもWARNも無い。または-IでWARNを失敗扱いにしない指定のとき |
| 1 | FAIL | ルールファイルでFAILに指定した項目が1件以上一致 |
| 2 | WARN | -IなしでWARNが1件以上(既定の失敗) |
| 3 | 実行エラー | 対象へ到達できない、設定ファイルの読み込み失敗など |
既定では、WARNが1件でもあれば終了コード2で失敗します。新規のヘッダー指摘でCIを毎回赤くしたくない段階では-Iを付けてWARNを許容し、「これだけは絶対に通さない」項目をルールファイルでFAILに格上げします。ルールファイルの雛形は-gで生成できます。
# 全ルールを WARN にした雛形を作る
docker run --rm --network zap-net -u root -v "$PWD:/zap/wrk:rw" \
zaproxy/zap-stable zap-baseline.py -t http://juice:3000 -g gen.conf -s生成されたgen.confを編集し、必要なものだけFAIL、誤検知の多いものをIGNOREにします。書式は「プラグインID・タブ・レベル・タブ・(任意のメモ)」で、区切りは必ずタブ、レベルは大文字です。
10038 FAIL (CSP未設定は必ず直す)
10096 IGNORE (Timestamp Disclosure は誤検知が多い)
10049 IGNORE (キャッシュ可否の情報提供)
10109 INFO (Modern Web Application)# ルールファイルを渡す(-I は付けない=FAIL/WARN を終了コードに反映)
docker run --rm --network zap-net -u root -v "$PWD:/zap/wrk:rw" \
zaproxy/zap-stable \
zap-baseline.py -t http://juice:3000 -c rules.conf
echo "exit=$?"この構成で回すと、10038がFAIL-NEW: 1として集計され、終了コードは1になりました。IGNOREにした10049と10096は集計から外れ、パイプラインを止める理由から除外されます。より多くの画面を巡回させたいときは、JavaScriptを実行する近代的なスパイダーを追加する-jを付けます。既定ではクライアントスパイダーが動き、シングルページアプリの内部リンクも辿ります。--ajax-spiderを足すとCrawljaxベースのAJAXスパイダーに切り替わりますが、認証が要る画面では検出URLが増えないこともあります(この検証ではAJAXスパイダーの追加取得は0件でした)。
動作確認
スキャンが正しく回ったかは、生成物と終了コードで確かめます。レポート3ファイルができ、JSONからバージョンとアラート数が読めれば成功です。
ls -1 report.*
# report.html report.json report.md
jq -r '.["@version"], (.site[0].alerts | length)' report.json
# 2.17.0
# 11危険度別の件数を数えたいときは、riskcodeで集計します(3=High, 2=Medium, 1=Low, 0=Informational)。
jq -r '.site[0].alerts | group_by(.riskcode)[]
| "\(.[0].riskdesc | split(" ")[0]): \(length)"' report.json
# Informational: 4
# Low: 5
# Medium: 2つまずきポイント
「Permission denied」でレポートが生成されない
-u rootを付けずにマウント付きで実行すると、次のように出てレポートだけ落ちます。
Unable to copy yaml file to /zap/wrk/zap.yaml [Errno 13] Permission denied: '/zap/wrk/zap.yaml'
...
Automation plan failures:
Job report failed to generate report: AccessDeniedException /zap/wrk/report.htmlイメージ内の既定ユーザーはuid 1000のzapで、ホスト所有の作業ディレクトリに書き込めないのが原因です。手早いのは-u rootを付けることです。rootで動かしたくない場合は、作業ディレクトリをuid 1000に渡し(chown 1000:1000 ~/zap-wrk)、-uを付けずに実行します。後者でもレポートは正しく生成され、終了コードも同じでした。
「Connection refused」で対象に到達できない
対象をhttp://localhost:3000と書くと、次で止まり終了コードは3になります。
Job spider failed to access URL http://localhost:3000 :
Connect to http://localhost:3000 [localhost/127.0.0.1, localhost/0:0:0:0:0:0:0:1] failed: Connection refusedZAPコンテナから見たlocalhostはZAP自身です。的は別コンテナなので、同じネットワークに参加させたうえでコンテナ名(http://juice:3000)で参照します。的をホストの127.0.0.1:3000に公開している場合は、ZAP側に--network hostを使うか、的のIPを直接指定します。
ルールファイルが読み込めない
ルールファイルの区切りにスペースを使うと、次のエラーで終了コード3になります。
Unexpected number of tokens on line - there should be at least 3, tab separated: 10038 FAIL (CSP)区切りはタブでなければなりません。またレベルを小文字で書くとLevel fail is not a supported level: ['PASS', 'IGNORE', 'INFO', 'WARN', 'FAIL']で弾かれます。-gで生成した雛形を編集する運用にすれば、この2つは避けられます。
コンテナのメモリ制限を超えてJVMが確保しようとする
ZAPの起動スクリプトは、JVMの最大ヒープ(-Xmx)を利用可能メモリの1/4に自動設定します。ところがcgroup v2のホストでは、旧来のcgroup v1のパスを見に行って失敗し、コンテナの--memory制限ではなくホストの物理メモリからヒープ量を決めてしまいます。8GBのホストで--memory 1500mを付けても、起動ログはAvailable memory: 7936 MB / Using JVM args: -Xmx1984mとなり、制限を超える1984MBを最大ヒープに設定していました。的が大きいとOOMで殺されかねません。ヒープは明示的に固定します。
# -z でZAP本体へJVM引数を渡し、ヒープをコンテナ制限内に収める
docker run --rm --network zap-net -u root -v "$PWD:/zap/wrk:rw" \
--memory 1500m zaproxy/zap-stable \
zap-baseline.py -t http://juice:3000 -I -z "-Xmx768m"この指定を入れると起動ログがUsing JVM args: -Xmx768mに変わり、実プロセスもjava -Xmx768mで動くことを確認しました。共有サーバーでZAPを回すなら、この上限固定は入れておく価値があります。
よくある質問
OWASP ZAPは何ができますか?
動いているWebアプリに対して、レスポンスの観測(パッシブ)と攻撃文字列の注入(アクティブ)を自動で行い、XSSやSQLインジェクション、ヘッダー不足、CORS設定ミスなどを検出します。手動テスト用のプロキシとしても使え、Dockerのスクリプトを使えばCIに組み込んで定期実行できます。
OWASP ZAPの料金はいくらですか?
本体は無償のオープンソース(Apache License 2.0)で、機能制限のある無料版という位置づけではありません。デスクトップ版もDockerイメージも費用はかかりません。商用のクラウド診断サービスとは別物で、実行環境と運用は自分で用意します。
OWASP ZAPの読み方は?
ZAPは「ザップ」と読みます。正式名称はZed Attack Proxyで、頭文字を取った略称です。製品名の表記はZAPですが、この記事で使うDockerイメージ名 zaproxy/zap-stable のように、配布物の名前には今も「zaproxy」が残っています。
OWASP ZAPで診断できる項目は?
反射型・格納型のクロスサイトスクリプティング、SQLインジェクション、パストラバーサル、CSPやセキュリティヘッダーの不足、CORSの設定ミス、Cookie属性の不備などが代表例です。個々の検査はプラグインID付きのルールとして提供され、-cのルールファイルで有効・無効や失敗判定を制御できます。
まとめ
ZAPは、動いているWebアプリを外から叩いて欠陥を探すDASTツールです。まずzap-baseline.pyでパッシブスキャンを回し、JSONレポートをjqで読んで危険度と確信度、証跡の中身で対応する指摘を選びます。ヘッダー不足の多くはnginxで直り、XSSやSQLインジェクションはアクティブスキャンで検出して修正後に再スキャンで裏取りします。CIには終了コードとルールファイルで合否を組み込み、共有サーバーではヒープ量を-z "-Xmx768m"で固定します。診断を向けてよいのは、自分の管理下の資産と許可を得た範囲だけです。
次の一手として、この記事のコマンドを自分のステージング環境のコピーに対して1回流し、出たアラートをプラグインIDごとに「直す・抑制する・裏取りする」に仕分けたルールファイルを1本作ってください。それがCIに載せる最初の基準線になります。