fxrank_lang_python/detect/
risk.rs1use fxrank_core::effect::{RiskFeature, RiskKind, Tier};
43use fxrank_core::score::weight_for_class;
44use libcst_native::{Arg, Assert, AssignTargetExpression, Call, Expression, Raise};
45
46use super::{
47 EffectSink,
48 expr::{leftmost_name, render_expr},
49 walk_own_body,
50};
51use crate::functions::FnUnit;
52use crate::imports::Imports;
53use crate::source::{SpanIndex, anchor_of_subslice};
54
55pub fn detect(unit: &FnUnit, imports: &Imports, span: &SpanIndex, path: &str) -> Vec<RiskFeature> {
63 let mut sink = RiskSink {
64 imports,
65 span,
66 path: path.to_owned(),
67 features: Vec::new(),
68 };
69 walk_own_body(unit, &mut sink);
70 sink.features
71}
72
73struct RiskSink<'a> {
74 imports: &'a Imports,
75 span: &'a SpanIndex<'a>,
76 path: String,
77 features: Vec<RiskFeature>,
78}
79
80impl RiskSink<'_> {
81 fn push(&mut self, kind: RiskKind, tier: Tier, line: usize, evidence: String) {
82 let class = kind.class();
83 self.features.push(RiskFeature {
84 kind,
85 class,
86 weight: weight_for_class(class),
87 path: self.path.clone(),
88 line,
89 evidence,
90 tier,
91 });
92 }
93
94 fn resolve_dotted(&self, rendered: &str) -> Option<String> {
97 let (root, rest) = match rendered.split_once('.') {
98 Some((r, rest)) => (r, Some(rest)),
99 None => (rendered, None),
100 };
101 let base = self.imports.resolve(root)?;
102 Some(match rest {
103 Some(rest) => format!("{base}.{rest}"),
104 None => base.to_string(),
105 })
106 }
107
108 fn is_imported_name(&self, name: &str) -> bool {
112 self.imports.resolve(name).is_some()
113 }
114}
115
116impl EffectSink for RiskSink<'_> {
117 fn on_call(&mut self, call: &Call) {
118 let Some(rendered) = render_expr(&call.func) else {
119 return;
120 };
121
122 let line = leftmost_name(&call.func)
124 .map(|n| {
125 self.span
126 .line_col(anchor_of_subslice(self.span.src(), n.value))
127 .0
128 })
129 .unwrap_or(0);
130
131 match rendered.as_str() {
133 "eval" => {
134 self.push(
135 RiskKind::DynamicCode,
136 Tier::Exact,
137 line,
138 "eval(…) — dynamic code execution".into(),
139 );
140 return;
141 }
142 "exec" => {
143 self.push(
144 RiskKind::DynamicCode,
145 Tier::Exact,
146 line,
147 "exec(…) — dynamic code execution".into(),
148 );
149 return;
150 }
151 "compile" => {
152 self.push(
153 RiskKind::DynamicCode,
154 Tier::Exact,
155 line,
156 "compile(…) — dynamic code compilation".into(),
157 );
158 return;
159 }
160 "__import__" => {
161 self.push(
162 RiskKind::DynamicCode,
163 Tier::Exact,
164 line,
165 "__import__(…) — dynamic import".into(),
166 );
167 return;
168 }
169 _ => {}
170 }
171
172 if rendered == "setattr" {
177 if let Some(first_arg) = call.args.first() {
178 if let Expression::Name(n) = &first_arg.value
179 && self.is_imported_name(n.value)
180 {
181 self.push(
182 RiskKind::DynamicCode,
183 Tier::Heuristic,
184 line,
185 format!("setattr({}, …) — monkey-patch on imported name", n.value),
186 );
187 }
188 }
189 return;
190 }
191
192 let resolved = self.resolve_dotted(&rendered);
194
195 if let Some(ref full) = resolved {
198 let root = full.split('.').next().unwrap_or(full.as_str());
199 if root == "subprocess" && has_shell_true(call) {
200 self.push(
201 RiskKind::DynamicCode,
202 Tier::Path,
203 line,
204 "subprocess(shell=True) — shell-injection surface".into(),
205 );
206 return;
207 }
208 }
209
210 if let Some(ref full) = resolved {
212 if matches!(full.as_str(), "pickle.load" | "pickle.loads") {
213 self.push(
214 RiskKind::DynamicCode,
215 Tier::Path,
216 line,
217 format!("{full}(…) — unsafe deserialization"),
218 );
219 return;
220 }
221 }
222
223 if let Some(ref full) = resolved {
225 if full == "yaml.load" {
226 self.push(
227 RiskKind::DynamicCode,
228 Tier::Path,
229 line,
230 "yaml.load(…) — unsafe YAML deserialization (use safe_load)".into(),
231 );
232 return;
233 }
234 }
235
236 if let Some(ref full) = resolved
238 && full == "importlib.import_module"
239 {
240 self.push(
241 RiskKind::DynamicCode,
242 Tier::Path,
243 line,
244 "importlib.import_module(…) — dynamic import".into(),
245 );
246 }
247 }
248
249 fn on_assert(&mut self, _assert: &Assert) {}
251 fn on_raise(&mut self, _raise: &Raise) {}
252 fn on_assign_target(&mut self, _target: &AssignTargetExpression, _is_aug: bool) {}
253}
254
255fn has_shell_true(call: &Call) -> bool {
259 call.args.iter().any(|arg| is_shell_true_kwarg(arg))
260}
261
262fn is_shell_true_kwarg(arg: &Arg) -> bool {
264 let Some(kw) = &arg.keyword else { return false };
265 if kw.value != "shell" {
266 return false;
267 }
268 matches!(
269 &arg.value,
270 Expression::Name(n) if n.value == "True"
271 )
272}
273
274#[cfg(test)]
277mod tests {
278 use super::*;
279 use crate::functions;
280 use crate::imports::Imports;
281 use crate::source::SpanIndex;
282 use std::collections::HashMap;
283
284 fn risk_features(name: &str) -> HashMap<String, Vec<String>> {
287 let src = std::fs::read_to_string(format!("tests/fixtures/{name}.py")).unwrap();
288 let module = libcst_native::parse_module(&src, None).unwrap();
289 let imports = Imports::build(&module);
290 let span = SpanIndex::new(&src);
291 let anchors = crate::source::lambda_anchors(&src).expect("tokenize must succeed");
292 let (units, _) = functions::collect(&module, &src, &span, &anchors);
293 let mut out: HashMap<String, Vec<String>> = HashMap::new();
294 for unit in &units {
295 let features = detect(unit, &imports, &span, "");
296 out.insert(
297 unit.symbol.clone(),
298 features.iter().map(|r| r.kind.wire().to_string()).collect(),
299 );
300 }
301 out
302 }
303
304 #[test]
305 fn detects_dynamic_code_and_shell() {
306 let r = risk_features("risk");
307 assert!(r["dyn"].contains(&"dynamic.code".to_string()));
308 assert!(r["deserialize"].contains(&"dynamic.code".to_string()));
309 assert!(r["shell"].contains(&"dynamic.code".to_string())); }
311
312 #[test]
313 fn detects_compile_and_dunder_import() {
314 let r = risk_features("risk");
315 assert!(
317 r["uses_compile"].contains(&"dynamic.code".to_string()),
318 "compile() must emit dynamic.code"
319 );
320 assert!(
322 r["uses_dunder_import"].contains(&"dynamic.code".to_string()),
323 "__import__() must emit dynamic.code"
324 );
325 }
326
327 #[test]
328 fn detects_yaml_load_but_not_safe_load() {
329 let r = risk_features("risk");
330 assert!(
332 r["unsafe_yaml"].contains(&"dynamic.code".to_string()),
333 "yaml.load() must emit dynamic.code"
334 );
335 assert!(
337 !r["safe_yaml"].contains(&"dynamic.code".to_string()),
338 "yaml.safe_load() must NOT emit dynamic.code"
339 );
340 }
341
342 #[test]
343 fn detects_importlib_import_module() {
344 let r = risk_features("risk");
345 assert!(
347 r["dynamic_import"].contains(&"dynamic.code".to_string()),
348 "importlib.import_module() must emit dynamic.code"
349 );
350 }
351
352 #[test]
353 fn detects_setattr_monkey_patch_on_imported_name_only() {
354 let r = risk_features("risk");
355 assert!(
357 r["monkey_patch"].contains(&"dynamic.code".to_string()),
358 "setattr on imported name must emit dynamic.code"
359 );
360 assert!(
362 !r["plain_setattr"].contains(&"dynamic.code".to_string()),
363 "setattr on non-imported name must NOT emit dynamic.code"
364 );
365 }
366}