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, 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    /// Resolve a rendered dotted name through the import table: split at the
95    /// first dot, resolve the root, re-attach the trailing path.
96    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    /// Return true if `name` is a name present in the import table (i.e. it
109    /// is an imported module or imported class-like). Used for the `setattr`
110    /// monkey-patch guard.
111    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        // ── line from the leftmost name anchor ──
123        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        // ── 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                    "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        // ── setattr monkey-patch guard ──────────────────────────────────────
173        // Flag `setattr(target, name, value)` only when `target` is an
174        // imported name — i.e. module or class re-binding (monkey-patching).
175        // Ordinary `setattr(obj, ...)` on non-imported objects is NOT flagged.
176        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        // ── Path-resolved through the import table ──────────────────────────
193        let resolved = self.resolve_dotted(&rendered);
194
195        // `subprocess.*(…, shell=True)` — dynamic.code RISK only (process.control
196        // effect is already emitted by calls::detect).
197        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        // `pickle.load` / `pickle.loads`
211        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        // `yaml.load` (NOT `yaml.safe_load`)
224        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        // `importlib.import_module`
237        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    // Risk detection does not classify assert/raise/assignment targets.
250    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
255// ─── shell=True detection ─────────────────────────────────────────────────────
256
257/// Return `true` when the call contains a `shell=True` keyword argument.
258fn has_shell_true(call: &Call) -> bool {
259    call.args.iter().any(|arg| is_shell_true_kwarg(arg))
260}
261
262/// Return `true` when `arg` is a `shell=True` keyword argument.
263fn 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// ─── tests ────────────────────────────────────────────────────────────────────
275
276#[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    /// Parse `tests/fixtures/<name>.py`, run `detect` per unit, and return a
285    /// `HashMap<symbol, Vec<risk_kind_wire_string>>`.
286    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())); // shell=True
310    }
311
312    #[test]
313    fn detects_compile_and_dunder_import() {
314        let r = risk_features("risk");
315        // compile(…) → dynamic.code (exact)
316        assert!(
317            r["uses_compile"].contains(&"dynamic.code".to_string()),
318            "compile() must emit dynamic.code"
319        );
320        // __import__(…) → dynamic.code (exact)
321        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        // yaml.load(…) → dynamic.code (path)
331        assert!(
332            r["unsafe_yaml"].contains(&"dynamic.code".to_string()),
333            "yaml.load() must emit dynamic.code"
334        );
335        // yaml.safe_load(…) → NO risk
336        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        // importlib.import_module(…) → dynamic.code (path)
346        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        // setattr(<imported module>, …) → dynamic.code (heuristic)
356        assert!(
357            r["monkey_patch"].contains(&"dynamic.code".to_string()),
358            "setattr on imported name must emit dynamic.code"
359        );
360        // setattr(<non-imported name>, …) → NO risk
361        assert!(
362            !r["plain_setattr"].contains(&"dynamic.code".to_string()),
363            "setattr on non-imported name must NOT emit dynamic.code"
364        );
365    }
366}