1use crate::types::{
37 CodeLocation, CodeType, Complexity, SecurityAnalysis, SecurityIssue, SecurityIssueType,
38 ValidationError,
39};
40use std::collections::HashSet;
41use swc_common::{sync::Lrc, SourceMap, Span};
42use swc_ecma_ast::*;
43use swc_ecma_parser::{lexer::Lexer, Parser, StringInput, Syntax};
44use swc_ecma_visit::{Visit, VisitWith};
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum HttpMethod {
49 Get,
50 Post,
51 Put,
52 Delete,
53 Patch,
54 Head,
55 Options,
56}
57
58impl HttpMethod {
59 pub fn is_read_only(&self) -> bool {
61 matches!(
62 self,
63 HttpMethod::Get | HttpMethod::Head | HttpMethod::Options
64 )
65 }
66
67 pub fn from_str(s: &str) -> Option<Self> {
69 match s.to_lowercase().as_str() {
70 "get" => Some(HttpMethod::Get),
71 "post" => Some(HttpMethod::Post),
72 "put" => Some(HttpMethod::Put),
73 "delete" => Some(HttpMethod::Delete),
74 "patch" => Some(HttpMethod::Patch),
75 "head" => Some(HttpMethod::Head),
76 "options" => Some(HttpMethod::Options),
77 _ => None,
78 }
79 }
80}
81
82#[derive(Debug, Clone)]
84pub struct ApiCall {
85 pub method: HttpMethod,
87 pub path: String,
89 pub is_dynamic_path: bool,
91 pub line: u32,
93 pub column: u32,
95}
96
97#[derive(Debug, Clone, Default)]
99pub struct OutputDeclaration {
100 pub has_declaration: bool,
102
103 pub type_string: Option<String>,
105
106 pub declared_fields: HashSet<String>,
108
109 pub has_spread_risk: bool,
111}
112
113#[derive(Debug, Clone, Default)]
115pub struct JavaScriptCodeInfo {
116 pub api_calls: Vec<ApiCall>,
118
119 pub is_read_only: bool,
121
122 pub endpoints_accessed: HashSet<String>,
124
125 pub methods_used: HashSet<String>,
127
128 pub uses_async: bool,
130
131 pub variable_names: Vec<String>,
133
134 pub max_depth: usize,
136
137 pub loop_count: usize,
139
140 pub all_loops_bounded: bool,
142
143 pub violations: Vec<SafetyViolation>,
145
146 pub statement_count: usize,
148
149 pub output_declaration: OutputDeclaration,
151
152 pub has_output_spread_risk: bool,
154}
155
156#[derive(Debug, Clone)]
158pub struct SafetyViolation {
159 pub violation_type: SafetyViolationType,
161 pub message: String,
163 pub location: Option<CodeLocation>,
165}
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub enum SafetyViolationType {
170 ImportExport,
172 DynamicCodeExecution,
174 UnboundedLoop,
176 FunctionDeclaration,
178 TryCatch,
180 NewKeyword,
182 ThisKeyword,
184 ClassDeclaration,
186 Generator,
188 WithStatement,
190 DeleteOperator,
192 PrototypeManipulation,
194 UnboundedForLoop,
196 UnknownApiCall,
198}
199
200pub struct JavaScriptValidator {
202 sensitive_paths: Vec<String>,
204
205 max_depth: usize,
207
208 max_api_calls: usize,
210
211 max_loops: usize,
213
214 max_statements: usize,
216
217 sdk_operations: HashSet<String>,
220}
221
222impl Default for JavaScriptValidator {
223 fn default() -> Self {
224 Self {
225 sensitive_paths: vec![
226 "/admin".into(),
227 "/internal".into(),
228 "/debug".into(),
229 "/metrics".into(),
230 "/health".into(),
231 ],
232 max_depth: 10,
233 max_api_calls: 50,
234 max_loops: 10,
235 max_statements: 100,
236 sdk_operations: HashSet::new(),
237 }
238 }
239}
240
241fn is_type_keyword(word: &str) -> bool {
243 matches!(
244 word,
245 "string"
246 | "number"
247 | "boolean"
248 | "null"
249 | "undefined"
250 | "void"
251 | "any"
252 | "never"
253 | "object"
254 | "Array"
255 | "Promise"
256 | "Record"
257 | "Map"
258 | "Set"
259 | "Date"
260 | "type"
261 | "interface"
262 )
263}
264
265impl JavaScriptValidator {
266 pub fn new(
268 sensitive_paths: Vec<String>,
269 max_depth: usize,
270 max_api_calls: usize,
271 max_loops: usize,
272 max_statements: usize,
273 ) -> Self {
274 Self {
275 sensitive_paths,
276 max_depth,
277 max_api_calls,
278 max_loops,
279 max_statements,
280 sdk_operations: HashSet::new(),
281 }
282 }
283
284 pub fn with_sdk_operations(mut self, operations: HashSet<String>) -> Self {
287 self.sdk_operations = operations;
288 self
289 }
290
291 fn parse_returns_annotation(code: &str) -> OutputDeclaration {
298 let mut declaration = OutputDeclaration::default();
299
300 for line in code.lines() {
302 let trimmed = line.trim();
303
304 if let Some(rest) = trimmed.strip_prefix("///") {
307 if let Some(returns_content) = Self::extract_returns_content(rest) {
308 declaration.has_declaration = true;
309 declaration.type_string = Some(returns_content.clone());
310 declaration.declared_fields = Self::extract_fields_from_type(&returns_content);
311 declaration.has_spread_risk = returns_content.contains("...");
312 return declaration;
313 }
314 }
315 else if let Some(rest) = trimmed.strip_prefix("//") {
317 if let Some(returns_content) = Self::extract_returns_content(rest) {
318 declaration.has_declaration = true;
319 declaration.type_string = Some(returns_content.clone());
320 declaration.declared_fields = Self::extract_fields_from_type(&returns_content);
321 declaration.has_spread_risk = returns_content.contains("...");
322 return declaration;
323 }
324 }
325
326 if trimmed.starts_with("/**") || trimmed.starts_with("*") {
328 let content = trimmed
329 .trim_start_matches("/**")
330 .trim_start_matches('*')
331 .trim_end_matches("*/")
332 .trim();
333
334 if let Some(returns_content) = Self::extract_returns_content(content) {
335 declaration.has_declaration = true;
336 declaration.type_string = Some(returns_content.clone());
337 declaration.declared_fields = Self::extract_fields_from_type(&returns_content);
338 declaration.has_spread_risk = returns_content.contains("...");
339 return declaration;
340 }
341 }
342 }
343
344 declaration
345 }
346
347 fn extract_returns_content(text: &str) -> Option<String> {
349 let text = text.trim();
350
351 let returns_pos = text.find("@returns").or_else(|| text.find("@return"))?;
353
354 let after_tag = &text[returns_pos..];
356 let content_start = after_tag.find(|c: char| c == '{' || c == '(')?;
357
358 let chars: Vec<char> = after_tag[content_start..].chars().collect();
360 let open_char = chars[0];
361 let close_char = if open_char == '{' { '}' } else { ')' };
362
363 let mut depth = 0;
364 let mut end_pos = 0;
365
366 for (i, c) in chars.iter().enumerate() {
367 if *c == open_char {
368 depth += 1;
369 } else if *c == close_char {
370 depth -= 1;
371 if depth == 0 {
372 end_pos = i + 1;
373 break;
374 }
375 }
376 }
377
378 if end_pos > 0 {
379 Some(after_tag[content_start..content_start + end_pos].to_string())
380 } else {
381 Some(after_tag[content_start..].trim().to_string())
383 }
384 }
385
386 fn extract_fields_from_type(type_string: &str) -> HashSet<String> {
391 let mut fields = HashSet::new();
392
393 let chars: Vec<char> = type_string.chars().collect();
395 let mut current_word = String::new();
396 let mut in_word = false;
397
398 for (_i, c) in chars.iter().enumerate() {
399 if c.is_alphanumeric() || *c == '_' {
400 current_word.push(*c);
401 in_word = true;
402 } else {
403 if in_word && *c == ':' {
404 if !current_word.is_empty()
406 && !is_type_keyword(¤t_word)
407 && !current_word.chars().next().unwrap().is_ascii_uppercase()
408 {
409 fields.insert(current_word.clone());
410 }
411 }
412 current_word.clear();
413 in_word = false;
414 }
415 }
416
417 fields
418 }
419
420 pub fn check_output_against_blocklist(
422 declaration: &OutputDeclaration,
423 blocked_fields: &HashSet<String>,
424 ) -> Vec<String> {
425 let mut violations = Vec::new();
426
427 for field in &declaration.declared_fields {
428 if blocked_fields.contains(field) {
430 violations.push(format!("Output declares blocked field: {}", field));
431 continue;
432 }
433
434 for blocked in blocked_fields {
436 if blocked.starts_with("*.") {
437 let pattern = &blocked[2..];
438 if field == pattern {
439 violations.push(format!(
440 "Output declares blocked field pattern: {}",
441 blocked
442 ));
443 }
444 }
445 }
446 }
447
448 violations
449 }
450
451 pub fn validate(&self, code: &str) -> Result<JavaScriptCodeInfo, ValidationError> {
453 let cm: Lrc<SourceMap> = Default::default();
455
456 let fm = cm.new_source_file(
458 swc_common::FileName::Custom("code.js".into()).into(),
459 code.to_string(),
460 );
461
462 let lexer = Lexer::new(
463 Syntax::Es(Default::default()),
464 EsVersion::Es2022,
465 StringInput::from(&*fm),
466 None,
467 );
468
469 let mut parser = Parser::new_from(lexer);
470
471 let module = parser
472 .parse_module()
473 .map_err(|e| ValidationError::ParseError {
474 message: format!("JavaScript parse error: {:?}", e.into_kind()),
475 line: 0,
476 column: 0,
477 })?;
478
479 let mut visitor = SafetyVisitor::new(&cm).with_sdk_operations(self.sdk_operations.clone());
481 module.visit_with(&mut visitor);
482
483 let mut info = visitor.into_info();
484
485 info.output_declaration = Self::parse_returns_annotation(code);
487
488 if info.api_calls.len() > self.max_api_calls {
490 return Err(ValidationError::SecurityError {
491 message: format!(
492 "Too many API calls: {} (max: {})",
493 info.api_calls.len(),
494 self.max_api_calls
495 ),
496 issue: SecurityIssueType::HighComplexity,
497 });
498 }
499
500 if info.max_depth > self.max_depth {
501 return Err(ValidationError::SecurityError {
502 message: format!(
503 "Code nesting depth {} exceeds maximum {}",
504 info.max_depth, self.max_depth
505 ),
506 issue: SecurityIssueType::DeepNesting,
507 });
508 }
509
510 if info.loop_count > self.max_loops {
511 return Err(ValidationError::SecurityError {
512 message: format!(
513 "Too many loops: {} (max: {})",
514 info.loop_count, self.max_loops
515 ),
516 issue: SecurityIssueType::HighComplexity,
517 });
518 }
519
520 if info.statement_count > self.max_statements {
521 return Err(ValidationError::SecurityError {
522 message: format!(
523 "Too many statements: {} (max: {})",
524 info.statement_count, self.max_statements
525 ),
526 issue: SecurityIssueType::HighComplexity,
527 });
528 }
529
530 if !info.violations.is_empty() {
532 let first = &info.violations[0];
533 return Err(ValidationError::SecurityError {
534 message: first.message.clone(),
535 issue: violation_to_security_issue(first.violation_type),
536 });
537 }
538
539 info.is_read_only = info.api_calls.iter().all(|c| c.method.is_read_only());
541
542 Ok(info)
543 }
544
545 pub fn analyze_security(&self, info: &JavaScriptCodeInfo) -> SecurityAnalysis {
547 let mut analysis = SecurityAnalysis {
548 is_read_only: info.is_read_only,
549 tables_accessed: info.endpoints_accessed.clone(),
550 fields_accessed: HashSet::new(),
551 has_aggregation: false,
552 has_subqueries: info.max_depth > 3,
553 estimated_complexity: self.estimate_complexity(info),
554 potential_issues: Vec::new(),
555 estimated_rows: None,
556 };
557
558 for endpoint in &info.endpoints_accessed {
560 let endpoint_lower = endpoint.to_lowercase();
561 if self
562 .sensitive_paths
563 .iter()
564 .any(|s| endpoint_lower.contains(&s.to_lowercase()))
565 {
566 analysis.potential_issues.push(SecurityIssue::new(
567 SecurityIssueType::SensitiveFields,
568 format!("Code accesses potentially sensitive endpoint: {}", endpoint),
569 ));
570 }
571 }
572
573 for call in &info.api_calls {
575 if call.is_dynamic_path {
576 analysis.potential_issues.push(
577 SecurityIssue::new(
578 SecurityIssueType::DynamicTableName,
579 format!(
580 "API call at line {} uses dynamic path interpolation",
581 call.line
582 ),
583 )
584 .with_location(CodeLocation {
585 line: call.line,
586 column: call.column,
587 }),
588 );
589 }
590 }
591
592 if info.max_depth > 5 {
594 analysis.potential_issues.push(SecurityIssue::new(
595 SecurityIssueType::DeepNesting,
596 format!("Code has deep nesting (depth: {})", info.max_depth),
597 ));
598 }
599
600 if !info.all_loops_bounded && info.loop_count > 0 {
602 analysis.potential_issues.push(SecurityIssue::new(
603 SecurityIssueType::UnboundedQuery,
604 "Code contains for...of loops without .slice() bounds",
605 ));
606 }
607
608 if matches!(analysis.estimated_complexity, Complexity::High) {
610 analysis.potential_issues.push(SecurityIssue::new(
611 SecurityIssueType::HighComplexity,
612 "Code has high complexity",
613 ));
614 }
615
616 analysis
617 }
618
619 fn estimate_complexity(&self, info: &JavaScriptCodeInfo) -> Complexity {
621 let api_count = info.api_calls.len();
622 let loop_count = info.loop_count;
623 let depth = info.max_depth;
624 let statement_count = info.statement_count;
625
626 let complexity_score = api_count * 3 + loop_count * 5 + depth * 2 + statement_count;
628
629 if complexity_score > 100 {
630 Complexity::High
631 } else if complexity_score > 50 {
632 Complexity::Medium
633 } else {
634 Complexity::Low
635 }
636 }
637
638 pub fn to_code_type(&self, info: &JavaScriptCodeInfo) -> CodeType {
640 if info.is_read_only {
641 CodeType::RestGet
642 } else {
643 CodeType::RestMutation
644 }
645 }
646}
647
648struct SafetyVisitor {
650 source_map: Lrc<SourceMap>,
651 api_calls: Vec<ApiCall>,
652 violations: Vec<SafetyViolation>,
653 variable_names: Vec<String>,
654 endpoints_accessed: HashSet<String>,
655 methods_used: HashSet<String>,
656 uses_async: bool,
657 current_depth: usize,
658 max_depth: usize,
659 loop_count: usize,
660 bounded_loops: usize,
661 statement_count: usize,
662 has_spread_in_return: bool,
664 in_return_context: bool,
666 sdk_operations: HashSet<String>,
668}
669
670impl SafetyVisitor {
671 fn new(source_map: &Lrc<SourceMap>) -> Self {
672 Self {
673 source_map: source_map.clone(),
674 api_calls: Vec::new(),
675 violations: Vec::new(),
676 variable_names: Vec::new(),
677 endpoints_accessed: HashSet::new(),
678 methods_used: HashSet::new(),
679 uses_async: false,
680 current_depth: 0,
681 max_depth: 0,
682 loop_count: 0,
683 bounded_loops: 0,
684 statement_count: 0,
685 has_spread_in_return: false,
686 in_return_context: false,
687 sdk_operations: HashSet::new(),
688 }
689 }
690
691 fn with_sdk_operations(mut self, operations: HashSet<String>) -> Self {
692 self.sdk_operations = operations;
693 self
694 }
695
696 fn into_info(self) -> JavaScriptCodeInfo {
697 JavaScriptCodeInfo {
698 api_calls: self.api_calls,
699 is_read_only: false, endpoints_accessed: self.endpoints_accessed,
701 methods_used: self.methods_used,
702 uses_async: self.uses_async,
703 variable_names: self.variable_names,
704 max_depth: self.max_depth,
705 loop_count: self.loop_count,
706 all_loops_bounded: self.loop_count == 0 || self.bounded_loops == self.loop_count,
707 violations: self.violations,
708 statement_count: self.statement_count,
709 output_declaration: OutputDeclaration::default(), has_output_spread_risk: self.has_spread_in_return,
711 }
712 }
713
714 fn span_to_location(&self, span: Span) -> CodeLocation {
715 let loc = self.source_map.lookup_char_pos(span.lo);
716 CodeLocation {
717 line: loc.line as u32,
718 column: loc.col_display as u32,
719 }
720 }
721
722 fn add_violation(&mut self, violation_type: SafetyViolationType, message: &str, span: Span) {
723 self.violations.push(SafetyViolation {
724 violation_type,
725 message: message.into(),
726 location: Some(self.span_to_location(span)),
727 });
728 }
729
730 fn check_api_call(&mut self, call: &CallExpr) {
731 if let Callee::Expr(expr) = &call.callee {
733 if let Expr::Member(member) = &**expr {
734 if let Expr::Ident(obj) = &*member.obj {
735 if obj.sym.as_ref() == "api" {
736 if let MemberProp::Ident(method_ident) = &member.prop {
737 let method_name = method_ident.sym.as_ref();
738
739 if !self.sdk_operations.is_empty() {
740 if self.sdk_operations.contains(method_name) {
742 self.methods_used.insert(method_name.to_string());
743 self.endpoints_accessed
744 .insert(format!("sdk:{}", method_name));
745 } else {
747 self.add_violation(
748 SafetyViolationType::UnknownApiCall,
749 &format!(
750 "Unknown SDK operation: api.{}(). Check the code mode schema resource for available operations.",
751 method_name
752 ),
753 call.span,
754 );
755 }
756 return;
757 }
758
759 if let Some(method) = HttpMethod::from_str(method_name) {
761 self.methods_used.insert(method_name.to_uppercase());
762
763 let (path, is_dynamic) = if let Some(arg) = call.args.first() {
765 self.extract_path(&arg.expr)
766 } else {
767 ("unknown".into(), false)
768 };
769
770 self.endpoints_accessed.insert(path.clone());
771
772 let loc = self.span_to_location(call.span);
773 self.api_calls.push(ApiCall {
774 method,
775 path,
776 is_dynamic_path: is_dynamic,
777 line: loc.line,
778 column: loc.column,
779 });
780 } else {
781 self.add_violation(
782 SafetyViolationType::UnknownApiCall,
783 &format!("Unknown api method: api.{}()", method_name),
784 call.span,
785 );
786 }
787 }
788 }
789 }
790 }
791 }
792 }
793
794 fn extract_path(&self, expr: &Expr) -> (String, bool) {
795 match expr {
796 Expr::Lit(Lit::Str(s)) => {
797 (s.value.to_string_lossy().into_owned(), false)
799 },
800 Expr::Tpl(tpl) => {
801 let mut path = String::new();
803 for quasi in &tpl.quasis {
804 path.push_str(&quasi.raw.to_string());
806 if !tpl.exprs.is_empty() {
807 path.push_str("{...}");
808 }
809 }
810 (path, !tpl.exprs.is_empty())
811 },
812 _ => ("dynamic".into(), true),
813 }
814 }
815
816 fn check_for_bounded(&mut self, for_of: &ForOfStmt) -> bool {
817 if let Expr::Call(call) = &*for_of.right {
819 if let Callee::Expr(callee) = &call.callee {
820 if let Expr::Member(member) = &**callee {
821 if let MemberProp::Ident(ident) = &member.prop {
822 if ident.sym.as_ref() == "slice" {
823 return true;
824 }
825 }
826 }
827 }
828 }
829 false
830 }
831}
832
833impl Visit for SafetyVisitor {
834 fn visit_block_stmt(&mut self, n: &BlockStmt) {
836 self.current_depth += 1;
837 self.max_depth = self.max_depth.max(self.current_depth);
838 n.visit_children_with(self);
839 self.current_depth -= 1;
840 }
841
842 fn visit_stmt(&mut self, n: &Stmt) {
844 self.statement_count += 1;
845 n.visit_children_with(self);
846 }
847
848 fn visit_import_decl(&mut self, n: &ImportDecl) {
850 self.add_violation(
851 SafetyViolationType::ImportExport,
852 "import statements are not allowed",
853 n.span,
854 );
855 }
856
857 fn visit_export_decl(&mut self, n: &ExportDecl) {
858 self.add_violation(
859 SafetyViolationType::ImportExport,
860 "export statements are not allowed",
861 n.span,
862 );
863 }
864
865 fn visit_export_default_decl(&mut self, n: &ExportDefaultDecl) {
866 self.add_violation(
867 SafetyViolationType::ImportExport,
868 "export default is not allowed",
869 n.span,
870 );
871 }
872
873 fn visit_export_default_expr(&mut self, n: &ExportDefaultExpr) {
874 self.add_violation(
875 SafetyViolationType::ImportExport,
876 "export default is not allowed",
877 n.span,
878 );
879 }
880
881 fn visit_call_expr(&mut self, n: &CallExpr) {
883 if let Callee::Expr(callee) = &n.callee {
885 if let Expr::Ident(ident) = &**callee {
886 let name = ident.sym.as_ref();
887 if name == "eval" || name == "Function" {
888 self.add_violation(
889 SafetyViolationType::DynamicCodeExecution,
890 &format!("{}() is not allowed", name),
891 n.span,
892 );
893 }
894 }
895 }
896
897 self.check_api_call(n);
899
900 n.visit_children_with(self);
901 }
902
903 fn visit_while_stmt(&mut self, n: &WhileStmt) {
905 self.add_violation(
906 SafetyViolationType::UnboundedLoop,
907 "while loops are not allowed (use bounded for...of with .slice())",
908 n.span,
909 );
910 n.visit_children_with(self);
911 }
912
913 fn visit_do_while_stmt(&mut self, n: &DoWhileStmt) {
914 self.add_violation(
915 SafetyViolationType::UnboundedLoop,
916 "do-while loops are not allowed (use bounded for...of with .slice())",
917 n.span,
918 );
919 n.visit_children_with(self);
920 }
921
922 fn visit_for_of_stmt(&mut self, n: &ForOfStmt) {
924 self.loop_count += 1;
925 if self.check_for_bounded(n) {
926 self.bounded_loops += 1;
927 }
928 n.visit_children_with(self);
929 }
930
931 fn visit_for_stmt(&mut self, n: &ForStmt) {
933 self.loop_count += 1;
934 self.bounded_loops += 1;
936 n.visit_children_with(self);
937 }
938
939 fn visit_fn_decl(&mut self, n: &FnDecl) {
941 self.add_violation(
942 SafetyViolationType::FunctionDeclaration,
943 "function declarations are not allowed (use arrow functions)",
944 n.function.span,
945 );
946 n.visit_children_with(self);
947 }
948
949 fn visit_try_stmt(&mut self, n: &TryStmt) {
952 n.visit_children_with(self);
954 }
955
956 fn visit_new_expr(&mut self, n: &NewExpr) {
958 let allowed = if let Expr::Ident(ident) = &*n.callee {
960 matches!(
961 ident.sym.as_ref(),
962 "Date" | "URL" | "URLSearchParams" | "Map" | "Set" | "Array"
963 )
964 } else {
965 false
966 };
967
968 if !allowed {
969 self.add_violation(
970 SafetyViolationType::NewKeyword,
971 "new keyword is only allowed for Date, URL, URLSearchParams, Map, Set, Array",
972 n.span,
973 );
974 }
975 n.visit_children_with(self);
976 }
977
978 fn visit_this_expr(&mut self, n: &ThisExpr) {
980 self.add_violation(
981 SafetyViolationType::ThisKeyword,
982 "'this' keyword is not allowed",
983 n.span,
984 );
985 }
986
987 fn visit_class_decl(&mut self, n: &ClassDecl) {
989 self.add_violation(
990 SafetyViolationType::ClassDeclaration,
991 "class declarations are not allowed",
992 n.class.span,
993 );
994 n.visit_children_with(self);
995 }
996
997 fn visit_with_stmt(&mut self, n: &WithStmt) {
999 self.add_violation(
1000 SafetyViolationType::WithStatement,
1001 "'with' statement is not allowed",
1002 n.span,
1003 );
1004 n.visit_children_with(self);
1005 }
1006
1007 fn visit_await_expr(&mut self, n: &AwaitExpr) {
1009 self.uses_async = true;
1010 n.visit_children_with(self);
1011 }
1012
1013 fn visit_var_decl(&mut self, n: &VarDecl) {
1015 for decl in &n.decls {
1016 if let Pat::Ident(ident) = &decl.name {
1017 self.variable_names.push(ident.id.sym.to_string());
1018 }
1019 }
1020 n.visit_children_with(self);
1021 }
1022
1023 fn visit_function(&mut self, n: &Function) {
1025 if n.is_generator {
1026 self.add_violation(
1027 SafetyViolationType::Generator,
1028 "generator functions are not allowed",
1029 n.span,
1030 );
1031 }
1032 n.visit_children_with(self);
1033 }
1034
1035 fn visit_unary_expr(&mut self, n: &UnaryExpr) {
1037 if n.op == UnaryOp::Delete {
1038 self.add_violation(
1039 SafetyViolationType::DeleteOperator,
1040 "'delete' operator is not allowed",
1041 n.span,
1042 );
1043 }
1044 n.visit_children_with(self);
1045 }
1046
1047 fn visit_member_expr(&mut self, n: &MemberExpr) {
1049 if let MemberProp::Ident(ident) = &n.prop {
1050 let name = ident.sym.as_ref();
1051 if name == "__proto__" || name == "prototype" {
1052 self.add_violation(
1053 SafetyViolationType::PrototypeManipulation,
1054 "prototype manipulation is not allowed",
1055 n.span,
1056 );
1057 }
1058 }
1059 n.visit_children_with(self);
1060 }
1061
1062 fn visit_return_stmt(&mut self, n: &ReturnStmt) {
1064 self.in_return_context = true;
1065 n.visit_children_with(self);
1066 self.in_return_context = false;
1067 }
1068
1069 fn visit_spread_element(&mut self, n: &SpreadElement) {
1071 if self.in_return_context {
1072 self.has_spread_in_return = true;
1073 }
1074 n.visit_children_with(self);
1075 }
1076}
1077
1078fn violation_to_security_issue(violation: SafetyViolationType) -> SecurityIssueType {
1080 match violation {
1081 SafetyViolationType::DynamicCodeExecution => SecurityIssueType::PotentialInjection,
1082 SafetyViolationType::PrototypeManipulation => SecurityIssueType::PotentialInjection,
1083 SafetyViolationType::UnboundedLoop | SafetyViolationType::UnboundedForLoop => {
1084 SecurityIssueType::UnboundedQuery
1085 },
1086 _ => SecurityIssueType::HighComplexity,
1087 }
1088}
1089
1090#[cfg(test)]
1091mod tests {
1092 use super::*;
1093
1094 #[test]
1095 fn test_simple_api_call() {
1096 let validator = JavaScriptValidator::default();
1097 let code = r#"
1098 const response = await api.get("/users");
1099 return response.data;
1100 "#;
1101
1102 let info = validator.validate(code).unwrap();
1103 assert!(info.is_read_only);
1104 assert_eq!(info.api_calls.len(), 1);
1105 assert_eq!(info.api_calls[0].method, HttpMethod::Get);
1106 assert!(info.endpoints_accessed.contains("/users"));
1107 }
1108
1109 #[test]
1110 fn test_multiple_api_calls() {
1111 let validator = JavaScriptValidator::default();
1112 let code = r#"
1113 const user = await api.get("/users/123");
1114 const orders = await api.get(`/users/${user.id}/orders`);
1115 return { user, orders };
1116 "#;
1117
1118 let info = validator.validate(code).unwrap();
1119 assert!(info.is_read_only);
1120 assert_eq!(info.api_calls.len(), 2);
1121 assert!(info.api_calls[1].is_dynamic_path);
1122 }
1123
1124 #[test]
1125 fn test_mutation_detection() {
1126 let validator = JavaScriptValidator::default();
1127 let code = r#"
1128 const result = await api.post("/users", { name: "test" });
1129 return result;
1130 "#;
1131
1132 let info = validator.validate(code).unwrap();
1133 assert!(!info.is_read_only);
1134 assert_eq!(info.api_calls[0].method, HttpMethod::Post);
1135 }
1136
1137 #[test]
1138 fn test_reject_eval() {
1139 let validator = JavaScriptValidator::default();
1140 let code = r#"
1141 const result = eval("api.get('/users')");
1142 "#;
1143
1144 let result = validator.validate(code);
1145 assert!(result.is_err());
1146 }
1147
1148 #[test]
1149 fn test_reject_while_loop() {
1150 let validator = JavaScriptValidator::default();
1151 let code = r#"
1152 let i = 0;
1153 while (i < 10) {
1154 await api.get("/data");
1155 i++;
1156 }
1157 "#;
1158
1159 let result = validator.validate(code);
1160 assert!(result.is_err());
1161 }
1162
1163 #[test]
1164 fn test_allow_bounded_for_of() {
1165 let validator = JavaScriptValidator::default();
1166 let code = r#"
1167 const results = [];
1168 for (const id of userIds.slice(0, 10)) {
1169 const user = await api.get(`/users/${id}`);
1170 results.push(user);
1171 }
1172 return results;
1173 "#;
1174
1175 let info = validator.validate(code).unwrap();
1176 assert!(info.all_loops_bounded);
1177 assert_eq!(info.loop_count, 1);
1178 }
1179
1180 #[test]
1181 fn test_reject_import() {
1182 let validator = JavaScriptValidator::default();
1183 let code = r#"
1184 import axios from 'axios';
1185 const result = await api.get("/users");
1186 "#;
1187
1188 let result = validator.validate(code);
1189 assert!(result.is_err());
1190 }
1191
1192 #[test]
1193 fn test_allow_arrow_functions() {
1194 let validator = JavaScriptValidator::default();
1195 let code = r#"
1196 const users = await api.get("/users");
1197 const names = users.data.map(u => u.name);
1198 return names;
1199 "#;
1200
1201 let info = validator.validate(code).unwrap();
1202 assert!(info.violations.is_empty());
1203 }
1204
1205 #[test]
1206 fn test_reject_function_declaration() {
1207 let validator = JavaScriptValidator::default();
1208 let code = r#"
1209 function fetchUser(id) {
1210 return api.get(`/users/${id}`);
1211 }
1212 "#;
1213
1214 let result = validator.validate(code);
1215 assert!(result.is_err());
1216 }
1217
1218 #[test]
1219 fn test_security_analysis_sensitive_endpoint() {
1220 let validator = JavaScriptValidator::default();
1221 let code = r#"
1222 const config = await api.get("/admin/config");
1223 return config;
1224 "#;
1225
1226 let info = validator.validate(code).unwrap();
1227 let analysis = validator.analyze_security(&info);
1228
1229 assert!(analysis
1230 .potential_issues
1231 .iter()
1232 .any(|i| matches!(i.issue_type, SecurityIssueType::SensitiveFields)));
1233 }
1234
1235 #[test]
1236 fn test_parse_returns_annotation_triple_slash() {
1237 let validator = JavaScriptValidator::default();
1238 let code = r#"
1239 /// @returns { users: Array<{ id: string, name: string }> }
1240 const users = await api.get("/users");
1241 return { users: users.map(u => ({ id: u.id, name: u.name })) };
1242 "#;
1243
1244 let info = validator.validate(code).unwrap();
1245 assert!(info.output_declaration.has_declaration);
1246 assert!(info.output_declaration.declared_fields.contains("id"));
1247 assert!(info.output_declaration.declared_fields.contains("name"));
1248 assert!(info.output_declaration.declared_fields.contains("users"));
1249 }
1250
1251 #[test]
1252 fn test_parse_returns_annotation_double_slash() {
1253 let validator = JavaScriptValidator::default();
1254 let code = r#"
1255 // @returns { products: Array<{ id: string, name: string, price: number }> }
1256 const products = await api.get("/products");
1257 return { products: products.map(p => ({ id: p.id, name: p.name, price: p.price })) };
1258 "#;
1259
1260 let info = validator.validate(code).unwrap();
1261 assert!(info.output_declaration.has_declaration);
1262 assert!(info.output_declaration.declared_fields.contains("id"));
1263 assert!(info.output_declaration.declared_fields.contains("name"));
1264 assert!(info.output_declaration.declared_fields.contains("price"));
1265 assert!(info.output_declaration.declared_fields.contains("products"));
1266 }
1267
1268 #[test]
1269 fn test_parse_returns_annotation_jsdoc() {
1270 let validator = JavaScriptValidator::default();
1271 let code = r#"
1272 /** @returns { user: { id: string, email: string } } */
1273 const user = await api.get("/users/123");
1274 return { user: { id: user.id, email: user.email } };
1275 "#;
1276
1277 let info = validator.validate(code).unwrap();
1278 assert!(info.output_declaration.has_declaration);
1279 assert!(info.output_declaration.declared_fields.contains("id"));
1280 assert!(info.output_declaration.declared_fields.contains("email"));
1281 assert!(info.output_declaration.declared_fields.contains("user"));
1282 }
1283
1284 #[test]
1285 fn test_no_returns_annotation() {
1286 let validator = JavaScriptValidator::default();
1287 let code = r#"
1288 const users = await api.get("/users");
1289 return users;
1290 "#;
1291
1292 let info = validator.validate(code).unwrap();
1293 assert!(!info.output_declaration.has_declaration);
1294 assert!(info.output_declaration.declared_fields.is_empty());
1295 }
1296
1297 #[test]
1298 fn test_spread_operator_detection() {
1299 let validator = JavaScriptValidator::default();
1300 let code = r#"
1301 const user = await api.get("/users/123");
1302 return { ...user, computed: "value" };
1303 "#;
1304
1305 let info = validator.validate(code).unwrap();
1306 assert!(info.has_output_spread_risk);
1307 }
1308
1309 #[test]
1310 fn test_no_spread_operator_in_return() {
1311 let validator = JavaScriptValidator::default();
1312 let code = r#"
1313 const user = await api.get("/users/123");
1314 return { id: user.id, name: user.name };
1315 "#;
1316
1317 let info = validator.validate(code).unwrap();
1318 assert!(!info.has_output_spread_risk);
1319 }
1320
1321 #[test]
1322 fn test_check_output_against_blocklist() {
1323 let declaration = OutputDeclaration {
1324 has_declaration: true,
1325 type_string: Some("{ id: string, ssn: string }".to_string()),
1326 declared_fields: ["id", "ssn"].iter().map(|s| s.to_string()).collect(),
1327 has_spread_risk: false,
1328 };
1329
1330 let blocked_fields: HashSet<String> =
1331 ["ssn", "password"].iter().map(|s| s.to_string()).collect();
1332
1333 let violations =
1334 JavaScriptValidator::check_output_against_blocklist(&declaration, &blocked_fields);
1335 assert_eq!(violations.len(), 1);
1336 assert!(violations[0].contains("ssn"));
1337 }
1338
1339 #[test]
1340 fn test_check_output_against_wildcard_blocklist() {
1341 let declaration = OutputDeclaration {
1342 has_declaration: true,
1343 type_string: Some("{ user: { id: string, salary: number } }".to_string()),
1344 declared_fields: ["user", "id", "salary"]
1345 .iter()
1346 .map(|s| s.to_string())
1347 .collect(),
1348 has_spread_risk: false,
1349 };
1350
1351 let blocked_fields: HashSet<String> = ["*.salary"].iter().map(|s| s.to_string()).collect();
1352
1353 let violations =
1354 JavaScriptValidator::check_output_against_blocklist(&declaration, &blocked_fields);
1355 assert_eq!(violations.len(), 1);
1356 assert!(violations[0].contains("salary"));
1357 }
1358}