"""Tests for the Security Audit grader.""" import sys, os sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from server.grader import grade_episode, match_single_finding, _match_findings, _detect_framework from server.scenarios import get_scenario def _perfect_easy_findings(): return [ {"title": "SQL Injection in /api/login", "host": "10.0.1.10", "endpoint": "/api/login", "type": "SQL Injection", "severity": "Critical", "cvss_score": 9.8, "cwe": "CWE-89", "owasp": "A03:2021 - Injection", "evidence": "Parameter username vulnerable to OR 1=1 bypass, MySQL error exposed in response body.", "remediation": "Use parameterized queries or prepared statements for all database operations."}, {"title": "Default credentials on admin panel", "host": "10.0.1.10", "endpoint": "/admin", "type": "Broken Authentication", "severity": "High", "cvss_score": 8.1, "cwe": "CWE-798", "owasp": "A07:2021 - Identification and Authentication Failures", "evidence": "Admin panel accessible with admin:admin123, no account lockout after multiple failures.", "remediation": "Change default credentials immediately. Implement account lockout and MFA."}, {"title": "MySQL publicly accessible", "host": "10.0.1.20", "type": "Security Misconfiguration", "severity": "Critical", "cvss_score": 9.1, "cwe": "CWE-200", "owasp": "A05:2021 - Security Misconfiguration", "evidence": "MySQL port 3306 accessible from public internet, remote root login enabled, no firewall rules.", "remediation": "Restrict MySQL access to application server IPs only using firewall rules."}, ] class TestDeterminism: def test_same_inputs_same_outputs(self): s = get_scenario("easy"); f = _perfect_easy_findings() g1 = grade_episode(s, f, ["10.0.1.10", "10.0.1.20"], {"10.0.1.10": [22,80,443], "10.0.1.20": [22,3306]}) g2 = grade_episode(s, f, ["10.0.1.10", "10.0.1.20"], {"10.0.1.10": [22,80,443], "10.0.1.20": [22,3306]}) assert g1 == g2 def test_all_scenarios(self): for sid in ["easy", "medium", "hard"]: s = get_scenario(sid) assert grade_episode(s, [], [], {}) == grade_episode(s, [], [], {}) class TestScoreBounds: def test_final_score_bounded(self): for sid in ["easy", "medium", "hard"]: g = grade_episode(get_scenario(sid), [], [], {}) assert 0.0 <= g["final_score"] <= 1.0 def test_component_scores_bounded(self): s = get_scenario("easy"); f = _perfect_easy_findings() g = grade_episode(s, f, ["10.0.1.10", "10.0.1.20"], {"10.0.1.10": [22,80,443], "10.0.1.20": [22,3306]}) for k in ["detection_rate", "coverage", "severity_accuracy", "classification_accuracy", "report_quality", "exploitation_proof", "compliance_coverage", "pivoting_score"]: assert 0.0 <= g[k] <= 1.0, f"{k} out of bounds: {g[k]}" class TestPerfectScore: def test_easy_perfect(self): s = get_scenario("easy"); f = _perfect_easy_findings() g = grade_episode(s, f, ["10.0.1.10", "10.0.1.20"], {"10.0.1.10": [22,80,443], "10.0.1.20": [22,3306]}) assert g["detection_rate"] == 1.0 assert g["coverage"] == 1.0 assert g["true_positives"] == 3 assert g["false_positives"] == 0 assert g["final_score"] >= 0.90 class TestEmptySubmission: def test_no_findings_no_hosts(self): g = grade_episode(get_scenario("easy"), [], [], {}) assert g["final_score"] >= 0.0 and g["detection_rate"] == 0.0 def test_no_findings_all_hosts(self): g = grade_episode(get_scenario("easy"), [], ["10.0.1.10", "10.0.1.20"], {}) assert g["coverage"] == 1.0 and g["detection_rate"] == 0.0 class TestPenalties: def test_fp_escalating(self): s = get_scenario("easy") g1 = grade_episode(s, [{"title": "F", "host": "10.0.1.10", "type": "X", "severity": "Low"}], ["10.0.1.10"], {}) g3 = grade_episode(s, [{"title": "F", "host": "10.0.1.10", "type": "X", "severity": "Low"}]*3, ["10.0.1.10"], {}) assert abs(g1["fp_penalty"] - 0.03) < 0.001 assert g3["fp_penalty"] > g1["fp_penalty"] * 3 def test_honeypot(self): g = grade_episode(get_scenario("hard"), [], ["10.0.3.99"], {}) assert g["honeypot_penalty"] == 0.15 def test_coverage_multiplier(self): g = grade_episode(get_scenario("hard"), [], ["10.0.3.10"], {}) assert g["coverage"] < 0.5 and g["coverage_multiplier"] < 1.0 class TestMatching: def test_by_cwe(self): assert match_single_finding({"host": "10.0.1.10", "type": "X", "cwe": "CWE-89"}, get_scenario("easy")["vulnerabilities"], set()) == "VULN-E001" def test_by_word_overlap(self): assert match_single_finding({"host": "10.0.1.10", "type": "SQL Injection vulnerability"}, get_scenario("easy")["vulnerabilities"], set()) == "VULN-E001" def test_by_endpoint(self): assert match_single_finding({"host": "10.0.1.10", "endpoint": "/api/login", "type": "Unknown"}, get_scenario("easy")["vulnerabilities"], set()) == "VULN-E001" def test_no_match_wrong_host(self): assert match_single_finding({"host": "10.0.1.99", "type": "SQL Injection", "cwe": "CWE-89"}, get_scenario("easy")["vulnerabilities"], set()) is None def test_no_double_match(self): assert match_single_finding({"host": "10.0.1.10", "type": "SQL Injection", "cwe": "CWE-89"}, get_scenario("easy")["vulnerabilities"], {"VULN-E001"}) is None def test_batch(self): assert len(_match_findings(get_scenario("easy")["vulnerabilities"], _perfect_easy_findings())) == 3 class TestCompliance: def test_pci(self): assert _detect_framework(get_scenario("medium")) == "PCI-DSS" def test_soc2(self): assert _detect_framework(get_scenario("hard")) == "SOC2" def test_generic(self): assert _detect_framework(get_scenario("easy")) == "Generic" def test_coverage_with_findings(self): g = grade_episode(get_scenario("easy"), _perfect_easy_findings(), ["10.0.1.10", "10.0.1.20"], {"10.0.1.10": [22,80,443], "10.0.1.20": [22,3306]}) assert g["compliance_coverage"] > 0.0 class TestOWASP: def test_perfect(self): g = grade_episode(get_scenario("easy"), _perfect_easy_findings(), ["10.0.1.10", "10.0.1.20"], {"10.0.1.10": [22,80,443], "10.0.1.20": [22,3306]}) assert g["owasp_accuracy"] == 1.0 def test_missing(self): g = grade_episode(get_scenario("easy"), [{"title": "SQLi", "host": "10.0.1.10", "type": "SQL Injection", "severity": "Critical", "cvss_score": 9.8, "cwe": "CWE-89"}], ["10.0.1.10"], {}) assert g["owasp_accuracy"] == 0.0 class TestReportQuality: def test_narrative(self): good = [{"title": "SQLi", "host": "10.0.1.10", "type": "SQL Injection", "severity": "Critical", "cvss_score": 9.8, "cwe": "CWE-89", "owasp": "A03:2021 - Injection", "evidence": "The username parameter is vulnerable to SQL injection via OR 1=1 payload", "remediation": "Use parameterized queries for all database operations in the login endpoint"}] bad = [{"title": "SQLi", "host": "10.0.1.10", "type": "SQL Injection", "severity": "Critical", "cvss_score": 9.8, "cwe": "CWE-89", "owasp": "A03:2021 - Injection", "evidence": "yes", "remediation": "fix"}] s = get_scenario("easy") assert grade_episode(s, good, ["10.0.1.10"], {})["narrative_quality"] > grade_episode(s, bad, ["10.0.1.10"], {})["narrative_quality"] class TestEfficiency: def test_calculated(self): assert abs(grade_episode(get_scenario("easy"), [], [], {}, steps_used=15)["efficiency"] - 0.5) < 0.01 def test_zero(self): assert grade_episode(get_scenario("easy"), [], [], {}, steps_used=0)["efficiency"] == 0.0 class TestPivoting: def test_easy_no_gateways(self): g = grade_episode(get_scenario("easy"), [], [], {}) assert g["pivoting_score"] == 1.0 # no gateway vulns = default 1.0 def test_medium_gateway(self): s = get_scenario("medium") # Submit only the SSRF (gateway vuln) f = [{"title": "SSRF", "host": "10.0.2.10", "endpoint": "/api/upload/image", "type": "Server-Side Request Forgery (SSRF)", "severity": "High", "cwe": "CWE-918"}] g = grade_episode(s, f, ["10.0.2.10"], {}) assert g["pivoting_score"] == 1.0 # found the gateway class TestExploitationProof: def test_proportional(self): s = get_scenario("easy") g = grade_episode(s, [_perfect_easy_findings()[0]], ["10.0.1.10"], {}) assert abs(g["exploitation_proof"] - 1.0/3.0) < 0.01