@justmpm/firebase-audit 0.5.4 → 0.5.6

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,20 +163,22 @@ 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
- ### 10 checks do V1
166
+ ### 9 checks implementados (+ avisos de infra e Storage/AppCheck)
167
167
 
168
168
  | # | Check | Sev | Conf |
169
169
  |---|-------|-----|------|
170
- | FBA001 | `allow write/create/update/delete: if true` | ERROR | CONFIRMED |
171
- | FBA002 | `allow read: if true` (pode ser público proposital) | WARNING | CONFIRMED |
172
- | FBA003 | Admin SDK dentro de boundary Client | ERROR | PROBABLE |
170
+ | FBA001 | `allow write/create/update/delete` público | ERROR | CONFIRMED/PROBABLE |
171
+ | FBA002 | `allow read/get/list` público (pode ser proposital) | WARNING | CONFIRMED/PROBABLE |
172
+ | FBA003 | Admin SDK em arquivo de cliente (+ origem indefinida como INFO) | ERROR/INFO | PROBABLE/UNKNOWN |
173
173
  | FBA004 | Permission usada e não declarada | ERROR | CONFIRMED |
174
- | FBA005 | Claim nas Rules não declarada no contrato (pega typo `admn`) | WARNING | PROBABLE |
175
- | FBA006 | Query provavelmente exige composto não encontrado | WARNING | PROBABLE |
176
- | FBA007 | Índice local sem query observada | INFO | NOT_OBSERVED |
177
- | FBA008 | Rule sem operação observada | INFO | NOT_OBSERVED |
178
- | FBA009 | Adapter não resolvido | WARNING | UNKNOWN |
179
- | FBA010 | Query shape dinâmica | INFO | UNKNOWN |
174
+ | FBA009 | Adapter não observado (com hint de portaria tenant) | WARNING/INFO | UNKNOWN/NOT_OBSERVED |
175
+ | FBA010 | Permissão dinâmica (não resolvida estaticamente) | INFO | UNKNOWN |
176
+ | FBA011 | Role referencia permission não declarada | ERROR | CONFIRMED |
177
+ | FBA012 | Rule ampla vs contrato admin-only | WARNING | PROBABLE |
178
+ | FBA013 | Role sem permissions | WARNING | CONFIRMED |
179
+ | AUTH_ANON_ALLOWED | `auth != null` sozinho (anônimo passa) | INFO | PROBABLE |
180
+ | STORAGE_* | Escrita aberta / curinga total / leitura aberta / anônimo (por bloco do match) | ERROR/WARNING/INFO | CONFIRMED/PROBABLE |
181
+ | APPCHECK_* | Carimbo observado / verificação manual (onRequest por rota) | INFO | CONFIRMED/PROBABLE |
180
182
 
181
183
  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.
182
184
 
