stemedb/applications/aphoria/src/extractors/security_headers.rs
jml 3b5f88b4f0 feat(aphoria): implement claims architecture (A1-A5) with verify engine, corpus, coverage, and explain
Complete Aphoria claims system overhaul:
- A1: Rename ExtractedClaim to Observation (extractors produce observations, not claims)
- A2: Add AuthoredClaim with full provenance, invariants, and authority tiers
- A3: Verify engine comparing observations against authored claims, CLI + formatters
- A4: Corpus as first-class assertions with predicate indexing, authority lens, trust packs
- A5: Coverage analysis, explain/docs generation, self-audit extractor, claim suggester skill

Also includes: 42 extractors updated for Observation type, verifiable_predicates trait,
conflict detection with comparison modes, claims TOML persistence, Grafana dashboard,
backup/restore scripts, and comprehensive test coverage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 09:11:47 +00:00

371 lines
11 KiB
Rust

//! 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<Observation> {
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());
}
}