use crate::detectors::base::{Detector, DetectorConfig};
use crate::graph::GraphStore;
use crate::models::{deterministic_finding_id, Finding, Severity};
use anyhow::Result;
use regex::Regex;
use std::path::PathBuf;
use std::sync::OnceLock;
use tracing::info;
static EXPRESS_APP: OnceLock<Regex> = OnceLock::new();
static ROUTE_HANDLER: OnceLock<Regex> = OnceLock::new();
fn express_app() -> &'static Regex {
EXPRESS_APP.get_or_init(|| {
Regex::new(r#"express\(\)|require\(["']express["']\)|from ['"]express['"']"#).expect("valid regex")
})
}
fn route_handler() -> &'static Regex {
ROUTE_HANDLER.get_or_init(|| Regex::new(r"\.(get|post|put|delete|patch|all|use)\s*\(").expect("valid regex"))
}
struct SecurityFeatures {
has_helmet: bool,
has_cors: bool,
has_rate_limit: bool,
has_body_parser_limit: bool,
has_hpp: bool,
has_csrf: bool,
has_compression: bool,
route_count: usize,
auth_middleware_count: usize,
}
impl SecurityFeatures {
fn from_content(content: &str) -> Self {
let lower = content.to_lowercase();
Self {
has_helmet: content.contains("helmet"),
has_cors: content.contains("cors(") || content.contains("cors."),
has_rate_limit: lower.contains("ratelimit")
|| lower.contains("rate-limit")
|| lower.contains("express-rate"),
has_body_parser_limit: content.contains("limit:")
|| content.contains("bodyParser") && content.contains("limit"),
has_hpp: content.contains("hpp"),
has_csrf: lower.contains("csrf") || lower.contains("csurf"),
has_compression: content.contains("compression"),
route_count: route_handler().find_iter(content).count(),
auth_middleware_count: Self::count_auth_middleware(content),
}
}
fn count_auth_middleware(content: &str) -> usize {
let auth_patterns = [
"passport.",
"jwt.",
"jsonwebtoken",
"express-jwt",
"isAuthenticated",
"requireAuth",
"authenticate",
"authorize",
"checkAuth",
"verifyToken",
];
auth_patterns
.iter()
.filter(|p| content.contains(*p))
.count()
}
fn security_score(&self) -> f64 {
let mut score = 0.0;
let mut max_score = 0.0;
max_score += 25.0;
if self.has_helmet {
score += 25.0;
}
max_score += 20.0;
if self.has_rate_limit {
score += 20.0;
}
max_score += 15.0;
if self.has_body_parser_limit {
score += 15.0;
}
max_score += 10.0;
if self.has_cors {
score += 10.0;
}
max_score += 10.0;
if self.has_csrf {
score += 10.0;
}
max_score += 20.0;
if self.auth_middleware_count > 0 {
score += 20.0;
}
(score / max_score) * 100.0
}
}
pub struct ExpressSecurityDetector {
repository_path: PathBuf,
max_findings: usize,
}
impl ExpressSecurityDetector {
pub fn new(repository_path: impl Into<PathBuf>) -> Self {
Self {
repository_path: repository_path.into(),
max_findings: 50,
}
}
fn find_containing_function(
graph: &dyn crate::graph::GraphQuery,
file_path: &str,
line: u32,
) -> Option<(String, usize)> {
graph
.get_functions()
.into_iter()
.find(|f| f.file_path == file_path && f.line_start <= line && f.line_end >= line)
.map(|f| (f.name, graph.get_callers(&f.qualified_name).len()))
}
}
impl Detector for ExpressSecurityDetector {
fn name(&self) -> &'static str {
"express-security"
}
fn description(&self) -> &'static str {
"Detects Express.js security issues"
}
fn detect(&self, _graph: &dyn crate::graph::GraphQuery) -> Result<Vec<Finding>> {
let mut findings = vec![];
let walker = ignore::WalkBuilder::new(&self.repository_path)
.hidden(false)
.git_ignore(true)
.build();
for entry in walker.filter_map(|e| e.ok()) {
if findings.len() >= self.max_findings {
break;
}
let path = entry.path();
if !path.is_file() {
continue;
}
let path_str = path.to_string_lossy().to_string();
if crate::detectors::base::is_test_path(&path_str) {
continue;
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if !matches!(ext, "js" | "ts" | "mjs") {
continue;
}
if let Some(content) = crate::cache::global_cache().get_content(path) {
if !express_app().is_match(&content) {
continue;
}
let features = SecurityFeatures::from_content(&content);
let security_score = features.security_score();
let mut app_notes = Vec::new();
app_notes.push(format!("📊 Security Score: {:.0}%", security_score));
app_notes.push(format!("🛣️ Routes: {}", features.route_count));
let mut missing = Vec::new();
if !features.has_helmet {
missing.push("helmet");
}
if !features.has_rate_limit {
missing.push("rate-limit");
}
if !features.has_body_parser_limit {
missing.push("body-parser-limit");
}
if features.auth_middleware_count == 0 {
missing.push("auth-middleware");
}
if !missing.is_empty() {
app_notes.push(format!("❌ Missing: {}", missing.join(", ")));
}
let context_notes = format!("\n\n**App Analysis:**\n{}", app_notes.join("\n"));
if !features.has_helmet {
let severity = if features.route_count > 5 {
Severity::High } else {
Severity::Medium
};
findings.push(Finding {
id: String::new(),
detector: "ExpressSecurityDetector".to_string(),
severity,
title: "Express app missing helmet".to_string(),
description: format!(
"Helmet sets important security headers to protect against common attacks.{}",
context_notes
),
affected_files: vec![path.to_path_buf()],
line_start: Some(1),
line_end: Some(1),
suggested_fix: Some(
"Install and use helmet:\n\
```bash\n\
npm install helmet\n\
```\n\
```javascript\n\
const helmet = require('helmet');\n\
app.use(helmet());\n\
\n\
// Or with custom config:\n\
app.use(helmet({\n\
contentSecurityPolicy: {\n\
directives: {\n\
defaultSrc: [\"'self'\"],\n\
scriptSrc: [\"'self'\", \"trusted-cdn.com\"],\n\
},\n\
},\n\
}));\n\
```".to_string()
),
estimated_effort: Some("10 minutes".to_string()),
category: Some("security".to_string()),
cwe_id: Some("CWE-693".to_string()),
why_it_matters: Some(
"Without helmet, your app is missing:\n\
• X-Content-Type-Options (prevents MIME sniffing)\n\
• X-Frame-Options (prevents clickjacking)\n\
• X-XSS-Protection (legacy XSS filter)\n\
• Strict-Transport-Security (enforces HTTPS)\n\
• Content-Security-Policy (prevents XSS)".to_string()
),
..Default::default()
});
}
if !features.has_rate_limit {
let severity = if features.route_count > 5 {
Severity::Medium
} else {
Severity::Low
};
findings.push(Finding {
id: String::new(),
detector: "ExpressSecurityDetector".to_string(),
severity,
title: "Express app missing rate limiting".to_string(),
description: format!(
"Rate limiting prevents brute force attacks and DoS.{}",
context_notes
),
affected_files: vec![path.to_path_buf()],
line_start: Some(1),
line_end: Some(1),
suggested_fix: Some(
"Install and use express-rate-limit:\n\
```bash\n\
npm install express-rate-limit\n\
```\n\
```javascript\n\
const rateLimit = require('express-rate-limit');\n\
\n\
const limiter = rateLimit({\n\
windowMs: 15 * 60 * 1000, // 15 minutes\n\
max: 100, // limit each IP to 100 requests per windowMs\n\
message: 'Too many requests, please try again later.',\n\
});\n\
\n\
app.use(limiter);\n\
\n\
// Stricter limits for auth endpoints\n\
const authLimiter = rateLimit({\n\
windowMs: 15 * 60 * 1000,\n\
max: 5,\n\
});\n\
app.use('/api/auth', authLimiter);\n\
```"
.to_string(),
),
estimated_effort: Some("15 minutes".to_string()),
category: Some("security".to_string()),
cwe_id: Some("CWE-770".to_string()),
why_it_matters: Some(
"Without rate limiting, attackers can:\n\
• Brute force passwords\n\
• Scrape data at scale\n\
• DoS your API\n\
• Abuse expensive endpoints"
.to_string(),
),
..Default::default()
});
}
if !features.has_body_parser_limit {
findings.push(Finding {
id: String::new(),
detector: "ExpressSecurityDetector".to_string(),
severity: Severity::Low,
title: "No body size limit configured".to_string(),
description: "Large request bodies can be used for DoS attacks."
.to_string(),
affected_files: vec![path.to_path_buf()],
line_start: Some(1),
line_end: Some(1),
suggested_fix: Some(
"Set body size limits:\n\
```javascript\n\
app.use(express.json({ limit: '10kb' }));\n\
app.use(express.urlencoded({ limit: '10kb', extended: true }));\n\
```"
.to_string(),
),
estimated_effort: Some("5 minutes".to_string()),
category: Some("security".to_string()),
cwe_id: Some("CWE-400".to_string()),
why_it_matters: Some(
"Large payloads can exhaust server memory.".to_string(),
),
..Default::default()
});
}
let has_error_handler = content.contains("err, req, res, next")
|| content.contains("error, req, res, next")
|| content.contains("err: Error");
if !has_error_handler && features.route_count > 3 {
findings.push(Finding {
id: String::new(),
detector: "ExpressSecurityDetector".to_string(),
severity: Severity::Medium,
title: "No global error handler".to_string(),
description: format!(
"Express apps should have a global error handler to prevent stack traces from leaking.{}",
context_notes
),
affected_files: vec![path.to_path_buf()],
line_start: Some(1),
line_end: Some(1),
suggested_fix: Some(
"Add a global error handler:\n\
```javascript\n\
// Error handler must be the LAST middleware\n\
app.use((err, req, res, next) => {\n\
console.error(err.stack);\n\
res.status(500).json({\n\
error: process.env.NODE_ENV === 'production' \n\
? 'Internal server error' \n\
: err.message\n\
});\n\
});\n\
```".to_string()
),
estimated_effort: Some("10 minutes".to_string()),
category: Some("security".to_string()),
cwe_id: Some("CWE-209".to_string()),
why_it_matters: Some("Unhandled errors leak stack traces and internal details.".to_string()),
..Default::default()
});
}
if features.auth_middleware_count == 0 && features.route_count > 5 {
findings.push(Finding {
id: String::new(),
detector: "ExpressSecurityDetector".to_string(),
severity: Severity::Medium,
title: "No authentication middleware detected".to_string(),
description: format!(
"This Express app has {} routes but no apparent authentication.{}",
features.route_count, context_notes
),
affected_files: vec![path.to_path_buf()],
line_start: Some(1),
line_end: Some(1),
suggested_fix: Some(
"Consider adding authentication:\n\
```javascript\n\
// Using Passport.js\n\
const passport = require('passport');\n\
app.use(passport.initialize());\n\
app.use('/api/protected', passport.authenticate('jwt'));\n\
\n\
// Or custom middleware\n\
const requireAuth = (req, res, next) => {\n\
if (!req.user) return res.status(401).json({ error: 'Unauthorized' });\n\
next();\n\
};\n\
```".to_string()
),
estimated_effort: Some("1-2 hours".to_string()),
category: Some("security".to_string()),
cwe_id: Some("CWE-306".to_string()),
why_it_matters: Some("APIs without authentication are open to abuse.".to_string()),
..Default::default()
});
}
}
}
info!(
"ExpressSecurityDetector found {} findings (graph-aware)",
findings.len()
);
Ok(findings)
}
}