Skip to main content

agentshield/parser/python/
mod.rs

1use std::path::{Path, PathBuf};
2
3use super::{LanguageParser, ParsedFile};
4use crate::error::Result;
5use crate::ir::Language;
6#[cfg(test)]
7use crate::ir::ArgumentSource;
8
9pub struct PythonParser;
10
11pub mod classify;
12pub mod defs;
13pub mod patterns;
14pub mod scanner;
15
16use defs::{collect_function_defs_and_params, collect_http_client_vars, collect_sanitizer_vars};
17use scanner::scan_python_source;
18
19impl LanguageParser for PythonParser {
20    fn language(&self) -> Language {
21        Language::Python
22    }
23
24    fn parse_file(&self, path: &Path, content: &str) -> Result<ParsedFile> {
25        let mut parsed = ParsedFile::default();
26        let file_path = PathBuf::from(path);
27
28        // Detect sanitizer assignments: safe_path = validate_path(x)
29        collect_sanitizer_vars(content, &mut parsed);
30
31        // Collect function parameter names + FunctionDef entries
32        let param_names = collect_function_defs_and_params(content, &file_path, &mut parsed);
33
34        // Collect variable names bound to HTTP clients via async context managers
35        let http_client_vars = collect_http_client_vars(content);
36
37        // Scan line by line for patterns and call sites
38        scan_python_source(
39            content,
40            &file_path,
41            &param_names,
42            &http_client_vars,
43            &mut parsed,
44        );
45
46        Ok(parsed)
47    }
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53
54    #[test]
55    fn detects_subprocess_with_param() {
56        let code = r#"
57def handle(cmd: str):
58    subprocess.run(cmd, shell=True)
59"#;
60        let parsed = PythonParser.parse_file(Path::new("test.py"), code).unwrap();
61        assert_eq!(parsed.commands.len(), 1);
62        assert!(matches!(
63            parsed.commands[0].command_arg,
64            ArgumentSource::Parameter { .. }
65        ));
66    }
67
68    #[test]
69    fn detects_requests_get_with_param() {
70        let code = r#"
71def fetch(url: str):
72    requests.get(url)
73"#;
74        let parsed = PythonParser.parse_file(Path::new("test.py"), code).unwrap();
75        assert_eq!(parsed.network_operations.len(), 1);
76        assert!(matches!(
77            parsed.network_operations[0].url_arg,
78            ArgumentSource::Parameter { .. }
79        ));
80    }
81
82    #[test]
83    fn safe_literal_not_flagged_as_param() {
84        let code = r#"
85def fetch():
86    requests.get("https://api.example.com")
87"#;
88        let parsed = PythonParser.parse_file(Path::new("test.py"), code).unwrap();
89        assert_eq!(parsed.network_operations.len(), 1);
90        assert!(matches!(
91            parsed.network_operations[0].url_arg,
92            ArgumentSource::Literal(_)
93        ));
94    }
95
96    #[test]
97    fn incomplete_quote_argument_is_unknown_not_panic() {
98        let code = r#"
99def fetch():
100    requests.get(
101        "
102    )
103"#;
104        let parsed = PythonParser.parse_file(Path::new("test.py"), code).unwrap();
105        assert_eq!(parsed.network_operations.len(), 1);
106        assert!(matches!(
107            parsed.network_operations[0].url_arg,
108            ArgumentSource::Unknown
109        ));
110    }
111
112    #[test]
113    fn detects_env_var_access() {
114        let code = r#"
115key = os.environ["AWS_SECRET_ACCESS_KEY"]
116"#;
117        let parsed = PythonParser.parse_file(Path::new("test.py"), code).unwrap();
118        assert_eq!(parsed.env_accesses.len(), 1);
119        assert!(parsed.env_accesses[0].is_sensitive);
120    }
121
122    #[test]
123    fn detects_eval() {
124        let code = r#"
125def run(code):
126    eval(code)
127"#;
128        let parsed = PythonParser.parse_file(Path::new("test.py"), code).unwrap();
129        assert_eq!(parsed.dynamic_exec.len(), 1);
130        assert!(matches!(
131            parsed.dynamic_exec[0].code_arg,
132            ArgumentSource::Parameter { .. }
133        ));
134    }
135
136    #[test]
137    fn detects_httpx_async_client_get() {
138        let code = r#"
139async def fetch(url: str):
140    async with httpx.AsyncClient() as client:
141        response = await client.get(url)
142"#;
143        let parsed = PythonParser.parse_file(Path::new("test.py"), code).unwrap();
144        assert_eq!(parsed.network_operations.len(), 1);
145        assert_eq!(parsed.network_operations[0].function, "client.get");
146        assert!(matches!(
147            parsed.network_operations[0].url_arg,
148            ArgumentSource::Parameter { .. }
149        ));
150    }
151
152    #[test]
153    fn detects_aiohttp_client_session_post() {
154        let code = r#"
155async def send_data(url: str, data):
156    async with aiohttp.ClientSession() as session:
157        await session.post(url, json=data)
158"#;
159        let parsed = PythonParser.parse_file(Path::new("test.py"), code).unwrap();
160        assert_eq!(parsed.network_operations.len(), 1);
161        assert_eq!(parsed.network_operations[0].function, "session.post");
162        assert!(parsed.network_operations[0].sends_data);
163    }
164
165    #[test]
166    fn detects_gitpython_command_execution() {
167        let code = r#"
168def git_log(repo, args):
169    repo.git.log(*args)
170"#;
171        let parsed = PythonParser.parse_file(Path::new("test.py"), code).unwrap();
172        assert_eq!(parsed.commands.len(), 1);
173        assert_eq!(parsed.commands[0].function, "repo.git.log");
174    }
175
176    #[test]
177    fn detects_gitpython_add_with_user_files() {
178        let code = r#"
179def stage_files(repo, files):
180    repo.git.add("--", *files)
181"#;
182        let parsed = PythonParser.parse_file(Path::new("test.py"), code).unwrap();
183        assert_eq!(parsed.commands.len(), 1);
184        assert_eq!(parsed.commands[0].function, "repo.git.add");
185    }
186
187    #[test]
188    fn no_false_positive_on_non_client_get() {
189        let code = r#"
190def process():
191    result = cache.get("key")
192"#;
193        let parsed = PythonParser.parse_file(Path::new("test.py"), code).unwrap();
194        assert!(parsed.network_operations.is_empty());
195    }
196
197    #[test]
198    fn detects_multiline_async_client_get() {
199        // Real-world pattern from the MCP fetch server
200        let code = r#"
201async def fetch_url(url: str):
202    async with AsyncClient(proxies=proxy_url) as client:
203        response = await client.get(
204            url,
205            follow_redirects=True,
206            headers={"User-Agent": user_agent},
207        )
208"#;
209        let parsed = PythonParser.parse_file(Path::new("test.py"), code).unwrap();
210        assert_eq!(
211            parsed.network_operations.len(),
212            1,
213            "should detect multi-line client.get() call"
214        );
215        assert_eq!(parsed.network_operations[0].function, "client.get");
216        assert!(matches!(
217            parsed.network_operations[0].url_arg,
218            ArgumentSource::Parameter { .. }
219        ));
220    }
221
222    #[test]
223    fn detects_multiline_subprocess_run() {
224        let code = r#"
225def execute(cmd: str):
226    subprocess.run(
227        cmd,
228        shell=True,
229        capture_output=True,
230    )
231"#;
232        let parsed = PythonParser.parse_file(Path::new("test.py"), code).unwrap();
233        assert_eq!(
234            parsed.commands.len(),
235            1,
236            "should detect multi-line subprocess.run() call"
237        );
238    }
239
240    // ── Cross-file support tests ──
241
242    #[test]
243    fn extracts_python_function_defs() {
244        let code = r#"
245def read_file(path: str) -> str:
246    with open(path) as f:
247        return f.read()
248
249def _internal_helper(x):
250    return x + 1
251"#;
252        let parsed = PythonParser.parse_file(Path::new("lib.py"), code).unwrap();
253        assert!(parsed.function_defs.len() >= 2);
254
255        let read_file = parsed.function_defs.iter().find(|d| d.name == "read_file");
256        assert!(read_file.is_some());
257        assert!(read_file.unwrap().is_exported); // no underscore prefix
258        assert_eq!(read_file.unwrap().params, vec!["path"]);
259
260        let helper = parsed
261            .function_defs
262            .iter()
263            .find(|d| d.name == "_internal_helper");
264        assert!(helper.is_some());
265        assert!(!helper.unwrap().is_exported); // underscore prefix = private
266    }
267
268    #[test]
269    fn records_nested_and_method_params_with_locations() {
270        let code = r#"
271class Handler:
272    def handle(self, url: str):
273        def inner(path: str):
274            return open(path)
275        return inner(url)
276"#;
277        let parsed = PythonParser
278            .parse_file(Path::new("handler.py"), code)
279            .unwrap();
280
281        let handle = parsed
282            .function_defs
283            .iter()
284            .find(|def| def.name == "handle")
285            .unwrap();
286        let inner = parsed
287            .function_defs
288            .iter()
289            .find(|def| def.name == "inner")
290            .unwrap();
291        assert_eq!(handle.params, vec!["url"]);
292        assert_eq!(inner.params, vec!["path"]);
293        assert!(
294            parsed
295                .function_params
296                .iter()
297                .any(|param| param.function_name == "inner" && param.param_name == "path")
298        );
299        assert_eq!(inner.location.end_line, Some(inner.location.line));
300
301        let inner_call = parsed
302            .call_sites
303            .iter()
304            .find(|site| site.callee == "inner")
305            .unwrap();
306        assert_eq!(inner_call.caller.as_deref(), Some("handle"));
307        assert_eq!(inner_call.location.end_line, Some(inner_call.location.line));
308        assert!(inner_call.location.column > 0);
309    }
310
311    #[test]
312    fn detects_python_sanitizer_assignment() {
313        let code = r#"
314def handler(raw_path: str):
315    safe_path = os.path.realpath(raw_path)
316    with open(safe_path) as f:
317        return f.read()
318"#;
319        let parsed = PythonParser.parse_file(Path::new("test.py"), code).unwrap();
320        assert!(parsed.sanitized_vars.contains("safe_path"));
321    }
322
323    #[test]
324    fn extracts_python_call_sites() {
325        let code = r#"
326def handler(args):
327    safe_path = os.path.realpath(args.path)
328    content = read_file(safe_path)
329    return content
330"#;
331        let parsed = PythonParser.parse_file(Path::new("test.py"), code).unwrap();
332        let rf_call = parsed.call_sites.iter().find(|cs| cs.callee == "read_file");
333        assert!(rf_call.is_some(), "Should find read_file call site");
334        let rf = rf_call.unwrap();
335        assert!(!rf.arguments.is_empty());
336        assert!(
337            matches!(&rf.arguments[0], ArgumentSource::Sanitized { .. }),
338            "safe_path should be Sanitized, got: {:?}",
339            rf.arguments[0]
340        );
341    }
342
343    #[test]
344    fn urlparse_assignment_is_not_sanitized_for_ssrf() {
345        let code = r#"
346from urllib.parse import urlparse
347import requests
348
349def handler(url: str):
350    parsed_url = urlparse(url)
351    return requests.get(parsed_url)
352"#;
353        let parsed = PythonParser.parse_file(Path::new("test.py"), code).unwrap();
354
355        assert!(!parsed.sanitized_vars.contains("parsed_url"));
356        assert_eq!(parsed.network_operations.len(), 1);
357        assert!(
358            parsed.network_operations[0].url_arg.is_tainted(),
359            "urlparse output must remain tainted for network sinks"
360        );
361    }
362
363    #[test]
364    fn redaction_assignment_is_not_sanitized_for_file_paths() {
365        let code = r#"
366def redactSecret(value: str) -> str:
367    return value.replace("secret", "[REDACTED]")
368
369def handler(path: str):
370    redacted_path = redactSecret(path)
371    return open(redacted_path).read()
372"#;
373        let parsed = PythonParser.parse_file(Path::new("test.py"), code).unwrap();
374
375        assert!(!parsed.sanitized_vars.contains("redacted_path"));
376        assert_eq!(parsed.file_operations.len(), 1);
377        assert!(
378            parsed.file_operations[0].path_arg.is_tainted(),
379            "redaction output must remain tainted for file path sinks"
380        );
381    }
382}