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 } } 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> + 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, }) }) } }