secure-web/backend/tests/test_scanner_parsing.py

348 lines
12 KiB
Python

"""
Tests for scanner result parsing.
"""
import pytest
import json
from pathlib import Path
# Sample Lighthouse JSON response
SAMPLE_LIGHTHOUSE_RESPONSE = {
"scanId": "test-123",
"url": "https://example.com",
"scores": {
"performance": 85,
"accessibility": 90,
"bestPractices": 75,
"seo": 80
},
"metrics": {
"firstContentfulPaint": {"value": 1200, "unit": "ms", "score": 0.9},
"largestContentfulPaint": {"value": 2500, "unit": "ms", "score": 0.75},
"speedIndex": {"value": 3400, "unit": "ms", "score": 0.7},
"timeToInteractive": {"value": 4500, "unit": "ms", "score": 0.65},
"totalBlockingTime": {"value": 150, "unit": "ms", "score": 0.85},
"cumulativeLayoutShift": {"value": 0.1, "unit": "score", "score": 0.95}
},
"resources": {
"totalByteWeight": 2500000,
"unusedJavascript": [
{"url": "https://example.com/bundle.js", "wastedBytes": 150000}
],
"renderBlockingResources": [
{"url": "https://example.com/styles.css", "wastedMs": 500}
]
},
"diagnostics": {
"numRequests": 45,
"numScripts": 12,
"numStylesheets": 3,
"numImages": 20
},
"issues": [
{
"id": "uses-long-cache-ttl",
"category": "performance",
"title": "Serve static assets with an efficient cache policy",
"description": "A long cache lifetime can speed up repeat visits.",
"score": 0.3,
"impact": 5
}
]
}
class TestLighthouseResultParsing:
"""Tests for parsing Lighthouse scanner results."""
def test_parse_scores(self):
"""Test extracting scores from Lighthouse response."""
scores = SAMPLE_LIGHTHOUSE_RESPONSE['scores']
assert scores['performance'] == 85
assert scores['accessibility'] == 90
assert scores['bestPractices'] == 75
assert scores['seo'] == 80
def test_parse_core_web_vitals(self):
"""Test extracting Core Web Vitals metrics."""
metrics = SAMPLE_LIGHTHOUSE_RESPONSE['metrics']
# FCP
assert metrics['firstContentfulPaint']['value'] == 1200
assert metrics['firstContentfulPaint']['unit'] == 'ms'
# LCP
assert metrics['largestContentfulPaint']['value'] == 2500
# CLS
assert metrics['cumulativeLayoutShift']['value'] == 0.1
assert metrics['cumulativeLayoutShift']['unit'] == 'score'
def test_parse_resource_metrics(self):
"""Test extracting resource metrics."""
resources = SAMPLE_LIGHTHOUSE_RESPONSE['resources']
diagnostics = SAMPLE_LIGHTHOUSE_RESPONSE['diagnostics']
assert resources['totalByteWeight'] == 2500000
assert diagnostics['numRequests'] == 45
assert diagnostics['numScripts'] == 12
def test_parse_issues(self):
"""Test extracting issues from Lighthouse."""
issues = SAMPLE_LIGHTHOUSE_RESPONSE['issues']
assert len(issues) == 1
issue = issues[0]
assert issue['category'] == 'performance'
assert issue['title'] == 'Serve static assets with an efficient cache policy'
# Sample ZAP response
SAMPLE_ZAP_ALERTS = [
{
"alert": "Cross-Site Scripting (Reflected)",
"risk": "3", # High
"confidence": "2",
"cweid": "79",
"wascid": "8",
"description": "Cross-site Scripting (XSS) is an attack technique...",
"url": "https://example.com/search?q=test",
"param": "q",
"evidence": "<script>alert(1)</script>",
"solution": "Phase: Architecture and Design\nUse a vetted library...",
"reference": "https://owasp.org/www-community/attacks/xss/"
},
{
"alert": "Missing Anti-clickjacking Header",
"risk": "2", # Medium
"confidence": "3",
"cweid": "1021",
"wascid": "15",
"description": "The response does not include X-Frame-Options...",
"url": "https://example.com/",
"solution": "Ensure X-Frame-Options HTTP header is included...",
"reference": "https://owasp.org/www-community/Security_Headers"
},
{
"alert": "Server Leaks Information",
"risk": "1", # Low
"confidence": "3",
"cweid": "200",
"description": "The web/application server is leaking information...",
"url": "https://example.com/",
"evidence": "nginx/1.18.0",
"solution": "Configure the server to hide version information."
},
{
"alert": "Information Disclosure",
"risk": "0", # Info
"confidence": "2",
"description": "This is an informational finding.",
"url": "https://example.com/"
}
]
class TestZAPResultParsing:
"""Tests for parsing OWASP ZAP results."""
def test_parse_alert_severity(self):
"""Test mapping ZAP risk levels to severity."""
risk_mapping = {
'0': 'info',
'1': 'low',
'2': 'medium',
'3': 'high',
}
for alert in SAMPLE_ZAP_ALERTS:
risk = alert['risk']
expected_severity = risk_mapping[risk]
assert expected_severity in ['info', 'low', 'medium', 'high']
def test_parse_xss_alert(self):
"""Test parsing XSS vulnerability alert."""
xss_alert = SAMPLE_ZAP_ALERTS[0]
assert xss_alert['alert'] == 'Cross-Site Scripting (Reflected)'
assert xss_alert['risk'] == '3' # High
assert xss_alert['cweid'] == '79' # XSS CWE ID
assert 'q' in xss_alert['param']
def test_parse_header_alert(self):
"""Test parsing missing header alert."""
header_alert = SAMPLE_ZAP_ALERTS[1]
assert 'X-Frame-Options' in header_alert['alert']
assert header_alert['risk'] == '2' # Medium
def test_categorize_alerts(self):
"""Test categorizing ZAP alerts."""
def categorize(alert_name):
alert_lower = alert_name.lower()
if 'xss' in alert_lower or 'cross-site scripting' in alert_lower:
return 'security'
if 'header' in alert_lower or 'x-frame' in alert_lower:
return 'headers'
if 'cookie' in alert_lower:
return 'security'
return 'security'
assert categorize(SAMPLE_ZAP_ALERTS[0]['alert']) == 'security'
assert categorize(SAMPLE_ZAP_ALERTS[1]['alert']) == 'headers'
# Sample HTTP headers response
SAMPLE_HEADERS = {
'content-type': 'text/html; charset=utf-8',
'server': 'nginx/1.18.0',
'x-powered-by': 'Express',
'strict-transport-security': 'max-age=31536000; includeSubDomains',
'x-content-type-options': 'nosniff',
'x-frame-options': 'SAMEORIGIN',
# Missing: Content-Security-Policy, Referrer-Policy, Permissions-Policy
}
class TestHeadersResultParsing:
"""Tests for parsing HTTP headers analysis."""
REQUIRED_HEADERS = [
'strict-transport-security',
'content-security-policy',
'x-frame-options',
'x-content-type-options',
'referrer-policy',
'permissions-policy',
]
def test_detect_present_headers(self):
"""Test detecting which security headers are present."""
headers_lower = {k.lower(): v for k, v in SAMPLE_HEADERS.items()}
present = [h for h in self.REQUIRED_HEADERS if h in headers_lower]
assert 'strict-transport-security' in present
assert 'x-frame-options' in present
assert 'x-content-type-options' in present
def test_detect_missing_headers(self):
"""Test detecting which security headers are missing."""
headers_lower = {k.lower(): v for k, v in SAMPLE_HEADERS.items()}
missing = [h for h in self.REQUIRED_HEADERS if h not in headers_lower]
assert 'content-security-policy' in missing
assert 'referrer-policy' in missing
assert 'permissions-policy' in missing
def test_detect_information_disclosure(self):
"""Test detecting information disclosure headers."""
info_disclosure_headers = ['server', 'x-powered-by', 'x-aspnet-version']
disclosed = [
h for h in info_disclosure_headers
if h.lower() in {k.lower() for k in SAMPLE_HEADERS.keys()}
]
assert 'server' in disclosed
assert 'x-powered-by' in disclosed
def test_check_hsts_max_age(self):
"""Test checking HSTS max-age value."""
hsts = SAMPLE_HEADERS.get('strict-transport-security', '')
# Extract max-age
if 'max-age=' in hsts.lower():
max_age_str = hsts.lower().split('max-age=')[1].split(';')[0]
max_age = int(max_age_str)
# Should be at least 1 year (31536000 seconds)
assert max_age >= 31536000
class TestScannerResultIntegration:
"""Integration tests for combining scanner results."""
def test_aggregate_scores(self):
"""Test aggregating scores from multiple scanners."""
lighthouse_scores = SAMPLE_LIGHTHOUSE_RESPONSE['scores']
# Simulate security score from ZAP findings
security_score = 100
for alert in SAMPLE_ZAP_ALERTS:
risk = alert['risk']
if risk == '3':
security_score -= 15 # High
elif risk == '2':
security_score -= 8 # Medium
elif risk == '1':
security_score -= 3 # Low
else:
security_score -= 1 # Info
security_score = max(0, security_score)
# Calculate overall (simplified)
overall = (
lighthouse_scores['performance'] * 0.25 +
security_score * 0.30 +
lighthouse_scores['accessibility'] * 0.15 +
lighthouse_scores['seo'] * 0.15 +
lighthouse_scores['bestPractices'] * 0.15
)
assert 0 <= overall <= 100
def test_combine_issues(self):
"""Test combining issues from multiple scanners."""
# Lighthouse issues
lighthouse_issues = [
{
'category': 'performance',
'severity': 'medium',
'tool': 'lighthouse',
'title': issue['title']
}
for issue in SAMPLE_LIGHTHOUSE_RESPONSE['issues']
]
# ZAP issues
risk_to_severity = {'0': 'info', '1': 'low', '2': 'medium', '3': 'high'}
zap_issues = [
{
'category': 'security',
'severity': risk_to_severity[alert['risk']],
'tool': 'owasp_zap',
'title': alert['alert']
}
for alert in SAMPLE_ZAP_ALERTS
]
# Header issues
headers_lower = {k.lower(): v for k, v in SAMPLE_HEADERS.items()}
header_issues = [
{
'category': 'headers',
'severity': 'high' if h == 'content-security-policy' else 'medium',
'tool': 'header_check',
'title': f'Missing {h} header'
}
for h in ['content-security-policy', 'referrer-policy', 'permissions-policy']
if h not in headers_lower
]
all_issues = lighthouse_issues + zap_issues + header_issues
assert len(all_issues) > 0
# Count by severity
severity_counts = {}
for issue in all_issues:
severity = issue['severity']
severity_counts[severity] = severity_counts.get(severity, 0) + 1
assert 'high' in severity_counts or 'medium' in severity_counts