Skip to main content

agentshield/analysis/cross_file/
engine.rs

1use std::collections::{HashMap, HashSet};
2use std::path::PathBuf;
3
4use crate::ir::{ArgumentSource, SinkClass};
5use crate::parser::ParsedFile;
6
7use super::sink_policy::{all_call_sites_safe_for_sink, cross_file_sanitizer_label};
8
9/// Result of cross-file sanitization analysis.
10#[derive(Debug)]
11pub struct CrossFileResult {
12    /// Number of operations whose ArgumentSource was downgraded.
13    pub downgraded_count: usize,
14    /// Functions determined to receive only sanitized input.
15    pub sanitized_functions: Vec<String>,
16}
17
18/// Perform cross-file sanitizer-aware analysis on parsed files.
19///
20/// For each function definition, checks if ALL discovered call sites pass
21/// sanitized (or literal) arguments for each parameter. If so, downgrades
22/// the function's operations from tainted to `Sanitized`.
23///
24/// Conservative: exported functions with zero discovered call sites keep
25/// their parameters tainted.
26pub fn apply_cross_file_sanitization(
27    parsed_files: &mut [(PathBuf, ParsedFile)],
28) -> CrossFileResult {
29    let mut downgraded_count = 0;
30    let mut sanitized_functions = Vec::new();
31
32    // Phase 1: Build function definition map.
33    // Key: function name → (file index, param names)
34    let mut func_defs: HashMap<String, Vec<(usize, Vec<String>, bool)>> = HashMap::new();
35    // Per-file set of (param name, sink) that are UNAMBIGUOUSLY safe:
36    // every function in the file declaring `param_name` is itself proven
37    // safe for `sink`. Used to scope the downgrade to the proven-safe
38    // function and avoid clearing an UNSAFE sibling that shares the param
39    // name (issue #33). When two functions in a file share a param
40    // name but only one is proven safe, that (param, sink) is excluded.
41    let mut file_safe_param_sinks: HashMap<usize, HashSet<(String, SinkClass)>> = HashMap::new();
42    for (idx, (_, parsed)) in parsed_files.iter().enumerate() {
43        let has_cmd = !parsed.commands.is_empty();
44        let has_file = !parsed.file_operations.is_empty();
45        let has_net = !parsed.network_operations.is_empty();
46        let has_exec = !parsed.dynamic_exec.is_empty();
47
48        for def in &parsed.function_defs {
49            for param in &def.params {
50                if has_cmd {
51                    file_safe_param_sinks
52                        .entry(idx)
53                        .or_default()
54                        .insert((param.clone(), SinkClass::Command));
55                }
56                if has_file {
57                    file_safe_param_sinks
58                        .entry(idx)
59                        .or_default()
60                        .insert((param.clone(), SinkClass::FilePath));
61                }
62                if has_net {
63                    file_safe_param_sinks
64                        .entry(idx)
65                        .or_default()
66                        .insert((param.clone(), SinkClass::NetworkUrl));
67                }
68                if has_exec {
69                    file_safe_param_sinks
70                        .entry(idx)
71                        .or_default()
72                        .insert((param.clone(), SinkClass::DynamicExec));
73                }
74            }
75            func_defs.entry(def.name.clone()).or_default().push((
76                idx,
77                def.params.clone(),
78                def.is_exported,
79            ));
80        }
81    }
82
83    // Phase 2: Build call-site map.
84    // Key: callee name → Vec of (argument sources)
85    let mut call_sites: HashMap<String, Vec<Vec<ArgumentSource>>> = HashMap::new();
86    for (_, parsed) in parsed_files.iter() {
87        for cs in &parsed.call_sites {
88            call_sites
89                .entry(cs.callee.clone())
90                .or_default()
91                .push(cs.arguments.clone());
92        }
93    }
94
95    // Phase 3: Determine which functions have all-sanitized parameters per sink.
96    // For each function with a definition AND call sites, check if every
97    // call site passes values safe for each sink category. When a function is
98    // proven safe for a (param, sink), record it; if ANY function in the
99    // same file declaring that param is NOT proven safe, drop the
100    // (param, sink) from the unambiguous-safe set (issue #33).
101    let mut params_to_downgrade: Vec<(usize, String, String, SinkClass)> = Vec::new();
102
103    for (func_name, defs) in &func_defs {
104        let sites = match call_sites.get(func_name) {
105            Some(s) if !s.is_empty() => s,
106            _ => {
107                // No discovered call sites. Uncalled functions must invalidate
108                // unambiguous safety for their params within their declaring files
109                // so no unsafe sibling sharing the param name gets downgraded.
110                for (file_idx, params, _) in defs {
111                    if let Some(set) = file_safe_param_sinks.get_mut(file_idx) {
112                        for param in params {
113                            set.remove(&(param.clone(), SinkClass::Command));
114                            set.remove(&(param.clone(), SinkClass::FilePath));
115                            set.remove(&(param.clone(), SinkClass::NetworkUrl));
116                            set.remove(&(param.clone(), SinkClass::DynamicExec));
117                        }
118                    }
119                }
120                continue;
121            }
122        };
123
124        for (file_idx, params, _is_exported) in defs {
125            // Check each parameter position
126            for (param_idx, param_name) in params.iter().enumerate() {
127                for sink in [
128                    SinkClass::Command,
129                    SinkClass::FilePath,
130                    SinkClass::NetworkUrl,
131                    SinkClass::DynamicExec,
132                ] {
133                    if all_call_sites_safe_for_sink(sites, param_idx, sink) {
134                        params_to_downgrade.push((
135                            *file_idx,
136                            param_name.clone(),
137                            func_name.clone(),
138                            sink,
139                        ));
140                    } else {
141                        // This function is NOT safe for this (param, sink), so the
142                        // param name is ambiguous within the file — remove it from
143                        // the unambiguous-safe set so no sibling gets downgraded.
144                        if let Some(set) = file_safe_param_sinks.get_mut(file_idx) {
145                            set.remove(&(param_name.clone(), sink));
146                        }
147                    }
148                }
149            }
150        }
151    }
152
153    // Sort params_to_downgrade deterministically
154    params_to_downgrade.sort();
155
156    // Phase 4: Downgrade operations in the target functions.
157    // Scope guard (issue #33): only downgrade a (param, sink) that is in
158    // the file's unambiguous-safe set — i.e. EVERY function in that file
159    // declaring the param name was proven safe for that sink. If an unsafe
160    // sibling shares the param name, the entry was removed in Phase 3 and
161    // we leave the argument tainted.
162    for (file_idx, param_name, func_name, sink) in &params_to_downgrade {
163        let safe = file_safe_param_sinks
164            .get(file_idx)
165            .is_some_and(|set| set.contains(&(param_name.clone(), *sink)));
166        if !safe {
167            continue;
168        }
169        let (_, parsed) = &mut parsed_files[*file_idx];
170        // Encode the exact sink this downgrade was proven safe for, so the
171        // label round-trips through `sanitizer_allows_sink` and clears taint
172        // for THIS sink only. A bare description would parse to no category and
173        // resurface as a false positive now that detectors are sink-aware.
174        let sanitizer_label = cross_file_sanitizer_label(*sink, func_name);
175
176        let sanitized = ArgumentSource::Sanitized {
177            sanitizer: sanitizer_label.clone(),
178        };
179        let mut local_downgraded = 0;
180
181        match sink {
182            SinkClass::Command => {
183                for cmd in &mut parsed.commands {
184                    if matches!(&cmd.command_arg, ArgumentSource::Parameter { name } if name == param_name)
185                    {
186                        cmd.command_arg = sanitized.clone();
187                        downgraded_count += 1;
188                        local_downgraded += 1;
189                    }
190                }
191            }
192            SinkClass::FilePath => {
193                for op in &mut parsed.file_operations {
194                    if matches!(&op.path_arg, ArgumentSource::Parameter { name } if name == param_name)
195                    {
196                        op.path_arg = sanitized.clone();
197                        downgraded_count += 1;
198                        local_downgraded += 1;
199                    }
200                }
201            }
202            SinkClass::NetworkUrl => {
203                for op in &mut parsed.network_operations {
204                    if matches!(&op.url_arg, ArgumentSource::Parameter { name } if name == param_name)
205                    {
206                        op.url_arg = sanitized.clone();
207                        downgraded_count += 1;
208                        local_downgraded += 1;
209                    }
210                }
211            }
212            SinkClass::DynamicExec => {
213                for op in &mut parsed.dynamic_exec {
214                    if matches!(&op.code_arg, ArgumentSource::Parameter { name } if name == param_name)
215                    {
216                        op.code_arg = sanitized.clone();
217                        downgraded_count += 1;
218                        local_downgraded += 1;
219                    }
220                }
221            }
222        }
223
224        if local_downgraded > 0 && !sanitized_functions.contains(func_name) {
225            sanitized_functions.push(func_name.clone());
226        }
227    }
228
229    sanitized_functions.sort();
230
231    CrossFileResult {
232        downgraded_count,
233        sanitized_functions,
234    }
235}