Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
3 changes: 3 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions app/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
18 changes: 16 additions & 2 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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,
Expand All @@ -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"}
29 changes: 25 additions & 4 deletions app/services/scan_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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()
Expand Down
Loading