@@ -193,12 +195,13 @@ Detalhes que quebram se deixar pra depois: `write` = create+update+delete (guard
193
195
 
194
196
  ## 2. Escopo
195
197
 
196
- **V1 — scan estático:** Discovery + 10 checks + `firebase-audit scan [--json|--strict]`. Zero-config, valor imediato.
197
- **V2 — check com contrato:** YAML + `Code ↔ Contract ↔ Rules` (mismatch muito amplo, role sem permissão, etc.).
198
- **V3 — verify:** gera cenários → roda no Emulator → tabela ALLOW/DENY + coverage.
199
- **V4 — drift:** local vs implantado (`LOCAL_ONLY / REMOTE_ONLY / MATCHED`).
198
+ **Scan:** Discovery + checks + `firebase-audit scan [--json|--strict]`. Zero-config, valor imediato.
199
+ **Check com contrato:** YAML + `Code ↔ Contract ↔ Rules` (mismatch muito amplo, role sem permissão, etc.).
200
+ **Verify:** gera matriz papel×operação×path → roda no Emulator → cobertura `ruleCoverage` (não substitui scan).
201
+ **Drift:** local vs implantado (`LOCAL_ONLY / REMOTE_ONLY / MATCHED`).
200
202
 
201
- Fora do V1: Storage, Realtime DB, Hosting, App Check, Functions complexas, IAM, Data Connect.
203
+ **Escopo atual:** Firestore Rules + Storage Rules (vitrine vs cofre, por bloco) + AppCheck (carimbo, não autorização) + contrato + índices.
204
+ **Fora de escopo (declarado em `summary.skipped`):** Realtime DB, Hosting, Functions complexas, IAM, Data Connect, isolamento por tenant, bancos/buckets extras além do default.
202
205
 
203
206
  ---
204
207
 
@@ -51,18 +51,39 @@ function discover(rootDir) {
51
51
  if (firebaseJson) {
52
52
  try {
53
53
  const raw = JSON.parse(readFileSync(firebaseJson, "utf-8"));
54
- if (typeof raw.firestore?.rules === "string") {
55
- const p = join(rootDir, raw.firestore.rules);
54
+ const first = (v) => Array.isArray(v) ? v[0] : v;
55
+ const fs = first(raw.firestore);
56
+ const st = first(raw.storage);
57
+ const fsAny = fs;
58
+ if (fsAny && typeof fsAny.database === "string" && fsAny.database.length > 0 && fsAny.database !== "(default)") {
59
+ databaseId = fsAny.database;
60
+ }
61
+ if (typeof fs?.rules === "string") {
62
+ const p = join(rootDir, fs.rules);
56
63
  if (existsSync(p)) rulesFile = p;
57
64
  }
58
- if (typeof raw.firestore?.indexes === "string") {
59
- const p = join(rootDir, raw.firestore.indexes);
65
+ if (typeof fs?.indexes === "string") {
66
+ const p = join(rootDir, fs.indexes);
60
67
  if (existsSync(p)) indexesFile = p;
61
68
  }
62
- if (typeof raw.storage?.rules === "string") {
63
- const p = join(rootDir, raw.storage.rules);
69
+ if (typeof st?.rules === "string") {
70
+ const p = join(rootDir, st.rules);
64
71
  if (existsSync(p)) storageFile = p;
65
72
  }
73
+ const fns = raw.functions;
74
+ if (typeof fns === "string" && fns.length > 0) {
75
+ const p = join(rootDir, fns);
76
+ try {
77
+ if (statSync(p).isDirectory()) functionsDir = p;
78
+ } catch {
79
+ }
80
+ } else if (fns && typeof fns === "object" && typeof fns.source === "string" && fns.source.length > 0) {
81
+ const p = join(rootDir, fns.source);
82
+ try {
83
+ if (statSync(p).isDirectory()) functionsDir = p;
84
+ } catch {
85
+ }
86
+ }
66
87
  } catch {
67
88
  }
68
89
  }
@@ -105,7 +126,7 @@ function classifyOrigin(file, content) {
105
126
  if (content.includes("firebase-admin") || content.includes("firebase-functions")) {
106
127
  return "CLIENT";
107
128
  }
108
- if (rel.includes("src/") || rel.includes("app/") || rel.includes("components/")) {
129
+ if (hasSeg("src", "app", "components")) {
109
130
  return "CLIENT";
110
131
  }
111
132
  return "UNKNOWN";
@@ -139,7 +160,7 @@ async function enrichWithGraph(rootDir, d) {
139
160
  const ai = await import("@justmpm/ai-tool");
140
161
  const res = await ai.map({ cwd: rootDir, format: "json" });
141
162
  const files = Array.isArray(res.files) ? res.files : [];
142
- 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);
163
+ 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|build|\.next|coverage|lib)\//.test(p)).slice(0, 500);
143
164
  const { readFileSync: readSync, existsSync: existsSyncFn, statSync: statSync2 } = await import("fs");
144
165
  const { join: joinP } = await import("path");
145
166
  for (const rel of code) {
@@ -164,6 +185,10 @@ async function enrichWithGraph(rootDir, d) {
164
185
  import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
165
186
  import { join as join2 } from "path";
166
187
  import { executeFind } from "@justmpm/supergrep";
188
+ function isBuildOutput(file) {
189
+ const n = file.replace(/\\/g, "/");
190
+ return /(^|\/)(node_modules|dist|build|\.next|coverage|out)\//.test(n);
191
+ }
167
192
  var IMPLEMENTED_CHECKS = ["FBA001", "FBA002", "FBA003", "FBA004", "FBA009", "FBA010", "FBA011", "FBA012", "FBA013"];
168
193
  function createEvidence() {
169
194
  let counter = 0;
@@ -546,6 +571,7 @@ async function collectPermissionCalls(rootDir, adapterFn) {
546
571
  }
547
572
  const seen = /* @__PURE__ */ new Set();
548
573
  const pushLiteral = (file, line, permission) => {
574
+ if (isBuildOutput(file)) return;
549
575
  const k = `${file}:${line}:${permission}`;
550
576
  if (seen.has(k)) return;
551
577
  seen.add(k);
@@ -558,10 +584,12 @@ async function collectPermissionCalls(rootDir, adapterFn) {
558
584
  const raw = (m.metaVariables["PERM"] ?? "").trim();
559
585
  const line = m.line + 1;
560
586
  if (raw.includes("${")) {
561
- const k = `${m.file}:${line}:${raw}`;
562
- if (!seen.has(k)) {
563
- seen.add(k);
564
- dynamic.push({ file: m.file, line, text: m.text });
587
+ if (!isBuildOutput(m.file)) {
588
+ const k = `${m.file}:${line}:${raw}`;
589
+ if (!seen.has(k)) {
590
+ seen.add(k);
591
+ dynamic.push({ file: m.file, line, text: m.text });
592
+ }
565
593
  }
566
594
  continue;
567
595
  }
@@ -570,18 +598,22 @@ async function collectPermissionCalls(rootDir, adapterFn) {
570
598
  const perm = raw.replace(/^['"`]|['"`]$/g, "").trim();
571
599
  if (/^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)*$/.test(perm)) pushLiteral(m.file, line, perm);
572
600
  else {
573
- const k = `${m.file}:${line}:${raw}:invalid`;
601
+ if (!isBuildOutput(m.file)) {
602
+ const k = `${m.file}:${line}:${raw}:invalid`;
603
+ if (!seen.has(k)) {
604
+ seen.add(k);
605
+ dynamic.push({ file: m.file, line, text: m.text });
606
+ }
607
+ }
608
+ }
609
+ } else if (raw.length > 0) {
610
+ if (!isBuildOutput(m.file)) {
611
+ const k = `${m.file}:${line}:${raw}`;
574
612
  if (!seen.has(k)) {
575
613
  seen.add(k);
576
614
  dynamic.push({ file: m.file, line, text: m.text });
577
615
  }
578
616
  }
579
- } else if (raw.length > 0) {
580
- const k = `${m.file}:${line}:${raw}`;
581
- if (!seen.has(k)) {
582
- seen.add(k);
583
- dynamic.push({ file: m.file, line, text: m.text });
584
- }
585
617
  }
586
618
  }
587
619
  } catch {
@@ -598,6 +630,10 @@ async function collectAdminImports(rootDir) {
598
630
  "import $DEFAULT from '$MODULE'",
599
631
  'import * as $NS from "$MODULE"',
600
632
  "import * as $NS from '$MODULE'",
633
+ 'export { $$$ITEMS } from "$MODULE"',
634
+ "export { $$$ITEMS } from '$MODULE'",
635
+ 'export * from "$MODULE"',
636
+ "export * from '$MODULE'",
601
637
  'import "$MODULE"',
602
638
  "import '$MODULE'",
603
639
  'const $X = require("$MODULE")',
@@ -614,6 +650,7 @@ async function collectAdminImports(rootDir) {
614
650
  for (const m of result.matches) {
615
651
  const mod = m.metaVariables["MODULE"] ?? "";
616
652
  if (mod.includes("firebase-admin") || mod.includes("firebase-functions")) {
653
+ if (isBuildOutput(m.file)) continue;
617
654
  const k = `${m.file}:${m.line}:${m.text}`;
618
655
  if (seen.has(k)) continue;
619
656
  seen.add(k);
@@ -796,7 +833,7 @@ var RoleNameSchema = z.string().regex(/^[a-z][a-z0-9_-]*$/);
796
833
  var AuditYamlSchema = z.strictObject({
797
834
  $schema: z.string().optional(),
798
835
  authorization: z.strictObject({
799
- adapter: z.strictObject({ function: z.string().min(1) }).optional(),
836
+ adapter: z.strictObject({ function: z.string().min(1) }).nullish(),
800
837
  roles: z.record(RoleNameSchema, z.strictObject({ permissions: z.array(PermissionNameSchema) })),
801
838
  permissions: z.record(
802
839
  PermissionNameSchema,
@@ -804,9 +841,10 @@ var AuditYamlSchema = z.strictObject({
804
841
  ),
805
842
  claims: z.strictObject({
806
843
  enabled: z.boolean(),
807
- roleKey: z.string().min(1),
844
+ // Reservado para o futuro (nenhum check consome ainda): opcional.
845
+ roleKey: z.string().min(1).optional(),
808
846
  samples: z.record(RoleNameSchema, z.record(z.string().min(1), z.json())).optional()
809
- }).optional()
847
+ }).nullish()
810
848
  })
811
849
  });
812
850
  var RESERVED_CLAIM_KEYS = [
@@ -860,11 +898,40 @@ async function scan(rootDir, opts = {}) {
860
898
  try {
861
899
  if (d.firebaseJson && existsSync3(d.firebaseJson)) {
862
900
  const raw = JSON.parse(readFileSync3(d.firebaseJson, "utf-8"));
863
- for (const [kind, rel, label] of [
864
- ["rules", raw.firestore?.rules, "firestore.rules"],
865
- ["indexes", raw.firestore?.indexes, "firestore.indexes"],
866
- ["storage-rules", raw.storage?.rules, "storage.rules"]
867
- ]) {
901
+ const asList = (v) => v === void 0 ? [] : Array.isArray(v) ? v : [v];
902
+ const entries = [];
903
+ for (const f of asList(raw.firestore)) {
904
+ entries.push({ kind: "rules", rel: f.rules, label: "firestore.rules" });
905
+ entries.push({ kind: "indexes", rel: f.indexes, label: "firestore.indexes" });
906
+ }
907
+ for (const s of asList(raw.storage)) {
908
+ entries.push({ kind: "storage-rules", rel: s.rules, label: "storage.rules" });
909
+ }
910
+ if (Array.isArray(raw.firestore) && raw.firestore.length > 1) {
911
+ findings_pre.push({
912
+ rule: "FIREBASE_JSON_ARRAY",
913
+ severity: "INFO",
914
+ confidence: "CONFIRMED",
915
+ message: `firebase.json com ${raw.firestore.length} databases \u2014 este scan audita s\xF3 o default. Bancos nomeados (databaseId) fora do escopo.`,
916
+ fingerprint: "FIREBASE_JSON_ARRAY:firestore",
917
+ evidence: [
918
+ { id: "ev-config-array-fs", kind: "config-parse", summary: `${raw.firestore.length} databases`, confidence: "CONFIRMED" }
919
+ ]
920
+ });
921
+ }
922
+ if (Array.isArray(raw.storage) && raw.storage.length > 1) {
923
+ findings_pre.push({
924
+ rule: "FIREBASE_JSON_ARRAY",
925
+ severity: "INFO",
926
+ confidence: "CONFIRMED",
927
+ message: `firebase.json com ${raw.storage.length} buckets \u2014 este scan audita s\xF3 o default. Buckets extras fora do escopo.`,
928
+ fingerprint: "FIREBASE_JSON_ARRAY:storage",
929
+ evidence: [
930
+ { id: "ev-config-array-st", kind: "config-parse", summary: `${raw.storage.length} buckets`, confidence: "CONFIRMED" }
931
+ ]
932
+ });
933
+ }
934
+ for (const [kind, rel, label] of entries.map((e) => [e.kind, e.rel, e.label])) {
868
935
  if (typeof rel === "string" && rel.length > 0) {
869
936
  if (!existsSync3(join3(rootDir, rel))) {
870
937
  findings_pre.push({
@@ -1122,6 +1189,9 @@ async function scan(rootDir, opts = {}) {
1122
1189
  return `ev-storage-${prefix}-${evCounter}`;
1123
1190
  };
1124
1191
  for (const m of parseStorageAllows(clean)) {
1192
+ if (m.condition !== void 0 && stripOuterParens(m.condition).toLowerCase().replace(/\s+/g, "") === "false") {
1193
+ continue;
1194
+ }
1125
1195
  const ops = expandStorageOps(m.target);
1126
1196
  const key = conditionKeyForFingerprint(m.condition);
1127
1197
  const normCond = (m.condition ?? "").toLowerCase().replace(/\s+/g, "");
@@ -1242,20 +1312,23 @@ async function scan(rootDir, opts = {}) {
1242
1312
  rule: "APPCHECK_ENFORCED",
1243
1313
  severity: "INFO",
1244
1314
  confidence: "CONFIRMED",
1245
- message: `enforceAppCheck observado (${appcheck.occurrences} ocorr\xEAncia(s) em ${appcheck.files} arquivo(s)). Carimbo do app oficial \u2014 n\xE3o prova quem \xE9 o usu\xE1rio nem o tenant. Autoriza\xE7\xE3o segue nas Rules/guardas.`,
1315
+ message: `enforceAppCheck observado (${appcheck.occurrences} ocorr\xEAncia(s) em ${appcheck.files} arquivo(s), fora de onRequest). Carimbo do app oficial \u2014 n\xE3o prova quem \xE9 o usu\xE1rio nem o tenant. Autoriza\xE7\xE3o segue nas Rules/guardas.`,
1246
1316
  fingerprint: "APPCHECK_ENFORCED",
1247
1317
  evidence: [{ id: "ev-appcheck", kind: "appcheck", summary: `${appcheck.occurrences} ocorr\xEAncias`, confidence: "CONFIRMED" }]
1248
1318
  });
1249
- } else if (appcheck.manual === 0) {
1319
+ } else if (appcheck.manual === 0 && appcheck.ignoredOnRequest === 0) {
1250
1320
  skipped.push("functions sem enforceAppCheck literal nem verifyToken observado \u2014 AppCheck sem cobertura afirmativa (config via vari\xE1vel conta como n\xE3o observada; onRequest exige verifica\xE7\xE3o manual por rota)");
1251
1321
  }
1322
+ if (appcheck.ignoredOnRequest > 0) {
1323
+ skipped.push(`enforceAppCheck em onRequest ignorado pelo runtime (${appcheck.ignoredOnRequest} ocorr\xEAncia(s)) \u2014 onRequest exige verifyToken por rota`);
1324
+ }
1252
1325
  if (appcheck.limitHit) {
1253
1326
  skipped.push("functions: teto de varredura atingido \u2014 AppCheck parcial neste scan");
1254
1327
  }
1255
1328
  } else {
1256
1329
  skipped.push("functions n\xE3o observadas \u2014 AppCheck fora deste scan");
1257
1330
  }
1258
- skipped.push("isolamento por tenant (ownerId/tenantId) n\xE3o verificado \u2014 fora dos checks implementados, nunca verde silencioso");
1331
+ skipped.push("isolamento por tenant (ownerId/tenantId) e mismatch de papel n\xE3o-admin fora dos checks \u2014 coberto s\xF3 via verify/Emulator, nunca verde silencioso");
1259
1332
  const modelCheck = ProjectModelSchema.safeParse(model);
1260
1333
  if (!modelCheck.success) {
1261
1334
  findings.push({
@@ -1320,6 +1393,7 @@ async function countAppCheckEnforced(rootDir) {
1320
1393
  let occurrences = 0;
1321
1394
  const files = /* @__PURE__ */ new Set();
1322
1395
  let manual = 0;
1396
+ let ignoredOnRequest = 0;
1323
1397
  let limitHit = false;
1324
1398
  const stack = [...roots];
1325
1399
  let guard = 0;
@@ -1341,18 +1415,22 @@ async function countAppCheckEnforced(rootDir) {
1341
1415
  const name = e.name;
1342
1416
  const abs = joinP(cur, name);
1343
1417
  let isDir = false;
1418
+ let size = 0;
1344
1419
  try {
1345
- isDir = stat(abs).isDirectory();
1420
+ const st = stat(abs);
1421
+ isDir = st.isDirectory();
1422
+ size = st.size;
1346
1423
  } catch {
1347
1424
  continue;
1348
1425
  }
1349
1426
  if (isDir) {
1350
- if (name === "node_modules" || name === "dist" || name === "lib") continue;
1427
+ if (name === "node_modules" || name === "dist" || name === "lib" || name === "build" || name === ".next" || name === "coverage" || name === ".git" || name === "out") continue;
1351
1428
  stack.push(abs);
1352
1429
  continue;
1353
1430
  }
1354
1431
  if (!/\.(ts|js|mjs|cjs|py)$/.test(name)) continue;
1355
1432
  if (isTestFile(abs)) continue;
1433
+ if (size > 100 * 1024) continue;
1356
1434
  try {
1357
1435
  let text = strip(readSync(abs, "utf-8"));
1358
1436
  if (name.endsWith(".py")) {
@@ -1361,10 +1439,16 @@ async function countAppCheckEnforced(rootDir) {
1361
1439
  return idx === -1 ? l : l.slice(0, idx);
1362
1440
  }).join("\n");
1363
1441
  }
1364
- const hits = text.match(/enforceAppCheck\s*:\s*true|enforce_app_check\s*=\s*True/g);
1365
- if (hits) {
1366
- occurrences += hits.length;
1367
- files.add(abs);
1442
+ for (const em of text.matchAll(/enforceAppCheck\s*:\s*true|enforce_app_check\s*=\s*True/g)) {
1443
+ const before = text.slice(Math.max(0, (em.index ?? 0) - 600), em.index ?? 0);
1444
+ const triggers = [...before.matchAll(/on(Request|Call|request|call)\s*[\(.]/g)].map((t) => t[1].toLowerCase());
1445
+ const last = triggers[triggers.length - 1];
1446
+ if (last === "request") {
1447
+ ignoredOnRequest += 1;
1448
+ } else {
1449
+ occurrences += 1;
1450
+ files.add(abs);
1451
+ }
1368
1452
  }
1369
1453
  const manualHits = text.match(/verifyToken\s*\(|verify_token\s*\(|consumeAppCheckToken|getLimitedUseToken|getAppCheck\s*\(/g);
1370
1454
  if (manualHits) manual += manualHits.length;
@@ -1372,9 +1456,9 @@ async function countAppCheckEnforced(rootDir) {
1372
1456
  }
1373
1457
  }
1374
1458
  }
1375
- return { occurrences, files: files.size, manual, limitHit };
1459
+ return { occurrences, files: files.size, manual, ignoredOnRequest, limitHit };
1376
1460
  } catch {
1377
- return { occurrences: 0, files: 0, manual: 0, limitHit: false };
1461
+ return { occurrences: 0, files: 0, manual: 0, ignoredOnRequest: 0, limitHit: false };
1378
1462
  }
1379
1463
  }
1380
1464
  function isOpenStorageCondition(condition) {
@@ -102,8 +102,9 @@ function verify(model, opts = {}) {
102
102
  }
103
103
  }
104
104
  }
105
+ const uncoveredNote = "uncovered lista os paths do modelo \u2014 atribui\xE7\xE3o contador\u2192path indispon\xEDvel (s\xF3 coverage 100% esvazia)";
105
106
  if (coveragePct === 100) {
106
- return { cases, uncovered: [], findings, coveragePct };
107
+ return { cases, uncovered: [], findings, coveragePct, uncoveredNote };
107
108
  }
108
109
  const seenPaths = /* @__PURE__ */ new Set();
109
110
  for (const rule of model.rules) {
@@ -112,7 +113,7 @@ function verify(model, opts = {}) {
112
113
  seenPaths.add(rule.path);
113
114
  uncovered.push(rule.path);
114
115
  }
115
- return { cases, uncovered, findings, coveragePct };
116
+ return { cases, uncovered, findings, coveragePct, uncoveredNote };
116
117
  }
117
118
 
118
119
  export {
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  scan
3
- } from "./chunk-AKPQ4OAJ.js";
3
+ } from "./chunk-BB6XXEIK.js";
4
4
  import {
5
5
  verify
6
- } from "./chunk-Y4VXWSKX.js";
6
+ } from "./chunk-DIO36A3I.js";
7
7
  import {
8
8
  drift
9
9
  } from "./chunk-XZ32YQQC.js";
@@ -152,7 +152,7 @@ function createMcpServer() {
152
152
  try {
153
153
  assertCwd(cwd);
154
154
  const { resolve } = await import("path");
155
- const { model } = await scan(cwd);
155
+ const { summary: scanSummary, model } = await scan(cwd);
156
156
  const res = verify(model, { coverageFile: coverage ? resolve(cwd, coverage) : void 0 });
157
157
  const roles = model.authorization.roles.length > 0 ? model.authorization.roles.map((r) => r.name) : ["anonymous", "user", "admin"];
158
158
  const truncated = res.cases.length > 50;
@@ -169,8 +169,10 @@ function createMcpServer() {
169
169
  truncated,
170
170
  uncovered: res.uncovered.slice(0, 20),
171
171
  uncoveredTotal: res.uncovered.length,
172
+ uncoveredNote: res.uncoveredNote,
172
173
  coveragePct: res.coveragePct,
173
- findings: res.findings
174
+ findings: res.findings,
175
+ scan: { errors: scanSummary.errors, warnings: scanSummary.warnings, note: "verify n\xE3o substitui scan/check" }
174
176
  },
175
177
  null,
176
178
  2
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  scan
4
- } from "./chunk-AKPQ4OAJ.js";
4
+ } from "./chunk-BB6XXEIK.js";
5
5
  import "./chunk-FECVBQUO.js";
6
6
 
7
7
  // src/cli.ts
@@ -41,13 +41,13 @@ Uso: firebase-audit scan [--json] [--strict] [--adapter=fn] [--cwd=path]`);
41
41
  return void 0;
42
42
  };
43
43
  const strictArg = getArg("strict");
44
- const strict = args.includes("--strict") || strictArg === "true" || cmd === "check";
44
+ const strict = (args.includes("--strict") || strictArg === "true") && strictArg !== "false" || cmd === "check";
45
45
  if (strictArg !== void 0 && strictArg !== "true" && strictArg !== "false") {
46
46
  console.error(`Valor inv\xE1lido para --strict: ${strictArg} (use --strict, --strict=true ou --strict=false)`);
47
47
  process.exit(1);
48
48
  }
49
49
  const asJsonArg = getArg("json");
50
- const asJson = args.includes("--json") || asJsonArg === "true";
50
+ const asJson = (args.includes("--json") || asJsonArg === "true") && asJsonArg !== "false";
51
51
  if (asJsonArg !== void 0 && asJsonArg !== "true" && asJsonArg !== "false") {
52
52
  console.error(`Valor inv\xE1lido para --json: ${asJsonArg} (use --json, --json=true ou --json=false)`);
53
53
  process.exit(1);
@@ -81,23 +81,25 @@ Uso: firebase-audit scan|check|verify|drift [--json] ...`);
81
81
  const { resolve: resolveRoot } = await import("path");
82
82
  const rootDir = cwdArg ? resolveRoot(process.cwd(), cwdArg) : process.cwd();
83
83
  if (cmd === "verify") {
84
- const { scan: scanForVerify } = await import("./scan-YUXXYBRU.js");
85
- const { verify } = await import("./verify-WBVSESHD.js");
84
+ const { scan: scanForVerify } = await import("./scan-SDJSDRDQ.js");
85
+ const { verify } = await import("./verify-DECP4IUW.js");
86
86
  const { resolve: resolveVerify } = await import("path");
87
87
  const coverageArg = getArg("coverage");
88
88
  const coveragePath = coverageArg ? resolveVerify(rootDir, coverageArg) : void 0;
89
- const { model } = await scanForVerify(rootDir, { adapterFn: adapterArg });
89
+ const { model, summary: scanSummary } = await scanForVerify(rootDir, { adapterFn: adapterArg });
90
90
  const res = verify(model, { coverageFile: coveragePath });
91
91
  const roles = model.authorization.roles.length > 0 ? model.authorization.roles.map((r) => r.name) : ["anonymous", "user", "admin"];
92
92
  const rolesReduced = model.authorization.roles.length === 0;
93
+ const scanRef = { errors: scanSummary.errors, warnings: scanSummary.warnings };
93
94
  if (asJson) {
94
- console.log(JSON.stringify({ roles, rolesReduced, cases: res.cases.slice(0, 50), casesTotal: res.cases.length, truncated: res.cases.length > 50, uncovered: res.uncovered.slice(0, 20), uncoveredTotal: res.uncovered.length, coveragePct: res.coveragePct, findings: res.findings }, null, 2));
95
+ console.log(JSON.stringify({ roles, rolesReduced, cases: res.cases.slice(0, 50), casesTotal: res.cases.length, truncated: res.cases.length > 50, uncovered: res.uncovered.slice(0, 20), uncoveredTotal: res.uncovered.length, uncoveredNote: res.uncoveredNote, coveragePct: res.coveragePct, findings: res.findings, scan: scanRef }, null, 2));
95
96
  return;
96
97
  }
97
98
  const covStr = res.coveragePct === null ? "sem cobertura observada" : `${res.coveragePct}% de express\xF5es visitadas`;
98
99
  const rolesNote = rolesReduced ? "pap\xE9is reduzidos (sem contrato: anonymous/user/admin)" : `${roles.length} pap\xE9is do contrato (${roles.join(", ")})`;
99
100
  console.log(`
100
101
  FIREBASE AUDIT verify \u2014 ${res.cases.length} casos planejados (${rolesNote}), ${res.uncovered.length} paths sem cobertura (${covStr}).`);
102
+ console.log(`Scan est\xE1tico junto: ${scanRef.errors} errors, ${scanRef.warnings} warnings \u2014 verify n\xE3o substitui scan/check.`);
101
103
  for (const f of res.findings) {
102
104
  const icon = f.severity === "ERROR" ? "\u274C" : f.severity === "WARNING" ? "\u26A0" : "\u2139";
103
105
  console.log(`
@@ -156,6 +158,9 @@ FIREBASE AUDIT drift \u2014 MATCHED ${counts("MATCHED")}, LOCAL_ONLY ${counts("L
156
158
  return;
157
159
  }
158
160
  const { findings, summary } = await scan(rootDir, { strict, adapterFn: adapterArg });
161
+ if (!strict && summary.errors > 0) {
162
+ console.error(`\u26A0 scan encontrou ${summary.errors} ERROR(s) mas sai 0 \u2014 use 'check' no CI para reprovar.`);
163
+ }
159
164
  if (asJson) {
160
165
  console.log(JSON.stringify({ summary, findings }, null, 2));
161
166
  if (strict && summary.errors > 0) process.exit(2);
package/dist/index.d.ts CHANGED
@@ -577,9 +577,9 @@ declare const FindingSchema: z.ZodObject<{
577
577
  declare const AuditYamlSchema: z.ZodObject<{
578
578
  $schema: z.ZodOptional<z.ZodString>;
579
579
  authorization: z.ZodObject<{
580
- adapter: z.ZodOptional<z.ZodObject<{
580
+ adapter: z.ZodOptional<z.ZodNullable<z.ZodObject<{
581
581
  function: z.ZodString;
582
- }, z.core.$strict>>;
582
+ }, z.core.$strict>>>;
583
583
  roles: z.ZodRecord<z.ZodString, z.ZodObject<{
584
584
  permissions: z.ZodArray<z.ZodString>;
585
585
  }, z.core.$strict>>;
@@ -595,11 +595,11 @@ declare const AuditYamlSchema: z.ZodObject<{
595
595
  write: "write";
596
596
  }>;
597
597
  }, z.core.$strict>>;
598
- claims: z.ZodOptional<z.ZodObject<{
598
+ claims: z.ZodOptional<z.ZodNullable<z.ZodObject<{
599
599
  enabled: z.ZodBoolean;
600
- roleKey: z.ZodString;
600
+ roleKey: z.ZodOptional<z.ZodString>;
601
601
  samples: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodJSONSchema>>>;
602
- }, z.core.$strict>>;
602
+ }, z.core.$strict>>>;
603
603
  }, z.core.$strict>;
604
604
  }, z.core.$strict>;
605
605
  /** Chaves OIDC/Firebase reservadas — nunca em custom claims (1000 bytes, só acesso). */
@@ -639,7 +639,8 @@ interface DiscoveryResult {
639
639
  declare function discover(rootDir: string): DiscoveryResult;
640
640
  /** Segmentos de servidor (fail-closed: `api` genérico NÃO é server, ver classifyOrigin). */
641
641
  declare const SERVER_HINTS_SEGMENTS: string[];
642
- /** Heurística CLIENT vs SERVER: caminho manda primeiro (grafo ai-tool preenche listas na Fase 3). */
642
+ /** Heurística CLIENT vs SERVER: caminho manda primeiro (grafo ai-tool preenche listas na Fase 3).
643
+ * Segmentos ancorados — nunca substring solta (`myapp/` não é `app/`). */
643
644
  declare function classifyOrigin(file: string, content: string): "CLIENT" | "SERVER" | "UNKNOWN";
644
645
  declare function emptyModel(rootDir: string, d: DiscoveryResult): ProjectModel;
645
646
  /**
@@ -752,8 +753,9 @@ interface ScanOptions {
752
753
  adapterFn?: string;
753
754
  strict?: boolean;
754
755
  /**
755
- * Opt-in: enriquece Discovery com grafo do ai-tool (clientFiles/serverFiles).
756
- * Default false (scan estático rápido; nenhum check consome o grafo por padrão).
756
+ * Prévia opt-in: enriquece Discovery com grafo do ai-tool (clientFiles/serverFiles).
757
+ * Nenhum check consome o grafo ainda resultado idêntico com ou sem.
758
+ * Default false (scan estático rápido).
757
759
  */
758
760
  graph?: boolean;
759
761
  }
@@ -806,6 +808,8 @@ interface VerifyResult {
806
808
  findings: Finding[];
807
809
  /** 0-100 quando o coverage tem visitCount avaliável; null = não observada. */
808
810
  coveragePct: number | null;
811
+ /** Como ler `uncovered`: atribuição por expressão indisponível (só 100% esvazia). */
812
+ uncoveredNote: string;
809
813
  }
810
814
  /** Gera matriz role × operação × path a partir do modelo (sem executar nada).
811
815
  * Mapeamento: read≡get+list, write≡create+update+delete (mesma semântica do opsFrom).
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  createMcpServer,
3
3
  startMcpServer
4
- } from "./chunk-RKQ3ZWWI.js";
4
+ } from "./chunk-SH6HFX5Y.js";
5
5
  import {
6
6
  AccessOriginSchema,
7
7
  AuditYamlSchema,
@@ -33,7 +33,7 @@ import {
33
33
  runChecks,
34
34
  scan,
35
35
  validateClaimSamples
36
- } from "./chunk-AKPQ4OAJ.js";
36
+ } from "./chunk-BB6XXEIK.js";
37
37
  import {
38
38
  buildFullPath,
39
39
  conditionKeyForFingerprint,
@@ -59,7 +59,7 @@ import {
59
59
  import {
60
60
  planVerify,
61
61
  verify
62
- } from "./chunk-Y4VXWSKX.js";
62
+ } from "./chunk-DIO36A3I.js";
63
63
  import {
64
64
  drift
65
65
  } from "./chunk-XZ32YQQC.js";
package/dist/mcp-cli.js CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  startMcpServer
4
- } from "./chunk-RKQ3ZWWI.js";
5
- import "./chunk-AKPQ4OAJ.js";
4
+ } from "./chunk-SH6HFX5Y.js";
5
+ import "./chunk-BB6XXEIK.js";
6
6
  import "./chunk-FECVBQUO.js";
7
- import "./chunk-Y4VXWSKX.js";
7
+ import "./chunk-DIO36A3I.js";
8
8
  import "./chunk-XZ32YQQC.js";
9
9
 
10
10
  // src/mcp-cli.ts
package/dist/mcp.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  createMcpServer,
3
3
  startMcpServer
4
- } from "./chunk-RKQ3ZWWI.js";
5
- import "./chunk-AKPQ4OAJ.js";
4
+ } from "./chunk-SH6HFX5Y.js";
5
+ import "./chunk-BB6XXEIK.js";
6
6
  import "./chunk-FECVBQUO.js";
7
- import "./chunk-Y4VXWSKX.js";
7
+ import "./chunk-DIO36A3I.js";
8
8
  import "./chunk-XZ32YQQC.js";
9
9
  export {
10
10
  createMcpServer,
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  scan
3
- } from "./chunk-AKPQ4OAJ.js";
3
+ } from "./chunk-BB6XXEIK.js";
4
4
  import "./chunk-FECVBQUO.js";
5
5
  export {
6
6
  scan
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  planVerify,
3
3
  verify
4
- } from "./chunk-Y4VXWSKX.js";
4
+ } from "./chunk-DIO36A3I.js";
5
5
  export {
6
6
  planVerify,
7
7
  verify
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justmpm/firebase-audit",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
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
@@ -35,4 +35,5 @@ Use `drift` para comparar índices locais vs implantados (MATCHED/LOCAL_ONLY/REM
35
35
  ## Limites honestos
36
36
 
37
37
  - AppCheck: varredura TS/Python por texto (`enforceAppCheck`, `verifyToken` e afins). Config via variável conta como não observada; `onRequest` exige revisão rota a rota.
38
- - Isolamento por tenant (ownerId/tenantId): fora dos checks — revise manualmente ou no Emulator.
38
+ - Isolamento por tenant (ownerId/tenantId) e mismatch de papel não-admin: fora dos checks — revise manualmente ou no Emulator.
39
+ - `graph: true` é prévia opt-in da biblioteca (nenhum check consome ainda).