//! Missing security headers extractor. //! //! Detects patterns where security headers are explicitly disabled or //! configured insecurely. use regex::Regex; use stemedb_core::types::ObjectValue; use super::traits::{build_claim, Extractor}; use crate::types::{Observation, Language}; /// Extractor for missing or disabled security headers. /// /// Detects patterns indicating insecure header configurations: /// - X-Frame-Options disabled or set to ALLOWALL /// - X-Content-Type-Options disabled /// - X-XSS-Protection disabled /// - HSTS disabled /// - Content-Security-Policy disabled pub struct SecurityHeadersExtractor { // Explicit header disabled header_disabled: Regex, // Django missing secure settings django_missing: Regex, // YAML headers disabled yaml_disabled: Regex, // Frame options ALLOWALL frame_allowall: Regex, // CSP disabled or unsafe csp_unsafe: Regex, // HSTS disabled hsts_disabled: Regex, } impl Default for SecurityHeadersExtractor { fn default() -> Self { Self::new() } } impl SecurityHeadersExtractor { /// Create a new security headers extractor with compiled regexes. /// /// # Panics /// Panics if any regex pattern is invalid (programmer error). #[allow(clippy::expect_used)] pub fn new() -> Self { Self { // Explicit header disabled in various formats header_disabled: Regex::new( r#"(?i)(?:X-Frame-Options|X-Content-Type-Options|X-XSS-Protection)\s*[:=]\s*["']?(?:none|disabled?|false|off)["']?"#, ) .expect("valid regex"), // Django missing secure settings django_missing: Regex::new( r#"(?i)SECURE_(?:BROWSER_XSS_FILTER|CONTENT_TYPE_NOSNIFF|HSTS_SECONDS|SSL_REDIRECT)\s*=\s*(?:False|0)"#, ) .expect("valid regex"), // YAML headers disabled yaml_disabled: Regex::new( r#"(?i)(?:x_frame_options|xss_protection|content_type_nosniff|hsts)\s*:\s*(?:false|no|disabled?|off)"#, ) .expect("valid regex"), // Frame options ALLOWALL (dangerous) frame_allowall: Regex::new(r#"(?i)X-Frame-Options\s*[:=]\s*["']?ALLOWALL"#) .expect("valid regex"), // CSP disabled or using unsafe-inline/unsafe-eval csp_unsafe: Regex::new( r#"(?i)(?:Content-Security-Policy|CSP)\s*[:=]\s*["']?(?:none|disabled?|.*unsafe-(?:inline|eval))"#, ) .expect("valid regex"), // HSTS disabled or set to 0 hsts_disabled: Regex::new( r#"(?i)(?:Strict-Transport-Security|HSTS|hsts_seconds)\s*[:=]\s*(?:["']?(?:none|disabled?|false|off)["']?|0)"#, ) .expect("valid regex"), } } fn make_claim( path_segments: &[String], file: &str, line: usize, matched: &str, header: &str, description: &str, ) -> Observation { build_claim( path_segments, &["http", "security_headers", header], "header_status", ObjectValue::Text("disabled".to_string()), file, line, matched, 0.8, description, ) } } impl Extractor for SecurityHeadersExtractor { fn name(&self) -> &str { "security_headers" } fn languages(&self) -> &[Language] { &[ Language::Python, Language::JavaScript, Language::TypeScript, Language::Go, Language::Yaml, Language::Json, Language::Toml, ] } fn extract( &self, path_segments: &[String], content: &str, _language: Language, file: &str, ) -> Vec { let mut claims = Vec::new(); for (line_idx, line) in content.lines().enumerate() { let line_num = line_idx + 1; // Check for explicitly disabled headers if let Some(m) = self.header_disabled.find(line) { let header = if line.to_lowercase().contains("frame") { "x_frame_options" } else if line.to_lowercase().contains("content-type") { "x_content_type_options" } else { "x_xss_protection" }; claims.push(Self::make_claim( path_segments, file, line_num, m.as_str(), header, "Security header explicitly disabled", )); } // Check Django secure settings if let Some(m) = self.django_missing.find(line) { let header = if line.to_lowercase().contains("xss") { "x_xss_protection" } else if line.to_lowercase().contains("nosniff") { "x_content_type_options" } else if line.to_lowercase().contains("hsts") { "hsts" } else { "ssl_redirect" }; claims.push(Self::make_claim( path_segments, file, line_num, m.as_str(), header, "Django security setting disabled", )); } // Check YAML disabled patterns if let Some(m) = self.yaml_disabled.find(line) { let header = if line.to_lowercase().contains("frame") { "x_frame_options" } else if line.to_lowercase().contains("xss") { "x_xss_protection" } else if line.to_lowercase().contains("nosniff") { "x_content_type_options" } else { "hsts" }; claims.push(Self::make_claim( path_segments, file, line_num, m.as_str(), header, "Security header disabled in configuration", )); } // Check for dangerous X-Frame-Options ALLOWALL if let Some(m) = self.frame_allowall.find(line) { claims.push(Self::make_claim( path_segments, file, line_num, m.as_str(), "x_frame_options", "X-Frame-Options set to ALLOWALL (clickjacking risk)", )); } // Check for CSP unsafe patterns if let Some(m) = self.csp_unsafe.find(line) { claims.push(Self::make_claim( path_segments, file, line_num, m.as_str(), "content_security_policy", "Content-Security-Policy disabled or uses unsafe directives", )); } // Check for HSTS disabled if let Some(m) = self.hsts_disabled.find(line) { claims.push(Self::make_claim( path_segments, file, line_num, m.as_str(), "hsts", "Strict-Transport-Security (HSTS) disabled", )); } } claims } fn verifiable_predicates(&self) -> Vec<(&str, &str)> { vec![ ("security_headers/x_frame_options", "header_status"), ("security_headers/x_content_type_options", "header_status"), ("security_headers/x_xss_protection", "header_status"), ("security_headers/hsts", "header_status"), ("security_headers/ssl_redirect", "header_status"), ("security_headers/content_security_policy", "header_status"), ] } } #[cfg(test)] mod tests { use super::*; #[test] fn test_header_disabled() { let extractor = SecurityHeadersExtractor::new(); let content = r#" X-Frame-Options: none "#; let claims = extractor.extract(&["config".to_string()], content, Language::Yaml, "nginx.conf"); assert_eq!(claims.len(), 1); assert!(claims[0].concept_path.contains("security_headers")); } #[test] fn test_django_missing() { let extractor = SecurityHeadersExtractor::new(); let content = r#" SECURE_BROWSER_XSS_FILTER = False "#; let claims = extractor.extract(&["python".to_string()], content, Language::Python, "settings.py"); assert_eq!(claims.len(), 1); assert!(claims[0].description.contains("Django")); } #[test] fn test_yaml_disabled() { let extractor = SecurityHeadersExtractor::new(); let content = r#" x_frame_options: false "#; let claims = extractor.extract(&["config".to_string()], content, Language::Yaml, "config.yaml"); assert_eq!(claims.len(), 1); } #[test] fn test_frame_allowall() { let extractor = SecurityHeadersExtractor::new(); let content = r#" X-Frame-Options = "ALLOWALL" "#; let claims = extractor.extract(&["config".to_string()], content, Language::Toml, "config.toml"); assert_eq!(claims.len(), 1); assert!(claims[0].description.contains("clickjacking")); } #[test] fn test_csp_unsafe() { let extractor = SecurityHeadersExtractor::new(); let content = r#" Content-Security-Policy: script-src 'unsafe-inline' "#; let claims = extractor.extract(&["config".to_string()], content, Language::Yaml, "config.yaml"); assert_eq!(claims.len(), 1); assert!(claims[0].concept_path.contains("content_security_policy")); } #[test] fn test_hsts_disabled() { let extractor = SecurityHeadersExtractor::new(); let content = r#" Strict-Transport-Security: none "#; let claims = extractor.extract(&["config".to_string()], content, Language::Yaml, "config.yaml"); assert_eq!(claims.len(), 1); assert!(claims[0].concept_path.contains("hsts")); } #[test] fn test_hsts_zero() { let extractor = SecurityHeadersExtractor::new(); let content = r#" HSTS_SECONDS = 0 "#; let claims = extractor.extract(&["python".to_string()], content, Language::Python, "settings.py"); // Should detect hsts_disabled pattern (HSTS = 0) assert_eq!(claims.len(), 1); } #[test] fn test_no_false_positives_enabled() { let extractor = SecurityHeadersExtractor::new(); // Safe: headers enabled let content = r#" X-Frame-Options: SAMEORIGIN X-Content-Type-Options: nosniff SECURE_BROWSER_XSS_FILTER = True "#; let claims = extractor.extract(&["config".to_string()], content, Language::Yaml, "config.yaml"); assert!(claims.is_empty()); } }