Skip to main content

agentshield/parser/
python.rs

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
15// Dangerous subprocess/exec functions
16static 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
32// GitPython's `repo.git.*` methods are dynamic dispatchers that execute
33// `git <method> ...` as shell commands. We match the `.git.` segment.
34static 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        // httpx.AsyncClient and aiohttp.ClientSession are tracked via
52        // HTTP_CLIENT_CTX_RE + HTTP_CLIENT_METHODS instead, so their actual
53        // method calls (client.get, session.post) are detected as network ops.
54    ]
55});
56
57// HTTP method names used on client variables (e.g. `client.get(url)` where
58// `client` was bound from `httpx.AsyncClient()` or `aiohttp.ClientSession()`).
59// Checked separately from NETWORK_PATTERNS because the caller object is a
60// variable, not a known module.
61static HTTP_CLIENT_METHODS: Lazy<Vec<&str>> = Lazy::new(|| {
62    vec![
63        "get", "post", "put", "patch", "delete", "head", "options", "request", "fetch", "send",
64    ]
65});
66
67// Regex to detect async context managers that produce HTTP clients.
68// Matches: `async with httpx.AsyncClient(...) as <name>:`
69//          `async with aiohttp.ClientSession(...) as <name>:`
70static 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
82// Regex to find function calls with arguments: func_name(args)
83static CALL_RE: Lazy<Regex> = Lazy::new(|| {
84    Regex::new(r"(?m)(\w+(?:\.\w+)*)\s*\(([^)]*)\)").expect("static regex pattern is valid")
85});
86
87// Regex to find the start of a multi-line call: func_name( with no closing )
88// Captures the function name so we can match it against patterns, then look
89// ahead to the next line(s) for the first argument.
90static PARTIAL_CALL_RE: Lazy<Regex> =
91    Lazy::new(|| Regex::new(r"(\w+(?:\.\w+)*)\s*\(\s*$").expect("static regex pattern is valid"));
92
93// Regex to find os.environ / os.getenv patterns
94static 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
101// Regex to find function definitions and their parameters
102static 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
107// Sanitizer assignment: valid_path = validate_path(x) or valid_path = await validate_path(x)
108static 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        // Detect sanitizer assignments: safe_path = validate_path(x)
123        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        // Collect function parameter names + FunctionDef entries
139        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            // In Python, functions starting with _ are conventionally private
144            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        // Collect variable names bound to HTTP clients via async context managers
175        // e.g. `async with httpx.AsyncClient() as client:` → "client"
176        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        // Collect lines for look-ahead on multi-line calls
182        let lines: Vec<&str> = content.lines().collect();
183
184        // Scan line by line for patterns. Keep the enclosing function context
185        // so Python call sites have the same caller metadata as TypeScript.
186        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            // Skip comments
205            if trimmed.starts_with('#') {
206                continue;
207            }
208
209            // A definition header has the same `name(args)` shape as a call
210            // for the regex below. It establishes scope, but is not a call
211            // site and must not participate in cross-file analysis.
212            if FUNC_DEF_RE.is_match(line) {
213                continue;
214            }
215
216            // Check env var access
217            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            // Check function calls
233            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, &param_names, &parsed.sanitized_vars);
246
247                // Record CallSite for cross-file analysis
248                let all_args = args_str
249                    .split(',')
250                    .map(|a| classify_argument(a.trim(), &param_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                // Subprocess/command execution
260                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                // Network operations
272                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                // Dynamic exec
300                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                // File operations (open with write mode)
309                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                // HTTP client variable method calls (FN-1 fix):
330                // Detect `client.get(url)` where `client` was bound from
331                // `async with AsyncClient() as client:`.
332                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            // GitPython command execution (FN-2 fix):
365            // Detect `repo.git.log(...)`, `repo.git.add(...)`, etc.
366            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, &param_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            // Multi-line call detection: handle calls like
385            //   client.get(
386            //       url,
387            //       follow_redirects=True,
388            //   )
389            // where CALL_RE fails because `(` and `)` are on different lines.
390            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                // Look ahead to find the first argument on the next non-empty line
402                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, &param_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                // Check all pattern categories for partial calls
416                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                // HTTP client variable methods (multi-line)
469                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
503/// Classify a call argument string to determine its source.
504fn 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    // Check if this is a sanitized variable first
516    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    // String literal. Single quote tokens can appear when a regex-level parse
523    // sees an incomplete multiline literal; keep those conservative.
524    if let Some(val) = strip_python_string_literal(first_arg) {
525        return ArgumentSource::Literal(val.to_string());
526    }
527
528    // f-string or format
529    if first_arg.starts_with("f\"") || first_arg.starts_with("f'") || first_arg.contains(".format(")
530    {
531        return ArgumentSource::Interpolated;
532    }
533
534    // os.environ / env var
535    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    // Known function parameter
542    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        // Real-world pattern from the MCP fetch server
761        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    // ── Cross-file support tests ──
802
803    #[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); // no underscore prefix
819        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); // underscore prefix = private
827    }
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}