Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions src/engine/rules/builtin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -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::*;
Expand Down Expand Up @@ -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"
));
}
}
201 changes: 199 additions & 2 deletions src/engine/security_scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──
Expand Down Expand Up @@ -130,6 +135,14 @@ pub fn scan_security(chunks: &[FileChunk], max_findings: usize) -> Vec<RuleFindi
continue;
}

// Skip documentation and non-code files — security patterns are designed
// for source code, not prose. Prevents false positives like flagging
// "CORS" in a markdown config guide (#483).
if is_doc_file(path) {
debug!(file = path, "skipping documentation file in security scan");
continue;
}

for hunk in &chunk.chunks {
for line in &hunk.lines {
if line.line_type != DiffLineType::Add {
Expand Down Expand Up @@ -216,6 +229,19 @@ fn is_test_file(path: &str) -> 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::*;
Expand Down Expand Up @@ -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
Expand All @@ -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"));
}
}
Loading