@justmpm/firebase-audit 0.5.2 → 0.5.4

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.
@@ -9,7 +9,7 @@ import {
9
9
  resolveHelperConditionDetailed,
10
10
  stripOuterParens,
11
11
  stripRuleComments
12
- } from "./chunk-7EWTBIKJ.js";
12
+ } from "./chunk-FECVBQUO.js";
13
13
 
14
14
  // src/scan.ts
15
15
  import { readFileSync as readFileSync3, existsSync as existsSync3 } from "fs";
@@ -253,7 +253,7 @@ async function runChecks(model, rootDir, opts = {}) {
253
253
  const declared = new Set(model.authorization.permissions.map((p) => p.name));
254
254
  if (permissionCalls.literals.length === 0 && permissionCalls.dynamic.length === 0) {
255
255
  const tenantHint = [...helperDetailed.keys()].find(
256
- (n) => /tenant|isMember|isSignedIn|_member/i.test(n)
256
+ (n) => /tenant|isMember|_member/i.test(n)
257
257
  );
258
258
  findings.push(
259
259
  make(
@@ -367,7 +367,7 @@ async function runChecks(model, rootDir, opts = {}) {
367
367
  const condResolved = resolveHelperConditionDetailed(condRaw, helperDetailed);
368
368
  const condInlined = inlineHelpersInCondition(condRaw, helperDetailed);
369
369
  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*\(\s*.*admin\s*==\s*true/i.test(cond);
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*\([^()]*admin\s*==\s*true/i.test(cond);
371
371
  const normClaim = (c) => c.toLowerCase().replace(/[_-]/g, "");
372
372
  const isAdminCall = /\bisadmin\s*\(([^)]*)\)/i.exec(condRaw) ?? /\bisadmin\s*\(([^)]*)\)/i.exec(cond);
373
373
  let isAdminBodyHasAdmin = false;
@@ -511,6 +511,22 @@ async function runChecks(model, rootDir, opts = {}) {
511
511
  }
512
512
  )
513
513
  );
514
+ } else if (origin === "UNKNOWN" && !isTestPath(hit.file)) {
515
+ findings.push(
516
+ make(
517
+ "ADMIN_ORIGIN_UNKNOWN",
518
+ "INFO",
519
+ "UNKNOWN",
520
+ `Admin SDK em arquivo de origem indefinida (${hit.file}). Se for cliente, Rules n\xE3o valem \u2014 revise manualmente.`,
521
+ `ADMIN_ORIGIN_UNKNOWN:${hit.file}:${hit.line}`,
522
+ {
523
+ file: hit.file,
524
+ line: hit.line,
525
+ evidence: [ev("admin-import-unknown", hit.text, "UNKNOWN", hit.file, hit.line)],
526
+ fix: "Mova para functions/, server/ ou scripts/ se for server; se for cliente, troque por Client SDK."
527
+ }
528
+ )
529
+ );
514
530
  }
515
531
  }
516
532
  return findings;
@@ -844,9 +860,10 @@ async function scan(rootDir, opts = {}) {
844
860
  try {
845
861
  if (d.firebaseJson && existsSync3(d.firebaseJson)) {
846
862
  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]
863
+ for (const [kind, rel, label] of [
864
+ ["rules", raw.firestore?.rules, "firestore.rules"],
865
+ ["indexes", raw.firestore?.indexes, "firestore.indexes"],
866
+ ["storage-rules", raw.storage?.rules, "storage.rules"]
850
867
  ]) {
851
868
  if (typeof rel === "string" && rel.length > 0) {
852
869
  if (!existsSync3(join3(rootDir, rel))) {
@@ -854,7 +871,7 @@ async function scan(rootDir, opts = {}) {
854
871
  rule: "RULES_CONFIG_MISMATCH",
855
872
  severity: "WARNING",
856
873
  confidence: "CONFIRMED",
857
- message: `firebase.json aponta firestore.${kind} para "${rel}" inexistente \u2014 usando default. Corrija o path.`,
874
+ message: `firebase.json aponta ${label} para "${rel}" inexistente \u2014 usando default. Corrija o path.`,
858
875
  fingerprint: `RULES_CONFIG_MISMATCH:${kind}:${rel}`,
859
876
  evidence: [
860
877
  { id: `ev-config-${kind}`, kind: "config-mismatch", summary: rel, confidence: "CONFIRMED" }
@@ -1078,8 +1095,21 @@ async function scan(rootDir, opts = {}) {
1078
1095
  }
1079
1096
  const checksFindings = await runChecks(model, rootDir, { adapterFn: opts.adapterFn ?? model.authorization.adapter ?? void 0 });
1080
1097
  const findings = [...findings_pre, ...checksFindings];
1081
- const checked = ["firestore.rules", "indexes", "contract", "client-code"];
1098
+ const checked = [];
1099
+ if (d.firestoreRulesFile && existsSync3(d.firestoreRulesFile)) checked.push("firestore.rules");
1100
+ if (d.firestoreIndexesFile && existsSync3(d.firestoreIndexesFile)) checked.push("indexes");
1101
+ if (d.auditYamlFile && existsSync3(d.auditYamlFile)) checked.push("contract");
1102
+ checked.push("client-code");
1082
1103
  const skipped = [];
1104
+ if (!d.firestoreRulesFile || !existsSync3(d.firestoreRulesFile)) {
1105
+ skipped.push("firestore.rules n\xE3o observado \u2014 FBA001/FBA002 sem cobertura");
1106
+ }
1107
+ if (!d.firestoreIndexesFile || !existsSync3(d.firestoreIndexesFile)) {
1108
+ skipped.push("firestore.indexes.json n\xE3o observado \u2014 drift de \xEDndices sem base local");
1109
+ }
1110
+ if (!d.auditYamlFile || !existsSync3(d.auditYamlFile)) {
1111
+ skipped.push("firebase-audit.yaml n\xE3o observado \u2014 permiss\xF5es seguem UNKNOWN sem contrato");
1112
+ }
1083
1113
  if (d.storageRulesFile && existsSync3(d.storageRulesFile)) {
1084
1114
  checked.push("storage.rules");
1085
1115
  try {
@@ -1092,46 +1122,97 @@ async function scan(rootDir, opts = {}) {
1092
1122
  return `ev-storage-${prefix}-${evCounter}`;
1093
1123
  };
1094
1124
  for (const m of parseStorageAllows(clean)) {
1095
- if (!isOpenStorageCondition(m.condition)) continue;
1096
1125
  const ops = expandStorageOps(m.target);
1097
1126
  const key = conditionKeyForFingerprint(m.condition);
1098
- const wildcard = /\{[^}]*=\*\*\}/.test(m.matchPath) || m.matchPath.toLowerCase().includes("allpaths");
1127
+ const normCond = (m.condition ?? "").toLowerCase().replace(/\s+/g, "");
1128
+ const hasAuth = normCond.includes("request.auth");
1129
+ const bareAuth = (/request\.auth(!==|!=)null/.test(normCond) || /request\.auth\.uid(!==|!=)null/.test(normCond)) && !normCond.includes("request.auth.token") && !normCond.includes("sign_in_provider");
1130
+ const wildcard = /\{\s*\w+\s*=\s*\*\*\s*\}/.test(m.matchPath);
1099
1131
  const where = `${relStorage}:${m.line} (${m.matchPath}, allow ${m.target})`;
1100
- if (ops.has("write") || ops.has("create") || ops.has("update") || ops.has("delete")) {
1132
+ if (isOpenStorageCondition(m.condition)) {
1133
+ if (ops.has("write") || ops.has("create") || ops.has("update") || ops.has("delete")) {
1134
+ findings.push({
1135
+ rule: "STORAGE_PUBLIC_WRITE",
1136
+ severity: "ERROR",
1137
+ confidence: "CONFIRMED",
1138
+ message: `Storage com escrita aberta (${where}). Qualquer cliente pode escrever \u2014 cofre aberto, n\xE3o vitrine.`,
1139
+ fingerprint: `STORAGE_PUBLIC_WRITE:${m.matchPath}:${normalizeTarget(m.target)}:${key}`,
1140
+ file: relStorage,
1141
+ line: m.line,
1142
+ evidence: [{ id: evId("write"), kind: "storage-public", summary: `${m.matchPath} allow ${m.target}`, confidence: "CONFIRMED", location: { file: relStorage, start: { line: m.line, column: 0 } } }],
1143
+ fix: "Exigir request.auth (e dono/tenant) no write. Deixe get aberto s\xF3 em pasta de vitrine como logos/."
1144
+ });
1145
+ continue;
1146
+ }
1147
+ if (ops.has("read") || ops.has("get") || ops.has("list")) {
1148
+ if (wildcard) {
1149
+ findings.push({
1150
+ rule: "STORAGE_ALL_PATHS_OPEN",
1151
+ severity: "WARNING",
1152
+ confidence: "PROBABLE",
1153
+ 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}.`,
1154
+ fingerprint: `STORAGE_ALL_PATHS_OPEN:${m.matchPath}:${normalizeTarget(m.target)}:${key}`,
1155
+ file: relStorage,
1156
+ line: m.line,
1157
+ evidence: [{ id: evId("all"), kind: "storage-public", summary: `${m.matchPath} allow ${m.target}`, confidence: "PROBABLE", location: { file: relStorage, start: { line: m.line, column: 0 } } }],
1158
+ fix: "Troque o curinga total por pastas expl\xEDcitas (ex: /logos/{logoId}) com get: if true e write restrito."
1159
+ });
1160
+ } else {
1161
+ const enumerates = ops.has("read") || ops.has("list");
1162
+ findings.push({
1163
+ rule: "STORAGE_PUBLIC_READ",
1164
+ severity: "INFO",
1165
+ confidence: "PROBABLE",
1166
+ 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.`,
1167
+ fingerprint: `STORAGE_PUBLIC_READ:${m.matchPath}:${normalizeTarget(m.target)}:${key}`,
1168
+ file: relStorage,
1169
+ line: m.line,
1170
+ evidence: [{ id: evId("read"), kind: "storage-public", summary: `${m.matchPath} allow ${m.target}`, confidence: "PROBABLE", location: { file: relStorage, start: { line: m.line, column: 0 } } }],
1171
+ fix: enumerates ? "Para vitrine, troque allow read por allow get (sem list) na pasta p\xFAblica." : void 0
1172
+ });
1173
+ }
1174
+ continue;
1175
+ }
1176
+ }
1177
+ if (bareAuth && (ops.has("read") || ops.has("get") || ops.has("list") || ops.has("write") || ops.has("create") || ops.has("update") || ops.has("delete"))) {
1101
1178
  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}`,
1179
+ rule: "STORAGE_ANON_ALLOWED",
1180
+ severity: "INFO",
1181
+ confidence: "PROBABLE",
1182
+ message: `Storage aceita qualquer autenticado incluindo an\xF4nimo (${where}). Exija claim/provider para dados sens\xEDveis.`,
1183
+ fingerprint: `STORAGE_ANON_ALLOWED:${m.matchPath}:${normalizeTarget(m.target)}:${key}`,
1107
1184
  file: relStorage,
1108
1185
  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/."
1186
+ evidence: [{ id: evId("anon"), kind: "anon-allowed", summary: `${m.matchPath} allow ${m.target}`, confidence: "PROBABLE", location: { file: relStorage, start: { line: m.line, column: 0 } } }],
1187
+ fix: "Exigir claim (token.admin/role) ou provider diferente de anonymous para dados sens\xEDveis."
1111
1188
  });
1112
- } else if (ops.has("read") || ops.has("get") || ops.has("list")) {
1113
- if (wildcard) {
1189
+ continue;
1190
+ }
1191
+ if (!hasAuth && m.condition !== void 0) {
1192
+ const canWrite = ops.has("write") || ops.has("create") || ops.has("update") || ops.has("delete");
1193
+ const canRead = ops.has("read") || ops.has("get") || ops.has("list");
1194
+ if (canWrite) {
1114
1195
  findings.push({
1115
- rule: "STORAGE_ALL_PATHS_OPEN",
1116
- severity: "WARNING",
1196
+ rule: "STORAGE_PUBLIC_WRITE",
1197
+ severity: "ERROR",
1117
1198
  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}`,
1199
+ 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.`,
1200
+ fingerprint: `STORAGE_PUBLIC_WRITE:${m.matchPath}:${normalizeTarget(m.target)}:${key}`,
1120
1201
  file: relStorage,
1121
1202
  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."
1203
+ 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 } } }],
1204
+ fix: "Exigir request.auth (e dono/tenant) em qualquer condi\xE7\xE3o de write."
1124
1205
  });
1125
- } else {
1206
+ } else if (canRead) {
1126
1207
  findings.push({
1127
1208
  rule: "STORAGE_PUBLIC_READ",
1128
1209
  severity: "INFO",
1129
1210
  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}`,
1211
+ 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.`,
1212
+ fingerprint: `STORAGE_PUBLIC_READ:${m.matchPath}:${normalizeTarget(m.target)}:${key}`,
1132
1213
  file: relStorage,
1133
1214
  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 } } }]
1215
+ 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 } } }]
1135
1216
  });
1136
1217
  }
1137
1218
  }
@@ -1166,7 +1247,7 @@ async function scan(rootDir, opts = {}) {
1166
1247
  evidence: [{ id: "ev-appcheck", kind: "appcheck", summary: `${appcheck.occurrences} ocorr\xEAncias`, confidence: "CONFIRMED" }]
1167
1248
  });
1168
1249
  } 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)");
1250
+ 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
1251
  }
1171
1252
  if (appcheck.limitHit) {
1172
1253
  skipped.push("functions: teto de varredura atingido \u2014 AppCheck parcial neste scan");
@@ -1234,7 +1315,7 @@ async function countAppCheckEnforced(rootDir) {
1234
1315
  try {
1235
1316
  const { readdirSync, readFileSync: readSync, statSync: stat } = await import("fs");
1236
1317
  const { join: joinP } = await import("path");
1237
- const { stripRuleComments: strip } = await import("./rules-GTGZWN5U.js");
1318
+ const { stripRuleComments: strip } = await import("./rules-W4VUUQM4.js");
1238
1319
  const roots = [joinP(rootDir, "functions")];
1239
1320
  let occurrences = 0;
1240
1321
  const files = /* @__PURE__ */ new Set();
@@ -1244,7 +1325,7 @@ async function countAppCheckEnforced(rootDir) {
1244
1325
  let guard = 0;
1245
1326
  const isTestFile = (p) => {
1246
1327
  const n = p.replace(/\\/g, "/");
1247
- return /\.test\.[tj]sx?$/.test(n) || /\.spec\.[tj]sx?$/.test(n) || /(^|\/)__tests__\//.test(n);
1328
+ return /\.test\.[tj]sx?$/.test(n) || /\.spec\.[tj]sx?$/.test(n) || /(^|\/)__tests__\//.test(n) || /(^|\/)(test|tests|testing)\//.test(n);
1248
1329
  };
1249
1330
  while (stack.length > 0 && guard < 200) {
1250
1331
  guard += 1;
@@ -1270,16 +1351,22 @@ async function countAppCheckEnforced(rootDir) {
1270
1351
  stack.push(abs);
1271
1352
  continue;
1272
1353
  }
1273
- if (!/\.(ts|js|mjs|cjs)$/.test(name)) continue;
1354
+ if (!/\.(ts|js|mjs|cjs|py)$/.test(name)) continue;
1274
1355
  if (isTestFile(abs)) continue;
1275
1356
  try {
1276
- const text = strip(readSync(abs, "utf-8"));
1277
- const hits = text.match(/enforceAppCheck\s*:\s*true/g);
1357
+ let text = strip(readSync(abs, "utf-8"));
1358
+ if (name.endsWith(".py")) {
1359
+ text = text.split("\n").map((l) => {
1360
+ const idx = l.indexOf("#");
1361
+ return idx === -1 ? l : l.slice(0, idx);
1362
+ }).join("\n");
1363
+ }
1364
+ const hits = text.match(/enforceAppCheck\s*:\s*true|enforce_app_check\s*=\s*True/g);
1278
1365
  if (hits) {
1279
1366
  occurrences += hits.length;
1280
1367
  files.add(abs);
1281
1368
  }
1282
- const manualHits = text.match(/verifyToken\s*\(/g);
1369
+ const manualHits = text.match(/verifyToken\s*\(|verify_token\s*\(|consumeAppCheckToken|getLimitedUseToken|getAppCheck\s*\(/g);
1283
1370
  if (manualHits) manual += manualHits.length;
1284
1371
  } catch {
1285
1372
  }
@@ -1294,6 +1381,8 @@ function isOpenStorageCondition(condition) {
1294
1381
  if (condition === void 0) return true;
1295
1382
  const norm = stripOuterParens(condition).toLowerCase().replace(/\s+/g, "");
1296
1383
  if (norm === "" || norm === "true") return true;
1384
+ if (/^!\(.*request\.auth(===|==)null/.test(norm)) return false;
1385
+ if (/^!\(.*request\.auth(!==|!=)null/.test(norm)) return true;
1297
1386
  if (/(^|\|\|)true($|\|\|)/.test(norm)) return true;
1298
1387
  if (norm.includes("request.auth==null") || norm.includes("request.auth===null")) return true;
1299
1388
  return false;
@@ -1304,13 +1393,24 @@ function parseStorageAllows(clean) {
1304
1393
  const matchRe = /match\s+/g;
1305
1394
  let mm;
1306
1395
  while ((mm = matchRe.exec(clean)) !== null) {
1307
- const lineStart = clean.lastIndexOf("\n", mm.index) + 1;
1308
- const lineEnd = clean.indexOf("\n", mm.index);
1309
- const line = clean.slice(lineStart, lineEnd === -1 ? void 0 : lineEnd);
1310
- const brace = line.lastIndexOf("{");
1311
- if (brace === -1) continue;
1312
- const path = line.slice(mm.index - lineStart + 5, brace).trim() || "/(unknown)";
1313
- const openBrace = lineStart + brace;
1396
+ const from = mm.index + mm[0].length;
1397
+ let openBrace = -1;
1398
+ let scan2 = from;
1399
+ while (scan2 < clean.length) {
1400
+ const b = clean.indexOf("{", scan2);
1401
+ if (b === -1) break;
1402
+ const between = clean.slice(from, b);
1403
+ if (/match\s|allow\s|;/.test(between)) break;
1404
+ const after = clean.slice(b + 1, b + 12).trimStart().replace(/^\n+/, "");
1405
+ const eol = clean.slice(b + 1).match(/^[ \t]*(\n|$)/) !== null;
1406
+ if (/^(allow|match|function|\})/.test(after) || eol) {
1407
+ openBrace = b;
1408
+ break;
1409
+ }
1410
+ scan2 = b + 1;
1411
+ }
1412
+ if (openBrace === -1) continue;
1413
+ const path = clean.slice(from, openBrace).trim() || "/(unknown)";
1314
1414
  let depth = 0;
1315
1415
  let quote = null;
1316
1416
  let end = clean.length;
@@ -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 t = condition.trim().replace(/;$/, "").trim();
167
- const call = /^([A-Za-z_][A-Za-z0-9_]*)\s*\((.*)\)$/.exec(t);
168
- if (!call) return condition;
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 t = condition.trim().replace(/;$/, "").trim();
175
- const call = /^([A-Za-z_][A-Za-z0-9_]*)\s*\((.*)\)$/.exec(t);
176
- if (!call) return condition;
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 = call[2].length === 0 ? [] : call[2].split(",").map((s) => s.trim());
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
- if (/(^|\|\|)true($|\|\|)/.test(low)) return { isPublic: true, confidence: "PROBABLE" };
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
  }
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  scan
3
- } from "./chunk-UKEKA2NQ.js";
3
+ } from "./chunk-AKPQ4OAJ.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 });
@@ -187,6 +201,7 @@ function createMcpServer() {
187
201
  try {
188
202
  const { resolve: resolveP, isAbsolute } = await import("path");
189
203
  const base = cwd && cwd.length > 0 ? cwd : process.cwd();
204
+ assertCwd(base);
190
205
  const resPath = (p) => {
191
206
  if (p === null || p === void 0) return null;
192
207
  return isAbsolute(p) ? p : resolveP(base, p);
package/dist/cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  scan
4
- } from "./chunk-UKEKA2NQ.js";
5
- import "./chunk-7EWTBIKJ.js";
4
+ } from "./chunk-AKPQ4OAJ.js";
5
+ import "./chunk-FECVBQUO.js";
6
6
 
7
7
  // src/cli.ts
8
8
  import { createRequire } from "module";
@@ -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,18 @@ 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 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
+ }
45
55
  const knownFlags = /* @__PURE__ */ new Set(["--json", "--strict", "--help", "-h", "help", "--version", "-v", "--coverage", "--local", "--remote"]);
46
56
  for (const a of args.slice(1)) {
47
57
  const base = a.includes("=") ? a.slice(0, a.indexOf("=")) : a;
@@ -54,18 +64,24 @@ Uso: firebase-audit scan|check|verify|drift [--json] ...`);
54
64
  const adapterArg = getArg("adapter");
55
65
  const cwdArg = getArg("cwd");
56
66
  if (cwdArg) {
57
- const { existsSync: existsCwd } = await import("fs");
67
+ const { existsSync: existsCwd, statSync: statCwd } = await import("fs");
58
68
  const { resolve: resolveCwd } = await import("path");
59
69
  const abs = resolveCwd(process.cwd(), cwdArg);
60
- if (!existsCwd(abs)) {
61
- console.error(`Diret\xF3rio --cwd n\xE3o existe: ${cwdArg}`);
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}`);
62
78
  process.exit(1);
63
79
  }
64
80
  }
65
81
  const { resolve: resolveRoot } = await import("path");
66
82
  const rootDir = cwdArg ? resolveRoot(process.cwd(), cwdArg) : process.cwd();
67
83
  if (cmd === "verify") {
68
- const { scan: scanForVerify } = await import("./scan-OEN65GHM.js");
84
+ const { scan: scanForVerify } = await import("./scan-YUXXYBRU.js");
69
85
  const { verify } = await import("./verify-WBVSESHD.js");
70
86
  const { resolve: resolveVerify } = await import("path");
71
87
  const coverageArg = getArg("coverage");
@@ -164,7 +180,7 @@ ${icon} ${f.rule} [${f.confidence}]${where}
164
180
  if (f.fix) console.log(` \u2192 ${f.fix}`);
165
181
  }
166
182
  console.log(`
167
- SUMMARY: ${summary.errors} errors, ${summary.warnings} warnings, ${summary.infos} infos (${summary.passedChecks}/${summary.totalChecks} checks FBA passaram)`);
183
+ 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` : ""})`);
168
184
  console.log(`Olhei: ${summary.checked.join(", ")}`);
169
185
  console.log(`Pulei: ${summary.skipped.join(" | ") || "nada"}
170
186
  `);
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-RKQ3ZWWI.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-AKPQ4OAJ.js";
37
37
  import {
38
38
  buildFullPath,
39
39
  conditionKeyForFingerprint,
@@ -55,7 +55,7 @@ import {
55
55
  resolveHelperConditionDetailed,
56
56
  stripOuterParens,
57
57
  stripRuleComments
58
- } from "./chunk-7EWTBIKJ.js";
58
+ } from "./chunk-FECVBQUO.js";
59
59
  import {
60
60
  planVerify,
61
61
  verify
package/dist/mcp-cli.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  startMcpServer
4
- } from "./chunk-AXTYJMA7.js";
5
- import "./chunk-UKEKA2NQ.js";
6
- import "./chunk-7EWTBIKJ.js";
4
+ } from "./chunk-RKQ3ZWWI.js";
5
+ import "./chunk-AKPQ4OAJ.js";
6
+ import "./chunk-FECVBQUO.js";
7
7
  import "./chunk-Y4VXWSKX.js";
8
8
  import "./chunk-XZ32YQQC.js";
9
9
 
package/dist/mcp.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  createMcpServer,
3
3
  startMcpServer
4
- } from "./chunk-AXTYJMA7.js";
5
- import "./chunk-UKEKA2NQ.js";
6
- import "./chunk-7EWTBIKJ.js";
4
+ } from "./chunk-RKQ3ZWWI.js";
5
+ import "./chunk-AKPQ4OAJ.js";
6
+ import "./chunk-FECVBQUO.js";
7
7
  import "./chunk-Y4VXWSKX.js";
8
8
  import "./chunk-XZ32YQQC.js";
9
9
  export {
@@ -19,7 +19,7 @@ import {
19
19
  resolveHelperConditionDetailed,
20
20
  stripOuterParens,
21
21
  stripRuleComments
22
- } from "./chunk-7EWTBIKJ.js";
22
+ } from "./chunk-FECVBQUO.js";
23
23
  export {
24
24
  buildFullPath,
25
25
  conditionKeyForFingerprint,
@@ -0,0 +1,7 @@
1
+ import {
2
+ scan
3
+ } from "./chunk-AKPQ4OAJ.js";
4
+ import "./chunk-FECVBQUO.js";
5
+ export {
6
+ scan
7
+ };
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.4",
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.
@@ -1,7 +0,0 @@
1
- import {
2
- scan
3
- } from "./chunk-UKEKA2NQ.js";
4
- import "./chunk-7EWTBIKJ.js";
5
- export {
6
- scan
7
- };