@justmpm/firebase-audit 0.5.12 → 0.5.14
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/README.md +2 -1
- package/dist/{chunk-CTQPAMOE.js → chunk-4RVGY4EB.js} +97 -54
- package/dist/{chunk-OYWTDV45.js → chunk-B5CIZS4Y.js} +1 -1
- package/dist/cli.js +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/mcp-cli.js +2 -2
- package/dist/mcp.js +2 -2
- package/dist/{scan-CU54MUN7.js → scan-DDGH7LPH.js} +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +3 -0
package/README.md
CHANGED
|
@@ -163,7 +163,7 @@ Limites assumidos: Emulator não prova índice (produção exige, ele deixa pass
|
|
|
163
163
|
- `UNKNOWN` — impossível resolver estaticamente (ex: `where(fieldVar, ...)`)
|
|
164
164
|
- `CONFLICTING` — interno, duas evidências se contradizem (ex: typo `order.delete` vs `orders.delete`)
|
|
165
165
|
|
|
166
|
-
###
|
|
166
|
+
### 10 checks implementados (+ avisos de infra e Storage/AppCheck)
|
|
167
167
|
|
|
168
168
|
| # | Check | Sev | Conf |
|
|
169
169
|
|---|-------|-----|------|
|
|
@@ -176,6 +176,7 @@ Limites assumidos: Emulator não prova índice (produção exige, ele deixa pass
|
|
|
176
176
|
| FBA011 | Role referencia permission não declarada | ERROR | CONFIRMED |
|
|
177
177
|
| FBA012 | Rule ampla vs contrato admin-only | WARNING | PROBABLE |
|
|
178
178
|
| FBA013 | Role sem permissions | WARNING | CONFIRMED |
|
|
179
|
+
| FBA014 | Create/update próprio em `admins/` sem gate de admin (auto-promoção) | WARNING | PROBABLE |
|
|
179
180
|
| AUTH_ANON_ALLOWED | `auth != null` sozinho (anônimo passa) | INFO | PROBABLE |
|
|
180
181
|
| STORAGE_* | Escrita aberta / curinga total / leitura aberta / anônimo (por bloco do match) | ERROR/WARNING/INFO | CONFIRMED/PROBABLE |
|
|
181
182
|
| APPCHECK_* | Carimbo observado / verificação manual (onRequest por rota) | INFO | CONFIRMED/PROBABLE |
|
|
@@ -196,7 +196,7 @@ function isBuildOutput(file) {
|
|
|
196
196
|
const n = file.replace(/\\/g, "/");
|
|
197
197
|
return /(^|\/)(node_modules|dist|build|\.next|coverage|out)\//.test(n) || /\/functions\/lib\//.test(n);
|
|
198
198
|
}
|
|
199
|
-
var IMPLEMENTED_CHECKS = ["FBA001", "FBA002", "FBA003", "FBA004", "FBA009", "FBA010", "FBA011", "FBA012", "FBA013"];
|
|
199
|
+
var IMPLEMENTED_CHECKS = ["FBA001", "FBA002", "FBA003", "FBA004", "FBA009", "FBA010", "FBA011", "FBA012", "FBA013", "FBA014"];
|
|
200
200
|
function createEvidence() {
|
|
201
201
|
let counter = 0;
|
|
202
202
|
return {
|
|
@@ -223,6 +223,38 @@ function make(rule, severity, confidence, message, fingerprint, opts = {}) {
|
|
|
223
223
|
...opts
|
|
224
224
|
};
|
|
225
225
|
}
|
|
226
|
+
function checksAdminPower(cond, condRaw, rule, helperDetailed) {
|
|
227
|
+
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*\([^()]*admin\s*==\s*true/i.test(cond);
|
|
228
|
+
const normClaim = (c) => c.toLowerCase().replace(/[_-]/g, "");
|
|
229
|
+
const isAdminCall = /\bisadmin\s*\(([^)]*)\)/i.exec(condRaw) ?? /\bisadmin\s*\(([^)]*)\)/i.exec(cond);
|
|
230
|
+
let isAdminBodyHasAdmin = false;
|
|
231
|
+
if (isAdminCall) {
|
|
232
|
+
const fnName = (() => {
|
|
233
|
+
const m = /\b([A-Za-z_][A-Za-z0-9_]*)\s*\(/i.exec(isAdminCall[0]);
|
|
234
|
+
return m ? m[1] : "isAdmin";
|
|
235
|
+
})();
|
|
236
|
+
const fn = helperDetailed.get(fnName) ?? helperDetailed.get("isAdmin") ?? helperDetailed.get("isadmin");
|
|
237
|
+
if (fn) {
|
|
238
|
+
const b = `${fn.body}`;
|
|
239
|
+
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);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const checksAdminClaim = !isInvertedAdmin && rule.claimReferences.some((c) => {
|
|
243
|
+
const n = normClaim(c);
|
|
244
|
+
return n === "admin" || n === "isadmin";
|
|
245
|
+
});
|
|
246
|
+
const hasTopOr = /\|\|/.test(cond);
|
|
247
|
+
const adminPerDisjunct = hasTopOr ? cond.split("||").every((part) => {
|
|
248
|
+
const p = part;
|
|
249
|
+
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);
|
|
250
|
+
}) : true;
|
|
251
|
+
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;
|
|
252
|
+
if (checksAdminClaim || checksAdminRole) {
|
|
253
|
+
if (hasTopOr && !adminPerDisjunct) return false;
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
226
258
|
async function runChecks(model, rootDir, opts = {}) {
|
|
227
259
|
const { ev } = createEvidence();
|
|
228
260
|
const findings = [];
|
|
@@ -421,37 +453,7 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
421
453
|
const condResolved = resolveHelperConditionDetailed(condRaw, helperDetailed);
|
|
422
454
|
const condInlined = inlineHelpersInCondition(condRaw, helperDetailed);
|
|
423
455
|
const cond = condResolved !== condRaw ? condResolved : condInlined !== condRaw ? condInlined : condRaw;
|
|
424
|
-
|
|
425
|
-
const normClaim = (c) => c.toLowerCase().replace(/[_-]/g, "");
|
|
426
|
-
const isAdminCall = /\bisadmin\s*\(([^)]*)\)/i.exec(condRaw) ?? /\bisadmin\s*\(([^)]*)\)/i.exec(cond);
|
|
427
|
-
let isAdminBodyHasAdmin = false;
|
|
428
|
-
if (isAdminCall) {
|
|
429
|
-
const fnName = (() => {
|
|
430
|
-
const m = /\b([A-Za-z_][A-Za-z0-9_]*)\s*\(/i.exec(isAdminCall[0]);
|
|
431
|
-
return m ? m[1] : "isAdmin";
|
|
432
|
-
})();
|
|
433
|
-
const fn = helperDetailed.get(fnName) ?? helperDetailed.get("isAdmin") ?? helperDetailed.get("isadmin");
|
|
434
|
-
if (fn) {
|
|
435
|
-
const b = `${fn.body}`;
|
|
436
|
-
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);
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
const checksAdminClaim = !isInvertedAdmin && rule.claimReferences.some((c) => {
|
|
440
|
-
const n = normClaim(c);
|
|
441
|
-
return n === "admin" || n === "isadmin";
|
|
442
|
-
});
|
|
443
|
-
const hasTopOr = /\|\|/.test(cond);
|
|
444
|
-
const adminPerDisjunct = hasTopOr ? cond.split("||").every((part) => {
|
|
445
|
-
const p = part;
|
|
446
|
-
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);
|
|
447
|
-
}) : true;
|
|
448
|
-
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;
|
|
449
|
-
if (checksAdminClaim || checksAdminRole) {
|
|
450
|
-
if (hasTopOr && !adminPerDisjunct) {
|
|
451
|
-
} else {
|
|
452
|
-
return;
|
|
453
|
-
}
|
|
454
|
-
}
|
|
456
|
+
if (checksAdminPower(cond, condRaw, rule, helperDetailed)) return;
|
|
455
457
|
const checksAuth = rule.authReferences.length > 0;
|
|
456
458
|
if (!checksAuth) return;
|
|
457
459
|
findings.push(
|
|
@@ -518,12 +520,13 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
518
520
|
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");
|
|
519
521
|
const isBareAuth = hasBareAuth || hasWeakConjunction;
|
|
520
522
|
if (isBareAuth && rule.claimReferences.length === 0) {
|
|
523
|
+
const gatedByDoc = /exists\s*\(/i.test(effectiveRaw);
|
|
521
524
|
findings.push(
|
|
522
525
|
make(
|
|
523
526
|
"AUTH_ANON_ALLOWED",
|
|
524
527
|
"INFO",
|
|
525
528
|
"PROBABLE",
|
|
526
|
-
`Rule ${rule.path} aceita qualquer autenticado incluindo an\xF4nimo (request.auth != null sem claim/provider).`,
|
|
529
|
+
gatedByDoc ? `Rule ${rule.path} exige login + documento via exists() (sem claim/provider) \u2014 an\xF4nimo com login passa no conjunto se obtiver o doc.` : `Rule ${rule.path} aceita qualquer autenticado incluindo an\xF4nimo (request.auth != null sem claim/provider).`,
|
|
527
530
|
`AUTH_ANON_ALLOWED:${rule.path}:${conditionKeyForFingerprint(rule.condition)}`,
|
|
528
531
|
{
|
|
529
532
|
file: rule.location.file,
|
|
@@ -536,6 +539,42 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
536
539
|
);
|
|
537
540
|
}
|
|
538
541
|
}
|
|
542
|
+
for (const rule of model.rules) {
|
|
543
|
+
const segments = rule.path.split("/").filter((s) => s.length > 0).map((s) => s.toLowerCase());
|
|
544
|
+
if (!segments.includes("admins")) continue;
|
|
545
|
+
const ruleOps = new Set(rule.operations.flatMap(expandOp));
|
|
546
|
+
const escalating = ["create", "write", "update"].filter((o) => ruleOps.has(o));
|
|
547
|
+
if (escalating.length === 0) continue;
|
|
548
|
+
const isPublicRule = [...ruleOps].some(
|
|
549
|
+
(op) => publicPaths.has(`${rule.path}::${op.toLowerCase()}`)
|
|
550
|
+
);
|
|
551
|
+
if (isPublicRule) continue;
|
|
552
|
+
const condRaw = rule.condition ?? "";
|
|
553
|
+
const condResolved = resolveHelperConditionDetailed(condRaw, helperDetailed);
|
|
554
|
+
const condInlined = inlineHelpersInCondition(condRaw, helperDetailed);
|
|
555
|
+
const cond = condResolved !== condRaw ? condResolved : condInlined !== condRaw ? condInlined : condRaw;
|
|
556
|
+
if (checksAdminPower(cond, condRaw, rule, helperDetailed)) continue;
|
|
557
|
+
const docGate = /exists\s*\((?:[^()]*|\([^()]*\))*admins?/i.test(cond) || /\bget\s*\(\s*\/(?:[^()]*|\([^()]*\))*admins?/i.test(cond);
|
|
558
|
+
if (docGate) continue;
|
|
559
|
+
const checksAuth = rule.authReferences.length > 0;
|
|
560
|
+
if (!checksAuth) continue;
|
|
561
|
+
findings.push(
|
|
562
|
+
make(
|
|
563
|
+
"FBA014",
|
|
564
|
+
"WARNING",
|
|
565
|
+
"PROBABLE",
|
|
566
|
+
`Rule ${rule.path} permite ${escalating.join("/")} sem exigir poder de admin (sem claim/papel de admin nem doc-gate em admins/). Qualquer logado cria/altera o pr\xF3prio doc \u2014 se o cadastro escrever role 'admin', \xE9 auto-promo\xE7\xE3o. Confirme o c\xF3digo de signup e valide no Emulator.`,
|
|
567
|
+
`FBA014:${rule.path}:${normalizeTarget(rule.operations.join(","))}:${conditionKeyForFingerprint(rule.condition)}`,
|
|
568
|
+
{
|
|
569
|
+
file: rule.location.file,
|
|
570
|
+
line: rule.location.start.line,
|
|
571
|
+
resource: rule.path,
|
|
572
|
+
evidence: [ev("self-admin-create", rule.condition ?? "sem condi\xE7\xE3o", "PROBABLE", rule.location.file, rule.location.start.line)],
|
|
573
|
+
fix: "Exigir claim/papel de admin ou doc-gate (exists/get em admins/) no create/update; nunca deixar o cadastro escrever role 'admin' para usu\xE1rio comum. Valide no Emulator (matriz anonymous/user/admin)."
|
|
574
|
+
}
|
|
575
|
+
)
|
|
576
|
+
);
|
|
577
|
+
}
|
|
539
578
|
const adminHits = await collectAdminImports(rootDir);
|
|
540
579
|
for (const hit of adminHits) {
|
|
541
580
|
let origin = "UNKNOWN";
|
|
@@ -1264,7 +1303,7 @@ async function scan(rootDir, opts = {}) {
|
|
|
1264
1303
|
severity: "ERROR",
|
|
1265
1304
|
confidence: "CONFIRMED",
|
|
1266
1305
|
message: `Storage com escrita aberta (${where}). Qualquer cliente pode escrever \u2014 cofre aberto, n\xE3o vitrine.`,
|
|
1267
|
-
fingerprint: `STORAGE_PUBLIC_WRITE:${m.
|
|
1306
|
+
fingerprint: `STORAGE_PUBLIC_WRITE:${m.fpPath}:${normalizeTarget(m.target)}:${key}`,
|
|
1268
1307
|
file: relStorage,
|
|
1269
1308
|
line: m.line,
|
|
1270
1309
|
evidence: [{ id: evId("write"), kind: "storage-public", summary: `${m.matchPath} allow ${m.target}`, confidence: "CONFIRMED", location: { file: relStorage, start: { line: m.line, column: 0 } } }],
|
|
@@ -1279,7 +1318,7 @@ async function scan(rootDir, opts = {}) {
|
|
|
1279
1318
|
severity: "WARNING",
|
|
1280
1319
|
confidence: "PROBABLE",
|
|
1281
1320
|
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}.`,
|
|
1282
|
-
fingerprint: `STORAGE_ALL_PATHS_OPEN:${m.
|
|
1321
|
+
fingerprint: `STORAGE_ALL_PATHS_OPEN:${m.fpPath}:${normalizeTarget(m.target)}:${key}`,
|
|
1283
1322
|
file: relStorage,
|
|
1284
1323
|
line: m.line,
|
|
1285
1324
|
evidence: [{ id: evId("all"), kind: "storage-public", summary: `${m.matchPath} allow ${m.target}`, confidence: "PROBABLE", location: { file: relStorage, start: { line: m.line, column: 0 } } }],
|
|
@@ -1292,7 +1331,7 @@ async function scan(rootDir, opts = {}) {
|
|
|
1292
1331
|
severity: "INFO",
|
|
1293
1332
|
confidence: "PROBABLE",
|
|
1294
1333
|
message: enumerates ? `Storage com leitura aberta (${where}). Pode ser vitrine proposital \u2014 mas allow read/list permite enumerar a pasta; para vitrine segura prefira allow get. Confirme que o write segue restrito.` : `Storage com leitura aberta (${where}). Pode ser vitrine proposital (logos) \u2014 confirme que o write segue restrito.`,
|
|
1295
|
-
fingerprint: `STORAGE_PUBLIC_READ:${m.
|
|
1334
|
+
fingerprint: `STORAGE_PUBLIC_READ:${m.fpPath}:${normalizeTarget(m.target)}:${key}`,
|
|
1296
1335
|
file: relStorage,
|
|
1297
1336
|
line: m.line,
|
|
1298
1337
|
evidence: [{ id: evId("read"), kind: "storage-public", summary: `${m.matchPath} allow ${m.target}`, confidence: "PROBABLE", location: { file: relStorage, start: { line: m.line, column: 0 } } }],
|
|
@@ -1304,12 +1343,13 @@ async function scan(rootDir, opts = {}) {
|
|
|
1304
1343
|
}
|
|
1305
1344
|
const noAuthBranch = findNoAuthBranch(effectiveCond);
|
|
1306
1345
|
if (noAuthBranch === void 0 && bareAuth && (ops.has("read") || ops.has("get") || ops.has("list") || ops.has("write") || ops.has("create") || ops.has("update") || ops.has("delete"))) {
|
|
1346
|
+
const gatedByDoc = /exists\(/.test(normCond);
|
|
1307
1347
|
findings.push({
|
|
1308
1348
|
rule: "STORAGE_ANON_ALLOWED",
|
|
1309
1349
|
severity: "INFO",
|
|
1310
1350
|
confidence: "PROBABLE",
|
|
1311
|
-
message: `Storage aceita qualquer autenticado incluindo an\xF4nimo (${where}). Exija claim/provider para dados sens\xEDveis.`,
|
|
1312
|
-
fingerprint: `STORAGE_ANON_ALLOWED:${m.
|
|
1351
|
+
message: gatedByDoc ? `Storage exige login + documento via exists() (${where}) \u2014 mais forte que login puro, mas sem claim/provider: an\xF4nimo com login passa no conjunto se obtiver o doc. Exija claim/provider para dados sens\xEDveis.` : `Storage aceita qualquer autenticado incluindo an\xF4nimo (${where}). Exija claim/provider para dados sens\xEDveis.`,
|
|
1352
|
+
fingerprint: `STORAGE_ANON_ALLOWED:${m.fpPath}:${normalizeTarget(m.target)}:${key}`,
|
|
1313
1353
|
file: relStorage,
|
|
1314
1354
|
line: m.line,
|
|
1315
1355
|
evidence: [{ id: evId("anon"), kind: "anon-allowed", summary: `${m.matchPath} allow ${m.target}`, confidence: "PROBABLE", location: { file: relStorage, start: { line: m.line, column: 0 } } }],
|
|
@@ -1330,7 +1370,7 @@ async function scan(rootDir, opts = {}) {
|
|
|
1330
1370
|
severity: "ERROR",
|
|
1331
1371
|
confidence: "PROBABLE",
|
|
1332
1372
|
message: `Storage com escrita condicionada sem request.auth (${where}). Se a condi\xE7\xE3o for verdadeira para an\xF4nimo, ele escreve \u2014 cofre prov\xE1vel.${branchNote}${noAuthNote}`,
|
|
1333
|
-
fingerprint: `STORAGE_PUBLIC_WRITE:${m.
|
|
1373
|
+
fingerprint: `STORAGE_PUBLIC_WRITE:${m.fpPath}:${normalizeTarget(m.target)}:${key}`,
|
|
1334
1374
|
file: relStorage,
|
|
1335
1375
|
line: m.line,
|
|
1336
1376
|
evidence: [{ id: evId("write"), kind: "storage-public", summary: `${m.matchPath} allow ${m.target} sem auth`, confidence: "PROBABLE", location: { file: relStorage, start: { line: m.line, column: 0 } } }],
|
|
@@ -1342,7 +1382,7 @@ async function scan(rootDir, opts = {}) {
|
|
|
1342
1382
|
severity: "INFO",
|
|
1343
1383
|
confidence: "PROBABLE",
|
|
1344
1384
|
message: `Storage com leitura condicionada sem request.auth (${where}). Se a condi\xE7\xE3o for verdadeira para an\xF4nimo, ele l\xEA \u2014 confirme que \xE9 vitrine.${branchNote}${noAuthNote}`,
|
|
1345
|
-
fingerprint: `STORAGE_PUBLIC_READ:${m.
|
|
1385
|
+
fingerprint: `STORAGE_PUBLIC_READ:${m.fpPath}:${normalizeTarget(m.target)}:${key}`,
|
|
1346
1386
|
file: relStorage,
|
|
1347
1387
|
line: m.line,
|
|
1348
1388
|
evidence: [{ id: evId("read"), kind: "storage-public", summary: `${m.matchPath} allow ${m.target} sem auth`, confidence: "PROBABLE", location: { file: relStorage, start: { line: m.line, column: 0 } } }]
|
|
@@ -1552,21 +1592,22 @@ function findNoAuthBranch(effective) {
|
|
|
1552
1592
|
return void 0;
|
|
1553
1593
|
}
|
|
1554
1594
|
function parseStorageAllows(clean) {
|
|
1595
|
+
const text = clean.replace(/\r\n?/g, "\n");
|
|
1555
1596
|
const out = [];
|
|
1556
1597
|
const matches = [];
|
|
1557
1598
|
const matchRe = /match\s+/g;
|
|
1558
1599
|
let mm;
|
|
1559
|
-
while ((mm = matchRe.exec(
|
|
1600
|
+
while ((mm = matchRe.exec(text)) !== null) {
|
|
1560
1601
|
const from = mm.index + mm[0].length;
|
|
1561
1602
|
let openBrace = -1;
|
|
1562
1603
|
let scan2 = from;
|
|
1563
|
-
while (scan2 <
|
|
1564
|
-
const b =
|
|
1604
|
+
while (scan2 < text.length) {
|
|
1605
|
+
const b = text.indexOf("{", scan2);
|
|
1565
1606
|
if (b === -1) break;
|
|
1566
|
-
const between =
|
|
1607
|
+
const between = text.slice(from, b);
|
|
1567
1608
|
if (/match\s|allow\s|;/.test(between)) break;
|
|
1568
|
-
const after =
|
|
1569
|
-
const eol =
|
|
1609
|
+
const after = text.slice(b + 1, b + 12).trimStart().replace(/^\n+/, "");
|
|
1610
|
+
const eol = text.slice(b + 1).match(/^[ \t]*(\n|$)/) !== null;
|
|
1570
1611
|
if (/^(allow|match|function|\})/.test(after) || eol) {
|
|
1571
1612
|
openBrace = b;
|
|
1572
1613
|
break;
|
|
@@ -1574,14 +1615,14 @@ function parseStorageAllows(clean) {
|
|
|
1574
1615
|
scan2 = b + 1;
|
|
1575
1616
|
}
|
|
1576
1617
|
if (openBrace === -1) continue;
|
|
1577
|
-
const path =
|
|
1618
|
+
const path = text.slice(from, openBrace).trim() || "/(unknown)";
|
|
1578
1619
|
let depth = 0;
|
|
1579
1620
|
let quote = null;
|
|
1580
|
-
let end =
|
|
1581
|
-
for (let i = openBrace; i <
|
|
1582
|
-
const c =
|
|
1621
|
+
let end = text.length;
|
|
1622
|
+
for (let i = openBrace; i < text.length; i++) {
|
|
1623
|
+
const c = text[i];
|
|
1583
1624
|
if (quote) {
|
|
1584
|
-
if (c === quote &&
|
|
1625
|
+
if (c === quote && text[i - 1] !== "\\") quote = null;
|
|
1585
1626
|
continue;
|
|
1586
1627
|
}
|
|
1587
1628
|
if (c === '"' || c === "'" || c === "`") {
|
|
@@ -1601,15 +1642,17 @@ function parseStorageAllows(clean) {
|
|
|
1601
1642
|
}
|
|
1602
1643
|
const re = /allow\s+([^;:]+?)(?::\s*if\s+([^;]+))?;/gi;
|
|
1603
1644
|
let m;
|
|
1604
|
-
while ((m = re.exec(
|
|
1645
|
+
while ((m = re.exec(text)) !== null) {
|
|
1605
1646
|
const containing = matches.filter((b) => b.start <= m.index && m.index < b.end).sort((a, b) => a.start - b.start);
|
|
1606
1647
|
const parts = [];
|
|
1607
1648
|
for (const b of containing) {
|
|
1608
1649
|
const p = b.path.trim().replace(/^\/+|\/+$/g, "");
|
|
1609
1650
|
if (p.length > 0) parts.push(p);
|
|
1610
1651
|
}
|
|
1611
|
-
const line =
|
|
1612
|
-
|
|
1652
|
+
const line = text.slice(0, m.index).split("\n").length;
|
|
1653
|
+
const matchPath = parts.length > 0 ? `/${parts.join("/")}` : "/(unknown)";
|
|
1654
|
+
const fpPath = matchPath === "/(unknown)" ? `/(unknown):L${line}` : matchPath;
|
|
1655
|
+
out.push({ target: m[1].trim(), condition: m[2]?.trim(), matchPath, fpPath, line });
|
|
1613
1656
|
}
|
|
1614
1657
|
return out;
|
|
1615
1658
|
}
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
scan
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-4RVGY4EB.js";
|
|
5
5
|
import "./chunk-7J3ADEBW.js";
|
|
6
6
|
|
|
7
7
|
// src/cli.ts
|
|
@@ -84,7 +84,7 @@ Uso: firebase-audit scan|check|verify|drift [--json] ...`);
|
|
|
84
84
|
const { resolve: resolveRoot } = await import("path");
|
|
85
85
|
const rootDir = cwdArg ? resolveRoot(process.cwd(), cwdArg) : process.cwd();
|
|
86
86
|
if (cmd === "verify") {
|
|
87
|
-
const { scan: scanForVerify } = await import("./scan-
|
|
87
|
+
const { scan: scanForVerify } = await import("./scan-DDGH7LPH.js");
|
|
88
88
|
const { verify } = await import("./verify-GQSR4FQL.js");
|
|
89
89
|
const { resolve: resolveVerify } = await import("path");
|
|
90
90
|
const coverageArg = getArg("coverage");
|
package/dist/index.d.ts
CHANGED
|
@@ -739,13 +739,13 @@ declare function findPublicAllowsContent(rawContent: string): {
|
|
|
739
739
|
}[];
|
|
740
740
|
|
|
741
741
|
/**
|
|
742
|
-
* Checks (FBA001–FBA004, FBA009–
|
|
742
|
+
* Checks (FBA001–FBA004, FBA009–FBA014, subconjunto intencional).
|
|
743
743
|
* Fingerprints: FBA001/FBA002 por regra-lógica + hash da condição
|
|
744
744
|
* (estáveis a formatação, únicos por regra duplicada);
|
|
745
745
|
* FBA003/FBA004/FBA010/FBA012 com linha (únicos por ocorrência).
|
|
746
746
|
*/
|
|
747
747
|
|
|
748
|
-
declare const IMPLEMENTED_CHECKS: readonly ["FBA001", "FBA002", "FBA003", "FBA004", "FBA009", "FBA010", "FBA011", "FBA012", "FBA013"];
|
|
748
|
+
declare const IMPLEMENTED_CHECKS: readonly ["FBA001", "FBA002", "FBA003", "FBA004", "FBA009", "FBA010", "FBA011", "FBA012", "FBA013", "FBA014"];
|
|
749
749
|
declare function runChecks(model: ProjectModel, rootDir: string, opts?: {
|
|
750
750
|
adapterFn?: string;
|
|
751
751
|
}): Promise<Finding[]>;
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createMcpServer,
|
|
3
3
|
startMcpServer
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-B5CIZS4Y.js";
|
|
5
5
|
import {
|
|
6
6
|
AccessOriginSchema,
|
|
7
7
|
AuditYamlSchema,
|
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
runChecks,
|
|
35
35
|
scan,
|
|
36
36
|
validateClaimSamples
|
|
37
|
-
} from "./chunk-
|
|
37
|
+
} from "./chunk-4RVGY4EB.js";
|
|
38
38
|
import {
|
|
39
39
|
buildFullPath,
|
|
40
40
|
conditionKeyForFingerprint,
|
package/dist/mcp-cli.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
startMcpServer
|
|
4
|
-
} from "./chunk-
|
|
5
|
-
import "./chunk-
|
|
4
|
+
} from "./chunk-B5CIZS4Y.js";
|
|
5
|
+
import "./chunk-4RVGY4EB.js";
|
|
6
6
|
import "./chunk-7J3ADEBW.js";
|
|
7
7
|
import "./chunk-CSMT4PIP.js";
|
|
8
8
|
import "./chunk-LD3F73XI.js";
|
package/dist/mcp.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createMcpServer,
|
|
3
3
|
startMcpServer
|
|
4
|
-
} from "./chunk-
|
|
5
|
-
import "./chunk-
|
|
4
|
+
} from "./chunk-B5CIZS4Y.js";
|
|
5
|
+
import "./chunk-4RVGY4EB.js";
|
|
6
6
|
import "./chunk-7J3ADEBW.js";
|
|
7
7
|
import "./chunk-CSMT4PIP.js";
|
|
8
8
|
import "./chunk-LD3F73XI.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@justmpm/firebase-audit",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.14",
|
|
4
4
|
"description": "Auditor de consistência e segurança para projetos Firebase: código + Rules + índices + contrato vs comportamento. Static-first, nunca inventa certeza.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"firebase",
|
package/skill/SKILL.md
CHANGED
|
@@ -21,6 +21,9 @@ Skill para agentes usarem o `@justmpm/firebase-audit` do jeito certo.
|
|
|
21
21
|
- AppCheck é carimbo do app oficial — não prova usuário nem tenant
|
|
22
22
|
- Logo pública (`get: if true` em `/logos/`) pode ser vitrine proposital — cofre é `write: if true` ou curinga total aberto
|
|
23
23
|
- Storage resolve funções auxiliares do `.rules` antes de acusar (portaria com auth dentro não é "sem auth", inclui `let` do rules v2); helper não declarado vira PROBABLE com o nome na mensagem, nunca silêncio
|
|
24
|
+
- Storage tolera CRLF (arquivo Windows) e path `/(unknown)` nunca repete fingerprint (sufixo de linha só no degenerado)
|
|
25
|
+
- Regra com `auth != null && exists(admins/uid)` diz "login + documento via exists()", não "qualquer autenticado" — sem claim/provider, anônimo com login passa no conjunto se obtiver o doc
|
|
26
|
+
- FBA014: `allow create/update` próprio em `admins/` sem claim/papel de admin nem doc-gate é auto-promoção provável (caso cadastro que escreve `role: 'admin'`) — confirme o signup e o Emulator; `users/{uid}` + campo role está fora do escopo estático
|
|
24
27
|
- Storage avalia disjunção por ramo (`auth || tamanho` acusa o ramo sem auth) e `return false` via helper é deny silencioso, igual ao `if false`
|
|
25
28
|
- Seeds/fixtures com Admin SDK moram em `scripts/` ou `server/`, nunca em `src/` (senão o scan acusa FBA003 de propósito)
|
|
26
29
|
|