1use std::collections::{HashMap, HashSet};
9use std::path::PathBuf;
10
11use crate::ir::ArgumentSource;
12use crate::parser::ParsedFile;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum SanitizerCategory {
17 Path,
18 Network,
19 Redaction,
20 TypeCoercion,
21}
22
23impl SanitizerCategory {
24 pub fn as_str(self) -> &'static str {
25 match self {
26 Self::Path => "path",
27 Self::Network => "network",
28 Self::Redaction => "redaction",
29 Self::TypeCoercion => "type",
30 }
31 }
32}
33
34use crate::ir::SinkClass;
35
36static PATH_SANITIZER_NAMES: &[&str] = &[
38 "validatePath",
39 "sanitizePath",
40 "normalizePath",
41 "resolvePath",
42 "canonicalizePath",
43 "realpath",
44 "path.resolve",
45 "path.normalize",
46 "resolve",
47 "normalize",
48 "os.path.realpath",
49 "os.path.abspath",
50 "os.path.normpath",
51 "abspath",
52 "normpath",
53];
54
55static NETWORK_SANITIZER_NAMES: &[&str] = &[
58 "validateUrl",
59 "validateURL",
60 "validateUri",
61 "validateURI",
62 "validateAllowedUrl",
63 "validateAllowedURL",
64 "validateAllowedUri",
65 "validateAllowedURI",
66 "allowlistUrl",
67 "allowlistURL",
68 "allowlistUri",
69 "allowlistURI",
70 "ensureAllowedUrl",
71 "ensureAllowedURL",
72 "ensureAllowedUri",
73 "ensureAllowedURI",
74 "assertAllowedUrl",
75 "assertAllowedURL",
76 "assertAllowedUri",
77 "assertAllowedURI",
78];
79
80static TYPE_COERCION_SANITIZER_NAMES: &[&str] =
82 &["parseInt", "parseFloat", "Number", "int", "float", "str"];
83
84static REDACTION_SANITIZER_NAMES: &[&str] = &[
87 "redactSecret",
88 "redactSecrets",
89 "redactToken",
90 "redactCredentials",
91 "maskSecret",
92 "maskToken",
93 "maskCredentials",
94 "scrubSecret",
95 "scrubToken",
96 "scrubCredentials",
97];
98
99fn exact_or_method_match(name: &str, names: &[&str]) -> bool {
100 if names.contains(&name) {
101 return true;
102 }
103
104 name.rsplit('.')
105 .next()
106 .is_some_and(|method| names.contains(&method))
107}
108
109fn compact_lower(name: &str) -> String {
110 name.chars()
111 .filter(|ch| *ch != '_' && *ch != '-')
112 .flat_map(char::to_lowercase)
113 .collect()
114}
115
116pub fn sanitizer_category(name: &str) -> Option<SanitizerCategory> {
118 if let Some((prefix, _)) = name.split_once(':') {
119 return match prefix {
120 "path" => Some(SanitizerCategory::Path),
121 "network" => Some(SanitizerCategory::Network),
122 "redaction" => Some(SanitizerCategory::Redaction),
123 "type" => Some(SanitizerCategory::TypeCoercion),
124 _ => None,
125 };
126 }
127
128 if exact_or_method_match(name, REDACTION_SANITIZER_NAMES) {
129 return Some(SanitizerCategory::Redaction);
130 }
131
132 if exact_or_method_match(name, PATH_SANITIZER_NAMES) {
133 return Some(SanitizerCategory::Path);
134 }
135
136 if exact_or_method_match(name, NETWORK_SANITIZER_NAMES) {
137 return Some(SanitizerCategory::Network);
138 }
139
140 if exact_or_method_match(name, TYPE_COERCION_SANITIZER_NAMES) {
141 return Some(SanitizerCategory::TypeCoercion);
142 }
143
144 let lower = compact_lower(name);
145
146 if (lower.starts_with("validate") || lower.starts_with("sanitize")) && lower.contains("path") {
147 return Some(SanitizerCategory::Path);
148 }
149
150 if (lower.starts_with("validate")
151 || lower.starts_with("allowlist")
152 || lower.starts_with("ensureallowed")
153 || lower.starts_with("assertallowed"))
154 && (lower.contains("url")
155 || lower.contains("uri")
156 || lower.contains("host")
157 || lower.contains("domain"))
158 {
159 return Some(SanitizerCategory::Network);
160 }
161
162 None
163}
164
165pub fn is_sanitizer(name: &str) -> bool {
169 matches!(
170 sanitizer_category(name),
171 Some(
172 SanitizerCategory::Path | SanitizerCategory::Network | SanitizerCategory::TypeCoercion
173 )
174 )
175}
176
177pub fn is_redaction_sanitizer(name: &str) -> bool {
178 matches!(sanitizer_category(name), Some(SanitizerCategory::Redaction))
179}
180
181pub fn sanitizer_label(name: &str) -> Option<String> {
182 sanitizer_category(name).map(|category| format!("{}:{name}", category.as_str()))
183}
184
185pub(crate) fn sanitizer_allows_sink(sanitizer: &str, sink: SinkClass) -> bool {
193 if let Some(downgraded_sink) = cross_file_sink(sanitizer) {
195 return downgraded_sink == sink;
196 }
197
198 matches!(
199 (sanitizer_category(sanitizer), sink),
200 (Some(SanitizerCategory::Path), SinkClass::FilePath)
201 | (Some(SanitizerCategory::Network), SinkClass::NetworkUrl)
202 )
203}
204
205fn arg_safe_for_sink(arg: &ArgumentSource, sink: SinkClass) -> bool {
206 !arg.is_tainted_for_sink(sink)
207}
208
209const CROSS_FILE_SANITIZER_PREFIX: &str = "crossfile";
214
215fn cross_file_sanitizer_label(sink: SinkClass, func_name: &str) -> String {
216 let sink_tag = match sink {
217 SinkClass::Command => "command",
218 SinkClass::FilePath => "filepath",
219 SinkClass::NetworkUrl => "networkurl",
220 SinkClass::DynamicExec => "dynamicexec",
221 };
222 format!("{CROSS_FILE_SANITIZER_PREFIX}:{sink_tag}:caller passes sanitized value to {func_name}")
223}
224
225fn cross_file_sink(sanitizer: &str) -> Option<SinkClass> {
226 let rest = sanitizer
227 .strip_prefix(CROSS_FILE_SANITIZER_PREFIX)?
228 .strip_prefix(':')?;
229 let tag = rest.split(':').next()?;
230 match tag {
231 "command" => Some(SinkClass::Command),
232 "filepath" => Some(SinkClass::FilePath),
233 "networkurl" => Some(SinkClass::NetworkUrl),
234 "dynamicexec" => Some(SinkClass::DynamicExec),
235 _ => None,
236 }
237}
238
239fn all_call_sites_safe_for_sink(
240 sites: &[Vec<ArgumentSource>],
241 param_idx: usize,
242 sink: SinkClass,
243) -> bool {
244 sites.iter().all(|args| {
245 args.get(param_idx)
246 .is_some_and(|arg| arg_safe_for_sink(arg, sink))
247 })
248}
249
250#[derive(Debug)]
252pub struct CrossFileResult {
253 pub downgraded_count: usize,
255 pub sanitized_functions: Vec<String>,
257}
258
259pub fn apply_cross_file_sanitization(
268 parsed_files: &mut [(PathBuf, ParsedFile)],
269) -> CrossFileResult {
270 let mut downgraded_count = 0;
271 let mut sanitized_functions = Vec::new();
272
273 let mut func_defs: HashMap<String, Vec<(usize, Vec<String>, bool)>> = HashMap::new();
276 let mut file_safe_param_sinks: HashMap<usize, HashSet<(String, SinkClass)>> = HashMap::new();
283 for (idx, (_, parsed)) in parsed_files.iter().enumerate() {
284 let has_cmd = !parsed.commands.is_empty();
285 let has_file = !parsed.file_operations.is_empty();
286 let has_net = !parsed.network_operations.is_empty();
287 let has_exec = !parsed.dynamic_exec.is_empty();
288
289 for def in &parsed.function_defs {
290 for param in &def.params {
291 if has_cmd {
292 file_safe_param_sinks
293 .entry(idx)
294 .or_default()
295 .insert((param.clone(), SinkClass::Command));
296 }
297 if has_file {
298 file_safe_param_sinks
299 .entry(idx)
300 .or_default()
301 .insert((param.clone(), SinkClass::FilePath));
302 }
303 if has_net {
304 file_safe_param_sinks
305 .entry(idx)
306 .or_default()
307 .insert((param.clone(), SinkClass::NetworkUrl));
308 }
309 if has_exec {
310 file_safe_param_sinks
311 .entry(idx)
312 .or_default()
313 .insert((param.clone(), SinkClass::DynamicExec));
314 }
315 }
316 func_defs.entry(def.name.clone()).or_default().push((
317 idx,
318 def.params.clone(),
319 def.is_exported,
320 ));
321 }
322 }
323
324 let mut call_sites: HashMap<String, Vec<Vec<ArgumentSource>>> = HashMap::new();
327 for (_, parsed) in parsed_files.iter() {
328 for cs in &parsed.call_sites {
329 call_sites
330 .entry(cs.callee.clone())
331 .or_default()
332 .push(cs.arguments.clone());
333 }
334 }
335
336 let mut params_to_downgrade: Vec<(usize, String, String, SinkClass)> = Vec::new();
343
344 for (func_name, defs) in &func_defs {
345 let sites = match call_sites.get(func_name) {
346 Some(s) if !s.is_empty() => s,
347 _ => {
348 continue;
350 }
351 };
352
353 for (file_idx, params, _is_exported) in defs {
354 for (param_idx, param_name) in params.iter().enumerate() {
356 for sink in [
357 SinkClass::Command,
358 SinkClass::FilePath,
359 SinkClass::NetworkUrl,
360 SinkClass::DynamicExec,
361 ] {
362 if all_call_sites_safe_for_sink(sites, param_idx, sink) {
363 params_to_downgrade.push((
364 *file_idx,
365 param_name.clone(),
366 func_name.clone(),
367 sink,
368 ));
369 } else {
370 if let Some(set) = file_safe_param_sinks.get_mut(file_idx) {
374 set.remove(&(param_name.clone(), sink));
375 }
376 }
377 }
378 }
379 }
380 }
381
382 for (file_idx, param_name, func_name, sink) in ¶ms_to_downgrade {
389 let safe = file_safe_param_sinks
390 .get(file_idx)
391 .is_some_and(|set| set.contains(&(param_name.clone(), *sink)));
392 if !safe {
393 continue;
394 }
395 let (_, parsed) = &mut parsed_files[*file_idx];
396 let sanitizer_label = cross_file_sanitizer_label(*sink, func_name);
401
402 let sanitized = ArgumentSource::Sanitized {
403 sanitizer: sanitizer_label.clone(),
404 };
405 let mut local_downgraded = 0;
406
407 match sink {
408 SinkClass::Command => {
409 for cmd in &mut parsed.commands {
410 if matches!(&cmd.command_arg, ArgumentSource::Parameter { name } if name == param_name)
411 {
412 cmd.command_arg = sanitized.clone();
413 downgraded_count += 1;
414 local_downgraded += 1;
415 }
416 }
417 }
418 SinkClass::FilePath => {
419 for op in &mut parsed.file_operations {
420 if matches!(&op.path_arg, ArgumentSource::Parameter { name } if name == param_name)
421 {
422 op.path_arg = sanitized.clone();
423 downgraded_count += 1;
424 local_downgraded += 1;
425 }
426 }
427 }
428 SinkClass::NetworkUrl => {
429 for op in &mut parsed.network_operations {
430 if matches!(&op.url_arg, ArgumentSource::Parameter { name } if name == param_name)
431 {
432 op.url_arg = sanitized.clone();
433 downgraded_count += 1;
434 local_downgraded += 1;
435 }
436 }
437 }
438 SinkClass::DynamicExec => {
439 for op in &mut parsed.dynamic_exec {
440 if matches!(&op.code_arg, ArgumentSource::Parameter { name } if name == param_name)
441 {
442 op.code_arg = sanitized.clone();
443 downgraded_count += 1;
444 local_downgraded += 1;
445 }
446 }
447 }
448 }
449
450 if local_downgraded > 0 && !sanitized_functions.contains(func_name) {
451 sanitized_functions.push(func_name.clone());
452 }
453 }
454
455 CrossFileResult {
456 downgraded_count,
457 sanitized_functions,
458 }
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464 use crate::adapter::auto_detect_and_load;
465 use crate::ir::SourceLocation;
466 use crate::ir::execution_surface::{FileOpType, FileOperation};
467 use crate::parser::{CallSite, FunctionDef};
468 use crate::rules::{Finding, RuleEngine};
469
470 fn loc(file: &str, line: usize) -> SourceLocation {
471 SourceLocation {
472 file: PathBuf::from(file),
473 line,
474 column: 0,
475 end_line: None,
476 end_column: None,
477 }
478 }
479
480 fn fixture_findings(name: &str) -> Vec<Finding> {
481 let fixture_path = PathBuf::from("tests/fixtures/mcp_servers").join(name);
482 let engine = RuleEngine::new();
483
484 auto_detect_and_load(&fixture_path, false)
485 .unwrap_or_else(|err| panic!("failed to load fixture {name}: {err}"))
486 .iter()
487 .flat_map(|target| engine.run(target))
488 .collect()
489 }
490
491 #[test]
492 fn sanitizer_names_recognized() {
493 assert!(is_sanitizer("validatePath"));
494 assert!(is_sanitizer("path.resolve"));
495 assert!(is_sanitizer("os.path.realpath"));
496 assert!(!is_sanitizer("URL.parse"));
497 assert!(is_sanitizer("parseInt"));
498 assert!(!is_sanitizer("urlparse"));
499 assert!(!is_sanitizer("sanitizeSecret"));
500 assert!(is_sanitizer("validateUrl"));
501 assert!(!is_sanitizer("processData"));
502 assert!(!is_sanitizer("readFile"));
503 }
504
505 #[test]
506 fn custom_validate_path_recognized() {
507 assert!(is_sanitizer("validate_path"));
508 assert!(is_sanitizer("validateUrl"));
509 assert!(is_sanitizer("sanitizeCustomPath"));
510 }
511
512 #[test]
513 fn redaction_helpers_recognized() {
514 assert!(is_redaction_sanitizer("redactSecret"));
515 assert!(is_redaction_sanitizer("redactSecrets"));
516 assert!(is_redaction_sanitizer("redactToken"));
517 assert!(is_redaction_sanitizer("redactCredentials"));
518 assert!(is_redaction_sanitizer("maskSecret"));
519 assert!(is_redaction_sanitizer("maskToken"));
520 assert!(is_redaction_sanitizer("maskCredentials"));
521 assert!(is_redaction_sanitizer("scrubSecret"));
522 assert!(is_redaction_sanitizer("scrubToken"));
523 assert!(is_redaction_sanitizer("scrubCredentials"));
524 assert!(!is_sanitizer("redactSecret"));
525 }
526
527 #[test]
528 fn cross_file_downgrade() {
529 let mut file_a = ParsedFile::default();
531 file_a.call_sites.push(CallSite {
532 callee: "readFileContent".into(),
533 arguments: vec![ArgumentSource::Sanitized {
534 sanitizer: "validatePath".into(),
535 }],
536 caller: Some("handleRead".into()),
537 location: loc("index.ts", 5),
538 });
539
540 let mut file_b = ParsedFile::default();
542 file_b.function_defs.push(FunctionDef {
543 name: "readFileContent".into(),
544 params: vec!["filePath".into()],
545 is_exported: true,
546 location: loc("lib.ts", 1),
547 });
548 file_b.file_operations.push(FileOperation {
549 path_arg: ArgumentSource::Parameter {
550 name: "filePath".into(),
551 },
552 operation: FileOpType::Read,
553 location: loc("lib.ts", 3),
554 });
555
556 let mut files = vec![
557 (PathBuf::from("index.ts"), file_a),
558 (PathBuf::from("lib.ts"), file_b),
559 ];
560
561 let result = apply_cross_file_sanitization(&mut files);
562
563 assert_eq!(result.downgraded_count, 1);
564 assert_eq!(result.sanitized_functions, vec!["readFileContent"]);
565
566 let lib_ops = &files[1].1.file_operations;
568 assert!(!lib_ops[0].path_arg.is_tainted());
569 assert!(matches!(
570 &lib_ops[0].path_arg,
571 ArgumentSource::Sanitized { .. }
572 ));
573 }
574
575 #[test]
576 fn redaction_sanitizers_do_not_downgrade_file_paths() {
577 let mut file_a = ParsedFile::default();
578 file_a.call_sites.push(CallSite {
579 callee: "logRedactedValues".into(),
580 arguments: vec![
581 ArgumentSource::Sanitized {
582 sanitizer: "redactSecret".into(),
583 },
584 ArgumentSource::Sanitized {
585 sanitizer: "maskToken".into(),
586 },
587 ArgumentSource::Sanitized {
588 sanitizer: "scrubCredentials".into(),
589 },
590 ],
591 caller: Some("handleLog".into()),
592 location: loc("index.ts", 8),
593 });
594
595 let mut file_b = ParsedFile::default();
596 file_b.function_defs.push(FunctionDef {
597 name: "logRedactedValues".into(),
598 params: vec!["secret".into(), "token".into(), "credentials".into()],
599 is_exported: true,
600 location: loc("logger.ts", 1),
601 });
602 file_b.file_operations.push(FileOperation {
603 path_arg: ArgumentSource::Parameter {
604 name: "secret".into(),
605 },
606 operation: FileOpType::Write,
607 location: loc("logger.ts", 3),
608 });
609 file_b.file_operations.push(FileOperation {
610 path_arg: ArgumentSource::Parameter {
611 name: "token".into(),
612 },
613 operation: FileOpType::Write,
614 location: loc("logger.ts", 4),
615 });
616 file_b.file_operations.push(FileOperation {
617 path_arg: ArgumentSource::Parameter {
618 name: "credentials".into(),
619 },
620 operation: FileOpType::Write,
621 location: loc("logger.ts", 5),
622 });
623
624 let mut files = vec![
625 (PathBuf::from("index.ts"), file_a),
626 (PathBuf::from("logger.ts"), file_b),
627 ];
628
629 let result = apply_cross_file_sanitization(&mut files);
630
631 assert_eq!(result.downgraded_count, 0);
632 assert!(result.sanitized_functions.is_empty());
633 for op in &files[1].1.file_operations {
634 assert!(
635 op.path_arg.is_tainted(),
636 "redaction-sanitized argument must not downgrade file paths"
637 );
638 }
639 }
640
641 #[test]
642 fn url_parse_does_not_downgrade_network_sink() {
643 let mut file_a = ParsedFile::default();
644 file_a.call_sites.push(CallSite {
645 callee: "fetchRemote".into(),
646 arguments: vec![ArgumentSource::Sanitized {
647 sanitizer: "URL.parse".into(),
648 }],
649 caller: Some("handler".into()),
650 location: loc("index.ts", 5),
651 });
652
653 let mut file_b = ParsedFile::default();
654 file_b.function_defs.push(FunctionDef {
655 name: "fetchRemote".into(),
656 params: vec!["url".into()],
657 is_exported: true,
658 location: loc("net.ts", 1),
659 });
660 file_b
661 .network_operations
662 .push(crate::ir::execution_surface::NetworkOperation {
663 function: "fetch".into(),
664 url_arg: ArgumentSource::Parameter { name: "url".into() },
665 method: Some("GET".into()),
666 sends_data: false,
667 location: loc("net.ts", 3),
668 });
669
670 let mut files = vec![
671 (PathBuf::from("index.ts"), file_a),
672 (PathBuf::from("net.ts"), file_b),
673 ];
674
675 let result = apply_cross_file_sanitization(&mut files);
676
677 assert_eq!(result.downgraded_count, 0);
678 assert!(files[1].1.network_operations[0].url_arg.is_tainted());
679 }
680
681 #[test]
682 fn url_parse_ssrf_fixture_still_flags_ssrf() {
683 let findings = fixture_findings("vuln_url_parse_ssrf");
684
685 assert!(
686 findings
687 .iter()
688 .any(|finding| finding.rule_id == "SHIELD-003"),
689 "URL.parse fixture should still trigger SSRF: {findings:?}"
690 );
691 }
692
693 #[test]
694 fn redacted_file_access_fixture_still_flags_arbitrary_file_access() {
695 let findings = fixture_findings("vuln_redacted_file_access");
696
697 assert!(
698 findings
699 .iter()
700 .any(|finding| finding.rule_id == "SHIELD-004"),
701 "redacted file path fixture should still trigger arbitrary file access: {findings:?}"
702 );
703 }
704
705 #[test]
706 fn wrong_category_sanitizer_does_not_suppress_file_sink() {
707 let findings = fixture_findings("vuln_wrong_category_sanitizer");
710
711 assert!(
712 findings
713 .iter()
714 .any(|finding| finding.rule_id == "SHIELD-004"),
715 "a network validator on a file-path sink must still trigger arbitrary file access: {findings:?}"
716 );
717 }
718
719 #[test]
720 fn type_coercion_does_not_suppress_eval_sink() {
721 let findings = fixture_findings("vuln_coercion_eval");
725
726 assert!(
727 findings
728 .iter()
729 .any(|finding| finding.rule_id == "SHIELD-011"),
730 "type coercion on an eval sink must still trigger dynamic exec: {findings:?}"
731 );
732 }
733
734 #[test]
735 fn type_coercion_is_not_a_command_sanitizer() {
736 let coerced = ArgumentSource::Sanitized {
740 sanitizer: "type:str".into(),
741 };
742 assert!(
743 !arg_safe_for_sink(&coerced, SinkClass::Command),
744 "type coercion must not sanitize a command sink"
745 );
746 assert!(
747 !arg_safe_for_sink(&coerced, SinkClass::DynamicExec),
748 "type coercion must not sanitize a dynamic-exec sink"
749 );
750 }
751
752 #[test]
753 fn argument_source_is_tainted_for_sink_respects_category() {
754 let net = ArgumentSource::Sanitized {
757 sanitizer: "network:validateUrl".into(),
758 };
759 assert!(!net.is_tainted_for_sink(SinkClass::NetworkUrl));
760 assert!(net.is_tainted_for_sink(SinkClass::FilePath));
761
762 let path = ArgumentSource::Sanitized {
763 sanitizer: "path:validatePath".into(),
764 };
765 assert!(!path.is_tainted_for_sink(SinkClass::FilePath));
766 assert!(path.is_tainted_for_sink(SinkClass::NetworkUrl));
767 }
768
769 #[test]
770 fn no_downgrade_when_unsanitized_caller_exists() {
771 let mut file_a = ParsedFile::default();
773 file_a.call_sites.push(CallSite {
774 callee: "readFile".into(),
775 arguments: vec![ArgumentSource::Sanitized {
776 sanitizer: "validatePath".into(),
777 }],
778 caller: Some("safeHandler".into()),
779 location: loc("safe.ts", 5),
780 });
781 file_a.call_sites.push(CallSite {
782 callee: "readFile".into(),
783 arguments: vec![ArgumentSource::Parameter {
784 name: "userInput".into(),
785 }],
786 caller: Some("unsafeHandler".into()),
787 location: loc("safe.ts", 10),
788 });
789
790 let mut file_b = ParsedFile::default();
791 file_b.function_defs.push(FunctionDef {
792 name: "readFile".into(),
793 params: vec!["path".into()],
794 is_exported: true,
795 location: loc("lib.ts", 1),
796 });
797 file_b.file_operations.push(FileOperation {
798 path_arg: ArgumentSource::Parameter {
799 name: "path".into(),
800 },
801 operation: FileOpType::Read,
802 location: loc("lib.ts", 3),
803 });
804
805 let mut files = vec![
806 (PathBuf::from("safe.ts"), file_a),
807 (PathBuf::from("lib.ts"), file_b),
808 ];
809
810 let result = apply_cross_file_sanitization(&mut files);
811
812 assert_eq!(result.downgraded_count, 0);
813 assert!(files[1].1.file_operations[0].path_arg.is_tainted());
815 }
816
817 #[test]
818 fn no_downgrade_for_exported_with_no_callers() {
819 let mut file_a = ParsedFile::default();
820 file_a.function_defs.push(FunctionDef {
821 name: "dangerousFunc".into(),
822 params: vec!["input".into()],
823 is_exported: true,
824 location: loc("lib.ts", 1),
825 });
826 file_a.file_operations.push(FileOperation {
827 path_arg: ArgumentSource::Parameter {
828 name: "input".into(),
829 },
830 operation: FileOpType::Write,
831 location: loc("lib.ts", 3),
832 });
833
834 let mut files = vec![(PathBuf::from("lib.ts"), file_a)];
835
836 let result = apply_cross_file_sanitization(&mut files);
837
838 assert_eq!(result.downgraded_count, 0);
839 assert!(files[0].1.file_operations[0].path_arg.is_tainted());
840 }
841
842 #[test]
843 fn downgrade_only_matching_params() {
844 let mut file_a = ParsedFile::default();
846 file_a.call_sites.push(CallSite {
847 callee: "copyFile".into(),
848 arguments: vec![
849 ArgumentSource::Sanitized {
850 sanitizer: "validatePath".into(),
851 },
852 ArgumentSource::Parameter {
853 name: "rawDest".into(),
854 },
855 ],
856 caller: Some("handler".into()),
857 location: loc("index.ts", 5),
858 });
859
860 let mut file_b = ParsedFile::default();
861 file_b.function_defs.push(FunctionDef {
862 name: "copyFile".into(),
863 params: vec!["src".into(), "dest".into()],
864 is_exported: true,
865 location: loc("lib.ts", 1),
866 });
867 file_b.file_operations.push(FileOperation {
869 path_arg: ArgumentSource::Parameter { name: "src".into() },
870 operation: FileOpType::Read,
871 location: loc("lib.ts", 3),
872 });
873 file_b.file_operations.push(FileOperation {
874 path_arg: ArgumentSource::Parameter {
875 name: "dest".into(),
876 },
877 operation: FileOpType::Write,
878 location: loc("lib.ts", 4),
879 });
880
881 let mut files = vec![
882 (PathBuf::from("index.ts"), file_a),
883 (PathBuf::from("lib.ts"), file_b),
884 ];
885
886 let result = apply_cross_file_sanitization(&mut files);
887
888 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()); }
892
893 #[test]
894 fn unsafe_sibling_with_shared_param_stays_tainted() {
895 let mut file_a = ParsedFile::default();
901 file_a.call_sites.push(CallSite {
903 callee: "safeRead".into(),
904 arguments: vec![ArgumentSource::Sanitized {
905 sanitizer: "validatePath".into(),
906 }],
907 caller: Some("handler".into()),
908 location: loc("index.ts", 5),
909 });
910 file_a.call_sites.push(CallSite {
912 callee: "rawRead".into(),
913 arguments: vec![ArgumentSource::Parameter {
914 name: "path".into(),
915 }],
916 caller: Some("handler".into()),
917 location: loc("index.ts", 9),
918 });
919
920 let mut file_b = ParsedFile::default();
921 file_b.function_defs.push(FunctionDef {
922 name: "safeRead".into(),
923 params: vec!["path".into()],
924 is_exported: true,
925 location: loc("lib.ts", 1),
926 });
927 file_b.function_defs.push(FunctionDef {
928 name: "rawRead".into(),
929 params: vec!["path".into()],
930 is_exported: true,
931 location: loc("lib.ts", 10),
932 });
933 file_b.file_operations.push(FileOperation {
935 path_arg: ArgumentSource::Parameter {
936 name: "path".into(),
937 },
938 operation: FileOpType::Read,
939 location: loc("lib.ts", 3),
940 });
941 file_b.file_operations.push(FileOperation {
943 path_arg: ArgumentSource::Parameter {
944 name: "path".into(),
945 },
946 operation: FileOpType::Read,
947 location: loc("lib.ts", 12),
948 });
949
950 let mut files = vec![
951 (PathBuf::from("index.ts"), file_a),
952 (PathBuf::from("lib.ts"), file_b),
953 ];
954
955 let result = apply_cross_file_sanitization(&mut files);
956
957 assert_eq!(result.downgraded_count, 0);
962 assert!(files[1].1.file_operations[0].path_arg.is_tainted()); assert!(files[1].1.file_operations[1].path_arg.is_tainted()); }
965}