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>
371 lines
11 KiB
Rust
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());
|
|
}
|
|
}
|