@justmpm/firebase-audit 0.3.1 → 0.4.1

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,7 +55,6 @@ 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) {
@@ -71,7 +71,8 @@ function classifyOrigin(file, content) {
71
71
  return "SERVER";
72
72
  }
73
73
  if (rel.includes("src/app/api/")) return "SERVER";
74
- if (hasSeg("functions", "server", "backend", "scripts")) return "SERVER";
74
+ if (rel.includes("server/api/")) return "SERVER";
75
+ if (hasSeg(...SERVER_HINTS_SEGMENTS)) return "SERVER";
75
76
  if (content.includes("firebase-admin") || content.includes("firebase-functions")) {
76
77
  return "CLIENT";
77
78
  }
@@ -102,9 +103,35 @@ function emptyModel(rootDir, d) {
102
103
  graph: []
103
104
  };
104
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
+ }
105
131
 
106
132
  // src/rules.ts
107
133
  import { readFileSync as readFileSync2 } from "fs";
134
+ import { createHash } from "crypto";
108
135
  var MATCH_RE = /match\s+(\/[^{\s]*(?:\{[^}]*\}[^{\s]*)*)\s*\{/g;
109
136
  var ALLOW_RE = /allow\s+([^;:]+)(?::\s*if\s+([^;]+))?;/g;
110
137
  function stripRuleComments(content) {
@@ -194,19 +221,105 @@ function findBlockEnd(content, openBraceIndex) {
194
221
  function normalizeTarget(target) {
195
222
  return target.toLowerCase().split(",").map((s) => s.trim()).filter(Boolean).sort().join(",");
196
223
  }
197
- function extractRules(rulesFile, relFile) {
198
- const rawContent = readFileSync2(rulesFile, "utf-8");
199
- const content = stripRuleComments(rawContent);
200
- const rules = [];
201
- const matchBlocks = [];
224
+ function parseMatchBlocks(content) {
225
+ const blocks = [];
202
226
  const matchRe = new RegExp(MATCH_RE.source, "g");
203
227
  let m;
204
228
  while ((m = matchRe.exec(content)) !== null) {
205
229
  const before = content.slice(0, m.index);
206
230
  const line = before.split("\n").length;
207
231
  const openBrace = m.index + m[0].length - 1;
208
- 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);
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
+ });
209
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);
210
323
  const allowRe = new RegExp(ALLOW_RE.source, "g");
211
324
  let a;
212
325
  let idx = 0;
@@ -214,26 +327,37 @@ function extractRules(rulesFile, relFile) {
214
327
  const before = content.slice(0, a.index);
215
328
  const line = before.split("\n").length;
216
329
  const target = a[1];
217
- const condition = (a[2] ?? "").trim();
330
+ const rawCondition = (a[2] ?? "").trim();
218
331
  const unconditional = a[2] === void 0;
219
- const containing = matchBlocks.filter((b) => b.blockStart <= a.index && a.index < b.blockEnd);
220
- 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;
221
339
  const authReferences = [];
222
- if (condition.includes("request.auth")) authReferences.push("request.auth");
223
- if (condition.includes("request.auth.uid")) authReferences.push("request.auth.uid");
224
- const claimReferences = [...condition.matchAll(/request\.auth\.token\.([A-Za-z0-9_]+)/g)].map(
225
- (x) => x[1]
226
- );
227
- 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(
228
350
  (x) => x[1]
229
351
  );
230
352
  const requestResourceReferences = [
231
- ...condition.matchAll(/request\.resource\.data\.([A-Za-z0-9_]+)/g)
353
+ ...big.matchAll(/request\.resource\.data\.([A-Za-z0-9_]+)/g)
232
354
  ].map((x) => x[1]);
233
355
  const { ops, known } = opsFrom(target);
356
+ const isHelperCall = !unconditional && rawCondition !== resolved;
357
+ const hasAuth = authSource.includes("request.auth");
234
358
  rules.push({
235
359
  id: `rule-${idx++}`,
236
- path: currentMatch?.path ?? "/(unknown)",
360
+ path: fullPath,
237
361
  operations: ops,
238
362
  conditionPresent: !unconditional && condition.length > 0,
239
363
  condition: unconditional ? void 0 : condition,
@@ -241,7 +365,7 @@ function extractRules(rulesFile, relFile) {
241
365
  claimReferences,
242
366
  resourceReferences,
243
367
  requestResourceReferences,
244
- confidence: known ? "CONFIRMED" : "UNKNOWN",
368
+ confidence: !known || isHelperCall && !hasAuth ? "UNKNOWN" : "CONFIRMED",
245
369
  location: { file: relFile, start: { line, column: 0 } }
246
370
  });
247
371
  }
@@ -271,6 +395,13 @@ function isPublicCondition(condition) {
271
395
  if (condition === void 0) return { isPublic: true, confidence: "CONFIRMED" };
272
396
  const norm = stripOuterParens(condition);
273
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
+ }
274
405
  if (low.includes("!(request.auth==null)") || low.includes("!(request.auth===null)") || low === "!request.auth==null" || low === "!request.auth===null") {
275
406
  return { isPublic: false, confidence: "CONFIRMED" };
276
407
  }
@@ -279,29 +410,55 @@ function isPublicCondition(condition) {
279
410
  if (low.includes("request.auth==null") || low.includes("request.auth===null")) {
280
411
  return { isPublic: true, confidence: "CONFIRMED" };
281
412
  }
413
+ if (!low.includes("request.auth")) {
414
+ return { isPublic: true, confidence: "PROBABLE" };
415
+ }
282
416
  return { isPublic: false, confidence: "CONFIRMED" };
283
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
+ }
284
427
  function findPublicAllows(rulesFile) {
285
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) {
286
437
  const content = stripRuleComments(rawContent);
438
+ const functions = parseRuleFunctions(content);
439
+ const detailed = parseRuleFunctionsDetailed(content);
287
440
  const out = [];
288
- const matchRe = new RegExp(MATCH_RE.source, "g");
289
- const blocks = [];
290
- let mm0;
291
- while ((mm0 = matchRe.exec(content)) !== null) {
292
- const openBrace = mm0.index + mm0[0].length - 1;
293
- blocks.push({ path: mm0[1], index: mm0.index, end: findBlockEnd(content, openBrace) });
294
- }
441
+ const parsed = parseMatchBlocks(content);
295
442
  const re = new RegExp(ALLOW_RE.source, "g");
296
443
  let mm;
297
444
  while ((mm = re.exec(content)) !== null) {
298
445
  const line = content.slice(0, mm.index).split("\n").length;
299
446
  const target = mm[1].trim();
300
- 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);
301
458
  if (!check.isPublic) continue;
302
- const containing = blocks.filter((b) => b.index <= mm.index && mm.index < b.end);
303
- const path = containing.sort((x, y) => y.index - x.index)[0]?.path ?? "/(unknown)";
304
- 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]) });
305
462
  }
