@justmpm/firebase-audit 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,962 @@
1
+ // src/discovery.ts
2
+ import { existsSync, readFileSync } from "fs";
3
+ import { join, relative } from "path";
4
+ function discover(rootDir) {
5
+ const pick = (name) => {
6
+ const p = join(rootDir, name);
7
+ return existsSync(p) ? p : null;
8
+ };
9
+ const firebaseJson = pick("firebase.json");
10
+ let projectId = null;
11
+ let databaseId = null;
12
+ let rulesFile = pick("firestore.rules");
13
+ let indexesFile = pick("firestore.indexes.json");
14
+ const firebaserc = pick(".firebaserc");
15
+ if (firebaserc) {
16
+ try {
17
+ const raw = JSON.parse(readFileSync(firebaserc, "utf-8"));
18
+ const preferred = raw.projects?.default ?? (raw.projects ? Object.values(raw.projects)[0] : void 0);
19
+ if (typeof preferred === "string" && preferred.length > 0) projectId = preferred;
20
+ } catch {
21
+ }
22
+ }
23
+ if (firebaseJson) {
24
+ try {
25
+ const raw = JSON.parse(readFileSync(firebaseJson, "utf-8"));
26
+ if (typeof raw.firestore?.rules === "string") {
27
+ const p = join(rootDir, raw.firestore.rules);
28
+ if (existsSync(p)) rulesFile = p;
29
+ }
30
+ if (typeof raw.firestore?.indexes === "string") {
31
+ const p = join(rootDir, raw.firestore.indexes);
32
+ if (existsSync(p)) indexesFile = p;
33
+ }
34
+ } catch {
35
+ }
36
+ }
37
+ return {
38
+ rootDir,
39
+ firebaseJson,
40
+ firestoreRulesFile: rulesFile,
41
+ firestoreIndexesFile: indexesFile,
42
+ auditYamlFile: pick("firebase-audit.yaml"),
43
+ projectId,
44
+ databaseId,
45
+ clientFiles: [],
46
+ serverFiles: []
47
+ };
48
+ }
49
+ var SERVER_HINTS_SEGMENTS = [
50
+ "functions",
51
+ "server",
52
+ "backend",
53
+ "api",
54
+ "scripts"
55
+ ];
56
+ function hasPathSegment(rel, seg) {
57
+ return rel.split("/").includes(seg);
58
+ }
59
+ function classifyOrigin(file, content) {
60
+ const rel = file.replace(/\\/g, "/");
61
+ if (rel.includes("app/api/") || rel.includes("/app/") && rel.endsWith("route.ts") || rel.includes("/app/") && rel.endsWith("route.js")) {
62
+ return "SERVER";
63
+ }
64
+ if (rel.includes("src/app/api/")) return "SERVER";
65
+ if (SERVER_HINTS_SEGMENTS.some((s) => hasPathSegment(rel, s))) {
66
+ if (hasPathSegment(rel, "functions") || hasPathSegment(rel, "server") || hasPathSegment(rel, "backend") || hasPathSegment(rel, "scripts") || rel.includes("src/app/api/") || rel.includes("app/api/")) {
67
+ return "SERVER";
68
+ }
69
+ }
70
+ if (content.includes("firebase-admin") || content.includes("firebase-functions")) {
71
+ return "CLIENT";
72
+ }
73
+ if (rel.includes("src/") || rel.includes("app/") || rel.includes("components/")) {
74
+ return "CLIENT";
75
+ }
76
+ return "UNKNOWN";
77
+ }
78
+ function emptyModel(rootDir, d) {
79
+ const rel = (abs) => abs ? relative(rootDir, abs).replace(/\\/g, "/") : void 0;
80
+ return {
81
+ schemaVersion: 1,
82
+ rootDir,
83
+ firebase: {
84
+ configFile: rel(d.firebaseJson),
85
+ projectId: d.projectId,
86
+ databaseId: d.databaseId,
87
+ firestore: {
88
+ rulesFile: rel(d.firestoreRulesFile),
89
+ indexesFile: rel(d.firestoreIndexesFile)
90
+ }
91
+ },
92
+ authorization: { adapter: null, roles: [], permissions: [] },
93
+ queries: [],
94
+ rules: [],
95
+ indexes: [],
96
+ evidence: [],
97
+ graph: []
98
+ };
99
+ }
100
+
101
+ // src/rules.ts
102
+ import { readFileSync as readFileSync2 } from "fs";
103
+ var MATCH_RE = /match\s+(\/[^{\s]*(?:\{[^}]*\}[^{\s]*)*)\s*\{/g;
104
+ var ALLOW_RE = /allow\s+([^;:]+)(?::\s*if\s+([^;]+))?;/g;
105
+ function stripRuleComments(content) {
106
+ let out = "";
107
+ let i = 0;
108
+ let quote = null;
109
+ while (i < content.length) {
110
+ const c = content[i];
111
+ const next = content[i + 1] ?? "";
112
+ if (quote) {
113
+ out += c;
114
+ if (c === quote && content[i - 1] !== "\\") quote = null;
115
+ i += 1;
116
+ continue;
117
+ }
118
+ if (c === '"' || c === "'" || c === "`") {
119
+ quote = c;
120
+ out += c;
121
+ i += 1;
122
+ continue;
123
+ }
124
+ if (c === "/" && next === "/") {
125
+ while (i < content.length && content[i] !== "\n") i += 1;
126
+ continue;
127
+ }
128
+ if (c === "/" && next === "*") {
129
+ i += 2;
130
+ while (i < content.length && !(content[i] === "*" && content[i + 1] === "/")) {
131
+ if (content[i] === "\n") out += "\n";
132
+ i += 1;
133
+ }
134
+ i += 2;
135
+ continue;
136
+ }
137
+ out += c;
138
+ i += 1;
139
+ }
140
+ return out;
141
+ }
142
+ function opsFrom(target) {
143
+ const t = target.trim().toLowerCase();
144
+ if (t === "read") return { ops: ["read", "get", "list"], known: true };
145
+ if (t === "write") return { ops: ["write", "create", "update", "delete"], known: true };
146
+ const parts = t.split(",").map((s) => s.trim()).filter(Boolean);
147
+ const valid = ["read", "get", "list", "create", "update", "delete", "write"];
148
+ const out = [];
149
+ let known = false;
150
+ for (const p of parts) {
151
+ if (valid.includes(p)) {
152
+ known = true;
153
+ out.push(p);
154
+ if (p === "write") {
155
+ for (const extra of ["create", "update", "delete"]) {
156
+ if (!out.includes(extra)) out.push(extra);
157
+ }
158
+ }
159
+ if (p === "read") {
160
+ for (const extra of ["get", "list"]) {
161
+ if (!out.includes(extra)) out.push(extra);
162
+ }
163
+ }
164
+ }
165
+ }
166
+ return { ops: out, known: out.length > 0 && known };
167
+ }
168
+ function extractRules(rulesFile, relFile) {
169
+ const rawContent = readFileSync2(rulesFile, "utf-8");
170
+ const content = stripRuleComments(rawContent);
171
+ const rules = [];
172
+ const matchBlocks = [];
173
+ const matchRe = new RegExp(MATCH_RE.source, "g");
174
+ let m;
175
+ while ((m = matchRe.exec(content)) !== null) {
176
+ const before = content.slice(0, m.index);
177
+ const line = before.split("\n").length;
178
+ matchBlocks.push({ path: m[1], line, blockStart: m.index });
179
+ }
180
+ const allowRe = new RegExp(ALLOW_RE.source, "g");
181
+ let a;
182
+ let idx = 0;
183
+ while ((a = allowRe.exec(content)) !== null) {
184
+ const before = content.slice(0, a.index);
185
+ const line = before.split("\n").length;
186
+ const target = a[1];
187
+ const condition = (a[2] ?? "").trim();
188
+ const unconditional = a[2] === void 0;
189
+ const currentMatch = [...matchBlocks].reverse().find((b) => b.blockStart <= a.index);
190
+ const authReferences = [];
191
+ if (condition.includes("request.auth")) authReferences.push("request.auth");
192
+ if (condition.includes("request.auth.uid")) authReferences.push("request.auth.uid");
193
+ const claimReferences = [...condition.matchAll(/request\.auth\.token\.([A-Za-z0-9_]+)/g)].map(
194
+ (x) => x[1]
195
+ );
196
+ const resourceReferences = [...condition.matchAll(/resource\.data\.([A-Za-z0-9_]+)/g)].map(
197
+ (x) => x[1]
198
+ );
199
+ const requestResourceReferences = [
200
+ ...condition.matchAll(/request\.resource\.data\.([A-Za-z0-9_]+)/g)
201
+ ].map((x) => x[1]);
202
+ const { ops, known } = opsFrom(target);
203
+ rules.push({
204
+ id: `rule-${idx++}`,
205
+ path: currentMatch?.path ?? "/(unknown)",
206
+ operations: ops,
207
+ conditionPresent: !unconditional && condition.length > 0,
208
+ authReferences,
209
+ claimReferences,
210
+ resourceReferences,
211
+ requestResourceReferences,
212
+ confidence: known ? "CONFIRMED" : "UNKNOWN",
213
+ location: { file: relFile, start: { line, column: 0 } }
214
+ });
215
+ }
216
+ return rules;
217
+ }
218
+ function stripOuterParens(s) {
219
+ let norm = s.trim();
220
+ for (; ; ) {
221
+ if (!(norm.startsWith("(") && norm.endsWith(")"))) return norm;
222
+ let depth = 0;
223
+ let wrapsAll = true;
224
+ for (let i = 0; i < norm.length; i++) {
225
+ if (norm[i] === "(") depth += 1;
226
+ else if (norm[i] === ")") {
227
+ depth -= 1;
228
+ if (depth === 0 && i !== norm.length - 1) {
229
+ wrapsAll = false;
230
+ break;
231
+ }
232
+ }
233
+ }
234
+ if (!wrapsAll || depth !== 0) return norm;
235
+ norm = norm.slice(1, -1).trim();
236
+ }
237
+ }
238
+ function isPublicCondition(condition) {
239
+ if (condition === void 0) return { isPublic: true, confidence: "CONFIRMED" };
240
+ const norm = stripOuterParens(condition);
241
+ const low = norm.toLowerCase().replace(/\s+/g, "");
242
+ if (low.includes("!(request.auth==null)") || low.includes("!(request.auth===null)") || low === "!request.auth==null" || low === "!request.auth===null") {
243
+ return { isPublic: false, confidence: "CONFIRMED" };
244
+ }
245
+ if (low === "true") return { isPublic: true, confidence: "CONFIRMED" };
246
+ if (/(^|\|\|)true($|\|\||&&)/.test(low)) return { isPublic: true, confidence: "PROBABLE" };
247
+ if (low.includes("request.auth==null") || low.includes("request.auth===null")) {
248
+ return { isPublic: true, confidence: "CONFIRMED" };
249
+ }
250
+ return { isPublic: false, confidence: "CONFIRMED" };
251
+ }
252
+ function findPublicAllows(rulesFile) {
253
+ const rawContent = readFileSync2(rulesFile, "utf-8");
254
+ const content = stripRuleComments(rawContent);
255
+ const out = [];
256
+ const matchRe = new RegExp(MATCH_RE.source, "g");
257
+ const blocks = [];
258
+ let mm0;
259
+ while ((mm0 = matchRe.exec(content)) !== null) blocks.push({ path: mm0[1], index: mm0.index });
260
+ const re = new RegExp(ALLOW_RE.source, "g");
261
+ let mm;
262
+ while ((mm = re.exec(content)) !== null) {
263
+ const line = content.slice(0, mm.index).split("\n").length;
264
+ const target = mm[1].trim();
265
+ const check = isPublicCondition(mm[2]);
266
+ if (!check.isPublic) continue;
267
+ const path = [...blocks].reverse().find((b) => b.index <= mm.index)?.path ?? "/(unknown)";
268
+ out.push({ line, target, path, confidence: check.confidence });
269
+ }
270
+ return out;
271
+ }
272
+
273
+ // src/checks.ts
274
+ import { readFileSync as readFileSync3, existsSync as existsSync2 } from "fs";
275
+ import { join as join2 } from "path";
276
+ import { executeFind } from "@justmpm/supergrep";
277
+ var IMPLEMENTED_CHECKS = ["FBA001", "FBA002", "FBA003", "FBA004", "FBA009", "FBA010", "FBA011", "FBA012", "FBA013"];
278
+ function createEvidence() {
279
+ let counter = 0;
280
+ return {
281
+ ev: (kind, summary, confidence, file, line) => {
282
+ counter += 1;
283
+ return {
284
+ id: `ev-${counter}`,
285
+ kind,
286
+ summary,
287
+ confidence,
288
+ location: file && line ? { file, start: { line, column: 0 } } : void 0
289
+ };
290
+ }
291
+ };
292
+ }
293
+ function make(rule, severity, confidence, message, fingerprint, opts = {}) {
294
+ return {
295
+ rule,
296
+ severity,
297
+ confidence,
298
+ message,
299
+ fingerprint,
300
+ evidence: [],
301
+ ...opts
302
+ };
303
+ }
304
+ async function runChecks(model, rootDir, opts = {}) {
305
+ const { ev } = createEvidence();
306
+ const findings = [];
307
+ const relRules = model.firebase.firestore.rulesFile;
308
+ const absRules = relRules ? join2(rootDir, relRules) : null;
309
+ if (absRules && existsSync2(absRules)) {
310
+ for (const pub of findPublicAllows(absRules)) {
311
+ const t = pub.target.toLowerCase();
312
+ const tokens = t.split(",").map((s) => s.trim());
313
+ const isWrite = tokens.some((x) => ["write", "create", "update", "delete"].includes(x));
314
+ const isRead = tokens.some((x) => ["read", "get", "list"].includes(x));
315
+ if (isWrite) {
316
+ findings.push(
317
+ make(
318
+ "FBA001",
319
+ "ERROR",
320
+ pub.confidence,
321
+ `Escrita p\xFAblica em ${pub.path} (${pub.target}). Qualquer cliente pode escrever.`,
322
+ `FBA001:${relRules}:${pub.path}:${pub.target}`,
323
+ {
324
+ file: relRules,
325
+ line: pub.line,
326
+ resource: pub.path,
327
+ evidence: [ev("public-allow", `${pub.target} em ${pub.path}`, pub.confidence, relRules, pub.line)],
328
+ fix: "Troque por checagem de auth/claim. Se for proposital, documente no contrato."
329
+ }
330
+ )
331
+ );
332
+ }
333
+ if (isRead) {
334
+ findings.push(
335
+ make(
336
+ "FBA002",
337
+ "WARNING",
338
+ pub.confidence,
339
+ `Leitura p\xFAblica em ${pub.path} (${pub.target}). Pode ser proposital.`,
340
+ `FBA002:${relRules}:${pub.path}:${pub.target}`,
341
+ {
342
+ file: relRules,
343
+ line: pub.line,
344
+ resource: pub.path,
345
+ evidence: [ev("public-allow", `${pub.target} em ${pub.path}`, pub.confidence, relRules, pub.line)]
346
+ }
347
+ )
348
+ );
349
+ }
350
+ }
351
+ }
352
+ const adapterFn = opts.adapterFn ?? "rbac.can";
353
+ const permissionCalls = await collectPermissionCalls(rootDir, adapterFn);
354
+ const declared = new Set(model.authorization.permissions.map((p) => p.name));
355
+ if (permissionCalls.literals.length === 0 && permissionCalls.dynamic.length === 0) {
356
+ findings.push(
357
+ make(
358
+ "FBA009",
359
+ "WARNING",
360
+ "UNKNOWN",
361
+ `Adapter de autoriza\xE7\xE3o "${adapterFn}" n\xE3o observado no c\xF3digo. Sem adapter, permiss\xF5es s\xE3o UNKNOWN.`,
362
+ `FBA009:${adapterFn}`,
363
+ { evidence: [ev("adapter-missing", adapterFn, "UNKNOWN")] }
364
+ )
365
+ );
366
+ } else if (declared.size === 0) {
367
+ findings.push(
368
+ make(
369
+ "FBA009",
370
+ "INFO",
371
+ "NOT_OBSERVED",
372
+ `Permiss\xF5es usadas sem contrato (${permissionCalls.literals.length} literais). Crie firebase-audit.yaml para ativar FBA004.`,
373
+ `FBA009:no-contract`,
374
+ { evidence: [ev("permissions-without-contract", `${permissionCalls.literals.length} calls`, "NOT_OBSERVED")] }
375
+ )
376
+ );
377
+ }
378
+ for (const call of permissionCalls.literals) {
379
+ if (!declared.has(call.permission) && declared.size > 0) {
380
+ findings.push(
381
+ make(
382
+ "FBA004",
383
+ "ERROR",
384
+ "CONFIRMED",
385
+ `Permission "${call.permission}" usada mas n\xE3o declarada no contrato.`,
386
+ `FBA004:${call.file}:${call.line}:${call.permission}`,
387
+ {
388
+ file: call.file,
389
+ line: call.line,
390
+ resource: call.permission.includes(".") ? call.permission.split(".")[0] : call.permission,
391
+ evidence: [ev("permission-call", `${adapterFn}("${call.permission}")`, "CONFIRMED", call.file, call.line)],
392
+ fix: `Declarar "${call.permission}" em firebase-audit.yaml ou corrigir o nome da chamada.`
393
+ }
394
+ )
395
+ );
396
+ }
397
+ }
398
+ for (const role of model.authorization.roles) {
399
+ for (const perm of role.permissions) {
400
+ if (!declared.has(perm)) {
401
+ findings.push(
402
+ make(
403
+ "FBA011",
404
+ "ERROR",
405
+ "CONFIRMED",
406
+ `Role "${role.name}" referencia permission "${perm}" n\xE3o declarada em permissions.`,
407
+ `FBA011:${role.name}:${perm}`,
408
+ {
409
+ evidence: [ev("role-permission", `${role.name} \u2192 ${perm}`, "CONFIRMED")],
410
+ fix: `Declarar "${perm}" em authorization.permissions.`
411
+ }
412
+ )
413
+ );
414
+ }
415
+ }
416
+ }
417
+ for (const role of model.authorization.roles) {
418
+ if (role.permissions.length === 0) {
419
+ findings.push(
420
+ make(
421
+ "FBA013",
422
+ "WARNING",
423
+ "CONFIRMED",
424
+ `Role "${role.name}" sem permissions \u2014 nunca autoriza nada.`,
425
+ `FBA013:${role.name}`,
426
+ {
427
+ evidence: [ev("role-empty", role.name, "CONFIRMED")],
428
+ fix: `Adicionar permissions \xE0 role "${role.name}" ou remov\xEA-la.`
429
+ }
430
+ )
431
+ );
432
+ }
433
+ }
434
+ const publicPaths = new Set(
435
+ absRules && existsSync2(absRules) ? findPublicAllows(absRules).map((p) => `${p.path}::${p.target}`) : []
436
+ );
437
+ const expandOp = (op) => {
438
+ if (op === "read") return ["read", "get", "list"];
439
+ if (op === "write") return ["write", "create", "update", "delete"];
440
+ return [op];
441
+ };
442
+ const permsByResource = /* @__PURE__ */ new Map();
443
+ for (const p of model.authorization.permissions) {
444
+ const holders = model.authorization.roles.filter((r) => r.permissions.includes(p.name)).map((r) => r.name);
445
+ const adminOnly = holders.length > 0 && holders.every((h) => h === "admin");
446
+ const list = permsByResource.get(p.resource) ?? [];
447
+ list.push({ perm: p.name, operation: p.operation, adminOnly });
448
+ permsByResource.set(p.resource, list);
449
+ }
450
+ for (const rule of model.rules) {
451
+ const resourceKey = rule.path.replace(/^\/+/, "").split("/")[0] ?? "";
452
+ if (resourceKey === "databases" || resourceKey === "(unknown)" || resourceKey === "") continue;
453
+ const entries = permsByResource.get(resourceKey);
454
+ if (!entries || entries.length === 0) continue;
455
+ const ruleOps = new Set(rule.operations.flatMap(expandOp));
456
+ const restricted = entries.filter((e) => e.adminOnly && ruleOps.has(e.operation));
457
+ if (restricted.length === 0) continue;
458
+ const isPublicRule = rule.operations.some(
459
+ (op) => publicPaths.has(`${rule.path}::${op}`)
460
+ ) || rule.operations.some((op) => publicPaths.has(`${rule.path}::read, ${op}`));
461
+ if (isPublicRule) continue;
462
+ const checksAdminClaim = rule.claimReferences.some((c) => c.toLowerCase() === "admin");
463
+ if (checksAdminClaim) continue;
464
+ const checksAuth = rule.authReferences.length > 0;
465
+ if (!checksAuth) continue;
466
+ findings.push(
467
+ make(
468
+ "FBA012",
469
+ "WARNING",
470
+ "PROBABLE",
471
+ `Rule ${rule.path} [${rule.operations.join(", ")}] aceita autenticado sem checar claim de admin, mas contrato restringe ${restricted.map((r) => r.perm).join(", ")} a admin.`,
472
+ `FBA012:${rule.path}:${[...rule.operations].sort().join(",")}`,
473
+ {
474
+ file: rule.location.file,
475
+ line: rule.location.start.line,
476
+ resource: rule.path,
477
+ evidence: [ev("rule-contract-mismatch", `${rule.path} vs ${restricted.map((r) => r.perm).join(", ")}`, "PROBABLE", rule.location.file, rule.location.start.line)],
478
+ fix: "Adicionar checagem de claim de admin na Rule (ex: request.auth.token.admin == true) ou relaxar o contrato."
479
+ }
480
+ )
481
+ );
482
+ }
483
+ for (const dyn of permissionCalls.dynamic) {
484
+ findings.push(
485
+ make(
486
+ "FBA010",
487
+ "INFO",
488
+ "UNKNOWN",
489
+ `Chamada de permiss\xE3o din\xE2mica em ${dyn.file}:${dyn.line} \u2014 campo n\xE3o resolvido estaticamente.`,
490
+ `FBA010:${dyn.file}:${dyn.line}`,
491
+ {
492
+ file: dyn.file,
493
+ line: dyn.line,
494
+ evidence: [ev("dynamic-permission", dyn.text, "UNKNOWN", dyn.file, dyn.line)]
495
+ }
496
+ )
497
+ );
498
+ }
499
+ const adminHits = await collectAdminImports(rootDir);
500
+ for (const hit of adminHits) {
501
+ let origin = "UNKNOWN";
502
+ try {
503
+ const content = readFileSync3(join2(rootDir, hit.file), "utf-8");
504
+ origin = classifyOrigin(hit.file, content);
505
+ } catch {
506
+ origin = "UNKNOWN";
507
+ }
508
+ if (origin === "CLIENT") {
509
+ findings.push(
510
+ make(
511
+ "FBA003",
512
+ "ERROR",
513
+ "PROBABLE",
514
+ `Admin SDK em arquivo de cliente (${hit.file}). Rules n\xE3o valem aqui \u2014 precisa auth da app.`,
515
+ `FBA003:${hit.file}:${hit.line}`,
516
+ {
517
+ file: hit.file,
518
+ line: hit.line,
519
+ evidence: [ev("admin-import", hit.text, "PROBABLE", hit.file, hit.line)],
520
+ fix: "Mover para Functions/API server ou trocar por Client SDK com Rules."
521
+ }
522
+ )
523
+ );
524
+ }
525
+ }
526
+ return findings;
527
+ }
528
+ async function collectPermissionCalls(rootDir, adapterFn) {
529
+ const literals = [];
530
+ const dynamic = [];
531
+ if (!adapterFn.includes(".")) {
532
+ return { literals, dynamic };
533
+ }
534
+ const bases = [adapterFn];
535
+ const patterns = [];
536
+ for (const b of bases) {
537
+ patterns.push(`${b}($PERM)`, `${b}($PERM, $$$ARGS)`);
538
+ }
539
+ const seen = /* @__PURE__ */ new Set();
540
+ const pushLiteral = (file, line, permission) => {
541
+ const k = `${file}:${line}:${permission}`;
542
+ if (seen.has(k)) return;
543
+ seen.add(k);
544
+ literals.push({ file, line, permission });
545
+ };
546
+ for (const pattern of patterns) {
547
+ try {
548
+ const { result } = await executeFind({ pattern, path: ".", cwd: rootDir });
549
+ for (const m of result.matches) {
550
+ const raw = (m.metaVariables["PERM"] ?? "").trim();
551
+ const line = m.line + 1;
552
+ const isQuoted = /^['"].*['"]$/.test(raw);
553
+ if (isQuoted) {
554
+ const perm = raw.replace(/^['"]|['"]$/g, "").trim();
555
+ if (perm.length >= 3) pushLiteral(m.file, line, perm);
556
+ } else if (raw.length > 0) {
557
+ const k = `${m.file}:${line}:${raw}`;
558
+ if (!seen.has(k)) {
559
+ seen.add(k);
560
+ dynamic.push({ file: m.file, line, text: m.text });
561
+ }
562
+ }
563
+ }
564
+ } catch {
565
+ }
566
+ }
567
+ return { literals, dynamic };
568
+ }
569
+ async function collectAdminImports(rootDir) {
570
+ const out = [];
571
+ const patterns = [
572
+ 'import { $$$ITEMS } from "$MODULE"',
573
+ 'import $DEFAULT from "$MODULE"',
574
+ 'import * as $NS from "$MODULE"',
575
+ 'const $X = require("$MODULE")',
576
+ 'require("$MODULE")'
577
+ ];
578
+ const seen = /* @__PURE__ */ new Set();
579
+ for (const pattern of patterns) {
580
+ try {
581
+ const { result } = await executeFind({ pattern, path: ".", cwd: rootDir });
582
+ for (const m of result.matches) {
583
+ const mod = m.metaVariables["MODULE"] ?? "";
584
+ if (mod.includes("firebase-admin") || mod.includes("firebase-functions")) {
585
+ const k = `${m.file}:${m.line}:${m.text}`;
586
+ if (seen.has(k)) continue;
587
+ seen.add(k);
588
+ out.push({ file: m.file, line: m.line + 1, text: m.text });
589
+ }
590
+ }
591
+ } catch {
592
+ }
593
+ }
594
+ return out;
595
+ }
596
+
597
+ // src/schemas.ts
598
+ import * as z from "zod";
599
+ var SeveritySchema = z.enum(["ERROR", "WARNING", "INFO"]);
600
+ var ConfidenceSchema = z.enum([
601
+ "CONFIRMED",
602
+ "PROBABLE",
603
+ "NOT_OBSERVED",
604
+ "UNKNOWN",
605
+ "CONFLICTING"
606
+ ]);
607
+ var OperationSchema = z.enum([
608
+ "read",
609
+ "get",
610
+ "list",
611
+ "create",
612
+ "update",
613
+ "delete",
614
+ "write"
615
+ ]);
616
+ var AccessOriginSchema = z.enum(["CLIENT", "SERVER", "UNKNOWN"]);
617
+ var PositionSchema = z.strictObject({
618
+ line: z.number().int().positive(),
619
+ column: z.number().int().nonnegative()
620
+ });
621
+ var LocationSchema = z.strictObject({
622
+ file: z.string().min(1),
623
+ start: PositionSchema,
624
+ end: PositionSchema.optional()
625
+ });
626
+ var EvidenceSchema = z.strictObject({
627
+ id: z.string().min(1),
628
+ kind: z.string().min(1),
629
+ location: LocationSchema.optional(),
630
+ summary: z.string().min(1),
631
+ confidence: ConfidenceSchema,
632
+ value: z.unknown().optional(),
633
+ fingerprint: z.string().min(1).optional()
634
+ });
635
+ var DynamicValueSchema = z.discriminatedUnion("kind", [
636
+ z.strictObject({ kind: z.literal("literal"), value: z.unknown() }),
637
+ z.strictObject({ kind: z.literal("symbolic"), expression: z.string() }),
638
+ z.strictObject({ kind: z.literal("unknown"), reason: z.string() })
639
+ ]);
640
+ var QueryFilterSchema = z.strictObject({
641
+ fieldPath: z.string().min(1),
642
+ operator: z.enum([
643
+ "==",
644
+ "!=",
645
+ "<",
646
+ "<=",
647
+ ">",
648
+ ">=",
649
+ "in",
650
+ "not-in",
651
+ "array-contains",
652
+ "array-contains-any"
653
+ ]),
654
+ value: DynamicValueSchema
655
+ });
656
+ var QueryOrderSchema = z.strictObject({
657
+ fieldPath: z.string().min(1),
658
+ direction: z.enum(["ASCENDING", "DESCENDING"])
659
+ });
660
+ var QueryShapeSchema = z.strictObject({
661
+ scope: z.enum(["COLLECTION", "COLLECTION_GROUP"]),
662
+ collectionId: z.string().min(1),
663
+ pathTemplate: z.string().min(1),
664
+ filters: z.array(QueryFilterSchema),
665
+ orderBy: z.array(QueryOrderSchema),
666
+ limit: DynamicValueSchema.optional(),
667
+ dynamic: z.boolean(),
668
+ confidence: ConfidenceSchema,
669
+ location: LocationSchema
670
+ });
671
+ var PermissionSchema = z.strictObject({
672
+ name: z.string().min(1),
673
+ resource: z.string().min(1),
674
+ operation: OperationSchema
675
+ });
676
+ var RoleSchema = z.strictObject({
677
+ name: z.string().min(1),
678
+ permissions: z.array(z.string().min(1))
679
+ });
680
+ var RuleSchema = z.strictObject({
681
+ id: z.string().min(1),
682
+ path: z.string().min(1),
683
+ operations: z.array(OperationSchema),
684
+ conditionPresent: z.boolean(),
685
+ authReferences: z.array(z.string()),
686
+ claimReferences: z.array(z.string()),
687
+ resourceReferences: z.array(z.string()),
688
+ requestResourceReferences: z.array(z.string()),
689
+ confidence: ConfidenceSchema,
690
+ location: LocationSchema
691
+ });
692
+ var IndexFieldSchema = z.strictObject({
693
+ fieldPath: z.string().min(1),
694
+ mode: z.enum(["ASCENDING", "DESCENDING", "ARRAY_CONTAINS", "VECTOR"])
695
+ });
696
+ var FirestoreIndexSchema = z.strictObject({
697
+ collectionGroup: z.string().min(1),
698
+ queryScope: z.enum(["COLLECTION", "COLLECTION_GROUP"]),
699
+ fields: z.array(IndexFieldSchema),
700
+ location: LocationSchema.optional()
701
+ });
702
+ var GraphEdgeSchema = z.strictObject({
703
+ from: z.string().min(1),
704
+ to: z.string().min(1),
705
+ kind: z.enum([
706
+ "CALLS",
707
+ "USES_PERMISSION",
708
+ "TARGETS_RESOURCE",
709
+ "GUARDED_BY_RULE",
710
+ "REQUIRES_INDEX",
711
+ "REFERENCES_ROLE",
712
+ "REFERENCES_CLAIM"
713
+ ]),
714
+ confidence: ConfidenceSchema,
715
+ evidenceIds: z.array(z.string())
716
+ });
717
+ var ProjectModelSchema = z.strictObject({
718
+ schemaVersion: z.literal(1),
719
+ rootDir: z.string().min(1),
720
+ firebase: z.strictObject({
721
+ configFile: z.string().optional(),
722
+ projectId: z.string().nullable(),
723
+ databaseId: z.string().nullable(),
724
+ firestore: z.strictObject({
725
+ rulesFile: z.string().optional(),
726
+ indexesFile: z.string().optional()
727
+ })
728
+ }),
729
+ authorization: z.strictObject({
730
+ adapter: z.string().nullable(),
731
+ roles: z.array(RoleSchema),
732
+ permissions: z.array(PermissionSchema)
733
+ }),
734
+ queries: z.array(QueryShapeSchema),
735
+ rules: z.array(RuleSchema),
736
+ indexes: z.array(FirestoreIndexSchema),
737
+ evidence: z.array(EvidenceSchema),
738
+ graph: z.array(GraphEdgeSchema)
739
+ });
740
+ var FindingSchema = z.strictObject({
741
+ rule: z.string().min(1),
742
+ severity: SeveritySchema,
743
+ confidence: ConfidenceSchema,
744
+ file: z.string().optional(),
745
+ line: z.number().int().positive().optional(),
746
+ message: z.string().min(1),
747
+ evidence: z.array(EvidenceSchema),
748
+ fix: z.string().optional(),
749
+ resource: z.string().optional(),
750
+ operation: OperationSchema.optional(),
751
+ fingerprint: z.string().min(1),
752
+ metadata: z.record(z.string(), z.unknown()).optional()
753
+ });
754
+ var PermissionNameSchema = z.string().regex(/^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)*$/);
755
+ var RoleNameSchema = z.string().regex(/^[a-z][a-z0-9_-]*$/);
756
+ var AuditYamlSchema = z.strictObject({
757
+ authorization: z.strictObject({
758
+ adapter: z.strictObject({ function: z.string().min(1) }).optional(),
759
+ roles: z.record(RoleNameSchema, z.strictObject({ permissions: z.array(PermissionNameSchema) })),
760
+ permissions: z.record(
761
+ PermissionNameSchema,
762
+ z.strictObject({ resource: z.string().min(1), operation: OperationSchema })
763
+ ),
764
+ claims: z.strictObject({
765
+ enabled: z.boolean(),
766
+ roleKey: z.string().min(1),
767
+ samples: z.record(RoleNameSchema, z.record(z.string().min(1), z.json())).optional()
768
+ }).optional()
769
+ })
770
+ });
771
+
772
+ // src/scan.ts
773
+ import { readFileSync as readFileSync4, existsSync as existsSync3 } from "fs";
774
+ import { parse as parseYaml } from "yaml";
775
+ async function scan(rootDir, opts = {}) {
776
+ const d = discover(rootDir);
777
+ const model = emptyModel(rootDir, d);
778
+ const findings_pre = [];
779
+ if (!d.firestoreRulesFile || !existsSync3(d.firestoreRulesFile)) {
780
+ findings_pre.push({
781
+ rule: "RULES_NOT_OBSERVED",
782
+ severity: "INFO",
783
+ confidence: "NOT_OBSERVED",
784
+ message: "firestore.rules n\xE3o encontrado \u2014 FBA001/FBA002 sem cobertura neste scan.",
785
+ fingerprint: "RULES_NOT_OBSERVED",
786
+ evidence: [
787
+ { id: "ev-no-rules", kind: "rules-missing", summary: "sem rules", confidence: "NOT_OBSERVED" }
788
+ ]
789
+ });
790
+ }
791
+ if (d.firestoreRulesFile && existsSync3(d.firestoreRulesFile)) {
792
+ const rel = d.firestoreRulesFile.replace(/\\/g, "/").startsWith(rootDir.replace(/\\/g, "/")) ? d.firestoreRulesFile.slice(rootDir.length + 1).replace(/\\/g, "/") : model.firebase.firestore.rulesFile ?? "firestore.rules";
793
+ model.rules = extractRules(d.firestoreRulesFile, rel);
794
+ }
795
+ if (d.firestoreIndexesFile && existsSync3(d.firestoreIndexesFile)) {
796
+ try {
797
+ const raw = JSON.parse(readFileSync4(d.firestoreIndexesFile, "utf-8"));
798
+ const validModes = ["ASCENDING", "DESCENDING", "ARRAY_CONTAINS", "VECTOR"];
799
+ const entries = [];
800
+ let skipped = 0;
801
+ for (const i of raw.indexes ?? []) {
802
+ if (typeof i.collectionGroup !== "string" || i.collectionGroup.length === 0) {
803
+ skipped += 1;
804
+ continue;
805
+ }
806
+ const fields = [];
807
+ for (const f of i.fields ?? []) {
808
+ if (typeof f.fieldPath !== "string" || f.fieldPath.length === 0) {
809
+ skipped += 1;
810
+ continue;
811
+ }
812
+ const mode = f.vectorConfig ? "VECTOR" : f.arrayConfig === "CONTAINS" ? "ARRAY_CONTAINS" : f.order;
813
+ if (mode !== "ASCENDING" && mode !== "DESCENDING" && mode !== "ARRAY_CONTAINS" && mode !== "VECTOR") {
814
+ skipped += 1;
815
+ continue;
816
+ }
817
+ fields.push({ fieldPath: f.fieldPath, mode });
818
+ }
819
+ entries.push({
820
+ collectionGroup: i.collectionGroup,
821
+ queryScope: i.queryScope === "COLLECTION_GROUP" ? "COLLECTION_GROUP" : "COLLECTION",
822
+ fields
823
+ });
824
+ }
825
+ model.indexes = entries;
826
+ if (skipped > 0) {
827
+ findings_pre.push({
828
+ rule: "INDEXES_INVALID",
829
+ severity: "WARNING",
830
+ confidence: "CONFIRMED",
831
+ message: `firestore.indexes.json com ${skipped} entrada(s) inv\xE1lidas ignoradas \u2014 verifique campos.`,
832
+ fingerprint: "INDEXES_INVALID",
833
+ evidence: [
834
+ { id: "ev-indexes-skipped", kind: "indexes-parse", summary: `${skipped} inv\xE1lidas`, confidence: "CONFIRMED" }
835
+ ]
836
+ });
837
+ }
838
+ } catch {
839
+ findings_pre.push({
840
+ rule: "INDEXES_INVALID",
841
+ severity: "WARNING",
842
+ confidence: "CONFIRMED",
843
+ message: "firestore.indexes.json inv\xE1lido \u2014 \xEDndices ignorados neste scan.",
844
+ fingerprint: "INDEXES_INVALID",
845
+ evidence: [
846
+ { id: "ev-indexes-invalid", kind: "indexes-parse", summary: "JSON inv\xE1lido", confidence: "CONFIRMED" }
847
+ ]
848
+ });
849
+ }
850
+ }
851
+ if (d.auditYamlFile && existsSync3(d.auditYamlFile)) {
852
+ try {
853
+ const text = readFileSync4(d.auditYamlFile, "utf-8");
854
+ const parsed = parseYaml(text);
855
+ const validated = AuditYamlSchema.safeParse(parsed);
856
+ if (validated.success) {
857
+ const auth = validated.data.authorization;
858
+ model.authorization.adapter = auth.adapter?.function ?? null;
859
+ model.authorization.permissions = Object.entries(auth.permissions).map(([name, p]) => ({
860
+ name,
861
+ resource: p.resource,
862
+ operation: p.operation
863
+ }));
864
+ model.authorization.roles = Object.entries(auth.roles).map(([name, r]) => ({
865
+ name,
866
+ permissions: r.permissions
867
+ }));
868
+ } else {
869
+ findings_pre.push({
870
+ rule: "CONTRACT_INVALID",
871
+ severity: "WARNING",
872
+ confidence: "CONFIRMED",
873
+ message: `firebase-audit.yaml existe mas \xE9 inv\xE1lido: ${validated.error.issues[0]?.message ?? "schema"} \u2014 contrato ignorado neste scan.`,
874
+ fingerprint: "CONTRACT_INVALID",
875
+ evidence: [
876
+ { id: "ev-contract-invalid", kind: "contract-parse", summary: "YAML inv\xE1lido", confidence: "CONFIRMED" }
877
+ ],
878
+ fix: "Valide contra templates/firebase-audit.yaml."
879
+ });
880
+ }
881
+ } catch (err) {
882
+ findings_pre.push({
883
+ rule: "CONTRACT_INVALID",
884
+ severity: "WARNING",
885
+ confidence: "CONFIRMED",
886
+ message: `firebase-audit.yaml n\xE3o p\xF4de ser lido: ${err instanceof Error ? err.message : "erro"} \u2014 contrato ignorado.`,
887
+ fingerprint: "CONTRACT_INVALID",
888
+ evidence: [
889
+ { id: "ev-contract-unreadable", kind: "contract-parse", summary: "leitura falhou", confidence: "CONFIRMED" }
890
+ ]
891
+ });
892
+ }
893
+ }
894
+ const checksFindings = await runChecks(model, rootDir, { adapterFn: opts.adapterFn ?? model.authorization.adapter ?? void 0 });
895
+ const findings = [...findings_pre, ...checksFindings];
896
+ const modelCheck = ProjectModelSchema.safeParse(model);
897
+ if (!modelCheck.success) {
898
+ findings.push({
899
+ rule: "MODEL_INVALID",
900
+ severity: "WARNING",
901
+ confidence: "CONFIRMED",
902
+ message: `ProjectModel inv\xE1lido: ${modelCheck.error.issues[0]?.message ?? "schema"} \u2014 verifique indexes/rules.`,
903
+ fingerprint: "MODEL_INVALID",
904
+ evidence: [
905
+ { id: "ev-model-invalid", kind: "model-validate", summary: "schema", confidence: "CONFIRMED" }
906
+ ]
907
+ });
908
+ }
909
+ if (opts.strict) {
910
+ const hasContract = model.authorization.permissions.length > 0;
911
+ if (!hasContract) {
912
+ findings.push({
913
+ rule: "CONTRACT_MISSING",
914
+ severity: "ERROR",
915
+ confidence: "CONFIRMED",
916
+ message: "Modo --strict exige firebase-audit.yaml com permissions declaradas.",
917
+ fingerprint: "CONTRACT_MISSING",
918
+ evidence: [
919
+ { id: "ev-strict", kind: "strict-mode", summary: "sem contrato", confidence: "CONFIRMED" }
920
+ ],
921
+ fix: "Crie firebase-audit.yaml (agente sugere, humano aprova)."
922
+ });
923
+ }
924
+ }
925
+ const errors = findings.filter((f) => f.severity === "ERROR").length;
926
+ const warnings = findings.filter((f) => f.severity === "WARNING").length;
927
+ const infos = findings.filter((f) => f.severity === "INFO").length;
928
+ return { model, findings, summary: { errors, warnings, infos, passed: IMPLEMENTED_CHECKS.length } };
929
+ }
930
+
931
+ export {
932
+ discover,
933
+ classifyOrigin,
934
+ emptyModel,
935
+ stripRuleComments,
936
+ extractRules,
937
+ isPublicCondition,
938
+ findPublicAllows,
939
+ IMPLEMENTED_CHECKS,
940
+ runChecks,
941
+ SeveritySchema,
942
+ ConfidenceSchema,
943
+ OperationSchema,
944
+ AccessOriginSchema,
945
+ PositionSchema,
946
+ LocationSchema,
947
+ EvidenceSchema,
948
+ DynamicValueSchema,
949
+ QueryFilterSchema,
950
+ QueryOrderSchema,
951
+ QueryShapeSchema,
952
+ PermissionSchema,
953
+ RoleSchema,
954
+ RuleSchema,
955
+ IndexFieldSchema,
956
+ FirestoreIndexSchema,
957
+ GraphEdgeSchema,
958
+ ProjectModelSchema,
959
+ FindingSchema,
960
+ AuditYamlSchema,
961
+ scan
962
+ };