1pub(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 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 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 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 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 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 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 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 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 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 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 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); assert!(!files[1].1.file_operations[0].path_arg.is_tainted()); assert!(files[1].1.file_operations[1].path_arg.is_tainted()); }
454
455 #[test]
456 fn unsafe_sibling_with_shared_param_stays_tainted() {
457 let mut file_a = ParsedFile::default();
463 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 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 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 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 assert_eq!(result.downgraded_count, 0);
524 assert!(files[1].1.file_operations[0].path_arg.is_tainted()); assert!(files[1].1.file_operations[1].path_arg.is_tainted()); }
527
528 #[test]
529 fn uncalled_sibling_with_shared_param_stays_tainted() {
530 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 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 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}