@justmpm/firebase-audit 0.2.0 → 0.3.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.
@@ -0,0 +1,79 @@
1
+ // src/verify.ts
2
+ import { readFileSync, existsSync } from "fs";
3
+ function planVerify(model) {
4
+ const roles = model.authorization.roles.length > 0 ? model.authorization.roles.map((r) => r.name) : ["anonymous", "user", "admin"];
5
+ const expand = (op) => {
6
+ if (op === "read") return ["get", "list"];
7
+ if (op === "get" || op === "list" || op === "create" || op === "update" || op === "delete") return [op];
8
+ if (op === "write") return ["create", "update", "delete"];
9
+ return [];
10
+ };
11
+ const cases = [];
12
+ for (const rule of model.rules) {
13
+ for (const role of roles) {
14
+ for (const op of rule.operations) {
15
+ for (const mapped of expand(op)) {
16
+ const isList = mapped === "list";
17
+ const path = isList ? rule.path.replace(/\{[^}]+\}/g, "demo-id").replace(/\/demo-id$/, "") : rule.path.replace(/\{[^}]+\}/g, "demo-id");
18
+ cases.push({ role, operation: mapped, path, pathTemplate: rule.path, expected: "UNKNOWN" });
19
+ }
20
+ }
21
+ }
22
+ }
23
+ const seen = /* @__PURE__ */ new Set();
24
+ return cases.filter((c) => {
25
+ const k = `${c.role}:${c.operation}:${c.path}`;
26
+ if (seen.has(k)) return false;
27
+ seen.add(k);
28
+ return true;
29
+ });
30
+ }
31
+ function verify(model, opts = {}) {
32
+ const cases = planVerify(model);
33
+ const findings = [];
34
+ const uncovered = [];
35
+ if (opts.coverageFile) {
36
+ if (!existsSync(opts.coverageFile)) {
37
+ findings.push({
38
+ rule: "COVERAGE_NOT_OBSERVED",
39
+ severity: "INFO",
40
+ confidence: "NOT_OBSERVED",
41
+ message: "Cobertura n\xE3o encontrada \u2014 rode `firebase emulators:exec` e passe --coverage=coverage.json.",
42
+ fingerprint: "COVERAGE_NOT_OBSERVED",
43
+ evidence: [{ id: "ev-no-coverage", kind: "coverage-missing", summary: "sem coverage", confidence: "NOT_OBSERVED" }],
44
+ fix: 'firebase emulators:exec --only firestore "npm test" + curl :ruleCoverage'
45
+ });
46
+ } else {
47
+ try {
48
+ const raw = JSON.parse(readFileSync(opts.coverageFile, "utf-8"));
49
+ void raw;
50
+ findings.push({
51
+ rule: "COVERAGE_NOT_EVALUATED",
52
+ severity: "WARNING",
53
+ confidence: "NOT_OBSERVED",
54
+ message: "Coverage recebido mas ainda n\xE3o avaliado nesta vers\xE3o (formato :ruleCoverage inst\xE1vel) \u2014 uncovered lista tudo por honestidade.",
55
+ fingerprint: "COVERAGE_NOT_EVALUATED",
56
+ evidence: [{ id: "ev-coverage-ne", kind: "coverage-parse", summary: "n\xE3o avaliado", confidence: "NOT_OBSERVED" }]
57
+ });
58
+ } catch {
59
+ findings.push({
60
+ rule: "COVERAGE_INVALID",
61
+ severity: "WARNING",
62
+ confidence: "CONFIRMED",
63
+ message: "Arquivo de cobertura inv\xE1lido \u2014 Verifier sem cobertura neste run.",
64
+ fingerprint: "COVERAGE_INVALID",
65
+ evidence: [{ id: "ev-coverage", kind: "coverage-parse", summary: "JSON inv\xE1lido", confidence: "CONFIRMED" }]
66
+ });
67
+ }
68
+ }
69
+ }
70
+ for (const rule of model.rules) {
71
+ uncovered.push(rule.path);
72
+ }
73
+ return { cases, uncovered, findings };
74
+ }
75
+
76
+ export {
77
+ planVerify,
78
+ verify
79
+ };
@@ -0,0 +1,147 @@
1
+ // src/drift.ts
2
+ import { readFileSync, existsSync } from "fs";
3
+ function normalizeMode(f) {
4
+ if (typeof f.fieldPath !== "string" || f.fieldPath.length === 0 || f.fieldPath === "__name__") return null;
5
+ if (f.vectorConfig !== void 0 && f.vectorConfig !== null) return { fieldPath: f.fieldPath, mode: "VECTOR" };
6
+ if (f.arrayConfig === "CONTAINS") return { fieldPath: f.fieldPath, mode: "ARRAY_CONTAINS" };
7
+ if (typeof f.mode === "string" && (f.mode === "ASCENDING" || f.mode === "DESCENDING" || f.mode === "ARRAY_CONTAINS" || f.mode === "VECTOR")) {
8
+ return { fieldPath: f.fieldPath, mode: f.mode };
9
+ }
10
+ if (typeof f.order === "string" && (f.order === "ASCENDING" || f.order === "DESCENDING")) {
11
+ return { fieldPath: f.fieldPath, mode: f.order };
12
+ }
13
+ return null;
14
+ }
15
+ function normalizeEntry(raw) {
16
+ let cg = typeof raw.collectionGroup === "string" ? raw.collectionGroup : void 0;
17
+ if (!cg && typeof raw.name === "string") {
18
+ const m = raw.name.match(/collectionGroups\/([^/]+)\/indexes\//);
19
+ if (m) cg = m[1];
20
+ }
21
+ if (!cg) return null;
22
+ const scope = raw.queryScope === "COLLECTION_GROUP" ? "COLLECTION_GROUP" : "COLLECTION";
23
+ const fields = [];
24
+ if (Array.isArray(raw.fields)) {
25
+ for (const f of raw.fields) {
26
+ const n = normalizeMode(f);
27
+ if (n) fields.push(n);
28
+ }
29
+ }
30
+ return { collectionGroup: cg, queryScope: scope, fields };
31
+ }
32
+ function keyOf(i) {
33
+ const fields = i.fields.map((f) => `${f.fieldPath}:${f.mode}`).join(",");
34
+ return `${i.collectionGroup}|${i.queryScope}|${fields}`;
35
+ }
36
+ function normalizeList(raw) {
37
+ if (Array.isArray(raw)) return raw;
38
+ if (raw && typeof raw === "object" && Array.isArray(raw.indexes)) {
39
+ return raw.indexes;
40
+ }
41
+ return [];
42
+ }
43
+ function drift(localFile, remoteFile) {
44
+ const findings = [];
45
+ let local = [];
46
+ let remote = [];
47
+ if (localFile && existsSync(localFile)) {
48
+ try {
49
+ const raw = JSON.parse(readFileSync(localFile, "utf-8"));
50
+ const rawIndexes = normalizeList(raw);
51
+ const parsed = [];
52
+ let skipped = 0;
53
+ for (const r of rawIndexes) {
54
+ const n = normalizeEntry(r);
55
+ if (n) parsed.push(n);
56
+ else skipped += 1;
57
+ }
58
+ local = parsed;
59
+ if (skipped > 0) {
60
+ findings.push({
61
+ rule: "DRIFT_LOCAL_SKIPPED",
62
+ severity: "INFO",
63
+ confidence: "NOT_OBSERVED",
64
+ message: `${skipped} \xEDndice(s) local(is) ignorados por formato inv\xE1lido.`,
65
+ fingerprint: "DRIFT_LOCAL_SKIPPED",
66
+ evidence: [{ id: "ev-drift-skip", kind: "drift-parse", summary: `${skipped} ignorados`, confidence: "NOT_OBSERVED" }]
67
+ });
68
+ }
69
+ } catch {
70
+ findings.push({
71
+ rule: "DRIFT_LOCAL_INVALID",
72
+ severity: "WARNING",
73
+ confidence: "CONFIRMED",
74
+ message: "\xCDndices locais inv\xE1lidos \u2014 drift sem base local.",
75
+ fingerprint: "DRIFT_LOCAL_INVALID",
76
+ evidence: [{ id: "ev-drift-local", kind: "drift-parse", summary: "local inv\xE1lido", confidence: "CONFIRMED" }]
77
+ });
78
+ }
79
+ }
80
+ if (remoteFile && existsSync(remoteFile)) {
81
+ try {
82
+ const list = normalizeList(JSON.parse(readFileSync(remoteFile, "utf-8")));
83
+ const parsed = [];
84
+ let skipped = 0;
85
+ for (const r of list) {
86
+ const n = normalizeEntry(r);
87
+ if (n) parsed.push(n);
88
+ else skipped += 1;
89
+ }
90
+ remote = parsed;
91
+ if (skipped > 0) {
92
+ findings.push({
93
+ rule: "DRIFT_REMOTE_SKIPPED",
94
+ severity: "INFO",
95
+ confidence: "NOT_OBSERVED",
96
+ message: `${skipped} \xEDndice(s) remoto(s) ignorados por formato inv\xE1lido.`,
97
+ fingerprint: "DRIFT_REMOTE_SKIPPED",
98
+ evidence: [{ id: "ev-drift-rskip", kind: "drift-parse", summary: `${skipped} ignorados`, confidence: "NOT_OBSERVED" }]
99
+ });
100
+ }
101
+ } catch {
102
+ findings.push({
103
+ rule: "DRIFT_REMOTE_INVALID",
104
+ severity: "WARNING",
105
+ confidence: "CONFIRMED",
106
+ message: "\xCDndices remotos inv\xE1lidos \u2014 drift sem base remota.",
107
+ fingerprint: "DRIFT_REMOTE_INVALID",
108
+ evidence: [{ id: "ev-drift-remote", kind: "drift-parse", summary: "remoto inv\xE1lido", confidence: "CONFIRMED" }]
109
+ });
110
+ }
111
+ }
112
+ const remoteByKey = new Map(remote.map((r) => [keyOf(r), r]));
113
+ const localByKey = new Map(local.map((l) => [keyOf(l), l]));
114
+ const entries = [];
115
+ for (const [key, l] of localByKey) {
116
+ if (remoteByKey.has(key)) entries.push({ key, status: "MATCHED", local: l, remote: remoteByKey.get(key) });
117
+ else {
118
+ entries.push({ key, status: "LOCAL_ONLY", local: l });
119
+ findings.push({
120
+ rule: "DRIFT_LOCAL_ONLY",
121
+ severity: "INFO",
122
+ confidence: "NOT_OBSERVED",
123
+ message: `\xCDndice s\xF3 local: ${key} \u2014 pode faltar deploy, n\xE3o significa unused remoto.`,
124
+ fingerprint: `DRIFT_LOCAL_ONLY:${key}`,
125
+ evidence: [{ id: `ev-drift-${entries.length}`, kind: "drift", summary: key, confidence: "NOT_OBSERVED" }]
126
+ });
127
+ }
128
+ }
129
+ for (const [key, r] of remoteByKey) {
130
+ if (!localByKey.has(key)) {
131
+ entries.push({ key, status: "REMOTE_ONLY", remote: r });
132
+ findings.push({
133
+ rule: "DRIFT_REMOTE_ONLY",
134
+ severity: "INFO",
135
+ confidence: "NOT_OBSERVED",
136
+ message: `\xCDndice s\xF3 em produ\xE7\xE3o: ${key} \u2014 pode ser de outro consumidor, n\xE3o significa unused.`,
137
+ fingerprint: `DRIFT_REMOTE_ONLY:${key}`,
138
+ evidence: [{ id: `ev-drift-r-${entries.length}`, kind: "drift", summary: key, confidence: "NOT_OBSERVED" }]
139
+ });
140
+ }
141
+ }
142
+ return { entries, findings };
143
+ }
144
+
145
+ export {
146
+ drift
147
+ };
@@ -1,3 +1,7 @@
1
+ // src/scan.ts
2
+ import { readFileSync as readFileSync4, existsSync as existsSync3 } from "fs";
3
+ import { parse as parseYaml } from "yaml";
4
+
1
5
  // src/discovery.ts
2
6
  import { existsSync, readFileSync } from "fs";
3
7
  import { join, relative } from "path";
@@ -53,20 +57,18 @@ var SERVER_HINTS_SEGMENTS = [
53
57
  "api",
54
58
  "scripts"
55
59
  ];
56
- function hasPathSegment(rel, seg) {
57
- return rel.split("/").includes(seg);
58
- }
59
60
  function classifyOrigin(file, content) {
60
61
  const rel = file.replace(/\\/g, "/");
61
- if (rel.includes("app/api/") || rel.includes("/app/") && rel.endsWith("route.ts") || rel.includes("/app/") && rel.endsWith("route.js")) {
62
+ const segs = rel.split("/");
63
+ const hasSeg = (...names) => names.some((n) => segs.includes(n));
64
+ if (rel.includes("app/api/") || rel.includes("pages/api/") || hasSeg("api") && segs.includes("pages")) {
62
65
  return "SERVER";
63
66
  }
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
- }
67
+ if (segs.includes("app") && rel.endsWith("route.ts") || segs.includes("app") && rel.endsWith("route.js")) {
68
+ return "SERVER";
69
69
  }
70
+ if (rel.includes("src/app/api/")) return "SERVER";
71
+ if (hasSeg("functions", "server", "backend", "scripts")) return "SERVER";
70
72
  if (content.includes("firebase-admin") || content.includes("firebase-functions")) {
71
73
  return "CLIENT";
72
74
  }
@@ -165,6 +167,30 @@ function opsFrom(target) {
165
167
  }
166
168
  return { ops: out, known: out.length > 0 && known };
167
169
  }
170
+ function findBlockEnd(content, openBraceIndex) {
171
+ let depth = 0;
172
+ let quote = null;
173
+ for (let i = openBraceIndex; i < content.length; i++) {
174
+ const c = content[i];
175
+ if (quote) {
176
+ if (c === quote && content[i - 1] !== "\\") quote = null;
177
+ continue;
178
+ }
179
+ if (c === '"' || c === "'" || c === "`") {
180
+ quote = c;
181
+ continue;
182
+ }
183
+ if (c === "{") depth += 1;
184
+ else if (c === "}") {
185
+ depth -= 1;
186
+ if (depth === 0) return i + 1;
187
+ }
188
+ }
189
+ return content.length;
190
+ }
191
+ function normalizeTarget(target) {
192
+ return target.toLowerCase().split(",").map((s) => s.trim()).filter(Boolean).sort().join(",");
193
+ }
168
194
  function extractRules(rulesFile, relFile) {
169
195
  const rawContent = readFileSync2(rulesFile, "utf-8");
170
196
  const content = stripRuleComments(rawContent);
@@ -175,7 +201,8 @@ function extractRules(rulesFile, relFile) {
175
201
  while ((m = matchRe.exec(content)) !== null) {
176
202
  const before = content.slice(0, m.index);
177
203
  const line = before.split("\n").length;
178
- matchBlocks.push({ path: m[1], line, blockStart: m.index });
204
+ const openBrace = m.index + m[0].length - 1;
205
+ matchBlocks.push({ path: m[1], line, blockStart: m.index, blockEnd: findBlockEnd(content, openBrace) });
179
206
  }
180
207
  const allowRe = new RegExp(ALLOW_RE.source, "g");
181
208
  let a;
@@ -186,7 +213,8 @@ function extractRules(rulesFile, relFile) {
186
213
  const target = a[1];
187
214
  const condition = (a[2] ?? "").trim();
188
215
  const unconditional = a[2] === void 0;
189
- const currentMatch = [...matchBlocks].reverse().find((b) => b.blockStart <= a.index);
216
+ const containing = matchBlocks.filter((b) => b.blockStart <= a.index && a.index < b.blockEnd);
217
+ const currentMatch = containing.sort((x, y) => y.blockStart - x.blockStart)[0];
190
218
  const authReferences = [];
191
219
  if (condition.includes("request.auth")) authReferences.push("request.auth");
192
220
  if (condition.includes("request.auth.uid")) authReferences.push("request.auth.uid");
@@ -205,6 +233,7 @@ function extractRules(rulesFile, relFile) {
205
233
  path: currentMatch?.path ?? "/(unknown)",
206
234
  operations: ops,
207
235
  conditionPresent: !unconditional && condition.length > 0,
236
+ condition: unconditional ? void 0 : condition,
208
237
  authReferences,
209
238
  claimReferences,
210
239
  resourceReferences,
@@ -243,7 +272,7 @@ function isPublicCondition(condition) {
243
272
  return { isPublic: false, confidence: "CONFIRMED" };
244
273
  }
245
274
  if (low === "true") return { isPublic: true, confidence: "CONFIRMED" };
246
- if (/(^|\|\|)true($|\|\||&&)/.test(low)) return { isPublic: true, confidence: "PROBABLE" };
275
+ if (/(^|\|\|)true($|\|\|)/.test(low)) return { isPublic: true, confidence: "PROBABLE" };
247
276
  if (low.includes("request.auth==null") || low.includes("request.auth===null")) {
248
277
  return { isPublic: true, confidence: "CONFIRMED" };
249
278
  }
@@ -256,7 +285,10 @@ function findPublicAllows(rulesFile) {
256
285
  const matchRe = new RegExp(MATCH_RE.source, "g");
257
286
  const blocks = [];
258
287
  let mm0;
259
- while ((mm0 = matchRe.exec(content)) !== null) blocks.push({ path: mm0[1], index: mm0.index });
288
+ while ((mm0 = matchRe.exec(content)) !== null) {
289
+ const openBrace = mm0.index + mm0[0].length - 1;
290
+ blocks.push({ path: mm0[1], index: mm0.index, end: findBlockEnd(content, openBrace) });
291
+ }
260
292
  const re = new RegExp(ALLOW_RE.source, "g");
261
293
  let mm;
262
294
  while ((mm = re.exec(content)) !== null) {
@@ -264,7 +296,8 @@ function findPublicAllows(rulesFile) {
264
296
  const target = mm[1].trim();
265
297
  const check = isPublicCondition(mm[2]);
266
298
  if (!check.isPublic) continue;
267
- const path = [...blocks].reverse().find((b) => b.index <= mm.index)?.path ?? "/(unknown)";
299
+ const containing = blocks.filter((b) => b.index <= mm.index && mm.index < b.end);
300
+ const path = containing.sort((x, y) => y.index - x.index)[0]?.path ?? "/(unknown)";
268
301
  out.push({ line, target, path, confidence: check.confidence });
269
302
  }
270
303
  return out;
@@ -319,7 +352,7 @@ async function runChecks(model, rootDir, opts = {}) {
319
352
  "ERROR",
320
353
  pub.confidence,
321
354
  `Escrita p\xFAblica em ${pub.path} (${pub.target}). Qualquer cliente pode escrever.`,
322
- `FBA001:${relRules}:${pub.path}:${pub.target}`,
355
+ `FBA001:${relRules}:${pub.path}:${normalizeTarget(pub.target)}`,
323
356
  {
324
357
  file: relRules,
325
358
  line: pub.line,
@@ -337,7 +370,7 @@ async function runChecks(model, rootDir, opts = {}) {
337
370
  "WARNING",
338
371
  pub.confidence,
339
372
  `Leitura p\xFAblica em ${pub.path} (${pub.target}). Pode ser proposital.`,
340
- `FBA002:${relRules}:${pub.path}:${pub.target}`,
373
+ `FBA002:${relRules}:${pub.path}:${normalizeTarget(pub.target)}`,
341
374
  {
342
375
  file: relRules,
343
376
  line: pub.line,
@@ -431,14 +464,20 @@ async function runChecks(model, rootDir, opts = {}) {
431
464
  );
432
465
  }
433
466
  }
434
- const publicPaths = new Set(
435
- absRules && existsSync2(absRules) ? findPublicAllows(absRules).map((p) => `${p.path}::${p.target}`) : []
436
- );
437
467
  const expandOp = (op) => {
438
468
  if (op === "read") return ["read", "get", "list"];
439
469
  if (op === "write") return ["write", "create", "update", "delete"];
440
470
  return [op];
441
471
  };
472
+ const publicPaths = /* @__PURE__ */ new Set();
473
+ if (absRules && existsSync2(absRules)) {
474
+ for (const p of findPublicAllows(absRules)) {
475
+ const { ops } = opsFrom(p.target);
476
+ for (const op of /* @__PURE__ */ new Set([...ops, ...ops.flatMap(expandOp)])) {
477
+ publicPaths.add(`${p.path}::${op.toLowerCase()}`);
478
+ }
479
+ }
480
+ }
442
481
  const permsByResource = /* @__PURE__ */ new Map();
443
482
  for (const p of model.authorization.permissions) {
444
483
  const holders = model.authorization.roles.filter((r) => r.permissions.includes(p.name)).map((r) => r.name);
@@ -447,29 +486,28 @@ async function runChecks(model, rootDir, opts = {}) {
447
486
  list.push({ perm: p.name, operation: p.operation, adminOnly });
448
487
  permsByResource.set(p.resource, list);
449
488
  }
450
- for (const rule of model.rules) {
451
- const resourceKey = rule.path.replace(/^\/+/, "").split("/")[0] ?? "";
452
- if (resourceKey === "databases" || resourceKey === "(unknown)" || resourceKey === "") continue;
453
- const entries = permsByResource.get(resourceKey);
454
- if (!entries || entries.length === 0) continue;
489
+ const checkRuleAgainstEntries = (rule, resourceKey, entries) => {
455
490
  const ruleOps = new Set(rule.operations.flatMap(expandOp));
456
- const restricted = entries.filter((e) => e.adminOnly && ruleOps.has(e.operation));
457
- if (restricted.length === 0) continue;
458
- const isPublicRule = rule.operations.some(
459
- (op) => publicPaths.has(`${rule.path}::${op}`)
460
- ) || rule.operations.some((op) => publicPaths.has(`${rule.path}::read, ${op}`));
461
- if (isPublicRule) continue;
491
+ const restricted = entries.filter(
492
+ (e) => e.adminOnly && expandOp(e.operation).some((o) => ruleOps.has(o))
493
+ );
494
+ if (restricted.length === 0) return;
495
+ const isPublicRule = [...ruleOps].some(
496
+ (op) => publicPaths.has(`${rule.path}::${op.toLowerCase()}`)
497
+ );
498
+ if (isPublicRule) return;
462
499
  const checksAdminClaim = rule.claimReferences.some((c) => c.toLowerCase() === "admin");
463
- if (checksAdminClaim) continue;
500
+ const checksAdminRole = /token\.\w+\s*==\s*['"]admin['"]/i.test(rule.condition ?? "");
501
+ if (checksAdminClaim || checksAdminRole) return;
464
502
  const checksAuth = rule.authReferences.length > 0;
465
- if (!checksAuth) continue;
503
+ if (!checksAuth) return;
466
504
  findings.push(
467
505
  make(
468
506
  "FBA012",
469
507
  "WARNING",
470
508
  "PROBABLE",
471
509
  `Rule ${rule.path} [${rule.operations.join(", ")}] aceita autenticado sem checar claim de admin, mas contrato restringe ${restricted.map((r) => r.perm).join(", ")} a admin.`,
472
- `FBA012:${rule.path}:${[...rule.operations].sort().join(",")}`,
510
+ `FBA012:${rule.path}:${[...rule.operations].sort().join(",")}:${rule.location.start.line}:${resourceKey}`,
473
511
  {
474
512
  file: rule.location.file,
475
513
  line: rule.location.start.line,
@@ -479,6 +517,27 @@ async function runChecks(model, rootDir, opts = {}) {
479
517
  }
480
518
  )
481
519
  );
520
+ };
521
+ for (const rule of model.rules) {
522
+ const segments = rule.path.replace(/^\/+/, "").split("/").filter(Boolean);
523
+ if (segments.length === 0 || segments[0] === "(unknown)") continue;
524
+ if (segments[0] === "databases") {
525
+ const docIdx = segments.indexOf("documents");
526
+ const candidate = docIdx !== -1 ? segments[docIdx + 1] : void 0;
527
+ if (!candidate || candidate.startsWith("{")) continue;
528
+ const entriesAbs = permsByResource.get(candidate);
529
+ if (!entriesAbs) continue;
530
+ checkRuleAgainstEntries(rule, candidate, entriesAbs);
531
+ continue;
532
+ }
533
+ const tried = /* @__PURE__ */ new Set();
534
+ for (const seg of segments) {
535
+ if (seg.startsWith("{") || seg.startsWith("(") || tried.has(seg)) continue;
536
+ tried.add(seg);
537
+ const entries = permsByResource.get(seg);
538
+ if (!entries || entries.length === 0) continue;
539
+ checkRuleAgainstEntries(rule, seg, entries);
540
+ }
482
541
  }
483
542
  for (const dyn of permissionCalls.dynamic) {
484
543
  findings.push(
@@ -528,9 +587,6 @@ async function runChecks(model, rootDir, opts = {}) {
528
587
  async function collectPermissionCalls(rootDir, adapterFn) {
529
588
  const literals = [];
530
589
  const dynamic = [];
531
- if (!adapterFn.includes(".")) {
532
- return { literals, dynamic };
533
- }
534
590
  const bases = [adapterFn];
535
591
  const patterns = [];
536
592
  for (const b of bases) {
@@ -549,10 +605,25 @@ async function collectPermissionCalls(rootDir, adapterFn) {
549
605
  for (const m of result.matches) {
550
606
  const raw = (m.metaVariables["PERM"] ?? "").trim();
551
607
  const line = m.line + 1;
552
- const isQuoted = /^['"].*['"]$/.test(raw);
608
+ if (raw.includes("${")) {
609
+ const k = `${m.file}:${line}:${raw}`;
610
+ if (!seen.has(k)) {
611
+ seen.add(k);
612
+ dynamic.push({ file: m.file, line, text: m.text });
613
+ }
614
+ continue;
615
+ }
616
+ const isQuoted = /^['"`].*['"`]$/.test(raw);
553
617
  if (isQuoted) {
554
- const perm = raw.replace(/^['"]|['"]$/g, "").trim();
618
+ const perm = raw.replace(/^['"`]|['"`]$/g, "").trim();
555
619
  if (perm.length >= 3) pushLiteral(m.file, line, perm);
620
+ else if (perm.length > 0) {
621
+ const k = `${m.file}:${line}:${raw}:short`;
622
+ if (!seen.has(k)) {
623
+ seen.add(k);
624
+ dynamic.push({ file: m.file, line, text: m.text });
625
+ }
626
+ }
556
627
  } else if (raw.length > 0) {
557
628
  const k = `${m.file}:${line}:${raw}`;
558
629
  if (!seen.has(k)) {
@@ -682,6 +753,7 @@ var RuleSchema = z.strictObject({
682
753
  path: z.string().min(1),
683
754
  operations: z.array(OperationSchema),
684
755
  conditionPresent: z.boolean(),
756
+ condition: z.string().optional(),
685
757
  authReferences: z.array(z.string()),
686
758
  claimReferences: z.array(z.string()),
687
759
  resourceReferences: z.array(z.string()),
@@ -753,7 +825,7 @@ var FindingSchema = z.strictObject({
753
825
  });
754
826
  var PermissionNameSchema = z.string().regex(/^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)*$/);
755
827
  var RoleNameSchema = z.string().regex(/^[a-z][a-z0-9_-]*$/);
756
- var AuditYamlSchema = z.strictObject({
828
+ var AuditYamlSchema = z.object({
757
829
  authorization: z.strictObject({
758
830
  adapter: z.strictObject({ function: z.string().min(1) }).optional(),
759
831
  roles: z.record(RoleNameSchema, z.strictObject({ permissions: z.array(PermissionNameSchema) })),
@@ -770,8 +842,6 @@ var AuditYamlSchema = z.strictObject({
770
842
  });
771
843
 
772
844
  // src/scan.ts
773
- import { readFileSync as readFileSync4, existsSync as existsSync3 } from "fs";
774
- import { parse as parseYaml } from "yaml";
775
845
  async function scan(rootDir, opts = {}) {
776
846
  const d = discover(rootDir);
777
847
  const model = emptyModel(rootDir, d);
@@ -789,8 +859,21 @@ async function scan(rootDir, opts = {}) {
789
859
  });
790
860
  }
791
861
  if (d.firestoreRulesFile && existsSync3(d.firestoreRulesFile)) {
792
- const rel = d.firestoreRulesFile.replace(/\\/g, "/").startsWith(rootDir.replace(/\\/g, "/")) ? d.firestoreRulesFile.slice(rootDir.length + 1).replace(/\\/g, "/") : model.firebase.firestore.rulesFile ?? "firestore.rules";
793
- model.rules = extractRules(d.firestoreRulesFile, rel);
862
+ const rel = model.firebase.firestore.rulesFile ?? "firestore.rules";
863
+ try {
864
+ model.rules = extractRules(d.firestoreRulesFile, rel);
865
+ } catch {
866
+ findings_pre.push({
867
+ rule: "RULES_UNREADABLE",
868
+ severity: "WARNING",
869
+ confidence: "CONFIRMED",
870
+ message: "firestore.rules existe mas n\xE3o p\xF4de ser lido \u2014 FBA001/FBA002 sem cobertura.",
871
+ fingerprint: "RULES_UNREADABLE",
872
+ evidence: [
873
+ { id: "ev-rules-unreadable", kind: "rules-read", summary: "leitura falhou", confidence: "CONFIRMED" }
874
+ ]
875
+ });
876
+ }
794
877
  }
795
878
  if (d.firestoreIndexesFile && existsSync3(d.firestoreIndexesFile)) {
796
879
  try {
@@ -925,14 +1008,21 @@ async function scan(rootDir, opts = {}) {
925
1008
  const errors = findings.filter((f) => f.severity === "ERROR").length;
926
1009
  const warnings = findings.filter((f) => f.severity === "WARNING").length;
927
1010
  const infos = findings.filter((f) => f.severity === "INFO").length;
928
- return { model, findings, summary: { errors, warnings, infos, passed: IMPLEMENTED_CHECKS.length } };
1011
+ const failedChecks = new Set(
1012
+ findings.filter((f) => f.severity === "ERROR" || f.severity === "WARNING").map((f) => f.rule)
1013
+ );
1014
+ const total = IMPLEMENTED_CHECKS.length;
1015
+ const passed = IMPLEMENTED_CHECKS.filter((c) => !failedChecks.has(c)).length;
1016
+ return { model, findings, summary: { errors, warnings, infos, passed, total } };
929
1017
  }
930
1018
 
931
1019
  export {
932
1020
  discover,
1021
+ SERVER_HINTS_SEGMENTS,
933
1022
  classifyOrigin,
934
1023
  emptyModel,
935
1024
  stripRuleComments,
1025
+ opsFrom,
936
1026
  extractRules,
937
1027
  isPublicCondition,
938
1028
  findPublicAllows,
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  scan
4
- } from "./chunk-PIRAC3KT.js";
4
+ } from "./chunk-N57MCLTZ.js";
5
5
 
6
6
  // src/cli.ts
7
7
  import { createRequire } from "module";
@@ -20,11 +20,13 @@ async function main() {
20
20
  Uso:
21
21
  firebase-audit scan [--json] [--strict] [--adapter=fn] [--cwd=path]
22
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)
23
25
 
24
- Exit codes: 0 ok (ou WARNING/INFO fora do strict); 2 quando --strict encontra ERROR.`);
26
+ Exit codes: 0 ok informativo; 2 quando --strict/check encontra ERROR. scan sozinho nunca reprova (use check no CI).`);
25
27
  return;
26
28
  }
27
- if (cmd !== "scan" && cmd !== "check") {
29
+ if (cmd !== "scan" && cmd !== "check" && cmd !== "verify" && cmd !== "drift") {
28
30
  console.error(`Comando desconhecido: ${cmd}
29
31
  Uso: firebase-audit scan [--json] [--strict] [--adapter=fn] [--cwd=path]`);
30
32
  process.exit(1);
@@ -39,17 +41,90 @@ Uso: firebase-audit scan [--json] [--strict] [--adapter=fn] [--cwd=path]`);
39
41
  if (idx !== -1 && args[idx + 1] && !args[idx + 1].startsWith("--")) return args[idx + 1];
40
42
  return void 0;
41
43
  };
42
- const knownFlags = /* @__PURE__ */ new Set(["--json", "--strict", "--help", "-h", "help", "--version", "-v"]);
44
+ const knownFlags = /* @__PURE__ */ new Set(["--json", "--strict", "--help", "-h", "help", "--version", "-v", "--coverage", "--local", "--remote"]);
43
45
  for (const a of args.slice(1)) {
44
- if (a.startsWith("--") && !a.startsWith("--adapter=") && !a.startsWith("--cwd=") && a !== "--adapter" && a !== "--cwd" && !knownFlags.has(a)) {
46
+ const base = a.includes("=") ? a.slice(0, a.indexOf("=")) : a;
47
+ if (a.startsWith("--") && base !== "--adapter" && base !== "--cwd" && base !== "--coverage" && base !== "--local" && base !== "--remote" && !knownFlags.has(a) && !knownFlags.has(base)) {
45
48
  console.error(`Flag desconhecida: ${a}
46
- Uso: firebase-audit scan [--json] [--strict] [--adapter=fn] [--cwd=path]`);
49
+ Uso: firebase-audit scan|check|verify|drift [--json] ...`);
47
50
  process.exit(1);
48
51
  }
49
52
  }
50
53
  const adapterArg = getArg("adapter");
51
54
  const cwdArg = getArg("cwd");
52
- const rootDir = cwdArg ?? process.cwd();
55
+ if (cwdArg) {
56
+ const { existsSync: existsCwd } = await import("fs");
57
+ const { resolve: resolveCwd } = await import("path");
58
+ const abs = resolveCwd(process.cwd(), cwdArg);
59
+ if (!existsCwd(abs)) {
60
+ console.error(`Diret\xF3rio --cwd n\xE3o existe: ${cwdArg}`);
61
+ process.exit(1);
62
+ }
63
+ }
64
+ const { resolve: resolveRoot } = await import("path");
65
+ const rootDir = cwdArg ? resolveRoot(process.cwd(), cwdArg) : process.cwd();
66
+ if (cmd === "verify") {
67
+ const { scan: scanForVerify } = await import("./scan-773ZFQXB.js");
68
+ const { verify } = await import("./verify-Y7BWAFPL.js");
69
+ const { resolve: resolveVerify } = await import("path");
70
+ const coverageArg = getArg("coverage");
71
+ const coveragePath = coverageArg ? resolveVerify(rootDir, coverageArg) : void 0;
72
+ const { model } = await scanForVerify(rootDir, { adapterFn: adapterArg });
73
+ const res = verify(model, { coverageFile: coveragePath });
74
+ if (asJson) {
75
+ console.log(JSON.stringify({ version: pkg.version, cases: res.cases.length, uncovered: res.uncovered, findings: res.findings }, null, 2));
76
+ return;
77
+ }
78
+ console.log(`
79
+ FIREBASE AUDIT verify \u2014 ${res.cases.length} casos planejados, ${res.uncovered.length} paths sem cobertura observada.`);
80
+ for (const f of res.findings) {
81
+ const icon = f.severity === "ERROR" ? "\u274C" : f.severity === "WARNING" ? "\u26A0" : "\u2139";
82
+ console.log(`
83
+ ${icon} ${f.rule} [${f.confidence}]
84
+ ${f.message}`);
85
+ if (f.fix) console.log(` \u2192 ${f.fix}`);
86
+ }
87
+ if (res.uncovered.length > 0) {
88
+ console.log("\nUncovered paths:");
89
+ for (const u of res.uncovered.slice(0, 20)) console.log(` \u2022 ${u}`);
90
+ if (res.uncovered.length > 20) console.log(` ... e mais ${res.uncovered.length - 20}`);
91
+ }
92
+ console.log('\nEmulator: firebase emulators:exec --only firestore "npm test" + :ruleCoverage.\n');
93
+ return;
94
+ }
95
+ if (cmd === "drift") {
96
+ const { drift } = await import("./drift-BEXSL2IX.js");
97
+ const { resolve: resolveDrift } = await import("path");
98
+ const { existsSync: existsDrift } = await import("fs");
99
+ const localArg = getArg("local");
100
+ const remoteArg = getArg("remote");
101
+ if (!localArg && !remoteArg) {
102
+ console.error("drift exige --local e/ou --remote.\nUso: firebase-audit drift --local=indexes.json --remote=deployed.json [--cwd=path]");
103
+ process.exit(1);
104
+ }
105
+ const localPath = localArg ? resolveDrift(rootDir, localArg) : null;
106
+ const remotePath = remoteArg ? resolveDrift(rootDir, remoteArg) : null;
107
+ for (const [label, p] of [["--local", localPath], ["--remote", remotePath]]) {
108
+ if (p && !existsDrift(p)) {
109
+ console.error(`Arquivo ${label} n\xE3o encontrado: ${p}`);
110
+ process.exit(1);
111
+ }
112
+ }
113
+ const res = drift(localPath, remotePath);
114
+ if (asJson) {
115
+ console.log(JSON.stringify({ version: pkg.version, entries: res.entries, findings: res.findings }, null, 2));
116
+ return;
117
+ }
118
+ const counts = (s) => res.entries.filter((e) => e.status === s).length;
119
+ console.log(`
120
+ FIREBASE AUDIT drift \u2014 MATCHED ${counts("MATCHED")}, LOCAL_ONLY ${counts("LOCAL_ONLY")}, REMOTE_ONLY ${counts("REMOTE_ONLY")}.
121
+ `);
122
+ for (const f of res.findings.slice(0, 20)) {
123
+ const icon = f.severity === "ERROR" ? "\u274C" : f.severity === "WARNING" ? "\u26A0" : "\u2139";
124
+ console.log(`${icon} ${f.rule}: ${f.message}`);
125
+ }
126
+ return;
127
+ }
53
128
  const { findings, summary } = await scan(rootDir, { strict, adapterFn: adapterArg });
54
129
  if (asJson) {
55
130
  console.log(JSON.stringify({ version: pkg.version, summary, findings }, null, 2));
@@ -0,0 +1,6 @@
1
+ import {
2
+ drift
3
+ } from "./chunk-GUSNXUAT.js";
4
+ export {
5
+ drift
6
+ };
package/dist/index.d.ts CHANGED
@@ -216,6 +216,7 @@ declare const RuleSchema: z.ZodObject<{
216
216
  write: "write";
217
217
  }>>;
218
218
  conditionPresent: z.ZodBoolean;
219
+ condition: z.ZodOptional<z.ZodString>;
219
220
  authReferences: z.ZodArray<z.ZodString>;
220
221
  claimReferences: z.ZodArray<z.ZodString>;
221
222
  resourceReferences: z.ZodArray<z.ZodString>;
@@ -410,6 +411,7 @@ declare const ProjectModelSchema: z.ZodObject<{
410
411
  write: "write";
411
412
  }>>;
412
413
  conditionPresent: z.ZodBoolean;
414
+ condition: z.ZodOptional<z.ZodString>;
413
415
  authReferences: z.ZodArray<z.ZodString>;
414
416
  claimReferences: z.ZodArray<z.ZodString>;
415
417
  resourceReferences: z.ZodArray<z.ZodString>;
@@ -589,7 +591,7 @@ declare const AuditYamlSchema: z.ZodObject<{
589
591
  samples: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodJSONSchema>>>;
590
592
  }, z.core.$strict>>;
591
593
  }, z.core.$strict>;
592
- }, z.core.$strict>;
594
+ }, z.core.$strip>;
593
595
  type ProjectModel = z.infer<typeof ProjectModelSchema>;
594
596
  type Finding = z.infer<typeof FindingSchema>;
595
597
  type AuditYaml = z.infer<typeof AuditYamlSchema>;
@@ -616,6 +618,8 @@ interface DiscoveryResult {
616
618
  serverFiles: string[];
617
619
  }
618
620
  declare function discover(rootDir: string): DiscoveryResult;
621
+ /** Segmentos de servidor usados por classifyOrigin (documentados; ai-tool graph na V3). */
622
+ declare const SERVER_HINTS_SEGMENTS: string[];
619
623
  /** Heurística CLIENT vs SERVER: caminho manda primeiro (grafo/voz do ai-tool na V2). */
620
624
  declare function classifyOrigin(file: string, content: string): "CLIENT" | "SERVER" | "UNKNOWN";
621
625
  declare function emptyModel(rootDir: string, d: DiscoveryResult): ProjectModel;
@@ -628,6 +632,10 @@ declare function emptyModel(rootDir: string, d: DiscoveryResult): ProjectModel;
628
632
  type Rule = z$1.infer<typeof RuleSchema>;
629
633
  /** Remove comentários // e block preservando strings simples. */
630
634
  declare function stripRuleComments(content: string): string;
635
+ declare function opsFrom(target: string): {
636
+ ops: Rule["operations"];
637
+ known: boolean;
638
+ };
631
639
  declare function extractRules(rulesFile: string, relFile: string): Rule[];
632
640
  /** Normaliza condição para detectar público: sem if, `true`, `(true)`, `|| true`, `auth == null`. */
633
641
  declare function isPublicCondition(condition: string | undefined): {
@@ -643,11 +651,9 @@ declare function findPublicAllows(rulesFile: string): {
643
651
  }[];
644
652
 
645
653
  /**
646
- * Checks estáticos V1 (FBA001–FBA004, FBA009–FBA011, subconjunto intencional).
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).
654
+ * Checks V1+V2 (FBA001–FBA004, FBA009–FBA013, subconjunto intencional).
655
+ * Fingerprints: FBA001/FBA002 por regra-lógica (sem linha, estáveis a formatação);
656
+ * FBA003/FBA004/FBA010/FBA012 com linha (únicos por ocorrência).
651
657
  */
652
658
 
653
659
  declare const IMPLEMENTED_CHECKS: readonly ["FBA001", "FBA002", "FBA003", "FBA004", "FBA009", "FBA010", "FBA011", "FBA012", "FBA013"];
@@ -671,14 +677,67 @@ interface ScanResult {
671
677
  warnings: number;
672
678
  infos: number;
673
679
  passed: number;
680
+ total: number;
674
681
  };
675
682
  }
676
683
  declare function scan(rootDir: string, opts?: ScanOptions): Promise<ScanResult>;
677
684
 
685
+ /**
686
+ * V3 — Verifier (Emulator) como interface honesta.
687
+ *
688
+ * Não executa o Emulator sozinho (precisa de firebase-tools + Java no host).
689
+ * Gera a matriz de casos (TestCase → Scenario) e avalia cobertura quando
690
+ * o JSON do endpoint `:ruleCoverage` é fornecido (--coverage).
691
+ */
692
+
693
+ interface VerifyCase {
694
+ role: string;
695
+ operation: "read" | "get" | "list" | "create" | "update" | "delete";
696
+ path: string;
697
+ pathTemplate: string;
698
+ expected: "ALLOW" | "DENY" | "UNKNOWN";
699
+ }
700
+ interface VerifyOptions {
701
+ coverageFile?: string;
702
+ }
703
+ interface VerifyResult {
704
+ cases: VerifyCase[];
705
+ uncovered: string[];
706
+ findings: Finding[];
707
+ }
708
+ /** Gera matriz role × operação × path a partir do modelo (sem executar nada).
709
+ * Mapeamento: read≡get+list, write≡create+update+delete (mesma semântica do opsFrom).
710
+ */
711
+ declare function planVerify(model: ProjectModel): VerifyCase[];
712
+ /** Avalia cobertura a partir do JSON de `:ruleCoverage` (quando fornecido). */
713
+ declare function verify(model: ProjectModel, opts?: VerifyOptions): VerifyResult;
714
+
715
+ /**
716
+ * V4 — Drift local vs implantado (comparação honesta de índices).
717
+ *
718
+ * Compara `firestore.indexes.json` local contra um JSON remoto
719
+ * (`gcloud firestore indexes composite list --format=json` ou REST).
720
+ * Nunca afirma "unused" — apenas LOCAL_ONLY / REMOTE_ONLY / MATCHED.
721
+ */
722
+
723
+ type Index = z$1.infer<typeof FirestoreIndexSchema>;
724
+ type DriftStatus = "MATCHED" | "LOCAL_ONLY" | "REMOTE_ONLY";
725
+ interface DriftEntry {
726
+ key: string;
727
+ status: DriftStatus;
728
+ local?: Index;
729
+ remote?: Index;
730
+ }
731
+ interface DriftResult {
732
+ entries: DriftEntry[];
733
+ findings: Finding[];
734
+ }
735
+ declare function drift(localFile: string | null, remoteFile: string | null): DriftResult;
736
+
678
737
  /**
679
738
  * firebase-audit — entry point como biblioteca.
680
739
  */
681
740
 
682
741
  declare const VERSION: string;
683
742
 
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 };
743
+ export { AccessOriginSchema, type AuditYaml, AuditYamlSchema, ConfidenceSchema, type DiscoveryResult, type DriftEntry, type DriftResult, type DriftStatus, 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, SERVER_HINTS_SEGMENTS, type ScanOptions, type ScanResult, SeveritySchema, VERSION, type VerifyCase, type VerifyOptions, type VerifyResult, classifyOrigin, discover, drift, emptyModel, extractRules, findPublicAllows, isPublicCondition, opsFrom, planVerify, runChecks, scan, stripRuleComments, verify };
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  QueryShapeSchema,
20
20
  RoleSchema,
21
21
  RuleSchema,
22
+ SERVER_HINTS_SEGMENTS,
22
23
  SeveritySchema,
23
24
  classifyOrigin,
24
25
  discover,
@@ -26,10 +27,18 @@ import {
26
27
  extractRules,
27
28
  findPublicAllows,
28
29
  isPublicCondition,
30
+ opsFrom,
29
31
  runChecks,
30
32
  scan,
31
33
  stripRuleComments
32
- } from "./chunk-PIRAC3KT.js";
34
+ } from "./chunk-N57MCLTZ.js";
35
+ import {
36
+ planVerify,
37
+ verify
38
+ } from "./chunk-CPEIPRCF.js";
39
+ import {
40
+ drift
41
+ } from "./chunk-GUSNXUAT.js";
33
42
 
34
43
  // src/index.ts
35
44
  import { createRequire } from "module";
@@ -57,15 +66,20 @@ export {
57
66
  QueryShapeSchema,
58
67
  RoleSchema,
59
68
  RuleSchema,
69
+ SERVER_HINTS_SEGMENTS,
60
70
  SeveritySchema,
61
71
  VERSION,
62
72
  classifyOrigin,
63
73
  discover,
74
+ drift,
64
75
  emptyModel,
65
76
  extractRules,
66
77
  findPublicAllows,
67
78
  isPublicCondition,
79
+ opsFrom,
80
+ planVerify,
68
81
  runChecks,
69
82
  scan,
70
- stripRuleComments
83
+ stripRuleComments,
84
+ verify
71
85
  };
@@ -0,0 +1,6 @@
1
+ import {
2
+ scan
3
+ } from "./chunk-N57MCLTZ.js";
4
+ export {
5
+ scan
6
+ };
@@ -0,0 +1,8 @@
1
+ import {
2
+ planVerify,
3
+ verify
4
+ } from "./chunk-CPEIPRCF.js";
5
+ export {
6
+ planVerify,
7
+ verify
8
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justmpm/firebase-audit",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",