Skip to main content

infigraph_core/security/
rules.rs

1use serde::{Deserialize, Serialize};
2
3/// Severity of a security finding.
4#[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/// Category of security issue.
26#[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/// A single security finding in the codebase.
64#[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/// Summary stats from a security scan.
79#[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
112// ---------------------------------------------------------------------------
113// Rule definitions
114// ---------------------------------------------------------------------------
115
116pub(crate) struct Rule {
117    pub(crate) id: &'static str,
118    pub(crate) category: fn() -> Category,
119    pub(crate) severity: Severity,
120    /// Extension filter: None = all files, Some = only these extensions
121    pub(crate) extensions: Option<&'static [&'static str]>,
122    /// Pattern to search for (substring match, case-insensitive)
123    pub(crate) pattern: &'static str,
124    /// If Some, the line must NOT contain this to avoid false positives
125    pub(crate) exclude_if: Option<&'static str>,
126    pub(crate) message: &'static str,
127}
128
129pub(crate) static RULES: &[Rule] = &[
130    // ── SQL Injection ────────────────────────────────────────────────────────
131    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    // ── Hardcoded Secrets ────────────────────────────────────────────────────
178    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    // ── Dangerous Eval ───────────────────────────────────────────────────────
251    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    // ── Insecure Deserialization ──────────────────────────────────────────────
279    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    // ── Path Traversal ───────────────────────────────────────────────────────
325    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    // ── SSRF ─────────────────────────────────────────────────────────────────
344    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    // ── XXE ──────────────────────────────────────────────────────────────────
372    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    // ── Weak Crypto ───────────────────────────────────────────────────────────
391    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    // ── Command Injection ─────────────────────────────────────────────────────
446    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    // ── Insecure Random ───────────────────────────────────────────────────────
492    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    // ── XSS Risk ─────────────────────────────────────────────────────────────
520    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    // ── Open Redirect ─────────────────────────────────────────────────────────
548    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
559// ---------------------------------------------------------------------------
560// Sanitizer definitions — suppress findings when nearby code sanitizes input
561// ---------------------------------------------------------------------------
562
563pub(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}