@justmpm/firebase-audit 0.1.1 → 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,
@@ -239,8 +268,11 @@ function isPublicCondition(condition) {
239
268
  if (condition === void 0) return { isPublic: true, confidence: "CONFIRMED" };
240
269
  const norm = stripOuterParens(condition);
241
270
  const low = norm.toLowerCase().replace(/\s+/g, "");
271
+ if (low.includes("!(request.auth==null)") || low.includes("!(request.auth===null)") || low === "!request.auth==null" || low === "!request.auth===null") {
272
+ return { isPublic: false, confidence: "CONFIRMED" };
273
+ }
242
274
  if (low === "true") return { isPublic: true, confidence: "CONFIRMED" };
243
- if (/(^|\|\|)true($|\|\||&&)/.test(low)) return { isPublic: true, confidence: "PROBABLE" };
275
+ if (/(^|\|\|)true($|\|\|)/.test(low)) return { isPublic: true, confidence: "PROBABLE" };
244
276
  if (low.includes("request.auth==null") || low.includes("request.auth===null")) {
245
277
  return { isPublic: true, confidence: "CONFIRMED" };
246
278
  }
@@ -253,7 +285,10 @@ function findPublicAllows(rulesFile) {
253
285
  const matchRe = new RegExp(MATCH_RE.source, "g");
254
286
  const blocks = [];
255
287
  let mm0;
256
- 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
+ }
257
292
  const re = new RegExp(ALLOW_RE.source, "g");
258
293
  let mm;
259
294
  while ((mm = re.exec(content)) !== null) {
@@ -261,7 +296,8 @@ function findPublicAllows(rulesFile) {
261
296
  const target = mm[1].trim();
262
297
  const check = isPublicCondition(mm[2]);
263
298
  if (!check.isPublic) continue;
264
- 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)";
265
301
  out.push({ line, target, path, confidence: check.confidence });
266
302
  }
267
303
  return out;
@@ -271,7 +307,7 @@ function findPublicAllows(rulesFile) {
271
307
  import { readFileSync as readFileSync3, existsSync as existsSync2 } from "fs";
272
308
  import { join as join2 } from "path";
273
309
  import { executeFind } from "@justmpm/supergrep";
274
- var IMPLEMENTED_CHECKS = ["FBA001", "FBA002", "FBA003", "FBA004", "FBA009", "FBA010", "FBA011"];
310
+ var IMPLEMENTED_CHECKS = ["FBA001", "FBA002", "FBA003", "FBA004", "FBA009", "FBA010", "FBA011", "FBA012", "FBA013"];
275
311
  function createEvidence() {
276
312
  let counter = 0;
277
313
  return {
@@ -316,7 +352,7 @@ async function runChecks(model, rootDir, opts = {}) {
316
352
  "ERROR",
317
353
  pub.confidence,
318
354
  `Escrita p\xFAblica em ${pub.path} (${pub.target}). Qualquer cliente pode escrever.`,
319
- `FBA001:${relRules}:${pub.path}:${pub.target}`,
355
+ `FBA001:${relRules}:${pub.path}:${normalizeTarget(pub.target)}`,
320
356
  {
321
357
  file: relRules,
322
358
  line: pub.line,
@@ -334,7 +370,7 @@ async function runChecks(model, rootDir, opts = {}) {
334
370
  "WARNING",
335
371
  pub.confidence,
336
372
  `Leitura p\xFAblica em ${pub.path} (${pub.target}). Pode ser proposital.`,
337
- `FBA002:${relRules}:${pub.path}:${pub.target}`,
373
+ `FBA002:${relRules}:${pub.path}:${normalizeTarget(pub.target)}`,
338
374
  {
339
375
  file: relRules,
340
376
  line: pub.line,
@@ -411,6 +447,98 @@ async function runChecks(model, rootDir, opts = {}) {
411
447
  }
412
448
  }
413
449
  }
450
+ for (const role of model.authorization.roles) {
451
+ if (role.permissions.length === 0) {
452
+ findings.push(
453
+ make(
454
+ "FBA013",
455
+ "WARNING",
456
+ "CONFIRMED",
457
+ `Role "${role.name}" sem permissions \u2014 nunca autoriza nada.`,
458
+ `FBA013:${role.name}`,
459
+ {
460
+ evidence: [ev("role-empty", role.name, "CONFIRMED")],
461
+ fix: `Adicionar permissions \xE0 role "${role.name}" ou remov\xEA-la.`
462
+ }
463
+ )
464
+ );
465
+ }
466
+ }
467
+ const expandOp = (op) => {
468
+ if (op === "read") return ["read", "get", "list"];
469
+ if (op === "write") return ["write", "create", "update", "delete"];
470
+ return [op];
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
+ }
481
+ const permsByResource = /* @__PURE__ */ new Map();
482
+ for (const p of model.authorization.permissions) {
483
+ const holders = model.authorization.roles.filter((r) => r.permissions.includes(p.name)).map((r) => r.name);
484
+ const adminOnly = holders.length > 0 && holders.every((h) => h === "admin");
485
+ const list = permsByResource.get(p.resource) ?? [];
486
+ list.push({ perm: p.name, operation: p.operation, adminOnly });
487
+ permsByResource.set(p.resource, list);
488
+ }
489
+ const checkRuleAgainstEntries = (rule, resourceKey, entries) => {
490
+ const ruleOps = new Set(rule.operations.flatMap(expandOp));
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;
499
+ const checksAdminClaim = rule.claimReferences.some((c) => c.toLowerCase() === "admin");
500
+ const checksAdminRole = /token\.\w+\s*==\s*['"]admin['"]/i.test(rule.condition ?? "");
501
+ if (checksAdminClaim || checksAdminRole) return;
502
+ const checksAuth = rule.authReferences.length > 0;
503
+ if (!checksAuth) return;
504
+ findings.push(
505
+ make(
506
+ "FBA012",
507
+ "WARNING",
508
+ "PROBABLE",
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.`,
510
+ `FBA012:${rule.path}:${[...rule.operations].sort().join(",")}:${rule.location.start.line}:${resourceKey}`,
511
+ {
512
+ file: rule.location.file,
513
+ line: rule.location.start.line,
514
+ resource: rule.path,
515
+ evidence: [ev("rule-contract-mismatch", `${rule.path} vs ${restricted.map((r) => r.perm).join(", ")}`, "PROBABLE", rule.location.file, rule.location.start.line)],
516
+ fix: "Adicionar checagem de claim de admin na Rule (ex: request.auth.token.admin == true) ou relaxar o contrato."
517
+ }
518
+ )
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
+ }
541
+ }
414
542
  for (const dyn of permissionCalls.dynamic) {
415
543
  findings.push(
416
544
  make(
@@ -459,9 +587,6 @@ async function runChecks(model, rootDir, opts = {}) {
459
587
  async function collectPermissionCalls(rootDir, adapterFn) {
460
588
  const literals = [];
461
589
  const dynamic = [];
462
- if (!adapterFn.includes(".")) {
463
- return { literals, dynamic };
464
- }
465
590
  const bases = [adapterFn];
466
591
  const patterns = [];
467
592
  for (const b of bases) {
@@ -480,10 +605,25 @@ async function collectPermissionCalls(rootDir, adapterFn) {
480
605
  for (const m of result.matches) {
481
606
  const raw = (m.metaVariables["PERM"] ?? "").trim();
482
607
  const line = m.line + 1;
483
- 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);
484
617
  if (isQuoted) {
485
- const perm = raw.replace(/^['"]|['"]$/g, "").trim();
618
+ const perm = raw.replace(/^['"`]|['"`]$/g, "").trim();
486
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
+ }
487
627
  } else if (raw.length > 0) {
488
628
  const k = `${m.file}:${line}:${raw}`;
489
629
  if (!seen.has(k)) {
@@ -600,7 +740,7 @@ var QueryShapeSchema = z.strictObject({
600
740
  location: LocationSchema
601
741
  });
602
742
  var PermissionSchema = z.strictObject({
603
- name: z.string().min(3),
743
+ name: z.string().min(1),
604
744
  resource: z.string().min(1),
605
745
  operation: OperationSchema
606
746
  });
@@ -613,6 +753,7 @@ var RuleSchema = z.strictObject({
613
753
  path: z.string().min(1),
614
754
  operations: z.array(OperationSchema),
615
755
  conditionPresent: z.boolean(),
756
+ condition: z.string().optional(),
616
757
  authReferences: z.array(z.string()),
617
758
  claimReferences: z.array(z.string()),
618
759
  resourceReferences: z.array(z.string()),
@@ -684,7 +825,7 @@ var FindingSchema = z.strictObject({
684
825
  });
685
826
  var PermissionNameSchema = z.string().regex(/^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)*$/);
686
827
  var RoleNameSchema = z.string().regex(/^[a-z][a-z0-9_-]*$/);
687
- var AuditYamlSchema = z.strictObject({
828
+ var AuditYamlSchema = z.object({
688
829
  authorization: z.strictObject({
689
830
  adapter: z.strictObject({ function: z.string().min(1) }).optional(),
690
831
  roles: z.record(RoleNameSchema, z.strictObject({ permissions: z.array(PermissionNameSchema) })),
@@ -701,8 +842,6 @@ var AuditYamlSchema = z.strictObject({
701
842
  });
702
843
 
703
844
  // src/scan.ts
704
- import { readFileSync as readFileSync4, existsSync as existsSync3 } from "fs";
705
- import { parse as parseYaml } from "yaml";
706
845
  async function scan(rootDir, opts = {}) {
707
846
  const d = discover(rootDir);
708
847
  const model = emptyModel(rootDir, d);
@@ -720,20 +859,65 @@ async function scan(rootDir, opts = {}) {
720
859
  });
721
860
  }
722
861
  if (d.firestoreRulesFile && existsSync3(d.firestoreRulesFile)) {
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";
724
- 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
+ }
725
877
  }
726
878
  if (d.firestoreIndexesFile && existsSync3(d.firestoreIndexesFile)) {
727
879
  try {
728
880
  const raw = JSON.parse(readFileSync4(d.firestoreIndexesFile, "utf-8"));
729
- model.indexes = raw.indexes?.map((i) => ({
730
- collectionGroup: i.collectionGroup ?? "(unknown)",
731
- queryScope: i.queryScope === "COLLECTION_GROUP" ? "COLLECTION_GROUP" : "COLLECTION",
732
- fields: (i.fields ?? []).map((f) => ({
733
- fieldPath: f.fieldPath ?? "(unknown)",
734
- mode: f.vectorConfig ? "VECTOR" : f.arrayConfig === "CONTAINS" ? "ARRAY_CONTAINS" : f.order ?? "ASCENDING"
735
- }))
736
- })) ?? [];
881
+ const validModes = ["ASCENDING", "DESCENDING", "ARRAY_CONTAINS", "VECTOR"];
882
+ const entries = [];
883
+ let skipped = 0;
884
+ for (const i of raw.indexes ?? []) {
885
+ if (typeof i.collectionGroup !== "string" || i.collectionGroup.length === 0) {
886
+ skipped += 1;
887
+ continue;
888
+ }
889
+ const fields = [];
890
+ for (const f of i.fields ?? []) {
891
+ if (typeof f.fieldPath !== "string" || f.fieldPath.length === 0) {
892
+ skipped += 1;
893
+ continue;
894
+ }
895
+ const mode = f.vectorConfig ? "VECTOR" : f.arrayConfig === "CONTAINS" ? "ARRAY_CONTAINS" : f.order;
896
+ if (mode !== "ASCENDING" && mode !== "DESCENDING" && mode !== "ARRAY_CONTAINS" && mode !== "VECTOR") {
897
+ skipped += 1;
898
+ continue;
899
+ }
900
+ fields.push({ fieldPath: f.fieldPath, mode });
901
+ }
902
+ entries.push({
903
+ collectionGroup: i.collectionGroup,
904
+ queryScope: i.queryScope === "COLLECTION_GROUP" ? "COLLECTION_GROUP" : "COLLECTION",
905
+ fields
906
+ });
907
+ }
908
+ model.indexes = entries;
909
+ if (skipped > 0) {
910
+ findings_pre.push({
911
+ rule: "INDEXES_INVALID",
912
+ severity: "WARNING",
913
+ confidence: "CONFIRMED",
914
+ message: `firestore.indexes.json com ${skipped} entrada(s) inv\xE1lidas ignoradas \u2014 verifique campos.`,
915
+ fingerprint: "INDEXES_INVALID",
916
+ evidence: [
917
+ { id: "ev-indexes-skipped", kind: "indexes-parse", summary: `${skipped} inv\xE1lidas`, confidence: "CONFIRMED" }
918
+ ]
919
+ });
920
+ }
737
921
  } catch {
738
922
  findings_pre.push({
739
923
  rule: "INDEXES_INVALID",
@@ -824,14 +1008,21 @@ async function scan(rootDir, opts = {}) {
824
1008
  const errors = findings.filter((f) => f.severity === "ERROR").length;
825
1009
  const warnings = findings.filter((f) => f.severity === "WARNING").length;
826
1010
  const infos = findings.filter((f) => f.severity === "INFO").length;
827
- 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 } };
828
1017
  }
829
1018
 
830
1019
  export {
831
1020
  discover,
1021
+ SERVER_HINTS_SEGMENTS,
832
1022
  classifyOrigin,
833
1023
  emptyModel,
834
1024
  stripRuleComments,
1025
+ opsFrom,
835
1026
  extractRules,
836
1027
  isPublicCondition,
837
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-N72YT24M.js";
4
+ } from "./chunk-N57MCLTZ.js";
5
5
 
6
6
  // src/cli.ts
7
7
  import { createRequire } from "module";
@@ -14,16 +14,117 @@ async function main() {
14
14
  console.log(pkg.version);
15
15
  return;
16
16
  }
17
- if (cmd !== "scan") {
17
+ if (cmd === "--help" || cmd === "-h" || cmd === "help") {
18
+ console.log(`firebase-audit v${pkg.version}
19
+
20
+ Uso:
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)
25
+
26
+ Exit codes: 0 ok informativo; 2 quando --strict/check encontra ERROR. scan sozinho nunca reprova (use check no CI).`);
27
+ return;
28
+ }
29
+ if (cmd !== "scan" && cmd !== "check" && cmd !== "verify" && cmd !== "drift") {
18
30
  console.error(`Comando desconhecido: ${cmd}
19
31
  Uso: firebase-audit scan [--json] [--strict] [--adapter=fn] [--cwd=path]`);
20
32
  process.exit(1);
21
33
  }
34
+ const strict = args.includes("--strict") || cmd === "check";
22
35
  const asJson = args.includes("--json");
23
- const strict = args.includes("--strict");
24
- const adapterArg = args.find((a) => a.startsWith("--adapter="))?.split("=")[1];
25
- const cwdArg = args.find((a) => a.startsWith("--cwd="))?.split("=")[1];
26
- const rootDir = cwdArg ?? process.cwd();
36
+ const getArg = (name) => {
37
+ const pref = `--${name}=`;
38
+ const hit = args.find((a) => a.startsWith(pref));
39
+ if (hit) return hit.slice(pref.length);
40
+ const idx = args.findIndex((a) => a === `--${name}`);
41
+ if (idx !== -1 && args[idx + 1] && !args[idx + 1].startsWith("--")) return args[idx + 1];
42
+ return void 0;
43
+ };
44
+ const knownFlags = /* @__PURE__ */ new Set(["--json", "--strict", "--help", "-h", "help", "--version", "-v", "--coverage", "--local", "--remote"]);
45
+ for (const a of args.slice(1)) {
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)) {
48
+ console.error(`Flag desconhecida: ${a}
49
+ Uso: firebase-audit scan|check|verify|drift [--json] ...`);
50
+ process.exit(1);
51
+ }
52
+ }
53
+ const adapterArg = getArg("adapter");
54
+ const cwdArg = getArg("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
+ }
27
128
  const { findings, summary } = await scan(rootDir, { strict, adapterFn: adapterArg });
28
129
  if (asJson) {
29
130
  console.log(JSON.stringify({ version: pkg.version, summary, findings }, null, 2));
@@ -35,7 +136,7 @@ FIREBASE AUDIT v${pkg.version}`);
35
136
  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
137
  if (findings.length === 0) {
37
138
  console.log(`
38
- \u2713 Nenhum problema nos checks V1 implementados (parcial: FBA001-FBA004, FBA009-FBA011).
139
+ \u2713 Nenhum problema nos checks V1+V2 implementados (parcial: FBA001-FBA004, FBA009-FBA013).
39
140
  `);
40
141
  return;
41
142
  }
@@ -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,14 +651,12 @@ 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
- declare const IMPLEMENTED_CHECKS: readonly ["FBA001", "FBA002", "FBA003", "FBA004", "FBA009", "FBA010", "FBA011"];
659
+ declare const IMPLEMENTED_CHECKS: readonly ["FBA001", "FBA002", "FBA003", "FBA004", "FBA009", "FBA010", "FBA011", "FBA012", "FBA013"];
654
660
  declare function runChecks(model: ProjectModel, rootDir: string, opts?: {
655
661
  adapterFn?: string;
656
662
  }): Promise<Finding[]>;
@@ -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-N72YT24M.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.1.1",
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",