Skip to main content

agentshield/analysis/
cross_file.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' `ArgumentSource` from
5//! tainted to `Sanitized`. This eliminates false positives from internal
6//! helper functions that receive already-validated input from their callers.
7
8use std::collections::HashMap;
9use std::path::PathBuf;
10
11use crate::ir::ArgumentSource;
12use crate::parser::ParsedFile;
13
14/// Sanitizer category. A sanitizer is only safe for matching sink types.
15#[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
36/// Path/file sanitizers. These are safe for file/path sinks only.
37static 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
55/// Network/url validators. Parse-only helpers such as URL.parse/urlparse are
56/// intentionally excluded: parsing is not allowlist validation.
57static 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
80/// Type coercion helpers. These are not path or network validators.
81static TYPE_COERCION_SANITIZER_NAMES: &[&str] =
82    &["parseInt", "parseFloat", "Number", "int", "float", "str"];
83
84/// Credential/log redaction helpers. These are safe only for credential/log
85/// leakage analysis and must not sanitize file, network, command, or eval sinks.
86static 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
116/// Categorize a sanitizer helper by the sink family it protects.
117pub 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
165/// Check if a function name is a non-redaction input sanitizer. Kept for parser
166/// compatibility; redaction helpers are intentionally excluded from this global
167/// taint downgrade path.
168pub 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
185/// Whether `sanitizer` neutralizes taint for `sink`.
186///
187/// Each sanitizer category protects only its own sink family. Type coercion
188/// (`str()`/`Number()`) is identity on a string and is NOT accepted for any
189/// injection sink — it neither escapes shell metacharacters nor constrains a
190/// path or URL. Redaction sanitizers protect no input sink (only credential/log
191/// leakage analysis), so they are absent here.
192pub(crate) fn sanitizer_allows_sink(sanitizer: &str, sink: SinkClass) -> bool {
193    // A cross-file downgrade is proven safe for exactly one sink.
194    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
209/// Prefix marking a cross-file downgrade label, followed by the exact sink it
210/// was proven safe for. Unlike a named sanitizer (which protects a whole
211/// category), a cross-file downgrade is proven safe for precisely one sink, so
212/// the sink is encoded directly and matched back in [`sanitizer_allows_sink`].
213const 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/// Result of cross-file sanitization analysis.
251#[derive(Debug)]
252pub struct CrossFileResult {
253    /// Number of operations whose ArgumentSource was downgraded.
254    pub downgraded_count: usize,
255    /// Functions determined to receive only sanitized input.
256    pub sanitized_functions: Vec<String>,
257}
258
259/// Perform cross-file sanitizer-aware analysis on parsed files.
260///
261/// For each function definition, checks if ALL discovered call sites pass
262/// sanitized (or literal) arguments for each parameter. If so, downgrades
263/// the function's operations from tainted to `Sanitized`.
264///
265/// Conservative: exported functions with zero discovered call sites keep
266/// their parameters tainted.
267pub 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    // Phase 1: Build function definition map.
274    // Key: function name → (file index, param names)
275    let mut func_defs: HashMap<String, Vec<(usize, Vec<String>, bool)>> = HashMap::new();
276    for (idx, (_, parsed)) in parsed_files.iter().enumerate() {
277        for def in &parsed.function_defs {
278            func_defs.entry(def.name.clone()).or_default().push((
279                idx,
280                def.params.clone(),
281                def.is_exported,
282            ));
283        }
284    }
285
286    // Phase 2: Build call-site map.
287    // Key: callee name → Vec of (argument sources)
288    let mut call_sites: HashMap<String, Vec<Vec<ArgumentSource>>> = HashMap::new();
289    for (_, parsed) in parsed_files.iter() {
290        for cs in &parsed.call_sites {
291            call_sites
292                .entry(cs.callee.clone())
293                .or_default()
294                .push(cs.arguments.clone());
295        }
296    }
297
298    // Phase 3: Determine which functions have all-sanitized parameters per sink.
299    // For each function with a definition AND call sites, check if every
300    // call site passes values safe for each sink category.
301    let mut params_to_downgrade: Vec<(usize, String, String, SinkClass)> = Vec::new();
302
303    for (func_name, defs) in &func_defs {
304        let sites = match call_sites.get(func_name) {
305            Some(s) if !s.is_empty() => s,
306            _ => {
307                // No discovered call sites. If exported, stay conservative.
308                continue;
309            }
310        };
311
312        for (file_idx, params, _is_exported) in defs {
313            // Check each parameter position
314            for (param_idx, param_name) in params.iter().enumerate() {
315                for sink in [
316                    SinkClass::Command,
317                    SinkClass::FilePath,
318                    SinkClass::NetworkUrl,
319                    SinkClass::DynamicExec,
320                ] {
321                    if all_call_sites_safe_for_sink(sites, param_idx, sink) {
322                        params_to_downgrade.push((
323                            *file_idx,
324                            param_name.clone(),
325                            func_name.clone(),
326                            sink,
327                        ));
328                    }
329                }
330            }
331        }
332    }
333
334    // Phase 4: Downgrade operations in the target functions.
335    for (file_idx, param_name, func_name, sink) in &params_to_downgrade {
336        let (_, parsed) = &mut parsed_files[*file_idx];
337        // Encode the exact sink this downgrade was proven safe for, so the
338        // label round-trips through `sanitizer_allows_sink` and clears taint
339        // for THIS sink only. A bare description would parse to no category and
340        // resurface as a false positive now that detectors are sink-aware.
341        let sanitizer_label = cross_file_sanitizer_label(*sink, func_name);
342
343        let sanitized = ArgumentSource::Sanitized {
344            sanitizer: sanitizer_label.clone(),
345        };
346        let mut local_downgraded = 0;
347
348        match sink {
349            SinkClass::Command => {
350                for cmd in &mut parsed.commands {
351                    if matches!(&cmd.command_arg, ArgumentSource::Parameter { name } if name == param_name)
352                    {
353                        cmd.command_arg = sanitized.clone();
354                        downgraded_count += 1;
355                        local_downgraded += 1;
356                    }
357                }
358            }
359            SinkClass::FilePath => {
360                for op in &mut parsed.file_operations {
361                    if matches!(&op.path_arg, ArgumentSource::Parameter { name } if name == param_name)
362                    {
363                        op.path_arg = sanitized.clone();
364                        downgraded_count += 1;
365                        local_downgraded += 1;
366                    }
367                }
368            }
369            SinkClass::NetworkUrl => {
370                for op in &mut parsed.network_operations {
371                    if matches!(&op.url_arg, ArgumentSource::Parameter { name } if name == param_name)
372                    {
373                        op.url_arg = sanitized.clone();
374                        downgraded_count += 1;
375                        local_downgraded += 1;
376                    }
377                }
378            }
379            SinkClass::DynamicExec => {
380                for op in &mut parsed.dynamic_exec {
381                    if matches!(&op.code_arg, ArgumentSource::Parameter { name } if name == param_name)
382                    {
383                        op.code_arg = sanitized.clone();
384                        downgraded_count += 1;
385                        local_downgraded += 1;
386                    }
387                }
388            }
389        }
390
391        if local_downgraded > 0 && !sanitized_functions.contains(func_name) {
392            sanitized_functions.push(func_name.clone());
393        }
394    }
395
396    CrossFileResult {
397        downgraded_count,
398        sanitized_functions,
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405    use crate::adapter::auto_detect_and_load;
406    use crate::ir::execution_surface::{FileOpType, FileOperation};
407    use crate::ir::SourceLocation;
408    use crate::parser::{CallSite, FunctionDef};
409    use crate::rules::{Finding, RuleEngine};
410
411    fn loc(file: &str, line: usize) -> SourceLocation {
412        SourceLocation {
413            file: PathBuf::from(file),
414            line,
415            column: 0,
416            end_line: None,
417            end_column: None,
418        }
419    }
420
421    fn fixture_findings(name: &str) -> Vec<Finding> {
422        let fixture_path = PathBuf::from("tests/fixtures/mcp_servers").join(name);
423        let engine = RuleEngine::new();
424
425        auto_detect_and_load(&fixture_path, false)
426            .unwrap_or_else(|err| panic!("failed to load fixture {name}: {err}"))
427            .iter()
428            .flat_map(|target| engine.run(target))
429            .collect()
430    }
431
432    #[test]
433    fn sanitizer_names_recognized() {
434        assert!(is_sanitizer("validatePath"));
435        assert!(is_sanitizer("path.resolve"));
436        assert!(is_sanitizer("os.path.realpath"));
437        assert!(!is_sanitizer("URL.parse"));
438        assert!(is_sanitizer("parseInt"));
439        assert!(!is_sanitizer("urlparse"));
440        assert!(!is_sanitizer("sanitizeSecret"));
441        assert!(is_sanitizer("validateUrl"));
442        assert!(!is_sanitizer("processData"));
443        assert!(!is_sanitizer("readFile"));
444    }
445
446    #[test]
447    fn custom_validate_path_recognized() {
448        assert!(is_sanitizer("validate_path"));
449        assert!(is_sanitizer("validateUrl"));
450        assert!(is_sanitizer("sanitizeCustomPath"));
451    }
452
453    #[test]
454    fn redaction_helpers_recognized() {
455        assert!(is_redaction_sanitizer("redactSecret"));
456        assert!(is_redaction_sanitizer("redactSecrets"));
457        assert!(is_redaction_sanitizer("redactToken"));
458        assert!(is_redaction_sanitizer("redactCredentials"));
459        assert!(is_redaction_sanitizer("maskSecret"));
460        assert!(is_redaction_sanitizer("maskToken"));
461        assert!(is_redaction_sanitizer("maskCredentials"));
462        assert!(is_redaction_sanitizer("scrubSecret"));
463        assert!(is_redaction_sanitizer("scrubToken"));
464        assert!(is_redaction_sanitizer("scrubCredentials"));
465        assert!(!is_sanitizer("redactSecret"));
466    }
467
468    #[test]
469    fn cross_file_downgrade() {
470        // File A (index.ts): calls readFileContent with sanitized arg
471        let mut file_a = ParsedFile::default();
472        file_a.call_sites.push(CallSite {
473            callee: "readFileContent".into(),
474            arguments: vec![ArgumentSource::Sanitized {
475                sanitizer: "validatePath".into(),
476            }],
477            caller: Some("handleRead".into()),
478            location: loc("index.ts", 5),
479        });
480
481        // File B (lib.ts): defines readFileContent, uses filePath param
482        let mut file_b = ParsedFile::default();
483        file_b.function_defs.push(FunctionDef {
484            name: "readFileContent".into(),
485            params: vec!["filePath".into()],
486            is_exported: true,
487            location: loc("lib.ts", 1),
488        });
489        file_b.file_operations.push(FileOperation {
490            path_arg: ArgumentSource::Parameter {
491                name: "filePath".into(),
492            },
493            operation: FileOpType::Read,
494            location: loc("lib.ts", 3),
495        });
496
497        let mut files = vec![
498            (PathBuf::from("index.ts"), file_a),
499            (PathBuf::from("lib.ts"), file_b),
500        ];
501
502        let result = apply_cross_file_sanitization(&mut files);
503
504        assert_eq!(result.downgraded_count, 1);
505        assert_eq!(result.sanitized_functions, vec!["readFileContent"]);
506
507        // Verify the operation was downgraded
508        let lib_ops = &files[1].1.file_operations;
509        assert!(!lib_ops[0].path_arg.is_tainted());
510        assert!(matches!(
511            &lib_ops[0].path_arg,
512            ArgumentSource::Sanitized { .. }
513        ));
514    }
515
516    #[test]
517    fn redaction_sanitizers_do_not_downgrade_file_paths() {
518        let mut file_a = ParsedFile::default();
519        file_a.call_sites.push(CallSite {
520            callee: "logRedactedValues".into(),
521            arguments: vec![
522                ArgumentSource::Sanitized {
523                    sanitizer: "redactSecret".into(),
524                },
525                ArgumentSource::Sanitized {
526                    sanitizer: "maskToken".into(),
527                },
528                ArgumentSource::Sanitized {
529                    sanitizer: "scrubCredentials".into(),
530                },
531            ],
532            caller: Some("handleLog".into()),
533            location: loc("index.ts", 8),
534        });
535
536        let mut file_b = ParsedFile::default();
537        file_b.function_defs.push(FunctionDef {
538            name: "logRedactedValues".into(),
539            params: vec!["secret".into(), "token".into(), "credentials".into()],
540            is_exported: true,
541            location: loc("logger.ts", 1),
542        });
543        file_b.file_operations.push(FileOperation {
544            path_arg: ArgumentSource::Parameter {
545                name: "secret".into(),
546            },
547            operation: FileOpType::Write,
548            location: loc("logger.ts", 3),
549        });
550        file_b.file_operations.push(FileOperation {
551            path_arg: ArgumentSource::Parameter {
552                name: "token".into(),
553            },
554            operation: FileOpType::Write,
555            location: loc("logger.ts", 4),
556        });
557        file_b.file_operations.push(FileOperation {
558            path_arg: ArgumentSource::Parameter {
559                name: "credentials".into(),
560            },
561            operation: FileOpType::Write,
562            location: loc("logger.ts", 5),
563        });
564
565        let mut files = vec![
566            (PathBuf::from("index.ts"), file_a),
567            (PathBuf::from("logger.ts"), file_b),
568        ];
569
570        let result = apply_cross_file_sanitization(&mut files);
571
572        assert_eq!(result.downgraded_count, 0);
573        assert!(result.sanitized_functions.is_empty());
574        for op in &files[1].1.file_operations {
575            assert!(
576                op.path_arg.is_tainted(),
577                "redaction-sanitized argument must not downgrade file paths"
578            );
579        }
580    }
581
582    #[test]
583    fn url_parse_does_not_downgrade_network_sink() {
584        let mut file_a = ParsedFile::default();
585        file_a.call_sites.push(CallSite {
586            callee: "fetchRemote".into(),
587            arguments: vec![ArgumentSource::Sanitized {
588                sanitizer: "URL.parse".into(),
589            }],
590            caller: Some("handler".into()),
591            location: loc("index.ts", 5),
592        });
593
594        let mut file_b = ParsedFile::default();
595        file_b.function_defs.push(FunctionDef {
596            name: "fetchRemote".into(),
597            params: vec!["url".into()],
598            is_exported: true,
599            location: loc("net.ts", 1),
600        });
601        file_b
602            .network_operations
603            .push(crate::ir::execution_surface::NetworkOperation {
604                function: "fetch".into(),
605                url_arg: ArgumentSource::Parameter { name: "url".into() },
606                method: Some("GET".into()),
607                sends_data: false,
608                location: loc("net.ts", 3),
609            });
610
611        let mut files = vec![
612            (PathBuf::from("index.ts"), file_a),
613            (PathBuf::from("net.ts"), file_b),
614        ];
615
616        let result = apply_cross_file_sanitization(&mut files);
617
618        assert_eq!(result.downgraded_count, 0);
619        assert!(files[1].1.network_operations[0].url_arg.is_tainted());
620    }
621
622    #[test]
623    fn url_parse_ssrf_fixture_still_flags_ssrf() {
624        let findings = fixture_findings("vuln_url_parse_ssrf");
625
626        assert!(
627            findings
628                .iter()
629                .any(|finding| finding.rule_id == "SHIELD-003"),
630            "URL.parse fixture should still trigger SSRF: {findings:?}"
631        );
632    }
633
634    #[test]
635    fn redacted_file_access_fixture_still_flags_arbitrary_file_access() {
636        let findings = fixture_findings("vuln_redacted_file_access");
637
638        assert!(
639            findings
640                .iter()
641                .any(|finding| finding.rule_id == "SHIELD-004"),
642            "redacted file path fixture should still trigger arbitrary file access: {findings:?}"
643        );
644    }
645
646    #[test]
647    fn wrong_category_sanitizer_does_not_suppress_file_sink() {
648        // A network-category validator (validateUrl) applied to a value used as
649        // a FILE PATH within the same function must NOT suppress SHIELD-004.
650        let findings = fixture_findings("vuln_wrong_category_sanitizer");
651
652        assert!(
653            findings
654                .iter()
655                .any(|finding| finding.rule_id == "SHIELD-004"),
656            "a network validator on a file-path sink must still trigger arbitrary file access: {findings:?}"
657        );
658    }
659
660    #[test]
661    fn type_coercion_does_not_suppress_eval_sink() {
662        // String()/str() coercion on an attacker value passed to eval must
663        // still fire SHIELD-011 — coercion is the wrong sanitizer category for
664        // a dynamic-exec sink and escapes nothing.
665        let findings = fixture_findings("vuln_coercion_eval");
666
667        assert!(
668            findings
669                .iter()
670                .any(|finding| finding.rule_id == "SHIELD-011"),
671            "type coercion on an eval sink must still trigger dynamic exec: {findings:?}"
672        );
673    }
674
675    #[test]
676    fn type_coercion_is_not_a_command_sanitizer() {
677        // str()/String() coercion is identity on a string and does not
678        // neutralize shell metacharacters, so it must not be accepted as a
679        // sanitizer for command or dynamic-exec sinks.
680        let coerced = ArgumentSource::Sanitized {
681            sanitizer: "type:str".into(),
682        };
683        assert!(
684            !arg_safe_for_sink(&coerced, SinkClass::Command),
685            "type coercion must not sanitize a command sink"
686        );
687        assert!(
688            !arg_safe_for_sink(&coerced, SinkClass::DynamicExec),
689            "type coercion must not sanitize a dynamic-exec sink"
690        );
691    }
692
693    #[test]
694    fn argument_source_is_tainted_for_sink_respects_category() {
695        // A network-category sanitizer is safe for a network sink but tainted
696        // for a file-path sink.
697        let net = ArgumentSource::Sanitized {
698            sanitizer: "network:validateUrl".into(),
699        };
700        assert!(!net.is_tainted_for_sink(SinkClass::NetworkUrl));
701        assert!(net.is_tainted_for_sink(SinkClass::FilePath));
702
703        let path = ArgumentSource::Sanitized {
704            sanitizer: "path:validatePath".into(),
705        };
706        assert!(!path.is_tainted_for_sink(SinkClass::FilePath));
707        assert!(path.is_tainted_for_sink(SinkClass::NetworkUrl));
708    }
709
710    #[test]
711    fn no_downgrade_when_unsanitized_caller_exists() {
712        // Two call sites: one safe, one tainted
713        let mut file_a = ParsedFile::default();
714        file_a.call_sites.push(CallSite {
715            callee: "readFile".into(),
716            arguments: vec![ArgumentSource::Sanitized {
717                sanitizer: "validatePath".into(),
718            }],
719            caller: Some("safeHandler".into()),
720            location: loc("safe.ts", 5),
721        });
722        file_a.call_sites.push(CallSite {
723            callee: "readFile".into(),
724            arguments: vec![ArgumentSource::Parameter {
725                name: "userInput".into(),
726            }],
727            caller: Some("unsafeHandler".into()),
728            location: loc("safe.ts", 10),
729        });
730
731        let mut file_b = ParsedFile::default();
732        file_b.function_defs.push(FunctionDef {
733            name: "readFile".into(),
734            params: vec!["path".into()],
735            is_exported: true,
736            location: loc("lib.ts", 1),
737        });
738        file_b.file_operations.push(FileOperation {
739            path_arg: ArgumentSource::Parameter {
740                name: "path".into(),
741            },
742            operation: FileOpType::Read,
743            location: loc("lib.ts", 3),
744        });
745
746        let mut files = vec![
747            (PathBuf::from("safe.ts"), file_a),
748            (PathBuf::from("lib.ts"), file_b),
749        ];
750
751        let result = apply_cross_file_sanitization(&mut files);
752
753        assert_eq!(result.downgraded_count, 0);
754        // Operation stays tainted
755        assert!(files[1].1.file_operations[0].path_arg.is_tainted());
756    }
757
758    #[test]
759    fn no_downgrade_for_exported_with_no_callers() {
760        let mut file_a = ParsedFile::default();
761        file_a.function_defs.push(FunctionDef {
762            name: "dangerousFunc".into(),
763            params: vec!["input".into()],
764            is_exported: true,
765            location: loc("lib.ts", 1),
766        });
767        file_a.file_operations.push(FileOperation {
768            path_arg: ArgumentSource::Parameter {
769                name: "input".into(),
770            },
771            operation: FileOpType::Write,
772            location: loc("lib.ts", 3),
773        });
774
775        let mut files = vec![(PathBuf::from("lib.ts"), file_a)];
776
777        let result = apply_cross_file_sanitization(&mut files);
778
779        assert_eq!(result.downgraded_count, 0);
780        assert!(files[0].1.file_operations[0].path_arg.is_tainted());
781    }
782
783    #[test]
784    fn downgrade_only_matching_params() {
785        // Function with 2 params, only first is always sanitized
786        let mut file_a = ParsedFile::default();
787        file_a.call_sites.push(CallSite {
788            callee: "copyFile".into(),
789            arguments: vec![
790                ArgumentSource::Sanitized {
791                    sanitizer: "validatePath".into(),
792                },
793                ArgumentSource::Parameter {
794                    name: "rawDest".into(),
795                },
796            ],
797            caller: Some("handler".into()),
798            location: loc("index.ts", 5),
799        });
800
801        let mut file_b = ParsedFile::default();
802        file_b.function_defs.push(FunctionDef {
803            name: "copyFile".into(),
804            params: vec!["src".into(), "dest".into()],
805            is_exported: true,
806            location: loc("lib.ts", 1),
807        });
808        // Two file operations, one per param
809        file_b.file_operations.push(FileOperation {
810            path_arg: ArgumentSource::Parameter { name: "src".into() },
811            operation: FileOpType::Read,
812            location: loc("lib.ts", 3),
813        });
814        file_b.file_operations.push(FileOperation {
815            path_arg: ArgumentSource::Parameter {
816                name: "dest".into(),
817            },
818            operation: FileOpType::Write,
819            location: loc("lib.ts", 4),
820        });
821
822        let mut files = vec![
823            (PathBuf::from("index.ts"), file_a),
824            (PathBuf::from("lib.ts"), file_b),
825        ];
826
827        let result = apply_cross_file_sanitization(&mut files);
828
829        assert_eq!(result.downgraded_count, 1); // Only src
830        assert!(!files[1].1.file_operations[0].path_arg.is_tainted()); // src: safe
831        assert!(files[1].1.file_operations[1].path_arg.is_tainted()); // dest: still tainted
832    }
833}