@justmpm/firebase-audit 0.5.4 → 0.5.6
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 +18 -15
- package/dist/{chunk-AKPQ4OAJ.js → chunk-BB6XXEIK.js} +122 -38
- package/dist/{chunk-Y4VXWSKX.js → chunk-DIO36A3I.js} +3 -2
- package/dist/{chunk-RKQ3ZWWI.js → chunk-SH6HFX5Y.js} +6 -4
- package/dist/cli.js +12 -7
- package/dist/index.d.ts +12 -8
- package/dist/index.js +3 -3
- package/dist/mcp-cli.js +3 -3
- package/dist/mcp.js +3 -3
- package/dist/{scan-YUXXYBRU.js → scan-SDJSDRDQ.js} +1 -1
- package/dist/{verify-WBVSESHD.js → verify-DECP4IUW.js} +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +2 -1
package/README.md
CHANGED
|
@@ -163,20 +163,22 @@ 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
|
+
### 9 checks implementados (+ avisos de infra e Storage/AppCheck)
|
|
167
167
|
|
|
168
168
|
| # | Check | Sev | Conf |
|
|
169
169
|
|---|-------|-----|------|
|
|
170
|
-
| FBA001 | `allow write/create/update/delete
|
|
171
|
-
| FBA002 | `allow read
|
|
172
|
-
| FBA003 | Admin SDK
|
|
170
|
+
| FBA001 | `allow write/create/update/delete` público | ERROR | CONFIRMED/PROBABLE |
|
|
171
|
+
| FBA002 | `allow read/get/list` público (pode ser proposital) | WARNING | CONFIRMED/PROBABLE |
|
|
172
|
+
| FBA003 | Admin SDK em arquivo de cliente (+ origem indefinida como INFO) | ERROR/INFO | PROBABLE/UNKNOWN |
|
|
173
173
|
| FBA004 | Permission usada e não declarada | ERROR | CONFIRMED |
|
|
174
|
-
|
|
|
175
|
-
|
|
|
176
|
-
|
|
|
177
|
-
|
|
|
178
|
-
|
|
|
179
|
-
|
|
|
174
|
+
| FBA009 | Adapter não observado (com hint de portaria tenant) | WARNING/INFO | UNKNOWN/NOT_OBSERVED |
|
|
175
|
+
| FBA010 | Permissão dinâmica (não resolvida estaticamente) | INFO | UNKNOWN |
|
|
176
|
+
| FBA011 | Role referencia permission não declarada | ERROR | CONFIRMED |
|
|
177
|
+
| FBA012 | Rule ampla vs contrato admin-only | WARNING | PROBABLE |
|
|
178
|
+
| FBA013 | Role sem permissions | WARNING | CONFIRMED |
|
|
179
|
+
| AUTH_ANON_ALLOWED | `auth != null` sozinho (anônimo passa) | INFO | PROBABLE |
|
|
180
|
+
| STORAGE_* | Escrita aberta / curinga total / leitura aberta / anônimo (por bloco do match) | ERROR/WARNING/INFO | CONFIRMED/PROBABLE |
|
|
181
|
+
| APPCHECK_* | Carimbo observado / verificação manual (onRequest por rota) | INFO | CONFIRMED/PROBABLE |
|
|
180
182
|
|
|
181
183
|
Detalhes que quebram se deixar pra depois: `write` = create+update+delete (guardar origem), `OR` nas Rules (`isAdmin() || public==true`), `resource` vs `request.resource`, `collectionGroup` com escopo próprio, `databaseId` nomeado desde o dia 1.
|
|
182
184
|
|
|
@@ -193,12 +195,13 @@ Detalhes que quebram se deixar pra depois: `write` = create+update+delete (guard
|
|
|
193
195
|
|
|
194
196
|
## 2. Escopo
|
|
195
197
|
|
|
196
|
-
**
|
|
197
|
-
**
|
|
198
|
-
**
|
|
199
|
-
**
|
|
198
|
+
**Scan:** Discovery + checks + `firebase-audit scan [--json|--strict]`. Zero-config, valor imediato.
|
|
199
|
+
**Check com contrato:** YAML + `Code ↔ Contract ↔ Rules` (mismatch muito amplo, role sem permissão, etc.).
|
|
200
|
+
**Verify:** gera matriz papel×operação×path → roda no Emulator → cobertura `ruleCoverage` (não substitui scan).
|
|
201
|
+
**Drift:** local vs implantado (`LOCAL_ONLY / REMOTE_ONLY / MATCHED`).
|
|
200
202
|
|
|
201
|
-
|
|
203
|
+
**Escopo atual:** Firestore Rules + Storage Rules (vitrine vs cofre, por bloco) + AppCheck (carimbo, não autorização) + contrato + índices.
|
|
204
|
+
**Fora de escopo (declarado em `summary.skipped`):** Realtime DB, Hosting, Functions complexas, IAM, Data Connect, isolamento por tenant, bancos/buckets extras além do default.
|
|
202
205
|
|
|
203
206
|
---
|
|
204
207
|
|
|
@@ -51,18 +51,39 @@ function discover(rootDir) {
|
|
|
51
51
|
if (firebaseJson) {
|
|
52
52
|
try {
|
|
53
53
|
const raw = JSON.parse(readFileSync(firebaseJson, "utf-8"));
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
const first = (v) => Array.isArray(v) ? v[0] : v;
|
|
55
|
+
const fs = first(raw.firestore);
|
|
56
|
+
const st = first(raw.storage);
|
|
57
|
+
const fsAny = fs;
|
|
58
|
+
if (fsAny && typeof fsAny.database === "string" && fsAny.database.length > 0 && fsAny.database !== "(default)") {
|
|
59
|
+
databaseId = fsAny.database;
|
|
60
|
+
}
|
|
61
|
+
if (typeof fs?.rules === "string") {
|
|
62
|
+
const p = join(rootDir, fs.rules);
|
|
56
63
|
if (existsSync(p)) rulesFile = p;
|
|
57
64
|
}
|
|
58
|
-
if (typeof
|
|
59
|
-
const p = join(rootDir,
|
|
65
|
+
if (typeof fs?.indexes === "string") {
|
|
66
|
+
const p = join(rootDir, fs.indexes);
|
|
60
67
|
if (existsSync(p)) indexesFile = p;
|
|
61
68
|
}
|
|
62
|
-
if (typeof
|
|
63
|
-
const p = join(rootDir,
|
|
69
|
+
if (typeof st?.rules === "string") {
|
|
70
|
+
const p = join(rootDir, st.rules);
|
|
64
71
|
if (existsSync(p)) storageFile = p;
|
|
65
72
|
}
|
|
73
|
+
const fns = raw.functions;
|
|
74
|
+
if (typeof fns === "string" && fns.length > 0) {
|
|
75
|
+
const p = join(rootDir, fns);
|
|
76
|
+
try {
|
|
77
|
+
if (statSync(p).isDirectory()) functionsDir = p;
|
|
78
|
+
} catch {
|
|
79
|
+
}
|
|
80
|
+
} else if (fns && typeof fns === "object" && typeof fns.source === "string" && fns.source.length > 0) {
|
|
81
|
+
const p = join(rootDir, fns.source);
|
|
82
|
+
try {
|
|
83
|
+
if (statSync(p).isDirectory()) functionsDir = p;
|
|
84
|
+
} catch {
|
|
85
|
+
}
|
|
86
|
+
}
|
|
66
87
|
} catch {
|
|
67
88
|
}
|
|
68
89
|
}
|
|
@@ -105,7 +126,7 @@ function classifyOrigin(file, content) {
|
|
|
105
126
|
if (content.includes("firebase-admin") || content.includes("firebase-functions")) {
|
|
106
127
|
return "CLIENT";
|
|
107
128
|
}
|
|
108
|
-
if (
|
|
129
|
+
if (hasSeg("src", "app", "components")) {
|
|
109
130
|
return "CLIENT";
|
|
110
131
|
}
|
|
111
132
|
return "UNKNOWN";
|
|
@@ -139,7 +160,7 @@ async function enrichWithGraph(rootDir, d) {
|
|
|
139
160
|
const ai = await import("@justmpm/ai-tool");
|
|
140
161
|
const res = await ai.map({ cwd: rootDir, format: "json" });
|
|
141
162
|
const files = Array.isArray(res.files) ? res.files : [];
|
|
142
|
-
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);
|
|
163
|
+
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|build|\.next|coverage|lib)\//.test(p)).slice(0, 500);
|
|
143
164
|
const { readFileSync: readSync, existsSync: existsSyncFn, statSync: statSync2 } = await import("fs");
|
|
144
165
|
const { join: joinP } = await import("path");
|
|
145
166
|
for (const rel of code) {
|
|
@@ -164,6 +185,10 @@ async function enrichWithGraph(rootDir, d) {
|
|
|
164
185
|
import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
|
|
165
186
|
import { join as join2 } from "path";
|
|
166
187
|
import { executeFind } from "@justmpm/supergrep";
|
|
188
|
+
function isBuildOutput(file) {
|
|
189
|
+
const n = file.replace(/\\/g, "/");
|
|
190
|
+
return /(^|\/)(node_modules|dist|build|\.next|coverage|out)\//.test(n);
|
|
191
|
+
}
|
|
167
192
|
var IMPLEMENTED_CHECKS = ["FBA001", "FBA002", "FBA003", "FBA004", "FBA009", "FBA010", "FBA011", "FBA012", "FBA013"];
|
|
168
193
|
function createEvidence() {
|
|
169
194
|
let counter = 0;
|
|
@@ -546,6 +571,7 @@ async function collectPermissionCalls(rootDir, adapterFn) {
|
|
|
546
571
|
}
|
|
547
572
|
const seen = /* @__PURE__ */ new Set();
|
|
548
573
|
const pushLiteral = (file, line, permission) => {
|
|
574
|
+
if (isBuildOutput(file)) return;
|
|
549
575
|
const k = `${file}:${line}:${permission}`;
|
|
550
576
|
if (seen.has(k)) return;
|
|
551
577
|
seen.add(k);
|
|
@@ -558,10 +584,12 @@ async function collectPermissionCalls(rootDir, adapterFn) {
|
|
|
558
584
|
const raw = (m.metaVariables["PERM"] ?? "").trim();
|
|
559
585
|
const line = m.line + 1;
|
|
560
586
|
if (raw.includes("${")) {
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
seen.
|
|
564
|
-
|
|
587
|
+
if (!isBuildOutput(m.file)) {
|
|
588
|
+
const k = `${m.file}:${line}:${raw}`;
|
|
589
|
+
if (!seen.has(k)) {
|
|
590
|
+
seen.add(k);
|
|
591
|
+
dynamic.push({ file: m.file, line, text: m.text });
|
|
592
|
+
}
|
|
565
593
|
}
|
|
566
594
|
continue;
|
|
567
595
|
}
|
|
@@ -570,18 +598,22 @@ async function collectPermissionCalls(rootDir, adapterFn) {
|
|
|
570
598
|
const perm = raw.replace(/^['"`]|['"`]$/g, "").trim();
|
|
571
599
|
if (/^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)*$/.test(perm)) pushLiteral(m.file, line, perm);
|
|
572
600
|
else {
|
|
573
|
-
|
|
601
|
+
if (!isBuildOutput(m.file)) {
|
|
602
|
+
const k = `${m.file}:${line}:${raw}:invalid`;
|
|
603
|
+
if (!seen.has(k)) {
|
|
604
|
+
seen.add(k);
|
|
605
|
+
dynamic.push({ file: m.file, line, text: m.text });
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
} else if (raw.length > 0) {
|
|
610
|
+
if (!isBuildOutput(m.file)) {
|
|
611
|
+
const k = `${m.file}:${line}:${raw}`;
|
|
574
612
|
if (!seen.has(k)) {
|
|
575
613
|
seen.add(k);
|
|
576
614
|
dynamic.push({ file: m.file, line, text: m.text });
|
|
577
615
|
}
|
|
578
616
|
}
|
|
579
|
-
} else if (raw.length > 0) {
|
|
580
|
-
const k = `${m.file}:${line}:${raw}`;
|
|
581
|
-
if (!seen.has(k)) {
|
|
582
|
-
seen.add(k);
|
|
583
|
-
dynamic.push({ file: m.file, line, text: m.text });
|
|
584
|
-
}
|
|
585
617
|
}
|
|
586
618
|
}
|
|
587
619
|
} catch {
|
|
@@ -598,6 +630,10 @@ async function collectAdminImports(rootDir) {
|
|
|
598
630
|
"import $DEFAULT from '$MODULE'",
|
|
599
631
|
'import * as $NS from "$MODULE"',
|
|
600
632
|
"import * as $NS from '$MODULE'",
|
|
633
|
+
'export { $$$ITEMS } from "$MODULE"',
|
|
634
|
+
"export { $$$ITEMS } from '$MODULE'",
|
|
635
|
+
'export * from "$MODULE"',
|
|
636
|
+
"export * from '$MODULE'",
|
|
601
637
|
'import "$MODULE"',
|
|
602
638
|
"import '$MODULE'",
|
|
603
639
|
'const $X = require("$MODULE")',
|
|
@@ -614,6 +650,7 @@ async function collectAdminImports(rootDir) {
|
|
|
614
650
|
for (const m of result.matches) {
|
|
615
651
|
const mod = m.metaVariables["MODULE"] ?? "";
|
|
616
652
|
if (mod.includes("firebase-admin") || mod.includes("firebase-functions")) {
|
|
653
|
+
if (isBuildOutput(m.file)) continue;
|
|
617
654
|
const k = `${m.file}:${m.line}:${m.text}`;
|
|
618
655
|
if (seen.has(k)) continue;
|
|
619
656
|
seen.add(k);
|
|
@@ -796,7 +833,7 @@ var RoleNameSchema = z.string().regex(/^[a-z][a-z0-9_-]*$/);
|
|
|
796
833
|
var AuditYamlSchema = z.strictObject({
|
|
797
834
|
$schema: z.string().optional(),
|
|
798
835
|
authorization: z.strictObject({
|
|
799
|
-
adapter: z.strictObject({ function: z.string().min(1) }).
|
|
836
|
+
adapter: z.strictObject({ function: z.string().min(1) }).nullish(),
|
|
800
837
|
roles: z.record(RoleNameSchema, z.strictObject({ permissions: z.array(PermissionNameSchema) })),
|
|
801
838
|
permissions: z.record(
|
|
802
839
|
PermissionNameSchema,
|
|
@@ -804,9 +841,10 @@ var AuditYamlSchema = z.strictObject({
|
|
|
804
841
|
),
|
|
805
842
|
claims: z.strictObject({
|
|
806
843
|
enabled: z.boolean(),
|
|
807
|
-
|
|
844
|
+
// Reservado para o futuro (nenhum check consome ainda): opcional.
|
|
845
|
+
roleKey: z.string().min(1).optional(),
|
|
808
846
|
samples: z.record(RoleNameSchema, z.record(z.string().min(1), z.json())).optional()
|
|
809
|
-
}).
|
|
847
|
+
}).nullish()
|
|
810
848
|
})
|
|
811
849
|
});
|
|
812
850
|
var RESERVED_CLAIM_KEYS = [
|
|
@@ -860,11 +898,40 @@ async function scan(rootDir, opts = {}) {
|
|
|
860
898
|
try {
|
|
861
899
|
if (d.firebaseJson && existsSync3(d.firebaseJson)) {
|
|
862
900
|
const raw = JSON.parse(readFileSync3(d.firebaseJson, "utf-8"));
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
901
|
+
const asList = (v) => v === void 0 ? [] : Array.isArray(v) ? v : [v];
|
|
902
|
+
const entries = [];
|
|
903
|
+
for (const f of asList(raw.firestore)) {
|
|
904
|
+
entries.push({ kind: "rules", rel: f.rules, label: "firestore.rules" });
|
|
905
|
+
entries.push({ kind: "indexes", rel: f.indexes, label: "firestore.indexes" });
|
|
906
|
+
}
|
|
907
|
+
for (const s of asList(raw.storage)) {
|
|
908
|
+
entries.push({ kind: "storage-rules", rel: s.rules, label: "storage.rules" });
|
|
909
|
+
}
|
|
910
|
+
if (Array.isArray(raw.firestore) && raw.firestore.length > 1) {
|
|
911
|
+
findings_pre.push({
|
|
912
|
+
rule: "FIREBASE_JSON_ARRAY",
|
|
913
|
+
severity: "INFO",
|
|
914
|
+
confidence: "CONFIRMED",
|
|
915
|
+
message: `firebase.json com ${raw.firestore.length} databases \u2014 este scan audita s\xF3 o default. Bancos nomeados (databaseId) fora do escopo.`,
|
|
916
|
+
fingerprint: "FIREBASE_JSON_ARRAY:firestore",
|
|
917
|
+
evidence: [
|
|
918
|
+
{ id: "ev-config-array-fs", kind: "config-parse", summary: `${raw.firestore.length} databases`, confidence: "CONFIRMED" }
|
|
919
|
+
]
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
if (Array.isArray(raw.storage) && raw.storage.length > 1) {
|
|
923
|
+
findings_pre.push({
|
|
924
|
+
rule: "FIREBASE_JSON_ARRAY",
|
|
925
|
+
severity: "INFO",
|
|
926
|
+
confidence: "CONFIRMED",
|
|
927
|
+
message: `firebase.json com ${raw.storage.length} buckets \u2014 este scan audita s\xF3 o default. Buckets extras fora do escopo.`,
|
|
928
|
+
fingerprint: "FIREBASE_JSON_ARRAY:storage",
|
|
929
|
+
evidence: [
|
|
930
|
+
{ id: "ev-config-array-st", kind: "config-parse", summary: `${raw.storage.length} buckets`, confidence: "CONFIRMED" }
|
|
931
|
+
]
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
for (const [kind, rel, label] of entries.map((e) => [e.kind, e.rel, e.label])) {
|
|
868
935
|
if (typeof rel === "string" && rel.length > 0) {
|
|
869
936
|
if (!existsSync3(join3(rootDir, rel))) {
|
|
870
937
|
findings_pre.push({
|
|
@@ -1122,6 +1189,9 @@ async function scan(rootDir, opts = {}) {
|
|
|
1122
1189
|
return `ev-storage-${prefix}-${evCounter}`;
|
|
1123
1190
|
};
|
|
1124
1191
|
for (const m of parseStorageAllows(clean)) {
|
|
1192
|
+
if (m.condition !== void 0 && stripOuterParens(m.condition).toLowerCase().replace(/\s+/g, "") === "false") {
|
|
1193
|
+
continue;
|
|
1194
|
+
}
|
|
1125
1195
|
const ops = expandStorageOps(m.target);
|
|
1126
1196
|
const key = conditionKeyForFingerprint(m.condition);
|
|
1127
1197
|
const normCond = (m.condition ?? "").toLowerCase().replace(/\s+/g, "");
|
|
@@ -1242,20 +1312,23 @@ async function scan(rootDir, opts = {}) {
|
|
|
1242
1312
|
rule: "APPCHECK_ENFORCED",
|
|
1243
1313
|
severity: "INFO",
|
|
1244
1314
|
confidence: "CONFIRMED",
|
|
1245
|
-
message: `enforceAppCheck observado (${appcheck.occurrences} ocorr\xEAncia(s) em ${appcheck.files} arquivo(s)). Carimbo do app oficial \u2014 n\xE3o prova quem \xE9 o usu\xE1rio nem o tenant. Autoriza\xE7\xE3o segue nas Rules/guardas.`,
|
|
1315
|
+
message: `enforceAppCheck observado (${appcheck.occurrences} ocorr\xEAncia(s) em ${appcheck.files} arquivo(s), fora de onRequest). Carimbo do app oficial \u2014 n\xE3o prova quem \xE9 o usu\xE1rio nem o tenant. Autoriza\xE7\xE3o segue nas Rules/guardas.`,
|
|
1246
1316
|
fingerprint: "APPCHECK_ENFORCED",
|
|
1247
1317
|
evidence: [{ id: "ev-appcheck", kind: "appcheck", summary: `${appcheck.occurrences} ocorr\xEAncias`, confidence: "CONFIRMED" }]
|
|
1248
1318
|
});
|
|
1249
|
-
} else if (appcheck.manual === 0) {
|
|
1319
|
+
} else if (appcheck.manual === 0 && appcheck.ignoredOnRequest === 0) {
|
|
1250
1320
|
skipped.push("functions sem enforceAppCheck literal nem verifyToken observado \u2014 AppCheck sem cobertura afirmativa (config via vari\xE1vel conta como n\xE3o observada; onRequest exige verifica\xE7\xE3o manual por rota)");
|
|
1251
1321
|
}
|
|
1322
|
+
if (appcheck.ignoredOnRequest > 0) {
|
|
1323
|
+
skipped.push(`enforceAppCheck em onRequest ignorado pelo runtime (${appcheck.ignoredOnRequest} ocorr\xEAncia(s)) \u2014 onRequest exige verifyToken por rota`);
|
|
1324
|
+
}
|
|
1252
1325
|
if (appcheck.limitHit) {
|
|
1253
1326
|
skipped.push("functions: teto de varredura atingido \u2014 AppCheck parcial neste scan");
|
|
1254
1327
|
}
|
|
1255
1328
|
} else {
|
|
1256
1329
|
skipped.push("functions n\xE3o observadas \u2014 AppCheck fora deste scan");
|
|
1257
1330
|
}
|
|
1258
|
-
skipped.push("isolamento por tenant (ownerId/tenantId) n\xE3o
|
|
1331
|
+
skipped.push("isolamento por tenant (ownerId/tenantId) e mismatch de papel n\xE3o-admin fora dos checks \u2014 coberto s\xF3 via verify/Emulator, nunca verde silencioso");
|
|
1259
1332
|
const modelCheck = ProjectModelSchema.safeParse(model);
|
|
1260
1333
|
if (!modelCheck.success) {
|
|
1261
1334
|
findings.push({
|
|
@@ -1320,6 +1393,7 @@ async function countAppCheckEnforced(rootDir) {
|
|
|
1320
1393
|
let occurrences = 0;
|
|
1321
1394
|
const files = /* @__PURE__ */ new Set();
|
|
1322
1395
|
let manual = 0;
|
|
1396
|
+
let ignoredOnRequest = 0;
|
|
1323
1397
|
let limitHit = false;
|
|
1324
1398
|
const stack = [...roots];
|
|
1325
1399
|
let guard = 0;
|
|
@@ -1341,18 +1415,22 @@ async function countAppCheckEnforced(rootDir) {
|
|
|
1341
1415
|
const name = e.name;
|
|
1342
1416
|
const abs = joinP(cur, name);
|
|
1343
1417
|
let isDir = false;
|
|
1418
|
+
let size = 0;
|
|
1344
1419
|
try {
|
|
1345
|
-
|
|
1420
|
+
const st = stat(abs);
|
|
1421
|
+
isDir = st.isDirectory();
|
|
1422
|
+
size = st.size;
|
|
1346
1423
|
} catch {
|
|
1347
1424
|
continue;
|
|
1348
1425
|
}
|
|
1349
1426
|
if (isDir) {
|
|
1350
|
-
if (name === "node_modules" || name === "dist" || name === "lib") continue;
|
|
1427
|
+
if (name === "node_modules" || name === "dist" || name === "lib" || name === "build" || name === ".next" || name === "coverage" || name === ".git" || name === "out") continue;
|
|
1351
1428
|
stack.push(abs);
|
|
1352
1429
|
continue;
|
|
1353
1430
|
}
|
|
1354
1431
|
if (!/\.(ts|js|mjs|cjs|py)$/.test(name)) continue;
|
|
1355
1432
|
if (isTestFile(abs)) continue;
|
|
1433
|
+
if (size > 100 * 1024) continue;
|
|
1356
1434
|
try {
|
|
1357
1435
|
let text = strip(readSync(abs, "utf-8"));
|
|
1358
1436
|
if (name.endsWith(".py")) {
|
|
@@ -1361,10 +1439,16 @@ async function countAppCheckEnforced(rootDir) {
|
|
|
1361
1439
|
return idx === -1 ? l : l.slice(0, idx);
|
|
1362
1440
|
}).join("\n");
|
|
1363
1441
|
}
|
|
1364
|
-
const
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1442
|
+
for (const em of text.matchAll(/enforceAppCheck\s*:\s*true|enforce_app_check\s*=\s*True/g)) {
|
|
1443
|
+
const before = text.slice(Math.max(0, (em.index ?? 0) - 600), em.index ?? 0);
|
|
1444
|
+
const triggers = [...before.matchAll(/on(Request|Call|request|call)\s*[\(.]/g)].map((t) => t[1].toLowerCase());
|
|
1445
|
+
const last = triggers[triggers.length - 1];
|
|
1446
|
+
if (last === "request") {
|
|
1447
|
+
ignoredOnRequest += 1;
|
|
1448
|
+
} else {
|
|
1449
|
+
occurrences += 1;
|
|
1450
|
+
files.add(abs);
|
|
1451
|
+
}
|
|
1368
1452
|
}
|
|
1369
1453
|
const manualHits = text.match(/verifyToken\s*\(|verify_token\s*\(|consumeAppCheckToken|getLimitedUseToken|getAppCheck\s*\(/g);
|
|
1370
1454
|
if (manualHits) manual += manualHits.length;
|
|
@@ -1372,9 +1456,9 @@ async function countAppCheckEnforced(rootDir) {
|
|
|
1372
1456
|
}
|
|
1373
1457
|
}
|
|
1374
1458
|
}
|
|
1375
|
-
return { occurrences, files: files.size, manual, limitHit };
|
|
1459
|
+
return { occurrences, files: files.size, manual, ignoredOnRequest, limitHit };
|
|
1376
1460
|
} catch {
|
|
1377
|
-
return { occurrences: 0, files: 0, manual: 0, limitHit: false };
|
|
1461
|
+
return { occurrences: 0, files: 0, manual: 0, ignoredOnRequest: 0, limitHit: false };
|
|
1378
1462
|
}
|
|
1379
1463
|
}
|
|
1380
1464
|
function isOpenStorageCondition(condition) {
|
|
@@ -102,8 +102,9 @@ function verify(model, opts = {}) {
|
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
|
+
const uncoveredNote = "uncovered lista os paths do modelo \u2014 atribui\xE7\xE3o contador\u2192path indispon\xEDvel (s\xF3 coverage 100% esvazia)";
|
|
105
106
|
if (coveragePct === 100) {
|
|
106
|
-
return { cases, uncovered: [], findings, coveragePct };
|
|
107
|
+
return { cases, uncovered: [], findings, coveragePct, uncoveredNote };
|
|
107
108
|
}
|
|
108
109
|
const seenPaths = /* @__PURE__ */ new Set();
|
|
109
110
|
for (const rule of model.rules) {
|
|
@@ -112,7 +113,7 @@ function verify(model, opts = {}) {
|
|
|
112
113
|
seenPaths.add(rule.path);
|
|
113
114
|
uncovered.push(rule.path);
|
|
114
115
|
}
|
|
115
|
-
return { cases, uncovered, findings, coveragePct };
|
|
116
|
+
return { cases, uncovered, findings, coveragePct, uncoveredNote };
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
export {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
scan
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-BB6XXEIK.js";
|
|
4
4
|
import {
|
|
5
5
|
verify
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-DIO36A3I.js";
|
|
7
7
|
import {
|
|
8
8
|
drift
|
|
9
9
|
} from "./chunk-XZ32YQQC.js";
|
|
@@ -152,7 +152,7 @@ function createMcpServer() {
|
|
|
152
152
|
try {
|
|
153
153
|
assertCwd(cwd);
|
|
154
154
|
const { resolve } = await import("path");
|
|
155
|
-
const { model } = await scan(cwd);
|
|
155
|
+
const { summary: scanSummary, model } = await scan(cwd);
|
|
156
156
|
const res = verify(model, { coverageFile: coverage ? resolve(cwd, coverage) : void 0 });
|
|
157
157
|
const roles = model.authorization.roles.length > 0 ? model.authorization.roles.map((r) => r.name) : ["anonymous", "user", "admin"];
|
|
158
158
|
const truncated = res.cases.length > 50;
|
|
@@ -169,8 +169,10 @@ function createMcpServer() {
|
|
|
169
169
|
truncated,
|
|
170
170
|
uncovered: res.uncovered.slice(0, 20),
|
|
171
171
|
uncoveredTotal: res.uncovered.length,
|
|
172
|
+
uncoveredNote: res.uncoveredNote,
|
|
172
173
|
coveragePct: res.coveragePct,
|
|
173
|
-
findings: res.findings
|
|
174
|
+
findings: res.findings,
|
|
175
|
+
scan: { errors: scanSummary.errors, warnings: scanSummary.warnings, note: "verify n\xE3o substitui scan/check" }
|
|
174
176
|
},
|
|
175
177
|
null,
|
|
176
178
|
2
|
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-BB6XXEIK.js";
|
|
5
5
|
import "./chunk-FECVBQUO.js";
|
|
6
6
|
|
|
7
7
|
// src/cli.ts
|
|
@@ -41,13 +41,13 @@ Uso: firebase-audit scan [--json] [--strict] [--adapter=fn] [--cwd=path]`);
|
|
|
41
41
|
return void 0;
|
|
42
42
|
};
|
|
43
43
|
const strictArg = getArg("strict");
|
|
44
|
-
const strict = args.includes("--strict") || strictArg === "true" || cmd === "check";
|
|
44
|
+
const strict = (args.includes("--strict") || strictArg === "true") && strictArg !== "false" || cmd === "check";
|
|
45
45
|
if (strictArg !== void 0 && strictArg !== "true" && strictArg !== "false") {
|
|
46
46
|
console.error(`Valor inv\xE1lido para --strict: ${strictArg} (use --strict, --strict=true ou --strict=false)`);
|
|
47
47
|
process.exit(1);
|
|
48
48
|
}
|
|
49
49
|
const asJsonArg = getArg("json");
|
|
50
|
-
const asJson = args.includes("--json") || asJsonArg === "true";
|
|
50
|
+
const asJson = (args.includes("--json") || asJsonArg === "true") && asJsonArg !== "false";
|
|
51
51
|
if (asJsonArg !== void 0 && asJsonArg !== "true" && asJsonArg !== "false") {
|
|
52
52
|
console.error(`Valor inv\xE1lido para --json: ${asJsonArg} (use --json, --json=true ou --json=false)`);
|
|
53
53
|
process.exit(1);
|
|
@@ -81,23 +81,25 @@ Uso: firebase-audit scan|check|verify|drift [--json] ...`);
|
|
|
81
81
|
const { resolve: resolveRoot } = await import("path");
|
|
82
82
|
const rootDir = cwdArg ? resolveRoot(process.cwd(), cwdArg) : process.cwd();
|
|
83
83
|
if (cmd === "verify") {
|
|
84
|
-
const { scan: scanForVerify } = await import("./scan-
|
|
85
|
-
const { verify } = await import("./verify-
|
|
84
|
+
const { scan: scanForVerify } = await import("./scan-SDJSDRDQ.js");
|
|
85
|
+
const { verify } = await import("./verify-DECP4IUW.js");
|
|
86
86
|
const { resolve: resolveVerify } = await import("path");
|
|
87
87
|
const coverageArg = getArg("coverage");
|
|
88
88
|
const coveragePath = coverageArg ? resolveVerify(rootDir, coverageArg) : void 0;
|
|
89
|
-
const { model } = await scanForVerify(rootDir, { adapterFn: adapterArg });
|
|
89
|
+
const { model, summary: scanSummary } = await scanForVerify(rootDir, { adapterFn: adapterArg });
|
|
90
90
|
const res = verify(model, { coverageFile: coveragePath });
|
|
91
91
|
const roles = model.authorization.roles.length > 0 ? model.authorization.roles.map((r) => r.name) : ["anonymous", "user", "admin"];
|
|
92
92
|
const rolesReduced = model.authorization.roles.length === 0;
|
|
93
|
+
const scanRef = { errors: scanSummary.errors, warnings: scanSummary.warnings };
|
|
93
94
|
if (asJson) {
|
|
94
|
-
console.log(JSON.stringify({ roles, rolesReduced, cases: res.cases.slice(0, 50), casesTotal: res.cases.length, truncated: res.cases.length > 50, uncovered: res.uncovered.slice(0, 20), uncoveredTotal: res.uncovered.length, coveragePct: res.coveragePct, findings: res.findings }, null, 2));
|
|
95
|
+
console.log(JSON.stringify({ roles, rolesReduced, cases: res.cases.slice(0, 50), casesTotal: res.cases.length, truncated: res.cases.length > 50, uncovered: res.uncovered.slice(0, 20), uncoveredTotal: res.uncovered.length, uncoveredNote: res.uncoveredNote, coveragePct: res.coveragePct, findings: res.findings, scan: scanRef }, null, 2));
|
|
95
96
|
return;
|
|
96
97
|
}
|
|
97
98
|
const covStr = res.coveragePct === null ? "sem cobertura observada" : `${res.coveragePct}% de express\xF5es visitadas`;
|
|
98
99
|
const rolesNote = rolesReduced ? "pap\xE9is reduzidos (sem contrato: anonymous/user/admin)" : `${roles.length} pap\xE9is do contrato (${roles.join(", ")})`;
|
|
99
100
|
console.log(`
|
|
100
101
|
FIREBASE AUDIT verify \u2014 ${res.cases.length} casos planejados (${rolesNote}), ${res.uncovered.length} paths sem cobertura (${covStr}).`);
|
|
102
|
+
console.log(`Scan est\xE1tico junto: ${scanRef.errors} errors, ${scanRef.warnings} warnings \u2014 verify n\xE3o substitui scan/check.`);
|
|
101
103
|
for (const f of res.findings) {
|
|
102
104
|
const icon = f.severity === "ERROR" ? "\u274C" : f.severity === "WARNING" ? "\u26A0" : "\u2139";
|
|
103
105
|
console.log(`
|
|
@@ -156,6 +158,9 @@ FIREBASE AUDIT drift \u2014 MATCHED ${counts("MATCHED")}, LOCAL_ONLY ${counts("L
|
|
|
156
158
|
return;
|
|
157
159
|
}
|
|
158
160
|
const { findings, summary } = await scan(rootDir, { strict, adapterFn: adapterArg });
|
|
161
|
+
if (!strict && summary.errors > 0) {
|
|
162
|
+
console.error(`\u26A0 scan encontrou ${summary.errors} ERROR(s) mas sai 0 \u2014 use 'check' no CI para reprovar.`);
|
|
163
|
+
}
|
|
159
164
|
if (asJson) {
|
|
160
165
|
console.log(JSON.stringify({ summary, findings }, null, 2));
|
|
161
166
|
if (strict && summary.errors > 0) process.exit(2);
|
package/dist/index.d.ts
CHANGED
|
@@ -577,9 +577,9 @@ declare const FindingSchema: z.ZodObject<{
|
|
|
577
577
|
declare const AuditYamlSchema: z.ZodObject<{
|
|
578
578
|
$schema: z.ZodOptional<z.ZodString>;
|
|
579
579
|
authorization: z.ZodObject<{
|
|
580
|
-
adapter: z.ZodOptional<z.ZodObject<{
|
|
580
|
+
adapter: z.ZodOptional<z.ZodNullable<z.ZodObject<{
|
|
581
581
|
function: z.ZodString;
|
|
582
|
-
}, z.core.$strict
|
|
582
|
+
}, z.core.$strict>>>;
|
|
583
583
|
roles: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
584
584
|
permissions: z.ZodArray<z.ZodString>;
|
|
585
585
|
}, z.core.$strict>>;
|
|
@@ -595,11 +595,11 @@ declare const AuditYamlSchema: z.ZodObject<{
|
|
|
595
595
|
write: "write";
|
|
596
596
|
}>;
|
|
597
597
|
}, z.core.$strict>>;
|
|
598
|
-
claims: z.ZodOptional<z.ZodObject<{
|
|
598
|
+
claims: z.ZodOptional<z.ZodNullable<z.ZodObject<{
|
|
599
599
|
enabled: z.ZodBoolean;
|
|
600
|
-
roleKey: z.ZodString
|
|
600
|
+
roleKey: z.ZodOptional<z.ZodString>;
|
|
601
601
|
samples: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodJSONSchema>>>;
|
|
602
|
-
}, z.core.$strict
|
|
602
|
+
}, z.core.$strict>>>;
|
|
603
603
|
}, z.core.$strict>;
|
|
604
604
|
}, z.core.$strict>;
|
|
605
605
|
/** Chaves OIDC/Firebase reservadas — nunca em custom claims (1000 bytes, só acesso). */
|
|
@@ -639,7 +639,8 @@ interface DiscoveryResult {
|
|
|
639
639
|
declare function discover(rootDir: string): DiscoveryResult;
|
|
640
640
|
/** Segmentos de servidor (fail-closed: `api` genérico NÃO é server, ver classifyOrigin). */
|
|
641
641
|
declare const SERVER_HINTS_SEGMENTS: string[];
|
|
642
|
-
/** Heurística CLIENT vs SERVER: caminho manda primeiro (grafo ai-tool preenche listas na Fase 3).
|
|
642
|
+
/** Heurística CLIENT vs SERVER: caminho manda primeiro (grafo ai-tool preenche listas na Fase 3).
|
|
643
|
+
* Segmentos ancorados — nunca substring solta (`myapp/` não é `app/`). */
|
|
643
644
|
declare function classifyOrigin(file: string, content: string): "CLIENT" | "SERVER" | "UNKNOWN";
|
|
644
645
|
declare function emptyModel(rootDir: string, d: DiscoveryResult): ProjectModel;
|
|
645
646
|
/**
|
|
@@ -752,8 +753,9 @@ interface ScanOptions {
|
|
|
752
753
|
adapterFn?: string;
|
|
753
754
|
strict?: boolean;
|
|
754
755
|
/**
|
|
755
|
-
*
|
|
756
|
-
*
|
|
756
|
+
* Prévia opt-in: enriquece Discovery com grafo do ai-tool (clientFiles/serverFiles).
|
|
757
|
+
* Nenhum check consome o grafo ainda — resultado idêntico com ou sem.
|
|
758
|
+
* Default false (scan estático rápido).
|
|
757
759
|
*/
|
|
758
760
|
graph?: boolean;
|
|
759
761
|
}
|
|
@@ -806,6 +808,8 @@ interface VerifyResult {
|
|
|
806
808
|
findings: Finding[];
|
|
807
809
|
/** 0-100 quando o coverage tem visitCount avaliável; null = não observada. */
|
|
808
810
|
coveragePct: number | null;
|
|
811
|
+
/** Como ler `uncovered`: atribuição por expressão indisponível (só 100% esvazia). */
|
|
812
|
+
uncoveredNote: string;
|
|
809
813
|
}
|
|
810
814
|
/** Gera matriz role × operação × path a partir do modelo (sem executar nada).
|
|
811
815
|
* Mapeamento: read≡get+list, write≡create+update+delete (mesma semântica do opsFrom).
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createMcpServer,
|
|
3
3
|
startMcpServer
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-SH6HFX5Y.js";
|
|
5
5
|
import {
|
|
6
6
|
AccessOriginSchema,
|
|
7
7
|
AuditYamlSchema,
|
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
runChecks,
|
|
34
34
|
scan,
|
|
35
35
|
validateClaimSamples
|
|
36
|
-
} from "./chunk-
|
|
36
|
+
} from "./chunk-BB6XXEIK.js";
|
|
37
37
|
import {
|
|
38
38
|
buildFullPath,
|
|
39
39
|
conditionKeyForFingerprint,
|
|
@@ -59,7 +59,7 @@ import {
|
|
|
59
59
|
import {
|
|
60
60
|
planVerify,
|
|
61
61
|
verify
|
|
62
|
-
} from "./chunk-
|
|
62
|
+
} from "./chunk-DIO36A3I.js";
|
|
63
63
|
import {
|
|
64
64
|
drift
|
|
65
65
|
} from "./chunk-XZ32YQQC.js";
|
package/dist/mcp-cli.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
startMcpServer
|
|
4
|
-
} from "./chunk-
|
|
5
|
-
import "./chunk-
|
|
4
|
+
} from "./chunk-SH6HFX5Y.js";
|
|
5
|
+
import "./chunk-BB6XXEIK.js";
|
|
6
6
|
import "./chunk-FECVBQUO.js";
|
|
7
|
-
import "./chunk-
|
|
7
|
+
import "./chunk-DIO36A3I.js";
|
|
8
8
|
import "./chunk-XZ32YQQC.js";
|
|
9
9
|
|
|
10
10
|
// src/mcp-cli.ts
|
package/dist/mcp.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createMcpServer,
|
|
3
3
|
startMcpServer
|
|
4
|
-
} from "./chunk-
|
|
5
|
-
import "./chunk-
|
|
4
|
+
} from "./chunk-SH6HFX5Y.js";
|
|
5
|
+
import "./chunk-BB6XXEIK.js";
|
|
6
6
|
import "./chunk-FECVBQUO.js";
|
|
7
|
-
import "./chunk-
|
|
7
|
+
import "./chunk-DIO36A3I.js";
|
|
8
8
|
import "./chunk-XZ32YQQC.js";
|
|
9
9
|
export {
|
|
10
10
|
createMcpServer,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@justmpm/firebase-audit",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.6",
|
|
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
|
@@ -35,4 +35,5 @@ Use `drift` para comparar índices locais vs implantados (MATCHED/LOCAL_ONLY/REM
|
|
|
35
35
|
## Limites honestos
|
|
36
36
|
|
|
37
37
|
- AppCheck: varredura TS/Python por texto (`enforceAppCheck`, `verifyToken` e afins). Config via variável conta como não observada; `onRequest` exige revisão rota a rota.
|
|
38
|
-
- Isolamento por tenant (ownerId/tenantId): fora dos checks — revise manualmente ou no Emulator.
|
|
38
|
+
- Isolamento por tenant (ownerId/tenantId) e mismatch de papel não-admin: fora dos checks — revise manualmente ou no Emulator.
|
|
39
|
+
- `graph: true` é prévia opt-in da biblioteca (nenhum check consome ainda).
|