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 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: **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.
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
- > Статус: **v0.5, ранняя версия.** Лучше всего поддержан стек **Next.js + Supabase**. Осмотр кода работает на любом стеке; живой пробинг бэкенда — только чтение и заточен под Supabase/Firebase. Это не пентест.
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: [outDir],
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: [outDir] });
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
- if (existsSync(dir) && !statSync(dir).isDirectory())
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
- writeFileSync(join(dir, '.gitignore'), '# Written by EasyVibeGate: this report can contain secret prefixes and hosts. Never commit it.\n*\n', 'utf8');
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
- let counts = null;
103
- let vulnMap = {};
104
- if (pm === 'yarn') {
105
- for (const line of res.stdout.split('\n')) {
106
- const s = line.trim();
107
- if (!s.startsWith('{'))
108
- continue;
109
- try {
110
- const obj = JSON.parse(s);
111
- if (obj.type === 'auditSummary')
112
- counts = validCounts(obj.data?.vulnerabilities);
113
- }
114
- catch { /* skip */ }
115
- }
116
- if (!counts)
117
- return failed('unparseable audit output (no valid yarn auditSummary)');
118
- }
119
- else {
120
- let parsed;
121
- try {
122
- parsed = JSON.parse(res.stdout);
123
- }
124
- catch {
125
- const line = res.stdout.trim().split('\n').filter(Boolean).pop() ?? '';
126
- try {
127
- parsed = JSON.parse(line);
128
- }
129
- catch {
130
- return failed('unparseable audit output (not JSON)');
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 (total > 0) {
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: `${total} vulnerable dependenc${total === 1 ? 'y' : 'ies'}`,
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 = Object.values(vulnMap)
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
- // "completed" with zero findings means genuinely no known vulns — the status,
174
- // not an info finding, records that the check ran cleanly.
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
- const PUBLIC_ASSIGN = new RegExp(`\\b(${PUBLIC_PREFIX}[A-Z0-9_]*)\\s*[:=]\\s*["']?([^"'\\s]{6,})`, 'gi');
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
- for (const m of content.matchAll(PUBLIC_ASSIGN)) {
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
- 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[^"'\n]{0,200}\b(?:FROM|INTO|WHERE|VALUES|SET|JOIN)\b[^"'\n]{0,200}\{/gi,
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
- const VALUE_POSITION = /(=|<>|!=|<=|>=|<|>|\bLIKE|\bILIKE|\bIN\s*\(|\bVALUES\s*\([^)]*|\bBETWEEN|\bAND|\bOR|\bLIMIT|\bOFFSET|\bTHEN|\bELSE)\s*$/i;
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).