easyvibegate 0.6.8 → 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
 
@@ -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
  ];
@@ -0,0 +1,137 @@
1
+ import { lineAt } from '../../util/text.js';
2
+ import { maskCode } from '../../util/mask.js';
3
+ const NEXT_CONFIG_RE = /(^|\/)next\.config\.(js|mjs|cjs|ts)$/;
4
+ const MIDDLEWARE_RE = /(^|\/)middleware\.(js|mjs|ts)$/;
5
+ /**
6
+ * A `remotePatterns` entry with a wildcard hostname. Next.js's built-in image
7
+ * optimizer (`/_next/image?url=...`) will then fetch ANY https URL a caller
8
+ * gives it and return the decoded result — a textbook SSRF primitive (probe
9
+ * internal services, cloud metadata endpoints, port-scan the private network)
10
+ * independent of whatever RCE a given `next`/`sharp` version does or doesn't
11
+ * carry this month. Flagging only the version misses this: upgrading closes
12
+ * today's CVE but leaves the wildcard open for the next one.
13
+ */
14
+ const WILDCARD_HOSTNAME = /hostname\s*:\s*["'`]\*{1,2}["'`]/;
15
+ const REMOTE_PATTERNS_BLOCK = /remotePatterns\s*:\s*\[/g;
16
+ /** How far past `remotePatterns: [` to look for a wildcard entry inside it. */
17
+ const BLOCK_WINDOW = 2000;
18
+ /**
19
+ * The Next.js docs' own idiom for "run this middleware on everything except
20
+ * X": a negative lookahead inside the matcher regex,
21
+ * `'/((?!_next/static|_next/image|favicon.ico).*)'`. `_next/image` appearing
22
+ * inside a matcher block that also contains a lookahead means the image
23
+ * optimizer is explicitly carved out of whatever auth gate this middleware
24
+ * otherwise enforces — reachable with no session at all.
25
+ */
26
+ const MATCHER_BLOCK = /matcher\s*[:=]/g;
27
+ const MATCHER_WINDOW = 500;
28
+ const dirOf = (rel) => {
29
+ const i = rel.lastIndexOf('/');
30
+ return i === -1 ? '' : rel.slice(0, i);
31
+ };
32
+ /** middleware.ts commonly sits at the app root OR under src/ — normalize both to the app root. */
33
+ const appRootOf = (rel) => {
34
+ const d = dirOf(rel);
35
+ return d.endsWith('/src') ? d.slice(0, -4) : d === 'src' ? '' : d;
36
+ };
37
+ function findWildcardRemotePatterns(content) {
38
+ const hits = [];
39
+ for (const m of content.matchAll(REMOTE_PATTERNS_BLOCK)) {
40
+ const start = (m.index ?? 0) + m[0].length;
41
+ const window = content.slice(start, start + BLOCK_WINDOW);
42
+ // Stop at the block's own closing `]` when it's inside the window, so a
43
+ // SECOND, unrelated remotePatterns array later in a long file is not
44
+ // credited with this one's wildcard.
45
+ const closeAt = window.indexOf(']');
46
+ const scoped = closeAt === -1 ? window : window.slice(0, closeAt);
47
+ if (WILDCARD_HOSTNAME.test(scoped))
48
+ hits.push(m.index ?? 0);
49
+ }
50
+ return hits;
51
+ }
52
+ function findImageExcludedMatchers(content) {
53
+ const hits = [];
54
+ for (const m of content.matchAll(MATCHER_BLOCK)) {
55
+ const start = m.index ?? 0;
56
+ const window = content.slice(start, start + MATCHER_WINDOW);
57
+ if (window.includes('(?!') && window.includes('_next/image'))
58
+ hits.push(start);
59
+ }
60
+ return hits;
61
+ }
62
+ /**
63
+ * Correlates two static signals that no per-file, per-checker rule can see
64
+ * together: an image optimizer open to any host (`next.config.*`), and an
65
+ * auth middleware that explicitly exempts the optimizer's own route
66
+ * (`middleware.*`). Neither file alone shows the hole; grep for either one in
67
+ * isolation reads as routine Next.js configuration.
68
+ */
69
+ export const nextImageSsrfChecker = {
70
+ id: 'next-image-ssrf',
71
+ title: 'Next.js image optimizer open to any host',
72
+ level: 0,
73
+ run(ctx) {
74
+ const configFiles = ctx.files.filter((f) => NEXT_CONFIG_RE.test(f.rel));
75
+ const middlewareFiles = ctx.files.filter((f) => MIDDLEWARE_RE.test(f.rel));
76
+ const wildcardHits = [];
77
+ // Comments never count as config: `// hostname: '**' — SSRF risk, do not
78
+ // do this` in a file that sets a real, specific hostname must not fire.
79
+ for (const f of configFiles) {
80
+ const masked = maskCode(f.content, { file: f.rel });
81
+ for (const index of findWildcardRemotePatterns(masked))
82
+ wildcardHits.push({ file: f.rel, index });
83
+ }
84
+ if (wildcardHits.length === 0)
85
+ return { findings: [] };
86
+ const excludedHits = [];
87
+ for (const f of middlewareFiles) {
88
+ const masked = maskCode(f.content, { file: f.rel });
89
+ for (const index of findImageExcludedMatchers(masked))
90
+ excludedHits.push({ file: f.rel, index });
91
+ }
92
+ // Pair a config hit with a middleware hit when there is only one of each
93
+ // in the whole project (the common single-app case), or when one file's
94
+ // app root is a prefix of the other's (a monorepo with `apps/<name>/`).
95
+ const pairFor = (configFile) => {
96
+ if (excludedHits.length === 1)
97
+ return excludedHits[0];
98
+ const root = appRootOf(configFile);
99
+ return excludedHits.find((h) => {
100
+ const hRoot = appRootOf(h.file);
101
+ return hRoot === root || hRoot.startsWith(root ? `${root}/` : '') || (root.startsWith(hRoot ? `${hRoot}/` : '') && hRoot !== '');
102
+ });
103
+ };
104
+ const findings = [];
105
+ for (const hit of wildcardHits) {
106
+ const file = ctx.files.find((f) => f.rel === hit.file);
107
+ const line = file ? lineAt(file.content, hit.index) : 1;
108
+ const paired = configFiles.length === 1 ? excludedHits[0] : pairFor(hit.file);
109
+ if (paired) {
110
+ findings.push({
111
+ id: 'next_image_ssrf_unauth',
112
+ severity: 'critical',
113
+ title: 'Next.js image optimizer is open to any host AND reachable without auth',
114
+ detail: `${hit.file}:${line} allows the built-in image optimizer (${'/_next/image'}) to fetch an https URL from ANY host (a wildcard \`hostname\` in \`remotePatterns\`). ${paired.file} explicitly excludes \`_next/image\` from its auth matcher, so any unauthenticated caller can make this server fetch an attacker-chosen URL — a classic SSRF (internal services, cloud metadata endpoints, private-network scanning), and a live vector for whatever RCE the installed \`next\`/\`sharp\` version carries in its image decoder. One \`GET\` request against the real path confirms this without needing to read either file in isolation.`,
115
+ fix: 'Restrict `images.remotePatterns` to the specific hosts you actually serve images from — never a bare `\'*\'`/`\'**\'` hostname. If the optimizer must stay open to arbitrary hosts, remove `_next/image` from the middleware matcher exclusion so it is not reachable pre-auth. Also keep `next` and `sharp` on a current, patched version — the wildcard host and the decoder CVEs compound each other.',
116
+ checker: 'next-image-ssrf',
117
+ level: 0,
118
+ file: hit.file,
119
+ line,
120
+ });
121
+ continue;
122
+ }
123
+ findings.push({
124
+ id: 'next_image_ssrf_open',
125
+ severity: 'warning',
126
+ title: 'Next.js image optimizer accepts any remote host',
127
+ detail: `${hit.file}:${line} sets a wildcard \`hostname\` in \`images.remotePatterns\`, so the built-in image optimizer will fetch an https URL from any host a caller supplies. This is an SSRF primitive on its own — internal services, cloud metadata endpoints, and the private network become reachable through this server — independent of whether \`_next/image\` also sits behind auth.`,
128
+ fix: 'List the specific hosts you actually serve images from in `remotePatterns` instead of a bare `\'*\'`/`\'**\'` hostname. If the route is not behind auth, this is worth fixing regardless of the current `next`/`sharp` version.',
129
+ checker: 'next-image-ssrf',
130
+ level: 0,
131
+ file: hit.file,
132
+ line,
133
+ });
134
+ }
135
+ return { findings };
136
+ },
137
+ };
@@ -1,5 +1,5 @@
1
1
  import { decodeJwtPayload, lineAt, looksLikePlaceholder, looksLikeTestOrDocPath, redact, shannonEntropy } from '../../util/text.js';
2
- import { createExposure } from '../../util/git-exposure.js';
2
+ import { createExposure, createHistoryChecker } from '../../util/git-exposure.js';
3
3
  import { partitionIgnores } from '../../config.js';
4
4
  /** No project-wide rules — used to run ONLY the inline-marker half of partitionIgnores. */
5
5
  const EMPTY_CONFIG = { ignore: [], ignorePaths: [] };
@@ -246,7 +246,7 @@ function collectRealEnvValues(files) {
246
246
  * it is. Builds the per-file `push` used by every scan pass below, so that
247
247
  * pipeline lives in one place instead of being reimplemented per pass.
248
248
  */
249
- function makePush({ rel, env, example, ex, realEnvValues, findings }) {
249
+ function makePush({ rel, env, example, ex, realEnvValues, findings, wasCommitted }) {
250
250
  return (f, proven = false, raw) => {
251
251
  let out = f;
252
252
  const realValueInExample = !!raw && isExampleEnv(rel) && (realEnvValues.get(dirOf(rel))?.has(raw) ?? false);
@@ -291,15 +291,28 @@ function makePush({ rel, env, example, ex, realEnvValues, findings }) {
291
291
  // there is nothing a separate ignored-env-file branch would add.
292
292
  out = { ...out, severity: 'advisory', detail: `${out.detail} Not committed — this is where the value belongs; keep the file out of git and out of client bundles.` };
293
293
  }
294
- else if (ex === 'ignored') {
295
- // Gitignored, but not an env file: a credential pasted into a tool
296
- // config or a script (a prod DB password inside a permission rule in
297
- // .claude/settings.local.json was reported as a mere advisory). It
298
- // cannot leak through git today; it still does not belong there.
299
- out = { ...out, severity: atMost(out.severity, 'warning'), detail: `${out.detail} The file is gitignored, so this cannot leak through git — but it is a credential pasted into a config/source file, not an env var. Move it to .env (also gitignored) so one \`git add -A\` or a shared zip never carries it.` };
300
- }
301
- else if (ex === 'untracked') {
302
- out = { ...out, severity: atMost(out.severity, 'warning'), detail: `${out.detail} The file is not committed yet — it is one \`git add -A\` away from being. Add it to .gitignore or move the value to an env var.` };
294
+ else if (ex === 'ignored' || ex === 'untracked') {
295
+ // Gitignored or untracked now — but "not committed NOW" is not the
296
+ // same claim as "never committed": a file `git rm --cached` removed
297
+ // after the mistake was noticed still carries the real value forever
298
+ // in history. For a structurally-proven credential (not a name-based
299
+ // guess), check whether THIS value ever showed up in a version of
300
+ // this file that WAS committed — "rotate immediately" is warranted
301
+ // for a real incident and needless (if harmless) urgency otherwise,
302
+ // and rotating a production service_role key across every
303
+ // environment that holds it is not a free action to ask for blindly.
304
+ const committedBefore = proven && !!raw ? wasCommitted(raw) : null;
305
+ const base = ex === 'ignored'
306
+ ? `${out.detail} The file is gitignored, so this cannot leak through git — but it is a credential pasted into a config/source file, not an env var. Move it to .env (also gitignored) so one \`git add -A\` or a shared zip never carries it.`
307
+ : `${out.detail} The file is not committed yet — it is one \`git add -A\` away from being. Add it to .gitignore or move the value to an env var.`;
308
+ out = {
309
+ ...out,
310
+ severity: atMost(out.severity, 'warning'),
311
+ detail: committedBefore === false
312
+ ? `${base} Checked this file's git history for this exact value and did not find it — it does not look like it was ever actually committed, so this reads as a hygiene fix, not a confirmed leak.`
313
+ : base,
314
+ fix: committedBefore === false ? `${out.fix} (No evidence this exact value was ever committed, so rotation is precautionary here, not a confirmed-leak emergency.)` : out.fix,
315
+ };
303
316
  }
304
317
  else if (ex === 'no-git') {
305
318
  out = { ...out, severity: atMost(out.severity, 'warning'), detail: `${out.detail} This folder is not a git repository, so nothing leaks through git; the risk is copying or zipping the folder. Move the value to an env var before this becomes a repo.` };
@@ -459,21 +472,30 @@ function scanInlineEnv(content, rel, push) {
459
472
  }, false, value);
460
473
  }
461
474
  }
475
+ const NAME_BASED = new Set(['env_secret', 'generic_secret']);
462
476
  /**
463
477
  * De-duplicate only true repeats: the same secret, same place, same rule.
464
478
  * (Keying on file:line alone hid every extra key on a minified line.)
479
+ *
480
+ * env_secret and generic_secret are bucketed together (not by their own id):
481
+ * a config-ish file's single assignment runs through BOTH the line-anchored
482
+ * ASSIGN rule and the mid-line GENERIC rule, and when they independently
483
+ * derive the same value from the same line (an `auth_token = "env(NAME)"` in
484
+ * a Supabase config.toml matched both, each producing its own id) that is one
485
+ * fact reported twice, not two facts. Still keyed on evidence, not line alone
486
+ * — two DIFFERENT secrets on one line stay two findings either way.
465
487
  */
466
488
  function dedupeFindings(findings) {
467
489
  const seen = new Map();
468
490
  for (const f of findings) {
469
- const key = `${f.file ?? ''}:${f.line ?? 0}:${f.id}:${f.evidence ?? ''}`;
491
+ const bucket = NAME_BASED.has(f.id) ? 'name-based' : f.id;
492
+ const key = `${f.file ?? ''}:${f.line ?? 0}:${bucket}:${f.evidence ?? ''}`;
470
493
  const cur = seen.get(key);
471
494
  if (!cur || SEVERITY_RANK[f.severity] < SEVERITY_RANK[cur.severity])
472
495
  seen.set(key, f);
473
496
  }
474
497
  return [...seen.values()];
475
498
  }
476
- const NAME_BASED = new Set(['env_secret', 'generic_secret']);
477
499
  /**
478
500
  * A vendor pattern or a decoded JWT already identifies a VALUE on a line; the
479
501
  * name-based env_secret / generic_secret finding for that SAME value is the
@@ -539,6 +561,7 @@ export const secretsChecker = {
539
561
  run(ctx) {
540
562
  const findings = [];
541
563
  const exposure = createExposure(ctx.root);
564
+ const history = createHistoryChecker(ctx.root);
542
565
  const realEnvValues = collectRealEnvValues(ctx.files);
543
566
  for (const file of ctx.files) {
544
567
  const { rel } = file;
@@ -546,11 +569,16 @@ export const secretsChecker = {
546
569
  const env = isEnvFile(rel);
547
570
  const example = looksLikeTestOrDocPath(rel);
548
571
  const ex = exposure(rel);
549
- const push = makePush({ rel, env, example, ex, realEnvValues, findings });
572
+ const wasCommitted = (raw) => history(rel, raw);
573
+ const push = makePush({ rel, env, example, ex, realEnvValues, findings, wasCommitted });
550
574
  scanVendorPatterns(content, rel, env, push);
551
575
  scanServiceRoleJwt(content, rel, env, push);
552
- scanGenericAssignments(content, rel, push);
576
+ // scanConfigAssignments before scanGenericAssignments/scanInlineEnv: on
577
+ // a config-ish file, all three can match the same line, and this one's
578
+ // title/fix is the most specific (it knows the file IS config/env) —
579
+ // dedupeFindings keeps the first-pushed on a tie, so it should win.
553
580
  scanConfigAssignments(content, rel, env, push);
581
+ scanGenericAssignments(content, rel, push);
554
582
  scanInlineEnv(content, rel, push);
555
583
  }
556
584
  const kept = dropRedundantNameBased(dedupeFindings(findings));
@@ -31,3 +31,44 @@ export function createExposure(root) {
31
31
  return ex;
32
32
  };
33
33
  }
34
+ /**
35
+ * For a file that is NOT committed now (ignored or untracked): was THIS
36
+ * value ever in a version of it that WAS committed? Tells apart "moved to
37
+ * .gitignore before the real value was ever written here — never leaked"
38
+ * from "was committed with this exact credential, then removed — a real
39
+ * incident, still in history forever." Advice like "rotate it" is sound for
40
+ * the second and needless noise for the first, especially for a production
41
+ * key whose rotation itself carries real operational risk.
42
+ *
43
+ * Bounded to the file's last `maxCommits` commits touching it (a `git log`,
44
+ * then up to that many `git show` blob reads) — deliberate for a file with
45
+ * unusually long history; the answer degrades to "unknown", never "safe".
46
+ *
47
+ * Returns `true` (found it committed), `false` (checked history, never
48
+ * found), or `null` (no history to check, or git failed) — `null` must be
49
+ * treated as "no evidence either way", never as "confirmed safe".
50
+ */
51
+ export function createHistoryChecker(root) {
52
+ const commitsCache = new Map();
53
+ const commitsFor = (rel, maxCommits) => {
54
+ const key = `${rel}\u0000${maxCommits}`;
55
+ const hit = commitsCache.get(key);
56
+ if (hit !== undefined)
57
+ return hit;
58
+ const log = git(root, ['log', '--all', '-n', String(maxCommits), '--format=%H', '--', rel]);
59
+ const commits = log === null ? null : log.split('\n').filter(Boolean);
60
+ commitsCache.set(key, commits);
61
+ return commits;
62
+ };
63
+ return (rel, value, maxCommits = 20) => {
64
+ const commits = commitsFor(rel, maxCommits);
65
+ if (commits === null || commits.length === 0)
66
+ return null; // never tracked at all, or git failed
67
+ for (const sha of commits) {
68
+ const blob = git(root, ['show', `${sha}:${rel}`]);
69
+ if (blob !== null && blob.includes(value))
70
+ return true;
71
+ }
72
+ return false;
73
+ };
74
+ }
@@ -29,7 +29,17 @@ export function lineAt(content, index) {
29
29
  }
30
30
  return line;
31
31
  }
32
- const PLACEHOLDER = /(x{3,}|your[_-]?|<[^>]+>|\$\{|process\.env|import\.meta\.env|example|placeholder|changeme|dummy|test[_-]?key|xxxxx|\.\.\.)/i;
32
+ /**
33
+ * A value that IS a reference to a secret, not the secret itself: `${NAME}`
34
+ * (shell/JS template), `env(NAME)` (Supabase CLI's config.toml substitution —
35
+ * the exact shape `supabase init` writes into every generated config, so this
36
+ * fired on every Supabase project's default file, not one repo's mistake),
37
+ * `{{ NAME }}` (Jinja/Helm/GitHub Actions expression), or a bare `$NAME`
38
+ * shell variable with nothing else in the value. The last one is anchored to
39
+ * the WHOLE value (`^...$`) — a real secret that merely contains a `$`
40
+ * somewhere must not be excluded, only a value that IS just a var reference.
41
+ */
42
+ const PLACEHOLDER = /(x{3,}|your[_-]?|<[^>]+>|\$\{|process\.env|import\.meta\.env|example|placeholder|changeme|dummy|test[_-]?key|xxxxx|\.\.\.|env\(\s*[A-Z][A-Z0-9_]*\s*\)|\{\{\s*[\w.]+\s*\}\}|^\$[A-Z][A-Z0-9_]*$)/i;
33
43
  /** True if a captured value looks like a template/placeholder, not a real secret. */
34
44
  export function looksLikePlaceholder(value) {
35
45
  return PLACEHOLDER.test(value);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "easyvibegate",
3
- "version": "0.6.8",
3
+ "version": "0.6.9",
4
4
  "description": "Interactive security scanner for vibe-coded apps (focus: Next.js + Supabase). Checks common risks and shows evidence where it can.",
5
5
  "type": "module",
6
6
  "bin": {