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 ¶ms_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}