1use std::path::{Path, PathBuf};
2
3use once_cell::sync::Lazy;
4use regex::Regex;
5
6use super::{CallSite, FunctionDef, FunctionParam, LanguageParser, ParsedFile};
7use crate::analysis::cross_file::{SanitizerCategory, sanitizer_category, sanitizer_label};
8use crate::analysis::sensitivity::looks_sensitive_name;
9use crate::error::Result;
10use crate::ir::execution_surface::*;
11use crate::ir::{ArgumentSource, Language, SourceLocation};
12
13pub struct PythonParser;
14
15static SUBPROCESS_PATTERNS: Lazy<Vec<&str>> = Lazy::new(|| {
17 vec![
18 "subprocess.run",
19 "subprocess.call",
20 "subprocess.check_call",
21 "subprocess.check_output",
22 "subprocess.Popen",
23 "os.system",
24 "os.popen",
25 "os.exec",
26 "os.execv",
27 "os.execve",
28 "os.execvp",
29 ]
30});
31
32static GITPYTHON_RE: Lazy<Regex> = Lazy::new(|| {
35 Regex::new(r"(?m)(\w+)\.git\.(\w+)\s*\(([^)]*)\)").expect("static regex pattern is valid")
36});
37
38static NETWORK_PATTERNS: Lazy<Vec<&str>> = Lazy::new(|| {
39 vec![
40 "requests.get",
41 "requests.post",
42 "requests.put",
43 "requests.patch",
44 "requests.delete",
45 "requests.head",
46 "requests.request",
47 "urllib.request.urlopen",
48 "httpx.get",
49 "httpx.post",
50 "httpx.put",
51 ]
55});
56
57static HTTP_CLIENT_METHODS: Lazy<Vec<&str>> = Lazy::new(|| {
62 vec![
63 "get", "post", "put", "patch", "delete", "head", "options", "request", "fetch", "send",
64 ]
65});
66
67static HTTP_CLIENT_CTX_RE: Lazy<Regex> = Lazy::new(|| {
71 Regex::new(
72 r"(?m)async\s+with\s+(?:\w+\.)*(?:AsyncClient|ClientSession)\s*\([^)]*\)\s+as\s+(\w+)",
73 )
74 .expect("static regex pattern is valid")
75});
76
77static DYNAMIC_EXEC_PATTERNS: Lazy<Vec<&str>> =
78 Lazy::new(|| vec!["eval", "exec", "compile", "__import__"]);
79
80static FILE_READ_PATTERNS: Lazy<Vec<&str>> = Lazy::new(|| vec!["open", "pathlib.Path"]);
81
82static CALL_RE: Lazy<Regex> = Lazy::new(|| {
84 Regex::new(r"(?m)(\w+(?:\.\w+)*)\s*\(([^)]*)\)").expect("static regex pattern is valid")
85});
86
87static PARTIAL_CALL_RE: Lazy<Regex> =
91 Lazy::new(|| Regex::new(r"(\w+(?:\.\w+)*)\s*\(\s*$").expect("static regex pattern is valid"));
92
93static ENV_ACCESS_RE: Lazy<Regex> = Lazy::new(|| {
95 Regex::new(
96 r#"(?m)os\.(?:environ\s*(?:\[\s*["']([^"']+)["']\s*\]|\.get\s*\(\s*["']([^"']+)["'])|getenv\s*\(\s*["']([^"']+)["']\s*\))"#,
97 )
98 .expect("static regex pattern is valid")
99});
100
101static FUNC_DEF_RE: Lazy<Regex> = Lazy::new(|| {
103 Regex::new(r"(?m)^\s*(?:async\s+)?def\s+(\w+)\s*\(([^)]*)\)")
104 .expect("static regex pattern is valid")
105});
106
107static SANITIZER_ASSIGN_RE: Lazy<Regex> = Lazy::new(|| {
109 Regex::new(r"(\w+)\s*=\s*(?:await\s+)?(\w+(?:\.\w+)*)\s*\(")
110 .expect("static regex pattern is valid")
111});
112
113impl LanguageParser for PythonParser {
114 fn language(&self) -> Language {
115 Language::Python
116 }
117
118 fn parse_file(&self, path: &Path, content: &str) -> Result<ParsedFile> {
119 let mut parsed = ParsedFile::default();
120 let file_path = PathBuf::from(path);
121
122 for cap in SANITIZER_ASSIGN_RE.captures_iter(content) {
124 let var_name = &cap[1];
125 let func_name = &cap[2];
126 if sanitizer_category(func_name)
127 .is_some_and(|category| !matches!(category, SanitizerCategory::Redaction))
128 {
129 parsed.sanitized_vars.insert(var_name.to_string());
130 if let Some(label) = sanitizer_label(func_name) {
131 parsed
132 .sanitized_vars
133 .insert(sanitized_var_marker(var_name, &label));
134 }
135 }
136 }
137
138 let mut param_names = std::collections::HashSet::new();
140 for cap in FUNC_DEF_RE.captures_iter(content) {
141 let func_name = &cap[1];
142 let params_str = &cap[2];
143 let is_exported = !func_name.starts_with('_');
145 let func_line = content[..cap.get(0).map(|m| m.start()).unwrap_or(0)]
146 .lines()
147 .count()
148 + 1;
149 let function_location = loc(&file_path, func_line);
150
151 let mut func_params = Vec::new();
152 for param in params_str.split(',') {
153 let param = param.trim().split(':').next().unwrap_or("").trim();
154 let param = param.split('=').next().unwrap_or("").trim();
155 if !param.is_empty() && param != "self" && param != "cls" {
156 param_names.insert(param.to_string());
157 func_params.push(param.to_string());
158 parsed.function_params.push(FunctionParam {
159 function_name: func_name.to_string(),
160 param_name: param.to_string(),
161 location: function_location.clone(),
162 });
163 }
164 }
165
166 parsed.function_defs.push(FunctionDef {
167 name: func_name.to_string(),
168 params: func_params,
169 is_exported,
170 location: function_location,
171 });
172 }
173
174 let mut http_client_vars = std::collections::HashSet::new();
177 for cap in HTTP_CLIENT_CTX_RE.captures_iter(content) {
178 http_client_vars.insert(cap[1].to_string());
179 }
180
181 let lines: Vec<&str> = content.lines().collect();
183
184 let mut current_functions: Vec<(String, usize)> = Vec::new();
187 for (line_idx, line) in lines.iter().enumerate() {
188 let line_num = line_idx + 1;
189 let trimmed = line.trim();
190 let indent = line.chars().take_while(|c| c.is_whitespace()).count();
191
192 if !trimmed.is_empty() {
193 while current_functions
194 .last()
195 .is_some_and(|(_, function_indent)| indent <= *function_indent)
196 {
197 current_functions.pop();
198 }
199 }
200 if let Some(cap) = FUNC_DEF_RE.captures(line) {
201 current_functions.push((cap[1].to_string(), indent));
202 }
203
204 if trimmed.starts_with('#') {
206 continue;
207 }
208
209 if FUNC_DEF_RE.is_match(line) {
213 continue;
214 }
215
216 for cap in ENV_ACCESS_RE.captures_iter(line) {
218 let var_name = cap
219 .get(1)
220 .or_else(|| cap.get(2))
221 .or_else(|| cap.get(3))
222 .map(|m| m.as_str().to_string())
223 .unwrap_or_default();
224 let is_sensitive = looks_sensitive_name(&var_name);
225 parsed.env_accesses.push(EnvAccess {
226 var_name: ArgumentSource::Literal(var_name),
227 is_sensitive,
228 location: loc(&file_path, line_num),
229 });
230 }
231
232 for cap in CALL_RE.captures_iter(line) {
234 let func_name = &cap[1];
235 let args_str = &cap[2];
236 let call_range = cap.get(0).expect("call capture");
237 let call_location = loc_from_range(
238 &file_path,
239 line_num,
240 line,
241 call_range.start(),
242 call_range.end(),
243 );
244
245 let arg_source = classify_argument(args_str, ¶m_names, &parsed.sanitized_vars);
246
247 let all_args = args_str
249 .split(',')
250 .map(|a| classify_argument(a.trim(), ¶m_names, &parsed.sanitized_vars))
251 .collect::<Vec<_>>();
252 parsed.call_sites.push(CallSite {
253 callee: func_name.to_string(),
254 arguments: all_args,
255 caller: current_functions.last().map(|(name, _)| name.clone()),
256 location: call_location.clone(),
257 });
258
259 if SUBPROCESS_PATTERNS
261 .iter()
262 .any(|p| func_name.ends_with(p) || func_name == *p)
263 {
264 parsed.commands.push(CommandInvocation {
265 function: func_name.to_string(),
266 command_arg: arg_source.clone(),
267 location: call_location.clone(),
268 });
269 }
270
271 if NETWORK_PATTERNS
273 .iter()
274 .any(|p| func_name.ends_with(p) || func_name == *p)
275 {
276 let sends_data = func_name.contains("post")
277 || func_name.contains("put")
278 || func_name.contains("patch")
279 || args_str.contains("data=")
280 || args_str.contains("json=");
281 let method = if func_name.contains("get") {
282 Some("GET".into())
283 } else if func_name.contains("post") {
284 Some("POST".into())
285 } else if func_name.contains("put") {
286 Some("PUT".into())
287 } else {
288 None
289 };
290 parsed.network_operations.push(NetworkOperation {
291 function: func_name.to_string(),
292 url_arg: arg_source.clone(),
293 method,
294 sends_data,
295 location: call_location.clone(),
296 });
297 }
298
299 if DYNAMIC_EXEC_PATTERNS.contains(&func_name) {
301 parsed.dynamic_exec.push(DynamicExec {
302 function: func_name.to_string(),
303 code_arg: arg_source.clone(),
304 location: call_location.clone(),
305 });
306 }
307
308 if FILE_READ_PATTERNS
310 .iter()
311 .any(|p| func_name.ends_with(p) || func_name == *p)
312 {
313 let op_type = if args_str.contains("'w")
314 || args_str.contains("\"w")
315 || args_str.contains("'a")
316 || args_str.contains("\"a")
317 {
318 FileOpType::Write
319 } else {
320 FileOpType::Read
321 };
322 parsed.file_operations.push(FileOperation {
323 operation: op_type,
324 path_arg: arg_source.clone(),
325 location: call_location.clone(),
326 });
327 }
328
329 if func_name.contains('.') {
333 let parts: Vec<&str> = func_name.rsplitn(2, '.').collect();
334 if parts.len() == 2 {
335 let method = parts[0];
336 let obj = parts[1];
337 if http_client_vars.contains(obj) && HTTP_CLIENT_METHODS.contains(&method) {
338 let sends_data = method == "post"
339 || method == "put"
340 || method == "patch"
341 || args_str.contains("data=")
342 || args_str.contains("json=");
343 let http_method = match method {
344 "get" => Some("GET".into()),
345 "post" => Some("POST".into()),
346 "put" => Some("PUT".into()),
347 "delete" => Some("DELETE".into()),
348 "head" => Some("HEAD".into()),
349 "patch" => Some("PATCH".into()),
350 _ => None,
351 };
352 parsed.network_operations.push(NetworkOperation {
353 function: func_name.to_string(),
354 url_arg: arg_source.clone(),
355 method: http_method,
356 sends_data,
357 location: call_location.clone(),
358 });
359 }
360 }
361 }
362 }
363
364 for cap in GITPYTHON_RE.captures_iter(line) {
367 let full_call = format!("{}.git.{}", &cap[1], &cap[2]);
368 let args_str = &cap[3];
369 let arg_source = classify_argument(args_str, ¶m_names, &parsed.sanitized_vars);
370 let call_range = cap.get(0).expect("GitPython call capture");
371 parsed.commands.push(CommandInvocation {
372 function: full_call,
373 command_arg: arg_source,
374 location: loc_from_range(
375 &file_path,
376 line_num,
377 line,
378 call_range.start(),
379 call_range.end(),
380 ),
381 });
382 }
383
384 if let Some(cap) = PARTIAL_CALL_RE.captures(trimmed) {
391 let func_name = &cap[1];
392 let call_range = cap.get(1).expect("partial call name capture");
393 let trim_offset = line.find(trimmed).unwrap_or_default();
394 let call_location = loc_from_range(
395 &file_path,
396 line_num,
397 line,
398 trim_offset + call_range.start(),
399 trim_offset + call_range.end(),
400 );
401 let first_arg_str = lines
403 .get(line_idx + 1)
404 .map(|l| l.trim().trim_end_matches(','))
405 .unwrap_or("");
406 let arg_source =
407 classify_argument(first_arg_str, ¶m_names, &parsed.sanitized_vars);
408 parsed.call_sites.push(CallSite {
409 callee: func_name.to_string(),
410 arguments: vec![arg_source.clone()],
411 caller: current_functions.last().map(|(name, _)| name.clone()),
412 location: call_location.clone(),
413 });
414
415 if SUBPROCESS_PATTERNS
417 .iter()
418 .any(|p| func_name.ends_with(p) || func_name == *p)
419 {
420 parsed.commands.push(CommandInvocation {
421 function: func_name.to_string(),
422 command_arg: arg_source.clone(),
423 location: call_location.clone(),
424 });
425 }
426 if NETWORK_PATTERNS
427 .iter()
428 .any(|p| func_name.ends_with(p) || func_name == *p)
429 {
430 let sends_data = func_name.contains("post")
431 || func_name.contains("put")
432 || func_name.contains("patch");
433 let method = if func_name.contains("get") {
434 Some("GET".into())
435 } else if func_name.contains("post") {
436 Some("POST".into())
437 } else if func_name.contains("put") {
438 Some("PUT".into())
439 } else {
440 None
441 };
442 parsed.network_operations.push(NetworkOperation {
443 function: func_name.to_string(),
444 url_arg: arg_source.clone(),
445 method,
446 sends_data,
447 location: call_location.clone(),
448 });
449 }
450 if DYNAMIC_EXEC_PATTERNS.contains(&func_name) {
451 parsed.dynamic_exec.push(DynamicExec {
452 function: func_name.to_string(),
453 code_arg: arg_source.clone(),
454 location: call_location.clone(),
455 });
456 }
457 if FILE_READ_PATTERNS
458 .iter()
459 .any(|p| func_name.ends_with(p) || func_name == *p)
460 {
461 parsed.file_operations.push(FileOperation {
462 operation: FileOpType::Read,
463 path_arg: arg_source.clone(),
464 location: call_location.clone(),
465 });
466 }
467
468 if func_name.contains('.') {
470 let parts: Vec<&str> = func_name.rsplitn(2, '.').collect();
471 if parts.len() == 2 {
472 let method = parts[0];
473 let obj = parts[1];
474 if http_client_vars.contains(obj) && HTTP_CLIENT_METHODS.contains(&method) {
475 let sends_data =
476 method == "post" || method == "put" || method == "patch";
477 let http_method = match method {
478 "get" => Some("GET".into()),
479 "post" => Some("POST".into()),
480 "put" => Some("PUT".into()),
481 "delete" => Some("DELETE".into()),
482 "head" => Some("HEAD".into()),
483 "patch" => Some("PATCH".into()),
484 _ => None,
485 };
486 parsed.network_operations.push(NetworkOperation {
487 function: func_name.to_string(),
488 url_arg: arg_source.clone(),
489 method: http_method,
490 sends_data,
491 location: call_location.clone(),
492 });
493 }
494 }
495 }
496 }
497 }
498
499 Ok(parsed)
500 }
501}
502
503fn classify_argument(
505 args_str: &str,
506 param_names: &std::collections::HashSet<String>,
507 sanitized_vars: &std::collections::HashSet<String>,
508) -> ArgumentSource {
509 let first_arg = args_str.split(',').next().unwrap_or("").trim();
510
511 if first_arg.is_empty() {
512 return ArgumentSource::Unknown;
513 }
514
515 let ident = first_arg.split('.').next().unwrap_or(first_arg);
517 let ident = ident.split('[').next().unwrap_or(ident);
518 if let Some(sanitizer) = sanitized_label_for_var(ident, sanitized_vars) {
519 return ArgumentSource::Sanitized { sanitizer };
520 }
521
522 if let Some(val) = strip_python_string_literal(first_arg) {
525 return ArgumentSource::Literal(val.to_string());
526 }
527
528 if first_arg.starts_with("f\"") || first_arg.starts_with("f'") || first_arg.contains(".format(")
530 {
531 return ArgumentSource::Interpolated;
532 }
533
534 if first_arg.contains("os.environ") || first_arg.contains("os.getenv") {
536 return ArgumentSource::EnvVar {
537 name: first_arg.to_string(),
538 };
539 }
540
541 if param_names.contains(ident) {
543 return ArgumentSource::Parameter {
544 name: ident.to_string(),
545 };
546 }
547
548 ArgumentSource::Unknown
549}
550
551fn strip_python_string_literal(arg: &str) -> Option<&str> {
552 arg.strip_prefix('"')
553 .and_then(|inner| inner.strip_suffix('"'))
554 .or_else(|| {
555 arg.strip_prefix('\'')
556 .and_then(|inner| inner.strip_suffix('\''))
557 })
558}
559
560fn sanitized_var_marker(var_name: &str, sanitizer_label: &str) -> String {
561 format!("{var_name}::{sanitizer_label}")
562}
563
564fn sanitized_label_for_var(
565 ident: &str,
566 sanitized_vars: &std::collections::HashSet<String>,
567) -> Option<String> {
568 for category in [
569 SanitizerCategory::Path,
570 SanitizerCategory::Network,
571 SanitizerCategory::TypeCoercion,
572 ] {
573 let prefix = format!("{}:", category.as_str());
574 if let Some(marker) = sanitized_vars
575 .iter()
576 .find(|value| value.starts_with(&format!("{ident}::{prefix}")))
577 {
578 return marker.split_once("::").map(|(_, label)| label.to_string());
579 }
580 }
581
582 sanitized_vars.contains(ident).then(|| ident.to_string())
583}
584
585fn loc(file: &Path, line: usize) -> SourceLocation {
586 SourceLocation {
587 file: file.to_path_buf(),
588 line,
589 column: 0,
590 end_line: Some(line),
591 end_column: Some(0),
592 }
593}
594
595fn loc_from_range(
596 file: &Path,
597 line: usize,
598 source_line: &str,
599 start_byte: usize,
600 end_byte: usize,
601) -> SourceLocation {
602 SourceLocation {
603 file: file.to_path_buf(),
604 line,
605 column: source_line[..start_byte].chars().count(),
606 end_line: Some(line),
607 end_column: Some(source_line[..end_byte].chars().count()),
608 }
609}
610
611#[cfg(test)]
612mod tests {
613 use super::*;
614
615 #[test]
616 fn detects_subprocess_with_param() {
617 let code = r#"
618def handle(cmd: str):
619 subprocess.run(cmd, shell=True)
620"#;
621 let parsed = PythonParser.parse_file(Path::new("test.py"), code).unwrap();
622 assert_eq!(parsed.commands.len(), 1);
623 assert!(matches!(
624 parsed.commands[0].command_arg,
625 ArgumentSource::Parameter { .. }
626 ));
627 }
628
629 #[test]
630 fn detects_requests_get_with_param() {
631 let code = r#"
632def fetch(url: str):
633 requests.get(url)
634"#;
635 let parsed = PythonParser.parse_file(Path::new("test.py"), code).unwrap();
636 assert_eq!(parsed.network_operations.len(), 1);
637 assert!(matches!(
638 parsed.network_operations[0].url_arg,
639 ArgumentSource::Parameter { .. }
640 ));
641 }
642
643 #[test]
644 fn safe_literal_not_flagged_as_param() {
645 let code = r#"
646def fetch():
647 requests.get("https://api.example.com")
648"#;
649 let parsed = PythonParser.parse_file(Path::new("test.py"), code).unwrap();
650 assert_eq!(parsed.network_operations.len(), 1);
651 assert!(matches!(
652 parsed.network_operations[0].url_arg,
653 ArgumentSource::Literal(_)
654 ));
655 }
656
657 #[test]
658 fn incomplete_quote_argument_is_unknown_not_panic() {
659 let code = r#"
660def fetch():
661 requests.get(
662 "
663 )
664"#;
665 let parsed = PythonParser.parse_file(Path::new("test.py"), code).unwrap();
666 assert_eq!(parsed.network_operations.len(), 1);
667 assert!(matches!(
668 parsed.network_operations[0].url_arg,
669 ArgumentSource::Unknown
670 ));
671 }
672
673 #[test]
674 fn detects_env_var_access() {
675 let code = r#"
676key = os.environ["AWS_SECRET_ACCESS_KEY"]
677"#;
678 let parsed = PythonParser.parse_file(Path::new("test.py"), code).unwrap();
679 assert_eq!(parsed.env_accesses.len(), 1);
680 assert!(parsed.env_accesses[0].is_sensitive);
681 }
682
683 #[test]
684 fn detects_eval() {
685 let code = r#"
686def run(code):
687 eval(code)
688"#;
689 let parsed = PythonParser.parse_file(Path::new("test.py"), code).unwrap();
690 assert_eq!(parsed.dynamic_exec.len(), 1);
691 assert!(matches!(
692 parsed.dynamic_exec[0].code_arg,
693 ArgumentSource::Parameter { .. }
694 ));
695 }
696
697 #[test]
698 fn detects_httpx_async_client_get() {
699 let code = r#"
700async def fetch(url: str):
701 async with httpx.AsyncClient() as client:
702 response = await client.get(url)
703"#;
704 let parsed = PythonParser.parse_file(Path::new("test.py"), code).unwrap();
705 assert_eq!(parsed.network_operations.len(), 1);
706 assert_eq!(parsed.network_operations[0].function, "client.get");
707 assert!(matches!(
708 parsed.network_operations[0].url_arg,
709 ArgumentSource::Parameter { .. }
710 ));
711 }
712
713 #[test]
714 fn detects_aiohttp_client_session_post() {
715 let code = r#"
716async def send_data(url: str, data):
717 async with aiohttp.ClientSession() as session:
718 await session.post(url, json=data)
719"#;
720 let parsed = PythonParser.parse_file(Path::new("test.py"), code).unwrap();
721 assert_eq!(parsed.network_operations.len(), 1);
722 assert_eq!(parsed.network_operations[0].function, "session.post");
723 assert!(parsed.network_operations[0].sends_data);
724 }
725
726 #[test]
727 fn detects_gitpython_command_execution() {
728 let code = r#"
729def git_log(repo, args):
730 repo.git.log(*args)
731"#;
732 let parsed = PythonParser.parse_file(Path::new("test.py"), code).unwrap();
733 assert_eq!(parsed.commands.len(), 1);
734 assert_eq!(parsed.commands[0].function, "repo.git.log");
735 }
736
737 #[test]
738 fn detects_gitpython_add_with_user_files() {
739 let code = r#"
740def stage_files(repo, files):
741 repo.git.add("--", *files)
742"#;
743 let parsed = PythonParser.parse_file(Path::new("test.py"), code).unwrap();
744 assert_eq!(parsed.commands.len(), 1);
745 assert_eq!(parsed.commands[0].function, "repo.git.add");
746 }
747
748 #[test]
749 fn no_false_positive_on_non_client_get() {
750 let code = r#"
751def process():
752 result = cache.get("key")
753"#;
754 let parsed = PythonParser.parse_file(Path::new("test.py"), code).unwrap();
755 assert!(parsed.network_operations.is_empty());
756 }
757
758 #[test]
759 fn detects_multiline_async_client_get() {
760 let code = r#"
762async def fetch_url(url: str):
763 async with AsyncClient(proxies=proxy_url) as client:
764 response = await client.get(
765 url,
766 follow_redirects=True,
767 headers={"User-Agent": user_agent},
768 )
769"#;
770 let parsed = PythonParser.parse_file(Path::new("test.py"), code).unwrap();
771 assert_eq!(
772 parsed.network_operations.len(),
773 1,
774 "should detect multi-line client.get() call"
775 );
776 assert_eq!(parsed.network_operations[0].function, "client.get");
777 assert!(matches!(
778 parsed.network_operations[0].url_arg,
779 ArgumentSource::Parameter { .. }
780 ));
781 }
782
783 #[test]
784 fn detects_multiline_subprocess_run() {
785 let code = r#"
786def execute(cmd: str):
787 subprocess.run(
788 cmd,
789 shell=True,
790 capture_output=True,
791 )
792"#;
793 let parsed = PythonParser.parse_file(Path::new("test.py"), code).unwrap();
794 assert_eq!(
795 parsed.commands.len(),
796 1,
797 "should detect multi-line subprocess.run() call"
798 );
799 }
800
801 #[test]
804 fn extracts_python_function_defs() {
805 let code = r#"
806def read_file(path: str) -> str:
807 with open(path) as f:
808 return f.read()
809
810def _internal_helper(x):
811 return x + 1
812"#;
813 let parsed = PythonParser.parse_file(Path::new("lib.py"), code).unwrap();
814 assert!(parsed.function_defs.len() >= 2);
815
816 let read_file = parsed.function_defs.iter().find(|d| d.name == "read_file");
817 assert!(read_file.is_some());
818 assert!(read_file.unwrap().is_exported); assert_eq!(read_file.unwrap().params, vec!["path"]);
820
821 let helper = parsed
822 .function_defs
823 .iter()
824 .find(|d| d.name == "_internal_helper");
825 assert!(helper.is_some());
826 assert!(!helper.unwrap().is_exported); }
828
829 #[test]
830 fn records_nested_and_method_params_with_locations() {
831 let code = r#"
832class Handler:
833 def handle(self, url: str):
834 def inner(path: str):
835 return open(path)
836 return inner(url)
837"#;
838 let parsed = PythonParser
839 .parse_file(Path::new("handler.py"), code)
840 .unwrap();
841
842 let handle = parsed
843 .function_defs
844 .iter()
845 .find(|def| def.name == "handle")
846 .unwrap();
847 let inner = parsed
848 .function_defs
849 .iter()
850 .find(|def| def.name == "inner")
851 .unwrap();
852 assert_eq!(handle.params, vec!["url"]);
853 assert_eq!(inner.params, vec!["path"]);
854 assert!(
855 parsed
856 .function_params
857 .iter()
858 .any(|param| param.function_name == "inner" && param.param_name == "path")
859 );
860 assert_eq!(inner.location.end_line, Some(inner.location.line));
861
862 let inner_call = parsed
863 .call_sites
864 .iter()
865 .find(|site| site.callee == "inner")
866 .unwrap();
867 assert_eq!(inner_call.caller.as_deref(), Some("handle"));
868 assert_eq!(inner_call.location.end_line, Some(inner_call.location.line));
869 assert!(inner_call.location.column > 0);
870 }
871
872 #[test]
873 fn detects_python_sanitizer_assignment() {
874 let code = r#"
875def handler(raw_path: str):
876 safe_path = os.path.realpath(raw_path)
877 with open(safe_path) as f:
878 return f.read()
879"#;
880 let parsed = PythonParser.parse_file(Path::new("test.py"), code).unwrap();
881 assert!(parsed.sanitized_vars.contains("safe_path"));
882 }
883
884 #[test]
885 fn extracts_python_call_sites() {
886 let code = r#"
887def handler(args):
888 safe_path = os.path.realpath(args.path)
889 content = read_file(safe_path)
890 return content
891"#;
892 let parsed = PythonParser.parse_file(Path::new("test.py"), code).unwrap();
893 let rf_call = parsed.call_sites.iter().find(|cs| cs.callee == "read_file");
894 assert!(rf_call.is_some(), "Should find read_file call site");
895 let rf = rf_call.unwrap();
896 assert!(!rf.arguments.is_empty());
897 assert!(
898 matches!(&rf.arguments[0], ArgumentSource::Sanitized { .. }),
899 "safe_path should be Sanitized, got: {:?}",
900 rf.arguments[0]
901 );
902 }
903
904 #[test]
905 fn urlparse_assignment_is_not_sanitized_for_ssrf() {
906 let code = r#"
907from urllib.parse import urlparse
908import requests
909
910def handler(url: str):
911 parsed_url = urlparse(url)
912 return requests.get(parsed_url)
913"#;
914 let parsed = PythonParser.parse_file(Path::new("test.py"), code).unwrap();
915
916 assert!(!parsed.sanitized_vars.contains("parsed_url"));
917 assert_eq!(parsed.network_operations.len(), 1);
918 assert!(
919 parsed.network_operations[0].url_arg.is_tainted(),
920 "urlparse output must remain tainted for network sinks"
921 );
922 }
923
924 #[test]
925 fn redaction_assignment_is_not_sanitized_for_file_paths() {
926 let code = r#"
927def redactSecret(value: str) -> str:
928 return value.replace("secret", "[REDACTED]")
929
930def handler(path: str):
931 redacted_path = redactSecret(path)
932 return open(redacted_path).read()
933"#;
934 let parsed = PythonParser.parse_file(Path::new("test.py"), code).unwrap();
935
936 assert!(!parsed.sanitized_vars.contains("redacted_path"));
937 assert_eq!(parsed.file_operations.len(), 1);
938 assert!(
939 parsed.file_operations[0].path_arg.is_tainted(),
940 "redaction output must remain tainted for file path sinks"
941 );
942 }
943}