Skip to main content

rippy_cli/
python_safety.rs

1//! Heuristic safety analysis for inline Python source code.
2//!
3//! Scans for dangerous imports, builtins, and attribute accesses.
4//! Returns `true` if no dangerous patterns are found.
5
6const DANGEROUS_IMPORTS: &[&str] = &[
7    "os",
8    "subprocess",
9    "socket",
10    "pickle",
11    "shutil",
12    "ctypes",
13    "signal",
14    "pty",
15    "commands",
16    "webbrowser",
17    "tempfile",
18    "pathlib",
19    "io",
20    "multiprocessing",
21    "threading",
22];
23
24const DANGEROUS_BUILTINS: &[&str] = &[
25    "eval(",
26    "exec(",
27    "open(",
28    "__import__(",
29    "compile(",
30    "globals(",
31    "locals(",
32    "getattr(",
33    "setattr(",
34    "delattr(",
35    "breakpoint(",
36];
37
38const DANGEROUS_ATTRIBUTES: &[&str] = &[
39    ".system(",
40    ".popen(",
41    ".call(",
42    ".run(",
43    ".check_output(",
44    ".check_call(",
45    ".Popen(",
46    ".connect(",
47    ".write(",
48    ".remove(",
49    ".rmdir(",
50    ".unlink(",
51    ".rename(",
52    ".mkdir(",
53];
54
55/// Check whether inline Python source appears safe to execute.
56///
57/// This is a heuristic check — it may have false positives (blocking safe code)
58/// but should not have false negatives (allowing dangerous code).
59#[must_use]
60pub fn is_python_source_safe(source: &str) -> bool {
61    !has_dangerous_imports(source)
62        && !has_dangerous_builtins(source)
63        && !has_dangerous_attributes(source)
64}
65
66fn has_dangerous_imports(source: &str) -> bool {
67    for token in source.split([';', '\n']) {
68        let trimmed = token.trim();
69        // "import os" or "import os, subprocess"
70        if let Some(rest) = trimmed.strip_prefix("import ") {
71            for module in rest.split(',') {
72                let name = module.split_whitespace().next().unwrap_or("");
73                // Handle "import os.path" → check "os"
74                let top_level = name.split('.').next().unwrap_or("");
75                if DANGEROUS_IMPORTS.contains(&top_level) {
76                    return true;
77                }
78            }
79        }
80        // "from os import system" or "from os.path import join"
81        if let Some(rest) = trimmed.strip_prefix("from ")
82            && let Some(module_part) = rest.split_whitespace().next()
83        {
84            let top_level = module_part.split('.').next().unwrap_or("");
85            if DANGEROUS_IMPORTS.contains(&top_level) {
86                return true;
87            }
88        }
89    }
90    false
91}
92
93fn has_dangerous_builtins(source: &str) -> bool {
94    DANGEROUS_BUILTINS.iter().any(|b| source.contains(b))
95}
96
97fn has_dangerous_attributes(source: &str) -> bool {
98    DANGEROUS_ATTRIBUTES.iter().any(|a| source.contains(a))
99}
100
101#[cfg(test)]
102#[allow(clippy::unwrap_used)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn print_is_safe() {
108        assert!(is_python_source_safe("print(1)"));
109    }
110
111    #[test]
112    fn simple_math_is_safe() {
113        assert!(is_python_source_safe("x = 1 + 2; print(x)"));
114    }
115
116    #[test]
117    fn json_import_is_safe() {
118        assert!(is_python_source_safe("import json; print(json.dumps({}))"));
119    }
120
121    #[test]
122    fn sys_import_is_safe() {
123        assert!(is_python_source_safe("import sys; print(sys.version)"));
124    }
125
126    #[test]
127    fn os_import_is_dangerous() {
128        assert!(!is_python_source_safe("import os; os.system('rm -rf /')"));
129    }
130
131    #[test]
132    fn subprocess_import_is_dangerous() {
133        assert!(!is_python_source_safe("import subprocess"));
134    }
135
136    #[test]
137    fn from_os_import_is_dangerous() {
138        assert!(!is_python_source_safe("from os import system"));
139    }
140
141    #[test]
142    fn from_os_path_import_is_dangerous() {
143        assert!(!is_python_source_safe("from os.path import join"));
144    }
145
146    #[test]
147    fn import_os_path_is_dangerous() {
148        assert!(!is_python_source_safe("import os.path"));
149    }
150
151    #[test]
152    fn eval_is_dangerous() {
153        assert!(!is_python_source_safe("eval('code')"));
154    }
155
156    #[test]
157    fn exec_is_dangerous() {
158        assert!(!is_python_source_safe("exec('code')"));
159    }
160
161    #[test]
162    fn open_is_dangerous() {
163        assert!(!is_python_source_safe("open('file')"));
164    }
165
166    #[test]
167    fn dunder_import_is_dangerous() {
168        assert!(!is_python_source_safe("__import__('os')"));
169    }
170
171    #[test]
172    fn compile_is_dangerous() {
173        assert!(!is_python_source_safe("compile('code', '', 'exec')"));
174    }
175
176    #[test]
177    fn attribute_system_is_dangerous() {
178        assert!(!is_python_source_safe("foo.system('cmd')"));
179    }
180
181    #[test]
182    fn attribute_popen_is_dangerous() {
183        assert!(!is_python_source_safe("foo.popen('cmd')"));
184    }
185
186    #[test]
187    fn attribute_connect_is_dangerous() {
188        assert!(!is_python_source_safe("s.connect(('host', 80))"));
189    }
190
191    #[test]
192    fn pickle_import_is_dangerous() {
193        assert!(!is_python_source_safe("import pickle"));
194    }
195
196    #[test]
197    fn socket_import_is_dangerous() {
198        assert!(!is_python_source_safe("import socket"));
199    }
200
201    #[test]
202    fn multiple_safe_imports() {
203        assert!(is_python_source_safe(
204            "import json, math, re; print(json.dumps({'a': math.pi}))"
205        ));
206    }
207
208    #[test]
209    fn mixed_safe_and_dangerous_imports() {
210        assert!(!is_python_source_safe("import json, os"));
211    }
212
213    #[test]
214    fn empty_source_is_safe() {
215        assert!(is_python_source_safe(""));
216    }
217
218    #[test]
219    fn string_operations_safe() {
220        assert!(is_python_source_safe(
221            "s = 'hello'; print(s.upper(), len(s))"
222        ));
223    }
224
225    #[test]
226    fn list_comprehension_safe() {
227        assert!(is_python_source_safe("print([x**2 for x in range(10)])"));
228    }
229
230    #[test]
231    fn breakpoint_is_dangerous() {
232        assert!(!is_python_source_safe("breakpoint()"));
233    }
234
235    #[test]
236    fn getattr_is_dangerous() {
237        assert!(!is_python_source_safe("getattr(obj, 'method')"));
238    }
239
240    #[test]
241    fn multiprocessing_is_dangerous() {
242        assert!(!is_python_source_safe("import multiprocessing"));
243    }
244}