flareguard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/Cargo.lock +2962 -0
  2. package/Cargo.toml +66 -0
  3. package/LICENSE +21 -0
  4. package/README.md +115 -0
  5. package/bin/flareguard.js +78 -0
  6. package/package.json +50 -0
  7. package/scripts/postinstall.mjs +83 -0
  8. package/src/bindings/ast_scanner.rs +950 -0
  9. package/src/bindings/cli.rs +49 -0
  10. package/src/bindings/jsonc.rs +166 -0
  11. package/src/bindings/mod.rs +7 -0
  12. package/src/bindings/reporter.rs +328 -0
  13. package/src/bindings/types.rs +129 -0
  14. package/src/bindings/validator.rs +227 -0
  15. package/src/bindings/wrangler.rs +647 -0
  16. package/src/cli.rs +77 -0
  17. package/src/lib.rs +7 -0
  18. package/src/main.rs +451 -0
  19. package/src/origin/cli.rs +92 -0
  20. package/src/origin/cloudflare.rs +170 -0
  21. package/src/origin/confidence.rs +320 -0
  22. package/src/origin/crtsh.rs +144 -0
  23. package/src/origin/dns.rs +177 -0
  24. package/src/origin/enumerator.rs +271 -0
  25. package/src/origin/error.rs +19 -0
  26. package/src/origin/mock.rs +320 -0
  27. package/src/origin/mod.rs +19 -0
  28. package/src/origin/models.rs +167 -0
  29. package/src/origin/prober.rs +260 -0
  30. package/src/origin/remediation.rs +73 -0
  31. package/src/origin/report.rs +625 -0
  32. package/src/origin/scanner.rs +217 -0
  33. package/src/secrets/cli.rs +94 -0
  34. package/src/secrets/env_parser.rs +464 -0
  35. package/src/secrets/ignore.rs +139 -0
  36. package/src/secrets/mod.rs +14 -0
  37. package/src/secrets/report/json_format.rs +97 -0
  38. package/src/secrets/report/mod.rs +48 -0
  39. package/src/secrets/report/sarif.rs +225 -0
  40. package/src/secrets/report/text.rs +105 -0
  41. package/src/secrets/rules/builtin.rs +280 -0
  42. package/src/secrets/rules/entropy.rs +66 -0
  43. package/src/secrets/rules/mod.rs +7 -0
  44. package/src/secrets/rules/types.rs +183 -0
  45. package/src/secrets/scanner.rs +444 -0
  46. package/src/zone/cli.rs +111 -0
  47. package/src/zone/client/cf_client.rs +405 -0
  48. package/src/zone/client/mod.rs +5 -0
  49. package/src/zone/client/provider.rs +12 -0
  50. package/src/zone/mock_data.rs +481 -0
  51. package/src/zone/mod.rs +125 -0
  52. package/src/zone/models/audit.rs +194 -0
  53. package/src/zone/models/cloudflare.rs +252 -0
  54. package/src/zone/models/mod.rs +7 -0
  55. package/src/zone/models/sarif.rs +89 -0
  56. package/src/zone/reporters/html_rep.rs +345 -0
  57. package/src/zone/reporters/json_rep.rs +7 -0
  58. package/src/zone/reporters/mod.rs +9 -0
  59. package/src/zone/reporters/sarif_rep.rs +100 -0
  60. package/src/zone/reporters/terminal.rs +322 -0
  61. package/src/zone/rules/definitions.rs +201 -0
  62. package/src/zone/rules/evaluator.rs +484 -0
  63. package/src/zone/rules/mod.rs +5 -0
  64. package/src/zone/scoring.rs +104 -0
