secure-web/backend/scanner/scanners/zap.py

308 lines
9.5 KiB
Python

"""
OWASP ZAP Scanner Integration.
This module integrates with OWASP ZAP for security scanning,
detecting vulnerabilities like XSS, injection flaws, and
misconfigurations.
"""
import logging
import time
from typing import Any, Dict, List, Optional
import httpx
from django.conf import settings
from .base import (
BaseScanner,
ScannerResult,
ScannerStatus,
IssueData,
MetricData,
)
logger = logging.getLogger(__name__)
class ZAPScanner(BaseScanner):
"""
Scanner using OWASP ZAP for security vulnerability detection.
Performs baseline scans to identify common security issues:
- XSS vulnerabilities
- SQL injection patterns
- Insecure cookies
- Missing security headers
- SSL/TLS issues
- And more...
"""
name = "owasp_zap"
def __init__(self, config: Optional[Dict[str, Any]] = None):
super().__init__(config)
scanner_config = settings.SCANNER_CONFIG
self.zap_host = self.config.get('zap_host', scanner_config.get('ZAP_HOST', 'http://zap:8080'))
self.api_key = self.config.get('api_key', scanner_config.get('ZAP_API_KEY', ''))
self.timeout = self.config.get('timeout', scanner_config.get('ZAP_TIMEOUT', 120))
def is_available(self) -> bool:
"""Check if ZAP service is available."""
try:
with httpx.Client(timeout=10) as client:
response = client.get(
f"{self.zap_host}/JSON/core/view/version/",
params={'apikey': self.api_key}
)
return response.status_code == 200
except Exception as e:
self.logger.warning(f"ZAP service not available: {e}")
return False
def run(self, url: str) -> ScannerResult:
"""
Run ZAP security scan against the URL.
Args:
url: The URL to scan
Returns:
ScannerResult with security findings
"""
self.logger.info(f"Starting ZAP scan for {url}")
try:
# Access the target to populate ZAP's site tree
self._access_url(url)
# Spider the site (limited crawl)
self._spider_url(url)
# Run active scan
self._active_scan(url)
# Get alerts
alerts = self._get_alerts(url)
return self._parse_results(url, alerts)
except httpx.TimeoutException:
return self._create_error_result(
Exception("ZAP scan timed out")
)
except httpx.HTTPStatusError as e:
return self._create_error_result(
Exception(f"ZAP service error: {e.response.status_code}")
)
except Exception as e:
return self._create_error_result(e)
def _zap_request(self, endpoint: str, params: Optional[Dict] = None) -> Dict:
"""Make a request to the ZAP API."""
if params is None:
params = {}
params['apikey'] = self.api_key
with httpx.Client(timeout=self.timeout) as client:
response = client.get(
f"{self.zap_host}{endpoint}",
params=params
)
response.raise_for_status()
return response.json()
def _access_url(self, url: str) -> None:
"""Access the URL to add it to ZAP's site tree."""
self.logger.debug(f"Accessing URL in ZAP: {url}")
self._zap_request(
'/JSON/core/action/accessUrl/',
{'url': url, 'followRedirects': 'true'}
)
time.sleep(2) # Wait for ZAP to process
def _spider_url(self, url: str) -> None:
"""Spider the URL to discover pages."""
self.logger.debug(f"Spidering URL: {url}")
# Start spider
result = self._zap_request(
'/JSON/spider/action/scan/',
{
'url': url,
'maxChildren': '5', # Limited crawl
'recurse': 'true',
'subtreeOnly': 'true'
}
)
scan_id = result.get('scan')
if not scan_id:
return
# Wait for spider to complete (with timeout)
start_time = time.time()
while time.time() - start_time < 60: # 60 second spider timeout
status = self._zap_request(
'/JSON/spider/view/status/',
{'scanId': scan_id}
)
if int(status.get('status', '100')) >= 100:
break
time.sleep(2)
def _active_scan(self, url: str) -> None:
"""Run active scan against the URL."""
self.logger.debug(f"Starting active scan: {url}")
# Start active scan
result = self._zap_request(
'/JSON/ascan/action/scan/',
{
'url': url,
'recurse': 'true',
'inScopeOnly': 'true'
}
)
scan_id = result.get('scan')
if not scan_id:
return
# Wait for scan to complete (with timeout)
start_time = time.time()
while time.time() - start_time < self.timeout:
status = self._zap_request(
'/JSON/ascan/view/status/',
{'scanId': scan_id}
)
if int(status.get('status', '100')) >= 100:
break
time.sleep(5)
def _get_alerts(self, url: str) -> List[Dict]:
"""Get alerts for the scanned URL."""
self.logger.debug(f"Fetching alerts for: {url}")
result = self._zap_request(
'/JSON/core/view/alerts/',
{
'baseurl': url,
'start': '0',
'count': '100' # Limit alerts
}
)
return result.get('alerts', [])
def _parse_results(self, url: str, alerts: List[Dict]) -> ScannerResult:
"""
Parse ZAP alerts into ScannerResult format.
Args:
url: The scanned URL
alerts: List of ZAP alerts
Returns:
Parsed ScannerResult
"""
issues = []
metrics = []
# Count alerts by risk level
risk_counts = {
'High': 0,
'Medium': 0,
'Low': 0,
'Informational': 0
}
for alert in alerts:
risk = alert.get('risk', 'Informational')
risk_counts[risk] = risk_counts.get(risk, 0) + 1
severity = self._map_risk_to_severity(risk)
issues.append(IssueData(
category='security',
severity=severity,
title=alert.get('name', 'Unknown vulnerability'),
description=self._format_description(alert),
tool='owasp_zap',
affected_url=alert.get('url', url),
remediation=alert.get('solution', 'Review and fix the vulnerability.'),
raw_data={
'alert_ref': alert.get('alertRef'),
'cweid': alert.get('cweid'),
'wascid': alert.get('wascid'),
'confidence': alert.get('confidence'),
'evidence': alert.get('evidence', '')[:500], # Truncate evidence
}
))
# Create metrics for vulnerability counts
for risk_level, count in risk_counts.items():
if count > 0:
metrics.append(MetricData(
name=f'zap_{risk_level.lower()}_alerts',
display_name=f'{risk_level} Risk Alerts',
value=float(count),
unit='count',
source='owasp_zap'
))
metrics.append(MetricData(
name='total_security_alerts',
display_name='Total Security Alerts',
value=float(len(alerts)),
unit='count',
source='owasp_zap'
))
self.logger.info(
f"ZAP scan complete: {len(alerts)} alerts "
f"(High: {risk_counts['High']}, Medium: {risk_counts['Medium']}, "
f"Low: {risk_counts['Low']})"
)
return ScannerResult(
scanner_name=self.name,
status=ScannerStatus.SUCCESS,
issues=issues,
metrics=metrics,
raw_data={
'total_alerts': len(alerts),
'risk_counts': risk_counts,
'alerts': alerts[:50] # Store limited raw alerts
}
)
def _map_risk_to_severity(self, risk: str) -> str:
"""Map ZAP risk level to our severity."""
mapping = {
'High': 'high',
'Medium': 'medium',
'Low': 'low',
'Informational': 'info',
}
return mapping.get(risk, 'info')
def _format_description(self, alert: Dict) -> str:
"""Format ZAP alert into readable description."""
parts = []
if alert.get('description'):
parts.append(alert['description'])
if alert.get('attack'):
parts.append(f"\nAttack: {alert['attack']}")
if alert.get('evidence'):
evidence = alert['evidence'][:200]
parts.append(f"\nEvidence: {evidence}")
if alert.get('reference'):
parts.append(f"\nReference: {alert['reference']}")
return '\n'.join(parts)