@justmpm/firebase-audit 0.5.16 → 0.5.18

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
@@ -46,7 +46,7 @@ Princípio gravado no README:
46
46
  // context("src/orders/delete.ts") → assinaturas sem implementação
47
47
  ```
48
48
  Proibido chamar via terminal (`spawn npx ai-tool`) ou via MCP dentro do código. Chamada direta = tipada, rápida, testável.
49
- - `@justmpm/supergrep` (v0.6.0, hoje só MCP) → acha candidatos `where()`, `orderBy()`, `requirePermission()` ignorando formatação/quebra de linha. Usar como **dependência** após a Etapa 0:
49
+ - `@justmpm/supergrep` (^0.7.0, dependência direta) → acha candidatos `where()`, `orderBy()`, `requirePermission()` ignorando formatação/quebra de linha:
50
50
  ```ts
51
51
  import { executeFind } from "@justmpm/supergrep";
52
52
  // executeFind('where($FIELD, $OP, $VAL)') → candidatos
@@ -97,7 +97,7 @@ Exemplo de `Finding` (agente copia este formato):
97
97
  file: "src/orders/delete.ts",
98
98
  line: 42,
99
99
  message: 'Permission "orders.delete" usada mas não declarada no contrato.',
100
- fingerprint: "FBA004:src/orders/delete.ts:orders.delete",
100
+ fingerprint: "FBA004:src/orders/delete.ts:42:orders.delete",
101
101
  evidence: [{ kind: "permission-call", summary: 'rbac.can("orders.delete")', confidence: "CONFIRMED" }],
102
102
  fix: 'Declarar "orders.delete" em firebase-audit.yaml ou corrigir o nome da chamada.',
103
103
  }
@@ -176,13 +176,15 @@ 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
+ | FBA014 | Create/update/write próprio em `admins/` sem gate de admin (auto-promoção) | WARNING | PROBABLE |
180
180
  | AUTH_ANON_ALLOWED | `auth != null` sozinho (anônimo passa) | INFO | PROBABLE |
181
- | STORAGE_* | Escrita aberta / curinga total / leitura aberta / anônimo (por bloco do match) | ERROR/WARNING/INFO | CONFIRMED/PROBABLE |
181
+ | STORAGE_* | Escrita aberta (ERROR) / curinga total na raiz (WARNING) / curinga em pasta dedicada ou leitura aberta = vitrine a confirmar (INFO, prefira `get` sem `list`) / anônimo (INFO, por bloco do match) | ERROR/WARNING/INFO | CONFIRMED/PROBABLE |
182
182
  | APPCHECK_* | Carimbo observado / verificação manual (onRequest por rota) | INFO | CONFIRMED/PROBABLE |
183
183
 
184
184
  Detalhes que quebram se deixar pra depois: `write` = create+update+delete (guardar origem), `OR` nas Rules (`isAdmin() || public==true`), `resource` vs `request.resource`, `collectionGroup` com escopo próprio, `databaseId` nomeado desde o dia 1.
185
185
 
186
+ `summary.implementedChecks` lista os checks FBA cobrados (10); `summary.checked` lista áreas olhadas (arquivos) — não confundir um com o outro.
187
+
186
188
  ### Decisões (porquês — não reverter sem discutir)
187
189
 
188
190
  1. **Static-first, Verifier como interface.** V1 entrega valor sem Emulator. Verifier entra depois sem reescrever o núcleo.
