easyvibegate 0.4.4 → 0.6.2
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 +30 -12
- package/dist/cli/index.js +40 -39
- package/dist/cli/wizard.js +51 -35
- package/dist/engine/checkers/backend/firebase.js +79 -55
- package/dist/engine/checkers/backend/supabase.js +117 -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 +706 -192
- package/dist/engine/checkers/static/secrets.js +259 -15
- package/dist/engine/config.js +131 -28
- package/dist/engine/detect.js +24 -12
- 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/util/text.js +12 -0
- package/dist/engine/walk.js +52 -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,26 @@ 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
|
+
Секрет в gitignored-файле, который **не** `.env` (например, пароль от прода в `.claude/settings.local.json`), — предупреждение: через git не утечёт, но лежит не там. Папка `easyvibegate-report/` сама пишет себе `.gitignore` — отчёт содержит префиксы секретов и карту эндпоинтов, коммитить его нельзя.
|
|
110
|
+
|
|
111
|
+
Статическая проверка RLS видит только `CREATE TABLE` из миграций в репозитории. Таблицы, созданные из дашборда или из другого репо, перечисляет только живая проверка — если отказаться от неё, в отчёте будет часть картины, и он об этом скажет.
|
|
112
|
+
|
|
113
|
+
Оценка — это отметка, а не счётчик: после пяти предупреждений балл перестаёт падать (число всё равно показано), критичные могут обнулить его по-прежнему.
|
|
114
|
+
|
|
100
115
|
## Оценка и статусы
|
|
101
116
|
|
|
102
117
|
- **Итог — одна политика для всего** (CI, JSON, бейдж, консоль): `fail` — есть критичное; `incomplete` — критичного нет, но какая-то проверка упала / выполнилась частично / не поддержана; `pass` — всё проверено и чисто.
|
|
@@ -126,9 +141,12 @@ jobs:
|
|
|
126
141
|
|
|
127
142
|
EasyVibeGate — **линтер частых дыр вайб-кодинга, а не пентест.** Ноль находок не значит «безопасно». Статический слой не знает, проверяет ли эндпоинт доступ — это может подтвердить только живая проверка на запущенном приложении, которым ты владеешь. Никогда не направляй живую проверку на чужие системы.
|
|
128
143
|
|
|
129
|
-
Что
|
|
144
|
+
SQL не «ищется регулярками», а разбирается лексером: строки, комментарии и имена в кавычках — это отдельные токены, поэтому ключевое слово внутри строки или имени столбца не может быть принято за команду. Что анализатор знает про себя:
|
|
130
145
|
|
|
146
|
+
- **Движок определяется по файлу.** SQLite и Cloudflare D1 (`AUTOINCREMENT`, `INTEGER PRIMARY KEY`, `wrangler.*` с `d1_databases`) и MySQL не получают находок про RLS — у них его не существует. Пропуск виден в отчёте как info-строка «RLS-проверка пропущена», а не молчание.
|
|
147
|
+
- **«Нет RLS» критично только там, где до базы дотягивается клиент** — Supabase/PostgREST с anon-ключом, Hasura. Если в базу ходит только твой сервер (`pg`, Prisma, Drizzle без клиентского API), это предупреждение с пояснением, а не крит. Без сигналов в обе стороны считается, что база открыта наружу — неизвестное не читается как безопасное.
|
|
131
148
|
- **Порядок миграций внутри одной папки — настоящий**, и `DISABLE ROW LEVEL SECURITY` в более поздней миграции даёт критичную находку. **Порядок между разными папками — догадка**: если противоречащие друг другу команды лежат в разных каталогах, находка выдаётся как предупреждение «порядок неясен» — независимо от того, какая папка оказалась последней при сортировке.
|
|
149
|
+
- **Неизвестное ≠ чистое.** Если команда может менять таблицу или RLS, а анализатор её не понял — например, динамический `EXECUTE 'ALTER TABLE …'` — проверка помечается как **неполная**, вердикт `incomplete`, код выхода `3`. Такие места перечисляются в отчёте по файлу и строке.
|
|
132
150
|
- **DDL внутри `DO $$ … $$` считается выполненным**, если он безусловный. Любая команда под `IF … THEN` (`ENABLE`, `DISABLE`, `CREATE`, `DROP`) даёт предупреждение «состояние RLS не подтверждено»: выполнится ли ветка, статически не узнать, и утверждать обратное — в любую сторону — значит врать. Вложенные `IF` учитываются, `-- end if` в комментарии условие не закрывает. Если у тебя идемпотентная обёртка `IF NOT EXISTS`, проверь состояние в базе и при желании подави находку через `easyvibegate.config.json`.
|
|
133
151
|
|
|
134
152
|
## Язык
|
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:
|
|
@@ -256,6 +262,17 @@ async function main() {
|
|
|
256
262
|
// Reports land next to the scanned project by default, so scanning several
|
|
257
263
|
// projects from one shell never overwrites another project's report.
|
|
258
264
|
const outDir = args.output !== undefined ? resolve(args.output) : join(root, 'easyvibegate-report');
|
|
265
|
+
// The report directory gets its OWN `.gitignore` (see prepareOutputDir) and
|
|
266
|
+
// has its stale report files deleted on every run. Both are safe for a
|
|
267
|
+
// directory that exists only to hold reports — neither is safe for the
|
|
268
|
+
// scanned project itself: `--output .` from the project root would replace
|
|
269
|
+
// the project's real .gitignore with a bare `*`, silently un-tracking the
|
|
270
|
+
// whole repo.
|
|
271
|
+
if (args.output !== undefined && outDir === root) {
|
|
272
|
+
process.stderr.write(`easyvibegate: --output must not be the scanned project itself (${root}) — pick a subdirectory, e.g. --output ${join(root, 'easyvibegate-report')}
|
|
273
|
+
`);
|
|
274
|
+
process.exit(2);
|
|
275
|
+
}
|
|
259
276
|
// Use the friendly wizard when a human runs it in a terminal without
|
|
260
277
|
// automation flags; --wizard forces it. Either way the pipeline below is shared.
|
|
261
278
|
// --ci is a non-interactive contract: never ask questions there.
|
|
@@ -275,6 +292,7 @@ async function main() {
|
|
|
275
292
|
let result;
|
|
276
293
|
if (useWizard) {
|
|
277
294
|
result = await runWizard({
|
|
295
|
+
excludeAbs: [outDir],
|
|
278
296
|
path: args.path,
|
|
279
297
|
config: args.config,
|
|
280
298
|
lang,
|
|
@@ -300,14 +318,21 @@ async function main() {
|
|
|
300
318
|
const ans = await ask(color.yellow(` Probe ${req.kind} → ${req.target}?\n (${req.detail}) [y/N] `));
|
|
301
319
|
return /^y(es)?$/i.test(ans.trim());
|
|
302
320
|
};
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
321
|
+
// Same contract as the wizard: plan the concrete targets once, ask about
|
|
322
|
+
// exactly those, execute exactly those.
|
|
323
|
+
const staticResult = await scanStatic(root, { configPath: args.config, excludeAbs: [outDir] });
|
|
324
|
+
const plan = planTargets(staticResult.files, loadConfig(root, args.config), {
|
|
306
325
|
appUrl: args.appUrl,
|
|
307
326
|
supabaseUrl: args.supabaseUrl,
|
|
308
327
|
supabaseKey: args.supabaseKey,
|
|
309
|
-
runDeps: args.deps,
|
|
310
328
|
idorTokens: args.idorTokens,
|
|
329
|
+
});
|
|
330
|
+
result = await runFlow({
|
|
331
|
+
root,
|
|
332
|
+
configPath: args.config,
|
|
333
|
+
runDeps: args.deps,
|
|
334
|
+
precomputedStatic: staticResult,
|
|
335
|
+
plan,
|
|
311
336
|
consent,
|
|
312
337
|
log,
|
|
313
338
|
});
|
|
@@ -354,6 +379,12 @@ function prepareOutputDir(dir) {
|
|
|
354
379
|
if (existsSync(dir) && !statSync(dir).isDirectory())
|
|
355
380
|
return 'exists and is not a directory';
|
|
356
381
|
mkdirSync(dir, { recursive: true });
|
|
382
|
+
// The report names secret prefixes, database hosts and every endpoint —
|
|
383
|
+
// exactly what must not be committed. A `.gitignore` containing `*` inside
|
|
384
|
+
// the directory makes git ignore it wherever the project's own .gitignore
|
|
385
|
+
// stands (the trick node_modules-style caches use); the user's files are
|
|
386
|
+
// never edited. Rewritten every run so a stray edit cannot un-ignore it.
|
|
387
|
+
writeFileSync(join(dir, '.gitignore'), '# Written by EasyVibeGate: this report can contain secret prefixes and hosts. Never commit it.\n*\n', 'utf8');
|
|
357
388
|
// Old report.md next to a fresh report.json told two different stories.
|
|
358
389
|
for (const name of ['report.md', 'report.json', 'ai-fix-prompt.md']) {
|
|
359
390
|
const p = join(dir, name);
|
|
@@ -366,36 +397,6 @@ function prepareOutputDir(dir) {
|
|
|
366
397
|
return e instanceof Error ? e.message : String(e);
|
|
367
398
|
}
|
|
368
399
|
}
|
|
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
400
|
main().catch((err) => {
|
|
400
401
|
process.stderr.write(`easyvibegate: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`);
|
|
401
402
|
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,5 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { looksLikePlaceholder } from '../../util/text.js';
|
|
1
|
+
import { classifyBody, request, sleep } from '../../net/http.js';
|
|
2
|
+
import { DNS_LABEL, looksLikePlaceholder } from '../../util/text.js';
|
|
3
|
+
const FIREBASEIO_URL = new RegExp(`https://${DNS_LABEL}(?:-default-rtdb)?\\.firebaseio\\.com`);
|
|
4
|
+
const FIREBASEAPP_DOMAIN = new RegExp(`(${DNS_LABEL})\\.firebaseapp\\.com`);
|
|
5
|
+
const FIREBASEDATABASE_APP = new RegExp(`^${DNS_LABEL}\\.firebasedatabase\\.app$`);
|
|
3
6
|
const COMMON_COLLECTIONS = [
|
|
4
7
|
'users', 'user', 'profiles', 'accounts', 'messages', 'chats', 'posts',
|
|
5
8
|
'orders', 'payments', 'products', 'items', 'settings', 'admin', 'config',
|
|
@@ -11,13 +14,16 @@ export function discoverFirebase(all) {
|
|
|
11
14
|
let projectId;
|
|
12
15
|
let databaseURL;
|
|
13
16
|
let storageBucket;
|
|
17
|
+
// This loop runs against every project file's raw content, including large
|
|
18
|
+
// ones — see `DNS_LABEL` in util/text.ts for why the two host regexes below
|
|
19
|
+
// are bounded instead of `[a-z0-9-]+`.
|
|
14
20
|
for (const f of files) {
|
|
15
21
|
projectId ??= f.content.match(/projectId\s*:\s*["']([^"']+)["']/)?.[1];
|
|
16
22
|
databaseURL ??= f.content.match(/databaseURL\s*:\s*["']([^"']+)["']/)?.[1]
|
|
17
|
-
?? f.content.match(
|
|
23
|
+
?? f.content.match(FIREBASEIO_URL)?.[0];
|
|
18
24
|
storageBucket ??= f.content.match(/storageBucket\s*:\s*["']([^"']+)["']/)?.[1];
|
|
19
25
|
if (!projectId) {
|
|
20
|
-
const dom = f.content.match(
|
|
26
|
+
const dom = f.content.match(FIREBASEAPP_DOMAIN)?.[1];
|
|
21
27
|
if (dom)
|
|
22
28
|
projectId = dom;
|
|
23
29
|
}
|
|
@@ -25,66 +31,81 @@ export function discoverFirebase(all) {
|
|
|
25
31
|
if (!projectId || looksLikePlaceholder(projectId))
|
|
26
32
|
return null;
|
|
27
33
|
// Only keep hosts that belong to the project we will name in the consent prompt.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
storageBucket
|
|
32
|
-
|
|
34
|
+
return {
|
|
35
|
+
projectId,
|
|
36
|
+
databaseURL: databaseURL ? ownDatabaseURL(databaseURL, projectId) : undefined,
|
|
37
|
+
storageBucket: storageBucket ? ownStorageBucket(storageBucket, projectId) : undefined,
|
|
38
|
+
};
|
|
33
39
|
}
|
|
34
|
-
/**
|
|
35
|
-
function
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
return Object.keys(o).length > 0;
|
|
47
|
-
}
|
|
48
|
-
return true;
|
|
49
|
-
}
|
|
50
|
-
catch {
|
|
51
|
-
return false;
|
|
52
|
-
}
|
|
40
|
+
/** The RTDB hostnames Firebase itself issues for a project — nothing else can be "its" database. */
|
|
41
|
+
function isOwnRtdbHost(host, projectId) {
|
|
42
|
+
const p = projectId.toLowerCase();
|
|
43
|
+
const regional = `${p}-default-rtdb.`; // <project>-default-rtdb.<region>.firebasedatabase.app
|
|
44
|
+
return host === `${p}.firebaseio.com`
|
|
45
|
+
|| host === `${p}-default-rtdb.firebaseio.com`
|
|
46
|
+
// `host` here is already `new URL(...).hostname` (bounded by the caller,
|
|
47
|
+
// ownDatabaseURL below), not raw file content — but it's bounded via the
|
|
48
|
+
// shared constant anyway rather than `+`, so this stays safe even if that
|
|
49
|
+
// ever changes and nothing here looks unlike its two siblings above.
|
|
50
|
+
|| (host.startsWith(regional) && FIREBASEDATABASE_APP.test(host.slice(regional.length)))
|
|
51
|
+
|| host === `${p}.firebaseapp.com`;
|
|
53
52
|
}
|
|
54
|
-
|
|
53
|
+
/**
|
|
54
|
+
* A `databaseURL` is the project's own only when its HOSTNAME is one Firebase
|
|
55
|
+
* issues for that project. A substring match would accept
|
|
56
|
+
* `https://unrelated.invalid/?project=<id>` and send probes to a stranger.
|
|
57
|
+
* Only the origin is kept: a path or query string is never part of a database URL.
|
|
58
|
+
*/
|
|
59
|
+
function ownDatabaseURL(raw, projectId) {
|
|
60
|
+
let u;
|
|
55
61
|
try {
|
|
56
|
-
|
|
57
|
-
return !v.error && Array.isArray(v.documents) && v.documents.length > 0;
|
|
62
|
+
u = new URL(raw.trim());
|
|
58
63
|
}
|
|
59
64
|
catch {
|
|
60
|
-
return
|
|
65
|
+
return undefined;
|
|
61
66
|
}
|
|
67
|
+
if (u.protocol !== 'https:' || !isOwnRtdbHost(u.hostname.toLowerCase(), projectId))
|
|
68
|
+
return undefined;
|
|
69
|
+
return u.origin;
|
|
62
70
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
+
/** A bucket is the project's own only under the two names Firebase assigns it. */
|
|
72
|
+
function ownStorageBucket(raw, projectId) {
|
|
73
|
+
const name = raw.trim().replace(/^gs:\/\//i, '').replace(/\/.*$/, '').toLowerCase();
|
|
74
|
+
const p = projectId.toLowerCase();
|
|
75
|
+
return name === `${p}.appspot.com` || name === `${p}.firebasestorage.app` ? name : undefined;
|
|
76
|
+
}
|
|
77
|
+
// The payload shapes that prove an anonymous read got real objects back. The
|
|
78
|
+
// body itself has already passed classifyBody (parsed JSON, not an error
|
|
79
|
+
// envelope, not truncated), so these only look at the shape.
|
|
80
|
+
function hasFirestoreDocs(v) {
|
|
81
|
+
const o = v;
|
|
82
|
+
return Array.isArray(o?.documents) && o.documents.length > 0;
|
|
83
|
+
}
|
|
84
|
+
function hasStorageObjects(v) {
|
|
85
|
+
const o = v;
|
|
86
|
+
return (Array.isArray(o?.items) && o.items.length > 0) || (Array.isArray(o?.prefixes) && o.prefixes.length > 0);
|
|
71
87
|
}
|
|
72
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* Probe Firebase RTDB, Firestore and Storage for anonymous read access.
|
|
90
|
+
* Every response goes through classifyBody: a redirect, a truncated body, an
|
|
91
|
+
* HTML page or an error envelope in place of JSON is an unverified sub-check
|
|
92
|
+
* (run `partial`, named in the note), never "nothing readable".
|
|
93
|
+
*/
|
|
73
94
|
export async function probeFirebase(opts) {
|
|
74
95
|
const { creds } = opts;
|
|
75
96
|
const rl = opts.rateLimitMs ?? 120;
|
|
76
97
|
const log = opts.log ?? (() => { });
|
|
77
98
|
const findings = [];
|
|
78
99
|
let attempts = 0;
|
|
79
|
-
|
|
100
|
+
const lost = []; // "what (reason)"
|
|
80
101
|
// 1. Realtime Database: the root .json endpoint.
|
|
81
102
|
const rtdbBase = creds.databaseURL?.replace(/\/$/, '') ?? `https://${creds.projectId}-default-rtdb.firebaseio.com`;
|
|
82
103
|
await sleep(rl);
|
|
83
|
-
const rtdb = await request(`${rtdbBase}/.json?shallow=true`);
|
|
104
|
+
const rtdb = classifyBody(await request(`${rtdbBase}/.json?shallow=true`), 'json');
|
|
84
105
|
attempts++;
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
if (
|
|
106
|
+
if (rtdb.kind === 'unknown')
|
|
107
|
+
lost.push(`RTDB (${rtdb.reason})`);
|
|
108
|
+
if (rtdb.kind === 'data') {
|
|
88
109
|
findings.push({
|
|
89
110
|
id: 'firebase_rtdb_open',
|
|
90
111
|
severity: 'critical',
|
|
@@ -100,11 +121,11 @@ export async function probeFirebase(opts) {
|
|
|
100
121
|
const readable = [];
|
|
101
122
|
for (const col of COMMON_COLLECTIONS) {
|
|
102
123
|
await sleep(rl);
|
|
103
|
-
const res = await request(`https://firestore.googleapis.com/v1/projects/${creds.projectId}/databases/(default)/documents/${col}?pageSize=1`);
|
|
124
|
+
const res = classifyBody(await request(`https://firestore.googleapis.com/v1/projects/${creds.projectId}/databases/(default)/documents/${col}?pageSize=1`), 'json');
|
|
104
125
|
attempts++;
|
|
105
|
-
if (
|
|
106
|
-
|
|
107
|
-
if (
|
|
126
|
+
if (res.kind === 'unknown')
|
|
127
|
+
lost.push(`firestore/${col} (${res.reason})`);
|
|
128
|
+
if (res.kind === 'data' && hasFirestoreDocs(res.json)) {
|
|
108
129
|
readable.push(col);
|
|
109
130
|
}
|
|
110
131
|
}
|
|
@@ -123,11 +144,11 @@ export async function probeFirebase(opts) {
|
|
|
123
144
|
// 3. Storage bucket object listing.
|
|
124
145
|
const bucket = creds.storageBucket ?? `${creds.projectId}.appspot.com`;
|
|
125
146
|
await sleep(rl);
|
|
126
|
-
const storage = await request(`https://firebasestorage.googleapis.com/v0/b/${bucket}/o`);
|
|
147
|
+
const storage = classifyBody(await request(`https://firebasestorage.googleapis.com/v0/b/${bucket}/o`), 'json');
|
|
127
148
|
attempts++;
|
|
128
|
-
if (
|
|
129
|
-
|
|
130
|
-
if (
|
|
149
|
+
if (storage.kind === 'unknown')
|
|
150
|
+
lost.push(`storage/${bucket} (${storage.reason})`);
|
|
151
|
+
if (storage.kind === 'data' && hasStorageObjects(storage.json)) {
|
|
131
152
|
findings.push({
|
|
132
153
|
id: 'firebase_storage_open',
|
|
133
154
|
severity: 'critical',
|
|
@@ -140,7 +161,10 @@ export async function probeFirebase(opts) {
|
|
|
140
161
|
});
|
|
141
162
|
}
|
|
142
163
|
log(`Firebase: probed RTDB, ${COMMON_COLLECTIONS.length} Firestore collections, storage bucket ${bucket}`);
|
|
164
|
+
const errors = lost.length;
|
|
143
165
|
const status = errors === 0 ? 'completed' : errors < attempts ? 'partial' : 'failed';
|
|
144
|
-
const note = errors > 0
|
|
166
|
+
const note = errors > 0
|
|
167
|
+
? `${errors}/${attempts} probe(s) not verified: ${lost.slice(0, 5).join(', ')}${errors > 5 ? ', …' : ''}`
|
|
168
|
+
: undefined;
|
|
145
169
|
return { findings, run: { id: 'firebase-probe', level: 2, status, note } };
|
|
146
170
|
}
|