@justmpm/firebase-audit 0.2.0 → 0.3.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.
@@ -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,21 @@ 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 (hasSeg("pages", "api") && rel.includes("pages/api/")) {
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 (hasSeg("app", "api") && rel.includes("app/api/")) {
68
+ return "SERVER";
69
69
  }
70
+ if (segs.includes("app") && rel.endsWith("route.ts") || segs.includes("app") && rel.endsWith("route.js")) {
71
+ return "SERVER";
72
+ }
73
+ if (rel.includes("src/app/api/")) return "SERVER";
74
+ if (hasSeg("functions", "server", "backend", "scripts")) return "SERVER";
70
75
  if (content.includes("firebase-admin") || content.includes("firebase-functions")) {
71
76
  return "CLIENT";
72
77
  }
@@ -165,6 +170,30 @@ function opsFrom(target) {
165
170
  }
166
171
  return { ops: out, known: out.length > 0 && known };
167
172
  }
173
+ function findBlockEnd(content, openBraceIndex) {
174
+ let depth = 0;
175
+ let quote = null;
176
+ for (let i = openBraceIndex; i < content.length; i++) {
177
+ const c = content[i];
178
+ if (quote) {
179
+ if (c === quote && content[i - 1] !== "\\") quote = null;
180
+ continue;
181
+ }
182
+ if (c === '"' || c === "'" || c === "`") {
183
+ quote = c;
184
+ continue;
185
+ }
186
+ if (c === "{") depth += 1;
187
+ else if (c === "}") {
188
+ depth -= 1;
189
+ if (depth === 0) return i + 1;
190
+ }
191
+ }
192
+ return content.length;
193
+ }
194
+ function normalizeTarget(target) {
195
+ return target.toLowerCase().split(",").map((s) => s.trim()).filter(Boolean).sort().join(",");
196
+ }
168
197
  function extractRules(rulesFile, relFile) {
169
198
  const rawContent = readFileSync2(rulesFile, "utf-8");
170
199
  const content = stripRuleComments(rawContent);
@@ -175,7 +204,8 @@ function extractRules(rulesFile, relFile) {
175
204
  while ((m = matchRe.exec(content)) !== null) {
176
205
  const before = content.slice(0, m.index);
177
206
  const line = before.split("\n").length;
178
- matchBlocks.push({ path: m[1], line, blockStart: m.index });
207
+ const openBrace = m.index + m[0].length - 1;
208
+ matchBlocks.push({ path: m[1], line, blockStart: m.index, blockEnd: findBlockEnd(content, openBrace) });
179
209
  }
180
210
  const allowRe = new RegExp(ALLOW_RE.source, "g");
181
211
  let a;
@@ -186,7 +216,8 @@ function extractRules(rulesFile, relFile) {
186
216
  const target = a[1];
187
217
  const condition = (a[2] ?? "").trim();
188
218
  const unconditional = a[2] === void 0;
189
- const currentMatch = [...matchBlocks].reverse().find((b) => b.blockStart <= a.index);
219
+ const containing = matchBlocks.filter((b) => b.blockStart <= a.index && a.index < b.blockEnd);
220
+ const currentMatch = containing.sort((x, y) => y.blockStart - x.blockStart)[0];
190
221
  const authReferences = [];
191
222
  if (condition.includes("request.auth")) authReferences.push("request.auth");
192
223
  if (condition.includes("request.auth.uid")) authReferences.push("request.auth.uid");
@@ -205,6 +236,7 @@ function extractRules(rulesFile, relFile) {
205
236
  path: currentMatch?.path ?? "/(unknown)",
206
237
  operations: ops,
207
238
  conditionPresent: !unconditional && condition.length > 0,
239
+ condition: unconditional ? void 0 : condition,
208
240
  authReferences,
209
241
  claimReferences,
210
242
  resourceReferences,
@@ -243,7 +275,7 @@ function isPublicCondition(condition) {
243
275
  return { isPublic: false, confidence: "CONFIRMED" };
244
276
  }
245
277
  if (low === "true") return { isPublic: true, confidence: "CONFIRMED" };
246
- if (/(^|\|\|)true($|\|\||&&)/.test(low)) return { isPublic: true, confidence: "PROBABLE" };
278
+ if (/(^|\|\|)true($|\|\|)/.test(low)) return { isPublic: true, confidence: "PROBABLE" };
247
279
  if (low.includes("request.auth==null") || low.includes("request.auth===null")) {
248
280
  return { isPublic: true, confidence: "CONFIRMED" };
249
281
  }
@@ -256,7 +288,10 @@ function findPublicAllows(rulesFile) {
256
288
  const matchRe = new RegExp(MATCH_RE.source, "g");
257
289
  const blocks = [];
258
290
  let mm0;
259
- while ((mm0 = matchRe.exec(content)) !== null) blocks.push({ path: mm0[1], index: mm0.index });
291
+ while ((mm0 = matchRe.exec(content)) !== null) {
292
+ const openBrace = mm0.index + mm0[0].length - 1;
293
+ blocks.push({ path: mm0[1], index: mm0.index, end: findBlockEnd(content, openBrace) });
294
+ }
260
295
  const re = new RegExp(ALLOW_RE.source, "g");
261
296
  let mm;
262
297
  while ((mm = re.exec(content)) !== null) {
@@ -264,7 +299,8 @@ function findPublicAllows(rulesFile) {
264
299
  const target = mm[1].trim();
265
300
  const check = isPublicCondition(mm[2]);
266
301
  if (!check.isPublic) continue;
267
- const path = [...blocks].reverse().find((b) => b.index <= mm.index)?.path ?? "/(unknown)";
302
+ const containing = blocks.filter((b) => b.index <= mm.index && mm.index < b.end);
303
+ const path = containing.sort((x, y) => y.index - x.index)[0]?.path ?? "/(unknown)";
268
304
  out.push({ line, target, path, confidence: check.confidence });
269
305
  }
270
306
  return out;
@@ -319,7 +355,7 @@ async function runChecks(model, rootDir, opts = {}) {
319
355
  "ERROR",
320
356
  pub.confidence,
321
357
  `Escrita p\xFAblica em ${pub.path} (${pub.target}). Qualquer cliente pode escrever.`,
322
- `FBA001:${relRules}:${pub.path}:${pub.target}`,
358
+ `FBA001:${relRules}:${pub.path}:${normalizeTarget(pub.target)}`,
323
359
  {
324
360
  file: relRules,
325
361
  line: pub.line,
@@ -337,7 +373,7 @@ async function runChecks(model, rootDir, opts = {}) {
337
373
  "WARNING",
338
374
  pub.confidence,
339
375
  `Leitura p\xFAblica em ${pub.path} (${pub.target}). Pode ser proposital.`,
340
- `FBA002:${relRules}:${pub.path}:${pub.target}`,
376
+ `FBA002:${relRules}:${pub.path}:${normalizeTarget(pub.target)}`,
341
377
  {
342
378
  file: relRules,
343
379
  line: pub.line,
@@ -431,14 +467,20 @@ async function runChecks(model, rootDir, opts = {}) {
431
467
  );
432
468
  }
433
469
  }
434
- const publicPaths = new Set(
435
- absRules && existsSync2(absRules) ? findPublicAllows(absRules).map((p) => `${p.path}::${p.target}`) : []
436
- );
437
470
  const expandOp = (op) => {
438
471
  if (op === "read") return ["read", "get", "list"];
439
472
  if (op === "write") return ["write", "create", "update", "delete"];
440
473
  return [op];
441
474
  };
475
+ const publicPaths = /* @__PURE__ */ new Set();
476
+ if (absRules && existsSync2(absRules)) {
477
+ for (const p of findPublicAllows(absRules)) {
478
+ const { ops } = opsFrom(p.target);
479
+ for (const op of /* @__PURE__ */ new Set([...ops, ...ops.flatMap(expandOp)])) {
480
+ publicPaths.add(`${p.path}::${op.toLowerCase()}`);
481
+ }
482
+ }
483
+ }
442
484
  const permsByResource = /* @__PURE__ */ new Map();
443
485
  for (const p of model.authorization.permissions) {
444
486
  const holders = model.authorization.roles.filter((r) => r.permissions.includes(p.name)).map((r) => r.name);
@@ -447,29 +489,28 @@ async function runChecks(model, rootDir, opts = {}) {
447
489
  list.push({ perm: p.name, operation: p.operation, adminOnly });
448
490
  permsByResource.set(p.resource, list);
449
491
  }
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;
492
+ const checkRuleAgainstEntries = (rule, resourceKey, entries) => {
455
493
  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;
494
+ const restricted = entries.filter(
495
+ (e) => e.adminOnly && expandOp(e.operation).some((o) => ruleOps.has(o))
496
+ );
497
+ if (restricted.length === 0) return;
498
+ const isPublicRule = [...ruleOps].some(
499
+ (op) => publicPaths.has(`${rule.path}::${op.toLowerCase()}`)
500
+ );
501
+ if (isPublicRule) return;
462
502
  const checksAdminClaim = rule.claimReferences.some((c) => c.toLowerCase() === "admin");
463
- if (checksAdminClaim) continue;
503
+ const checksAdminRole = /token\.\w+\s*==\s*['"]admin['"]/i.test(rule.condition ?? "");
504
+ if (checksAdminClaim || checksAdminRole) return;
464
505
  const checksAuth = rule.authReferences.length > 0;
465
- if (!checksAuth) continue;
506
+ if (!checksAuth) return;
466
507
  findings.push(
467
508
  make(
468
509
  "FBA012",
469
510
  "WARNING",
470
511
  "PROBABLE",
471
512
  `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(",")}`,
513
+ `FBA012:${rule.path}:${[...rule.operations].sort().join(",")}:${rule.location.start.line}:${resourceKey}`,
473
514
  {
474
515
  file: rule.location.file,
475
516
  line: rule.location.start.line,
@@ -479,6 +520,27 @@ async function runChecks(model, rootDir, opts = {}) {
479
520
  }
480
521
  )
481
522
  );
523
+ };
524
+ for (const rule of model.rules) {
525
+ const segments = rule.path.replace(/^\/+/, "").split("/").filter(Boolean);
526
+ if (segments.length === 0 || segments[0] === "(unknown)") continue;
527
+ if (segments[0] === "databases") {
528
+ const docIdx = segments.indexOf("documents");
529
+ const candidate = docIdx !== -1 ? segments[docIdx + 1] : void 0;
530
+ if (!candidate || candidate.startsWith("{")) continue;
531
+ const entriesAbs = permsByResource.get(candidate);
532
+ if (!entriesAbs) continue;
533
+ checkRuleAgainstEntries(rule, candidate, entriesAbs);
534
+ continue;
535
+ }
536
+ const tried = /* @__PURE__ */ new Set();
537
+ for (const seg of segments) {
538
+ if (seg.startsWith("{") || seg.startsWith("(") || tried.has(seg)) continue;
539
+ tried.add(seg);
540
+ const entries = permsByResource.get(seg);
541
+ if (!entries || entries.length === 0) continue;
542
+ checkRuleAgainstEntries(rule, seg, entries);
543
+ }
482
544
  }
483
545
  for (const dyn of permissionCalls.dynamic) {
484
546
  findings.push(
@@ -528,9 +590,6 @@ async function runChecks(model, rootDir, opts = {}) {
528
590
  async function collectPermissionCalls(rootDir, adapterFn) {
529
591
  const literals = [];
530
592
  const dynamic = [];
531
- if (!adapterFn.includes(".")) {
532
- return { literals, dynamic };
533
- }
534
593
  const bases = [adapterFn];
535
594
  const patterns = [];
536
595
  for (const b of bases) {
@@ -549,10 +608,25 @@ async function collectPermissionCalls(rootDir, adapterFn) {
549
608
  for (const m of result.matches) {
550
609
  const raw = (m.metaVariables["PERM"] ?? "").trim();
551
610
  const line = m.line + 1;
552
- const isQuoted = /^['"].*['"]$/.test(raw);
611
+ if (raw.includes("${")) {
612
+ const k = `${m.file}:${line}:${raw}`;
613
+ if (!seen.has(k)) {
614
+ seen.add(k);
615
+ dynamic.push({ file: m.file, line, text: m.text });
616
+ }
617
+ continue;
618
+ }
619
+ const isQuoted = /^['"`].*['"`]$/.test(raw);
553
620
  if (isQuoted) {
554
- const perm = raw.replace(/^['"]|['"]$/g, "").trim();
621
+ const perm = raw.replace(/^['"`]|['"`]$/g, "").trim();
555
622
  if (perm.length >= 3) pushLiteral(m.file, line, perm);
623
+ else if (perm.length > 0) {
624
+ const k = `${m.file}:${line}:${raw}:short`;
625
+ if (!seen.has(k)) {
626
+ seen.add(k);
627
+ dynamic.push({ file: m.file, line, text: m.text });
628
+ }
629
+ }
556
630
  } else if (raw.length > 0) {
557
631
  const k = `${m.file}:${line}:${raw}`;
558
632
  if (!seen.has(k)) {
@@ -570,10 +644,19 @@ async function collectAdminImports(rootDir) {
570
644
  const out = [];
571
645
  const patterns = [
572
646
  'import { $$$ITEMS } from "$MODULE"',
647
+ "import { $$$ITEMS } from '$MODULE'",
573
648
  'import $DEFAULT from "$MODULE"',
649
+ "import $DEFAULT from '$MODULE'",
574
650
  'import * as $NS from "$MODULE"',
651
+ "import * as $NS from '$MODULE'",
652
+ 'import "$MODULE"',
653
+ "import '$MODULE'",
575
654
  'const $X = require("$MODULE")',
576
- 'require("$MODULE")'
655
+ "const $X = require('$MODULE')",
656
+ 'require("$MODULE")',
657
+ "require('$MODULE')",
658
+ 'import("$MODULE")',
659
+ "import('$MODULE')"
577
660
  ];
578
661
  const seen = /* @__PURE__ */ new Set();
579
662
  for (const pattern of patterns) {
@@ -682,6 +765,7 @@ var RuleSchema = z.strictObject({
682
765
  path: z.string().min(1),
683
766
  operations: z.array(OperationSchema),
684
767
  conditionPresent: z.boolean(),
768
+ condition: z.string().optional(),
685
769
  authReferences: z.array(z.string()),
686
770
  claimReferences: z.array(z.string()),
687
771
  resourceReferences: z.array(z.string()),
@@ -770,8 +854,6 @@ var AuditYamlSchema = z.strictObject({
770
854
  });
771
855
 
772
856
  // src/scan.ts
773
- import { readFileSync as readFileSync4, existsSync as existsSync3 } from "fs";
774
- import { parse as parseYaml } from "yaml";
775
857
  async function scan(rootDir, opts = {}) {
776
858
  const d = discover(rootDir);
777
859
  const model = emptyModel(rootDir, d);
@@ -789,8 +871,21 @@ async function scan(rootDir, opts = {}) {
789
871
  });
790
872
  }
791
873
  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);
874
+ const rel = model.firebase.firestore.rulesFile ?? "firestore.rules";
875
+ try {
876
+ model.rules = extractRules(d.firestoreRulesFile, rel);
877
+ } catch {
878
+ findings_pre.push({
879
+ rule: "RULES_UNREADABLE",
880
+ severity: "WARNING",
881
+ confidence: "CONFIRMED",
882
+ message: "firestore.rules existe mas n\xE3o p\xF4de ser lido \u2014 FBA001/FBA002 sem cobertura.",
883
+ fingerprint: "RULES_UNREADABLE",
884
+ evidence: [
885
+ { id: "ev-rules-unreadable", kind: "rules-read", summary: "leitura falhou", confidence: "CONFIRMED" }
886
+ ]
887
+ });
888
+ }
794
889
  }
795
890
  if (d.firestoreIndexesFile && existsSync3(d.firestoreIndexesFile)) {
796
891
  try {
@@ -798,18 +893,24 @@ async function scan(rootDir, opts = {}) {
798
893
  const validModes = ["ASCENDING", "DESCENDING", "ARRAY_CONTAINS", "VECTOR"];
799
894
  const entries = [];
800
895
  let skipped = 0;
896
+ let scopeSkipped = 0;
801
897
  for (const i of raw.indexes ?? []) {
802
898
  if (typeof i.collectionGroup !== "string" || i.collectionGroup.length === 0) {
803
899
  skipped += 1;
804
900
  continue;
805
901
  }
902
+ if (i.queryScope !== void 0 && i.queryScope !== "COLLECTION" && i.queryScope !== "COLLECTION_GROUP") {
903
+ scopeSkipped += 1;
904
+ continue;
905
+ }
806
906
  const fields = [];
807
907
  for (const f of i.fields ?? []) {
808
- if (typeof f.fieldPath !== "string" || f.fieldPath.length === 0) {
908
+ if (typeof f.fieldPath !== "string" || f.fieldPath.length === 0 || f.fieldPath === "__name__") {
809
909
  skipped += 1;
810
910
  continue;
811
911
  }
812
- const mode = f.vectorConfig ? "VECTOR" : f.arrayConfig === "CONTAINS" ? "ARRAY_CONTAINS" : f.order;
912
+ const rawMode = f.vectorConfig ? "VECTOR" : f.arrayConfig === "CONTAINS" ? "ARRAY_CONTAINS" : typeof f.mode === "string" ? f.mode : f.order;
913
+ const mode = rawMode;
813
914
  if (mode !== "ASCENDING" && mode !== "DESCENDING" && mode !== "ARRAY_CONTAINS" && mode !== "VECTOR") {
814
915
  skipped += 1;
815
916
  continue;
@@ -823,15 +924,16 @@ async function scan(rootDir, opts = {}) {
823
924
  });
824
925
  }
825
926
  model.indexes = entries;
826
- if (skipped > 0) {
927
+ const totalSkipped = skipped + scopeSkipped;
928
+ if (totalSkipped > 0) {
827
929
  findings_pre.push({
828
930
  rule: "INDEXES_INVALID",
829
931
  severity: "WARNING",
830
932
  confidence: "CONFIRMED",
831
- message: `firestore.indexes.json com ${skipped} entrada(s) inv\xE1lidas ignoradas \u2014 verifique campos.`,
933
+ message: `firestore.indexes.json com ${totalSkipped} entrada(s) inv\xE1lidas ignoradas \u2014 verifique campos.`,
832
934
  fingerprint: "INDEXES_INVALID",
833
935
  evidence: [
834
- { id: "ev-indexes-skipped", kind: "indexes-parse", summary: `${skipped} inv\xE1lidas`, confidence: "CONFIRMED" }
936
+ { id: "ev-indexes-skipped", kind: "indexes-parse", summary: `${totalSkipped} inv\xE1lidas`, confidence: "CONFIRMED" }
835
937
  ]
836
938
  });
837
939
  }
@@ -925,14 +1027,21 @@ async function scan(rootDir, opts = {}) {
925
1027
  const errors = findings.filter((f) => f.severity === "ERROR").length;
926
1028
  const warnings = findings.filter((f) => f.severity === "WARNING").length;
927
1029
  const infos = findings.filter((f) => f.severity === "INFO").length;
928
- return { model, findings, summary: { errors, warnings, infos, passed: IMPLEMENTED_CHECKS.length } };
1030
+ const failedChecks = new Set(
1031
+ findings.filter((f) => f.severity === "ERROR" || f.severity === "WARNING").map((f) => f.rule)
1032
+ );
1033
+ const total = IMPLEMENTED_CHECKS.length;
1034
+ const passed = IMPLEMENTED_CHECKS.filter((c) => !failedChecks.has(c)).length;
1035
+ return { model, findings, summary: { errors, warnings, infos, passed, total } };
929
1036
  }
930
1037
 
931
1038
  export {
932
1039
  discover,
1040
+ SERVER_HINTS_SEGMENTS,
933
1041
  classifyOrigin,
934
1042
  emptyModel,
935
1043
  stripRuleComments,
1044
+ opsFrom,
936
1045
  extractRules,
937
1046
  isPublicCondition,
938
1047
  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-UMQH7UDI.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-TLHI777J.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>;
@@ -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-UMQH7UDI.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-UMQH7UDI.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.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",