@@ -0,0 +1,950 @@
1
+ use crate::bindings::types::{AccessKind, BindingAccess};
2
+ use oxc_allocator::Allocator;
3
+ use oxc_ast::ast::*;
4
+ use oxc_parser::Parser;
5
+ use oxc_span::{SourceType, Span};
6
+ use std::collections::HashSet;
7
+ use std::fs;
8
+ use std::path::Path;
9
+
10
+ /// Index to convert byte offsets into 1-based line and column numbers.
11
+ pub struct LineIndex {
12
+ line_starts: Vec<usize>,
13
+ }
14
+
15
+ impl LineIndex {
16
+ pub fn new(source: &str) -> Self {
17
+ let mut line_starts = vec![0];
18
+ for (i, byte) in source.bytes().enumerate() {
19
+ if byte == b'\n' {
20
+ line_starts.push(i + 1);
21
+ }
22
+ }
23
+ Self { line_starts }
24
+ }
25
+
26
+ pub fn line_col(&self, byte_offset: usize) -> (usize, usize) {
27
+ match self.line_starts.binary_search(&byte_offset) {
28
+ Ok(idx) => (idx + 1, 1),
29
+ Err(idx) => {
30
+ let line = idx; // 1-based line
31
+ let line_start = self.line_starts[idx - 1];
32
+ let col = byte_offset.saturating_sub(line_start) + 1;
33
+ (line, col)
34
+ }
35
+ }
36
+ }
37
+ }
38
+
39
+ /// Scanner that walks an AST to collect all Cloudflare binding references.
40
+ pub struct AstScanner<'a> {
41
+ file_path: &'a str,
42
+ #[allow(dead_code)]
43
+ source_text: &'a str,
44
+ line_index: LineIndex,
45
+ ignore_lines: HashSet<usize>,
46
+ accesses: Vec<BindingAccess>,
47
+ }
48
+
49
+ impl<'a> AstScanner<'a> {
50
+ pub fn new(file_path: &'a str, source_text: &'a str) -> Self {
51
+ let line_index = LineIndex::new(source_text);
52
+ let mut ignore_lines = HashSet::new();
53
+
54
+ // Scan for // cf-ignore or // cf-binding-ignore comments
55
+ for (idx, line) in source_text.lines().enumerate() {
56
+ let line_num = idx + 1;
57
+ if line.contains("cf-ignore") || line.contains("cf-binding-ignore") {
58
+ ignore_lines.insert(line_num);
59
+ ignore_lines.insert(line_num + 1); // Also ignore next line
60
+ }
61
+ }
62
+
63
+ Self {
64
+ file_path,
65
+ source_text,
66
+ line_index,
67
+ ignore_lines,
68
+ accesses: Vec::new(),
69
+ }
70
+ }
71
+
72
+ pub fn scan_program(&mut self, program: &Program) -> Vec<BindingAccess> {
73
+ for stmt in &program.body {
74
+ self.walk_statement(stmt);
75
+ }
76
+ self.accesses.clone()
77
+ }
78
+
79
+ fn record_access(&mut self, name: &str, span: Span, kind: AccessKind, raw_expr: &str) {
80
+ let (line, column) = self.line_index.line_col(span.start as usize);
81
+ if self.ignore_lines.contains(&line) {
82
+ return;
83
+ }
84
+
85
+ // Avoid common false positives / standard JS built-ins
86
+ if name.is_empty()
87
+ || name == "undefined"
88
+ || name == "null"
89
+ || name == "prototype"
90
+ || name == "constructor"
91
+ || name == "length"
92
+ || name == "toString"
93
+ || name == "valueOf"
94
+ {
95
+ return;
96
+ }
97
+
98
+ self.accesses.push(BindingAccess {
99
+ name: name.to_string(),
100
+ file_path: self.file_path.to_string(),
101
+ line,
102
+ column,
103
+ raw_expression: raw_expr.to_string(),
104
+ access_kind: kind,
105
+ });
106
+ }
107
+
108
+ // ==========================================
109
+ // AST Walkers
110
+ // ==========================================
111
+
112
+ fn walk_block_statement(&mut self, b: &BlockStatement) {
113
+ for s in &b.body {
114
+ self.walk_statement(s);
115
+ }
116
+ }
117
+
118
+ fn walk_statement(&mut self, stmt: &Statement) {
119
+ match stmt {
120
+ Statement::BlockStatement(b) => {
121
+ self.walk_block_statement(b);
122
+ }
123
+ Statement::ExpressionStatement(e) => {
124
+ self.walk_expression(&e.expression);
125
+ }
126
+ Statement::IfStatement(s) => {
127
+ self.walk_expression(&s.test);
128
+ self.walk_statement(&s.consequent);
129
+ if let Some(alt) = &s.alternate {
130
+ self.walk_statement(alt);
131
+ }
132
+ }
133
+ Statement::DoWhileStatement(s) => {
134
+ self.walk_statement(&s.body);
135
+ self.walk_expression(&s.test);
136
+ }
137
+ Statement::WhileStatement(s) => {
138
+ self.walk_expression(&s.test);
139
+ self.walk_statement(&s.body);
140
+ }
141
+ Statement::ForStatement(s) => {
142
+ if let Some(init) = &s.init {
143
+ match init {
144
+ ForStatementInit::VariableDeclaration(d) => {
145
+ self.walk_variable_declaration(d)
146
+ }
147
+ _ => {
148
+ if let Some(e) = init.as_expression() {
149
+ self.walk_expression(e);
150
+ }
151
+ }
152
+ }
153
+ }
154
+ if let Some(test) = &s.test {
155
+ self.walk_expression(test);
156
+ }
157
+ if let Some(update) = &s.update {
158
+ self.walk_expression(update);
159
+ }
160
+ self.walk_statement(&s.body);
161
+ }
162
+ Statement::ForInStatement(s) => {
163
+ self.walk_expression(&s.right);
164
+ self.walk_statement(&s.body);
165
+ }
166
+ Statement::ForOfStatement(s) => {
167
+ self.walk_expression(&s.right);
168
+ self.walk_statement(&s.body);
169
+ }
170
+ Statement::ReturnStatement(s) => {
171
+ if let Some(arg) = &s.argument {
172
+ self.walk_expression(arg);
173
+ }
174
+ }
175
+ Statement::SwitchStatement(s) => {
176
+ self.walk_expression(&s.discriminant);
177
+ for case in &s.cases {
178
+ if let Some(test) = &case.test {
179
+ self.walk_expression(test);
180
+ }
181
+ for c_stmt in &case.consequent {
182
+ self.walk_statement(c_stmt);
183
+ }
184
+ }
185
+ }
186
+ Statement::ThrowStatement(s) => {
187
+ self.walk_expression(&s.argument);
188
+ }
189
+ Statement::TryStatement(s) => {
190
+ self.walk_block_statement(&s.block);
191
+ if let Some(handler) = &s.handler {
192
+ self.walk_block_statement(&handler.body);
193
+ }
194
+ if let Some(finalizer) = &s.finalizer {
195
+ self.walk_block_statement(finalizer);
196
+ }
197
+ }
198
+ Statement::VariableDeclaration(d) => {
199
+ self.walk_variable_declaration(d);
200
+ }
201
+ Statement::FunctionDeclaration(f) => {
202
+ let name = f.id.as_ref().map(|id| id.name.as_str());
203
+ self.walk_function(f, name);
204
+ }
205
+ Statement::ClassDeclaration(c) => {
206
+ self.walk_class(c);
207
+ }
208
+ Statement::ExportDeclaration(d) => {
209
+ self.walk_declaration(&d.declaration);
210
+ }
211
+ Statement::ExportDefaultDeclaration(d) => match &d.declaration {
212
+ ExportDefaultDeclarationKind::FunctionDeclaration(f) => {
213
+ let name = f.id.as_ref().map(|id| id.name.as_str());
214
+ self.walk_function(f, name);
215
+ }
216
+ ExportDefaultDeclarationKind::ClassDeclaration(c) => {
217
+ self.walk_class(c);
218
+ }
219
+ _ => {
220
+ if let Some(expr) = d.declaration.as_expression() {
221
+ self.walk_expression(expr);
222
+ }
223
+ }
224
+ },
225
+ _ => {}
226
+ }
227
+ }
228
+
229
+ fn walk_declaration(&mut self, decl: &Declaration) {
230
+ match decl {
231
+ Declaration::VariableDeclaration(d) => self.walk_variable_declaration(d),
232
+ Declaration::FunctionDeclaration(f) => {
233
+ let name = f.id.as_ref().map(|id| id.name.as_str());
234
+ self.walk_function(f, name);
235
+ }
236
+ Declaration::ClassDeclaration(c) => self.walk_class(c),
237
+ _ => {}
238
+ }
239
+ }
240
+
241
+ fn walk_variable_declaration(&mut self, decl: &VariableDeclaration) {
242
+ for declarator in &decl.declarations {
243
+ let var_name = if let BindingPattern::BindingIdentifier(ident) = &declarator.id {
244
+ Some(ident.name.as_str())
245
+ } else {
246
+ None
247
+ };
248
+
249
+ if let Some(init) = &declarator.init {
250
+ // Check if init is an env source: const { KV_1, KV_2 } = env;
251
+ if let Some(env_expr_str) = is_env_source(init) {
252
+ if let BindingPattern::ObjectPattern(obj) = &declarator.id {
253
+ for prop in &obj.properties {
254
+ if let Some(name) = prop.key.name() {
255
+ let raw = format!("const {{ {} }} = {}", name, env_expr_str);
256
+ self.record_access(
257
+ &name,
258
+ prop.span,
259
+ AccessKind::Destructured,
260
+ &raw,
261
+ );
262
+ }
263
+ }
264
+ }
265
+ } else if is_process_env(init)
266
+ && let BindingPattern::ObjectPattern(obj) = &declarator.id {
267
+ for prop in &obj.properties {
268
+ if let Some(name) = prop.key.name() {
269
+ let raw = format!("const {{ {} }} = process.env", name);
270
+ self.record_access(&name, prop.span, AccessKind::ProcessEnv, &raw);
271
+ }
272
+ }
273
+ }
274
+
275
+ match init {
276
+ Expression::ArrowFunctionExpression(f) => {
277
+ self.walk_arrow_function(f, var_name);
278
+ }
279
+ Expression::FunctionExpression(f) => {
280
+ self.walk_function(f, var_name);
281
+ }
282
+ _ => {
283
+ self.walk_expression(init);
284
+ }
285
+ }
286
+ }
287
+ }
288
+ }
289
+
290
+ fn walk_function(&mut self, func: &Function, func_name: Option<&str>) {
291
+ let is_worker_handler = func_name.is_some_and(|name| {
292
+ matches!(
293
+ name,
294
+ "fetch"
295
+ | "scheduled"
296
+ | "queue"
297
+ | "email"
298
+ | "tail"
299
+ | "trace"
300
+ | "onRequest"
301
+ | "onRequestGet"
302
+ | "onRequestPost"
303
+ | "onRequestPut"
304
+ | "onRequestDelete"
305
+ )
306
+ });
307
+
308
+ for (param_idx, param) in func.params.items.iter().enumerate() {
309
+ if let BindingPattern::ObjectPattern(obj) = &param.pattern {
310
+ for prop in &obj.properties {
311
+ if let Some(prop_name) = prop.key.name() {
312
+ if prop_name == "env" {
313
+ if let BindingPattern::ObjectPattern(nested_obj) = &prop.value {
314
+ for nested_prop in &nested_obj.properties {
315
+ if let Some(nested_name) = nested_prop.key.name() {
316
+ let raw = format!("{{ env: {{ {} }} }}", nested_name);
317
+ self.record_access(
318
+ &nested_name,
319
+ nested_prop.span,
320
+ AccessKind::ParamDestructured,
321
+ &raw,
322
+ );
323
+ }
324
+ }
325
+ }
326
+ } else if (param_idx == 1
327
+ && (is_worker_handler || func.params.items.len() >= 2))
328
+ || (param_idx == 0 && is_worker_handler)
329
+ {
330
+ let raw = format!("handler(req, {{ {} }}, ctx)", prop_name);
331
+ self.record_access(
332
+ &prop_name,
333
+ prop.span,
334
+ AccessKind::ParamDestructured,
335
+ &raw,
336
+ );
337
+ }
338
+ }
339
+ }
340
+ }
341
+ }
342
+
343
+ if let Some(body) = &func.body {
344
+ for stmt in &body.statements {
345
+ self.walk_statement(stmt);
346
+ }
347
+ }
348
+ }
349
+
350
+ fn walk_class(&mut self, class: &Class) {
351
+ for element in &class.body.body {
352
+ match element {
353
+ ClassElement::MethodDefinition(m) => {
354
+ let name = m.key.name();
355
+ self.walk_function(&m.value, name.as_deref());
356
+ }
357
+ ClassElement::PropertyDefinition(p) => {
358
+ if let Some(val) = &p.value {
359
+ self.walk_expression(val);
360
+ }
361
+ }
362
+ _ => {}
363
+ }
364
+ }
365
+ }
366
+
367
+ fn walk_expression(&mut self, expr: &Expression) {
368
+ match expr {
369
+ // Check direct property access: env.MY_KV or c.env.DB or Astro.locals.runtime.env.AI
370
+ Expression::StaticMemberExpression(m) => {
371
+ if let Some(env_expr_str) = is_env_source(&m.object) {
372
+ let prop_name = m.property.name.as_str();
373
+ let raw = format!("{}.{}", env_expr_str, prop_name);
374
+ self.record_access(prop_name, m.span, AccessKind::DirectMember, &raw);
375
+ return;
376
+ } else if is_process_env(&m.object) {
377
+ let prop_name = m.property.name.as_str();
378
+ let raw = format!("process.env.{}", prop_name);
379
+ self.record_access(prop_name, m.span, AccessKind::ProcessEnv, &raw);
380
+ return;
381
+ }
382
+ self.walk_expression(&m.object);
383
+ }
384
+
385
+ // Check computed property access: env["MY_KV"] or c.env['DB'] or process.env["API_KEY"]
386
+ Expression::ComputedMemberExpression(m) => {
387
+ if let Some(env_expr_str) = is_env_source(&m.object) {
388
+ if let Some(prop_name) = extract_string_literal(&m.expression) {
389
+ let raw = format!("{}[\"{}\"]", env_expr_str, prop_name);
390
+ self.record_access(&prop_name, m.span, AccessKind::DirectMember, &raw);
391
+ return;
392
+ }
393
+ } else if is_process_env(&m.object)
394
+ && let Some(prop_name) = extract_string_literal(&m.expression) {
395
+ let raw = format!("process.env[\"{}\"]", prop_name);
396
+ self.record_access(&prop_name, m.span, AccessKind::ProcessEnv, &raw);
397
+ return;
398
+ }
399
+ self.walk_expression(&m.object);
400
+ self.walk_expression(&m.expression);
401
+ }
402
+
403
+ // Check Call expressions: c.get('MY_VAR') or c.env.get('MY_KV') or env.get('MY_KV')
404
+ Expression::CallExpression(call) => {
405
+ if let Expression::StaticMemberExpression(m) = &call.callee {
406
+ let method_name = m.property.name.as_str();
407
+ if method_name == "get" {
408
+ // Check if object is `c` (Hono context) or `env`
409
+ if (is_ident_name(&m.object, "c") || is_env_source(&m.object).is_some())
410
+ && let Some(first_arg) = call.arguments.first()
411
+ && let Some(arg_expr) = first_arg.as_expression()
412
+ && let Some(key_name) = extract_string_literal(arg_expr) {
413
+ let raw = format!("c.get('{}')", key_name);
414
+ self.record_access(
415
+ &key_name,
416
+ call.span,
417
+ AccessKind::HelperCall,
418
+ &raw,
419
+ );
420
+ }
421
+ }
422
+ }
423
+
424
+ self.walk_expression(&call.callee);
425
+ for arg in &call.arguments {
426
+ if let Some(e) = arg.as_expression() {
427
+ self.walk_expression(e);
428
+ }
429
+ }
430
+ }
431
+
432
+ // New expression: new Response(env.VAR)
433
+ Expression::NewExpression(n) => {
434
+ self.walk_expression(&n.callee);
435
+ for arg in &n.arguments {
436
+ if let Some(e) = arg.as_expression() {
437
+ self.walk_expression(e);
438
+ }
439
+ }
440
+ }
441
+
442
+ // Assignment expression: ({ MY_KV } = env)
443
+ Expression::AssignmentExpression(assign) => {
444
+ if let Some(env_expr_str) = is_env_source(&assign.right)
445
+ && let AssignmentTarget::ObjectAssignmentTarget(obj) = &assign.left {
446
+ for prop in &obj.properties {
447
+ if let AssignmentTargetProperty::AssignmentTargetPropertyIdentifier(
448
+ ident,
449
+ ) = prop
450
+ {
451
+ let name = ident.binding.name.as_str();
452
+ let raw = format!("({{ {} }} = {})", name, env_expr_str);
453
+ self.record_access(
454
+ name,
455
+ ident.span,
456
+ AccessKind::Destructured,
457
+ &raw,
458
+ );
459
+ }
460
+ }
461
+ }
462
+ self.walk_expression(&assign.right);
463
+ }
464
+
465
+ // Object literal: { async fetch(req, env, ctx) { ... } }
466
+ Expression::ObjectExpression(obj) => {
467
+ for prop in &obj.properties {
468
+ match prop {
469
+ ObjectPropertyKind::ObjectProperty(p) => {
470
+ let key_name = p.key.name();
471
+ if let Expression::FunctionExpression(f) = &p.value {
472
+ self.walk_function(f, key_name.as_deref());
473
+ } else if let Expression::ArrowFunctionExpression(f) = &p.value {
474
+ self.walk_arrow_function(f, key_name.as_deref());
475
+ } else {
476
+ self.walk_expression(&p.value);
477
+ }
478
+ }
479
+ ObjectPropertyKind::SpreadProperty(s) => {
480
+ self.walk_expression(&s.argument);
481
+ }
482
+ }
483
+ }
484
+ }
485
+
486
+ Expression::FunctionExpression(f) => {
487
+ self.walk_function(f, None);
488
+ }
489
+ Expression::ArrowFunctionExpression(f) => {
490
+ self.walk_arrow_function(f, None);
491
+ }
492
+ Expression::ArrayExpression(arr) => {
493
+ for el in &arr.elements {
494
+ if let Some(e) = el.as_expression() {
495
+ self.walk_expression(e);
496
+ }
497
+ }
498
+ }
499
+ Expression::AwaitExpression(a) => {
500
+ self.walk_expression(&a.argument);
501
+ }
502
+ Expression::UnaryExpression(u) => {
503
+ self.walk_expression(&u.argument);
504
+ }
505
+ Expression::UpdateExpression(u) => {
506
+ if let Some(m) = u.argument.as_member_expression() {
507
+ self.walk_member_expression(m);
508
+ }
509
+ }
510
+ Expression::BinaryExpression(b) => {
511
+ self.walk_expression(&b.left);
512
+ self.walk_expression(&b.right);
513
+ }
514
+ Expression::LogicalExpression(l) => {
515
+ self.walk_expression(&l.left);
516
+ self.walk_expression(&l.right);
517
+ }
518
+ Expression::ConditionalExpression(c) => {
519
+ self.walk_expression(&c.test);
520
+ self.walk_expression(&c.consequent);
521
+ self.walk_expression(&c.alternate);
522
+ }
523
+ Expression::SequenceExpression(s) => {
524
+ for e in &s.expressions {
525
+ self.walk_expression(e);
526
+ }
527
+ }
528
+ Expression::ParenthesizedExpression(p) => {
529
+ self.walk_expression(&p.expression);
530
+ }
531
+ Expression::ChainExpression(c) => {
532
+ if let Some(m) = c.expression.member_expression() {
533
+ self.walk_member_expression(m);
534
+ } else if let ChainElement::CallExpression(call) = &c.expression {
535
+ self.walk_expression(&call.callee);
536
+ for arg in &call.arguments {
537
+ if let Some(e) = arg.as_expression() {
538
+ self.walk_expression(e);
539
+ }
540
+ }
541
+ }
542
+ }
543
+ Expression::TSAsExpression(a) => {
544
+ self.walk_expression(&a.expression);
545
+ }
546
+ Expression::TSTypeAssertion(a) => {
547
+ self.walk_expression(&a.expression);
548
+ }
549
+ Expression::TSNonNullExpression(n) => {
550
+ self.walk_expression(&n.expression);
551
+ }
552
+ Expression::TSSatisfiesExpression(s) => {
553
+ self.walk_expression(&s.expression);
554
+ }
555
+ Expression::TemplateLiteral(t) => {
556
+ for e in &t.expressions {
557
+ self.walk_expression(e);
558
+ }
559
+ }
560
+ Expression::TaggedTemplateExpression(t) => {
561
+ self.walk_expression(&t.tag);
562
+ }
563
+ Expression::YieldExpression(y) => {
564
+ if let Some(arg) = &y.argument {
565
+ self.walk_expression(arg);
566
+ }
567
+ }
568
+ Expression::ImportExpression(i) => {
569
+ self.walk_expression(&i.source);
570
+ }
571
+ _ => {}
572
+ }
573
+ }
574
+
575
+ fn walk_arrow_function(&mut self, func: &ArrowFunctionExpression, func_name: Option<&str>) {
576
+ let is_worker_handler = func_name.is_some_and(|name| {
577
+ matches!(
578
+ name,
579
+ "fetch"
580
+ | "scheduled"
581
+ | "queue"
582
+ | "email"
583
+ | "tail"
584
+ | "trace"
585
+ | "onRequest"
586
+ | "onRequestGet"
587
+ | "onRequestPost"
588
+ | "onRequestPut"
589
+ | "onRequestDelete"
590
+ )
591
+ });
592
+
593
+ for (param_idx, param) in func.params.items.iter().enumerate() {
594
+ if let BindingPattern::ObjectPattern(obj) = &param.pattern {
595
+ for prop in &obj.properties {
596
+ if let Some(prop_name) = prop.key.name() {
597
+ if prop_name == "env" {
598
+ if let BindingPattern::ObjectPattern(nested_obj) = &prop.value {
599
+ for nested_prop in &nested_obj.properties {
600
+ if let Some(nested_name) = nested_prop.key.name() {
601
+ let raw = format!("{{ env: {{ {} }} }}", nested_name);
602
+ self.record_access(
603
+ &nested_name,
604
+ nested_prop.span,
605
+ AccessKind::ParamDestructured,
606
+ &raw,
607
+ );
608
+ }
609
+ }
610
+ }
611
+ } else if (param_idx == 1
612
+ && (is_worker_handler || func.params.items.len() >= 2))
613
+ || (param_idx == 0 && is_worker_handler)
614
+ {
615
+ let raw = format!("handler(req, {{ {} }}, ctx)", prop_name);
616
+ self.record_access(
617
+ &prop_name,
618
+ prop.span,
619
+ AccessKind::ParamDestructured,
620
+ &raw,
621
+ );
622
+ }
623
+ }
624
+ }
625
+ }
626
+ }
627
+
628
+ match &func.body {
629
+ ArrowFunctionBody::FunctionBody(b) => {
630
+ for stmt in &b.statements {
631
+ self.walk_statement(stmt);
632
+ }
633
+ }
634
+ _ => {
635
+ if let Some(expr) = func.body.as_expression() {
636
+ self.walk_expression(expr);
637
+ }
638
+ }
639
+ }
640
+ }
641
+
642
+ fn walk_member_expression(&mut self, m: &MemberExpression) {
643
+ match m {
644
+ MemberExpression::StaticMemberExpression(s) => {
645
+ if let Some(env_expr_str) = is_env_source(&s.object) {
646
+ let prop_name = s.property.name.as_str();
647
+ let raw = format!("{}.{}", env_expr_str, prop_name);
648
+ self.record_access(prop_name, s.span, AccessKind::DirectMember, &raw);
649
+ } else if is_process_env(&s.object) {
650
+ let prop_name = s.property.name.as_str();
651
+ let raw = format!("process.env.{}", prop_name);
652
+ self.record_access(prop_name, s.span, AccessKind::ProcessEnv, &raw);
653
+ } else {
654
+ self.walk_expression(&s.object);
655
+ }
656
+ }
657
+ MemberExpression::ComputedMemberExpression(c) => {
658
+ if let Some(env_expr_str) = is_env_source(&c.object) {
659
+ if let Some(prop_name) = extract_string_literal(&c.expression) {
660
+ let raw = format!("{}[\"{}\"]", env_expr_str, prop_name);
661
+ self.record_access(&prop_name, c.span, AccessKind::DirectMember, &raw);
662
+ return;
663
+ }
664
+ } else if is_process_env(&c.object)
665
+ && let Some(prop_name) = extract_string_literal(&c.expression) {
666
+ let raw = format!("process.env[\"{}\"]", prop_name);
667
+ self.record_access(&prop_name, c.span, AccessKind::ProcessEnv, &raw);
668
+ return;
669
+ }
670
+ self.walk_expression(&c.object);
671
+ self.walk_expression(&c.expression);
672
+ }
673
+ _ => {}
674
+ }
675
+ }
676
+ }
677
+
678
+ // ==========================================
679
+ // Helper functions for matching env sources
680
+ // ==========================================
681
+
682
+ /// Checks if an identifier expression matches a specific name
683
+ fn is_ident_name(expr: &Expression, expected: &str) -> bool {
684
+ if let Expression::Identifier(ident) = expr {
685
+ ident.name == expected
686
+ } else {
687
+ false
688
+ }
689
+ }
690
+
691
+ /// Checks if an expression is `process.env`
692
+ fn is_process_env(expr: &Expression) -> bool {
693
+ let chain = get_member_chain(expr);
694
+ chain == "process.env"
695
+ }
696
+
697
+ /// Determines if an expression is an environment access root (e.g. `env`, `c.env`, `context.env`, `Astro.locals.runtime.env`).
698
+ /// Returns a friendly string description of the environment source (e.g. `"c.env"`).
699
+ fn is_env_source(expr: &Expression) -> Option<String> {
700
+ let chain = get_member_chain(expr);
701
+ match chain.as_str() {
702
+ "env"
703
+ | "c.env"
704
+ | "context.env"
705
+ | "ctx.env"
706
+ | "request.env"
707
+ | "event.env"
708
+ | "this.env"
709
+ | "props.env"
710
+ | "locals.env"
711
+ | "Astro.locals.env"
712
+ | "Astro.locals.runtime.env" => Some(chain),
713
+ _ if chain.ends_with(".env") => Some(chain),
714
+ _ => None,
715
+ }
716
+ }
717
+
718
+ /// Extracts full member chain like "Astro.locals.runtime.env"
719
+ fn get_member_chain(expr: &Expression) -> String {
720
+ match expr {
721
+ Expression::Identifier(id) => id.name.to_string(),
722
+ Expression::ThisExpression(_) => "this".to_string(),
723
+ Expression::StaticMemberExpression(m) => {
724
+ let parent = get_member_chain(&m.object);
725
+ if parent.is_empty() {
726
+ m.property.name.to_string()
727
+ } else {
728
+ format!("{}.{}", parent, m.property.name)
729
+ }
730
+ }
731
+ _ => String::new(),
732
+ }
733
+ }
734
+
735
+ /// Extracts string literal value from expression if present
736
+ fn extract_string_literal(expr: &Expression) -> Option<String> {
737
+ match expr {
738
+ Expression::StringLiteral(s) => Some(s.value.to_string()),
739
+ Expression::TemplateLiteral(t) if t.expressions.is_empty() && !t.quasis.is_empty() => t
740
+ .quasis
741
+ .first()
742
+ .and_then(|q| q.value.cooked.as_ref())
743
+ .map(|c| c.to_string()),
744
+ _ => None,
745
+ }
746
+ }
747
+
748
+ /// Parse and scan a source file (supporting TS, JS, TSX, JSX, and Astro).
749
+ pub fn scan_source_file(
750
+ path: &Path,
751
+ ) -> Result<Vec<BindingAccess>, Box<dyn std::error::Error + Send + Sync>> {
752
+ let content = fs::read_to_string(path)
753
+ .map_err(|e| format!("Failed to read source file {}: {}", path.display(), e))?;
754
+ let path_str = path.to_string_lossy().to_string();
755
+
756
+ let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
757
+ if ext == "astro" {
758
+ scan_astro_content(&path_str, &content)
759
+ } else {
760
+ let source_type = match ext {
761
+ "ts" => SourceType::ts(),
762
+ "tsx" => SourceType::tsx(),
763
+ "jsx" => SourceType::jsx(),
764
+ _ => SourceType::mjs(),
765
+ };
766
+ scan_code_content(&path_str, &content, source_type)
767
+ }
768
+ }
769
+
770
+ /// Scan arbitrary source code content with specified SourceType
771
+ pub fn scan_code_content(
772
+ file_path: &str,
773
+ source_text: &str,
774
+ source_type: SourceType,
775
+ ) -> Result<Vec<BindingAccess>, Box<dyn std::error::Error + Send + Sync>> {
776
+ let allocator = Allocator::default();
777
+ let parsed = Parser::new(&allocator, source_text, source_type).parse();
778
+
779
+ let mut scanner = AstScanner::new(file_path, source_text);
780
+ let accesses = scanner.scan_program(&parsed.program);
781
+ Ok(accesses)
782
+ }
783
+
784
+ /// Scan `.astro` file content by extracting frontmatter and script blocks while preserving line numbering.
785
+ pub fn scan_astro_content(
786
+ file_path: &str,
787
+ source_text: &str,
788
+ ) -> Result<Vec<BindingAccess>, Box<dyn std::error::Error + Send + Sync>> {
789
+ let mut all_accesses = Vec::new();
790
+
791
+ // 1. Extract Frontmatter (between first --- and second ---)
792
+ let lines: Vec<&str> = source_text.lines().collect();
793
+ if let Some(first_fence) = lines.iter().position(|l| l.trim() == "---")
794
+ && let Some(second_fence_offset) = lines[first_fence + 1..]
795
+ .iter()
796
+ .position(|l| l.trim() == "---")
797
+ {
798
+ let second_fence = first_fence + 1 + second_fence_offset;
799
+
800
+ // Build padded source so line numbers match the .astro file exactly
801
+ let mut padded_script = String::new();
802
+ for _ in 0..first_fence + 1 {
803
+ padded_script.push('\n');
804
+ }
805
+ for line in lines.iter().take(second_fence).skip(first_fence + 1) {
806
+ padded_script.push_str(line);
807
+ padded_script.push('\n');
808
+ }
809
+
810
+ let fm_accesses = scan_code_content(file_path, &padded_script, SourceType::ts())?;
811
+ all_accesses.extend(fm_accesses);
812
+ }
813
+
814
+ // 2. Extract <script> tags
815
+ let mut script_start = 0;
816
+ while let Some(start_tag) = source_text[script_start..].find("<script") {
817
+ let tag_abs_start = script_start + start_tag;
818
+ if let Some(tag_close) = source_text[tag_abs_start..].find('>') {
819
+ let content_start = tag_abs_start + tag_close + 1;
820
+ if let Some(end_tag) = source_text[content_start..].find("</script>") {
821
+ let content_end = content_start + end_tag;
822
+ let script_body = &source_text[content_start..content_end];
823
+
824
+ // Calculate number of preceding lines to pad
825
+ let preceding_text = &source_text[..content_start];
826
+ let line_count = preceding_text.matches('\n').count();
827
+
828
+ let mut padded_script = String::new();
829
+ for _ in 0..line_count {
830
+ padded_script.push('\n');
831
+ }
832
+ padded_script.push_str(script_body);
833
+
834
+ if let Ok(script_accesses) =
835
+ scan_code_content(file_path, &padded_script, SourceType::ts())
836
+ {
837
+ all_accesses.extend(script_accesses);
838
+ }
839
+
840
+ script_start = content_end + 9;
841
+ continue;
842
+ }
843
+ }
844
+ break;
845
+ }
846
+
847
+ Ok(all_accesses)
848
+ }
849
+
850
+ #[cfg(test)]
851
+ mod tests {
852
+ use super::*;
853
+
854
+ #[test]
855
+ fn test_scan_all_patterns() {
856
+ let code = r#"
857
+ import { Hono } from 'hono';
858
+
859
+ const app = new Hono();
860
+
861
+ // 1. Direct env access
862
+ const kv = env.MY_KV;
863
+ const db = c.env.MY_D1;
864
+ const bucket = context.env.MY_R2;
865
+ const ai = Astro.locals.runtime.env.AI_MODEL;
866
+ const localAi = locals.env.LOCAL_AI;
867
+
868
+ // 2. Computed member access
869
+ const computedKv = env['COMPUTED_KV'];
870
+ const bracketD1 = c.env["BRACKET_D1"];
871
+
872
+ // 3. Destructuring
873
+ const { VAR_A, VAR_B: aliasB } = env;
874
+ const { HONO_SECRET } = c.env;
875
+ const { ASTRO_VAR } = Astro.locals.runtime.env;
876
+
877
+ // 4. Process.env
878
+ const apiKey = process.env.API_KEY;
879
+ const { PROCESS_VAR } = process.env;
880
+
881
+ // 5. Helper call
882
+ const helperVar = c.get('HONO_VAR');
883
+
884
+ // 6. Function parameter destructuring
885
+ export default {
886
+ async fetch(request, { HANDLER_KV, HANDLER_DB }, ctx) {
887
+ return new Response("ok");
888
+ },
889
+ async scheduled(event, { CRON_SECRET }, ctx) {
890
+ // cron
891
+ }
892
+ };
893
+
894
+ // 7. Pages function handler
895
+ export const onRequest = async ({ env: { PAGES_KV } }) => {
896
+ return new Response("ok");
897
+ };
898
+ "#;
899
+
900
+ let accesses = scan_code_content("test.ts", code, SourceType::ts()).unwrap();
901
+ let names: Vec<&str> = accesses.iter().map(|a| a.name.as_str()).collect();
902
+
903
+ assert!(names.contains(&"MY_KV"));
904
+ assert!(names.contains(&"MY_D1"));
905
+ assert!(names.contains(&"MY_R2"));
906
+ assert!(names.contains(&"AI_MODEL"));
907
+ assert!(names.contains(&"LOCAL_AI"));
908
+ assert!(names.contains(&"COMPUTED_KV"));
909
+ assert!(names.contains(&"BRACKET_D1"));
910
+ assert!(names.contains(&"VAR_A"));
911
+ assert!(names.contains(&"VAR_B"));
912
+ assert!(names.contains(&"HONO_SECRET"));
913
+ assert!(names.contains(&"ASTRO_VAR"));
914
+ assert!(names.contains(&"API_KEY"));
915
+ assert!(names.contains(&"PROCESS_VAR"));
916
+ assert!(names.contains(&"HONO_VAR"));
917
+ assert!(names.contains(&"HANDLER_KV"));
918
+ assert!(names.contains(&"HANDLER_DB"));
919
+ assert!(names.contains(&"CRON_SECRET"));
920
+ assert!(names.contains(&"PAGES_KV"));
921
+ }
922
+
923
+ #[test]
924
+ fn test_scan_astro_frontmatter() {
925
+ let astro_code = r#"---
926
+ import Header from '../components/Header.astro';
927
+ const db = Astro.locals.runtime.env.ASTRO_DB;
928
+ const { ASTRO_KV } = Astro.locals.env;
929
+ ---
930
+
931
+ <div>
932
+ <h1>Hello</h1>
933
+ </div>
934
+
935
+ <script>
936
+ console.log("Client script");
937
+ </script>
938
+ "#;
939
+ let accesses = scan_astro_content("src/pages/index.astro", astro_code).unwrap();
940
+ let names: Vec<&str> = accesses.iter().map(|a| a.name.as_str()).collect();
941
+ assert!(names.contains(&"ASTRO_DB"));
942
+ assert!(names.contains(&"ASTRO_KV"));
943
+
944
+ // Check line numbers match frontmatter lines (line 3 and 4)
945
+ let db_access = accesses.iter().find(|a| a.name == "ASTRO_DB").unwrap();
946
+ assert_eq!(db_access.line, 3);
947
+ let kv_access = accesses.iter().find(|a| a.name == "ASTRO_KV").unwrap();
948
+ assert_eq!(kv_access.line, 4);
949
+ }
950
+ }