use crate::detectors::base::{Detector, DetectorConfig};
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 EXPRESS_APP: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"express\(\)|require\(["']express["']\)|from ['"]express['"']"#)
.expect("valid regex")
});
static ROUTE_HANDLER: LazyLock<Regex> = LazyLock::new(|| {
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,
#[allow(dead_code)] has_hpp: bool,
has_csrf: bool,
#[allow(dead_code)] 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 {
#[allow(dead_code)] repository_path: PathBuf,
max_findings: usize,
}
impl ExpressSecurityDetector {
crate::detectors::detector_new!(50);
}
impl Detector for ExpressSecurityDetector {
fn name(&self) -> &'static str {
"express-security"
}
fn description(&self) -> &'static str {
"Detects Express.js security issues"
}
fn bypass_postprocessor(&self) -> bool {
true
}
fn file_extensions(&self) -> &'static [&'static str] {
&["js", "ts"]
}
fn content_requirements(&self) -> crate::detectors::detector_context::ContentFlags {
crate::detectors::detector_context::ContentFlags::HAS_EXPRESS
}
fn detect(
&self,
ctx: &crate::detectors::analysis_context::AnalysisContext,
) -> Result<Vec<Finding>> {
let files = &ctx.as_file_provider();
let has_express = files
.files_with_extensions(&["js", "ts"])
.iter()
.any(|p| files.content(p).is_some_and(|c| c.contains("express")));
if !has_express {
return Ok(vec![]);
}
let mut findings = vec![];
for path in files.files_with_extensions(&["js", "ts", "mjs"]) {
if findings.len() >= self.max_findings {
break;
}
let path_str = path.to_string_lossy().to_string();
if crate::detectors::base::is_test_path(&path_str) {
continue;
}
if let Some(content) = files.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)
}
}
impl crate::detectors::RegisteredDetector for ExpressSecurityDetector {
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_missing_helmet() {
let store = GraphBuilder::new().freeze();
let detector = ExpressSecurityDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(&store, vec![
("app.js", "const express = require('express');\nconst app = express();\n\napp.get('/api/users', (req, res) => {\n res.json({ users: [] });\n});\n\napp.post('/api/users', (req, res) => {\n res.json({ created: true });\n});\n\napp.listen(3000);\n"),
]);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(!findings.is_empty(), "Should detect security issues");
assert!(
findings.iter().any(|f| f.title.contains("helmet")),
"Should detect missing helmet. Titles: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_secure_express_fewer_findings() {
let store = GraphBuilder::new().freeze();
let detector = ExpressSecurityDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(&store, vec![
("server.js", "const express = require('express');\nconst helmet = require('helmet');\nconst cors = require('cors');\nconst rateLimit = require('express-rate-limit');\n\nconst app = express();\napp.use(helmet());\napp.use(cors());\napp.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }));\napp.use(express.json({ limit: '10kb' }));\n\napp.get('/api/data', (req, res) => {\n res.json({ ok: true });\n});\n\napp.listen(3000);\n"),
]);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.iter().any(|f| f.title.contains("helmet")),
"Should not flag helmet when present"
);
assert!(
!findings.iter().any(|f| f.title.contains("rate limiting")),
"Should not flag rate limiting when present"
);
assert!(
!findings.iter().any(|f| f.title.contains("body size limit")),
"Should not flag body size limit when present"
);
}
#[test]
fn test_non_express_file_no_findings() {
let store = GraphBuilder::new().freeze();
let detector = ExpressSecurityDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(&store, vec![
("utils.js", "function add(a, b) {\n return a + b;\n}\n\nfunction multiply(a, b) {\n return a * b;\n}\n\nmodule.exports = { add, multiply };\n"),
]);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
findings.is_empty(),
"Non-Express file should produce no findings, got: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_express_app_without_helmet_or_rate_limit() {
let store = GraphBuilder::new().freeze();
let detector = ExpressSecurityDetector::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();\n\napp.get('/api/users', (req, res) => res.json([]));\napp.post('/api/users', (req, res) => res.json({}));\napp.get('/api/posts', (req, res) => res.json([]));\napp.put('/api/posts/:id', (req, res) => res.json({}));\napp.delete('/api/posts/:id', (req, res) => res.json({}));\napp.get('/api/comments', (req, res) => res.json([]));\n\napp.listen(3000);\n",
)]);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
findings.iter().any(|f| f.title.contains("helmet")),
"Should flag missing helmet. Titles: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
assert!(
findings.iter().any(|f| f.title.contains("rate limiting")),
"Should flag missing rate limiting for app with >5 routes. Titles: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_express_with_helmet_no_helmet_finding() {
let store = GraphBuilder::new().freeze();
let detector = ExpressSecurityDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(&store, vec![(
"app.js",
"const express = require('express');\nconst helmet = require('helmet');\nconst app = express();\napp.use(helmet());\n\napp.get('/api/data', (req, res) => res.json({ ok: true }));\napp.listen(3000);\n",
)]);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.iter().any(|f| f.title.contains("helmet")),
"Should NOT flag helmet when it is installed. Titles: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_express_with_cors_has_cors_detected() {
let store = GraphBuilder::new().freeze();
let detector = ExpressSecurityDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(&store, vec![(
"app.js",
"const express = require('express');\nconst cors = require('cors');\nconst app = express();\napp.use(cors({ origin: 'https://example.com' }));\n\napp.get('/api/data', (req, res) => res.json({ ok: true }));\napp.listen(3000);\n",
)]);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings
.iter()
.any(|f| f.title.to_lowercase().contains("cors")),
"Should not produce a CORS finding when cors middleware is present. Titles: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_express_missing_error_handler_with_many_routes() {
let store = GraphBuilder::new().freeze();
let detector = ExpressSecurityDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(&store, vec![(
"app.js",
"const express = require('express');\nconst app = express();\n\napp.get('/a', (req, res) => res.json({}));\napp.get('/b', (req, res) => res.json({}));\napp.get('/c', (req, res) => res.json({}));\napp.get('/d', (req, res) => res.json({}));\n\napp.listen(3000);\n",
)]);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
findings.iter().any(|f| f.title.contains("error handler")),
"Should detect missing global error handler for app with >3 routes. Titles: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_express_with_error_handler_no_error_finding() {
let store = GraphBuilder::new().freeze();
let detector = ExpressSecurityDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(&store, vec![(
"app.js",
"const express = require('express');\nconst app = express();\n\napp.get('/a', (req, res) => res.json({}));\napp.get('/b', (req, res) => res.json({}));\napp.get('/c', (req, res) => res.json({}));\napp.get('/d', (req, res) => res.json({}));\n\napp.use((err, req, res, next) => {\n console.error(err.stack);\n res.status(500).json({ error: 'Internal server error' });\n});\n\napp.listen(3000);\n",
)]);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.iter().any(|f| f.title.contains("error handler")),
"Should NOT flag error handler when one is present. Titles: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
}