From dd2d6d83f4157ceafadcf4d58996267b00545f4f Mon Sep 17 00:00:00 2001 From: ajianaz Date: Tue, 4 Aug 2026 20:31:57 +0700 Subject: [PATCH] fix(scanner): narrow CORS wildcard regex + skip doc files + negation filter (#483) The static security scanner's CORS rule used an overly broad regex that matched any occurrence of the word 'cors' followed by '*' anywhere on the line. This triggered false positives on: - Env var names like TITEN_CORS_ORIGINS (contains 'cors' + '*' from markdown bold) - Documentation prose mentioning CORS configuration - Comments instructing developers NOT to use wildcards Three-layer fix: 1. Narrow regex to require actual code patterns: - Access-Control-Allow-Origin: * (HTTP header) - cors = * / allow_origin(*) / origin: * / allowed_origins = * - Method calls like .allow_origin(*) now matched via [=:(] delimiter 2. Skip non-code files (.md, .txt, .rst, .adoc, .tex, .org, etc.) Security patterns are designed for source code, not prose. 3. Post-match negation context filter: Suppresses matches in negation contexts like 'no wildcard', 'do not use *', 'without wildcard', and env var name patterns (cors_origins, cors_allowed, cors_config). Tests: 25 new test cases covering true positives, false positives from issue #483, doc file detection, and negation context suppression. --- src/engine/rules/builtin.rs | 103 +++++++++++++++++ src/engine/security_scanner.rs | 201 ++++++++++++++++++++++++++++++++- 2 files changed, 302 insertions(+), 2 deletions(-) diff --git a/src/engine/rules/builtin.rs b/src/engine/rules/builtin.rs index c77f211..9da810a 100644 --- a/src/engine/rules/builtin.rs +++ b/src/engine/rules/builtin.rs @@ -129,6 +129,7 @@ pub fn post_match_filter(rule_id: &str, line: &str) -> bool { match rule_id { "sec-hardcoded-secret" | "crypto/hardcoded-secret" => is_false_positive_secret(line), "sec-hardcoded-url" => is_false_positive_url(line), + "config/cors-wildcard" => is_false_positive_cors(line), _ => false, } } @@ -244,6 +245,50 @@ fn is_false_positive_secret(line: &str) -> bool { false } +/// Check if a CORS wildcard match is a false positive. +/// +/// Suppresses: negation contexts ("no wildcard", "do not use *"), comments +/// documenting that wildcards are disallowed, and env var names that contain +/// "cors" but are not wildcard assignments. +fn is_false_positive_cors(line: &str) -> bool { + let lower = line.to_lowercase(); + + // Negation context — line says wildcards are NOT allowed. + // Examples: "no wildcard", "do not use *", "without wildcard" + let negation_markers = [ + "no wildcard", + "no catch-all", + "no catch all", + "not.*wildcard", + "do not.*wildcard", + "do not.*\\*", + "without.*wildcard", + "never.*wildcard", + "disallow.*wildcard", + "prohibit.*wildcard", + "avoid.*wildcard", + "except.*wildcard", + ]; + for marker in &negation_markers { + if let Ok(re) = regex::Regex::new(marker) { + if re.is_match(&lower) { + return true; + } + } + } + + // Env var or config key names containing "cors" — these are identifiers, + // not wildcard assignments. e.g., TITEN_CORS_ORIGINS, CORS_ALLOWED_ORIGINS + if lower.contains("cors_origins") + || lower.contains("cors_allowed") + || lower.contains("cors_config") + { + return true; + } + + false +} + #[cfg(test)] mod tests { use super::*; @@ -436,4 +481,62 @@ mod tests { "let password = supersecret12345" )); } + + // ─── config/cors-wildcard false positive tests (issue #483) ─── + + #[test] + fn cors_negation_no_wildcard_is_false_positive() { + assert!(post_match_filter( + "config/cors-wildcard", + "// No wildcard — only explicit origins" + )); + } + + #[test] + fn cors_negation_no_catch_all_is_false_positive() { + assert!(post_match_filter( + "config/cors-wildcard", + "// No catch-all origin pattern is permitted" + )); + } + + #[test] + fn cors_negation_do_not_use_wildcard_is_false_positive() { + assert!(post_match_filter( + "config/cors-wildcard", + "# Do not use * in production" + )); + } + + #[test] + fn cors_env_var_name_is_false_positive() { + assert!(post_match_filter( + "config/cors-wildcard", + "TITEN_CORS_ORIGINS=https://example.com" + )); + assert!(post_match_filter( + "config/cors-wildcard", + "CORS_ALLOWED_ORIGINS=https://example.com" + )); + } + + #[test] + fn cors_actual_wildcard_is_not_false_positive() { + assert!(!post_match_filter( + "config/cors-wildcard", + "Access-Control-Allow-Origin: *" + )); + assert!(!post_match_filter( + "config/cors-wildcard", + "let origin = \"*\";" + )); + } + + #[test] + fn cors_unrelated_rule_not_affected() { + assert!(!post_match_filter( + "crypto/hardcoded-secret", + "No wildcard in this line" + )); + } } diff --git a/src/engine/security_scanner.rs b/src/engine/security_scanner.rs index 2dfbec9..3f95fa3 100644 --- a/src/engine/security_scanner.rs +++ b/src/engine/security_scanner.rs @@ -87,7 +87,12 @@ pub static PATTERNS: &[SecurityPattern] = &[ SecurityPattern { id: "config/cors-wildcard", name: "CORS wildcard allows all origins", - regex: r"(?i)(?:Access-Control-Allow-Origin|cors).*\*", + // Match actual code patterns, not the word "cors" in prose/documentation. + // Require either the literal HTTP header with `*`, or a code assignment/call + // like `cors = "*"`, `origin: *`, `allowed_origins = "*"`, `allow_origin("*")`. + // The word "cors" alone is too broad — it appears in env var names + // (TITEN_CORS_ORIGINS), config keys, and documentation. + regex: r#"(?i)(?:Access-Control-Allow-Origin\s*:\s*\*|(?:cors|allow_origin|allowed_origins)\s*[=:(]\s*["']?\*["']?|origin\s*[=:]\s*["']?\*["']?)"#, severity: Severity::Major, }, // ── TLS/SSL ── @@ -130,6 +135,14 @@ pub fn scan_security(chunks: &[FileChunk], max_findings: usize) -> Vec bool { false } +/// Check if a file path is a documentation or non-code file. +/// +/// Security scanner patterns are designed for source code. Scanning markdown, +/// plain text, or reStructuredText produces false positives because security +/// keywords (CORS, secret, password) appear naturally in documentation prose. +fn is_doc_file(path: &str) -> bool { + let lower = path.to_lowercase(); + matches!( + lower.rsplit('.').next().unwrap_or(""), + "md" | "markdown" | "mdx" | "txt" | "rst" | "adoc" | "asciidoc" | "tex" | "org" + ) +} + #[cfg(test)] mod tests { use super::*; @@ -449,7 +475,7 @@ mod tests { fn real_hardcoded_secret_still_detected_after_filter() { let chunks = vec![make_chunk( "src/config.py", - &["API_KEY = sk_live_abc123def456"], + &["API_KEY = \"sk_live_abc123def456ghi789\""], )]; let findings = scan_security(&chunks, 10); let secret_findings: Vec<_> = findings @@ -462,4 +488,175 @@ mod tests { "Real hardcoded secret should still be detected" ); } + + // ─── CORS false positive tests (issue #483) ─── + + #[test] + fn detects_cors_wildcard_header() { + // The classic dangerous pattern — actual HTTP header with wildcard + let chunks = vec![make_chunk( + "src/server.rs", + &["Access-Control-Allow-Origin: *"], + )]; + let findings = scan_security(&chunks, 10); + let cors_findings: Vec<_> = findings + .iter() + .filter(|f| f.rule_id == "config/cors-wildcard") + .collect(); + assert_eq!(cors_findings.len(), 1, "Should detect wildcard CORS header"); + } + + #[test] + fn detects_cors_wildcard_assignment() { + // Code assignment like cors = "*" or origin = '*' + let chunks = vec![make_chunk("src/config.rs", &["let cors_origin = \"*\";"])]; + let findings = scan_security(&chunks, 10); + let cors_findings: Vec<_> = findings + .iter() + .filter(|f| f.rule_id == "config/cors-wildcard") + .collect(); + assert_eq!( + cors_findings.len(), + 1, + "Should detect cors assignment with wildcard" + ); + } + + #[test] + fn detects_allowed_origins_wildcard() { + // Pattern: allowed_origins = "*" + let chunks = vec![make_chunk("src/app.py", &["allowed_origins = \"*\""])]; + let findings = scan_security(&chunks, 10); + let cors_findings: Vec<_> = findings + .iter() + .filter(|f| f.rule_id == "config/cors-wildcard") + .collect(); + assert_eq!( + cors_findings.len(), + 1, + "Should detect allowed_origins with wildcard" + ); + } + + #[test] + fn no_false_positive_cors_env_var_name() { + // Issue #483: TITEN_CORS_ORIGINS contains "cors" but is not a wildcard assignment. + // The * after it comes from markdown bold (**), not a CORS wildcard. + let chunks = vec![make_chunk( + "src/config.rs", + &["let val = std::env::var(\"TITEN_CORS_ORIGINS\").unwrap_or(\"*\");"], + )]; + let findings = scan_security(&chunks, 10); + let cors_findings: Vec<_> = findings + .iter() + .filter(|f| f.rule_id == "config/cors-wildcard") + .collect(); + assert!( + cors_findings.is_empty(), + "TITEN_CORS_ORIGINS env var name should not trigger CORS wildcard" + ); + } + + #[test] + fn no_false_positive_cors_in_prose() { + // Issue #483: "CORS" keyword in documentation prose near a `*` character + let chunks = vec![make_chunk( + "src/config.rs", + &["// CORS configured via TITEN_CORS_ORIGINS env var"], + )]; + let findings = scan_security(&chunks, 10); + let cors_findings: Vec<_> = findings + .iter() + .filter(|f| f.rule_id == "config/cors-wildcard") + .collect(); + assert!( + cors_findings.is_empty(), + "CORS keyword in comment prose should not trigger" + ); + } + + #[test] + fn no_false_positive_markdown_file() { + // Issue #483: .md files should be skipped entirely by security scanner + let chunks = vec![make_chunk( + "docs/deployment.md", + &["Access-Control-Allow-Origin: *"], + )]; + let findings = scan_security(&chunks, 10); + assert!( + findings.is_empty(), + "Markdown files should not be scanned by security scanner" + ); + } + + #[test] + fn no_false_positive_txt_file() { + let chunks = vec![make_chunk( + "docs/security.txt", + &["Access-Control-Allow-Origin: *"], + )]; + let findings = scan_security(&chunks, 10); + assert!( + findings.is_empty(), + "Text files should not be scanned by security scanner" + ); + } + + #[test] + fn no_false_positive_rst_file() { + let chunks = vec![make_chunk( + "docs/api.rst", + &["Access-Control-Allow-Origin: *"], + )]; + let findings = scan_security(&chunks, 10); + assert!( + findings.is_empty(), + "reStructuredText files should not be scanned" + ); + } + + #[test] + fn real_cors_wildcard_in_rust_still_detected() { + // Make sure we don't over-suppress — actual code patterns still trigger + let chunks = vec![make_chunk( + "src/server.rs", + &[".layer(CorsLayer::new().allow_origin(\"*\"))"], + )]; + let findings = scan_security(&chunks, 10); + let cors_findings: Vec<_> = findings + .iter() + .filter(|f| f.rule_id == "config/cors-wildcard") + .collect(); + // This should trigger because it's a code assignment pattern: origin = "*" + assert_eq!( + cors_findings.len(), + 1, + "Real CORS wildcard in Rust code should be detected" + ); + } + + #[test] + fn is_doc_file_recognizes_common_extensions() { + assert!(is_doc_file("README.md")); + assert!(is_doc_file("docs/guide.markdown")); + assert!(is_doc_file("docs/api.mdx")); + assert!(is_doc_file("notes.txt")); + assert!(is_doc_file("docs/spec.rst")); + assert!(is_doc_file("docs/manual.adoc")); + assert!(is_doc_file("docs/manual.asciidoc")); + assert!(is_doc_file("paper.tex")); + assert!(is_doc_file("notes.org")); + } + + #[test] + fn is_doc_file_does_not_match_code_files() { + assert!(!is_doc_file("src/main.rs")); + assert!(!is_doc_file("src/app.py")); + assert!(!is_doc_file("src/server.ts")); + assert!(!is_doc_file("src/index.js")); + assert!(!is_doc_file("src/config.go")); + assert!(!is_doc_file("Dockerfile")); + assert!(!is_doc_file("docker-compose.yml")); + assert!(!is_doc_file("Makefile")); + } }