easyvibegate 0.4.4 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # 🛡 EasyVibeGate
2
2
 
3
- ![license: MIT](https://img.shields.io/badge/license-MIT-brightgreen) ![node](https://img.shields.io/badge/node-%3E%3D18-blue) ![lang](https://img.shields.io/badge/lang-ru%20%7C%20en-informational)
3
+ ![npm](https://img.shields.io/npm/v/easyvibegate) ![license: MIT](https://img.shields.io/badge/license-MIT-brightgreen) ![node](https://img.shields.io/badge/node-%3E%3D18-blue) ![lang](https://img.shields.io/badge/lang-ru%20%7C%20en-informational)
4
4
 
5
5
  🇷🇺 Русская версия: [README.md](README.md)
6
6
 
@@ -8,11 +8,7 @@
8
8
 
9
9
  Most scanners grep your code and say *"you might have leaked a key."* EasyVibeGate also goes to the running backend: for **Supabase/Firebase** it walks in with the public key and shows which tables are readable by anyone, with the exact `curl` — plus SQL to close it. It is honest about coverage: every check reports whether it actually ran, so a failed or skipped check is never shown as a green "all clear".
10
10
 
11
- > ⚠️ **Not published to npm yet** `npx easyvibegate` will work after publishing.
12
- > Today, install straight from GitHub: `npx github:valedol190387/easyvibegate`
13
- > (it fetches the repo and builds itself).
14
- >
15
- > Status: **v0.4, early.** Best-supported stack: **Next.js + Supabase**. Code review runs on any stack; the live backend probe is read-only and Supabase/Firebase-focused. Not a penetration test.
11
+ > Status: **v0.5, early.** Best-supported stack: **Next.js + Supabase**. Code review runs on any stack; the live backend probe is read-only and Supabase/Firebase-focused. Not a penetration test.
16
12
 
17
13
  ## How to run
18
14
 
@@ -25,7 +21,7 @@ If you use **Cursor, Claude Code, or Codex**, you don't need to type anything in
25
21
  ```
26
22
  Check this project's security with EasyVibeGate and explain the result in plain language.
27
23
 
28
- 1. Run at the project root: npx github:valedol190387/easyvibegate . --no-wizard --deps
24
+ 1. Run at the project root: npx easyvibegate . --no-wizard --deps
29
25
  (After the npm release the short form is: npx easyvibegate . --no-wizard --deps)
30
26
  (Add --url <my-app-url> --i-own-this only if this is my project
31
27
  and I allow the live backend probe.)
@@ -39,7 +35,7 @@ The agent runs it, reads the report, and explains the findings. You can also jus
39
35
  ### Option 2. One command in the terminal
40
36
 
41
37
  ```bash
42
- npx github:valedol190387/easyvibegate
38
+ npx easyvibegate
43
39
  ```
44
40
 
45
41
  Run it inside your project. (After the npm release: `npx easyvibegate`.) A friendly wizard asks a few plain yes/no questions, tells you what's wrong in plain language, and writes a ready-to-use fix plan. No install, no config, no runtime deps.
@@ -58,7 +54,7 @@ jobs:
58
54
  - uses: actions/checkout@v4
59
55
  - uses: actions/setup-node@v4
60
56
  with: { node-version: 20 }
61
- - run: npx github:valedol190387/easyvibegate . --ci
57
+ - run: npx easyvibegate . --ci
62
58
  ```
63
59
 
64
60
  Exit codes: `2` = critical, `1` = warnings, `3` = a check failed to run, `0` = clean.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # 🛡 EasyVibeGate
2
2
 
3
- ![license: MIT](https://img.shields.io/badge/license-MIT-brightgreen) ![node](https://img.shields.io/badge/node-%3E%3D18-blue) ![lang](https://img.shields.io/badge/lang-ru%20%7C%20en-informational)
3
+ ![npm](https://img.shields.io/npm/v/easyvibegate) ![license: MIT](https://img.shields.io/badge/license-MIT-brightgreen) ![node](https://img.shields.io/badge/node-%3E%3D18-blue) ![lang](https://img.shields.io/badge/lang-ru%20%7C%20en-informational)
4
4
 
5
5
  🇬🇧 English version: [README.en.md](README.en.md)
6
6
 
@@ -8,11 +8,7 @@
8
8
 
9
9
  Большинство сканеров просто грепают код и говорят «возможно, ты слил ключ». EasyVibeGate идёт дальше: для **Supabase/Firebase** заходит публичным ключом и показывает, какие таблицы читаются кем угодно, с готовым `curl` и SQL для починки. И он честен про покрытие: каждая проверка сообщает, реально ли она выполнилась, поэтому упавшая или пропущенная проверка **никогда не выдаётся за зелёное «всё чисто»**.
10
10
 
11
- > ⚠️ **Пакет ещё не опубликован в npm.** Команда `npx easyvibegate` заработает после публикации.
12
- > Сейчас рабочий способ — установка прямо из GitHub: `npx github:valedol190387/easyvibegate`
13
- > (скачает репозиторий и соберёт его сам).
14
- >
15
- > Статус: **v0.4, ранняя версия.** Лучше всего поддержан стек **Next.js + Supabase**. Осмотр кода работает на любом стеке; живой пробинг бэкенда — только чтение и заточен под Supabase/Firebase. Это не пентест.
11
+ > Статус: **v0.5, ранняя версия.** Лучше всего поддержан стек **Next.js + Supabase**. Осмотр кода работает на любом стеке; живой пробинг бэкенда — только чтение и заточен под Supabase/Firebase. Это не пентест.
16
12
 
17
13
  ## 🇷🇺 Простыми словами
18
14
 
@@ -38,8 +34,7 @@
38
34
  ```
39
35
  Проверь безопасность этого проекта с помощью EasyVibeGate и объясни результат простыми словами.
40
36
 
41
- 1. Запусти в корне проекта: npx github:valedol190387/easyvibegate . --no-wizard --deps
42
- (После публикации в npm короткая форма: npx easyvibegate . --no-wizard --deps)
37
+ 1. Запусти в корне проекта: npx easyvibegate . --no-wizard --deps
43
38
  (Добавь --url <адрес-моего-приложения> --i-own-this только если это мой проект
44
39
  и я разрешаю живую проверку бэкенда.)
45
40
  2. Открой и изучи файл: easyvibegate-report/ai-fix-prompt.md
@@ -52,10 +47,10 @@
52
47
  ### Способ 2. Одной командой в терминале
53
48
 
54
49
  ```bash
55
- npx github:valedol190387/easyvibegate
50
+ npx easyvibegate
56
51
  ```
57
52
 
58
- Запусти внутри своего проекта. (После публикации в npm будет короче: `npx easyvibegate`.) Дружелюбный мастер задаст пару вопросов «да/нет», понятным языком расскажет, что не так, и создаст готовый план починки. Ни установки, ни настройки, ни зависимостей.
53
+ Запусти внутри своего проекта. Дружелюбный мастер задаст пару вопросов «да/нет», понятным языком расскажет, что не так, и создаст готовый план починки. Ни установки, ни настройки, ни зависимостей.
59
54
 
60
55
  ### Способ 3. В CI (GitHub Actions)
61
56
 
@@ -71,7 +66,7 @@ jobs:
71
66
  - uses: actions/checkout@v4
72
67
  - uses: actions/setup-node@v4
73
68
  with: { node-version: 20 }
74
- - run: npx github:valedol190387/easyvibegate . --ci
69
+ - run: npx easyvibegate . --ci
75
70
  ```
76
71
 
77
72
  Коды выхода: `2` — есть критичное, `1` — предупреждения, `3` — проверка не завершилась, `0` — чисто.
@@ -97,6 +92,22 @@ jobs:
97
92
  - **Supabase / Firebase** — заходим публичным ключом и показываем, какие таблицы/бакеты читаются кем угодно, с воспроизводящим `curl`. Таблицы с «чувствительными» именами (users, payments…) — критично; публичный контент — «подтверди намеренность».
98
93
  - **Любой бэкенд, best-effort** — дёргаем найденные эндпоинты без входа; с двумя тест-аккаунтами (`--idor-tokens a,b`) сравниваем ответы на предмет доступа к чужому (IDOR). Это подсказки для проверки, а не доказательство; инструмент никогда не пишет.
99
94
 
95
+ ## Серьёзность секретов: по тому, куда он утекает
96
+
97
+ Секрет в `.env`, который лежит в `.gitignore`, — не проблема, а правильное место. Секрет в закоммиченном файле — утечка. Раньше оба показывались одинаково, и люди переставали читать предупреждения — вместе с настоящими. Теперь серьёзность считается так:
98
+
99
+ | Где лежит | Уровень | Почему |
100
+ |---|---|---|
101
+ | в git (закоммичен) | как у находки: крит/предупреждение | уже в истории, есть у всех, у кого репозиторий |
102
+ | в репо, но не закоммичен и не в `.gitignore` | не выше предупреждения | «один `git add -A` до утечки» |
103
+ | в `.gitignore` | к сведению | через git утечь не может — так и должно быть |
104
+ | папка не под git | не выше предупреждения | через git не утекает; риск — скопировать/заархивировать |
105
+ | **`.env.example` с тем же значением, что в `.env`** | **критично** | это не пример, это опубликованный ключ |
106
+
107
+ Плюс: `postgres://app:app@localhost` в docker-compose / CI — дефолтный логин локального контейнера, не секрет; `tracking_token` и `pagination_token` из кэша API — не секреты; папки `cache/`, `tmp/` не сканируются.
108
+
109
+ Оценка — это отметка, а не счётчик: после пяти предупреждений балл перестаёт падать (число всё равно показано), критичные могут обнулить его по-прежнему.
110
+
100
111
  ## Оценка и статусы
101
112
 
102
113
  - **Итог — одна политика для всего** (CI, JSON, бейдж, консоль): `fail` — есть критичное; `incomplete` — критичного нет, но какая-то проверка упала / выполнилась частично / не поддержана; `pass` — всё проверено и чисто.
@@ -126,9 +137,12 @@ jobs:
126
137
 
127
138
  EasyVibeGate — **линтер частых дыр вайб-кодинга, а не пентест.** Ноль находок не значит «безопасно». Статический слой не знает, проверяет ли эндпоинт доступ — это может подтвердить только живая проверка на запущенном приложении, которым ты владеешь. Никогда не направляй живую проверку на чужие системы.
128
139
 
129
- Что именно анализатор SQL знает про себя:
140
+ SQL не «ищется регулярками», а разбирается лексером: строки, комментарии и имена в кавычках — это отдельные токены, поэтому ключевое слово внутри строки или имени столбца не может быть принято за команду. Что анализатор знает про себя:
130
141
 
142
+ - **Движок определяется по файлу.** SQLite и Cloudflare D1 (`AUTOINCREMENT`, `INTEGER PRIMARY KEY`, `wrangler.*` с `d1_databases`) и MySQL не получают находок про RLS — у них его не существует. Пропуск виден в отчёте как info-строка «RLS-проверка пропущена», а не молчание.
143
+ - **«Нет RLS» критично только там, где до базы дотягивается клиент** — Supabase/PostgREST с anon-ключом, Hasura. Если в базу ходит только твой сервер (`pg`, Prisma, Drizzle без клиентского API), это предупреждение с пояснением, а не крит. Без сигналов в обе стороны считается, что база открыта наружу — неизвестное не читается как безопасное.
131
144
  - **Порядок миграций внутри одной папки — настоящий**, и `DISABLE ROW LEVEL SECURITY` в более поздней миграции даёт критичную находку. **Порядок между разными папками — догадка**: если противоречащие друг другу команды лежат в разных каталогах, находка выдаётся как предупреждение «порядок неясен» — независимо от того, какая папка оказалась последней при сортировке.
145
+ - **Неизвестное ≠ чистое.** Если команда может менять таблицу или RLS, а анализатор её не понял — например, динамический `EXECUTE 'ALTER TABLE …'` — проверка помечается как **неполная**, вердикт `incomplete`, код выхода `3`. Такие места перечисляются в отчёте по файлу и строке.
132
146
  - **DDL внутри `DO $$ … $$` считается выполненным**, если он безусловный. Любая команда под `IF … THEN` (`ENABLE`, `DISABLE`, `CREATE`, `DROP`) даёт предупреждение «состояние RLS не подтверждено»: выполнится ли ветка, статически не узнать, и утверждать обратное — в любую сторону — значит врать. Вложенные `IF` учитываются, `-- end if` в комментарии условие не закрывает. Если у тебя идемпотентная обёртка `IF NOT EXISTS`, проверь состояние в базе и при желании подави находку через `easyvibegate.config.json`.
133
147
 
134
148
  ## Язык
package/dist/cli/index.js CHANGED
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
3
  import { join, resolve } from 'node:path';
4
4
  import { createInterface } from 'node:readline';
5
- import { runFlow } from '../orchestrator/flow.js';
5
+ import { planTargets, runFlow } from '../orchestrator/flow.js';
6
+ import { scanStatic } from '../engine/scan.js';
7
+ import { loadConfig, validateConfigFile } from '../engine/config.js';
6
8
  import { badgeMarkdown, exitCodeFor, renderConsole, renderJson, renderMarkdown, renderNextSteps, renderVerdict, summarize, } from '../engine/report.js';
7
9
  import { buildAiFixPrompt } from '../engine/aifix.js';
8
10
  import { runWizard } from './wizard.js';
@@ -166,10 +168,14 @@ function parseArgs(argv) {
166
168
  if (v === undefined)
167
169
  break;
168
170
  const parts = v.split(',').map((s) => s.trim()).filter(Boolean);
169
- if (parts.length === 2)
170
- a.idorTokens = [parts[0], parts[1]];
171
- else
171
+ if (parts.length !== 2)
172
172
  a.badIdorTokens = true;
173
+ // Two identical tokens are one identity: the differential could never
174
+ // observe cross-user access, so the check would silently prove nothing.
175
+ else if (parts[0] === parts[1])
176
+ a.argErrors.push('--idor-tokens needs two DIFFERENT tokens (two accounts) — identical tokens cannot test cross-user access');
177
+ else
178
+ a.idorTokens = [parts[0], parts[1]];
173
179
  break;
174
180
  }
175
181
  default:
@@ -275,6 +281,7 @@ async function main() {
275
281
  let result;
276
282
  if (useWizard) {
277
283
  result = await runWizard({
284
+ excludeAbs: [outDir],
278
285
  path: args.path,
279
286
  config: args.config,
280
287
  lang,
@@ -300,14 +307,21 @@ async function main() {
300
307
  const ans = await ask(color.yellow(` Probe ${req.kind} → ${req.target}?\n (${req.detail}) [y/N] `));
301
308
  return /^y(es)?$/i.test(ans.trim());
302
309
  };
303
- result = await runFlow({
304
- root,
305
- configPath: args.config,
310
+ // Same contract as the wizard: plan the concrete targets once, ask about
311
+ // exactly those, execute exactly those.
312
+ const staticResult = await scanStatic(root, { configPath: args.config, excludeAbs: [outDir] });
313
+ const plan = planTargets(staticResult.files, loadConfig(root, args.config), {
306
314
  appUrl: args.appUrl,
307
315
  supabaseUrl: args.supabaseUrl,
308
316
  supabaseKey: args.supabaseKey,
309
- runDeps: args.deps,
310
317
  idorTokens: args.idorTokens,
318
+ });
319
+ result = await runFlow({
320
+ root,
321
+ configPath: args.config,
322
+ runDeps: args.deps,
323
+ precomputedStatic: staticResult,
324
+ plan,
311
325
  consent,
312
326
  log,
313
327
  });
@@ -366,36 +380,6 @@ function prepareOutputDir(dir) {
366
380
  return e instanceof Error ? e.message : String(e);
367
381
  }
368
382
  }
369
- /** Returns a human message when an explicitly given config is unusable. */
370
- function validateConfigFile(path) {
371
- if (!existsSync(path))
372
- return 'file not found';
373
- if (!statSync(path).isFile())
374
- return 'not a file';
375
- let parsed;
376
- try {
377
- parsed = JSON.parse(readFileSync(path, 'utf8'));
378
- }
379
- catch (e) {
380
- return `invalid JSON (${e instanceof Error ? e.message : String(e)})`;
381
- }
382
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
383
- return 'must be a JSON object';
384
- const cfg = parsed;
385
- const known = ['ignore', 'ignorePaths'];
386
- for (const key of known) {
387
- const v = cfg[key];
388
- if (v === undefined)
389
- continue;
390
- if (!Array.isArray(v) || v.some((x) => typeof x !== 'string'))
391
- return `"${key}" must be an array of strings`;
392
- }
393
- // A typo like "ignorePath" would silently do nothing — say so instead.
394
- const unknown = Object.keys(cfg).filter((k) => !known.includes(k));
395
- if (unknown.length)
396
- return `unknown key(s): ${unknown.join(', ')} (expected ${known.join(', ')})`;
397
- return null;
398
- }
399
383
  main().catch((err) => {
400
384
  process.stderr.write(`easyvibegate: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`);
401
385
  process.exit(1);
@@ -1,9 +1,8 @@
1
1
  import { resolve } from 'node:path';
2
2
  import { createInterface } from 'node:readline';
3
3
  import { scanStatic } from '../engine/scan.js';
4
- import { runFlow } from '../orchestrator/flow.js';
5
- import { discoverSupabase } from '../engine/checkers/backend/supabase.js';
6
- import { discoverFirebase } from '../engine/checkers/backend/firebase.js';
4
+ import { consentKey, liveTargets, planTargets, runFlow } from '../orchestrator/flow.js';
5
+ import { loadConfig } from '../engine/config.js';
7
6
  import { summarize } from '../engine/report.js';
8
7
  import { t } from '../engine/i18n.js';
9
8
  import { color } from '../engine/util/color.js';
@@ -114,7 +113,7 @@ export async function runWizard(args) {
114
113
  w();
115
114
  // Step 1 — static code review (always, safe).
116
115
  w(` ${color.bold(t(lang, 'wiz.step1'))}${color.gray(t(lang, 'wiz.step1hint'))}`);
117
- const staticResult = await scanStatic(root, { configPath: args.config });
116
+ const staticResult = await scanStatic(root, { configPath: args.config, excludeAbs: args.excludeAbs });
118
117
  const s0 = summarize(staticResult.findings, staticResult.runs);
119
118
  w(color.gray(` ${t(lang, 'wiz.step1result', { files: staticResult.fileCount, crit: s0.counts.critical, warn: s0.counts.warning })}`));
120
119
  w();
@@ -125,36 +124,53 @@ export async function runWizard(args) {
125
124
  w(color.gray(` --deps → ${t(lang, 'wiz.fromFlag')}`));
126
125
  w();
127
126
  // Step 3 — live checks (opt-in, own project only).
127
+ // The targets are planned ONCE here, over the same ignorePaths-filtered file
128
+ // set the flow uses, and the very same plan is handed to runFlow. Consent is
129
+ // bound to a concrete normalized target, so the host shown in a question is
130
+ // exactly the host that gets probed — never a second discovery's pick.
128
131
  w(` ${color.bold(t(lang, 'wiz.step3'))}${color.gray(t(lang, 'wiz.step3hint'))}`);
129
- const sb = args.supabaseUrl && args.supabaseKey
130
- ? { url: args.supabaseUrl, anonKey: args.supabaseKey, keyKind: 'jwt-anon' }
131
- : discoverSupabase(staticResult.files);
132
- const fb = discoverFirebase(staticResult.files);
133
- let approveSupabase = false;
134
- let approveFirebase = false;
132
+ const config = loadConfig(root, args.config);
133
+ const base = planTargets(staticResult.files, config, {
134
+ appUrl: args.appUrl,
135
+ supabaseUrl: args.supabaseUrl,
136
+ supabaseKey: args.supabaseKey,
137
+ idorTokens: args.idorTokens,
138
+ });
139
+ const approved = new Set();
140
+ const sb = base.targets.find((x) => x.kind === 'supabase');
141
+ const fb = base.targets.find((x) => x.kind === 'firebase');
135
142
  if (sb) {
136
- w(color.gray(t(lang, 'wiz.sbFound', { url: sb.url })));
143
+ w(color.gray(t(lang, sb.explicit ? 'wiz.sbFromFlag' : 'wiz.sbFound', { url: sb.target })));
137
144
  w(color.gray(t(lang, 'wiz.sbDesc1')));
138
145
  w(color.gray(t(lang, 'wiz.sbDesc2')));
139
- approveSupabase = args.autoYes ? true : await askYesNo(t(lang, 'wiz.qSb'), false);
146
+ const yes = args.autoYes ? true : await askYesNo(t(lang, 'wiz.qSb'), false);
147
+ if (yes)
148
+ approved.add(consentKey('supabase', sb.target));
149
+ else if (sb.explicit)
150
+ w(color.yellow(t(lang, 'wiz.notRun', { target: sb.target })));
140
151
  w();
141
152
  }
142
153
  if (fb) {
143
- w(color.gray(t(lang, 'wiz.fbFound', { id: fb.projectId })));
144
- approveFirebase = args.autoYes ? true : await askYesNo(t(lang, 'wiz.qFb'), false);
154
+ w(color.gray(t(lang, 'wiz.fbFound', { id: fb.target })));
155
+ const yes = args.autoYes ? true : await askYesNo(t(lang, 'wiz.qFb'), false);
156
+ if (yes)
157
+ approved.add(consentKey('firebase', fb.target));
145
158
  w();
146
159
  }
147
160
  // A URL from the command line wins; otherwise ask — and never silently discard
148
- // a non-empty answer that merely lacks a scheme.
149
- let appUrl = args.appUrl;
150
- if (!appUrl) {
151
- for (let attempt = 0; attempt < 2 && !appUrl; attempt++) {
161
+ // a non-empty answer that merely lacks a scheme. A typed URL extends the plan
162
+ // the same way a flag would; it is still a request the person made.
163
+ let live = base.targets.find((x) => x.kind === 'live');
164
+ const extra = [];
165
+ if (!live) {
166
+ for (let attempt = 0; attempt < 2 && !live; attempt++) {
152
167
  const raw = await ask(t(lang, 'wiz.qUrl'));
153
168
  if (raw === null || raw === '')
154
169
  break; // EOF or empty = deliberately skip
155
170
  const normalized = normalizeUrl(raw);
156
171
  if (normalized) {
157
- appUrl = normalized;
172
+ extra.push(...liveTargets(normalized, args.idorTokens, 'wizard'));
173
+ live = extra.find((x) => x.kind === 'live');
158
174
  if (normalized !== raw)
159
175
  w(color.gray(` → ${t(lang, 'wiz.urlNormalized', { url: normalized })}`));
160
176
  }
@@ -164,32 +180,32 @@ export async function runWizard(args) {
164
180
  }
165
181
  }
166
182
  // Probing a live host always needs ownership confirmation, even from --url.
167
- let approveLive = !!appUrl;
168
- if (appUrl && !args.autoYes) {
169
- approveLive = await askYesNo(t(lang, 'wiz.qOwn', { url: appUrl }), false);
183
+ // A declined request is NOT dropped: it stays in the plan so the flow records
184
+ // it as a requested check that did not run (visible in coverage, gate incomplete).
185
+ if (live) {
186
+ const yes = args.autoYes ? true : await askYesNo(t(lang, 'wiz.qOwn', { url: live.target }), false);
187
+ if (yes) {
188
+ approved.add(consentKey('live', live.target));
189
+ if (args.idorTokens)
190
+ approved.add(consentKey('idor', live.target));
191
+ }
192
+ else {
193
+ w(color.yellow(t(lang, 'wiz.notRun', { target: live.target })));
194
+ }
170
195
  }
171
196
  rl?.close();
172
197
  w();
173
- const consent = async (req) => {
174
- switch (req.kind) {
175
- case 'supabase': return approveSupabase;
176
- case 'firebase': return approveFirebase;
177
- case 'live': return approveLive;
178
- case 'idor': return approveLive && !!args.idorTokens;
179
- default: return false;
180
- }
181
- };
198
+ const plan = { targets: [...base.targets, ...extra] };
199
+ const consent = async (req) => approved.has(consentKey(req.kind, req.target));
182
200
  const log = (m) => process.stdout.write(color.gray(` … ${m}\n`));
183
201
  w(` ${color.bold(t(lang, 'wiz.running'))}`);
184
202
  return runFlow({
203
+ excludeAbs: args.excludeAbs,
185
204
  root,
186
205
  configPath: args.config,
187
- appUrl: approveLive ? appUrl : undefined,
188
- supabaseUrl: args.supabaseUrl,
189
- supabaseKey: args.supabaseKey,
190
206
  runDeps,
191
- idorTokens: args.idorTokens,
192
207
  precomputedStatic: staticResult,
208
+ plan,
193
209
  consent,
194
210
  log,
195
211
  });
@@ -1,4 +1,4 @@
1
- import { isErr, request, sleep, unreliable } from '../../net/http.js';
1
+ import { classifyBody, request, sleep } from '../../net/http.js';
2
2
  import { looksLikePlaceholder } from '../../util/text.js';
3
3
  const COMMON_COLLECTIONS = [
4
4
  'users', 'user', 'profiles', 'accounts', 'messages', 'chats', 'posts',
@@ -25,66 +25,77 @@ export function discoverFirebase(all) {
25
25
  if (!projectId || looksLikePlaceholder(projectId))
26
26
  return null;
27
27
  // Only keep hosts that belong to the project we will name in the consent prompt.
28
- if (databaseURL && !databaseURL.includes(projectId))
29
- databaseURL = undefined;
30
- if (storageBucket && !storageBucket.includes(projectId))
31
- storageBucket = undefined;
32
- return { projectId, databaseURL, storageBucket };
28
+ return {
29
+ projectId,
30
+ databaseURL: databaseURL ? ownDatabaseURL(databaseURL, projectId) : undefined,
31
+ storageBucket: storageBucket ? ownStorageBucket(storageBucket, projectId) : undefined,
32
+ };
33
33
  }
34
- /** Only a parsable, non-empty JSON payload proves anonymous read access. */
35
- function hasJsonData(body) {
36
- try {
37
- const v = JSON.parse(body);
38
- if (v === null || v === undefined)
39
- return false;
40
- if (Array.isArray(v))
41
- return v.length > 0;
42
- if (typeof v === 'object') {
43
- const o = v;
44
- if ('error' in o)
45
- return false;
46
- return Object.keys(o).length > 0;
47
- }
48
- return true;
49
- }
50
- catch {
51
- return false;
52
- }
34
+ /** The RTDB hostnames Firebase itself issues for a project nothing else can be "its" database. */
35
+ function isOwnRtdbHost(host, projectId) {
36
+ const p = projectId.toLowerCase();
37
+ const regional = `${p}-default-rtdb.`; // <project>-default-rtdb.<region>.firebasedatabase.app
38
+ return host === `${p}.firebaseio.com`
39
+ || host === `${p}-default-rtdb.firebaseio.com`
40
+ || (host.startsWith(regional) && /^[a-z0-9-]+\.firebasedatabase\.app$/.test(host.slice(regional.length)))
41
+ || host === `${p}.firebaseapp.com`;
53
42
  }
54
- function hasFirestoreDocs(body) {
43
+ /**
44
+ * A `databaseURL` is the project's own only when its HOSTNAME is one Firebase
45
+ * issues for that project. A substring match would accept
46
+ * `https://unrelated.invalid/?project=<id>` and send probes to a stranger.
47
+ * Only the origin is kept: a path or query string is never part of a database URL.
48
+ */
49
+ function ownDatabaseURL(raw, projectId) {
50
+ let u;
55
51
  try {
56
- const v = JSON.parse(body);
57
- return !v.error && Array.isArray(v.documents) && v.documents.length > 0;
52
+ u = new URL(raw.trim());
58
53
  }
59
54
  catch {
60
- return false;
55
+ return undefined;
61
56
  }
57
+ if (u.protocol !== 'https:' || !isOwnRtdbHost(u.hostname.toLowerCase(), projectId))
58
+ return undefined;
59
+ return u.origin;
62
60
  }
63
- function hasStorageObjects(body) {
64
- try {
65
- const v = JSON.parse(body);
66
- return !v.error && ((Array.isArray(v.items) && v.items.length > 0) || (Array.isArray(v.prefixes) && v.prefixes.length > 0));
67
- }
68
- catch {
69
- return false;
70
- }
61
+ /** A bucket is the project's own only under the two names Firebase assigns it. */
62
+ function ownStorageBucket(raw, projectId) {
63
+ const name = raw.trim().replace(/^gs:\/\//i, '').replace(/\/.*$/, '').toLowerCase();
64
+ const p = projectId.toLowerCase();
65
+ return name === `${p}.appspot.com` || name === `${p}.firebasestorage.app` ? name : undefined;
66
+ }
67
+ // The payload shapes that prove an anonymous read got real objects back. The
68
+ // body itself has already passed classifyBody (parsed JSON, not an error
69
+ // envelope, not truncated), so these only look at the shape.
70
+ function hasFirestoreDocs(v) {
71
+ const o = v;
72
+ return Array.isArray(o?.documents) && o.documents.length > 0;
73
+ }
74
+ function hasStorageObjects(v) {
75
+ const o = v;
76
+ return (Array.isArray(o?.items) && o.items.length > 0) || (Array.isArray(o?.prefixes) && o.prefixes.length > 0);
71
77
  }
72
- /** Probe Firebase RTDB, Firestore and Storage for anonymous read access. */
78
+ /**
79
+ * Probe Firebase RTDB, Firestore and Storage for anonymous read access.
80
+ * Every response goes through classifyBody: a redirect, a truncated body, an
81
+ * HTML page or an error envelope in place of JSON is an unverified sub-check
82
+ * (run `partial`, named in the note), never "nothing readable".
83
+ */
73
84
  export async function probeFirebase(opts) {
74
85
  const { creds } = opts;
75
86
  const rl = opts.rateLimitMs ?? 120;
76
87
  const log = opts.log ?? (() => { });
77
88
  const findings = [];
78
89
  let attempts = 0;
79
- let errors = 0;
90
+ const lost = []; // "what (reason)"
80
91
  // 1. Realtime Database: the root .json endpoint.
81
92
  const rtdbBase = creds.databaseURL?.replace(/\/$/, '') ?? `https://${creds.projectId}-default-rtdb.firebaseio.com`;
82
93
  await sleep(rl);
83
- const rtdb = await request(`${rtdbBase}/.json?shallow=true`);
94
+ const rtdb = classifyBody(await request(`${rtdbBase}/.json?shallow=true`), 'json');
84
95
  attempts++;
85
- if (unreliable(rtdb) || (!isErr(rtdb) && rtdb.status >= 300 && rtdb.status < 400))
86
- errors++;
87
- if (!isErr(rtdb) && rtdb.status === 200 && hasJsonData(rtdb.body)) {
96
+ if (rtdb.kind === 'unknown')
97
+ lost.push(`RTDB (${rtdb.reason})`);
98
+ if (rtdb.kind === 'data') {
88
99
  findings.push({
89
100
  id: 'firebase_rtdb_open',
90
101
  severity: 'critical',
@@ -100,11 +111,11 @@ export async function probeFirebase(opts) {
100
111
  const readable = [];
101
112
  for (const col of COMMON_COLLECTIONS) {
102
113
  await sleep(rl);
103
- const res = await request(`https://firestore.googleapis.com/v1/projects/${creds.projectId}/databases/(default)/documents/${col}?pageSize=1`);
114
+ const res = classifyBody(await request(`https://firestore.googleapis.com/v1/projects/${creds.projectId}/databases/(default)/documents/${col}?pageSize=1`), 'json');
104
115
  attempts++;
105
- if (unreliable(res) || (!isErr(res) && res.status >= 300 && res.status < 400))
106
- errors++;
107
- if (!isErr(res) && res.status === 200 && hasFirestoreDocs(res.body)) {
116
+ if (res.kind === 'unknown')
117
+ lost.push(`firestore/${col} (${res.reason})`);
118
+ if (res.kind === 'data' && hasFirestoreDocs(res.json)) {
108
119
  readable.push(col);
109
120
  }
110
121
  }
@@ -123,11 +134,11 @@ export async function probeFirebase(opts) {
123
134
  // 3. Storage bucket object listing.
124
135
  const bucket = creds.storageBucket ?? `${creds.projectId}.appspot.com`;
125
136
  await sleep(rl);
126
- const storage = await request(`https://firebasestorage.googleapis.com/v0/b/${bucket}/o`);
137
+ const storage = classifyBody(await request(`https://firebasestorage.googleapis.com/v0/b/${bucket}/o`), 'json');
127
138
  attempts++;
128
- if (unreliable(storage) || (!isErr(storage) && storage.status >= 300 && storage.status < 400))
129
- errors++;
130
- if (!isErr(storage) && storage.status === 200 && hasStorageObjects(storage.body)) {
139
+ if (storage.kind === 'unknown')
140
+ lost.push(`storage/${bucket} (${storage.reason})`);
141
+ if (storage.kind === 'data' && hasStorageObjects(storage.json)) {
131
142
  findings.push({
132
143
  id: 'firebase_storage_open',
133
144
  severity: 'critical',
@@ -140,7 +151,10 @@ export async function probeFirebase(opts) {
140
151
  });
141
152
  }
142
153
  log(`Firebase: probed RTDB, ${COMMON_COLLECTIONS.length} Firestore collections, storage bucket ${bucket}`);
154
+ const errors = lost.length;
143
155
  const status = errors === 0 ? 'completed' : errors < attempts ? 'partial' : 'failed';
144
- const note = errors > 0 ? `${errors}/${attempts} requests errored` : undefined;
156
+ const note = errors > 0
157
+ ? `${errors}/${attempts} probe(s) not verified: ${lost.slice(0, 5).join(', ')}${errors > 5 ? ', …' : ''}`
158
+ : undefined;
145
159
  return { findings, run: { id: 'firebase-probe', level: 2, status, note } };
146
160
  }