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