@justmpm/firebase-audit 0.5.0 → 0.5.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,356 @@
1
+ // src/rules.ts
2
+ import { readFileSync } from "fs";
3
+ import { createHash } from "crypto";
4
+ var MATCH_RE = /match\s+(\/[^{\s]*(?:\{[^}]*\}[^{\s]*)*)\s*\{/g;
5
+ var ALLOW_RE = /allow\s+([^;:]+)(?::\s*if\s+([^;]+))?;/g;
6
+ function stripRuleComments(content) {
7
+ let out = "";
8
+ let i = 0;
9
+ let quote = null;
10
+ while (i < content.length) {
11
+ const c = content[i];
12
+ const next = content[i + 1] ?? "";
13
+ if (quote) {
14
+ out += c;
15
+ if (c === quote && content[i - 1] !== "\\") quote = null;
16
+ i += 1;
17
+ continue;
18
+ }
19
+ if (c === '"' || c === "'" || c === "`") {
20
+ quote = c;
21
+ out += c;
22
+ i += 1;
23
+ continue;
24
+ }
25
+ if (c === "/" && next === "/") {
26
+ while (i < content.length && content[i] !== "\n") i += 1;
27
+ continue;
28
+ }
29
+ if (c === "/" && next === "*") {
30
+ i += 2;
31
+ while (i < content.length && !(content[i] === "*" && content[i + 1] === "/")) {
32
+ if (content[i] === "\n") out += "\n";
33
+ i += 1;
34
+ }
35
+ i += 2;
36
+ continue;
37
+ }
38
+ out += c;
39
+ i += 1;
40
+ }
41
+ return out;
42
+ }
43
+ function opsFrom(target) {
44
+ const t = target.trim().toLowerCase();
45
+ if (t === "read") return { ops: ["read", "get", "list"], known: true };
46
+ if (t === "write") return { ops: ["write", "create", "update", "delete"], known: true };
47
+ const parts = t.split(",").map((s) => s.trim()).filter(Boolean);
48
+ const valid = ["read", "get", "list", "create", "update", "delete", "write"];
49
+ const out = [];
50
+ let known = false;
51
+ for (const p of parts) {
52
+ if (valid.includes(p)) {
53
+ known = true;
54
+ out.push(p);
55
+ if (p === "write") {
56
+ for (const extra of ["create", "update", "delete"]) {
57
+ if (!out.includes(extra)) out.push(extra);
58
+ }
59
+ }
60
+ if (p === "read") {
61
+ for (const extra of ["get", "list"]) {
62
+ if (!out.includes(extra)) out.push(extra);
63
+ }
64
+ }
65
+ }
66
+ }
67
+ return { ops: out, known: out.length > 0 && known };
68
+ }
69
+ function findBlockEnd(content, openBraceIndex) {
70
+ let depth = 0;
71
+ let quote = null;
72
+ for (let i = openBraceIndex; i < content.length; i++) {
73
+ const c = content[i];
74
+ if (quote) {
75
+ if (c === quote && content[i - 1] !== "\\") quote = null;
76
+ continue;
77
+ }
78
+ if (c === '"' || c === "'" || c === "`") {
79
+ quote = c;
80
+ continue;
81
+ }
82
+ if (c === "{") depth += 1;
83
+ else if (c === "}") {
84
+ depth -= 1;
85
+ if (depth === 0) return i + 1;
86
+ }
87
+ }
88
+ return content.length;
89
+ }
90
+ function normalizeTarget(target) {
91
+ return target.toLowerCase().split(",").map((s) => s.trim()).filter(Boolean).sort().join(",");
92
+ }
93
+ function parseMatchBlocks(content) {
94
+ const blocks = [];
95
+ const matchRe = new RegExp(MATCH_RE.source, "g");
96
+ let m;
97
+ while ((m = matchRe.exec(content)) !== null) {
98
+ const before = content.slice(0, m.index);
99
+ const line = before.split("\n").length;
100
+ const openBrace = m.index + m[0].length - 1;
101
+ blocks.push({ path: m[1], line, blockStart: m.index, blockEnd: findBlockEnd(content, openBrace) });
102
+ }
103
+ return blocks;
104
+ }
105
+ function buildFullPath(containingAsc) {
106
+ const parts = [];
107
+ for (const b of containingAsc) {
108
+ const p = b.path.trim();
109
+ if (/^\/databases\/\{[^}]+\}\/documents\/?$/.test(p)) continue;
110
+ const stripped = p.replace(/^\/+|\/+$/g, "");
111
+ if (stripped.length === 0) continue;
112
+ const docIdx = stripped.indexOf("/documents/");
113
+ if (stripped.startsWith("databases/") && docIdx !== -1) {
114
+ const rest = stripped.slice(docIdx + "/documents/".length);
115
+ if (rest.length > 0) parts.push(rest);
116
+ continue;
117
+ }
118
+ parts.push(stripped);
119
+ }
120
+ if (parts.length === 0) return "/(unknown)";
121
+ return "/" + parts.join("/");
122
+ }
123
+ function parseRuleFunctions(content) {
124
+ const out = /* @__PURE__ */ new Map();
125
+ for (const [name, fn] of parseRuleFunctionsDetailed(content)) {
126
+ out.set(name, fn.body);
127
+ }
128
+ return out;
129
+ }
130
+ function parseRuleFunctionsDetailed(content) {
131
+ const out = /* @__PURE__ */ new Map();
132
+ const re = /function\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)\s*\{/g;
133
+ let m;
134
+ while ((m = re.exec(content)) !== null) {
135
+ const openBrace = m.index + m[0].length - 1;
136
+ const end = findBlockEnd(content, openBrace);
137
+ const body = content.slice(openBrace + 1, end - 1);
138
+ const params = m[2].trim() === "" ? [] : m[2].split(",").map((s) => s.trim()).filter((s) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(s));
139
+ out.set(m[1], { params, body });
140
+ }
141
+ return out;
142
+ }
143
+ function inlineHelperArgs(body, params, args) {
144
+ let out = body;
145
+ for (let i = 0; i < params.length; i++) {
146
+ const p = params[i];
147
+ const a = (args[i] ?? "").trim();
148
+ if (!p || !a) continue;
149
+ out = out.replace(new RegExp(`\\b${p}\\b`, "g"), () => a);
150
+ }
151
+ return out;
152
+ }
153
+ function inlineHelpersInCondition(condition, detailed) {
154
+ let out = condition;
155
+ for (const [name, fn] of detailed) {
156
+ const re = new RegExp(`\\b${name}\\s*\\(([^()]*)\\)`, "g");
157
+ out = out.replace(re, (_m, argsStr) => {
158
+ const args = argsStr.length === 0 ? [] : String(argsStr).split(",").map((s) => s.trim());
159
+ const inlined = inlineHelperArgs(fn.body, fn.params, args);
160
+ return `(${inlined})`;
161
+ });
162
+ }
163
+ return out;
164
+ }
165
+ function resolveHelperCondition(condition, functions) {
166
+ const t = condition.trim().replace(/;$/, "").trim();
167
+ const call = /^([A-Za-z_][A-Za-z0-9_]*)\s*\((.*)\)$/.exec(t);
168
+ if (!call) return condition;
169
+ const body = functions.get(call[1]);
170
+ if (body === void 0) return condition;
171
+ return body;
172
+ }
173
+ function resolveHelperConditionDetailed(condition, detailed) {
174
+ const t = condition.trim().replace(/;$/, "").trim();
175
+ const call = /^([A-Za-z_][A-Za-z0-9_]*)\s*\((.*)\)$/.exec(t);
176
+ if (!call) return condition;
177
+ const fn = detailed.get(call[1]);
178
+ if (!fn) return condition;
179
+ const args = call[2].length === 0 ? [] : call[2].split(",").map((s) => s.trim());
180
+ return inlineHelperArgs(fn.body, fn.params, args);
181
+ }
182
+ function extractRules(rulesFile, relFile) {
183
+ const rawContent = readFileSync(rulesFile, "utf-8");
184
+ return extractRulesContent(rawContent, relFile);
185
+ }
186
+ function extractRulesContent(rawContent, relFile) {
187
+ const content = stripRuleComments(rawContent);
188
+ const rules = [];
189
+ const functions = parseRuleFunctions(content);
190
+ const detailed = parseRuleFunctionsDetailed(content);
191
+ const matchBlocks = parseMatchBlocks(content);
192
+ const allowRe = new RegExp(ALLOW_RE.source, "g");
193
+ let a;
194
+ let idx = 0;
195
+ while ((a = allowRe.exec(content)) !== null) {
196
+ const before = content.slice(0, a.index);
197
+ const line = before.split("\n").length;
198
+ const target = a[1];
199
+ const rawCondition = (a[2] ?? "").trim();
200
+ const unconditional = a[2] === void 0;
201
+ const containing = matchBlocks.filter((b) => b.blockStart <= a.index && a.index < b.blockEnd).sort((x, y) => x.blockStart - y.blockStart);
202
+ const fullPath = containing.length > 0 ? buildFullPath(containing) : "/(unknown)";
203
+ const resolved = unconditional ? "" : resolveHelperCondition(rawCondition, functions);
204
+ const resolvedDetailed = unconditional ? "" : resolveHelperConditionDetailed(rawCondition, detailed);
205
+ const inlinedBody = unconditional ? "" : inlineHelpersInCondition(rawCondition, detailed);
206
+ const effectiveBody = resolvedDetailed !== rawCondition ? resolvedDetailed : resolved !== rawCondition ? resolved : inlinedBody;
207
+ const condition = rawCondition;
208
+ const authReferences = [];
209
+ const authSource = `${rawCondition} ${resolved} ${effectiveBody} ${inlinedBody}`;
210
+ if (authSource.includes("request.auth")) authReferences.push("request.auth");
211
+ if (authSource.includes("request.auth.uid")) authReferences.push("request.auth.uid");
212
+ const big = `${rawCondition} ${resolved} ${effectiveBody} ${inlinedBody}`;
213
+ const claimReferences = [
214
+ ...big.matchAll(/request\.auth\.token\.([A-Za-z0-9_]+)/g),
215
+ ...big.matchAll(/request\.auth\.token\.get\(\s*['"]([A-Za-z0-9_]+)['"]/g),
216
+ ...big.matchAll(/request\.auth\.token\[\s*['"]([A-Za-z0-9_]+)['"]\s*\]/g)
217
+ ].map((x) => x[1]);
218
+ const resourceReferences = [...big.matchAll(/resource\.data\.([A-Za-z0-9_]+)/g)].map(
219
+ (x) => x[1]
220
+ );
221
+ const requestResourceReferences = [
222
+ ...big.matchAll(/request\.resource\.data\.([A-Za-z0-9_]+)/g)
223
+ ].map((x) => x[1]);
224
+ const { ops, known } = opsFrom(target);
225
+ const isHelperCall = !unconditional && rawCondition !== resolved;
226
+ const hasAuth = authSource.includes("request.auth");
227
+ rules.push({
228
+ id: `rule-${idx++}`,
229
+ path: fullPath,
230
+ operations: ops,
231
+ conditionPresent: !unconditional && condition.length > 0,
232
+ condition: unconditional ? void 0 : condition,
233
+ authReferences,
234
+ claimReferences,
235
+ resourceReferences,
236
+ requestResourceReferences,
237
+ confidence: !known || isHelperCall && !hasAuth ? "UNKNOWN" : "CONFIRMED",
238
+ location: { file: relFile, start: { line, column: 0 } }
239
+ });
240
+ }
241
+ return rules;
242
+ }
243
+ function stripOuterParens(s) {
244
+ let norm = s.trim();
245
+ for (; ; ) {
246
+ if (!(norm.startsWith("(") && norm.endsWith(")"))) return norm;
247
+ let depth = 0;
248
+ let wrapsAll = true;
249
+ for (let i = 0; i < norm.length; i++) {
250
+ if (norm[i] === "(") depth += 1;
251
+ else if (norm[i] === ")") {
252
+ depth -= 1;
253
+ if (depth === 0 && i !== norm.length - 1) {
254
+ wrapsAll = false;
255
+ break;
256
+ }
257
+ }
258
+ }
259
+ if (!wrapsAll || depth !== 0) return norm;
260
+ norm = norm.slice(1, -1).trim();
261
+ }
262
+ }
263
+ function isPublicCondition(condition) {
264
+ if (condition === void 0) return { isPublic: true, confidence: "CONFIRMED" };
265
+ const norm = stripOuterParens(condition);
266
+ const low = norm.toLowerCase().replace(/\s+/g, "");
267
+ if (low === "false") return { isPublic: false, confidence: "CONFIRMED" };
268
+ if (/^!\(.*request\.auth(===|==)null/.test(low)) {
269
+ return { isPublic: false, confidence: "CONFIRMED" };
270
+ }
271
+ if (/^!\(.*request\.auth(!==|!=)null/.test(low)) {
272
+ return { isPublic: true, confidence: "CONFIRMED" };
273
+ }
274
+ if (low.includes("!(request.auth==null)") || low.includes("!(request.auth===null)") || low === "!request.auth==null" || low === "!request.auth===null") {
275
+ return { isPublic: false, confidence: "CONFIRMED" };
276
+ }
277
+ if (low === "true") return { isPublic: true, confidence: "CONFIRMED" };
278
+ if (/(^|\|\|)true($|\|\|)/.test(low)) return { isPublic: true, confidence: "PROBABLE" };
279
+ if (low.includes("request.auth==null") || low.includes("request.auth===null")) {
280
+ return { isPublic: true, confidence: "CONFIRMED" };
281
+ }
282
+ if (!low.includes("request.auth")) {
283
+ return { isPublic: true, confidence: "PROBABLE" };
284
+ }
285
+ return { isPublic: false, confidence: "CONFIRMED" };
286
+ }
287
+ function hashShort(text) {
288
+ return createHash("sha256").update(text, "utf-8").digest("hex").slice(0, 8);
289
+ }
290
+ function conditionKeyForFingerprint(condition) {
291
+ if (condition === void 0) return "uncond";
292
+ const norm = stripOuterParens(condition).toLowerCase().replace(/\s+/g, "");
293
+ if (norm === "") return "uncond";
294
+ return hashShort(norm);
295
+ }
296
+ function findPublicAllows(rulesFile) {
297
+ const rawContent = readFileSync(rulesFile, "utf-8");
298
+ return findPublicAllowsContent(rawContent);
299
+ }
300
+ function helperBodyToCondition(body) {
301
+ const m = /\breturn\b\s*([^;]+);?/.exec(body);
302
+ if (m) return m[1].trim();
303
+ return body;
304
+ }
305
+ function findPublicAllowsContent(rawContent) {
306
+ const content = stripRuleComments(rawContent);
307
+ const functions = parseRuleFunctions(content);
308
+ const detailed = parseRuleFunctionsDetailed(content);
309
+ const out = [];
310
+ const parsed = parseMatchBlocks(content);
311
+ const re = new RegExp(ALLOW_RE.source, "g");
312
+ let mm;
313
+ while ((mm = re.exec(content)) !== null) {
314
+ const line = content.slice(0, mm.index).split("\n").length;
315
+ const target = mm[1].trim();
316
+ const single = mm[2] === void 0 ? void 0 : resolveHelperCondition(mm[2], functions);
317
+ const singleD = mm[2] === void 0 ? void 0 : resolveHelperConditionDetailed(mm[2], detailed);
318
+ const inlined = mm[2] === void 0 ? void 0 : inlineHelpersInCondition(mm[2], detailed);
319
+ const effective = singleD !== void 0 && singleD !== mm[2] ? singleD : single !== void 0 && single !== mm[2] ? single : inlined !== void 0 && inlined !== mm[2] ? inlined : mm[2];
320
+ if (mm[2] !== void 0 && effective !== mm[2] && !String(effective).includes("request.auth")) {
321
+ const bodyCheck = isPublicCondition(helperBodyToCondition(String(effective)));
322
+ if (!bodyCheck.isPublic) {
323
+ continue;
324
+ }
325
+ }
326
+ const check = isPublicCondition(effective);
327
+ if (!check.isPublic) continue;
328
+ const ascBlocks = parsed.filter((b) => b.blockStart <= mm.index && mm.index < b.blockEnd).sort((x, y) => x.blockStart - y.blockStart);
329
+ const path = ascBlocks.length > 0 ? buildFullPath(ascBlocks) : "/(unknown)";
330
+ out.push({ line, target, path, confidence: check.confidence, conditionKey: conditionKeyForFingerprint(mm[2]) });
331
+ }
332
+ return out;
333
+ }
334
+
335
+ export {
336
+ stripRuleComments,
337
+ opsFrom,
338
+ normalizeTarget,
339
+ parseMatchBlocks,
340
+ buildFullPath,
341
+ parseRuleFunctions,
342
+ parseRuleFunctionsDetailed,
343
+ inlineHelperArgs,
344
+ inlineHelpersInCondition,
345
+ resolveHelperCondition,
346
+ resolveHelperConditionDetailed,
347
+ extractRules,
348
+ extractRulesContent,
349
+ stripOuterParens,
350
+ isPublicCondition,
351
+ hashShort,
352
+ conditionKeyForFingerprint,
353
+ findPublicAllows,
354
+ helperBodyToCondition,
355
+ findPublicAllowsContent
356
+ };
@@ -1,5 +1,18 @@
1
+ import {
2
+ conditionKeyForFingerprint,
3
+ extractRules,
4
+ findPublicAllowsContent,
5
+ inlineHelpersInCondition,
6
+ normalizeTarget,
7
+ opsFrom,
8
+ parseRuleFunctionsDetailed,
9
+ resolveHelperConditionDetailed,
10
+ stripOuterParens,
11
+ stripRuleComments
12
+ } from "./chunk-7EWTBIKJ.js";
13
+
1
14
  // src/scan.ts
2
- import { readFileSync as readFileSync4, existsSync as existsSync3 } from "fs";
15
+ import { readFileSync as readFileSync3, existsSync as existsSync3 } from "fs";
3
16
  import { join as join3 } from "path";
4
17
  import { parse as parseYaml } from "yaml";
5
18
 
@@ -147,342 +160,8 @@ async function enrichWithGraph(rootDir, d) {
147
160
  }
148
161
  }
149
162
 
150
- // src/rules.ts
151
- import { readFileSync as readFileSync2 } from "fs";
152
- import { createHash } from "crypto";
153
- var MATCH_RE = /match\s+(\/[^{\s]*(?:\{[^}]*\}[^{\s]*)*)\s*\{/g;
154
- var ALLOW_RE = /allow\s+([^;:]+)(?::\s*if\s+([^;]+))?;/g;
155
- function stripRuleComments(content) {
156
- let out = "";
157
- let i = 0;
158
- let quote = null;
159
- while (i < content.length) {
160
- const c = content[i];
161
- const next = content[i + 1] ?? "";
162
- if (quote) {
163
- out += c;
164
- if (c === quote && content[i - 1] !== "\\") quote = null;
165
- i += 1;
166
- continue;
167
- }
168
- if (c === '"' || c === "'" || c === "`") {
169
- quote = c;
170
- out += c;
171
- i += 1;
172
- continue;
173
- }
174
- if (c === "/" && next === "/") {
175
- while (i < content.length && content[i] !== "\n") i += 1;
176
- continue;
177
- }
178
- if (c === "/" && next === "*") {
179
- i += 2;
180
- while (i < content.length && !(content[i] === "*" && content[i + 1] === "/")) {
181
- if (content[i] === "\n") out += "\n";
182
- i += 1;
183
- }
184
- i += 2;
185
- continue;
186
- }
187
- out += c;
188
- i += 1;
189
- }
190
- return out;
191
- }
192
- function opsFrom(target) {
193
- const t = target.trim().toLowerCase();
194
- if (t === "read") return { ops: ["read", "get", "list"], known: true };
195
- if (t === "write") return { ops: ["write", "create", "update", "delete"], known: true };
196
- const parts = t.split(",").map((s) => s.trim()).filter(Boolean);
197
- const valid = ["read", "get", "list", "create", "update", "delete", "write"];
198
- const out = [];
199
- let known = false;
200
- for (const p of parts) {
201
- if (valid.includes(p)) {
202
- known = true;
203
- out.push(p);
204
- if (p === "write") {
205
- for (const extra of ["create", "update", "delete"]) {
206
- if (!out.includes(extra)) out.push(extra);
207
- }
208
- }
209
- if (p === "read") {
210
- for (const extra of ["get", "list"]) {
211
- if (!out.includes(extra)) out.push(extra);
212
- }
213
- }
214
- }
215
- }
216
- return { ops: out, known: out.length > 0 && known };
217
- }
218
- function findBlockEnd(content, openBraceIndex) {
219
- let depth = 0;
220
- let quote = null;
221
- for (let i = openBraceIndex; i < content.length; i++) {
222
- const c = content[i];
223
- if (quote) {
224
- if (c === quote && content[i - 1] !== "\\") quote = null;
225
- continue;
226
- }
227
- if (c === '"' || c === "'" || c === "`") {
228
- quote = c;
229
- continue;
230
- }
231
- if (c === "{") depth += 1;
232
- else if (c === "}") {
233
- depth -= 1;
234
- if (depth === 0) return i + 1;
235
- }
236
- }
237
- return content.length;
238
- }
239
- function normalizeTarget(target) {
240
- return target.toLowerCase().split(",").map((s) => s.trim()).filter(Boolean).sort().join(",");
241
- }
242
- function parseMatchBlocks(content) {
243
- const blocks = [];
244
- const matchRe = new RegExp(MATCH_RE.source, "g");
245
- let m;
246
- while ((m = matchRe.exec(content)) !== null) {
247
- const before = content.slice(0, m.index);
248
- const line = before.split("\n").length;
249
- const openBrace = m.index + m[0].length - 1;
250
- blocks.push({ path: m[1], line, blockStart: m.index, blockEnd: findBlockEnd(content, openBrace) });
251
- }
252
- return blocks;
253
- }
254
- function buildFullPath(containingAsc) {
255
- const parts = [];
256
- for (const b of containingAsc) {
257
- const p = b.path.trim();
258
- if (/^\/databases\/\{[^}]+\}\/documents\/?$/.test(p)) continue;
259
- const stripped = p.replace(/^\/+|\/+$/g, "");
260
- if (stripped.length === 0) continue;
261
- const docIdx = stripped.indexOf("/documents/");
262
- if (stripped.startsWith("databases/") && docIdx !== -1) {
263
- const rest = stripped.slice(docIdx + "/documents/".length);
264
- if (rest.length > 0) parts.push(rest);
265
- continue;
266
- }
267
- parts.push(stripped);
268
- }
269
- if (parts.length === 0) return "/(unknown)";
270
- return "/" + parts.join("/");
271
- }
272
- function parseRuleFunctions(content) {
273
- const out = /* @__PURE__ */ new Map();
274
- for (const [name, fn] of parseRuleFunctionsDetailed(content)) {
275
- out.set(name, fn.body);
276
- }
277
- return out;
278
- }
279
- function parseRuleFunctionsDetailed(content) {
280
- const out = /* @__PURE__ */ new Map();
281
- const re = /function\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)\s*\{/g;
282
- let m;
283
- while ((m = re.exec(content)) !== null) {
284
- const openBrace = m.index + m[0].length - 1;
285
- const end = findBlockEnd(content, openBrace);
286
- const body = content.slice(openBrace + 1, end - 1);
287
- const params = m[2].trim() === "" ? [] : m[2].split(",").map((s) => s.trim()).filter((s) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(s));
288
- out.set(m[1], { params, body });
289
- }
290
- return out;
291
- }
292
- function inlineHelperArgs(body, params, args) {
293
- let out = body;
294
- for (let i = 0; i < params.length; i++) {
295
- const p = params[i];
296
- const a = (args[i] ?? "").trim();
297
- if (!p || !a) continue;
298
- out = out.replace(new RegExp(`\\b${p}\\b`, "g"), () => a);
299
- }
300
- return out;
301
- }
302
- function inlineHelpersInCondition(condition, detailed) {
303
- let out = condition;
304
- for (const [name, fn] of detailed) {
305
- const re = new RegExp(`\\b${name}\\s*\\(([^()]*)\\)`, "g");
306
- out = out.replace(re, (_m, argsStr) => {
307
- const args = argsStr.length === 0 ? [] : String(argsStr).split(",").map((s) => s.trim());
308
- const inlined = inlineHelperArgs(fn.body, fn.params, args);
309
- return `(${inlined})`;
310
- });
311
- }
312
- return out;
313
- }
314
- function resolveHelperCondition(condition, functions) {
315
- const t = condition.trim().replace(/;$/, "").trim();
316
- const call = /^([A-Za-z_][A-Za-z0-9_]*)\s*\((.*)\)$/.exec(t);
317
- if (!call) return condition;
318
- const body = functions.get(call[1]);
319
- if (body === void 0) return condition;
320
- return body;
321
- }
322
- function resolveHelperConditionDetailed(condition, detailed) {
323
- const t = condition.trim().replace(/;$/, "").trim();
324
- const call = /^([A-Za-z_][A-Za-z0-9_]*)\s*\((.*)\)$/.exec(t);
325
- if (!call) return condition;
326
- const fn = detailed.get(call[1]);
327
- if (!fn) return condition;
328
- const args = call[2].length === 0 ? [] : call[2].split(",").map((s) => s.trim());
329
- return inlineHelperArgs(fn.body, fn.params, args);
330
- }
331
- function extractRules(rulesFile, relFile) {
332
- const rawContent = readFileSync2(rulesFile, "utf-8");
333
- return extractRulesContent(rawContent, relFile);
334
- }
335
- function extractRulesContent(rawContent, relFile) {
336
- const content = stripRuleComments(rawContent);
337
- const rules = [];
338
- const functions = parseRuleFunctions(content);
339
- const detailed = parseRuleFunctionsDetailed(content);
340
- const matchBlocks = parseMatchBlocks(content);
341
- const allowRe = new RegExp(ALLOW_RE.source, "g");
342
- let a;
343
- let idx = 0;
344
- while ((a = allowRe.exec(content)) !== null) {
345
- const before = content.slice(0, a.index);
346
- const line = before.split("\n").length;
347
- const target = a[1];
348
- const rawCondition = (a[2] ?? "").trim();
349
- const unconditional = a[2] === void 0;
350
- const containing = matchBlocks.filter((b) => b.blockStart <= a.index && a.index < b.blockEnd).sort((x, y) => x.blockStart - y.blockStart);
351
- const fullPath = containing.length > 0 ? buildFullPath(containing) : "/(unknown)";
352
- const resolved = unconditional ? "" : resolveHelperCondition(rawCondition, functions);
353
- const resolvedDetailed = unconditional ? "" : resolveHelperConditionDetailed(rawCondition, detailed);
354
- const inlinedBody = unconditional ? "" : inlineHelpersInCondition(rawCondition, detailed);
355
- const effectiveBody = resolvedDetailed !== rawCondition ? resolvedDetailed : resolved !== rawCondition ? resolved : inlinedBody;
356
- const condition = rawCondition;
357
- const authReferences = [];
358
- const authSource = `${rawCondition} ${resolved} ${effectiveBody} ${inlinedBody}`;
359
- if (authSource.includes("request.auth")) authReferences.push("request.auth");
360
- if (authSource.includes("request.auth.uid")) authReferences.push("request.auth.uid");
361
- const big = `${rawCondition} ${resolved} ${effectiveBody} ${inlinedBody}`;
362
- const claimReferences = [
363
- ...big.matchAll(/request\.auth\.token\.([A-Za-z0-9_]+)/g),
364
- ...big.matchAll(/request\.auth\.token\.get\(\s*['"]([A-Za-z0-9_]+)['"]/g),
365
- ...big.matchAll(/request\.auth\.token\[\s*['"]([A-Za-z0-9_]+)['"]\s*\]/g)
366
- ].map((x) => x[1]);
367
- const resourceReferences = [...big.matchAll(/resource\.data\.([A-Za-z0-9_]+)/g)].map(
368
- (x) => x[1]
369
- );
370
- const requestResourceReferences = [
371
- ...big.matchAll(/request\.resource\.data\.([A-Za-z0-9_]+)/g)
372
- ].map((x) => x[1]);
373
- const { ops, known } = opsFrom(target);
374
- const isHelperCall = !unconditional && rawCondition !== resolved;
375
- const hasAuth = authSource.includes("request.auth");
376
- rules.push({
377
- id: `rule-${idx++}`,
378
- path: fullPath,
379
- operations: ops,
380
- conditionPresent: !unconditional && condition.length > 0,
381
- condition: unconditional ? void 0 : condition,
382
- authReferences,
383
- claimReferences,
384
- resourceReferences,
385
- requestResourceReferences,
386
- confidence: !known || isHelperCall && !hasAuth ? "UNKNOWN" : "CONFIRMED",
387
- location: { file: relFile, start: { line, column: 0 } }
388
- });
389
- }
390
- return rules;
391
- }
392
- function stripOuterParens(s) {
393
- let norm = s.trim();
394
- for (; ; ) {
395
- if (!(norm.startsWith("(") && norm.endsWith(")"))) return norm;
396
- let depth = 0;
397
- let wrapsAll = true;
398
- for (let i = 0; i < norm.length; i++) {
399
- if (norm[i] === "(") depth += 1;
400
- else if (norm[i] === ")") {
401
- depth -= 1;
402
- if (depth === 0 && i !== norm.length - 1) {
403
- wrapsAll = false;
404
- break;
405
- }
406
- }
407
- }
408
- if (!wrapsAll || depth !== 0) return norm;
409
- norm = norm.slice(1, -1).trim();
410
- }
411
- }
412
- function isPublicCondition(condition) {
413
- if (condition === void 0) return { isPublic: true, confidence: "CONFIRMED" };
414
- const norm = stripOuterParens(condition);
415
- const low = norm.toLowerCase().replace(/\s+/g, "");
416
- if (low === "false") return { isPublic: false, confidence: "CONFIRMED" };
417
- if (/^!\(.*request\.auth(===|==)null/.test(low)) {
418
- return { isPublic: false, confidence: "CONFIRMED" };
419
- }
420
- if (/^!\(.*request\.auth(!==|!=)null/.test(low)) {
421
- return { isPublic: true, confidence: "CONFIRMED" };
422
- }
423
- if (low.includes("!(request.auth==null)") || low.includes("!(request.auth===null)") || low === "!request.auth==null" || low === "!request.auth===null") {
424
- return { isPublic: false, confidence: "CONFIRMED" };
425
- }
426
- if (low === "true") return { isPublic: true, confidence: "CONFIRMED" };
427
- if (/(^|\|\|)true($|\|\|)/.test(low)) return { isPublic: true, confidence: "PROBABLE" };
428
- if (low.includes("request.auth==null") || low.includes("request.auth===null")) {
429
- return { isPublic: true, confidence: "CONFIRMED" };
430
- }
431
- if (!low.includes("request.auth")) {
432
- return { isPublic: true, confidence: "PROBABLE" };
433
- }
434
- return { isPublic: false, confidence: "CONFIRMED" };
435
- }
436
- function hashShort(text) {
437
- return createHash("sha256").update(text, "utf-8").digest("hex").slice(0, 8);
438
- }
439
- function conditionKeyForFingerprint(condition) {
440
- if (condition === void 0) return "uncond";
441
- const norm = stripOuterParens(condition).toLowerCase().replace(/\s+/g, "");
442
- if (norm === "") return "uncond";
443
- return hashShort(norm);
444
- }
445
- function findPublicAllows(rulesFile) {
446
- const rawContent = readFileSync2(rulesFile, "utf-8");
447
- return findPublicAllowsContent(rawContent);
448
- }
449
- function helperBodyToCondition(body) {
450
- const m = /\breturn\b\s*([^;]+);?/.exec(body);
451
- if (m) return m[1].trim();
452
- return body;
453
- }
454
- function findPublicAllowsContent(rawContent) {
455
- const content = stripRuleComments(rawContent);
456
- const functions = parseRuleFunctions(content);
457
- const detailed = parseRuleFunctionsDetailed(content);
458
- const out = [];
459
- const parsed = parseMatchBlocks(content);
460
- const re = new RegExp(ALLOW_RE.source, "g");
461
- let mm;
462
- while ((mm = re.exec(content)) !== null) {
463
- const line = content.slice(0, mm.index).split("\n").length;
464
- const target = mm[1].trim();
465
- const single = mm[2] === void 0 ? void 0 : resolveHelperCondition(mm[2], functions);
466
- const singleD = mm[2] === void 0 ? void 0 : resolveHelperConditionDetailed(mm[2], detailed);
467
- const inlined = mm[2] === void 0 ? void 0 : inlineHelpersInCondition(mm[2], detailed);
468
- const effective = singleD !== void 0 && singleD !== mm[2] ? singleD : single !== void 0 && single !== mm[2] ? single : inlined !== void 0 && inlined !== mm[2] ? inlined : mm[2];
469
- if (mm[2] !== void 0 && effective !== mm[2] && !String(effective).includes("request.auth")) {
470
- const bodyCheck = isPublicCondition(helperBodyToCondition(String(effective)));
471
- if (!bodyCheck.isPublic) {
472
- continue;
473
- }
474
- }
475
- const check = isPublicCondition(effective);
476
- if (!check.isPublic) continue;
477
- const ascBlocks = parsed.filter((b) => b.blockStart <= mm.index && mm.index < b.blockEnd).sort((x, y) => x.blockStart - y.blockStart);
478
- const path = ascBlocks.length > 0 ? buildFullPath(ascBlocks) : "/(unknown)";
479
- out.push({ line, target, path, confidence: check.confidence, conditionKey: conditionKeyForFingerprint(mm[2]) });
480
- }
481
- return out;
482
- }
483
-
484
163
  // src/checks.ts
485
- import { readFileSync as readFileSync3, existsSync as existsSync2 } from "fs";
164
+ import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
486
165
  import { join as join2 } from "path";
487
166
  import { executeFind } from "@justmpm/supergrep";
488
167
  var IMPLEMENTED_CHECKS = ["FBA001", "FBA002", "FBA003", "FBA004", "FBA009", "FBA010", "FBA011", "FBA012", "FBA013"];
@@ -521,7 +200,7 @@ async function runChecks(model, rootDir, opts = {}) {
521
200
  let helperDetailed = /* @__PURE__ */ new Map();
522
201
  if (absRules && existsSync2(absRules)) {
523
202
  try {
524
- const rawAll = readFileSync3(absRules, "utf-8");
203
+ const rawAll = readFileSync2(absRules, "utf-8");
525
204
  pubs = findPublicAllowsContent(rawAll);
526
205
  helperDetailed = parseRuleFunctionsDetailed(rawAll);
527
206
  } catch {
@@ -574,7 +253,7 @@ async function runChecks(model, rootDir, opts = {}) {
574
253
  const declared = new Set(model.authorization.permissions.map((p) => p.name));
575
254
  if (permissionCalls.literals.length === 0 && permissionCalls.dynamic.length === 0) {
576
255
  const tenantHint = [...helperDetailed.keys()].find(
577
- (n) => /tenant|member|signedin/i.test(n)
256
+ (n) => /tenant|isMember|isSignedIn|_member/i.test(n)
578
257
  );
579
258
  findings.push(
580
259
  make(
@@ -811,7 +490,7 @@ async function runChecks(model, rootDir, opts = {}) {
811
490
  for (const hit of adminHits) {
812
491
  let origin = "UNKNOWN";
813
492
  try {
814
- const content = readFileSync3(join2(rootDir, hit.file), "utf-8");
493
+ const content = readFileSync2(join2(rootDir, hit.file), "utf-8");
815
494
  origin = classifyOrigin(hit.file, content);
816
495
  } catch {
817
496
  origin = "UNKNOWN";
@@ -1164,7 +843,7 @@ async function scan(rootDir, opts = {}) {
1164
843
  const findings_pre = [];
1165
844
  try {
1166
845
  if (d.firebaseJson && existsSync3(d.firebaseJson)) {
1167
- const raw = JSON.parse(readFileSync4(d.firebaseJson, "utf-8"));
846
+ const raw = JSON.parse(readFileSync3(d.firebaseJson, "utf-8"));
1168
847
  for (const [kind, rel] of [
1169
848
  ["rules", raw.firestore?.rules],
1170
849
  ["indexes", raw.firestore?.indexes]
@@ -1219,7 +898,7 @@ async function scan(rootDir, opts = {}) {
1219
898
  }
1220
899
  if (d.firestoreIndexesFile && existsSync3(d.firestoreIndexesFile)) {
1221
900
  try {
1222
- const raw = JSON.parse(readFileSync4(d.firestoreIndexesFile, "utf-8"));
901
+ const raw = JSON.parse(readFileSync3(d.firestoreIndexesFile, "utf-8"));
1223
902
  if (Array.isArray(raw.fieldOverrides) && raw.fieldOverrides.length > 0) {
1224
903
  findings_pre.push({
1225
904
  rule: "FIELD_OVERRIDES_PRESENT",
@@ -1299,7 +978,7 @@ async function scan(rootDir, opts = {}) {
1299
978
  }
1300
979
  if (d.auditYamlFile && existsSync3(d.auditYamlFile)) {
1301
980
  try {
1302
- const text = readFileSync4(d.auditYamlFile, "utf-8");
981
+ const text = readFileSync3(d.auditYamlFile, "utf-8");
1303
982
  const parsed = parseYaml(text);
1304
983
  const validated = AuditYamlSchema.safeParse(parsed);
1305
984
  if (validated.success) {
@@ -1394,44 +1073,51 @@ async function scan(rootDir, opts = {}) {
1394
1073
  if (d.storageRulesFile && existsSync3(d.storageRulesFile)) {
1395
1074
  checked.push("storage.rules");
1396
1075
  try {
1397
- const raw = readFileSync4(d.storageRulesFile, "utf-8");
1076
+ const raw = readFileSync3(d.storageRulesFile, "utf-8");
1398
1077
  const relStorage = model.firebase.storage?.rulesFile ?? "storage.rules";
1399
- const low = raw.toLowerCase().replace(/\s+/g, "");
1400
- const hasAllPaths = raw.includes("allPaths=**") || raw.includes("allPaths = **");
1401
- const writeOpen = /allowwrite:[^;]*iftrue/.test(low);
1402
- const getOpen = /allowget:[^;]*iftrue/.test(low);
1403
- if (writeOpen) {
1404
- findings.push({
1405
- rule: "STORAGE_PUBLIC_WRITE",
1406
- severity: "ERROR",
1407
- confidence: "CONFIRMED",
1408
- message: `Storage com escrita aberta (${relStorage}). Qualquer cliente pode escrever \u2014 cofre aberto, n\xE3o vitrine.`,
1409
- fingerprint: "STORAGE_PUBLIC_WRITE",
1410
- file: relStorage,
1411
- evidence: [{ id: "ev-storage-write", kind: "storage-public", summary: "write if true", confidence: "CONFIRMED" }],
1412
- fix: "Exigir request.auth (e dono/tenant) no write. Deixe get aberto s\xF3 em pasta de vitrine como logos/."
1413
- });
1414
- } else if (hasAllPaths && getOpen) {
1415
- findings.push({
1416
- rule: "STORAGE_ALL_PATHS_OPEN",
1417
- severity: "WARNING",
1418
- confidence: "PROBABLE",
1419
- message: `Storage com get aberto em /{allPaths=**} (${relStorage}). Vale para tudo \u2014 se a inten\xE7\xE3o era s\xF3 logo, restrinja a /logos/{id}.`,
1420
- fingerprint: "STORAGE_ALL_PATHS_OPEN",
1421
- file: relStorage,
1422
- evidence: [{ id: "ev-storage-all", kind: "storage-public", summary: "allPaths get true", confidence: "PROBABLE" }],
1423
- fix: "Troque /{allPaths=**} por pastas expl\xEDcitas (ex: /logos/{logoId}) com get: if true e write restrito."
1424
- });
1425
- } else if (getOpen) {
1426
- findings.push({
1427
- rule: "STORAGE_PUBLIC_READ",
1428
- severity: "INFO",
1429
- confidence: "PROBABLE",
1430
- message: `Storage com leitura aberta (${relStorage}). Pode ser vitrine proposital (logos) \u2014 confirme que o write segue restrito.`,
1431
- fingerprint: "STORAGE_PUBLIC_READ",
1432
- file: relStorage,
1433
- evidence: [{ id: "ev-storage-read", kind: "storage-public", summary: "get if true", confidence: "PROBABLE" }]
1434
- });
1078
+ const clean = stripRuleComments(raw);
1079
+ const low = clean.toLowerCase().replace(/\s+/g, "");
1080
+ const hasAllPaths = low.includes("allpaths=**") || /\{[^}]*=\*\*\}/.test(low);
1081
+ for (const m of parseStorageAllows(clean)) {
1082
+ const open = m.condition === void 0 || m.condition.trim().toLowerCase().replace(/\s+/g, "") === "true";
1083
+ if (!open) continue;
1084
+ const ops = expandStorageOps(m.target);
1085
+ const key = conditionKeyForFingerprint(m.condition);
1086
+ if (ops.has("write") || ops.has("create") || ops.has("update") || ops.has("delete")) {
1087
+ findings.push({
1088
+ rule: "STORAGE_PUBLIC_WRITE",
1089
+ severity: "ERROR",
1090
+ confidence: "CONFIRMED",
1091
+ message: `Storage com escrita aberta (${relStorage}, allow ${m.target}). Qualquer cliente pode escrever \u2014 cofre aberto, n\xE3o vitrine.`,
1092
+ fingerprint: `STORAGE_PUBLIC_WRITE:${m.target}:${key}`,
1093
+ file: relStorage,
1094
+ evidence: [{ id: `ev-storage-write-${findings.length}`, kind: "storage-public", summary: `${m.target} if true`, confidence: "CONFIRMED" }],
1095
+ fix: "Exigir request.auth (e dono/tenant) no write. Deixe get aberto s\xF3 em pasta de vitrine como logos/."
1096
+ });
1097
+ } else if (ops.has("read") || ops.has("get") || ops.has("list")) {
1098
+ if (hasAllPaths) {
1099
+ findings.push({
1100
+ rule: "STORAGE_ALL_PATHS_OPEN",
1101
+ severity: "WARNING",
1102
+ confidence: "PROBABLE",
1103
+ message: `Storage com leitura aberta em curinga total (${relStorage}, allow ${m.target}). Vale para tudo \u2014 se a inten\xE7\xE3o era s\xF3 logo, restrinja a /logos/{id}.`,
1104
+ fingerprint: `STORAGE_ALL_PATHS_OPEN:${m.target}:${key}`,
1105
+ file: relStorage,
1106
+ evidence: [{ id: `ev-storage-all-${findings.length}`, kind: "storage-public", summary: `${m.target} if true`, confidence: "PROBABLE" }],
1107
+ fix: "Troque o curinga total por pastas expl\xEDcitas (ex: /logos/{logoId}) com get: if true e write restrito."
1108
+ });
1109
+ } else {
1110
+ findings.push({
1111
+ rule: "STORAGE_PUBLIC_READ",
1112
+ severity: "INFO",
1113
+ confidence: "PROBABLE",
1114
+ message: `Storage com leitura aberta (${relStorage}, allow ${m.target}). Pode ser vitrine proposital (logos) \u2014 confirme que o write segue restrito.`,
1115
+ fingerprint: `STORAGE_PUBLIC_READ:${m.target}:${key}`,
1116
+ file: relStorage,
1117
+ evidence: [{ id: `ev-storage-read-${findings.length}`, kind: "storage-public", summary: `${m.target} if true`, confidence: "PROBABLE" }]
1118
+ });
1119
+ }
1120
+ }
1435
1121
  }
1436
1122
  } catch {
1437
1123
  skipped.push("storage.rules ileg\xEDvel \u2014 Storage sem cobertura neste scan");
@@ -1517,10 +1203,15 @@ async function countAppCheckEnforced(rootDir) {
1517
1203
  try {
1518
1204
  const { readdirSync, readFileSync: readSync, statSync: stat } = await import("fs");
1519
1205
  const { join: joinP } = await import("path");
1206
+ const { stripRuleComments: strip } = await import("./rules-GTGZWN5U.js");
1520
1207
  const roots = [joinP(rootDir, "functions")];
1521
1208
  let count = 0;
1522
1209
  const stack = [...roots];
1523
1210
  let guard = 0;
1211
+ const isTestFile = (p) => {
1212
+ const n = p.replace(/\\/g, "/");
1213
+ return /\.test\.[tj]sx?$/.test(n) || /\.spec\.[tj]sx?$/.test(n) || /(^|\/)__tests__\//.test(n);
1214
+ };
1524
1215
  while (stack.length > 0 && guard < 200) {
1525
1216
  guard += 1;
1526
1217
  const cur = stack.pop();
@@ -1545,9 +1236,11 @@ async function countAppCheckEnforced(rootDir) {
1545
1236
  continue;
1546
1237
  }
1547
1238
  if (!/\.(ts|js|mjs|cjs)$/.test(name)) continue;
1239
+ if (isTestFile(abs)) continue;
1548
1240
  try {
1549
- const text = readSync(abs, "utf-8");
1550
- if (/enforceAppCheck\s*:\s*true/.test(text)) count += 1;
1241
+ const text = strip(readSync(abs, "utf-8"));
1242
+ const hits = text.match(/enforceAppCheck\s*:\s*true/g);
1243
+ if (hits) count += hits.length;
1551
1244
  } catch {
1552
1245
  }
1553
1246
  }
@@ -1557,6 +1250,33 @@ async function countAppCheckEnforced(rootDir) {
1557
1250
  return 0;
1558
1251
  }
1559
1252
  }
1253
+ function parseStorageAllows(clean) {
1254
+ const out = [];
1255
+ const re = /allow\s+([^;:]+?)(?::\s*if\s+([^;]+))?;/gi;
1256
+ let m;
1257
+ while ((m = re.exec(clean)) !== null) {
1258
+ out.push({ target: m[1].trim(), condition: m[2]?.trim() });
1259
+ }
1260
+ return out;
1261
+ }
1262
+ function expandStorageOps(target) {
1263
+ const out = /* @__PURE__ */ new Set();
1264
+ for (const part of target.toLowerCase().split(",").map((s) => s.trim()).filter(Boolean)) {
1265
+ if (part === "read") {
1266
+ out.add("read");
1267
+ out.add("get");
1268
+ out.add("list");
1269
+ } else if (part === "write") {
1270
+ out.add("write");
1271
+ out.add("create");
1272
+ out.add("update");
1273
+ out.add("delete");
1274
+ } else {
1275
+ out.add(part);
1276
+ }
1277
+ }
1278
+ return out;
1279
+ }
1560
1280
 
1561
1281
  export {
1562
1282
  discover,
@@ -1564,26 +1284,6 @@ export {
1564
1284
  classifyOrigin,
1565
1285
  emptyModel,
1566
1286
  enrichWithGraph,
1567
- stripRuleComments,
1568
- opsFrom,
1569
- normalizeTarget,
1570
- parseMatchBlocks,
1571
- buildFullPath,
1572
- parseRuleFunctions,
1573
- parseRuleFunctionsDetailed,
1574
- inlineHelperArgs,
1575
- inlineHelpersInCondition,
1576
- resolveHelperCondition,
1577
- resolveHelperConditionDetailed,
1578
- extractRules,
1579
- extractRulesContent,
1580
- stripOuterParens,
1581
- isPublicCondition,
1582
- hashShort,
1583
- conditionKeyForFingerprint,
1584
- findPublicAllows,
1585
- helperBodyToCondition,
1586
- findPublicAllowsContent,
1587
1287
  IMPLEMENTED_CHECKS,
1588
1288
  runChecks,
1589
1289
  SeveritySchema,
@@ -1,12 +1,12 @@
1
1
  import {
2
2
  scan
3
- } from "./chunk-TQZTE2NR.js";
3
+ } from "./chunk-GDO2DLNQ.js";
4
4
  import {
5
5
  verify
6
- } from "./chunk-2X3BTSGP.js";
6
+ } from "./chunk-Y4VXWSKX.js";
7
7
  import {
8
8
  drift
9
- } from "./chunk-NJBCSW7E.js";
9
+ } from "./chunk-XZ32YQQC.js";
10
10
 
11
11
  // src/mcp.ts
12
12
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -128,6 +128,16 @@ function drift(localFile, remoteFile) {
128
128
  }
129
129
  const remoteByKey = new Map(remote.map((r) => [keyOf(r), r]));
130
130
  const localByKey = new Map(local.map((l) => [keyOf(l), l]));
131
+ if (local.length !== localByKey.size || remote.length !== remoteByKey.size) {
132
+ findings.push({
133
+ rule: "DRIFT_DUPLICATE_KEY",
134
+ severity: "INFO",
135
+ confidence: "CONFIRMED",
136
+ message: "\xCDndices duplicados colapsados por chave \u2014 revise o arquivo de origem.",
137
+ fingerprint: "DRIFT_DUPLICATE_KEY",
138
+ evidence: [{ id: "ev-drift-dup", kind: "drift-parse", summary: "chave duplicada", confidence: "CONFIRMED" }]
139
+ });
140
+ }
131
141
  const entries = [];
132
142
  for (const [key, l] of localByKey) {
133
143
  if (remoteByKey.has(key)) entries.push({ key, status: "MATCHED", local: l, remote: remoteByKey.get(key) });
@@ -39,7 +39,7 @@ function planVerify(model) {
39
39
  }
40
40
  const seen = /* @__PURE__ */ new Set();
41
41
  return cases.filter((c) => {
42
- const k = `${c.role}:${c.operation}:${c.path}`;
42
+ const k = `${c.role}:${c.operation}:${c.pathTemplate}:${c.path}`;
43
43
  if (seen.has(k)) return false;
44
44
  seen.add(k);
45
45
  return true;
@@ -105,8 +105,11 @@ function verify(model, opts = {}) {
105
105
  if (coveragePct === 100) {
106
106
  return { cases, uncovered: [], findings, coveragePct };
107
107
  }
108
+ const seenPaths = /* @__PURE__ */ new Set();
108
109
  for (const rule of model.rules) {
109
110
  if (rule.path === "/(unknown)") continue;
111
+ if (seenPaths.has(rule.path)) continue;
112
+ seenPaths.add(rule.path);
110
113
  uncovered.push(rule.path);
111
114
  }
112
115
  return { cases, uncovered, findings, coveragePct };
package/dist/cli.js CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  scan
4
- } from "./chunk-TQZTE2NR.js";
4
+ } from "./chunk-GDO2DLNQ.js";
5
+ import "./chunk-7EWTBIKJ.js";
5
6
 
6
7
  // src/cli.ts
7
8
  import { createRequire } from "module";
@@ -64,19 +65,21 @@ Uso: firebase-audit scan|check|verify|drift [--json] ...`);
64
65
  const { resolve: resolveRoot } = await import("path");
65
66
  const rootDir = cwdArg ? resolveRoot(process.cwd(), cwdArg) : process.cwd();
66
67
  if (cmd === "verify") {
67
- const { scan: scanForVerify } = await import("./scan-FYAWNRIE.js");
68
- const { verify } = await import("./verify-GWRLO344.js");
68
+ const { scan: scanForVerify } = await import("./scan-4Q4XVLJU.js");
69
+ const { verify } = await import("./verify-WBVSESHD.js");
69
70
  const { resolve: resolveVerify } = await import("path");
70
71
  const coverageArg = getArg("coverage");
71
72
  const coveragePath = coverageArg ? resolveVerify(rootDir, coverageArg) : void 0;
72
73
  const { model } = await scanForVerify(rootDir, { adapterFn: adapterArg });
73
74
  const res = verify(model, { coverageFile: coveragePath });
75
+ const roles = model.authorization.roles.length > 0 ? model.authorization.roles.map((r) => r.name) : ["anonymous", "user", "admin"];
76
+ const rolesReduced = model.authorization.roles.length === 0;
74
77
  if (asJson) {
75
- console.log(JSON.stringify({ cases: res.cases.slice(0, 50), casesTotal: res.cases.length, truncated: res.cases.length > 50, uncovered: res.uncovered.slice(0, 20), uncoveredTotal: res.uncovered.length, coveragePct: res.coveragePct, findings: res.findings }, null, 2));
78
+ console.log(JSON.stringify({ roles, rolesReduced, cases: res.cases.slice(0, 50), casesTotal: res.cases.length, truncated: res.cases.length > 50, uncovered: res.uncovered.slice(0, 20), uncoveredTotal: res.uncovered.length, coveragePct: res.coveragePct, findings: res.findings }, null, 2));
76
79
  return;
77
80
  }
78
81
  const covStr = res.coveragePct === null ? "sem cobertura observada" : `${res.coveragePct}% de express\xF5es visitadas`;
79
- const rolesNote = "pap\xE9is reduzidos (sem contrato: anonymous/user/admin)";
82
+ const rolesNote = rolesReduced ? "pap\xE9is reduzidos (sem contrato: anonymous/user/admin)" : `${roles.length} pap\xE9is do contrato (${roles.join(", ")})`;
80
83
  console.log(`
81
84
  FIREBASE AUDIT verify \u2014 ${res.cases.length} casos planejados (${rolesNote}), ${res.uncovered.length} paths sem cobertura (${covStr}).`);
82
85
  for (const f of res.findings) {
@@ -95,7 +98,7 @@ ${icon} ${f.rule} [${f.confidence}]
95
98
  return;
96
99
  }
97
100
  if (cmd === "drift") {
98
- const { drift } = await import("./drift-KU7HWL7M.js");
101
+ const { drift } = await import("./drift-QXALNO2V.js");
99
102
  const { resolve: resolveDrift } = await import("path");
100
103
  const { existsSync: existsDrift } = await import("fs");
101
104
  const localArg = getArg("local");
@@ -109,7 +112,7 @@ ${icon} ${f.rule} [${f.confidence}]
109
112
  for (const [label, p] of [["--local", localPath], ["--remote", remotePath]]) {
110
113
  if (p && !existsDrift(p)) {
111
114
  if (asJson) {
112
- const { drift: driftMissing } = await import("./drift-KU7HWL7M.js");
115
+ const { drift: driftMissing } = await import("./drift-QXALNO2V.js");
113
116
  const res2 = driftMissing(
114
117
  localPath && existsDrift(localPath) ? localPath : null,
115
118
  remotePath && existsDrift(remotePath) ? remotePath : null
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  drift
3
- } from "./chunk-NJBCSW7E.js";
3
+ } from "./chunk-XZ32YQQC.js";
4
4
  export {
5
5
  drift
6
6
  };
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  createMcpServer,
3
3
  startMcpServer
4
- } from "./chunk-EHGW2EX6.js";
4
+ } from "./chunk-QACXCMYB.js";
5
5
  import {
6
6
  AccessOriginSchema,
7
7
  AuditYamlSchema,
@@ -26,12 +26,17 @@ import {
26
26
  RuleSchema,
27
27
  SERVER_HINTS_SEGMENTS,
28
28
  SeveritySchema,
29
- buildFullPath,
30
29
  classifyOrigin,
31
- conditionKeyForFingerprint,
32
30
  discover,
33
31
  emptyModel,
34
32
  enrichWithGraph,
33
+ runChecks,
34
+ scan,
35
+ validateClaimSamples
36
+ } from "./chunk-GDO2DLNQ.js";
37
+ import {
38
+ buildFullPath,
39
+ conditionKeyForFingerprint,
35
40
  extractRules,
36
41
  extractRulesContent,
37
42
  findPublicAllows,
@@ -48,19 +53,16 @@ import {
48
53
  parseRuleFunctionsDetailed,
49
54
  resolveHelperCondition,
50
55
  resolveHelperConditionDetailed,
51
- runChecks,
52
- scan,
53
56
  stripOuterParens,
54
- stripRuleComments,
55
- validateClaimSamples
56
- } from "./chunk-TQZTE2NR.js";
57
+ stripRuleComments
58
+ } from "./chunk-7EWTBIKJ.js";
57
59
  import {
58
60
  planVerify,
59
61
  verify
60
- } from "./chunk-2X3BTSGP.js";
62
+ } from "./chunk-Y4VXWSKX.js";
61
63
  import {
62
64
  drift
63
- } from "./chunk-NJBCSW7E.js";
65
+ } from "./chunk-XZ32YQQC.js";
64
66
 
65
67
  // src/index.ts
66
68
  import { createRequire } from "module";
package/dist/mcp-cli.js CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  startMcpServer
4
- } from "./chunk-EHGW2EX6.js";
5
- import "./chunk-TQZTE2NR.js";
6
- import "./chunk-2X3BTSGP.js";
7
- import "./chunk-NJBCSW7E.js";
4
+ } from "./chunk-QACXCMYB.js";
5
+ import "./chunk-GDO2DLNQ.js";
6
+ import "./chunk-7EWTBIKJ.js";
7
+ import "./chunk-Y4VXWSKX.js";
8
+ import "./chunk-XZ32YQQC.js";
8
9
 
9
10
  // src/mcp-cli.ts
10
11
  startMcpServer().catch((err) => {
package/dist/mcp.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import {
2
2
  createMcpServer,
3
3
  startMcpServer
4
- } from "./chunk-EHGW2EX6.js";
5
- import "./chunk-TQZTE2NR.js";
6
- import "./chunk-2X3BTSGP.js";
7
- import "./chunk-NJBCSW7E.js";
4
+ } from "./chunk-QACXCMYB.js";
5
+ import "./chunk-GDO2DLNQ.js";
6
+ import "./chunk-7EWTBIKJ.js";
7
+ import "./chunk-Y4VXWSKX.js";
8
+ import "./chunk-XZ32YQQC.js";
8
9
  export {
9
10
  createMcpServer,
10
11
  startMcpServer
@@ -0,0 +1,44 @@
1
+ import {
2
+ buildFullPath,
3
+ conditionKeyForFingerprint,
4
+ extractRules,
5
+ extractRulesContent,
6
+ findPublicAllows,
7
+ findPublicAllowsContent,
8
+ hashShort,
9
+ helperBodyToCondition,
10
+ inlineHelperArgs,
11
+ inlineHelpersInCondition,
12
+ isPublicCondition,
13
+ normalizeTarget,
14
+ opsFrom,
15
+ parseMatchBlocks,
16
+ parseRuleFunctions,
17
+ parseRuleFunctionsDetailed,
18
+ resolveHelperCondition,
19
+ resolveHelperConditionDetailed,
20
+ stripOuterParens,
21
+ stripRuleComments
22
+ } from "./chunk-7EWTBIKJ.js";
23
+ export {
24
+ buildFullPath,
25
+ conditionKeyForFingerprint,
26
+ extractRules,
27
+ extractRulesContent,
28
+ findPublicAllows,
29
+ findPublicAllowsContent,
30
+ hashShort,
31
+ helperBodyToCondition,
32
+ inlineHelperArgs,
33
+ inlineHelpersInCondition,
34
+ isPublicCondition,
35
+ normalizeTarget,
36
+ opsFrom,
37
+ parseMatchBlocks,
38
+ parseRuleFunctions,
39
+ parseRuleFunctionsDetailed,
40
+ resolveHelperCondition,
41
+ resolveHelperConditionDetailed,
42
+ stripOuterParens,
43
+ stripRuleComments
44
+ };
@@ -0,0 +1,7 @@
1
+ import {
2
+ scan
3
+ } from "./chunk-GDO2DLNQ.js";
4
+ import "./chunk-7EWTBIKJ.js";
5
+ export {
6
+ scan
7
+ };
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  planVerify,
3
3
  verify
4
- } from "./chunk-2X3BTSGP.js";
4
+ } from "./chunk-Y4VXWSKX.js";
5
5
  export {
6
6
  planVerify,
7
7
  verify
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justmpm/firebase-audit",
3
- "version": "0.5.0",
3
+ "version": "0.5.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",
package/skill/SKILL.md CHANGED
@@ -19,7 +19,8 @@ Skill para agentes usarem o `@justmpm/firebase-audit` do jeito certo.
19
19
  - Nunca dizer "unused" — apenas NOT_OBSERVED
20
20
  - Claims: 1000 bytes max, sem chaves OIDC reservadas, só controle de acesso
21
21
  - AppCheck é carimbo do app oficial — não prova usuário nem tenant
22
- - Logo pública (`get: if true` em `/logos/`) pode ser vitrine proposital — cofre é `write: if true` ou `/{allPaths=**}` aberto
22
+ - Logo pública (`get: if true` em `/logos/`) pode ser vitrine proposital — cofre é `write: if true` ou curinga total aberto
23
+ - Seeds/fixtures com Admin SDK moram em `scripts/` ou `server/`, nunca em `src/` (senão o scan acusa FBA003 de propósito)
23
24
 
24
25
  ## Emulador
25
26
 
@@ -1,6 +0,0 @@
1
- import {
2
- scan
3
- } from "./chunk-TQZTE2NR.js";
4
- export {
5
- scan
6
- };