Skip to main content

fxrank_lang_python/detect/
risk.rs

1//! Dynamic-code risk detection for the Python frontend.
2//!
3//! [`detect`] walks a function's own body for dangerous Python patterns and
4//! emits a [`RiskFeature`] per signal. This is the libcst analog of
5//! `fxrank-lang-ts`'s `detect/risk.rs`.
6//!
7//! # Dedup note — `type.escape` is NOT detected here
8//!
9//! The `Any`-family `type.escape` risk is OWNED by the coverage gate in
10//! `analyze_unit` (Task 9). This module detects `type.escape` only for the
11//! non-null-assertion signal in the TS frontend — there is no direct Python
12//! analog. Do not emit `type.escape` here.
13//!
14//! # Signals detected
15//!
16//! | signal                                      | `RiskKind`    | tier      |
17//! |---------------------------------------------|---------------|-----------|
18//! | `eval(…)`                                   | `DynamicCode` | exact     |
19//! | `exec(…)`                                   | `DynamicCode` | exact     |
20//! | `compile(…)`                                | `DynamicCode` | exact     |
21//! | `__import__(…)`                             | `DynamicCode` | exact     |
22//! | `pickle.load(…)` / `pickle.loads(…)`        | `DynamicCode` | path      |
23//! | `yaml.load(…)` (not `yaml.safe_load`)       | `DynamicCode` | path      |
24//! | `importlib.import_module(…)`                | `DynamicCode` | path      |
25//! | `setattr(<imported module/class>, …)`       | `DynamicCode` | heuristic |
26//! | `subprocess(…, shell=True)`                 | `DynamicCode` | path      |
27//!
28//! # Shell=True companion note
29//!
30//! Task 6 (`calls::detect`) already emits a `process.control` EFFECT for any
31//! `subprocess.*` call. This module adds the RISK (dynamic.code class 7) only
32//! when `shell=True` is a keyword argument — signalling shell-injection surface.
33//! Do NOT emit a second `process.control` effect here.
34//!
35//! # Setattr guard
36//!
37//! Only `setattr` whose first argument is a name that resolves to an imported
38//! module or imported class-like (i.e. present in the import table) is flagged
39//! as monkey-patching. Ordinary `setattr(obj, "x", v)` on a non-imported
40//! name is NOT flagged.
41
42use 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
55/// Detect dynamic-code risk features in `unit`'s own body.
56///
57/// `path` is the source file path to embed in each emitted [`RiskFeature`].
58///
59/// Returns a `Vec<RiskFeature>` for each dangerous dynamic-code pattern found.
60/// The `type.escape` risk is intentionally NOT detected here — it is owned by
61/// the coverage gate in `analyze_unit`.
62pub 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    /// Resolve a rendered dotted name through the import table: split at the
96    /// first dot, resolve the root, re-attach the trailing path.
97    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    /// Return true if `name` is a name present in the import table (i.e. it
110    /// is an imported module or imported class-like). Used for the `setattr`
111    /// monkey-patch guard.
112    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        // ── (line, col) from the leftmost name anchor ──
124        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        // ── Bare builtin names — eval/exec/compile/__import__ ──────────────
132        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        // ── setattr monkey-patch guard ──────────────────────────────────────
177        // Flag `setattr(target, name, value)` only when `target` is an
178        // imported name — i.e. module or class re-binding (monkey-patching).
179        // Ordinary `setattr(obj, ...)` on non-imported objects is NOT flagged.
180        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        // ── Path-resolved through the import table ──────────────────────────
198        let resolved = self.resolve_dotted(&rendered);
199
200        // `subprocess.*(…, shell=True)` — dynamic.code RISK only (process.control
201        // effect is already emitted by calls::detect).
202        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        // `pickle.load` / `pickle.loads`
217        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        // `yaml.load` (NOT `yaml.safe_load`)
231        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        // `importlib.import_module`
245        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    // Risk detection does not classify assert/raise/assignment targets.
259    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
264// ─── shell=True detection ─────────────────────────────────────────────────────
265
266/// Return `true` when the call contains a `shell=True` keyword argument.
267fn has_shell_true(call: &Call) -> bool {
268    call.args.iter().any(|arg| is_shell_true_kwarg(arg))
269}
270
271/// Return `true` when `arg` is a `shell=True` keyword argument.
272fn 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// ─── tests ────────────────────────────────────────────────────────────────────
284
285#[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    /// Parse `tests/fixtures/<name>.py`, run `detect` per unit, and return a
294    /// `HashMap<symbol, Vec<risk_kind_wire_string>>`.
295    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())); // shell=True
319    }
320
321    #[test]
322    fn detects_compile_and_dunder_import() {
323        let r = risk_features("risk");
324        // compile(…) → dynamic.code (exact)
325        assert!(
326            r["uses_compile"].contains(&"dynamic.code".to_string()),
327            "compile() must emit dynamic.code"
328        );
329        // __import__(…) → dynamic.code (exact)
330        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        // yaml.load(…) → dynamic.code (path)
340        assert!(
341            r["unsafe_yaml"].contains(&"dynamic.code".to_string()),
342            "yaml.load() must emit dynamic.code"
343        );
344        // yaml.safe_load(…) → NO risk
345        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        // importlib.import_module(…) → dynamic.code (path)
355        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        // setattr(<imported module>, …) → dynamic.code (heuristic)
365        assert!(
366            r["monkey_patch"].contains(&"dynamic.code".to_string()),
367            "setattr on imported name must emit dynamic.code"
368        );
369        // setattr(<non-imported name>, …) → NO risk
370        assert!(
371            !r["plain_setattr"].contains(&"dynamic.code".to_string()),
372            "setattr on non-imported name must NOT emit dynamic.code"
373        );
374    }
375}