@justmpm/firebase-audit 0.4.3 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-7EWTBIKJ.js +356 -0
- package/dist/{chunk-3SEVYYCX.js → chunk-GDO2DLNQ.js} +218 -377
- package/dist/{chunk-IXMAWDJH.js → chunk-QACXCMYB.js} +33 -20
- package/dist/{chunk-NJBCSW7E.js → chunk-XZ32YQQC.js} +10 -0
- package/dist/{chunk-PSSMYRRU.js → chunk-Y4VXWSKX.js} +5 -2
- package/dist/cli.js +25 -18
- package/dist/{drift-KU7HWL7M.js → drift-QXALNO2V.js} +1 -1
- package/dist/index.d.ts +30 -20
- 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-O722ZZBV.js → verify-WBVSESHD.js} +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +10 -7
- package/dist/scan-VREY2FGO.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
|
+
};
|