@justmpm/firebase-audit 0.5.12 → 0.5.14

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.
package/README.md CHANGED
@@ -163,7 +163,7 @@ Limites assumidos: Emulator não prova índice (produção exige, ele deixa pass
163
163
  - `UNKNOWN` — impossível resolver estaticamente (ex: `where(fieldVar, ...)`)
164
164
  - `CONFLICTING` — interno, duas evidências se contradizem (ex: typo `order.delete` vs `orders.delete`)
165
165
 
166
- ### 9 checks implementados (+ avisos de infra e Storage/AppCheck)
166
+ ### 10 checks implementados (+ avisos de infra e Storage/AppCheck)
167
167
 
168
168
  | # | Check | Sev | Conf |
169
169
  |---|-------|-----|------|
@@ -176,6 +176,7 @@ Limites assumidos: Emulator não prova índice (produção exige, ele deixa pass
176
176
  | FBA011 | Role referencia permission não declarada | ERROR | CONFIRMED |
177
177
  | FBA012 | Rule ampla vs contrato admin-only | WARNING | PROBABLE |
178
178
  | FBA013 | Role sem permissions | WARNING | CONFIRMED |
179
+ | FBA014 | Create/update próprio em `admins/` sem gate de admin (auto-promoção) | WARNING | PROBABLE |
179
180
  | AUTH_ANON_ALLOWED | `auth != null` sozinho (anônimo passa) | INFO | PROBABLE |
180
181
  | STORAGE_* | Escrita aberta / curinga total / leitura aberta / anônimo (por bloco do match) | ERROR/WARNING/INFO | CONFIRMED/PROBABLE |
181
182
  | APPCHECK_* | Carimbo observado / verificação manual (onRequest por rota) | INFO | CONFIRMED/PROBABLE |
@@ -196,7 +196,7 @@ function isBuildOutput(file) {
196
196
  const n = file.replace(/\\/g, "/");
197
197
  return /(^|\/)(node_modules|dist|build|\.next|coverage|out)\//.test(n) || /\/functions\/lib\//.test(n);
198
198
  }
199
- var IMPLEMENTED_CHECKS = ["FBA001", "FBA002", "FBA003", "FBA004", "FBA009", "FBA010", "FBA011", "FBA012", "FBA013"];
199
+ var IMPLEMENTED_CHECKS = ["FBA001", "FBA002", "FBA003", "FBA004", "FBA009", "FBA010", "FBA011", "FBA012", "FBA013", "FBA014"];
200
200
  function createEvidence() {
201
201
  let counter = 0;
202
202
  return {
@@ -223,6 +223,38 @@ function make(rule, severity, confidence, message, fingerprint, opts = {}) {
223
223
  ...opts
224
224
  };
225
225
  }
226
+ function checksAdminPower(cond, condRaw, rule, helperDetailed) {
227
+ 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*\([^()]*admin\s*==\s*true/i.test(cond);
228
+ const normClaim = (c) => c.toLowerCase().replace(/[_-]/g, "");
229
+ const isAdminCall = /\bisadmin\s*\(([^)]*)\)/i.exec(condRaw) ?? /\bisadmin\s*\(([^)]*)\)/i.exec(cond);
230
+ let isAdminBodyHasAdmin = false;
231
+ if (isAdminCall) {
232
+ const fnName = (() => {
233
+ const m = /\b([A-Za-z_][A-Za-z0-9_]*)\s*\(/i.exec(isAdminCall[0]);
234
+ return m ? m[1] : "isAdmin";
235
+ })();
236
+ const fn = helperDetailed.get(fnName) ?? helperDetailed.get("isAdmin") ?? helperDetailed.get("isadmin");
237
+ if (fn) {
238
+ const b = `${fn.body}`;
239
+ 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);
240
+ }
241
+ }
242
+ const checksAdminClaim = !isInvertedAdmin && rule.claimReferences.some((c) => {
243
+ const n = normClaim(c);
244
+ return n === "admin" || n === "isadmin";
245
+ });
246
+ const hasTopOr = /\|\|/.test(cond);
247
+ const adminPerDisjunct = hasTopOr ? cond.split("||").every((part) => {
248
+ const p = part;
249
+ 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);
250
+ }) : true;
251
+ 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;
252
+ if (checksAdminClaim || checksAdminRole) {
253
+ if (hasTopOr && !adminPerDisjunct) return false;
254
+ return true;
255
+ }
256
+ return false;
257
+ }
226
258
  async function runChecks(model, rootDir, opts = {}) {
227
259
  const { ev } = createEvidence();
228
260
  const findings = [];
@@ -421,37 +453,7 @@ async function runChecks(model, rootDir, opts = {}) {
421
453
  const condResolved = resolveHelperConditionDetailed(condRaw, helperDetailed);
422
454
  const condInlined = inlineHelpersInCondition(condRaw, helperDetailed);
423
455
  const cond = condResolved !== condRaw ? condResolved : condInlined !== condRaw ? condInlined : condRaw;
424
- 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*\([^()]*admin\s*==\s*true/i.test(cond);
425
- const normClaim = (c) => c.toLowerCase().replace(/[_-]/g, "");
426
- const isAdminCall = /\bisadmin\s*\(([^)]*)\)/i.exec(condRaw) ?? /\bisadmin\s*\(([^)]*)\)/i.exec(cond);
427
- let isAdminBodyHasAdmin = false;
428
- if (isAdminCall) {
429
- const fnName = (() => {
430
- const m = /\b([A-Za-z_][A-Za-z0-9_]*)\s*\(/i.exec(isAdminCall[0]);
431
- return m ? m[1] : "isAdmin";
432
- })();
433
- const fn = helperDetailed.get(fnName) ?? helperDetailed.get("isAdmin") ?? helperDetailed.get("isadmin");
434
- if (fn) {
435
- const b = `${fn.body}`;
436
- 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);
437
- }
438
- }
439
- const checksAdminClaim = !isInvertedAdmin && rule.claimReferences.some((c) => {
440
- const n = normClaim(c);
441
- return n === "admin" || n === "isadmin";
442
- });
443
- const hasTopOr = /\|\|/.test(cond);
444
- const adminPerDisjunct = hasTopOr ? cond.split("||").every((part) => {
445
- const p = part;
446
- 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);
447
- }) : true;
448
- 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;
449
- if (checksAdminClaim || checksAdminRole) {
450
- if (hasTopOr && !adminPerDisjunct) {
451
- } else {
452
- return;
453
- }
454
- }
456
+ if (checksAdminPower(cond, condRaw, rule, helperDetailed)) return;
455
457
  const checksAuth = rule.authReferences.length > 0;
456
458
  if (!checksAuth) return;
457
459
  findings.push(
@@ -518,12 +520,13 @@ async function runChecks(model, rootDir, opts = {}) {
518
520
  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");
519
521
  const isBareAuth = hasBareAuth || hasWeakConjunction;
520
522
  if (isBareAuth && rule.claimReferences.length === 0) {
523
+ const gatedByDoc = /exists\s*\(/i.test(effectiveRaw);
521
524
  findings.push(
522
525
  make(
523
526
  "AUTH_ANON_ALLOWED",
524
527
  "INFO",
525
528
  "PROBABLE",
526
- `Rule ${rule.path} aceita qualquer autenticado incluindo an\xF4nimo (request.auth != null sem claim/provider).`,
529
+ gatedByDoc ? `Rule ${rule.path} exige login + documento via exists() (sem claim/provider) \u2014 an\xF4nimo com login passa no conjunto se obtiver o doc.` : `Rule ${rule.path} aceita qualquer autenticado incluindo an\xF4nimo (request.auth != null sem claim/provider).`,
527
530
  `AUTH_ANON_ALLOWED:${rule.path}:${conditionKeyForFingerprint(rule.condition)}`,
528
531
  {
529
532
  file: rule.location.file,
@@ -536,6 +539,42 @@ async function runChecks(model, rootDir, opts = {}) {
536
539
  );
537
540
  }
538
541
  }
542
+ for (const rule of model.rules) {
543
+ const segments = rule.path.split("/").filter((s) => s.length > 0).map((s) => s.toLowerCase());
544
+ if (!segments.includes("admins")) continue;
545
+ const ruleOps = new Set(rule.operations.flatMap(expandOp));
546
+ const escalating = ["create", "write", "update"].filter((o) => ruleOps.has(o));
547
+ if (escalating.length === 0) continue;
548
+ const isPublicRule = [...ruleOps].some(
549
+ (op) => publicPaths.has(`${rule.path}::${op.toLowerCase()}`)
550
+ );
551
+ if (isPublicRule) continue;
552
+ const condRaw = rule.condition ?? "";
553
+ const condResolved = resolveHelperConditionDetailed(condRaw, helperDetailed);
554
+ const condInlined = inlineHelpersInCondition(condRaw, helperDetailed);
555
+ const cond = condResolved !== condRaw ? condResolved : condInlined !== condRaw ? condInlined : condRaw;
556
+ if (checksAdminPower(cond, condRaw, rule, helperDetailed)) continue;
557
+ const docGate = /exists\s*\((?:[^()]*|\([^()]*\))*admins?/i.test(cond) || /\bget\s*\(\s*\/(?:[^()]*|\([^()]*\))*admins?/i.test(cond);
558
+ if (docGate) continue;
559
+ const checksAuth = rule.authReferences.length > 0;
560
+ if (!checksAuth) continue;
561
+ findings.push(
562
+ make(
563
+ "FBA014",
564
+ "WARNING",
565
+ "PROBABLE",
566
+ `Rule ${rule.path} permite ${escalating.join("/")} sem exigir poder de admin (sem claim/papel de admin nem doc-gate em admins/). Qualquer logado cria/altera o pr\xF3prio doc \u2014 se o cadastro escrever role 'admin', \xE9 auto-promo\xE7\xE3o. Confirme o c\xF3digo de signup e valide no Emulator.`,
567
+ `FBA014:${rule.path}:${normalizeTarget(rule.operations.join(","))}:${conditionKeyForFingerprint(rule.condition)}`,
568
+ {
569
+ file: rule.location.file,
570
+ line: rule.location.start.line,
571
+ resource: rule.path,
572
+ evidence: [ev("self-admin-create", rule.condition ?? "sem condi\xE7\xE3o", "PROBABLE", rule.location.file, rule.location.start.line)],
573
+ fix: "Exigir claim/papel de admin ou doc-gate (exists/get em admins/) no create/update; nunca deixar o cadastro escrever role 'admin' para usu\xE1rio comum. Valide no Emulator (matriz anonymous/user/admin)."
574
+ }
575
+ )
576
+ );
577
+ }
539
578
  const adminHits = await collectAdminImports(rootDir);
540
579
  for (const hit of adminHits) {
541
580
  let origin = "UNKNOWN";
@@ -1264,7 +1303,7 @@ async function scan(rootDir, opts = {}) {
1264
1303
  severity: "ERROR",
1265
1304
  confidence: "CONFIRMED",
1266
1305
  message: `Storage com escrita aberta (${where}). Qualquer cliente pode escrever \u2014 cofre aberto, n\xE3o vitrine.`,
1267
- fingerprint: `STORAGE_PUBLIC_WRITE:${m.matchPath}:${normalizeTarget(m.target)}:${key}`,
1306
+ fingerprint: `STORAGE_PUBLIC_WRITE:${m.fpPath}:${normalizeTarget(m.target)}:${key}`,
1268
1307
  file: relStorage,
1269
1308
  line: m.line,
1270
1309
  evidence: [{ id: evId("write"), kind: "storage-public", summary: `${m.matchPath} allow ${m.target}`, confidence: "CONFIRMED", location: { file: relStorage, start: { line: m.line, column: 0 } } }],
@@ -1279,7 +1318,7 @@ async function scan(rootDir, opts = {}) {
1279
1318
  severity: "WARNING",
1280
1319
  confidence: "PROBABLE",
1281
1320
  message: `Storage com leitura aberta em curinga total (${where}). Vale para tudo \u2014 se a inten\xE7\xE3o era s\xF3 logo, restrinja a /logos/{id}.`,
1282
- fingerprint: `STORAGE_ALL_PATHS_OPEN:${m.matchPath}:${normalizeTarget(m.target)}:${key}`,
1321
+ fingerprint: `STORAGE_ALL_PATHS_OPEN:${m.fpPath}:${normalizeTarget(m.target)}:${key}`,
1283
1322
  file: relStorage,
1284
1323
  line: m.line,
1285
1324
  evidence: [{ id: evId("all"), kind: "storage-public", summary: `${m.matchPath} allow ${m.target}`, confidence: "PROBABLE", location: { file: relStorage, start: { line: m.line, column: 0 } } }],
@@ -1292,7 +1331,7 @@ async function scan(rootDir, opts = {}) {
1292
1331
  severity: "INFO",
1293
1332
  confidence: "PROBABLE",
1294
1333
  message: enumerates ? `Storage com leitura aberta (${where}). Pode ser vitrine proposital \u2014 mas allow read/list permite enumerar a pasta; para vitrine segura prefira allow get. Confirme que o write segue restrito.` : `Storage com leitura aberta (${where}). Pode ser vitrine proposital (logos) \u2014 confirme que o write segue restrito.`,
1295
- fingerprint: `STORAGE_PUBLIC_READ:${m.matchPath}:${normalizeTarget(m.target)}:${key}`,
1334
+ fingerprint: `STORAGE_PUBLIC_READ:${m.fpPath}:${normalizeTarget(m.target)}:${key}`,
1296
1335
  file: relStorage,
1297
1336
  line: m.line,
1298
1337
  evidence: [{ id: evId("read"), kind: "storage-public", summary: `${m.matchPath} allow ${m.target}`, confidence: "PROBABLE", location: { file: relStorage, start: { line: m.line, column: 0 } } }],
@@ -1304,12 +1343,13 @@ async function scan(rootDir, opts = {}) {
1304
1343
  }
1305
1344
  const noAuthBranch = findNoAuthBranch(effectiveCond);
1306
1345
  if (noAuthBranch === void 0 && bareAuth && (ops.has("read") || ops.has("get") || ops.has("list") || ops.has("write") || ops.has("create") || ops.has("update") || ops.has("delete"))) {
1346
+ const gatedByDoc = /exists\(/.test(normCond);
1307
1347
  findings.push({
1308
1348
  rule: "STORAGE_ANON_ALLOWED",
1309
1349
  severity: "INFO",
1310
1350
  confidence: "PROBABLE",
1311
- message: `Storage aceita qualquer autenticado incluindo an\xF4nimo (${where}). Exija claim/provider para dados sens\xEDveis.`,
1312
- fingerprint: `STORAGE_ANON_ALLOWED:${m.matchPath}:${normalizeTarget(m.target)}:${key}`,
1351
+ message: gatedByDoc ? `Storage exige login + documento via exists() (${where}) \u2014 mais forte que login puro, mas sem claim/provider: an\xF4nimo com login passa no conjunto se obtiver o doc. Exija claim/provider para dados sens\xEDveis.` : `Storage aceita qualquer autenticado incluindo an\xF4nimo (${where}). Exija claim/provider para dados sens\xEDveis.`,
1352
+ fingerprint: `STORAGE_ANON_ALLOWED:${m.fpPath}:${normalizeTarget(m.target)}:${key}`,
1313
1353
  file: relStorage,
1314
1354
  line: m.line,
1315
1355
  evidence: [{ id: evId("anon"), kind: "anon-allowed", summary: `${m.matchPath} allow ${m.target}`, confidence: "PROBABLE", location: { file: relStorage, start: { line: m.line, column: 0 } } }],
@@ -1330,7 +1370,7 @@ async function scan(rootDir, opts = {}) {
1330
1370
  severity: "ERROR",
1331
1371
  confidence: "PROBABLE",
1332
1372
  message: `Storage com escrita condicionada sem request.auth (${where}). Se a condi\xE7\xE3o for verdadeira para an\xF4nimo, ele escreve \u2014 cofre prov\xE1vel.${branchNote}${noAuthNote}`,
1333
- fingerprint: `STORAGE_PUBLIC_WRITE:${m.matchPath}:${normalizeTarget(m.target)}:${key}`,
1373
+ fingerprint: `STORAGE_PUBLIC_WRITE:${m.fpPath}:${normalizeTarget(m.target)}:${key}`,
1334
1374
  file: relStorage,
1335
1375
  line: m.line,
1336
1376
  evidence: [{ id: evId("write"), kind: "storage-public", summary: `${m.matchPath} allow ${m.target} sem auth`, confidence: "PROBABLE", location: { file: relStorage, start: { line: m.line, column: 0 } } }],
@@ -1342,7 +1382,7 @@ async function scan(rootDir, opts = {}) {
1342
1382
  severity: "INFO",
1343
1383
  confidence: "PROBABLE",
1344
1384
  message: `Storage com leitura condicionada sem request.auth (${where}). Se a condi\xE7\xE3o for verdadeira para an\xF4nimo, ele l\xEA \u2014 confirme que \xE9 vitrine.${branchNote}${noAuthNote}`,
1345
- fingerprint: `STORAGE_PUBLIC_READ:${m.matchPath}:${normalizeTarget(m.target)}:${key}`,
1385
+ fingerprint: `STORAGE_PUBLIC_READ:${m.fpPath}:${normalizeTarget(m.target)}:${key}`,
1346
1386
  file: relStorage,
1347
1387
  line: m.line,
1348
1388
  evidence: [{ id: evId("read"), kind: "storage-public", summary: `${m.matchPath} allow ${m.target} sem auth`, confidence: "PROBABLE", location: { file: relStorage, start: { line: m.line, column: 0 } } }]
@@ -1552,21 +1592,22 @@ function findNoAuthBranch(effective) {
1552
1592
  return void 0;
1553
1593
  }
1554
1594
  function parseStorageAllows(clean) {
1595
+ const text = clean.replace(/\r\n?/g, "\n");
1555
1596
  const out = [];
1556
1597
  const matches = [];
1557
1598
  const matchRe = /match\s+/g;
1558
1599
  let mm;
1559
- while ((mm = matchRe.exec(clean)) !== null) {
1600
+ while ((mm = matchRe.exec(text)) !== null) {
1560
1601
  const from = mm.index + mm[0].length;
1561
1602
  let openBrace = -1;
1562
1603
  let scan2 = from;
1563
- while (scan2 < clean.length) {
1564
- const b = clean.indexOf("{", scan2);
1604
+ while (scan2 < text.length) {
1605
+ const b = text.indexOf("{", scan2);
1565
1606
  if (b === -1) break;
1566
- const between = clean.slice(from, b);
1607
+ const between = text.slice(from, b);
1567
1608
  if (/match\s|allow\s|;/.test(between)) break;
1568
- const after = clean.slice(b + 1, b + 12).trimStart().replace(/^\n+/, "");
1569
- const eol = clean.slice(b + 1).match(/^[ \t]*(\n|$)/) !== null;
1609
+ const after = text.slice(b + 1, b + 12).trimStart().replace(/^\n+/, "");
1610
+ const eol = text.slice(b + 1).match(/^[ \t]*(\n|$)/) !== null;
1570
1611
  if (/^(allow|match|function|\})/.test(after) || eol) {
1571
1612
  openBrace = b;
1572
1613
  break;
@@ -1574,14 +1615,14 @@ function parseStorageAllows(clean) {
1574
1615
  scan2 = b + 1;
1575
1616
  }
1576
1617
  if (openBrace === -1) continue;
1577
- const path = clean.slice(from, openBrace).trim() || "/(unknown)";
1618
+ const path = text.slice(from, openBrace).trim() || "/(unknown)";
1578
1619
  let depth = 0;
1579
1620
  let quote = null;
1580
- let end = clean.length;
1581
- for (let i = openBrace; i < clean.length; i++) {
1582
- const c = clean[i];
1621
+ let end = text.length;
1622
+ for (let i = openBrace; i < text.length; i++) {
1623
+ const c = text[i];
1583
1624
  if (quote) {
1584
- if (c === quote && clean[i - 1] !== "\\") quote = null;
1625
+ if (c === quote && text[i - 1] !== "\\") quote = null;
1585
1626
  continue;
1586
1627
  }
1587
1628
  if (c === '"' || c === "'" || c === "`") {
@@ -1601,15 +1642,17 @@ function parseStorageAllows(clean) {
1601
1642
  }
1602
1643
  const re = /allow\s+([^;:]+?)(?::\s*if\s+([^;]+))?;/gi;
1603
1644
  let m;
1604
- while ((m = re.exec(clean)) !== null) {
1645
+ while ((m = re.exec(text)) !== null) {
1605
1646
  const containing = matches.filter((b) => b.start <= m.index && m.index < b.end).sort((a, b) => a.start - b.start);
1606
1647
  const parts = [];
1607
1648
  for (const b of containing) {
1608
1649
  const p = b.path.trim().replace(/^\/+|\/+$/g, "");
1609
1650
  if (p.length > 0) parts.push(p);
1610
1651
  }
1611
- const line = clean.slice(0, m.index).split("\n").length;
1612
- out.push({ target: m[1].trim(), condition: m[2]?.trim(), matchPath: parts.length > 0 ? `/${parts.join("/")}` : "/(unknown)", line });
1652
+ const line = text.slice(0, m.index).split("\n").length;
1653
+ const matchPath = parts.length > 0 ? `/${parts.join("/")}` : "/(unknown)";
1654
+ const fpPath = matchPath === "/(unknown)" ? `/(unknown):L${line}` : matchPath;
1655
+ out.push({ target: m[1].trim(), condition: m[2]?.trim(), matchPath, fpPath, line });
1613
1656
  }
1614
1657
  return out;
1615
1658
  }
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  scan
3
- } from "./chunk-CTQPAMOE.js";
3
+ } from "./chunk-4RVGY4EB.js";
4
4
  import {
5
5
  verify
6
6
  } from "./chunk-CSMT4PIP.js";
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  scan
4
- } from "./chunk-CTQPAMOE.js";
4
+ } from "./chunk-4RVGY4EB.js";
5
5
  import "./chunk-7J3ADEBW.js";
6
6
 
7
7
  // src/cli.ts
@@ -84,7 +84,7 @@ Uso: firebase-audit scan|check|verify|drift [--json] ...`);
84
84
  const { resolve: resolveRoot } = await import("path");
85
85
  const rootDir = cwdArg ? resolveRoot(process.cwd(), cwdArg) : process.cwd();
86
86
  if (cmd === "verify") {
87
- const { scan: scanForVerify } = await import("./scan-CU54MUN7.js");
87
+ const { scan: scanForVerify } = await import("./scan-DDGH7LPH.js");
88
88
  const { verify } = await import("./verify-GQSR4FQL.js");
89
89
  const { resolve: resolveVerify } = await import("path");
90
90
  const coverageArg = getArg("coverage");
package/dist/index.d.ts CHANGED
@@ -739,13 +739,13 @@ declare function findPublicAllowsContent(rawContent: string): {
739
739
  }[];
740
740
 
741
741
  /**
742
- * Checks (FBA001–FBA004, FBA009–FBA013, subconjunto intencional).
742
+ * Checks (FBA001–FBA004, FBA009–FBA014, subconjunto intencional).
743
743
  * Fingerprints: FBA001/FBA002 por regra-lógica + hash da condição
744
744
  * (estáveis a formatação, únicos por regra duplicada);
745
745
  * FBA003/FBA004/FBA010/FBA012 com linha (únicos por ocorrência).
746
746
  */
747
747
 
748
- declare const IMPLEMENTED_CHECKS: readonly ["FBA001", "FBA002", "FBA003", "FBA004", "FBA009", "FBA010", "FBA011", "FBA012", "FBA013"];
748
+ declare const IMPLEMENTED_CHECKS: readonly ["FBA001", "FBA002", "FBA003", "FBA004", "FBA009", "FBA010", "FBA011", "FBA012", "FBA013", "FBA014"];
749
749
  declare function runChecks(model: ProjectModel, rootDir: string, opts?: {
750
750
  adapterFn?: string;
751
751
  }): Promise<Finding[]>;
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  createMcpServer,
3
3
  startMcpServer
4
- } from "./chunk-OYWTDV45.js";
4
+ } from "./chunk-B5CIZS4Y.js";
5
5
  import {
6
6
  AccessOriginSchema,
7
7
  AuditYamlSchema,
@@ -34,7 +34,7 @@ import {
34
34
  runChecks,
35
35
  scan,
36
36
  validateClaimSamples
37
- } from "./chunk-CTQPAMOE.js";
37
+ } from "./chunk-4RVGY4EB.js";
38
38
  import {
39
39
  buildFullPath,
40
40
  conditionKeyForFingerprint,
package/dist/mcp-cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  startMcpServer
4
- } from "./chunk-OYWTDV45.js";
5
- import "./chunk-CTQPAMOE.js";
4
+ } from "./chunk-B5CIZS4Y.js";
5
+ import "./chunk-4RVGY4EB.js";
6
6
  import "./chunk-7J3ADEBW.js";
7
7
  import "./chunk-CSMT4PIP.js";
8
8
  import "./chunk-LD3F73XI.js";
package/dist/mcp.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  createMcpServer,
3
3
  startMcpServer
4
- } from "./chunk-OYWTDV45.js";
5
- import "./chunk-CTQPAMOE.js";
4
+ } from "./chunk-B5CIZS4Y.js";
5
+ import "./chunk-4RVGY4EB.js";
6
6
  import "./chunk-7J3ADEBW.js";
7
7
  import "./chunk-CSMT4PIP.js";
8
8
  import "./chunk-LD3F73XI.js";
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  scan
3
- } from "./chunk-CTQPAMOE.js";
3
+ } from "./chunk-4RVGY4EB.js";
4
4
  import "./chunk-7J3ADEBW.js";
5
5
  export {
6
6
  scan
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justmpm/firebase-audit",
3
- "version": "0.5.12",
3
+ "version": "0.5.14",
4
4
  "description": "Auditor de consistência e segurança para projetos Firebase: código + Rules + índices + contrato vs comportamento. Static-first, nunca inventa certeza.",
5
5
  "keywords": [
6
6
  "firebase",
package/skill/SKILL.md CHANGED
@@ -21,6 +21,9 @@ Skill para agentes usarem o `@justmpm/firebase-audit` do jeito certo.
21
21
  - AppCheck é carimbo do app oficial — não prova usuário nem tenant
22
22
  - Logo pública (`get: if true` em `/logos/`) pode ser vitrine proposital — cofre é `write: if true` ou curinga total aberto
23
23
  - Storage resolve funções auxiliares do `.rules` antes de acusar (portaria com auth dentro não é "sem auth", inclui `let` do rules v2); helper não declarado vira PROBABLE com o nome na mensagem, nunca silêncio
24
+ - Storage tolera CRLF (arquivo Windows) e path `/(unknown)` nunca repete fingerprint (sufixo de linha só no degenerado)
25
+ - Regra com `auth != null && exists(admins/uid)` diz "login + documento via exists()", não "qualquer autenticado" — sem claim/provider, anônimo com login passa no conjunto se obtiver o doc
26
+ - FBA014: `allow create/update` próprio em `admins/` sem claim/papel de admin nem doc-gate é auto-promoção provável (caso cadastro que escreve `role: 'admin'`) — confirme o signup e o Emulator; `users/{uid}` + campo role está fora do escopo estático
24
27
  - Storage avalia disjunção por ramo (`auth || tamanho` acusa o ramo sem auth) e `return false` via helper é deny silencioso, igual ao `if false`
25
28
  - Seeds/fixtures com Admin SDK moram em `scripts/` ou `server/`, nunca em `src/` (senão o scan acusa FBA003 de propósito)
26
29