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 +5 -9
- package/README.md +26 -12
- package/dist/cli/index.js +23 -39
- package/dist/cli/wizard.js +51 -35
- package/dist/engine/checkers/backend/firebase.js +66 -52
- package/dist/engine/checkers/backend/supabase.js +113 -91
- package/dist/engine/checkers/deep/deps.js +87 -29
- package/dist/engine/checkers/live/endpoint-probe.js +29 -33
- package/dist/engine/checkers/live/http-checks.js +23 -15
- package/dist/engine/checkers/live/idor.js +65 -49
- package/dist/engine/checkers/static/client-exposure.js +58 -3
- package/dist/engine/checkers/static/config-risks.js +70 -10
- package/dist/engine/checkers/static/rls-migrations.js +676 -188
- package/dist/engine/checkers/static/secrets.js +165 -15
- package/dist/engine/config.js +131 -28
- package/dist/engine/detect.js +18 -10
- package/dist/engine/endpoints.js +219 -13
- package/dist/engine/i18n.js +6 -2
- package/dist/engine/net/http.js +68 -0
- package/dist/engine/report.js +11 -1
- package/dist/engine/scan.js +16 -7
- package/dist/engine/util/code-lex.js +260 -0
- package/dist/engine/util/git-exposure.js +33 -0
- package/dist/engine/util/mask.js +32 -44
- package/dist/engine/util/sql-lex.js +172 -0
- package/dist/engine/walk.js +36 -7
- package/dist/orchestrator/flow.js +137 -82
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 🛡 EasyVibeGate
|
|
2
2
|
|
|
3
|
-
  
|
|
3
|
+
   
|
|
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
|
-
>
|
|
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
|
|
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
|
|
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
|
|
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
|
-
  
|
|
3
|
+
   
|
|
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
|
-
>
|
|
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
|
|
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
|
|
50
|
+
npx easyvibegate
|
|
56
51
|
```
|
|
57
52
|
|
|
58
|
-
Запусти внутри своего проекта.
|
|
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
|
|
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
|
-
Что
|
|
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,
|
|
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
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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);
|
package/dist/cli/wizard.js
CHANGED
|
@@ -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 {
|
|
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
|
|
130
|
-
|
|
131
|
-
:
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
144
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
174
|
-
|
|
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 {
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
storageBucket
|
|
32
|
-
|
|
28
|
+
return {
|
|
29
|
+
projectId,
|
|
30
|
+
databaseURL: databaseURL ? ownDatabaseURL(databaseURL, projectId) : undefined,
|
|
31
|
+
storageBucket: storageBucket ? ownStorageBucket(storageBucket, projectId) : undefined,
|
|
32
|
+
};
|
|
33
33
|
}
|
|
34
|
-
/**
|
|
35
|
-
function
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 (
|
|
86
|
-
|
|
87
|
-
if (
|
|
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 (
|
|
106
|
-
|
|
107
|
-
if (
|
|
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 (
|
|
129
|
-
|
|
130
|
-
if (
|
|
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
|
|
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
|
}
|