@justmpm/firebase-audit 0.1.0 → 0.1.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.
- package/dist/{chunk-VKCDUCYE.js → chunk-N72YT24M.js} +332 -150
- package/dist/cli.js +4 -2
- package/dist/index.d.ts +33 -6
- package/dist/index.js +9 -3
- package/package.json +5 -2
- package/skill/SKILL.md +29 -0
- package/templates/firebase-audit.yaml +39 -0
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// src/discovery.ts
|
|
2
2
|
import { existsSync, readFileSync } from "fs";
|
|
3
3
|
import { join, relative } from "path";
|
|
4
|
-
var SERVER_HINTS = ["functions/", "src/app/api/", "server/", "admin-sdk", "firebase-admin"];
|
|
5
4
|
function discover(rootDir) {
|
|
6
5
|
const pick = (name) => {
|
|
7
6
|
const p = join(rootDir, name);
|
|
@@ -12,6 +11,15 @@ function discover(rootDir) {
|
|
|
12
11
|
let databaseId = null;
|
|
13
12
|
let rulesFile = pick("firestore.rules");
|
|
14
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
|
+
}
|
|
15
23
|
if (firebaseJson) {
|
|
16
24
|
try {
|
|
17
25
|
const raw = JSON.parse(readFileSync(firebaseJson, "utf-8"));
|
|
@@ -38,13 +46,29 @@ function discover(rootDir) {
|
|
|
38
46
|
serverFiles: []
|
|
39
47
|
};
|
|
40
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
|
+
}
|
|
41
59
|
function classifyOrigin(file, content) {
|
|
42
60
|
const rel = file.replace(/\\/g, "/");
|
|
43
|
-
if (
|
|
61
|
+
if (rel.includes("app/api/") || rel.includes("/app/") && rel.endsWith("route.ts") || rel.includes("/app/") && rel.endsWith("route.js")) {
|
|
44
62
|
return "SERVER";
|
|
45
63
|
}
|
|
46
|
-
if (
|
|
47
|
-
|
|
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";
|
|
48
72
|
}
|
|
49
73
|
if (rel.includes("src/") || rel.includes("app/") || rel.includes("components/")) {
|
|
50
74
|
return "CLIENT";
|
|
@@ -76,47 +100,92 @@ function emptyModel(rootDir, d) {
|
|
|
76
100
|
|
|
77
101
|
// src/rules.ts
|
|
78
102
|
import { readFileSync as readFileSync2 } from "fs";
|
|
79
|
-
var MATCH_RE = /match\s+(\/[
|
|
80
|
-
var ALLOW_RE = /allow\s+([
|
|
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
|
+
}
|
|
81
142
|
function opsFrom(target) {
|
|
82
143
|
const t = target.trim().toLowerCase();
|
|
83
|
-
if (t === "read") return ["read"];
|
|
84
|
-
if (t === "write") return ["write", "create", "update", "delete"];
|
|
144
|
+
if (t === "read") return { ops: ["read", "get", "list"], known: true };
|
|
145
|
+
if (t === "write") return { ops: ["write", "create", "update", "delete"], known: true };
|
|
85
146
|
const parts = t.split(",").map((s) => s.trim()).filter(Boolean);
|
|
86
147
|
const valid = ["read", "get", "list", "create", "update", "delete", "write"];
|
|
87
148
|
const out = [];
|
|
149
|
+
let known = false;
|
|
88
150
|
for (const p of parts) {
|
|
89
151
|
if (valid.includes(p)) {
|
|
152
|
+
known = true;
|
|
90
153
|
out.push(p);
|
|
91
154
|
if (p === "write") {
|
|
92
155
|
for (const extra of ["create", "update", "delete"]) {
|
|
93
156
|
if (!out.includes(extra)) out.push(extra);
|
|
94
157
|
}
|
|
95
158
|
}
|
|
159
|
+
if (p === "read") {
|
|
160
|
+
for (const extra of ["get", "list"]) {
|
|
161
|
+
if (!out.includes(extra)) out.push(extra);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
96
164
|
}
|
|
97
165
|
}
|
|
98
|
-
return out.length > 0
|
|
166
|
+
return { ops: out, known: out.length > 0 && known };
|
|
99
167
|
}
|
|
100
168
|
function extractRules(rulesFile, relFile) {
|
|
101
|
-
const
|
|
102
|
-
const
|
|
169
|
+
const rawContent = readFileSync2(rulesFile, "utf-8");
|
|
170
|
+
const content = stripRuleComments(rawContent);
|
|
103
171
|
const rules = [];
|
|
104
172
|
const matchBlocks = [];
|
|
173
|
+
const matchRe = new RegExp(MATCH_RE.source, "g");
|
|
105
174
|
let m;
|
|
106
|
-
|
|
107
|
-
while ((m = MATCH_RE.exec(content)) !== null) {
|
|
175
|
+
while ((m = matchRe.exec(content)) !== null) {
|
|
108
176
|
const before = content.slice(0, m.index);
|
|
109
177
|
const line = before.split("\n").length;
|
|
110
178
|
matchBlocks.push({ path: m[1], line, blockStart: m.index });
|
|
111
179
|
}
|
|
112
|
-
ALLOW_RE.
|
|
180
|
+
const allowRe = new RegExp(ALLOW_RE.source, "g");
|
|
113
181
|
let a;
|
|
114
182
|
let idx = 0;
|
|
115
|
-
while ((a =
|
|
183
|
+
while ((a = allowRe.exec(content)) !== null) {
|
|
116
184
|
const before = content.slice(0, a.index);
|
|
117
185
|
const line = before.split("\n").length;
|
|
118
186
|
const target = a[1];
|
|
119
|
-
const condition = a[2].trim();
|
|
187
|
+
const condition = (a[2] ?? "").trim();
|
|
188
|
+
const unconditional = a[2] === void 0;
|
|
120
189
|
const currentMatch = [...matchBlocks].reverse().find((b) => b.blockStart <= a.index);
|
|
121
190
|
const authReferences = [];
|
|
122
191
|
if (condition.includes("request.auth")) authReferences.push("request.auth");
|
|
@@ -130,30 +199,70 @@ function extractRules(rulesFile, relFile) {
|
|
|
130
199
|
const requestResourceReferences = [
|
|
131
200
|
...condition.matchAll(/request\.resource\.data\.([A-Za-z0-9_]+)/g)
|
|
132
201
|
].map((x) => x[1]);
|
|
202
|
+
const { ops, known } = opsFrom(target);
|
|
133
203
|
rules.push({
|
|
134
204
|
id: `rule-${idx++}`,
|
|
135
205
|
path: currentMatch?.path ?? "/(unknown)",
|
|
136
|
-
operations:
|
|
137
|
-
conditionPresent: condition.length > 0
|
|
206
|
+
operations: ops,
|
|
207
|
+
conditionPresent: !unconditional && condition.length > 0,
|
|
138
208
|
authReferences,
|
|
139
209
|
claimReferences,
|
|
140
210
|
resourceReferences,
|
|
141
211
|
requestResourceReferences,
|
|
142
|
-
confidence: "CONFIRMED",
|
|
212
|
+
confidence: known ? "CONFIRMED" : "UNKNOWN",
|
|
143
213
|
location: { file: relFile, start: { line, column: 0 } }
|
|
144
214
|
});
|
|
145
|
-
void lines;
|
|
146
215
|
}
|
|
147
216
|
return rules;
|
|
148
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 === "true") return { isPublic: true, confidence: "CONFIRMED" };
|
|
243
|
+
if (/(^|\|\|)true($|\|\||&&)/.test(low)) return { isPublic: true, confidence: "PROBABLE" };
|
|
244
|
+
if (low.includes("request.auth==null") || low.includes("request.auth===null")) {
|
|
245
|
+
return { isPublic: true, confidence: "CONFIRMED" };
|
|
246
|
+
}
|
|
247
|
+
return { isPublic: false, confidence: "CONFIRMED" };
|
|
248
|
+
}
|
|
149
249
|
function findPublicAllows(rulesFile) {
|
|
150
|
-
const
|
|
250
|
+
const rawContent = readFileSync2(rulesFile, "utf-8");
|
|
251
|
+
const content = stripRuleComments(rawContent);
|
|
151
252
|
const out = [];
|
|
152
|
-
const
|
|
253
|
+
const matchRe = new RegExp(MATCH_RE.source, "g");
|
|
254
|
+
const blocks = [];
|
|
255
|
+
let mm0;
|
|
256
|
+
while ((mm0 = matchRe.exec(content)) !== null) blocks.push({ path: mm0[1], index: mm0.index });
|
|
257
|
+
const re = new RegExp(ALLOW_RE.source, "g");
|
|
153
258
|
let mm;
|
|
154
259
|
while ((mm = re.exec(content)) !== null) {
|
|
155
260
|
const line = content.slice(0, mm.index).split("\n").length;
|
|
156
|
-
|
|
261
|
+
const target = mm[1].trim();
|
|
262
|
+
const check = isPublicCondition(mm[2]);
|
|
263
|
+
if (!check.isPublic) continue;
|
|
264
|
+
const path = [...blocks].reverse().find((b) => b.index <= mm.index)?.path ?? "/(unknown)";
|
|
265
|
+
out.push({ line, target, path, confidence: check.confidence });
|
|
157
266
|
}
|
|
158
267
|
return out;
|
|
159
268
|
}
|
|
@@ -162,15 +271,20 @@ function findPublicAllows(rulesFile) {
|
|
|
162
271
|
import { readFileSync as readFileSync3, existsSync as existsSync2 } from "fs";
|
|
163
272
|
import { join as join2 } from "path";
|
|
164
273
|
import { executeFind } from "@justmpm/supergrep";
|
|
165
|
-
var
|
|
166
|
-
function
|
|
167
|
-
|
|
274
|
+
var IMPLEMENTED_CHECKS = ["FBA001", "FBA002", "FBA003", "FBA004", "FBA009", "FBA010", "FBA011"];
|
|
275
|
+
function createEvidence() {
|
|
276
|
+
let counter = 0;
|
|
168
277
|
return {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
278
|
+
ev: (kind, summary, confidence, file, line) => {
|
|
279
|
+
counter += 1;
|
|
280
|
+
return {
|
|
281
|
+
id: `ev-${counter}`,
|
|
282
|
+
kind,
|
|
283
|
+
summary,
|
|
284
|
+
confidence,
|
|
285
|
+
location: file && line ? { file, start: { line, column: 0 } } : void 0
|
|
286
|
+
};
|
|
287
|
+
}
|
|
174
288
|
};
|
|
175
289
|
}
|
|
176
290
|
function make(rule, severity, confidence, message, fingerprint, opts = {}) {
|
|
@@ -185,27 +299,30 @@ function make(rule, severity, confidence, message, fingerprint, opts = {}) {
|
|
|
185
299
|
};
|
|
186
300
|
}
|
|
187
301
|
async function runChecks(model, rootDir, opts = {}) {
|
|
302
|
+
const { ev } = createEvidence();
|
|
188
303
|
const findings = [];
|
|
189
304
|
const relRules = model.firebase.firestore.rulesFile;
|
|
190
305
|
const absRules = relRules ? join2(rootDir, relRules) : null;
|
|
191
306
|
if (absRules && existsSync2(absRules)) {
|
|
192
307
|
for (const pub of findPublicAllows(absRules)) {
|
|
193
308
|
const t = pub.target.toLowerCase();
|
|
194
|
-
const
|
|
195
|
-
const
|
|
309
|
+
const tokens = t.split(",").map((s) => s.trim());
|
|
310
|
+
const isWrite = tokens.some((x) => ["write", "create", "update", "delete"].includes(x));
|
|
311
|
+
const isRead = tokens.some((x) => ["read", "get", "list"].includes(x));
|
|
196
312
|
if (isWrite) {
|
|
197
313
|
findings.push(
|
|
198
314
|
make(
|
|
199
315
|
"FBA001",
|
|
200
316
|
"ERROR",
|
|
201
|
-
|
|
202
|
-
`
|
|
203
|
-
`FBA001:${relRules}:${pub.target}`,
|
|
317
|
+
pub.confidence,
|
|
318
|
+
`Escrita p\xFAblica em ${pub.path} (${pub.target}). Qualquer cliente pode escrever.`,
|
|
319
|
+
`FBA001:${relRules}:${pub.path}:${pub.target}`,
|
|
204
320
|
{
|
|
205
321
|
file: relRules,
|
|
206
322
|
line: pub.line,
|
|
207
|
-
|
|
208
|
-
|
|
323
|
+
resource: pub.path,
|
|
324
|
+
evidence: [ev("public-allow", `${pub.target} em ${pub.path}`, pub.confidence, relRules, pub.line)],
|
|
325
|
+
fix: "Troque por checagem de auth/claim. Se for proposital, documente no contrato."
|
|
209
326
|
}
|
|
210
327
|
)
|
|
211
328
|
);
|
|
@@ -215,13 +332,14 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
215
332
|
make(
|
|
216
333
|
"FBA002",
|
|
217
334
|
"WARNING",
|
|
218
|
-
|
|
219
|
-
`Leitura p\xFAblica
|
|
220
|
-
`FBA002:${relRules}:${pub.target}`,
|
|
335
|
+
pub.confidence,
|
|
336
|
+
`Leitura p\xFAblica em ${pub.path} (${pub.target}). Pode ser proposital.`,
|
|
337
|
+
`FBA002:${relRules}:${pub.path}:${pub.target}`,
|
|
221
338
|
{
|
|
222
339
|
file: relRules,
|
|
223
340
|
line: pub.line,
|
|
224
|
-
|
|
341
|
+
resource: pub.path,
|
|
342
|
+
evidence: [ev("public-allow", `${pub.target} em ${pub.path}`, pub.confidence, relRules, pub.line)]
|
|
225
343
|
}
|
|
226
344
|
)
|
|
227
345
|
);
|
|
@@ -231,7 +349,7 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
231
349
|
const adapterFn = opts.adapterFn ?? "rbac.can";
|
|
232
350
|
const permissionCalls = await collectPermissionCalls(rootDir, adapterFn);
|
|
233
351
|
const declared = new Set(model.authorization.permissions.map((p) => p.name));
|
|
234
|
-
if (permissionCalls.length === 0) {
|
|
352
|
+
if (permissionCalls.literals.length === 0 && permissionCalls.dynamic.length === 0) {
|
|
235
353
|
findings.push(
|
|
236
354
|
make(
|
|
237
355
|
"FBA009",
|
|
@@ -242,8 +360,19 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
242
360
|
{ evidence: [ev("adapter-missing", adapterFn, "UNKNOWN")] }
|
|
243
361
|
)
|
|
244
362
|
);
|
|
363
|
+
} else if (declared.size === 0) {
|
|
364
|
+
findings.push(
|
|
365
|
+
make(
|
|
366
|
+
"FBA009",
|
|
367
|
+
"INFO",
|
|
368
|
+
"NOT_OBSERVED",
|
|
369
|
+
`Permiss\xF5es usadas sem contrato (${permissionCalls.literals.length} literais). Crie firebase-audit.yaml para ativar FBA004.`,
|
|
370
|
+
`FBA009:no-contract`,
|
|
371
|
+
{ evidence: [ev("permissions-without-contract", `${permissionCalls.literals.length} calls`, "NOT_OBSERVED")] }
|
|
372
|
+
)
|
|
373
|
+
);
|
|
245
374
|
}
|
|
246
|
-
for (const call of permissionCalls) {
|
|
375
|
+
for (const call of permissionCalls.literals) {
|
|
247
376
|
if (!declared.has(call.permission) && declared.size > 0) {
|
|
248
377
|
findings.push(
|
|
249
378
|
make(
|
|
@@ -251,11 +380,11 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
251
380
|
"ERROR",
|
|
252
381
|
"CONFIRMED",
|
|
253
382
|
`Permission "${call.permission}" usada mas n\xE3o declarada no contrato.`,
|
|
254
|
-
`FBA004:${call.file}:${call.permission}`,
|
|
383
|
+
`FBA004:${call.file}:${call.line}:${call.permission}`,
|
|
255
384
|
{
|
|
256
385
|
file: call.file,
|
|
257
386
|
line: call.line,
|
|
258
|
-
resource: call.permission.split(".")[0],
|
|
387
|
+
resource: call.permission.includes(".") ? call.permission.split(".")[0] : call.permission,
|
|
259
388
|
evidence: [ev("permission-call", `${adapterFn}("${call.permission}")`, "CONFIRMED", call.file, call.line)],
|
|
260
389
|
fix: `Declarar "${call.permission}" em firebase-audit.yaml ou corrigir o nome da chamada.`
|
|
261
390
|
}
|
|
@@ -263,6 +392,41 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
263
392
|
);
|
|
264
393
|
}
|
|
265
394
|
}
|
|
395
|
+
for (const role of model.authorization.roles) {
|
|
396
|
+
for (const perm of role.permissions) {
|
|
397
|
+
if (!declared.has(perm)) {
|
|
398
|
+
findings.push(
|
|
399
|
+
make(
|
|
400
|
+
"FBA011",
|
|
401
|
+
"ERROR",
|
|
402
|
+
"CONFIRMED",
|
|
403
|
+
`Role "${role.name}" referencia permission "${perm}" n\xE3o declarada em permissions.`,
|
|
404
|
+
`FBA011:${role.name}:${perm}`,
|
|
405
|
+
{
|
|
406
|
+
evidence: [ev("role-permission", `${role.name} \u2192 ${perm}`, "CONFIRMED")],
|
|
407
|
+
fix: `Declarar "${perm}" em authorization.permissions.`
|
|
408
|
+
}
|
|
409
|
+
)
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
for (const dyn of permissionCalls.dynamic) {
|
|
415
|
+
findings.push(
|
|
416
|
+
make(
|
|
417
|
+
"FBA010",
|
|
418
|
+
"INFO",
|
|
419
|
+
"UNKNOWN",
|
|
420
|
+
`Chamada de permiss\xE3o din\xE2mica em ${dyn.file}:${dyn.line} \u2014 campo n\xE3o resolvido estaticamente.`,
|
|
421
|
+
`FBA010:${dyn.file}:${dyn.line}`,
|
|
422
|
+
{
|
|
423
|
+
file: dyn.file,
|
|
424
|
+
line: dyn.line,
|
|
425
|
+
evidence: [ev("dynamic-permission", dyn.text, "UNKNOWN", dyn.file, dyn.line)]
|
|
426
|
+
}
|
|
427
|
+
)
|
|
428
|
+
);
|
|
429
|
+
}
|
|
266
430
|
const adminHits = await collectAdminImports(rootDir);
|
|
267
431
|
for (const hit of adminHits) {
|
|
268
432
|
let origin = "UNKNOWN";
|
|
@@ -279,7 +443,7 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
279
443
|
"ERROR",
|
|
280
444
|
"PROBABLE",
|
|
281
445
|
`Admin SDK em arquivo de cliente (${hit.file}). Rules n\xE3o valem aqui \u2014 precisa auth da app.`,
|
|
282
|
-
`FBA003:${hit.file}`,
|
|
446
|
+
`FBA003:${hit.file}:${hit.line}`,
|
|
283
447
|
{
|
|
284
448
|
file: hit.file,
|
|
285
449
|
line: hit.line,
|
|
@@ -290,59 +454,73 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
290
454
|
);
|
|
291
455
|
}
|
|
292
456
|
}
|
|
293
|
-
if (model.indexes.length > 0 && model.queries.length === 0) {
|
|
294
|
-
findings.push(
|
|
295
|
-
make(
|
|
296
|
-
"FBA007",
|
|
297
|
-
"INFO",
|
|
298
|
-
"NOT_OBSERVED",
|
|
299
|
-
"\xCDndices locais sem query observada (query-shape V1 ainda parcial). N\xE3o significa unused.",
|
|
300
|
-
"FBA007:indexes",
|
|
301
|
-
{ evidence: [ev("index-not-observed", `${model.indexes.length} \xEDndices`, "NOT_OBSERVED")] }
|
|
302
|
-
)
|
|
303
|
-
);
|
|
304
|
-
}
|
|
305
457
|
return findings;
|
|
306
458
|
}
|
|
307
459
|
async function collectPermissionCalls(rootDir, adapterFn) {
|
|
308
|
-
const
|
|
309
|
-
const
|
|
310
|
-
|
|
460
|
+
const literals = [];
|
|
461
|
+
const dynamic = [];
|
|
462
|
+
if (!adapterFn.includes(".")) {
|
|
463
|
+
return { literals, dynamic };
|
|
464
|
+
}
|
|
465
|
+
const bases = [adapterFn];
|
|
466
|
+
const patterns = [];
|
|
467
|
+
for (const b of bases) {
|
|
468
|
+
patterns.push(`${b}($PERM)`, `${b}($PERM, $$$ARGS)`);
|
|
469
|
+
}
|
|
470
|
+
const seen = /* @__PURE__ */ new Set();
|
|
471
|
+
const pushLiteral = (file, line, permission) => {
|
|
472
|
+
const k = `${file}:${line}:${permission}`;
|
|
473
|
+
if (seen.has(k)) return;
|
|
474
|
+
seen.add(k);
|
|
475
|
+
literals.push({ file, line, permission });
|
|
476
|
+
};
|
|
311
477
|
for (const pattern of patterns) {
|
|
312
478
|
try {
|
|
313
479
|
const { result } = await executeFind({ pattern, path: ".", cwd: rootDir });
|
|
314
480
|
for (const m of result.matches) {
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
481
|
+
const raw = (m.metaVariables["PERM"] ?? "").trim();
|
|
482
|
+
const line = m.line + 1;
|
|
483
|
+
const isQuoted = /^['"].*['"]$/.test(raw);
|
|
484
|
+
if (isQuoted) {
|
|
485
|
+
const perm = raw.replace(/^['"]|['"]$/g, "").trim();
|
|
486
|
+
if (perm.length >= 3) pushLiteral(m.file, line, perm);
|
|
487
|
+
} else if (raw.length > 0) {
|
|
488
|
+
const k = `${m.file}:${line}:${raw}`;
|
|
489
|
+
if (!seen.has(k)) {
|
|
490
|
+
seen.add(k);
|
|
491
|
+
dynamic.push({ file: m.file, line, text: m.text });
|
|
492
|
+
}
|
|
318
493
|
}
|
|
319
494
|
}
|
|
320
495
|
} catch {
|
|
321
496
|
}
|
|
322
497
|
}
|
|
323
|
-
|
|
324
|
-
return out.filter((o) => {
|
|
325
|
-
const k = `${o.file}:${o.line}:${o.permission}`;
|
|
326
|
-
if (seen.has(k)) return false;
|
|
327
|
-
seen.add(k);
|
|
328
|
-
return true;
|
|
329
|
-
});
|
|
498
|
+
return { literals, dynamic };
|
|
330
499
|
}
|
|
331
500
|
async function collectAdminImports(rootDir) {
|
|
332
501
|
const out = [];
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
502
|
+
const patterns = [
|
|
503
|
+
'import { $$$ITEMS } from "$MODULE"',
|
|
504
|
+
'import $DEFAULT from "$MODULE"',
|
|
505
|
+
'import * as $NS from "$MODULE"',
|
|
506
|
+
'const $X = require("$MODULE")',
|
|
507
|
+
'require("$MODULE")'
|
|
508
|
+
];
|
|
509
|
+
const seen = /* @__PURE__ */ new Set();
|
|
510
|
+
for (const pattern of patterns) {
|
|
511
|
+
try {
|
|
512
|
+
const { result } = await executeFind({ pattern, path: ".", cwd: rootDir });
|
|
513
|
+
for (const m of result.matches) {
|
|
514
|
+
const mod = m.metaVariables["MODULE"] ?? "";
|
|
515
|
+
if (mod.includes("firebase-admin") || mod.includes("firebase-functions")) {
|
|
516
|
+
const k = `${m.file}:${m.line}:${m.text}`;
|
|
517
|
+
if (seen.has(k)) continue;
|
|
518
|
+
seen.add(k);
|
|
519
|
+
out.push({ file: m.file, line: m.line + 1, text: m.text });
|
|
520
|
+
}
|
|
343
521
|
}
|
|
522
|
+
} catch {
|
|
344
523
|
}
|
|
345
|
-
} catch {
|
|
346
524
|
}
|
|
347
525
|
return out;
|
|
348
526
|
}
|
|
@@ -354,7 +532,8 @@ var ConfidenceSchema = z.enum([
|
|
|
354
532
|
"CONFIRMED",
|
|
355
533
|
"PROBABLE",
|
|
356
534
|
"NOT_OBSERVED",
|
|
357
|
-
"UNKNOWN"
|
|
535
|
+
"UNKNOWN",
|
|
536
|
+
"CONFLICTING"
|
|
358
537
|
]);
|
|
359
538
|
var OperationSchema = z.enum([
|
|
360
539
|
"read",
|
|
@@ -503,7 +682,7 @@ var FindingSchema = z.strictObject({
|
|
|
503
682
|
fingerprint: z.string().min(1),
|
|
504
683
|
metadata: z.record(z.string(), z.unknown()).optional()
|
|
505
684
|
});
|
|
506
|
-
var PermissionNameSchema = z.string().regex(/^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)
|
|
685
|
+
var PermissionNameSchema = z.string().regex(/^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)*$/);
|
|
507
686
|
var RoleNameSchema = z.string().regex(/^[a-z][a-z0-9_-]*$/);
|
|
508
687
|
var AuditYamlSchema = z.strictObject({
|
|
509
688
|
authorization: z.strictObject({
|
|
@@ -523,10 +702,23 @@ var AuditYamlSchema = z.strictObject({
|
|
|
523
702
|
|
|
524
703
|
// src/scan.ts
|
|
525
704
|
import { readFileSync as readFileSync4, existsSync as existsSync3 } from "fs";
|
|
526
|
-
import {
|
|
705
|
+
import { parse as parseYaml } from "yaml";
|
|
527
706
|
async function scan(rootDir, opts = {}) {
|
|
528
707
|
const d = discover(rootDir);
|
|
529
708
|
const model = emptyModel(rootDir, d);
|
|
709
|
+
const findings_pre = [];
|
|
710
|
+
if (!d.firestoreRulesFile || !existsSync3(d.firestoreRulesFile)) {
|
|
711
|
+
findings_pre.push({
|
|
712
|
+
rule: "RULES_NOT_OBSERVED",
|
|
713
|
+
severity: "INFO",
|
|
714
|
+
confidence: "NOT_OBSERVED",
|
|
715
|
+
message: "firestore.rules n\xE3o encontrado \u2014 FBA001/FBA002 sem cobertura neste scan.",
|
|
716
|
+
fingerprint: "RULES_NOT_OBSERVED",
|
|
717
|
+
evidence: [
|
|
718
|
+
{ id: "ev-no-rules", kind: "rules-missing", summary: "sem rules", confidence: "NOT_OBSERVED" }
|
|
719
|
+
]
|
|
720
|
+
});
|
|
721
|
+
}
|
|
530
722
|
if (d.firestoreRulesFile && existsSync3(d.firestoreRulesFile)) {
|
|
531
723
|
const rel = d.firestoreRulesFile.replace(/\\/g, "/").startsWith(rootDir.replace(/\\/g, "/")) ? d.firestoreRulesFile.slice(rootDir.length + 1).replace(/\\/g, "/") : model.firebase.firestore.rulesFile ?? "firestore.rules";
|
|
532
724
|
model.rules = extractRules(d.firestoreRulesFile, rel);
|
|
@@ -539,16 +731,26 @@ async function scan(rootDir, opts = {}) {
|
|
|
539
731
|
queryScope: i.queryScope === "COLLECTION_GROUP" ? "COLLECTION_GROUP" : "COLLECTION",
|
|
540
732
|
fields: (i.fields ?? []).map((f) => ({
|
|
541
733
|
fieldPath: f.fieldPath ?? "(unknown)",
|
|
542
|
-
mode: f.arrayConfig ? "ARRAY_CONTAINS" : f.order ?? "ASCENDING"
|
|
734
|
+
mode: f.vectorConfig ? "VECTOR" : f.arrayConfig === "CONTAINS" ? "ARRAY_CONTAINS" : f.order ?? "ASCENDING"
|
|
543
735
|
}))
|
|
544
736
|
})) ?? [];
|
|
545
737
|
} catch {
|
|
738
|
+
findings_pre.push({
|
|
739
|
+
rule: "INDEXES_INVALID",
|
|
740
|
+
severity: "WARNING",
|
|
741
|
+
confidence: "CONFIRMED",
|
|
742
|
+
message: "firestore.indexes.json inv\xE1lido \u2014 \xEDndices ignorados neste scan.",
|
|
743
|
+
fingerprint: "INDEXES_INVALID",
|
|
744
|
+
evidence: [
|
|
745
|
+
{ id: "ev-indexes-invalid", kind: "indexes-parse", summary: "JSON inv\xE1lido", confidence: "CONFIRMED" }
|
|
746
|
+
]
|
|
747
|
+
});
|
|
546
748
|
}
|
|
547
749
|
}
|
|
548
750
|
if (d.auditYamlFile && existsSync3(d.auditYamlFile)) {
|
|
549
751
|
try {
|
|
550
752
|
const text = readFileSync4(d.auditYamlFile, "utf-8");
|
|
551
|
-
const parsed =
|
|
753
|
+
const parsed = parseYaml(text);
|
|
552
754
|
const validated = AuditYamlSchema.safeParse(parsed);
|
|
553
755
|
if (validated.success) {
|
|
554
756
|
const auth = validated.data.authorization;
|
|
@@ -562,11 +764,47 @@ async function scan(rootDir, opts = {}) {
|
|
|
562
764
|
name,
|
|
563
765
|
permissions: r.permissions
|
|
564
766
|
}));
|
|
767
|
+
} else {
|
|
768
|
+
findings_pre.push({
|
|
769
|
+
rule: "CONTRACT_INVALID",
|
|
770
|
+
severity: "WARNING",
|
|
771
|
+
confidence: "CONFIRMED",
|
|
772
|
+
message: `firebase-audit.yaml existe mas \xE9 inv\xE1lido: ${validated.error.issues[0]?.message ?? "schema"} \u2014 contrato ignorado neste scan.`,
|
|
773
|
+
fingerprint: "CONTRACT_INVALID",
|
|
774
|
+
evidence: [
|
|
775
|
+
{ id: "ev-contract-invalid", kind: "contract-parse", summary: "YAML inv\xE1lido", confidence: "CONFIRMED" }
|
|
776
|
+
],
|
|
777
|
+
fix: "Valide contra templates/firebase-audit.yaml."
|
|
778
|
+
});
|
|
565
779
|
}
|
|
566
|
-
} catch {
|
|
780
|
+
} catch (err) {
|
|
781
|
+
findings_pre.push({
|
|
782
|
+
rule: "CONTRACT_INVALID",
|
|
783
|
+
severity: "WARNING",
|
|
784
|
+
confidence: "CONFIRMED",
|
|
785
|
+
message: `firebase-audit.yaml n\xE3o p\xF4de ser lido: ${err instanceof Error ? err.message : "erro"} \u2014 contrato ignorado.`,
|
|
786
|
+
fingerprint: "CONTRACT_INVALID",
|
|
787
|
+
evidence: [
|
|
788
|
+
{ id: "ev-contract-unreadable", kind: "contract-parse", summary: "leitura falhou", confidence: "CONFIRMED" }
|
|
789
|
+
]
|
|
790
|
+
});
|
|
567
791
|
}
|
|
568
792
|
}
|
|
569
|
-
const
|
|
793
|
+
const checksFindings = await runChecks(model, rootDir, { adapterFn: opts.adapterFn ?? model.authorization.adapter ?? void 0 });
|
|
794
|
+
const findings = [...findings_pre, ...checksFindings];
|
|
795
|
+
const modelCheck = ProjectModelSchema.safeParse(model);
|
|
796
|
+
if (!modelCheck.success) {
|
|
797
|
+
findings.push({
|
|
798
|
+
rule: "MODEL_INVALID",
|
|
799
|
+
severity: "WARNING",
|
|
800
|
+
confidence: "CONFIRMED",
|
|
801
|
+
message: `ProjectModel inv\xE1lido: ${modelCheck.error.issues[0]?.message ?? "schema"} \u2014 verifique indexes/rules.`,
|
|
802
|
+
fingerprint: "MODEL_INVALID",
|
|
803
|
+
evidence: [
|
|
804
|
+
{ id: "ev-model-invalid", kind: "model-validate", summary: "schema", confidence: "CONFIRMED" }
|
|
805
|
+
]
|
|
806
|
+
});
|
|
807
|
+
}
|
|
570
808
|
if (opts.strict) {
|
|
571
809
|
const hasContract = model.authorization.permissions.length > 0;
|
|
572
810
|
if (!hasContract) {
|
|
@@ -586,74 +824,18 @@ async function scan(rootDir, opts = {}) {
|
|
|
586
824
|
const errors = findings.filter((f) => f.severity === "ERROR").length;
|
|
587
825
|
const warnings = findings.filter((f) => f.severity === "WARNING").length;
|
|
588
826
|
const infos = findings.filter((f) => f.severity === "INFO").length;
|
|
589
|
-
return { model, findings, summary: { errors, warnings, infos, passed:
|
|
590
|
-
}
|
|
591
|
-
function parseSimpleYaml(text) {
|
|
592
|
-
const lines = text.split("\n");
|
|
593
|
-
const root = {};
|
|
594
|
-
const stack = [
|
|
595
|
-
{ indent: -1, obj: root }
|
|
596
|
-
];
|
|
597
|
-
let pendingListKey = null;
|
|
598
|
-
const cur = () => stack[stack.length - 1].obj;
|
|
599
|
-
for (const rawLine of lines) {
|
|
600
|
-
const line = rawLine.replace(/\t/g, " ");
|
|
601
|
-
if (line.trim() === "" || line.trim().startsWith("#")) continue;
|
|
602
|
-
const indent = line.length - line.trimStart().length;
|
|
603
|
-
const trimmed = line.trim();
|
|
604
|
-
if (trimmed.startsWith("- ")) {
|
|
605
|
-
const val = trimmed.slice(2).trim();
|
|
606
|
-
if (pendingListKey && indent >= pendingListKey.indent) {
|
|
607
|
-
pendingListKey.arr.push(val);
|
|
608
|
-
continue;
|
|
609
|
-
}
|
|
610
|
-
continue;
|
|
611
|
-
}
|
|
612
|
-
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
|
|
613
|
-
stack.pop();
|
|
614
|
-
}
|
|
615
|
-
pendingListKey = null;
|
|
616
|
-
const colon = trimmed.indexOf(":");
|
|
617
|
-
if (colon === -1) continue;
|
|
618
|
-
const key = trimmed.slice(0, colon).trim();
|
|
619
|
-
const rest = trimmed.slice(colon + 1).trim();
|
|
620
|
-
if (rest === "") {
|
|
621
|
-
const child = {};
|
|
622
|
-
cur()[key] = child;
|
|
623
|
-
stack.push({ indent, obj: child, key });
|
|
624
|
-
const arr = [];
|
|
625
|
-
child.__maybeList = arr;
|
|
626
|
-
pendingListKey = { indent: indent + 1, arr };
|
|
627
|
-
stack[stack.length - 1].listProbe = arr;
|
|
628
|
-
void join3;
|
|
629
|
-
} else {
|
|
630
|
-
const parent = cur();
|
|
631
|
-
delete parent.__maybeList;
|
|
632
|
-
parent[key] = rest === "true" ? true : rest === "false" ? false : rest;
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
const fix = (o) => {
|
|
636
|
-
if (Array.isArray(o)) return o.map(fix);
|
|
637
|
-
if (o && typeof o === "object") {
|
|
638
|
-
const rec = o;
|
|
639
|
-
if (Array.isArray(rec.__maybeList) && Object.keys(rec).length === 1) {
|
|
640
|
-
return rec.__maybeList;
|
|
641
|
-
}
|
|
642
|
-
delete rec.__maybeList;
|
|
643
|
-
for (const k of Object.keys(rec)) rec[k] = fix(rec[k]);
|
|
644
|
-
return rec;
|
|
645
|
-
}
|
|
646
|
-
return o;
|
|
647
|
-
};
|
|
648
|
-
return fix(root);
|
|
827
|
+
return { model, findings, summary: { errors, warnings, infos, passed: IMPLEMENTED_CHECKS.length } };
|
|
649
828
|
}
|
|
650
829
|
|
|
651
830
|
export {
|
|
652
831
|
discover,
|
|
653
832
|
classifyOrigin,
|
|
654
833
|
emptyModel,
|
|
834
|
+
stripRuleComments,
|
|
655
835
|
extractRules,
|
|
836
|
+
isPublicCondition,
|
|
656
837
|
findPublicAllows,
|
|
838
|
+
IMPLEMENTED_CHECKS,
|
|
657
839
|
runChecks,
|
|
658
840
|
SeveritySchema,
|
|
659
841
|
ConfidenceSchema,
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
scan
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-N72YT24M.js";
|
|
5
5
|
|
|
6
6
|
// src/cli.ts
|
|
7
7
|
import { createRequire } from "module";
|
|
@@ -34,7 +34,9 @@ Uso: firebase-audit scan [--json] [--strict] [--adapter=fn] [--cwd=path]`);
|
|
|
34
34
|
FIREBASE AUDIT v${pkg.version}`);
|
|
35
35
|
console.log("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
36
36
|
if (findings.length === 0) {
|
|
37
|
-
console.log(
|
|
37
|
+
console.log(`
|
|
38
|
+
\u2713 Nenhum problema nos checks V1 implementados (parcial: FBA001-FBA004, FBA009-FBA011).
|
|
39
|
+
`);
|
|
38
40
|
return;
|
|
39
41
|
}
|
|
40
42
|
for (const f of findings) {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as z from 'zod';
|
|
2
|
-
import
|
|
2
|
+
import { z as z$1 } from 'zod';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* firebase-audit — schemas centrais (Zod v4).
|
|
@@ -19,6 +19,7 @@ declare const ConfidenceSchema: z.ZodEnum<{
|
|
|
19
19
|
PROBABLE: "PROBABLE";
|
|
20
20
|
NOT_OBSERVED: "NOT_OBSERVED";
|
|
21
21
|
UNKNOWN: "UNKNOWN";
|
|
22
|
+
CONFLICTING: "CONFLICTING";
|
|
22
23
|
}>;
|
|
23
24
|
declare const OperationSchema: z.ZodEnum<{
|
|
24
25
|
read: "read";
|
|
@@ -69,6 +70,7 @@ declare const EvidenceSchema: z.ZodObject<{
|
|
|
69
70
|
PROBABLE: "PROBABLE";
|
|
70
71
|
NOT_OBSERVED: "NOT_OBSERVED";
|
|
71
72
|
UNKNOWN: "UNKNOWN";
|
|
73
|
+
CONFLICTING: "CONFLICTING";
|
|
72
74
|
}>;
|
|
73
75
|
value: z.ZodOptional<z.ZodUnknown>;
|
|
74
76
|
fingerprint: z.ZodOptional<z.ZodString>;
|
|
@@ -170,6 +172,7 @@ declare const QueryShapeSchema: z.ZodObject<{
|
|
|
170
172
|
PROBABLE: "PROBABLE";
|
|
171
173
|
NOT_OBSERVED: "NOT_OBSERVED";
|
|
172
174
|
UNKNOWN: "UNKNOWN";
|
|
175
|
+
CONFLICTING: "CONFLICTING";
|
|
173
176
|
}>;
|
|
174
177
|
location: z.ZodObject<{
|
|
175
178
|
file: z.ZodString;
|
|
@@ -222,6 +225,7 @@ declare const RuleSchema: z.ZodObject<{
|
|
|
222
225
|
PROBABLE: "PROBABLE";
|
|
223
226
|
NOT_OBSERVED: "NOT_OBSERVED";
|
|
224
227
|
UNKNOWN: "UNKNOWN";
|
|
228
|
+
CONFLICTING: "CONFLICTING";
|
|
225
229
|
}>;
|
|
226
230
|
location: z.ZodObject<{
|
|
227
231
|
file: z.ZodString;
|
|
@@ -288,6 +292,7 @@ declare const GraphEdgeSchema: z.ZodObject<{
|
|
|
288
292
|
PROBABLE: "PROBABLE";
|
|
289
293
|
NOT_OBSERVED: "NOT_OBSERVED";
|
|
290
294
|
UNKNOWN: "UNKNOWN";
|
|
295
|
+
CONFLICTING: "CONFLICTING";
|
|
291
296
|
}>;
|
|
292
297
|
evidenceIds: z.ZodArray<z.ZodString>;
|
|
293
298
|
}, z.core.$strict>;
|
|
@@ -378,6 +383,7 @@ declare const ProjectModelSchema: z.ZodObject<{
|
|
|
378
383
|
PROBABLE: "PROBABLE";
|
|
379
384
|
NOT_OBSERVED: "NOT_OBSERVED";
|
|
380
385
|
UNKNOWN: "UNKNOWN";
|
|
386
|
+
CONFLICTING: "CONFLICTING";
|
|
381
387
|
}>;
|
|
382
388
|
location: z.ZodObject<{
|
|
383
389
|
file: z.ZodString;
|
|
@@ -413,6 +419,7 @@ declare const ProjectModelSchema: z.ZodObject<{
|
|
|
413
419
|
PROBABLE: "PROBABLE";
|
|
414
420
|
NOT_OBSERVED: "NOT_OBSERVED";
|
|
415
421
|
UNKNOWN: "UNKNOWN";
|
|
422
|
+
CONFLICTING: "CONFLICTING";
|
|
416
423
|
}>;
|
|
417
424
|
location: z.ZodObject<{
|
|
418
425
|
file: z.ZodString;
|
|
@@ -473,6 +480,7 @@ declare const ProjectModelSchema: z.ZodObject<{
|
|
|
473
480
|
PROBABLE: "PROBABLE";
|
|
474
481
|
NOT_OBSERVED: "NOT_OBSERVED";
|
|
475
482
|
UNKNOWN: "UNKNOWN";
|
|
483
|
+
CONFLICTING: "CONFLICTING";
|
|
476
484
|
}>;
|
|
477
485
|
value: z.ZodOptional<z.ZodUnknown>;
|
|
478
486
|
fingerprint: z.ZodOptional<z.ZodString>;
|
|
@@ -494,6 +502,7 @@ declare const ProjectModelSchema: z.ZodObject<{
|
|
|
494
502
|
PROBABLE: "PROBABLE";
|
|
495
503
|
NOT_OBSERVED: "NOT_OBSERVED";
|
|
496
504
|
UNKNOWN: "UNKNOWN";
|
|
505
|
+
CONFLICTING: "CONFLICTING";
|
|
497
506
|
}>;
|
|
498
507
|
evidenceIds: z.ZodArray<z.ZodString>;
|
|
499
508
|
}, z.core.$strict>>;
|
|
@@ -510,6 +519,7 @@ declare const FindingSchema: z.ZodObject<{
|
|
|
510
519
|
PROBABLE: "PROBABLE";
|
|
511
520
|
NOT_OBSERVED: "NOT_OBSERVED";
|
|
512
521
|
UNKNOWN: "UNKNOWN";
|
|
522
|
+
CONFLICTING: "CONFLICTING";
|
|
513
523
|
}>;
|
|
514
524
|
file: z.ZodOptional<z.ZodString>;
|
|
515
525
|
line: z.ZodOptional<z.ZodNumber>;
|
|
@@ -534,6 +544,7 @@ declare const FindingSchema: z.ZodObject<{
|
|
|
534
544
|
PROBABLE: "PROBABLE";
|
|
535
545
|
NOT_OBSERVED: "NOT_OBSERVED";
|
|
536
546
|
UNKNOWN: "UNKNOWN";
|
|
547
|
+
CONFLICTING: "CONFLICTING";
|
|
537
548
|
}>;
|
|
538
549
|
value: z.ZodOptional<z.ZodUnknown>;
|
|
539
550
|
fingerprint: z.ZodOptional<z.ZodString>;
|
|
@@ -597,12 +608,15 @@ interface DiscoveryResult {
|
|
|
597
608
|
firestoreIndexesFile: string | null;
|
|
598
609
|
auditYamlFile: string | null;
|
|
599
610
|
projectId: string | null;
|
|
611
|
+
/** V2: databaseId nomeado (sempre null na V1). */
|
|
600
612
|
databaseId: string | null;
|
|
613
|
+
/** V2: preenchido via varredura + ai-tool (sempre [] na V1). */
|
|
601
614
|
clientFiles: string[];
|
|
615
|
+
/** V2: preenchido via varredura + ai-tool (sempre [] na V1). */
|
|
602
616
|
serverFiles: string[];
|
|
603
617
|
}
|
|
604
618
|
declare function discover(rootDir: string): DiscoveryResult;
|
|
605
|
-
/** Heurística
|
|
619
|
+
/** Heurística CLIENT vs SERVER: caminho manda primeiro (grafo/voz do ai-tool na V2). */
|
|
606
620
|
declare function classifyOrigin(file: string, content: string): "CLIENT" | "SERVER" | "UNKNOWN";
|
|
607
621
|
declare function emptyModel(rootDir: string, d: DiscoveryResult): ProjectModel;
|
|
608
622
|
|
|
@@ -611,19 +625,32 @@ declare function emptyModel(rootDir: string, d: DiscoveryResult): ProjectModel;
|
|
|
611
625
|
* Parser leve e intencionalmente incompleto: avaliação real é do Emulator (V3).
|
|
612
626
|
*/
|
|
613
627
|
|
|
614
|
-
type Rule =
|
|
628
|
+
type Rule = z$1.infer<typeof RuleSchema>;
|
|
629
|
+
/** Remove comentários // e block preservando strings simples. */
|
|
630
|
+
declare function stripRuleComments(content: string): string;
|
|
615
631
|
declare function extractRules(rulesFile: string, relFile: string): Rule[];
|
|
616
|
-
/**
|
|
632
|
+
/** Normaliza condição para detectar público: sem if, `true`, `(true)`, `|| true`, `auth == null`. */
|
|
633
|
+
declare function isPublicCondition(condition: string | undefined): {
|
|
634
|
+
isPublic: boolean;
|
|
635
|
+
confidence: "CONFIRMED" | "PROBABLE";
|
|
636
|
+
};
|
|
637
|
+
/** Detecta allows públicos preservando linha, path e confiança para FBA001/FBA002. */
|
|
617
638
|
declare function findPublicAllows(rulesFile: string): {
|
|
618
639
|
line: number;
|
|
619
640
|
target: string;
|
|
641
|
+
path: string;
|
|
642
|
+
confidence: "CONFIRMED" | "PROBABLE";
|
|
620
643
|
}[];
|
|
621
644
|
|
|
622
645
|
/**
|
|
623
|
-
* Checks estáticos V1 (FBA001–
|
|
646
|
+
* Checks estáticos V1 (FBA001–FBA004, FBA009–FBA011, subconjunto intencional).
|
|
624
647
|
* Cada check retorna Findings com fingerprint estável para CI.
|
|
648
|
+
* Nota: FBA001/FBA002 sem linha no fingerprint (estável a formatação);
|
|
649
|
+
* FBA003/FBA004/FBA010 com linha (únicos por ocorrência — duplicatas no mesmo
|
|
650
|
+
* arquivo geram findings distintos).
|
|
625
651
|
*/
|
|
626
652
|
|
|
653
|
+
declare const IMPLEMENTED_CHECKS: readonly ["FBA001", "FBA002", "FBA003", "FBA004", "FBA009", "FBA010", "FBA011"];
|
|
627
654
|
declare function runChecks(model: ProjectModel, rootDir: string, opts?: {
|
|
628
655
|
adapterFn?: string;
|
|
629
656
|
}): Promise<Finding[]>;
|
|
@@ -654,4 +681,4 @@ declare function scan(rootDir: string, opts?: ScanOptions): Promise<ScanResult>;
|
|
|
654
681
|
|
|
655
682
|
declare const VERSION: string;
|
|
656
683
|
|
|
657
|
-
export { AccessOriginSchema, type AuditYaml, AuditYamlSchema, ConfidenceSchema, type DiscoveryResult, DynamicValueSchema, type Evidence, EvidenceSchema, type Finding, FindingSchema, FirestoreIndexSchema, GraphEdgeSchema, IndexFieldSchema, LocationSchema, OperationSchema, PermissionSchema, PositionSchema, type ProjectModel, ProjectModelSchema, QueryFilterSchema, QueryOrderSchema, type QueryShape, QueryShapeSchema, RoleSchema, RuleSchema, SeveritySchema, VERSION, classifyOrigin, discover, emptyModel, extractRules, findPublicAllows, runChecks, scan };
|
|
684
|
+
export { AccessOriginSchema, type AuditYaml, AuditYamlSchema, ConfidenceSchema, type DiscoveryResult, DynamicValueSchema, type Evidence, EvidenceSchema, type Finding, FindingSchema, FirestoreIndexSchema, GraphEdgeSchema, IMPLEMENTED_CHECKS, IndexFieldSchema, LocationSchema, OperationSchema, PermissionSchema, PositionSchema, type ProjectModel, ProjectModelSchema, QueryFilterSchema, QueryOrderSchema, type QueryShape, QueryShapeSchema, RoleSchema, RuleSchema, type ScanOptions, type ScanResult, SeveritySchema, VERSION, classifyOrigin, discover, emptyModel, extractRules, findPublicAllows, isPublicCondition, runChecks, scan, stripRuleComments };
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
FindingSchema,
|
|
8
8
|
FirestoreIndexSchema,
|
|
9
9
|
GraphEdgeSchema,
|
|
10
|
+
IMPLEMENTED_CHECKS,
|
|
10
11
|
IndexFieldSchema,
|
|
11
12
|
LocationSchema,
|
|
12
13
|
OperationSchema,
|
|
@@ -24,9 +25,11 @@ import {
|
|
|
24
25
|
emptyModel,
|
|
25
26
|
extractRules,
|
|
26
27
|
findPublicAllows,
|
|
28
|
+
isPublicCondition,
|
|
27
29
|
runChecks,
|
|
28
|
-
scan
|
|
29
|
-
|
|
30
|
+
scan,
|
|
31
|
+
stripRuleComments
|
|
32
|
+
} from "./chunk-N72YT24M.js";
|
|
30
33
|
|
|
31
34
|
// src/index.ts
|
|
32
35
|
import { createRequire } from "module";
|
|
@@ -42,6 +45,7 @@ export {
|
|
|
42
45
|
FindingSchema,
|
|
43
46
|
FirestoreIndexSchema,
|
|
44
47
|
GraphEdgeSchema,
|
|
48
|
+
IMPLEMENTED_CHECKS,
|
|
45
49
|
IndexFieldSchema,
|
|
46
50
|
LocationSchema,
|
|
47
51
|
OperationSchema,
|
|
@@ -60,6 +64,8 @@ export {
|
|
|
60
64
|
emptyModel,
|
|
61
65
|
extractRules,
|
|
62
66
|
findPublicAllows,
|
|
67
|
+
isPublicCondition,
|
|
63
68
|
runChecks,
|
|
64
|
-
scan
|
|
69
|
+
scan,
|
|
70
|
+
stripRuleComments
|
|
65
71
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@justmpm/firebase-audit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Auditor de consistência e segurança para projetos Firebase: código + Rules + índices + contrato vs comportamento. Static-first, nunca inventa certeza.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"firebase",
|
|
@@ -26,7 +26,9 @@
|
|
|
26
26
|
}
|
|
27
27
|
},
|
|
28
28
|
"files": [
|
|
29
|
-
"dist"
|
|
29
|
+
"dist",
|
|
30
|
+
"templates",
|
|
31
|
+
"skill"
|
|
30
32
|
],
|
|
31
33
|
"scripts": {
|
|
32
34
|
"build": "tsup src/index.ts src/cli.ts --format esm --dts --clean",
|
|
@@ -39,6 +41,7 @@
|
|
|
39
41
|
"@justmpm/ai-tool": "^6.1.1",
|
|
40
42
|
"@justmpm/supergrep": "^0.7.0",
|
|
41
43
|
"@modelcontextprotocol/sdk": "^1.25.3",
|
|
44
|
+
"yaml": "^2.9.1",
|
|
42
45
|
"zod": "^4.1.0"
|
|
43
46
|
},
|
|
44
47
|
"devDependencies": {
|
package/skill/SKILL.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# firebase-audit-skill
|
|
2
|
+
|
|
3
|
+
Skill para agentes usarem o `@justmpm/firebase-audit` do jeito certo.
|
|
4
|
+
|
|
5
|
+
## Sequência obrigatória
|
|
6
|
+
|
|
7
|
+
1. Descobrir projeto (`firebase.json`, `firestore.rules`, `firestore.indexes.json`)
|
|
8
|
+
2. Verificar `firebase-audit.yaml` (se não existe, V1 roda zero-config)
|
|
9
|
+
3. Inspecionar adapter de autorização (ex: `rbac.can`). Sem adapter resolvido → UNKNOWN, nunca adivinhar
|
|
10
|
+
4. Executar `firebase-audit scan` (ou `--strict` com contrato)
|
|
11
|
+
5. Ler findings (CONFIRMED corrige agora; PROBABLE investiga; NOT_OBSERVED/UNKNOWN não afirma)
|
|
12
|
+
6. Corrigir código, nunca o contrato para esconder finding
|
|
13
|
+
7. Reexecutar scan antes de declarar pronto
|
|
14
|
+
|
|
15
|
+
## Travas
|
|
16
|
+
|
|
17
|
+
- `The contract is an assertion of intent. Do not modify the assertion to make implementation violations disappear.`
|
|
18
|
+
- Nunca transformar UNKNOWN em CONFIRMED sem teste no Emulator
|
|
19
|
+
- Nunca dizer "unused" — apenas NOT_OBSERVED
|
|
20
|
+
- Claims: 1000 bytes max, sem chaves OIDC reservadas, só controle de acesso
|
|
21
|
+
|
|
22
|
+
## Emulador (V3)
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
firebase emulators:exec --only firestore "npm test"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Matriz `anon/user/admin x read/create/update/delete` com `@firebase/rules-unit-testing`.
|
|
29
|
+
Cobertura em `:ruleCoverage` (JSON). Emulator não prova índice.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
authorization:
|
|
2
|
+
adapter:
|
|
3
|
+
function: "rbac.can"
|
|
4
|
+
roles:
|
|
5
|
+
admin:
|
|
6
|
+
permissions:
|
|
7
|
+
- users.read
|
|
8
|
+
- users.write
|
|
9
|
+
- orders.read
|
|
10
|
+
- orders.delete
|
|
11
|
+
user:
|
|
12
|
+
permissions:
|
|
13
|
+
- users.read
|
|
14
|
+
- orders.read
|
|
15
|
+
- orders.create
|
|
16
|
+
permissions:
|
|
17
|
+
users.read:
|
|
18
|
+
resource: users
|
|
19
|
+
operation: read
|
|
20
|
+
users.write:
|
|
21
|
+
resource: users
|
|
22
|
+
operation: write
|
|
23
|
+
orders.delete:
|
|
24
|
+
resource: orders
|
|
25
|
+
operation: delete
|
|
26
|
+
orders.read:
|
|
27
|
+
resource: orders
|
|
28
|
+
operation: read
|
|
29
|
+
orders.create:
|
|
30
|
+
resource: orders
|
|
31
|
+
operation: create
|
|
32
|
+
claims:
|
|
33
|
+
enabled: true
|
|
34
|
+
roleKey: role
|
|
35
|
+
samples:
|
|
36
|
+
admin:
|
|
37
|
+
role: admin
|
|
38
|
+
user:
|
|
39
|
+
role: user
|