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, HashSet};
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    // Per-file set of (param name, sink) that are UNAMBIGUOUSLY safe:
277    // every function in the file declaring `param_name` is itself proven
278    // safe for `sink`. Used to scope the downgrade to the proven-safe
279    // function and avoid clearing an UNSAFE sibling that shares the param
280    // name (issue #33). When two functions in a file share a param
281    // name but only one is proven safe, that (param, sink) is excluded.
282    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    // Phase 2: Build call-site map.
325    // Key: callee name → Vec of (argument sources)
326    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    // Phase 3: Determine which functions have all-sanitized parameters per sink.
337    // For each function with a definition AND call sites, check if every
338    // call site passes values safe for each sink category. When a function is
339    // proven safe for a (param, sink), record it; if ANY function in the
340    // same file declaring that param is NOT proven safe, drop the
341    // (param, sink) from the unambiguous-safe set (issue #33).
342    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                // No discovered call sites. If exported, stay conservative.
349                continue;
350            }
351        };
352
353        for (file_idx, params, _is_exported) in defs {
354            // Check each parameter position
355            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                        // This function is NOT safe for this (param, sink), so the
371                        // param name is ambiguous within the file — remove it from
372                        // the unambiguous-safe set so no sibling gets downgraded.
373                        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    // Phase 4: Downgrade operations in the target functions.
383    // Scope guard (issue #33): only downgrade a (param, sink) that is in
384    // the file's unambiguous-safe set — i.e. EVERY function in that file
385    // declaring the param name was proven safe for that sink. If an unsafe
386    // sibling shares the param name, the entry was removed in Phase 3 and
387    // we leave the argument tainted.
388    for (file_idx, param_name, func_name, sink) in &params_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        // Encode the exact sink this downgrade was proven safe for, so the
397        // label round-trips through `sanitizer_allows_sink` and clears taint
398        // for THIS sink only. A bare description would parse to no category and
399        // resurface as a false positive now that detectors are sink-aware.
400        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        // File A (index.ts): calls readFileContent with sanitized arg
530        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        // File B (lib.ts): defines readFileContent, uses filePath param
541        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        // Verify the operation was downgraded
567        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        // A network-category validator (validateUrl) applied to a value used as
708        // a FILE PATH within the same function must NOT suppress SHIELD-004.
709        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        // String()/str() coercion on an attacker value passed to eval must
722        // still fire SHIELD-011 — coercion is the wrong sanitizer category for
723        // a dynamic-exec sink and escapes nothing.
724        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        // str()/String() coercion is identity on a string and does not
737        // neutralize shell metacharacters, so it must not be accepted as a
738        // sanitizer for command or dynamic-exec sinks.
739        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        // A network-category sanitizer is safe for a network sink but tainted
755        // for a file-path sink.
756        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        // Two call sites: one safe, one tainted
772        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        // Operation stays tainted
814        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        // Function with 2 params, only first is always sanitized
845        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        // Two file operations, one per param
868        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); // Only src
889        assert!(!files[1].1.file_operations[0].path_arg.is_tainted()); // src: safe
890        assert!(files[1].1.file_operations[1].path_arg.is_tainted()); // dest: still tainted
891    }
892
893    #[test]
894    fn unsafe_sibling_with_shared_param_stays_tainted() {
895        // Issue #33: two functions in the same file share a param name
896        // (`path`). `safeRead` is only ever called with a sanitized
897        // value, but `rawRead` is called with a tainted parameter. The
898        // unsafe sibling must NOT be downgraded even though the safe one
899        // is.
900        let mut file_a = ParsedFile::default();
901        // safeRead is called with a sanitized path
902        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        // rawRead is called with a TAINTED parameter
911        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        // safeRead's op (should downgrade)
934        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        // rawRead's op (must stay tainted)
942        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        // `path` is shared between a safe and an unsafe function in the
958        // same file, so ownership is ambiguous. The conservative fix (issue
959        // #33) refuses to downgrade either, which correctly keeps the
960        // unsafe sibling's operation tainted (no false negative).
961        assert_eq!(result.downgraded_count, 0);
962        assert!(files[1].1.file_operations[0].path_arg.is_tainted()); // safeRead: stays tainted (ambiguous)
963        assert!(files[1].1.file_operations[1].path_arg.is_tainted()); // rawRead: stays tainted (unsafe sibling protected)
964    }
965}