@justmpm/firebase-audit 0.1.1 → 0.3.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-CPEIPRCF.js +79 -0
- package/dist/chunk-GUSNXUAT.js +147 -0
- package/dist/{chunk-N72YT24M.js → chunk-N57MCLTZ.js} +228 -37
- package/dist/cli.js +108 -7
- package/dist/drift-BEXSL2IX.js +6 -0
- package/dist/index.d.ts +67 -8
- package/dist/index.js +16 -2
- package/dist/scan-773ZFQXB.js +6 -0
- package/dist/verify-Y7BWAFPL.js +8 -0
- package/package.json +1 -1
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// src/verify.ts
|
|
2
|
+
import { readFileSync, existsSync } from "fs";
|
|
3
|
+
function planVerify(model) {
|
|
4
|
+
const roles = model.authorization.roles.length > 0 ? model.authorization.roles.map((r) => r.name) : ["anonymous", "user", "admin"];
|
|
5
|
+
const expand = (op) => {
|
|
6
|
+
if (op === "read") return ["get", "list"];
|
|
7
|
+
if (op === "get" || op === "list" || op === "create" || op === "update" || op === "delete") return [op];
|
|
8
|
+
if (op === "write") return ["create", "update", "delete"];
|
|
9
|
+
return [];
|
|
10
|
+
};
|
|
11
|
+
const cases = [];
|
|
12
|
+
for (const rule of model.rules) {
|
|
13
|
+
for (const role of roles) {
|
|
14
|
+
for (const op of rule.operations) {
|
|
15
|
+
for (const mapped of expand(op)) {
|
|
16
|
+
const isList = mapped === "list";
|
|
17
|
+
const path = isList ? rule.path.replace(/\{[^}]+\}/g, "demo-id").replace(/\/demo-id$/, "") : rule.path.replace(/\{[^}]+\}/g, "demo-id");
|
|
18
|
+
cases.push({ role, operation: mapped, path, pathTemplate: rule.path, expected: "UNKNOWN" });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const seen = /* @__PURE__ */ new Set();
|
|
24
|
+
return cases.filter((c) => {
|
|
25
|
+
const k = `${c.role}:${c.operation}:${c.path}`;
|
|
26
|
+
if (seen.has(k)) return false;
|
|
27
|
+
seen.add(k);
|
|
28
|
+
return true;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
function verify(model, opts = {}) {
|
|
32
|
+
const cases = planVerify(model);
|
|
33
|
+
const findings = [];
|
|
34
|
+
const uncovered = [];
|
|
35
|
+
if (opts.coverageFile) {
|
|
36
|
+
if (!existsSync(opts.coverageFile)) {
|
|
37
|
+
findings.push({
|
|
38
|
+
rule: "COVERAGE_NOT_OBSERVED",
|
|
39
|
+
severity: "INFO",
|
|
40
|
+
confidence: "NOT_OBSERVED",
|
|
41
|
+
message: "Cobertura n\xE3o encontrada \u2014 rode `firebase emulators:exec` e passe --coverage=coverage.json.",
|
|
42
|
+
fingerprint: "COVERAGE_NOT_OBSERVED",
|
|
43
|
+
evidence: [{ id: "ev-no-coverage", kind: "coverage-missing", summary: "sem coverage", confidence: "NOT_OBSERVED" }],
|
|
44
|
+
fix: 'firebase emulators:exec --only firestore "npm test" + curl :ruleCoverage'
|
|
45
|
+
});
|
|
46
|
+
} else {
|
|
47
|
+
try {
|
|
48
|
+
const raw = JSON.parse(readFileSync(opts.coverageFile, "utf-8"));
|
|
49
|
+
void raw;
|
|
50
|
+
findings.push({
|
|
51
|
+
rule: "COVERAGE_NOT_EVALUATED",
|
|
52
|
+
severity: "WARNING",
|
|
53
|
+
confidence: "NOT_OBSERVED",
|
|
54
|
+
message: "Coverage recebido mas ainda n\xE3o avaliado nesta vers\xE3o (formato :ruleCoverage inst\xE1vel) \u2014 uncovered lista tudo por honestidade.",
|
|
55
|
+
fingerprint: "COVERAGE_NOT_EVALUATED",
|
|
56
|
+
evidence: [{ id: "ev-coverage-ne", kind: "coverage-parse", summary: "n\xE3o avaliado", confidence: "NOT_OBSERVED" }]
|
|
57
|
+
});
|
|
58
|
+
} catch {
|
|
59
|
+
findings.push({
|
|
60
|
+
rule: "COVERAGE_INVALID",
|
|
61
|
+
severity: "WARNING",
|
|
62
|
+
confidence: "CONFIRMED",
|
|
63
|
+
message: "Arquivo de cobertura inv\xE1lido \u2014 Verifier sem cobertura neste run.",
|
|
64
|
+
fingerprint: "COVERAGE_INVALID",
|
|
65
|
+
evidence: [{ id: "ev-coverage", kind: "coverage-parse", summary: "JSON inv\xE1lido", confidence: "CONFIRMED" }]
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
for (const rule of model.rules) {
|
|
71
|
+
uncovered.push(rule.path);
|
|
72
|
+
}
|
|
73
|
+
return { cases, uncovered, findings };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export {
|
|
77
|
+
planVerify,
|
|
78
|
+
verify
|
|
79
|
+
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// src/drift.ts
|
|
2
|
+
import { readFileSync, existsSync } from "fs";
|
|
3
|
+
function normalizeMode(f) {
|
|
4
|
+
if (typeof f.fieldPath !== "string" || f.fieldPath.length === 0 || f.fieldPath === "__name__") return null;
|
|
5
|
+
if (f.vectorConfig !== void 0 && f.vectorConfig !== null) return { fieldPath: f.fieldPath, mode: "VECTOR" };
|
|
6
|
+
if (f.arrayConfig === "CONTAINS") return { fieldPath: f.fieldPath, mode: "ARRAY_CONTAINS" };
|
|
7
|
+
if (typeof f.mode === "string" && (f.mode === "ASCENDING" || f.mode === "DESCENDING" || f.mode === "ARRAY_CONTAINS" || f.mode === "VECTOR")) {
|
|
8
|
+
return { fieldPath: f.fieldPath, mode: f.mode };
|
|
9
|
+
}
|
|
10
|
+
if (typeof f.order === "string" && (f.order === "ASCENDING" || f.order === "DESCENDING")) {
|
|
11
|
+
return { fieldPath: f.fieldPath, mode: f.order };
|
|
12
|
+
}
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
function normalizeEntry(raw) {
|
|
16
|
+
let cg = typeof raw.collectionGroup === "string" ? raw.collectionGroup : void 0;
|
|
17
|
+
if (!cg && typeof raw.name === "string") {
|
|
18
|
+
const m = raw.name.match(/collectionGroups\/([^/]+)\/indexes\//);
|
|
19
|
+
if (m) cg = m[1];
|
|
20
|
+
}
|
|
21
|
+
if (!cg) return null;
|
|
22
|
+
const scope = raw.queryScope === "COLLECTION_GROUP" ? "COLLECTION_GROUP" : "COLLECTION";
|
|
23
|
+
const fields = [];
|
|
24
|
+
if (Array.isArray(raw.fields)) {
|
|
25
|
+
for (const f of raw.fields) {
|
|
26
|
+
const n = normalizeMode(f);
|
|
27
|
+
if (n) fields.push(n);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return { collectionGroup: cg, queryScope: scope, fields };
|
|
31
|
+
}
|
|
32
|
+
function keyOf(i) {
|
|
33
|
+
const fields = i.fields.map((f) => `${f.fieldPath}:${f.mode}`).join(",");
|
|
34
|
+
return `${i.collectionGroup}|${i.queryScope}|${fields}`;
|
|
35
|
+
}
|
|
36
|
+
function normalizeList(raw) {
|
|
37
|
+
if (Array.isArray(raw)) return raw;
|
|
38
|
+
if (raw && typeof raw === "object" && Array.isArray(raw.indexes)) {
|
|
39
|
+
return raw.indexes;
|
|
40
|
+
}
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
function drift(localFile, remoteFile) {
|
|
44
|
+
const findings = [];
|
|
45
|
+
let local = [];
|
|
46
|
+
let remote = [];
|
|
47
|
+
if (localFile && existsSync(localFile)) {
|
|
48
|
+
try {
|
|
49
|
+
const raw = JSON.parse(readFileSync(localFile, "utf-8"));
|
|
50
|
+
const rawIndexes = normalizeList(raw);
|
|
51
|
+
const parsed = [];
|
|
52
|
+
let skipped = 0;
|
|
53
|
+
for (const r of rawIndexes) {
|
|
54
|
+
const n = normalizeEntry(r);
|
|
55
|
+
if (n) parsed.push(n);
|
|
56
|
+
else skipped += 1;
|
|
57
|
+
}
|
|
58
|
+
local = parsed;
|
|
59
|
+
if (skipped > 0) {
|
|
60
|
+
findings.push({
|
|
61
|
+
rule: "DRIFT_LOCAL_SKIPPED",
|
|
62
|
+
severity: "INFO",
|
|
63
|
+
confidence: "NOT_OBSERVED",
|
|
64
|
+
message: `${skipped} \xEDndice(s) local(is) ignorados por formato inv\xE1lido.`,
|
|
65
|
+
fingerprint: "DRIFT_LOCAL_SKIPPED",
|
|
66
|
+
evidence: [{ id: "ev-drift-skip", kind: "drift-parse", summary: `${skipped} ignorados`, confidence: "NOT_OBSERVED" }]
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
findings.push({
|
|
71
|
+
rule: "DRIFT_LOCAL_INVALID",
|
|
72
|
+
severity: "WARNING",
|
|
73
|
+
confidence: "CONFIRMED",
|
|
74
|
+
message: "\xCDndices locais inv\xE1lidos \u2014 drift sem base local.",
|
|
75
|
+
fingerprint: "DRIFT_LOCAL_INVALID",
|
|
76
|
+
evidence: [{ id: "ev-drift-local", kind: "drift-parse", summary: "local inv\xE1lido", confidence: "CONFIRMED" }]
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (remoteFile && existsSync(remoteFile)) {
|
|
81
|
+
try {
|
|
82
|
+
const list = normalizeList(JSON.parse(readFileSync(remoteFile, "utf-8")));
|
|
83
|
+
const parsed = [];
|
|
84
|
+
let skipped = 0;
|
|
85
|
+
for (const r of list) {
|
|
86
|
+
const n = normalizeEntry(r);
|
|
87
|
+
if (n) parsed.push(n);
|
|
88
|
+
else skipped += 1;
|
|
89
|
+
}
|
|
90
|
+
remote = parsed;
|
|
91
|
+
if (skipped > 0) {
|
|
92
|
+
findings.push({
|
|
93
|
+
rule: "DRIFT_REMOTE_SKIPPED",
|
|
94
|
+
severity: "INFO",
|
|
95
|
+
confidence: "NOT_OBSERVED",
|
|
96
|
+
message: `${skipped} \xEDndice(s) remoto(s) ignorados por formato inv\xE1lido.`,
|
|
97
|
+
fingerprint: "DRIFT_REMOTE_SKIPPED",
|
|
98
|
+
evidence: [{ id: "ev-drift-rskip", kind: "drift-parse", summary: `${skipped} ignorados`, confidence: "NOT_OBSERVED" }]
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
findings.push({
|
|
103
|
+
rule: "DRIFT_REMOTE_INVALID",
|
|
104
|
+
severity: "WARNING",
|
|
105
|
+
confidence: "CONFIRMED",
|
|
106
|
+
message: "\xCDndices remotos inv\xE1lidos \u2014 drift sem base remota.",
|
|
107
|
+
fingerprint: "DRIFT_REMOTE_INVALID",
|
|
108
|
+
evidence: [{ id: "ev-drift-remote", kind: "drift-parse", summary: "remoto inv\xE1lido", confidence: "CONFIRMED" }]
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const remoteByKey = new Map(remote.map((r) => [keyOf(r), r]));
|
|
113
|
+
const localByKey = new Map(local.map((l) => [keyOf(l), l]));
|
|
114
|
+
const entries = [];
|
|
115
|
+
for (const [key, l] of localByKey) {
|
|
116
|
+
if (remoteByKey.has(key)) entries.push({ key, status: "MATCHED", local: l, remote: remoteByKey.get(key) });
|
|
117
|
+
else {
|
|
118
|
+
entries.push({ key, status: "LOCAL_ONLY", local: l });
|
|
119
|
+
findings.push({
|
|
120
|
+
rule: "DRIFT_LOCAL_ONLY",
|
|
121
|
+
severity: "INFO",
|
|
122
|
+
confidence: "NOT_OBSERVED",
|
|
123
|
+
message: `\xCDndice s\xF3 local: ${key} \u2014 pode faltar deploy, n\xE3o significa unused remoto.`,
|
|
124
|
+
fingerprint: `DRIFT_LOCAL_ONLY:${key}`,
|
|
125
|
+
evidence: [{ id: `ev-drift-${entries.length}`, kind: "drift", summary: key, confidence: "NOT_OBSERVED" }]
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
for (const [key, r] of remoteByKey) {
|
|
130
|
+
if (!localByKey.has(key)) {
|
|
131
|
+
entries.push({ key, status: "REMOTE_ONLY", remote: r });
|
|
132
|
+
findings.push({
|
|
133
|
+
rule: "DRIFT_REMOTE_ONLY",
|
|
134
|
+
severity: "INFO",
|
|
135
|
+
confidence: "NOT_OBSERVED",
|
|
136
|
+
message: `\xCDndice s\xF3 em produ\xE7\xE3o: ${key} \u2014 pode ser de outro consumidor, n\xE3o significa unused.`,
|
|
137
|
+
fingerprint: `DRIFT_REMOTE_ONLY:${key}`,
|
|
138
|
+
evidence: [{ id: `ev-drift-r-${entries.length}`, kind: "drift", summary: key, confidence: "NOT_OBSERVED" }]
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { entries, findings };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export {
|
|
146
|
+
drift
|
|
147
|
+
};
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
// src/scan.ts
|
|
2
|
+
import { readFileSync as readFileSync4, existsSync as existsSync3 } from "fs";
|
|
3
|
+
import { parse as parseYaml } from "yaml";
|
|
4
|
+
|
|
1
5
|
// src/discovery.ts
|
|
2
6
|
import { existsSync, readFileSync } from "fs";
|
|
3
7
|
import { join, relative } from "path";
|
|
@@ -53,20 +57,18 @@ var SERVER_HINTS_SEGMENTS = [
|
|
|
53
57
|
"api",
|
|
54
58
|
"scripts"
|
|
55
59
|
];
|
|
56
|
-
function hasPathSegment(rel, seg) {
|
|
57
|
-
return rel.split("/").includes(seg);
|
|
58
|
-
}
|
|
59
60
|
function classifyOrigin(file, content) {
|
|
60
61
|
const rel = file.replace(/\\/g, "/");
|
|
61
|
-
|
|
62
|
+
const segs = rel.split("/");
|
|
63
|
+
const hasSeg = (...names) => names.some((n) => segs.includes(n));
|
|
64
|
+
if (rel.includes("app/api/") || rel.includes("pages/api/") || hasSeg("api") && segs.includes("pages")) {
|
|
62
65
|
return "SERVER";
|
|
63
66
|
}
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
if (hasPathSegment(rel, "functions") || hasPathSegment(rel, "server") || hasPathSegment(rel, "backend") || hasPathSegment(rel, "scripts") || rel.includes("src/app/api/") || rel.includes("app/api/")) {
|
|
67
|
-
return "SERVER";
|
|
68
|
-
}
|
|
67
|
+
if (segs.includes("app") && rel.endsWith("route.ts") || segs.includes("app") && rel.endsWith("route.js")) {
|
|
68
|
+
return "SERVER";
|
|
69
69
|
}
|
|
70
|
+
if (rel.includes("src/app/api/")) return "SERVER";
|
|
71
|
+
if (hasSeg("functions", "server", "backend", "scripts")) return "SERVER";
|
|
70
72
|
if (content.includes("firebase-admin") || content.includes("firebase-functions")) {
|
|
71
73
|
return "CLIENT";
|
|
72
74
|
}
|
|
@@ -165,6 +167,30 @@ function opsFrom(target) {
|
|
|
165
167
|
}
|
|
166
168
|
return { ops: out, known: out.length > 0 && known };
|
|
167
169
|
}
|
|
170
|
+
function findBlockEnd(content, openBraceIndex) {
|
|
171
|
+
let depth = 0;
|
|
172
|
+
let quote = null;
|
|
173
|
+
for (let i = openBraceIndex; i < content.length; i++) {
|
|
174
|
+
const c = content[i];
|
|
175
|
+
if (quote) {
|
|
176
|
+
if (c === quote && content[i - 1] !== "\\") quote = null;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
180
|
+
quote = c;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (c === "{") depth += 1;
|
|
184
|
+
else if (c === "}") {
|
|
185
|
+
depth -= 1;
|
|
186
|
+
if (depth === 0) return i + 1;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return content.length;
|
|
190
|
+
}
|
|
191
|
+
function normalizeTarget(target) {
|
|
192
|
+
return target.toLowerCase().split(",").map((s) => s.trim()).filter(Boolean).sort().join(",");
|
|
193
|
+
}
|
|
168
194
|
function extractRules(rulesFile, relFile) {
|
|
169
195
|
const rawContent = readFileSync2(rulesFile, "utf-8");
|
|
170
196
|
const content = stripRuleComments(rawContent);
|
|
@@ -175,7 +201,8 @@ function extractRules(rulesFile, relFile) {
|
|
|
175
201
|
while ((m = matchRe.exec(content)) !== null) {
|
|
176
202
|
const before = content.slice(0, m.index);
|
|
177
203
|
const line = before.split("\n").length;
|
|
178
|
-
|
|
204
|
+
const openBrace = m.index + m[0].length - 1;
|
|
205
|
+
matchBlocks.push({ path: m[1], line, blockStart: m.index, blockEnd: findBlockEnd(content, openBrace) });
|
|
179
206
|
}
|
|
180
207
|
const allowRe = new RegExp(ALLOW_RE.source, "g");
|
|
181
208
|
let a;
|
|
@@ -186,7 +213,8 @@ function extractRules(rulesFile, relFile) {
|
|
|
186
213
|
const target = a[1];
|
|
187
214
|
const condition = (a[2] ?? "").trim();
|
|
188
215
|
const unconditional = a[2] === void 0;
|
|
189
|
-
const
|
|
216
|
+
const containing = matchBlocks.filter((b) => b.blockStart <= a.index && a.index < b.blockEnd);
|
|
217
|
+
const currentMatch = containing.sort((x, y) => y.blockStart - x.blockStart)[0];
|
|
190
218
|
const authReferences = [];
|
|
191
219
|
if (condition.includes("request.auth")) authReferences.push("request.auth");
|
|
192
220
|
if (condition.includes("request.auth.uid")) authReferences.push("request.auth.uid");
|
|
@@ -205,6 +233,7 @@ function extractRules(rulesFile, relFile) {
|
|
|
205
233
|
path: currentMatch?.path ?? "/(unknown)",
|
|
206
234
|
operations: ops,
|
|
207
235
|
conditionPresent: !unconditional && condition.length > 0,
|
|
236
|
+
condition: unconditional ? void 0 : condition,
|
|
208
237
|
authReferences,
|
|
209
238
|
claimReferences,
|
|
210
239
|
resourceReferences,
|
|
@@ -239,8 +268,11 @@ function isPublicCondition(condition) {
|
|
|
239
268
|
if (condition === void 0) return { isPublic: true, confidence: "CONFIRMED" };
|
|
240
269
|
const norm = stripOuterParens(condition);
|
|
241
270
|
const low = norm.toLowerCase().replace(/\s+/g, "");
|
|
271
|
+
if (low.includes("!(request.auth==null)") || low.includes("!(request.auth===null)") || low === "!request.auth==null" || low === "!request.auth===null") {
|
|
272
|
+
return { isPublic: false, confidence: "CONFIRMED" };
|
|
273
|
+
}
|
|
242
274
|
if (low === "true") return { isPublic: true, confidence: "CONFIRMED" };
|
|
243
|
-
if (/(^|\|\|)true(
|
|
275
|
+
if (/(^|\|\|)true($|\|\|)/.test(low)) return { isPublic: true, confidence: "PROBABLE" };
|
|
244
276
|
if (low.includes("request.auth==null") || low.includes("request.auth===null")) {
|
|
245
277
|
return { isPublic: true, confidence: "CONFIRMED" };
|
|
246
278
|
}
|
|
@@ -253,7 +285,10 @@ function findPublicAllows(rulesFile) {
|
|
|
253
285
|
const matchRe = new RegExp(MATCH_RE.source, "g");
|
|
254
286
|
const blocks = [];
|
|
255
287
|
let mm0;
|
|
256
|
-
while ((mm0 = matchRe.exec(content)) !== null)
|
|
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
|
+
}
|
|
257
292
|
const re = new RegExp(ALLOW_RE.source, "g");
|
|
258
293
|
let mm;
|
|
259
294
|
while ((mm = re.exec(content)) !== null) {
|
|
@@ -261,7 +296,8 @@ function findPublicAllows(rulesFile) {
|
|
|
261
296
|
const target = mm[1].trim();
|
|
262
297
|
const check = isPublicCondition(mm[2]);
|
|
263
298
|
if (!check.isPublic) continue;
|
|
264
|
-
const
|
|
299
|
+
const containing = blocks.filter((b) => b.index <= mm.index && mm.index < b.end);
|
|
300
|
+
const path = containing.sort((x, y) => y.index - x.index)[0]?.path ?? "/(unknown)";
|
|
265
301
|
out.push({ line, target, path, confidence: check.confidence });
|
|
266
302
|
}
|
|
267
303
|
return out;
|
|
@@ -271,7 +307,7 @@ function findPublicAllows(rulesFile) {
|
|
|
271
307
|
import { readFileSync as readFileSync3, existsSync as existsSync2 } from "fs";
|
|
272
308
|
import { join as join2 } from "path";
|
|
273
309
|
import { executeFind } from "@justmpm/supergrep";
|
|
274
|
-
var IMPLEMENTED_CHECKS = ["FBA001", "FBA002", "FBA003", "FBA004", "FBA009", "FBA010", "FBA011"];
|
|
310
|
+
var IMPLEMENTED_CHECKS = ["FBA001", "FBA002", "FBA003", "FBA004", "FBA009", "FBA010", "FBA011", "FBA012", "FBA013"];
|
|
275
311
|
function createEvidence() {
|
|
276
312
|
let counter = 0;
|
|
277
313
|
return {
|
|
@@ -316,7 +352,7 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
316
352
|
"ERROR",
|
|
317
353
|
pub.confidence,
|
|
318
354
|
`Escrita p\xFAblica em ${pub.path} (${pub.target}). Qualquer cliente pode escrever.`,
|
|
319
|
-
`FBA001:${relRules}:${pub.path}:${pub.target}`,
|
|
355
|
+
`FBA001:${relRules}:${pub.path}:${normalizeTarget(pub.target)}`,
|
|
320
356
|
{
|
|
321
357
|
file: relRules,
|
|
322
358
|
line: pub.line,
|
|
@@ -334,7 +370,7 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
334
370
|
"WARNING",
|
|
335
371
|
pub.confidence,
|
|
336
372
|
`Leitura p\xFAblica em ${pub.path} (${pub.target}). Pode ser proposital.`,
|
|
337
|
-
`FBA002:${relRules}:${pub.path}:${pub.target}`,
|
|
373
|
+
`FBA002:${relRules}:${pub.path}:${normalizeTarget(pub.target)}`,
|
|
338
374
|
{
|
|
339
375
|
file: relRules,
|
|
340
376
|
line: pub.line,
|
|
@@ -411,6 +447,98 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
411
447
|
}
|
|
412
448
|
}
|
|
413
449
|
}
|
|
450
|
+
for (const role of model.authorization.roles) {
|
|
451
|
+
if (role.permissions.length === 0) {
|
|
452
|
+
findings.push(
|
|
453
|
+
make(
|
|
454
|
+
"FBA013",
|
|
455
|
+
"WARNING",
|
|
456
|
+
"CONFIRMED",
|
|
457
|
+
`Role "${role.name}" sem permissions \u2014 nunca autoriza nada.`,
|
|
458
|
+
`FBA013:${role.name}`,
|
|
459
|
+
{
|
|
460
|
+
evidence: [ev("role-empty", role.name, "CONFIRMED")],
|
|
461
|
+
fix: `Adicionar permissions \xE0 role "${role.name}" ou remov\xEA-la.`
|
|
462
|
+
}
|
|
463
|
+
)
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
const expandOp = (op) => {
|
|
468
|
+
if (op === "read") return ["read", "get", "list"];
|
|
469
|
+
if (op === "write") return ["write", "create", "update", "delete"];
|
|
470
|
+
return [op];
|
|
471
|
+
};
|
|
472
|
+
const publicPaths = /* @__PURE__ */ new Set();
|
|
473
|
+
if (absRules && existsSync2(absRules)) {
|
|
474
|
+
for (const p of findPublicAllows(absRules)) {
|
|
475
|
+
const { ops } = opsFrom(p.target);
|
|
476
|
+
for (const op of /* @__PURE__ */ new Set([...ops, ...ops.flatMap(expandOp)])) {
|
|
477
|
+
publicPaths.add(`${p.path}::${op.toLowerCase()}`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
const permsByResource = /* @__PURE__ */ new Map();
|
|
482
|
+
for (const p of model.authorization.permissions) {
|
|
483
|
+
const holders = model.authorization.roles.filter((r) => r.permissions.includes(p.name)).map((r) => r.name);
|
|
484
|
+
const adminOnly = holders.length > 0 && holders.every((h) => h === "admin");
|
|
485
|
+
const list = permsByResource.get(p.resource) ?? [];
|
|
486
|
+
list.push({ perm: p.name, operation: p.operation, adminOnly });
|
|
487
|
+
permsByResource.set(p.resource, list);
|
|
488
|
+
}
|
|
489
|
+
const checkRuleAgainstEntries = (rule, resourceKey, entries) => {
|
|
490
|
+
const ruleOps = new Set(rule.operations.flatMap(expandOp));
|
|
491
|
+
const restricted = entries.filter(
|
|
492
|
+
(e) => e.adminOnly && expandOp(e.operation).some((o) => ruleOps.has(o))
|
|
493
|
+
);
|
|
494
|
+
if (restricted.length === 0) return;
|
|
495
|
+
const isPublicRule = [...ruleOps].some(
|
|
496
|
+
(op) => publicPaths.has(`${rule.path}::${op.toLowerCase()}`)
|
|
497
|
+
);
|
|
498
|
+
if (isPublicRule) return;
|
|
499
|
+
const checksAdminClaim = rule.claimReferences.some((c) => c.toLowerCase() === "admin");
|
|
500
|
+
const checksAdminRole = /token\.\w+\s*==\s*['"]admin['"]/i.test(rule.condition ?? "");
|
|
501
|
+
if (checksAdminClaim || checksAdminRole) return;
|
|
502
|
+
const checksAuth = rule.authReferences.length > 0;
|
|
503
|
+
if (!checksAuth) return;
|
|
504
|
+
findings.push(
|
|
505
|
+
make(
|
|
506
|
+
"FBA012",
|
|
507
|
+
"WARNING",
|
|
508
|
+
"PROBABLE",
|
|
509
|
+
`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.location.start.line}:${resourceKey}`,
|
|
511
|
+
{
|
|
512
|
+
file: rule.location.file,
|
|
513
|
+
line: rule.location.start.line,
|
|
514
|
+
resource: rule.path,
|
|
515
|
+
evidence: [ev("rule-contract-mismatch", `${rule.path} vs ${restricted.map((r) => r.perm).join(", ")}`, "PROBABLE", rule.location.file, rule.location.start.line)],
|
|
516
|
+
fix: "Adicionar checagem de claim de admin na Rule (ex: request.auth.token.admin == true) ou relaxar o contrato."
|
|
517
|
+
}
|
|
518
|
+
)
|
|
519
|
+
);
|
|
520
|
+
};
|
|
521
|
+
for (const rule of model.rules) {
|
|
522
|
+
const segments = rule.path.replace(/^\/+/, "").split("/").filter(Boolean);
|
|
523
|
+
if (segments.length === 0 || segments[0] === "(unknown)") continue;
|
|
524
|
+
if (segments[0] === "databases") {
|
|
525
|
+
const docIdx = segments.indexOf("documents");
|
|
526
|
+
const candidate = docIdx !== -1 ? segments[docIdx + 1] : void 0;
|
|
527
|
+
if (!candidate || candidate.startsWith("{")) continue;
|
|
528
|
+
const entriesAbs = permsByResource.get(candidate);
|
|
529
|
+
if (!entriesAbs) continue;
|
|
530
|
+
checkRuleAgainstEntries(rule, candidate, entriesAbs);
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
const tried = /* @__PURE__ */ new Set();
|
|
534
|
+
for (const seg of segments) {
|
|
535
|
+
if (seg.startsWith("{") || seg.startsWith("(") || tried.has(seg)) continue;
|
|
536
|
+
tried.add(seg);
|
|
537
|
+
const entries = permsByResource.get(seg);
|
|
538
|
+
if (!entries || entries.length === 0) continue;
|
|
539
|
+
checkRuleAgainstEntries(rule, seg, entries);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
414
542
|
for (const dyn of permissionCalls.dynamic) {
|
|
415
543
|
findings.push(
|
|
416
544
|
make(
|
|
@@ -459,9 +587,6 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
459
587
|
async function collectPermissionCalls(rootDir, adapterFn) {
|
|
460
588
|
const literals = [];
|
|
461
589
|
const dynamic = [];
|
|
462
|
-
if (!adapterFn.includes(".")) {
|
|
463
|
-
return { literals, dynamic };
|
|
464
|
-
}
|
|
465
590
|
const bases = [adapterFn];
|
|
466
591
|
const patterns = [];
|
|
467
592
|
for (const b of bases) {
|
|
@@ -480,10 +605,25 @@ async function collectPermissionCalls(rootDir, adapterFn) {
|
|
|
480
605
|
for (const m of result.matches) {
|
|
481
606
|
const raw = (m.metaVariables["PERM"] ?? "").trim();
|
|
482
607
|
const line = m.line + 1;
|
|
483
|
-
|
|
608
|
+
if (raw.includes("${")) {
|
|
609
|
+
const k = `${m.file}:${line}:${raw}`;
|
|
610
|
+
if (!seen.has(k)) {
|
|
611
|
+
seen.add(k);
|
|
612
|
+
dynamic.push({ file: m.file, line, text: m.text });
|
|
613
|
+
}
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
const isQuoted = /^['"`].*['"`]$/.test(raw);
|
|
484
617
|
if (isQuoted) {
|
|
485
|
-
const perm = raw.replace(/^['"]|['"]$/g, "").trim();
|
|
618
|
+
const perm = raw.replace(/^['"`]|['"`]$/g, "").trim();
|
|
486
619
|
if (perm.length >= 3) pushLiteral(m.file, line, perm);
|
|
620
|
+
else if (perm.length > 0) {
|
|
621
|
+
const k = `${m.file}:${line}:${raw}:short`;
|
|
622
|
+
if (!seen.has(k)) {
|
|
623
|
+
seen.add(k);
|
|
624
|
+
dynamic.push({ file: m.file, line, text: m.text });
|
|
625
|
+
}
|
|
626
|
+
}
|
|
487
627
|
} else if (raw.length > 0) {
|
|
488
628
|
const k = `${m.file}:${line}:${raw}`;
|
|
489
629
|
if (!seen.has(k)) {
|
|
@@ -600,7 +740,7 @@ var QueryShapeSchema = z.strictObject({
|
|
|
600
740
|
location: LocationSchema
|
|
601
741
|
});
|
|
602
742
|
var PermissionSchema = z.strictObject({
|
|
603
|
-
name: z.string().min(
|
|
743
|
+
name: z.string().min(1),
|
|
604
744
|
resource: z.string().min(1),
|
|
605
745
|
operation: OperationSchema
|
|
606
746
|
});
|
|
@@ -613,6 +753,7 @@ var RuleSchema = z.strictObject({
|
|
|
613
753
|
path: z.string().min(1),
|
|
614
754
|
operations: z.array(OperationSchema),
|
|
615
755
|
conditionPresent: z.boolean(),
|
|
756
|
+
condition: z.string().optional(),
|
|
616
757
|
authReferences: z.array(z.string()),
|
|
617
758
|
claimReferences: z.array(z.string()),
|
|
618
759
|
resourceReferences: z.array(z.string()),
|
|
@@ -684,7 +825,7 @@ var FindingSchema = z.strictObject({
|
|
|
684
825
|
});
|
|
685
826
|
var PermissionNameSchema = z.string().regex(/^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)*$/);
|
|
686
827
|
var RoleNameSchema = z.string().regex(/^[a-z][a-z0-9_-]*$/);
|
|
687
|
-
var AuditYamlSchema = z.
|
|
828
|
+
var AuditYamlSchema = z.object({
|
|
688
829
|
authorization: z.strictObject({
|
|
689
830
|
adapter: z.strictObject({ function: z.string().min(1) }).optional(),
|
|
690
831
|
roles: z.record(RoleNameSchema, z.strictObject({ permissions: z.array(PermissionNameSchema) })),
|
|
@@ -701,8 +842,6 @@ var AuditYamlSchema = z.strictObject({
|
|
|
701
842
|
});
|
|
702
843
|
|
|
703
844
|
// src/scan.ts
|
|
704
|
-
import { readFileSync as readFileSync4, existsSync as existsSync3 } from "fs";
|
|
705
|
-
import { parse as parseYaml } from "yaml";
|
|
706
845
|
async function scan(rootDir, opts = {}) {
|
|
707
846
|
const d = discover(rootDir);
|
|
708
847
|
const model = emptyModel(rootDir, d);
|
|
@@ -720,20 +859,65 @@ async function scan(rootDir, opts = {}) {
|
|
|
720
859
|
});
|
|
721
860
|
}
|
|
722
861
|
if (d.firestoreRulesFile && existsSync3(d.firestoreRulesFile)) {
|
|
723
|
-
const rel =
|
|
724
|
-
|
|
862
|
+
const rel = model.firebase.firestore.rulesFile ?? "firestore.rules";
|
|
863
|
+
try {
|
|
864
|
+
model.rules = extractRules(d.firestoreRulesFile, rel);
|
|
865
|
+
} catch {
|
|
866
|
+
findings_pre.push({
|
|
867
|
+
rule: "RULES_UNREADABLE",
|
|
868
|
+
severity: "WARNING",
|
|
869
|
+
confidence: "CONFIRMED",
|
|
870
|
+
message: "firestore.rules existe mas n\xE3o p\xF4de ser lido \u2014 FBA001/FBA002 sem cobertura.",
|
|
871
|
+
fingerprint: "RULES_UNREADABLE",
|
|
872
|
+
evidence: [
|
|
873
|
+
{ id: "ev-rules-unreadable", kind: "rules-read", summary: "leitura falhou", confidence: "CONFIRMED" }
|
|
874
|
+
]
|
|
875
|
+
});
|
|
876
|
+
}
|
|
725
877
|
}
|
|
726
878
|
if (d.firestoreIndexesFile && existsSync3(d.firestoreIndexesFile)) {
|
|
727
879
|
try {
|
|
728
880
|
const raw = JSON.parse(readFileSync4(d.firestoreIndexesFile, "utf-8"));
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
881
|
+
const validModes = ["ASCENDING", "DESCENDING", "ARRAY_CONTAINS", "VECTOR"];
|
|
882
|
+
const entries = [];
|
|
883
|
+
let skipped = 0;
|
|
884
|
+
for (const i of raw.indexes ?? []) {
|
|
885
|
+
if (typeof i.collectionGroup !== "string" || i.collectionGroup.length === 0) {
|
|
886
|
+
skipped += 1;
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
const fields = [];
|
|
890
|
+
for (const f of i.fields ?? []) {
|
|
891
|
+
if (typeof f.fieldPath !== "string" || f.fieldPath.length === 0) {
|
|
892
|
+
skipped += 1;
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
const mode = f.vectorConfig ? "VECTOR" : f.arrayConfig === "CONTAINS" ? "ARRAY_CONTAINS" : f.order;
|
|
896
|
+
if (mode !== "ASCENDING" && mode !== "DESCENDING" && mode !== "ARRAY_CONTAINS" && mode !== "VECTOR") {
|
|
897
|
+
skipped += 1;
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
fields.push({ fieldPath: f.fieldPath, mode });
|
|
901
|
+
}
|
|
902
|
+
entries.push({
|
|
903
|
+
collectionGroup: i.collectionGroup,
|
|
904
|
+
queryScope: i.queryScope === "COLLECTION_GROUP" ? "COLLECTION_GROUP" : "COLLECTION",
|
|
905
|
+
fields
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
model.indexes = entries;
|
|
909
|
+
if (skipped > 0) {
|
|
910
|
+
findings_pre.push({
|
|
911
|
+
rule: "INDEXES_INVALID",
|
|
912
|
+
severity: "WARNING",
|
|
913
|
+
confidence: "CONFIRMED",
|
|
914
|
+
message: `firestore.indexes.json com ${skipped} entrada(s) inv\xE1lidas ignoradas \u2014 verifique campos.`,
|
|
915
|
+
fingerprint: "INDEXES_INVALID",
|
|
916
|
+
evidence: [
|
|
917
|
+
{ id: "ev-indexes-skipped", kind: "indexes-parse", summary: `${skipped} inv\xE1lidas`, confidence: "CONFIRMED" }
|
|
918
|
+
]
|
|
919
|
+
});
|
|
920
|
+
}
|
|
737
921
|
} catch {
|
|
738
922
|
findings_pre.push({
|
|
739
923
|
rule: "INDEXES_INVALID",
|
|
@@ -824,14 +1008,21 @@ async function scan(rootDir, opts = {}) {
|
|
|
824
1008
|
const errors = findings.filter((f) => f.severity === "ERROR").length;
|
|
825
1009
|
const warnings = findings.filter((f) => f.severity === "WARNING").length;
|
|
826
1010
|
const infos = findings.filter((f) => f.severity === "INFO").length;
|
|
827
|
-
|
|
1011
|
+
const failedChecks = new Set(
|
|
1012
|
+
findings.filter((f) => f.severity === "ERROR" || f.severity === "WARNING").map((f) => f.rule)
|
|
1013
|
+
);
|
|
1014
|
+
const total = IMPLEMENTED_CHECKS.length;
|
|
1015
|
+
const passed = IMPLEMENTED_CHECKS.filter((c) => !failedChecks.has(c)).length;
|
|
1016
|
+
return { model, findings, summary: { errors, warnings, infos, passed, total } };
|
|
828
1017
|
}
|
|
829
1018
|
|
|
830
1019
|
export {
|
|
831
1020
|
discover,
|
|
1021
|
+
SERVER_HINTS_SEGMENTS,
|
|
832
1022
|
classifyOrigin,
|
|
833
1023
|
emptyModel,
|
|
834
1024
|
stripRuleComments,
|
|
1025
|
+
opsFrom,
|
|
835
1026
|
extractRules,
|
|
836
1027
|
isPublicCondition,
|
|
837
1028
|
findPublicAllows,
|
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-N57MCLTZ.js";
|
|
5
5
|
|
|
6
6
|
// src/cli.ts
|
|
7
7
|
import { createRequire } from "module";
|
|
@@ -14,16 +14,117 @@ async function main() {
|
|
|
14
14
|
console.log(pkg.version);
|
|
15
15
|
return;
|
|
16
16
|
}
|
|
17
|
-
if (cmd
|
|
17
|
+
if (cmd === "--help" || cmd === "-h" || cmd === "help") {
|
|
18
|
+
console.log(`firebase-audit v${pkg.version}
|
|
19
|
+
|
|
20
|
+
Uso:
|
|
21
|
+
firebase-audit scan [--json] [--strict] [--adapter=fn] [--cwd=path]
|
|
22
|
+
firebase-audit check [--json] [--adapter=fn] [--cwd=path] (alias de scan --strict)
|
|
23
|
+
firebase-audit verify [--coverage=path] [--cwd=path] (V3: matriz + cobertura)
|
|
24
|
+
firebase-audit drift --local=indexes.json --remote=deployed.json (V4: LOCAL_ONLY/REMOTE_ONLY/MATCHED)
|
|
25
|
+
|
|
26
|
+
Exit codes: 0 ok informativo; 2 quando --strict/check encontra ERROR. scan sozinho nunca reprova (use check no CI).`);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (cmd !== "scan" && cmd !== "check" && cmd !== "verify" && cmd !== "drift") {
|
|
18
30
|
console.error(`Comando desconhecido: ${cmd}
|
|
19
31
|
Uso: firebase-audit scan [--json] [--strict] [--adapter=fn] [--cwd=path]`);
|
|
20
32
|
process.exit(1);
|
|
21
33
|
}
|
|
34
|
+
const strict = args.includes("--strict") || cmd === "check";
|
|
22
35
|
const asJson = args.includes("--json");
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
36
|
+
const getArg = (name) => {
|
|
37
|
+
const pref = `--${name}=`;
|
|
38
|
+
const hit = args.find((a) => a.startsWith(pref));
|
|
39
|
+
if (hit) return hit.slice(pref.length);
|
|
40
|
+
const idx = args.findIndex((a) => a === `--${name}`);
|
|
41
|
+
if (idx !== -1 && args[idx + 1] && !args[idx + 1].startsWith("--")) return args[idx + 1];
|
|
42
|
+
return void 0;
|
|
43
|
+
};
|
|
44
|
+
const knownFlags = /* @__PURE__ */ new Set(["--json", "--strict", "--help", "-h", "help", "--version", "-v", "--coverage", "--local", "--remote"]);
|
|
45
|
+
for (const a of args.slice(1)) {
|
|
46
|
+
const base = a.includes("=") ? a.slice(0, a.indexOf("=")) : a;
|
|
47
|
+
if (a.startsWith("--") && base !== "--adapter" && base !== "--cwd" && base !== "--coverage" && base !== "--local" && base !== "--remote" && !knownFlags.has(a) && !knownFlags.has(base)) {
|
|
48
|
+
console.error(`Flag desconhecida: ${a}
|
|
49
|
+
Uso: firebase-audit scan|check|verify|drift [--json] ...`);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const adapterArg = getArg("adapter");
|
|
54
|
+
const cwdArg = getArg("cwd");
|
|
55
|
+
if (cwdArg) {
|
|
56
|
+
const { existsSync: existsCwd } = await import("fs");
|
|
57
|
+
const { resolve: resolveCwd } = await import("path");
|
|
58
|
+
const abs = resolveCwd(process.cwd(), cwdArg);
|
|
59
|
+
if (!existsCwd(abs)) {
|
|
60
|
+
console.error(`Diret\xF3rio --cwd n\xE3o existe: ${cwdArg}`);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const { resolve: resolveRoot } = await import("path");
|
|
65
|
+
const rootDir = cwdArg ? resolveRoot(process.cwd(), cwdArg) : process.cwd();
|
|
66
|
+
if (cmd === "verify") {
|
|
67
|
+
const { scan: scanForVerify } = await import("./scan-773ZFQXB.js");
|
|
68
|
+
const { verify } = await import("./verify-Y7BWAFPL.js");
|
|
69
|
+
const { resolve: resolveVerify } = await import("path");
|
|
70
|
+
const coverageArg = getArg("coverage");
|
|
71
|
+
const coveragePath = coverageArg ? resolveVerify(rootDir, coverageArg) : void 0;
|
|
72
|
+
const { model } = await scanForVerify(rootDir, { adapterFn: adapterArg });
|
|
73
|
+
const res = verify(model, { coverageFile: coveragePath });
|
|
74
|
+
if (asJson) {
|
|
75
|
+
console.log(JSON.stringify({ version: pkg.version, cases: res.cases.length, uncovered: res.uncovered, findings: res.findings }, null, 2));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
console.log(`
|
|
79
|
+
FIREBASE AUDIT verify \u2014 ${res.cases.length} casos planejados, ${res.uncovered.length} paths sem cobertura observada.`);
|
|
80
|
+
for (const f of res.findings) {
|
|
81
|
+
const icon = f.severity === "ERROR" ? "\u274C" : f.severity === "WARNING" ? "\u26A0" : "\u2139";
|
|
82
|
+
console.log(`
|
|
83
|
+
${icon} ${f.rule} [${f.confidence}]
|
|
84
|
+
${f.message}`);
|
|
85
|
+
if (f.fix) console.log(` \u2192 ${f.fix}`);
|
|
86
|
+
}
|
|
87
|
+
if (res.uncovered.length > 0) {
|
|
88
|
+
console.log("\nUncovered paths:");
|
|
89
|
+
for (const u of res.uncovered.slice(0, 20)) console.log(` \u2022 ${u}`);
|
|
90
|
+
if (res.uncovered.length > 20) console.log(` ... e mais ${res.uncovered.length - 20}`);
|
|
91
|
+
}
|
|
92
|
+
console.log('\nEmulator: firebase emulators:exec --only firestore "npm test" + :ruleCoverage.\n');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (cmd === "drift") {
|
|
96
|
+
const { drift } = await import("./drift-BEXSL2IX.js");
|
|
97
|
+
const { resolve: resolveDrift } = await import("path");
|
|
98
|
+
const { existsSync: existsDrift } = await import("fs");
|
|
99
|
+
const localArg = getArg("local");
|
|
100
|
+
const remoteArg = getArg("remote");
|
|
101
|
+
if (!localArg && !remoteArg) {
|
|
102
|
+
console.error("drift exige --local e/ou --remote.\nUso: firebase-audit drift --local=indexes.json --remote=deployed.json [--cwd=path]");
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
const localPath = localArg ? resolveDrift(rootDir, localArg) : null;
|
|
106
|
+
const remotePath = remoteArg ? resolveDrift(rootDir, remoteArg) : null;
|
|
107
|
+
for (const [label, p] of [["--local", localPath], ["--remote", remotePath]]) {
|
|
108
|
+
if (p && !existsDrift(p)) {
|
|
109
|
+
console.error(`Arquivo ${label} n\xE3o encontrado: ${p}`);
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const res = drift(localPath, remotePath);
|
|
114
|
+
if (asJson) {
|
|
115
|
+
console.log(JSON.stringify({ version: pkg.version, entries: res.entries, findings: res.findings }, null, 2));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const counts = (s) => res.entries.filter((e) => e.status === s).length;
|
|
119
|
+
console.log(`
|
|
120
|
+
FIREBASE AUDIT drift \u2014 MATCHED ${counts("MATCHED")}, LOCAL_ONLY ${counts("LOCAL_ONLY")}, REMOTE_ONLY ${counts("REMOTE_ONLY")}.
|
|
121
|
+
`);
|
|
122
|
+
for (const f of res.findings.slice(0, 20)) {
|
|
123
|
+
const icon = f.severity === "ERROR" ? "\u274C" : f.severity === "WARNING" ? "\u26A0" : "\u2139";
|
|
124
|
+
console.log(`${icon} ${f.rule}: ${f.message}`);
|
|
125
|
+
}
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
27
128
|
const { findings, summary } = await scan(rootDir, { strict, adapterFn: adapterArg });
|
|
28
129
|
if (asJson) {
|
|
29
130
|
console.log(JSON.stringify({ version: pkg.version, summary, findings }, null, 2));
|
|
@@ -35,7 +136,7 @@ FIREBASE AUDIT v${pkg.version}`);
|
|
|
35
136
|
console.log("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
36
137
|
if (findings.length === 0) {
|
|
37
138
|
console.log(`
|
|
38
|
-
\u2713 Nenhum problema nos checks V1 implementados (parcial: FBA001-FBA004, FBA009-
|
|
139
|
+
\u2713 Nenhum problema nos checks V1+V2 implementados (parcial: FBA001-FBA004, FBA009-FBA013).
|
|
39
140
|
`);
|
|
40
141
|
return;
|
|
41
142
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -216,6 +216,7 @@ declare const RuleSchema: z.ZodObject<{
|
|
|
216
216
|
write: "write";
|
|
217
217
|
}>>;
|
|
218
218
|
conditionPresent: z.ZodBoolean;
|
|
219
|
+
condition: z.ZodOptional<z.ZodString>;
|
|
219
220
|
authReferences: z.ZodArray<z.ZodString>;
|
|
220
221
|
claimReferences: z.ZodArray<z.ZodString>;
|
|
221
222
|
resourceReferences: z.ZodArray<z.ZodString>;
|
|
@@ -410,6 +411,7 @@ declare const ProjectModelSchema: z.ZodObject<{
|
|
|
410
411
|
write: "write";
|
|
411
412
|
}>>;
|
|
412
413
|
conditionPresent: z.ZodBoolean;
|
|
414
|
+
condition: z.ZodOptional<z.ZodString>;
|
|
413
415
|
authReferences: z.ZodArray<z.ZodString>;
|
|
414
416
|
claimReferences: z.ZodArray<z.ZodString>;
|
|
415
417
|
resourceReferences: z.ZodArray<z.ZodString>;
|
|
@@ -589,7 +591,7 @@ declare const AuditYamlSchema: z.ZodObject<{
|
|
|
589
591
|
samples: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodJSONSchema>>>;
|
|
590
592
|
}, z.core.$strict>>;
|
|
591
593
|
}, z.core.$strict>;
|
|
592
|
-
}, z.core.$
|
|
594
|
+
}, z.core.$strip>;
|
|
593
595
|
type ProjectModel = z.infer<typeof ProjectModelSchema>;
|
|
594
596
|
type Finding = z.infer<typeof FindingSchema>;
|
|
595
597
|
type AuditYaml = z.infer<typeof AuditYamlSchema>;
|
|
@@ -616,6 +618,8 @@ interface DiscoveryResult {
|
|
|
616
618
|
serverFiles: string[];
|
|
617
619
|
}
|
|
618
620
|
declare function discover(rootDir: string): DiscoveryResult;
|
|
621
|
+
/** Segmentos de servidor usados por classifyOrigin (documentados; ai-tool graph na V3). */
|
|
622
|
+
declare const SERVER_HINTS_SEGMENTS: string[];
|
|
619
623
|
/** Heurística CLIENT vs SERVER: caminho manda primeiro (grafo/voz do ai-tool na V2). */
|
|
620
624
|
declare function classifyOrigin(file: string, content: string): "CLIENT" | "SERVER" | "UNKNOWN";
|
|
621
625
|
declare function emptyModel(rootDir: string, d: DiscoveryResult): ProjectModel;
|
|
@@ -628,6 +632,10 @@ declare function emptyModel(rootDir: string, d: DiscoveryResult): ProjectModel;
|
|
|
628
632
|
type Rule = z$1.infer<typeof RuleSchema>;
|
|
629
633
|
/** Remove comentários // e block preservando strings simples. */
|
|
630
634
|
declare function stripRuleComments(content: string): string;
|
|
635
|
+
declare function opsFrom(target: string): {
|
|
636
|
+
ops: Rule["operations"];
|
|
637
|
+
known: boolean;
|
|
638
|
+
};
|
|
631
639
|
declare function extractRules(rulesFile: string, relFile: string): Rule[];
|
|
632
640
|
/** Normaliza condição para detectar público: sem if, `true`, `(true)`, `|| true`, `auth == null`. */
|
|
633
641
|
declare function isPublicCondition(condition: string | undefined): {
|
|
@@ -643,14 +651,12 @@ declare function findPublicAllows(rulesFile: string): {
|
|
|
643
651
|
}[];
|
|
644
652
|
|
|
645
653
|
/**
|
|
646
|
-
* Checks
|
|
647
|
-
*
|
|
648
|
-
*
|
|
649
|
-
* FBA003/FBA004/FBA010 com linha (únicos por ocorrência — duplicatas no mesmo
|
|
650
|
-
* arquivo geram findings distintos).
|
|
654
|
+
* Checks V1+V2 (FBA001–FBA004, FBA009–FBA013, subconjunto intencional).
|
|
655
|
+
* Fingerprints: FBA001/FBA002 por regra-lógica (sem linha, estáveis a formatação);
|
|
656
|
+
* FBA003/FBA004/FBA010/FBA012 com linha (únicos por ocorrência).
|
|
651
657
|
*/
|
|
652
658
|
|
|
653
|
-
declare const IMPLEMENTED_CHECKS: readonly ["FBA001", "FBA002", "FBA003", "FBA004", "FBA009", "FBA010", "FBA011"];
|
|
659
|
+
declare const IMPLEMENTED_CHECKS: readonly ["FBA001", "FBA002", "FBA003", "FBA004", "FBA009", "FBA010", "FBA011", "FBA012", "FBA013"];
|
|
654
660
|
declare function runChecks(model: ProjectModel, rootDir: string, opts?: {
|
|
655
661
|
adapterFn?: string;
|
|
656
662
|
}): Promise<Finding[]>;
|
|
@@ -671,14 +677,67 @@ interface ScanResult {
|
|
|
671
677
|
warnings: number;
|
|
672
678
|
infos: number;
|
|
673
679
|
passed: number;
|
|
680
|
+
total: number;
|
|
674
681
|
};
|
|
675
682
|
}
|
|
676
683
|
declare function scan(rootDir: string, opts?: ScanOptions): Promise<ScanResult>;
|
|
677
684
|
|
|
685
|
+
/**
|
|
686
|
+
* V3 — Verifier (Emulator) como interface honesta.
|
|
687
|
+
*
|
|
688
|
+
* Não executa o Emulator sozinho (precisa de firebase-tools + Java no host).
|
|
689
|
+
* Gera a matriz de casos (TestCase → Scenario) e avalia cobertura quando
|
|
690
|
+
* o JSON do endpoint `:ruleCoverage` é fornecido (--coverage).
|
|
691
|
+
*/
|
|
692
|
+
|
|
693
|
+
interface VerifyCase {
|
|
694
|
+
role: string;
|
|
695
|
+
operation: "read" | "get" | "list" | "create" | "update" | "delete";
|
|
696
|
+
path: string;
|
|
697
|
+
pathTemplate: string;
|
|
698
|
+
expected: "ALLOW" | "DENY" | "UNKNOWN";
|
|
699
|
+
}
|
|
700
|
+
interface VerifyOptions {
|
|
701
|
+
coverageFile?: string;
|
|
702
|
+
}
|
|
703
|
+
interface VerifyResult {
|
|
704
|
+
cases: VerifyCase[];
|
|
705
|
+
uncovered: string[];
|
|
706
|
+
findings: Finding[];
|
|
707
|
+
}
|
|
708
|
+
/** Gera matriz role × operação × path a partir do modelo (sem executar nada).
|
|
709
|
+
* Mapeamento: read≡get+list, write≡create+update+delete (mesma semântica do opsFrom).
|
|
710
|
+
*/
|
|
711
|
+
declare function planVerify(model: ProjectModel): VerifyCase[];
|
|
712
|
+
/** Avalia cobertura a partir do JSON de `:ruleCoverage` (quando fornecido). */
|
|
713
|
+
declare function verify(model: ProjectModel, opts?: VerifyOptions): VerifyResult;
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* V4 — Drift local vs implantado (comparação honesta de índices).
|
|
717
|
+
*
|
|
718
|
+
* Compara `firestore.indexes.json` local contra um JSON remoto
|
|
719
|
+
* (`gcloud firestore indexes composite list --format=json` ou REST).
|
|
720
|
+
* Nunca afirma "unused" — apenas LOCAL_ONLY / REMOTE_ONLY / MATCHED.
|
|
721
|
+
*/
|
|
722
|
+
|
|
723
|
+
type Index = z$1.infer<typeof FirestoreIndexSchema>;
|
|
724
|
+
type DriftStatus = "MATCHED" | "LOCAL_ONLY" | "REMOTE_ONLY";
|
|
725
|
+
interface DriftEntry {
|
|
726
|
+
key: string;
|
|
727
|
+
status: DriftStatus;
|
|
728
|
+
local?: Index;
|
|
729
|
+
remote?: Index;
|
|
730
|
+
}
|
|
731
|
+
interface DriftResult {
|
|
732
|
+
entries: DriftEntry[];
|
|
733
|
+
findings: Finding[];
|
|
734
|
+
}
|
|
735
|
+
declare function drift(localFile: string | null, remoteFile: string | null): DriftResult;
|
|
736
|
+
|
|
678
737
|
/**
|
|
679
738
|
* firebase-audit — entry point como biblioteca.
|
|
680
739
|
*/
|
|
681
740
|
|
|
682
741
|
declare const VERSION: string;
|
|
683
742
|
|
|
684
|
-
export { AccessOriginSchema, type AuditYaml, AuditYamlSchema, ConfidenceSchema, type DiscoveryResult, DynamicValueSchema, type Evidence, EvidenceSchema, type Finding, FindingSchema, FirestoreIndexSchema, GraphEdgeSchema, IMPLEMENTED_CHECKS, IndexFieldSchema, LocationSchema, OperationSchema, PermissionSchema, PositionSchema, type ProjectModel, ProjectModelSchema, QueryFilterSchema, QueryOrderSchema, type QueryShape, QueryShapeSchema, RoleSchema, RuleSchema, type ScanOptions, type ScanResult, SeveritySchema, VERSION, classifyOrigin, discover, emptyModel, extractRules, findPublicAllows, isPublicCondition, runChecks, scan, stripRuleComments };
|
|
743
|
+
export { AccessOriginSchema, type AuditYaml, AuditYamlSchema, ConfidenceSchema, type DiscoveryResult, type DriftEntry, type DriftResult, type DriftStatus, DynamicValueSchema, type Evidence, EvidenceSchema, type Finding, FindingSchema, FirestoreIndexSchema, GraphEdgeSchema, IMPLEMENTED_CHECKS, IndexFieldSchema, LocationSchema, OperationSchema, PermissionSchema, PositionSchema, type ProjectModel, ProjectModelSchema, QueryFilterSchema, QueryOrderSchema, type QueryShape, QueryShapeSchema, RoleSchema, RuleSchema, SERVER_HINTS_SEGMENTS, type ScanOptions, type ScanResult, SeveritySchema, VERSION, type VerifyCase, type VerifyOptions, type VerifyResult, classifyOrigin, discover, drift, emptyModel, extractRules, findPublicAllows, isPublicCondition, opsFrom, planVerify, runChecks, scan, stripRuleComments, verify };
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
QueryShapeSchema,
|
|
20
20
|
RoleSchema,
|
|
21
21
|
RuleSchema,
|
|
22
|
+
SERVER_HINTS_SEGMENTS,
|
|
22
23
|
SeveritySchema,
|
|
23
24
|
classifyOrigin,
|
|
24
25
|
discover,
|
|
@@ -26,10 +27,18 @@ import {
|
|
|
26
27
|
extractRules,
|
|
27
28
|
findPublicAllows,
|
|
28
29
|
isPublicCondition,
|
|
30
|
+
opsFrom,
|
|
29
31
|
runChecks,
|
|
30
32
|
scan,
|
|
31
33
|
stripRuleComments
|
|
32
|
-
} from "./chunk-
|
|
34
|
+
} from "./chunk-N57MCLTZ.js";
|
|
35
|
+
import {
|
|
36
|
+
planVerify,
|
|
37
|
+
verify
|
|
38
|
+
} from "./chunk-CPEIPRCF.js";
|
|
39
|
+
import {
|
|
40
|
+
drift
|
|
41
|
+
} from "./chunk-GUSNXUAT.js";
|
|
33
42
|
|
|
34
43
|
// src/index.ts
|
|
35
44
|
import { createRequire } from "module";
|
|
@@ -57,15 +66,20 @@ export {
|
|
|
57
66
|
QueryShapeSchema,
|
|
58
67
|
RoleSchema,
|
|
59
68
|
RuleSchema,
|
|
69
|
+
SERVER_HINTS_SEGMENTS,
|
|
60
70
|
SeveritySchema,
|
|
61
71
|
VERSION,
|
|
62
72
|
classifyOrigin,
|
|
63
73
|
discover,
|
|
74
|
+
drift,
|
|
64
75
|
emptyModel,
|
|
65
76
|
extractRules,
|
|
66
77
|
findPublicAllows,
|
|
67
78
|
isPublicCondition,
|
|
79
|
+
opsFrom,
|
|
80
|
+
planVerify,
|
|
68
81
|
runChecks,
|
|
69
82
|
scan,
|
|
70
|
-
stripRuleComments
|
|
83
|
+
stripRuleComments,
|
|
84
|
+
verify
|
|
71
85
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@justmpm/firebase-audit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|