@justmpm/firebase-audit 0.3.1 → 0.4.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-UMQH7UDI.js → chunk-3SEVYYCX.js} +457 -57
- package/dist/chunk-GOLWQBD5.js +165 -0
- package/dist/{chunk-GUSNXUAT.js → chunk-NJBCSW7E.js} +18 -1
- package/dist/{chunk-CPEIPRCF.js → chunk-PSSMYRRU.js} +49 -10
- package/dist/cli.js +16 -6
- package/dist/{drift-BEXSL2IX.js → drift-KU7HWL7M.js} +1 -1
- package/dist/index.d.ts +102 -11
- package/dist/index.js +46 -4
- package/dist/mcp-cli.d.ts +1 -0
- package/dist/mcp-cli.js +13 -0
- package/dist/mcp.d.ts +19 -0
- package/dist/mcp.js +11 -0
- package/dist/{scan-TLHI777J.js → scan-VREY2FGO.js} +1 -1
- package/dist/{verify-Y7BWAFPL.js → verify-O722ZZBV.js} +1 -1
- package/package.json +5 -4
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// src/scan.ts
|
|
2
2
|
import { readFileSync as readFileSync4, existsSync as existsSync3 } from "fs";
|
|
3
|
+
import { join as join3 } from "path";
|
|
3
4
|
import { parse as parseYaml } from "yaml";
|
|
4
5
|
|
|
5
6
|
// src/discovery.ts
|
|
@@ -54,7 +55,6 @@ var SERVER_HINTS_SEGMENTS = [
|
|
|
54
55
|
"functions",
|
|
55
56
|
"server",
|
|
56
57
|
"backend",
|
|
57
|
-
"api",
|
|
58
58
|
"scripts"
|
|
59
59
|
];
|
|
60
60
|
function classifyOrigin(file, content) {
|
|
@@ -71,7 +71,8 @@ function classifyOrigin(file, content) {
|
|
|
71
71
|
return "SERVER";
|
|
72
72
|
}
|
|
73
73
|
if (rel.includes("src/app/api/")) return "SERVER";
|
|
74
|
-
if (
|
|
74
|
+
if (rel.includes("server/api/")) return "SERVER";
|
|
75
|
+
if (hasSeg(...SERVER_HINTS_SEGMENTS)) return "SERVER";
|
|
75
76
|
if (content.includes("firebase-admin") || content.includes("firebase-functions")) {
|
|
76
77
|
return "CLIENT";
|
|
77
78
|
}
|
|
@@ -102,9 +103,35 @@ function emptyModel(rootDir, d) {
|
|
|
102
103
|
graph: []
|
|
103
104
|
};
|
|
104
105
|
}
|
|
106
|
+
async function enrichWithGraph(rootDir, d) {
|
|
107
|
+
try {
|
|
108
|
+
const ai = await import("@justmpm/ai-tool");
|
|
109
|
+
const res = await ai.map({ cwd: rootDir, format: "json" });
|
|
110
|
+
const files = Array.isArray(res.files) ? res.files : [];
|
|
111
|
+
const code = files.map((f) => String(f.path).replace(/\\/g, "/")).filter((p) => /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(p)).filter((p) => !p.includes("node_modules/") && !/(^|\/)dist\//.test(p)).slice(0, 500);
|
|
112
|
+
const { readFileSync: readSync, existsSync: existsSyncFn, statSync } = await import("fs");
|
|
113
|
+
const { join: joinP } = await import("path");
|
|
114
|
+
for (const rel of code) {
|
|
115
|
+
let content = "";
|
|
116
|
+
try {
|
|
117
|
+
const abs = joinP(rootDir, rel);
|
|
118
|
+
if (!existsSyncFn(abs)) continue;
|
|
119
|
+
if (statSync(abs).size > 100 * 1024) continue;
|
|
120
|
+
content = readSync(abs, "utf-8");
|
|
121
|
+
} catch {
|
|
122
|
+
content = "";
|
|
123
|
+
}
|
|
124
|
+
const origin = classifyOrigin(rel, content);
|
|
125
|
+
if (origin === "CLIENT") d.clientFiles.push(rel);
|
|
126
|
+
else if (origin === "SERVER") d.serverFiles.push(rel);
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
}
|
|
130
|
+
}
|
|
105
131
|
|
|
106
132
|
// src/rules.ts
|
|
107
133
|
import { readFileSync as readFileSync2 } from "fs";
|
|
134
|
+
import { createHash } from "crypto";
|
|
108
135
|
var MATCH_RE = /match\s+(\/[^{\s]*(?:\{[^}]*\}[^{\s]*)*)\s*\{/g;
|
|
109
136
|
var ALLOW_RE = /allow\s+([^;:]+)(?::\s*if\s+([^;]+))?;/g;
|
|
110
137
|
function stripRuleComments(content) {
|
|
@@ -194,19 +221,105 @@ function findBlockEnd(content, openBraceIndex) {
|
|
|
194
221
|
function normalizeTarget(target) {
|
|
195
222
|
return target.toLowerCase().split(",").map((s) => s.trim()).filter(Boolean).sort().join(",");
|
|
196
223
|
}
|
|
197
|
-
function
|
|
198
|
-
const
|
|
199
|
-
const content = stripRuleComments(rawContent);
|
|
200
|
-
const rules = [];
|
|
201
|
-
const matchBlocks = [];
|
|
224
|
+
function parseMatchBlocks(content) {
|
|
225
|
+
const blocks = [];
|
|
202
226
|
const matchRe = new RegExp(MATCH_RE.source, "g");
|
|
203
227
|
let m;
|
|
204
228
|
while ((m = matchRe.exec(content)) !== null) {
|
|
205
229
|
const before = content.slice(0, m.index);
|
|
206
230
|
const line = before.split("\n").length;
|
|
207
231
|
const openBrace = m.index + m[0].length - 1;
|
|
208
|
-
|
|
232
|
+
blocks.push({ path: m[1], line, blockStart: m.index, blockEnd: findBlockEnd(content, openBrace) });
|
|
233
|
+
}
|
|
234
|
+
return blocks;
|
|
235
|
+
}
|
|
236
|
+
function buildFullPath(containingAsc) {
|
|
237
|
+
const parts = [];
|
|
238
|
+
for (const b of containingAsc) {
|
|
239
|
+
const p = b.path.trim();
|
|
240
|
+
if (/^\/databases\/\{[^}]+\}\/documents\/?$/.test(p)) continue;
|
|
241
|
+
const stripped = p.replace(/^\/+|\/+$/g, "");
|
|
242
|
+
if (stripped.length === 0) continue;
|
|
243
|
+
const docIdx = stripped.indexOf("/documents/");
|
|
244
|
+
if (stripped.startsWith("databases/") && docIdx !== -1) {
|
|
245
|
+
const rest = stripped.slice(docIdx + "/documents/".length);
|
|
246
|
+
if (rest.length > 0) parts.push(rest);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
parts.push(stripped);
|
|
250
|
+
}
|
|
251
|
+
if (parts.length === 0) return "/(unknown)";
|
|
252
|
+
return "/" + parts.join("/");
|
|
253
|
+
}
|
|
254
|
+
function parseRuleFunctions(content) {
|
|
255
|
+
const out = /* @__PURE__ */ new Map();
|
|
256
|
+
for (const [name, fn] of parseRuleFunctionsDetailed(content)) {
|
|
257
|
+
out.set(name, fn.body);
|
|
258
|
+
}
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
function parseRuleFunctionsDetailed(content) {
|
|
262
|
+
const out = /* @__PURE__ */ new Map();
|
|
263
|
+
const re = /function\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)\s*\{/g;
|
|
264
|
+
let m;
|
|
265
|
+
while ((m = re.exec(content)) !== null) {
|
|
266
|
+
const openBrace = m.index + m[0].length - 1;
|
|
267
|
+
const end = findBlockEnd(content, openBrace);
|
|
268
|
+
const body = content.slice(openBrace + 1, end - 1);
|
|
269
|
+
const params = m[2].trim() === "" ? [] : m[2].split(",").map((s) => s.trim()).filter((s) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(s));
|
|
270
|
+
out.set(m[1], { params, body });
|
|
271
|
+
}
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
function inlineHelperArgs(body, params, args) {
|
|
275
|
+
let out = body;
|
|
276
|
+
for (let i = 0; i < params.length; i++) {
|
|
277
|
+
const p = params[i];
|
|
278
|
+
const a = (args[i] ?? "").trim();
|
|
279
|
+
if (!p || !a) continue;
|
|
280
|
+
out = out.replace(new RegExp(`\\b${p}\\b`, "g"), () => a);
|
|
281
|
+
}
|
|
282
|
+
return out;
|
|
283
|
+
}
|
|
284
|
+
function inlineHelpersInCondition(condition, detailed) {
|
|
285
|
+
let out = condition;
|
|
286
|
+
for (const [name, fn] of detailed) {
|
|
287
|
+
const re = new RegExp(`\\b${name}\\s*\\(([^()]*)\\)`, "g");
|
|
288
|
+
out = out.replace(re, (_m, argsStr) => {
|
|
289
|
+
const args = argsStr.length === 0 ? [] : String(argsStr).split(",").map((s) => s.trim());
|
|
290
|
+
const inlined = inlineHelperArgs(fn.body, fn.params, args);
|
|
291
|
+
return `(${inlined})`;
|
|
292
|
+
});
|
|
209
293
|
}
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
296
|
+
function resolveHelperCondition(condition, functions) {
|
|
297
|
+
const t = condition.trim().replace(/;$/, "").trim();
|
|
298
|
+
const call = /^([A-Za-z_][A-Za-z0-9_]*)\s*\((.*)\)$/.exec(t);
|
|
299
|
+
if (!call) return condition;
|
|
300
|
+
const body = functions.get(call[1]);
|
|
301
|
+
if (body === void 0) return condition;
|
|
302
|
+
return body;
|
|
303
|
+
}
|
|
304
|
+
function resolveHelperConditionDetailed(condition, detailed) {
|
|
305
|
+
const t = condition.trim().replace(/;$/, "").trim();
|
|
306
|
+
const call = /^([A-Za-z_][A-Za-z0-9_]*)\s*\((.*)\)$/.exec(t);
|
|
307
|
+
if (!call) return condition;
|
|
308
|
+
const fn = detailed.get(call[1]);
|
|
309
|
+
if (!fn) return condition;
|
|
310
|
+
const args = call[2].length === 0 ? [] : call[2].split(",").map((s) => s.trim());
|
|
311
|
+
return inlineHelperArgs(fn.body, fn.params, args);
|
|
312
|
+
}
|
|
313
|
+
function extractRules(rulesFile, relFile) {
|
|
314
|
+
const rawContent = readFileSync2(rulesFile, "utf-8");
|
|
315
|
+
return extractRulesContent(rawContent, relFile);
|
|
316
|
+
}
|
|
317
|
+
function extractRulesContent(rawContent, relFile) {
|
|
318
|
+
const content = stripRuleComments(rawContent);
|
|
319
|
+
const rules = [];
|
|
320
|
+
const functions = parseRuleFunctions(content);
|
|
321
|
+
const detailed = parseRuleFunctionsDetailed(content);
|
|
322
|
+
const matchBlocks = parseMatchBlocks(content);
|
|
210
323
|
const allowRe = new RegExp(ALLOW_RE.source, "g");
|
|
211
324
|
let a;
|
|
212
325
|
let idx = 0;
|
|
@@ -214,26 +327,37 @@ function extractRules(rulesFile, relFile) {
|
|
|
214
327
|
const before = content.slice(0, a.index);
|
|
215
328
|
const line = before.split("\n").length;
|
|
216
329
|
const target = a[1];
|
|
217
|
-
const
|
|
330
|
+
const rawCondition = (a[2] ?? "").trim();
|
|
218
331
|
const unconditional = a[2] === void 0;
|
|
219
|
-
const containing = matchBlocks.filter((b) => b.blockStart <= a.index && a.index < b.blockEnd);
|
|
220
|
-
const
|
|
332
|
+
const containing = matchBlocks.filter((b) => b.blockStart <= a.index && a.index < b.blockEnd).sort((x, y) => x.blockStart - y.blockStart);
|
|
333
|
+
const fullPath = containing.length > 0 ? buildFullPath(containing) : "/(unknown)";
|
|
334
|
+
const resolved = unconditional ? "" : resolveHelperCondition(rawCondition, functions);
|
|
335
|
+
const resolvedDetailed = unconditional ? "" : resolveHelperConditionDetailed(rawCondition, detailed);
|
|
336
|
+
const inlinedBody = unconditional ? "" : inlineHelpersInCondition(rawCondition, detailed);
|
|
337
|
+
const effectiveBody = resolvedDetailed !== rawCondition ? resolvedDetailed : resolved !== rawCondition ? resolved : inlinedBody;
|
|
338
|
+
const condition = rawCondition;
|
|
221
339
|
const authReferences = [];
|
|
222
|
-
|
|
223
|
-
if (
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
340
|
+
const authSource = `${rawCondition} ${resolved} ${effectiveBody} ${inlinedBody}`;
|
|
341
|
+
if (authSource.includes("request.auth")) authReferences.push("request.auth");
|
|
342
|
+
if (authSource.includes("request.auth.uid")) authReferences.push("request.auth.uid");
|
|
343
|
+
const big = `${rawCondition} ${resolved} ${effectiveBody} ${inlinedBody}`;
|
|
344
|
+
const claimReferences = [
|
|
345
|
+
...big.matchAll(/request\.auth\.token\.([A-Za-z0-9_]+)/g),
|
|
346
|
+
...big.matchAll(/request\.auth\.token\.get\(\s*['"]([A-Za-z0-9_]+)['"]/g),
|
|
347
|
+
...big.matchAll(/request\.auth\.token\[\s*['"]([A-Za-z0-9_]+)['"]\s*\]/g)
|
|
348
|
+
].map((x) => x[1]);
|
|
349
|
+
const resourceReferences = [...big.matchAll(/resource\.data\.([A-Za-z0-9_]+)/g)].map(
|
|
228
350
|
(x) => x[1]
|
|
229
351
|
);
|
|
230
352
|
const requestResourceReferences = [
|
|
231
|
-
...
|
|
353
|
+
...big.matchAll(/request\.resource\.data\.([A-Za-z0-9_]+)/g)
|
|
232
354
|
].map((x) => x[1]);
|
|
233
355
|
const { ops, known } = opsFrom(target);
|
|
356
|
+
const isHelperCall = !unconditional && rawCondition !== resolved;
|
|
357
|
+
const hasAuth = authSource.includes("request.auth");
|
|
234
358
|
rules.push({
|
|
235
359
|
id: `rule-${idx++}`,
|
|
236
|
-
path:
|
|
360
|
+
path: fullPath,
|
|
237
361
|
operations: ops,
|
|
238
362
|
conditionPresent: !unconditional && condition.length > 0,
|
|
239
363
|
condition: unconditional ? void 0 : condition,
|
|
@@ -241,7 +365,7 @@ function extractRules(rulesFile, relFile) {
|
|
|
241
365
|
claimReferences,
|
|
242
366
|
resourceReferences,
|
|
243
367
|
requestResourceReferences,
|
|
244
|
-
confidence: known ? "
|
|
368
|
+
confidence: !known || isHelperCall && !hasAuth ? "UNKNOWN" : "CONFIRMED",
|
|
245
369
|
location: { file: relFile, start: { line, column: 0 } }
|
|
246
370
|
});
|
|
247
371
|
}
|
|
@@ -271,6 +395,13 @@ function isPublicCondition(condition) {
|
|
|
271
395
|
if (condition === void 0) return { isPublic: true, confidence: "CONFIRMED" };
|
|
272
396
|
const norm = stripOuterParens(condition);
|
|
273
397
|
const low = norm.toLowerCase().replace(/\s+/g, "");
|
|
398
|
+
if (low === "false") return { isPublic: false, confidence: "CONFIRMED" };
|
|
399
|
+
if (/^!\(.*request\.auth(===|==)null/.test(low)) {
|
|
400
|
+
return { isPublic: false, confidence: "CONFIRMED" };
|
|
401
|
+
}
|
|
402
|
+
if (/^!\(.*request\.auth(!==|!=)null/.test(low)) {
|
|
403
|
+
return { isPublic: true, confidence: "CONFIRMED" };
|
|
404
|
+
}
|
|
274
405
|
if (low.includes("!(request.auth==null)") || low.includes("!(request.auth===null)") || low === "!request.auth==null" || low === "!request.auth===null") {
|
|
275
406
|
return { isPublic: false, confidence: "CONFIRMED" };
|
|
276
407
|
}
|
|
@@ -279,29 +410,55 @@ function isPublicCondition(condition) {
|
|
|
279
410
|
if (low.includes("request.auth==null") || low.includes("request.auth===null")) {
|
|
280
411
|
return { isPublic: true, confidence: "CONFIRMED" };
|
|
281
412
|
}
|
|
413
|
+
if (!low.includes("request.auth")) {
|
|
414
|
+
return { isPublic: true, confidence: "PROBABLE" };
|
|
415
|
+
}
|
|
282
416
|
return { isPublic: false, confidence: "CONFIRMED" };
|
|
283
417
|
}
|
|
418
|
+
function hashShort(text) {
|
|
419
|
+
return createHash("sha256").update(text, "utf-8").digest("hex").slice(0, 8);
|
|
420
|
+
}
|
|
421
|
+
function conditionKeyForFingerprint(condition) {
|
|
422
|
+
if (condition === void 0) return "uncond";
|
|
423
|
+
const norm = stripOuterParens(condition).toLowerCase().replace(/\s+/g, "");
|
|
424
|
+
if (norm === "") return "uncond";
|
|
425
|
+
return hashShort(norm);
|
|
426
|
+
}
|
|
284
427
|
function findPublicAllows(rulesFile) {
|
|
285
428
|
const rawContent = readFileSync2(rulesFile, "utf-8");
|
|
429
|
+
return findPublicAllowsContent(rawContent);
|
|
430
|
+
}
|
|
431
|
+
function helperBodyToCondition(body) {
|
|
432
|
+
const m = /\breturn\b\s*([^;]+);?/.exec(body);
|
|
433
|
+
if (m) return m[1].trim();
|
|
434
|
+
return body;
|
|
435
|
+
}
|
|
436
|
+
function findPublicAllowsContent(rawContent) {
|
|
286
437
|
const content = stripRuleComments(rawContent);
|
|
438
|
+
const functions = parseRuleFunctions(content);
|
|
439
|
+
const detailed = parseRuleFunctionsDetailed(content);
|
|
287
440
|
const out = [];
|
|
288
|
-
const
|
|
289
|
-
const blocks = [];
|
|
290
|
-
let mm0;
|
|
291
|
-
while ((mm0 = matchRe.exec(content)) !== null) {
|
|
292
|
-
const openBrace = mm0.index + mm0[0].length - 1;
|
|
293
|
-
blocks.push({ path: mm0[1], index: mm0.index, end: findBlockEnd(content, openBrace) });
|
|
294
|
-
}
|
|
441
|
+
const parsed = parseMatchBlocks(content);
|
|
295
442
|
const re = new RegExp(ALLOW_RE.source, "g");
|
|
296
443
|
let mm;
|
|
297
444
|
while ((mm = re.exec(content)) !== null) {
|
|
298
445
|
const line = content.slice(0, mm.index).split("\n").length;
|
|
299
446
|
const target = mm[1].trim();
|
|
300
|
-
const
|
|
447
|
+
const single = mm[2] === void 0 ? void 0 : resolveHelperCondition(mm[2], functions);
|
|
448
|
+
const singleD = mm[2] === void 0 ? void 0 : resolveHelperConditionDetailed(mm[2], detailed);
|
|
449
|
+
const inlined = mm[2] === void 0 ? void 0 : inlineHelpersInCondition(mm[2], detailed);
|
|
450
|
+
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];
|
|
451
|
+
if (mm[2] !== void 0 && effective !== mm[2] && !String(effective).includes("request.auth")) {
|
|
452
|
+
const bodyCheck = isPublicCondition(helperBodyToCondition(String(effective)));
|
|
453
|
+
if (!bodyCheck.isPublic) {
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
const check = isPublicCondition(effective);
|
|
301
458
|
if (!check.isPublic) continue;
|
|
302
|
-
const
|
|
303
|
-
const path =
|
|
304
|
-
out.push({ line, target, path, confidence: check.confidence });
|
|
459
|
+
const ascBlocks = parsed.filter((b) => b.blockStart <= mm.index && mm.index < b.blockEnd).sort((x, y) => x.blockStart - y.blockStart);
|
|
460
|
+
const path = ascBlocks.length > 0 ? buildFullPath(ascBlocks) : "/(unknown)";
|
|
461
|
+
out.push({ line, target, path, confidence: check.confidence, conditionKey: conditionKeyForFingerprint(mm[2]) });
|
|
305
462
|
}
|
|
306
463
|
return out;
|
|
307
464
|
}
|
|
@@ -342,8 +499,17 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
342
499
|
const findings = [];
|
|
343
500
|
const relRules = model.firebase.firestore.rulesFile;
|
|
344
501
|
const absRules = relRules ? join2(rootDir, relRules) : null;
|
|
502
|
+
let pubs = [];
|
|
503
|
+
let helperDetailed = /* @__PURE__ */ new Map();
|
|
345
504
|
if (absRules && existsSync2(absRules)) {
|
|
346
|
-
|
|
505
|
+
try {
|
|
506
|
+
const rawAll = readFileSync3(absRules, "utf-8");
|
|
507
|
+
pubs = findPublicAllowsContent(rawAll);
|
|
508
|
+
helperDetailed = parseRuleFunctionsDetailed(rawAll);
|
|
509
|
+
} catch {
|
|
510
|
+
pubs = [];
|
|
511
|
+
}
|
|
512
|
+
for (const pub of pubs) {
|
|
347
513
|
const t = pub.target.toLowerCase();
|
|
348
514
|
const tokens = t.split(",").map((s) => s.trim());
|
|
349
515
|
const isWrite = tokens.some((x) => ["write", "create", "update", "delete"].includes(x));
|
|
@@ -355,7 +521,7 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
355
521
|
"ERROR",
|
|
356
522
|
pub.confidence,
|
|
357
523
|
`Escrita p\xFAblica em ${pub.path} (${pub.target}). Qualquer cliente pode escrever.`,
|
|
358
|
-
`FBA001:${relRules}:${pub.path}:${normalizeTarget(pub.target)}`,
|
|
524
|
+
`FBA001:${relRules}:${pub.path}:${normalizeTarget(pub.target)}:${pub.conditionKey}`,
|
|
359
525
|
{
|
|
360
526
|
file: relRules,
|
|
361
527
|
line: pub.line,
|
|
@@ -373,7 +539,7 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
373
539
|
"WARNING",
|
|
374
540
|
pub.confidence,
|
|
375
541
|
`Leitura p\xFAblica em ${pub.path} (${pub.target}). Pode ser proposital.`,
|
|
376
|
-
`FBA002:${relRules}:${pub.path}:${normalizeTarget(pub.target)}`,
|
|
542
|
+
`FBA002:${relRules}:${pub.path}:${normalizeTarget(pub.target)}:${pub.conditionKey}`,
|
|
377
543
|
{
|
|
378
544
|
file: relRules,
|
|
379
545
|
line: pub.line,
|
|
@@ -473,12 +639,10 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
473
639
|
return [op];
|
|
474
640
|
};
|
|
475
641
|
const publicPaths = /* @__PURE__ */ new Set();
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
publicPaths.add(`${p.path}::${op.toLowerCase()}`);
|
|
481
|
-
}
|
|
642
|
+
for (const p of pubs) {
|
|
643
|
+
const { ops } = opsFrom(p.target);
|
|
644
|
+
for (const op of /* @__PURE__ */ new Set([...ops, ...ops.flatMap(expandOp)])) {
|
|
645
|
+
publicPaths.add(`${p.path}::${op.toLowerCase()}`);
|
|
482
646
|
}
|
|
483
647
|
}
|
|
484
648
|
const permsByResource = /* @__PURE__ */ new Map();
|
|
@@ -499,9 +663,41 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
499
663
|
(op) => publicPaths.has(`${rule.path}::${op.toLowerCase()}`)
|
|
500
664
|
);
|
|
501
665
|
if (isPublicRule) return;
|
|
502
|
-
const
|
|
503
|
-
const
|
|
504
|
-
|
|
666
|
+
const condRaw = rule.condition ?? "";
|
|
667
|
+
const condResolved = resolveHelperConditionDetailed(condRaw, helperDetailed);
|
|
668
|
+
const condInlined = inlineHelpersInCondition(condRaw, helperDetailed);
|
|
669
|
+
const cond = condResolved !== condRaw ? condResolved : condInlined !== condRaw ? condInlined : condRaw;
|
|
670
|
+
const isInvertedAdmin = /admin\s*==\s*false/i.test(cond) || /admin\s*!=\s*true/i.test(cond) || /admin\s*!==\s*true/i.test(cond) || /!\s*request\.auth\.token\.admin\b/i.test(cond) || /!\s*\(\s*request\.auth\.token\.admin\b/i.test(cond) || /!\s*\(\s*.*admin\s*==\s*true/i.test(cond);
|
|
671
|
+
const normClaim = (c) => c.toLowerCase().replace(/[_-]/g, "");
|
|
672
|
+
const isAdminCall = /\bisadmin\s*\(([^)]*)\)/i.exec(condRaw) ?? /\bisadmin\s*\(([^)]*)\)/i.exec(cond);
|
|
673
|
+
let isAdminBodyHasAdmin = false;
|
|
674
|
+
if (isAdminCall) {
|
|
675
|
+
const fnName = (() => {
|
|
676
|
+
const m = /\b([A-Za-z_][A-Za-z0-9_]*)\s*\(/i.exec(isAdminCall[0]);
|
|
677
|
+
return m ? m[1] : "isAdmin";
|
|
678
|
+
})();
|
|
679
|
+
const fn = helperDetailed.get(fnName) ?? helperDetailed.get("isAdmin") ?? helperDetailed.get("isadmin");
|
|
680
|
+
if (fn) {
|
|
681
|
+
const b = `${fn.body}`;
|
|
682
|
+
isAdminBodyHasAdmin = /token\.admin\b/i.test(b) || /token\.get\(\s*['"]admin['"]/i.test(b) || /token\s*\[\s*['"]admin['"]/i.test(b) || /==\s*['"]admin['"]/i.test(b);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
const checksAdminClaim = !isInvertedAdmin && rule.claimReferences.some((c) => {
|
|
686
|
+
const n = normClaim(c);
|
|
687
|
+
return n === "admin" || n === "isadmin";
|
|
688
|
+
});
|
|
689
|
+
const hasTopOr = /\|\|/.test(cond);
|
|
690
|
+
const adminPerDisjunct = hasTopOr ? cond.split("||").every((part) => {
|
|
691
|
+
const p = part;
|
|
692
|
+
return /token\.admin\b/i.test(p) || /token\.get\(\s*['"]admin['"]/i.test(p) || /token\s*\[\s*['"]admin['"]/i.test(p) || /token\.\w+\s*==\s*['"]admin['"]/i.test(p) || /token\.\w+\s+in\s+[^\n;]*['"]admin['"]/i.test(p);
|
|
693
|
+
}) : true;
|
|
694
|
+
const checksAdminRole = !isInvertedAdmin && hasTopOr ? adminPerDisjunct : /token\.\w+\s*==\s*['"]admin['"]/i.test(cond) || /token\s*\[\s*['"]\w+['"]\s*\]\s*==\s*['"]admin['"]/i.test(cond) || /token\.\w+\s+in\s+[^\n;]*['"]admin['"]/i.test(cond) || /token\.get\(\s*['"]admin['"]/i.test(cond) || isAdminBodyHasAdmin;
|
|
695
|
+
if (checksAdminClaim || checksAdminRole) {
|
|
696
|
+
if (hasTopOr && !adminPerDisjunct) {
|
|
697
|
+
} else {
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
505
701
|
const checksAuth = rule.authReferences.length > 0;
|
|
506
702
|
if (!checksAuth) return;
|
|
507
703
|
findings.push(
|
|
@@ -510,7 +706,7 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
510
706
|
"WARNING",
|
|
511
707
|
"PROBABLE",
|
|
512
708
|
`Rule ${rule.path} [${rule.operations.join(", ")}] aceita autenticado sem checar claim de admin, mas contrato restringe ${restricted.map((r) => r.perm).join(", ")} a admin.`,
|
|
513
|
-
`FBA012:${rule.path}:${[...rule.operations].sort().join(",")}:${rule.
|
|
709
|
+
`FBA012:${rule.path}:${[...rule.operations].sort().join(",")}:${conditionKeyForFingerprint(rule.condition)}:${resourceKey}`,
|
|
514
710
|
{
|
|
515
711
|
file: rule.location.file,
|
|
516
712
|
line: rule.location.start.line,
|
|
@@ -558,7 +754,39 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
558
754
|
)
|
|
559
755
|
);
|
|
560
756
|
}
|
|
757
|
+
for (const rule of model.rules) {
|
|
758
|
+
const raw = (rule.condition ?? "").trim();
|
|
759
|
+
const rawResolved = resolveHelperConditionDetailed(raw, helperDetailed);
|
|
760
|
+
const rawInlined = inlineHelpersInCondition(raw, helperDetailed);
|
|
761
|
+
const effectiveRaw = rawResolved !== raw ? rawResolved : rawInlined !== raw ? rawInlined : raw;
|
|
762
|
+
const norm = stripOuterParens(effectiveRaw).toLowerCase().replace(/\s+/g, "");
|
|
763
|
+
const hasBareAuth = norm === "request.auth!=null" || norm === "request.auth!==null" || norm === "request.auth.uid!=null" || norm === "request.auth.uid!==null";
|
|
764
|
+
const hasWeakConjunction = (norm.includes("request.auth!=null") || norm.includes("request.auth!==null") || norm.includes("request.auth.uid!=null") || norm.includes("request.auth.uid!==null")) && !norm.includes("sign_in_provider") && !norm.includes("email_verified") && !norm.includes("request.auth.token");
|
|
765
|
+
const isBareAuth = hasBareAuth || hasWeakConjunction;
|
|
766
|
+
if (isBareAuth && rule.claimReferences.length === 0) {
|
|
767
|
+
findings.push(
|
|
768
|
+
make(
|
|
769
|
+
"AUTH_ANON_ALLOWED",
|
|
770
|
+
"INFO",
|
|
771
|
+
"PROBABLE",
|
|
772
|
+
`Rule ${rule.path} aceita qualquer autenticado incluindo an\xF4nimo (request.auth != null sem claim/provider).`,
|
|
773
|
+
`AUTH_ANON_ALLOWED:${rule.path}:${conditionKeyForFingerprint(rule.condition)}`,
|
|
774
|
+
{
|
|
775
|
+
file: rule.location.file,
|
|
776
|
+
line: rule.location.start.line,
|
|
777
|
+
resource: rule.path,
|
|
778
|
+
evidence: [ev("anon-allowed", rule.condition ?? "auth!=null", "PROBABLE", rule.location.file, rule.location.start.line)],
|
|
779
|
+
fix: "Exigir claim (token.admin/role) ou checar provider diferente de anonymous para dados sens\xEDveis."
|
|
780
|
+
}
|
|
781
|
+
)
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
561
785
|
const adminHits = await collectAdminImports(rootDir);
|
|
786
|
+
const isTestPath = (f) => {
|
|
787
|
+
const n = f.replace(/\\/g, "/");
|
|
788
|
+
return /\.test\.[tj]sx?$/.test(n) || /\.spec\.[tj]sx?$/.test(n) || /(^|\/)__tests__\//.test(n) || /(^|\/)(test|tests|testing)\//.test(n);
|
|
789
|
+
};
|
|
562
790
|
for (const hit of adminHits) {
|
|
563
791
|
let origin = "UNKNOWN";
|
|
564
792
|
try {
|
|
@@ -567,7 +795,7 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
567
795
|
} catch {
|
|
568
796
|
origin = "UNKNOWN";
|
|
569
797
|
}
|
|
570
|
-
if (origin === "CLIENT") {
|
|
798
|
+
if (origin === "CLIENT" && !isTestPath(hit.file)) {
|
|
571
799
|
findings.push(
|
|
572
800
|
make(
|
|
573
801
|
"FBA003",
|
|
@@ -590,7 +818,12 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
590
818
|
async function collectPermissionCalls(rootDir, adapterFn) {
|
|
591
819
|
const literals = [];
|
|
592
820
|
const dynamic = [];
|
|
593
|
-
const
|
|
821
|
+
const ADAPTER_RE = /^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$/;
|
|
822
|
+
const safeAdapter = ADAPTER_RE.test(adapterFn) ? adapterFn : null;
|
|
823
|
+
if (!safeAdapter) {
|
|
824
|
+
return { literals, dynamic };
|
|
825
|
+
}
|
|
826
|
+
const bases = [safeAdapter];
|
|
594
827
|
const patterns = [];
|
|
595
828
|
for (const b of bases) {
|
|
596
829
|
patterns.push(`${b}($PERM)`, `${b}($PERM, $$$ARGS)`);
|
|
@@ -619,9 +852,9 @@ async function collectPermissionCalls(rootDir, adapterFn) {
|
|
|
619
852
|
const isQuoted = /^['"`].*['"`]$/.test(raw);
|
|
620
853
|
if (isQuoted) {
|
|
621
854
|
const perm = raw.replace(/^['"`]|['"`]$/g, "").trim();
|
|
622
|
-
if (perm
|
|
623
|
-
else
|
|
624
|
-
const k = `${m.file}:${line}:${raw}:
|
|
855
|
+
if (/^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)*$/.test(perm)) pushLiteral(m.file, line, perm);
|
|
856
|
+
else {
|
|
857
|
+
const k = `${m.file}:${line}:${raw}:invalid`;
|
|
625
858
|
if (!seen.has(k)) {
|
|
626
859
|
seen.add(k);
|
|
627
860
|
dynamic.push({ file: m.file, line, text: m.text });
|
|
@@ -838,6 +1071,7 @@ var FindingSchema = z.strictObject({
|
|
|
838
1071
|
var PermissionNameSchema = z.string().regex(/^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)*$/);
|
|
839
1072
|
var RoleNameSchema = z.string().regex(/^[a-z][a-z0-9_-]*$/);
|
|
840
1073
|
var AuditYamlSchema = z.strictObject({
|
|
1074
|
+
$schema: z.string().optional(),
|
|
841
1075
|
authorization: z.strictObject({
|
|
842
1076
|
adapter: z.strictObject({ function: z.string().min(1) }).optional(),
|
|
843
1077
|
roles: z.record(RoleNameSchema, z.strictObject({ permissions: z.array(PermissionNameSchema) })),
|
|
@@ -852,12 +1086,80 @@ var AuditYamlSchema = z.strictObject({
|
|
|
852
1086
|
}).optional()
|
|
853
1087
|
})
|
|
854
1088
|
});
|
|
1089
|
+
var RESERVED_CLAIM_KEYS = [
|
|
1090
|
+
"sub",
|
|
1091
|
+
"iat",
|
|
1092
|
+
"iss",
|
|
1093
|
+
"exp",
|
|
1094
|
+
"aud",
|
|
1095
|
+
"auth_time",
|
|
1096
|
+
"acr",
|
|
1097
|
+
"amr",
|
|
1098
|
+
"azp",
|
|
1099
|
+
"cnf",
|
|
1100
|
+
"c_hash",
|
|
1101
|
+
"at_hash",
|
|
1102
|
+
"jti",
|
|
1103
|
+
"nbf",
|
|
1104
|
+
"nonce",
|
|
1105
|
+
"firebase",
|
|
1106
|
+
"user_id"
|
|
1107
|
+
];
|
|
1108
|
+
function validateClaimSamples(samples) {
|
|
1109
|
+
const errors = [];
|
|
1110
|
+
if (!samples) return { ok: true, errors };
|
|
1111
|
+
const enc = new TextEncoder();
|
|
1112
|
+
for (const [role, claims] of Object.entries(samples)) {
|
|
1113
|
+
const bytes = enc.encode(JSON.stringify(claims)).length;
|
|
1114
|
+
if (bytes > 1e3) {
|
|
1115
|
+
errors.push(`samples.${role} com ${bytes} bytes (limite 1000 do crach\xE1)`);
|
|
1116
|
+
}
|
|
1117
|
+
for (const k of Object.keys(claims)) {
|
|
1118
|
+
if (RESERVED_CLAIM_KEYS.includes(k)) {
|
|
1119
|
+
errors.push(`samples.${role}.${k} \xE9 chave reservada OIDC/Firebase`);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
return { ok: errors.length === 0, errors };
|
|
1124
|
+
}
|
|
855
1125
|
|
|
856
1126
|
// src/scan.ts
|
|
857
1127
|
async function scan(rootDir, opts = {}) {
|
|
858
1128
|
const d = discover(rootDir);
|
|
1129
|
+
if (opts.graph === true) {
|
|
1130
|
+
try {
|
|
1131
|
+
await enrichWithGraph(rootDir, d);
|
|
1132
|
+
} catch {
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
859
1135
|
const model = emptyModel(rootDir, d);
|
|
860
1136
|
const findings_pre = [];
|
|
1137
|
+
try {
|
|
1138
|
+
if (d.firebaseJson && existsSync3(d.firebaseJson)) {
|
|
1139
|
+
const raw = JSON.parse(readFileSync4(d.firebaseJson, "utf-8"));
|
|
1140
|
+
for (const [kind, rel] of [
|
|
1141
|
+
["rules", raw.firestore?.rules],
|
|
1142
|
+
["indexes", raw.firestore?.indexes]
|
|
1143
|
+
]) {
|
|
1144
|
+
if (typeof rel === "string" && rel.length > 0) {
|
|
1145
|
+
if (!existsSync3(join3(rootDir, rel))) {
|
|
1146
|
+
findings_pre.push({
|
|
1147
|
+
rule: "RULES_CONFIG_MISMATCH",
|
|
1148
|
+
severity: "WARNING",
|
|
1149
|
+
confidence: "CONFIRMED",
|
|
1150
|
+
message: `firebase.json aponta firestore.${kind} para "${rel}" inexistente \u2014 usando default. Corrija o path.`,
|
|
1151
|
+
fingerprint: `RULES_CONFIG_MISMATCH:${kind}:${rel}`,
|
|
1152
|
+
evidence: [
|
|
1153
|
+
{ id: `ev-config-${kind}`, kind: "config-mismatch", summary: rel, confidence: "CONFIRMED" }
|
|
1154
|
+
],
|
|
1155
|
+
fix: "Ajuste o path em firebase.json ou crie o arquivo."
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
} catch {
|
|
1162
|
+
}
|
|
861
1163
|
if (!d.firestoreRulesFile || !existsSync3(d.firestoreRulesFile)) {
|
|
862
1164
|
findings_pre.push({
|
|
863
1165
|
rule: "RULES_NOT_OBSERVED",
|
|
@@ -890,6 +1192,18 @@ async function scan(rootDir, opts = {}) {
|
|
|
890
1192
|
if (d.firestoreIndexesFile && existsSync3(d.firestoreIndexesFile)) {
|
|
891
1193
|
try {
|
|
892
1194
|
const raw = JSON.parse(readFileSync4(d.firestoreIndexesFile, "utf-8"));
|
|
1195
|
+
if (Array.isArray(raw.fieldOverrides) && raw.fieldOverrides.length > 0) {
|
|
1196
|
+
findings_pre.push({
|
|
1197
|
+
rule: "FIELD_OVERRIDES_PRESENT",
|
|
1198
|
+
severity: "INFO",
|
|
1199
|
+
confidence: "CONFIRMED",
|
|
1200
|
+
message: `firestore.indexes.json com ${raw.fieldOverrides.length} fieldOverrides (isen\xE7\xE3o campo \xFAnico, fora do drift composto).`,
|
|
1201
|
+
fingerprint: "FIELD_OVERRIDES_PRESENT",
|
|
1202
|
+
evidence: [
|
|
1203
|
+
{ id: "ev-overrides", kind: "indexes-overrides", summary: `${raw.fieldOverrides.length} overrides`, confidence: "CONFIRMED" }
|
|
1204
|
+
]
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
893
1207
|
const validModes = ["ASCENDING", "DESCENDING", "ARRAY_CONTAINS", "VECTOR"];
|
|
894
1208
|
const entries = [];
|
|
895
1209
|
let skipped = 0;
|
|
@@ -905,7 +1219,7 @@ async function scan(rootDir, opts = {}) {
|
|
|
905
1219
|
}
|
|
906
1220
|
const fields = [];
|
|
907
1221
|
for (const f of i.fields ?? []) {
|
|
908
|
-
if (typeof f.fieldPath !== "string" || f.fieldPath.length === 0
|
|
1222
|
+
if (typeof f.fieldPath !== "string" || f.fieldPath.length === 0) {
|
|
909
1223
|
skipped += 1;
|
|
910
1224
|
continue;
|
|
911
1225
|
}
|
|
@@ -922,6 +1236,11 @@ async function scan(rootDir, opts = {}) {
|
|
|
922
1236
|
queryScope: i.queryScope === "COLLECTION_GROUP" ? "COLLECTION_GROUP" : "COLLECTION",
|
|
923
1237
|
fields
|
|
924
1238
|
});
|
|
1239
|
+
const last = entries[entries.length - 1];
|
|
1240
|
+
if (last.fields.length === 0) {
|
|
1241
|
+
entries.pop();
|
|
1242
|
+
skipped += 1;
|
|
1243
|
+
}
|
|
925
1244
|
}
|
|
926
1245
|
model.indexes = entries;
|
|
927
1246
|
const totalSkipped = skipped + scopeSkipped;
|
|
@@ -957,7 +1276,24 @@ async function scan(rootDir, opts = {}) {
|
|
|
957
1276
|
const validated = AuditYamlSchema.safeParse(parsed);
|
|
958
1277
|
if (validated.success) {
|
|
959
1278
|
const auth = validated.data.authorization;
|
|
960
|
-
|
|
1279
|
+
const rawAdapter = auth.adapter?.function ?? null;
|
|
1280
|
+
const ADAPTER_RE = /^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$/;
|
|
1281
|
+
if (rawAdapter !== null && !ADAPTER_RE.test(rawAdapter)) {
|
|
1282
|
+
findings_pre.push({
|
|
1283
|
+
rule: "CONTRACT_INVALID",
|
|
1284
|
+
severity: "WARNING",
|
|
1285
|
+
confidence: "CONFIRMED",
|
|
1286
|
+
message: `adapter.function "${rawAdapter}" inv\xE1lido \u2014 use identificador como "rbac.can". Coleta de permiss\xF5es ignorada.`,
|
|
1287
|
+
fingerprint: "CONTRACT_INVALID:adapter",
|
|
1288
|
+
evidence: [
|
|
1289
|
+
{ id: "ev-adapter-invalid", kind: "contract-parse", summary: rawAdapter, confidence: "CONFIRMED" }
|
|
1290
|
+
],
|
|
1291
|
+
fix: "Corrija authorization.adapter.function para identificador v\xE1lido."
|
|
1292
|
+
});
|
|
1293
|
+
model.authorization.adapter = null;
|
|
1294
|
+
} else {
|
|
1295
|
+
model.authorization.adapter = rawAdapter;
|
|
1296
|
+
}
|
|
961
1297
|
model.authorization.permissions = Object.entries(auth.permissions).map(([name, p]) => ({
|
|
962
1298
|
name,
|
|
963
1299
|
resource: p.resource,
|
|
@@ -967,17 +1303,47 @@ async function scan(rootDir, opts = {}) {
|
|
|
967
1303
|
name,
|
|
968
1304
|
permissions: r.permissions
|
|
969
1305
|
}));
|
|
1306
|
+
const samples = auth.claims?.samples;
|
|
1307
|
+
if (samples) {
|
|
1308
|
+
const res = validateClaimSamples(samples);
|
|
1309
|
+
if (!res.ok) {
|
|
1310
|
+
findings_pre.push({
|
|
1311
|
+
rule: "CONTRACT_INVALID",
|
|
1312
|
+
severity: "WARNING",
|
|
1313
|
+
confidence: "CONFIRMED",
|
|
1314
|
+
message: `claims.samples inv\xE1lido: ${res.errors.join("; ")} \u2014 samples ignorados.`,
|
|
1315
|
+
fingerprint: "CONTRACT_INVALID:claims",
|
|
1316
|
+
evidence: [
|
|
1317
|
+
{ id: "ev-claims-invalid", kind: "contract-parse", summary: res.errors.join("; "), confidence: "CONFIRMED" }
|
|
1318
|
+
],
|
|
1319
|
+
fix: "Claims s\xF3 para acesso, 1000 bytes, sem chaves OIDC/Firebase reservadas."
|
|
1320
|
+
});
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
if (auth.claims && !auth.claims.enabled) {
|
|
1324
|
+
findings_pre.push({
|
|
1325
|
+
rule: "CLAIMS_DISABLED",
|
|
1326
|
+
severity: "INFO",
|
|
1327
|
+
confidence: "CONFIRMED",
|
|
1328
|
+
message: "Contrato com claims desabilitadas \u2014 FBA012 usa heur\xEDstica de papel sem crach\xE1.",
|
|
1329
|
+
fingerprint: "CLAIMS_DISABLED",
|
|
1330
|
+
evidence: [
|
|
1331
|
+
{ id: "ev-claims-off", kind: "contract-parse", summary: "claims off", confidence: "CONFIRMED" }
|
|
1332
|
+
]
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
970
1335
|
} else {
|
|
1336
|
+
const details = validated.error.issues.slice(0, 5).map((i) => `${String(i.path.join(".")) || "(raiz)"}: ${i.message}`).join("; ");
|
|
971
1337
|
findings_pre.push({
|
|
972
1338
|
rule: "CONTRACT_INVALID",
|
|
973
1339
|
severity: "WARNING",
|
|
974
1340
|
confidence: "CONFIRMED",
|
|
975
|
-
message: `firebase-audit.yaml
|
|
1341
|
+
message: `firebase-audit.yaml inv\xE1lido (${validated.error.issues.length} problema(s): ${details}) \u2014 contrato ignorado neste scan.`,
|
|
976
1342
|
fingerprint: "CONTRACT_INVALID",
|
|
977
1343
|
evidence: [
|
|
978
|
-
{ id: "ev-contract-invalid", kind: "contract-parse", summary:
|
|
1344
|
+
{ id: "ev-contract-invalid", kind: "contract-parse", summary: details.slice(0, 200), confidence: "CONFIRMED" }
|
|
979
1345
|
],
|
|
980
|
-
fix: "Valide contra templates/firebase-audit.yaml."
|
|
1346
|
+
fix: "Valide contra templates/firebase-audit.yaml (min\xFAsculas, sem chaves extras)."
|
|
981
1347
|
});
|
|
982
1348
|
}
|
|
983
1349
|
} catch (err) {
|
|
@@ -1027,12 +1393,28 @@ async function scan(rootDir, opts = {}) {
|
|
|
1027
1393
|
const errors = findings.filter((f) => f.severity === "ERROR").length;
|
|
1028
1394
|
const warnings = findings.filter((f) => f.severity === "WARNING").length;
|
|
1029
1395
|
const infos = findings.filter((f) => f.severity === "INFO").length;
|
|
1396
|
+
const implemented = new Set(IMPLEMENTED_CHECKS);
|
|
1030
1397
|
const failedChecks = new Set(
|
|
1031
1398
|
findings.filter((f) => f.severity === "ERROR" || f.severity === "WARNING").map((f) => f.rule)
|
|
1032
1399
|
);
|
|
1033
|
-
const
|
|
1034
|
-
const
|
|
1035
|
-
|
|
1400
|
+
const totalChecks = IMPLEMENTED_CHECKS.length;
|
|
1401
|
+
const passedChecks = IMPLEMENTED_CHECKS.filter((c) => !failedChecks.has(c)).length;
|
|
1402
|
+
const infraSkipped = findings.filter((f) => !implemented.has(f.rule)).length;
|
|
1403
|
+
return {
|
|
1404
|
+
model,
|
|
1405
|
+
findings,
|
|
1406
|
+
summary: {
|
|
1407
|
+
errors,
|
|
1408
|
+
warnings,
|
|
1409
|
+
infos,
|
|
1410
|
+
passed: passedChecks,
|
|
1411
|
+
total: totalChecks,
|
|
1412
|
+
passedChecks,
|
|
1413
|
+
totalChecks,
|
|
1414
|
+
infraSkipped,
|
|
1415
|
+
coveragePct: null
|
|
1416
|
+
}
|
|
1417
|
+
};
|
|
1036
1418
|
}
|
|
1037
1419
|
|
|
1038
1420
|
export {
|
|
@@ -1040,11 +1422,27 @@ export {
|
|
|
1040
1422
|
SERVER_HINTS_SEGMENTS,
|
|
1041
1423
|
classifyOrigin,
|
|
1042
1424
|
emptyModel,
|
|
1425
|
+
enrichWithGraph,
|
|
1043
1426
|
stripRuleComments,
|
|
1044
1427
|
opsFrom,
|
|
1428
|
+
normalizeTarget,
|
|
1429
|
+
parseMatchBlocks,
|
|
1430
|
+
buildFullPath,
|
|
1431
|
+
parseRuleFunctions,
|
|
1432
|
+
parseRuleFunctionsDetailed,
|
|
1433
|
+
inlineHelperArgs,
|
|
1434
|
+
inlineHelpersInCondition,
|
|
1435
|
+
resolveHelperCondition,
|
|
1436
|
+
resolveHelperConditionDetailed,
|
|
1045
1437
|
extractRules,
|
|
1438
|
+
extractRulesContent,
|
|
1439
|
+
stripOuterParens,
|
|
1046
1440
|
isPublicCondition,
|
|
1441
|
+
hashShort,
|
|
1442
|
+
conditionKeyForFingerprint,
|
|
1047
1443
|
findPublicAllows,
|
|
1444
|
+
helperBodyToCondition,
|
|
1445
|
+
findPublicAllowsContent,
|
|
1048
1446
|
IMPLEMENTED_CHECKS,
|
|
1049
1447
|
runChecks,
|
|
1050
1448
|
SeveritySchema,
|
|
@@ -1067,5 +1465,7 @@ export {
|
|
|
1067
1465
|
ProjectModelSchema,
|
|
1068
1466
|
FindingSchema,
|
|
1069
1467
|
AuditYamlSchema,
|
|
1468
|
+
RESERVED_CLAIM_KEYS,
|
|
1469
|
+
validateClaimSamples,
|
|
1070
1470
|
scan
|
|
1071
1471
|
};
|