1use 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 "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 "dangerous-eval" | "unsafe-deserialization" | "sql-injection" => Confidence::Uncertain,
22 "insecure-random" | "request-without-timeout" | "try-except-pass" => Confidence::Uncertain,
24 "hardcoded-secret" => Confidence::Likely,
26 _ => Confidence::Likely,
27 }
28}
29
30fn 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
58pub 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 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 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}