1use std::path::Path;
2
3use anyhow::Result;
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
8pub enum Severity {
9 Critical,
10 High,
11 Medium,
12 Low,
13 Info,
14}
15
16impl std::fmt::Display for Severity {
17 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18 match self {
19 Severity::Critical => write!(f, "CRITICAL"),
20 Severity::High => write!(f, "HIGH"),
21 Severity::Medium => write!(f, "MEDIUM"),
22 Severity::Low => write!(f, "LOW"),
23 Severity::Info => write!(f, "INFO"),
24 }
25 }
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30pub enum Category {
31 SqlInjection,
32 HardcodedSecret,
33 DangerousEval,
34 InsecureDeserialization,
35 PathTraversal,
36 Ssrf,
37 Xxe,
38 WeakCrypto,
39 CommandInjection,
40 InsecureRandom,
41 XssRisk,
42 OpenRedirect,
43 Other(String),
44}
45
46impl std::fmt::Display for Category {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 match self {
49 Category::SqlInjection => write!(f, "SQL Injection"),
50 Category::HardcodedSecret => write!(f, "Hardcoded Secret"),
51 Category::DangerousEval => write!(f, "Dangerous Eval"),
52 Category::InsecureDeserialization => write!(f, "Insecure Deserialization"),
53 Category::PathTraversal => write!(f, "Path Traversal"),
54 Category::Ssrf => write!(f, "SSRF"),
55 Category::Xxe => write!(f, "XXE"),
56 Category::WeakCrypto => write!(f, "Weak Crypto"),
57 Category::CommandInjection => write!(f, "Command Injection"),
58 Category::InsecureRandom => write!(f, "Insecure Random"),
59 Category::XssRisk => write!(f, "XSS Risk"),
60 Category::OpenRedirect => write!(f, "Open Redirect"),
61 Category::Other(s) => write!(f, "{}", s),
62 }
63 }
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct Finding {
69 pub file: String,
70 pub line: u32,
71 pub col: u32,
72 pub severity: Severity,
73 pub category: Category,
74 pub rule_id: String,
75 pub message: String,
76 pub snippet: String,
77}
78
79#[derive(Debug, Default)]
81pub struct ScanStats {
82 pub files_scanned: usize,
83 pub findings: Vec<Finding>,
84}
85
86impl ScanStats {
87 pub fn critical_count(&self) -> usize {
88 self.findings
89 .iter()
90 .filter(|f| f.severity == Severity::Critical)
91 .count()
92 }
93 pub fn high_count(&self) -> usize {
94 self.findings
95 .iter()
96 .filter(|f| f.severity == Severity::High)
97 .count()
98 }
99 pub fn medium_count(&self) -> usize {
100 self.findings
101 .iter()
102 .filter(|f| f.severity == Severity::Medium)
103 .count()
104 }
105 pub fn low_count(&self) -> usize {
106 self.findings
107 .iter()
108 .filter(|f| f.severity == Severity::Low)
109 .count()
110 }
111}
112
113struct Rule {
118 id: &'static str,
119 category: fn() -> Category,
120 severity: Severity,
121 extensions: Option<&'static [&'static str]>,
123 pattern: &'static str,
125 exclude_if: Option<&'static str>,
127 message: &'static str,
128}
129
130static RULES: &[Rule] = &[
131 Rule {
133 id: "SEC001",
134 category: || Category::SqlInjection,
135 severity: Severity::High,
136 extensions: Some(&["py", "js", "ts", "java", "go", "rb", "php", "cs", "rs"]),
137 pattern: "execute(",
138 exclude_if: Some("# nosec"),
139 message:
140 "Possible SQL injection: raw string passed to execute(). Use parameterized queries.",
141 },
142 Rule {
143 id: "SEC002",
144 category: || Category::SqlInjection,
145 severity: Severity::High,
146 extensions: Some(&["py", "js", "ts", "java", "go", "rb", "php", "cs", "rs"]),
147 pattern: "raw_query(",
148 exclude_if: None,
149 message: "raw_query() call — ensure parameters are not interpolated from user input.",
150 },
151 Rule {
152 id: "SEC003",
153 category: || Category::SqlInjection,
154 severity: Severity::Critical,
155 extensions: Some(&["py", "js", "ts", "java", "go", "rb", "php", "cs"]),
156 pattern: "format!(\"select",
157 exclude_if: None,
158 message: "String-interpolated SQL SELECT — classic SQL injection risk.",
159 },
160 Rule {
161 id: "SEC004",
162 category: || Category::SqlInjection,
163 severity: Severity::Critical,
164 extensions: Some(&["py", "js", "ts", "java", "go", "rb", "php"]),
165 pattern: "f\"select",
166 exclude_if: None,
167 message: "f-string SQL SELECT — SQL injection risk.",
168 },
169 Rule {
170 id: "SEC005",
171 category: || Category::SqlInjection,
172 severity: Severity::Critical,
173 extensions: Some(&["py", "js", "ts", "java", "go", "rb", "php"]),
174 pattern: "f'select",
175 exclude_if: None,
176 message: "f-string SQL SELECT — SQL injection risk.",
177 },
178 Rule {
180 id: "SEC010",
181 category: || Category::HardcodedSecret,
182 severity: Severity::Critical,
183 extensions: None,
184 pattern: "password = \"",
185 exclude_if: Some("example"),
186 message: "Hardcoded password literal.",
187 },
188 Rule {
189 id: "SEC011",
190 category: || Category::HardcodedSecret,
191 severity: Severity::Critical,
192 extensions: None,
193 pattern: "password = '",
194 exclude_if: Some("example"),
195 message: "Hardcoded password literal.",
196 },
197 Rule {
198 id: "SEC012",
199 category: || Category::HardcodedSecret,
200 severity: Severity::Critical,
201 extensions: None,
202 pattern: "secret_key = \"",
203 exclude_if: None,
204 message: "Hardcoded secret key.",
205 },
206 Rule {
207 id: "SEC013",
208 category: || Category::HardcodedSecret,
209 severity: Severity::Critical,
210 extensions: None,
211 pattern: "api_key = \"",
212 exclude_if: Some("os."),
213 message: "Hardcoded API key.",
214 },
215 Rule {
216 id: "SEC014",
217 category: || Category::HardcodedSecret,
218 severity: Severity::High,
219 extensions: None,
220 pattern: "aws_secret_access_key",
221 exclude_if: Some("os.environ"),
222 message: "AWS secret access key reference — ensure not hardcoded.",
223 },
224 Rule {
225 id: "SEC015",
226 category: || Category::HardcodedSecret,
227 severity: Severity::High,
228 extensions: Some(&["py", "js", "ts", "go", "java", "rb", "cs", "rs"]),
229 pattern: "private_key = \"",
230 exclude_if: None,
231 message: "Hardcoded private key.",
232 },
233 Rule {
234 id: "SEC016",
235 category: || Category::HardcodedSecret,
236 severity: Severity::High,
237 extensions: None,
238 pattern: "-----begin rsa private key-----",
239 exclude_if: None,
240 message: "RSA private key literal in source code.",
241 },
242 Rule {
243 id: "SEC017",
244 category: || Category::HardcodedSecret,
245 severity: Severity::High,
246 extensions: None,
247 pattern: "-----begin ec private key-----",
248 exclude_if: None,
249 message: "EC private key literal in source code.",
250 },
251 Rule {
253 id: "SEC020",
254 category: || Category::DangerousEval,
255 severity: Severity::High,
256 extensions: Some(&["py"]),
257 pattern: "eval(",
258 exclude_if: Some("#"),
259 message: "eval() with dynamic input is dangerous — possible code injection.",
260 },
261 Rule {
262 id: "SEC021",
263 category: || Category::DangerousEval,
264 severity: Severity::High,
265 extensions: Some(&["js", "ts"]),
266 pattern: "eval(",
267 exclude_if: None,
268 message: "JavaScript eval() — code injection risk.",
269 },
270 Rule {
271 id: "SEC022",
272 category: || Category::DangerousEval,
273 severity: Severity::Medium,
274 extensions: Some(&["py"]),
275 pattern: "exec(",
276 exclude_if: Some("#"),
277 message: "Python exec() — code injection risk if input is not sanitized.",
278 },
279 Rule {
281 id: "SEC030",
282 category: || Category::InsecureDeserialization,
283 severity: Severity::Critical,
284 extensions: Some(&["py"]),
285 pattern: "pickle.loads(",
286 exclude_if: None,
287 message: "pickle.loads() on untrusted data allows arbitrary code execution.",
288 },
289 Rule {
290 id: "SEC031",
291 category: || Category::InsecureDeserialization,
292 severity: Severity::Critical,
293 extensions: Some(&["py"]),
294 pattern: "pickle.load(",
295 exclude_if: None,
296 message: "pickle.load() on untrusted data allows arbitrary code execution.",
297 },
298 Rule {
299 id: "SEC032",
300 category: || Category::InsecureDeserialization,
301 severity: Severity::High,
302 extensions: Some(&["py"]),
303 pattern: "yaml.load(",
304 exclude_if: Some("loader=yaml.SafeLoader"),
305 message: "yaml.load() without SafeLoader — use yaml.safe_load() instead.",
306 },
307 Rule {
308 id: "SEC033",
309 category: || Category::InsecureDeserialization,
310 severity: Severity::High,
311 extensions: Some(&["java"]),
312 pattern: "objectinputstream",
313 exclude_if: None,
314 message: "Java ObjectInputStream deserialization — gadget chain risk.",
315 },
316 Rule {
317 id: "SEC034",
318 category: || Category::InsecureDeserialization,
319 severity: Severity::High,
320 extensions: Some(&["rb"]),
321 pattern: "marshal.load(",
322 exclude_if: None,
323 message: "Ruby Marshal.load on untrusted data — code execution risk.",
324 },
325 Rule {
327 id: "SEC040",
328 category: || Category::PathTraversal,
329 severity: Severity::High,
330 extensions: Some(&["py", "js", "ts", "go", "java", "rb", "php", "cs", "rs"]),
331 pattern: "../",
332 exclude_if: Some("test"),
333 message: "Literal '../' in path construction — possible path traversal.",
334 },
335 Rule {
336 id: "SEC041",
337 category: || Category::PathTraversal,
338 severity: Severity::Medium,
339 extensions: Some(&["py"]),
340 pattern: "open(request.",
341 exclude_if: None,
342 message: "File open with request parameter — path traversal risk.",
343 },
344 Rule {
346 id: "SEC050",
347 category: || Category::Ssrf,
348 severity: Severity::High,
349 extensions: Some(&["py"]),
350 pattern: "requests.get(request.",
351 exclude_if: None,
352 message: "HTTP GET with user-controlled URL — SSRF risk.",
353 },
354 Rule {
355 id: "SEC051",
356 category: || Category::Ssrf,
357 severity: Severity::High,
358 extensions: Some(&["py"]),
359 pattern: "requests.post(request.",
360 exclude_if: None,
361 message: "HTTP POST with user-controlled URL — SSRF risk.",
362 },
363 Rule {
364 id: "SEC052",
365 category: || Category::Ssrf,
366 severity: Severity::Medium,
367 extensions: Some(&["js", "ts"]),
368 pattern: "fetch(req.",
369 exclude_if: None,
370 message: "fetch() with request-derived URL — SSRF risk.",
371 },
372 Rule {
374 id: "SEC060",
375 category: || Category::Xxe,
376 severity: Severity::High,
377 extensions: Some(&["py"]),
378 pattern: "etree.parse(",
379 exclude_if: Some("defusedxml"),
380 message: "xml.etree.parse() — XXE risk. Use defusedxml.",
381 },
382 Rule {
383 id: "SEC061",
384 category: || Category::Xxe,
385 severity: Severity::High,
386 extensions: Some(&["java"]),
387 pattern: "documentbuilderfactory.newinstance()",
388 exclude_if: Some("setfeature"),
389 message: "DocumentBuilderFactory without XXE protection.",
390 },
391 Rule {
393 id: "SEC070",
394 category: || Category::WeakCrypto,
395 severity: Severity::Medium,
396 extensions: None,
397 pattern: "md5(",
398 exclude_if: Some("test"),
399 message: "MD5 is cryptographically broken. Use SHA-256 or better.",
400 },
401 Rule {
402 id: "SEC071",
403 category: || Category::WeakCrypto,
404 severity: Severity::Medium,
405 extensions: None,
406 pattern: "sha1(",
407 exclude_if: Some("test"),
408 message: "SHA-1 is cryptographically weak. Use SHA-256 or better.",
409 },
410 Rule {
411 id: "SEC072",
412 category: || Category::WeakCrypto,
413 severity: Severity::High,
414 extensions: None,
415 pattern: "des(",
416 exclude_if: None,
417 message: "DES is broken. Use AES-256.",
418 },
419 Rule {
420 id: "SEC073",
421 category: || Category::WeakCrypto,
422 severity: Severity::Medium,
423 extensions: Some(&["py", "js", "ts", "go", "java", "rb", "rs"]),
424 pattern: "hashlib.md5(",
425 exclude_if: None,
426 message: "hashlib.md5 — not suitable for security-sensitive hashing.",
427 },
428 Rule {
430 id: "SEC080",
431 category: || Category::CommandInjection,
432 severity: Severity::Critical,
433 extensions: Some(&["py"]),
434 pattern: "os.system(",
435 exclude_if: None,
436 message: "os.system() with dynamic input — command injection risk.",
437 },
438 Rule {
439 id: "SEC081",
440 category: || Category::CommandInjection,
441 severity: Severity::High,
442 extensions: Some(&["py"]),
443 pattern: "subprocess.call(",
444 exclude_if: Some("shell=False"),
445 message: "subprocess.call() — use shell=False and list arguments.",
446 },
447 Rule {
448 id: "SEC082",
449 category: || Category::CommandInjection,
450 severity: Severity::High,
451 extensions: Some(&["py"]),
452 pattern: "subprocess.popen(",
453 exclude_if: Some("shell=false"),
454 message: "subprocess.Popen() — use shell=False and list arguments.",
455 },
456 Rule {
457 id: "SEC083",
458 category: || Category::CommandInjection,
459 severity: Severity::High,
460 extensions: Some(&["js", "ts"]),
461 pattern: "exec(",
462 exclude_if: Some("test"),
463 message: "child_process.exec() with dynamic input — command injection risk.",
464 },
465 Rule {
466 id: "SEC084",
467 category: || Category::CommandInjection,
468 severity: Severity::High,
469 extensions: Some(&["go"]),
470 pattern: "exec.command(",
471 exclude_if: None,
472 message: "exec.Command with user-controlled args — verify input is sanitized.",
473 },
474 Rule {
476 id: "SEC090",
477 category: || Category::InsecureRandom,
478 severity: Severity::Medium,
479 extensions: Some(&["py"]),
480 pattern: "random.random(",
481 exclude_if: None,
482 message: "random.random() is not cryptographically secure. Use secrets module.",
483 },
484 Rule {
485 id: "SEC091",
486 category: || Category::InsecureRandom,
487 severity: Severity::Medium,
488 extensions: Some(&["py"]),
489 pattern: "random.randint(",
490 exclude_if: None,
491 message: "random.randint() is not cryptographically secure. Use secrets.randbelow().",
492 },
493 Rule {
494 id: "SEC092",
495 category: || Category::InsecureRandom,
496 severity: Severity::Medium,
497 extensions: Some(&["js", "ts"]),
498 pattern: "math.random()",
499 exclude_if: None,
500 message: "Math.random() is not cryptographically secure. Use crypto.getRandomValues().",
501 },
502 Rule {
504 id: "SEC100",
505 category: || Category::XssRisk,
506 severity: Severity::High,
507 extensions: Some(&["js", "ts"]),
508 pattern: "innerhtml",
509 exclude_if: None,
510 message: "innerHTML assignment — XSS risk if content is user-controlled.",
511 },
512 Rule {
513 id: "SEC101",
514 category: || Category::XssRisk,
515 severity: Severity::High,
516 extensions: Some(&["js", "ts"]),
517 pattern: "dangerouslysetinnerhtml",
518 exclude_if: None,
519 message: "React dangerouslySetInnerHTML — XSS risk.",
520 },
521 Rule {
522 id: "SEC102",
523 category: || Category::XssRisk,
524 severity: Severity::Medium,
525 extensions: Some(&["py"]),
526 pattern: "mark_safe(",
527 exclude_if: None,
528 message: "Django mark_safe() — ensure content is sanitized before marking safe.",
529 },
530 Rule {
532 id: "SEC110",
533 category: || Category::OpenRedirect,
534 severity: Severity::Medium,
535 extensions: Some(&["py", "js", "ts", "go", "java", "rb"]),
536 pattern: "redirect(request.",
537 exclude_if: None,
538 message: "redirect() with user-supplied URL — open redirect risk.",
539 },
540];
541
542pub fn scan_project(root: &Path) -> Result<ScanStats> {
550 let mut stats = ScanStats::default();
551
552 walk_and_scan(root, root, &mut stats)?;
553 stats.findings.sort_by(|a, b| {
555 a.severity
556 .cmp(&b.severity)
557 .then(a.file.cmp(&b.file))
558 .then(a.line.cmp(&b.line))
559 });
560
561 Ok(stats)
562}
563
564static IGNORE_DIRS: &[&str] = &[
565 ".git",
566 "node_modules",
567 ".venv",
568 "venv",
569 "target",
570 "build",
571 "dist",
572 "__pycache__",
573 ".tox",
574 ".infigraph",
575 "vendor",
576 ".idea",
577 ".mypy_cache",
578 "coverage",
579 ".pytest_cache",
580];
581
582fn walk_and_scan(root: &Path, dir: &Path, stats: &mut ScanStats) -> Result<()> {
583 for entry in std::fs::read_dir(dir)? {
584 let entry = entry?;
585 let path = entry.path();
586 let name = entry.file_name();
587 let name_str = name.to_string_lossy();
588
589 if path.is_dir() {
590 if !IGNORE_DIRS.contains(&name_str.as_ref()) && !name_str.starts_with('.') {
591 walk_and_scan(root, &path, stats)?;
592 }
593 } else if path.is_file() {
594 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
595 let rel = path
596 .strip_prefix(root)
597 .unwrap_or(&path)
598 .to_string_lossy()
599 .replace('\\', "/");
600 scan_file(&path, &rel, ext, stats)?;
601 }
602 }
603 }
604 Ok(())
605}
606
607fn scan_file(path: &Path, rel_path: &str, ext: &str, stats: &mut ScanStats) -> Result<()> {
608 let content = match std::fs::read_to_string(path) {
609 Ok(c) => c,
610 Err(_) => return Ok(()), };
612
613 stats.files_scanned += 1;
614 let ext_lower = ext.to_lowercase();
615
616 for (line_no, line) in content.lines().enumerate() {
617 let line_lower = line.to_lowercase();
618 let line_no = (line_no + 1) as u32;
619
620 for rule in RULES {
621 if let Some(exts) = rule.extensions {
623 if !exts.contains(&ext_lower.as_str()) {
624 continue;
625 }
626 }
627
628 if !line_lower.contains(rule.pattern) {
630 continue;
631 }
632
633 if let Some(excl) = rule.exclude_if {
635 if line_lower.contains(&excl.to_lowercase() as &str) {
636 continue;
637 }
638 }
639
640 let col = line_lower.find(rule.pattern).unwrap_or(0) as u32 + 1;
642
643 stats.findings.push(Finding {
644 file: rel_path.to_string(),
645 line: line_no,
646 col,
647 severity: rule.severity.clone(),
648 category: (rule.category)(),
649 rule_id: rule.id.to_string(),
650 message: rule.message.to_string(),
651 snippet: line.trim().chars().take(120).collect(),
652 });
653 }
654 }
655
656 Ok(())
657}
658
659pub fn format_scan_results(stats: &ScanStats) -> String {
661 if stats.findings.is_empty() {
662 return format!(
663 "Security scan complete: {} files scanned, no issues found.",
664 stats.files_scanned
665 );
666 }
667
668 let mut out = format!(
669 "Security scan: {} files, {} findings [CRITICAL:{} HIGH:{} MEDIUM:{} LOW:{}]\n\n",
670 stats.files_scanned,
671 stats.findings.len(),
672 stats.critical_count(),
673 stats.high_count(),
674 stats.medium_count(),
675 stats.low_count(),
676 );
677
678 let mut cur_file = String::new();
679 for f in &stats.findings {
680 if f.file != cur_file {
681 out.push_str(&format!("\n {}\n", f.file));
682 cur_file = f.file.clone();
683 }
684 out.push_str(&format!(
685 " [{sev:<8}] L{line:<5} [{rule}] {msg}\n",
686 sev = f.severity.to_string(),
687 line = f.line,
688 rule = f.rule_id,
689 msg = f.message,
690 ));
691 out.push_str(&format!(" {}\n", f.snippet));
692 }
693
694 out
695}
696
697#[cfg(test)]
698mod tests {
699 use super::*;
700 use std::io::Write;
701
702 fn scan_str(content: &str, ext: &str) -> Vec<Finding> {
703 let dir = tempfile::tempdir().unwrap();
704 let file = dir.path().join(format!("test.{}", ext));
705 let mut f = std::fs::File::create(&file).unwrap();
706 f.write_all(content.as_bytes()).unwrap();
707 let mut stats = ScanStats::default();
708 scan_file(&file, &format!("test.{}", ext), ext, &mut stats).unwrap();
709 stats.findings
710 }
711
712 #[test]
713 fn detects_pickle_loads() {
714 let findings = scan_str("data = pickle.loads(user_input)", "py");
715 assert!(findings.iter().any(|f| f.rule_id == "SEC030"));
716 }
717
718 #[test]
719 fn detects_hardcoded_password() {
720 let findings = scan_str("password = \"s3cr3t\"", "py");
721 assert!(findings.iter().any(|f| f.rule_id == "SEC010"));
722 }
723
724 #[test]
725 fn detects_eval_js() {
726 let findings = scan_str("eval(userInput)", "js");
727 assert!(findings.iter().any(|f| f.rule_id == "SEC021"));
728 }
729
730 #[test]
731 fn detects_md5() {
732 let findings = scan_str("digest = md5(password)", "py");
733 assert!(findings.iter().any(|f| f.category == Category::WeakCrypto));
734 }
735
736 #[test]
737 fn detects_innerhtml() {
738 let findings = scan_str("el.innerHTML = userInput", "js");
739 assert!(findings.iter().any(|f| f.rule_id == "SEC100"));
740 }
741
742 #[test]
743 fn no_false_positive_yaml_safe() {
744 let findings = scan_str("data = yaml.load(f, loader=yaml.SafeLoader)", "py");
745 assert!(!findings.iter().any(|f| f.rule_id == "SEC032"));
746 }
747}