@@ -208,9 +210,11 @@ Detalhes que quebram se deixar pra depois: `write` = create+update+delete (guard
208
210
 
209
211
  ## 3. Plano de ação
210
212
 
211
- ### Etapa 0 — Adaptar o supergrep (pré-requisito)
213
+ ### Etapa 0 — Adaptar o supergrep (pré-requisito) — ✅ concluída
214
+
215
+ O supergrep já exporta `executeFind` como dependência direta (`^0.7.0`); o bloco abaixo é histórico, mantido como contexto:
212
216
 
213
- Hoje o supergrep só exporta `startMcpServer`. O `find` core está preso em `src/tools/find.ts`.
217
+ ~~Hoje o supergrep só exporta `startMcpServer`. O `find` core está preso em `src/tools/find.ts`.~~
214
218
 
215
219
  1. Exportar `executeFind(pattern, options)` e `getTree()` em `src/index.ts` do supergrep.
216
220
  2. Publicar `@justmpm/supergrep` minor nova.
@@ -237,7 +241,7 @@ Hoje o supergrep só exporta `startMcpServer`. O `find` core está preso em `src
237
241
 
238
242
  ### Etapa 4 — 10 checks + CLI
239
243
 
240
- - Implementar FBA001–FBA010 com `fingerprint` estável pra CI (`FBA001:firestore.rules:orders:write`).
244
+ - Implementar FBA001–FBA004, FBA009–FBA014 com `fingerprint` estável pra CI (v2: path + target normalizado + hash da condição, ex: `FBA001:firestore.rules:orders:write:<hash>`).
241
245
  - Regressão com fixtures.
242
246
 
243
247
  ### Etapa 5 — V2 contrato + V3 emulator + V4 drift
@@ -226,6 +226,16 @@ function make(rule, severity, confidence, message, fingerprint, opts = {}) {
226
226
  function checksAdminPower(cond, condRaw, rule, helperDetailed) {
227
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) || /!\s*\([^()]*==\s*['"]admin['"]/i.test(cond);
228
228
  const normClaim = (c) => c.toLowerCase().replace(/[_-]/g, "");
229
+ for (const target of [condRaw, cond]) {
230
+ const negCall = /!\s*\(?\s*((?:[A-Za-z_][A-Za-z0-9_]*\.)*[A-Za-z_][A-Za-z0-9_]*)\s*\(/.exec(target) ?? /((?:[A-Za-z_][A-Za-z0-9_]*\.)*[A-Za-z_][A-Za-z0-9_]*)\s*\([^()]*\)\s*(==\s*false|!=\s*true|!==\s*true)/.exec(target);
231
+ if (!negCall) continue;
232
+ const dotted = negCall[1];
233
+ const simple = dotted.split(".").pop();
234
+ const fn = helperDetailed.get(simple) ?? helperDetailed.get(simple.toLowerCase());
235
+ const body = fn ? fn.body : "";
236
+ const bodyHasAdmin = /token\.admin\b/i.test(body) || /token\.get\(\s*['"]admin['"]/i.test(body) || /==\s*['"]admin['"]/i.test(body) || /request\.auth\.token/i.test(body);
237
+ if (bodyHasAdmin || /^request\.auth\.token\b/i.test(dotted)) return false;
238
+ }
229
239
  const isAdminCall = /\bisadmin\s*\(([^)]*)\)/i.exec(condRaw) ?? /\bisadmin\s*\(([^)]*)\)/i.exec(cond);
230
240
  let isAdminBodyHasAdmin = false;
231
241
  if (isAdminCall) {
@@ -260,7 +270,7 @@ function branchHasCallerDocGate(branch) {
260
270
  const re = /\b(?:exists|get)\s*\(/gi;
261
271
  let m;
262
272
  while ((m = re.exec(branch)) !== null) {
263
- if (/!\s*(?:firestore\.)?$/.test(branch.slice(0, m.index))) continue;
273
+ if (/![\s(]*(?:firestore\.)?$/.test(branch.slice(0, m.index))) continue;
264
274
  let depth = 0;
265
275
  const start = m.index + m[0].length;
266
276
  let end = -1;
@@ -567,7 +577,7 @@ async function runChecks(model, rootDir, opts = {}) {
567
577
  for (const rule of model.rules) {
568
578
  const segments = rule.path.split("/").filter((s) => s.length > 0).map((s) => s.toLowerCase());
569
579
  const adminIdx = segments.indexOf("admins");
570
- if (adminIdx === -1 || adminIdx + 1 >= segments.length) continue;
580
+ if (adminIdx === -1 || adminIdx + 2 !== segments.length) continue;
571
581
  const ruleOps = new Set(rule.operations.flatMap(expandOp));
572
582
  const escalating = ["create", "write", "update"].filter((o) => ruleOps.has(o));
573
583
  if (escalating.length === 0) continue;
@@ -1338,16 +1348,18 @@ async function scan(rootDir, opts = {}) {
1338
1348
  }
1339
1349
  if (ops.has("read") || ops.has("get") || ops.has("list")) {
1340
1350
  if (wildcard) {
1351
+ const scope = storageScopeOf(m.matchPath);
1352
+ const enumerates = ops.has("read") || ops.has("list");
1341
1353
  findings.push({
1342
1354
  rule: "STORAGE_ALL_PATHS_OPEN",
1343
- severity: "WARNING",
1355
+ severity: scope.isRoot ? "WARNING" : "INFO",
1344
1356
  confidence: "PROBABLE",
1345
- 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}.`,
1357
+ message: scope.isRoot ? `Storage com leitura aberta em curinga total na raiz (${where}). Vale para o bucket inteiro \u2014 pelo OR, anula as pastas privadas dos outros blocos. Restrinja a pastas expl\xEDcitas (ex: /logos/{logoId}) com get: if true e write restrito.` : `Storage com leitura aberta em curinga da pasta ${scope.folder} (${where}). Pode ser vitrine proposital \u2014 mas o =** libera tudo abaixo da pasta${enumerates ? " e allow read/list permite enumerar" : ""}; para vitrine segura prefira allow get (sem list). Confirme que o write segue restrito.`,
1346
1358
  fingerprint: `STORAGE_ALL_PATHS_OPEN:${m.fpPath}:${normalizeTarget(m.target)}:${key}`,
1347
1359
  file: relStorage,
1348
1360
  line: m.line,
1349
1361
  evidence: [{ id: evId("all"), kind: "storage-public", summary: `${m.matchPath} allow ${m.target}`, confidence: "PROBABLE", location: { file: relStorage, start: { line: m.line, column: 0 } } }],
1350
- fix: "Troque o curinga total por pastas expl\xEDcitas (ex: /logos/{logoId}) com get: if true e write restrito."
1362
+ fix: scope.isRoot ? "Troque o curinga total por pastas expl\xEDcitas (ex: /logos/{logoId}) com get: if true e write restrito." : "Para vitrine, troque allow read por allow get (sem list) na pasta p\xFAblica; valide no Emulator."
1351
1363
  });
