@justmpm/firebase-audit 0.3.0 → 0.4.0
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-N57MCLTZ.js → chunk-5PVQB3ZI.js} +473 -59
- package/dist/{chunk-GUSNXUAT.js → chunk-NJBCSW7E.js} +18 -1
- package/dist/{chunk-CPEIPRCF.js → chunk-TVEZQU7C.js} +47 -10
- package/dist/chunk-YVV7YRF3.js +165 -0
- package/dist/cli.js +7 -6
- package/dist/{drift-BEXSL2IX.js → drift-KU7HWL7M.js} +1 -1
- package/dist/index.d.ts +99 -12
- 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-773ZFQXB.js → scan-6FRUE5BE.js} +1 -1
- package/dist/{verify-Y7BWAFPL.js → verify-R6LI2KNZ.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,21 +55,24 @@ 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) {
|
|
61
61
|
const rel = file.replace(/\\/g, "/");
|
|
62
62
|
const segs = rel.split("/");
|
|
63
63
|
const hasSeg = (...names) => names.some((n) => segs.includes(n));
|
|
64
|
-
if (
|
|
64
|
+
if (hasSeg("pages", "api") && rel.includes("pages/api/")) {
|
|
65
|
+
return "SERVER";
|
|
66
|
+
}
|
|
67
|
+
if (hasSeg("app", "api") && rel.includes("app/api/")) {
|
|
65
68
|
return "SERVER";
|
|
66
69
|
}
|
|
67
70
|
if (segs.includes("app") && rel.endsWith("route.ts") || segs.includes("app") && rel.endsWith("route.js")) {
|
|
68
71
|
return "SERVER";
|
|
69
72
|
}
|
|
70
73
|
if (rel.includes("src/app/api/")) return "SERVER";
|
|
71
|
-
if (
|
|
74
|
+
if (rel.includes("server/api/")) return "SERVER";
|
|
75
|
+
if (hasSeg(...SERVER_HINTS_SEGMENTS)) return "SERVER";
|
|
72
76
|
if (content.includes("firebase-admin") || content.includes("firebase-functions")) {
|
|
73
77
|
return "CLIENT";
|
|
74
78
|
}
|
|
@@ -99,9 +103,35 @@ function emptyModel(rootDir, d) {
|
|
|
99
103
|
graph: []
|
|
100
104
|
};
|
|
101
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
|
+
}
|
|
102
131
|
|
|
103
132
|
// src/rules.ts
|
|
104
133
|
import { readFileSync as readFileSync2 } from "fs";
|
|
134
|
+
import { createHash } from "crypto";
|
|
105
135
|
var MATCH_RE = /match\s+(\/[^{\s]*(?:\{[^}]*\}[^{\s]*)*)\s*\{/g;
|
|
106
136
|
var ALLOW_RE = /allow\s+([^;:]+)(?::\s*if\s+([^;]+))?;/g;
|
|
107
137
|
function stripRuleComments(content) {
|
|
@@ -191,19 +221,105 @@ function findBlockEnd(content, openBraceIndex) {
|
|
|
191
221
|
function normalizeTarget(target) {
|
|
192
222
|
return target.toLowerCase().split(",").map((s) => s.trim()).filter(Boolean).sort().join(",");
|
|
193
223
|
}
|
|
194
|
-
function
|
|
195
|
-
const
|
|
196
|
-
const content = stripRuleComments(rawContent);
|
|
197
|
-
const rules = [];
|
|
198
|
-
const matchBlocks = [];
|
|
224
|
+
function parseMatchBlocks(content) {
|
|
225
|
+
const blocks = [];
|
|
199
226
|
const matchRe = new RegExp(MATCH_RE.source, "g");
|
|
200
227
|
let m;
|
|
201
228
|
while ((m = matchRe.exec(content)) !== null) {
|
|
202
229
|
const before = content.slice(0, m.index);
|
|
203
230
|
const line = before.split("\n").length;
|
|
204
231
|
const openBrace = m.index + m[0].length - 1;
|
|
205
|
-
|
|
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);
|
|
206
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
|
+
});
|
|
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);
|
|
207
323
|
const allowRe = new RegExp(ALLOW_RE.source, "g");
|
|
208
324
|
let a;
|
|
209
325
|
let idx = 0;
|
|
@@ -211,26 +327,37 @@ function extractRules(rulesFile, relFile) {
|
|
|
211
327
|
const before = content.slice(0, a.index);
|
|
212
328
|
const line = before.split("\n").length;
|
|
213
329
|
const target = a[1];
|
|
214
|
-
const
|
|
330
|
+
const rawCondition = (a[2] ?? "").trim();
|
|
215
331
|
const unconditional = a[2] === void 0;
|
|
216
|
-
const containing = matchBlocks.filter((b) => b.blockStart <= a.index && a.index < b.blockEnd);
|
|
217
|
-
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;
|
|
218
339
|
const authReferences = [];
|
|
219
|
-
|
|
220
|
-
if (
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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(
|
|
225
350
|
(x) => x[1]
|
|
226
351
|
);
|
|
227
352
|
const requestResourceReferences = [
|
|
228
|
-
...
|
|
353
|
+
...big.matchAll(/request\.resource\.data\.([A-Za-z0-9_]+)/g)
|
|
229
354
|
].map((x) => x[1]);
|
|
230
355
|
const { ops, known } = opsFrom(target);
|
|
356
|
+
const isHelperCall = !unconditional && rawCondition !== resolved;
|
|
357
|
+
const hasAuth = authSource.includes("request.auth");
|
|
231
358
|
rules.push({
|
|
232
359
|
id: `rule-${idx++}`,
|
|
233
|
-
path:
|
|
360
|
+
path: fullPath,
|
|
234
361
|
operations: ops,
|
|
235
362
|
conditionPresent: !unconditional && condition.length > 0,
|
|
236
363
|
condition: unconditional ? void 0 : condition,
|
|
@@ -238,7 +365,7 @@ function extractRules(rulesFile, relFile) {
|
|
|
238
365
|
claimReferences,
|
|
239
366
|
resourceReferences,
|
|
240
367
|
requestResourceReferences,
|
|
241
|
-
confidence: known ? "
|
|
368
|
+
confidence: !known || isHelperCall && !hasAuth ? "UNKNOWN" : "CONFIRMED",
|
|
242
369
|
location: { file: relFile, start: { line, column: 0 } }
|
|
243
370
|
});
|
|
244
371
|
}
|
|
@@ -268,6 +395,13 @@ function isPublicCondition(condition) {
|
|
|
268
395
|
if (condition === void 0) return { isPublic: true, confidence: "CONFIRMED" };
|
|
269
396
|
const norm = stripOuterParens(condition);
|
|
270
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
|
+
}
|
|
271
405
|
if (low.includes("!(request.auth==null)") || low.includes("!(request.auth===null)") || low === "!request.auth==null" || low === "!request.auth===null") {
|
|
272
406
|
return { isPublic: false, confidence: "CONFIRMED" };
|
|
273
407
|
}
|
|
@@ -276,29 +410,55 @@ function isPublicCondition(condition) {
|
|
|
276
410
|
if (low.includes("request.auth==null") || low.includes("request.auth===null")) {
|
|
277
411
|
return { isPublic: true, confidence: "CONFIRMED" };
|
|
278
412
|
}
|
|
413
|
+
if (!low.includes("request.auth")) {
|
|
414
|
+
return { isPublic: true, confidence: "PROBABLE" };
|
|
415
|
+
}
|
|
279
416
|
return { isPublic: false, confidence: "CONFIRMED" };
|
|
280
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
|
+
}
|
|
281
427
|
function findPublicAllows(rulesFile) {
|
|
282
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) {
|
|
283
437
|
const content = stripRuleComments(rawContent);
|
|
438
|
+
const functions = parseRuleFunctions(content);
|
|
439
|
+
const detailed = parseRuleFunctionsDetailed(content);
|
|
284
440
|
const out = [];
|
|
285
|
-
const
|
|
286
|
-
const blocks = [];
|
|
287
|
-
let mm0;
|
|
288
|
-
while ((mm0 = matchRe.exec(content)) !== null) {
|
|
289
|
-
const openBrace = mm0.index + mm0[0].length - 1;
|
|
290
|
-
blocks.push({ path: mm0[1], index: mm0.index, end: findBlockEnd(content, openBrace) });
|
|
291
|
-
}
|
|
441
|
+
const parsed = parseMatchBlocks(content);
|
|
292
442
|
const re = new RegExp(ALLOW_RE.source, "g");
|
|
293
443
|
let mm;
|
|
294
444
|
while ((mm = re.exec(content)) !== null) {
|
|
295
445
|
const line = content.slice(0, mm.index).split("\n").length;
|
|
296
446
|
const target = mm[1].trim();
|
|
297
|
-
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);
|
|
298
458
|
if (!check.isPublic) continue;
|
|
299
|
-
const
|
|
300
|
-
const path =
|
|
301
|
-
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]) });
|
|
302
462
|
}
|
|
303
463
|
return out;
|
|
304
464
|
}
|
|
@@ -339,8 +499,17 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
339
499
|
const findings = [];
|
|
340
500
|
const relRules = model.firebase.firestore.rulesFile;
|
|
341
501
|
const absRules = relRules ? join2(rootDir, relRules) : null;
|
|
502
|
+
let pubs = [];
|
|
503
|
+
let helperDetailed = /* @__PURE__ */ new Map();
|
|
342
504
|
if (absRules && existsSync2(absRules)) {
|
|
343
|
-
|
|
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) {
|
|
344
513
|
const t = pub.target.toLowerCase();
|
|
345
514
|
const tokens = t.split(",").map((s) => s.trim());
|
|
346
515
|
const isWrite = tokens.some((x) => ["write", "create", "update", "delete"].includes(x));
|
|
@@ -352,7 +521,7 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
352
521
|
"ERROR",
|
|
353
522
|
pub.confidence,
|
|
354
523
|
`Escrita p\xFAblica em ${pub.path} (${pub.target}). Qualquer cliente pode escrever.`,
|
|
355
|
-
`FBA001:${relRules}:${pub.path}:${normalizeTarget(pub.target)}`,
|
|
524
|
+
`FBA001:${relRules}:${pub.path}:${normalizeTarget(pub.target)}:${pub.conditionKey}`,
|
|
356
525
|
{
|
|
357
526
|
file: relRules,
|
|
358
527
|
line: pub.line,
|
|
@@ -370,7 +539,7 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
370
539
|
"WARNING",
|
|
371
540
|
pub.confidence,
|
|
372
541
|
`Leitura p\xFAblica em ${pub.path} (${pub.target}). Pode ser proposital.`,
|
|
373
|
-
`FBA002:${relRules}:${pub.path}:${normalizeTarget(pub.target)}`,
|
|
542
|
+
`FBA002:${relRules}:${pub.path}:${normalizeTarget(pub.target)}:${pub.conditionKey}`,
|
|
374
543
|
{
|
|
375
544
|
file: relRules,
|
|
376
545
|
line: pub.line,
|
|
@@ -470,12 +639,10 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
470
639
|
return [op];
|
|
471
640
|
};
|
|
472
641
|
const publicPaths = /* @__PURE__ */ new Set();
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
publicPaths.add(`${p.path}::${op.toLowerCase()}`);
|
|
478
|
-
}
|
|
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()}`);
|
|
479
646
|
}
|
|
480
647
|
}
|
|
481
648
|
const permsByResource = /* @__PURE__ */ new Map();
|
|
@@ -496,9 +663,41 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
496
663
|
(op) => publicPaths.has(`${rule.path}::${op.toLowerCase()}`)
|
|
497
664
|
);
|
|
498
665
|
if (isPublicRule) return;
|
|
499
|
-
const
|
|
500
|
-
const
|
|
501
|
-
|
|
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
|
+
}
|
|
502
701
|
const checksAuth = rule.authReferences.length > 0;
|
|
503
702
|
if (!checksAuth) return;
|
|
504
703
|
findings.push(
|
|
@@ -507,7 +706,7 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
507
706
|
"WARNING",
|
|
508
707
|
"PROBABLE",
|
|
509
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.`,
|
|
510
|
-
`FBA012:${rule.path}:${[...rule.operations].sort().join(",")}:${rule.
|
|
709
|
+
`FBA012:${rule.path}:${[...rule.operations].sort().join(",")}:${conditionKeyForFingerprint(rule.condition)}:${resourceKey}`,
|
|
511
710
|
{
|
|
512
711
|
file: rule.location.file,
|
|
513
712
|
line: rule.location.start.line,
|
|
@@ -555,6 +754,34 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
555
754
|
)
|
|
556
755
|
);
|
|
557
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
|
+
}
|
|
558
785
|
const adminHits = await collectAdminImports(rootDir);
|
|
559
786
|
for (const hit of adminHits) {
|
|
560
787
|
let origin = "UNKNOWN";
|
|
@@ -587,7 +814,12 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
587
814
|
async function collectPermissionCalls(rootDir, adapterFn) {
|
|
588
815
|
const literals = [];
|
|
589
816
|
const dynamic = [];
|
|
590
|
-
const
|
|
817
|
+
const ADAPTER_RE = /^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$/;
|
|
818
|
+
const safeAdapter = ADAPTER_RE.test(adapterFn) ? adapterFn : null;
|
|
819
|
+
if (!safeAdapter) {
|
|
820
|
+
return { literals, dynamic };
|
|
821
|
+
}
|
|
822
|
+
const bases = [safeAdapter];
|
|
591
823
|
const patterns = [];
|
|
592
824
|
for (const b of bases) {
|
|
593
825
|
patterns.push(`${b}($PERM)`, `${b}($PERM, $$$ARGS)`);
|
|
@@ -616,9 +848,9 @@ async function collectPermissionCalls(rootDir, adapterFn) {
|
|
|
616
848
|
const isQuoted = /^['"`].*['"`]$/.test(raw);
|
|
617
849
|
if (isQuoted) {
|
|
618
850
|
const perm = raw.replace(/^['"`]|['"`]$/g, "").trim();
|
|
619
|
-
if (perm
|
|
620
|
-
else
|
|
621
|
-
const k = `${m.file}:${line}:${raw}:
|
|
851
|
+
if (/^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)*$/.test(perm)) pushLiteral(m.file, line, perm);
|
|
852
|
+
else {
|
|
853
|
+
const k = `${m.file}:${line}:${raw}:invalid`;
|
|
622
854
|
if (!seen.has(k)) {
|
|
623
855
|
seen.add(k);
|
|
624
856
|
dynamic.push({ file: m.file, line, text: m.text });
|
|
@@ -641,10 +873,19 @@ async function collectAdminImports(rootDir) {
|
|
|
641
873
|
const out = [];
|
|
642
874
|
const patterns = [
|
|
643
875
|
'import { $$$ITEMS } from "$MODULE"',
|
|
876
|
+
"import { $$$ITEMS } from '$MODULE'",
|
|
644
877
|
'import $DEFAULT from "$MODULE"',
|
|
878
|
+
"import $DEFAULT from '$MODULE'",
|
|
645
879
|
'import * as $NS from "$MODULE"',
|
|
880
|
+
"import * as $NS from '$MODULE'",
|
|
881
|
+
'import "$MODULE"',
|
|
882
|
+
"import '$MODULE'",
|
|
646
883
|
'const $X = require("$MODULE")',
|
|
647
|
-
|
|
884
|
+
"const $X = require('$MODULE')",
|
|
885
|
+
'require("$MODULE")',
|
|
886
|
+
"require('$MODULE')",
|
|
887
|
+
'import("$MODULE")',
|
|
888
|
+
"import('$MODULE')"
|
|
648
889
|
];
|
|
649
890
|
const seen = /* @__PURE__ */ new Set();
|
|
650
891
|
for (const pattern of patterns) {
|
|
@@ -825,7 +1066,8 @@ var FindingSchema = z.strictObject({
|
|
|
825
1066
|
});
|
|
826
1067
|
var PermissionNameSchema = z.string().regex(/^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)*$/);
|
|
827
1068
|
var RoleNameSchema = z.string().regex(/^[a-z][a-z0-9_-]*$/);
|
|
828
|
-
var AuditYamlSchema = z.
|
|
1069
|
+
var AuditYamlSchema = z.strictObject({
|
|
1070
|
+
$schema: z.string().optional(),
|
|
829
1071
|
authorization: z.strictObject({
|
|
830
1072
|
adapter: z.strictObject({ function: z.string().min(1) }).optional(),
|
|
831
1073
|
roles: z.record(RoleNameSchema, z.strictObject({ permissions: z.array(PermissionNameSchema) })),
|
|
@@ -840,12 +1082,80 @@ var AuditYamlSchema = z.object({
|
|
|
840
1082
|
}).optional()
|
|
841
1083
|
})
|
|
842
1084
|
});
|
|
1085
|
+
var RESERVED_CLAIM_KEYS = [
|
|
1086
|
+
"sub",
|
|
1087
|
+
"iat",
|
|
1088
|
+
"iss",
|
|
1089
|
+
"exp",
|
|
1090
|
+
"aud",
|
|
1091
|
+
"auth_time",
|
|
1092
|
+
"acr",
|
|
1093
|
+
"amr",
|
|
1094
|
+
"azp",
|
|
1095
|
+
"cnf",
|
|
1096
|
+
"c_hash",
|
|
1097
|
+
"at_hash",
|
|
1098
|
+
"jti",
|
|
1099
|
+
"nbf",
|
|
1100
|
+
"nonce",
|
|
1101
|
+
"firebase",
|
|
1102
|
+
"user_id"
|
|
1103
|
+
];
|
|
1104
|
+
function validateClaimSamples(samples) {
|
|
1105
|
+
const errors = [];
|
|
1106
|
+
if (!samples) return { ok: true, errors };
|
|
1107
|
+
const enc = new TextEncoder();
|
|
1108
|
+
for (const [role, claims] of Object.entries(samples)) {
|
|
1109
|
+
const bytes = enc.encode(JSON.stringify(claims)).length;
|
|
1110
|
+
if (bytes > 1e3) {
|
|
1111
|
+
errors.push(`samples.${role} com ${bytes} bytes (limite 1000 do crach\xE1)`);
|
|
1112
|
+
}
|
|
1113
|
+
for (const k of Object.keys(claims)) {
|
|
1114
|
+
if (RESERVED_CLAIM_KEYS.includes(k)) {
|
|
1115
|
+
errors.push(`samples.${role}.${k} \xE9 chave reservada OIDC/Firebase`);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
return { ok: errors.length === 0, errors };
|
|
1120
|
+
}
|
|
843
1121
|
|
|
844
1122
|
// src/scan.ts
|
|
845
1123
|
async function scan(rootDir, opts = {}) {
|
|
846
1124
|
const d = discover(rootDir);
|
|
1125
|
+
if (opts.graph === true) {
|
|
1126
|
+
try {
|
|
1127
|
+
await enrichWithGraph(rootDir, d);
|
|
1128
|
+
} catch {
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
847
1131
|
const model = emptyModel(rootDir, d);
|
|
848
1132
|
const findings_pre = [];
|
|
1133
|
+
try {
|
|
1134
|
+
if (d.firebaseJson && existsSync3(d.firebaseJson)) {
|
|
1135
|
+
const raw = JSON.parse(readFileSync4(d.firebaseJson, "utf-8"));
|
|
1136
|
+
for (const [kind, rel] of [
|
|
1137
|
+
["rules", raw.firestore?.rules],
|
|
1138
|
+
["indexes", raw.firestore?.indexes]
|
|
1139
|
+
]) {
|
|
1140
|
+
if (typeof rel === "string" && rel.length > 0) {
|
|
1141
|
+
if (!existsSync3(join3(rootDir, rel))) {
|
|
1142
|
+
findings_pre.push({
|
|
1143
|
+
rule: "RULES_CONFIG_MISMATCH",
|
|
1144
|
+
severity: "WARNING",
|
|
1145
|
+
confidence: "CONFIRMED",
|
|
1146
|
+
message: `firebase.json aponta firestore.${kind} para "${rel}" inexistente \u2014 usando default. Corrija o path.`,
|
|
1147
|
+
fingerprint: `RULES_CONFIG_MISMATCH:${kind}:${rel}`,
|
|
1148
|
+
evidence: [
|
|
1149
|
+
{ id: `ev-config-${kind}`, kind: "config-mismatch", summary: rel, confidence: "CONFIRMED" }
|
|
1150
|
+
],
|
|
1151
|
+
fix: "Ajuste o path em firebase.json ou crie o arquivo."
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
} catch {
|
|
1158
|
+
}
|
|
849
1159
|
if (!d.firestoreRulesFile || !existsSync3(d.firestoreRulesFile)) {
|
|
850
1160
|
findings_pre.push({
|
|
851
1161
|
rule: "RULES_NOT_OBSERVED",
|
|
@@ -878,21 +1188,39 @@ async function scan(rootDir, opts = {}) {
|
|
|
878
1188
|
if (d.firestoreIndexesFile && existsSync3(d.firestoreIndexesFile)) {
|
|
879
1189
|
try {
|
|
880
1190
|
const raw = JSON.parse(readFileSync4(d.firestoreIndexesFile, "utf-8"));
|
|
1191
|
+
if (Array.isArray(raw.fieldOverrides) && raw.fieldOverrides.length > 0) {
|
|
1192
|
+
findings_pre.push({
|
|
1193
|
+
rule: "FIELD_OVERRIDES_PRESENT",
|
|
1194
|
+
severity: "INFO",
|
|
1195
|
+
confidence: "CONFIRMED",
|
|
1196
|
+
message: `firestore.indexes.json com ${raw.fieldOverrides.length} fieldOverrides (isen\xE7\xE3o campo \xFAnico, fora do drift composto).`,
|
|
1197
|
+
fingerprint: "FIELD_OVERRIDES_PRESENT",
|
|
1198
|
+
evidence: [
|
|
1199
|
+
{ id: "ev-overrides", kind: "indexes-overrides", summary: `${raw.fieldOverrides.length} overrides`, confidence: "CONFIRMED" }
|
|
1200
|
+
]
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
881
1203
|
const validModes = ["ASCENDING", "DESCENDING", "ARRAY_CONTAINS", "VECTOR"];
|
|
882
1204
|
const entries = [];
|
|
883
1205
|
let skipped = 0;
|
|
1206
|
+
let scopeSkipped = 0;
|
|
884
1207
|
for (const i of raw.indexes ?? []) {
|
|
885
1208
|
if (typeof i.collectionGroup !== "string" || i.collectionGroup.length === 0) {
|
|
886
1209
|
skipped += 1;
|
|
887
1210
|
continue;
|
|
888
1211
|
}
|
|
1212
|
+
if (i.queryScope !== void 0 && i.queryScope !== "COLLECTION" && i.queryScope !== "COLLECTION_GROUP") {
|
|
1213
|
+
scopeSkipped += 1;
|
|
1214
|
+
continue;
|
|
1215
|
+
}
|
|
889
1216
|
const fields = [];
|
|
890
1217
|
for (const f of i.fields ?? []) {
|
|
891
1218
|
if (typeof f.fieldPath !== "string" || f.fieldPath.length === 0) {
|
|
892
1219
|
skipped += 1;
|
|
893
1220
|
continue;
|
|
894
1221
|
}
|
|
895
|
-
const
|
|
1222
|
+
const rawMode = f.vectorConfig ? "VECTOR" : f.arrayConfig === "CONTAINS" ? "ARRAY_CONTAINS" : typeof f.mode === "string" ? f.mode : f.order;
|
|
1223
|
+
const mode = rawMode;
|
|
896
1224
|
if (mode !== "ASCENDING" && mode !== "DESCENDING" && mode !== "ARRAY_CONTAINS" && mode !== "VECTOR") {
|
|
897
1225
|
skipped += 1;
|
|
898
1226
|
continue;
|
|
@@ -904,17 +1232,23 @@ async function scan(rootDir, opts = {}) {
|
|
|
904
1232
|
queryScope: i.queryScope === "COLLECTION_GROUP" ? "COLLECTION_GROUP" : "COLLECTION",
|
|
905
1233
|
fields
|
|
906
1234
|
});
|
|
1235
|
+
const last = entries[entries.length - 1];
|
|
1236
|
+
if (last.fields.length === 0) {
|
|
1237
|
+
entries.pop();
|
|
1238
|
+
skipped += 1;
|
|
1239
|
+
}
|
|
907
1240
|
}
|
|
908
1241
|
model.indexes = entries;
|
|
909
|
-
|
|
1242
|
+
const totalSkipped = skipped + scopeSkipped;
|
|
1243
|
+
if (totalSkipped > 0) {
|
|
910
1244
|
findings_pre.push({
|
|
911
1245
|
rule: "INDEXES_INVALID",
|
|
912
1246
|
severity: "WARNING",
|
|
913
1247
|
confidence: "CONFIRMED",
|
|
914
|
-
message: `firestore.indexes.json com ${
|
|
1248
|
+
message: `firestore.indexes.json com ${totalSkipped} entrada(s) inv\xE1lidas ignoradas \u2014 verifique campos.`,
|
|
915
1249
|
fingerprint: "INDEXES_INVALID",
|
|
916
1250
|
evidence: [
|
|
917
|
-
{ id: "ev-indexes-skipped", kind: "indexes-parse", summary: `${
|
|
1251
|
+
{ id: "ev-indexes-skipped", kind: "indexes-parse", summary: `${totalSkipped} inv\xE1lidas`, confidence: "CONFIRMED" }
|
|
918
1252
|
]
|
|
919
1253
|
});
|
|
920
1254
|
}
|
|
@@ -938,7 +1272,24 @@ async function scan(rootDir, opts = {}) {
|
|
|
938
1272
|
const validated = AuditYamlSchema.safeParse(parsed);
|
|
939
1273
|
if (validated.success) {
|
|
940
1274
|
const auth = validated.data.authorization;
|
|
941
|
-
|
|
1275
|
+
const rawAdapter = auth.adapter?.function ?? null;
|
|
1276
|
+
const ADAPTER_RE = /^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$/;
|
|
1277
|
+
if (rawAdapter !== null && !ADAPTER_RE.test(rawAdapter)) {
|
|
1278
|
+
findings_pre.push({
|
|
1279
|
+
rule: "CONTRACT_INVALID",
|
|
1280
|
+
severity: "WARNING",
|
|
1281
|
+
confidence: "CONFIRMED",
|
|
1282
|
+
message: `adapter.function "${rawAdapter}" inv\xE1lido \u2014 use identificador como "rbac.can". Coleta de permiss\xF5es ignorada.`,
|
|
1283
|
+
fingerprint: "CONTRACT_INVALID:adapter",
|
|
1284
|
+
evidence: [
|
|
1285
|
+
{ id: "ev-adapter-invalid", kind: "contract-parse", summary: rawAdapter, confidence: "CONFIRMED" }
|
|
1286
|
+
],
|
|
1287
|
+
fix: "Corrija authorization.adapter.function para identificador v\xE1lido."
|
|
1288
|
+
});
|
|
1289
|
+
model.authorization.adapter = null;
|
|
1290
|
+
} else {
|
|
1291
|
+
model.authorization.adapter = rawAdapter;
|
|
1292
|
+
}
|
|
942
1293
|
model.authorization.permissions = Object.entries(auth.permissions).map(([name, p]) => ({
|
|
943
1294
|
name,
|
|
944
1295
|
resource: p.resource,
|
|
@@ -948,6 +1299,35 @@ async function scan(rootDir, opts = {}) {
|
|
|
948
1299
|
name,
|
|
949
1300
|
permissions: r.permissions
|
|
950
1301
|
}));
|
|
1302
|
+
const samples = auth.claims?.samples;
|
|
1303
|
+
if (samples) {
|
|
1304
|
+
const res = validateClaimSamples(samples);
|
|
1305
|
+
if (!res.ok) {
|
|
1306
|
+
findings_pre.push({
|
|
1307
|
+
rule: "CONTRACT_INVALID",
|
|
1308
|
+
severity: "WARNING",
|
|
1309
|
+
confidence: "CONFIRMED",
|
|
1310
|
+
message: `claims.samples inv\xE1lido: ${res.errors.join("; ")} \u2014 samples ignorados.`,
|
|
1311
|
+
fingerprint: "CONTRACT_INVALID:claims",
|
|
1312
|
+
evidence: [
|
|
1313
|
+
{ id: "ev-claims-invalid", kind: "contract-parse", summary: res.errors.join("; "), confidence: "CONFIRMED" }
|
|
1314
|
+
],
|
|
1315
|
+
fix: "Claims s\xF3 para acesso, 1000 bytes, sem chaves OIDC/Firebase reservadas."
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
if (auth.claims && !auth.claims.enabled) {
|
|
1320
|
+
findings_pre.push({
|
|
1321
|
+
rule: "CLAIMS_DISABLED",
|
|
1322
|
+
severity: "INFO",
|
|
1323
|
+
confidence: "CONFIRMED",
|
|
1324
|
+
message: "Contrato com claims desabilitadas \u2014 FBA012 usa heur\xEDstica de papel sem crach\xE1.",
|
|
1325
|
+
fingerprint: "CLAIMS_DISABLED",
|
|
1326
|
+
evidence: [
|
|
1327
|
+
{ id: "ev-claims-off", kind: "contract-parse", summary: "claims off", confidence: "CONFIRMED" }
|
|
1328
|
+
]
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
951
1331
|
} else {
|
|
952
1332
|
findings_pre.push({
|
|
953
1333
|
rule: "CONTRACT_INVALID",
|
|
@@ -1008,12 +1388,28 @@ async function scan(rootDir, opts = {}) {
|
|
|
1008
1388
|
const errors = findings.filter((f) => f.severity === "ERROR").length;
|
|
1009
1389
|
const warnings = findings.filter((f) => f.severity === "WARNING").length;
|
|
1010
1390
|
const infos = findings.filter((f) => f.severity === "INFO").length;
|
|
1391
|
+
const implemented = new Set(IMPLEMENTED_CHECKS);
|
|
1011
1392
|
const failedChecks = new Set(
|
|
1012
1393
|
findings.filter((f) => f.severity === "ERROR" || f.severity === "WARNING").map((f) => f.rule)
|
|
1013
1394
|
);
|
|
1014
|
-
const
|
|
1015
|
-
const
|
|
1016
|
-
|
|
1395
|
+
const totalChecks = IMPLEMENTED_CHECKS.length;
|
|
1396
|
+
const passedChecks = IMPLEMENTED_CHECKS.filter((c) => !failedChecks.has(c)).length;
|
|
1397
|
+
const infraSkipped = findings.filter((f) => !implemented.has(f.rule)).length;
|
|
1398
|
+
return {
|
|
1399
|
+
model,
|
|
1400
|
+
findings,
|
|
1401
|
+
summary: {
|
|
1402
|
+
errors,
|
|
1403
|
+
warnings,
|
|
1404
|
+
infos,
|
|
1405
|
+
passed: passedChecks,
|
|
1406
|
+
total: totalChecks,
|
|
1407
|
+
passedChecks,
|
|
1408
|
+
totalChecks,
|
|
1409
|
+
infraSkipped,
|
|
1410
|
+
coveragePct: null
|
|
1411
|
+
}
|
|
1412
|
+
};
|
|
1017
1413
|
}
|
|
1018
1414
|
|
|
1019
1415
|
export {
|
|
@@ -1021,11 +1417,27 @@ export {
|
|
|
1021
1417
|
SERVER_HINTS_SEGMENTS,
|
|
1022
1418
|
classifyOrigin,
|
|
1023
1419
|
emptyModel,
|
|
1420
|
+
enrichWithGraph,
|
|
1024
1421
|
stripRuleComments,
|
|
1025
1422
|
opsFrom,
|
|
1423
|
+
normalizeTarget,
|
|
1424
|
+
parseMatchBlocks,
|
|
1425
|
+
buildFullPath,
|
|
1426
|
+
parseRuleFunctions,
|
|
1427
|
+
parseRuleFunctionsDetailed,
|
|
1428
|
+
inlineHelperArgs,
|
|
1429
|
+
inlineHelpersInCondition,
|
|
1430
|
+
resolveHelperCondition,
|
|
1431
|
+
resolveHelperConditionDetailed,
|
|
1026
1432
|
extractRules,
|
|
1433
|
+
extractRulesContent,
|
|
1434
|
+
stripOuterParens,
|
|
1027
1435
|
isPublicCondition,
|
|
1436
|
+
hashShort,
|
|
1437
|
+
conditionKeyForFingerprint,
|
|
1028
1438
|
findPublicAllows,
|
|
1439
|
+
helperBodyToCondition,
|
|
1440
|
+
findPublicAllowsContent,
|
|
1029
1441
|
IMPLEMENTED_CHECKS,
|
|
1030
1442
|
runChecks,
|
|
1031
1443
|
SeveritySchema,
|
|
@@ -1048,5 +1460,7 @@ export {
|
|
|
1048
1460
|
ProjectModelSchema,
|
|
1049
1461
|
FindingSchema,
|
|
1050
1462
|
AuditYamlSchema,
|
|
1463
|
+
RESERVED_CLAIM_KEYS,
|
|
1464
|
+
validateClaimSamples,
|
|
1051
1465
|
scan
|
|
1052
1466
|
};
|