@justmpm/firebase-audit 0.2.0 → 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-PIRAC3KT.js → chunk-N57MCLTZ.js} +134 -44
- package/dist/cli.js +82 -7
- package/dist/drift-BEXSL2IX.js +6 -0
- package/dist/index.d.ts +66 -7
- 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,
|
|
@@ -243,7 +272,7 @@ function isPublicCondition(condition) {
|
|
|
243
272
|
return { isPublic: false, confidence: "CONFIRMED" };
|
|
244
273
|
}
|
|
245
274
|
if (low === "true") return { isPublic: true, confidence: "CONFIRMED" };
|
|
246
|
-
if (/(^|\|\|)true(
|
|
275
|
+
if (/(^|\|\|)true($|\|\|)/.test(low)) return { isPublic: true, confidence: "PROBABLE" };
|
|
247
276
|
if (low.includes("request.auth==null") || low.includes("request.auth===null")) {
|
|
248
277
|
return { isPublic: true, confidence: "CONFIRMED" };
|
|
249
278
|
}
|
|
@@ -256,7 +285,10 @@ function findPublicAllows(rulesFile) {
|
|
|
256
285
|
const matchRe = new RegExp(MATCH_RE.source, "g");
|
|
257
286
|
const blocks = [];
|
|
258
287
|
let mm0;
|
|
259
|
-
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
|
+
}
|
|
260
292
|
const re = new RegExp(ALLOW_RE.source, "g");
|
|
261
293
|
let mm;
|
|
262
294
|
while ((mm = re.exec(content)) !== null) {
|
|
@@ -264,7 +296,8 @@ function findPublicAllows(rulesFile) {
|
|
|
264
296
|
const target = mm[1].trim();
|
|
265
297
|
const check = isPublicCondition(mm[2]);
|
|
266
298
|
if (!check.isPublic) continue;
|
|
267
|
-
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)";
|
|
268
301
|
out.push({ line, target, path, confidence: check.confidence });
|
|
269
302
|
}
|
|
270
303
|
return out;
|
|
@@ -319,7 +352,7 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
319
352
|
"ERROR",
|
|
320
353
|
pub.confidence,
|
|
321
354
|
`Escrita p\xFAblica em ${pub.path} (${pub.target}). Qualquer cliente pode escrever.`,
|
|
322
|
-
`FBA001:${relRules}:${pub.path}:${pub.target}`,
|
|
355
|
+
`FBA001:${relRules}:${pub.path}:${normalizeTarget(pub.target)}`,
|
|
323
356
|
{
|
|
324
357
|
file: relRules,
|
|
325
358
|
line: pub.line,
|
|
@@ -337,7 +370,7 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
337
370
|
"WARNING",
|
|
338
371
|
pub.confidence,
|
|
339
372
|
`Leitura p\xFAblica em ${pub.path} (${pub.target}). Pode ser proposital.`,
|
|
340
|
-
`FBA002:${relRules}:${pub.path}:${pub.target}`,
|
|
373
|
+
`FBA002:${relRules}:${pub.path}:${normalizeTarget(pub.target)}`,
|
|
341
374
|
{
|
|
342
375
|
file: relRules,
|
|
343
376
|
line: pub.line,
|
|
@@ -431,14 +464,20 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
431
464
|
);
|
|
432
465
|
}
|
|
433
466
|
}
|
|
434
|
-
const publicPaths = new Set(
|
|
435
|
-
absRules && existsSync2(absRules) ? findPublicAllows(absRules).map((p) => `${p.path}::${p.target}`) : []
|
|
436
|
-
);
|
|
437
467
|
const expandOp = (op) => {
|
|
438
468
|
if (op === "read") return ["read", "get", "list"];
|
|
439
469
|
if (op === "write") return ["write", "create", "update", "delete"];
|
|
440
470
|
return [op];
|
|
441
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
|
+
}
|
|
442
481
|
const permsByResource = /* @__PURE__ */ new Map();
|
|
443
482
|
for (const p of model.authorization.permissions) {
|
|
444
483
|
const holders = model.authorization.roles.filter((r) => r.permissions.includes(p.name)).map((r) => r.name);
|
|
@@ -447,29 +486,28 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
447
486
|
list.push({ perm: p.name, operation: p.operation, adminOnly });
|
|
448
487
|
permsByResource.set(p.resource, list);
|
|
449
488
|
}
|
|
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;
|
|
489
|
+
const checkRuleAgainstEntries = (rule, resourceKey, entries) => {
|
|
455
490
|
const ruleOps = new Set(rule.operations.flatMap(expandOp));
|
|
456
|
-
const restricted = entries.filter(
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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;
|
|
462
499
|
const checksAdminClaim = rule.claimReferences.some((c) => c.toLowerCase() === "admin");
|
|
463
|
-
|
|
500
|
+
const checksAdminRole = /token\.\w+\s*==\s*['"]admin['"]/i.test(rule.condition ?? "");
|
|
501
|
+
if (checksAdminClaim || checksAdminRole) return;
|
|
464
502
|
const checksAuth = rule.authReferences.length > 0;
|
|
465
|
-
if (!checksAuth)
|
|
503
|
+
if (!checksAuth) return;
|
|
466
504
|
findings.push(
|
|
467
505
|
make(
|
|
468
506
|
"FBA012",
|
|
469
507
|
"WARNING",
|
|
470
508
|
"PROBABLE",
|
|
471
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.`,
|
|
472
|
-
`FBA012:${rule.path}:${[...rule.operations].sort().join(",")}`,
|
|
510
|
+
`FBA012:${rule.path}:${[...rule.operations].sort().join(",")}:${rule.location.start.line}:${resourceKey}`,
|
|
473
511
|
{
|
|
474
512
|
file: rule.location.file,
|
|
475
513
|
line: rule.location.start.line,
|
|
@@ -479,6 +517,27 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
479
517
|
}
|
|
480
518
|
)
|
|
481
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
|
+
}
|
|
482
541
|
}
|
|
483
542
|
for (const dyn of permissionCalls.dynamic) {
|
|
484
543
|
findings.push(
|
|
@@ -528,9 +587,6 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
528
587
|
async function collectPermissionCalls(rootDir, adapterFn) {
|
|
529
588
|
const literals = [];
|
|
530
589
|
const dynamic = [];
|
|
531
|
-
if (!adapterFn.includes(".")) {
|
|
532
|
-
return { literals, dynamic };
|
|
533
|
-
}
|
|
534
590
|
const bases = [adapterFn];
|
|
535
591
|
const patterns = [];
|
|
536
592
|
for (const b of bases) {
|
|
@@ -549,10 +605,25 @@ async function collectPermissionCalls(rootDir, adapterFn) {
|
|
|
549
605
|
for (const m of result.matches) {
|
|
550
606
|
const raw = (m.metaVariables["PERM"] ?? "").trim();
|
|
551
607
|
const line = m.line + 1;
|
|
552
|
-
|
|
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);
|
|
553
617
|
if (isQuoted) {
|
|
554
|
-
const perm = raw.replace(/^['"]|['"]$/g, "").trim();
|
|
618
|
+
const perm = raw.replace(/^['"`]|['"`]$/g, "").trim();
|
|
555
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
|
+
}
|
|
556
627
|
} else if (raw.length > 0) {
|
|
557
628
|
const k = `${m.file}:${line}:${raw}`;
|
|
558
629
|
if (!seen.has(k)) {
|
|
@@ -682,6 +753,7 @@ var RuleSchema = z.strictObject({
|
|
|
682
753
|
path: z.string().min(1),
|
|
683
754
|
operations: z.array(OperationSchema),
|
|
684
755
|
conditionPresent: z.boolean(),
|
|
756
|
+
condition: z.string().optional(),
|
|
685
757
|
authReferences: z.array(z.string()),
|
|
686
758
|
claimReferences: z.array(z.string()),
|
|
687
759
|
resourceReferences: z.array(z.string()),
|
|
@@ -753,7 +825,7 @@ var FindingSchema = z.strictObject({
|
|
|
753
825
|
});
|
|
754
826
|
var PermissionNameSchema = z.string().regex(/^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)*$/);
|
|
755
827
|
var RoleNameSchema = z.string().regex(/^[a-z][a-z0-9_-]*$/);
|
|
756
|
-
var AuditYamlSchema = z.
|
|
828
|
+
var AuditYamlSchema = z.object({
|
|
757
829
|
authorization: z.strictObject({
|
|
758
830
|
adapter: z.strictObject({ function: z.string().min(1) }).optional(),
|
|
759
831
|
roles: z.record(RoleNameSchema, z.strictObject({ permissions: z.array(PermissionNameSchema) })),
|
|
@@ -770,8 +842,6 @@ var AuditYamlSchema = z.strictObject({
|
|
|
770
842
|
});
|
|
771
843
|
|
|
772
844
|
// src/scan.ts
|
|
773
|
-
import { readFileSync as readFileSync4, existsSync as existsSync3 } from "fs";
|
|
774
|
-
import { parse as parseYaml } from "yaml";
|
|
775
845
|
async function scan(rootDir, opts = {}) {
|
|
776
846
|
const d = discover(rootDir);
|
|
777
847
|
const model = emptyModel(rootDir, d);
|
|
@@ -789,8 +859,21 @@ async function scan(rootDir, opts = {}) {
|
|
|
789
859
|
});
|
|
790
860
|
}
|
|
791
861
|
if (d.firestoreRulesFile && existsSync3(d.firestoreRulesFile)) {
|
|
792
|
-
const rel =
|
|
793
|
-
|
|
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
|
+
}
|
|
794
877
|
}
|
|
795
878
|
if (d.firestoreIndexesFile && existsSync3(d.firestoreIndexesFile)) {
|
|
796
879
|
try {
|
|
@@ -925,14 +1008,21 @@ async function scan(rootDir, opts = {}) {
|
|
|
925
1008
|
const errors = findings.filter((f) => f.severity === "ERROR").length;
|
|
926
1009
|
const warnings = findings.filter((f) => f.severity === "WARNING").length;
|
|
927
1010
|
const infos = findings.filter((f) => f.severity === "INFO").length;
|
|
928
|
-
|
|
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 } };
|
|
929
1017
|
}
|
|
930
1018
|
|
|
931
1019
|
export {
|
|
932
1020
|
discover,
|
|
1021
|
+
SERVER_HINTS_SEGMENTS,
|
|
933
1022
|
classifyOrigin,
|
|
934
1023
|
emptyModel,
|
|
935
1024
|
stripRuleComments,
|
|
1025
|
+
opsFrom,
|
|
936
1026
|
extractRules,
|
|
937
1027
|
isPublicCondition,
|
|
938
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";
|
|
@@ -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-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
|
+
}
|
|
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>;
|
|
@@ -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,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-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",
|