@justmpm/firebase-audit 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-CPEIPRCF.js +79 -0
- package/dist/chunk-GUSNXUAT.js +147 -0
- package/dist/{chunk-PIRAC3KT.js → chunk-UMQH7UDI.js} +158 -49
- package/dist/cli.js +82 -7
- package/dist/drift-BEXSL2IX.js +6 -0
- package/dist/index.d.ts +65 -6
- package/dist/index.js +16 -2
- package/dist/scan-TLHI777J.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,21 @@ 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 (hasSeg("pages", "api") && rel.includes("pages/api/")) {
|
|
62
65
|
return "SERVER";
|
|
63
66
|
}
|
|
64
|
-
if (rel.includes("
|
|
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 (hasSeg("app", "api") && rel.includes("app/api/")) {
|
|
68
|
+
return "SERVER";
|
|
69
69
|
}
|
|
70
|
+
if (segs.includes("app") && rel.endsWith("route.ts") || segs.includes("app") && rel.endsWith("route.js")) {
|
|
71
|
+
return "SERVER";
|
|
72
|
+
}
|
|
73
|
+
if (rel.includes("src/app/api/")) return "SERVER";
|
|
74
|
+
if (hasSeg("functions", "server", "backend", "scripts")) return "SERVER";
|
|
70
75
|
if (content.includes("firebase-admin") || content.includes("firebase-functions")) {
|
|
71
76
|
return "CLIENT";
|
|
72
77
|
}
|
|
@@ -165,6 +170,30 @@ function opsFrom(target) {
|
|
|
165
170
|
}
|
|
166
171
|
return { ops: out, known: out.length > 0 && known };
|
|
167
172
|
}
|
|
173
|
+
function findBlockEnd(content, openBraceIndex) {
|
|
174
|
+
let depth = 0;
|
|
175
|
+
let quote = null;
|
|
176
|
+
for (let i = openBraceIndex; i < content.length; i++) {
|
|
177
|
+
const c = content[i];
|
|
178
|
+
if (quote) {
|
|
179
|
+
if (c === quote && content[i - 1] !== "\\") quote = null;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
183
|
+
quote = c;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (c === "{") depth += 1;
|
|
187
|
+
else if (c === "}") {
|
|
188
|
+
depth -= 1;
|
|
189
|
+
if (depth === 0) return i + 1;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return content.length;
|
|
193
|
+
}
|
|
194
|
+
function normalizeTarget(target) {
|
|
195
|
+
return target.toLowerCase().split(",").map((s) => s.trim()).filter(Boolean).sort().join(",");
|
|
196
|
+
}
|
|
168
197
|
function extractRules(rulesFile, relFile) {
|
|
169
198
|
const rawContent = readFileSync2(rulesFile, "utf-8");
|
|
170
199
|
const content = stripRuleComments(rawContent);
|
|
@@ -175,7 +204,8 @@ function extractRules(rulesFile, relFile) {
|
|
|
175
204
|
while ((m = matchRe.exec(content)) !== null) {
|
|
176
205
|
const before = content.slice(0, m.index);
|
|
177
206
|
const line = before.split("\n").length;
|
|
178
|
-
|
|
207
|
+
const openBrace = m.index + m[0].length - 1;
|
|
208
|
+
matchBlocks.push({ path: m[1], line, blockStart: m.index, blockEnd: findBlockEnd(content, openBrace) });
|
|
179
209
|
}
|
|
180
210
|
const allowRe = new RegExp(ALLOW_RE.source, "g");
|
|
181
211
|
let a;
|
|
@@ -186,7 +216,8 @@ function extractRules(rulesFile, relFile) {
|
|
|
186
216
|
const target = a[1];
|
|
187
217
|
const condition = (a[2] ?? "").trim();
|
|
188
218
|
const unconditional = a[2] === void 0;
|
|
189
|
-
const
|
|
219
|
+
const containing = matchBlocks.filter((b) => b.blockStart <= a.index && a.index < b.blockEnd);
|
|
220
|
+
const currentMatch = containing.sort((x, y) => y.blockStart - x.blockStart)[0];
|
|
190
221
|
const authReferences = [];
|
|
191
222
|
if (condition.includes("request.auth")) authReferences.push("request.auth");
|
|
192
223
|
if (condition.includes("request.auth.uid")) authReferences.push("request.auth.uid");
|
|
@@ -205,6 +236,7 @@ function extractRules(rulesFile, relFile) {
|
|
|
205
236
|
path: currentMatch?.path ?? "/(unknown)",
|
|
206
237
|
operations: ops,
|
|
207
238
|
conditionPresent: !unconditional && condition.length > 0,
|
|
239
|
+
condition: unconditional ? void 0 : condition,
|
|
208
240
|
authReferences,
|
|
209
241
|
claimReferences,
|
|
210
242
|
resourceReferences,
|
|
@@ -243,7 +275,7 @@ function isPublicCondition(condition) {
|
|
|
243
275
|
return { isPublic: false, confidence: "CONFIRMED" };
|
|
244
276
|
}
|
|
245
277
|
if (low === "true") return { isPublic: true, confidence: "CONFIRMED" };
|
|
246
|
-
if (/(^|\|\|)true(
|
|
278
|
+
if (/(^|\|\|)true($|\|\|)/.test(low)) return { isPublic: true, confidence: "PROBABLE" };
|
|
247
279
|
if (low.includes("request.auth==null") || low.includes("request.auth===null")) {
|
|
248
280
|
return { isPublic: true, confidence: "CONFIRMED" };
|
|
249
281
|
}
|
|
@@ -256,7 +288,10 @@ function findPublicAllows(rulesFile) {
|
|
|
256
288
|
const matchRe = new RegExp(MATCH_RE.source, "g");
|
|
257
289
|
const blocks = [];
|
|
258
290
|
let mm0;
|
|
259
|
-
while ((mm0 = matchRe.exec(content)) !== null)
|
|
291
|
+
while ((mm0 = matchRe.exec(content)) !== null) {
|
|
292
|
+
const openBrace = mm0.index + mm0[0].length - 1;
|
|
293
|
+
blocks.push({ path: mm0[1], index: mm0.index, end: findBlockEnd(content, openBrace) });
|
|
294
|
+
}
|
|
260
295
|
const re = new RegExp(ALLOW_RE.source, "g");
|
|
261
296
|
let mm;
|
|
262
297
|
while ((mm = re.exec(content)) !== null) {
|
|
@@ -264,7 +299,8 @@ function findPublicAllows(rulesFile) {
|
|
|
264
299
|
const target = mm[1].trim();
|
|
265
300
|
const check = isPublicCondition(mm[2]);
|
|
266
301
|
if (!check.isPublic) continue;
|
|
267
|
-
const
|
|
302
|
+
const containing = blocks.filter((b) => b.index <= mm.index && mm.index < b.end);
|
|
303
|
+
const path = containing.sort((x, y) => y.index - x.index)[0]?.path ?? "/(unknown)";
|
|
268
304
|
out.push({ line, target, path, confidence: check.confidence });
|
|
269
305
|
}
|
|
270
306
|
return out;
|
|
@@ -319,7 +355,7 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
319
355
|
"ERROR",
|
|
320
356
|
pub.confidence,
|
|
321
357
|
`Escrita p\xFAblica em ${pub.path} (${pub.target}). Qualquer cliente pode escrever.`,
|
|
322
|
-
`FBA001:${relRules}:${pub.path}:${pub.target}`,
|
|
358
|
+
`FBA001:${relRules}:${pub.path}:${normalizeTarget(pub.target)}`,
|
|
323
359
|
{
|
|
324
360
|
file: relRules,
|
|
325
361
|
line: pub.line,
|
|
@@ -337,7 +373,7 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
337
373
|
"WARNING",
|
|
338
374
|
pub.confidence,
|
|
339
375
|
`Leitura p\xFAblica em ${pub.path} (${pub.target}). Pode ser proposital.`,
|
|
340
|
-
`FBA002:${relRules}:${pub.path}:${pub.target}`,
|
|
376
|
+
`FBA002:${relRules}:${pub.path}:${normalizeTarget(pub.target)}`,
|
|
341
377
|
{
|
|
342
378
|
file: relRules,
|
|
343
379
|
line: pub.line,
|
|
@@ -431,14 +467,20 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
431
467
|
);
|
|
432
468
|
}
|
|
433
469
|
}
|
|
434
|
-
const publicPaths = new Set(
|
|
435
|
-
absRules && existsSync2(absRules) ? findPublicAllows(absRules).map((p) => `${p.path}::${p.target}`) : []
|
|
436
|
-
);
|
|
437
470
|
const expandOp = (op) => {
|
|
438
471
|
if (op === "read") return ["read", "get", "list"];
|
|
439
472
|
if (op === "write") return ["write", "create", "update", "delete"];
|
|
440
473
|
return [op];
|
|
441
474
|
};
|
|
475
|
+
const publicPaths = /* @__PURE__ */ new Set();
|
|
476
|
+
if (absRules && existsSync2(absRules)) {
|
|
477
|
+
for (const p of findPublicAllows(absRules)) {
|
|
478
|
+
const { ops } = opsFrom(p.target);
|
|
479
|
+
for (const op of /* @__PURE__ */ new Set([...ops, ...ops.flatMap(expandOp)])) {
|
|
480
|
+
publicPaths.add(`${p.path}::${op.toLowerCase()}`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
442
484
|
const permsByResource = /* @__PURE__ */ new Map();
|
|
443
485
|
for (const p of model.authorization.permissions) {
|
|
444
486
|
const holders = model.authorization.roles.filter((r) => r.permissions.includes(p.name)).map((r) => r.name);
|
|
@@ -447,29 +489,28 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
447
489
|
list.push({ perm: p.name, operation: p.operation, adminOnly });
|
|
448
490
|
permsByResource.set(p.resource, list);
|
|
449
491
|
}
|
|
450
|
-
|
|
451
|
-
const resourceKey = rule.path.replace(/^\/+/, "").split("/")[0] ?? "";
|
|
452
|
-
if (resourceKey === "databases" || resourceKey === "(unknown)" || resourceKey === "") continue;
|
|
453
|
-
const entries = permsByResource.get(resourceKey);
|
|
454
|
-
if (!entries || entries.length === 0) continue;
|
|
492
|
+
const checkRuleAgainstEntries = (rule, resourceKey, entries) => {
|
|
455
493
|
const ruleOps = new Set(rule.operations.flatMap(expandOp));
|
|
456
|
-
const restricted = entries.filter(
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
494
|
+
const restricted = entries.filter(
|
|
495
|
+
(e) => e.adminOnly && expandOp(e.operation).some((o) => ruleOps.has(o))
|
|
496
|
+
);
|
|
497
|
+
if (restricted.length === 0) return;
|
|
498
|
+
const isPublicRule = [...ruleOps].some(
|
|
499
|
+
(op) => publicPaths.has(`${rule.path}::${op.toLowerCase()}`)
|
|
500
|
+
);
|
|
501
|
+
if (isPublicRule) return;
|
|
462
502
|
const checksAdminClaim = rule.claimReferences.some((c) => c.toLowerCase() === "admin");
|
|
463
|
-
|
|
503
|
+
const checksAdminRole = /token\.\w+\s*==\s*['"]admin['"]/i.test(rule.condition ?? "");
|
|
504
|
+
if (checksAdminClaim || checksAdminRole) return;
|
|
464
505
|
const checksAuth = rule.authReferences.length > 0;
|
|
465
|
-
if (!checksAuth)
|
|
506
|
+
if (!checksAuth) return;
|
|
466
507
|
findings.push(
|
|
467
508
|
make(
|
|
468
509
|
"FBA012",
|
|
469
510
|
"WARNING",
|
|
470
511
|
"PROBABLE",
|
|
471
512
|
`Rule ${rule.path} [${rule.operations.join(", ")}] aceita autenticado sem checar claim de admin, mas contrato restringe ${restricted.map((r) => r.perm).join(", ")} a admin.`,
|
|
472
|
-
`FBA012:${rule.path}:${[...rule.operations].sort().join(",")}`,
|
|
513
|
+
`FBA012:${rule.path}:${[...rule.operations].sort().join(",")}:${rule.location.start.line}:${resourceKey}`,
|
|
473
514
|
{
|
|
474
515
|
file: rule.location.file,
|
|
475
516
|
line: rule.location.start.line,
|
|
@@ -479,6 +520,27 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
479
520
|
}
|
|
480
521
|
)
|
|
481
522
|
);
|
|
523
|
+
};
|
|
524
|
+
for (const rule of model.rules) {
|
|
525
|
+
const segments = rule.path.replace(/^\/+/, "").split("/").filter(Boolean);
|
|
526
|
+
if (segments.length === 0 || segments[0] === "(unknown)") continue;
|
|
527
|
+
if (segments[0] === "databases") {
|
|
528
|
+
const docIdx = segments.indexOf("documents");
|
|
529
|
+
const candidate = docIdx !== -1 ? segments[docIdx + 1] : void 0;
|
|
530
|
+
if (!candidate || candidate.startsWith("{")) continue;
|
|
531
|
+
const entriesAbs = permsByResource.get(candidate);
|
|
532
|
+
if (!entriesAbs) continue;
|
|
533
|
+
checkRuleAgainstEntries(rule, candidate, entriesAbs);
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
const tried = /* @__PURE__ */ new Set();
|
|
537
|
+
for (const seg of segments) {
|
|
538
|
+
if (seg.startsWith("{") || seg.startsWith("(") || tried.has(seg)) continue;
|
|
539
|
+
tried.add(seg);
|
|
540
|
+
const entries = permsByResource.get(seg);
|
|
541
|
+
if (!entries || entries.length === 0) continue;
|
|
542
|
+
checkRuleAgainstEntries(rule, seg, entries);
|
|
543
|
+
}
|
|
482
544
|
}
|
|
483
545
|
for (const dyn of permissionCalls.dynamic) {
|
|
484
546
|
findings.push(
|
|
@@ -528,9 +590,6 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
528
590
|
async function collectPermissionCalls(rootDir, adapterFn) {
|
|
529
591
|
const literals = [];
|
|
530
592
|
const dynamic = [];
|
|
531
|
-
if (!adapterFn.includes(".")) {
|
|
532
|
-
return { literals, dynamic };
|
|
533
|
-
}
|
|
534
593
|
const bases = [adapterFn];
|
|
535
594
|
const patterns = [];
|
|
536
595
|
for (const b of bases) {
|
|
@@ -549,10 +608,25 @@ async function collectPermissionCalls(rootDir, adapterFn) {
|
|
|
549
608
|
for (const m of result.matches) {
|
|
550
609
|
const raw = (m.metaVariables["PERM"] ?? "").trim();
|
|
551
610
|
const line = m.line + 1;
|
|
552
|
-
|
|
611
|
+
if (raw.includes("${")) {
|
|
612
|
+
const k = `${m.file}:${line}:${raw}`;
|
|
613
|
+
if (!seen.has(k)) {
|
|
614
|
+
seen.add(k);
|
|
615
|
+
dynamic.push({ file: m.file, line, text: m.text });
|
|
616
|
+
}
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
const isQuoted = /^['"`].*['"`]$/.test(raw);
|
|
553
620
|
if (isQuoted) {
|
|
554
|
-
const perm = raw.replace(/^['"]|['"]$/g, "").trim();
|
|
621
|
+
const perm = raw.replace(/^['"`]|['"`]$/g, "").trim();
|
|
555
622
|
if (perm.length >= 3) pushLiteral(m.file, line, perm);
|
|
623
|
+
else if (perm.length > 0) {
|
|
624
|
+
const k = `${m.file}:${line}:${raw}:short`;
|
|
625
|
+
if (!seen.has(k)) {
|
|
626
|
+
seen.add(k);
|
|
627
|
+
dynamic.push({ file: m.file, line, text: m.text });
|
|
628
|
+
}
|
|
629
|
+
}
|
|
556
630
|
} else if (raw.length > 0) {
|
|
557
631
|
const k = `${m.file}:${line}:${raw}`;
|
|
558
632
|
if (!seen.has(k)) {
|
|
@@ -570,10 +644,19 @@ async function collectAdminImports(rootDir) {
|
|
|
570
644
|
const out = [];
|
|
571
645
|
const patterns = [
|
|
572
646
|
'import { $$$ITEMS } from "$MODULE"',
|
|
647
|
+
"import { $$$ITEMS } from '$MODULE'",
|
|
573
648
|
'import $DEFAULT from "$MODULE"',
|
|
649
|
+
"import $DEFAULT from '$MODULE'",
|
|
574
650
|
'import * as $NS from "$MODULE"',
|
|
651
|
+
"import * as $NS from '$MODULE'",
|
|
652
|
+
'import "$MODULE"',
|
|
653
|
+
"import '$MODULE'",
|
|
575
654
|
'const $X = require("$MODULE")',
|
|
576
|
-
|
|
655
|
+
"const $X = require('$MODULE')",
|
|
656
|
+
'require("$MODULE")',
|
|
657
|
+
"require('$MODULE')",
|
|
658
|
+
'import("$MODULE")',
|
|
659
|
+
"import('$MODULE')"
|
|
577
660
|
];
|
|
578
661
|
const seen = /* @__PURE__ */ new Set();
|
|
579
662
|
for (const pattern of patterns) {
|
|
@@ -682,6 +765,7 @@ var RuleSchema = z.strictObject({
|
|
|
682
765
|
path: z.string().min(1),
|
|
683
766
|
operations: z.array(OperationSchema),
|
|
684
767
|
conditionPresent: z.boolean(),
|
|
768
|
+
condition: z.string().optional(),
|
|
685
769
|
authReferences: z.array(z.string()),
|
|
686
770
|
claimReferences: z.array(z.string()),
|
|
687
771
|
resourceReferences: z.array(z.string()),
|
|
@@ -770,8 +854,6 @@ var AuditYamlSchema = z.strictObject({
|
|
|
770
854
|
});
|
|
771
855
|
|
|
772
856
|
// src/scan.ts
|
|
773
|
-
import { readFileSync as readFileSync4, existsSync as existsSync3 } from "fs";
|
|
774
|
-
import { parse as parseYaml } from "yaml";
|
|
775
857
|
async function scan(rootDir, opts = {}) {
|
|
776
858
|
const d = discover(rootDir);
|
|
777
859
|
const model = emptyModel(rootDir, d);
|
|
@@ -789,8 +871,21 @@ async function scan(rootDir, opts = {}) {
|
|
|
789
871
|
});
|
|
790
872
|
}
|
|
791
873
|
if (d.firestoreRulesFile && existsSync3(d.firestoreRulesFile)) {
|
|
792
|
-
const rel =
|
|
793
|
-
|
|
874
|
+
const rel = model.firebase.firestore.rulesFile ?? "firestore.rules";
|
|
875
|
+
try {
|
|
876
|
+
model.rules = extractRules(d.firestoreRulesFile, rel);
|
|
877
|
+
} catch {
|
|
878
|
+
findings_pre.push({
|
|
879
|
+
rule: "RULES_UNREADABLE",
|
|
880
|
+
severity: "WARNING",
|
|
881
|
+
confidence: "CONFIRMED",
|
|
882
|
+
message: "firestore.rules existe mas n\xE3o p\xF4de ser lido \u2014 FBA001/FBA002 sem cobertura.",
|
|
883
|
+
fingerprint: "RULES_UNREADABLE",
|
|
884
|
+
evidence: [
|
|
885
|
+
{ id: "ev-rules-unreadable", kind: "rules-read", summary: "leitura falhou", confidence: "CONFIRMED" }
|
|
886
|
+
]
|
|
887
|
+
});
|
|
888
|
+
}
|
|
794
889
|
}
|
|
795
890
|
if (d.firestoreIndexesFile && existsSync3(d.firestoreIndexesFile)) {
|
|
796
891
|
try {
|
|
@@ -798,18 +893,24 @@ async function scan(rootDir, opts = {}) {
|
|
|
798
893
|
const validModes = ["ASCENDING", "DESCENDING", "ARRAY_CONTAINS", "VECTOR"];
|
|
799
894
|
const entries = [];
|
|
800
895
|
let skipped = 0;
|
|
896
|
+
let scopeSkipped = 0;
|
|
801
897
|
for (const i of raw.indexes ?? []) {
|
|
802
898
|
if (typeof i.collectionGroup !== "string" || i.collectionGroup.length === 0) {
|
|
803
899
|
skipped += 1;
|
|
804
900
|
continue;
|
|
805
901
|
}
|
|
902
|
+
if (i.queryScope !== void 0 && i.queryScope !== "COLLECTION" && i.queryScope !== "COLLECTION_GROUP") {
|
|
903
|
+
scopeSkipped += 1;
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
806
906
|
const fields = [];
|
|
807
907
|
for (const f of i.fields ?? []) {
|
|
808
|
-
if (typeof f.fieldPath !== "string" || f.fieldPath.length === 0) {
|
|
908
|
+
if (typeof f.fieldPath !== "string" || f.fieldPath.length === 0 || f.fieldPath === "__name__") {
|
|
809
909
|
skipped += 1;
|
|
810
910
|
continue;
|
|
811
911
|
}
|
|
812
|
-
const
|
|
912
|
+
const rawMode = f.vectorConfig ? "VECTOR" : f.arrayConfig === "CONTAINS" ? "ARRAY_CONTAINS" : typeof f.mode === "string" ? f.mode : f.order;
|
|
913
|
+
const mode = rawMode;
|
|
813
914
|
if (mode !== "ASCENDING" && mode !== "DESCENDING" && mode !== "ARRAY_CONTAINS" && mode !== "VECTOR") {
|
|
814
915
|
skipped += 1;
|
|
815
916
|
continue;
|
|
@@ -823,15 +924,16 @@ async function scan(rootDir, opts = {}) {
|
|
|
823
924
|
});
|
|
824
925
|
}
|
|
825
926
|
model.indexes = entries;
|
|
826
|
-
|
|
927
|
+
const totalSkipped = skipped + scopeSkipped;
|
|
928
|
+
if (totalSkipped > 0) {
|
|
827
929
|
findings_pre.push({
|
|
828
930
|
rule: "INDEXES_INVALID",
|
|
829
931
|
severity: "WARNING",
|
|
830
932
|
confidence: "CONFIRMED",
|
|
831
|
-
message: `firestore.indexes.json com ${
|
|
933
|
+
message: `firestore.indexes.json com ${totalSkipped} entrada(s) inv\xE1lidas ignoradas \u2014 verifique campos.`,
|
|
832
934
|
fingerprint: "INDEXES_INVALID",
|
|
833
935
|
evidence: [
|
|
834
|
-
{ id: "ev-indexes-skipped", kind: "indexes-parse", summary: `${
|
|
936
|
+
{ id: "ev-indexes-skipped", kind: "indexes-parse", summary: `${totalSkipped} inv\xE1lidas`, confidence: "CONFIRMED" }
|
|
835
937
|
]
|
|
836
938
|
});
|
|
837
939
|
}
|
|
@@ -925,14 +1027,21 @@ async function scan(rootDir, opts = {}) {
|
|
|
925
1027
|
const errors = findings.filter((f) => f.severity === "ERROR").length;
|
|
926
1028
|
const warnings = findings.filter((f) => f.severity === "WARNING").length;
|
|
927
1029
|
const infos = findings.filter((f) => f.severity === "INFO").length;
|
|
928
|
-
|
|
1030
|
+
const failedChecks = new Set(
|
|
1031
|
+
findings.filter((f) => f.severity === "ERROR" || f.severity === "WARNING").map((f) => f.rule)
|
|
1032
|
+
);
|
|
1033
|
+
const total = IMPLEMENTED_CHECKS.length;
|
|
1034
|
+
const passed = IMPLEMENTED_CHECKS.filter((c) => !failedChecks.has(c)).length;
|
|
1035
|
+
return { model, findings, summary: { errors, warnings, infos, passed, total } };
|
|
929
1036
|
}
|
|
930
1037
|
|
|
931
1038
|
export {
|
|
932
1039
|
discover,
|
|
1040
|
+
SERVER_HINTS_SEGMENTS,
|
|
933
1041
|
classifyOrigin,
|
|
934
1042
|
emptyModel,
|
|
935
1043
|
stripRuleComments,
|
|
1044
|
+
opsFrom,
|
|
936
1045
|
extractRules,
|
|
937
1046
|
isPublicCondition,
|
|
938
1047
|
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-UMQH7UDI.js";
|
|
5
5
|
|
|
6
6
|
// src/cli.ts
|
|
7
7
|
import { createRequire } from "module";
|
|
@@ -20,11 +20,13 @@ async function main() {
|
|
|
20
20
|
Uso:
|
|
21
21
|
firebase-audit scan [--json] [--strict] [--adapter=fn] [--cwd=path]
|
|
22
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)
|
|
23
25
|
|
|
24
|
-
Exit codes: 0 ok
|
|
26
|
+
Exit codes: 0 ok informativo; 2 quando --strict/check encontra ERROR. scan sozinho nunca reprova (use check no CI).`);
|
|
25
27
|
return;
|
|
26
28
|
}
|
|
27
|
-
if (cmd !== "scan" && cmd !== "check") {
|
|
29
|
+
if (cmd !== "scan" && cmd !== "check" && cmd !== "verify" && cmd !== "drift") {
|
|
28
30
|
console.error(`Comando desconhecido: ${cmd}
|
|
29
31
|
Uso: firebase-audit scan [--json] [--strict] [--adapter=fn] [--cwd=path]`);
|
|
30
32
|
process.exit(1);
|
|
@@ -39,17 +41,90 @@ Uso: firebase-audit scan [--json] [--strict] [--adapter=fn] [--cwd=path]`);
|
|
|
39
41
|
if (idx !== -1 && args[idx + 1] && !args[idx + 1].startsWith("--")) return args[idx + 1];
|
|
40
42
|
return void 0;
|
|
41
43
|
};
|
|
42
|
-
const knownFlags = /* @__PURE__ */ new Set(["--json", "--strict", "--help", "-h", "help", "--version", "-v"]);
|
|
44
|
+
const knownFlags = /* @__PURE__ */ new Set(["--json", "--strict", "--help", "-h", "help", "--version", "-v", "--coverage", "--local", "--remote"]);
|
|
43
45
|
for (const a of args.slice(1)) {
|
|
44
|
-
|
|
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)) {
|
|
45
48
|
console.error(`Flag desconhecida: ${a}
|
|
46
|
-
Uso: firebase-audit scan [--json]
|
|
49
|
+
Uso: firebase-audit scan|check|verify|drift [--json] ...`);
|
|
47
50
|
process.exit(1);
|
|
48
51
|
}
|
|
49
52
|
}
|
|
50
53
|
const adapterArg = getArg("adapter");
|
|
51
54
|
const cwdArg = getArg("cwd");
|
|
52
|
-
|
|
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-TLHI777J.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
|
+
}
|
|
53
128
|
const { findings, summary } = await scan(rootDir, { strict, adapterFn: adapterArg });
|
|
54
129
|
if (asJson) {
|
|
55
130
|
console.log(JSON.stringify({ version: pkg.version, summary, findings }, null, 2));
|
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>;
|
|
@@ -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,11 +651,9 @@ 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
659
|
declare const IMPLEMENTED_CHECKS: readonly ["FBA001", "FBA002", "FBA003", "FBA004", "FBA009", "FBA010", "FBA011", "FBA012", "FBA013"];
|
|
@@ -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-UMQH7UDI.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.1",
|
|
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",
|