use std::collections::HashMap; 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 checks for the presence and correctness of security headers. pub struct SecurityHeadersTool { http: reqwest::Client, } /// A security header we expect to be present and its metadata. struct ExpectedHeader { name: &'static str, description: &'static str, severity: Severity, cwe: &'static str, remediation: &'static str, /// If present, the value must contain one of these substrings to be considered valid. valid_values: Option>, } impl SecurityHeadersTool { pub fn new(http: reqwest::Client) -> Self { Self { http } } fn expected_headers() -> Vec { vec![ ExpectedHeader { name: "strict-transport-security", description: "HTTP Strict Transport Security (HSTS) forces browsers to use HTTPS", severity: Severity::Medium, cwe: "CWE-319", remediation: "Add 'Strict-Transport-Security: max-age=31536000; includeSubDomains' header.", valid_values: None, }, ExpectedHeader { name: "x-content-type-options", description: "Prevents MIME type sniffing", severity: Severity::Low, cwe: "CWE-16", remediation: "Add 'X-Content-Type-Options: nosniff' header.", valid_values: Some(vec!["nosniff"]), }, ExpectedHeader { name: "x-frame-options", description: "Prevents clickjacking by controlling iframe embedding", severity: Severity::Medium, cwe: "CWE-1021", remediation: "Add 'X-Frame-Options: DENY' or 'X-Frame-Options: SAMEORIGIN' header.", valid_values: Some(vec!["deny", "sameorigin"]), }, ExpectedHeader { name: "x-xss-protection", description: "Enables browser XSS filtering (legacy but still recommended)", severity: Severity::Low, cwe: "CWE-79", remediation: "Add 'X-XSS-Protection: 1; mode=block' header.", valid_values: None, }, ExpectedHeader { name: "referrer-policy", description: "Controls how much referrer information is shared", severity: Severity::Low, cwe: "CWE-200", remediation: "Add 'Referrer-Policy: strict-origin-when-cross-origin' or 'no-referrer' header.", valid_values: None, }, ExpectedHeader { name: "permissions-policy", description: "Controls browser feature access (camera, microphone, geolocation, etc.)", severity: Severity::Low, cwe: "CWE-16", remediation: "Add a Permissions-Policy header to restrict browser feature access. \ Example: 'Permissions-Policy: camera=(), microphone=(), geolocation=()'.", valid_values: None, }, ] } } impl PentestTool for SecurityHeadersTool { fn name(&self) -> &str { "security_headers" } fn description(&self) -> &str { "Checks a URL for the presence and correctness of security headers: HSTS, \ X-Content-Type-Options, X-Frame-Options, X-XSS-Protection, Referrer-Policy, \ and Permissions-Policy." } fn input_schema(&self) -> serde_json::Value { json!({ "type": "object", "properties": { "url": { "type": "string", "description": "URL to check security headers for" } }, "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(); let response_headers: HashMap = response .headers() .iter() .map(|(k, v)| { ( k.to_string().to_lowercase(), v.to_str().unwrap_or("").to_string(), ) }) .collect(); let mut findings = Vec::new(); let mut header_results: HashMap = HashMap::new(); for expected in Self::expected_headers() { let header_value = response_headers.get(expected.name); match header_value { Some(value) => { let mut is_valid = true; if let Some(ref valid) = expected.valid_values { let lower = value.to_lowercase(); is_valid = valid.iter().any(|v| lower.contains(v)); } header_results.insert( expected.name.to_string(), json!({ "present": true, "value": value, "valid": is_valid, }), ); if !is_valid { let evidence = DastEvidence { request_method: "GET".to_string(), request_url: url.to_string(), request_headers: None, request_body: None, response_status: status, response_headers: Some(response_headers.clone()), response_snippet: Some(format!("{}: {}", expected.name, value)), screenshot_path: None, payload: None, response_time_ms: None, }; let mut finding = DastFinding::new( String::new(), target_id.clone(), DastVulnType::SecurityHeaderMissing, format!("Invalid {} header value", expected.name), format!( "The {} header is present but has an invalid or weak value: '{}'. \ {}", expected.name, value, expected.description ), expected.severity.clone(), url.to_string(), "GET".to_string(), ); finding.cwe = Some(expected.cwe.to_string()); finding.evidence = vec![evidence]; finding.remediation = Some(expected.remediation.to_string()); findings.push(finding); } } None => { header_results.insert( expected.name.to_string(), json!({ "present": false, "value": null, "valid": false, }), ); let evidence = DastEvidence { request_method: "GET".to_string(), request_url: url.to_string(), request_headers: None, request_body: None, response_status: status, response_headers: Some(response_headers.clone()), response_snippet: Some(format!("{} header is missing", expected.name)), screenshot_path: None, payload: None, response_time_ms: None, }; let mut finding = DastFinding::new( String::new(), target_id.clone(), DastVulnType::SecurityHeaderMissing, format!("Missing {} header", expected.name), format!( "The {} header is not present in the response. {}", expected.name, expected.description ), expected.severity.clone(), url.to_string(), "GET".to_string(), ); finding.cwe = Some(expected.cwe.to_string()); finding.evidence = vec![evidence]; finding.remediation = Some(expected.remediation.to_string()); findings.push(finding); } } } // Also check for information disclosure headers let disclosure_headers = [ "server", "x-powered-by", "x-aspnet-version", "x-aspnetmvc-version", ]; for h in &disclosure_headers { if let Some(value) = response_headers.get(*h) { header_results.insert( format!("{h}_disclosure"), json!({ "present": true, "value": value }), ); let evidence = DastEvidence { request_method: "GET".to_string(), request_url: url.to_string(), request_headers: None, request_body: None, response_status: status, response_headers: Some(response_headers.clone()), response_snippet: Some(format!("{h}: {value}")), screenshot_path: None, payload: None, response_time_ms: None, }; let mut finding = DastFinding::new( String::new(), target_id.clone(), DastVulnType::SecurityHeaderMissing, format!("Information disclosure via {h} header"), format!( "The {h} header exposes server technology information: '{value}'. \ This helps attackers fingerprint the server and find known vulnerabilities." ), Severity::Info, url.to_string(), "GET".to_string(), ); finding.cwe = Some("CWE-200".to_string()); finding.evidence = vec![evidence]; finding.remediation = Some(format!( "Remove or suppress the {h} header in your server configuration." )); findings.push(finding); } } let count = findings.len(); info!(url, findings = count, "Security headers check complete"); Ok(PentestToolResult { summary: if count > 0 { format!("Found {count} security header issues for {url}.") } else { format!("All checked security headers are present and valid for {url}.") }, findings, data: json!(header_results), }) }) } }