348 lines
12 KiB
Python
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
|