Skip to main content

mollify_core/
security.rs

1//! Security engine โ€” a deterministic **candidate producer** (bandit-style).
2//! It emits syntactic candidates; it never decides exploitability (the
3//! candidate/verifier split โ€” RESEARCH.md ยง2.11). Maps parser `SecurityHit`s to
4//! findings with per-rule confidence.
5
6use crate::fingerprint::fingerprint;
7use mollify_graph::ModuleGraph;
8use mollify_types::{Action, Category, Confidence, Finding, Location, Severity};
9
10fn confidence_for(rule: &str) -> Confidence {
11    match rule {
12        // Provable-ish footguns.
13        "subprocess-shell-true"
14        | "tls-verify-disabled"
15        | "unsafe-yaml-load"
16        | "weak-hash"
17        | "weak-cipher"
18        | "flask-debug-true"
19        | "jinja2-autoescape-false" => Confidence::Likely,
20        // Depends on whether input is trusted / context.
21        "dangerous-eval" | "unsafe-deserialization" | "sql-injection" => Confidence::Uncertain,
22        // Noisy without context: stdlib random is fine for non-security use.
23        "insecure-random" | "request-without-timeout" | "try-except-pass" => Confidence::Uncertain,
24        // Could be a placeholder / test fixture.
25        "hardcoded-secret" => Confidence::Likely,
26        _ => Confidence::Likely,
27    }
28}
29
30/// Best-effort CWE id for a rule, surfaced in the reason for compliance/SARIF.
31fn cwe_for(rule: &str) -> Option<&'static str> {
32    Some(match rule {
33        "dangerous-eval" => "CWE-95",
34        "subprocess-shell-true" => "CWE-78",
35        "sql-injection" => "CWE-89",
36        "unsafe-yaml-load" => "CWE-20",
37        "unsafe-deserialization" => "CWE-502",
38        "tls-verify-disabled" => "CWE-295",
39        "hardcoded-secret" => "CWE-798",
40        "weak-hash" | "weak-cipher" => "CWE-327",
41        "insecure-random" => "CWE-330",
42        "request-without-timeout" => "CWE-400",
43        "flask-debug-true" => "CWE-94",
44        "jinja2-autoescape-false" => "CWE-79",
45        "try-except-pass" => "CWE-703",
46        _ => return None,
47    })
48}
49
50pub fn analyze(graph: &ModuleGraph) -> Vec<Finding> {
51    let mut findings = Vec::new();
52    for m in &graph.modules {
53        findings.extend(analyze_parsed(&m.path, &m.parsed));
54    }
55    findings
56}
57
58/// Security findings for a single parsed module (also used by the live LSP path).
59pub fn analyze_parsed(
60    path: &camino::Utf8Path,
61    parsed: &mollify_parse::ParsedModule,
62) -> Vec<Finding> {
63    let mut findings = Vec::new();
64    for hit in &parsed.security_hits {
65        findings.push(Finding {
66            fingerprint: fingerprint(hit.rule, &[path.as_str(), &hit.line.to_string()]),
67            rule: hit.rule.to_string(),
68            category: Category::Security,
69            severity: Severity::Warn,
70            confidence: confidence_for(hit.rule),
71            attribution: None,
72            reason: match cwe_for(hit.rule) {
73                Some(cwe) => format!("{} [{cwe}]", hit.detail),
74                None => hit.detail.clone(),
75            },
76            location: Location {
77                path: path.to_owned(),
78                line: hit.line,
79                column: 0,
80                end_line: None,
81            },
82            actions: vec![Action {
83                kind: "review-security".into(),
84                description: "Review this security candidate; confirm before acting".into(),
85                auto_fixable: false,
86                suppression_comment: Some(format!("# mollify: ignore[{}]", hit.rule)),
87            }],
88        });
89    }
90    findings
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use camino::{Utf8Path, Utf8PathBuf};
97    use mollify_graph::discover_python_files;
98
99    fn temp(tag: &str) -> Utf8PathBuf {
100        let base =
101            std::env::temp_dir().join(format!("mollify-core-sec-{}-{tag}", std::process::id()));
102        let _ = std::fs::remove_dir_all(&base);
103        Utf8PathBuf::from_path_buf(base).unwrap()
104    }
105    fn write(dir: &Utf8Path, rel: &str, src: &str) {
106        let p = dir.join(rel);
107        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
108        std::fs::write(p, src).unwrap();
109    }
110
111    #[test]
112    fn surfaces_candidates() {
113        let d = temp("sec");
114        write(
115            &d,
116            "__init__.py",
117            "import subprocess\napi_key = \"sk-abcdef123\"\nsubprocess.run(c, shell=True)\n",
118        );
119        let files = discover_python_files(&d);
120        let g = ModuleGraph::build(&d, &files);
121        let f = analyze(&g);
122        let rules: Vec<_> = f.iter().map(|x| x.rule.as_str()).collect();
123        assert!(rules.contains(&"hardcoded-secret"), "got {rules:?}");
124        assert!(rules.contains(&"subprocess-shell-true"), "got {rules:?}");
125        assert!(f.iter().all(|x| x.category == Category::Security));
126        std::fs::remove_dir_all(&d).ok();
127    }
128
129    #[test]
130    fn surfaces_expanded_rules_with_cwe() {
131        let d = temp("sec2");
132        write(
133            &d,
134            "__init__.py",
135            "import hashlib, os, random\nhashlib.md5(b'x')\nos.system(cmd)\nrandom.random()\ncur.execute(f\"select {x}\")\nrequests.get(url)\n",
136        );
137        let files = discover_python_files(&d);
138        let g = ModuleGraph::build(&d, &files);
139        let f = analyze(&g);
140        let rules: Vec<_> = f.iter().map(|x| x.rule.as_str()).collect();
141        for expected in [
142            "weak-hash",
143            "subprocess-shell-true",
144            "insecure-random",
145            "sql-injection",
146            "request-without-timeout",
147        ] {
148            assert!(rules.contains(&expected), "missing {expected}: {rules:?}");
149        }
150        // CWE is surfaced in the reason.
151        assert!(f
152            .iter()
153            .find(|x| x.rule == "weak-hash")
154            .unwrap()
155            .reason
156            .contains("CWE-327"));
157        std::fs::remove_dir_all(&d).ok();
158    }
159
160    #[test]
161    fn surfaces_weak_cipher_with_cwe() {
162        let d = temp("sec3");
163        // Import-aliased weak cipher โ€” the real-world (bandit) idiom that the
164        // previous call-only matcher missed entirely.
165        write(
166            &d,
167            "__init__.py",
168            "from Crypto.Cipher import DES as d\ncipher = d.new(key, d.MODE_ECB)\n",
169        );
170        let files = discover_python_files(&d);
171        let g = ModuleGraph::build(&d, &files);
172        let f = analyze(&g);
173        let wc = f
174            .iter()
175            .find(|x| x.rule == "weak-cipher")
176            .expect("weak-cipher should be flagged");
177        assert_eq!(wc.category, Category::Security);
178        assert!(wc.reason.contains("CWE-327"), "got {}", wc.reason);
179        std::fs::remove_dir_all(&d).ok();
180    }
181}