@justmpm/firebase-audit 0.5.3 → 0.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -15
- package/dist/{chunk-PWJ4DI24.js → chunk-CHC7ZHUK.js} +7 -4
- package/dist/{chunk-Y4VXWSKX.js → chunk-DIO36A3I.js} +3 -2
- package/dist/{chunk-7EWTBIKJ.js → chunk-FECVBQUO.js} +39 -10
- package/dist/{chunk-4BBFPTOR.js → chunk-ME6WO45S.js} +170 -54
- package/dist/cli.js +27 -11
- package/dist/index.d.ts +9 -6
- package/dist/index.js +4 -4
- package/dist/mcp-cli.js +4 -4
- package/dist/mcp.js +4 -4
- package/dist/{rules-GTGZWN5U.js → rules-W4VUUQM4.js} +1 -1
- package/dist/scan-HE5WAMWJ.js +7 -0
- package/dist/{verify-WBVSESHD.js → verify-DECP4IUW.js} +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +2 -1
- package/dist/scan-LJ3TCHMR.js +0 -7
package/README.md
CHANGED
|
@@ -163,20 +163,22 @@ Limites assumidos: Emulator não prova índice (produção exige, ele deixa pass
|
|
|
163
163
|
- `UNKNOWN` — impossível resolver estaticamente (ex: `where(fieldVar, ...)`)
|
|
164
164
|
- `CONFLICTING` — interno, duas evidências se contradizem (ex: typo `order.delete` vs `orders.delete`)
|
|
165
165
|
|
|
166
|
-
###
|
|
166
|
+
### 9 checks implementados (+ avisos de infra e Storage/AppCheck)
|
|
167
167
|
|
|
168
168
|
| # | Check | Sev | Conf |
|
|
169
169
|
|---|-------|-----|------|
|
|
170
|
-
| FBA001 | `allow write/create/update/delete
|
|
171
|
-
| FBA002 | `allow read
|
|
172
|
-
| FBA003 | Admin SDK
|
|
170
|
+
| FBA001 | `allow write/create/update/delete` público | ERROR | CONFIRMED/PROBABLE |
|
|
171
|
+
| FBA002 | `allow read/get/list` público (pode ser proposital) | WARNING | CONFIRMED/PROBABLE |
|
|
172
|
+
| FBA003 | Admin SDK em arquivo de cliente (+ origem indefinida como INFO) | ERROR/INFO | PROBABLE/UNKNOWN |
|
|
173
173
|
| FBA004 | Permission usada e não declarada | ERROR | CONFIRMED |
|
|
174
|
-
|
|
|
175
|
-
|
|
|
176
|
-
|
|
|
177
|
-
|
|
|
178
|
-
|
|
|
179
|
-
|
|
|
174
|
+
| FBA009 | Adapter não observado (com hint de portaria tenant) | WARNING/INFO | UNKNOWN/NOT_OBSERVED |
|
|
175
|
+
| FBA010 | Permissão dinâmica (não resolvida estaticamente) | INFO | UNKNOWN |
|
|
176
|
+
| FBA011 | Role referencia permission não declarada | ERROR | CONFIRMED |
|
|
177
|
+
| FBA012 | Rule ampla vs contrato admin-only | WARNING | PROBABLE |
|
|
178
|
+
| FBA013 | Role sem permissions | WARNING | CONFIRMED |
|
|
179
|
+
| AUTH_ANON_ALLOWED | `auth != null` sozinho (anônimo passa) | INFO | PROBABLE |
|
|
180
|
+
| STORAGE_* | Escrita aberta / curinga total / leitura aberta / anônimo (por bloco do match) | ERROR/WARNING/INFO | CONFIRMED/PROBABLE |
|
|
181
|
+
| APPCHECK_* | Carimbo observado / verificação manual (onRequest por rota) | INFO | CONFIRMED/PROBABLE |
|
|
180
182
|
|
|
181
183
|
Detalhes que quebram se deixar pra depois: `write` = create+update+delete (guardar origem), `OR` nas Rules (`isAdmin() || public==true`), `resource` vs `request.resource`, `collectionGroup` com escopo próprio, `databaseId` nomeado desde o dia 1.
|
|
182
184
|
|
|
@@ -193,12 +195,13 @@ Detalhes que quebram se deixar pra depois: `write` = create+update+delete (guard
|
|
|
193
195
|
|
|
194
196
|
## 2. Escopo
|
|
195
197
|
|
|
196
|
-
**
|
|
197
|
-
**
|
|
198
|
-
**
|
|
199
|
-
**
|
|
198
|
+
**Scan:** Discovery + checks + `firebase-audit scan [--json|--strict]`. Zero-config, valor imediato.
|
|
199
|
+
**Check com contrato:** YAML + `Code ↔ Contract ↔ Rules` (mismatch muito amplo, role sem permissão, etc.).
|
|
200
|
+
**Verify:** gera matriz papel×operação×path → roda no Emulator → cobertura `ruleCoverage` (não substitui scan).
|
|
201
|
+
**Drift:** local vs implantado (`LOCAL_ONLY / REMOTE_ONLY / MATCHED`).
|
|
200
202
|
|
|
201
|
-
|
|
203
|
+
**Escopo atual:** Firestore Rules + Storage Rules (vitrine vs cofre, por bloco) + AppCheck (carimbo, não autorização) + contrato + índices.
|
|
204
|
+
**Fora de escopo (declarado em `summary.skipped`):** Realtime DB, Hosting, Functions complexas, IAM, Data Connect, isolamento por tenant, bancos/buckets extras além do default.
|
|
202
205
|
|
|
203
206
|
---
|
|
204
207
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
scan
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-ME6WO45S.js";
|
|
4
4
|
import {
|
|
5
5
|
verify
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-DIO36A3I.js";
|
|
7
7
|
import {
|
|
8
8
|
drift
|
|
9
9
|
} from "./chunk-XZ32YQQC.js";
|
|
@@ -152,7 +152,7 @@ function createMcpServer() {
|
|
|
152
152
|
try {
|
|
153
153
|
assertCwd(cwd);
|
|
154
154
|
const { resolve } = await import("path");
|
|
155
|
-
const { model } = await scan(cwd);
|
|
155
|
+
const { summary: scanSummary, model } = await scan(cwd);
|
|
156
156
|
const res = verify(model, { coverageFile: coverage ? resolve(cwd, coverage) : void 0 });
|
|
157
157
|
const roles = model.authorization.roles.length > 0 ? model.authorization.roles.map((r) => r.name) : ["anonymous", "user", "admin"];
|
|
158
158
|
const truncated = res.cases.length > 50;
|
|
@@ -169,8 +169,10 @@ function createMcpServer() {
|
|
|
169
169
|
truncated,
|
|
170
170
|
uncovered: res.uncovered.slice(0, 20),
|
|
171
171
|
uncoveredTotal: res.uncovered.length,
|
|
172
|
+
uncoveredNote: res.uncoveredNote,
|
|
172
173
|
coveragePct: res.coveragePct,
|
|
173
|
-
findings: res.findings
|
|
174
|
+
findings: res.findings,
|
|
175
|
+
scan: { errors: scanSummary.errors, warnings: scanSummary.warnings, note: "verify n\xE3o substitui scan/check" }
|
|
174
176
|
},
|
|
175
177
|
null,
|
|
176
178
|
2
|
|
@@ -201,6 +203,7 @@ function createMcpServer() {
|
|
|
201
203
|
try {
|
|
202
204
|
const { resolve: resolveP, isAbsolute } = await import("path");
|
|
203
205
|
const base = cwd && cwd.length > 0 ? cwd : process.cwd();
|
|
206
|
+
assertCwd(base);
|
|
204
207
|
const resPath = (p) => {
|
|
205
208
|
if (p === null || p === void 0) return null;
|
|
206
209
|
return isAbsolute(p) ? p : resolveP(base, p);
|
|
@@ -102,8 +102,9 @@ function verify(model, opts = {}) {
|
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
|
+
const uncoveredNote = "uncovered lista os paths do modelo \u2014 atribui\xE7\xE3o contador\u2192path indispon\xEDvel (s\xF3 coverage 100% esvazia)";
|
|
105
106
|
if (coveragePct === 100) {
|
|
106
|
-
return { cases, uncovered: [], findings, coveragePct };
|
|
107
|
+
return { cases, uncovered: [], findings, coveragePct, uncoveredNote };
|
|
107
108
|
}
|
|
108
109
|
const seenPaths = /* @__PURE__ */ new Set();
|
|
109
110
|
for (const rule of model.rules) {
|
|
@@ -112,7 +113,7 @@ function verify(model, opts = {}) {
|
|
|
112
113
|
seenPaths.add(rule.path);
|
|
113
114
|
uncovered.push(rule.path);
|
|
114
115
|
}
|
|
115
|
-
return { cases, uncovered, findings, coveragePct };
|
|
116
|
+
return { cases, uncovered, findings, coveragePct, uncoveredNote };
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
export {
|
|
@@ -162,21 +162,47 @@ function inlineHelpersInCondition(condition, detailed) {
|
|
|
162
162
|
}
|
|
163
163
|
return out;
|
|
164
164
|
}
|
|
165
|
+
function isSingleCall(condition) {
|
|
166
|
+
const m = /^([A-Za-z_][A-Za-z0-9_]*)\s*\(/.exec(condition.trim());
|
|
167
|
+
if (!m) return null;
|
|
168
|
+
const open = condition.indexOf("(", m[0].length - 1);
|
|
169
|
+
let depth = 0;
|
|
170
|
+
let quote = null;
|
|
171
|
+
for (let i = open; i < condition.length; i++) {
|
|
172
|
+
const c = condition[i];
|
|
173
|
+
if (quote) {
|
|
174
|
+
if (c === quote && condition[i - 1] !== "\\") quote = null;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
178
|
+
quote = c;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (c === "(") depth += 1;
|
|
182
|
+
else if (c === ")") {
|
|
183
|
+
depth -= 1;
|
|
184
|
+
if (depth === 0) {
|
|
185
|
+
const rest = condition.slice(i + 1).replace(/;$/, "").trim();
|
|
186
|
+
if (rest !== "") return null;
|
|
187
|
+
return { name: m[1], args: condition.slice(open + 1, i) };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
165
193
|
function resolveHelperCondition(condition, functions) {
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const body = functions.get(call[1]);
|
|
194
|
+
const single = isSingleCall(condition);
|
|
195
|
+
if (!single) return condition;
|
|
196
|
+
const body = functions.get(single.name);
|
|
170
197
|
if (body === void 0) return condition;
|
|
171
198
|
return body;
|
|
172
199
|
}
|
|
173
200
|
function resolveHelperConditionDetailed(condition, detailed) {
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const fn = detailed.get(call[1]);
|
|
201
|
+
const single = isSingleCall(condition);
|
|
202
|
+
if (!single) return condition;
|
|
203
|
+
const fn = detailed.get(single.name);
|
|
178
204
|
if (!fn) return condition;
|
|
179
|
-
const args =
|
|
205
|
+
const args = single.args.length === 0 ? [] : single.args.split(",").map((s) => s.trim());
|
|
180
206
|
return inlineHelperArgs(fn.body, fn.params, args);
|
|
181
207
|
}
|
|
182
208
|
function extractRules(rulesFile, relFile) {
|
|
@@ -275,7 +301,10 @@ function isPublicCondition(condition) {
|
|
|
275
301
|
return { isPublic: false, confidence: "CONFIRMED" };
|
|
276
302
|
}
|
|
277
303
|
if (low === "true") return { isPublic: true, confidence: "CONFIRMED" };
|
|
278
|
-
|
|
304
|
+
for (const part of low.split("||")) {
|
|
305
|
+
const branch = stripOuterParens(part.replace(/return/g, "").replace(/;/g, "").trim());
|
|
306
|
+
if (branch === "true") return { isPublic: true, confidence: "PROBABLE" };
|
|
307
|
+
}
|
|
279
308
|
if (low.includes("request.auth==null") || low.includes("request.auth===null")) {
|
|
280
309
|
return { isPublic: true, confidence: "CONFIRMED" };
|
|
281
310
|
}
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
resolveHelperConditionDetailed,
|
|
10
10
|
stripOuterParens,
|
|
11
11
|
stripRuleComments
|
|
12
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-FECVBQUO.js";
|
|
13
13
|
|
|
14
14
|
// src/scan.ts
|
|
15
15
|
import { readFileSync as readFileSync3, existsSync as existsSync3 } from "fs";
|
|
@@ -51,18 +51,35 @@ function discover(rootDir) {
|
|
|
51
51
|
if (firebaseJson) {
|
|
52
52
|
try {
|
|
53
53
|
const raw = JSON.parse(readFileSync(firebaseJson, "utf-8"));
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
const first = (v) => Array.isArray(v) ? v[0] : v;
|
|
55
|
+
const fs = first(raw.firestore);
|
|
56
|
+
const st = first(raw.storage);
|
|
57
|
+
if (typeof fs?.rules === "string") {
|
|
58
|
+
const p = join(rootDir, fs.rules);
|
|
56
59
|
if (existsSync(p)) rulesFile = p;
|
|
57
60
|
}
|
|
58
|
-
if (typeof
|
|
59
|
-
const p = join(rootDir,
|
|
61
|
+
if (typeof fs?.indexes === "string") {
|
|
62
|
+
const p = join(rootDir, fs.indexes);
|
|
60
63
|
if (existsSync(p)) indexesFile = p;
|
|
61
64
|
}
|
|
62
|
-
if (typeof
|
|
63
|
-
const p = join(rootDir,
|
|
65
|
+
if (typeof st?.rules === "string") {
|
|
66
|
+
const p = join(rootDir, st.rules);
|
|
64
67
|
if (existsSync(p)) storageFile = p;
|
|
65
68
|
}
|
|
69
|
+
const fns = raw.functions;
|
|
70
|
+
if (typeof fns === "string" && fns.length > 0) {
|
|
71
|
+
const p = join(rootDir, fns);
|
|
72
|
+
try {
|
|
73
|
+
if (statSync(p).isDirectory()) functionsDir = p;
|
|
74
|
+
} catch {
|
|
75
|
+
}
|
|
76
|
+
} else if (fns && typeof fns === "object" && typeof fns.source === "string" && fns.source.length > 0) {
|
|
77
|
+
const p = join(rootDir, fns.source);
|
|
78
|
+
try {
|
|
79
|
+
if (statSync(p).isDirectory()) functionsDir = p;
|
|
80
|
+
} catch {
|
|
81
|
+
}
|
|
82
|
+
}
|
|
66
83
|
} catch {
|
|
67
84
|
}
|
|
68
85
|
}
|
|
@@ -105,7 +122,7 @@ function classifyOrigin(file, content) {
|
|
|
105
122
|
if (content.includes("firebase-admin") || content.includes("firebase-functions")) {
|
|
106
123
|
return "CLIENT";
|
|
107
124
|
}
|
|
108
|
-
if (
|
|
125
|
+
if (hasSeg("src", "app", "components")) {
|
|
109
126
|
return "CLIENT";
|
|
110
127
|
}
|
|
111
128
|
return "UNKNOWN";
|
|
@@ -139,7 +156,7 @@ async function enrichWithGraph(rootDir, d) {
|
|
|
139
156
|
const ai = await import("@justmpm/ai-tool");
|
|
140
157
|
const res = await ai.map({ cwd: rootDir, format: "json" });
|
|
141
158
|
const files = Array.isArray(res.files) ? res.files : [];
|
|
142
|
-
const code = files.map((f) => String(f.path).replace(/\\/g, "/")).filter((p) => /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(p)).filter((p) => !p.includes("node_modules/") && !/(^|\/)dist\//.test(p)).slice(0, 500);
|
|
159
|
+
const code = files.map((f) => String(f.path).replace(/\\/g, "/")).filter((p) => /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(p)).filter((p) => !p.includes("node_modules/") && !/(^|\/)(dist|build|\.next|coverage|lib)\//.test(p)).slice(0, 500);
|
|
143
160
|
const { readFileSync: readSync, existsSync: existsSyncFn, statSync: statSync2 } = await import("fs");
|
|
144
161
|
const { join: joinP } = await import("path");
|
|
145
162
|
for (const rel of code) {
|
|
@@ -253,7 +270,7 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
253
270
|
const declared = new Set(model.authorization.permissions.map((p) => p.name));
|
|
254
271
|
if (permissionCalls.literals.length === 0 && permissionCalls.dynamic.length === 0) {
|
|
255
272
|
const tenantHint = [...helperDetailed.keys()].find(
|
|
256
|
-
(n) => /tenant|isMember|
|
|
273
|
+
(n) => /tenant|isMember|_member/i.test(n)
|
|
257
274
|
);
|
|
258
275
|
findings.push(
|
|
259
276
|
make(
|
|
@@ -367,7 +384,7 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
367
384
|
const condResolved = resolveHelperConditionDetailed(condRaw, helperDetailed);
|
|
368
385
|
const condInlined = inlineHelpersInCondition(condRaw, helperDetailed);
|
|
369
386
|
const cond = condResolved !== condRaw ? condResolved : condInlined !== condRaw ? condInlined : condRaw;
|
|
370
|
-
const isInvertedAdmin = /admin\s*==\s*false/i.test(cond) || /admin\s*!=\s*true/i.test(cond) || /admin\s*!==\s*true/i.test(cond) || /!\s*request\.auth\.token\.admin\b/i.test(cond) || /!\s*\(\s*request\.auth\.token\.admin\b/i.test(cond) || /!\s*\(
|
|
387
|
+
const isInvertedAdmin = /admin\s*==\s*false/i.test(cond) || /admin\s*!=\s*true/i.test(cond) || /admin\s*!==\s*true/i.test(cond) || /!\s*request\.auth\.token\.admin\b/i.test(cond) || /!\s*\(\s*request\.auth\.token\.admin\b/i.test(cond) || /!\s*\([^()]*admin\s*==\s*true/i.test(cond);
|
|
371
388
|
const normClaim = (c) => c.toLowerCase().replace(/[_-]/g, "");
|
|
372
389
|
const isAdminCall = /\bisadmin\s*\(([^)]*)\)/i.exec(condRaw) ?? /\bisadmin\s*\(([^)]*)\)/i.exec(cond);
|
|
373
390
|
let isAdminBodyHasAdmin = false;
|
|
@@ -511,6 +528,22 @@ async function runChecks(model, rootDir, opts = {}) {
|
|
|
511
528
|
}
|
|
512
529
|
)
|
|
513
530
|
);
|
|
531
|
+
} else if (origin === "UNKNOWN" && !isTestPath(hit.file)) {
|
|
532
|
+
findings.push(
|
|
533
|
+
make(
|
|
534
|
+
"ADMIN_ORIGIN_UNKNOWN",
|
|
535
|
+
"INFO",
|
|
536
|
+
"UNKNOWN",
|
|
537
|
+
`Admin SDK em arquivo de origem indefinida (${hit.file}). Se for cliente, Rules n\xE3o valem \u2014 revise manualmente.`,
|
|
538
|
+
`ADMIN_ORIGIN_UNKNOWN:${hit.file}:${hit.line}`,
|
|
539
|
+
{
|
|
540
|
+
file: hit.file,
|
|
541
|
+
line: hit.line,
|
|
542
|
+
evidence: [ev("admin-import-unknown", hit.text, "UNKNOWN", hit.file, hit.line)],
|
|
543
|
+
fix: "Mova para functions/, server/ ou scripts/ se for server; se for cliente, troque por Client SDK."
|
|
544
|
+
}
|
|
545
|
+
)
|
|
546
|
+
);
|
|
514
547
|
}
|
|
515
548
|
}
|
|
516
549
|
return findings;
|
|
@@ -582,6 +615,10 @@ async function collectAdminImports(rootDir) {
|
|
|
582
615
|
"import $DEFAULT from '$MODULE'",
|
|
583
616
|
'import * as $NS from "$MODULE"',
|
|
584
617
|
"import * as $NS from '$MODULE'",
|
|
618
|
+
'export { $$$ITEMS } from "$MODULE"',
|
|
619
|
+
"export { $$$ITEMS } from '$MODULE'",
|
|
620
|
+
'export * from "$MODULE"',
|
|
621
|
+
"export * from '$MODULE'",
|
|
585
622
|
'import "$MODULE"',
|
|
586
623
|
"import '$MODULE'",
|
|
587
624
|
'const $X = require("$MODULE")',
|
|
@@ -780,7 +817,7 @@ var RoleNameSchema = z.string().regex(/^[a-z][a-z0-9_-]*$/);
|
|
|
780
817
|
var AuditYamlSchema = z.strictObject({
|
|
781
818
|
$schema: z.string().optional(),
|
|
782
819
|
authorization: z.strictObject({
|
|
783
|
-
adapter: z.strictObject({ function: z.string().min(1) }).
|
|
820
|
+
adapter: z.strictObject({ function: z.string().min(1) }).nullish(),
|
|
784
821
|
roles: z.record(RoleNameSchema, z.strictObject({ permissions: z.array(PermissionNameSchema) })),
|
|
785
822
|
permissions: z.record(
|
|
786
823
|
PermissionNameSchema,
|
|
@@ -788,9 +825,10 @@ var AuditYamlSchema = z.strictObject({
|
|
|
788
825
|
),
|
|
789
826
|
claims: z.strictObject({
|
|
790
827
|
enabled: z.boolean(),
|
|
791
|
-
|
|
828
|
+
// Reservado para o futuro (nenhum check consome ainda): opcional.
|
|
829
|
+
roleKey: z.string().min(1).optional(),
|
|
792
830
|
samples: z.record(RoleNameSchema, z.record(z.string().min(1), z.json())).optional()
|
|
793
|
-
}).
|
|
831
|
+
}).nullish()
|
|
794
832
|
})
|
|
795
833
|
});
|
|
796
834
|
var RESERVED_CLAIM_KEYS = [
|
|
@@ -844,11 +882,40 @@ async function scan(rootDir, opts = {}) {
|
|
|
844
882
|
try {
|
|
845
883
|
if (d.firebaseJson && existsSync3(d.firebaseJson)) {
|
|
846
884
|
const raw = JSON.parse(readFileSync3(d.firebaseJson, "utf-8"));
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
885
|
+
const asList = (v) => v === void 0 ? [] : Array.isArray(v) ? v : [v];
|
|
886
|
+
const entries = [];
|
|
887
|
+
for (const f of asList(raw.firestore)) {
|
|
888
|
+
entries.push({ kind: "rules", rel: f.rules, label: "firestore.rules" });
|
|
889
|
+
entries.push({ kind: "indexes", rel: f.indexes, label: "firestore.indexes" });
|
|
890
|
+
}
|
|
891
|
+
for (const s of asList(raw.storage)) {
|
|
892
|
+
entries.push({ kind: "storage-rules", rel: s.rules, label: "storage.rules" });
|
|
893
|
+
}
|
|
894
|
+
if (Array.isArray(raw.firestore) && raw.firestore.length > 1) {
|
|
895
|
+
findings_pre.push({
|
|
896
|
+
rule: "FIREBASE_JSON_ARRAY",
|
|
897
|
+
severity: "INFO",
|
|
898
|
+
confidence: "CONFIRMED",
|
|
899
|
+
message: `firebase.json com ${raw.firestore.length} databases \u2014 este scan audita s\xF3 o default. Bancos nomeados (databaseId) fora do escopo.`,
|
|
900
|
+
fingerprint: "FIREBASE_JSON_ARRAY:firestore",
|
|
901
|
+
evidence: [
|
|
902
|
+
{ id: "ev-config-array-fs", kind: "config-parse", summary: `${raw.firestore.length} databases`, confidence: "CONFIRMED" }
|
|
903
|
+
]
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
if (Array.isArray(raw.storage) && raw.storage.length > 1) {
|
|
907
|
+
findings_pre.push({
|
|
908
|
+
rule: "FIREBASE_JSON_ARRAY",
|
|
909
|
+
severity: "INFO",
|
|
910
|
+
confidence: "CONFIRMED",
|
|
911
|
+
message: `firebase.json com ${raw.storage.length} buckets \u2014 este scan audita s\xF3 o default. Buckets extras fora do escopo.`,
|
|
912
|
+
fingerprint: "FIREBASE_JSON_ARRAY:storage",
|
|
913
|
+
evidence: [
|
|
914
|
+
{ id: "ev-config-array-st", kind: "config-parse", summary: `${raw.storage.length} buckets`, confidence: "CONFIRMED" }
|
|
915
|
+
]
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
for (const [kind, rel, label] of entries.map((e) => [e.kind, e.rel, e.label])) {
|
|
852
919
|
if (typeof rel === "string" && rel.length > 0) {
|
|
853
920
|
if (!existsSync3(join3(rootDir, rel))) {
|
|
854
921
|
findings_pre.push({
|
|
@@ -1120,7 +1187,7 @@ async function scan(rootDir, opts = {}) {
|
|
|
1120
1187
|
severity: "ERROR",
|
|
1121
1188
|
confidence: "CONFIRMED",
|
|
1122
1189
|
message: `Storage com escrita aberta (${where}). Qualquer cliente pode escrever \u2014 cofre aberto, n\xE3o vitrine.`,
|
|
1123
|
-
fingerprint: `STORAGE_PUBLIC_WRITE:${m.matchPath}:${m.target}:${key}`,
|
|
1190
|
+
fingerprint: `STORAGE_PUBLIC_WRITE:${m.matchPath}:${normalizeTarget(m.target)}:${key}`,
|
|
1124
1191
|
file: relStorage,
|
|
1125
1192
|
line: m.line,
|
|
1126
1193
|
evidence: [{ id: evId("write"), kind: "storage-public", summary: `${m.matchPath} allow ${m.target}`, confidence: "CONFIRMED", location: { file: relStorage, start: { line: m.line, column: 0 } } }],
|
|
@@ -1135,7 +1202,7 @@ async function scan(rootDir, opts = {}) {
|
|
|
1135
1202
|
severity: "WARNING",
|
|
1136
1203
|
confidence: "PROBABLE",
|
|
1137
1204
|
message: `Storage com leitura aberta em curinga total (${where}). Vale para tudo \u2014 se a inten\xE7\xE3o era s\xF3 logo, restrinja a /logos/{id}.`,
|
|
1138
|
-
fingerprint: `STORAGE_ALL_PATHS_OPEN:${m.matchPath}:${m.target}:${key}`,
|
|
1205
|
+
fingerprint: `STORAGE_ALL_PATHS_OPEN:${m.matchPath}:${normalizeTarget(m.target)}:${key}`,
|
|
1139
1206
|
file: relStorage,
|
|
1140
1207
|
line: m.line,
|
|
1141
1208
|
evidence: [{ id: evId("all"), kind: "storage-public", summary: `${m.matchPath} allow ${m.target}`, confidence: "PROBABLE", location: { file: relStorage, start: { line: m.line, column: 0 } } }],
|
|
@@ -1148,7 +1215,7 @@ async function scan(rootDir, opts = {}) {
|
|
|
1148
1215
|
severity: "INFO",
|
|
1149
1216
|
confidence: "PROBABLE",
|
|
1150
1217
|
message: enumerates ? `Storage com leitura aberta (${where}). Pode ser vitrine proposital \u2014 mas allow read/list permite enumerar a pasta; para vitrine segura prefira allow get. Confirme que o write segue restrito.` : `Storage com leitura aberta (${where}). Pode ser vitrine proposital (logos) \u2014 confirme que o write segue restrito.`,
|
|
1151
|
-
fingerprint: `STORAGE_PUBLIC_READ:${m.matchPath}:${m.target}:${key}`,
|
|
1218
|
+
fingerprint: `STORAGE_PUBLIC_READ:${m.matchPath}:${normalizeTarget(m.target)}:${key}`,
|
|
1152
1219
|
file: relStorage,
|
|
1153
1220
|
line: m.line,
|
|
1154
1221
|
evidence: [{ id: evId("read"), kind: "storage-public", summary: `${m.matchPath} allow ${m.target}`, confidence: "PROBABLE", location: { file: relStorage, start: { line: m.line, column: 0 } } }],
|
|
@@ -1164,7 +1231,7 @@ async function scan(rootDir, opts = {}) {
|
|
|
1164
1231
|
severity: "INFO",
|
|
1165
1232
|
confidence: "PROBABLE",
|
|
1166
1233
|
message: `Storage aceita qualquer autenticado incluindo an\xF4nimo (${where}). Exija claim/provider para dados sens\xEDveis.`,
|
|
1167
|
-
fingerprint: `STORAGE_ANON_ALLOWED:${m.matchPath}:${m.target}:${key}`,
|
|
1234
|
+
fingerprint: `STORAGE_ANON_ALLOWED:${m.matchPath}:${normalizeTarget(m.target)}:${key}`,
|
|
1168
1235
|
file: relStorage,
|
|
1169
1236
|
line: m.line,
|
|
1170
1237
|
evidence: [{ id: evId("anon"), kind: "anon-allowed", summary: `${m.matchPath} allow ${m.target}`, confidence: "PROBABLE", location: { file: relStorage, start: { line: m.line, column: 0 } } }],
|
|
@@ -1172,17 +1239,33 @@ async function scan(rootDir, opts = {}) {
|
|
|
1172
1239
|
});
|
|
1173
1240
|
continue;
|
|
1174
1241
|
}
|
|
1175
|
-
if (!hasAuth && m.condition !== void 0
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1242
|
+
if (!hasAuth && m.condition !== void 0) {
|
|
1243
|
+
const canWrite = ops.has("write") || ops.has("create") || ops.has("update") || ops.has("delete");
|
|
1244
|
+
const canRead = ops.has("read") || ops.has("get") || ops.has("list");
|
|
1245
|
+
if (canWrite) {
|
|
1246
|
+
findings.push({
|
|
1247
|
+
rule: "STORAGE_PUBLIC_WRITE",
|
|
1248
|
+
severity: "ERROR",
|
|
1249
|
+
confidence: "PROBABLE",
|
|
1250
|
+
message: `Storage com escrita condicionada sem request.auth (${where}). Se a condi\xE7\xE3o for verdadeira para an\xF4nimo, ele escreve \u2014 cofre prov\xE1vel.`,
|
|
1251
|
+
fingerprint: `STORAGE_PUBLIC_WRITE:${m.matchPath}:${normalizeTarget(m.target)}:${key}`,
|
|
1252
|
+
file: relStorage,
|
|
1253
|
+
line: m.line,
|
|
1254
|
+
evidence: [{ id: evId("write"), kind: "storage-public", summary: `${m.matchPath} allow ${m.target} sem auth`, confidence: "PROBABLE", location: { file: relStorage, start: { line: m.line, column: 0 } } }],
|
|
1255
|
+
fix: "Exigir request.auth (e dono/tenant) em qualquer condi\xE7\xE3o de write."
|
|
1256
|
+
});
|
|
1257
|
+
} else if (canRead) {
|
|
1258
|
+
findings.push({
|
|
1259
|
+
rule: "STORAGE_PUBLIC_READ",
|
|
1260
|
+
severity: "INFO",
|
|
1261
|
+
confidence: "PROBABLE",
|
|
1262
|
+
message: `Storage com leitura condicionada sem request.auth (${where}). Se a condi\xE7\xE3o for verdadeira para an\xF4nimo, ele l\xEA \u2014 confirme que \xE9 vitrine.`,
|
|
1263
|
+
fingerprint: `STORAGE_PUBLIC_READ:${m.matchPath}:${normalizeTarget(m.target)}:${key}`,
|
|
1264
|
+
file: relStorage,
|
|
1265
|
+
line: m.line,
|
|
1266
|
+
evidence: [{ id: evId("read"), kind: "storage-public", summary: `${m.matchPath} allow ${m.target} sem auth`, confidence: "PROBABLE", location: { file: relStorage, start: { line: m.line, column: 0 } } }]
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1186
1269
|
}
|
|
1187
1270
|
}
|
|
1188
1271
|
} catch {
|
|
@@ -1210,20 +1293,23 @@ async function scan(rootDir, opts = {}) {
|
|
|
1210
1293
|
rule: "APPCHECK_ENFORCED",
|
|
1211
1294
|
severity: "INFO",
|
|
1212
1295
|
confidence: "CONFIRMED",
|
|
1213
|
-
message: `enforceAppCheck observado (${appcheck.occurrences} ocorr\xEAncia(s) em ${appcheck.files} arquivo(s)). Carimbo do app oficial \u2014 n\xE3o prova quem \xE9 o usu\xE1rio nem o tenant. Autoriza\xE7\xE3o segue nas Rules/guardas.`,
|
|
1296
|
+
message: `enforceAppCheck observado (${appcheck.occurrences} ocorr\xEAncia(s) em ${appcheck.files} arquivo(s), fora de onRequest). Carimbo do app oficial \u2014 n\xE3o prova quem \xE9 o usu\xE1rio nem o tenant. Autoriza\xE7\xE3o segue nas Rules/guardas.`,
|
|
1214
1297
|
fingerprint: "APPCHECK_ENFORCED",
|
|
1215
1298
|
evidence: [{ id: "ev-appcheck", kind: "appcheck", summary: `${appcheck.occurrences} ocorr\xEAncias`, confidence: "CONFIRMED" }]
|
|
1216
1299
|
});
|
|
1217
|
-
} else if (appcheck.manual === 0) {
|
|
1300
|
+
} else if (appcheck.manual === 0 && appcheck.ignoredOnRequest === 0) {
|
|
1218
1301
|
skipped.push("functions sem enforceAppCheck literal nem verifyToken observado \u2014 AppCheck sem cobertura afirmativa (config via vari\xE1vel conta como n\xE3o observada; onRequest exige verifica\xE7\xE3o manual por rota)");
|
|
1219
1302
|
}
|
|
1303
|
+
if (appcheck.ignoredOnRequest > 0) {
|
|
1304
|
+
skipped.push(`enforceAppCheck em onRequest ignorado pelo runtime (${appcheck.ignoredOnRequest} ocorr\xEAncia(s)) \u2014 onRequest exige verifyToken por rota`);
|
|
1305
|
+
}
|
|
1220
1306
|
if (appcheck.limitHit) {
|
|
1221
1307
|
skipped.push("functions: teto de varredura atingido \u2014 AppCheck parcial neste scan");
|
|
1222
1308
|
}
|
|
1223
1309
|
} else {
|
|
1224
1310
|
skipped.push("functions n\xE3o observadas \u2014 AppCheck fora deste scan");
|
|
1225
1311
|
}
|
|
1226
|
-
skipped.push("isolamento por tenant (ownerId/tenantId) n\xE3o
|
|
1312
|
+
skipped.push("isolamento por tenant (ownerId/tenantId) e mismatch de papel n\xE3o-admin fora dos checks \u2014 coberto s\xF3 via verify/Emulator, nunca verde silencioso");
|
|
1227
1313
|
const modelCheck = ProjectModelSchema.safeParse(model);
|
|
1228
1314
|
if (!modelCheck.success) {
|
|
1229
1315
|
findings.push({
|
|
@@ -1283,17 +1369,18 @@ async function countAppCheckEnforced(rootDir) {
|
|
|
1283
1369
|
try {
|
|
1284
1370
|
const { readdirSync, readFileSync: readSync, statSync: stat } = await import("fs");
|
|
1285
1371
|
const { join: joinP } = await import("path");
|
|
1286
|
-
const { stripRuleComments: strip } = await import("./rules-
|
|
1372
|
+
const { stripRuleComments: strip } = await import("./rules-W4VUUQM4.js");
|
|
1287
1373
|
const roots = [joinP(rootDir, "functions")];
|
|
1288
1374
|
let occurrences = 0;
|
|
1289
1375
|
const files = /* @__PURE__ */ new Set();
|
|
1290
1376
|
let manual = 0;
|
|
1377
|
+
let ignoredOnRequest = 0;
|
|
1291
1378
|
let limitHit = false;
|
|
1292
1379
|
const stack = [...roots];
|
|
1293
1380
|
let guard = 0;
|
|
1294
1381
|
const isTestFile = (p) => {
|
|
1295
1382
|
const n = p.replace(/\\/g, "/");
|
|
1296
|
-
return /\.test\.[tj]sx?$/.test(n) || /\.spec\.[tj]sx?$/.test(n) || /(^|\/)__tests__\//.test(n);
|
|
1383
|
+
return /\.test\.[tj]sx?$/.test(n) || /\.spec\.[tj]sx?$/.test(n) || /(^|\/)__tests__\//.test(n) || /(^|\/)(test|tests|testing)\//.test(n);
|
|
1297
1384
|
};
|
|
1298
1385
|
while (stack.length > 0 && guard < 200) {
|
|
1299
1386
|
guard += 1;
|
|
@@ -1309,8 +1396,11 @@ async function countAppCheckEnforced(rootDir) {
|
|
|
1309
1396
|
const name = e.name;
|
|
1310
1397
|
const abs = joinP(cur, name);
|
|
1311
1398
|
let isDir = false;
|
|
1399
|
+
let size = 0;
|
|
1312
1400
|
try {
|
|
1313
|
-
|
|
1401
|
+
const st = stat(abs);
|
|
1402
|
+
isDir = st.isDirectory();
|
|
1403
|
+
size = st.size;
|
|
1314
1404
|
} catch {
|
|
1315
1405
|
continue;
|
|
1316
1406
|
}
|
|
@@ -1321,12 +1411,25 @@ async function countAppCheckEnforced(rootDir) {
|
|
|
1321
1411
|
}
|
|
1322
1412
|
if (!/\.(ts|js|mjs|cjs|py)$/.test(name)) continue;
|
|
1323
1413
|
if (isTestFile(abs)) continue;
|
|
1414
|
+
if (size > 100 * 1024) continue;
|
|
1324
1415
|
try {
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1416
|
+
let text = strip(readSync(abs, "utf-8"));
|
|
1417
|
+
if (name.endsWith(".py")) {
|
|
1418
|
+
text = text.split("\n").map((l) => {
|
|
1419
|
+
const idx = l.indexOf("#");
|
|
1420
|
+
return idx === -1 ? l : l.slice(0, idx);
|
|
1421
|
+
}).join("\n");
|
|
1422
|
+
}
|
|
1423
|
+
for (const em of text.matchAll(/enforceAppCheck\s*:\s*true|enforce_app_check\s*=\s*True/g)) {
|
|
1424
|
+
const before = text.slice(Math.max(0, (em.index ?? 0) - 600), em.index ?? 0);
|
|
1425
|
+
const triggers = [...before.matchAll(/on(Request|Call|request|call)\s*[\(.]/g)].map((t) => t[1].toLowerCase());
|
|
1426
|
+
const last = triggers[triggers.length - 1];
|
|
1427
|
+
if (last === "request") {
|
|
1428
|
+
ignoredOnRequest += 1;
|
|
1429
|
+
} else {
|
|
1430
|
+
occurrences += 1;
|
|
1431
|
+
files.add(abs);
|
|
1432
|
+
}
|
|
1330
1433
|
}
|
|
1331
1434
|
const manualHits = text.match(/verifyToken\s*\(|verify_token\s*\(|consumeAppCheckToken|getLimitedUseToken|getAppCheck\s*\(/g);
|
|
1332
1435
|
if (manualHits) manual += manualHits.length;
|
|
@@ -1334,15 +1437,17 @@ async function countAppCheckEnforced(rootDir) {
|
|
|
1334
1437
|
}
|
|
1335
1438
|
}
|
|
1336
1439
|
}
|
|
1337
|
-
return { occurrences, files: files.size, manual, limitHit };
|
|
1440
|
+
return { occurrences, files: files.size, manual, ignoredOnRequest, limitHit };
|
|
1338
1441
|
} catch {
|
|
1339
|
-
return { occurrences: 0, files: 0, manual: 0, limitHit: false };
|
|
1442
|
+
return { occurrences: 0, files: 0, manual: 0, ignoredOnRequest: 0, limitHit: false };
|
|
1340
1443
|
}
|
|
1341
1444
|
}
|
|
1342
1445
|
function isOpenStorageCondition(condition) {
|
|
1343
1446
|
if (condition === void 0) return true;
|
|
1344
1447
|
const norm = stripOuterParens(condition).toLowerCase().replace(/\s+/g, "");
|
|
1345
1448
|
if (norm === "" || norm === "true") return true;
|
|
1449
|
+
if (/^!\(.*request\.auth(===|==)null/.test(norm)) return false;
|
|
1450
|
+
if (/^!\(.*request\.auth(!==|!=)null/.test(norm)) return true;
|
|
1346
1451
|
if (/(^|\|\|)true($|\|\|)/.test(norm)) return true;
|
|
1347
1452
|
if (norm.includes("request.auth==null") || norm.includes("request.auth===null")) return true;
|
|
1348
1453
|
return false;
|
|
@@ -1353,13 +1458,24 @@ function parseStorageAllows(clean) {
|
|
|
1353
1458
|
const matchRe = /match\s+/g;
|
|
1354
1459
|
let mm;
|
|
1355
1460
|
while ((mm = matchRe.exec(clean)) !== null) {
|
|
1356
|
-
const
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1461
|
+
const from = mm.index + mm[0].length;
|
|
1462
|
+
let openBrace = -1;
|
|
1463
|
+
let scan2 = from;
|
|
1464
|
+
while (scan2 < clean.length) {
|
|
1465
|
+
const b = clean.indexOf("{", scan2);
|
|
1466
|
+
if (b === -1) break;
|
|
1467
|
+
const between = clean.slice(from, b);
|
|
1468
|
+
if (/match\s|allow\s|;/.test(between)) break;
|
|
1469
|
+
const after = clean.slice(b + 1, b + 12).trimStart().replace(/^\n+/, "");
|
|
1470
|
+
const eol = clean.slice(b + 1).match(/^[ \t]*(\n|$)/) !== null;
|
|
1471
|
+
if (/^(allow|match|function|\})/.test(after) || eol) {
|
|
1472
|
+
openBrace = b;
|
|
1473
|
+
break;
|
|
1474
|
+
}
|
|
1475
|
+
scan2 = b + 1;
|
|
1476
|
+
}
|
|
1477
|
+
if (openBrace === -1) continue;
|
|
1478
|
+
const path = clean.slice(from, openBrace).trim() || "/(unknown)";
|
|
1363
1479
|
let depth = 0;
|
|
1364
1480
|
let quote = null;
|
|
1365
1481
|
let end = clean.length;
|
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
scan
|
|
4
|
-
} from "./chunk-
|
|
5
|
-
import "./chunk-
|
|
4
|
+
} from "./chunk-ME6WO45S.js";
|
|
5
|
+
import "./chunk-FECVBQUO.js";
|
|
6
6
|
|
|
7
7
|
// src/cli.ts
|
|
8
8
|
import { createRequire } from "module";
|
|
@@ -46,7 +46,12 @@ Uso: firebase-audit scan [--json] [--strict] [--adapter=fn] [--cwd=path]`);
|
|
|
46
46
|
console.error(`Valor inv\xE1lido para --strict: ${strictArg} (use --strict, --strict=true ou --strict=false)`);
|
|
47
47
|
process.exit(1);
|
|
48
48
|
}
|
|
49
|
-
const
|
|
49
|
+
const asJsonArg = getArg("json");
|
|
50
|
+
const asJson = args.includes("--json") || asJsonArg === "true";
|
|
51
|
+
if (asJsonArg !== void 0 && asJsonArg !== "true" && asJsonArg !== "false") {
|
|
52
|
+
console.error(`Valor inv\xE1lido para --json: ${asJsonArg} (use --json, --json=true ou --json=false)`);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
50
55
|
const knownFlags = /* @__PURE__ */ new Set(["--json", "--strict", "--help", "-h", "help", "--version", "-v", "--coverage", "--local", "--remote"]);
|
|
51
56
|
for (const a of args.slice(1)) {
|
|
52
57
|
const base = a.includes("=") ? a.slice(0, a.indexOf("=")) : a;
|
|
@@ -59,34 +64,42 @@ Uso: firebase-audit scan|check|verify|drift [--json] ...`);
|
|
|
59
64
|
const adapterArg = getArg("adapter");
|
|
60
65
|
const cwdArg = getArg("cwd");
|
|
61
66
|
if (cwdArg) {
|
|
62
|
-
const { existsSync: existsCwd } = await import("fs");
|
|
67
|
+
const { existsSync: existsCwd, statSync: statCwd } = await import("fs");
|
|
63
68
|
const { resolve: resolveCwd } = await import("path");
|
|
64
69
|
const abs = resolveCwd(process.cwd(), cwdArg);
|
|
65
|
-
|
|
66
|
-
|
|
70
|
+
let isDir = false;
|
|
71
|
+
try {
|
|
72
|
+
isDir = existsCwd(abs) && statCwd(abs).isDirectory();
|
|
73
|
+
} catch {
|
|
74
|
+
isDir = false;
|
|
75
|
+
}
|
|
76
|
+
if (!isDir) {
|
|
77
|
+
console.error(`Diret\xF3rio --cwd n\xE3o existe ou n\xE3o \xE9 diret\xF3rio: ${cwdArg}`);
|
|
67
78
|
process.exit(1);
|
|
68
79
|
}
|
|
69
80
|
}
|
|
70
81
|
const { resolve: resolveRoot } = await import("path");
|
|
71
82
|
const rootDir = cwdArg ? resolveRoot(process.cwd(), cwdArg) : process.cwd();
|
|
72
83
|
if (cmd === "verify") {
|
|
73
|
-
const { scan: scanForVerify } = await import("./scan-
|
|
74
|
-
const { verify } = await import("./verify-
|
|
84
|
+
const { scan: scanForVerify } = await import("./scan-HE5WAMWJ.js");
|
|
85
|
+
const { verify } = await import("./verify-DECP4IUW.js");
|
|
75
86
|
const { resolve: resolveVerify } = await import("path");
|
|
76
87
|
const coverageArg = getArg("coverage");
|
|
77
88
|
const coveragePath = coverageArg ? resolveVerify(rootDir, coverageArg) : void 0;
|
|
78
|
-
const { model } = await scanForVerify(rootDir, { adapterFn: adapterArg });
|
|
89
|
+
const { model, summary: scanSummary } = await scanForVerify(rootDir, { adapterFn: adapterArg });
|
|
79
90
|
const res = verify(model, { coverageFile: coveragePath });
|
|
80
91
|
const roles = model.authorization.roles.length > 0 ? model.authorization.roles.map((r) => r.name) : ["anonymous", "user", "admin"];
|
|
81
92
|
const rolesReduced = model.authorization.roles.length === 0;
|
|
93
|
+
const scanRef = { errors: scanSummary.errors, warnings: scanSummary.warnings };
|
|
82
94
|
if (asJson) {
|
|
83
|
-
console.log(JSON.stringify({ roles, rolesReduced, cases: res.cases.slice(0, 50), casesTotal: res.cases.length, truncated: res.cases.length > 50, uncovered: res.uncovered.slice(0, 20), uncoveredTotal: res.uncovered.length, coveragePct: res.coveragePct, findings: res.findings }, null, 2));
|
|
95
|
+
console.log(JSON.stringify({ roles, rolesReduced, cases: res.cases.slice(0, 50), casesTotal: res.cases.length, truncated: res.cases.length > 50, uncovered: res.uncovered.slice(0, 20), uncoveredTotal: res.uncovered.length, uncoveredNote: res.uncoveredNote, coveragePct: res.coveragePct, findings: res.findings, scan: scanRef }, null, 2));
|
|
84
96
|
return;
|
|
85
97
|
}
|
|
86
98
|
const covStr = res.coveragePct === null ? "sem cobertura observada" : `${res.coveragePct}% de express\xF5es visitadas`;
|
|
87
99
|
const rolesNote = rolesReduced ? "pap\xE9is reduzidos (sem contrato: anonymous/user/admin)" : `${roles.length} pap\xE9is do contrato (${roles.join(", ")})`;
|
|
88
100
|
console.log(`
|
|
89
101
|
FIREBASE AUDIT verify \u2014 ${res.cases.length} casos planejados (${rolesNote}), ${res.uncovered.length} paths sem cobertura (${covStr}).`);
|
|
102
|
+
console.log(`Scan est\xE1tico junto: ${scanRef.errors} errors, ${scanRef.warnings} warnings \u2014 verify n\xE3o substitui scan/check.`);
|
|
90
103
|
for (const f of res.findings) {
|
|
91
104
|
const icon = f.severity === "ERROR" ? "\u274C" : f.severity === "WARNING" ? "\u26A0" : "\u2139";
|
|
92
105
|
console.log(`
|
|
@@ -145,6 +158,9 @@ FIREBASE AUDIT drift \u2014 MATCHED ${counts("MATCHED")}, LOCAL_ONLY ${counts("L
|
|
|
145
158
|
return;
|
|
146
159
|
}
|
|
147
160
|
const { findings, summary } = await scan(rootDir, { strict, adapterFn: adapterArg });
|
|
161
|
+
if (!strict && summary.errors > 0) {
|
|
162
|
+
console.error(`\u26A0 scan encontrou ${summary.errors} ERROR(s) mas sai 0 \u2014 use 'check' no CI para reprovar.`);
|
|
163
|
+
}
|
|
148
164
|
if (asJson) {
|
|
149
165
|
console.log(JSON.stringify({ summary, findings }, null, 2));
|
|
150
166
|
if (strict && summary.errors > 0) process.exit(2);
|
|
@@ -169,7 +185,7 @@ ${icon} ${f.rule} [${f.confidence}]${where}
|
|
|
169
185
|
if (f.fix) console.log(` \u2192 ${f.fix}`);
|
|
170
186
|
}
|
|
171
187
|
console.log(`
|
|
172
|
-
SUMMARY: ${summary.errors} errors, ${summary.warnings} warnings, ${summary.infos} infos (${summary.passedChecks}/${summary.totalChecks} checks FBA passaram)`);
|
|
188
|
+
SUMMARY: ${summary.errors} errors, ${summary.warnings} warnings, ${summary.infos} infos (${summary.passedChecks}/${summary.totalChecks} checks FBA passaram${summary.infra.length > 0 ? `, ${summary.infra.length} achado(s) fora dos FBA` : ""})`);
|
|
173
189
|
console.log(`Olhei: ${summary.checked.join(", ")}`);
|
|
174
190
|
console.log(`Pulei: ${summary.skipped.join(" | ") || "nada"}
|
|
175
191
|
`);
|
package/dist/index.d.ts
CHANGED
|
@@ -577,9 +577,9 @@ declare const FindingSchema: z.ZodObject<{
|
|
|
577
577
|
declare const AuditYamlSchema: z.ZodObject<{
|
|
578
578
|
$schema: z.ZodOptional<z.ZodString>;
|
|
579
579
|
authorization: z.ZodObject<{
|
|
580
|
-
adapter: z.ZodOptional<z.ZodObject<{
|
|
580
|
+
adapter: z.ZodOptional<z.ZodNullable<z.ZodObject<{
|
|
581
581
|
function: z.ZodString;
|
|
582
|
-
}, z.core.$strict
|
|
582
|
+
}, z.core.$strict>>>;
|
|
583
583
|
roles: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
584
584
|
permissions: z.ZodArray<z.ZodString>;
|
|
585
585
|
}, z.core.$strict>>;
|
|
@@ -595,11 +595,11 @@ declare const AuditYamlSchema: z.ZodObject<{
|
|
|
595
595
|
write: "write";
|
|
596
596
|
}>;
|
|
597
597
|
}, z.core.$strict>>;
|
|
598
|
-
claims: z.ZodOptional<z.ZodObject<{
|
|
598
|
+
claims: z.ZodOptional<z.ZodNullable<z.ZodObject<{
|
|
599
599
|
enabled: z.ZodBoolean;
|
|
600
|
-
roleKey: z.ZodString
|
|
600
|
+
roleKey: z.ZodOptional<z.ZodString>;
|
|
601
601
|
samples: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodJSONSchema>>>;
|
|
602
|
-
}, z.core.$strict
|
|
602
|
+
}, z.core.$strict>>>;
|
|
603
603
|
}, z.core.$strict>;
|
|
604
604
|
}, z.core.$strict>;
|
|
605
605
|
/** Chaves OIDC/Firebase reservadas — nunca em custom claims (1000 bytes, só acesso). */
|
|
@@ -639,7 +639,8 @@ interface DiscoveryResult {
|
|
|
639
639
|
declare function discover(rootDir: string): DiscoveryResult;
|
|
640
640
|
/** Segmentos de servidor (fail-closed: `api` genérico NÃO é server, ver classifyOrigin). */
|
|
641
641
|
declare const SERVER_HINTS_SEGMENTS: string[];
|
|
642
|
-
/** Heurística CLIENT vs SERVER: caminho manda primeiro (grafo ai-tool preenche listas na Fase 3).
|
|
642
|
+
/** Heurística CLIENT vs SERVER: caminho manda primeiro (grafo ai-tool preenche listas na Fase 3).
|
|
643
|
+
* Segmentos ancorados — nunca substring solta (`myapp/` não é `app/`). */
|
|
643
644
|
declare function classifyOrigin(file: string, content: string): "CLIENT" | "SERVER" | "UNKNOWN";
|
|
644
645
|
declare function emptyModel(rootDir: string, d: DiscoveryResult): ProjectModel;
|
|
645
646
|
/**
|
|
@@ -806,6 +807,8 @@ interface VerifyResult {
|
|
|
806
807
|
findings: Finding[];
|
|
807
808
|
/** 0-100 quando o coverage tem visitCount avaliável; null = não observada. */
|
|
808
809
|
coveragePct: number | null;
|
|
810
|
+
/** Como ler `uncovered`: atribuição por expressão indisponível (só 100% esvazia). */
|
|
811
|
+
uncoveredNote: string;
|
|
809
812
|
}
|
|
810
813
|
/** Gera matriz role × operação × path a partir do modelo (sem executar nada).
|
|
811
814
|
* Mapeamento: read≡get+list, write≡create+update+delete (mesma semântica do opsFrom).
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createMcpServer,
|
|
3
3
|
startMcpServer
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-CHC7ZHUK.js";
|
|
5
5
|
import {
|
|
6
6
|
AccessOriginSchema,
|
|
7
7
|
AuditYamlSchema,
|
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
runChecks,
|
|
34
34
|
scan,
|
|
35
35
|
validateClaimSamples
|
|
36
|
-
} from "./chunk-
|
|
36
|
+
} from "./chunk-ME6WO45S.js";
|
|
37
37
|
import {
|
|
38
38
|
buildFullPath,
|
|
39
39
|
conditionKeyForFingerprint,
|
|
@@ -55,11 +55,11 @@ import {
|
|
|
55
55
|
resolveHelperConditionDetailed,
|
|
56
56
|
stripOuterParens,
|
|
57
57
|
stripRuleComments
|
|
58
|
-
} from "./chunk-
|
|
58
|
+
} from "./chunk-FECVBQUO.js";
|
|
59
59
|
import {
|
|
60
60
|
planVerify,
|
|
61
61
|
verify
|
|
62
|
-
} from "./chunk-
|
|
62
|
+
} from "./chunk-DIO36A3I.js";
|
|
63
63
|
import {
|
|
64
64
|
drift
|
|
65
65
|
} from "./chunk-XZ32YQQC.js";
|
package/dist/mcp-cli.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
startMcpServer
|
|
4
|
-
} from "./chunk-
|
|
5
|
-
import "./chunk-
|
|
6
|
-
import "./chunk-
|
|
7
|
-
import "./chunk-
|
|
4
|
+
} from "./chunk-CHC7ZHUK.js";
|
|
5
|
+
import "./chunk-ME6WO45S.js";
|
|
6
|
+
import "./chunk-FECVBQUO.js";
|
|
7
|
+
import "./chunk-DIO36A3I.js";
|
|
8
8
|
import "./chunk-XZ32YQQC.js";
|
|
9
9
|
|
|
10
10
|
// src/mcp-cli.ts
|
package/dist/mcp.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createMcpServer,
|
|
3
3
|
startMcpServer
|
|
4
|
-
} from "./chunk-
|
|
5
|
-
import "./chunk-
|
|
6
|
-
import "./chunk-
|
|
7
|
-
import "./chunk-
|
|
4
|
+
} from "./chunk-CHC7ZHUK.js";
|
|
5
|
+
import "./chunk-ME6WO45S.js";
|
|
6
|
+
import "./chunk-FECVBQUO.js";
|
|
7
|
+
import "./chunk-DIO36A3I.js";
|
|
8
8
|
import "./chunk-XZ32YQQC.js";
|
|
9
9
|
export {
|
|
10
10
|
createMcpServer,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@justmpm/firebase-audit",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.5",
|
|
4
4
|
"description": "Auditor de consistência e segurança para projetos Firebase: código + Rules + índices + contrato vs comportamento. Static-first, nunca inventa certeza.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"firebase",
|
package/skill/SKILL.md
CHANGED
|
@@ -35,4 +35,5 @@ Use `drift` para comparar índices locais vs implantados (MATCHED/LOCAL_ONLY/REM
|
|
|
35
35
|
## Limites honestos
|
|
36
36
|
|
|
37
37
|
- AppCheck: varredura TS/Python por texto (`enforceAppCheck`, `verifyToken` e afins). Config via variável conta como não observada; `onRequest` exige revisão rota a rota.
|
|
38
|
-
- Isolamento por tenant (ownerId/tenantId): fora dos checks — revise manualmente ou no Emulator.
|
|
38
|
+
- Isolamento por tenant (ownerId/tenantId) e mismatch de papel não-admin: fora dos checks — revise manualmente ou no Emulator.
|
|
39
|
+
- `graph: true` é prévia opt-in da biblioteca (nenhum check consome ainda).
|