use crate::detectors::base::{Detector, DetectorConfig};
use crate::graph::GraphQueryExt;
use crate::models::{deterministic_finding_id, Finding, Severity};
use anyhow::Result;
use regex::Regex;
use std::path::PathBuf;
use std::sync::LazyLock;
use tracing::info;
static CORS_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r#"(?i)(Access-Control-Allow-Origin|cors.*origin|allowedOrigins?)\s*[:=]\s*["'*]?\*"#,
)
.expect("valid regex")
});
static CREDENTIALS_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r#"(?i)(credentials|allow.?credentials|with.?credentials)\s*[:=]\s*(true|["']include["'])"#,
)
.expect("valid regex")
});
pub struct CorsMisconfigDetector {
#[allow(dead_code)] repository_path: PathBuf,
max_findings: usize,
}
impl CorsMisconfigDetector {
crate::detectors::detector_new!(50);
fn is_dev_only_path(path: &str) -> bool {
let dev_patterns = [
"/dev/",
"/development/",
"/local/",
"/test/",
"/debug/",
"dev.config",
"development.config",
"local.config",
".dev.",
".local.",
".development.",
"/fixtures/",
"/mocks/",
];
let path_lower = path.to_lowercase();
let normalized = if path_lower.starts_with('/') {
path_lower
} else {
format!("/{}", path_lower)
};
dev_patterns.iter().any(|p| normalized.contains(p))
}
fn involves_sensitive_data(content: &str, surrounding_lines: &[&str]) -> bool {
let sensitive_patterns = [
"auth", "login", "password", "token", "session", "cookie", "payment", "credit",
"billing", "order", "user", "profile", "account", "private", "admin", "delete",
"update", "create", "write",
];
let combined = format!("{}\n{}", content, surrounding_lines.join("\n")).to_lowercase();
sensitive_patterns.iter().any(|p| combined.contains(p))
}
fn allows_credentials(lines: &[&str], cors_line: usize) -> bool {
let start = cors_line.saturating_sub(5);
let end = (cors_line + 5).min(lines.len());
for line in lines.get(start..end).unwrap_or(&[]) {
if CREDENTIALS_PATTERN.is_match(line) {
return true;
}
}
false
}
}
impl Detector for CorsMisconfigDetector {
fn name(&self) -> &'static str {
"cors-misconfig"
}
fn description(&self) -> &'static str {
"Detects overly permissive CORS configuration"
}
fn requires_graph(&self) -> bool {
false
}
fn bypass_postprocessor(&self) -> bool {
true
}
fn file_extensions(&self) -> &'static [&'static str] {
&["py", "js", "ts", "jsx", "tsx", "rb", "php", "java"]
}
fn detect(
&self,
ctx: &crate::detectors::analysis_context::AnalysisContext,
) -> Result<Vec<Finding>> {
let graph = ctx.graph;
let files = &ctx.as_file_provider();
let mut findings = vec![];
for path in files.files_with_extensions(&[
"py", "js", "ts", "java", "go", "rb", "php", "yaml", "yml", "json", "conf",
]) {
if findings.len() >= self.max_findings {
break;
}
let path_str = path.to_string_lossy().to_string();
let raw = match files.content(path) {
Some(c) => c,
None => continue,
};
let raw_lower = raw.to_ascii_lowercase();
if !raw_lower.contains("cors")
&& !raw_lower.contains("access-control")
&& !raw_lower.contains("allowedorigin")
{
continue;
}
let masked = match files.masked_content(path) {
Some(c) => c,
None => continue,
};
{
let raw_lines: Vec<&str> = raw.lines().collect();
let masked_lines: Vec<&str> = masked.lines().collect();
for (i, line) in raw_lines.iter().enumerate() {
let prev_line = if i > 0 { Some(raw_lines[i - 1]) } else { None };
if crate::detectors::is_line_suppressed(line, prev_line) {
continue;
}
if CORS_PATTERN.is_match(line) {
let masked_line = masked_lines.get(i).copied().unwrap_or("");
if masked_line.trim().is_empty() {
continue;
}
let line_num = (i + 1) as u32;
let start = i.saturating_sub(10);
let end = (i + 10).min(raw_lines.len());
let surrounding = &raw_lines[start..end];
let is_dev_only = Self::is_dev_only_path(&path_str);
let has_credentials = Self::allows_credentials(&raw_lines, i);
let is_sensitive = Self::involves_sensitive_data(line, surrounding);
let containing_func =
graph.find_function_at(&path_str, line_num).map(|f| {
f.node_name(crate::graph::interner::global_interner())
.to_string()
});
if is_dev_only {
continue;
}
let severity = if has_credentials {
Severity::Critical } else if is_sensitive {
Severity::High } else {
Severity::Medium };
let mut notes = Vec::new();
if has_credentials {
notes.push("🚨 **CRITICAL**: Also allows credentials! This is a serious vulnerability.".to_string());
}
if is_sensitive {
notes.push(
"⚠️ Appears to handle sensitive data (auth/user/payment)"
.to_string(),
);
}
if let Some(func) = &containing_func {
notes.push(format!("📦 In function: `{}`", func));
}
let context_notes = if notes.is_empty() {
String::new()
} else {
format!("\n\n**Analysis:**\n{}", notes.join("\n"))
};
let suggestion = if has_credentials {
"**CRITICAL FIX REQUIRED:**\n\
CORS: * with credentials is extremely dangerous and allows any website to:\n\
- Read authenticated responses\n\
- Perform actions as the user\n\n\
Options:\n\
1. Specify exact allowed origins\n\
2. Validate Origin header against allowlist\n\
3. Use dynamic origin reflection (carefully validated)".to_string()
} else {
"Specify allowed origins explicitly:\n\
```javascript\n\
// Instead of: Access-Control-Allow-Origin: *\n\
// Use:\n\
const allowedOrigins = ['https://app.example.com', 'https://admin.example.com'];\n\
if (allowedOrigins.includes(request.origin)) {\n\
response.setHeader('Access-Control-Allow-Origin', request.origin);\n\
}\n\
```".to_string()
};
findings.push(Finding {
id: String::new(),
detector: "CorsMisconfigDetector".to_string(),
severity,
title: if has_credentials {
"CRITICAL: Wildcard CORS with credentials".to_string()
} else {
"Overly permissive CORS (*)".to_string()
},
description: format!(
"Access-Control-Allow-Origin: * allows any website to make cross-origin requests.{}",
context_notes
),
affected_files: vec![path.to_path_buf()],
line_start: Some(line_num),
line_end: Some(line_num),
suggested_fix: Some(suggestion),
estimated_effort: Some("15 minutes".to_string()),
category: Some("security".to_string()),
cwe_id: Some("CWE-942".to_string()),
why_it_matters: Some(
"Wildcard CORS allows any website to make requests to your API. \
Combined with credentials, attackers can steal user data or perform \
actions on behalf of logged-in users.".to_string()
),
..Default::default()
});
}
}
}
}
info!(
"CorsMisconfigDetector found {} findings (graph-aware)",
findings.len()
);
Ok(findings)
}
}
impl crate::detectors::RegisteredDetector for CorsMisconfigDetector {
fn create(init: &crate::detectors::DetectorInit) -> std::sync::Arc<dyn Detector> {
std::sync::Arc::new(Self::new(init.repo_path))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::builder::GraphBuilder;
#[test]
fn test_detects_wildcard_cors() {
let store = GraphBuilder::new().freeze();
let detector = CorsMisconfigDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("config.conf", "Access-Control-Allow-Origin: *\n")],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(!findings.is_empty(), "Should detect wildcard CORS");
assert!(
findings.iter().any(|f| f.title.contains("CORS")),
"Finding should mention CORS. Titles: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_no_finding_for_specific_origin() {
let store = GraphBuilder::new().freeze();
let detector = CorsMisconfigDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(&store, vec![
("server.js", "const express = require('express');\nconst app = express();\napp.use((req, res, next) => {\n res.setHeader('Access-Control-Allow-Origin', 'https://myapp.example.com');\n next();\n});\n"),
]);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
findings.is_empty(),
"Should not detect CORS with specific origin. Found: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
}