@justmpm/firebase-audit 0.5.13 → 0.5.15

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,62 @@ 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
+ }
258
+ function branchHasCallerDocGate(branch) {
259
+ const args = [];
260
+ const re = /\b(?:exists|get)\s*\(/gi;
261
+ let m;
262
+ while ((m = re.exec(branch)) !== null) {
263
+ let depth = 0;
264
+ const start = m.index + m[0].length;
265
+ let end = -1;
266
+ for (let i = start - 1; i < branch.length; i++) {
267
+ if (branch[i] === "(") depth += 1;
268
+ else if (branch[i] === ")") {
269
+ depth -= 1;
270
+ if (depth === 0) {
271
+ end = i;
272
+ break;
273
+ }
274
+ }
275
+ }
276
+ if (end !== -1) args.push(branch.slice(start, end));
277
+ }
278
+ return args.some(
279
+ (arg) => /\/admins\//i.test(arg) && /request\.auth\.(uid|token)/i.test(arg)
280
+ );
281
+ }
226
282
  async function runChecks(model, rootDir, opts = {}) {
227
283
  const { ev } = createEvidence();
228
284
  const findings = [];
@@ -421,37 +477,7 @@ async function runChecks(model, rootDir, opts = {}) {
421
477
  const condResolved = resolveHelperConditionDetailed(condRaw, helperDetailed);
422
478
  const condInlined = inlineHelpersInCondition(condRaw, helperDetailed);
423
479
  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
- }
480
+ if (checksAdminPower(cond, condRaw, rule, helperDetailed)) return;
455
481
  const checksAuth = rule.authReferences.length > 0;
456
482
  if (!checksAuth) return;
457
483
  findings.push(
@@ -537,6 +563,42 @@ async function runChecks(model, rootDir, opts = {}) {
537
563
  );
538
564
  }
539
565
  }
566
+ for (const rule of model.rules) {
567
+ const segments = rule.path.split("/").filter((s) => s.length > 0).map((s) => s.toLowerCase());
568
+ const adminIdx = segments.indexOf("admins");
569
+ if (adminIdx === -1 || adminIdx + 1 >= segments.length) continue;
570
+ const ruleOps = new Set(rule.operations.flatMap(expandOp));
571
+ const escalating = ["create", "write", "update"].filter((o) => ruleOps.has(o));
572
+ if (escalating.length === 0) continue;
573
+ const isPublicRule = [...ruleOps].some(
574
+ (op) => publicPaths.has(`${rule.path}::${op.toLowerCase()}`)
575
+ );
576
+ if (isPublicRule) continue;
577
+ const condRaw = rule.condition ?? "";
578
+ const condResolved = resolveHelperConditionDetailed(condRaw, helperDetailed);
579
+ const condInlined = inlineHelpersInCondition(condRaw, helperDetailed);
580
+ const cond = condResolved !== condRaw ? condResolved : condInlined !== condRaw ? condInlined : condRaw;
581
+ if (checksAdminPower(cond, condRaw, rule, helperDetailed)) continue;
582
+ if (cond.split("||").every(branchHasCallerDocGate)) continue;
583
+ const checksAuth = rule.authReferences.length > 0;
584
+ if (!checksAuth) continue;
585
+ findings.push(
586
+ make(
587
+ "FBA014",
588
+ "WARNING",
589
+ "PROBABLE",
590
+ `Rule ${rule.path} permite ${escalating.join("/")} sem exigir poder de admin (sem claim/papel de admin nem doc-gate em /admins/ amarrado ao seu login). 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.`,
591
+ `FBA014:${rule.path}:${normalizeTarget(rule.operations.join(","))}:${conditionKeyForFingerprint(rule.condition)}`,
592
+ {
593
+ file: rule.location.file,
594
+ line: rule.location.start.line,
595
+ resource: rule.path,
596
+ evidence: [ev("self-admin-create", rule.condition ?? "sem condi\xE7\xE3o", "PROBABLE", rule.location.file, rule.location.start.line)],
597
+ fix: "Exigir claim/papel de admin ou doc-gate (exists/get em /admins/ amarrado ao request.auth.uid) no create/update; nunca deixar o cadastro escrever role 'admin' para usu\xE1rio comum. Valide no Emulator (matriz anonymous/user/admin)."
598
+ }
599
+ )
600
+ );
601
+ }
540
602
  const adminHits = await collectAdminImports(rootDir);
541
603
  for (const hit of adminHits) {
542
604
  let origin = "UNKNOWN";
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  scan
3
- } from "./chunk-NOIROFKB.js";
3
+ } from "./chunk-SYGLJ457.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-NOIROFKB.js";
4
+ } from "./chunk-SYGLJ457.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-2TMTMC7C.js");
87
+ const { scan: scanForVerify } = await import("./scan-3XBJMCAO.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,14 @@ 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
- * FBA003/FBA004/FBA010/FBA012 com linha (únicos por ocorrência).
745
+ * FBA003/FBA004/FBA010 com linha (únicos por ocorrência);
746
+ * FBA012/FBA014 por path + target + hash da condição (estáveis, sem linha).
746
747
  */
747
748
 
748
- declare const IMPLEMENTED_CHECKS: readonly ["FBA001", "FBA002", "FBA003", "FBA004", "FBA009", "FBA010", "FBA011", "FBA012", "FBA013"];
749
+ declare const IMPLEMENTED_CHECKS: readonly ["FBA001", "FBA002", "FBA003", "FBA004", "FBA009", "FBA010", "FBA011", "FBA012", "FBA013", "FBA014"];
749
750
  declare function runChecks(model: ProjectModel, rootDir: string, opts?: {
750
751
  adapterFn?: string;
751
752
  }): Promise<Finding[]>;
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  createMcpServer,
3
3
  startMcpServer
4
- } from "./chunk-LK5MKSCZ.js";
4
+ } from "./chunk-ZILSDI3F.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-NOIROFKB.js";
37
+ } from "./chunk-SYGLJ457.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-LK5MKSCZ.js";
5
- import "./chunk-NOIROFKB.js";
4
+ } from "./chunk-ZILSDI3F.js";
5
+ import "./chunk-SYGLJ457.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-LK5MKSCZ.js";
5
- import "./chunk-NOIROFKB.js";
4
+ } from "./chunk-ZILSDI3F.js";
5
+ import "./chunk-SYGLJ457.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-NOIROFKB.js";
3
+ } from "./chunk-SYGLJ457.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.13",
3
+ "version": "0.5.15",
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
@@ -23,6 +23,7 @@ Skill para agentes usarem o `@justmpm/firebase-audit` do jeito certo.
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
24
  - Storage tolera CRLF (arquivo Windows) e path `/(unknown)` nunca repete fingerprint (sufixo de linha só no degenerado)
25
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'`) — doc-gate só vale amarrado ao uid em todo ramo de `||`; confirme o signup e o Emulator; `users/{uid}` + campo role e `admin` singular estão fora do escopo estático
26
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`
27
28
  - Seeds/fixtures com Admin SDK moram em `scripts/` ou `server/`, nunca em `src/` (senão o scan acusa FBA003 de propósito)
28
29