agentshield/parser/python/
mod.rs1use 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 collect_sanitizer_vars(content, &mut parsed);
30
31 let param_names = collect_function_defs_and_params(content, &file_path, &mut parsed);
33
34 let http_client_vars = collect_http_client_vars(content);
36
37 scan_python_source(
39 content,
40 &file_path,
41 ¶m_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 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 #[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); 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); }
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}