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