1352
1364
  } else {
1353
1365
  const enumerates = ops.has("read") || ops.has("list");
@@ -1355,7 +1367,7 @@ async function scan(rootDir, opts = {}) {
1355
1367
  rule: "STORAGE_PUBLIC_READ",
1356
1368
  severity: "INFO",
1357
1369
  confidence: "PROBABLE",
1358
- 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.`,
1370
+ 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 \u2014 confirme que o write segue restrito.`,
1359
1371
  fingerprint: `STORAGE_PUBLIC_READ:${m.fpPath}:${normalizeTarget(m.target)}:${key}`,
1360
1372
  file: relStorage,
1361
1373
  line: m.line,
@@ -1505,6 +1517,7 @@ async function scan(rootDir, opts = {}) {
1505
1517
  infos,
1506
1518
  passedChecks,
1507
1519
  totalChecks,
1520
+ implementedChecks: [...IMPLEMENTED_CHECKS],
1508
1521
  infra,
1509
1522
  checked,
1510
1523
  skipped,
@@ -1616,6 +1629,16 @@ function findNoAuthBranch(effective) {
1616
1629
  }
1617
1630
  return void 0;
1618
1631
  }
1632
+ function storageScopeOf(matchPath) {
1633
+ const segs = matchPath.split("/").filter((s) => s.length > 0);
1634
+ let rest = segs;
1635
+ if (rest.length >= 3 && rest[0] === "b" && rest[2] === "o") rest = rest.slice(3);
1636
+ const wildIdx = rest.findIndex((s) => /\{\s*\w+\s*=\s*\*\*\s*\}/.test(s));
1637
+ const prefix = wildIdx === -1 ? rest : rest.slice(0, wildIdx);
1638
+ const meaningful = prefix.filter((s) => !/^\{.*\}$/.test(s) && !/^\(.*\)$/.test(s));
1639
+ if (meaningful.length === 0) return { isRoot: true, folder: "/" };
1640
+ return { isRoot: false, folder: `/${meaningful.join("/")}/` };
1641
+ }
1619
1642
  function parseStorageAllows(clean) {
1620
1643
  const text = clean.replace(/\r\n?/g, "\n");
1621
1644
  const out = [];
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  scan
3
- } from "./chunk-DXYPKVMR.js";
3
+ } from "./chunk-4EL5FUQG.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-DXYPKVMR.js";
4
+ } from "./chunk-4EL5FUQG.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-6HV5HFKH.js");
87
+ const { scan: scanForVerify } = await import("./scan-FMH76BG3.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
@@ -773,6 +773,9 @@ interface ScanSummary {
773
773
  passedChecks: number;
774
774
  /** Total de checks FBA implementados. */
775
775
  totalChecks: number;
776
+ /** Quais são os checks FBA (ordem de IMPLEMENTED_CHECKS): `checked[]`
777
+ * lista ÁREAS olhadas (arquivos), este lista os CHECKS cobrados. */
778
+ implementedChecks: string[];
776
779
  /** Infra nomeada (fora dos checks FBA): cada item diz qual e por quê. */
777
780
  infra: {
778
781
  rule: string;
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  createMcpServer,
3
3
  startMcpServer
4
- } from "./chunk-PVB6GT4F.js";
4
+ } from "./chunk-JOM45NO4.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-DXYPKVMR.js";
37
+ } from "./chunk-4EL5FUQG.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-PVB6GT4F.js";
5
- import "./chunk-DXYPKVMR.js";
4
+ } from "./chunk-JOM45NO4.js";
5
+ import "./chunk-4EL5FUQG.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-PVB6GT4F.js";
5
- import "./chunk-DXYPKVMR.js";
4
+ } from "./chunk-JOM45NO4.js";
5
+ import "./chunk-4EL5FUQG.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-DXYPKVMR.js";
3
+ } from "./chunk-4EL5FUQG.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.16",
3
+ "version": "0.5.18",
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
@@ -19,11 +19,12 @@ Skill para agentes usarem o `@justmpm/firebase-audit` do jeito certo.
19
19
  - Nunca dizer "unused" — apenas NOT_OBSERVED
20
20
  - Claims: 1000 bytes max, sem chaves OIDC reservadas, só controle de acesso
21
21
  - AppCheck é carimbo do app oficial — não prova usuário nem tenant
22
- - Logo pública (`get: if true` em `/logos/`) pode ser vitrine proposital — cofre é `write: if true` ou curinga total aberto
22
+ - Logo pública (`get: if true` em `/logos/`) pode ser vitrine proposital — cofre é `write: if true` ou curinga total na raiz; curinga em pasta dedicada com leitura aberta é vitrine INFO (prefira `get` sem `list`), não cofre
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
+ - FBA014: `allow create/update/write` 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, `admin` singular e `if false` (deny) estão fora do escopo estático
27
+ - `summary.implementedChecks` lista os checks FBA cobrados; `summary.checked` lista áreas olhadas (arquivos) — 10 checks, não 10 arquivos
27
28
  - Storage avalia disjunção por ramo (`auth || tamanho` acusa o ramo sem auth) e `return false` via helper é deny silencioso, igual ao `if false`
28
29
  - Seeds/fixtures com Admin SDK moram em `scripts/` ou `server/`, nunca em `src/` (senão o scan acusa FBA003 de propósito)
29
30