@justmpm/firebase-audit 0.4.3 → 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.
@@ -1,10 +1,23 @@
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
 
6
19
  // src/discovery.ts
7
- import { existsSync, readFileSync } from "fs";
20
+ import { existsSync, readFileSync, statSync } from "fs";
8
21
  import { join, relative } from "path";
9
22
  function discover(rootDir) {
10
23
  const pick = (name) => {
@@ -16,6 +29,16 @@ function discover(rootDir) {
16
29
  let databaseId = null;
17
30
  let rulesFile = pick("firestore.rules");
18
31
  let indexesFile = pick("firestore.indexes.json");
32
+ let storageFile = pick("storage.rules");
33
+ let functionsDir = null;
34
+ const functionsPick = pick("functions");
35
+ if (functionsPick) {
36
+ try {
37
+ functionsDir = statSync(functionsPick).isDirectory() ? functionsPick : null;
38
+ } catch {
39
+ functionsDir = null;
40
+ }
41
+ }
19
42
  const firebaserc = pick(".firebaserc");
20
43
  if (firebaserc) {
21
44
  try {
@@ -36,6 +59,10 @@ function discover(rootDir) {
36
59
  const p = join(rootDir, raw.firestore.indexes);
37
60
  if (existsSync(p)) indexesFile = p;
38
61
  }
62
+ if (typeof raw.storage?.rules === "string") {
63
+ const p = join(rootDir, raw.storage.rules);
64
+ if (existsSync(p)) storageFile = p;
65
+ }
39
66
  } catch {
40
67
  }
41
68
  }
@@ -44,6 +71,8 @@ function discover(rootDir) {
44
71
  firebaseJson,
45
72
  firestoreRulesFile: rulesFile,
46
73
  firestoreIndexesFile: indexesFile,
74
+ storageRulesFile: storageFile,
75
+ functionsDir,
47
76
  auditYamlFile: pick("firebase-audit.yaml"),
48
77
  projectId,
49
78
  databaseId,
@@ -93,8 +122,10 @@ function emptyModel(rootDir, d) {
93
122
  firestore: {
94
123
  rulesFile: rel(d.firestoreRulesFile),
95
124
  indexesFile: rel(d.firestoreIndexesFile)
96
- }
125
+ },
126
+ storage: d.storageRulesFile ? { rulesFile: rel(d.storageRulesFile) } : void 0
97
127
  },
128
+ functions: d.functionsDir ? { dir: rel(d.functionsDir) } : void 0,
98
129
  authorization: { adapter: null, roles: [], permissions: [] },
99
130
  queries: [],
100
131
  rules: [],
@@ -109,14 +140,14 @@ async function enrichWithGraph(rootDir, d) {
109
140
  const res = await ai.map({ cwd: rootDir, format: "json" });
110
141
  const files = Array.isArray(res.files) ? res.files : [];
111
142
  const code = files.map((f) => String(f.path).replace(/\\/g, "/")).filter((p) => /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(p)).filter((p) => !p.includes("node_modules/") && !/(^|\/)dist\//.test(p)).slice(0, 500);
112
- const { readFileSync: readSync, existsSync: existsSyncFn, statSync } = await import("fs");
143
+ const { readFileSync: readSync, existsSync: existsSyncFn, statSync: statSync2 } = await import("fs");
113
144
  const { join: joinP } = await import("path");
114
145
  for (const rel of code) {
115
146
  let content = "";
116
147
  try {
117
148
  const abs = joinP(rootDir, rel);
118
149
  if (!existsSyncFn(abs)) continue;
119
- if (statSync(abs).size > 100 * 1024) continue;
150
+ if (statSync2(abs).size > 100 * 1024) continue;
120
151
  content = readSync(abs, "utf-8");
121
152
  } catch {
122
153
  content = "";
@@ -129,342 +160,8 @@ async function enrichWithGraph(rootDir, d) {
129
160
  }
130
161
  }
131
162
 
132
- // src/rules.ts
133
- import { readFileSync as readFileSync2 } from "fs";
134
- import { createHash } from "crypto";
135
- var MATCH_RE = /match\s+(\/[^{\s]*(?:\{[^}]*\}[^{\s]*)*)\s*\{/g;
136
- var ALLOW_RE = /allow\s+([^;:]+)(?::\s*if\s+([^;]+))?;/g;
137
- function stripRuleComments(content) {
138
- let out = "";
139
- let i = 0;
140
- let quote = null;
141
- while (i < content.length) {
142
- const c = content[i];
143
- const next = content[i + 1] ?? "";
144
- if (quote) {
145
- out += c;
146
- if (c === quote && content[i - 1] !== "\\") quote = null;
147
- i += 1;
148
- continue;
149
- }
150
- if (c === '"' || c === "'" || c === "`") {
151
- quote = c;
152
- out += c;
153
- i += 1;
154
- continue;
155
- }
156
- if (c === "/" && next === "/") {
157
- while (i < content.length && content[i] !== "\n") i += 1;
158
- continue;
159
- }
160
- if (c === "/" && next === "*") {
161
- i += 2;
162
- while (i < content.length && !(content[i] === "*" && content[i + 1] === "/")) {
163
- if (content[i] === "\n") out += "\n";
164
- i += 1;
165
- }
166
- i += 2;
167
- continue;
168
- }
169
- out += c;
170
- i += 1;
171
- }
172
- return out;
173
- }
174
- function opsFrom(target) {
175
- const t = target.trim().toLowerCase();
176
- if (t === "read") return { ops: ["read", "get", "list"], known: true };
177
- if (t === "write") return { ops: ["write", "create", "update", "delete"], known: true };
178
- const parts = t.split(",").map((s) => s.trim()).filter(Boolean);
179
- const valid = ["read", "get", "list", "create", "update", "delete", "write"];
180
- const out = [];
181
- let known = false;
182
- for (const p of parts) {
183
- if (valid.includes(p)) {
184
- known = true;
185
- out.push(p);
186
- if (p === "write") {
187
- for (const extra of ["create", "update", "delete"]) {
188
- if (!out.includes(extra)) out.push(extra);
189
- }
190
- }
191
- if (p === "read") {
192
- for (const extra of ["get", "list"]) {
193
- if (!out.includes(extra)) out.push(extra);
194
- }
195
- }
196
- }
197
- }
198
- return { ops: out, known: out.length > 0 && known };
199
- }
200
- function findBlockEnd(content, openBraceIndex) {
201
- let depth = 0;
202
- let quote = null;
203
- for (let i = openBraceIndex; i < content.length; i++) {
204
- const c = content[i];
205
- if (quote) {
206
- if (c === quote && content[i - 1] !== "\\") quote = null;
207
- continue;
208
- }
209
- if (c === '"' || c === "'" || c === "`") {
210
- quote = c;
211
- continue;
212
- }
213
- if (c === "{") depth += 1;
214
- else if (c === "}") {
215
- depth -= 1;
216
- if (depth === 0) return i + 1;
217
- }
218
- }
219
- return content.length;
220
- }
221
- function normalizeTarget(target) {
222
- return target.toLowerCase().split(",").map((s) => s.trim()).filter(Boolean).sort().join(",");
223
- }
224
- function parseMatchBlocks(content) {
225
- const blocks = [];
226
- const matchRe = new RegExp(MATCH_RE.source, "g");
227
- let m;
228
- while ((m = matchRe.exec(content)) !== null) {
229
- const before = content.slice(0, m.index);
230
- const line = before.split("\n").length;
231
- const openBrace = m.index + m[0].length - 1;
232
- blocks.push({ path: m[1], line, blockStart: m.index, blockEnd: findBlockEnd(content, openBrace) });
233
- }
234
- return blocks;
235
- }
236
- function buildFullPath(containingAsc) {
237
- const parts = [];
238
- for (const b of containingAsc) {
239
- const p = b.path.trim();
240
- if (/^\/databases\/\{[^}]+\}\/documents\/?$/.test(p)) continue;
241
- const stripped = p.replace(/^\/+|\/+$/g, "");
242
- if (stripped.length === 0) continue;
243
- const docIdx = stripped.indexOf("/documents/");
244
- if (stripped.startsWith("databases/") && docIdx !== -1) {
245
- const rest = stripped.slice(docIdx + "/documents/".length);
246
- if (rest.length > 0) parts.push(rest);
247
- continue;
248
- }
249
- parts.push(stripped);
250
- }
251
- if (parts.length === 0) return "/(unknown)";
252
- return "/" + parts.join("/");
253
- }
254
- function parseRuleFunctions(content) {
255
- const out = /* @__PURE__ */ new Map();
256
- for (const [name, fn] of parseRuleFunctionsDetailed(content)) {
257
- out.set(name, fn.body);
258
- }
259
- return out;
260
- }
261
- function parseRuleFunctionsDetailed(content) {
262
- const out = /* @__PURE__ */ new Map();
263
- const re = /function\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)\s*\{/g;
264
- let m;
265
- while ((m = re.exec(content)) !== null) {
266
- const openBrace = m.index + m[0].length - 1;
267
- const end = findBlockEnd(content, openBrace);
268
- const body = content.slice(openBrace + 1, end - 1);
269
- const params = m[2].trim() === "" ? [] : m[2].split(",").map((s) => s.trim()).filter((s) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(s));
270
- out.set(m[1], { params, body });
271
- }
272
- return out;
273
- }
274
- function inlineHelperArgs(body, params, args) {
275
- let out = body;
276
- for (let i = 0; i < params.length; i++) {
277
- const p = params[i];
278
- const a = (args[i] ?? "").trim();
279
- if (!p || !a) continue;
280
- out = out.replace(new RegExp(`\\b${p}\\b`, "g"), () => a);
281
- }
282
- return out;
283
- }
284
- function inlineHelpersInCondition(condition, detailed) {
285
- let out = condition;
286
- for (const [name, fn] of detailed) {
287
- const re = new RegExp(`\\b${name}\\s*\\(([^()]*)\\)`, "g");
288
- out = out.replace(re, (_m, argsStr) => {
289
- const args = argsStr.length === 0 ? [] : String(argsStr).split(",").map((s) => s.trim());
290
- const inlined = inlineHelperArgs(fn.body, fn.params, args);
291
- return `(${inlined})`;
292
- });
293
- }
294
- return out;
295
- }
296
- function resolveHelperCondition(condition, functions) {
297
- const t = condition.trim().replace(/;$/, "").trim();
298
- const call = /^([A-Za-z_][A-Za-z0-9_]*)\s*\((.*)\)$/.exec(t);
299
- if (!call) return condition;
300
- const body = functions.get(call[1]);
301
- if (body === void 0) return condition;
302
- return body;
303
- }
304
- function resolveHelperConditionDetailed(condition, detailed) {
305
- const t = condition.trim().replace(/;$/, "").trim();
306
- const call = /^([A-Za-z_][A-Za-z0-9_]*)\s*\((.*)\)$/.exec(t);
307
- if (!call) return condition;
308
- const fn = detailed.get(call[1]);
309
- if (!fn) return condition;
310
- const args = call[2].length === 0 ? [] : call[2].split(",").map((s) => s.trim());
311
- return inlineHelperArgs(fn.body, fn.params, args);
312
- }
313
- function extractRules(rulesFile, relFile) {
314
- const rawContent = readFileSync2(rulesFile, "utf-8");
315
- return extractRulesContent(rawContent, relFile);
316
- }
317
- function extractRulesContent(rawContent, relFile) {
318
- const content = stripRuleComments(rawContent);
319
- const rules = [];
320
- const functions = parseRuleFunctions(content);
321
- const detailed = parseRuleFunctionsDetailed(content);
322
- const matchBlocks = parseMatchBlocks(content);
323
- const allowRe = new RegExp(ALLOW_RE.source, "g");
324
- let a;
325
- let idx = 0;
326
- while ((a = allowRe.exec(content)) !== null) {
327
- const before = content.slice(0, a.index);
328
- const line = before.split("\n").length;
329
- const target = a[1];
330
- const rawCondition = (a[2] ?? "").trim();
331
- const unconditional = a[2] === void 0;
332
- const containing = matchBlocks.filter((b) => b.blockStart <= a.index && a.index < b.blockEnd).sort((x, y) => x.blockStart - y.blockStart);
333
- const fullPath = containing.length > 0 ? buildFullPath(containing) : "/(unknown)";
334
- const resolved = unconditional ? "" : resolveHelperCondition(rawCondition, functions);
335
- const resolvedDetailed = unconditional ? "" : resolveHelperConditionDetailed(rawCondition, detailed);
336
- const inlinedBody = unconditional ? "" : inlineHelpersInCondition(rawCondition, detailed);
337
- const effectiveBody = resolvedDetailed !== rawCondition ? resolvedDetailed : resolved !== rawCondition ? resolved : inlinedBody;
338
- const condition = rawCondition;
339
- const authReferences = [];
340
- const authSource = `${rawCondition} ${resolved} ${effectiveBody} ${inlinedBody}`;
341
- if (authSource.includes("request.auth")) authReferences.push("request.auth");
342
- if (authSource.includes("request.auth.uid")) authReferences.push("request.auth.uid");
343
- const big = `${rawCondition} ${resolved} ${effectiveBody} ${inlinedBody}`;
344
- const claimReferences = [
345
- ...big.matchAll(/request\.auth\.token\.([A-Za-z0-9_]+)/g),
346
- ...big.matchAll(/request\.auth\.token\.get\(\s*['"]([A-Za-z0-9_]+)['"]/g),
347
- ...big.matchAll(/request\.auth\.token\[\s*['"]([A-Za-z0-9_]+)['"]\s*\]/g)
348
- ].map((x) => x[1]);
349
- const resourceReferences = [...big.matchAll(/resource\.data\.([A-Za-z0-9_]+)/g)].map(
350
- (x) => x[1]
351
- );
352
- const requestResourceReferences = [
353
- ...big.matchAll(/request\.resource\.data\.([A-Za-z0-9_]+)/g)
354
- ].map((x) => x[1]);
355
- const { ops, known } = opsFrom(target);
356
- const isHelperCall = !unconditional && rawCondition !== resolved;
357
- const hasAuth = authSource.includes("request.auth");
358
- rules.push({
359
- id: `rule-${idx++}`,
360
- path: fullPath,
361
- operations: ops,
362
- conditionPresent: !unconditional && condition.length > 0,
363
- condition: unconditional ? void 0 : condition,
364
- authReferences,
365
- claimReferences,
366
- resourceReferences,
367
- requestResourceReferences,
368
- confidence: !known || isHelperCall && !hasAuth ? "UNKNOWN" : "CONFIRMED",
369
- location: { file: relFile, start: { line, column: 0 } }
370
- });
371
- }
372
- return rules;
373
- }
374
- function stripOuterParens(s) {
375
- let norm = s.trim();
376
- for (; ; ) {
377
- if (!(norm.startsWith("(") && norm.endsWith(")"))) return norm;
378
- let depth = 0;
379
- let wrapsAll = true;
380
- for (let i = 0; i < norm.length; i++) {
381
- if (norm[i] === "(") depth += 1;
382
- else if (norm[i] === ")") {
383
- depth -= 1;
384
- if (depth === 0 && i !== norm.length - 1) {
385
- wrapsAll = false;
386
- break;
387
- }
388
- }
389
- }
390
- if (!wrapsAll || depth !== 0) return norm;
391
- norm = norm.slice(1, -1).trim();
392
- }
393
- }
394
- function isPublicCondition(condition) {
395
- if (condition === void 0) return { isPublic: true, confidence: "CONFIRMED" };
396
- const norm = stripOuterParens(condition);
397
- const low = norm.toLowerCase().replace(/\s+/g, "");
398
- if (low === "false") return { isPublic: false, confidence: "CONFIRMED" };
399
- if (/^!\(.*request\.auth(===|==)null/.test(low)) {
400
- return { isPublic: false, confidence: "CONFIRMED" };
401
- }
402
- if (/^!\(.*request\.auth(!==|!=)null/.test(low)) {
403
- return { isPublic: true, confidence: "CONFIRMED" };
404
- }
405
- if (low.includes("!(request.auth==null)") || low.includes("!(request.auth===null)") || low === "!request.auth==null" || low === "!request.auth===null") {
406
- return { isPublic: false, confidence: "CONFIRMED" };
407
- }
408
- if (low === "true") return { isPublic: true, confidence: "CONFIRMED" };
409
- if (/(^|\|\|)true($|\|\|)/.test(low)) return { isPublic: true, confidence: "PROBABLE" };
410
- if (low.includes("request.auth==null") || low.includes("request.auth===null")) {
411
- return { isPublic: true, confidence: "CONFIRMED" };
412
- }
413
- if (!low.includes("request.auth")) {
414
- return { isPublic: true, confidence: "PROBABLE" };
415
- }
416
- return { isPublic: false, confidence: "CONFIRMED" };
417
- }
418
- function hashShort(text) {
419
- return createHash("sha256").update(text, "utf-8").digest("hex").slice(0, 8);
420
- }
421
- function conditionKeyForFingerprint(condition) {
422
- if (condition === void 0) return "uncond";
423
- const norm = stripOuterParens(condition).toLowerCase().replace(/\s+/g, "");
424
- if (norm === "") return "uncond";
425
- return hashShort(norm);
426
- }
427
- function findPublicAllows(rulesFile) {
428
- const rawContent = readFileSync2(rulesFile, "utf-8");
429
- return findPublicAllowsContent(rawContent);
430
- }
431
- function helperBodyToCondition(body) {
432
- const m = /\breturn\b\s*([^;]+);?/.exec(body);
433
- if (m) return m[1].trim();
434
- return body;
435
- }
436
- function findPublicAllowsContent(rawContent) {
437
- const content = stripRuleComments(rawContent);
438
- const functions = parseRuleFunctions(content);
439
- const detailed = parseRuleFunctionsDetailed(content);
440
- const out = [];
441
- const parsed = parseMatchBlocks(content);
442
- const re = new RegExp(ALLOW_RE.source, "g");
443
- let mm;
444
- while ((mm = re.exec(content)) !== null) {
445
- const line = content.slice(0, mm.index).split("\n").length;
446
- const target = mm[1].trim();
447
- const single = mm[2] === void 0 ? void 0 : resolveHelperCondition(mm[2], functions);
448
- const singleD = mm[2] === void 0 ? void 0 : resolveHelperConditionDetailed(mm[2], detailed);
449
- const inlined = mm[2] === void 0 ? void 0 : inlineHelpersInCondition(mm[2], detailed);
450
- 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];
451
- if (mm[2] !== void 0 && effective !== mm[2] && !String(effective).includes("request.auth")) {
452
- const bodyCheck = isPublicCondition(helperBodyToCondition(String(effective)));
453
- if (!bodyCheck.isPublic) {
454
- continue;
455
- }
456
- }
457
- const check = isPublicCondition(effective);
458
- if (!check.isPublic) continue;
459
- const ascBlocks = parsed.filter((b) => b.blockStart <= mm.index && mm.index < b.blockEnd).sort((x, y) => x.blockStart - y.blockStart);
460
- const path = ascBlocks.length > 0 ? buildFullPath(ascBlocks) : "/(unknown)";
461
- out.push({ line, target, path, confidence: check.confidence, conditionKey: conditionKeyForFingerprint(mm[2]) });
462
- }
463
- return out;
464
- }
465
-
466
163
  // src/checks.ts
467
- import { readFileSync as readFileSync3, existsSync as existsSync2 } from "fs";
164
+ import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
468
165
  import { join as join2 } from "path";
469
166
  import { executeFind } from "@justmpm/supergrep";
470
167
  var IMPLEMENTED_CHECKS = ["FBA001", "FBA002", "FBA003", "FBA004", "FBA009", "FBA010", "FBA011", "FBA012", "FBA013"];
@@ -503,7 +200,7 @@ async function runChecks(model, rootDir, opts = {}) {
503
200
  let helperDetailed = /* @__PURE__ */ new Map();
504
201
  if (absRules && existsSync2(absRules)) {
505
202
  try {
506
- const rawAll = readFileSync3(absRules, "utf-8");
203
+ const rawAll = readFileSync2(absRules, "utf-8");
507
204
  pubs = findPublicAllowsContent(rawAll);
508
205
  helperDetailed = parseRuleFunctionsDetailed(rawAll);
509
206
  } catch {
@@ -555,12 +252,15 @@ async function runChecks(model, rootDir, opts = {}) {
555
252
  const permissionCalls = await collectPermissionCalls(rootDir, adapterFn);
556
253
  const declared = new Set(model.authorization.permissions.map((p) => p.name));
557
254
  if (permissionCalls.literals.length === 0 && permissionCalls.dynamic.length === 0) {
255
+ const tenantHint = [...helperDetailed.keys()].find(
256
+ (n) => /tenant|isMember|isSignedIn|_member/i.test(n)
257
+ );
558
258
  findings.push(
559
259
  make(
560
260
  "FBA009",
561
261
  "WARNING",
562
262
  "UNKNOWN",
563
- `Adapter de autoriza\xE7\xE3o "${adapterFn}" n\xE3o observado no c\xF3digo. Sem adapter, permiss\xF5es s\xE3o UNKNOWN.`,
263
+ tenantHint ? `Adapter "${adapterFn}" n\xE3o observado no c\xF3digo. Encontrei portaria tenant (${tenantHint}) nas Rules, mas sem fun\xE7\xE3o de permiss\xE3o que receba o nome como texto n\xE3o d\xE1 para ligar papel\xD7recurso \u2014 permiss\xF5es seguem UNKNOWN.` : `Adapter "${adapterFn}" n\xE3o observado no c\xF3digo. Sem fun\xE7\xE3o de permiss\xE3o que receba o nome como texto, permiss\xF5es s\xE3o UNKNOWN.`,
564
264
  `FBA009:${adapterFn}`,
565
265
  { evidence: [ev("adapter-missing", adapterFn, "UNKNOWN")] }
566
266
  )
@@ -790,7 +490,7 @@ async function runChecks(model, rootDir, opts = {}) {
790
490
  for (const hit of adminHits) {
791
491
  let origin = "UNKNOWN";
792
492
  try {
793
- const content = readFileSync3(join2(rootDir, hit.file), "utf-8");
493
+ const content = readFileSync2(join2(rootDir, hit.file), "utf-8");
794
494
  origin = classifyOrigin(hit.file, content);
795
495
  } catch {
796
496
  origin = "UNKNOWN";
@@ -1041,8 +741,15 @@ var ProjectModelSchema = z.strictObject({
1041
741
  firestore: z.strictObject({
1042
742
  rulesFile: z.string().optional(),
1043
743
  indexesFile: z.string().optional()
1044
- })
744
+ }),
745
+ storage: z.strictObject({
746
+ rulesFile: z.string().optional()
747
+ }).optional()
1045
748
  }),
749
+ functions: z.strictObject({
750
+ dir: z.string().optional(),
751
+ appCheckEnforced: z.number().int().nonnegative().optional()
752
+ }).optional(),
1046
753
  authorization: z.strictObject({
1047
754
  adapter: z.string().nullable(),
1048
755
  roles: z.array(RoleSchema),
@@ -1136,7 +843,7 @@ async function scan(rootDir, opts = {}) {
1136
843
  const findings_pre = [];
1137
844
  try {
1138
845
  if (d.firebaseJson && existsSync3(d.firebaseJson)) {
1139
- const raw = JSON.parse(readFileSync4(d.firebaseJson, "utf-8"));
846
+ const raw = JSON.parse(readFileSync3(d.firebaseJson, "utf-8"));
1140
847
  for (const [kind, rel] of [
1141
848
  ["rules", raw.firestore?.rules],
1142
849
  ["indexes", raw.firestore?.indexes]
@@ -1191,7 +898,7 @@ async function scan(rootDir, opts = {}) {
1191
898
  }
1192
899
  if (d.firestoreIndexesFile && existsSync3(d.firestoreIndexesFile)) {
1193
900
  try {
1194
- const raw = JSON.parse(readFileSync4(d.firestoreIndexesFile, "utf-8"));
901
+ const raw = JSON.parse(readFileSync3(d.firestoreIndexesFile, "utf-8"));
1195
902
  if (Array.isArray(raw.fieldOverrides) && raw.fieldOverrides.length > 0) {
1196
903
  findings_pre.push({
1197
904
  rule: "FIELD_OVERRIDES_PRESENT",
@@ -1206,11 +913,11 @@ async function scan(rootDir, opts = {}) {
1206
913
  }
1207
914
  const validModes = ["ASCENDING", "DESCENDING", "ARRAY_CONTAINS", "VECTOR"];
1208
915
  const entries = [];
1209
- let skipped = 0;
916
+ let skipped2 = 0;
1210
917
  let scopeSkipped = 0;
1211
918
  for (const i of raw.indexes ?? []) {
1212
919
  if (typeof i.collectionGroup !== "string" || i.collectionGroup.length === 0) {
1213
- skipped += 1;
920
+ skipped2 += 1;
1214
921
  continue;
1215
922
  }
1216
923
  if (i.queryScope !== void 0 && i.queryScope !== "COLLECTION" && i.queryScope !== "COLLECTION_GROUP") {
@@ -1220,13 +927,13 @@ async function scan(rootDir, opts = {}) {
1220
927
  const fields = [];
1221
928
  for (const f of i.fields ?? []) {
1222
929
  if (typeof f.fieldPath !== "string" || f.fieldPath.length === 0) {
1223
- skipped += 1;
930
+ skipped2 += 1;
1224
931
  continue;
1225
932
  }
1226
933
  const rawMode = f.vectorConfig ? "VECTOR" : f.arrayConfig === "CONTAINS" ? "ARRAY_CONTAINS" : typeof f.mode === "string" ? f.mode : f.order;
1227
934
  const mode = rawMode;
1228
935
  if (mode !== "ASCENDING" && mode !== "DESCENDING" && mode !== "ARRAY_CONTAINS" && mode !== "VECTOR") {
1229
- skipped += 1;
936
+ skipped2 += 1;
1230
937
  continue;
1231
938
  }
1232
939
  fields.push({ fieldPath: f.fieldPath, mode });
@@ -1239,11 +946,11 @@ async function scan(rootDir, opts = {}) {
1239
946
  const last = entries[entries.length - 1];
1240
947
  if (last.fields.length === 0) {
1241
948
  entries.pop();
1242
- skipped += 1;
949
+ skipped2 += 1;
1243
950
  }
1244
951
  }
1245
952
  model.indexes = entries;
1246
- const totalSkipped = skipped + scopeSkipped;
953
+ const totalSkipped = skipped2 + scopeSkipped;
1247
954
  if (totalSkipped > 0) {
1248
955
  findings_pre.push({
1249
956
  rule: "INDEXES_INVALID",
@@ -1271,7 +978,7 @@ async function scan(rootDir, opts = {}) {
1271
978
  }
1272
979
  if (d.auditYamlFile && existsSync3(d.auditYamlFile)) {
1273
980
  try {
1274
- const text = readFileSync4(d.auditYamlFile, "utf-8");
981
+ const text = readFileSync3(d.auditYamlFile, "utf-8");
1275
982
  const parsed = parseYaml(text);
1276
983
  const validated = AuditYamlSchema.safeParse(parsed);
1277
984
  if (validated.success) {
@@ -1361,6 +1068,82 @@ async function scan(rootDir, opts = {}) {
1361
1068
  }
1362
1069
  const checksFindings = await runChecks(model, rootDir, { adapterFn: opts.adapterFn ?? model.authorization.adapter ?? void 0 });
1363
1070
  const findings = [...findings_pre, ...checksFindings];
1071
+ const checked = ["firestore.rules", "indexes", "contract", "client-code"];
1072
+ const skipped = [];
1073
+ if (d.storageRulesFile && existsSync3(d.storageRulesFile)) {
1074
+ checked.push("storage.rules");
1075
+ try {
1076
+ const raw = readFileSync3(d.storageRulesFile, "utf-8");
1077
+ const relStorage = model.firebase.storage?.rulesFile ?? "storage.rules";
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
+ }
1121
+ }
1122
+ } catch {
1123
+ skipped.push("storage.rules ileg\xEDvel \u2014 Storage sem cobertura neste scan");
1124
+ }
1125
+ } else {
1126
+ skipped.push("storage.rules n\xE3o observado \u2014 Storage fora deste scan");
1127
+ }
1128
+ if (d.functionsDir) {
1129
+ const enforced = await countAppCheckEnforced(rootDir);
1130
+ model.functions = { dir: model.functions?.dir, appCheckEnforced: enforced };
1131
+ checked.push("functions-appcheck");
1132
+ if (enforced > 0) {
1133
+ findings.push({
1134
+ rule: "APPCHECK_ENFORCED",
1135
+ severity: "INFO",
1136
+ confidence: "CONFIRMED",
1137
+ message: `AppCheck ativo em ${enforced} function(s). Carimbo do app oficial \u2014 n\xE3o prova quem \xE9 o usu\xE1rio nem o tenant. Autoriza\xE7\xE3o segue nas Rules/guardas.`,
1138
+ fingerprint: "APPCHECK_ENFORCED",
1139
+ evidence: [{ id: "ev-appcheck", kind: "appcheck", summary: `${enforced} enforced`, confidence: "CONFIRMED" }]
1140
+ });
1141
+ } else {
1142
+ skipped.push("functions sem enforceAppCheck observado \u2014 AppCheck sem cobertura afirmativa");
1143
+ }
1144
+ } else {
1145
+ skipped.push("functions n\xE3o observadas \u2014 AppCheck fora deste scan");
1146
+ }
1364
1147
  const modelCheck = ProjectModelSchema.safeParse(model);
1365
1148
  if (!modelCheck.success) {
1366
1149
  findings.push({
@@ -1399,7 +1182,7 @@ async function scan(rootDir, opts = {}) {
1399
1182
  );
1400
1183
  const totalChecks = IMPLEMENTED_CHECKS.length;
1401
1184
  const passedChecks = IMPLEMENTED_CHECKS.filter((c) => !failedChecks.has(c)).length;
1402
- const infraSkipped = findings.filter((f) => !implemented.has(f.rule)).length;
1185
+ const infra = findings.filter((f) => !implemented.has(f.rule)).map((f) => ({ rule: f.rule, message: f.message }));
1403
1186
  return {
1404
1187
  model,
1405
1188
  findings,
@@ -1407,15 +1190,93 @@ async function scan(rootDir, opts = {}) {
1407
1190
  errors,
1408
1191
  warnings,
1409
1192
  infos,
1410
- passed: passedChecks,
1411
- total: totalChecks,
1412
1193
  passedChecks,
1413
1194
  totalChecks,
1414
- infraSkipped,
1195
+ infra,
1196
+ checked,
1197
+ skipped,
1415
1198
  coveragePct: null
1416
1199
  }
1417
1200
  };
1418
1201
  }
1202
+ async function countAppCheckEnforced(rootDir) {
1203
+ try {
1204
+ const { readdirSync, readFileSync: readSync, statSync: stat } = await import("fs");
1205
+ const { join: joinP } = await import("path");
1206
+ const { stripRuleComments: strip } = await import("./rules-GTGZWN5U.js");
1207
+ const roots = [joinP(rootDir, "functions")];
1208
+ let count = 0;
1209
+ const stack = [...roots];
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
+ };
1215
+ while (stack.length > 0 && guard < 200) {
1216
+ guard += 1;
1217
+ const cur = stack.pop();
1218
+ let entries = [];
1219
+ try {
1220
+ entries = readdirSync(cur, { withFileTypes: true });
1221
+ } catch {
1222
+ continue;
1223
+ }
1224
+ for (const e of entries) {
1225
+ const name = e.name;
1226
+ const abs = joinP(cur, name);
1227
+ let isDir = false;
1228
+ try {
1229
+ isDir = stat(abs).isDirectory();
1230
+ } catch {
1231
+ continue;
1232
+ }
1233
+ if (isDir) {
1234
+ if (name === "node_modules" || name === "dist" || name === "lib") continue;
1235
+ stack.push(abs);
1236
+ continue;
1237
+ }
1238
+ if (!/\.(ts|js|mjs|cjs)$/.test(name)) continue;
1239
+ if (isTestFile(abs)) continue;
1240
+ try {
1241
+ const text = strip(readSync(abs, "utf-8"));
1242
+ const hits = text.match(/enforceAppCheck\s*:\s*true/g);
1243
+ if (hits) count += hits.length;
1244
+ } catch {
1245
+ }
1246
+ }
1247
+ }
1248
+ return count;
1249
+ } catch {
1250
+ return 0;
1251
+ }
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
+ }
1419
1280
 
1420
1281
  export {
1421
1282
  discover,
@@ -1423,26 +1284,6 @@ export {
1423
1284
  classifyOrigin,
1424
1285
  emptyModel,
1425
1286
  enrichWithGraph,
1426
- stripRuleComments,
1427
- opsFrom,
1428
- normalizeTarget,
1429
- parseMatchBlocks,
1430
- buildFullPath,
1431
- parseRuleFunctions,
1432
- parseRuleFunctionsDetailed,
1433
- inlineHelperArgs,
1434
- inlineHelpersInCondition,
1435
- resolveHelperCondition,
1436
- resolveHelperConditionDetailed,
1437
- extractRules,
1438
- extractRulesContent,
1439
- stripOuterParens,
1440
- isPublicCondition,
1441
- hashShort,
1442
- conditionKeyForFingerprint,
1443
- findPublicAllows,
1444
- helperBodyToCondition,
1445
- findPublicAllowsContent,
1446
1287
  IMPLEMENTED_CHECKS,
1447
1288
  runChecks,
1448
1289
  SeveritySchema,