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