Skip to main content

agentshield/analysis/
interprocedural.rs

1//! Interprocedural call-graph and cross-function taint propagation engine (milestone v1.0.0).
2//!
3//! Analyzes cross-function and cross-method control/data flow across Python and TypeScript
4//! agent extensions. Connects tool input parameters that are passed through intermediate
5//! helper functions, utility wrappers, and class methods to downstream execution sinks.
6
7use std::collections::{HashMap, HashSet};
8use std::path::{Path, PathBuf};
9
10use once_cell::sync::Lazy;
11use regex::Regex;
12
13use crate::analysis::AnalysisBundle;
14use crate::ir::data_surface::{TaintPath, TaintSink, TaintSource};
15use crate::ir::{ScanTarget, SourceLocation};
16
17static PY_FUNC_DEF_RE: Lazy<Regex> = Lazy::new(|| {
18    Regex::new(r#"(?m)^\s*(?:async\s+)?def\s+([A-Za-z0-9_]+)\s*\(([^)]*)\)"#).expect("valid regex")
19});
20
21static TS_FUNC_DEF_RE: Lazy<Regex> = Lazy::new(|| {
22    Regex::new(r#"(?m)^\s*(?:export\s+)?(?:async\s+)?function\s+([A-Za-z0-9_]+)\s*\(([^)]*)\)|(?:const|let|var)\s+([A-Za-z0-9_]+)\s*=\s*(?:async\s*)?\(([^)]*)\)\s*=>"#)
23        .expect("valid regex")
24});
25
26static CALL_EXPR_RE: Lazy<Regex> =
27    Lazy::new(|| Regex::new(r#"\b([A-Za-z0-9_]+)\s*\(([^)]*)\)"#).expect("valid regex"));
28
29/// A representation of a function definition in the project.
30#[derive(Debug, Clone)]
31pub struct FunctionNode {
32    pub name: String,
33    pub file_path: PathBuf,
34    pub params: Vec<String>,
35    pub start_line: usize,
36    pub end_line: usize,
37    pub location: SourceLocation,
38    pub sinks: Vec<TaintSink>,
39}
40
41/// A call-site invoking a function.
42#[derive(Debug, Clone)]
43pub struct CallSite {
44    pub caller_name: String,
45    pub callee_name: String,
46    pub file_path: PathBuf,
47    pub line_number: usize,
48    pub args: Vec<String>,
49    pub location: SourceLocation,
50}
51
52/// Interprocedural Call Graph.
53#[derive(Debug, Default)]
54pub struct CallGraph {
55    pub functions: HashMap<String, Vec<FunctionNode>>,
56    pub call_sites: Vec<CallSite>,
57}
58
59impl CallGraph {
60    pub fn new() -> Self {
61        Self::default()
62    }
63
64    /// Build call graph from all source files in a ScanTarget.
65    pub fn build(target: &ScanTarget) -> Self {
66        let mut graph = CallGraph::new();
67
68        for sf in &target.source_files {
69            let ext = sf.path.extension().and_then(|e| e.to_str()).unwrap_or("");
70            if ext == "py" {
71                graph.parse_python_file(&sf.path, &sf.content, target);
72            } else if matches!(ext, "ts" | "js" | "tsx" | "jsx" | "mjs") {
73                graph.parse_typescript_file(&sf.path, &sf.content, target);
74            }
75        }
76
77        graph
78    }
79
80    fn parse_python_file(&mut self, file_path: &Path, content: &str, target: &ScanTarget) {
81        let lines: Vec<&str> = content.lines().collect();
82
83        // 1. Extract function definitions
84        for (line_idx, line) in lines.iter().enumerate() {
85            let line_num = line_idx + 1;
86            if let Some(cap) = PY_FUNC_DEF_RE.captures(line) {
87                let func_name = cap[1].to_string();
88                let raw_params = &cap[2];
89
90                let params: Vec<String> = raw_params
91                    .split(',')
92                    .map(|p| {
93                        p.split(':')
94                            .next()
95                            .unwrap_or("")
96                            .split('=')
97                            .next()
98                            .unwrap_or("")
99                            .trim()
100                            .to_string()
101                    })
102                    .filter(|p| !p.is_empty() && p != "self" && p != "cls")
103                    .collect();
104
105                let start_line = line_num;
106                let func_indent = line.len() - line.trim_start().len();
107                let mut end_line = start_line;
108
109                for (sub_idx, next_line) in lines.iter().enumerate().skip(start_line) {
110                    let next_trimmed = next_line.trim();
111                    if next_trimmed.is_empty() || next_trimmed.starts_with('#') {
112                        continue;
113                    }
114                    let next_indent = next_line.len() - next_line.trim_start().len();
115                    if next_indent <= func_indent
116                        || next_trimmed.starts_with('@')
117                        || next_trimmed.starts_with("def ")
118                        || next_trimmed.starts_with("async def ")
119                        || next_trimmed.starts_with("class ")
120                    {
121                        break;
122                    }
123                    end_line = sub_idx + 1;
124                }
125
126                let func_sinks = target
127                    .data
128                    .sinks
129                    .iter()
130                    .filter(|s| {
131                        s.location.file == file_path
132                            && s.location.line >= start_line
133                            && s.location.line <= end_line
134                    })
135                    .cloned()
136                    .collect();
137
138                let node = FunctionNode {
139                    name: func_name.clone(),
140                    file_path: file_path.to_path_buf(),
141                    params,
142                    start_line,
143                    end_line,
144                    location: SourceLocation {
145                        file: file_path.to_path_buf(),
146                        line: start_line,
147                        column: func_indent,
148                        end_line: Some(end_line),
149                        end_column: None,
150                    },
151                    sinks: func_sinks,
152                };
153
154                self.functions.entry(func_name).or_default().push(node);
155            }
156        }
157
158        // 2. Extract call sites
159        for (line_idx, line) in lines.iter().enumerate() {
160            let line_num = line_idx + 1;
161            let trimmed = line.trim();
162            if trimmed.starts_with('#')
163                || trimmed.starts_with("def ")
164                || trimmed.starts_with("async def ")
165            {
166                continue;
167            }
168
169            for cap in CALL_EXPR_RE.captures_iter(line) {
170                let callee_name = cap[1].to_string();
171                let raw_args = &cap[2];
172
173                // Determine caller function containing this line
174                let caller_name = self.find_enclosing_function(file_path, line_num);
175
176                let args: Vec<String> = raw_args
177                    .split(',')
178                    .map(|a| a.split('=').next().unwrap_or("").trim().to_string())
179                    .filter(|a| !a.is_empty())
180                    .collect();
181
182                self.call_sites.push(CallSite {
183                    caller_name,
184                    callee_name,
185                    file_path: file_path.to_path_buf(),
186                    line_number: line_num,
187                    args,
188                    location: SourceLocation {
189                        file: file_path.to_path_buf(),
190                        line: line_num,
191                        column: line.find(&cap[0]).unwrap_or(0),
192                        end_line: None,
193                        end_column: None,
194                    },
195                });
196            }
197        }
198    }
199
200    fn parse_typescript_file(&mut self, file_path: &Path, content: &str, target: &ScanTarget) {
201        let lines: Vec<&str> = content.lines().collect();
202
203        // 1. Extract function definitions
204        for (line_idx, line) in lines.iter().enumerate() {
205            let line_num = line_idx + 1;
206            if let Some(cap) = TS_FUNC_DEF_RE.captures(line) {
207                let func_name = cap
208                    .get(1)
209                    .or_else(|| cap.get(3))
210                    .map(|m| m.as_str().to_string())
211                    .unwrap_or_default();
212
213                if func_name.is_empty() {
214                    continue;
215                }
216
217                let raw_params = cap
218                    .get(2)
219                    .or_else(|| cap.get(4))
220                    .map(|m| m.as_str())
221                    .unwrap_or("");
222
223                let params: Vec<String> = raw_params
224                    .split(',')
225                    .map(|p| {
226                        p.split(':')
227                            .next()
228                            .unwrap_or("")
229                            .split('=')
230                            .next()
231                            .unwrap_or("")
232                            .trim()
233                            .to_string()
234                    })
235                    .filter(|p| !p.is_empty())
236                    .collect();
237
238                let start_line = line_num;
239                let mut end_line = start_line;
240                let mut brace_count: i32 = 0;
241                for (sub_idx, next_line) in
242                    lines.iter().enumerate().skip(start_line.saturating_sub(1))
243                {
244                    let next_trimmed = next_line.trim();
245                    if next_trimmed.is_empty() || next_trimmed.starts_with("//") {
246                        continue;
247                    }
248                    brace_count += next_line.chars().filter(|&c| c == '{').count() as i32;
249                    brace_count -= next_line.chars().filter(|&c| c == '}').count() as i32;
250                    end_line = sub_idx + 1;
251                    if brace_count <= 0 && sub_idx + 1 >= start_line {
252                        break;
253                    }
254                }
255
256                let func_sinks = target
257                    .data
258                    .sinks
259                    .iter()
260                    .filter(|s| {
261                        s.location.file == file_path
262                            && s.location.line >= start_line
263                            && s.location.line <= end_line
264                    })
265                    .cloned()
266                    .collect();
267
268                let node = FunctionNode {
269                    name: func_name.clone(),
270                    file_path: file_path.to_path_buf(),
271                    params,
272                    start_line,
273                    end_line,
274                    location: SourceLocation {
275                        file: file_path.to_path_buf(),
276                        line: start_line,
277                        column: 0,
278                        end_line: Some(end_line),
279                        end_column: None,
280                    },
281                    sinks: func_sinks,
282                };
283
284                self.functions.entry(func_name).or_default().push(node);
285            }
286        }
287
288        // Call sites
289        for (line_idx, line) in lines.iter().enumerate() {
290            let line_num = line_idx + 1;
291            let trimmed = line.trim();
292            if trimmed.starts_with("//")
293                || trimmed.starts_with("/*")
294                || trimmed.starts_with("function ")
295            {
296                continue;
297            }
298
299            for cap in CALL_EXPR_RE.captures_iter(line) {
300                let callee_name = cap[1].to_string();
301                let raw_args = &cap[2];
302                let caller_name = self.find_enclosing_function(file_path, line_num);
303
304                let args: Vec<String> = raw_args
305                    .split(',')
306                    .map(|a| a.trim().to_string())
307                    .filter(|a| !a.is_empty())
308                    .collect();
309
310                self.call_sites.push(CallSite {
311                    caller_name,
312                    callee_name,
313                    file_path: file_path.to_path_buf(),
314                    line_number: line_num,
315                    args,
316                    location: SourceLocation {
317                        file: file_path.to_path_buf(),
318                        line: line_num,
319                        column: line.find(&cap[0]).unwrap_or(0),
320                        end_line: None,
321                        end_column: None,
322                    },
323                });
324            }
325        }
326    }
327
328    fn find_enclosing_function(&self, file_path: &Path, line: usize) -> String {
329        let mut best_match: Option<(&FunctionNode, usize)> = None;
330        for nodes in self.functions.values() {
331            for node in nodes {
332                let start = node.start_line.saturating_sub(1);
333                if node.file_path == file_path && line >= start && line <= node.end_line {
334                    let span = node.end_line - start;
335                    if best_match.is_none() || span < best_match.unwrap().1 {
336                        best_match = Some((node, span));
337                    }
338                }
339            }
340        }
341        best_match
342            .map(|(n, _)| n.name.clone())
343            .unwrap_or_else(|| "<global>".to_string())
344    }
345}
346
347/// Propagates taint across call-graph edges and constructs multi-hop `TaintPath` instances.
348pub fn propagate_interprocedural_taint(target: &ScanTarget, graph: &CallGraph) -> Vec<TaintPath> {
349    let mut new_paths = Vec::new();
350    let mut visited_paths = HashSet::new();
351
352    // Map each taint source to its enclosing function
353    for source in &target.data.sources {
354        let caller_name =
355            graph.find_enclosing_function(&source.location.file, source.location.line);
356
357        // Find calls originating from this function passing a tainted variable
358        for call in &graph.call_sites {
359            if call.file_path == source.location.file && call.caller_name == caller_name {
360                let mut trace = vec![call.location.clone()];
361                let mut visited_functions = HashSet::new();
362                visited_functions.insert(caller_name.clone());
363
364                trace_callee(
365                    &call.callee_name,
366                    source,
367                    &mut trace,
368                    &mut visited_functions,
369                    graph,
370                    &mut new_paths,
371                    &mut visited_paths,
372                );
373            }
374        }
375    }
376
377    new_paths
378}
379
380fn trace_callee(
381    callee_name: &str,
382    source: &TaintSource,
383    trace: &mut Vec<SourceLocation>,
384    visited_functions: &mut HashSet<String>,
385    graph: &CallGraph,
386    new_paths: &mut Vec<TaintPath>,
387    visited_paths: &mut HashSet<(String, usize, usize)>,
388) {
389    if visited_functions.contains(callee_name) {
390        return;
391    }
392    visited_functions.insert(callee_name.to_string());
393
394    if let Some(nodes) = graph.functions.get(callee_name) {
395        for node in nodes {
396            trace.push(node.location.clone());
397
398            // Check if this callee contains execution sinks
399            for sink in &node.sinks {
400                let path_key = (
401                    source.description.clone(),
402                    source.location.line,
403                    sink.location.line,
404                );
405                if !visited_paths.contains(&path_key) {
406                    visited_paths.insert(path_key);
407                    new_paths.push(TaintPath {
408                        source: source.clone(),
409                        sink: sink.clone(),
410                        through: trace.clone(),
411                        confidence: 0.9,
412                    });
413                }
414            }
415
416            // Recurse into calls made by this callee
417            for next_call in &graph.call_sites {
418                if next_call.file_path == node.file_path && next_call.caller_name == node.name {
419                    trace.push(next_call.location.clone());
420                    trace_callee(
421                        &next_call.callee_name,
422                        source,
423                        trace,
424                        visited_functions,
425                        graph,
426                        new_paths,
427                        visited_paths,
428                    );
429                    trace.pop();
430                }
431            }
432
433            trace.pop();
434        }
435    }
436
437    visited_functions.remove(callee_name);
438}
439
440/// Analyze analysis bundles and enrich target DataSurface with interprocedural taint paths.
441pub(crate) fn analyze_and_enrich_targets(bundles: &mut [AnalysisBundle]) {
442    for bundle in bundles {
443        let call_graph = CallGraph::build(&bundle.target);
444        let interprocedural_paths = propagate_interprocedural_taint(&bundle.target, &call_graph);
445
446        for path in interprocedural_paths {
447            if !bundle.target.data.taint_paths.iter().any(|p| {
448                p.source.location.line == path.source.location.line
449                    && p.sink.location.line == path.sink.location.line
450            }) {
451                bundle.target.data.taint_paths.push(path);
452            }
453        }
454    }
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460    use crate::ir::Language;
461    use crate::ir::data_surface::{DataSurface, TaintSinkType, TaintSourceType};
462    use crate::ir::dependency_surface::DependencySurface;
463    use crate::ir::execution_surface::ExecutionSurface;
464    use crate::ir::provenance_surface::ProvenanceSurface;
465    use crate::ir::tool_surface::ToolSurface;
466
467    #[test]
468    fn test_cross_function_command_injection_propagation() {
469        let py_code = r#"
470def helper_run(cmd):
471    import subprocess
472    subprocess.run(cmd, shell=True)
473
474@mcp.tool()
475def execute_tool(user_query: str):
476    helper_run(user_query)
477"#;
478        let file_path = PathBuf::from("server.py");
479
480        let target = ScanTarget {
481            name: "test-mcp".into(),
482            framework: crate::ir::Framework::Mcp,
483            root_path: PathBuf::from("/test"),
484            source_files: vec![crate::ir::SourceFile {
485                path: file_path.clone(),
486                language: Language::Python,
487                content: py_code.into(),
488                size_bytes: py_code.len() as u64,
489                content_hash: "hash".into(),
490            }],
491            dependencies: DependencySurface::default(),
492            data: DataSurface {
493                sources: vec![TaintSource {
494                    source_type: TaintSourceType::ToolArgument,
495                    description: "Tool 'execute_tool' parameter 'user_query'".into(),
496                    location: SourceLocation {
497                        file: file_path.clone(),
498                        line: 7,
499                        column: 0,
500                        end_line: None,
501                        end_column: None,
502                    },
503                }],
504                sinks: vec![TaintSink {
505                    sink_type: TaintSinkType::ProcessExec,
506                    description: "Process execution via subprocess.run".into(),
507                    location: SourceLocation {
508                        file: file_path.clone(),
509                        line: 4,
510                        column: 4,
511                        end_line: None,
512                        end_column: None,
513                    },
514                }],
515                taint_paths: vec![],
516            },
517            tools: vec![ToolSurface {
518                name: "execute_tool".into(),
519                description: None,
520                input_schema: None,
521                output_schema: None,
522                declared_permissions: vec![],
523                defined_at: Some(SourceLocation {
524                    file: file_path.clone(),
525                    line: 7,
526                    column: 0,
527                    end_line: None,
528                    end_column: None,
529                }),
530                declared_capabilities: std::collections::BTreeSet::new(),
531                capability_declarations: vec![],
532                observed_capabilities: std::collections::BTreeSet::new(),
533                capability_observation_complete: false,
534                capability_evidence: vec![],
535            }],
536            execution: ExecutionSurface::default(),
537            provenance: ProvenanceSurface::default(),
538        };
539
540        let graph = CallGraph::build(&target);
541        assert!(graph.functions.contains_key("helper_run"));
542        assert!(graph.functions.contains_key("execute_tool"));
543
544        let paths = propagate_interprocedural_taint(&target, &graph);
545        assert_eq!(paths.len(), 1);
546        assert_eq!(paths[0].source.source_type, TaintSourceType::ToolArgument);
547        assert_eq!(paths[0].sink.sink_type, TaintSinkType::ProcessExec);
548        assert!(!paths[0].through.is_empty());
549    }
550
551    #[test]
552    fn test_cross_function_typescript_ssrf_propagation() {
553        let ts_code = r#"
554async function sendHttpRequest(targetUrl: string) {
555    return await fetch(targetUrl);
556}
557
558export async function handleApiRequest(urlInput: string) {
559    return await sendHttpRequest(urlInput);
560}
561"#;
562        let file_path = PathBuf::from("index.ts");
563
564        let target = ScanTarget {
565            name: "test-ts-agent".into(),
566            framework: crate::ir::Framework::OpenClaw,
567            root_path: PathBuf::from("/test-ts"),
568            source_files: vec![crate::ir::SourceFile {
569                path: file_path.clone(),
570                language: Language::TypeScript,
571                content: ts_code.into(),
572                size_bytes: ts_code.len() as u64,
573                content_hash: "hash-ts".into(),
574            }],
575            dependencies: DependencySurface::default(),
576            data: DataSurface {
577                sources: vec![TaintSource {
578                    source_type: TaintSourceType::ToolArgument,
579                    description: "Tool 'handleApiRequest' parameter 'urlInput'".into(),
580                    location: SourceLocation {
581                        file: file_path.clone(),
582                        line: 6,
583                        column: 0,
584                        end_line: None,
585                        end_column: None,
586                    },
587                }],
588                sinks: vec![TaintSink {
589                    sink_type: TaintSinkType::HttpRequest,
590                    description: "HTTP fetch request".into(),
591                    location: SourceLocation {
592                        file: file_path.clone(),
593                        line: 3,
594                        column: 4,
595                        end_line: None,
596                        end_column: None,
597                    },
598                }],
599                taint_paths: vec![],
600            },
601            tools: vec![ToolSurface {
602                name: "handleApiRequest".into(),
603                description: None,
604                input_schema: None,
605                output_schema: None,
606                declared_permissions: vec![],
607                defined_at: Some(SourceLocation {
608                    file: file_path.clone(),
609                    line: 6,
610                    column: 0,
611                    end_line: None,
612                    end_column: None,
613                }),
614                declared_capabilities: std::collections::BTreeSet::new(),
615                capability_declarations: vec![],
616                observed_capabilities: std::collections::BTreeSet::new(),
617                capability_observation_complete: false,
618                capability_evidence: vec![],
619            }],
620            execution: ExecutionSurface::default(),
621            provenance: ProvenanceSurface::default(),
622        };
623
624        let graph = CallGraph::build(&target);
625        assert!(graph.functions.contains_key("sendHttpRequest"));
626        assert!(graph.functions.contains_key("handleApiRequest"));
627
628        let paths = propagate_interprocedural_taint(&target, &graph);
629        assert_eq!(paths.len(), 1);
630        assert_eq!(paths[0].source.source_type, TaintSourceType::ToolArgument);
631        assert_eq!(paths[0].sink.sink_type, TaintSinkType::HttpRequest);
632        assert!(!paths[0].through.is_empty());
633    }
634}