1use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14use std::fs;
15use regex::Regex;
16use log::{debug, info};
17
18use super::{SecurityError, SecurityFinding, SecuritySeverity, SecurityCategory, SecurityReport, SecurityAnalysisConfig, GitIgnoreAnalyzer, GitIgnoreRisk};
19
20pub struct JavaScriptSecurityAnalyzer {
22 config: SecurityAnalysisConfig,
23 js_patterns: Vec<JavaScriptSecretPattern>,
24 framework_patterns: HashMap<String, Vec<FrameworkPattern>>,
25 env_var_patterns: Vec<EnvVarPattern>,
26 gitignore_analyzer: Option<GitIgnoreAnalyzer>,
27}
28
29#[derive(Debug, Clone)]
31pub struct JavaScriptSecretPattern {
32 pub id: String,
33 pub name: String,
34 pub pattern: Regex,
35 pub severity: SecuritySeverity,
36 pub description: String,
37 pub context_indicators: Vec<String>, pub false_positive_indicators: Vec<String>, }
40
41#[derive(Debug, Clone)]
43pub struct FrameworkPattern {
44 pub pattern: Regex,
45 pub severity: SecuritySeverity,
46 pub description: String,
47 pub file_extensions: Vec<String>,
48}
49
50#[derive(Debug, Clone)]
52pub struct EnvVarPattern {
53 pub pattern: Regex,
54 pub severity: SecuritySeverity,
55 pub description: String,
56 pub public_prefixes: Vec<String>, }
58
59impl JavaScriptSecurityAnalyzer {
60 pub fn new() -> Result<Self, SecurityError> {
61 Self::with_config(SecurityAnalysisConfig::default())
62 }
63
64 pub fn with_config(config: SecurityAnalysisConfig) -> Result<Self, SecurityError> {
65 let js_patterns = Self::initialize_js_patterns()?;
66 let framework_patterns = Self::initialize_framework_patterns()?;
67 let env_var_patterns = Self::initialize_env_var_patterns()?;
68
69 Ok(Self {
70 config,
71 js_patterns,
72 framework_patterns,
73 env_var_patterns,
74 gitignore_analyzer: None, })
76 }
77
78 pub fn analyze_project(&mut self, project_root: &Path) -> Result<SecurityReport, SecurityError> {
80 let mut findings = Vec::new();
81
82 let mut gitignore_analyzer = GitIgnoreAnalyzer::new(project_root)
84 .map_err(|e| SecurityError::AnalysisFailed(format!("Failed to initialize gitignore analyzer: {}", e)))?;
85
86 info!("🔍 Using gitignore-aware security analysis for {}", project_root.display());
87
88 let js_extensions = ["js", "jsx", "ts", "tsx", "vue", "svelte"];
90 let js_files = gitignore_analyzer.get_files_to_analyze(&js_extensions)
91 .map_err(|e| SecurityError::Io(e))?
92 .into_iter()
93 .filter(|file| {
94 if let Some(ext) = file.extension().and_then(|e| e.to_str()) {
95 js_extensions.contains(&ext)
96 } else {
97 false
98 }
99 })
100 .collect::<Vec<_>>();
101
102 info!("Found {} JavaScript/TypeScript files to analyze (gitignore-filtered)", js_files.len());
103
104 for file_path in &js_files {
106 let gitignore_status = gitignore_analyzer.analyze_file(file_path);
107 let mut file_findings = self.analyze_js_file(file_path)?;
108
109 for finding in &mut file_findings {
111 self.enhance_finding_with_gitignore_status(finding, &gitignore_status);
112 }
113
114 findings.extend(file_findings);
115 }
116
117 findings.extend(self.analyze_config_files_with_gitignore(project_root, &mut gitignore_analyzer)?);
119
120 findings.extend(self.analyze_env_files_with_gitignore(project_root, &mut gitignore_analyzer)?);
122
123 let secret_files: Vec<PathBuf> = findings.iter()
125 .filter_map(|f| f.file_path.as_ref())
126 .cloned()
127 .collect();
128
129 let gitignore_recommendations = gitignore_analyzer.generate_gitignore_recommendations(&secret_files);
130
131 let mut report = SecurityReport::from_findings(findings);
133 report.recommendations.extend(gitignore_recommendations);
134
135 Ok(report)
136 }
137
138 fn initialize_js_patterns() -> Result<Vec<JavaScriptSecretPattern>, SecurityError> {
140 let patterns = vec![
141 JavaScriptSecretPattern {
143 id: "js-firebase-config".to_string(),
144 name: "Firebase Configuration Object".to_string(),
145 pattern: Regex::new(r#"(?i)(?:const\s+|let\s+|var\s+)?firebaseConfig\s*[=:]\s*\{[^}]*apiKey\s*:\s*["']([^"']+)["'][^}]*\}"#)?,
146 severity: SecuritySeverity::Medium,
147 description: "Firebase configuration object with API key detected".to_string(),
148 context_indicators: vec!["initializeApp".to_string(), "firebase".to_string()],
149 false_positive_indicators: vec!["example".to_string(), "placeholder".to_string(), "your-api-key".to_string()],
150 },
151
152 JavaScriptSecretPattern {
154 id: "js-stripe-public-key".to_string(),
155 name: "Stripe Publishable Key".to_string(),
156 pattern: Regex::new(r#"(?i)pk_(?:test_|live_)[a-zA-Z0-9]{24,}"#)?,
157 severity: SecuritySeverity::Low,
158 description: "Stripe publishable key detected (public but should be environment variable)".to_string(),
159 context_indicators: vec!["stripe".to_string(), "payment".to_string()],
160 false_positive_indicators: vec![],
161 },
162
163 JavaScriptSecretPattern {
165 id: "js-supabase-anon-key".to_string(),
166 name: "Supabase Anonymous Key".to_string(),
167 pattern: Regex::new(r#"(?i)(?:supabase|anon).*?["\']eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+["\']"#)?,
168 severity: SecuritySeverity::Medium,
169 description: "Supabase anonymous key detected".to_string(),
170 context_indicators: vec!["supabase".to_string(), "createClient".to_string()],
171 false_positive_indicators: vec!["example".to_string(), "placeholder".to_string()],
172 },
173
174 JavaScriptSecretPattern {
176 id: "js-auth0-config".to_string(),
177 name: "Auth0 Configuration".to_string(),
178 pattern: Regex::new(r#"(?i)(?:domain|clientId)\s*:\s*["']([a-zA-Z0-9.-]+\.auth0\.com|[a-zA-Z0-9]{32})["']"#)?,
179 severity: SecuritySeverity::Medium,
180 description: "Auth0 configuration detected".to_string(),
181 context_indicators: vec!["auth0".to_string(), "webAuth".to_string()],
182 false_positive_indicators: vec!["example".to_string(), "your-domain".to_string()],
183 },
184
185 JavaScriptSecretPattern {
187 id: "js-hardcoded-env".to_string(),
188 name: "Hardcoded process.env Assignment".to_string(),
189 pattern: Regex::new(r#"process\.env\.[A-Z_]+\s*=\s*["']([^"']+)["']"#)?,
190 severity: SecuritySeverity::High,
191 description: "Hardcoded assignment to process.env detected".to_string(),
192 context_indicators: vec![],
193 false_positive_indicators: vec!["development".to_string(), "test".to_string()],
194 },
195
196 JavaScriptSecretPattern {
198 id: "js-clerk-key".to_string(),
199 name: "Clerk API Key".to_string(),
200 pattern: Regex::new(r#"(?i)(?:clerk|pk_test_|pk_live_)[a-zA-Z0-9_-]{20,}"#)?,
201 severity: SecuritySeverity::Medium,
202 description: "Clerk API key detected".to_string(),
203 context_indicators: vec!["clerk".to_string(), "ClerkProvider".to_string()],
204 false_positive_indicators: vec![],
205 },
206
207 JavaScriptSecretPattern {
209 id: "js-api-key-object".to_string(),
210 name: "API Key in Object Assignment".to_string(),
211 pattern: Regex::new(r#"(?i)(?:apiKey|api_key|clientSecret|client_secret|accessToken|access_token|secretKey|secret_key)\s*:\s*["']([A-Za-z0-9_-]{20,})["']"#)?,
212 severity: SecuritySeverity::High,
213 description: "API key or secret assigned in object literal".to_string(),
214 context_indicators: vec!["fetch".to_string(), "axios".to_string(), "headers".to_string()],
215 false_positive_indicators: vec!["process.env".to_string(), "import.meta.env".to_string(), "placeholder".to_string()],
216 },
217
218 JavaScriptSecretPattern {
220 id: "js-bearer-token".to_string(),
221 name: "Bearer Token in Code".to_string(),
222 pattern: Regex::new(r#"(?i)(?:authorization|bearer)\s*:\s*["'](?:bearer\s+)?([A-Za-z0-9_-]{20,})["']"#)?,
223 severity: SecuritySeverity::Critical,
224 description: "Bearer token hardcoded in authorization header".to_string(),
225 context_indicators: vec!["fetch".to_string(), "axios".to_string(), "headers".to_string()],
226 false_positive_indicators: vec!["${".to_string(), "process.env".to_string(), "import.meta.env".to_string()],
227 },
228
229 JavaScriptSecretPattern {
231 id: "js-database-url".to_string(),
232 name: "Database Connection URL".to_string(),
233 pattern: Regex::new(r#"(?i)(?:mongodb|postgres|mysql)://[^"'\s]+:[^"'\s]+@[^"'\s]+"#)?,
234 severity: SecuritySeverity::Critical,
235 description: "Database connection string with credentials detected".to_string(),
236 context_indicators: vec!["connect".to_string(), "mongoose".to_string(), "client".to_string()],
237 false_positive_indicators: vec!["localhost".to_string(), "example.com".to_string()],
238 },
239 ];
240
241 Ok(patterns)
242 }
243
244 fn initialize_framework_patterns() -> Result<HashMap<String, Vec<FrameworkPattern>>, SecurityError> {
246 let mut frameworks = HashMap::new();
247
248 frameworks.insert("react".to_string(), vec![
250 FrameworkPattern {
251 pattern: Regex::new(r#"(?i)react_app_[a-z_]+\s*=\s*["']([^"']+)["']"#)?,
252 severity: SecuritySeverity::Medium,
253 description: "React environment variable potentially exposed in build".to_string(),
254 file_extensions: vec!["js".to_string(), "jsx".to_string(), "ts".to_string(), "tsx".to_string()],
255 },
256 ]);
257
258 frameworks.insert("nextjs".to_string(), vec![
260 FrameworkPattern {
261 pattern: Regex::new(r#"(?i)next_public_[a-z_]+\s*=\s*["']([^"']+)["']"#)?,
262 severity: SecuritySeverity::Low,
263 description: "Next.js public environment variable (ensure it should be public)".to_string(),
264 file_extensions: vec!["js".to_string(), "jsx".to_string(), "ts".to_string(), "tsx".to_string()],
265 },
266 ]);
267
268 frameworks.insert("vite".to_string(), vec![
270 FrameworkPattern {
271 pattern: Regex::new(r#"(?i)vite_[a-z_]+\s*=\s*["']([^"']+)["']"#)?,
272 severity: SecuritySeverity::Medium,
273 description: "Vite environment variable potentially exposed in build".to_string(),
274 file_extensions: vec!["js".to_string(), "jsx".to_string(), "ts".to_string(), "tsx".to_string(), "vue".to_string()],
275 },
276 ]);
277
278 Ok(frameworks)
279 }
280
281 fn initialize_env_var_patterns() -> Result<Vec<EnvVarPattern>, SecurityError> {
283 let patterns = vec![
284 EnvVarPattern {
285 pattern: Regex::new(r#"process\.env\.([A-Z_]+)"#)?,
286 severity: SecuritySeverity::Info,
287 description: "Environment variable usage detected".to_string(),
288 public_prefixes: vec![
289 "REACT_APP_".to_string(),
290 "NEXT_PUBLIC_".to_string(),
291 "VITE_".to_string(),
292 "VUE_APP_".to_string(),
293 "EXPO_PUBLIC_".to_string(),
294 "NUXT_PUBLIC_".to_string(),
295 ],
296 },
297 EnvVarPattern {
298 pattern: Regex::new(r#"import\.meta\.env\.([A-Z_]+)"#)?,
299 severity: SecuritySeverity::Info,
300 description: "Vite environment variable usage detected".to_string(),
301 public_prefixes: vec!["VITE_".to_string()],
302 },
303 ];
304
305 Ok(patterns)
306 }
307
308 fn collect_js_files(&self, project_root: &Path) -> Result<Vec<PathBuf>, SecurityError> {
310 let extensions = ["js", "jsx", "ts", "tsx", "vue", "svelte"];
311 let mut files = Vec::new();
312
313 fn collect_recursive(dir: &Path, extensions: &[&str], files: &mut Vec<PathBuf>) -> Result<(), std::io::Error> {
314 for entry in fs::read_dir(dir)? {
315 let entry = entry?;
316 let path = entry.path();
317
318 if path.is_dir() {
319 if let Some(dir_name) = path.file_name().and_then(|n| n.to_str()) {
321 if matches!(dir_name, "node_modules" | ".git" | "build" | "dist" | ".next" | "coverage") {
322 continue;
323 }
324 }
325 collect_recursive(&path, extensions, files)?;
326 } else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
327 if extensions.contains(&ext) {
328 files.push(path);
329 }
330 }
331 }
332 Ok(())
333 }
334
335 collect_recursive(project_root, &extensions, &mut files)?;
336 Ok(files)
337 }
338
339 fn analyze_js_file(&self, file_path: &Path) -> Result<Vec<SecurityFinding>, SecurityError> {
341 let content = fs::read_to_string(file_path)?;
342 let mut findings = Vec::new();
343
344 for pattern in &self.js_patterns {
346 findings.extend(self.check_pattern_in_content(&content, pattern, file_path)?);
347 }
348
349 findings.extend(self.check_env_var_usage(&content, file_path)?);
351
352 Ok(findings)
353 }
354
355 fn check_pattern_in_content(
357 &self,
358 content: &str,
359 pattern: &JavaScriptSecretPattern,
360 file_path: &Path,
361 ) -> Result<Vec<SecurityFinding>, SecurityError> {
362 let mut findings = Vec::new();
363
364 for (line_num, line) in content.lines().enumerate() {
365 if let Some(captures) = pattern.pattern.captures(line) {
366 if pattern.false_positive_indicators.iter().any(|indicator| {
368 line.to_lowercase().contains(&indicator.to_lowercase())
369 }) {
370 debug!("Skipping potential false positive in {}: {}", file_path.display(), line.trim());
371 continue;
372 }
373
374 let (evidence, column_number) = if captures.len() > 1 {
376 if let Some(match_) = captures.get(1) {
377 (Some(match_.as_str().to_string()), Some(match_.start() + 1))
378 } else {
379 (Some(line.trim().to_string()), None)
380 }
381 } else {
382 if let Some(match_) = captures.get(0) {
384 (Some(line.trim().to_string()), Some(match_.start() + 1))
385 } else {
386 (Some(line.trim().to_string()), None)
387 }
388 };
389
390 let context_score = self.calculate_context_confidence(content, &pattern.context_indicators);
392 let adjusted_severity = self.adjust_severity_by_context(pattern.severity.clone(), context_score);
393
394 findings.push(SecurityFinding {
395 id: format!("{}-{}", pattern.id, line_num),
396 title: format!("{} Detected", pattern.name),
397 description: format!("{} (Context confidence: {:.1})", pattern.description, context_score),
398 severity: adjusted_severity,
399 category: SecurityCategory::SecretsExposure,
400 file_path: Some(file_path.to_path_buf()),
401 line_number: Some(line_num + 1),
402 column_number,
403 evidence,
404 remediation: self.generate_js_remediation(&pattern.id),
405 references: vec![
406 "https://owasp.org/www-project-top-ten/2021/A05_2021-Security_Misconfiguration/".to_string(),
407 "https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html".to_string(),
408 ],
409 cwe_id: Some("CWE-200".to_string()),
410 compliance_frameworks: vec!["SOC2".to_string(), "GDPR".to_string()],
411 });
412 }
413 }
414
415 Ok(findings)
416 }
417
418 fn check_env_var_usage(&self, content: &str, file_path: &Path) -> Result<Vec<SecurityFinding>, SecurityError> {
420 let mut findings = Vec::new();
421
422 let is_server_side = self.is_server_side_file(file_path, content);
424
425 for pattern in &self.env_var_patterns {
426 for (line_num, line) in content.lines().enumerate() {
427 if let Some(captures) = pattern.pattern.captures(line) {
428 if let Some(var_name) = captures.get(1) {
429 let var_name = var_name.as_str();
430
431 let is_public = pattern.public_prefixes.iter().any(|prefix| var_name.starts_with(prefix));
433
434 if !is_public && self.is_sensitive_var_name(var_name) && !is_server_side {
439 let column_number = captures.get(0)
441 .map(|m| m.start() + 1);
442
443 findings.push(SecurityFinding {
444 id: format!("js-env-sensitive-{}", line_num),
445 title: "Sensitive Environment Variable in Client Code".to_string(),
446 description: format!("Environment variable '{}' appears sensitive and may be exposed to client in browser code", var_name),
447 severity: SecuritySeverity::High,
448 category: SecurityCategory::SecretsExposure,
449 file_path: Some(file_path.to_path_buf()),
450 line_number: Some(line_num + 1),
451 column_number,
452 evidence: Some(line.trim().to_string()),
453 remediation: vec![
454 "Move sensitive environment variables to server-side code".to_string(),
455 "Use public environment variable prefixes only for non-sensitive data".to_string(),
456 "Consider using a backend API endpoint to handle sensitive operations".to_string(),
457 ],
458 references: vec![
459 "https://nextjs.org/docs/basic-features/environment-variables".to_string(),
460 "https://vitejs.dev/guide/env-and-mode.html".to_string(),
461 ],
462 cwe_id: Some("CWE-200".to_string()),
463 compliance_frameworks: vec!["SOC2".to_string()],
464 });
465 }
466 }
468 }
469 }
470 }
471
472 Ok(findings)
473 }
474
475 fn analyze_config_files(&self, project_root: &Path) -> Result<Vec<SecurityFinding>, SecurityError> {
477 let mut findings = Vec::new();
478
479 let package_json = project_root.join("package.json");
481 if package_json.exists() {
482 findings.extend(self.analyze_package_json(&package_json)?);
483 }
484
485 Ok(findings)
486 }
487
488 fn analyze_package_json(&self, package_json: &Path) -> Result<Vec<SecurityFinding>, SecurityError> {
490 let mut findings = Vec::new();
491 let content = fs::read_to_string(package_json)?;
492
493 if content.contains("REACT_APP_") || content.contains("NEXT_PUBLIC_") || content.contains("VITE_") {
495 for (line_num, line) in content.lines().enumerate() {
496 if line.contains("sk_") || line.contains("pk_live_") || line.contains("eyJ") {
497 findings.push(SecurityFinding {
498 id: format!("package-json-secret-{}", line_num),
499 title: "Potential Secret in package.json".to_string(),
500 description: "Potential API key or token found in package.json".to_string(),
501 severity: SecuritySeverity::High,
502 category: SecurityCategory::SecretsExposure,
503 file_path: Some(package_json.to_path_buf()),
504 line_number: Some(line_num + 1),
505 column_number: None,
506 evidence: Some(line.trim().to_string()),
507 remediation: vec![
508 "Remove secrets from package.json".to_string(),
509 "Use environment variables instead".to_string(),
510 "Add package.json to .gitignore if it contains secrets (not recommended)".to_string(),
511 ],
512 references: vec![
513 "https://docs.npmjs.com/cli/v8/configuring-npm/package-json".to_string(),
514 ],
515 cwe_id: Some("CWE-200".to_string()),
516 compliance_frameworks: vec!["SOC2".to_string()],
517 });
518 }
519 }
520 }
521
522 Ok(findings)
523 }
524
525 fn analyze_env_files(&self, project_root: &Path) -> Result<Vec<SecurityFinding>, SecurityError> {
527 let mut findings = Vec::new();
528
529 let env_files = [".env", ".env.local", ".env.production", ".env.development"];
531
532 for env_file in &env_files {
533 if self.is_template_file(env_file) {
535 debug!("Skipping template env file: {}", env_file);
536 continue;
537 }
538
539 let env_path = project_root.join(env_file);
540 if env_path.exists() {
541 findings.push(SecurityFinding {
543 id: format!("env-file-{}", env_file.replace('.', "-")),
544 title: "Environment File Detected".to_string(),
545 description: format!("Environment file '{}' found - ensure it's properly protected", env_file),
546 severity: SecuritySeverity::Medium,
547 category: SecurityCategory::SecretsExposure,
548 file_path: Some(env_path),
549 line_number: None,
550 column_number: None,
551 evidence: None,
552 remediation: vec![
553 "Ensure environment files are in .gitignore".to_string(),
554 "Use .env.example files for documentation".to_string(),
555 "Never commit actual environment files to version control".to_string(),
556 ],
557 references: vec![
558 "https://github.com/motdotla/dotenv#should-i-commit-my-env-file".to_string(),
559 ],
560 cwe_id: Some("CWE-200".to_string()),
561 compliance_frameworks: vec!["SOC2".to_string()],
562 });
563 }
564 }
565
566 Ok(findings)
567 }
568
569 fn calculate_context_confidence(&self, content: &str, indicators: &[String]) -> f32 {
571 let total_indicators = indicators.len() as f32;
572 if total_indicators == 0.0 {
573 return 0.5; }
575
576 let found_indicators = indicators.iter()
577 .filter(|indicator| content.to_lowercase().contains(&indicator.to_lowercase()))
578 .count() as f32;
579
580 found_indicators / total_indicators
581 }
582
583 fn adjust_severity_by_context(&self, base_severity: SecuritySeverity, confidence: f32) -> SecuritySeverity {
585 match base_severity {
586 SecuritySeverity::Critical => base_severity, SecuritySeverity::High => {
588 if confidence < 0.3 {
589 SecuritySeverity::Medium
590 } else {
591 base_severity
592 }
593 }
594 SecuritySeverity::Medium => {
595 if confidence > 0.7 {
596 SecuritySeverity::High
597 } else if confidence < 0.3 {
598 SecuritySeverity::Low
599 } else {
600 base_severity
601 }
602 }
603 _ => base_severity,
604 }
605 }
606
607 fn is_sensitive_var_name(&self, var_name: &str) -> bool {
609 let sensitive_keywords = [
610 "SECRET", "KEY", "TOKEN", "PASSWORD", "PASS", "AUTH", "API",
611 "PRIVATE", "CREDENTIAL", "CERT", "SSL", "TLS", "OAUTH",
612 "CLIENT_SECRET", "ACCESS_TOKEN", "REFRESH_TOKEN",
613 ];
614
615 let var_upper = var_name.to_uppercase();
616 sensitive_keywords.iter().any(|keyword| var_upper.contains(keyword))
617 }
618
619 fn is_server_side_file(&self, file_path: &Path, content: &str) -> bool {
621 let path_str = file_path.to_string_lossy().to_lowercase();
623 let server_path_indicators = [
624 "/server/", "/backend/", "/api/", "/routes/", "/controllers/",
625 "/middleware/", "/models/", "/services/", "/utils/", "/lib/",
626 "server.js", "server.ts", "index.js", "index.ts", "app.js", "app.ts",
627 "/pages/api/", "/app/api/", "server-side", "backend", "node_modules", ];
630
631 let client_path_indicators = [
632 "/client/", "/frontend/", "/public/", "/static/", "/assets/",
633 "/components/", "/views/", "/pages/", "/src/components/",
634 "client.js", "client.ts", "main.js", "main.ts", "app.tsx", "index.html",
635 ];
636
637 if server_path_indicators.iter().any(|indicator| path_str.contains(indicator)) {
639 return true;
640 }
641
642 if client_path_indicators.iter().any(|indicator| path_str.contains(indicator)) {
644 return false;
645 }
646
647 let server_content_indicators = [
649 "require(", "module.exports", "exports.", "__dirname", "__filename",
650 "process.env", "process.exit", "process.argv", "fs.readFile", "fs.writeFile",
651 "http.createServer", "express(", "app.listen", "app.use", "app.get", "app.post",
652 "import express", "import fs", "import path", "import http", "import https",
653 "cors(", "bodyParser", "middleware", "mongoose.connect", "sequelize",
654 "jwt.sign", "bcrypt", "crypto.createHash", "nodemailer", "socket.io",
655 "console.log", ];
657
658 let client_content_indicators = [
659 "document.", "window.", "navigator.", "localStorage", "sessionStorage",
660 "addEventListener", "querySelector", "getElementById", "fetch(",
661 "XMLHttpRequest", "React.", "ReactDOM", "useState", "useEffect",
662 "Vue.", "Angular", "svelte", "alert(", "confirm(", "prompt(",
663 "location.href", "history.push", "router.push", "browser",
664 ];
665
666 let server_matches = server_content_indicators.iter()
667 .filter(|&indicator| content.contains(indicator))
668 .count();
669
670 let client_matches = client_content_indicators.iter()
671 .filter(|&indicator| content.contains(indicator))
672 .count();
673
674 if server_matches > 0 && client_matches == 0 {
676 return true;
677 }
678
679 if client_matches > 0 && server_matches == 0 {
681 return false;
682 }
683
684 if server_matches > client_matches {
686 return true;
687 }
688
689 false
691 }
692
693 fn generate_js_remediation(&self, pattern_id: &str) -> Vec<String> {
695 match pattern_id {
696 id if id.contains("firebase") => vec![
697 "Move Firebase configuration to environment variables".to_string(),
698 "Use Firebase App Check for additional security".to_string(),
699 "Implement proper Firebase security rules".to_string(),
700 ],
701 id if id.contains("stripe") => vec![
702 "Use environment variables for Stripe keys".to_string(),
703 "Ensure you're using publishable keys in client-side code".to_string(),
704 "Keep secret keys on the server side only".to_string(),
705 ],
706 id if id.contains("bearer") => vec![
707 "Never hardcode bearer tokens in client-side code".to_string(),
708 "Use secure token storage mechanisms".to_string(),
709 "Implement token refresh flows".to_string(),
710 ],
711 _ => vec![
712 "Move secrets to environment variables".to_string(),
713 "Use server-side API routes for sensitive operations".to_string(),
714 "Implement proper secret management practices".to_string(),
715 ],
716 }
717 }
718
719 fn enhance_finding_with_gitignore_status(
721 &self,
722 finding: &mut SecurityFinding,
723 gitignore_status: &super::gitignore::GitIgnoreStatus,
724 ) {
725 finding.severity = match gitignore_status.risk_level {
727 GitIgnoreRisk::Tracked => SecuritySeverity::Critical, GitIgnoreRisk::Exposed => {
729 match &finding.severity {
731 SecuritySeverity::Medium => SecuritySeverity::High,
732 SecuritySeverity::Low => SecuritySeverity::Medium,
733 other => other.clone(),
734 }
735 }
736 GitIgnoreRisk::Protected => {
737 match &finding.severity {
739 SecuritySeverity::Critical => SecuritySeverity::High,
740 SecuritySeverity::High => SecuritySeverity::Medium,
741 other => other.clone(),
742 }
743 }
744 GitIgnoreRisk::Safe => finding.severity.clone(),
745 };
746
747 finding.description.push_str(&format!(" (GitIgnore: {})", gitignore_status.description()));
749
750 let gitignore_action = gitignore_status.recommended_action();
752 if gitignore_action != "No action needed" {
753 finding.remediation.insert(0, format!("🔒 GitIgnore: {}", gitignore_action));
754 }
755
756 if gitignore_status.risk_level == GitIgnoreRisk::Tracked {
758 finding.remediation.insert(1, "⚠️ CRITICAL: Remove this file from git history using git-filter-branch or BFG Repo-Cleaner".to_string());
759 finding.remediation.insert(2, "🔑 Rotate any exposed secrets immediately".to_string());
760 }
761 }
762
763 fn analyze_config_files_with_gitignore(
765 &self,
766 project_root: &Path,
767 gitignore_analyzer: &mut GitIgnoreAnalyzer,
768 ) -> Result<Vec<SecurityFinding>, SecurityError> {
769 let mut findings = Vec::new();
770
771 let package_json = project_root.join("package.json");
773 if package_json.exists() {
774 let gitignore_status = gitignore_analyzer.analyze_file(&package_json);
775 let mut package_findings = self.analyze_package_json(&package_json)?;
776
777 for finding in &mut package_findings {
779 self.enhance_finding_with_gitignore_status(finding, &gitignore_status);
780 }
781
782 findings.extend(package_findings);
783 }
784
785 let config_files = [
787 "tsconfig.json",
788 "vite.config.js",
789 "vite.config.ts",
790 "next.config.js",
791 "next.config.ts",
792 "nuxt.config.js",
793 "nuxt.config.ts",
794 ];
796
797 for config_file in &config_files {
798 if self.is_template_file(config_file) {
800 debug!("Skipping template config file: {}", config_file);
801 continue;
802 }
803
804 let config_path = project_root.join(config_file);
805 if config_path.exists() {
806 let gitignore_status = gitignore_analyzer.analyze_file(&config_path);
807
808 if gitignore_status.should_be_ignored || !gitignore_status.is_ignored {
810 if let Ok(content) = fs::read_to_string(&config_path) {
811 if self.contains_potential_secrets(&content) {
813 let mut finding = SecurityFinding {
814 id: format!("config-file-{}", config_file.replace('.', "-")),
815 title: "Potential Secrets in Configuration File".to_string(),
816 description: format!("Configuration file '{}' may contain secrets", config_file),
817 severity: SecuritySeverity::Medium,
818 category: SecurityCategory::SecretsExposure,
819 file_path: Some(config_path.clone()),
820 line_number: None,
821 column_number: None,
822 evidence: None,
823 remediation: vec![
824 "Review configuration file for hardcoded secrets".to_string(),
825 "Use environment variables for sensitive configuration".to_string(),
826 ],
827 references: vec![],
828 cwe_id: Some("CWE-200".to_string()),
829 compliance_frameworks: vec!["SOC2".to_string()],
830 };
831
832 self.enhance_finding_with_gitignore_status(&mut finding, &gitignore_status);
833 findings.push(finding);
834 }
835 }
836 }
837 }
838 }
839
840 Ok(findings)
841 }
842
843 fn is_template_file(&self, file_name: &str) -> bool {
845 let template_indicators = [
846 "sample", "example", "template", "template.env", "env.template",
847 "sample.env", "env.sample", "example.env", "env.example",
848 "examples", "samples", "templates", "demo", "test",
849 ".env.sample", ".env.example", ".env.template", ".env.demo", ".env.test"
850 ];
851
852 let file_name_lower = file_name.to_lowercase();
853
854 template_indicators.iter().any(|indicator| {
856 file_name_lower == *indicator ||
857 file_name_lower.contains(indicator) ||
858 file_name_lower.ends_with(indicator)
859 })
860 }
861
862 fn analyze_env_files_with_gitignore(
864 &self,
865 project_root: &Path,
866 gitignore_analyzer: &mut GitIgnoreAnalyzer,
867 ) -> Result<Vec<SecurityFinding>, SecurityError> {
868 let mut findings = Vec::new();
869
870 let env_files = gitignore_analyzer.get_files_to_analyze(&[])
872 .map_err(|e| SecurityError::Io(e))?
873 .into_iter()
874 .filter(|file| {
875 if let Some(file_name) = file.file_name().and_then(|n| n.to_str()) {
876 if self.is_template_file(file_name) {
878 debug!("Skipping template file: {}", file_name);
879 return false;
880 }
881
882 file_name.starts_with(".env") ||
883 file_name.contains("credentials") ||
884 file_name.contains("secrets") ||
885 file_name.contains("config") ||
886 file_name.ends_with(".key") ||
887 file_name.ends_with(".pem")
888 } else {
889 false
890 }
891 })
892 .collect::<Vec<_>>();
893
894 for env_file in env_files {
895 let gitignore_status = gitignore_analyzer.analyze_file(&env_file);
896 let relative_path = env_file.strip_prefix(project_root)
897 .unwrap_or(&env_file);
898
899 let (severity, title, description) = match gitignore_status.risk_level {
901 GitIgnoreRisk::Tracked => (
902 SecuritySeverity::Critical,
903 "Secret File Tracked by Git".to_string(),
904 format!("Secret file '{}' is tracked by git and may expose credentials in version history", relative_path.display()),
905 ),
906 GitIgnoreRisk::Exposed => (
907 SecuritySeverity::High,
908 "Secret File Not in GitIgnore".to_string(),
909 format!("Secret file '{}' exists but is not protected by .gitignore", relative_path.display()),
910 ),
911 GitIgnoreRisk::Protected => (
912 SecuritySeverity::Info,
913 "Secret File Properly Protected".to_string(),
914 format!("Secret file '{}' is properly ignored but detected for verification", relative_path.display()),
915 ),
916 GitIgnoreRisk::Safe => continue, };
918
919 let mut finding = SecurityFinding {
920 id: format!("env-file-{}", relative_path.to_string_lossy().replace('/', "-").replace('.', "-")),
921 title,
922 description,
923 severity,
924 category: SecurityCategory::SecretsExposure,
925 file_path: Some(env_file.clone()),
926 line_number: None,
927 column_number: None,
928 evidence: None,
929 remediation: vec![
930 "Ensure sensitive files are in .gitignore".to_string(),
931 "Use .env.example files for documentation".to_string(),
932 "Never commit actual environment files to version control".to_string(),
933 ],
934 references: vec![
935 "https://github.com/motdotla/dotenv#should-i-commit-my-env-file".to_string(),
936 ],
937 cwe_id: Some("CWE-200".to_string()),
938 compliance_frameworks: vec!["SOC2".to_string()],
939 };
940
941 self.enhance_finding_with_gitignore_status(&mut finding, &gitignore_status);
942 findings.push(finding);
943 }
944
945 Ok(findings)
946 }
947
948 fn contains_potential_secrets(&self, content: &str) -> bool {
950 let secret_indicators = [
951 "sk_", "pk_live_", "eyJ", "AKIA", "-----BEGIN",
952 "client_secret", "api_key", "access_token",
953 "private_key", "secret_key", "bearer",
954 ];
955
956 let content_lower = content.to_lowercase();
957 secret_indicators.iter().any(|indicator| content_lower.contains(&indicator.to_lowercase()))
958 }
959}
960
961impl SecurityReport {
962 pub fn from_findings(findings: Vec<SecurityFinding>) -> Self {
964 let total_findings = findings.len();
965 let mut findings_by_severity = HashMap::new();
966 let mut findings_by_category = HashMap::new();
967
968 for finding in &findings {
969 *findings_by_severity.entry(finding.severity.clone()).or_insert(0) += 1;
970 *findings_by_category.entry(finding.category.clone()).or_insert(0) += 1;
971 }
972
973 let score_penalty = findings.iter().map(|f| match f.severity {
975 SecuritySeverity::Critical => 25.0,
976 SecuritySeverity::High => 15.0,
977 SecuritySeverity::Medium => 8.0,
978 SecuritySeverity::Low => 3.0,
979 SecuritySeverity::Info => 1.0,
980 }).sum::<f32>();
981
982 let overall_score = (100.0 - score_penalty).max(0.0);
983
984 let risk_level = if findings.iter().any(|f| f.severity == SecuritySeverity::Critical) {
986 SecuritySeverity::Critical
987 } else if findings.iter().any(|f| f.severity == SecuritySeverity::High) {
988 SecuritySeverity::High
989 } else if findings.iter().any(|f| f.severity == SecuritySeverity::Medium) {
990 SecuritySeverity::Medium
991 } else if !findings.is_empty() {
992 SecuritySeverity::Low
993 } else {
994 SecuritySeverity::Info
995 };
996
997 Self {
998 analyzed_at: chrono::Utc::now(),
999 overall_score,
1000 risk_level,
1001 total_findings,
1002 findings_by_severity,
1003 findings_by_category,
1004 findings,
1005 recommendations: vec![
1006 "Review all detected secrets and move them to environment variables".to_string(),
1007 "Implement proper secret management practices".to_string(),
1008 "Use framework-specific environment variable patterns correctly".to_string(),
1009 ],
1010 compliance_status: HashMap::new(),
1011 }
1012 }
1013}