Skip to main content

infigraph_core/security/
mod.rs

1use std::path::Path;
2
3use anyhow::Result;
4use serde::{Deserialize, Serialize};
5
6/// Severity of a security finding.
7#[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/// Category of security issue.
29#[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/// A single security finding in the codebase.
67#[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/// Summary stats from a security scan.
80#[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
113// ---------------------------------------------------------------------------
114// Rule definitions
115// ---------------------------------------------------------------------------
116
117struct Rule {
118    id: &'static str,
119    category: fn() -> Category,
120    severity: Severity,
121    /// Extension filter: None = all files, Some = only these extensions
122    extensions: Option<&'static [&'static str]>,
123    /// Pattern to search for (substring match, case-insensitive)
124    pattern: &'static str,
125    /// If Some, the line must NOT contain this to avoid false positives
126    exclude_if: Option<&'static str>,
127    message: &'static str,
128}
129
130static RULES: &[Rule] = &[
131    // ── SQL Injection ────────────────────────────────────────────────────────
132    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    // ── Hardcoded Secrets ────────────────────────────────────────────────────
179    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    // ── Dangerous Eval ───────────────────────────────────────────────────────
252    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    // ── Insecure Deserialization ──────────────────────────────────────────────
280    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    // ── Path Traversal ───────────────────────────────────────────────────────
326    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    // ── SSRF ─────────────────────────────────────────────────────────────────
345    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    // ── XXE ──────────────────────────────────────────────────────────────────
373    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    // ── Weak Crypto ───────────────────────────────────────────────────────────
392    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    // ── Command Injection ─────────────────────────────────────────────────────
429    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    // ── Insecure Random ───────────────────────────────────────────────────────
475    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    // ── XSS Risk ─────────────────────────────────────────────────────────────
503    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    // ── Open Redirect ─────────────────────────────────────────────────────────
531    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
542// ---------------------------------------------------------------------------
543// Scanner
544// ---------------------------------------------------------------------------
545
546/// Scan the project rooted at `root` for security issues.
547///
548/// Walks all non-vendor files and applies pattern-based rules.
549pub fn scan_project(root: &Path) -> Result<ScanStats> {
550    let mut stats = ScanStats::default();
551
552    walk_and_scan(root, root, &mut stats)?;
553    // Sort findings: Critical first, then High, etc.
554    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(()), // skip binary files
611    };
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            // Extension filter
622            if let Some(exts) = rule.extensions {
623                if !exts.contains(&ext_lower.as_str()) {
624                    continue;
625                }
626            }
627
628            // Pattern match (case-insensitive)
629            if !line_lower.contains(rule.pattern) {
630                continue;
631            }
632
633            // Exclusion check (case-insensitive)
634            if let Some(excl) = rule.exclude_if {
635                if line_lower.contains(&excl.to_lowercase() as &str) {
636                    continue;
637                }
638            }
639
640            // Find column of match
641            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
659/// Format scan results as a human-readable report.
660pub 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}