Skip to main content

agentshield/analysis/cross_file/
mod.rs

1//! Cross-file sanitizer-aware validation tracking.
2//!
3//! Runs after parsing, before detection. When a function is only ever called
4//! with sanitized arguments, downgrades its parameters'  from
5//! tainted to . This eliminates false positives from internal
6//! helper functions that receive already-validated input from their callers.
7
8pub(crate) mod engine;
9mod sanitizer;
10mod sink_policy;
11
12pub use engine::{apply_cross_file_sanitization, CrossFileResult};
13#[allow(unused_imports)]
14pub(crate) use sanitizer::{
15    SanitizerCategory, is_redaction_sanitizer, is_sanitizer, sanitizer_category, sanitizer_label,
16};
17pub use sink_policy::sanitizer_allows_sink;
18
19#[cfg(test)]
20mod tests {
21    use super::*;
22    use std::path::PathBuf;
23    use crate::ir::{ArgumentSource, SinkClass};
24    use crate::parser::ParsedFile;
25    use crate::adapter::auto_detect_and_load;
26    use crate::analysis::cross_file::sanitizer::{is_redaction_sanitizer, is_sanitizer};
27    use crate::ir::SourceLocation;
28    use crate::ir::execution_surface::{FileOpType, FileOperation};
29    use crate::parser::{CallSite, FunctionDef};
30    use crate::rules::{Finding, RuleEngine};
31
32    fn loc(file: &str, line: usize) -> SourceLocation {
33        SourceLocation {
34            file: PathBuf::from(file),
35            line,
36            column: 0,
37            end_line: None,
38            end_column: None,
39        }
40    }
41
42    fn fixture_findings(name: &str) -> Vec<Finding> {
43        let fixture_path = PathBuf::from("tests/fixtures/mcp_servers").join(name);
44        let engine = RuleEngine::new();
45
46        auto_detect_and_load(&fixture_path, false)
47            .unwrap_or_else(|err| panic!("failed to load fixture {name}: {err}"))
48            .iter()
49            .flat_map(|target| engine.run(target))
50            .collect()
51    }
52
53    #[test]
54    fn sanitizer_names_recognized() {
55        assert!(is_sanitizer("validatePath"));
56        assert!(is_sanitizer("path.resolve"));
57        assert!(is_sanitizer("os.path.realpath"));
58        assert!(!is_sanitizer("URL.parse"));
59        assert!(is_sanitizer("parseInt"));
60        assert!(!is_sanitizer("urlparse"));
61        assert!(!is_sanitizer("sanitizeSecret"));
62        assert!(is_sanitizer("validateUrl"));
63        assert!(!is_sanitizer("processData"));
64        assert!(!is_sanitizer("readFile"));
65    }
66
67    #[test]
68    fn custom_validate_path_recognized() {
69        assert!(is_sanitizer("validate_path"));
70        assert!(is_sanitizer("validateUrl"));
71        assert!(is_sanitizer("sanitizeCustomPath"));
72    }
73
74    #[test]
75    fn redaction_helpers_recognized() {
76        assert!(is_redaction_sanitizer("redactSecret"));
77        assert!(is_redaction_sanitizer("redactSecrets"));
78        assert!(is_redaction_sanitizer("redactToken"));
79        assert!(is_redaction_sanitizer("redactCredentials"));
80        assert!(is_redaction_sanitizer("maskSecret"));
81        assert!(is_redaction_sanitizer("maskToken"));
82        assert!(is_redaction_sanitizer("maskCredentials"));
83        assert!(is_redaction_sanitizer("scrubSecret"));
84        assert!(is_redaction_sanitizer("scrubToken"));
85        assert!(is_redaction_sanitizer("scrubCredentials"));
86        assert!(!is_sanitizer("redactSecret"));
87    }
88
89    #[test]
90    fn cross_file_downgrade() {
91        // File A (index.ts): calls readFileContent with sanitized arg
92        let mut file_a = ParsedFile::default();
93        file_a.call_sites.push(CallSite {
94            callee: "readFileContent".into(),
95            arguments: vec![ArgumentSource::Sanitized {
96                sanitizer: "validatePath".into(),
97            }],
98            caller: Some("handleRead".into()),
99            location: loc("index.ts", 5),
100        });
101
102        // File B (lib.ts): defines readFileContent, uses filePath param
103        let mut file_b = ParsedFile::default();
104        file_b.function_defs.push(FunctionDef {
105            name: "readFileContent".into(),
106            params: vec!["filePath".into()],
107            is_exported: true,
108            location: loc("lib.ts", 1),
109        });
110        file_b.file_operations.push(FileOperation {
111            path_arg: ArgumentSource::Parameter {
112                name: "filePath".into(),
113            },
114            operation: FileOpType::Read,
115            location: loc("lib.ts", 3),
116        });
117
118        let mut files = vec![
119            (PathBuf::from("index.ts"), file_a),
120            (PathBuf::from("lib.ts"), file_b),
121        ];
122
123        let result = apply_cross_file_sanitization(&mut files);
124
125        assert_eq!(result.downgraded_count, 1);
126        assert_eq!(result.sanitized_functions, vec!["readFileContent"]);
127
128        // Verify the operation was downgraded
129        let lib_ops = &files[1].1.file_operations;
130        assert!(!lib_ops[0].path_arg.is_tainted());
131        assert!(matches!(
132            &lib_ops[0].path_arg,
133            ArgumentSource::Sanitized { .. }
134        ));
135    }
136
137    #[test]
138    fn redaction_sanitizers_do_not_downgrade_file_paths() {
139        let mut file_a = ParsedFile::default();
140        file_a.call_sites.push(CallSite {
141            callee: "logRedactedValues".into(),
142            arguments: vec![
143                ArgumentSource::Sanitized {
144                    sanitizer: "redactSecret".into(),
145                },
146                ArgumentSource::Sanitized {
147                    sanitizer: "maskToken".into(),
148                },
149                ArgumentSource::Sanitized {
150                    sanitizer: "scrubCredentials".into(),
151                },
152            ],
153            caller: Some("handleLog".into()),
154            location: loc("index.ts", 8),
155        });
156
157        let mut file_b = ParsedFile::default();
158        file_b.function_defs.push(FunctionDef {
159            name: "logRedactedValues".into(),
160            params: vec!["secret".into(), "token".into(), "credentials".into()],
161            is_exported: true,
162            location: loc("logger.ts", 1),
163        });
164        file_b.file_operations.push(FileOperation {
165            path_arg: ArgumentSource::Parameter {
166                name: "secret".into(),
167            },
168            operation: FileOpType::Write,
169            location: loc("logger.ts", 3),
170        });
171        file_b.file_operations.push(FileOperation {
172            path_arg: ArgumentSource::Parameter {
173                name: "token".into(),
174            },
175            operation: FileOpType::Write,
176            location: loc("logger.ts", 4),
177        });
178        file_b.file_operations.push(FileOperation {
179            path_arg: ArgumentSource::Parameter {
180                name: "credentials".into(),
181            },
182            operation: FileOpType::Write,
183            location: loc("logger.ts", 5),
184        });
185
186        let mut files = vec![
187            (PathBuf::from("index.ts"), file_a),
188            (PathBuf::from("logger.ts"), file_b),
189        ];
190
191        let result = apply_cross_file_sanitization(&mut files);
192
193        assert_eq!(result.downgraded_count, 0);
194        assert!(result.sanitized_functions.is_empty());
195        for op in &files[1].1.file_operations {
196            assert!(
197                op.path_arg.is_tainted(),
198                "redaction-sanitized argument must not downgrade file paths"
199            );
200        }
201    }
202
203    #[test]
204    fn url_parse_does_not_downgrade_network_sink() {
205        let mut file_a = ParsedFile::default();
206        file_a.call_sites.push(CallSite {
207            callee: "fetchRemote".into(),
208            arguments: vec![ArgumentSource::Sanitized {
209                sanitizer: "URL.parse".into(),
210            }],
211            caller: Some("handler".into()),
212            location: loc("index.ts", 5),
213        });
214
215        let mut file_b = ParsedFile::default();
216        file_b.function_defs.push(FunctionDef {
217            name: "fetchRemote".into(),
218            params: vec!["url".into()],
219            is_exported: true,
220            location: loc("net.ts", 1),
221        });
222        file_b
223            .network_operations
224            .push(crate::ir::execution_surface::NetworkOperation {
225                function: "fetch".into(),
226                url_arg: ArgumentSource::Parameter { name: "url".into() },
227                method: Some("GET".into()),
228                sends_data: false,
229                location: loc("net.ts", 3),
230            });
231
232        let mut files = vec![
233            (PathBuf::from("index.ts"), file_a),
234            (PathBuf::from("net.ts"), file_b),
235        ];
236
237        let result = apply_cross_file_sanitization(&mut files);
238
239        assert_eq!(result.downgraded_count, 0);
240        assert!(files[1].1.network_operations[0].url_arg.is_tainted());
241    }
242
243    #[test]
244    fn url_parse_ssrf_fixture_still_flags_ssrf() {
245        let findings = fixture_findings("vuln_url_parse_ssrf");
246
247        assert!(
248            findings
249                .iter()
250                .any(|finding| finding.rule_id == "SHIELD-003"),
251            "URL.parse fixture should still trigger SSRF: {findings:?}"
252        );
253    }
254
255    #[test]
256    fn redacted_file_access_fixture_still_flags_arbitrary_file_access() {
257        let findings = fixture_findings("vuln_redacted_file_access");
258
259        assert!(
260            findings
261                .iter()
262                .any(|finding| finding.rule_id == "SHIELD-004"),
263            "redacted file path fixture should still trigger arbitrary file access: {findings:?}"
264        );
265    }
266
267    #[test]
268    fn wrong_category_sanitizer_does_not_suppress_file_sink() {
269        // A network-category validator (validateUrl) applied to a value used as
270        // a FILE PATH within the same function must NOT suppress SHIELD-004.
271        let findings = fixture_findings("vuln_wrong_category_sanitizer");
272
273        assert!(
274            findings
275                .iter()
276                .any(|finding| finding.rule_id == "SHIELD-004"),
277            "a network validator on a file-path sink must still trigger arbitrary file access: {findings:?}"
278        );
279    }
280
281    #[test]
282    fn type_coercion_does_not_suppress_eval_sink() {
283        // String()/str() coercion on an attacker value passed to eval must
284        // still fire SHIELD-011 — coercion is the wrong sanitizer category for
285        // a dynamic-exec sink and escapes nothing.
286        let findings = fixture_findings("vuln_coercion_eval");
287
288        assert!(
289            findings
290                .iter()
291                .any(|finding| finding.rule_id == "SHIELD-011"),
292            "type coercion on an eval sink must still trigger dynamic exec: {findings:?}"
293        );
294    }
295
296    #[test]
297    fn type_coercion_is_not_a_command_sanitizer() {
298        // str()/String() coercion is identity on a string and does not
299        // neutralize shell metacharacters, so it must not be accepted as a
300        // sanitizer for command or dynamic-exec sinks.
301        let coerced = ArgumentSource::Sanitized {
302            sanitizer: "type:str".into(),
303        };
304        assert!(
305            !sink_policy::arg_safe_for_sink(&coerced, SinkClass::Command),
306            "type coercion must not sanitize a command sink"
307        );
308        assert!(
309            !sink_policy::arg_safe_for_sink(&coerced, SinkClass::DynamicExec),
310            "type coercion must not sanitize a dynamic-exec sink"
311        );
312    }
313
314    #[test]
315    fn argument_source_is_tainted_for_sink_respects_category() {
316        // A network-category sanitizer is safe for a network sink but tainted
317        // for a file-path sink.
318        let net = ArgumentSource::Sanitized {
319            sanitizer: "network:validateUrl".into(),
320        };
321        assert!(!net.is_tainted_for_sink(SinkClass::NetworkUrl));
322        assert!(net.is_tainted_for_sink(SinkClass::FilePath));
323
324        let path = ArgumentSource::Sanitized {
325            sanitizer: "path:validatePath".into(),
326        };
327        assert!(!path.is_tainted_for_sink(SinkClass::FilePath));
328        assert!(path.is_tainted_for_sink(SinkClass::NetworkUrl));
329    }
330
331    #[test]
332    fn no_downgrade_when_unsanitized_caller_exists() {
333        // Two call sites: one safe, one tainted
334        let mut file_a = ParsedFile::default();
335        file_a.call_sites.push(CallSite {
336            callee: "readFile".into(),
337            arguments: vec![ArgumentSource::Sanitized {
338                sanitizer: "validatePath".into(),
339            }],
340            caller: Some("safeHandler".into()),
341            location: loc("safe.ts", 5),
342        });
343        file_a.call_sites.push(CallSite {
344            callee: "readFile".into(),
345            arguments: vec![ArgumentSource::Parameter {
346                name: "userInput".into(),
347            }],
348            caller: Some("unsafeHandler".into()),
349            location: loc("safe.ts", 10),
350        });
351
352        let mut file_b = ParsedFile::default();
353        file_b.function_defs.push(FunctionDef {
354            name: "readFile".into(),
355            params: vec!["path".into()],
356            is_exported: true,
357            location: loc("lib.ts", 1),
358        });
359        file_b.file_operations.push(FileOperation {
360            path_arg: ArgumentSource::Parameter {
361                name: "path".into(),
362            },
363            operation: FileOpType::Read,
364            location: loc("lib.ts", 3),
365        });
366
367        let mut files = vec![
368            (PathBuf::from("safe.ts"), file_a),
369            (PathBuf::from("lib.ts"), file_b),
370        ];
371
372        let result = apply_cross_file_sanitization(&mut files);
373
374        assert_eq!(result.downgraded_count, 0);
375        // Operation stays tainted
376        assert!(files[1].1.file_operations[0].path_arg.is_tainted());
377    }
378
379    #[test]
380    fn no_downgrade_for_exported_with_no_callers() {
381        let mut file_a = ParsedFile::default();
382        file_a.function_defs.push(FunctionDef {
383            name: "dangerousFunc".into(),
384            params: vec!["input".into()],
385            is_exported: true,
386            location: loc("lib.ts", 1),
387        });
388        file_a.file_operations.push(FileOperation {
389            path_arg: ArgumentSource::Parameter {
390                name: "input".into(),
391            },
392            operation: FileOpType::Write,
393            location: loc("lib.ts", 3),
394        });
395
396        let mut files = vec![(PathBuf::from("lib.ts"), file_a)];
397
398        let result = apply_cross_file_sanitization(&mut files);
399
400        assert_eq!(result.downgraded_count, 0);
401        assert!(files[0].1.file_operations[0].path_arg.is_tainted());
402    }
403
404    #[test]
405    fn downgrade_only_matching_params() {
406        // Function with 2 params, only first is always sanitized
407        let mut file_a = ParsedFile::default();
408        file_a.call_sites.push(CallSite {
409            callee: "copyFile".into(),
410            arguments: vec![
411                ArgumentSource::Sanitized {
412                    sanitizer: "validatePath".into(),
413                },
414                ArgumentSource::Parameter {
415                    name: "rawDest".into(),
416                },
417            ],
418            caller: Some("handler".into()),
419            location: loc("index.ts", 5),
420        });
421
422        let mut file_b = ParsedFile::default();
423        file_b.function_defs.push(FunctionDef {
424            name: "copyFile".into(),
425            params: vec!["src".into(), "dest".into()],
426            is_exported: true,
427            location: loc("lib.ts", 1),
428        });
429        // Two file operations, one per param
430        file_b.file_operations.push(FileOperation {
431            path_arg: ArgumentSource::Parameter { name: "src".into() },
432            operation: FileOpType::Read,
433            location: loc("lib.ts", 3),
434        });
435        file_b.file_operations.push(FileOperation {
436            path_arg: ArgumentSource::Parameter {
437                name: "dest".into(),
438            },
439            operation: FileOpType::Write,
440            location: loc("lib.ts", 4),
441        });
442
443        let mut files = vec![
444            (PathBuf::from("index.ts"), file_a),
445            (PathBuf::from("lib.ts"), file_b),
446        ];
447
448        let result = apply_cross_file_sanitization(&mut files);
449
450        assert_eq!(result.downgraded_count, 1); // Only src
451        assert!(!files[1].1.file_operations[0].path_arg.is_tainted()); // src: safe
452        assert!(files[1].1.file_operations[1].path_arg.is_tainted()); // dest: still tainted
453    }
454
455    #[test]
456    fn unsafe_sibling_with_shared_param_stays_tainted() {
457        // Issue #33: two functions in the same file share a param name
458        // (`path`). `safeRead` is only ever called with a sanitized
459        // value, but `rawRead` is called with a tainted parameter. The
460        // unsafe sibling must NOT be downgraded even though the safe one
461        // is.
462        let mut file_a = ParsedFile::default();
463        // safeRead is called with a sanitized path
464        file_a.call_sites.push(CallSite {
465            callee: "safeRead".into(),
466            arguments: vec![ArgumentSource::Sanitized {
467                sanitizer: "validatePath".into(),
468            }],
469            caller: Some("handler".into()),
470            location: loc("index.ts", 5),
471        });
472        // rawRead is called with a TAINTED parameter
473        file_a.call_sites.push(CallSite {
474            callee: "rawRead".into(),
475            arguments: vec![ArgumentSource::Parameter {
476                name: "path".into(),
477            }],
478            caller: Some("handler".into()),
479            location: loc("index.ts", 9),
480        });
481
482        let mut file_b = ParsedFile::default();
483        file_b.function_defs.push(FunctionDef {
484            name: "safeRead".into(),
485            params: vec!["path".into()],
486            is_exported: true,
487            location: loc("lib.ts", 1),
488        });
489        file_b.function_defs.push(FunctionDef {
490            name: "rawRead".into(),
491            params: vec!["path".into()],
492            is_exported: true,
493            location: loc("lib.ts", 10),
494        });
495        // safeRead's op (should downgrade)
496        file_b.file_operations.push(FileOperation {
497            path_arg: ArgumentSource::Parameter {
498                name: "path".into(),
499            },
500            operation: FileOpType::Read,
501            location: loc("lib.ts", 3),
502        });
503        // rawRead's op (must stay tainted)
504        file_b.file_operations.push(FileOperation {
505            path_arg: ArgumentSource::Parameter {
506                name: "path".into(),
507            },
508            operation: FileOpType::Read,
509            location: loc("lib.ts", 12),
510        });
511
512        let mut files = vec![
513            (PathBuf::from("index.ts"), file_a),
514            (PathBuf::from("lib.ts"), file_b),
515        ];
516
517        let result = apply_cross_file_sanitization(&mut files);
518
519        // `path` is shared between a safe and an unsafe function in the
520        // same file, so ownership is ambiguous. The conservative fix (issue
521        // #33) refuses to downgrade either, which correctly keeps the
522        // unsafe sibling's operation tainted (no false negative).
523        assert_eq!(result.downgraded_count, 0);
524        assert!(files[1].1.file_operations[0].path_arg.is_tainted()); // safeRead: stays tainted (ambiguous)
525        assert!(files[1].1.file_operations[1].path_arg.is_tainted()); // rawRead: stays tainted (unsafe sibling protected)
526    }
527
528    #[test]
529    fn uncalled_sibling_with_shared_param_stays_tainted() {
530        // Uncalled/exported function sharing a parameter name with a called safe function
531        // in the same file must NOT have its operations downgraded.
532        let mut file_a = ParsedFile::default();
533        file_a.call_sites.push(CallSite {
534            callee: "internalRead".into(),
535            arguments: vec![ArgumentSource::Sanitized {
536                sanitizer: "validatePath".into(),
537            }],
538            caller: Some("handler".into()),
539            location: loc("index.ts", 5),
540        });
541
542        let mut file_b = ParsedFile::default();
543        // internalRead is called safely
544        file_b.function_defs.push(FunctionDef {
545            name: "internalRead".into(),
546            params: vec!["path".into()],
547            is_exported: false,
548            location: loc("lib.ts", 1),
549        });
550        // exportRead has ZERO discovered call sites (uncalled)
551        file_b.function_defs.push(FunctionDef {
552            name: "exportRead".into(),
553            params: vec!["path".into()],
554            is_exported: true,
555            location: loc("lib.ts", 10),
556        });
557        file_b.file_operations.push(FileOperation {
558            path_arg: ArgumentSource::Parameter {
559                name: "path".into(),
560            },
561            operation: FileOpType::Read,
562            location: loc("lib.ts", 3),
563        });
564
565        let mut files = vec![
566            (PathBuf::from("index.ts"), file_a),
567            (PathBuf::from("lib.ts"), file_b),
568        ];
569
570        let result = apply_cross_file_sanitization(&mut files);
571
572        assert_eq!(
573            result.downgraded_count, 0,
574            "uncalled sibling with shared param must invalidate unambiguous safety"
575        );
576        assert!(files[1].1.file_operations[0].path_arg.is_tainted());
577    }
578}