@justmpm/firebase-audit 0.5.4 → 0.5.5

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
 
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  scan
3
- } from "./chunk-AKPQ4OAJ.js";
3
+ } from "./chunk-ME6WO45S.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
@@ -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 {
@@ -51,18 +51,35 @@ 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
+ if (typeof fs?.rules === "string") {
58
+ const p = join(rootDir, fs.rules);
56
59
  if (existsSync(p)) rulesFile = p;
57
60
  }
58
- if (typeof raw.firestore?.indexes === "string") {
59
- const p = join(rootDir, raw.firestore.indexes);
61
+ if (typeof fs?.indexes === "string") {
62
+ const p = join(rootDir, fs.indexes);
60
63
  if (existsSync(p)) indexesFile = p;
61
64
  }
62
- if (typeof raw.storage?.rules === "string") {
63
- const p = join(rootDir, raw.storage.rules);
65
+ if (typeof st?.rules === "string") {
66
+ const p = join(rootDir, st.rules);
64
67
  if (existsSync(p)) storageFile = p;
65
68
  }
69
+ const fns = raw.functions;
70
+ if (typeof fns === "string" && fns.length > 0) {
71
+ const p = join(rootDir, fns);
72
+ try {
73
+ if (statSync(p).isDirectory()) functionsDir = p;
74
+ } catch {
75
+ }
76
+ } else if (fns && typeof fns === "object" && typeof fns.source === "string" && fns.source.length > 0) {
77
+ const p = join(rootDir, fns.source);
78
+ try {
79
+ if (statSync(p).isDirectory()) functionsDir = p;
80
+ } catch {
81
+ }
82
+ }
66
83
  } catch {
67
84
  }
68
85
  }
@@ -105,7 +122,7 @@ function classifyOrigin(file, content) {
105
122
  if (content.includes("firebase-admin") || content.includes("firebase-functions")) {
106
123
  return "CLIENT";
107
124
  }
108
- if (rel.includes("src/") || rel.includes("app/") || rel.includes("components/")) {
125
+ if (hasSeg("src", "app", "components")) {
109
126
  return "CLIENT";
110
127
  }
111
128
  return "UNKNOWN";
@@ -139,7 +156,7 @@ async function enrichWithGraph(rootDir, d) {
139
156
  const ai = await import("@justmpm/ai-tool");
140
157
  const res = await ai.map({ cwd: rootDir, format: "json" });
141
158
  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);
159
+ 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
160
  const { readFileSync: readSync, existsSync: existsSyncFn, statSync: statSync2 } = await import("fs");
144
161
  const { join: joinP } = await import("path");
145
162
  for (const rel of code) {
@@ -598,6 +615,10 @@ async function collectAdminImports(rootDir) {
598
615
  "import $DEFAULT from '$MODULE'",
599
616
  'import * as $NS from "$MODULE"',
600
617
  "import * as $NS from '$MODULE'",
618
+ 'export { $$$ITEMS } from "$MODULE"',
619
+ "export { $$$ITEMS } from '$MODULE'",
620
+ 'export * from "$MODULE"',
621
+ "export * from '$MODULE'",
601
622
  'import "$MODULE"',
602
623
  "import '$MODULE'",
603
624
  'const $X = require("$MODULE")',
@@ -796,7 +817,7 @@ var RoleNameSchema = z.string().regex(/^[a-z][a-z0-9_-]*$/);
796
817
  var AuditYamlSchema = z.strictObject({
797
818
  $schema: z.string().optional(),
798
819
  authorization: z.strictObject({
799
- adapter: z.strictObject({ function: z.string().min(1) }).optional(),
820
+ adapter: z.strictObject({ function: z.string().min(1) }).nullish(),
800
821
  roles: z.record(RoleNameSchema, z.strictObject({ permissions: z.array(PermissionNameSchema) })),
801
822
  permissions: z.record(
802
823
  PermissionNameSchema,
@@ -804,9 +825,10 @@ var AuditYamlSchema = z.strictObject({
804
825
  ),
805
826
  claims: z.strictObject({
806
827
  enabled: z.boolean(),
807
- roleKey: z.string().min(1),
828
+ // Reservado para o futuro (nenhum check consome ainda): opcional.
829
+ roleKey: z.string().min(1).optional(),
808
830
  samples: z.record(RoleNameSchema, z.record(z.string().min(1), z.json())).optional()
809
- }).optional()
831
+ }).nullish()
810
832
  })
811
833
  });
812
834
  var RESERVED_CLAIM_KEYS = [
@@ -860,11 +882,40 @@ async function scan(rootDir, opts = {}) {
860
882
  try {
861
883
  if (d.firebaseJson && existsSync3(d.firebaseJson)) {
862
884
  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
- ]) {
885
+ const asList = (v) => v === void 0 ? [] : Array.isArray(v) ? v : [v];
886
+ const entries = [];
887
+ for (const f of asList(raw.firestore)) {
888
+ entries.push({ kind: "rules", rel: f.rules, label: "firestore.rules" });
889
+ entries.push({ kind: "indexes", rel: f.indexes, label: "firestore.indexes" });
890
+ }
891
+ for (const s of asList(raw.storage)) {
892
+ entries.push({ kind: "storage-rules", rel: s.rules, label: "storage.rules" });
893
+ }
894
+ if (Array.isArray(raw.firestore) && raw.firestore.length > 1) {
895
+ findings_pre.push({
896
+ rule: "FIREBASE_JSON_ARRAY",
897
+ severity: "INFO",
898
+ confidence: "CONFIRMED",
899
+ message: `firebase.json com ${raw.firestore.length} databases \u2014 este scan audita s\xF3 o default. Bancos nomeados (databaseId) fora do escopo.`,
900
+ fingerprint: "FIREBASE_JSON_ARRAY:firestore",
901
+ evidence: [
902
+ { id: "ev-config-array-fs", kind: "config-parse", summary: `${raw.firestore.length} databases`, confidence: "CONFIRMED" }
903
+ ]
904
+ });
905
+ }
906
+ if (Array.isArray(raw.storage) && raw.storage.length > 1) {
907
+ findings_pre.push({
908
+ rule: "FIREBASE_JSON_ARRAY",
909
+ severity: "INFO",
910
+ confidence: "CONFIRMED",
911
+ message: `firebase.json com ${raw.storage.length} buckets \u2014 este scan audita s\xF3 o default. Buckets extras fora do escopo.`,
912
+ fingerprint: "FIREBASE_JSON_ARRAY:storage",
913
+ evidence: [
914
+ { id: "ev-config-array-st", kind: "config-parse", summary: `${raw.storage.length} buckets`, confidence: "CONFIRMED" }
915
+ ]
916
+ });
917
+ }
918
+ for (const [kind, rel, label] of entries.map((e) => [e.kind, e.rel, e.label])) {
868
919
  if (typeof rel === "string" && rel.length > 0) {
869
920
  if (!existsSync3(join3(rootDir, rel))) {
870
921
  findings_pre.push({
@@ -1242,20 +1293,23 @@ async function scan(rootDir, opts = {}) {
1242
1293
  rule: "APPCHECK_ENFORCED",
1243
1294
  severity: "INFO",
1244
1295
  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.`,
1296
+ 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
1297
  fingerprint: "APPCHECK_ENFORCED",
1247
1298
  evidence: [{ id: "ev-appcheck", kind: "appcheck", summary: `${appcheck.occurrences} ocorr\xEAncias`, confidence: "CONFIRMED" }]
1248
1299
  });
1249
- } else if (appcheck.manual === 0) {
1300
+ } else if (appcheck.manual === 0 && appcheck.ignoredOnRequest === 0) {
1250
1301
  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
1302
  }
1303
+ if (appcheck.ignoredOnRequest > 0) {
1304
+ skipped.push(`enforceAppCheck em onRequest ignorado pelo runtime (${appcheck.ignoredOnRequest} ocorr\xEAncia(s)) \u2014 onRequest exige verifyToken por rota`);
1305
+ }
1252
1306
  if (appcheck.limitHit) {
1253
1307
  skipped.push("functions: teto de varredura atingido \u2014 AppCheck parcial neste scan");
1254
1308
  }
1255
1309
  } else {
1256
1310
  skipped.push("functions n\xE3o observadas \u2014 AppCheck fora deste scan");
1257
1311
  }
1258
- skipped.push("isolamento por tenant (ownerId/tenantId) n\xE3o verificado \u2014 fora dos checks implementados, nunca verde silencioso");
1312
+ 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
1313
  const modelCheck = ProjectModelSchema.safeParse(model);
1260
1314
  if (!modelCheck.success) {
1261
1315
  findings.push({
@@ -1320,6 +1374,7 @@ async function countAppCheckEnforced(rootDir) {
1320
1374
  let occurrences = 0;
1321
1375
  const files = /* @__PURE__ */ new Set();
1322
1376
  let manual = 0;
1377
+ let ignoredOnRequest = 0;
1323
1378
  let limitHit = false;
1324
1379
  const stack = [...roots];
1325
1380
  let guard = 0;
@@ -1341,8 +1396,11 @@ async function countAppCheckEnforced(rootDir) {
1341
1396
  const name = e.name;
1342
1397
  const abs = joinP(cur, name);
1343
1398
  let isDir = false;
1399
+ let size = 0;
1344
1400
  try {
1345
- isDir = stat(abs).isDirectory();
1401
+ const st = stat(abs);
1402
+ isDir = st.isDirectory();
1403
+ size = st.size;
1346
1404
  } catch {
1347
1405
  continue;
1348
1406
  }
@@ -1353,6 +1411,7 @@ async function countAppCheckEnforced(rootDir) {
1353
1411
  }
1354
1412
  if (!/\.(ts|js|mjs|cjs|py)$/.test(name)) continue;
1355
1413
  if (isTestFile(abs)) continue;
1414
+ if (size > 100 * 1024) continue;
1356
1415
  try {
1357
1416
  let text = strip(readSync(abs, "utf-8"));
1358
1417
  if (name.endsWith(".py")) {
@@ -1361,10 +1420,16 @@ async function countAppCheckEnforced(rootDir) {
1361
1420
  return idx === -1 ? l : l.slice(0, idx);
1362
1421
  }).join("\n");
1363
1422
  }
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);
1423
+ for (const em of text.matchAll(/enforceAppCheck\s*:\s*true|enforce_app_check\s*=\s*True/g)) {
1424
+ const before = text.slice(Math.max(0, (em.index ?? 0) - 600), em.index ?? 0);
1425
+ const triggers = [...before.matchAll(/on(Request|Call|request|call)\s*[\(.]/g)].map((t) => t[1].toLowerCase());
1426
+ const last = triggers[triggers.length - 1];
1427
+ if (last === "request") {
1428
+ ignoredOnRequest += 1;
1429
+ } else {
1430
+ occurrences += 1;
1431
+ files.add(abs);
1432
+ }
1368
1433
  }
1369
1434
  const manualHits = text.match(/verifyToken\s*\(|verify_token\s*\(|consumeAppCheckToken|getLimitedUseToken|getAppCheck\s*\(/g);
1370
1435
  if (manualHits) manual += manualHits.length;
@@ -1372,9 +1437,9 @@ async function countAppCheckEnforced(rootDir) {
1372
1437
  }
1373
1438
  }
1374
1439
  }
1375
- return { occurrences, files: files.size, manual, limitHit };
1440
+ return { occurrences, files: files.size, manual, ignoredOnRequest, limitHit };
1376
1441
  } catch {
1377
- return { occurrences: 0, files: 0, manual: 0, limitHit: false };
1442
+ return { occurrences: 0, files: 0, manual: 0, ignoredOnRequest: 0, limitHit: false };
1378
1443
  }
1379
1444
  }
1380
1445
  function isOpenStorageCondition(condition) {
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-ME6WO45S.js";
5
5
  import "./chunk-FECVBQUO.js";
6
6
 
7
7
  // src/cli.ts
@@ -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-HE5WAMWJ.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
  /**
@@ -806,6 +807,8 @@ interface VerifyResult {
806
807
  findings: Finding[];
807
808
  /** 0-100 quando o coverage tem visitCount avaliável; null = não observada. */
808
809
  coveragePct: number | null;
810
+ /** Como ler `uncovered`: atribuição por expressão indisponível (só 100% esvazia). */
811
+ uncoveredNote: string;
809
812
  }
810
813
  /** Gera matriz role × operação × path a partir do modelo (sem executar nada).
811
814
  * 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-CHC7ZHUK.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-ME6WO45S.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-CHC7ZHUK.js";
5
+ import "./chunk-ME6WO45S.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-CHC7ZHUK.js";
5
+ import "./chunk-ME6WO45S.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-ME6WO45S.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.5",
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).