use compliance_core::error::CoreError; use compliance_core::models::dast::{DastEvidence, DastFinding, DastVulnType}; use compliance_core::models::Severity; use compliance_core::traits::pentest_tool::{PentestTool, PentestToolContext, PentestToolResult}; use serde_json::json; use tracing::info; /// Tool that analyzes Content-Security-Policy headers. pub struct CspAnalyzerTool { http: reqwest::Client, } /// A parsed CSP directive. #[derive(Debug)] struct CspDirective { name: String, values: Vec, } impl CspAnalyzerTool { pub fn new(http: reqwest::Client) -> Self { Self { http } } /// Parse a CSP header string into directives. fn parse_csp(csp: &str) -> Vec { let mut directives = Vec::new(); for part in csp.split(';') { let trimmed = part.trim(); if trimmed.is_empty() { continue; } let tokens: Vec<&str> = trimmed.split_whitespace().collect(); if let Some((name, values)) = tokens.split_first() { directives.push(CspDirective { name: name.to_lowercase(), values: values.iter().map(|v| v.to_string()).collect(), }); } } directives } /// Check a CSP for common issues and return findings. fn analyze_directives( directives: &[CspDirective], url: &str, target_id: &str, status: u16, _csp_raw: &str, ) -> Vec { let mut findings = Vec::new(); let make_evidence = |snippet: String| DastEvidence { request_method: "GET".to_string(), request_url: url.to_string(), request_headers: None, request_body: None, response_status: status, response_headers: None, response_snippet: Some(snippet), screenshot_path: None, payload: None, response_time_ms: None, }; // Check for unsafe-inline in script-src for d in directives { if (d.name == "script-src" || d.name == "default-src") && d.values.iter().any(|v| v == "'unsafe-inline'") { let evidence = make_evidence(format!("{}: {}", d.name, d.values.join(" "))); let mut finding = DastFinding::new( String::new(), target_id.to_string(), DastVulnType::CspIssue, format!("CSP allows 'unsafe-inline' in {}", d.name), format!( "The Content-Security-Policy directive '{}' includes 'unsafe-inline', \ which defeats the purpose of CSP by allowing inline scripts that \ could be exploited via XSS.", d.name ), Severity::High, url.to_string(), "GET".to_string(), ); finding.cwe = Some("CWE-79".to_string()); finding.evidence = vec![evidence]; finding.remediation = Some( "Remove 'unsafe-inline' from script-src. Use nonces or hashes for \ legitimate inline scripts instead." .to_string(), ); findings.push(finding); } // Check for unsafe-eval if (d.name == "script-src" || d.name == "default-src") && d.values.iter().any(|v| v == "'unsafe-eval'") { let evidence = make_evidence(format!("{}: {}", d.name, d.values.join(" "))); let mut finding = DastFinding::new( String::new(), target_id.to_string(), DastVulnType::CspIssue, format!("CSP allows 'unsafe-eval' in {}", d.name), format!( "The Content-Security-Policy directive '{}' includes 'unsafe-eval', \ which allows the use of eval() and similar dynamic code execution \ that can be exploited via XSS.", d.name ), Severity::Medium, url.to_string(), "GET".to_string(), ); finding.cwe = Some("CWE-79".to_string()); finding.evidence = vec![evidence]; finding.remediation = Some( "Remove 'unsafe-eval' from script-src. Refactor code to avoid eval(), \ Function(), and similar constructs." .to_string(), ); findings.push(finding); } // Check for wildcard sources if d.values.iter().any(|v| v == "*") { let evidence = make_evidence(format!("{}: {}", d.name, d.values.join(" "))); let mut finding = DastFinding::new( String::new(), target_id.to_string(), DastVulnType::CspIssue, format!("CSP wildcard source in {}", d.name), format!( "The Content-Security-Policy directive '{}' uses a wildcard '*' source, \ which allows loading resources from any origin and largely negates CSP protection.", d.name ), Severity::Medium, url.to_string(), "GET".to_string(), ); finding.cwe = Some("CWE-16".to_string()); finding.evidence = vec![evidence]; finding.remediation = Some(format!( "Replace the wildcard '*' in {} with specific allowed origins.", d.name )); findings.push(finding); } // Check for http: sources (non-HTTPS) if d.values.iter().any(|v| v == "http:") { let evidence = make_evidence(format!("{}: {}", d.name, d.values.join(" "))); let mut finding = DastFinding::new( String::new(), target_id.to_string(), DastVulnType::CspIssue, format!("CSP allows HTTP sources in {}", d.name), format!( "The Content-Security-Policy directive '{}' allows loading resources \ over unencrypted HTTP, which can be exploited via man-in-the-middle attacks.", d.name ), Severity::Medium, url.to_string(), "GET".to_string(), ); finding.cwe = Some("CWE-319".to_string()); finding.evidence = vec![evidence]; finding.remediation = Some(format!( "Replace 'http:' with 'https:' in {} to enforce encrypted resource loading.", d.name )); findings.push(finding); } // Check for data: in script-src (can be used to bypass CSP) if (d.name == "script-src" || d.name == "default-src") && d.values.iter().any(|v| v == "data:") { let evidence = make_evidence(format!("{}: {}", d.name, d.values.join(" "))); let mut finding = DastFinding::new( String::new(), target_id.to_string(), DastVulnType::CspIssue, format!("CSP allows data: URIs in {}", d.name), format!( "The Content-Security-Policy directive '{}' allows 'data:' URIs, \ which can be used to bypass CSP and execute arbitrary scripts.", d.name ), Severity::High, url.to_string(), "GET".to_string(), ); finding.cwe = Some("CWE-79".to_string()); finding.evidence = vec![evidence]; finding.remediation = Some(format!( "Remove 'data:' from {}. If data URIs are needed, restrict them to \ non-executable content types only (e.g., img-src).", d.name )); findings.push(finding); } } // Check for missing important directives let directive_names: Vec<&str> = directives.iter().map(|d| d.name.as_str()).collect(); let has_default_src = directive_names.contains(&"default-src"); let important_directives = [ ("script-src", "Controls which scripts can execute"), ("object-src", "Controls plugins like Flash"), ("base-uri", "Controls the base URL for relative URLs"), ("form-action", "Controls where forms can submit to"), ( "frame-ancestors", "Controls who can embed this page in iframes", ), ]; for (dir_name, desc) in &important_directives { if !directive_names.contains(dir_name) && (!has_default_src || *dir_name == "frame-ancestors" || *dir_name == "base-uri" || *dir_name == "form-action") { let evidence = make_evidence(format!("CSP missing directive: {dir_name}")); let mut finding = DastFinding::new( String::new(), target_id.to_string(), DastVulnType::CspIssue, format!("CSP missing '{}' directive", dir_name), format!( "The Content-Security-Policy is missing the '{}' directive. {}. \ Without this directive{}, the browser may fall back to less restrictive defaults.", dir_name, desc, if has_default_src && (*dir_name == "frame-ancestors" || *dir_name == "base-uri" || *dir_name == "form-action") { " (not covered by default-src)" } else { "" } ), Severity::Low, url.to_string(), "GET".to_string(), ); finding.cwe = Some("CWE-16".to_string()); finding.evidence = vec![evidence]; finding.remediation = Some(format!( "Add '{}: 'none'' or an appropriate restrictive value to your CSP.", dir_name )); findings.push(finding); } } findings } } #[cfg(test)] mod tests { use super::*; #[test] fn parse_csp_basic() { let directives = CspAnalyzerTool::parse_csp( "default-src 'self'; script-src 'self' https://cdn.example.com", ); assert_eq!(directives.len(), 2); assert_eq!(directives[0].name, "default-src"); assert_eq!(directives[0].values, vec!["'self'"]); assert_eq!(directives[1].name, "script-src"); assert_eq!( directives[1].values, vec!["'self'", "https://cdn.example.com"] ); } #[test] fn parse_csp_empty() { let directives = CspAnalyzerTool::parse_csp(""); assert!(directives.is_empty()); } #[test] fn parse_csp_trailing_semicolons() { let directives = CspAnalyzerTool::parse_csp("default-src 'none';;;"); assert_eq!(directives.len(), 1); assert_eq!(directives[0].name, "default-src"); assert_eq!(directives[0].values, vec!["'none'"]); } #[test] fn parse_csp_directive_without_value() { let directives = CspAnalyzerTool::parse_csp("upgrade-insecure-requests"); assert_eq!(directives.len(), 1); assert_eq!(directives[0].name, "upgrade-insecure-requests"); assert!(directives[0].values.is_empty()); } #[test] fn parse_csp_names_are_lowercased() { let directives = CspAnalyzerTool::parse_csp("Script-Src 'self'"); assert_eq!(directives[0].name, "script-src"); } #[test] fn analyze_detects_unsafe_inline() { let directives = CspAnalyzerTool::parse_csp("script-src 'self' 'unsafe-inline'"); let findings = CspAnalyzerTool::analyze_directives(&directives, "https://example.com", "t1", 200, ""); assert!(findings.iter().any(|f| f.title.contains("unsafe-inline"))); } #[test] fn analyze_detects_unsafe_eval() { let directives = CspAnalyzerTool::parse_csp("script-src 'self' 'unsafe-eval'"); let findings = CspAnalyzerTool::analyze_directives(&directives, "https://example.com", "t1", 200, ""); assert!(findings.iter().any(|f| f.title.contains("unsafe-eval"))); } #[test] fn analyze_detects_wildcard() { let directives = CspAnalyzerTool::parse_csp("img-src *"); let findings = CspAnalyzerTool::analyze_directives(&directives, "https://example.com", "t1", 200, ""); assert!(findings.iter().any(|f| f.title.contains("wildcard"))); } #[test] fn analyze_detects_data_uri_in_script_src() { let directives = CspAnalyzerTool::parse_csp("script-src 'self' data:"); let findings = CspAnalyzerTool::analyze_directives(&directives, "https://example.com", "t1", 200, ""); assert!(findings.iter().any(|f| f.title.contains("data:"))); } #[test] fn analyze_detects_http_sources() { let directives = CspAnalyzerTool::parse_csp("script-src http:"); let findings = CspAnalyzerTool::analyze_directives(&directives, "https://example.com", "t1", 200, ""); assert!(findings.iter().any(|f| f.title.contains("HTTP sources"))); } #[test] fn analyze_detects_missing_directives_without_default_src() { let directives = CspAnalyzerTool::parse_csp("img-src 'self'"); let findings = CspAnalyzerTool::analyze_directives(&directives, "https://example.com", "t1", 200, ""); let missing_names: Vec<&str> = findings .iter() .filter(|f| f.title.contains("missing")) .map(|f| f.title.as_str()) .collect(); // Should flag script-src, object-src, base-uri, form-action, frame-ancestors assert!(missing_names.len() >= 4); } #[test] fn analyze_good_csp_no_unsafe_findings() { let directives = CspAnalyzerTool::parse_csp( "default-src 'none'; script-src 'self'; style-src 'self'; \ img-src 'self'; object-src 'none'; base-uri 'self'; \ form-action 'self'; frame-ancestors 'none'", ); let findings = CspAnalyzerTool::analyze_directives(&directives, "https://example.com", "t1", 200, ""); // A well-configured CSP should not produce unsafe-inline/eval/wildcard findings assert!(findings.iter().all(|f| { !f.title.contains("unsafe-inline") && !f.title.contains("unsafe-eval") && !f.title.contains("wildcard") })); } } impl PentestTool for CspAnalyzerTool { fn name(&self) -> &str { "csp_analyzer" } fn description(&self) -> &str { "Analyzes Content-Security-Policy headers. Checks for unsafe-inline, unsafe-eval, \ wildcard sources, data: URIs in script-src, missing directives, and other CSP weaknesses." } fn input_schema(&self) -> serde_json::Value { json!({ "type": "object", "properties": { "url": { "type": "string", "description": "URL to fetch and analyze CSP from" } }, "required": ["url"] }) } fn execute<'a>( &'a self, input: serde_json::Value, context: &'a PentestToolContext, ) -> std::pin::Pin< Box> + Send + 'a>, > { Box::pin(async move { let url = input .get("url") .and_then(|v| v.as_str()) .ok_or_else(|| CoreError::Dast("Missing required 'url' parameter".to_string()))?; let target_id = context .target .id .map(|oid| oid.to_hex()) .unwrap_or_else(|| "unknown".to_string()); let response = self .http .get(url) .send() .await .map_err(|e| CoreError::Dast(format!("Failed to fetch {url}: {e}")))?; let status = response.status().as_u16(); // Check for CSP header let csp_header = response .headers() .get("content-security-policy") .and_then(|v| v.to_str().ok()) .map(String::from); // Also check for report-only variant let csp_report_only = response .headers() .get("content-security-policy-report-only") .and_then(|v| v.to_str().ok()) .map(String::from); let mut findings = Vec::new(); let mut csp_data = json!({}); match &csp_header { Some(csp) => { let directives = Self::parse_csp(csp); let directive_map: serde_json::Value = directives .iter() .map(|d| (d.name.clone(), json!(d.values))) .collect::>() .into(); csp_data["csp_header"] = json!(csp); csp_data["directives"] = directive_map; findings.extend(Self::analyze_directives( &directives, url, &target_id, status, csp, )); } None => { csp_data["csp_header"] = json!(null); let evidence = DastEvidence { request_method: "GET".to_string(), request_url: url.to_string(), request_headers: None, request_body: None, response_status: status, response_headers: None, response_snippet: Some( "Content-Security-Policy header is missing".to_string(), ), screenshot_path: None, payload: None, response_time_ms: None, }; let mut finding = DastFinding::new( String::new(), target_id.clone(), DastVulnType::CspIssue, "Missing Content-Security-Policy header".to_string(), format!( "No Content-Security-Policy header is present on {url}. \ Without CSP, the browser has no instructions on which sources are \ trusted, making XSS exploitation much easier." ), Severity::Medium, url.to_string(), "GET".to_string(), ); finding.cwe = Some("CWE-16".to_string()); finding.evidence = vec![evidence]; finding.remediation = Some( "Add a Content-Security-Policy header. Start with a restrictive policy like \ \"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; \ object-src 'none'; frame-ancestors 'none'; base-uri 'self'\"." .to_string(), ); findings.push(finding); } } if let Some(ref report_only) = csp_report_only { csp_data["csp_report_only"] = json!(report_only); // If ONLY report-only exists (no enforcing CSP), warn if csp_header.is_none() { let evidence = DastEvidence { request_method: "GET".to_string(), request_url: url.to_string(), request_headers: None, request_body: None, response_status: status, response_headers: None, response_snippet: Some(format!( "Content-Security-Policy-Report-Only: {}", report_only )), screenshot_path: None, payload: None, response_time_ms: None, }; let mut finding = DastFinding::new( String::new(), target_id.clone(), DastVulnType::CspIssue, "CSP is report-only, not enforcing".to_string(), "A Content-Security-Policy-Report-Only header is present but no enforcing \ Content-Security-Policy header exists. Report-only mode only logs violations \ but does not block them." .to_string(), Severity::Low, url.to_string(), "GET".to_string(), ); finding.cwe = Some("CWE-16".to_string()); finding.evidence = vec![evidence]; finding.remediation = Some( "Once you have verified the CSP policy works correctly in report-only mode, \ deploy it as an enforcing Content-Security-Policy header." .to_string(), ); findings.push(finding); } } let count = findings.len(); info!(url, findings = count, "CSP analysis complete"); Ok(PentestToolResult { summary: if count > 0 { format!("Found {count} CSP issues for {url}.") } else { format!("Content-Security-Policy looks good for {url}.") }, findings, data: csp_data, }) }) } }