306
463
  return out;
307
464
  }
@@ -342,8 +499,17 @@ async function runChecks(model, rootDir, opts = {}) {
342
499
  const findings = [];
343
500
  const relRules = model.firebase.firestore.rulesFile;
344
501
  const absRules = relRules ? join2(rootDir, relRules) : null;
502
+ let pubs = [];
503
+ let helperDetailed = /* @__PURE__ */ new Map();
345
504
  if (absRules && existsSync2(absRules)) {
346
- 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) {
347
513
  const t = pub.target.toLowerCase();
348
514
  const tokens = t.split(",").map((s) => s.trim());
349
515
  const isWrite = tokens.some((x) => ["write", "create", "update", "delete"].includes(x));
@@ -355,7 +521,7 @@ async function runChecks(model, rootDir, opts = {}) {
355
521
  "ERROR",
356
522
  pub.confidence,
357
523
  `Escrita p\xFAblica em ${pub.path} (${pub.target}). Qualquer cliente pode escrever.`,
358
- `FBA001:${relRules}:${pub.path}:${normalizeTarget(pub.target)}`,
524
+ `FBA001:${relRules}:${pub.path}:${normalizeTarget(pub.target)}:${pub.conditionKey}`,
359
525
  {
360
526
  file: relRules,
361
527
  line: pub.line,
@@ -373,7 +539,7 @@ async function runChecks(model, rootDir, opts = {}) {
373
539
  "WARNING",
374
540
  pub.confidence,
375
541
  `Leitura p\xFAblica em ${pub.path} (${pub.target}). Pode ser proposital.`,
376
- `FBA002:${relRules}:${pub.path}:${normalizeTarget(pub.target)}`,
542
+ `FBA002:${relRules}:${pub.path}:${normalizeTarget(pub.target)}:${pub.conditionKey}`,
377
543
  {
378
544
  file: relRules,
379
545
  line: pub.line,
@@ -473,12 +639,10 @@ async function runChecks(model, rootDir, opts = {}) {
473
639
  return [op];
474
640
  };
475
641
  const publicPaths = /* @__PURE__ */ new Set();
476
- if (absRules && existsSync2(absRules)) {
477
- for (const p of findPublicAllows(absRules)) {
478
- const { ops } = opsFrom(p.target);
479
- for (const op of /* @__PURE__ */ new Set([...ops, ...ops.flatMap(expandOp)])) {
480
- publicPaths.add(`${p.path}::${op.toLowerCase()}`);
481
- }
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()}`);
482
646
  }
483
647
  }
484
648
  const permsByResource = /* @__PURE__ */ new Map();
@@ -499,9 +663,41 @@ async function runChecks(model, rootDir, opts = {}) {
499
663
  (op) => publicPaths.has(`${rule.path}::${op.toLowerCase()}`)
500
664
  );
501
665
  if (isPublicRule) return;
502
- const checksAdminClaim = rule.claimReferences.some((c) => c.toLowerCase() === "admin");
503
- const checksAdminRole = /token\.\w+\s*==\s*['"]admin['"]/i.test(rule.condition ?? "");
504
- 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
+ }
505
701
  const checksAuth = rule.authReferences.length > 0;
506
702
  if (!checksAuth) return;
507
703
  findings.push(
@@ -510,7 +706,7 @@ async function runChecks(model, rootDir, opts = {}) {
510
706
  "WARNING",
511
707
  "PROBABLE",
512
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.`,
513
- `FBA012:${rule.path}:${[...rule.operations].sort().join(",")}:${rule.location.start.line}:${resourceKey}`,
709
+ `FBA012:${rule.path}:${[...rule.operations].sort().join(",")}:${conditionKeyForFingerprint(rule.condition)}:${resourceKey}`,
514
710
  {
515
711
  file: rule.location.file,
516
712
  line: rule.location.start.line,
@@ -558,7 +754,39 @@ async function runChecks(model, rootDir, opts = {}) {
558
754
  )
559
755
  );
560
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
+ }
561
785
  const adminHits = await collectAdminImports(rootDir);
786
+ const isTestPath = (f) => {
787
+ const n = f.replace(/\\/g, "/");
788
+ return /\.test\.[tj]sx?$/.test(n) || /\.spec\.[tj]sx?$/.test(n) || /(^|\/)__tests__\//.test(n) || /(^|\/)(test|tests|testing)\//.test(n);
789
+ };
562
790
  for (const hit of adminHits) {
563
791
  let origin = "UNKNOWN";
564
792
  try {
@@ -567,7 +795,7 @@ async function runChecks(model, rootDir, opts = {}) {
567
795
  } catch {
568
796
  origin = "UNKNOWN";
569
797
  }
570
- if (origin === "CLIENT") {
798
+ if (origin === "CLIENT" && !isTestPath(hit.file)) {
571
799
  findings.push(
572
800
  make(
573
801
  "FBA003",
@@ -590,7 +818,12 @@ async function runChecks(model, rootDir, opts = {}) {
590
818
  async function collectPermissionCalls(rootDir, adapterFn) {
591
819
  const literals = [];
592
820
  const dynamic = [];
593
- const bases = [adapterFn];
821
+ const ADAPTER_RE = /^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$/;
822
+ const safeAdapter = ADAPTER_RE.test(adapterFn) ? adapterFn : null;
823
+ if (!safeAdapter) {
824
+ return { literals, dynamic };
825
+ }
826
+ const bases = [safeAdapter];
594
827
  const patterns = [];
595
828
  for (const b of bases) {
596
829
  patterns.push(`${b}($PERM)`, `${b}($PERM, $$$ARGS)`);
@@ -619,9 +852,9 @@ async function collectPermissionCalls(rootDir, adapterFn) {
619
852
  const isQuoted = /^['"`].*['"`]$/.test(raw);
620
853
  if (isQuoted) {
621
854
  const perm = raw.replace(/^['"`]|['"`]$/g, "").trim();
622
- if (perm.length >= 3) pushLiteral(m.file, line, perm);
623
- else if (perm.length > 0) {
624
- const k = `${m.file}:${line}:${raw}:short`;
855
+ if (/^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)*$/.test(perm)) pushLiteral(m.file, line, perm);
856
+ else {
857
+ const k = `${m.file}:${line}:${raw}:invalid`;
625
858
  if (!seen.has(k)) {
626
859
  seen.add(k);
627
860
  dynamic.push({ file: m.file, line, text: m.text });
@@ -838,6 +1071,7 @@ var FindingSchema = z.strictObject({
838
1071
  var PermissionNameSchema = z.string().regex(/^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)*$/);
839
1072
  var RoleNameSchema = z.string().regex(/^[a-z][a-z0-9_-]*$/);
840
1073
  var AuditYamlSchema = z.strictObject({
1074
+ $schema: z.string().optional(),
841
1075
  authorization: z.strictObject({
842
1076
  adapter: z.strictObject({ function: z.string().min(1) }).optional(),
843
1077
  roles: z.record(RoleNameSchema, z.strictObject({ permissions: z.array(PermissionNameSchema) })),
@@ -852,12 +1086,80 @@ var AuditYamlSchema = z.strictObject({
852
1086
  }).optional()
853
1087
  })
854
1088
  });
1089
+ var RESERVED_CLAIM_KEYS = [
1090
+ "sub",
1091
+ "iat",
1092
+ "iss",
1093
+ "exp",
1094
+ "aud",
1095
+ "auth_time",
1096
+ "acr",
1097
+ "amr",
1098
+ "azp",
1099
+ "cnf",
1100
+ "c_hash",
1101
+ "at_hash",
1102
+ "jti",
1103
+ "nbf",
1104
+ "nonce",
1105
+ "firebase",
1106
+ "user_id"
1107
+ ];
1108
+ function validateClaimSamples(samples) {
1109
+ const errors = [];
1110
+ if (!samples) return { ok: true, errors };
1111
+ const enc = new TextEncoder();
1112
+ for (const [role, claims] of Object.entries(samples)) {
1113
+ const bytes = enc.encode(JSON.stringify(claims)).length;
1114
+ if (bytes > 1e3) {
1115
+ errors.push(`samples.${role} com ${bytes} bytes (limite 1000 do crach\xE1)`);
1116
+ }
1117
+ for (const k of Object.keys(claims)) {
1118
+ if (RESERVED_CLAIM_KEYS.includes(k)) {
1119
+ errors.push(`samples.${role}.${k} \xE9 chave reservada OIDC/Firebase`);
1120
+ }
1121
+ }
1122
+ }
1123
+ return { ok: errors.length === 0, errors };
1124
+ }
855
1125
 
856
1126
  // src/scan.ts
857
1127
  async function scan(rootDir, opts = {}) {
858
1128
  const d = discover(rootDir);
1129
+ if (opts.graph === true) {
1130
+ try {
1131
+ await enrichWithGraph(rootDir, d);
1132
+ } catch {
1133
+ }
1134
+ }
859
1135
  const model = emptyModel(rootDir, d);
860
1136
  const findings_pre = [];
1137
+ try {
1138
+ if (d.firebaseJson && existsSync3(d.firebaseJson)) {
1139
+ const raw = JSON.parse(readFileSync4(d.firebaseJson, "utf-8"));
1140
+ for (const [kind, rel] of [
1141
+ ["rules", raw.firestore?.rules],
1142
+ ["indexes", raw.firestore?.indexes]
1143
+ ]) {
1144
+ if (typeof rel === "string" && rel.length > 0) {
1145
+ if (!existsSync3(join3(rootDir, rel))) {
1146
+ findings_pre.push({
1147
+ rule: "RULES_CONFIG_MISMATCH",
1148
+ severity: "WARNING",
1149
+ confidence: "CONFIRMED",
1150
+ message: `firebase.json aponta firestore.${kind} para "${rel}" inexistente \u2014 usando default. Corrija o path.`,
1151
+ fingerprint: `RULES_CONFIG_MISMATCH:${kind}:${rel}`,
1152
+ evidence: [
1153
+ { id: `ev-config-${kind}`, kind: "config-mismatch", summary: rel, confidence: "CONFIRMED" }
1154
+ ],
1155
+ fix: "Ajuste o path em firebase.json ou crie o arquivo."
1156
+ });
1157
+ }
1158
+ }
1159
+ }
1160
+ }
1161
+ } catch {
1162
+ }
861
1163
  if (!d.firestoreRulesFile || !existsSync3(d.firestoreRulesFile)) {
862
1164
  findings_pre.push({
863
1165
  rule: "RULES_NOT_OBSERVED",
@@ -890,6 +1192,18 @@ async function scan(rootDir, opts = {}) {
890
1192
  if (d.firestoreIndexesFile && existsSync3(d.firestoreIndexesFile)) {
891
1193
  try {
892
1194
  const raw = JSON.parse(readFileSync4(d.firestoreIndexesFile, "utf-8"));
1195
+ if (Array.isArray(raw.fieldOverrides) && raw.fieldOverrides.length > 0) {
1196
+ findings_pre.push({
1197
+ rule: "FIELD_OVERRIDES_PRESENT",
1198
+ severity: "INFO",
1199
+ confidence: "CONFIRMED",
1200
+ message: `firestore.indexes.json com ${raw.fieldOverrides.length} fieldOverrides (isen\xE7\xE3o campo \xFAnico, fora do drift composto).`,
1201
+ fingerprint: "FIELD_OVERRIDES_PRESENT",
1202
+ evidence: [
1203
+ { id: "ev-overrides", kind: "indexes-overrides", summary: `${raw.fieldOverrides.length} overrides`, confidence: "CONFIRMED" }
1204
+ ]
1205
+ });
1206
+ }
893
1207
  const validModes = ["ASCENDING", "DESCENDING", "ARRAY_CONTAINS", "VECTOR"];
894
1208
  const entries = [];
895
1209
  let skipped = 0;
@@ -905,7 +1219,7 @@ async function scan(rootDir, opts = {}) {
905
1219
  }
906
1220
  const fields = [];
907
1221
  for (const f of i.fields ?? []) {
908
- if (typeof f.fieldPath !== "string" || f.fieldPath.length === 0 || f.fieldPath === "__name__") {
1222
+ if (typeof f.fieldPath !== "string" || f.fieldPath.length === 0) {
909
1223
  skipped += 1;
910
1224
  continue;
911
1225
  }
@@ -922,6 +1236,11 @@ async function scan(rootDir, opts = {}) {
922
1236
  queryScope: i.queryScope === "COLLECTION_GROUP" ? "COLLECTION_GROUP" : "COLLECTION",
923
1237
  fields
924
1238
  });
1239
+ const last = entries[entries.length - 1];
1240
+ if (last.fields.length === 0) {
1241
+ entries.pop();
1242
+ skipped += 1;
1243
+ }
925
1244
  }
926
1245
  model.indexes = entries;
927
1246
  const totalSkipped = skipped + scopeSkipped;
@@ -957,7 +1276,24 @@ async function scan(rootDir, opts = {}) {
957
1276
  const validated = AuditYamlSchema.safeParse(parsed);
958
1277
  if (validated.success) {
959
1278
  const auth = validated.data.authorization;
960
- model.authorization.adapter = auth.adapter?.function ?? null;
1279
+ const rawAdapter = auth.adapter?.function ?? null;
1280
+ const ADAPTER_RE = /^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$/;
1281
+ if (rawAdapter !== null && !ADAPTER_RE.test(rawAdapter)) {
1282
+ findings_pre.push({
1283
+ rule: "CONTRACT_INVALID",
1284
+ severity: "WARNING",
1285
+ confidence: "CONFIRMED",
1286
+ message: `adapter.function "${rawAdapter}" inv\xE1lido \u2014 use identificador como "rbac.can". Coleta de permiss\xF5es ignorada.`,
1287
+ fingerprint: "CONTRACT_INVALID:adapter",
1288
+ evidence: [
1289
+ { id: "ev-adapter-invalid", kind: "contract-parse", summary: rawAdapter, confidence: "CONFIRMED" }
1290
+ ],
1291
+ fix: "Corrija authorization.adapter.function para identificador v\xE1lido."
1292
+ });
1293
+ model.authorization.adapter = null;
1294
+ } else {
1295
+ model.authorization.adapter = rawAdapter;
1296
+ }
961
1297
  model.authorization.permissions = Object.entries(auth.permissions).map(([name, p]) => ({
962
1298
  name,
963
1299
  resource: p.resource,
@@ -967,17 +1303,47 @@ async function scan(rootDir, opts = {}) {
967
1303
  name,
968
1304
  permissions: r.permissions
969
1305
  }));
1306
+ const samples = auth.claims?.samples;
1307
+ if (samples) {
1308
+ const res = validateClaimSamples(samples);
1309
+ if (!res.ok) {
1310
+ findings_pre.push({
1311
+ rule: "CONTRACT_INVALID",
1312
+ severity: "WARNING",
1313
+ confidence: "CONFIRMED",
1314
+ message: `claims.samples inv\xE1lido: ${res.errors.join("; ")} \u2014 samples ignorados.`,
1315
+ fingerprint: "CONTRACT_INVALID:claims",
1316
+ evidence: [
1317
+ { id: "ev-claims-invalid", kind: "contract-parse", summary: res.errors.join("; "), confidence: "CONFIRMED" }
1318
+ ],
1319
+ fix: "Claims s\xF3 para acesso, 1000 bytes, sem chaves OIDC/Firebase reservadas."
1320
+ });
1321
+ }
1322
+ }
1323
+ if (auth.claims && !auth.claims.enabled) {
1324
+ findings_pre.push({
1325
+ rule: "CLAIMS_DISABLED",
1326
+ severity: "INFO",
1327
+ confidence: "CONFIRMED",
1328
+ message: "Contrato com claims desabilitadas \u2014 FBA012 usa heur\xEDstica de papel sem crach\xE1.",
1329
+ fingerprint: "CLAIMS_DISABLED",
1330
+ evidence: [
1331
+ { id: "ev-claims-off", kind: "contract-parse", summary: "claims off", confidence: "CONFIRMED" }
1332
+ ]
1333
+ });
1334
+ }
970
1335
  } else {
1336
+ const details = validated.error.issues.slice(0, 5).map((i) => `${String(i.path.join(".")) || "(raiz)"}: ${i.message}`).join("; ");
971
1337
  findings_pre.push({
972
1338
  rule: "CONTRACT_INVALID",
973
1339
  severity: "WARNING",
974
1340
  confidence: "CONFIRMED",
975
- message: `firebase-audit.yaml existe mas \xE9 inv\xE1lido: ${validated.error.issues[0]?.message ?? "schema"} \u2014 contrato ignorado neste scan.`,
1341
+ message: `firebase-audit.yaml inv\xE1lido (${validated.error.issues.length} problema(s): ${details}) \u2014 contrato ignorado neste scan.`,
976
1342
  fingerprint: "CONTRACT_INVALID",
977
1343
  evidence: [
978
- { id: "ev-contract-invalid", kind: "contract-parse", summary: "YAML inv\xE1lido", confidence: "CONFIRMED" }
1344
+ { id: "ev-contract-invalid", kind: "contract-parse", summary: details.slice(0, 200), confidence: "CONFIRMED" }
979
1345
  ],
980
- fix: "Valide contra templates/firebase-audit.yaml."
1346
+ fix: "Valide contra templates/firebase-audit.yaml (min\xFAsculas, sem chaves extras)."
981
1347
  });
982
1348
  }
983
1349
  } catch (err) {
@@ -1027,12 +1393,28 @@ async function scan(rootDir, opts = {}) {
1027
1393
  const errors = findings.filter((f) => f.severity === "ERROR").length;
1028
1394
  const warnings = findings.filter((f) => f.severity === "WARNING").length;
1029
1395
  const infos = findings.filter((f) => f.severity === "INFO").length;
1396
+ const implemented = new Set(IMPLEMENTED_CHECKS);
1030
1397
  const failedChecks = new Set(
1031
1398
  findings.filter((f) => f.severity === "ERROR" || f.severity === "WARNING").map((f) => f.rule)
1032
1399
  );
1033
- const total = IMPLEMENTED_CHECKS.length;
1034
- const passed = IMPLEMENTED_CHECKS.filter((c) => !failedChecks.has(c)).length;
1035
- return { model, findings, summary: { errors, warnings, infos, passed, total } };
1400
+ const totalChecks = IMPLEMENTED_CHECKS.length;
1401
+ const passedChecks = IMPLEMENTED_CHECKS.filter((c) => !failedChecks.has(c)).length;
1402
+ const infraSkipped = findings.filter((f) => !implemented.has(f.rule)).length;
1403
+ return {
1404
+ model,
1405
+ findings,
1406
+ summary: {
1407
+ errors,
1408
+ warnings,
1409
+ infos,
1410
+ passed: passedChecks,
1411
+ total: totalChecks,
1412
+ passedChecks,
1413
+ totalChecks,
1414
+ infraSkipped,
1415
+ coveragePct: null
1416
+ }
1417
+ };
1036
1418
  }
1037
1419
 
1038
1420
  export {
@@ -1040,11 +1422,27 @@ export {
1040
1422
  SERVER_HINTS_SEGMENTS,
1041
1423
  classifyOrigin,
1042
1424
  emptyModel,
1425
+ enrichWithGraph,
1043
1426
  stripRuleComments,
1044
1427
  opsFrom,
1428
+ normalizeTarget,
1429
+ parseMatchBlocks,
1430
+ buildFullPath,
1431
+ parseRuleFunctions,
1432
+ parseRuleFunctionsDetailed,
1433
+ inlineHelperArgs,
1434
+ inlineHelpersInCondition,
1435
+ resolveHelperCondition,
1436
+ resolveHelperConditionDetailed,
1045
1437
  extractRules,
1438
+ extractRulesContent,
1439
+ stripOuterParens,
1046
1440
  isPublicCondition,
1441
+ hashShort,
1442
+ conditionKeyForFingerprint,
1047
1443
  findPublicAllows,
1444
+ helperBodyToCondition,
1445
+ findPublicAllowsContent,
1048
1446
  IMPLEMENTED_CHECKS,
1049
1447
  runChecks,
1050
1448
  SeveritySchema,
@@ -1067,5 +1465,7 @@ export {
1067
1465
  ProjectModelSchema,
1068
1466
  FindingSchema,
1069
1467
  AuditYamlSchema,
1468
+ RESERVED_CLAIM_KEYS,
1469
+ validateClaimSamples,
1070
1470
  scan
1071
1471
  };