@justmpm/firebase-audit 0.3.1 → 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,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,6 +754,34 @@ 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);
562
786
  for (const hit of adminHits) {
563
787
  let origin = "UNKNOWN";
@@ -590,7 +814,12 @@ async function runChecks(model, rootDir, opts = {}) {
590
814
  async function collectPermissionCalls(rootDir, adapterFn) {
591
815
  const literals = [];
592
816
  const dynamic = [];
593
- 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];
594
823
  const patterns = [];
595
824
  for (const b of bases) {
596
825
  patterns.push(`${b}($PERM)`, `${b}($PERM, $$$ARGS)`);
@@ -619,9 +848,9 @@ async function collectPermissionCalls(rootDir, adapterFn) {
619
848
  const isQuoted = /^['"`].*['"`]$/.test(raw);
620
849
  if (isQuoted) {
621
850
  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`;
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`;
625
854
  if (!seen.has(k)) {
626
855
  seen.add(k);
627
856
  dynamic.push({ file: m.file, line, text: m.text });
@@ -838,6 +1067,7 @@ var FindingSchema = z.strictObject({
838
1067
  var PermissionNameSchema = z.string().regex(/^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)*$/);
839
1068
  var RoleNameSchema = z.string().regex(/^[a-z][a-z0-9_-]*$/);
840
1069
  var AuditYamlSchema = z.strictObject({
1070
+ $schema: z.string().optional(),
841
1071
  authorization: z.strictObject({
842
1072
  adapter: z.strictObject({ function: z.string().min(1) }).optional(),
843
1073
  roles: z.record(RoleNameSchema, z.strictObject({ permissions: z.array(PermissionNameSchema) })),
@@ -852,12 +1082,80 @@ var AuditYamlSchema = z.strictObject({
852
1082
  }).optional()
853
1083
  })
854
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
+ }
855
1121
 
856
1122
  // src/scan.ts
857
1123
  async function scan(rootDir, opts = {}) {
858
1124
  const d = discover(rootDir);
1125
+ if (opts.graph === true) {
1126
+ try {
1127
+ await enrichWithGraph(rootDir, d);
1128
+ } catch {
1129
+ }
1130
+ }
859
1131
  const model = emptyModel(rootDir, d);
860
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
+ }
861
1159
  if (!d.firestoreRulesFile || !existsSync3(d.firestoreRulesFile)) {
862
1160
  findings_pre.push({
863
1161
  rule: "RULES_NOT_OBSERVED",
@@ -890,6 +1188,18 @@ async function scan(rootDir, opts = {}) {
890
1188
  if (d.firestoreIndexesFile && existsSync3(d.firestoreIndexesFile)) {
891
1189
  try {
892
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
+ }
893
1203
  const validModes = ["ASCENDING", "DESCENDING", "ARRAY_CONTAINS", "VECTOR"];
894
1204
  const entries = [];
895
1205
  let skipped = 0;
@@ -905,7 +1215,7 @@ async function scan(rootDir, opts = {}) {
905
1215
  }
906
1216
  const fields = [];
907
1217
  for (const f of i.fields ?? []) {
908
- if (typeof f.fieldPath !== "string" || f.fieldPath.length === 0 || f.fieldPath === "__name__") {
1218
+ if (typeof f.fieldPath !== "string" || f.fieldPath.length === 0) {
909
1219
  skipped += 1;
910
1220
  continue;
911
1221
  }
@@ -922,6 +1232,11 @@ async function scan(rootDir, opts = {}) {
922
1232
  queryScope: i.queryScope === "COLLECTION_GROUP" ? "COLLECTION_GROUP" : "COLLECTION",
923
1233
  fields
924
1234
  });
1235
+ const last = entries[entries.length - 1];
1236
+ if (last.fields.length === 0) {
1237
+ entries.pop();
1238
+ skipped += 1;
1239
+ }
925
1240
  }
926
1241
  model.indexes = entries;
927
1242
  const totalSkipped = skipped + scopeSkipped;
@@ -957,7 +1272,24 @@ async function scan(rootDir, opts = {}) {
957
1272
  const validated = AuditYamlSchema.safeParse(parsed);
958
1273
  if (validated.success) {
959
1274
  const auth = validated.data.authorization;
960
- 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
+ }
961
1293
  model.authorization.permissions = Object.entries(auth.permissions).map(([name, p]) => ({
962
1294
  name,
963
1295
  resource: p.resource,
@@ -967,6 +1299,35 @@ async function scan(rootDir, opts = {}) {
967
1299
  name,
968
1300
  permissions: r.permissions
969
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
+ }
970
1331
  } else {
971
1332
  findings_pre.push({
972
1333
  rule: "CONTRACT_INVALID",
@@ -1027,12 +1388,28 @@ async function scan(rootDir, opts = {}) {
1027
1388
  const errors = findings.filter((f) => f.severity === "ERROR").length;
1028
1389
  const warnings = findings.filter((f) => f.severity === "WARNING").length;
1029
1390
  const infos = findings.filter((f) => f.severity === "INFO").length;
1391
+ const implemented = new Set(IMPLEMENTED_CHECKS);
1030
1392
  const failedChecks = new Set(
1031
1393
  findings.filter((f) => f.severity === "ERROR" || f.severity === "WARNING").map((f) => f.rule)
1032
1394
  );
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 } };
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
+ };
1036
1413
  }
1037
1414
 
1038
1415
  export {
@@ -1040,11 +1417,27 @@ export {
1040
1417
  SERVER_HINTS_SEGMENTS,
1041
1418
  classifyOrigin,
1042
1419
  emptyModel,
1420
+ enrichWithGraph,
1043
1421
  stripRuleComments,
1044
1422
  opsFrom,
1423
+ normalizeTarget,
1424
+ parseMatchBlocks,
1425
+ buildFullPath,
1426
+ parseRuleFunctions,
1427
+ parseRuleFunctionsDetailed,
1428
+ inlineHelperArgs,
1429
+ inlineHelpersInCondition,
1430
+ resolveHelperCondition,
1431
+ resolveHelperConditionDetailed,
1045
1432
  extractRules,
1433
+ extractRulesContent,
1434
+ stripOuterParens,
1046
1435
  isPublicCondition,
1436
+ hashShort,
1437
+ conditionKeyForFingerprint,
1047
1438
  findPublicAllows,
1439
+ helperBodyToCondition,
1440
+ findPublicAllowsContent,
1048
1441
  IMPLEMENTED_CHECKS,
1049
1442
  runChecks,
1050
1443
  SeveritySchema,
@@ -1067,5 +1460,7 @@ export {
1067
1460
  ProjectModelSchema,
1068
1461
  FindingSchema,
1069
1462
  AuditYamlSchema,
1463
+ RESERVED_CLAIM_KEYS,
1464
+ validateClaimSamples,
1070
1465
  scan
1071
1466
  };