easyvibegate 0.6.2 → 0.6.9
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 +10 -2
- package/README.md +11 -2
- package/dist/cli/index.js +27 -6
- package/dist/engine/checkers/deep/deps.js +198 -54
- package/dist/engine/checkers/index.js +2 -0
- package/dist/engine/checkers/static/client-exposure.js +16 -3
- package/dist/engine/checkers/static/config-risks.js +12 -2
- package/dist/engine/checkers/static/next-image-ssrf.js +137 -0
- package/dist/engine/checkers/static/rls-migrations.js +112 -44
- package/dist/engine/checkers/static/secrets.js +372 -291
- package/dist/engine/detect.js +17 -3
- package/dist/engine/util/git-exposure.js +41 -0
- package/dist/engine/util/text.js +25 -1
- package/dist/engine/walk.js +22 -12
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -8,7 +8,16 @@
|
|
|
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
|
-
> Status: **
|
|
11
|
+
> Status: **actively maintained, version 0.6.8.** 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.
|
|
12
|
+
|
|
13
|
+
### What it actually checks
|
|
14
|
+
|
|
15
|
+
1. **Secrets hardcoded in source** — API keys (OpenAI, Stripe, AWS, Supabase, etc.) and database passwords written directly into code instead of environment variables. If the code ever leaves your machine (a public repo, a screenshot, a shared link), those keys leak with it.
|
|
16
|
+
2. **Row Level Security (RLS) disabled in the database** — if a Supabase/Postgres table has no row-level protection, anyone holding only your app's public (anon) key — i.e. literally every visitor — can read and sometimes write every user's data through the API, not just their own.
|
|
17
|
+
3. **Secrets shipped in the client bundle** — variables prefixed `NEXT_PUBLIC_` / `VITE_` / `REACT_APP_` are baked straight into the JS every visitor's browser downloads. If a real secret ends up behind one of those prefixes, it's visible in the page source (F12 → Sources).
|
|
18
|
+
4. **Dangerous config** — CORS allowing any origin, debug/verbose mode left on, use of `eval`, SQL built by string concatenation (injection risk), a JWT with `alg: none`.
|
|
19
|
+
5. **`.env` / git hygiene** — a secret in a committed `.env` is already in git history and visible to anyone with repo access, even after the file is deleted later.
|
|
20
|
+
6. **Live backend probe** (opt-in, your own projects only) — the tool actually talks to your running Supabase/Firebase with the public key and checks which tables/buckets are readable without auth, plus hits discovered API endpoints with no token. Read-only — it never writes or breaks anything.
|
|
12
21
|
|
|
13
22
|
## How to run
|
|
14
23
|
|
|
@@ -22,7 +31,6 @@ If you use **Cursor, Claude Code, or Codex**, you don't need to type anything in
|
|
|
22
31
|
Check this project's security with EasyVibeGate and explain the result in plain language.
|
|
23
32
|
|
|
24
33
|
1. Run at the project root: npx easyvibegate . --no-wizard --deps
|
|
25
|
-
(After the npm release the short form is: npx easyvibegate . --no-wizard --deps)
|
|
26
34
|
(Add --url <my-app-url> --i-own-this only if this is my project
|
|
27
35
|
and I allow the live backend probe.)
|
|
28
36
|
2. Open and read the file: easyvibegate-report/ai-fix-prompt.md
|
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
Большинство сканеров просто грепают код и говорят «возможно, ты слил ключ». EasyVibeGate идёт дальше: для **Supabase/Firebase** заходит публичным ключом и показывает, какие таблицы читаются кем угодно, с готовым `curl` и SQL для починки. И он честен про покрытие: каждая проверка сообщает, реально ли она выполнилась, поэтому упавшая или пропущенная проверка **никогда не выдаётся за зелёное «всё чисто»**.
|
|
10
10
|
|
|
11
|
-
> Статус:
|
|
11
|
+
> Статус: **активно развивается, версия 0.6.8.** Лучше всего поддержан стек **Next.js + Supabase**. Осмотр кода работает на любом стеке; живой пробинг бэкенда — только чтение и заточен под Supabase/Firebase. Это не пентест.
|
|
12
12
|
|
|
13
13
|
## 🇷🇺 Простыми словами
|
|
14
14
|
|
|
@@ -21,7 +21,16 @@
|
|
|
21
21
|
2. Ответь на пару вопросов «да / нет».
|
|
22
22
|
3. Получишь понятный отчёт и готовый план починки — вставь его в свой ИИ (Cursor/Claude), и он всё исправит.
|
|
23
23
|
|
|
24
|
-
Разбираться в том, что внутри, не нужно: сложное — под капотом, снаружи одна команда и вопросы «да / нет».
|
|
24
|
+
Разбираться в том, что внутри, не нужно: сложное — под капотом, снаружи одна команда и вопросы «да / нет». Но если интересно — вот что конкретно проверяется.
|
|
25
|
+
|
|
26
|
+
### Что именно оно ищет
|
|
27
|
+
|
|
28
|
+
1. **Секреты прямо в коде** — API-ключи (OpenAI, Stripe, AWS, Supabase и т.д.) и пароли от базы, вписанные в код текстом, а не через переменные окружения. Если код когда-нибудь попадёт наружу (публичный репозиторий, скриншот, share-ссылка) — эти ключи утекут вместе с ним.
|
|
29
|
+
2. **Отключённый RLS (Row Level Security) в базе** — если у таблицы в Supabase/Postgres не включена построчная защита, то любой человек, у которого есть только публичный (анонимный) ключ твоего приложения — то есть буквально каждый посетитель сайта — может через API читать и иногда менять данные ВСЕХ пользователей, а не только свои.
|
|
30
|
+
3. **Секреты в клиентском бандле** — переменные с префиксом `NEXT_PUBLIC_` / `VITE_` / `REACT_APP_` попадают прямо в JS-файлы, которые скачивает браузер каждого посетителя. Если под таким префиксом лежит не публичный, а секретный ключ — его видно в исходниках страницы (F12 → Sources).
|
|
31
|
+
4. **Опасные настройки конфигурации** — CORS, разрешающий запросы с любого домена, включённый debug/verbose-режим, использование `eval`, сборка SQL-запроса через склейку строк (риск SQL-инъекции), JWT с `alg: none`.
|
|
32
|
+
5. **Гигиена `.env` и git** — секрет в закоммиченном `.env` уже попал в историю git и виден всем, у кого есть доступ к репозиторию (даже если файл потом удалить — история останется).
|
|
33
|
+
6. **Живая проверка бэкенда** (по желанию, только на своих проектах) — инструмент реально обращается к твоему работающему Supabase/Firebase с публичным ключом и смотрит, какие таблицы/бакеты читаются без авторизации, плюс дёргает найденные API-эндпоинты без токена. Только чтение, ничего не пишет и не ломает.
|
|
25
34
|
|
|
26
35
|
## Как запустить
|
|
27
36
|
|
package/dist/cli/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { existsSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { join, resolve } from 'node:path';
|
|
4
4
|
import { createInterface } from 'node:readline';
|
|
5
5
|
import { planTargets, runFlow } from '../orchestrator/flow.js';
|
|
@@ -262,6 +262,12 @@ async function main() {
|
|
|
262
262
|
// Reports land next to the scanned project by default, so scanning several
|
|
263
263
|
// projects from one shell never overwrites another project's report.
|
|
264
264
|
const outDir = args.output !== undefined ? resolve(args.output) : join(root, 'easyvibegate-report');
|
|
265
|
+
// Excluding outDir from the scan only makes sense when something is
|
|
266
|
+
// actually going to be written there. With --no-report (format 'none'),
|
|
267
|
+
// nothing is ever written to it — so a `--no-report --output <project>/src`
|
|
268
|
+
// run must not exclude real source just because it shares a path with an
|
|
269
|
+
// output directory nothing will use.
|
|
270
|
+
const excludeOutDir = args.format !== 'none' ? [outDir] : [];
|
|
265
271
|
// The report directory gets its OWN `.gitignore` (see prepareOutputDir) and
|
|
266
272
|
// has its stale report files deleted on every run. Both are safe for a
|
|
267
273
|
// directory that exists only to hold reports — neither is safe for the
|
|
@@ -292,7 +298,7 @@ async function main() {
|
|
|
292
298
|
let result;
|
|
293
299
|
if (useWizard) {
|
|
294
300
|
result = await runWizard({
|
|
295
|
-
excludeAbs:
|
|
301
|
+
excludeAbs: excludeOutDir,
|
|
296
302
|
path: args.path,
|
|
297
303
|
config: args.config,
|
|
298
304
|
lang,
|
|
@@ -320,7 +326,7 @@ async function main() {
|
|
|
320
326
|
};
|
|
321
327
|
// Same contract as the wizard: plan the concrete targets once, ask about
|
|
322
328
|
// exactly those, execute exactly those.
|
|
323
|
-
const staticResult = await scanStatic(root, { configPath: args.config, excludeAbs:
|
|
329
|
+
const staticResult = await scanStatic(root, { configPath: args.config, excludeAbs: excludeOutDir });
|
|
324
330
|
const plan = planTargets(staticResult.files, loadConfig(root, args.config), {
|
|
325
331
|
appUrl: args.appUrl,
|
|
326
332
|
supabaseUrl: args.supabaseUrl,
|
|
@@ -373,18 +379,33 @@ async function main() {
|
|
|
373
379
|
// Same contract in every mode: 2 critical, 1 warning, 3 incomplete, 0 clean.
|
|
374
380
|
process.exit(exitCodeFor(summary));
|
|
375
381
|
}
|
|
382
|
+
// Written verbatim (never partially, never appended-to) so an exact match on
|
|
383
|
+
// disk is proof WE created this directory, not the user re-pointing --output
|
|
384
|
+
// at a folder of their own. Checking "does outDir === project root" only was
|
|
385
|
+
// not enough: `--output docs` on a real docs/ folder replaced its real
|
|
386
|
+
// .gitignore (losing rules like `drafts/`) and deleted a real docs/report.md.
|
|
387
|
+
const OUTPUT_DIR_MARKER = '# Written by EasyVibeGate: this report can contain secret prefixes and hosts. Never commit it.\n*\n';
|
|
376
388
|
/** Create the report dir and clear our own stale files, or explain why we cannot. */
|
|
377
389
|
function prepareOutputDir(dir) {
|
|
378
390
|
try {
|
|
379
|
-
|
|
391
|
+
const existed = existsSync(dir);
|
|
392
|
+
if (existed && !statSync(dir).isDirectory())
|
|
380
393
|
return 'exists and is not a directory';
|
|
394
|
+
if (existed) {
|
|
395
|
+
const ownsIt = existsSync(join(dir, '.gitignore')) && readFileSync(join(dir, '.gitignore'), 'utf8') === OUTPUT_DIR_MARKER;
|
|
396
|
+
if (!ownsIt && readdirSync(dir).length > 0) {
|
|
397
|
+
return 'already exists, is not empty, and was not created by a previous EasyVibeGate run — refusing to overwrite its .gitignore or delete files in it; point --output at an empty or dedicated directory';
|
|
398
|
+
}
|
|
399
|
+
}
|
|
381
400
|
mkdirSync(dir, { recursive: true });
|
|
382
401
|
// The report names secret prefixes, database hosts and every endpoint —
|
|
383
402
|
// exactly what must not be committed. A `.gitignore` containing `*` inside
|
|
384
403
|
// the directory makes git ignore it wherever the project's own .gitignore
|
|
385
404
|
// 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
|
-
|
|
405
|
+
// never edited. Rewritten every run so a stray edit cannot un-ignore it —
|
|
406
|
+
// safe now because we only ever reach this line for a directory that was
|
|
407
|
+
// either empty or already marked as ours.
|
|
408
|
+
writeFileSync(join(dir, '.gitignore'), OUTPUT_DIR_MARKER, 'utf8');
|
|
388
409
|
// Old report.md next to a fresh report.json told two different stories.
|
|
389
410
|
for (const name of ['report.md', 'report.json', 'ai-fix-prompt.md']) {
|
|
390
411
|
const p = join(dir, name);
|
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
const MANAGERS = new Set(['npm', 'pnpm', 'yarn', 'bun']);
|
|
3
|
+
/**
|
|
4
|
+
* Extra args that restrict the audit to non-dev dependencies, per manager.
|
|
5
|
+
* Used for a SECOND audit pass so dev-only tooling (eslint, vitest, turbo —
|
|
6
|
+
* never shipped, never run on the server) can be told apart from the runtime
|
|
7
|
+
* dependency tree, without guessing from node_modules paths.
|
|
8
|
+
*/
|
|
9
|
+
const PROD_ONLY_ARGS = {
|
|
10
|
+
npm: ['audit', '--omit=dev', '--json'],
|
|
11
|
+
pnpm: ['audit', '--prod', '--json'],
|
|
12
|
+
// yarn's --groups takes each value as its OWN argument, not comma-joined —
|
|
13
|
+
// `--groups dependencies,optionalDependencies` silently audited 0 packages.
|
|
14
|
+
yarn: ['audit', '--groups', 'dependencies', 'optionalDependencies', '--json'],
|
|
15
|
+
};
|
|
3
16
|
function run(cmd, args, cwd, timeoutMs) {
|
|
4
17
|
return new Promise((resolve) => {
|
|
5
18
|
execFile(cmd, args, { cwd, timeout: timeoutMs, maxBuffer: 20 * 1024 * 1024 }, (err, stdout) => {
|
|
@@ -48,6 +61,128 @@ function validCounts(v) {
|
|
|
48
61
|
}
|
|
49
62
|
return out;
|
|
50
63
|
}
|
|
64
|
+
/** `fixAvailable` in npm's (and yarn's) newer `vulnerabilities` map: `true`, an object, or absent/false. */
|
|
65
|
+
function formatFixAvailable(fixAvailable) {
|
|
66
|
+
if (fixAvailable === true)
|
|
67
|
+
return 'update available';
|
|
68
|
+
if (fixAvailable && typeof fixAvailable === 'object') {
|
|
69
|
+
const f = fixAvailable;
|
|
70
|
+
if (f.name && f.version)
|
|
71
|
+
return `needs ${f.name}@${f.version}${f.isSemVerMajor ? ' (major)' : ''}`;
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
/** Keep the worst-severity entry per package name, in original order, capped. */
|
|
76
|
+
function dedupePackages(list, cap) {
|
|
77
|
+
const rank = { critical: 0, high: 1, moderate: 2, low: 3, info: 4 };
|
|
78
|
+
const best = new Map();
|
|
79
|
+
for (const p of list) {
|
|
80
|
+
const cur = best.get(p.name);
|
|
81
|
+
if (!cur || (rank[p.severity] ?? 9) < (rank[cur.severity] ?? 9))
|
|
82
|
+
best.set(p.name, p);
|
|
83
|
+
}
|
|
84
|
+
return [...best.values()].slice(0, cap);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Normalize one audit run's stdout into counts + a package list, across three
|
|
88
|
+
* incompatible report shapes:
|
|
89
|
+
* - npm (v7+) / yarn's `vulnerabilities` map, keyed by package name, each
|
|
90
|
+
* entry carrying `name`/`severity`/`range`/`fixAvailable` directly.
|
|
91
|
+
* - pnpm's `advisories` map — the OLDER npm-audit-v1 report shape pnpm still
|
|
92
|
+
* emits, keyed by numeric advisory id, each entry carrying `module_name` /
|
|
93
|
+
* `vulnerable_versions` / `patched_versions` instead. A package with
|
|
94
|
+
* several advisories appears more than once, so results get deduped by
|
|
95
|
+
* name. (The previous version of this reader only knew the newer shape,
|
|
96
|
+
* so pnpm projects silently got an empty package list — 0 names, always —
|
|
97
|
+
* even though `metadata.vulnerabilities` parsed fine.)
|
|
98
|
+
* - yarn v1's NDJSON stream of `{type, data}` lines: one `auditSummary` line
|
|
99
|
+
* with the counts, and one `auditAdvisory` line per finding, each nesting
|
|
100
|
+
* the same `module_name`/`vulnerable_versions`/`patched_versions` shape as
|
|
101
|
+
* pnpm's under `data.advisory`.
|
|
102
|
+
*/
|
|
103
|
+
function parseAuditPayload(pm, stdout) {
|
|
104
|
+
if (pm === 'yarn') {
|
|
105
|
+
let counts = null;
|
|
106
|
+
const packages = [];
|
|
107
|
+
for (const line of stdout.split('\n')) {
|
|
108
|
+
const s = line.trim();
|
|
109
|
+
if (!s.startsWith('{'))
|
|
110
|
+
continue;
|
|
111
|
+
let obj;
|
|
112
|
+
try {
|
|
113
|
+
obj = JSON.parse(s);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (obj.type === 'auditSummary') {
|
|
119
|
+
counts = validCounts(obj.data?.vulnerabilities);
|
|
120
|
+
}
|
|
121
|
+
else if (obj.type === 'auditAdvisory') {
|
|
122
|
+
const a = obj.data?.advisory;
|
|
123
|
+
if (a && typeof a['module_name'] === 'string' && typeof a['severity'] === 'string') {
|
|
124
|
+
packages.push({
|
|
125
|
+
name: a['module_name'],
|
|
126
|
+
severity: a['severity'],
|
|
127
|
+
range: typeof a['vulnerable_versions'] === 'string' ? a['vulnerable_versions'] : undefined,
|
|
128
|
+
fix: typeof a['patched_versions'] === 'string' ? a['patched_versions'] : undefined,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return counts ? { counts, packages } : null;
|
|
134
|
+
}
|
|
135
|
+
let parsed;
|
|
136
|
+
try {
|
|
137
|
+
parsed = JSON.parse(stdout);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
const line = stdout.trim().split('\n').filter(Boolean).pop() ?? '';
|
|
141
|
+
try {
|
|
142
|
+
parsed = JSON.parse(line);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (typeof parsed !== 'object' || parsed === null || parsed['error'] !== undefined)
|
|
149
|
+
return null;
|
|
150
|
+
const counts = validCounts(parsed['metadata']?.vulnerabilities);
|
|
151
|
+
if (!counts)
|
|
152
|
+
return null;
|
|
153
|
+
const packages = [];
|
|
154
|
+
const vulnMap = parsed['vulnerabilities'];
|
|
155
|
+
if (vulnMap && typeof vulnMap === 'object') {
|
|
156
|
+
// npm v2 / yarn-berry-style: keyed by package name, one entry each.
|
|
157
|
+
for (const v of Object.values(vulnMap)) {
|
|
158
|
+
if (typeof v['name'] !== 'string' || typeof v['severity'] !== 'string')
|
|
159
|
+
continue;
|
|
160
|
+
packages.push({
|
|
161
|
+
name: v['name'],
|
|
162
|
+
severity: v['severity'],
|
|
163
|
+
range: typeof v['range'] === 'string' ? v['range'] : undefined,
|
|
164
|
+
fix: formatFixAvailable(v['fixAvailable']),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
// pnpm: the old npm-audit-v1 shape, keyed by numeric advisory id.
|
|
170
|
+
const advisories = parsed['advisories'];
|
|
171
|
+
if (advisories && typeof advisories === 'object') {
|
|
172
|
+
for (const a of Object.values(advisories)) {
|
|
173
|
+
if (typeof a['module_name'] !== 'string' || typeof a['severity'] !== 'string')
|
|
174
|
+
continue;
|
|
175
|
+
packages.push({
|
|
176
|
+
name: a['module_name'],
|
|
177
|
+
severity: a['severity'],
|
|
178
|
+
range: typeof a['vulnerable_versions'] === 'string' ? a['vulnerable_versions'] : undefined,
|
|
179
|
+
fix: typeof a['patched_versions'] === 'string' ? a['patched_versions'] : undefined,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return { counts, packages };
|
|
185
|
+
}
|
|
51
186
|
/**
|
|
52
187
|
* Level 1: dependency vulnerability audit via the project's package manager.
|
|
53
188
|
* Returns a status so an audit that could not run (offline, missing tool,
|
|
@@ -99,78 +234,87 @@ export async function auditDeps(root, packageManagers, timeoutMs = 60000) {
|
|
|
99
234
|
return failed(`${pm} audit exited with code ${res.code}`);
|
|
100
235
|
if (!res.stdout.trim())
|
|
101
236
|
return failed(`${pm} audit produced no output (offline or no lockfile?)`);
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
// A valid JSON error envelope (e.g. registry unavailable) is NOT "clean".
|
|
134
|
-
if (typeof parsed !== 'object' || parsed === null || parsed.error !== undefined) {
|
|
135
|
-
return failed('audit returned an error envelope');
|
|
136
|
-
}
|
|
137
|
-
counts = validCounts(parsed.metadata?.vulnerabilities);
|
|
138
|
-
if (!counts)
|
|
139
|
-
return failed('unparseable audit output (metadata.vulnerabilities missing or malformed)');
|
|
140
|
-
vulnMap = parsed.vulnerabilities ?? {};
|
|
141
|
-
}
|
|
142
|
-
const { critical, high, moderate, low } = counts;
|
|
143
|
-
const total = counts.total ?? critical + high + moderate + low + counts.info;
|
|
237
|
+
const full = parseAuditPayload(pm, res.stdout);
|
|
238
|
+
if (!full)
|
|
239
|
+
return failed('unparseable audit output');
|
|
240
|
+
// Second pass, prod-only: tells apart a real runtime hole from a vulnerable
|
|
241
|
+
// devDependency (eslint, vitest, turbo — never shipped, never executed on
|
|
242
|
+
// the server) that would otherwise inflate the score and the verdict the
|
|
243
|
+
// same way a live SQL injection would. Best-effort: if this second call
|
|
244
|
+
// fails for any reason, fall back to treating the full count as the runtime
|
|
245
|
+
// count — never the other way around, so a failure here cannot make a real
|
|
246
|
+
// vulnerability look dev-only.
|
|
247
|
+
const prodRes = await run(pm, PROD_ONLY_ARGS[pm], root, timeoutMs);
|
|
248
|
+
const prod = !prodRes.failedToSpawn && prodRes.code !== null
|
|
249
|
+
&& (prodRes.code === 0 || isVulnsFoundExit(pm, prodRes.code))
|
|
250
|
+
&& prodRes.stdout.trim()
|
|
251
|
+
? parseAuditPayload(pm, prodRes.stdout)
|
|
252
|
+
: null;
|
|
253
|
+
const runtime = prod ?? full;
|
|
254
|
+
// Only ever attribute MORE to dev, never invent runtime risk the tool
|
|
255
|
+
// itself never reported — clamp at 0 in case the two audit runs disagree
|
|
256
|
+
// (a lockfile changing between calls, or manager-specific flag quirks).
|
|
257
|
+
const devOnly = {
|
|
258
|
+
info: Math.max(0, full.counts.info - runtime.counts.info),
|
|
259
|
+
low: Math.max(0, full.counts.low - runtime.counts.low),
|
|
260
|
+
moderate: Math.max(0, full.counts.moderate - runtime.counts.moderate),
|
|
261
|
+
high: Math.max(0, full.counts.high - runtime.counts.high),
|
|
262
|
+
critical: Math.max(0, full.counts.critical - runtime.counts.critical),
|
|
263
|
+
};
|
|
264
|
+
const devTotal = devOnly.info + devOnly.low + devOnly.moderate + devOnly.high + devOnly.critical;
|
|
265
|
+
const splitKnown = prod !== null;
|
|
266
|
+
const { critical, high, moderate, low } = runtime.counts;
|
|
267
|
+
const runtimeTotal = runtime.counts.total ?? critical + high + moderate + low + runtime.counts.info;
|
|
144
268
|
const findings = [...conflictFindings];
|
|
145
|
-
if (
|
|
269
|
+
if (runtimeTotal > 0) {
|
|
146
270
|
const severity = critical + high > 0 ? 'critical' : moderate > 0 ? 'warning' : 'info';
|
|
271
|
+
const devNote = splitKnown && devTotal > 0
|
|
272
|
+
? ` (plus ${devTotal} in dev-only tooling — not counted here, does not ship or run on the server)`
|
|
273
|
+
: !splitKnown
|
|
274
|
+
? ' (dev/runtime split unavailable — this count includes dev dependencies)'
|
|
275
|
+
: '';
|
|
147
276
|
findings.push({
|
|
148
277
|
id: 'deps_vulnerabilities',
|
|
149
278
|
severity,
|
|
150
|
-
title: `${
|
|
151
|
-
detail: `${pm} audit: ${critical} critical, ${high} high, ${moderate} moderate, ${low} low.`,
|
|
279
|
+
title: `${runtimeTotal} vulnerable runtime dependenc${runtimeTotal === 1 ? 'y' : 'ies'}${devNote}`,
|
|
280
|
+
detail: `${pm} audit (runtime only): ${critical} critical, ${high} high, ${moderate} moderate, ${low} low.`,
|
|
152
281
|
fix: `Run \`${pm} audit${pm === 'npm' ? ' fix' : ''}\` and upgrade the flagged packages; check breaking changes.`,
|
|
153
282
|
checker: 'deps',
|
|
154
283
|
level: 1,
|
|
155
284
|
});
|
|
156
|
-
const named =
|
|
157
|
-
.filter((v) => v.severity === 'critical' || v.severity === 'high')
|
|
158
|
-
.map((v) => v.name)
|
|
159
|
-
.filter(Boolean)
|
|
160
|
-
.slice(0, 8);
|
|
285
|
+
const named = dedupePackages(runtime.packages.filter((p) => p.severity === 'critical' || p.severity === 'high'), 8);
|
|
161
286
|
if (named.length) {
|
|
162
287
|
findings.push({
|
|
163
288
|
id: 'deps_top_packages',
|
|
164
289
|
severity: 'info',
|
|
165
|
-
title: 'High/critical packages to upgrade',
|
|
166
|
-
detail: named.join(', '),
|
|
290
|
+
title: 'High/critical runtime packages to upgrade',
|
|
291
|
+
detail: named.map((p) => `${p.name} (${p.range ?? '?'})${p.fix ? ` → ${p.fix}` : ''}`).join(', '),
|
|
167
292
|
fix: 'Upgrade these first; they carry the most severe advisories.',
|
|
168
293
|
checker: 'deps',
|
|
169
294
|
level: 1,
|
|
170
295
|
});
|
|
171
296
|
}
|
|
172
297
|
}
|
|
173
|
-
//
|
|
174
|
-
//
|
|
298
|
+
// Dev-only vulnerabilities are still worth knowing about (a compromised
|
|
299
|
+
// build-time tool can still do damage), but they are hygiene, not a hole a
|
|
300
|
+
// client can reach — info, and never why the gate fails.
|
|
301
|
+
if (splitKnown && devTotal > 0) {
|
|
302
|
+
const devCritHigh = devOnly.critical + devOnly.high;
|
|
303
|
+
const devNamed = dedupePackages(full.packages
|
|
304
|
+
.filter((p) => p.severity === 'critical' || p.severity === 'high')
|
|
305
|
+
.filter((p) => !runtime.packages.some((r) => r.name === p.name)), 8);
|
|
306
|
+
findings.push({
|
|
307
|
+
id: 'deps_dev_vulnerabilities',
|
|
308
|
+
severity: 'info',
|
|
309
|
+
title: `${devTotal} vulnerable dev-only dependenc${devTotal === 1 ? 'y' : 'ies'}`,
|
|
310
|
+
detail: `${devOnly.critical} critical, ${devOnly.high} high, ${devOnly.moderate} moderate, ${devOnly.low} low — only in build/test tooling (e.g. eslint, vitest, turbo), never shipped to the browser or run on the server.${devCritHigh > 0 && devNamed.length ? ` Top: ${devNamed.map((p) => p.name).join(', ')}.` : ''}`,
|
|
311
|
+
fix: `Worth updating on its own schedule — run \`${pm} audit\` (full) to see them; they do not affect this project's runtime exposure.`,
|
|
312
|
+
checker: 'deps',
|
|
313
|
+
level: 1,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
// "completed" with zero runtime findings means genuinely no known runtime
|
|
317
|
+
// vulns — the status, not an info finding, records that the check ran
|
|
318
|
+
// cleanly. A dev-only info finding can still be present.
|
|
175
319
|
return { findings, run: withNote({ id: 'deps', level: 1, status: 'completed' }) };
|
|
176
320
|
}
|
|
@@ -4,6 +4,7 @@ import { configRisksChecker } from './static/config-risks.js';
|
|
|
4
4
|
import { rlsMigrationsChecker } from './static/rls-migrations.js';
|
|
5
5
|
import { envGitChecker } from './static/env-git.js';
|
|
6
6
|
import { routeInventoryChecker } from './static/route-inventory.js';
|
|
7
|
+
import { nextImageSsrfChecker } from './static/next-image-ssrf.js';
|
|
7
8
|
/** Level 0 checkers: read-only static analysis, no install, any stack. */
|
|
8
9
|
export const staticCheckers = [
|
|
9
10
|
secretsChecker,
|
|
@@ -12,4 +13,5 @@ export const staticCheckers = [
|
|
|
12
13
|
rlsMigrationsChecker,
|
|
13
14
|
envGitChecker,
|
|
14
15
|
routeInventoryChecker,
|
|
16
|
+
nextImageSsrfChecker,
|
|
15
17
|
];
|
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import { lineAt, looksLikePlaceholder, redact } from '../../util/text.js';
|
|
2
|
+
import { maskCode } from '../../util/mask.js';
|
|
2
3
|
import { classifyKey } from '../backend/supabase.js';
|
|
3
4
|
// Public env prefixes are inlined into the browser bundle by the bundler.
|
|
4
5
|
const PUBLIC_PREFIX = '(?:NEXT_PUBLIC_|VITE_|REACT_APP_|EXPO_PUBLIC_|GATSBY_|PUBLIC_)';
|
|
5
6
|
// Every public-prefixed assignment; what it carries is decided by the VALUE
|
|
6
|
-
// first and the NAME second (below).
|
|
7
|
-
|
|
7
|
+
// first and the NAME second (below). The name gets an optional closing quote
|
|
8
|
+
// before the `:`/`=` — a JSON/object literal's `"NEXT_PUBLIC_KEY": "value"`
|
|
9
|
+
// otherwise never matched at all, since nothing after the bare name expected
|
|
10
|
+
// that stray `"`. The gap between `:`/`=` and the value is same-line
|
|
11
|
+
// whitespace only (`[ \t]*`, not `\s*`) — an EMPTY-valued var followed by
|
|
12
|
+
// blank/commented lines otherwise let the value group skip straight past
|
|
13
|
+
// them onto the NEXT var's name, reporting that name as a leaked secret
|
|
14
|
+
// value. Once comments started getting masked to blank space (below), a
|
|
15
|
+
// `.env.example`'s usual "KEY=\n\n# comment\nNEXT_KEY=" shape hit this on
|
|
16
|
+
// real projects.
|
|
17
|
+
const PUBLIC_ASSIGN = new RegExp(`\\b(${PUBLIC_PREFIX}[A-Z0-9_]*)["']?\\s*[:=][ \\t]*["']?([^"'\\s]{6,})`, 'gi');
|
|
8
18
|
// A public var whose NAME implies a real secret (not an anon/publishable key).
|
|
9
19
|
const SECRET_NAME = /(SERVICE_ROLE|SECRET|PRIVATE|PASSWORD|PASSWD|TOKEN|CREDENTIAL|API_KEY|ACCESS_KEY)/i;
|
|
10
20
|
// Value shapes that are server secrets no matter what the variable is called.
|
|
@@ -40,7 +50,10 @@ export const clientExposureChecker = {
|
|
|
40
50
|
const advised = new Set();
|
|
41
51
|
for (const file of ctx.files) {
|
|
42
52
|
const { content, rel } = file;
|
|
43
|
-
|
|
53
|
+
// Commented-out code is not a live exposure; masking preserves length
|
|
54
|
+
// and newlines so match offsets/line numbers still point at the source.
|
|
55
|
+
const scan = maskCode(content, { file: rel });
|
|
56
|
+
for (const m of scan.matchAll(PUBLIC_ASSIGN)) {
|
|
44
57
|
const name = m[1] ?? '';
|
|
45
58
|
const value = m[2] ?? '';
|
|
46
59
|
if (looksLikePlaceholder(value))
|
|
@@ -37,7 +37,13 @@ const RULES = [
|
|
|
37
37
|
// inside one string literal — so prose mentioning "insert" won't match.
|
|
38
38
|
// Quantifiers are length-bounded ({0,200}) to prevent catastrophic
|
|
39
39
|
// backtracking (ReDoS) on very long / minified lines.
|
|
40
|
-
|
|
40
|
+
//
|
|
41
|
+
// The f-string branch tracks its OWN opening quote via a backreference
|
|
42
|
+
// (`(?:(?!\1)[^\n])` — any char that isn't a newline or that quote) rather
|
|
43
|
+
// than excluding both quote characters outright: `f"SELECT … WHERE name =
|
|
44
|
+
// '{x}'"` nests a single-quoted SQL string value inside a double-quoted
|
|
45
|
+
// f-string, and excluding `'` too made the whole pattern never match.
|
|
46
|
+
re: /`\s*(?:SELECT|INSERT|UPDATE|DELETE)\b[^`]{0,200}\b(?:FROM|INTO|WHERE|VALUES|SET|JOIN)\b[^`]{0,200}\$\{|f(["'])\s*(?:SELECT|INSERT|UPDATE|DELETE)\b(?:(?!\1)[^\n]){0,200}\b(?:FROM|INTO|WHERE|VALUES|SET|JOIN)\b(?:(?!\1)[^\n]){0,200}\{/gi,
|
|
41
47
|
severity: 'warning',
|
|
42
48
|
detail: 'Interpolating values into SQL invites SQL injection.',
|
|
43
49
|
fix: 'Use parameterized queries / prepared statements instead of string interpolation.',
|
|
@@ -73,7 +79,11 @@ function isParameterizedTemplate(scan, backtickAt) {
|
|
|
73
79
|
* from request data, so it is reported as info, not as an injection. About half
|
|
74
80
|
* of the real-world hits were structural.
|
|
75
81
|
*/
|
|
76
|
-
|
|
82
|
+
// A trailing `["']?` handles the equally common quoted-value shape
|
|
83
|
+
// (`name = '${name}'`, `WHERE id = "${id}"`) — the SQL literal's own quote
|
|
84
|
+
// sits directly before the hole, between the operator and `${`/`{`, and
|
|
85
|
+
// without it that shape read as unquoted structure, not a value.
|
|
86
|
+
const VALUE_POSITION = /(=|<>|!=|<=|>=|<|>|\bLIKE|\bILIKE|\bIN\s*\(|\bVALUES\s*\([^)]*|\bBETWEEN|\bAND|\bOR|\bLIMIT|\bOFFSET|\bTHEN|\bELSE)\s*["']?\s*$/i;
|
|
77
87
|
const PLACEHOLDER_EXPR = /^\s*(?:\w+\.)?(placeholders?|params?|marks|questions|qs|values|binds?)\b/i;
|
|
78
88
|
function onlyStructuralInterpolation(scan, at) {
|
|
79
89
|
// The literal: from the opening delimiter to its close (bounded).
|