@justmpm/firebase-audit 0.1.0 → 0.1.1

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.
@@ -1,7 +1,6 @@
1
1
  // src/discovery.ts
2
2
  import { existsSync, readFileSync } from "fs";
3
3
  import { join, relative } from "path";
4
- var SERVER_HINTS = ["functions/", "src/app/api/", "server/", "admin-sdk", "firebase-admin"];
5
4
  function discover(rootDir) {
6
5
  const pick = (name) => {
7
6
  const p = join(rootDir, name);
@@ -12,6 +11,15 @@ function discover(rootDir) {
12
11
  let databaseId = null;
13
12
  let rulesFile = pick("firestore.rules");
14
13
  let indexesFile = pick("firestore.indexes.json");
14
+ const firebaserc = pick(".firebaserc");
15
+ if (firebaserc) {
16
+ try {
17
+ const raw = JSON.parse(readFileSync(firebaserc, "utf-8"));
18
+ const preferred = raw.projects?.default ?? (raw.projects ? Object.values(raw.projects)[0] : void 0);
19
+ if (typeof preferred === "string" && preferred.length > 0) projectId = preferred;
20
+ } catch {
21
+ }
22
+ }
15
23
  if (firebaseJson) {
16
24
  try {
17
25
  const raw = JSON.parse(readFileSync(firebaseJson, "utf-8"));
@@ -38,13 +46,29 @@ function discover(rootDir) {
38
46
  serverFiles: []
39
47
  };
40
48
  }
49
+ var SERVER_HINTS_SEGMENTS = [
50
+ "functions",
51
+ "server",
52
+ "backend",
53
+ "api",
54
+ "scripts"
55
+ ];
56
+ function hasPathSegment(rel, seg) {
57
+ return rel.split("/").includes(seg);
58
+ }
41
59
  function classifyOrigin(file, content) {
42
60
  const rel = file.replace(/\\/g, "/");
43
- if (content.includes("firebase-admin") || content.includes("firebase-functions")) {
61
+ if (rel.includes("app/api/") || rel.includes("/app/") && rel.endsWith("route.ts") || rel.includes("/app/") && rel.endsWith("route.js")) {
44
62
  return "SERVER";
45
63
  }
46
- if (SERVER_HINTS.some((h) => rel.includes(h))) {
47
- return "SERVER";
64
+ if (rel.includes("src/app/api/")) return "SERVER";
65
+ if (SERVER_HINTS_SEGMENTS.some((s) => hasPathSegment(rel, s))) {
66
+ if (hasPathSegment(rel, "functions") || hasPathSegment(rel, "server") || hasPathSegment(rel, "backend") || hasPathSegment(rel, "scripts") || rel.includes("src/app/api/") || rel.includes("app/api/")) {
67
+ return "SERVER";
68
+ }
69
+ }
70
+ if (content.includes("firebase-admin") || content.includes("firebase-functions")) {
71
+ return "CLIENT";
48
72
  }
49
73
  if (rel.includes("src/") || rel.includes("app/") || rel.includes("components/")) {
50
74
  return "CLIENT";
@@ -76,47 +100,92 @@ function emptyModel(rootDir, d) {
76
100
 
77
101
  // src/rules.ts
78
102
  import { readFileSync as readFileSync2 } from "fs";
79
- var MATCH_RE = /match\s+(\/[^\s{]+)\s*\{/g;
80
- var ALLOW_RE = /allow\s+([^:]+):\s*if\s+([^;]+);/g;
103
+ var MATCH_RE = /match\s+(\/[^{\s]*(?:\{[^}]*\}[^{\s]*)*)\s*\{/g;
104
+ var ALLOW_RE = /allow\s+([^;:]+)(?::\s*if\s+([^;]+))?;/g;
105
+ function stripRuleComments(content) {
106
+ let out = "";
107
+ let i = 0;
108
+ let quote = null;
109
+ while (i < content.length) {
110
+ const c = content[i];
111
+ const next = content[i + 1] ?? "";
112
+ if (quote) {
113
+ out += c;
114
+ if (c === quote && content[i - 1] !== "\\") quote = null;
115
+ i += 1;
116
+ continue;
117
+ }
118
+ if (c === '"' || c === "'" || c === "`") {
119
+ quote = c;
120
+ out += c;
121
+ i += 1;
122
+ continue;
123
+ }
124
+ if (c === "/" && next === "/") {
125
+ while (i < content.length && content[i] !== "\n") i += 1;
126
+ continue;
127
+ }
128
+ if (c === "/" && next === "*") {
129
+ i += 2;
130
+ while (i < content.length && !(content[i] === "*" && content[i + 1] === "/")) {
131
+ if (content[i] === "\n") out += "\n";
132
+ i += 1;
133
+ }
134
+ i += 2;
135
+ continue;
136
+ }
137
+ out += c;
138
+ i += 1;
139
+ }
140
+ return out;
141
+ }
81
142
  function opsFrom(target) {
82
143
  const t = target.trim().toLowerCase();
83
- if (t === "read") return ["read"];
84
- if (t === "write") return ["write", "create", "update", "delete"];
144
+ if (t === "read") return { ops: ["read", "get", "list"], known: true };
145
+ if (t === "write") return { ops: ["write", "create", "update", "delete"], known: true };
85
146
  const parts = t.split(",").map((s) => s.trim()).filter(Boolean);
86
147
  const valid = ["read", "get", "list", "create", "update", "delete", "write"];
87
148
  const out = [];
149
+ let known = false;
88
150
  for (const p of parts) {
89
151
  if (valid.includes(p)) {
152
+ known = true;
90
153
  out.push(p);
91
154
  if (p === "write") {
92
155
  for (const extra of ["create", "update", "delete"]) {
93
156
  if (!out.includes(extra)) out.push(extra);
94
157
  }
95
158
  }
159
+ if (p === "read") {
160
+ for (const extra of ["get", "list"]) {
161
+ if (!out.includes(extra)) out.push(extra);
162
+ }
163
+ }
96
164
  }
97
165
  }
98
- return out.length > 0 ? out : ["read"];
166
+ return { ops: out, known: out.length > 0 && known };
99
167
  }
100
168
  function extractRules(rulesFile, relFile) {
101
- const content = readFileSync2(rulesFile, "utf-8");
102
- const lines = content.split("\n");
169
+ const rawContent = readFileSync2(rulesFile, "utf-8");
170
+ const content = stripRuleComments(rawContent);
103
171
  const rules = [];
104
172
  const matchBlocks = [];
173
+ const matchRe = new RegExp(MATCH_RE.source, "g");
105
174
  let m;
106
- MATCH_RE.lastIndex = 0;
107
- while ((m = MATCH_RE.exec(content)) !== null) {
175
+ while ((m = matchRe.exec(content)) !== null) {
108
176
  const before = content.slice(0, m.index);
109
177
  const line = before.split("\n").length;
110
178
  matchBlocks.push({ path: m[1], line, blockStart: m.index });
111
179
  }
112
- ALLOW_RE.lastIndex = 0;
180
+ const allowRe = new RegExp(ALLOW_RE.source, "g");
113
181
  let a;
114
182
  let idx = 0;
115
- while ((a = ALLOW_RE.exec(content)) !== null) {
183
+ while ((a = allowRe.exec(content)) !== null) {
116
184
  const before = content.slice(0, a.index);
117
185
  const line = before.split("\n").length;
118
186
  const target = a[1];
119
- const condition = a[2].trim();
187
+ const condition = (a[2] ?? "").trim();
188
+ const unconditional = a[2] === void 0;
120
189
  const currentMatch = [...matchBlocks].reverse().find((b) => b.blockStart <= a.index);
121
190
  const authReferences = [];
122
191
  if (condition.includes("request.auth")) authReferences.push("request.auth");
@@ -130,30 +199,70 @@ function extractRules(rulesFile, relFile) {
130
199
  const requestResourceReferences = [
131
200
  ...condition.matchAll(/request\.resource\.data\.([A-Za-z0-9_]+)/g)
132
201
  ].map((x) => x[1]);
202
+ const { ops, known } = opsFrom(target);
133
203
  rules.push({
134
204
  id: `rule-${idx++}`,
135
205
  path: currentMatch?.path ?? "/(unknown)",
136
- operations: opsFrom(target),
137
- conditionPresent: condition.length > 0 && condition !== "true" && condition !== "false",
206
+ operations: ops,
207
+ conditionPresent: !unconditional && condition.length > 0,
138
208
  authReferences,
139
209
  claimReferences,
140
210
  resourceReferences,
141
211
  requestResourceReferences,
142
- confidence: "CONFIRMED",
212
+ confidence: known ? "CONFIRMED" : "UNKNOWN",
143
213
  location: { file: relFile, start: { line, column: 0 } }
144
214
  });
145
- void lines;
146
215
  }
147
216
  return rules;
148
217
  }
218
+ function stripOuterParens(s) {
219
+ let norm = s.trim();
220
+ for (; ; ) {
221
+ if (!(norm.startsWith("(") && norm.endsWith(")"))) return norm;
222
+ let depth = 0;
223
+ let wrapsAll = true;
224
+ for (let i = 0; i < norm.length; i++) {
225
+ if (norm[i] === "(") depth += 1;
226
+ else if (norm[i] === ")") {
227
+ depth -= 1;
228
+ if (depth === 0 && i !== norm.length - 1) {
229
+ wrapsAll = false;
230
+ break;
231
+ }
232
+ }
233
+ }
234
+ if (!wrapsAll || depth !== 0) return norm;
235
+ norm = norm.slice(1, -1).trim();
236
+ }
237
+ }
238
+ function isPublicCondition(condition) {
239
+ if (condition === void 0) return { isPublic: true, confidence: "CONFIRMED" };
240
+ const norm = stripOuterParens(condition);
241
+ const low = norm.toLowerCase().replace(/\s+/g, "");
242
+ if (low === "true") return { isPublic: true, confidence: "CONFIRMED" };
243
+ if (/(^|\|\|)true($|\|\||&&)/.test(low)) return { isPublic: true, confidence: "PROBABLE" };
244
+ if (low.includes("request.auth==null") || low.includes("request.auth===null")) {
245
+ return { isPublic: true, confidence: "CONFIRMED" };
246
+ }
247
+ return { isPublic: false, confidence: "CONFIRMED" };
248
+ }
149
249
  function findPublicAllows(rulesFile) {
150
- const content = readFileSync2(rulesFile, "utf-8");
250
+ const rawContent = readFileSync2(rulesFile, "utf-8");
251
+ const content = stripRuleComments(rawContent);
151
252
  const out = [];
152
- const re = /allow\s+([^:]+):\s*if\s+true\s*;/g;
253
+ const matchRe = new RegExp(MATCH_RE.source, "g");
254
+ const blocks = [];
255
+ let mm0;
256
+ while ((mm0 = matchRe.exec(content)) !== null) blocks.push({ path: mm0[1], index: mm0.index });
257
+ const re = new RegExp(ALLOW_RE.source, "g");
153
258
  let mm;
154
259
  while ((mm = re.exec(content)) !== null) {
155
260
  const line = content.slice(0, mm.index).split("\n").length;
156
- out.push({ line, target: mm[1].trim() });
261
+ const target = mm[1].trim();
262
+ const check = isPublicCondition(mm[2]);
263
+ if (!check.isPublic) continue;
264
+ const path = [...blocks].reverse().find((b) => b.index <= mm.index)?.path ?? "/(unknown)";
265
+ out.push({ line, target, path, confidence: check.confidence });
157
266
  }
158
267
  return out;
159
268
  }
@@ -162,15 +271,20 @@ function findPublicAllows(rulesFile) {
162
271
  import { readFileSync as readFileSync3, existsSync as existsSync2 } from "fs";
163
272
  import { join as join2 } from "path";
164
273
  import { executeFind } from "@justmpm/supergrep";
165
- var evidenceCounter = 0;
166
- function ev(kind, summary, confidence, file, line) {
167
- evidenceCounter += 1;
274
+ var IMPLEMENTED_CHECKS = ["FBA001", "FBA002", "FBA003", "FBA004", "FBA009", "FBA010", "FBA011"];
275
+ function createEvidence() {
276
+ let counter = 0;
168
277
  return {
169
- id: `ev-${evidenceCounter}`,
170
- kind,
171
- summary,
172
- confidence,
173
- location: file && line ? { file, start: { line, column: 0 } } : void 0
278
+ ev: (kind, summary, confidence, file, line) => {
279
+ counter += 1;
280
+ return {
281
+ id: `ev-${counter}`,
282
+ kind,
283
+ summary,
284
+ confidence,
285
+ location: file && line ? { file, start: { line, column: 0 } } : void 0
286
+ };
287
+ }
174
288
  };
175
289
  }
176
290
  function make(rule, severity, confidence, message, fingerprint, opts = {}) {
@@ -185,27 +299,30 @@ function make(rule, severity, confidence, message, fingerprint, opts = {}) {
185
299
  };
186
300
  }
187
301
  async function runChecks(model, rootDir, opts = {}) {
302
+ const { ev } = createEvidence();
188
303
  const findings = [];
189
304
  const relRules = model.firebase.firestore.rulesFile;
190
305
  const absRules = relRules ? join2(rootDir, relRules) : null;
191
306
  if (absRules && existsSync2(absRules)) {
192
307
  for (const pub of findPublicAllows(absRules)) {
193
308
  const t = pub.target.toLowerCase();
194
- const isWrite = t.includes("write") || t.includes("create") || t.includes("update") || t.includes("delete");
195
- const isRead = t.includes("read") || t.includes("get") || t.includes("list");
309
+ const tokens = t.split(",").map((s) => s.trim());
310
+ const isWrite = tokens.some((x) => ["write", "create", "update", "delete"].includes(x));
311
+ const isRead = tokens.some((x) => ["read", "get", "list"].includes(x));
196
312
  if (isWrite) {
197
313
  findings.push(
198
314
  make(
199
315
  "FBA001",
200
316
  "ERROR",
201
- "CONFIRMED",
202
- `Public write aberto (${pub.target}: if true). Qualquer cliente pode escrever.`,
203
- `FBA001:${relRules}:${pub.target}`,
317
+ pub.confidence,
318
+ `Escrita p\xFAblica em ${pub.path} (${pub.target}). Qualquer cliente pode escrever.`,
319
+ `FBA001:${relRules}:${pub.path}:${pub.target}`,
204
320
  {
205
321
  file: relRules,
206
322
  line: pub.line,
207
- evidence: [ev("public-allow", `${pub.target}: if true`, "CONFIRMED", relRules, pub.line)],
208
- fix: "Troque `if true` por checagem de auth/claim. Se for proposital, documente no contrato."
323
+ resource: pub.path,
324
+ evidence: [ev("public-allow", `${pub.target} em ${pub.path}`, pub.confidence, relRules, pub.line)],
325
+ fix: "Troque por checagem de auth/claim. Se for proposital, documente no contrato."
209
326
  }
210
327
  )
211
328
  );
@@ -215,13 +332,14 @@ async function runChecks(model, rootDir, opts = {}) {
215
332
  make(
216
333
  "FBA002",
217
334
  "WARNING",
218
- "CONFIRMED",
219
- `Leitura p\xFAblica confirmada (${pub.target}: if true). Pode ser proposital.`,
220
- `FBA002:${relRules}:${pub.target}`,
335
+ pub.confidence,
336
+ `Leitura p\xFAblica em ${pub.path} (${pub.target}). Pode ser proposital.`,
337
+ `FBA002:${relRules}:${pub.path}:${pub.target}`,
221
338
  {
222
339
  file: relRules,
223
340
  line: pub.line,
224
- evidence: [ev("public-allow", `${pub.target}: if true`, "CONFIRMED", relRules, pub.line)]
341
+ resource: pub.path,
342
+ evidence: [ev("public-allow", `${pub.target} em ${pub.path}`, pub.confidence, relRules, pub.line)]
225
343
  }
226
344
  )
227
345
  );
@@ -231,7 +349,7 @@ async function runChecks(model, rootDir, opts = {}) {
231
349
  const adapterFn = opts.adapterFn ?? "rbac.can";
232
350
  const permissionCalls = await collectPermissionCalls(rootDir, adapterFn);
233
351
  const declared = new Set(model.authorization.permissions.map((p) => p.name));
234
- if (permissionCalls.length === 0) {
352
+ if (permissionCalls.literals.length === 0 && permissionCalls.dynamic.length === 0) {
235
353
  findings.push(
236
354
  make(
237
355
  "FBA009",
@@ -242,8 +360,19 @@ async function runChecks(model, rootDir, opts = {}) {
242
360
  { evidence: [ev("adapter-missing", adapterFn, "UNKNOWN")] }
243
361
  )
244
362
  );
363
+ } else if (declared.size === 0) {
364
+ findings.push(
365
+ make(
366
+ "FBA009",
367
+ "INFO",
368
+ "NOT_OBSERVED",
369
+ `Permiss\xF5es usadas sem contrato (${permissionCalls.literals.length} literais). Crie firebase-audit.yaml para ativar FBA004.`,
370
+ `FBA009:no-contract`,
371
+ { evidence: [ev("permissions-without-contract", `${permissionCalls.literals.length} calls`, "NOT_OBSERVED")] }
372
+ )
373
+ );
245
374
  }
246
- for (const call of permissionCalls) {
375
+ for (const call of permissionCalls.literals) {
247
376
  if (!declared.has(call.permission) && declared.size > 0) {
248
377
  findings.push(
249
378
  make(
@@ -251,11 +380,11 @@ async function runChecks(model, rootDir, opts = {}) {
251
380
  "ERROR",
252
381
  "CONFIRMED",
253
382
  `Permission "${call.permission}" usada mas n\xE3o declarada no contrato.`,
254
- `FBA004:${call.file}:${call.permission}`,
383
+ `FBA004:${call.file}:${call.line}:${call.permission}`,
255
384
  {
256
385
  file: call.file,
257
386
  line: call.line,
258
- resource: call.permission.split(".")[0],
387
+ resource: call.permission.includes(".") ? call.permission.split(".")[0] : call.permission,
259
388
  evidence: [ev("permission-call", `${adapterFn}("${call.permission}")`, "CONFIRMED", call.file, call.line)],
260
389
  fix: `Declarar "${call.permission}" em firebase-audit.yaml ou corrigir o nome da chamada.`
261
390
  }
@@ -263,6 +392,41 @@ async function runChecks(model, rootDir, opts = {}) {
263
392
  );
264
393
  }
265
394
  }
395
+ for (const role of model.authorization.roles) {
396
+ for (const perm of role.permissions) {
397
+ if (!declared.has(perm)) {
398
+ findings.push(
399
+ make(
400
+ "FBA011",
401
+ "ERROR",
402
+ "CONFIRMED",
403
+ `Role "${role.name}" referencia permission "${perm}" n\xE3o declarada em permissions.`,
404
+ `FBA011:${role.name}:${perm}`,
405
+ {
406
+ evidence: [ev("role-permission", `${role.name} \u2192 ${perm}`, "CONFIRMED")],
407
+ fix: `Declarar "${perm}" em authorization.permissions.`
408
+ }
409
+ )
410
+ );
411
+ }
412
+ }
413
+ }
414
+ for (const dyn of permissionCalls.dynamic) {
415
+ findings.push(
416
+ make(
417
+ "FBA010",
418
+ "INFO",
419
+ "UNKNOWN",
420
+ `Chamada de permiss\xE3o din\xE2mica em ${dyn.file}:${dyn.line} \u2014 campo n\xE3o resolvido estaticamente.`,
421
+ `FBA010:${dyn.file}:${dyn.line}`,
422
+ {
423
+ file: dyn.file,
424
+ line: dyn.line,
425
+ evidence: [ev("dynamic-permission", dyn.text, "UNKNOWN", dyn.file, dyn.line)]
426
+ }
427
+ )
428
+ );
429
+ }
266
430
  const adminHits = await collectAdminImports(rootDir);
267
431
  for (const hit of adminHits) {
268
432
  let origin = "UNKNOWN";
@@ -279,7 +443,7 @@ async function runChecks(model, rootDir, opts = {}) {
279
443
  "ERROR",
280
444
  "PROBABLE",
281
445
  `Admin SDK em arquivo de cliente (${hit.file}). Rules n\xE3o valem aqui \u2014 precisa auth da app.`,
282
- `FBA003:${hit.file}`,
446
+ `FBA003:${hit.file}:${hit.line}`,
283
447
  {
284
448
  file: hit.file,
285
449
  line: hit.line,
@@ -290,59 +454,73 @@ async function runChecks(model, rootDir, opts = {}) {
290
454
  );
291
455
  }
292
456
  }
293
- if (model.indexes.length > 0 && model.queries.length === 0) {
294
- findings.push(
295
- make(
296
- "FBA007",
297
- "INFO",
298
- "NOT_OBSERVED",
299
- "\xCDndices locais sem query observada (query-shape V1 ainda parcial). N\xE3o significa unused.",
300
- "FBA007:indexes",
301
- { evidence: [ev("index-not-observed", `${model.indexes.length} \xEDndices`, "NOT_OBSERVED")] }
302
- )
303
- );
304
- }
305
457
  return findings;
306
458
  }
307
459
  async function collectPermissionCalls(rootDir, adapterFn) {
308
- const out = [];
309
- const short = adapterFn.includes(".") ? adapterFn.split(".").pop() : adapterFn;
310
- const patterns = [`${short}($PERM)`, `${adapterFn}($PERM)`];
460
+ const literals = [];
461
+ const dynamic = [];
462
+ if (!adapterFn.includes(".")) {
463
+ return { literals, dynamic };
464
+ }
465
+ const bases = [adapterFn];
466
+ const patterns = [];
467
+ for (const b of bases) {
468
+ patterns.push(`${b}($PERM)`, `${b}($PERM, $$$ARGS)`);
469
+ }
470
+ const seen = /* @__PURE__ */ new Set();
471
+ const pushLiteral = (file, line, permission) => {
472
+ const k = `${file}:${line}:${permission}`;
473
+ if (seen.has(k)) return;
474
+ seen.add(k);
475
+ literals.push({ file, line, permission });
476
+ };
311
477
  for (const pattern of patterns) {
312
478
  try {
313
479
  const { result } = await executeFind({ pattern, path: ".", cwd: rootDir });
314
480
  for (const m of result.matches) {
315
- const perm = (m.metaVariables["PERM"] ?? "").replace(/['"]/g, "").trim();
316
- if (perm.length >= 3) {
317
- out.push({ file: m.file, line: m.line + 1, permission: perm });
481
+ const raw = (m.metaVariables["PERM"] ?? "").trim();
482
+ const line = m.line + 1;
483
+ const isQuoted = /^['"].*['"]$/.test(raw);
484
+ if (isQuoted) {
485
+ const perm = raw.replace(/^['"]|['"]$/g, "").trim();
486
+ if (perm.length >= 3) pushLiteral(m.file, line, perm);
487
+ } else if (raw.length > 0) {
488
+ const k = `${m.file}:${line}:${raw}`;
489
+ if (!seen.has(k)) {
490
+ seen.add(k);
491
+ dynamic.push({ file: m.file, line, text: m.text });
492
+ }
318
493
  }
319
494
  }
320
495
  } catch {
321
496
  }
322
497
  }
323
- const seen = /* @__PURE__ */ new Set();
324
- return out.filter((o) => {
325
- const k = `${o.file}:${o.line}:${o.permission}`;
326
- if (seen.has(k)) return false;
327
- seen.add(k);
328
- return true;
329
- });
498
+ return { literals, dynamic };
330
499
  }
331
500
  async function collectAdminImports(rootDir) {
332
501
  const out = [];
333
- try {
334
- const { result } = await executeFind({
335
- pattern: "import { $$$ITEMS } from '$MODULE'",
336
- path: ".",
337
- cwd: rootDir
338
- });
339
- for (const m of result.matches) {
340
- const mod = m.metaVariables["MODULE"] ?? "";
341
- if (mod.includes("firebase-admin")) {
342
- out.push({ file: m.file, line: m.line + 1, text: m.text });
502
+ const patterns = [
503
+ 'import { $$$ITEMS } from "$MODULE"',
504
+ 'import $DEFAULT from "$MODULE"',
505
+ 'import * as $NS from "$MODULE"',
506
+ 'const $X = require("$MODULE")',
507
+ 'require("$MODULE")'
508
+ ];
509
+ const seen = /* @__PURE__ */ new Set();
510
+ for (const pattern of patterns) {
511
+ try {
512
+ const { result } = await executeFind({ pattern, path: ".", cwd: rootDir });
513
+ for (const m of result.matches) {
514
+ const mod = m.metaVariables["MODULE"] ?? "";
515
+ if (mod.includes("firebase-admin") || mod.includes("firebase-functions")) {
516
+ const k = `${m.file}:${m.line}:${m.text}`;
517
+ if (seen.has(k)) continue;
518
+ seen.add(k);
519
+ out.push({ file: m.file, line: m.line + 1, text: m.text });
520
+ }
343
521
  }
522
+ } catch {
344
523
  }
345
- } catch {
346
524
  }
347
525
  return out;
348
526
  }
@@ -354,7 +532,8 @@ var ConfidenceSchema = z.enum([
354
532
  "CONFIRMED",
355
533
  "PROBABLE",
356
534
  "NOT_OBSERVED",
357
- "UNKNOWN"
535
+ "UNKNOWN",
536
+ "CONFLICTING"
358
537
  ]);
359
538
  var OperationSchema = z.enum([
360
539
  "read",
@@ -503,7 +682,7 @@ var FindingSchema = z.strictObject({
503
682
  fingerprint: z.string().min(1),
504
683
  metadata: z.record(z.string(), z.unknown()).optional()
505
684
  });
506
- var PermissionNameSchema = z.string().regex(/^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)?$/);
685
+ var PermissionNameSchema = z.string().regex(/^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)*$/);
507
686
  var RoleNameSchema = z.string().regex(/^[a-z][a-z0-9_-]*$/);
508
687
  var AuditYamlSchema = z.strictObject({
509
688
  authorization: z.strictObject({
@@ -523,10 +702,23 @@ var AuditYamlSchema = z.strictObject({
523
702
 
524
703
  // src/scan.ts
525
704
  import { readFileSync as readFileSync4, existsSync as existsSync3 } from "fs";
526
- import { join as join3 } from "path";
705
+ import { parse as parseYaml } from "yaml";
527
706
  async function scan(rootDir, opts = {}) {
528
707
  const d = discover(rootDir);
529
708
  const model = emptyModel(rootDir, d);
709
+ const findings_pre = [];
710
+ if (!d.firestoreRulesFile || !existsSync3(d.firestoreRulesFile)) {
711
+ findings_pre.push({
712
+ rule: "RULES_NOT_OBSERVED",
713
+ severity: "INFO",
714
+ confidence: "NOT_OBSERVED",
715
+ message: "firestore.rules n\xE3o encontrado \u2014 FBA001/FBA002 sem cobertura neste scan.",
716
+ fingerprint: "RULES_NOT_OBSERVED",
717
+ evidence: [
718
+ { id: "ev-no-rules", kind: "rules-missing", summary: "sem rules", confidence: "NOT_OBSERVED" }
719
+ ]
720
+ });
721
+ }
530
722
  if (d.firestoreRulesFile && existsSync3(d.firestoreRulesFile)) {
531
723
  const rel = d.firestoreRulesFile.replace(/\\/g, "/").startsWith(rootDir.replace(/\\/g, "/")) ? d.firestoreRulesFile.slice(rootDir.length + 1).replace(/\\/g, "/") : model.firebase.firestore.rulesFile ?? "firestore.rules";
532
724
  model.rules = extractRules(d.firestoreRulesFile, rel);
@@ -539,16 +731,26 @@ async function scan(rootDir, opts = {}) {
539
731
  queryScope: i.queryScope === "COLLECTION_GROUP" ? "COLLECTION_GROUP" : "COLLECTION",
540
732
  fields: (i.fields ?? []).map((f) => ({
541
733
  fieldPath: f.fieldPath ?? "(unknown)",
542
- mode: f.arrayConfig ? "ARRAY_CONTAINS" : f.order ?? "ASCENDING"
734
+ mode: f.vectorConfig ? "VECTOR" : f.arrayConfig === "CONTAINS" ? "ARRAY_CONTAINS" : f.order ?? "ASCENDING"
543
735
  }))
544
736
  })) ?? [];
545
737
  } catch {
738
+ findings_pre.push({
739
+ rule: "INDEXES_INVALID",
740
+ severity: "WARNING",
741
+ confidence: "CONFIRMED",
742
+ message: "firestore.indexes.json inv\xE1lido \u2014 \xEDndices ignorados neste scan.",
743
+ fingerprint: "INDEXES_INVALID",
744
+ evidence: [
745
+ { id: "ev-indexes-invalid", kind: "indexes-parse", summary: "JSON inv\xE1lido", confidence: "CONFIRMED" }
746
+ ]
747
+ });
546
748
  }
547
749
  }
548
750
  if (d.auditYamlFile && existsSync3(d.auditYamlFile)) {
549
751
  try {
550
752
  const text = readFileSync4(d.auditYamlFile, "utf-8");
551
- const parsed = parseSimpleYaml(text);
753
+ const parsed = parseYaml(text);
552
754
  const validated = AuditYamlSchema.safeParse(parsed);
553
755
  if (validated.success) {
554
756
  const auth = validated.data.authorization;
@@ -562,11 +764,47 @@ async function scan(rootDir, opts = {}) {
562
764
  name,
563
765
  permissions: r.permissions
564
766
  }));
767
+ } else {
768
+ findings_pre.push({
769
+ rule: "CONTRACT_INVALID",
770
+ severity: "WARNING",
771
+ confidence: "CONFIRMED",
772
+ message: `firebase-audit.yaml existe mas \xE9 inv\xE1lido: ${validated.error.issues[0]?.message ?? "schema"} \u2014 contrato ignorado neste scan.`,
773
+ fingerprint: "CONTRACT_INVALID",
774
+ evidence: [
775
+ { id: "ev-contract-invalid", kind: "contract-parse", summary: "YAML inv\xE1lido", confidence: "CONFIRMED" }
776
+ ],
777
+ fix: "Valide contra templates/firebase-audit.yaml."
778
+ });
565
779
  }
566
- } catch {
780
+ } catch (err) {
781
+ findings_pre.push({
782
+ rule: "CONTRACT_INVALID",
783
+ severity: "WARNING",
784
+ confidence: "CONFIRMED",
785
+ message: `firebase-audit.yaml n\xE3o p\xF4de ser lido: ${err instanceof Error ? err.message : "erro"} \u2014 contrato ignorado.`,
786
+ fingerprint: "CONTRACT_INVALID",
787
+ evidence: [
788
+ { id: "ev-contract-unreadable", kind: "contract-parse", summary: "leitura falhou", confidence: "CONFIRMED" }
789
+ ]
790
+ });
567
791
  }
568
792
  }
569
- const findings = await runChecks(model, rootDir, { adapterFn: opts.adapterFn ?? model.authorization.adapter ?? void 0 });
793
+ const checksFindings = await runChecks(model, rootDir, { adapterFn: opts.adapterFn ?? model.authorization.adapter ?? void 0 });
794
+ const findings = [...findings_pre, ...checksFindings];
795
+ const modelCheck = ProjectModelSchema.safeParse(model);
796
+ if (!modelCheck.success) {
797
+ findings.push({
798
+ rule: "MODEL_INVALID",
799
+ severity: "WARNING",
800
+ confidence: "CONFIRMED",
801
+ message: `ProjectModel inv\xE1lido: ${modelCheck.error.issues[0]?.message ?? "schema"} \u2014 verifique indexes/rules.`,
802
+ fingerprint: "MODEL_INVALID",
803
+ evidence: [
804
+ { id: "ev-model-invalid", kind: "model-validate", summary: "schema", confidence: "CONFIRMED" }
805
+ ]
806
+ });
807
+ }
570
808
  if (opts.strict) {
571
809
  const hasContract = model.authorization.permissions.length > 0;
572
810
  if (!hasContract) {
@@ -586,74 +824,18 @@ async function scan(rootDir, opts = {}) {
586
824
  const errors = findings.filter((f) => f.severity === "ERROR").length;
587
825
  const warnings = findings.filter((f) => f.severity === "WARNING").length;
588
826
  const infos = findings.filter((f) => f.severity === "INFO").length;
589
- return { model, findings, summary: { errors, warnings, infos, passed: findings.length === 0 ? 10 : 0 } };
590
- }
591
- function parseSimpleYaml(text) {
592
- const lines = text.split("\n");
593
- const root = {};
594
- const stack = [
595
- { indent: -1, obj: root }
596
- ];
597
- let pendingListKey = null;
598
- const cur = () => stack[stack.length - 1].obj;
599
- for (const rawLine of lines) {
600
- const line = rawLine.replace(/\t/g, " ");
601
- if (line.trim() === "" || line.trim().startsWith("#")) continue;
602
- const indent = line.length - line.trimStart().length;
603
- const trimmed = line.trim();
604
- if (trimmed.startsWith("- ")) {
605
- const val = trimmed.slice(2).trim();
606
- if (pendingListKey && indent >= pendingListKey.indent) {
607
- pendingListKey.arr.push(val);
608
- continue;
609
- }
610
- continue;
611
- }
612
- while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
613
- stack.pop();
614
- }
615
- pendingListKey = null;
616
- const colon = trimmed.indexOf(":");
617
- if (colon === -1) continue;
618
- const key = trimmed.slice(0, colon).trim();
619
- const rest = trimmed.slice(colon + 1).trim();
620
- if (rest === "") {
621
- const child = {};
622
- cur()[key] = child;
623
- stack.push({ indent, obj: child, key });
624
- const arr = [];
625
- child.__maybeList = arr;
626
- pendingListKey = { indent: indent + 1, arr };
627
- stack[stack.length - 1].listProbe = arr;
628
- void join3;
629
- } else {
630
- const parent = cur();
631
- delete parent.__maybeList;
632
- parent[key] = rest === "true" ? true : rest === "false" ? false : rest;
633
- }
634
- }
635
- const fix = (o) => {
636
- if (Array.isArray(o)) return o.map(fix);
637
- if (o && typeof o === "object") {
638
- const rec = o;
639
- if (Array.isArray(rec.__maybeList) && Object.keys(rec).length === 1) {
640
- return rec.__maybeList;
641
- }
642
- delete rec.__maybeList;
643
- for (const k of Object.keys(rec)) rec[k] = fix(rec[k]);
644
- return rec;
645
- }
646
- return o;
647
- };
648
- return fix(root);
827
+ return { model, findings, summary: { errors, warnings, infos, passed: IMPLEMENTED_CHECKS.length } };
649
828
  }
650
829
 
651
830
  export {
652
831
  discover,
653
832
  classifyOrigin,
654
833
  emptyModel,
834
+ stripRuleComments,
655
835
  extractRules,
836
+ isPublicCondition,
656
837
  findPublicAllows,
838
+ IMPLEMENTED_CHECKS,
657
839
  runChecks,
658
840
  SeveritySchema,
659
841
  ConfidenceSchema,
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  scan
4
- } from "./chunk-VKCDUCYE.js";
4
+ } from "./chunk-N72YT24M.js";
5
5
 
6
6
  // src/cli.ts
7
7
  import { createRequire } from "module";
@@ -34,7 +34,9 @@ Uso: firebase-audit scan [--json] [--strict] [--adapter=fn] [--cwd=path]`);
34
34
  FIREBASE AUDIT v${pkg.version}`);
35
35
  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");
36
36
  if (findings.length === 0) {
37
- console.log("\n\u2713 Nenhum problema encontrado (V1 static-first, 10 checks).\n");
37
+ console.log(`
38
+ \u2713 Nenhum problema nos checks V1 implementados (parcial: FBA001-FBA004, FBA009-FBA011).
39
+ `);
38
40
  return;
39
41
  }
40
42
  for (const f of findings) {
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as z from 'zod';
2
- import z__default from 'zod';
2
+ import { z as z$1 } from 'zod';
3
3
 
4
4
  /**
5
5
  * firebase-audit — schemas centrais (Zod v4).
@@ -19,6 +19,7 @@ declare const ConfidenceSchema: z.ZodEnum<{
19
19
  PROBABLE: "PROBABLE";
20
20
  NOT_OBSERVED: "NOT_OBSERVED";
21
21
  UNKNOWN: "UNKNOWN";
22
+ CONFLICTING: "CONFLICTING";
22
23
  }>;
23
24
  declare const OperationSchema: z.ZodEnum<{
24
25
  read: "read";
@@ -69,6 +70,7 @@ declare const EvidenceSchema: z.ZodObject<{
69
70
  PROBABLE: "PROBABLE";
70
71
  NOT_OBSERVED: "NOT_OBSERVED";
71
72
  UNKNOWN: "UNKNOWN";
73
+ CONFLICTING: "CONFLICTING";
72
74
  }>;
73
75
  value: z.ZodOptional<z.ZodUnknown>;
74
76
  fingerprint: z.ZodOptional<z.ZodString>;
@@ -170,6 +172,7 @@ declare const QueryShapeSchema: z.ZodObject<{
170
172
  PROBABLE: "PROBABLE";
171
173
  NOT_OBSERVED: "NOT_OBSERVED";
172
174
  UNKNOWN: "UNKNOWN";
175
+ CONFLICTING: "CONFLICTING";
173
176
  }>;
174
177
  location: z.ZodObject<{
175
178
  file: z.ZodString;
@@ -222,6 +225,7 @@ declare const RuleSchema: z.ZodObject<{
222
225
  PROBABLE: "PROBABLE";
223
226
  NOT_OBSERVED: "NOT_OBSERVED";
224
227
  UNKNOWN: "UNKNOWN";
228
+ CONFLICTING: "CONFLICTING";
225
229
  }>;
226
230
  location: z.ZodObject<{
227
231
  file: z.ZodString;
@@ -288,6 +292,7 @@ declare const GraphEdgeSchema: z.ZodObject<{
288
292
  PROBABLE: "PROBABLE";
289
293
  NOT_OBSERVED: "NOT_OBSERVED";
290
294
  UNKNOWN: "UNKNOWN";
295
+ CONFLICTING: "CONFLICTING";
291
296
  }>;
292
297
  evidenceIds: z.ZodArray<z.ZodString>;
293
298
  }, z.core.$strict>;
@@ -378,6 +383,7 @@ declare const ProjectModelSchema: z.ZodObject<{
378
383
  PROBABLE: "PROBABLE";
379
384
  NOT_OBSERVED: "NOT_OBSERVED";
380
385
  UNKNOWN: "UNKNOWN";
386
+ CONFLICTING: "CONFLICTING";
381
387
  }>;
382
388
  location: z.ZodObject<{
383
389
  file: z.ZodString;
@@ -413,6 +419,7 @@ declare const ProjectModelSchema: z.ZodObject<{
413
419
  PROBABLE: "PROBABLE";
414
420
  NOT_OBSERVED: "NOT_OBSERVED";
415
421
  UNKNOWN: "UNKNOWN";
422
+ CONFLICTING: "CONFLICTING";
416
423
  }>;
417
424
  location: z.ZodObject<{
418
425
  file: z.ZodString;
@@ -473,6 +480,7 @@ declare const ProjectModelSchema: z.ZodObject<{
473
480
  PROBABLE: "PROBABLE";
474
481
  NOT_OBSERVED: "NOT_OBSERVED";
475
482
  UNKNOWN: "UNKNOWN";
483
+ CONFLICTING: "CONFLICTING";
476
484
  }>;
477
485
  value: z.ZodOptional<z.ZodUnknown>;
478
486
  fingerprint: z.ZodOptional<z.ZodString>;
@@ -494,6 +502,7 @@ declare const ProjectModelSchema: z.ZodObject<{
494
502
  PROBABLE: "PROBABLE";
495
503
  NOT_OBSERVED: "NOT_OBSERVED";
496
504
  UNKNOWN: "UNKNOWN";
505
+ CONFLICTING: "CONFLICTING";
497
506
  }>;
498
507
  evidenceIds: z.ZodArray<z.ZodString>;
499
508
  }, z.core.$strict>>;
@@ -510,6 +519,7 @@ declare const FindingSchema: z.ZodObject<{
510
519
  PROBABLE: "PROBABLE";
511
520
  NOT_OBSERVED: "NOT_OBSERVED";
512
521
  UNKNOWN: "UNKNOWN";
522
+ CONFLICTING: "CONFLICTING";
513
523
  }>;
514
524
  file: z.ZodOptional<z.ZodString>;
515
525
  line: z.ZodOptional<z.ZodNumber>;
@@ -534,6 +544,7 @@ declare const FindingSchema: z.ZodObject<{
534
544
  PROBABLE: "PROBABLE";
535
545
  NOT_OBSERVED: "NOT_OBSERVED";
536
546
  UNKNOWN: "UNKNOWN";
547
+ CONFLICTING: "CONFLICTING";
537
548
  }>;
538
549
  value: z.ZodOptional<z.ZodUnknown>;
539
550
  fingerprint: z.ZodOptional<z.ZodString>;
@@ -597,12 +608,15 @@ interface DiscoveryResult {
597
608
  firestoreIndexesFile: string | null;
598
609
  auditYamlFile: string | null;
599
610
  projectId: string | null;
611
+ /** V2: databaseId nomeado (sempre null na V1). */
600
612
  databaseId: string | null;
613
+ /** V2: preenchido via varredura + ai-tool (sempre [] na V1). */
601
614
  clientFiles: string[];
615
+ /** V2: preenchido via varredura + ai-tool (sempre [] na V1). */
602
616
  serverFiles: string[];
603
617
  }
604
618
  declare function discover(rootDir: string): DiscoveryResult;
605
- /** Heurística simples CLIENT vs SERVER por caminho + import admin. */
619
+ /** Heurística CLIENT vs SERVER: caminho manda primeiro (grafo/voz do ai-tool na V2). */
606
620
  declare function classifyOrigin(file: string, content: string): "CLIENT" | "SERVER" | "UNKNOWN";
607
621
  declare function emptyModel(rootDir: string, d: DiscoveryResult): ProjectModel;
608
622
 
@@ -611,19 +625,32 @@ declare function emptyModel(rootDir: string, d: DiscoveryResult): ProjectModel;
611
625
  * Parser leve e intencionalmente incompleto: avaliação real é do Emulator (V3).
612
626
  */
613
627
 
614
- type Rule = z__default.infer<typeof RuleSchema>;
628
+ type Rule = z$1.infer<typeof RuleSchema>;
629
+ /** Remove comentários // e block preservando strings simples. */
630
+ declare function stripRuleComments(content: string): string;
615
631
  declare function extractRules(rulesFile: string, relFile: string): Rule[];
616
- /** Detecta allow ... if true preservando linha para FBA001/FBA002. */
632
+ /** Normaliza condição para detectar público: sem if, `true`, `(true)`, `|| true`, `auth == null`. */
633
+ declare function isPublicCondition(condition: string | undefined): {
634
+ isPublic: boolean;
635
+ confidence: "CONFIRMED" | "PROBABLE";
636
+ };
637
+ /** Detecta allows públicos preservando linha, path e confiança para FBA001/FBA002. */
617
638
  declare function findPublicAllows(rulesFile: string): {
618
639
  line: number;
619
640
  target: string;
641
+ path: string;
642
+ confidence: "CONFIRMED" | "PROBABLE";
620
643
  }[];
621
644
 
622
645
  /**
623
- * Checks estáticos V1 (FBA001–FBA010, subconjunto intencional).
646
+ * Checks estáticos V1 (FBA001–FBA004, FBA009–FBA011, subconjunto intencional).
624
647
  * Cada check retorna Findings com fingerprint estável para CI.
648
+ * Nota: FBA001/FBA002 sem linha no fingerprint (estável a formatação);
649
+ * FBA003/FBA004/FBA010 com linha (únicos por ocorrência — duplicatas no mesmo
650
+ * arquivo geram findings distintos).
625
651
  */
626
652
 
653
+ declare const IMPLEMENTED_CHECKS: readonly ["FBA001", "FBA002", "FBA003", "FBA004", "FBA009", "FBA010", "FBA011"];
627
654
  declare function runChecks(model: ProjectModel, rootDir: string, opts?: {
628
655
  adapterFn?: string;
629
656
  }): Promise<Finding[]>;
@@ -654,4 +681,4 @@ declare function scan(rootDir: string, opts?: ScanOptions): Promise<ScanResult>;
654
681
 
655
682
  declare const VERSION: string;
656
683
 
657
- export { AccessOriginSchema, type AuditYaml, AuditYamlSchema, ConfidenceSchema, type DiscoveryResult, DynamicValueSchema, type Evidence, EvidenceSchema, type Finding, FindingSchema, FirestoreIndexSchema, GraphEdgeSchema, IndexFieldSchema, LocationSchema, OperationSchema, PermissionSchema, PositionSchema, type ProjectModel, ProjectModelSchema, QueryFilterSchema, QueryOrderSchema, type QueryShape, QueryShapeSchema, RoleSchema, RuleSchema, SeveritySchema, VERSION, classifyOrigin, discover, emptyModel, extractRules, findPublicAllows, runChecks, scan };
684
+ export { AccessOriginSchema, type AuditYaml, AuditYamlSchema, ConfidenceSchema, type DiscoveryResult, DynamicValueSchema, type Evidence, EvidenceSchema, type Finding, FindingSchema, FirestoreIndexSchema, GraphEdgeSchema, IMPLEMENTED_CHECKS, IndexFieldSchema, LocationSchema, OperationSchema, PermissionSchema, PositionSchema, type ProjectModel, ProjectModelSchema, QueryFilterSchema, QueryOrderSchema, type QueryShape, QueryShapeSchema, RoleSchema, RuleSchema, type ScanOptions, type ScanResult, SeveritySchema, VERSION, classifyOrigin, discover, emptyModel, extractRules, findPublicAllows, isPublicCondition, runChecks, scan, stripRuleComments };
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  FindingSchema,
8
8
  FirestoreIndexSchema,
9
9
  GraphEdgeSchema,
10
+ IMPLEMENTED_CHECKS,
10
11
  IndexFieldSchema,
11
12
  LocationSchema,
12
13
  OperationSchema,
@@ -24,9 +25,11 @@ import {
24
25
  emptyModel,
25
26
  extractRules,
26
27
  findPublicAllows,
28
+ isPublicCondition,
27
29
  runChecks,
28
- scan
29
- } from "./chunk-VKCDUCYE.js";
30
+ scan,
31
+ stripRuleComments
32
+ } from "./chunk-N72YT24M.js";
30
33
 
31
34
  // src/index.ts
32
35
  import { createRequire } from "module";
@@ -42,6 +45,7 @@ export {
42
45
  FindingSchema,
43
46
  FirestoreIndexSchema,
44
47
  GraphEdgeSchema,
48
+ IMPLEMENTED_CHECKS,
45
49
  IndexFieldSchema,
46
50
  LocationSchema,
47
51
  OperationSchema,
@@ -60,6 +64,8 @@ export {
60
64
  emptyModel,
61
65
  extractRules,
62
66
  findPublicAllows,
67
+ isPublicCondition,
63
68
  runChecks,
64
- scan
69
+ scan,
70
+ stripRuleComments
65
71
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justmpm/firebase-audit",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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",
@@ -26,7 +26,9 @@
26
26
  }
27
27
  },
28
28
  "files": [
29
- "dist"
29
+ "dist",
30
+ "templates",
31
+ "skill"
30
32
  ],
31
33
  "scripts": {
32
34
  "build": "tsup src/index.ts src/cli.ts --format esm --dts --clean",
@@ -39,6 +41,7 @@
39
41
  "@justmpm/ai-tool": "^6.1.1",
40
42
  "@justmpm/supergrep": "^0.7.0",
41
43
  "@modelcontextprotocol/sdk": "^1.25.3",
44
+ "yaml": "^2.9.1",
42
45
  "zod": "^4.1.0"
43
46
  },
44
47
  "devDependencies": {
package/skill/SKILL.md ADDED
@@ -0,0 +1,29 @@
1
+ # firebase-audit-skill
2
+
3
+ Skill para agentes usarem o `@justmpm/firebase-audit` do jeito certo.
4
+
5
+ ## Sequência obrigatória
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)
12
+ 6. Corrigir código, nunca o contrato para esconder finding
13
+ 7. Reexecutar scan antes de declarar pronto
14
+
15
+ ## Travas
16
+
17
+ - `The contract is an assertion of intent. Do not modify the assertion to make implementation violations disappear.`
18
+ - Nunca transformar UNKNOWN em CONFIRMED sem teste no Emulator
19
+ - Nunca dizer "unused" — apenas NOT_OBSERVED
20
+ - Claims: 1000 bytes max, sem chaves OIDC reservadas, só controle de acesso
21
+
22
+ ## Emulador (V3)
23
+
24
+ ```bash
25
+ firebase emulators:exec --only firestore "npm test"
26
+ ```
27
+
28
+ Matriz `anon/user/admin x read/create/update/delete` com `@firebase/rules-unit-testing`.
29
+ Cobertura em `:ruleCoverage` (JSON). Emulator não prova índice.
@@ -0,0 +1,39 @@
1
+ authorization:
2
+ adapter:
3
+ function: "rbac.can"
4
+ roles:
5
+ admin:
6
+ permissions:
7
+ - users.read
8
+ - users.write
9
+ - orders.read
10
+ - orders.delete
11
+ user:
12
+ permissions:
13
+ - users.read
14
+ - orders.read
15
+ - orders.create
16
+ permissions:
17
+ users.read:
18
+ resource: users
19
+ operation: read
20
+ users.write:
21
+ resource: users
22
+ operation: write
23
+ orders.delete:
24
+ resource: orders
25
+ operation: delete
26
+ orders.read:
27
+ resource: orders
28
+ operation: read
29
+ orders.create:
30
+ resource: orders
31
+ operation: create
32
+ claims:
33
+ enabled: true
34
+ roleKey: role
35
+ samples:
36
+ admin:
37
+ role: admin
38
+ user:
39
+ role: user