@justmpm/firebase-audit 0.5.2 → 0.5.3

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.
@@ -844,9 +844,10 @@ async function scan(rootDir, opts = {}) {
844
844
  try {
845
845
  if (d.firebaseJson && existsSync3(d.firebaseJson)) {
846
846
  const raw = JSON.parse(readFileSync3(d.firebaseJson, "utf-8"));
847
- for (const [kind, rel] of [
848
- ["rules", raw.firestore?.rules],
849
- ["indexes", raw.firestore?.indexes]
847
+ for (const [kind, rel, label] of [
848
+ ["rules", raw.firestore?.rules, "firestore.rules"],
849
+ ["indexes", raw.firestore?.indexes, "firestore.indexes"],
850
+ ["storage-rules", raw.storage?.rules, "storage.rules"]
850
851
  ]) {
851
852
  if (typeof rel === "string" && rel.length > 0) {
852
853
  if (!existsSync3(join3(rootDir, rel))) {
@@ -854,7 +855,7 @@ async function scan(rootDir, opts = {}) {
854
855
  rule: "RULES_CONFIG_MISMATCH",
855
856
  severity: "WARNING",
856
857
  confidence: "CONFIRMED",
857
- message: `firebase.json aponta firestore.${kind} para "${rel}" inexistente \u2014 usando default. Corrija o path.`,
858
+ message: `firebase.json aponta ${label} para "${rel}" inexistente \u2014 usando default. Corrija o path.`,
858
859
  fingerprint: `RULES_CONFIG_MISMATCH:${kind}:${rel}`,
859
860
  evidence: [
860
861
  { id: `ev-config-${kind}`, kind: "config-mismatch", summary: rel, confidence: "CONFIRMED" }
@@ -1078,8 +1079,21 @@ async function scan(rootDir, opts = {}) {
1078
1079
  }
1079
1080
  const checksFindings = await runChecks(model, rootDir, { adapterFn: opts.adapterFn ?? model.authorization.adapter ?? void 0 });
1080
1081
  const findings = [...findings_pre, ...checksFindings];
1081
- const checked = ["firestore.rules", "indexes", "contract", "client-code"];
1082
+ const checked = [];
1083
+ if (d.firestoreRulesFile && existsSync3(d.firestoreRulesFile)) checked.push("firestore.rules");
1084
+ if (d.firestoreIndexesFile && existsSync3(d.firestoreIndexesFile)) checked.push("indexes");
1085
+ if (d.auditYamlFile && existsSync3(d.auditYamlFile)) checked.push("contract");
1086
+ checked.push("client-code");
1082
1087
  const skipped = [];
1088
+ if (!d.firestoreRulesFile || !existsSync3(d.firestoreRulesFile)) {
1089
+ skipped.push("firestore.rules n\xE3o observado \u2014 FBA001/FBA002 sem cobertura");
1090
+ }
1091
+ if (!d.firestoreIndexesFile || !existsSync3(d.firestoreIndexesFile)) {
1092
+ skipped.push("firestore.indexes.json n\xE3o observado \u2014 drift de \xEDndices sem base local");
1093
+ }
1094
+ if (!d.auditYamlFile || !existsSync3(d.auditYamlFile)) {
1095
+ skipped.push("firebase-audit.yaml n\xE3o observado \u2014 permiss\xF5es seguem UNKNOWN sem contrato");
1096
+ }
1083
1097
  if (d.storageRulesFile && existsSync3(d.storageRulesFile)) {
1084
1098
  checked.push("storage.rules");
1085
1099
  try {
@@ -1092,48 +1106,83 @@ async function scan(rootDir, opts = {}) {
1092
1106
  return `ev-storage-${prefix}-${evCounter}`;
1093
1107
  };
1094
1108
  for (const m of parseStorageAllows(clean)) {
1095
- if (!isOpenStorageCondition(m.condition)) continue;
1096
1109
  const ops = expandStorageOps(m.target);
1097
1110
  const key = conditionKeyForFingerprint(m.condition);
1098
- const wildcard = /\{[^}]*=\*\*\}/.test(m.matchPath) || m.matchPath.toLowerCase().includes("allpaths");
1111
+ const normCond = (m.condition ?? "").toLowerCase().replace(/\s+/g, "");
1112
+ const hasAuth = normCond.includes("request.auth");
1113
+ const bareAuth = (/request\.auth(!==|!=)null/.test(normCond) || /request\.auth\.uid(!==|!=)null/.test(normCond)) && !normCond.includes("request.auth.token") && !normCond.includes("sign_in_provider");
1114
+ const wildcard = /\{\s*\w+\s*=\s*\*\*\s*\}/.test(m.matchPath);
1099
1115
  const where = `${relStorage}:${m.line} (${m.matchPath}, allow ${m.target})`;
1100
- if (ops.has("write") || ops.has("create") || ops.has("update") || ops.has("delete")) {
1101
- findings.push({
1102
- rule: "STORAGE_PUBLIC_WRITE",
1103
- severity: "ERROR",
1104
- confidence: "CONFIRMED",
1105
- message: `Storage com escrita aberta (${where}). Qualquer cliente pode escrever \u2014 cofre aberto, n\xE3o vitrine.`,
1106
- fingerprint: `STORAGE_PUBLIC_WRITE:${m.matchPath}:${m.target}:${key}`,
1107
- file: relStorage,
1108
- line: m.line,
1109
- evidence: [{ id: evId("write"), kind: "storage-public", summary: `${m.matchPath} allow ${m.target}`, confidence: "CONFIRMED", location: { file: relStorage, start: { line: m.line, column: 0 } } }],
1110
- fix: "Exigir request.auth (e dono/tenant) no write. Deixe get aberto s\xF3 em pasta de vitrine como logos/."
1111
- });
1112
- } else if (ops.has("read") || ops.has("get") || ops.has("list")) {
1113
- if (wildcard) {
1114
- findings.push({
1115
- rule: "STORAGE_ALL_PATHS_OPEN",
1116
- severity: "WARNING",
1117
- confidence: "PROBABLE",
1118
- 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}.`,
1119
- fingerprint: `STORAGE_ALL_PATHS_OPEN:${m.matchPath}:${m.target}:${key}`,
1120
- file: relStorage,
1121
- line: m.line,
1122
- evidence: [{ id: evId("all"), kind: "storage-public", summary: `${m.matchPath} allow ${m.target}`, confidence: "PROBABLE", location: { file: relStorage, start: { line: m.line, column: 0 } } }],
1123
- fix: "Troque o curinga total por pastas expl\xEDcitas (ex: /logos/{logoId}) com get: if true e write restrito."
1124
- });
1125
- } else {
1116
+ if (isOpenStorageCondition(m.condition)) {
1117
+ if (ops.has("write") || ops.has("create") || ops.has("update") || ops.has("delete")) {
1126
1118
  findings.push({
1127
- rule: "STORAGE_PUBLIC_READ",
1128
- severity: "INFO",
1129
- confidence: "PROBABLE",
1130
- message: `Storage com leitura aberta (${where}). Pode ser vitrine proposital (logos) \u2014 confirme que o write segue restrito.`,
1131
- fingerprint: `STORAGE_PUBLIC_READ:${m.matchPath}:${m.target}:${key}`,
1119
+ rule: "STORAGE_PUBLIC_WRITE",
1120
+ severity: "ERROR",
1121
+ confidence: "CONFIRMED",
1122
+ 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}`,
1132
1124
  file: relStorage,
1133
1125
  line: m.line,
1134
- evidence: [{ id: evId("read"), kind: "storage-public", summary: `${m.matchPath} allow ${m.target}`, confidence: "PROBABLE", location: { file: relStorage, start: { line: m.line, column: 0 } } }]
1126
+ evidence: [{ id: evId("write"), kind: "storage-public", summary: `${m.matchPath} allow ${m.target}`, confidence: "CONFIRMED", location: { file: relStorage, start: { line: m.line, column: 0 } } }],
1127
+ fix: "Exigir request.auth (e dono/tenant) no write. Deixe get aberto s\xF3 em pasta de vitrine como logos/."
1135
1128
  });
1129
+ continue;
1136
1130
  }
1131
+ if (ops.has("read") || ops.has("get") || ops.has("list")) {
1132
+ if (wildcard) {
1133
+ findings.push({
1134
+ rule: "STORAGE_ALL_PATHS_OPEN",
1135
+ severity: "WARNING",
1136
+ confidence: "PROBABLE",
1137
+ 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}`,
1139
+ file: relStorage,
1140
+ line: m.line,
1141
+ evidence: [{ id: evId("all"), kind: "storage-public", summary: `${m.matchPath} allow ${m.target}`, confidence: "PROBABLE", location: { file: relStorage, start: { line: m.line, column: 0 } } }],
1142
+ fix: "Troque o curinga total por pastas expl\xEDcitas (ex: /logos/{logoId}) com get: if true e write restrito."
1143
+ });
1144
+ } else {
1145
+ const enumerates = ops.has("read") || ops.has("list");
1146
+ findings.push({
1147
+ rule: "STORAGE_PUBLIC_READ",
1148
+ severity: "INFO",
1149
+ confidence: "PROBABLE",
1150
+ 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}`,
1152
+ file: relStorage,
1153
+ line: m.line,
1154
+ evidence: [{ id: evId("read"), kind: "storage-public", summary: `${m.matchPath} allow ${m.target}`, confidence: "PROBABLE", location: { file: relStorage, start: { line: m.line, column: 0 } } }],
1155
+ fix: enumerates ? "Para vitrine, troque allow read por allow get (sem list) na pasta p\xFAblica." : void 0
1156
+ });
1157
+ }
1158
+ continue;
1159
+ }
1160
+ }
1161
+ if (bareAuth && (ops.has("read") || ops.has("get") || ops.has("list") || ops.has("write") || ops.has("create") || ops.has("update") || ops.has("delete"))) {
1162
+ findings.push({
1163
+ rule: "STORAGE_ANON_ALLOWED",
1164
+ severity: "INFO",
1165
+ confidence: "PROBABLE",
1166
+ 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}`,
1168
+ file: relStorage,
1169
+ line: m.line,
1170
+ evidence: [{ id: evId("anon"), kind: "anon-allowed", summary: `${m.matchPath} allow ${m.target}`, confidence: "PROBABLE", location: { file: relStorage, start: { line: m.line, column: 0 } } }],
1171
+ fix: "Exigir claim (token.admin/role) ou provider diferente de anonymous para dados sens\xEDveis."
1172
+ });
1173
+ continue;
1174
+ }
1175
+ if (!hasAuth && m.condition !== void 0 && (ops.has("read") || ops.has("get") || ops.has("list"))) {
1176
+ findings.push({
1177
+ rule: "STORAGE_PUBLIC_READ",
1178
+ severity: "INFO",
1179
+ confidence: "PROBABLE",
1180
+ 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.`,
1181
+ fingerprint: `STORAGE_PUBLIC_READ:${m.matchPath}:${m.target}:${key}`,
1182
+ file: relStorage,
1183
+ line: m.line,
1184
+ 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 } } }]
1185
+ });
1137
1186
  }
1138
1187
  }
1139
1188
  } catch {
@@ -1166,7 +1215,7 @@ async function scan(rootDir, opts = {}) {
1166
1215
  evidence: [{ id: "ev-appcheck", kind: "appcheck", summary: `${appcheck.occurrences} ocorr\xEAncias`, confidence: "CONFIRMED" }]
1167
1216
  });
1168
1217
  } else if (appcheck.manual === 0) {
1169
- skipped.push("functions sem enforceAppCheck nem verifyToken observado \u2014 AppCheck sem cobertura afirmativa (onRequest exige verifica\xE7\xE3o manual por rota)");
1218
+ 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)");
1170
1219
  }
1171
1220
  if (appcheck.limitHit) {
1172
1221
  skipped.push("functions: teto de varredura atingido \u2014 AppCheck parcial neste scan");
@@ -1270,16 +1319,16 @@ async function countAppCheckEnforced(rootDir) {
1270
1319
  stack.push(abs);
1271
1320
  continue;
1272
1321
  }
1273
- if (!/\.(ts|js|mjs|cjs)$/.test(name)) continue;
1322
+ if (!/\.(ts|js|mjs|cjs|py)$/.test(name)) continue;
1274
1323
  if (isTestFile(abs)) continue;
1275
1324
  try {
1276
1325
  const text = strip(readSync(abs, "utf-8"));
1277
- const hits = text.match(/enforceAppCheck\s*:\s*true/g);
1326
+ const hits = text.match(/enforceAppCheck\s*:\s*true|enforce_app_check\s*=\s*True/g);
1278
1327
  if (hits) {
1279
1328
  occurrences += hits.length;
1280
1329
  files.add(abs);
1281
1330
  }
1282
- const manualHits = text.match(/verifyToken\s*\(/g);
1331
+ const manualHits = text.match(/verifyToken\s*\(|verify_token\s*\(|consumeAppCheckToken|getLimitedUseToken|getAppCheck\s*\(/g);
1283
1332
  if (manualHits) manual += manualHits.length;
1284
1333
  } catch {
1285
1334
  }
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  scan
3
- } from "./chunk-UKEKA2NQ.js";
3
+ } from "./chunk-4BBFPTOR.js";
4
4
  import {
5
5
  verify
6
6
  } from "./chunk-Y4VXWSKX.js";
@@ -28,6 +28,16 @@ function readPkgText(rel) {
28
28
  return null;
29
29
  }
30
30
  }
31
+ function assertCwd(cwd) {
32
+ const { existsSync: existsSync2, statSync } = require2("node:fs");
33
+ let ok = false;
34
+ try {
35
+ ok = existsSync2(cwd) && statSync(cwd).isDirectory();
36
+ } catch {
37
+ ok = false;
38
+ }
39
+ if (!ok) throw new Error(`Diret\xF3rio n\xE3o existe: ${cwd}`);
40
+ }
31
41
  var FALLBACK_SKILL = [
32
42
  "# firebase-audit-skill (fallback)",
33
43
  "",
@@ -79,6 +89,7 @@ function createMcpServer() {
79
89
  },
80
90
  async ({ cwd, adapter, strict }) => {
81
91
  try {
92
+ assertCwd(cwd);
82
93
  const res = await scan(cwd, { adapterFn: adapter, strict });
83
94
  return {
84
95
  content: [
@@ -108,6 +119,7 @@ function createMcpServer() {
108
119
  },
109
120
  async ({ cwd, adapter }) => {
110
121
  try {
122
+ assertCwd(cwd);
111
123
  const res = await scan(cwd, { adapterFn: adapter, strict: true });
112
124
  return {
113
125
  content: [
@@ -115,7 +127,8 @@ function createMcpServer() {
115
127
  type: "text",
116
128
  text: JSON.stringify({ summary: res.summary, findings: res.findings }, null, 2)
117
129
  }
118
- ]
130
+ ],
131
+ isError: res.summary.errors > 0
119
132
  };
120
133
  } catch (err) {
121
134
  return {
@@ -137,6 +150,7 @@ function createMcpServer() {
137
150
  },
138
151
  async ({ cwd, coverage }) => {
139
152
  try {
153
+ assertCwd(cwd);
140
154
  const { resolve } = await import("path");
141
155
  const { model } = await scan(cwd);
142
156
  const res = verify(model, { coverageFile: coverage ? resolve(cwd, coverage) : void 0 });
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  scan
4
- } from "./chunk-UKEKA2NQ.js";
4
+ } from "./chunk-4BBFPTOR.js";
5
5
  import "./chunk-7EWTBIKJ.js";
6
6
 
7
7
  // src/cli.ts
@@ -32,8 +32,6 @@ Exit codes: 0 ok informativo; 2 quando --strict/check encontra ERROR. scan sozin
32
32
  Uso: firebase-audit scan [--json] [--strict] [--adapter=fn] [--cwd=path]`);
33
33
  process.exit(1);
34
34
  }
35
- const strict = args.includes("--strict") || cmd === "check";
36
- const asJson = args.includes("--json") || args.includes("--json=true");
37
35
  const getArg = (name) => {
38
36
  const pref = `--${name}=`;
39
37
  const hit = args.find((a) => a.startsWith(pref));
@@ -42,6 +40,13 @@ Uso: firebase-audit scan [--json] [--strict] [--adapter=fn] [--cwd=path]`);
42
40
  if (idx !== -1 && args[idx + 1] && !args[idx + 1].startsWith("--")) return args[idx + 1];
43
41
  return void 0;
44
42
  };
43
+ const strictArg = getArg("strict");
44
+ const strict = args.includes("--strict") || strictArg === "true" || cmd === "check";
45
+ if (strictArg !== void 0 && strictArg !== "true" && strictArg !== "false") {
46
+ console.error(`Valor inv\xE1lido para --strict: ${strictArg} (use --strict, --strict=true ou --strict=false)`);
47
+ process.exit(1);
48
+ }
49
+ const asJson = args.includes("--json") || args.includes("--json=true");
45
50
  const knownFlags = /* @__PURE__ */ new Set(["--json", "--strict", "--help", "-h", "help", "--version", "-v", "--coverage", "--local", "--remote"]);
46
51
  for (const a of args.slice(1)) {
47
52
  const base = a.includes("=") ? a.slice(0, a.indexOf("=")) : a;
@@ -65,7 +70,7 @@ Uso: firebase-audit scan|check|verify|drift [--json] ...`);
65
70
  const { resolve: resolveRoot } = await import("path");
66
71
  const rootDir = cwdArg ? resolveRoot(process.cwd(), cwdArg) : process.cwd();
67
72
  if (cmd === "verify") {
68
- const { scan: scanForVerify } = await import("./scan-OEN65GHM.js");
73
+ const { scan: scanForVerify } = await import("./scan-LJ3TCHMR.js");
69
74
  const { verify } = await import("./verify-WBVSESHD.js");
70
75
  const { resolve: resolveVerify } = await import("path");
71
76
  const coverageArg = getArg("coverage");
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  createMcpServer,
3
3
  startMcpServer
4
- } from "./chunk-AXTYJMA7.js";
4
+ } from "./chunk-PWJ4DI24.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-UKEKA2NQ.js";
36
+ } from "./chunk-4BBFPTOR.js";
37
37
  import {
38
38
  buildFullPath,
39
39
  conditionKeyForFingerprint,
package/dist/mcp-cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  startMcpServer
4
- } from "./chunk-AXTYJMA7.js";
5
- import "./chunk-UKEKA2NQ.js";
4
+ } from "./chunk-PWJ4DI24.js";
5
+ import "./chunk-4BBFPTOR.js";
6
6
  import "./chunk-7EWTBIKJ.js";
7
7
  import "./chunk-Y4VXWSKX.js";
8
8
  import "./chunk-XZ32YQQC.js";
package/dist/mcp.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  createMcpServer,
3
3
  startMcpServer
4
- } from "./chunk-AXTYJMA7.js";
5
- import "./chunk-UKEKA2NQ.js";
4
+ } from "./chunk-PWJ4DI24.js";
5
+ import "./chunk-4BBFPTOR.js";
6
6
  import "./chunk-7EWTBIKJ.js";
7
7
  import "./chunk-Y4VXWSKX.js";
8
8
  import "./chunk-XZ32YQQC.js";
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  scan
3
- } from "./chunk-UKEKA2NQ.js";
3
+ } from "./chunk-4BBFPTOR.js";
4
4
  import "./chunk-7EWTBIKJ.js";
5
5
  export {
6
6
  scan
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justmpm/firebase-audit",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
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
@@ -28,5 +28,11 @@ Skill para agentes usarem o `@justmpm/firebase-audit` do jeito certo.
28
28
  firebase emulators:exec --only firestore "npm test"
29
29
  ```
30
30
 
31
- Matriz `anon/user/admin x read/create/update/delete` com `@firebase/rules-unit-testing`.
31
+ Matriz papel×operação×path (`read` = `get` + `list`) com `@firebase/rules-unit-testing`.
32
32
  Cobertura em `ruleCoverage` (JSON). Emulator não prova índice. Sem contrato, papéis reduzidos (anonymous/user/admin).
33
+ Use `drift` para comparar índices locais vs implantados (MATCHED/LOCAL_ONLY/REMOTE_ONLY).
34
+
35
+ ## Limites honestos
36
+
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.