@justmpm/firebase-audit 0.3.0 → 0.4.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.
@@ -1,5 +1,6 @@
1
1
  // src/scan.ts
2
2
  import { readFileSync as readFileSync4, existsSync as existsSync3 } from "fs";
3
+ import { join as join3 } from "path";
3
4
  import { parse as parseYaml } from "yaml";
4
5
 
5
6
  // src/discovery.ts
@@ -54,21 +55,24 @@ var SERVER_HINTS_SEGMENTS = [
54
55
  "functions",
55
56
  "server",
56
57
  "backend",
57
- "api",
58
58
  "scripts"
59
59
  ];
60
60
  function classifyOrigin(file, content) {
61
61
  const rel = file.replace(/\\/g, "/");
62
62
  const segs = rel.split("/");
63
63
  const hasSeg = (...names) => names.some((n) => segs.includes(n));
64
- if (rel.includes("app/api/") || rel.includes("pages/api/") || hasSeg("api") && segs.includes("pages")) {
64
+ if (hasSeg("pages", "api") && rel.includes("pages/api/")) {
65
+ return "SERVER";
66
+ }
67
+ if (hasSeg("app", "api") && rel.includes("app/api/")) {
65
68
  return "SERVER";
66
69
  }
67
70
  if (segs.includes("app") && rel.endsWith("route.ts") || segs.includes("app") && rel.endsWith("route.js")) {
68
71
  return "SERVER";
69
72
  }
70
73
  if (rel.includes("src/app/api/")) return "SERVER";
71
- if (hasSeg("functions", "server", "backend", "scripts")) return "SERVER";
74
+ if (rel.includes("server/api/")) return "SERVER";
75
+ if (hasSeg(...SERVER_HINTS_SEGMENTS)) return "SERVER";
72
76
  if (content.includes("firebase-admin") || content.includes("firebase-functions")) {
73
77
  return "CLIENT";
74
78
  }
@@ -99,9 +103,35 @@ function emptyModel(rootDir, d) {
99
103
  graph: []
100
104
  };
101
105
  }
106
+ async function enrichWithGraph(rootDir, d) {
107
+ try {
108
+ const ai = await import("@justmpm/ai-tool");
109
+ const res = await ai.map({ cwd: rootDir, format: "json" });
110
+ const files = Array.isArray(res.files) ? res.files : [];
111
+ const code = files.map((f) => String(f.path).replace(/\\/g, "/")).filter((p) => /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(p)).filter((p) => !p.includes("node_modules/") && !/(^|\/)dist\//.test(p)).slice(0, 500);
112
+ const { readFileSync: readSync, existsSync: existsSyncFn, statSync } = await import("fs");
113
+ const { join: joinP } = await import("path");
114
+ for (const rel of code) {
115
+ let content = "";
116
+ try {
117
+ const abs = joinP(rootDir, rel);
118
+ if (!existsSyncFn(abs)) continue;
119
+ if (statSync(abs).size > 100 * 1024) continue;
120
+ content = readSync(abs, "utf-8");
121
+ } catch {
122
+ content = "";
123
+ }
124
+ const origin = classifyOrigin(rel, content);
125
+ if (origin === "CLIENT") d.clientFiles.push(rel);
126
+ else if (origin === "SERVER") d.serverFiles.push(rel);
127
+ }
128
+ } catch {
129
+ }
130
+ }
102
131
 
103
132
  // src/rules.ts
104
133
  import { readFileSync as readFileSync2 } from "fs";
134
+ import { createHash } from "crypto";
105
135
  var MATCH_RE = /match\s+(\/[^{\s]*(?:\{[^}]*\}[^{\s]*)*)\s*\{/g;
106
136
  var ALLOW_RE = /allow\s+([^;:]+)(?::\s*if\s+([^;]+))?;/g;
107
137
  function stripRuleComments(content) {
@@ -191,19 +221,105 @@ function findBlockEnd(content, openBraceIndex) {
191
221
  function normalizeTarget(target) {
192
222
  return target.toLowerCase().split(",").map((s) => s.trim()).filter(Boolean).sort().join(",");
193
223
  }
194
- function extractRules(rulesFile, relFile) {
195
- const rawContent = readFileSync2(rulesFile, "utf-8");
196
- const content = stripRuleComments(rawContent);
197
- const rules = [];
198
- const matchBlocks = [];
224
+ function parseMatchBlocks(content) {
225
+ const blocks = [];
199
226
  const matchRe = new RegExp(MATCH_RE.source, "g");
200
227
  let m;
201
228
  while ((m = matchRe.exec(content)) !== null) {
202
229
  const before = content.slice(0, m.index);
203
230
  const line = before.split("\n").length;
204
231
  const openBrace = m.index + m[0].length - 1;
205
- matchBlocks.push({ path: m[1], line, blockStart: m.index, blockEnd: findBlockEnd(content, openBrace) });
232
+ blocks.push({ path: m[1], line, blockStart: m.index, blockEnd: findBlockEnd(content, openBrace) });
233
+ }
234
+ return blocks;
235
+ }
236
+ function buildFullPath(containingAsc) {
237
+ const parts = [];
238
+ for (const b of containingAsc) {
239
+ const p = b.path.trim();
240
+ if (/^\/databases\/\{[^}]+\}\/documents\/?$/.test(p)) continue;
241
+ const stripped = p.replace(/^\/+|\/+$/g, "");
242
+ if (stripped.length === 0) continue;
243
+ const docIdx = stripped.indexOf("/documents/");
244
+ if (stripped.startsWith("databases/") && docIdx !== -1) {
245
+ const rest = stripped.slice(docIdx + "/documents/".length);
246
+ if (rest.length > 0) parts.push(rest);
247
+ continue;
248
+ }
249
+ parts.push(stripped);
250
+ }
251
+ if (parts.length === 0) return "/(unknown)";
252
+ return "/" + parts.join("/");
253
+ }
254
+ function parseRuleFunctions(content) {
255
+ const out = /* @__PURE__ */ new Map();
256
+ for (const [name, fn] of parseRuleFunctionsDetailed(content)) {
257
+ out.set(name, fn.body);
258
+ }
259
+ return out;
260
+ }
261
+ function parseRuleFunctionsDetailed(content) {
262
+ const out = /* @__PURE__ */ new Map();
263
+ const re = /function\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)\s*\{/g;
264
+ let m;
265
+ while ((m = re.exec(content)) !== null) {
266
+ const openBrace = m.index + m[0].length - 1;
267
+ const end = findBlockEnd(content, openBrace);
268
+ const body = content.slice(openBrace + 1, end - 1);
269
+ const params = m[2].trim() === "" ? [] : m[2].split(",").map((s) => s.trim()).filter((s) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(s));
270
+ out.set(m[1], { params, body });
271
+ }
272
+ return out;
273
+ }
274
+ function inlineHelperArgs(body, params, args) {
275
+ let out = body;
276
+ for (let i = 0; i < params.length; i++) {
277
+ const p = params[i];
278
+ const a = (args[i] ?? "").trim();
279
+ if (!p || !a) continue;
280
+ out = out.replace(new RegExp(`\\b${p}\\b`, "g"), () => a);
206
281
  }
282
+ return out;
283
+ }
284
+ function inlineHelpersInCondition(condition, detailed) {
285
+ let out = condition;
286
+ for (const [name, fn] of detailed) {
287
+ const re = new RegExp(`\\b${name}\\s*\\(([^()]*)\\)`, "g");
288
+ out = out.replace(re, (_m, argsStr) => {
289
+ const args = argsStr.length === 0 ? [] : String(argsStr).split(",").map((s) => s.trim());
290
+ const inlined = inlineHelperArgs(fn.body, fn.params, args);
291
+ return `(${inlined})`;
292
+ });
293
+ }
294
+ return out;
295
+ }
296
+ function resolveHelperCondition(condition, functions) {
297
+ const t = condition.trim().replace(/;$/, "").trim();
298
+ const call = /^([A-Za-z_][A-Za-z0-9_]*)\s*\((.*)\)$/.exec(t);
299
+ if (!call) return condition;
300
+ const body = functions.get(call[1]);
301
+ if (body === void 0) return condition;
302
+ return body;
303
+ }
304
+ function resolveHelperConditionDetailed(condition, detailed) {
305
+ const t = condition.trim().replace(/;$/, "").trim();
306
+ const call = /^([A-Za-z_][A-Za-z0-9_]*)\s*\((.*)\)$/.exec(t);
307
+ if (!call) return condition;
308
+ const fn = detailed.get(call[1]);
309
+ if (!fn) return condition;
310
+ const args = call[2].length === 0 ? [] : call[2].split(",").map((s) => s.trim());
311
+ return inlineHelperArgs(fn.body, fn.params, args);
312
+ }
313
+ function extractRules(rulesFile, relFile) {
314
+ const rawContent = readFileSync2(rulesFile, "utf-8");
315
+ return extractRulesContent(rawContent, relFile);
316
+ }
317
+ function extractRulesContent(rawContent, relFile) {
318
+ const content = stripRuleComments(rawContent);
319
+ const rules = [];
320
+ const functions = parseRuleFunctions(content);
321
+ const detailed = parseRuleFunctionsDetailed(content);
322
+ const matchBlocks = parseMatchBlocks(content);
207
323
  const allowRe = new RegExp(ALLOW_RE.source, "g");
208
324
  let a;
209
325
  let idx = 0;
@@ -211,26 +327,37 @@ function extractRules(rulesFile, relFile) {
211
327
  const before = content.slice(0, a.index);
212
328
  const line = before.split("\n").length;
213
329
  const target = a[1];
214
- const condition = (a[2] ?? "").trim();
330
+ const rawCondition = (a[2] ?? "").trim();
215
331
  const unconditional = a[2] === void 0;
216
- const containing = matchBlocks.filter((b) => b.blockStart <= a.index && a.index < b.blockEnd);
217
- const currentMatch = containing.sort((x, y) => y.blockStart - x.blockStart)[0];
332
+ const containing = matchBlocks.filter((b) => b.blockStart <= a.index && a.index < b.blockEnd).sort((x, y) => x.blockStart - y.blockStart);
333
+ const fullPath = containing.length > 0 ? buildFullPath(containing) : "/(unknown)";
334
+ const resolved = unconditional ? "" : resolveHelperCondition(rawCondition, functions);
335
+ const resolvedDetailed = unconditional ? "" : resolveHelperConditionDetailed(rawCondition, detailed);
336
+ const inlinedBody = unconditional ? "" : inlineHelpersInCondition(rawCondition, detailed);
337
+ const effectiveBody = resolvedDetailed !== rawCondition ? resolvedDetailed : resolved !== rawCondition ? resolved : inlinedBody;
338
+ const condition = rawCondition;
218
339
  const authReferences = [];
219
- if (condition.includes("request.auth")) authReferences.push("request.auth");
220
- if (condition.includes("request.auth.uid")) authReferences.push("request.auth.uid");
221
- const claimReferences = [...condition.matchAll(/request\.auth\.token\.([A-Za-z0-9_]+)/g)].map(
222
- (x) => x[1]
223
- );
224
- const resourceReferences = [...condition.matchAll(/resource\.data\.([A-Za-z0-9_]+)/g)].map(
340
+ const authSource = `${rawCondition} ${resolved} ${effectiveBody} ${inlinedBody}`;
341
+ if (authSource.includes("request.auth")) authReferences.push("request.auth");
342
+ if (authSource.includes("request.auth.uid")) authReferences.push("request.auth.uid");
343
+ const big = `${rawCondition} ${resolved} ${effectiveBody} ${inlinedBody}`;
344
+ const claimReferences = [
345
+ ...big.matchAll(/request\.auth\.token\.([A-Za-z0-9_]+)/g),
346
+ ...big.matchAll(/request\.auth\.token\.get\(\s*['"]([A-Za-z0-9_]+)['"]/g),
347
+ ...big.matchAll(/request\.auth\.token\[\s*['"]([A-Za-z0-9_]+)['"]\s*\]/g)
348
+ ].map((x) => x[1]);
349
+ const resourceReferences = [...big.matchAll(/resource\.data\.([A-Za-z0-9_]+)/g)].map(
225
350
  (x) => x[1]
226
351
  );
227
352
  const requestResourceReferences = [
228
- ...condition.matchAll(/request\.resource\.data\.([A-Za-z0-9_]+)/g)
353
+ ...big.matchAll(/request\.resource\.data\.([A-Za-z0-9_]+)/g)
229
354
  ].map((x) => x[1]);
230
355
  const { ops, known } = opsFrom(target);
356
+ const isHelperCall = !unconditional && rawCondition !== resolved;
357
+ const hasAuth = authSource.includes("request.auth");
231
358
  rules.push({
232
359
  id: `rule-${idx++}`,
233
- path: currentMatch?.path ?? "/(unknown)",
360
+ path: fullPath,
234
361
  operations: ops,
235
362
  conditionPresent: !unconditional && condition.length > 0,
236
363
  condition: unconditional ? void 0 : condition,
@@ -238,7 +365,7 @@ function extractRules(rulesFile, relFile) {
238
365
  claimReferences,
239
366
  resourceReferences,
240
367
  requestResourceReferences,
241
- confidence: known ? "CONFIRMED" : "UNKNOWN",
368
+ confidence: !known || isHelperCall && !hasAuth ? "UNKNOWN" : "CONFIRMED",
242
369
  location: { file: relFile, start: { line, column: 0 } }
243
370
  });
244
371
  }
@@ -268,6 +395,13 @@ function isPublicCondition(condition) {
268
395
  if (condition === void 0) return { isPublic: true, confidence: "CONFIRMED" };
269
396
  const norm = stripOuterParens(condition);
270
397
  const low = norm.toLowerCase().replace(/\s+/g, "");
398
+ if (low === "false") return { isPublic: false, confidence: "CONFIRMED" };
399
+ if (/^!\(.*request\.auth(===|==)null/.test(low)) {
400
+ return { isPublic: false, confidence: "CONFIRMED" };
401
+ }
402
+ if (/^!\(.*request\.auth(!==|!=)null/.test(low)) {
403
+ return { isPublic: true, confidence: "CONFIRMED" };
404
+ }
271
405
  if (low.includes("!(request.auth==null)") || low.includes("!(request.auth===null)") || low === "!request.auth==null" || low === "!request.auth===null") {
272
406
  return { isPublic: false, confidence: "CONFIRMED" };
273
407
  }
@@ -276,29 +410,55 @@ function isPublicCondition(condition) {
276
410
  if (low.includes("request.auth==null") || low.includes("request.auth===null")) {
277
411
  return { isPublic: true, confidence: "CONFIRMED" };
278
412
  }
413
+ if (!low.includes("request.auth")) {
414
+ return { isPublic: true, confidence: "PROBABLE" };
415
+ }
279
416
  return { isPublic: false, confidence: "CONFIRMED" };
280
417
  }
418
+ function hashShort(text) {
419
+ return createHash("sha256").update(text, "utf-8").digest("hex").slice(0, 8);
420
+ }
421
+ function conditionKeyForFingerprint(condition) {
422
+ if (condition === void 0) return "uncond";
423
+ const norm = stripOuterParens(condition).toLowerCase().replace(/\s+/g, "");
424
+ if (norm === "") return "uncond";
425
+ return hashShort(norm);
426
+ }
281
427
  function findPublicAllows(rulesFile) {
282
428
  const rawContent = readFileSync2(rulesFile, "utf-8");
429
+ return findPublicAllowsContent(rawContent);
430
+ }
431
+ function helperBodyToCondition(body) {
432
+ const m = /\breturn\b\s*([^;]+);?/.exec(body);
433
+ if (m) return m[1].trim();
434
+ return body;
435
+ }
436
+ function findPublicAllowsContent(rawContent) {
283
437
  const content = stripRuleComments(rawContent);
438
+ const functions = parseRuleFunctions(content);
439
+ const detailed = parseRuleFunctionsDetailed(content);
284
440
  const out = [];
285
- const matchRe = new RegExp(MATCH_RE.source, "g");
286
- const blocks = [];
287
- let mm0;
288
- while ((mm0 = matchRe.exec(content)) !== null) {
289
- const openBrace = mm0.index + mm0[0].length - 1;
290
- blocks.push({ path: mm0[1], index: mm0.index, end: findBlockEnd(content, openBrace) });
291
- }
441
+ const parsed = parseMatchBlocks(content);
292
442
  const re = new RegExp(ALLOW_RE.source, "g");
293
443
  let mm;
294
444
  while ((mm = re.exec(content)) !== null) {
295
445
  const line = content.slice(0, mm.index).split("\n").length;
296
446
  const target = mm[1].trim();
297
- const check = isPublicCondition(mm[2]);
447
+ const single = mm[2] === void 0 ? void 0 : resolveHelperCondition(mm[2], functions);
448
+ const singleD = mm[2] === void 0 ? void 0 : resolveHelperConditionDetailed(mm[2], detailed);
449
+ const inlined = mm[2] === void 0 ? void 0 : inlineHelpersInCondition(mm[2], detailed);
450
+ const effective = singleD !== void 0 && singleD !== mm[2] ? singleD : single !== void 0 && single !== mm[2] ? single : inlined !== void 0 && inlined !== mm[2] ? inlined : mm[2];
451
+ if (mm[2] !== void 0 && effective !== mm[2] && !String(effective).includes("request.auth")) {
452
+ const bodyCheck = isPublicCondition(helperBodyToCondition(String(effective)));
453
+ if (!bodyCheck.isPublic) {
454
+ continue;
455
+ }
456
+ }
457
+ const check = isPublicCondition(effective);
298
458
  if (!check.isPublic) continue;
299
- const containing = blocks.filter((b) => b.index <= mm.index && mm.index < b.end);
300
- const path = containing.sort((x, y) => y.index - x.index)[0]?.path ?? "/(unknown)";
301
- out.push({ line, target, path, confidence: check.confidence });
459
+ const ascBlocks = parsed.filter((b) => b.blockStart <= mm.index && mm.index < b.blockEnd).sort((x, y) => x.blockStart - y.blockStart);
460
+ const path = ascBlocks.length > 0 ? buildFullPath(ascBlocks) : "/(unknown)";
461
+ out.push({ line, target, path, confidence: check.confidence, conditionKey: conditionKeyForFingerprint(mm[2]) });
302
462
  }
303
463
  return out;
304
464
  }
@@ -339,8 +499,17 @@ async function runChecks(model, rootDir, opts = {}) {
339
499
  const findings = [];
340
500
  const relRules = model.firebase.firestore.rulesFile;
341
501
  const absRules = relRules ? join2(rootDir, relRules) : null;
502
+ let pubs = [];
503
+ let helperDetailed = /* @__PURE__ */ new Map();
342
504
  if (absRules && existsSync2(absRules)) {
343
- for (const pub of findPublicAllows(absRules)) {
505
+ try {
506
+ const rawAll = readFileSync3(absRules, "utf-8");
507
+ pubs = findPublicAllowsContent(rawAll);
508
+ helperDetailed = parseRuleFunctionsDetailed(rawAll);
509
+ } catch {
510
+ pubs = [];
511
+ }
512
+ for (const pub of pubs) {
344
513
  const t = pub.target.toLowerCase();
345
514
  const tokens = t.split(",").map((s) => s.trim());
346
515
  const isWrite = tokens.some((x) => ["write", "create", "update", "delete"].includes(x));
@@ -352,7 +521,7 @@ async function runChecks(model, rootDir, opts = {}) {
352
521
  "ERROR",
353
522
  pub.confidence,
354
523
  `Escrita p\xFAblica em ${pub.path} (${pub.target}). Qualquer cliente pode escrever.`,
355
- `FBA001:${relRules}:${pub.path}:${normalizeTarget(pub.target)}`,
524
+ `FBA001:${relRules}:${pub.path}:${normalizeTarget(pub.target)}:${pub.conditionKey}`,
356
525
  {
357
526
  file: relRules,
358
527
  line: pub.line,
@@ -370,7 +539,7 @@ async function runChecks(model, rootDir, opts = {}) {
370
539
  "WARNING",
371
540
  pub.confidence,
372
541
  `Leitura p\xFAblica em ${pub.path} (${pub.target}). Pode ser proposital.`,
373
- `FBA002:${relRules}:${pub.path}:${normalizeTarget(pub.target)}`,
542
+ `FBA002:${relRules}:${pub.path}:${normalizeTarget(pub.target)}:${pub.conditionKey}`,
374
543
  {
375
544
  file: relRules,
376
545
  line: pub.line,
@@ -470,12 +639,10 @@ async function runChecks(model, rootDir, opts = {}) {
470
639
  return [op];
471
640
  };
472
641
  const publicPaths = /* @__PURE__ */ new Set();
473
- if (absRules && existsSync2(absRules)) {
474
- for (const p of findPublicAllows(absRules)) {
475
- const { ops } = opsFrom(p.target);
476
- for (const op of /* @__PURE__ */ new Set([...ops, ...ops.flatMap(expandOp)])) {
477
- publicPaths.add(`${p.path}::${op.toLowerCase()}`);
478
- }
642
+ for (const p of pubs) {
643
+ const { ops } = opsFrom(p.target);
644
+ for (const op of /* @__PURE__ */ new Set([...ops, ...ops.flatMap(expandOp)])) {
645
+ publicPaths.add(`${p.path}::${op.toLowerCase()}`);
479
646
  }
480
647
  }
481
648
  const permsByResource = /* @__PURE__ */ new Map();
@@ -496,9 +663,41 @@ async function runChecks(model, rootDir, opts = {}) {
496
663
  (op) => publicPaths.has(`${rule.path}::${op.toLowerCase()}`)
497
664
  );
498
665
  if (isPublicRule) return;
499
- const checksAdminClaim = rule.claimReferences.some((c) => c.toLowerCase() === "admin");
500
- const checksAdminRole = /token\.\w+\s*==\s*['"]admin['"]/i.test(rule.condition ?? "");
501
- if (checksAdminClaim || checksAdminRole) return;
666
+ const condRaw = rule.condition ?? "";
667
+ const condResolved = resolveHelperConditionDetailed(condRaw, helperDetailed);
668
+ const condInlined = inlineHelpersInCondition(condRaw, helperDetailed);
669
+ const cond = condResolved !== condRaw ? condResolved : condInlined !== condRaw ? condInlined : condRaw;
670
+ const isInvertedAdmin = /admin\s*==\s*false/i.test(cond) || /admin\s*!=\s*true/i.test(cond) || /admin\s*!==\s*true/i.test(cond) || /!\s*request\.auth\.token\.admin\b/i.test(cond) || /!\s*\(\s*request\.auth\.token\.admin\b/i.test(cond) || /!\s*\(\s*.*admin\s*==\s*true/i.test(cond);
671
+ const normClaim = (c) => c.toLowerCase().replace(/[_-]/g, "");
672
+ const isAdminCall = /\bisadmin\s*\(([^)]*)\)/i.exec(condRaw) ?? /\bisadmin\s*\(([^)]*)\)/i.exec(cond);
673
+ let isAdminBodyHasAdmin = false;
674
+ if (isAdminCall) {
675
+ const fnName = (() => {
676
+ const m = /\b([A-Za-z_][A-Za-z0-9_]*)\s*\(/i.exec(isAdminCall[0]);
677
+ return m ? m[1] : "isAdmin";
678
+ })();
679
+ const fn = helperDetailed.get(fnName) ?? helperDetailed.get("isAdmin") ?? helperDetailed.get("isadmin");
680
+ if (fn) {
681
+ const b = `${fn.body}`;
682
+ isAdminBodyHasAdmin = /token\.admin\b/i.test(b) || /token\.get\(\s*['"]admin['"]/i.test(b) || /token\s*\[\s*['"]admin['"]/i.test(b) || /==\s*['"]admin['"]/i.test(b);
683
+ }
684
+ }
685
+ const checksAdminClaim = !isInvertedAdmin && rule.claimReferences.some((c) => {
686
+ const n = normClaim(c);
687
+ return n === "admin" || n === "isadmin";
688
+ });
689
+ const hasTopOr = /\|\|/.test(cond);
690
+ const adminPerDisjunct = hasTopOr ? cond.split("||").every((part) => {
691
+ const p = part;
692
+ return /token\.admin\b/i.test(p) || /token\.get\(\s*['"]admin['"]/i.test(p) || /token\s*\[\s*['"]admin['"]/i.test(p) || /token\.\w+\s*==\s*['"]admin['"]/i.test(p) || /token\.\w+\s+in\s+[^\n;]*['"]admin['"]/i.test(p);
693
+ }) : true;
694
+ const checksAdminRole = !isInvertedAdmin && hasTopOr ? adminPerDisjunct : /token\.\w+\s*==\s*['"]admin['"]/i.test(cond) || /token\s*\[\s*['"]\w+['"]\s*\]\s*==\s*['"]admin['"]/i.test(cond) || /token\.\w+\s+in\s+[^\n;]*['"]admin['"]/i.test(cond) || /token\.get\(\s*['"]admin['"]/i.test(cond) || isAdminBodyHasAdmin;
695
+ if (checksAdminClaim || checksAdminRole) {
696
+ if (hasTopOr && !adminPerDisjunct) {
697
+ } else {
698
+ return;
699
+ }
700
+ }
502
701
  const checksAuth = rule.authReferences.length > 0;
503
702
  if (!checksAuth) return;
504
703
  findings.push(
@@ -507,7 +706,7 @@ async function runChecks(model, rootDir, opts = {}) {
507
706
  "WARNING",
508
707
  "PROBABLE",
509
708
  `Rule ${rule.path} [${rule.operations.join(", ")}] aceita autenticado sem checar claim de admin, mas contrato restringe ${restricted.map((r) => r.perm).join(", ")} a admin.`,
510
- `FBA012:${rule.path}:${[...rule.operations].sort().join(",")}:${rule.location.start.line}:${resourceKey}`,
709
+ `FBA012:${rule.path}:${[...rule.operations].sort().join(",")}:${conditionKeyForFingerprint(rule.condition)}:${resourceKey}`,
511
710
  {
512
711
  file: rule.location.file,
513
712
  line: rule.location.start.line,
@@ -555,6 +754,34 @@ async function runChecks(model, rootDir, opts = {}) {
555
754
  )
556
755
  );
557
756
  }
757
+ for (const rule of model.rules) {
758
+ const raw = (rule.condition ?? "").trim();
759
+ const rawResolved = resolveHelperConditionDetailed(raw, helperDetailed);
760
+ const rawInlined = inlineHelpersInCondition(raw, helperDetailed);
761
+ const effectiveRaw = rawResolved !== raw ? rawResolved : rawInlined !== raw ? rawInlined : raw;
762
+ const norm = stripOuterParens(effectiveRaw).toLowerCase().replace(/\s+/g, "");
763
+ const hasBareAuth = norm === "request.auth!=null" || norm === "request.auth!==null" || norm === "request.auth.uid!=null" || norm === "request.auth.uid!==null";
764
+ const hasWeakConjunction = (norm.includes("request.auth!=null") || norm.includes("request.auth!==null") || norm.includes("request.auth.uid!=null") || norm.includes("request.auth.uid!==null")) && !norm.includes("sign_in_provider") && !norm.includes("email_verified") && !norm.includes("request.auth.token");
765
+ const isBareAuth = hasBareAuth || hasWeakConjunction;
766
+ if (isBareAuth && rule.claimReferences.length === 0) {
767
+ findings.push(
768
+ make(
769
+ "AUTH_ANON_ALLOWED",
770
+ "INFO",
771
+ "PROBABLE",
772
+ `Rule ${rule.path} aceita qualquer autenticado incluindo an\xF4nimo (request.auth != null sem claim/provider).`,
773
+ `AUTH_ANON_ALLOWED:${rule.path}:${conditionKeyForFingerprint(rule.condition)}`,
774
+ {
775
+ file: rule.location.file,
776
+ line: rule.location.start.line,
777
+ resource: rule.path,
778
+ evidence: [ev("anon-allowed", rule.condition ?? "auth!=null", "PROBABLE", rule.location.file, rule.location.start.line)],
779
+ fix: "Exigir claim (token.admin/role) ou checar provider diferente de anonymous para dados sens\xEDveis."
780
+ }
781
+ )
782
+ );
783
+ }
784
+ }
558
785
  const adminHits = await collectAdminImports(rootDir);
559
786
  for (const hit of adminHits) {
560
787
  let origin = "UNKNOWN";
@@ -587,7 +814,12 @@ async function runChecks(model, rootDir, opts = {}) {
587
814
  async function collectPermissionCalls(rootDir, adapterFn) {
588
815
  const literals = [];
589
816
  const dynamic = [];
590
- const bases = [adapterFn];
817
+ const ADAPTER_RE = /^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$/;
818
+ const safeAdapter = ADAPTER_RE.test(adapterFn) ? adapterFn : null;
819
+ if (!safeAdapter) {
820
+ return { literals, dynamic };
821
+ }
822
+ const bases = [safeAdapter];
591
823
  const patterns = [];
592
824
  for (const b of bases) {
593
825
  patterns.push(`${b}($PERM)`, `${b}($PERM, $$$ARGS)`);
@@ -616,9 +848,9 @@ async function collectPermissionCalls(rootDir, adapterFn) {
616
848
  const isQuoted = /^['"`].*['"`]$/.test(raw);
617
849
  if (isQuoted) {
618
850
  const perm = raw.replace(/^['"`]|['"`]$/g, "").trim();
619
- if (perm.length >= 3) pushLiteral(m.file, line, perm);
620
- else if (perm.length > 0) {
621
- const k = `${m.file}:${line}:${raw}:short`;
851
+ if (/^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)*$/.test(perm)) pushLiteral(m.file, line, perm);
852
+ else {
853
+ const k = `${m.file}:${line}:${raw}:invalid`;
622
854
  if (!seen.has(k)) {
623
855
  seen.add(k);
624
856
  dynamic.push({ file: m.file, line, text: m.text });
@@ -641,10 +873,19 @@ async function collectAdminImports(rootDir) {
641
873
  const out = [];
642
874
  const patterns = [
643
875
  'import { $$$ITEMS } from "$MODULE"',
876
+ "import { $$$ITEMS } from '$MODULE'",
644
877
  'import $DEFAULT from "$MODULE"',
878
+ "import $DEFAULT from '$MODULE'",
645
879
  'import * as $NS from "$MODULE"',
880
+ "import * as $NS from '$MODULE'",
881
+ 'import "$MODULE"',
882
+ "import '$MODULE'",
646
883
  'const $X = require("$MODULE")',
647
- 'require("$MODULE")'
884
+ "const $X = require('$MODULE')",
885
+ 'require("$MODULE")',
886
+ "require('$MODULE')",
887
+ 'import("$MODULE")',
888
+ "import('$MODULE')"
648
889
  ];
649
890
  const seen = /* @__PURE__ */ new Set();
650
891
  for (const pattern of patterns) {
@@ -825,7 +1066,8 @@ var FindingSchema = z.strictObject({
825
1066
  });
826
1067
  var PermissionNameSchema = z.string().regex(/^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)*$/);
827
1068
  var RoleNameSchema = z.string().regex(/^[a-z][a-z0-9_-]*$/);
828
- var AuditYamlSchema = z.object({
1069
+ var AuditYamlSchema = z.strictObject({
1070
+ $schema: z.string().optional(),
829
1071
  authorization: z.strictObject({
830
1072
  adapter: z.strictObject({ function: z.string().min(1) }).optional(),
831
1073
  roles: z.record(RoleNameSchema, z.strictObject({ permissions: z.array(PermissionNameSchema) })),
@@ -840,12 +1082,80 @@ var AuditYamlSchema = z.object({
840
1082
  }).optional()
841
1083
  })
842
1084
  });
1085
+ var RESERVED_CLAIM_KEYS = [
1086
+ "sub",
1087
+ "iat",
1088
+ "iss",
1089
+ "exp",
1090
+ "aud",
1091
+ "auth_time",
1092
+ "acr",
1093
+ "amr",
1094
+ "azp",
1095
+ "cnf",
1096
+ "c_hash",
1097
+ "at_hash",
1098
+ "jti",
1099
+ "nbf",
1100
+ "nonce",
1101
+ "firebase",
1102
+ "user_id"
1103
+ ];
1104
+ function validateClaimSamples(samples) {
1105
+ const errors = [];
1106
+ if (!samples) return { ok: true, errors };
1107
+ const enc = new TextEncoder();
1108
+ for (const [role, claims] of Object.entries(samples)) {
1109
+ const bytes = enc.encode(JSON.stringify(claims)).length;
1110
+ if (bytes > 1e3) {
1111
+ errors.push(`samples.${role} com ${bytes} bytes (limite 1000 do crach\xE1)`);
1112
+ }
1113
+ for (const k of Object.keys(claims)) {
1114
+ if (RESERVED_CLAIM_KEYS.includes(k)) {
1115
+ errors.push(`samples.${role}.${k} \xE9 chave reservada OIDC/Firebase`);
1116
+ }
1117
+ }
1118
+ }
1119
+ return { ok: errors.length === 0, errors };
1120
+ }
843
1121
 
844
1122
  // src/scan.ts
845
1123
  async function scan(rootDir, opts = {}) {
846
1124
  const d = discover(rootDir);
1125
+ if (opts.graph === true) {
1126
+ try {
1127
+ await enrichWithGraph(rootDir, d);
1128
+ } catch {
1129
+ }
1130
+ }
847
1131
  const model = emptyModel(rootDir, d);
848
1132
  const findings_pre = [];
1133
+ try {
1134
+ if (d.firebaseJson && existsSync3(d.firebaseJson)) {
1135
+ const raw = JSON.parse(readFileSync4(d.firebaseJson, "utf-8"));
1136
+ for (const [kind, rel] of [
1137
+ ["rules", raw.firestore?.rules],
1138
+ ["indexes", raw.firestore?.indexes]
1139
+ ]) {
1140
+ if (typeof rel === "string" && rel.length > 0) {
1141
+ if (!existsSync3(join3(rootDir, rel))) {
1142
+ findings_pre.push({
1143
+ rule: "RULES_CONFIG_MISMATCH",
1144
+ severity: "WARNING",
1145
+ confidence: "CONFIRMED",
1146
+ message: `firebase.json aponta firestore.${kind} para "${rel}" inexistente \u2014 usando default. Corrija o path.`,
1147
+ fingerprint: `RULES_CONFIG_MISMATCH:${kind}:${rel}`,
1148
+ evidence: [
1149
+ { id: `ev-config-${kind}`, kind: "config-mismatch", summary: rel, confidence: "CONFIRMED" }
1150
+ ],
1151
+ fix: "Ajuste o path em firebase.json ou crie o arquivo."
1152
+ });
1153
+ }
1154
+ }
1155
+ }
1156
+ }
1157
+ } catch {
1158
+ }
849
1159
  if (!d.firestoreRulesFile || !existsSync3(d.firestoreRulesFile)) {
850
1160
  findings_pre.push({
851
1161
  rule: "RULES_NOT_OBSERVED",
@@ -878,21 +1188,39 @@ async function scan(rootDir, opts = {}) {
878
1188
  if (d.firestoreIndexesFile && existsSync3(d.firestoreIndexesFile)) {
879
1189
  try {
880
1190
  const raw = JSON.parse(readFileSync4(d.firestoreIndexesFile, "utf-8"));
1191
+ if (Array.isArray(raw.fieldOverrides) && raw.fieldOverrides.length > 0) {
1192
+ findings_pre.push({
1193
+ rule: "FIELD_OVERRIDES_PRESENT",
1194
+ severity: "INFO",
1195
+ confidence: "CONFIRMED",
1196
+ message: `firestore.indexes.json com ${raw.fieldOverrides.length} fieldOverrides (isen\xE7\xE3o campo \xFAnico, fora do drift composto).`,
1197
+ fingerprint: "FIELD_OVERRIDES_PRESENT",
1198
+ evidence: [
1199
+ { id: "ev-overrides", kind: "indexes-overrides", summary: `${raw.fieldOverrides.length} overrides`, confidence: "CONFIRMED" }
1200
+ ]
1201
+ });
1202
+ }
881
1203
  const validModes = ["ASCENDING", "DESCENDING", "ARRAY_CONTAINS", "VECTOR"];
882
1204
  const entries = [];
883
1205
  let skipped = 0;
1206
+ let scopeSkipped = 0;
884
1207
  for (const i of raw.indexes ?? []) {
885
1208
  if (typeof i.collectionGroup !== "string" || i.collectionGroup.length === 0) {
886
1209
  skipped += 1;
887
1210
  continue;
888
1211
  }
1212
+ if (i.queryScope !== void 0 && i.queryScope !== "COLLECTION" && i.queryScope !== "COLLECTION_GROUP") {
1213
+ scopeSkipped += 1;
1214
+ continue;
1215
+ }
889
1216
  const fields = [];
890
1217
  for (const f of i.fields ?? []) {
891
1218
  if (typeof f.fieldPath !== "string" || f.fieldPath.length === 0) {
892
1219
  skipped += 1;
893
1220
  continue;
894
1221
  }
895
- const mode = f.vectorConfig ? "VECTOR" : f.arrayConfig === "CONTAINS" ? "ARRAY_CONTAINS" : f.order;
1222
+ const rawMode = f.vectorConfig ? "VECTOR" : f.arrayConfig === "CONTAINS" ? "ARRAY_CONTAINS" : typeof f.mode === "string" ? f.mode : f.order;
1223
+ const mode = rawMode;
896
1224
  if (mode !== "ASCENDING" && mode !== "DESCENDING" && mode !== "ARRAY_CONTAINS" && mode !== "VECTOR") {
897
1225
  skipped += 1;
898
1226
  continue;
@@ -904,17 +1232,23 @@ async function scan(rootDir, opts = {}) {
904
1232
  queryScope: i.queryScope === "COLLECTION_GROUP" ? "COLLECTION_GROUP" : "COLLECTION",
905
1233
  fields
906
1234
  });
1235
+ const last = entries[entries.length - 1];
1236
+ if (last.fields.length === 0) {
1237
+ entries.pop();
1238
+ skipped += 1;
1239
+ }
907
1240
  }
908
1241
  model.indexes = entries;
909
- if (skipped > 0) {
1242
+ const totalSkipped = skipped + scopeSkipped;
1243
+ if (totalSkipped > 0) {
910
1244
  findings_pre.push({
911
1245
  rule: "INDEXES_INVALID",
912
1246
  severity: "WARNING",
913
1247
  confidence: "CONFIRMED",
914
- message: `firestore.indexes.json com ${skipped} entrada(s) inv\xE1lidas ignoradas \u2014 verifique campos.`,
1248
+ message: `firestore.indexes.json com ${totalSkipped} entrada(s) inv\xE1lidas ignoradas \u2014 verifique campos.`,
915
1249
  fingerprint: "INDEXES_INVALID",
916
1250
  evidence: [
917
- { id: "ev-indexes-skipped", kind: "indexes-parse", summary: `${skipped} inv\xE1lidas`, confidence: "CONFIRMED" }
1251
+ { id: "ev-indexes-skipped", kind: "indexes-parse", summary: `${totalSkipped} inv\xE1lidas`, confidence: "CONFIRMED" }
918
1252
  ]
919
1253
  });
920
1254
  }
@@ -938,7 +1272,24 @@ async function scan(rootDir, opts = {}) {
938
1272
  const validated = AuditYamlSchema.safeParse(parsed);
939
1273
  if (validated.success) {
940
1274
  const auth = validated.data.authorization;
941
- model.authorization.adapter = auth.adapter?.function ?? null;
1275
+ const rawAdapter = auth.adapter?.function ?? null;
1276
+ const ADAPTER_RE = /^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$/;
1277
+ if (rawAdapter !== null && !ADAPTER_RE.test(rawAdapter)) {
1278
+ findings_pre.push({
1279
+ rule: "CONTRACT_INVALID",
1280
+ severity: "WARNING",
1281
+ confidence: "CONFIRMED",
1282
+ message: `adapter.function "${rawAdapter}" inv\xE1lido \u2014 use identificador como "rbac.can". Coleta de permiss\xF5es ignorada.`,
1283
+ fingerprint: "CONTRACT_INVALID:adapter",
1284
+ evidence: [
1285
+ { id: "ev-adapter-invalid", kind: "contract-parse", summary: rawAdapter, confidence: "CONFIRMED" }
1286
+ ],
1287
+ fix: "Corrija authorization.adapter.function para identificador v\xE1lido."
1288
+ });
1289
+ model.authorization.adapter = null;
1290
+ } else {
1291
+ model.authorization.adapter = rawAdapter;
1292
+ }
942
1293
  model.authorization.permissions = Object.entries(auth.permissions).map(([name, p]) => ({
943
1294
  name,
944
1295
  resource: p.resource,
@@ -948,6 +1299,35 @@ async function scan(rootDir, opts = {}) {
948
1299
  name,
949
1300
  permissions: r.permissions
950
1301
  }));
1302
+ const samples = auth.claims?.samples;
1303
+ if (samples) {
1304
+ const res = validateClaimSamples(samples);
1305
+ if (!res.ok) {
1306
+ findings_pre.push({
1307
+ rule: "CONTRACT_INVALID",
1308
+ severity: "WARNING",
1309
+ confidence: "CONFIRMED",
1310
+ message: `claims.samples inv\xE1lido: ${res.errors.join("; ")} \u2014 samples ignorados.`,
1311
+ fingerprint: "CONTRACT_INVALID:claims",
1312
+ evidence: [
1313
+ { id: "ev-claims-invalid", kind: "contract-parse", summary: res.errors.join("; "), confidence: "CONFIRMED" }
1314
+ ],
1315
+ fix: "Claims s\xF3 para acesso, 1000 bytes, sem chaves OIDC/Firebase reservadas."
1316
+ });
1317
+ }
1318
+ }
1319
+ if (auth.claims && !auth.claims.enabled) {
1320
+ findings_pre.push({
1321
+ rule: "CLAIMS_DISABLED",
1322
+ severity: "INFO",
1323
+ confidence: "CONFIRMED",
1324
+ message: "Contrato com claims desabilitadas \u2014 FBA012 usa heur\xEDstica de papel sem crach\xE1.",
1325
+ fingerprint: "CLAIMS_DISABLED",
1326
+ evidence: [
1327
+ { id: "ev-claims-off", kind: "contract-parse", summary: "claims off", confidence: "CONFIRMED" }
1328
+ ]
1329
+ });
1330
+ }
951
1331
  } else {
952
1332
  findings_pre.push({
953
1333
  rule: "CONTRACT_INVALID",
@@ -1008,12 +1388,28 @@ async function scan(rootDir, opts = {}) {
1008
1388
  const errors = findings.filter((f) => f.severity === "ERROR").length;
1009
1389
  const warnings = findings.filter((f) => f.severity === "WARNING").length;
1010
1390
  const infos = findings.filter((f) => f.severity === "INFO").length;
1391
+ const implemented = new Set(IMPLEMENTED_CHECKS);
1011
1392
  const failedChecks = new Set(
1012
1393
  findings.filter((f) => f.severity === "ERROR" || f.severity === "WARNING").map((f) => f.rule)
1013
1394
  );
1014
- const total = IMPLEMENTED_CHECKS.length;
1015
- const passed = IMPLEMENTED_CHECKS.filter((c) => !failedChecks.has(c)).length;
1016
- return { model, findings, summary: { errors, warnings, infos, passed, total } };
1395
+ const totalChecks = IMPLEMENTED_CHECKS.length;
1396
+ const passedChecks = IMPLEMENTED_CHECKS.filter((c) => !failedChecks.has(c)).length;
1397
+ const infraSkipped = findings.filter((f) => !implemented.has(f.rule)).length;
1398
+ return {
1399
+ model,
1400
+ findings,
1401
+ summary: {
1402
+ errors,
1403
+ warnings,
1404
+ infos,
1405
+ passed: passedChecks,
1406
+ total: totalChecks,
1407
+ passedChecks,
1408
+ totalChecks,
1409
+ infraSkipped,
1410
+ coveragePct: null
1411
+ }
1412
+ };
1017
1413
  }
1018
1414
 
1019
1415
  export {
@@ -1021,11 +1417,27 @@ export {
1021
1417
  SERVER_HINTS_SEGMENTS,
1022
1418
  classifyOrigin,
1023
1419
  emptyModel,
1420
+ enrichWithGraph,
1024
1421
  stripRuleComments,
1025
1422
  opsFrom,
1423
+ normalizeTarget,
1424
+ parseMatchBlocks,
1425
+ buildFullPath,
1426
+ parseRuleFunctions,
1427
+ parseRuleFunctionsDetailed,
1428
+ inlineHelperArgs,
1429
+ inlineHelpersInCondition,
1430
+ resolveHelperCondition,
1431
+ resolveHelperConditionDetailed,
1026
1432
  extractRules,
1433
+ extractRulesContent,
1434
+ stripOuterParens,
1027
1435
  isPublicCondition,
1436
+ hashShort,
1437
+ conditionKeyForFingerprint,
1028
1438
  findPublicAllows,
1439
+ helperBodyToCondition,
1440
+ findPublicAllowsContent,
1029
1441
  IMPLEMENTED_CHECKS,
1030
1442
  runChecks,
1031
1443
  SeveritySchema,
@@ -1048,5 +1460,7 @@ export {
1048
1460
  ProjectModelSchema,
1049
1461
  FindingSchema,
1050
1462
  AuditYamlSchema,
1463
+ RESERVED_CLAIM_KEYS,
1464
+ validateClaimSamples,
1051
1465
  scan
1052
1466
  };