@justmpm/firebase-audit 0.4.3 → 0.5.0

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.
@@ -71,7 +71,7 @@ function verify(model, opts = {}) {
71
71
  rule: "COVERAGE_NOT_EVALUATED",
72
72
  severity: "WARNING",
73
73
  confidence: "NOT_OBSERVED",
74
- message: "Coverage sem visitCount reconhec\xEDvel nesta vers\xE3o \u2014 uncovered lista tudo por honestidade.",
74
+ message: "Coverage sem visitCount reconhec\xEDvel \u2014 uncovered lista tudo por honestidade.",
75
75
  fingerprint: "COVERAGE_NOT_EVALUATED",
76
76
  evidence: [{ id: "ev-coverage-ne", kind: "coverage-parse", summary: "n\xE3o avaliado", confidence: "NOT_OBSERVED" }]
77
77
  });
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  scan
3
- } from "./chunk-3SEVYYCX.js";
3
+ } from "./chunk-TQZTE2NR.js";
4
4
  import {
5
5
  verify
6
- } from "./chunk-PSSMYRRU.js";
6
+ } from "./chunk-2X3BTSGP.js";
7
7
  import {
8
8
  drift
9
9
  } from "./chunk-NJBCSW7E.js";
@@ -31,10 +31,11 @@ function readPkgText(rel) {
31
31
  var FALLBACK_SKILL = [
32
32
  "# firebase-audit-skill (fallback)",
33
33
  "",
34
- "1. Descobrir projeto (firebase.json, firestore.rules, firestore.indexes.json).",
35
- "2. Executar scan (strict no CI). Sem adapter resolvido \u2192 UNKNOWN, nunca adivinhar.",
34
+ "1. Descobrir projeto (firebase.json, firestore.rules, storage.rules, firestore.indexes.json).",
35
+ "2. Executar scan (check com contrato no CI). Sem fun\xE7\xE3o de permiss\xE3o que receba o nome como texto \u2192 UNKNOWN, nunca adivinhar.",
36
36
  "3. CONFIRMED corrige agora; PROBABLE investiga; NOT_OBSERVED/UNKNOWN n\xE3o afirma.",
37
- "4. Corrigir c\xF3digo, nunca o contrato para esconder finding."
37
+ "4. AppCheck \xE9 carimbo do app, n\xE3o autoriza\xE7\xE3o. Logo p\xFAblica pode ser vitrine; cofre \xE9 write aberto.",
38
+ "5. Corrigir c\xF3digo, nunca o contrato para esconder finding."
38
39
  ].join("\n");
39
40
  function createMcpServer() {
40
41
  const server = new McpServer({
@@ -45,14 +46,14 @@ function createMcpServer() {
45
46
  "skill",
46
47
  {
47
48
  title: "Firebase Audit Skill (guia do agente)",
48
- description: "LEIA ANTES de auditar: sequ\xEAncia obrigat\xF3ria, travas (nunca mude o contrato para esconder finding, nunca afirme sem evid\xEAncia, nunca diga 'unused') e Emulador. Retorna o guia oficial da vers\xE3o instalada + modelo de contrato. Sempre sincronizado \u2014 dispensa skill copiada.",
49
+ description: "LEIA ANTES de auditar: sequ\xEAncia obrigat\xF3ria, travas (nunca mude o contrato para esconder finding, nunca afirme sem evid\xEAncia, nunca diga 'unused') e Emulador. Retorna o guia oficial instalado + modelo de contrato. Sempre sincronizado \u2014 dispensa skill copiada.",
49
50
  inputSchema: {}
50
51
  },
51
52
  async () => {
52
53
  const skill = readPkgText(join("skill", "SKILL.md")) ?? FALLBACK_SKILL;
53
54
  const template = readPkgText(join("templates", "firebase-audit.yaml")) ?? "";
54
55
  const text = [
55
- `# firebase-audit v${pkg.version} \u2014 guia do agente`,
56
+ "# firebase-audit \u2014 guia do agente",
56
57
  "",
57
58
  skill,
58
59
  "",
@@ -69,10 +70,10 @@ function createMcpServer() {
69
70
  "scan",
70
71
  {
71
72
  title: "Firebase Audit Scan",
72
- description: "Audita projeto Firebase (c\xF3digo + firestore.rules + \xEDndices + contrato). Static-first, nunca inventa certeza (CONFIRMED/PROBABLE/NOT_OBSERVED/UNKNOWN). Use para descobrir regras p\xFAblicas, Admin em cliente, permiss\xF5es sem contrato.",
73
+ description: "Audita projeto Firebase (c\xF3digo + firestore.rules + storage.rules + \xEDndices + contrato + AppCheck). Est\xE1tico primeiro, nunca inventa certeza (CONFIRMED/PROBABLE/NOT_OBSERVED/UNKNOWN). Use para descobrir regras p\xFAblicas, Admin em cliente, permiss\xF5es sem contrato.",
73
74
  inputSchema: {
74
75
  cwd: z.string().min(1).describe("Diret\xF3rio raiz do projeto Firebase a auditar"),
75
- adapter: z.string().optional().describe("Fun\xE7\xE3o de autoriza\xE7\xE3o (ex: rbac.can). Default: do contrato."),
76
+ adapter: z.string().optional().describe("Fun\xE7\xE3o de permiss\xE3o no c\xF3digo que recebe o nome como texto (ex: rbac.can). N\xE3o use guarda de rota nem function do .rules aqui. Default: do contrato."),
76
77
  strict: z.boolean().optional().describe("Exige contrato (firebase-audit.yaml). CI usa strict.")
77
78
  }
78
79
  },
@@ -83,7 +84,7 @@ function createMcpServer() {
83
84
  content: [
84
85
  {
85
86
  type: "text",
86
- text: JSON.stringify({ version: pkg.version, summary: res.summary, findings: res.findings }, null, 2)
87
+ text: JSON.stringify({ summary: res.summary, findings: res.findings }, null, 2)
87
88
  }
88
89
  ]
89
90
  };
@@ -99,10 +100,10 @@ function createMcpServer() {
99
100
  "check",
100
101
  {
101
102
  title: "Firebase Audit Check (strict)",
102
- description: "Alias de scan --strict: exige contrato e falha (exit 2 no CLI) se houver ERROR. Use no CI.",
103
+ description: "Alias de scan strict: exige contrato e reprova CI se houver ERROR. Use no CI.",
103
104
  inputSchema: {
104
105
  cwd: z.string().min(1).describe("Diret\xF3rio raiz do projeto Firebase"),
105
- adapter: z.string().optional().describe("Fun\xE7\xE3o de autoriza\xE7\xE3o (ex: rbac.can)")
106
+ adapter: z.string().optional().describe("Fun\xE7\xE3o de permiss\xE3o no c\xF3digo que recebe o nome como texto (ex: rbac.can)")
106
107
  }
107
108
  },
108
109
  async ({ cwd, adapter }) => {
@@ -112,7 +113,7 @@ function createMcpServer() {
112
113
  content: [
113
114
  {
114
115
  type: "text",
115
- text: JSON.stringify({ version: pkg.version, summary: res.summary, findings: res.findings }, null, 2)
116
+ text: JSON.stringify({ summary: res.summary, findings: res.findings }, null, 2)
116
117
  }
117
118
  ]
118
119
  };
@@ -128,10 +129,10 @@ function createMcpServer() {
128
129
  "verify",
129
130
  {
130
131
  title: "Firebase Audit Verify (Emulator)",
131
- description: "Gera matriz role\xD7opera\xE7\xE3o\xD7path e avalia coverage do :ruleCoverage. Requer Emulator (firebase emulators:exec). Nunca afirma cobertura sem teste real.",
132
+ description: "Gera matriz papel\xD7opera\xE7\xE3o\xD7path e avalia cobertura do ruleCoverage. Requer Emulator. Sem contrato usa pap\xE9is reduzidos (anonymous/user/admin). Nunca afirma cobertura sem teste real.",
132
133
  inputSchema: {
133
134
  cwd: z.string().min(1).describe("Diret\xF3rio raiz do projeto"),
134
- coverage: z.string().optional().describe("Path do coverage.json do :ruleCoverage (relativo ao cwd)")
135
+ coverage: z.string().optional().describe("Path do coverage.json do ruleCoverage (relativo ao cwd)")
135
136
  }
136
137
  },
137
138
  async ({ cwd, coverage }) => {
@@ -139,12 +140,24 @@ function createMcpServer() {
139
140
  const { resolve } = await import("path");
140
141
  const { model } = await scan(cwd);
141
142
  const res = verify(model, { coverageFile: coverage ? resolve(cwd, coverage) : void 0 });
143
+ const roles = model.authorization.roles.length > 0 ? model.authorization.roles.map((r) => r.name) : ["anonymous", "user", "admin"];
144
+ const truncated = res.cases.length > 50;
142
145
  return {
143
146
  content: [
144
147
  {
145
148
  type: "text",
146
149
  text: JSON.stringify(
147
- { version: pkg.version, cases: res.cases.length, uncovered: res.uncovered, coveragePct: res.coveragePct, findings: res.findings },
150
+ {
151
+ roles,
152
+ rolesReduced: model.authorization.roles.length === 0,
153
+ cases: res.cases.slice(0, 50),
154
+ casesTotal: res.cases.length,
155
+ truncated,
156
+ uncovered: res.uncovered.slice(0, 20),
157
+ uncoveredTotal: res.uncovered.length,
158
+ coveragePct: res.coveragePct,
159
+ findings: res.findings
160
+ },
148
161
  null,
149
162
  2
150
163
  )
@@ -166,7 +179,7 @@ function createMcpServer() {
166
179
  description: "Compara \xEDndices locais vs implantados. Retorna MATCHED/LOCAL_ONLY/REMOTE_ONLY. Nunca diz 'unused', apenas NOT_OBSERVED.",
167
180
  inputSchema: {
168
181
  local: z.string().nullable().optional().describe("Path do firestore.indexes.json local (ou null). Relativo resolve contra cwd."),
169
- remote: z.string().nullable().optional().describe("Path do JSON remoto (gcloud/REST) (ou null). Relativo resolve contra cwd."),
182
+ remote: z.string().nullable().optional().describe("Path do JSON remoto (ou null). Relativo resolve contra cwd."),
170
183
  cwd: z.string().optional().describe("Diret\xF3rio base para paths relativos (default: cwd do servidor).")
171
184
  }
172
185
  },
@@ -183,7 +196,7 @@ function createMcpServer() {
183
196
  content: [
184
197
  {
185
198
  type: "text",
186
- text: JSON.stringify({ version: pkg.version, entries: res.entries, findings: res.findings }, null, 2)
199
+ text: JSON.stringify({ entries: res.entries, findings: res.findings }, null, 2)
187
200
  }
188
201
  ]
189
202
  };
@@ -201,7 +214,7 @@ async function startMcpServer() {
201
214
  const server = createMcpServer();
202
215
  const transport = new StdioServerTransport();
203
216
  await server.connect(transport);
204
- console.error(`[firebase-audit] MCP server v${pkg.version} running via stdio`);
217
+ console.error("[firebase-audit] MCP server running via stdio");
205
218
  }
206
219
 
207
220
  export {
@@ -4,7 +4,7 @@ import { join as join3 } from "path";
4
4
  import { parse as parseYaml } from "yaml";
5
5
 
6
6
  // src/discovery.ts
7
- import { existsSync, readFileSync } from "fs";
7
+ import { existsSync, readFileSync, statSync } from "fs";
8
8
  import { join, relative } from "path";
9
9
  function discover(rootDir) {
10
10
  const pick = (name) => {
@@ -16,6 +16,16 @@ function discover(rootDir) {
16
16
  let databaseId = null;
17
17
  let rulesFile = pick("firestore.rules");
18
18
  let indexesFile = pick("firestore.indexes.json");
19
+ let storageFile = pick("storage.rules");
20
+ let functionsDir = null;
21
+ const functionsPick = pick("functions");
22
+ if (functionsPick) {
23
+ try {
24
+ functionsDir = statSync(functionsPick).isDirectory() ? functionsPick : null;
25
+ } catch {
26
+ functionsDir = null;
27
+ }
28
+ }
19
29
  const firebaserc = pick(".firebaserc");
20
30
  if (firebaserc) {
21
31
  try {
@@ -36,6 +46,10 @@ function discover(rootDir) {
36
46
  const p = join(rootDir, raw.firestore.indexes);
37
47
  if (existsSync(p)) indexesFile = p;
38
48
  }
49
+ if (typeof raw.storage?.rules === "string") {
50
+ const p = join(rootDir, raw.storage.rules);
51
+ if (existsSync(p)) storageFile = p;
52
+ }
39
53
  } catch {
40
54
  }
41
55
  }
@@ -44,6 +58,8 @@ function discover(rootDir) {
44
58
  firebaseJson,
45
59
  firestoreRulesFile: rulesFile,
46
60
  firestoreIndexesFile: indexesFile,
61
+ storageRulesFile: storageFile,
62
+ functionsDir,
47
63
  auditYamlFile: pick("firebase-audit.yaml"),
48
64
  projectId,
49
65
  databaseId,
@@ -93,8 +109,10 @@ function emptyModel(rootDir, d) {
93
109
  firestore: {
94
110
  rulesFile: rel(d.firestoreRulesFile),
95
111
  indexesFile: rel(d.firestoreIndexesFile)
96
- }
112
+ },
113
+ storage: d.storageRulesFile ? { rulesFile: rel(d.storageRulesFile) } : void 0
97
114
  },
115
+ functions: d.functionsDir ? { dir: rel(d.functionsDir) } : void 0,
98
116
  authorization: { adapter: null, roles: [], permissions: [] },
99
117
  queries: [],
100
118
  rules: [],
@@ -109,14 +127,14 @@ async function enrichWithGraph(rootDir, d) {
109
127
  const res = await ai.map({ cwd: rootDir, format: "json" });
110
128
  const files = Array.isArray(res.files) ? res.files : [];
111
129
  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);
112
- const { readFileSync: readSync, existsSync: existsSyncFn, statSync } = await import("fs");
130
+ const { readFileSync: readSync, existsSync: existsSyncFn, statSync: statSync2 } = await import("fs");
113
131
  const { join: joinP } = await import("path");
114
132
  for (const rel of code) {
115
133
  let content = "";
116
134
  try {
117
135
  const abs = joinP(rootDir, rel);
118
136
  if (!existsSyncFn(abs)) continue;
119
- if (statSync(abs).size > 100 * 1024) continue;
137
+ if (statSync2(abs).size > 100 * 1024) continue;
120
138
  content = readSync(abs, "utf-8");
121
139
  } catch {
122
140
  content = "";
@@ -555,12 +573,15 @@ async function runChecks(model, rootDir, opts = {}) {
555
573
  const permissionCalls = await collectPermissionCalls(rootDir, adapterFn);
556
574
  const declared = new Set(model.authorization.permissions.map((p) => p.name));
557
575
  if (permissionCalls.literals.length === 0 && permissionCalls.dynamic.length === 0) {
576
+ const tenantHint = [...helperDetailed.keys()].find(
577
+ (n) => /tenant|member|signedin/i.test(n)
578
+ );
558
579
  findings.push(
559
580
  make(
560
581
  "FBA009",
561
582
  "WARNING",
562
583
  "UNKNOWN",
563
- `Adapter de autoriza\xE7\xE3o "${adapterFn}" n\xE3o observado no c\xF3digo. Sem adapter, permiss\xF5es s\xE3o UNKNOWN.`,
584
+ tenantHint ? `Adapter "${adapterFn}" n\xE3o observado no c\xF3digo. Encontrei portaria tenant (${tenantHint}) nas Rules, mas sem fun\xE7\xE3o de permiss\xE3o que receba o nome como texto n\xE3o d\xE1 para ligar papel\xD7recurso \u2014 permiss\xF5es seguem UNKNOWN.` : `Adapter "${adapterFn}" n\xE3o observado no c\xF3digo. Sem fun\xE7\xE3o de permiss\xE3o que receba o nome como texto, permiss\xF5es s\xE3o UNKNOWN.`,
564
585
  `FBA009:${adapterFn}`,
565
586
  { evidence: [ev("adapter-missing", adapterFn, "UNKNOWN")] }
566
587
  )
@@ -1041,8 +1062,15 @@ var ProjectModelSchema = z.strictObject({
1041
1062
  firestore: z.strictObject({
1042
1063
  rulesFile: z.string().optional(),
1043
1064
  indexesFile: z.string().optional()
1044
- })
1065
+ }),
1066
+ storage: z.strictObject({
1067
+ rulesFile: z.string().optional()
1068
+ }).optional()
1045
1069
  }),
1070
+ functions: z.strictObject({
1071
+ dir: z.string().optional(),
1072
+ appCheckEnforced: z.number().int().nonnegative().optional()
1073
+ }).optional(),
1046
1074
  authorization: z.strictObject({
1047
1075
  adapter: z.string().nullable(),
1048
1076
  roles: z.array(RoleSchema),
@@ -1206,11 +1234,11 @@ async function scan(rootDir, opts = {}) {
1206
1234
  }
1207
1235
  const validModes = ["ASCENDING", "DESCENDING", "ARRAY_CONTAINS", "VECTOR"];
1208
1236
  const entries = [];
1209
- let skipped = 0;
1237
+ let skipped2 = 0;
1210
1238
  let scopeSkipped = 0;
1211
1239
  for (const i of raw.indexes ?? []) {
1212
1240
  if (typeof i.collectionGroup !== "string" || i.collectionGroup.length === 0) {
1213
- skipped += 1;
1241
+ skipped2 += 1;
1214
1242
  continue;
1215
1243
  }
1216
1244
  if (i.queryScope !== void 0 && i.queryScope !== "COLLECTION" && i.queryScope !== "COLLECTION_GROUP") {
@@ -1220,13 +1248,13 @@ async function scan(rootDir, opts = {}) {
1220
1248
  const fields = [];
1221
1249
  for (const f of i.fields ?? []) {
1222
1250
  if (typeof f.fieldPath !== "string" || f.fieldPath.length === 0) {
1223
- skipped += 1;
1251
+ skipped2 += 1;
1224
1252
  continue;
1225
1253
  }
1226
1254
  const rawMode = f.vectorConfig ? "VECTOR" : f.arrayConfig === "CONTAINS" ? "ARRAY_CONTAINS" : typeof f.mode === "string" ? f.mode : f.order;
1227
1255
  const mode = rawMode;
1228
1256
  if (mode !== "ASCENDING" && mode !== "DESCENDING" && mode !== "ARRAY_CONTAINS" && mode !== "VECTOR") {
1229
- skipped += 1;
1257
+ skipped2 += 1;
1230
1258
  continue;
1231
1259
  }
1232
1260
  fields.push({ fieldPath: f.fieldPath, mode });
@@ -1239,11 +1267,11 @@ async function scan(rootDir, opts = {}) {
1239
1267
  const last = entries[entries.length - 1];
1240
1268
  if (last.fields.length === 0) {
1241
1269
  entries.pop();
1242
- skipped += 1;
1270
+ skipped2 += 1;
1243
1271
  }
1244
1272
  }
1245
1273
  model.indexes = entries;
1246
- const totalSkipped = skipped + scopeSkipped;
1274
+ const totalSkipped = skipped2 + scopeSkipped;
1247
1275
  if (totalSkipped > 0) {
1248
1276
  findings_pre.push({
1249
1277
  rule: "INDEXES_INVALID",
@@ -1361,6 +1389,75 @@ async function scan(rootDir, opts = {}) {
1361
1389
  }
1362
1390
  const checksFindings = await runChecks(model, rootDir, { adapterFn: opts.adapterFn ?? model.authorization.adapter ?? void 0 });
1363
1391
  const findings = [...findings_pre, ...checksFindings];
1392
+ const checked = ["firestore.rules", "indexes", "contract", "client-code"];
1393
+ const skipped = [];
1394
+ if (d.storageRulesFile && existsSync3(d.storageRulesFile)) {
1395
+ checked.push("storage.rules");
1396
+ try {
1397
+ const raw = readFileSync4(d.storageRulesFile, "utf-8");
1398
+ const relStorage = model.firebase.storage?.rulesFile ?? "storage.rules";
1399
+ const low = raw.toLowerCase().replace(/\s+/g, "");
1400
+ const hasAllPaths = raw.includes("allPaths=**") || raw.includes("allPaths = **");
1401
+ const writeOpen = /allowwrite:[^;]*iftrue/.test(low);
1402
+ const getOpen = /allowget:[^;]*iftrue/.test(low);
1403
+ if (writeOpen) {
1404
+ findings.push({
1405
+ rule: "STORAGE_PUBLIC_WRITE",
1406
+ severity: "ERROR",
1407
+ confidence: "CONFIRMED",
1408
+ message: `Storage com escrita aberta (${relStorage}). Qualquer cliente pode escrever \u2014 cofre aberto, n\xE3o vitrine.`,
1409
+ fingerprint: "STORAGE_PUBLIC_WRITE",
1410
+ file: relStorage,
1411
+ evidence: [{ id: "ev-storage-write", kind: "storage-public", summary: "write if true", confidence: "CONFIRMED" }],
1412
+ fix: "Exigir request.auth (e dono/tenant) no write. Deixe get aberto s\xF3 em pasta de vitrine como logos/."
1413
+ });
1414
+ } else if (hasAllPaths && getOpen) {
1415
+ findings.push({
1416
+ rule: "STORAGE_ALL_PATHS_OPEN",
1417
+ severity: "WARNING",
1418
+ confidence: "PROBABLE",
1419
+ message: `Storage com get aberto em /{allPaths=**} (${relStorage}). Vale para tudo \u2014 se a inten\xE7\xE3o era s\xF3 logo, restrinja a /logos/{id}.`,
1420
+ fingerprint: "STORAGE_ALL_PATHS_OPEN",
1421
+ file: relStorage,
1422
+ evidence: [{ id: "ev-storage-all", kind: "storage-public", summary: "allPaths get true", confidence: "PROBABLE" }],
1423
+ fix: "Troque /{allPaths=**} por pastas expl\xEDcitas (ex: /logos/{logoId}) com get: if true e write restrito."
1424
+ });
1425
+ } else if (getOpen) {
1426
+ findings.push({
1427
+ rule: "STORAGE_PUBLIC_READ",
1428
+ severity: "INFO",
1429
+ confidence: "PROBABLE",
1430
+ message: `Storage com leitura aberta (${relStorage}). Pode ser vitrine proposital (logos) \u2014 confirme que o write segue restrito.`,
1431
+ fingerprint: "STORAGE_PUBLIC_READ",
1432
+ file: relStorage,
1433
+ evidence: [{ id: "ev-storage-read", kind: "storage-public", summary: "get if true", confidence: "PROBABLE" }]
1434
+ });
1435
+ }
1436
+ } catch {
1437
+ skipped.push("storage.rules ileg\xEDvel \u2014 Storage sem cobertura neste scan");
1438
+ }
1439
+ } else {
1440
+ skipped.push("storage.rules n\xE3o observado \u2014 Storage fora deste scan");
1441
+ }
1442
+ if (d.functionsDir) {
1443
+ const enforced = await countAppCheckEnforced(rootDir);
1444
+ model.functions = { dir: model.functions?.dir, appCheckEnforced: enforced };
1445
+ checked.push("functions-appcheck");
1446
+ if (enforced > 0) {
1447
+ findings.push({
1448
+ rule: "APPCHECK_ENFORCED",
1449
+ severity: "INFO",
1450
+ confidence: "CONFIRMED",
1451
+ message: `AppCheck ativo em ${enforced} function(s). Carimbo do app oficial \u2014 n\xE3o prova quem \xE9 o usu\xE1rio nem o tenant. Autoriza\xE7\xE3o segue nas Rules/guardas.`,
1452
+ fingerprint: "APPCHECK_ENFORCED",
1453
+ evidence: [{ id: "ev-appcheck", kind: "appcheck", summary: `${enforced} enforced`, confidence: "CONFIRMED" }]
1454
+ });
1455
+ } else {
1456
+ skipped.push("functions sem enforceAppCheck observado \u2014 AppCheck sem cobertura afirmativa");
1457
+ }
1458
+ } else {
1459
+ skipped.push("functions n\xE3o observadas \u2014 AppCheck fora deste scan");
1460
+ }
1364
1461
  const modelCheck = ProjectModelSchema.safeParse(model);
1365
1462
  if (!modelCheck.success) {
1366
1463
  findings.push({
@@ -1399,7 +1496,7 @@ async function scan(rootDir, opts = {}) {
1399
1496
  );
1400
1497
  const totalChecks = IMPLEMENTED_CHECKS.length;
1401
1498
  const passedChecks = IMPLEMENTED_CHECKS.filter((c) => !failedChecks.has(c)).length;
1402
- const infraSkipped = findings.filter((f) => !implemented.has(f.rule)).length;
1499
+ const infra = findings.filter((f) => !implemented.has(f.rule)).map((f) => ({ rule: f.rule, message: f.message }));
1403
1500
  return {
1404
1501
  model,
1405
1502
  findings,
@@ -1407,15 +1504,59 @@ async function scan(rootDir, opts = {}) {
1407
1504
  errors,
1408
1505
  warnings,
1409
1506
  infos,
1410
- passed: passedChecks,
1411
- total: totalChecks,
1412
1507
  passedChecks,
1413
1508
  totalChecks,
1414
- infraSkipped,
1509
+ infra,
1510
+ checked,
1511
+ skipped,
1415
1512
  coveragePct: null
1416
1513
  }
1417
1514
  };
1418
1515
  }
1516
+ async function countAppCheckEnforced(rootDir) {
1517
+ try {
1518
+ const { readdirSync, readFileSync: readSync, statSync: stat } = await import("fs");
1519
+ const { join: joinP } = await import("path");
1520
+ const roots = [joinP(rootDir, "functions")];
1521
+ let count = 0;
1522
+ const stack = [...roots];
1523
+ let guard = 0;
1524
+ while (stack.length > 0 && guard < 200) {
1525
+ guard += 1;
1526
+ const cur = stack.pop();
1527
+ let entries = [];
1528
+ try {
1529
+ entries = readdirSync(cur, { withFileTypes: true });
1530
+ } catch {
1531
+ continue;
1532
+ }
1533
+ for (const e of entries) {
1534
+ const name = e.name;
1535
+ const abs = joinP(cur, name);
1536
+ let isDir = false;
1537
+ try {
1538
+ isDir = stat(abs).isDirectory();
1539
+ } catch {
1540
+ continue;
1541
+ }
1542
+ if (isDir) {
1543
+ if (name === "node_modules" || name === "dist" || name === "lib") continue;
1544
+ stack.push(abs);
1545
+ continue;
1546
+ }
1547
+ if (!/\.(ts|js|mjs|cjs)$/.test(name)) continue;
1548
+ try {
1549
+ const text = readSync(abs, "utf-8");
1550
+ if (/enforceAppCheck\s*:\s*true/.test(text)) count += 1;
1551
+ } catch {
1552
+ }
1553
+ }
1554
+ }
1555
+ return count;
1556
+ } catch {
1557
+ return 0;
1558
+ }
1559
+ }
1419
1560
 
1420
1561
  export {
1421
1562
  discover,
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  scan
4
- } from "./chunk-3SEVYYCX.js";
4
+ } from "./chunk-TQZTE2NR.js";
5
5
 
6
6
  // src/cli.ts
7
7
  import { createRequire } from "module";
@@ -15,13 +15,13 @@ async function main() {
15
15
  return;
16
16
  }
17
17
  if (cmd === "--help" || cmd === "-h" || cmd === "help") {
18
- console.log(`firebase-audit v${pkg.version}
18
+ console.log(`firebase-audit
19
19
 
20
20
  Uso:
21
21
  firebase-audit scan [--json] [--strict] [--adapter=fn] [--cwd=path]
22
- firebase-audit check [--json] [--adapter=fn] [--cwd=path] (alias de scan --strict)
23
- firebase-audit verify [--coverage=path] [--cwd=path] (V3: matriz + cobertura)
24
- firebase-audit drift --local=indexes.json --remote=deployed.json (V4: LOCAL_ONLY/REMOTE_ONLY/MATCHED)
22
+ firebase-audit check [--json] [--adapter=fn] [--cwd=path] (scan strict para CI)
23
+ firebase-audit verify [--coverage=path] [--cwd=path] (matriz + cobertura)
24
+ firebase-audit drift --local=indexes.json --remote=deployed.json (LOCAL_ONLY/REMOTE_ONLY/MATCHED)
25
25
 
26
26
  Exit codes: 0 ok informativo; 2 quando --strict/check encontra ERROR. scan sozinho nunca reprova (use check no CI).`);
27
27
  return;
@@ -64,20 +64,21 @@ Uso: firebase-audit scan|check|verify|drift [--json] ...`);
64
64
  const { resolve: resolveRoot } = await import("path");
65
65
  const rootDir = cwdArg ? resolveRoot(process.cwd(), cwdArg) : process.cwd();
66
66
  if (cmd === "verify") {
67
- const { scan: scanForVerify } = await import("./scan-VREY2FGO.js");
68
- const { verify } = await import("./verify-O722ZZBV.js");
67
+ const { scan: scanForVerify } = await import("./scan-FYAWNRIE.js");
68
+ const { verify } = await import("./verify-GWRLO344.js");
69
69
  const { resolve: resolveVerify } = await import("path");
70
70
  const coverageArg = getArg("coverage");
71
71
  const coveragePath = coverageArg ? resolveVerify(rootDir, coverageArg) : void 0;
72
72
  const { model } = await scanForVerify(rootDir, { adapterFn: adapterArg });
73
73
  const res = verify(model, { coverageFile: coveragePath });
74
74
  if (asJson) {
75
- console.log(JSON.stringify({ version: pkg.version, cases: res.cases.length, uncovered: res.uncovered, coveragePct: res.coveragePct, findings: res.findings }, null, 2));
75
+ console.log(JSON.stringify({ 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));
76
76
  return;
77
77
  }
78
78
  const covStr = res.coveragePct === null ? "sem cobertura observada" : `${res.coveragePct}% de express\xF5es visitadas`;
79
+ const rolesNote = "pap\xE9is reduzidos (sem contrato: anonymous/user/admin)";
79
80
  console.log(`
80
- FIREBASE AUDIT verify \u2014 ${res.cases.length} casos planejados, ${res.uncovered.length} paths sem mapeamento n\xF3\u2192path (${covStr}).`);
81
+ FIREBASE AUDIT verify \u2014 ${res.cases.length} casos planejados (${rolesNote}), ${res.uncovered.length} paths sem cobertura (${covStr}).`);
81
82
  for (const f of res.findings) {
82
83
  const icon = f.severity === "ERROR" ? "\u274C" : f.severity === "WARNING" ? "\u26A0" : "\u2139";
83
84
  console.log(`
@@ -113,7 +114,7 @@ ${icon} ${f.rule} [${f.confidence}]
113
114
  localPath && existsDrift(localPath) ? localPath : null,
114
115
  remotePath && existsDrift(remotePath) ? remotePath : null
115
116
  );
116
- console.log(JSON.stringify({ version: pkg.version, entries: res2.entries, findings: res2.findings }, null, 2));
117
+ console.log(JSON.stringify({ entries: res2.entries, findings: res2.findings }, null, 2));
117
118
  process.exit(1);
118
119
  }
119
120
  console.error(`Arquivo ${label} n\xE3o encontrado: ${p}`);
@@ -122,7 +123,7 @@ ${icon} ${f.rule} [${f.confidence}]
122
123
  }
123
124
  const res = drift(localPath, remotePath);
124
125
  if (asJson) {
125
- console.log(JSON.stringify({ version: pkg.version, entries: res.entries, findings: res.findings }, null, 2));
126
+ console.log(JSON.stringify({ entries: res.entries, findings: res.findings }, null, 2));
126
127
  return;
127
128
  }
128
129
  const counts = (s) => res.entries.filter((e) => e.status === s).length;
@@ -137,16 +138,17 @@ FIREBASE AUDIT drift \u2014 MATCHED ${counts("MATCHED")}, LOCAL_ONLY ${counts("L
137
138
  }
138
139
  const { findings, summary } = await scan(rootDir, { strict, adapterFn: adapterArg });
139
140
  if (asJson) {
140
- console.log(JSON.stringify({ version: pkg.version, summary, findings }, null, 2));
141
+ console.log(JSON.stringify({ summary, findings }, null, 2));
141
142
  if (strict && summary.errors > 0) process.exit(2);
142
143
  return;
143
144
  }
144
- console.log(`
145
- FIREBASE AUDIT v${pkg.version}`);
145
+ console.log("\nFIREBASE AUDIT");
146
146
  console.log("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
147
147
  if (findings.length === 0) {
148
148
  console.log(`
149
- \u2713 Nenhum problema nos checks V1+V2 implementados (parcial: FBA001-FBA004, FBA009-FBA013).
149
+ \u2713 Nenhum problema nos checks implementados (${summary.passedChecks}/${summary.totalChecks} passaram).
150
+ Olhei: ${summary.checked.join(", ")}.
151
+ Pulei: ${summary.skipped.join(" | ") || "nada"}.
150
152
  `);
151
153
  return;
152
154
  }
@@ -159,7 +161,9 @@ ${icon} ${f.rule} [${f.confidence}]${where}
159
161
  if (f.fix) console.log(` \u2192 ${f.fix}`);
160
162
  }
161
163
  console.log(`
162
- SUMMARY: ${summary.errors} errors, ${summary.warnings} warnings, ${summary.infos} infos
164
+ SUMMARY: ${summary.errors} errors, ${summary.warnings} warnings, ${summary.infos} infos (${summary.passedChecks}/${summary.totalChecks} checks passaram)`);
165
+ console.log(`Olhei: ${summary.checked.join(", ")}`);
166
+ console.log(`Pulei: ${summary.skipped.join(" | ") || "nada"}
163
167
  `);
164
168
  if (strict && summary.errors > 0) process.exit(2);
165
169
  }
package/dist/index.d.ts CHANGED
@@ -310,7 +310,14 @@ declare const ProjectModelSchema: z.ZodObject<{
310
310
  rulesFile: z.ZodOptional<z.ZodString>;
311
311
  indexesFile: z.ZodOptional<z.ZodString>;
312
312
  }, z.core.$strict>;
313
+ storage: z.ZodOptional<z.ZodObject<{
314
+ rulesFile: z.ZodOptional<z.ZodString>;
315
+ }, z.core.$strict>>;
313
316
  }, z.core.$strict>;
317
+ functions: z.ZodOptional<z.ZodObject<{
318
+ dir: z.ZodOptional<z.ZodString>;
319
+ appCheckEnforced: z.ZodOptional<z.ZodNumber>;
320
+ }, z.core.$strict>>;
314
321
  authorization: z.ZodObject<{
315
322
  adapter: z.ZodNullable<z.ZodString>;
316
323
  roles: z.ZodArray<z.ZodObject<{
@@ -618,13 +625,15 @@ interface DiscoveryResult {
618
625
  firebaseJson: string | null;
619
626
  firestoreRulesFile: string | null;
620
627
  firestoreIndexesFile: string | null;
628
+ storageRulesFile: string | null;
629
+ functionsDir: string | null;
621
630
  auditYamlFile: string | null;
622
631
  projectId: string | null;
623
- /** V2: databaseId nomeado (sempre null na V1). */
632
+ /** databaseId nomeado (sempre null sem config explícita). */
624
633
  databaseId: string | null;
625
- /** V2: preenchido via varredura + ai-tool (sempre [] na V1). */
634
+ /** preenchido via varredura + ai-tool (vazio sem grafo). */
626
635
  clientFiles: string[];
627
- /** V2: preenchido via varredura + ai-tool (sempre [] na V1). */
636
+ /** preenchido via varredura + ai-tool (vazio sem grafo). */
628
637
  serverFiles: string[];
629
638
  }
630
639
  declare function discover(rootDir: string): DiscoveryResult;
@@ -634,7 +643,7 @@ declare const SERVER_HINTS_SEGMENTS: string[];
634
643
  declare function classifyOrigin(file: string, content: string): "CLIENT" | "SERVER" | "UNKNOWN";
635
644
  declare function emptyModel(rootDir: string, d: DiscoveryResult): ProjectModel;
636
645
  /**
637
- * Fase 3 — enriquece Discovery com grafo do ai-tool (como lib, sem spawn/MCP).
646
+ * Fase opcional — enriquece Discovery com grafo do ai-tool (como lib, sem spawn/MCP).
638
647
  * Preenche clientFiles/serverFiles a partir do mapa do projeto.
639
648
  * Nunca joga: falha do ai-tool mantém listas vazias (honesto).
640
649
  */
@@ -724,8 +733,8 @@ declare function findPublicAllowsContent(rawContent: string): {
724
733
  }[];
725
734
 
726
735
  /**
727
- * Checks V1+V2 (FBA001–FBA004, FBA009–FBA013, subconjunto intencional).
728
- * Fingerprints v2: FBA001/FBA002 por regra-lógica + hash da condição
736
+ * Checks (FBA001–FBA004, FBA009–FBA013, subconjunto intencional).
737
+ * Fingerprints: FBA001/FBA002 por regra-lógica + hash da condição
729
738
  * (estáveis a formatação, únicos por regra duplicada);
730
739
  * FBA003/FBA004/FBA010/FBA012 com linha (únicos por ocorrência).
731
740
  */
@@ -736,7 +745,7 @@ declare function runChecks(model: ProjectModel, rootDir: string, opts?: {
736
745
  }): Promise<Finding[]>;
737
746
 
738
747
  /**
739
- * scan — orquestra Discovery → Rules → Checks (V1 static-first).
748
+ * scan — orquestra Discovery → Rules → Checks (estático primeiro).
740
749
  */
741
750
 
742
751
  interface ScanOptions {
@@ -744,7 +753,7 @@ interface ScanOptions {
744
753
  strict?: boolean;
745
754
  /**
746
755
  * Opt-in: enriquece Discovery com grafo do ai-tool (clientFiles/serverFiles).
747
- * Default false (scan static-first rápido; nenhum check consome o grafo na 0.4.0).
756
+ * Default false (scan estático rápido; nenhum check consome o grafo por padrão).
748
757
  */
749
758
  graph?: boolean;
750
759
  }
@@ -752,14 +761,16 @@ interface ScanSummary {
752
761
  errors: number;
753
762
  warnings: number;
754
763
  infos: number;
755
- /** Compat: mesmo que passedChecks. */
756
- passed: number;
757
- /** Compat: mesmo que totalChecks. */
758
- total: number;
759
764
  passedChecks: number;
760
765
  totalChecks: number;
761
- /** Findings de infra (fora dos checks FBA): rules/contrato/índices/model. */
762
- infraSkipped: number;
766
+ /** Infra nomeada (fora dos checks FBA): cada item diz qual e por quê. */
767
+ infra: {
768
+ rule: string;
769
+ message: string;
770
+ }[];
771
+ /** O que foi olhado e o que foi pulado — nunca dizer "tudo certo" sem lista. */
772
+ checked: string[];
773
+ skipped: string[];
763
774
  /** Cobertura de Rules (0-100) quando verify com coverage avalia; null = não observada.
764
775
  * Sempre null no scan() por desenho (cobertura só via verify coveragePct). */
765
776
  coveragePct: number | null;
@@ -772,11 +783,11 @@ interface ScanResult {
772
783
  declare function scan(rootDir: string, opts?: ScanOptions): Promise<ScanResult>;
773
784
 
774
785
  /**
775
- * V3 — Verifier (Emulator) como interface honesta.
786
+ * Verifier (Emulator) como interface honesta.
776
787
  *
777
788
  * Não executa o Emulator sozinho (precisa de firebase-tools + Java no host).
778
- * Gera a matriz de casos (TestCase → Scenario) e avalia cobertura quando
779
- * o JSON do endpoint `:ruleCoverage` é fornecido (--coverage).
789
+ * Gera a matriz de casos e avalia cobertura quando
790
+ * o JSON do endpoint `ruleCoverage` é fornecido (--coverage).
780
791
  */
781
792
 
782
793
  interface VerifyCase {
@@ -804,10 +815,9 @@ declare function planVerify(model: ProjectModel): VerifyCase[];
804
815
  declare function verify(model: ProjectModel, opts?: VerifyOptions): VerifyResult;
805
816
 
806
817
  /**
807
- * V4 — Drift local vs implantado (comparação honesta de índices).
818
+ * Drift local vs implantado (comparação honesta de índices).
808
819
  *
809
- * Compara `firestore.indexes.json` local contra um JSON remoto
810
- * (`gcloud firestore indexes composite list --format=json` ou REST).
820
+ * Compara `firestore.indexes.json` local contra um JSON remoto.
811
821
  * Nunca afirma "unused" — apenas LOCAL_ONLY / REMOTE_ONLY / MATCHED.
812
822
  */
813
823
 
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  createMcpServer,
3
3
  startMcpServer
4
- } from "./chunk-IXMAWDJH.js";
4
+ } from "./chunk-EHGW2EX6.js";
5
5
  import {
6
6
  AccessOriginSchema,
7
7
  AuditYamlSchema,
@@ -53,11 +53,11 @@ import {
53
53
  stripOuterParens,
54
54
  stripRuleComments,
55
55
  validateClaimSamples
56
- } from "./chunk-3SEVYYCX.js";
56
+ } from "./chunk-TQZTE2NR.js";
57
57
  import {
58
58
  planVerify,
59
59
  verify
60
- } from "./chunk-PSSMYRRU.js";
60
+ } from "./chunk-2X3BTSGP.js";
61
61
  import {
62
62
  drift
63
63
  } from "./chunk-NJBCSW7E.js";
package/dist/mcp-cli.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  startMcpServer
4
- } from "./chunk-IXMAWDJH.js";
5
- import "./chunk-3SEVYYCX.js";
6
- import "./chunk-PSSMYRRU.js";
4
+ } from "./chunk-EHGW2EX6.js";
5
+ import "./chunk-TQZTE2NR.js";
6
+ import "./chunk-2X3BTSGP.js";
7
7
  import "./chunk-NJBCSW7E.js";
8
8
 
9
9
  // src/mcp-cli.ts
package/dist/mcp.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  createMcpServer,
3
3
  startMcpServer
4
- } from "./chunk-IXMAWDJH.js";
5
- import "./chunk-3SEVYYCX.js";
6
- import "./chunk-PSSMYRRU.js";
4
+ } from "./chunk-EHGW2EX6.js";
5
+ import "./chunk-TQZTE2NR.js";
6
+ import "./chunk-2X3BTSGP.js";
7
7
  import "./chunk-NJBCSW7E.js";
8
8
  export {
9
9
  createMcpServer,
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  scan
3
- } from "./chunk-3SEVYYCX.js";
3
+ } from "./chunk-TQZTE2NR.js";
4
4
  export {
5
5
  scan
6
6
  };
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  planVerify,
3
3
  verify
4
- } from "./chunk-PSSMYRRU.js";
4
+ } from "./chunk-2X3BTSGP.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.4.3",
3
+ "version": "0.5.0",
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
@@ -4,11 +4,11 @@ Skill para agentes usarem o `@justmpm/firebase-audit` do jeito certo.
4
4
 
5
5
  ## Sequência obrigatória
6
6
 
7
- 1. Descobrir projeto (`firebase.json`, `firestore.rules`, `firestore.indexes.json`)
8
- 2. Verificar `firebase-audit.yaml` (se não existe, V1 roda zero-config)
9
- 3. Inspecionar adapter de autorização (ex: `rbac.can`). Sem adapter resolvido → UNKNOWN, nunca adivinhar
10
- 4. Executar `firebase-audit scan` (ou `--strict` com contrato)
11
- 5. Ler findings (CONFIRMED corrige agora; PROBABLE investiga; NOT_OBSERVED/UNKNOWN não afirma)
7
+ 1. Descobrir projeto (`firebase.json`, `firestore.rules`, `storage.rules`, `firestore.indexes.json`)
8
+ 2. Verificar `firebase-audit.yaml` (se não existe, scan roda zero-config)
9
+ 3. Inspecionar função de permissão no código que recebe o nome como texto (ex: `rbac.can`). Guarda de rota ou function do `.rules` (ex: `isTenantMember`) não vale como adapter — sem adapter, permissões seguem UNKNOWN, nunca adivinhar
10
+ 4. Executar `firebase-audit scan` (ou `check` com contrato no CI)
11
+ 5. Ler findings (CONFIRMED corrige agora; PROBABLE investiga; NOT_OBSERVED/UNKNOWN não afirma) + `summary.skipped` (o que foi pulado)
12
12
  6. Corrigir código, nunca o contrato para esconder finding
13
13
  7. Reexecutar scan antes de declarar pronto
14
14
 
@@ -18,12 +18,14 @@ Skill para agentes usarem o `@justmpm/firebase-audit` do jeito certo.
18
18
  - Nunca transformar UNKNOWN em CONFIRMED sem teste no Emulator
19
19
  - Nunca dizer "unused" — apenas NOT_OBSERVED
20
20
  - Claims: 1000 bytes max, sem chaves OIDC reservadas, só controle de acesso
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 `/{allPaths=**}` aberto
21
23
 
22
- ## Emulador (V3)
24
+ ## Emulador
23
25
 
24
26
  ```bash
25
27
  firebase emulators:exec --only firestore "npm test"
26
28
  ```
27
29
 
28
30
  Matriz `anon/user/admin x read/create/update/delete` com `@firebase/rules-unit-testing`.
29
- Cobertura em `:ruleCoverage` (JSON). Emulator não prova índice.
31
+ Cobertura em `ruleCoverage` (JSON). Emulator não prova índice. Sem contrato, papéis reduzidos (anonymous/user/admin).