@justmpm/firebase-audit 0.5.0 → 0.5.2

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,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]
@@ -1187,6 +866,16 @@ async function scan(rootDir, opts = {}) {
1187
866
  }
1188
867
  }
1189
868
  } catch {
869
+ findings_pre.push({
870
+ rule: "FIREBASE_JSON_INVALID",
871
+ severity: "WARNING",
872
+ confidence: "CONFIRMED",
873
+ message: "firebase.json inv\xE1lido \u2014 seguindo com defaults (firestore.rules, firestore.indexes.json). Corrija o JSON.",
874
+ fingerprint: "FIREBASE_JSON_INVALID",
875
+ evidence: [
876
+ { id: "ev-firebase-json", kind: "config-parse", summary: "JSON inv\xE1lido", confidence: "CONFIRMED" }
877
+ ]
878
+ });
1190
879
  }
1191
880
  if (!d.firestoreRulesFile || !existsSync3(d.firestoreRulesFile)) {
1192
881
  findings_pre.push({
@@ -1219,7 +908,7 @@ async function scan(rootDir, opts = {}) {
1219
908
  }
1220
909
  if (d.firestoreIndexesFile && existsSync3(d.firestoreIndexesFile)) {
1221
910
  try {
1222
- const raw = JSON.parse(readFileSync4(d.firestoreIndexesFile, "utf-8"));
911
+ const raw = JSON.parse(readFileSync3(d.firestoreIndexesFile, "utf-8"));
1223
912
  if (Array.isArray(raw.fieldOverrides) && raw.fieldOverrides.length > 0) {
1224
913
  findings_pre.push({
1225
914
  rule: "FIELD_OVERRIDES_PRESENT",
@@ -1299,7 +988,7 @@ async function scan(rootDir, opts = {}) {
1299
988
  }
1300
989
  if (d.auditYamlFile && existsSync3(d.auditYamlFile)) {
1301
990
  try {
1302
- const text = readFileSync4(d.auditYamlFile, "utf-8");
991
+ const text = readFileSync3(d.auditYamlFile, "utf-8");
1303
992
  const parsed = parseYaml(text);
1304
993
  const validated = AuditYamlSchema.safeParse(parsed);
1305
994
  if (validated.success) {
@@ -1394,44 +1083,58 @@ async function scan(rootDir, opts = {}) {
1394
1083
  if (d.storageRulesFile && existsSync3(d.storageRulesFile)) {
1395
1084
  checked.push("storage.rules");
1396
1085
  try {
1397
- const raw = readFileSync4(d.storageRulesFile, "utf-8");
1086
+ const raw = readFileSync3(d.storageRulesFile, "utf-8");
1398
1087
  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
- });
1088
+ const clean = stripRuleComments(raw);
1089
+ let evCounter = 0;
1090
+ const evId = (prefix) => {
1091
+ evCounter += 1;
1092
+ return `ev-storage-${prefix}-${evCounter}`;
1093
+ };
1094
+ for (const m of parseStorageAllows(clean)) {
1095
+ if (!isOpenStorageCondition(m.condition)) continue;
1096
+ const ops = expandStorageOps(m.target);
1097
+ const key = conditionKeyForFingerprint(m.condition);
1098
+ const wildcard = /\{[^}]*=\*\*\}/.test(m.matchPath) || m.matchPath.toLowerCase().includes("allpaths");
1099
+ const where = `${relStorage}:${m.line} (${m.matchPath}, allow ${m.target})`;
1100
+ if (ops.has("write") || ops.has("create") || ops.has("update") || ops.has("delete")) {
1101
+ findings.push({
1102
+ rule: "STORAGE_PUBLIC_WRITE",
1103
+ severity: "ERROR",
1104
+ confidence: "CONFIRMED",
1105
+ message: `Storage com escrita aberta (${where}). Qualquer cliente pode escrever \u2014 cofre aberto, n\xE3o vitrine.`,
1106
+ fingerprint: `STORAGE_PUBLIC_WRITE:${m.matchPath}:${m.target}:${key}`,
1107
+ file: relStorage,
1108
+ line: m.line,
1109
+ evidence: [{ id: evId("write"), kind: "storage-public", summary: `${m.matchPath} allow ${m.target}`, confidence: "CONFIRMED", location: { file: relStorage, start: { line: m.line, column: 0 } } }],
1110
+ fix: "Exigir request.auth (e dono/tenant) no write. Deixe get aberto s\xF3 em pasta de vitrine como logos/."
1111
+ });
1112
+ } else if (ops.has("read") || ops.has("get") || ops.has("list")) {
1113
+ if (wildcard) {
1114
+ findings.push({
1115
+ rule: "STORAGE_ALL_PATHS_OPEN",
1116
+ severity: "WARNING",
1117
+ confidence: "PROBABLE",
1118
+ message: `Storage com leitura aberta em curinga total (${where}). Vale para tudo \u2014 se a inten\xE7\xE3o era s\xF3 logo, restrinja a /logos/{id}.`,
1119
+ fingerprint: `STORAGE_ALL_PATHS_OPEN:${m.matchPath}:${m.target}:${key}`,
1120
+ file: relStorage,
1121
+ line: m.line,
1122
+ evidence: [{ id: evId("all"), kind: "storage-public", summary: `${m.matchPath} allow ${m.target}`, confidence: "PROBABLE", location: { file: relStorage, start: { line: m.line, column: 0 } } }],
1123
+ fix: "Troque o curinga total por pastas expl\xEDcitas (ex: /logos/{logoId}) com get: if true e write restrito."
1124
+ });
1125
+ } else {
1126
+ findings.push({
1127
+ rule: "STORAGE_PUBLIC_READ",
1128
+ severity: "INFO",
1129
+ confidence: "PROBABLE",
1130
+ message: `Storage com leitura aberta (${where}). Pode ser vitrine proposital (logos) \u2014 confirme que o write segue restrito.`,
1131
+ fingerprint: `STORAGE_PUBLIC_READ:${m.matchPath}:${m.target}:${key}`,
1132
+ file: relStorage,
1133
+ line: m.line,
1134
+ evidence: [{ id: evId("read"), kind: "storage-public", summary: `${m.matchPath} allow ${m.target}`, confidence: "PROBABLE", location: { file: relStorage, start: { line: m.line, column: 0 } } }]
1135
+ });
1136
+ }
1137
+ }
1435
1138
  }
1436
1139
  } catch {
1437
1140
  skipped.push("storage.rules ileg\xEDvel \u2014 Storage sem cobertura neste scan");
@@ -1440,24 +1143,38 @@ async function scan(rootDir, opts = {}) {
1440
1143
  skipped.push("storage.rules n\xE3o observado \u2014 Storage fora deste scan");
1441
1144
  }
1442
1145
  if (d.functionsDir) {
1443
- const enforced = await countAppCheckEnforced(rootDir);
1444
- model.functions = { dir: model.functions?.dir, appCheckEnforced: enforced };
1146
+ const appcheck = await countAppCheckEnforced(rootDir);
1147
+ model.functions = { dir: model.functions?.dir, appCheckEnforced: appcheck.occurrences };
1445
1148
  checked.push("functions-appcheck");
1446
- if (enforced > 0) {
1149
+ if (appcheck.manual > 0) {
1150
+ findings.push({
1151
+ rule: "APPCHECK_MANUAL",
1152
+ severity: "INFO",
1153
+ confidence: "PROBABLE",
1154
+ message: `Verifica\xE7\xE3o manual de AppCheck observada (${appcheck.manual} ocorr\xEAncia(s) de verifyToken). Cubra rota a rota no onRequest \u2014 o scan n\xE3o audita cada rota.`,
1155
+ fingerprint: "APPCHECK_MANUAL",
1156
+ evidence: [{ id: "ev-appcheck-manual", kind: "appcheck", summary: `${appcheck.manual} verifyToken`, confidence: "PROBABLE" }]
1157
+ });
1158
+ }
1159
+ if (appcheck.occurrences > 0) {
1447
1160
  findings.push({
1448
1161
  rule: "APPCHECK_ENFORCED",
1449
1162
  severity: "INFO",
1450
1163
  confidence: "CONFIRMED",
1451
- 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.`,
1164
+ message: `enforceAppCheck observado (${appcheck.occurrences} ocorr\xEAncia(s) em ${appcheck.files} arquivo(s)). Carimbo do app oficial \u2014 n\xE3o prova quem \xE9 o usu\xE1rio nem o tenant. Autoriza\xE7\xE3o segue nas Rules/guardas.`,
1452
1165
  fingerprint: "APPCHECK_ENFORCED",
1453
- evidence: [{ id: "ev-appcheck", kind: "appcheck", summary: `${enforced} enforced`, confidence: "CONFIRMED" }]
1166
+ evidence: [{ id: "ev-appcheck", kind: "appcheck", summary: `${appcheck.occurrences} ocorr\xEAncias`, confidence: "CONFIRMED" }]
1454
1167
  });
1455
- } else {
1456
- skipped.push("functions sem enforceAppCheck observado \u2014 AppCheck sem cobertura afirmativa");
1168
+ } else if (appcheck.manual === 0) {
1169
+ skipped.push("functions sem enforceAppCheck nem verifyToken observado \u2014 AppCheck sem cobertura afirmativa (onRequest exige verifica\xE7\xE3o manual por rota)");
1170
+ }
1171
+ if (appcheck.limitHit) {
1172
+ skipped.push("functions: teto de varredura atingido \u2014 AppCheck parcial neste scan");
1457
1173
  }
1458
1174
  } else {
1459
1175
  skipped.push("functions n\xE3o observadas \u2014 AppCheck fora deste scan");
1460
1176
  }
1177
+ skipped.push("isolamento por tenant (ownerId/tenantId) n\xE3o verificado \u2014 fora dos checks implementados, nunca verde silencioso");
1461
1178
  const modelCheck = ProjectModelSchema.safeParse(model);
1462
1179
  if (!modelCheck.success) {
1463
1180
  findings.push({
@@ -1517,12 +1234,21 @@ async function countAppCheckEnforced(rootDir) {
1517
1234
  try {
1518
1235
  const { readdirSync, readFileSync: readSync, statSync: stat } = await import("fs");
1519
1236
  const { join: joinP } = await import("path");
1237
+ const { stripRuleComments: strip } = await import("./rules-GTGZWN5U.js");
1520
1238
  const roots = [joinP(rootDir, "functions")];
1521
- let count = 0;
1239
+ let occurrences = 0;
1240
+ const files = /* @__PURE__ */ new Set();
1241
+ let manual = 0;
1242
+ let limitHit = false;
1522
1243
  const stack = [...roots];
1523
1244
  let guard = 0;
1245
+ const isTestFile = (p) => {
1246
+ const n = p.replace(/\\/g, "/");
1247
+ return /\.test\.[tj]sx?$/.test(n) || /\.spec\.[tj]sx?$/.test(n) || /(^|\/)__tests__\//.test(n);
1248
+ };
1524
1249
  while (stack.length > 0 && guard < 200) {
1525
1250
  guard += 1;
1251
+ if (guard >= 200 && stack.length > 0) limitHit = true;
1526
1252
  const cur = stack.pop();
1527
1253
  let entries = [];
1528
1254
  try {
@@ -1545,17 +1271,96 @@ async function countAppCheckEnforced(rootDir) {
1545
1271
  continue;
1546
1272
  }
1547
1273
  if (!/\.(ts|js|mjs|cjs)$/.test(name)) continue;
1274
+ if (isTestFile(abs)) continue;
1548
1275
  try {
1549
- const text = readSync(abs, "utf-8");
1550
- if (/enforceAppCheck\s*:\s*true/.test(text)) count += 1;
1276
+ const text = strip(readSync(abs, "utf-8"));
1277
+ const hits = text.match(/enforceAppCheck\s*:\s*true/g);
1278
+ if (hits) {
1279
+ occurrences += hits.length;
1280
+ files.add(abs);
1281
+ }
1282
+ const manualHits = text.match(/verifyToken\s*\(/g);
1283
+ if (manualHits) manual += manualHits.length;
1551
1284
  } catch {
1552
1285
  }
1553
1286
  }
1554
1287
  }
1555
- return count;
1288
+ return { occurrences, files: files.size, manual, limitHit };
1556
1289
  } catch {
1557
- return 0;
1290
+ return { occurrences: 0, files: 0, manual: 0, limitHit: false };
1291
+ }
1292
+ }
1293
+ function isOpenStorageCondition(condition) {
1294
+ if (condition === void 0) return true;
1295
+ const norm = stripOuterParens(condition).toLowerCase().replace(/\s+/g, "");
1296
+ if (norm === "" || norm === "true") return true;
1297
+ if (/(^|\|\|)true($|\|\|)/.test(norm)) return true;
1298
+ if (norm.includes("request.auth==null") || norm.includes("request.auth===null")) return true;
1299
+ return false;
1300
+ }
1301
+ function parseStorageAllows(clean) {
1302
+ const out = [];
1303
+ const matches = [];
1304
+ const matchRe = /match\s+/g;
1305
+ let mm;
1306
+ while ((mm = matchRe.exec(clean)) !== null) {
1307
+ const lineStart = clean.lastIndexOf("\n", mm.index) + 1;
1308
+ const lineEnd = clean.indexOf("\n", mm.index);
1309
+ const line = clean.slice(lineStart, lineEnd === -1 ? void 0 : lineEnd);
1310
+ const brace = line.lastIndexOf("{");
1311
+ if (brace === -1) continue;
1312
+ const path = line.slice(mm.index - lineStart + 5, brace).trim() || "/(unknown)";
1313
+ const openBrace = lineStart + brace;
1314
+ let depth = 0;
1315
+ let quote = null;
1316
+ let end = clean.length;
1317
+ for (let i = openBrace; i < clean.length; i++) {
1318
+ const c = clean[i];
1319
+ if (quote) {
1320
+ if (c === quote && clean[i - 1] !== "\\") quote = null;
1321
+ continue;
1322
+ }
1323
+ if (c === '"' || c === "'" || c === "`") {
1324
+ quote = c;
1325
+ continue;
1326
+ }
1327
+ if (c === "{") depth += 1;
1328
+ else if (c === "}") {
1329
+ depth -= 1;
1330
+ if (depth === 0) {
1331
+ end = i + 1;
1332
+ break;
1333
+ }
1334
+ }
1335
+ }
1336
+ matches.push({ path, start: mm.index, end });
1337
+ }
1338
+ const re = /allow\s+([^;:]+?)(?::\s*if\s+([^;]+))?;/gi;
1339
+ let m;
1340
+ while ((m = re.exec(clean)) !== null) {
1341
+ const containing = matches.filter((b) => b.start <= m.index && m.index < b.end).sort((a, b) => b.start - a.start);
1342
+ const line = clean.slice(0, m.index).split("\n").length;
1343
+ out.push({ target: m[1].trim(), condition: m[2]?.trim(), matchPath: containing[0]?.path ?? "/(unknown)", line });
1344
+ }
1345
+ return out;
1346
+ }
1347
+ function expandStorageOps(target) {
1348
+ const out = /* @__PURE__ */ new Set();
1349
+ for (const part of target.toLowerCase().split(",").map((s) => s.trim()).filter(Boolean)) {
1350
+ if (part === "read") {
1351
+ out.add("read");
1352
+ out.add("get");
1353
+ out.add("list");
1354
+ } else if (part === "write") {
1355
+ out.add("write");
1356
+ out.add("create");
1357
+ out.add("update");
1358
+ out.add("delete");
1359
+ } else {
1360
+ out.add(part);
1361
+ }
1558
1362
  }
1363
+ return out;
1559
1364
  }
1560
1365
 
1561
1366
  export {
@@ -1564,26 +1369,6 @@ export {
1564
1369
  classifyOrigin,
1565
1370
  emptyModel,
1566
1371
  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
1372
  IMPLEMENTED_CHECKS,
1588
1373
  runChecks,
1589
1374
  SeveritySchema,