1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
5pub enum Severity {
6 Critical,
7 High,
8 Medium,
9 Low,
10 Info,
11}
12
13impl std::fmt::Display for Severity {
14 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
15 match self {
16 Severity::Critical => write!(f, "CRITICAL"),
17 Severity::High => write!(f, "HIGH"),
18 Severity::Medium => write!(f, "MEDIUM"),
19 Severity::Low => write!(f, "LOW"),
20 Severity::Info => write!(f, "INFO"),
21 }
22 }
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27pub enum Category {
28 SqlInjection,
29 HardcodedSecret,
30 DangerousEval,
31 InsecureDeserialization,
32 PathTraversal,
33 Ssrf,
34 Xxe,
35 WeakCrypto,
36 CommandInjection,
37 InsecureRandom,
38 XssRisk,
39 OpenRedirect,
40 Other(String),
41}
42
43impl std::fmt::Display for Category {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 match self {
46 Category::SqlInjection => write!(f, "SQL Injection"),
47 Category::HardcodedSecret => write!(f, "Hardcoded Secret"),
48 Category::DangerousEval => write!(f, "Dangerous Eval"),
49 Category::InsecureDeserialization => write!(f, "Insecure Deserialization"),
50 Category::PathTraversal => write!(f, "Path Traversal"),
51 Category::Ssrf => write!(f, "SSRF"),
52 Category::Xxe => write!(f, "XXE"),
53 Category::WeakCrypto => write!(f, "Weak Crypto"),
54 Category::CommandInjection => write!(f, "Command Injection"),
55 Category::InsecureRandom => write!(f, "Insecure Random"),
56 Category::XssRisk => write!(f, "XSS Risk"),
57 Category::OpenRedirect => write!(f, "Open Redirect"),
58 Category::Other(s) => write!(f, "{}", s),
59 }
60 }
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct Finding {
66 pub file: String,
67 pub line: u32,
68 pub col: u32,
69 pub severity: Severity,
70 pub category: Category,
71 pub rule_id: String,
72 pub message: String,
73 pub snippet: String,
74 pub suppressed: bool,
75 pub sanitizer_hint: Option<String>,
76}
77
78#[derive(Debug, Default)]
80pub struct ScanStats {
81 pub files_scanned: usize,
82 pub findings: Vec<Finding>,
83}
84
85impl ScanStats {
86 pub fn critical_count(&self) -> usize {
87 self.findings
88 .iter()
89 .filter(|f| f.severity == Severity::Critical)
90 .count()
91 }
92 pub fn high_count(&self) -> usize {
93 self.findings
94 .iter()
95 .filter(|f| f.severity == Severity::High)
96 .count()
97 }
98 pub fn medium_count(&self) -> usize {
99 self.findings
100 .iter()
101 .filter(|f| f.severity == Severity::Medium)
102 .count()
103 }
104 pub fn low_count(&self) -> usize {
105 self.findings
106 .iter()
107 .filter(|f| f.severity == Severity::Low)
108 .count()
109 }
110}
111
112pub(crate) struct Rule {
117 pub(crate) id: &'static str,
118 pub(crate) category: fn() -> Category,
119 pub(crate) severity: Severity,
120 pub(crate) extensions: Option<&'static [&'static str]>,
122 pub(crate) pattern: &'static str,
124 pub(crate) exclude_if: Option<&'static str>,
126 pub(crate) message: &'static str,
127}
128
129pub(crate) static RULES: &[Rule] = &[
130 Rule {
132 id: "SEC001",
133 category: || Category::SqlInjection,
134 severity: Severity::High,
135 extensions: Some(&["py", "js", "ts", "java", "go", "rb", "php", "cs", "rs"]),
136 pattern: "execute(",
137 exclude_if: Some("# nosec"),
138 message:
139 "Possible SQL injection: raw string passed to execute(). Use parameterized queries.",
140 },
141 Rule {
142 id: "SEC002",
143 category: || Category::SqlInjection,
144 severity: Severity::High,
145 extensions: Some(&["py", "js", "ts", "java", "go", "rb", "php", "cs", "rs"]),
146 pattern: "raw_query(",
147 exclude_if: None,
148 message: "raw_query() call — ensure parameters are not interpolated from user input.",
149 },
150 Rule {
151 id: "SEC003",
152 category: || Category::SqlInjection,
153 severity: Severity::Critical,
154 extensions: Some(&["py", "js", "ts", "java", "go", "rb", "php", "cs"]),
155 pattern: "format!(\"select",
156 exclude_if: None,
157 message: "String-interpolated SQL SELECT — classic SQL injection risk.",
158 },
159 Rule {
160 id: "SEC004",
161 category: || Category::SqlInjection,
162 severity: Severity::Critical,
163 extensions: Some(&["py", "js", "ts", "java", "go", "rb", "php"]),
164 pattern: "f\"select",
165 exclude_if: None,
166 message: "f-string SQL SELECT — SQL injection risk.",
167 },
168 Rule {
169 id: "SEC005",
170 category: || Category::SqlInjection,
171 severity: Severity::Critical,
172 extensions: Some(&["py", "js", "ts", "java", "go", "rb", "php"]),
173 pattern: "f'select",
174 exclude_if: None,
175 message: "f-string SQL SELECT — SQL injection risk.",
176 },
177 Rule {
179 id: "SEC010",
180 category: || Category::HardcodedSecret,
181 severity: Severity::Critical,
182 extensions: None,
183 pattern: "password = \"",
184 exclude_if: Some("example"),
185 message: "Hardcoded password literal.",
186 },
187 Rule {
188 id: "SEC011",
189 category: || Category::HardcodedSecret,
190 severity: Severity::Critical,
191 extensions: None,
192 pattern: "password = '",
193 exclude_if: Some("example"),
194 message: "Hardcoded password literal.",
195 },
196 Rule {
197 id: "SEC012",
198 category: || Category::HardcodedSecret,
199 severity: Severity::Critical,
200 extensions: None,
201 pattern: "secret_key = \"",
202 exclude_if: None,
203 message: "Hardcoded secret key.",
204 },
205 Rule {
206 id: "SEC013",
207 category: || Category::HardcodedSecret,
208 severity: Severity::Critical,
209 extensions: None,
210 pattern: "api_key = \"",
211 exclude_if: Some("os."),
212 message: "Hardcoded API key.",
213 },
214 Rule {
215 id: "SEC014",
216 category: || Category::HardcodedSecret,
217 severity: Severity::High,
218 extensions: None,
219 pattern: "aws_secret_access_key",
220 exclude_if: Some("os.environ"),
221 message: "AWS secret access key reference — ensure not hardcoded.",
222 },
223 Rule {
224 id: "SEC015",
225 category: || Category::HardcodedSecret,
226 severity: Severity::High,
227 extensions: Some(&["py", "js", "ts", "go", "java", "rb", "cs", "rs"]),
228 pattern: "private_key = \"",
229 exclude_if: None,
230 message: "Hardcoded private key.",
231 },
232 Rule {
233 id: "SEC016",
234 category: || Category::HardcodedSecret,
235 severity: Severity::High,
236 extensions: None,
237 pattern: "-----begin rsa private key-----",
238 exclude_if: None,
239 message: "RSA private key literal in source code.",
240 },
241 Rule {
242 id: "SEC017",
243 category: || Category::HardcodedSecret,
244 severity: Severity::High,
245 extensions: None,
246 pattern: "-----begin ec private key-----",
247 exclude_if: None,
248 message: "EC private key literal in source code.",
249 },
250 Rule {
252 id: "SEC020",
253 category: || Category::DangerousEval,
254 severity: Severity::High,
255 extensions: Some(&["py"]),
256 pattern: "eval(",
257 exclude_if: Some("#"),
258 message: "eval() with dynamic input is dangerous — possible code injection.",
259 },
260 Rule {
261 id: "SEC021",
262 category: || Category::DangerousEval,
263 severity: Severity::High,
264 extensions: Some(&["js", "ts"]),
265 pattern: "eval(",
266 exclude_if: None,
267 message: "JavaScript eval() — code injection risk.",
268 },
269 Rule {
270 id: "SEC022",
271 category: || Category::DangerousEval,
272 severity: Severity::Medium,
273 extensions: Some(&["py"]),
274 pattern: "exec(",
275 exclude_if: Some("#"),
276 message: "Python exec() — code injection risk if input is not sanitized.",
277 },
278 Rule {
280 id: "SEC030",
281 category: || Category::InsecureDeserialization,
282 severity: Severity::Critical,
283 extensions: Some(&["py"]),
284 pattern: "pickle.loads(",
285 exclude_if: None,
286 message: "pickle.loads() on untrusted data allows arbitrary code execution.",
287 },
288 Rule {
289 id: "SEC031",
290 category: || Category::InsecureDeserialization,
291 severity: Severity::Critical,
292 extensions: Some(&["py"]),
293 pattern: "pickle.load(",
294 exclude_if: None,
295 message: "pickle.load() on untrusted data allows arbitrary code execution.",
296 },
297 Rule {
298 id: "SEC032",
299 category: || Category::InsecureDeserialization,
300 severity: Severity::High,
301 extensions: Some(&["py"]),
302 pattern: "yaml.load(",
303 exclude_if: Some("loader=yaml.SafeLoader"),
304 message: "yaml.load() without SafeLoader — use yaml.safe_load() instead.",
305 },
306 Rule {
307 id: "SEC033",
308 category: || Category::InsecureDeserialization,
309 severity: Severity::High,
310 extensions: Some(&["java"]),
311 pattern: "objectinputstream",
312 exclude_if: None,
313 message: "Java ObjectInputStream deserialization — gadget chain risk.",
314 },
315 Rule {
316 id: "SEC034",
317 category: || Category::InsecureDeserialization,
318 severity: Severity::High,
319 extensions: Some(&["rb"]),
320 pattern: "marshal.load(",
321 exclude_if: None,
322 message: "Ruby Marshal.load on untrusted data — code execution risk.",
323 },
324 Rule {
326 id: "SEC040",
327 category: || Category::PathTraversal,
328 severity: Severity::High,
329 extensions: Some(&["py", "js", "ts", "go", "java", "rb", "php", "cs", "rs"]),
330 pattern: "../",
331 exclude_if: Some("test"),
332 message: "Literal '../' in path construction — possible path traversal.",
333 },
334 Rule {
335 id: "SEC041",
336 category: || Category::PathTraversal,
337 severity: Severity::Medium,
338 extensions: Some(&["py"]),
339 pattern: "open(request.",
340 exclude_if: None,
341 message: "File open with request parameter — path traversal risk.",
342 },
343 Rule {
345 id: "SEC050",
346 category: || Category::Ssrf,
347 severity: Severity::High,
348 extensions: Some(&["py"]),
349 pattern: "requests.get(request.",
350 exclude_if: None,
351 message: "HTTP GET with user-controlled URL — SSRF risk.",
352 },
353 Rule {
354 id: "SEC051",
355 category: || Category::Ssrf,
356 severity: Severity::High,
357 extensions: Some(&["py"]),
358 pattern: "requests.post(request.",
359 exclude_if: None,
360 message: "HTTP POST with user-controlled URL — SSRF risk.",
361 },
362 Rule {
363 id: "SEC052",
364 category: || Category::Ssrf,
365 severity: Severity::Medium,
366 extensions: Some(&["js", "ts"]),
367 pattern: "fetch(req.",
368 exclude_if: None,
369 message: "fetch() with request-derived URL — SSRF risk.",
370 },
371 Rule {
373 id: "SEC060",
374 category: || Category::Xxe,
375 severity: Severity::High,
376 extensions: Some(&["py"]),
377 pattern: "etree.parse(",
378 exclude_if: Some("defusedxml"),
379 message: "xml.etree.parse() — XXE risk. Use defusedxml.",
380 },
381 Rule {
382 id: "SEC061",
383 category: || Category::Xxe,
384 severity: Severity::High,
385 extensions: Some(&["java"]),
386 pattern: "documentbuilderfactory.newinstance()",
387 exclude_if: Some("setfeature"),
388 message: "DocumentBuilderFactory without XXE protection.",
389 },
390 Rule {
392 id: "SEC070",
393 category: || Category::WeakCrypto,
394 severity: Severity::Medium,
395 extensions: None,
396 pattern: "md5(",
397 exclude_if: Some("test"),
398 message: "MD5 is cryptographically broken. Use SHA-256 or better.",
399 },
400 Rule {
401 id: "SEC071",
402 category: || Category::WeakCrypto,
403 severity: Severity::Medium,
404 extensions: None,
405 pattern: "sha1(",
406 exclude_if: Some("test"),
407 message: "SHA-1 is cryptographically weak. Use SHA-256 or better.",
408 },
409 Rule {
410 id: "SEC072",
411 category: || Category::WeakCrypto,
412 severity: Severity::High,
413 extensions: None,
414 pattern: "des.new(",
415 exclude_if: Some("test"),
416 message: "DES is broken. Use AES-256.",
417 },
418 Rule {
419 id: "SEC072b",
420 category: || Category::WeakCrypto,
421 severity: Severity::High,
422 extensions: None,
423 pattern: "des_cbc",
424 exclude_if: Some("test"),
425 message: "DES/3DES is broken. Use AES-256.",
426 },
427 Rule {
428 id: "SEC072c",
429 category: || Category::WeakCrypto,
430 severity: Severity::High,
431 extensions: None,
432 pattern: "des_ede",
433 exclude_if: Some("test"),
434 message: "Triple-DES (DES-EDE) is deprecated. Use AES-256.",
435 },
436 Rule {
437 id: "SEC073",
438 category: || Category::WeakCrypto,
439 severity: Severity::Medium,
440 extensions: Some(&["py", "js", "ts", "go", "java", "rb", "rs"]),
441 pattern: "hashlib.md5(",
442 exclude_if: None,
443 message: "hashlib.md5 — not suitable for security-sensitive hashing.",
444 },
445 Rule {
447 id: "SEC080",
448 category: || Category::CommandInjection,
449 severity: Severity::Critical,
450 extensions: Some(&["py"]),
451 pattern: "os.system(",
452 exclude_if: None,
453 message: "os.system() with dynamic input — command injection risk.",
454 },
455 Rule {
456 id: "SEC081",
457 category: || Category::CommandInjection,
458 severity: Severity::High,
459 extensions: Some(&["py"]),
460 pattern: "subprocess.call(",
461 exclude_if: Some("shell=False"),
462 message: "subprocess.call() — use shell=False and list arguments.",
463 },
464 Rule {
465 id: "SEC082",
466 category: || Category::CommandInjection,
467 severity: Severity::High,
468 extensions: Some(&["py"]),
469 pattern: "subprocess.popen(",
470 exclude_if: Some("shell=false"),
471 message: "subprocess.Popen() — use shell=False and list arguments.",
472 },
473 Rule {
474 id: "SEC083",
475 category: || Category::CommandInjection,
476 severity: Severity::High,
477 extensions: Some(&["js", "ts"]),
478 pattern: "exec(",
479 exclude_if: Some("test"),
480 message: "child_process.exec() with dynamic input — command injection risk.",
481 },
482 Rule {
483 id: "SEC084",
484 category: || Category::CommandInjection,
485 severity: Severity::High,
486 extensions: Some(&["go"]),
487 pattern: "exec.command(",
488 exclude_if: None,
489 message: "exec.Command with user-controlled args — verify input is sanitized.",
490 },
491 Rule {
493 id: "SEC090",
494 category: || Category::InsecureRandom,
495 severity: Severity::Medium,
496 extensions: Some(&["py"]),
497 pattern: "random.random(",
498 exclude_if: None,
499 message: "random.random() is not cryptographically secure. Use secrets module.",
500 },
501 Rule {
502 id: "SEC091",
503 category: || Category::InsecureRandom,
504 severity: Severity::Medium,
505 extensions: Some(&["py"]),
506 pattern: "random.randint(",
507 exclude_if: None,
508 message: "random.randint() is not cryptographically secure. Use secrets.randbelow().",
509 },
510 Rule {
511 id: "SEC092",
512 category: || Category::InsecureRandom,
513 severity: Severity::Medium,
514 extensions: Some(&["js", "ts"]),
515 pattern: "math.random()",
516 exclude_if: None,
517 message: "Math.random() is not cryptographically secure. Use crypto.getRandomValues().",
518 },
519 Rule {
521 id: "SEC100",
522 category: || Category::XssRisk,
523 severity: Severity::High,
524 extensions: Some(&["js", "ts"]),
525 pattern: "innerhtml",
526 exclude_if: None,
527 message: "innerHTML assignment — XSS risk if content is user-controlled.",
528 },
529 Rule {
530 id: "SEC101",
531 category: || Category::XssRisk,
532 severity: Severity::High,
533 extensions: Some(&["js", "ts"]),
534 pattern: "dangerouslysetinnerhtml",
535 exclude_if: None,
536 message: "React dangerouslySetInnerHTML — XSS risk.",
537 },
538 Rule {
539 id: "SEC102",
540 category: || Category::XssRisk,
541 severity: Severity::Medium,
542 extensions: Some(&["py"]),
543 pattern: "mark_safe(",
544 exclude_if: None,
545 message: "Django mark_safe() — ensure content is sanitized before marking safe.",
546 },
547 Rule {
549 id: "SEC110",
550 category: || Category::OpenRedirect,
551 severity: Severity::Medium,
552 extensions: Some(&["py", "js", "ts", "go", "java", "rb"]),
553 pattern: "redirect(request.",
554 exclude_if: None,
555 message: "redirect() with user-supplied URL — open redirect risk.",
556 },
557];
558
559pub(crate) struct Sanitizer {
564 pub(crate) category: fn() -> Category,
565 pub(crate) patterns: &'static [&'static str],
566}
567
568pub(crate) static SANITIZERS: &[Sanitizer] = &[
569 Sanitizer {
570 category: || Category::SqlInjection,
571 patterns: &[
572 "parameterize",
573 "prepare(",
574 "bind_param",
575 "sanitize_sql",
576 "sqlalchemy.text(",
577 "prepared_statement",
578 "placeholders",
579 "cursor.execute(%s",
580 "cursor.execute(?,",
581 "?)",
582 ],
583 },
584 Sanitizer {
585 category: || Category::XssRisk,
586 patterns: &[
587 "escape_html",
588 "sanitize(",
589 "dompurify",
590 "bleach.clean(",
591 "html.escape(",
592 "encodeuricomponent(",
593 "cgi.escape(",
594 "markupsafe.escape(",
595 "xss_clean(",
596 ],
597 },
598 Sanitizer {
599 category: || Category::CommandInjection,
600 patterns: &[
601 "shlex.quote(",
602 "shell_escape",
603 "escapeshellarg(",
604 "escapeshellcmd(",
605 "shell=false",
606 "shlex.split(",
607 ],
608 },
609 Sanitizer {
610 category: || Category::PathTraversal,
611 patterns: &[
612 "realpath(",
613 "abspath(",
614 "normalize(",
615 "canonicalize(",
616 "path.resolve(",
617 "secure_filename(",
618 "os.path.basename(",
619 ],
620 },
621 Sanitizer {
622 category: || Category::Ssrf,
623 patterns: &[
624 "validate_url(",
625 "is_allowed_host(",
626 "urlparse(",
627 "allowed_hosts",
628 "url_validator(",
629 "safelist",
630 ],
631 },
632 Sanitizer {
633 category: || Category::OpenRedirect,
634 patterns: &[
635 "url_has_allowed_host(",
636 "is_safe_url(",
637 "validate_redirect(",
638 "allowed_hosts",
639 "safe_redirect(",
640 ],
641 },
642 Sanitizer {
643 category: || Category::InsecureDeserialization,
644 patterns: &[
645 "safe_load(",
646 "yaml.safe_load(",
647 "json.loads(",
648 "allowlist",
649 "whitelist_classes",
650 ],
651 },
652];
653
654pub(crate) const SANITIZER_WINDOW: usize = 5;
655
656pub(crate) fn find_sanitizer_for(
657 category: &Category,
658 lines: &[&str],
659 finding_line: usize,
660) -> Option<String> {
661 let start = finding_line.saturating_sub(SANITIZER_WINDOW);
662 let end = (finding_line + SANITIZER_WINDOW + 1).min(lines.len());
663
664 for sanitizer in SANITIZERS {
665 if (sanitizer.category)() != *category {
666 continue;
667 }
668 for &line in &lines[start..end] {
669 let lower = line.to_lowercase();
670 for &pat in sanitizer.patterns {
671 if lower.contains(pat) {
672 return Some(pat.to_string());
673 }
674 }
675 }
676 }
677 None
678}