From 7427eb14f5c61ba18969439bfd8a051062871481 Mon Sep 17 00:00:00 2001 From: hj Date: Tue, 27 Oct 2026 12:22:10 +0800 Subject: [PATCH] feat(zap): harden ZAP spider/passive/active scan reliability and readiness checks - Add ZAP active scan policy profiles (quick/standard/deep) and graceful stop-on-timeout hardening for spider, AJAX spider, and active scan in app/services/zap_client.py - Optimize passive scan wait with percentage progress callbacks and adaptive polling - Extend /ready in app/main.py to check ZAP connectivity when SCANNER_MODE=zap and return 503 DEPENDENCY_UNAVAILABLE when unreachable - Log explicit fallback to builtin scanner in ScanOrchestrator when ZAP is unreachable or interrupted - Add mocked ZAP API integration tests and DEPENDENCY_UNAVAILABLE contract test in tests/test_zap_phase3.py Closes #2 --- README.md | 5 +- app/config.py | 3 + app/errors.py | 19 +++ app/main.py | 18 ++- app/services/scan_orchestrator.py | 29 +++- app/services/zap_client.py | 223 ++++++++++++++++++++++-------- docs/architecture.md | 10 +- tests/test_zap_phase3.py | 192 +++++++++++++++++++++++++ 8 files changed, 436 insertions(+), 63 deletions(-) create mode 100644 tests/test_zap_phase3.py diff --git a/README.md b/README.md index 20c9ff5..feb88e6 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,12 @@ sudo usermod -aG docker $USER ### 2. Start lab targets and ZAP ```bash -docker compose up -d +./start-lab.sh full +docker compose ps ``` +Verify all three containers (`shieldscan-zap`, `shieldscan-dvwa`, `shieldscan-juiceshop`) show `Up` in `docker compose ps` before running a full ZAP DAST scan. + | Service | URL | |---------|-----| | ShieldScan | http://127.0.0.1:8000 | diff --git a/app/config.py b/app/config.py index 1b6514e..d3d7ee5 100644 --- a/app/config.py +++ b/app/config.py @@ -18,8 +18,11 @@ class Settings(BaseSettings): ai_model: str = "claude-sonnet-4-20250514" scan_spider_timeout: int = 300 + scan_ajax_spider_timeout: int = 120 + scan_passive_timeout: int = 90 scan_active_timeout: int = 600 max_scan_duration: int = 900 + zap_scan_policy: str = "standard" # quick | standard | deep crawl_max_pages: int = 80 crawl_max_depth: int = 4 fuzz_max_params: int = 45 diff --git a/app/errors.py b/app/errors.py index 66e85dd..9db69b6 100644 --- a/app/errors.py +++ b/app/errors.py @@ -14,10 +14,29 @@ 404: "NOT_FOUND", 422: "VALIDATION_ERROR", 500: "INTERNAL_ERROR", + 502: "DEPENDENCY_UNAVAILABLE", 503: "SERVICE_UNAVAILABLE", } +def dependency_unavailable_error( + dependency: str, + message: str, + details: dict[str, Any] | None = None, +) -> JSONResponse: + """Return a structured 503 DEPENDENCY_UNAVAILABLE response envelope.""" + merged_details = {"dependency": dependency, **(details or {})} + return JSONResponse( + status_code=503, + content={ + "status": "not_ready", + "database": "ok", + dependency: "unavailable", + **error_body("DEPENDENCY_UNAVAILABLE", message, details=merged_details), + }, + ) + + def error_body(code: str, message: str, details: Any = None) -> dict: body: dict[str, Any] = {"error": {"code": code, "message": message}} if details is not None: diff --git a/app/main.py b/app/main.py index 91c0ee2..d60520c 100644 --- a/app/main.py +++ b/app/main.py @@ -11,12 +11,14 @@ from app.config import get_settings from app.database import SessionLocal, init_db from app.errors import ( + dependency_unavailable_error, http_exception_handler, unhandled_exception_handler, validation_exception_handler, ) from app.routers import scans from app.services.secrets import validate_settings +from app.services.zap_client import ZAPClient logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") @@ -74,11 +76,10 @@ def health(): @app.get("/ready") -def ready(): +async def ready(): try: with SessionLocal() as db: db.execute(text("SELECT 1")) - return {"status": "ready", "database": "ok"} except Exception as exc: return JSONResponse( status_code=503, @@ -88,3 +89,16 @@ def ready(): "error": {"code": "SERVICE_UNAVAILABLE", "message": str(exc)}, }, ) + + settings = get_settings() + if settings.scanner_mode.strip().lower() == "zap": + zap = ZAPClient(settings) + if not await zap.is_available(): + return dependency_unavailable_error( + dependency="zap", + message=f"OWASP ZAP is unreachable at {settings.zap_api_url} while SCANNER_MODE=zap", + details={"zap_api_url": settings.zap_api_url, "scanner_mode": "zap"}, + ) + return {"status": "ready", "database": "ok", "scanner_mode": "zap", "zap": "ok"} + + return {"status": "ready", "database": "ok", "scanner_mode": settings.scanner_mode, "zap": "not_required"} diff --git a/app/services/scan_orchestrator.py b/app/services/scan_orchestrator.py index 1f26163..9e66526 100644 --- a/app/services/scan_orchestrator.py +++ b/app/services/scan_orchestrator.py @@ -48,7 +48,15 @@ async def run(self, scan_id: int) -> None: try: self._update(scan, ScanStatus.RUNNING, "Initialising scan") zap = ZAPClient(self.settings) - use_zap = self.settings.scanner_mode == "zap" and await zap.is_available() + zap_configured = self.settings.scanner_mode.strip().lower() == "zap" + use_zap = zap_configured and await zap.is_available() + + if zap_configured and not use_zap: + logger.warning( + "Fallback to builtin scanner for scan %s: OWASP ZAP unreachable at %s (SCANNER_MODE=zap)", + scan_id, + self.settings.zap_api_url, + ) if use_zap: scan.scanner_used = "zap+builtin" @@ -62,9 +70,22 @@ def zap_progress(msg: str) -> None: else: self._update(scan, ScanStatus.PASSIVE_SCAN, msg) - alerts = await zap.run_full_scan(target, on_progress=zap_progress) - findings = [zap_alert_to_finding(a, i) for i, a in enumerate(alerts)] - + try: + alerts = await zap.run_full_scan( + target, + on_progress=zap_progress, + policy_profile=getattr(self.settings, "zap_scan_policy", "standard"), + ) + findings = [zap_alert_to_finding(a, i) for i, a in enumerate(alerts)] + except Exception as zap_exc: + logger.warning( + "ZAP scan interrupted for scan %s (%s); falling back to builtin scanner", + scan_id, + zap_exc, + ) + use_zap = False + scan.scanner_used = "builtin" + self.db.commit() else: scan.scanner_used = "builtin" self.db.commit() diff --git a/app/services/zap_client.py b/app/services/zap_client.py index bae6958..c99cf12 100644 --- a/app/services/zap_client.py +++ b/app/services/zap_client.py @@ -1,3 +1,4 @@ +import asyncio import logging from typing import Any, Callable @@ -7,15 +8,70 @@ logger = logging.getLogger(__name__) +ZAP_SCAN_POLICY_PROFILES: dict[str, dict[str, Any]] = { + "quick": { + "scan_policy_name": "", + "spider_max_children": 30, + "ajax_spider_enabled": False, + "ajax_max_duration_mins": 2, + "spider_timeout": 90, + "active_timeout": 180, + "passive_timeout": 45, + "attack_strength": "LOW", + "alert_threshold": "MEDIUM", + }, + "standard": { + "scan_policy_name": "", + "spider_max_children": 100, + "ajax_spider_enabled": True, + "ajax_max_duration_mins": 5, + "spider_timeout": 300, + "active_timeout": 600, + "passive_timeout": 90, + "attack_strength": "MEDIUM", + "alert_threshold": "MEDIUM", + }, + "deep": { + "scan_policy_name": "", + "spider_max_children": 250, + "ajax_spider_enabled": True, + "ajax_max_duration_mins": 10, + "spider_timeout": 600, + "active_timeout": 1200, + "passive_timeout": 180, + "attack_strength": "HIGH", + "alert_threshold": "LOW", + }, +} + class ZAPClient: - """Client for OWASP ZAP REST API.""" + """Client for OWASP ZAP REST API with timeout hardening and policy profiles.""" - def __init__(self, settings: Settings): + def __init__( + self, + settings: Settings, + transport: httpx.AsyncBaseTransport | None = None, + poll_interval: float = 2.0, + ): self.base_url = settings.zap_api_url.rstrip("/") self.api_key = settings.zap_api_key - self.spider_timeout = settings.scan_spider_timeout - self.active_timeout = settings.scan_active_timeout + self.policy_name = getattr(settings, "zap_scan_policy", "standard").strip().lower() + self.policy = self.get_policy_config(self.policy_name) + self.spider_timeout = getattr(settings, "scan_spider_timeout", self.policy["spider_timeout"]) + self.ajax_spider_timeout = getattr(settings, "scan_ajax_spider_timeout", 120) + self.passive_timeout = getattr(settings, "scan_passive_timeout", self.policy["passive_timeout"]) + self.active_timeout = getattr(settings, "scan_active_timeout", self.policy["active_timeout"]) + self.transport = transport + self.poll_interval = poll_interval + + @staticmethod + def get_policy_config(profile: str = "standard") -> dict[str, Any]: + key = (profile or "standard").strip().lower() + return dict(ZAP_SCAN_POLICY_PROFILES.get(key, ZAP_SCAN_POLICY_PROFILES["standard"])) + + def _client(self, timeout: float = 30.0) -> httpx.AsyncClient: + return httpx.AsyncClient(timeout=timeout, transport=self.transport) def _params(self, extra: dict[str, Any] | None = None) -> dict[str, Any]: params = {"apikey": self.api_key} @@ -25,15 +81,15 @@ def _params(self, extra: dict[str, Any] | None = None) -> dict[str, Any]: async def is_available(self) -> bool: try: - async with httpx.AsyncClient(timeout=5.0) as client: + async with self._client(timeout=5.0) as client: resp = await client.get(f"{self.base_url}/JSON/core/view/version/", params=self._params()) return resp.status_code == 200 and "version" in resp.json() except Exception as exc: - logger.warning("ZAP unavailable: %s", exc) + logger.warning("ZAP unavailable at %s: %s", self.base_url, exc) return False - async def _get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]: - async with httpx.AsyncClient(timeout=120.0) as client: + async def _get(self, path: str, params: dict[str, Any] | None = None, timeout: float = 60.0) -> dict[str, Any]: + async with self._client(timeout=timeout) as client: resp = await client.get(f"{self.base_url}{path}", params=self._params(params)) resp.raise_for_status() return resp.json() @@ -45,104 +101,156 @@ async def access_url(self, url: str) -> None: await self._get("/JSON/core/action/accessUrl/", {"url": url, "followRedirects": "true"}) async def spider(self, url: str, on_progress: Callable[[str], None] | None = None) -> str: + max_children = str(self.policy["spider_max_children"]) data = await self._get( "/JSON/spider/action/scan/", - {"url": url, "maxChildren": "100", "recurse": "true", "subtreeOnly": "false"}, + {"url": url, "maxChildren": max_children, "recurse": "true", "subtreeOnly": "false"}, ) scan_id = str(data["scan"]) await self._wait_scan( - "/JSON/spider/view/status/", - scan_id, - self.spider_timeout, - on_progress, - "Spidering", + status_path="/JSON/spider/view/status/", + stop_path="/JSON/spider/action/stop/", + scan_id=scan_id, + timeout=self.spider_timeout, + on_progress=on_progress, + label="Spidering", ) return scan_id async def ajax_spider(self, url: str, on_progress: Callable[[str], None] | None = None) -> None: - """Optional AJAX spider for JavaScript-heavy apps (Juice Shop, SPAs).""" + """Optional AJAX spider for JavaScript-heavy apps (Juice Shop, SPAs) with timeout hardening.""" + if not self.policy.get("ajax_spider_enabled", True): + if on_progress: + on_progress("AJAX spider skipped for quick policy profile") + return + try: - await self._get("/JSON/ajaxSpider/action/setOptionMaxDuration/", {"Integer": "10"}) + max_mins = str(self.policy.get("ajax_max_duration_mins", 5)) + await self._get("/JSON/ajaxSpider/action/setOptionMaxDuration/", {"Integer": max_mins}) await self._get("/JSON/ajaxSpider/action/scan/", {"url": url, "inScope": "true"}) if on_progress: on_progress("AJAX spider started for JavaScript pages") - import asyncio - elapsed = 0 - while elapsed < 120: - status = await self._get("/JSON/ajaxSpider/view/status/") - if status.get("status") == "stopped": + elapsed = 0.0 + step = min(3.0, self.poll_interval) + while elapsed < self.ajax_spider_timeout: + status = await self._get("/JSON/ajaxSpider/view/status/", timeout=15.0) + state = str(status.get("status", "")).lower() + if state == "stopped": + if on_progress: + on_progress("AJAX spider complete") return - if on_progress and elapsed % 10 == 0: - on_progress("AJAX spider running...") - await asyncio.sleep(3) - elapsed += 3 + if on_progress: + on_progress(f"AJAX spider running ({int(elapsed)}s/{self.ajax_spider_timeout}s)...") + await asyncio.sleep(step) + elapsed += step + + logger.warning("AJAX spider timed out after %ss; stopping gracefully", self.ajax_spider_timeout) + try: + await self._get("/JSON/ajaxSpider/action/stop/", timeout=10.0) + except Exception: + pass + if on_progress: + on_progress(f"AJAX spider timed out after {self.ajax_spider_timeout}s (stopped gracefully)") except Exception as exc: logger.debug("AJAX spider skipped: %s", exc) async def passive_scan_wait(self, on_progress: Callable[[str], None] | None = None) -> None: - """Wait for passive scan to finish processing spider traffic.""" - import asyncio - + """Wait for passive scan to finish processing spider traffic with adaptive progress callbacks.""" if on_progress: on_progress("Passive scan analysing discovered traffic") - elapsed = 0 - while elapsed < 90: + elapsed = 0.0 + initial_records: int | None = None + + while elapsed < self.passive_timeout: try: - data = await self._get("/JSON/pscan/view/recordsToScan/") + data = await self._get("/JSON/pscan/view/recordsToScan/", timeout=15.0) remaining = int(data.get("recordsToScan", 0)) - if on_progress and elapsed % 6 == 0: - on_progress(f"Passive scan: {remaining} records remaining") + if initial_records is None: + initial_records = max(remaining, 1) + if remaining <= 0: + if on_progress: + on_progress("Passive scan: 0 records remaining (100%)") return - except Exception: + + done = max(0, initial_records - remaining) + pct = min(99, int((done / initial_records) * 100)) + if on_progress: + on_progress(f"Passive scan: {remaining} records remaining ({pct}%)") + except Exception as exc: + logger.debug("Passive scan poll interrupted: %s", exc) return - await asyncio.sleep(3) - elapsed += 3 - async def active_scan(self, url: str, on_progress: Callable[[str], None] | None = None) -> str: + step = min(1.0 if remaining < 10 else 2.0, self.poll_interval) + await asyncio.sleep(step) + elapsed += step + + logger.warning("Passive scan wait timed out after %ss", self.passive_timeout) + if on_progress: + on_progress(f"Passive scan wait reached {self.passive_timeout}s budget; proceeding") + + async def active_scan( + self, + url: str, + on_progress: Callable[[str], None] | None = None, + policy_profile: str | None = None, + ) -> str: + policy = self.get_policy_config(policy_profile or self.policy_name) data = await self._get( "/JSON/ascan/action/scan/", { "url": url, "recurse": "true", "inScopeOnly": "false", - "scanPolicyName": "", + "scanPolicyName": policy["scan_policy_name"], "method": "", "postData": "", }, ) scan_id = str(data["scan"]) await self._wait_scan( - "/JSON/ascan/view/status/", - scan_id, - self.active_timeout, - on_progress, - "Active scanning", + status_path="/JSON/ascan/view/status/", + stop_path="/JSON/ascan/action/stop/", + scan_id=scan_id, + timeout=self.active_timeout, + on_progress=on_progress, + label=f"Active scanning ({policy_profile or self.policy_name})", ) return scan_id async def _wait_scan( self, status_path: str, + stop_path: str, scan_id: str, timeout: int, on_progress: Callable[[str], None] | None, label: str, ) -> None: - import asyncio - - elapsed = 0 + elapsed = 0.0 while elapsed < timeout: - data = await self._get(status_path, {"scanId": scan_id}) - status = int(data.get("status", 100)) + try: + data = await self._get(status_path, {"scanId": scan_id}, timeout=20.0) + status = int(data.get("status", 100)) + except Exception as exc: + logger.warning("%s status poll failed for scan %s: %s", label, scan_id, exc) + break + if on_progress: on_progress(f"{label}: {status}%") if status >= 100: return - await asyncio.sleep(2) - elapsed += 2 - logger.warning("%s timed out after %ss", label, timeout) + await asyncio.sleep(self.poll_interval) + elapsed += self.poll_interval + + logger.warning("%s timed out after %ss; stopping ZAP scan %s", label, timeout, scan_id) + try: + await self._get(stop_path, {"scanId": scan_id}, timeout=10.0) + except Exception: + pass + if on_progress: + on_progress(f"{label}: timed out after {timeout}s (stopped gracefully)") async def get_alerts(self, base_url: str) -> list[dict[str, Any]]: all_alerts: list[dict[str, Any]] = [] @@ -166,19 +274,24 @@ async def run_full_scan( self, target_url: str, on_progress: Callable[[str], None] | None = None, + policy_profile: str | None = None, ) -> list[dict[str, Any]]: + if policy_profile: + self.policy_name = policy_profile.strip().lower() + self.policy = self.get_policy_config(self.policy_name) + await self.new_session() if on_progress: - on_progress("Initialising ZAP session") + on_progress(f"Initialising ZAP session (policy: {self.policy_name})") await self.access_url(target_url) if on_progress: - on_progress("Starting spider (up to 100 children per node)") + on_progress(f"Starting spider (up to {self.policy['spider_max_children']} children per node)") await self.spider(target_url, on_progress) await self.ajax_spider(target_url, on_progress) await self.passive_scan_wait(on_progress) if on_progress: - on_progress("Starting active scan (full attack payload suite)") - await self.active_scan(target_url, on_progress) + on_progress(f"Starting active scan ({self.policy_name} profile)") + await self.active_scan(target_url, on_progress, policy_profile=self.policy_name) if on_progress: on_progress("Collecting alerts") return await self.get_alerts(target_url) diff --git a/docs/architecture.md b/docs/architecture.md index b7cc9cd..a6b2625 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -123,6 +123,7 @@ All API errors return a stable JSON shape: | `TARGET_NOT_ALLOWED` | 400 | Blocked target (safety guard) | | `NOT_FOUND` | 404 | Scan or report missing | | `INTERNAL_ERROR` | 500 | Unhandled server error | +| `DEPENDENCY_UNAVAILABLE` | 503 | Required upstream dependency (e.g. OWASP ZAP in `SCANNER_MODE=zap`) unreachable | ## 5. Operational Endpoints (Wave #1034) @@ -132,7 +133,14 @@ Returns service identity, scanner version, and capability list. Used by process ### `/ready` — Readiness -Executes `SELECT 1` against SQLite. Returns `503` if database unavailable. Used by deploy gates and `make ci` smoke checks. +Executes `SELECT 1` against SQLite and, when `SCANNER_MODE=zap`, verifies OWASP ZAP API connectivity (`/JSON/core/view/version/`). Returns `503` with `DEPENDENCY_UNAVAILABLE` if ZAP is unreachable in `zap` mode, or `SERVICE_UNAVAILABLE` if database is unavailable. Used by deploy gates and `make ci` smoke checks. + +### Lab Runbook (`docker compose ps`) + +```bash +./start-lab.sh full +docker compose ps # confirm shieldscan-zap (:8081), shieldscan-dvwa (:4280), shieldscan-juiceshop (:3000) are Up +``` ## 6. Security Architecture diff --git a/tests/test_zap_phase3.py b/tests/test_zap_phase3.py new file mode 100644 index 0000000..06fb055 --- /dev/null +++ b/tests/test_zap_phase3.py @@ -0,0 +1,192 @@ +"""Phase 3 tests: ZAP DAST integration, timeout hardening, policy profiles, and DEPENDENCY_UNAVAILABLE readiness contract.""" + +import asyncio +import logging +from urllib.parse import urlparse + +import httpx +from fastapi.testclient import TestClient + +from app.config import Settings, get_settings +from app.database import SessionLocal +from app.main import app +from app.models import Scan, ScanStatus +from app.services.scan_orchestrator import ScanOrchestrator +from app.services.zap_client import ZAPClient + +client = TestClient(app) + + +def test_zap_policy_profiles(): + quick = ZAPClient.get_policy_config("quick") + standard = ZAPClient.get_policy_config("standard") + deep = ZAPClient.get_policy_config("deep") + + assert quick["spider_max_children"] == 30 + assert quick["ajax_spider_enabled"] is False + assert standard["spider_max_children"] == 100 + assert standard["ajax_spider_enabled"] is True + assert deep["spider_max_children"] == 250 + assert deep["attack_strength"] == "HIGH" + + +def test_zap_client_full_scan_with_mocked_zap_api(): + state = {"spider_polls": 0, "pscan_polls": 0, "ascan_polls": 0, "ajax_polls": 0} + progress_messages: list[str] = [] + + def zap_mock(request: httpx.Request) -> httpx.Response: + path = urlparse(str(request.url)).path + if path.endswith("/core/view/version/"): + return httpx.Response(200, json={"version": "2.15.0"}) + if path.endswith("/core/action/newSession/"): + return httpx.Response(200, json={"Result": "OK"}) + if path.endswith("/core/action/accessUrl/"): + return httpx.Response(200, json={"Result": "OK"}) + if path.endswith("/spider/action/scan/"): + return httpx.Response(200, json={"scan": "1"}) + if path.endswith("/spider/view/status/"): + state["spider_polls"] += 1 + pct = "50" if state["spider_polls"] == 1 else "100" + return httpx.Response(200, json={"status": pct}) + if path.endswith("/ajaxSpider/action/setOptionMaxDuration/"): + return httpx.Response(200, json={"Result": "OK"}) + if path.endswith("/ajaxSpider/action/scan/"): + return httpx.Response(200, json={"Result": "OK"}) + if path.endswith("/ajaxSpider/view/status/"): + state["ajax_polls"] += 1 + st = "running" if state["ajax_polls"] == 1 else "stopped" + return httpx.Response(200, json={"status": st}) + if path.endswith("/pscan/view/recordsToScan/"): + state["pscan_polls"] += 1 + rem = "4" if state["pscan_polls"] == 1 else "0" + return httpx.Response(200, json={"recordsToScan": rem}) + if path.endswith("/ascan/action/scan/"): + return httpx.Response(200, json={"scan": "2"}) + if path.endswith("/ascan/view/status/"): + state["ascan_polls"] += 1 + pct = "60" if state["ascan_polls"] == 1 else "100" + return httpx.Response(200, json={"status": pct}) + if path.endswith("/alert/view/alerts/"): + return httpx.Response( + 200, + json={ + "alerts": [ + { + "pluginId": "40012", + "alert": "Cross Site Scripting (Reflected)", + "risk": "High", + "confidence": "Medium", + "url": "http://127.0.0.1:4280/vulnerabilities/xss_r/", + "param": "name", + "evidence": "", + "cweid": "79", + "description": "Reflected XSS in name parameter.", + "solution": "Encode output.", + } + ] + }, + ) + return httpx.Response(200, json={"Result": "OK"}) + + settings = Settings(zap_scan_policy="standard") + zap = ZAPClient(settings, transport=httpx.MockTransport(zap_mock), poll_interval=0.01) + + async def _run(): + assert await zap.is_available() is True + return await zap.run_full_scan("http://127.0.0.1:4280", on_progress=progress_messages.append) + + alerts = asyncio.run(_run()) + assert len(alerts) == 1 + assert alerts[0]["alert"] == "Cross Site Scripting (Reflected)" + assert any("Passive scan: 4 records remaining" in m for m in progress_messages) + assert any("Passive scan: 0 records remaining (100%)" in m for m in progress_messages) + assert any("Active scanning (standard): 100%" in m for m in progress_messages) + + +def test_zap_spider_and_ajax_timeout_hardening_stops_gracefully(): + stopped_endpoints: list[str] = [] + + def hung_zap_mock(request: httpx.Request) -> httpx.Response: + path = urlparse(str(request.url)).path + if path.endswith("/action/stop/"): + stopped_endpoints.append(path) + return httpx.Response(200, json={"Result": "OK"}) + if path.endswith("/spider/action/scan/"): + return httpx.Response(200, json={"scan": "7"}) + if path.endswith("/spider/view/status/"): + return httpx.Response(200, json={"status": "25"}) + if path.endswith("/ajaxSpider/view/status/"): + return httpx.Response(200, json={"status": "running"}) + return httpx.Response(200, json={"Result": "OK"}) + + settings = Settings( + scan_spider_timeout=1, + scan_ajax_spider_timeout=1, + zap_scan_policy="standard", + ) + zap = ZAPClient(settings, transport=httpx.MockTransport(hung_zap_mock), poll_interval=0.4) + msgs: list[str] = [] + + async def _run(): + await zap.spider("http://127.0.0.1:4280", on_progress=msgs.append) + await zap.ajax_spider("http://127.0.0.1:4280", on_progress=msgs.append) + + asyncio.run(_run()) + assert "/JSON/spider/action/stop/" in stopped_endpoints + assert "/JSON/ajaxSpider/action/stop/" in stopped_endpoints + assert any("timed out" in m for m in msgs) + + +def test_ready_dependency_unavailable_contract_when_zap_down(monkeypatch): + get_settings.cache_clear() + monkeypatch.setenv("SCANNER_MODE", "zap") + monkeypatch.setenv("ZAP_API_URL", "http://127.0.0.1:59999") + get_settings.cache_clear() + + try: + resp = client.get("/ready") + assert resp.status_code == 503 + body = resp.json() + assert body["status"] == "not_ready" + assert body["zap"] == "unavailable" + assert "error" in body + assert body["error"]["code"] == "DEPENDENCY_UNAVAILABLE" + assert body["error"]["details"]["dependency"] == "zap" + finally: + monkeypatch.setenv("SCANNER_MODE", "builtin") + get_settings.cache_clear() + + +def test_orchestrator_logs_fallback_to_builtin_when_zap_unreachable(caplog, monkeypatch): + async def fake_builtin_scan(target_url: str, on_progress=None): + if on_progress: + on_progress("Mock builtin scan complete") + return [] + + monkeypatch.setattr("app.services.scan_orchestrator.run_builtin_scan", fake_builtin_scan) + + settings = Settings( + scanner_mode="zap", + zap_api_url="http://127.0.0.1:59999", + anthropic_api_key="", + ) + + with SessionLocal() as db: + scan = Scan( + target_url="http://127.0.0.1:4280", + status=ScanStatus.QUEUED, + status_message="Queued", + ) + db.add(scan) + db.commit() + db.refresh(scan) + scan_id = scan.id + + orchestrator = ScanOrchestrator(settings, db) + with caplog.at_level(logging.WARNING): + asyncio.run(orchestrator.run(scan_id)) + + db.refresh(scan) + assert scan.status == ScanStatus.COMPLETE + assert scan.scanner_used == "builtin" + assert any("Fallback to builtin scanner" in rec.message for rec in caplog.records)