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, col: 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 col,
90 evidence,
91 tier,
92 });
93 }
94
95 fn resolve_dotted(&self, rendered: &str) -> Option<String> {
98 let (root, rest) = match rendered.split_once('.') {
99 Some((r, rest)) => (r, Some(rest)),
100 None => (rendered, None),
101 };
102 let base = self.imports.resolve(root)?;
103 Some(match rest {
104 Some(rest) => format!("{base}.{rest}"),
105 None => base.to_string(),
106 })
107 }
108
109 fn is_imported_name(&self, name: &str) -> bool {
113 self.imports.resolve(name).is_some()
114 }
115}
116
117impl EffectSink for RiskSink<'_> {
118 fn on_call(&mut self, call: &Call) {
119 let Some(rendered) = render_expr(&call.func) else {
120 return;
121 };
122
123 let (line, col) = leftmost_name(&call.func)
125 .map(|n| {
126 self.span
127 .line_col(anchor_of_subslice(self.span.src(), n.value))
128 })
129 .unwrap_or((0, 0));
130
131 match rendered.as_str() {
133 "eval" => {
134 self.push(
135 RiskKind::DynamicCode,
136 Tier::Exact,
137 line,
138 col,
139 "eval(…) — dynamic code execution".into(),
140 );
141 return;
142 }
143 "exec" => {
144 self.push(
145 RiskKind::DynamicCode,
146 Tier::Exact,
147 line,
148 col,
149 "exec(…) — dynamic code execution".into(),
150 );
151 return;
152 }
153 "compile" => {
154 self.push(
155 RiskKind::DynamicCode,
156 Tier::Exact,
157 line,
158 col,
159 "compile(…) — dynamic code compilation".into(),
160 );
161 return;
162 }
163 "__import__" => {
164 self.push(
165 RiskKind::DynamicCode,
166 Tier::Exact,
167 line,
168 col,
169 "__import__(…) — dynamic import".into(),
170 );
171 return;
172 }
173 _ => {}
174 }
175
176 if rendered == "setattr" {
181 if let Some(first_arg) = call.args.first() {
182 if let Expression::Name(n) = &first_arg.value
183 && self.is_imported_name(n.value)
184 {
185 self.push(
186 RiskKind::DynamicCode,
187 Tier::Heuristic,
188 line,
189 col,
190 format!("setattr({}, …) — monkey-patch on imported name", n.value),
191 );
192 }
193 }
194 return;
195 }
196
197 let resolved = self.resolve_dotted(&rendered);
199
200 if let Some(ref full) = resolved {
203 let root = full.split('.').next().unwrap_or(full.as_str());
204 if root == "subprocess" && has_shell_true(call) {
205 self.push(
206 RiskKind::DynamicCode,
207 Tier::Path,
208 line,
209 col,
210 "subprocess(shell=True) — shell-injection surface".into(),
211 );
212 return;
213 }
214 }
215
216 if let Some(ref full) = resolved {
218 if matches!(full.as_str(), "pickle.load" | "pickle.loads") {
219 self.push(
220 RiskKind::DynamicCode,
221 Tier::Path,
222 line,
223 col,
224 format!("{full}(…) — unsafe deserialization"),
225 );
226 return;
227 }
228 }
229
230 if let Some(ref full) = resolved {
232 if full == "yaml.load" {
233 self.push(
234 RiskKind::DynamicCode,
235 Tier::Path,
236 line,
237 col,
238 "yaml.load(…) — unsafe YAML deserialization (use safe_load)".into(),
239 );
240 return;
241 }
242 }
243
244 if let Some(ref full) = resolved
246 && full == "importlib.import_module"
247 {
248 self.push(
249 RiskKind::DynamicCode,
250 Tier::Path,
251 line,
252 col,
253 "importlib.import_module(…) — dynamic import".into(),
254 );
255 }
256 }
257
258 fn on_assert(&mut self, _assert: &Assert) {}
260 fn on_raise(&mut self, _raise: &Raise) {}
261 fn on_assign_target(&mut self, _target: &AssignTargetExpression, _is_aug: bool) {}
262}
263
264fn has_shell_true(call: &Call) -> bool {
268 call.args.iter().any(|arg| is_shell_true_kwarg(arg))
269}
270
271fn is_shell_true_kwarg(arg: &Arg) -> bool {
273 let Some(kw) = &arg.keyword else { return false };
274 if kw.value != "shell" {
275 return false;
276 }
277 matches!(
278 &arg.value,
279 Expression::Name(n) if n.value == "True"
280 )
281}
282
283#[cfg(test)]
286mod tests {
287 use super::*;
288 use crate::functions;
289 use crate::imports::Imports;
290 use crate::source::SpanIndex;
291 use std::collections::HashMap;
292
293 fn risk_features(name: &str) -> HashMap<String, Vec<String>> {
296 let src = std::fs::read_to_string(format!("tests/fixtures/{name}.py")).unwrap();
297 let module = libcst_native::parse_module(&src, None).unwrap();
298 let imports = Imports::build(&module);
299 let span = SpanIndex::new(&src);
300 let anchors = crate::source::lambda_anchors(&src).expect("tokenize must succeed");
301 let (units, _) = functions::collect(&module, &src, &span, &anchors);
302 let mut out: HashMap<String, Vec<String>> = HashMap::new();
303 for unit in &units {
304 let features = detect(unit, &imports, &span, "");
305 out.insert(
306 unit.symbol.clone(),
307 features.iter().map(|r| r.kind.wire().to_string()).collect(),
308 );
309 }
310 out
311 }
312
313 #[test]
314 fn detects_dynamic_code_and_shell() {
315 let r = risk_features("risk");
316 assert!(r["dyn"].contains(&"dynamic.code".to_string()));
317 assert!(r["deserialize"].contains(&"dynamic.code".to_string()));
318 assert!(r["shell"].contains(&"dynamic.code".to_string())); }
320
321 #[test]
322 fn detects_compile_and_dunder_import() {
323 let r = risk_features("risk");
324 assert!(
326 r["uses_compile"].contains(&"dynamic.code".to_string()),
327 "compile() must emit dynamic.code"
328 );
329 assert!(
331 r["uses_dunder_import"].contains(&"dynamic.code".to_string()),
332 "__import__() must emit dynamic.code"
333 );
334 }
335
336 #[test]
337 fn detects_yaml_load_but_not_safe_load() {
338 let r = risk_features("risk");
339 assert!(
341 r["unsafe_yaml"].contains(&"dynamic.code".to_string()),
342 "yaml.load() must emit dynamic.code"
343 );
344 assert!(
346 !r["safe_yaml"].contains(&"dynamic.code".to_string()),
347 "yaml.safe_load() must NOT emit dynamic.code"
348 );
349 }
350
351 #[test]
352 fn detects_importlib_import_module() {
353 let r = risk_features("risk");
354 assert!(
356 r["dynamic_import"].contains(&"dynamic.code".to_string()),
357 "importlib.import_module() must emit dynamic.code"
358 );
359 }
360
361 #[test]
362 fn detects_setattr_monkey_patch_on_imported_name_only() {
363 let r = risk_features("risk");
364 assert!(
366 r["monkey_patch"].contains(&"dynamic.code".to_string()),
367 "setattr on imported name must emit dynamic.code"
368 );
369 assert!(
371 !r["plain_setattr"].contains(&"dynamic.code".to_string()),
372 "setattr on non-imported name must NOT emit dynamic.code"
373 );
374 }
375}