1use 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#[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#[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#[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 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 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 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 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 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 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
347pub 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 for source in &target.data.sources {
354 let caller_name =
355 graph.find_enclosing_function(&source.location.file, source.location.line);
356
357 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 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 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
440pub(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}