easyvibegate 0.6.0 → 0.6.8

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.
@@ -1,5 +1,11 @@
1
- import { decodeJwtPayload, lineAt, looksLikePlaceholder, redact, shannonEntropy } from '../../util/text.js';
1
+ import { decodeJwtPayload, lineAt, looksLikePlaceholder, looksLikeTestOrDocPath, redact, shannonEntropy } from '../../util/text.js';
2
2
  import { createExposure } from '../../util/git-exposure.js';
3
+ import { partitionIgnores } from '../../config.js';
4
+ /** No project-wide rules — used to run ONLY the inline-marker half of partitionIgnores. */
5
+ const EMPTY_CONFIG = { ignore: [], ignorePaths: [] };
6
+ /** Lower number = more severe. One copy, used both to cap a severity and to rank duplicates. */
7
+ const SEVERITY_RANK = { critical: 0, warning: 1, info: 2, advisory: 3 };
8
+ const atMost = (sev, cap) => (SEVERITY_RANK[sev] < SEVERITY_RANK[cap] ? cap : sev);
3
9
  /** Real tokens mix case and digits; kebab-case identifiers do not. */
4
10
  function looksRandom(s) {
5
11
  const body = s.replace(/^[a-z]+[-_]/i, '');
@@ -124,24 +130,29 @@ const PATTERNS = [
124
130
  {
125
131
  id: 'db_url_password',
126
132
  title: 'Database URL with an inline password',
127
- re: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp|mssql):\/\/[^\s:/@"']+:([^\s:/@"']{4,})@[^\s"']+/gi,
133
+ // The trailing char classes exclude backtick/paren/bracket/comma/semicolon
134
+ // as well as the usual quote+whitespace: a URL written as a markdown-style
135
+ // example (`` `postgres://user:pass@host` `` in a comment or docstring)
136
+ // otherwise swallows the closing backtick into the host, which broke the
137
+ // local-host check below (`localhost\`` never matches `^localhost$`).
138
+ re: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp|mssql):\/\/[^\s:/@"'`)\];,]+:([^\s:/@"'`)\];,]{4,})@[^\s"'`)\];,]+/gi,
128
139
  severity: 'critical',
129
140
  fix: 'Move the connection string to a server-side env var and rotate the database password — a committed DB URL grants full data access.',
130
141
  // `postgres://opencut:opencut@localhost` in docker-compose / CI / a Dockerfile
131
142
  // is a local container's default login, not a credential. A password that
132
- // equals the user name, or is a well-known default, is never reported; a
133
- // weak password on a local/single-label host (a compose service name) is
134
- // not either. A real-looking password on a real host still is.
143
+ // equals the user name, or is a well-known default, or is otherwise weak,
144
+ // is only exempt on a local/single-label host (a compose service name) —
145
+ // the exact same password on a real remote host is a live, guessable
146
+ // credential, not a throwaway dev default, and must still be reported.
135
147
  validate: (hit) => {
136
- const m = /^[a-z+]+:\/\/([^\s:/@"']+):([^\s:/@"']+)@([^/\s:"']+)/i.exec(hit);
148
+ const m = /^[a-z+]+:\/\/([^\s:/@"'`)\];,]+):([^\s:/@"'`)\];,]+)@([^/\s:"'`)\];,]+)/i.exec(hit);
137
149
  if (!m)
138
150
  return true;
139
151
  const [, user = '', pw = '', host = ''] = m;
140
- if (pw.toLowerCase() === user.toLowerCase() || DEFAULT_PASSWORDS.has(pw.toLowerCase()))
141
- return false;
142
152
  const local = /^(localhost|127\.0\.0\.1|0\.0\.0\.0|host\.docker\.internal|[a-z0-9_-]+)$/i.test(host);
143
153
  const strong = /[0-9]/.test(pw) && /[A-Za-z]/.test(pw) && shannonEntropy(pw) >= 3.0;
144
- return !(local && !strong);
154
+ const weak = pw.toLowerCase() === user.toLowerCase() || DEFAULT_PASSWORDS.has(pw.toLowerCase()) || !strong;
155
+ return !(local && weak);
145
156
  },
146
157
  },
147
158
  {
@@ -165,14 +176,37 @@ const GENERIC = /\b([A-Za-z0-9_-]*(?:api[_-]?key|secret|token|passwd|password|pw
165
176
  * tracking ids, CSRF nonces, push tokens. 559 of 569 generic hits in one real
166
177
  * project were `tracking_token` / `pagination_token` inside cached API
167
178
  * responses.
179
+ *
180
+ * Deliberately NOT here: "session" and "reset". `sessionSecret` (the signing
181
+ * key for express-session/cookie-session — a real, common config secret) and
182
+ * `sessionToken`/`resetToken` (bearer auth / password-reset tokens, both
183
+ * enough to take over an account) are genuine credentials, not opaque ids —
184
+ * unlike `trackingToken`/`paginationToken`, they were being excluded outright.
168
185
  */
169
- const NON_SECRET_NAME = /(pagination|tracking|page|next|prev|continuation|cursor|csrf|xsrf|cancel|request|device|push|fcm|expo|invite|share|verification|unsubscribe|reset|confirm|session)/i;
186
+ const NON_SECRET_NAME = /(pagination|tracking|page|next|prev|continuation|cursor|csrf|xsrf|cancel|request|device|push|fcm|expo|invite|share|verification|unsubscribe|confirm)/i;
170
187
  /** Base64 blobs (thumbnails, binary) and anything longer than a real token. */
171
188
  const looksLikeBlob = (v) => v.length > 200 || /^(\/9j\/|iVBOR|data:|R0lGOD|UklGR)/.test(v);
172
189
  const JWT = /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g;
173
190
  // KEY=value / key: value / Dockerfile ENV|ARG KEY=value, with a secret-looking NAME.
174
191
  const ASSIGN = /^[ \t]*(?:export[ \t]+|ENV[ \t]+|ARG[ \t]+)?([A-Za-z_][A-Za-z0-9_.-]*)[ \t]*[:=][ \t]*(.+)$/gm;
175
192
  const SECRET_NAME = /(secret|token|password|passwd|private[_-]?key|api[_-]?key|access[_-]?key|credential)/i;
193
+ /**
194
+ * Shell-style `NAME=value` anywhere in a line, in any file: `PGPASSWORD=… psql`,
195
+ * `TOKEN=… curl`, a permission rule in .claude/settings.local.json. Upper-case
196
+ * names only, so prose like "password=…" in docs does not count.
197
+ *
198
+ * The leading class is `*` (zero or more), not `+`: a bare name that IS one of
199
+ * the keywords (`SECRET=…`, `TOKEN=…`, `API_KEY=…`, no prefix at all — an
200
+ * extremely common shape in real scripts) needs zero characters before the
201
+ * keyword, and requiring at least one meant those never matched at all. There
202
+ * is deliberately no trailing lookahead: `{8,}` on a fixed character class
203
+ * already stops at the first character outside it, so a value followed by
204
+ * `;`, `)`, `,` or `]` (`export TOKEN=abc123;`, `foo(SECRET=abc123)`) still
205
+ * matches correctly — an earlier version's trailing negative lookahead
206
+ * rejected exactly those common shell/call-site endings.
207
+ */
208
+ // Values are ASCII token characters: `PASSWORD=та_же_что_и_выше` in a README is prose.
209
+ const INLINE_ENV = /\b([A-Z0-9_]*(?:PASSWORD|PASSWD|SECRET|TOKEN|API_KEY|ACCESS_KEY|PRIVATE_KEY)[A-Z0-9_]*)=([A-Za-z0-9_\-./+=:@]{8,})/g;
176
210
  /** Only .env* files are "server env by design" — a secret there is a warning
177
211
  * (env-git flags committing it). In real code/config it stays a source leak. */
178
212
  function isEnvFile(rel) {
@@ -183,15 +217,320 @@ function isConfigish(rel) {
183
217
  return /(^|\/)\.env($|\.)/.test(rel) || /\.(ya?ml|toml|ini|conf|properties|npmrc|netrc)$/.test(rel)
184
218
  || /(^|\/)(Dockerfile|\.npmrc|\.netrc)$/.test(rel) || /docker-compose\.ya?ml$/.test(rel);
185
219
  }
220
+ const dirOf = (rel) => (rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : '');
221
+ const isExampleEnv = (rel) => /(^|\/)\.env[^/]*\.(example|sample|template|dist)$/i.test(rel) || /(^|\/)(example|sample)\.env$/i.test(rel);
222
+ /**
223
+ * Values from the REAL env files, per directory. A committed .env.example
224
+ * that carries the same value as its sibling .env is not an example — it is
225
+ * the key, published. (Seen in the wild: OPENAI/ANTHROPIC keys identical
226
+ * in .env and a committed .env.example.)
227
+ */
228
+ function collectRealEnvValues(files) {
229
+ const realEnvValues = new Map();
230
+ for (const f of files) {
231
+ if (!isEnvFile(f.rel))
232
+ continue;
233
+ const set = realEnvValues.get(dirOf(f.rel)) ?? new Set();
234
+ for (const m of f.content.matchAll(ASSIGN)) {
235
+ const v = (m[2] ?? '').trim().replace(/^["']|["']$/g, '').replace(/["'].*$/, '');
236
+ if (v.length >= 8)
237
+ set.add(v);
238
+ }
239
+ realEnvValues.set(dirOf(f.rel), set);
240
+ }
241
+ return realEnvValues;
242
+ }
243
+ /**
244
+ * Severity = what the value is × how it can leak. The pattern says what it
245
+ * is; the example/docs path lowers confidence; git exposure decides how bad
246
+ * it is. Builds the per-file `push` used by every scan pass below, so that
247
+ * pipeline lives in one place instead of being reimplemented per pass.
248
+ */
249
+ function makePush({ rel, env, example, ex, realEnvValues, findings }) {
250
+ return (f, proven = false, raw) => {
251
+ let out = f;
252
+ const realValueInExample = !!raw && isExampleEnv(rel) && (realEnvValues.get(dirOf(rel))?.has(raw) ?? false);
253
+ if (realValueInExample) {
254
+ // Not an example at all: the live value copied into the example file.
255
+ out = {
256
+ ...f,
257
+ severity: ex === 'committed' ? 'critical' : 'warning',
258
+ title: `${f.title} — the REAL value from .env, in an example file${ex === 'committed' ? ' that is committed' : ''}`,
259
+ detail: `${f.detail} This value is identical to the one in the sibling .env: the example file carries the real credential${ex === 'committed' ? ', and it is committed to git' : ''}.`,
260
+ fix: 'Replace the value in the example file with a placeholder, and rotate the credential — it has been published.',
261
+ };
262
+ }
263
+ else if (example && proven) {
264
+ out = {
265
+ ...f,
266
+ severity: f.severity === 'critical' ? 'warning' : f.severity,
267
+ title: `${f.title} (in docs/example path — looks real)`,
268
+ detail: `${f.detail} The path suggests documentation or a fixture, but the value has the structure of real key material — verify it, and rotate it if it is genuine.`,
269
+ };
270
+ }
271
+ else if (example) {
272
+ out = {
273
+ ...f,
274
+ severity: 'info',
275
+ title: `${f.title} (in docs/example file)`,
276
+ detail: `${f.detail} This looks like documentation or a fixture — confirm it is not a real credential.`,
277
+ };
278
+ }
279
+ // Git exposure. A committed secret keeps its full severity. Everything
280
+ // else cannot leak through the repository right now: an ignored file is
281
+ // doing exactly what it should (advisory); an untracked file in a repo
282
+ // or a folder that is not a repo is hygiene, capped at warning.
283
+ if (!realValueInExample && out.severity !== 'info') {
284
+ // A .env that is not committed is doing its job: secrets belong there.
285
+ // Whether it WILL be committed (untracked, no .gitignore entry) is the
286
+ // env-git check's finding, not this one's — reporting it twice at
287
+ // warning is what made people stop reading.
288
+ if (env && ex !== 'committed') {
289
+ // This also covers `ex === 'ignored'`: an env file is always the
290
+ // right place for a secret whether or not it's ALSO gitignored, so
291
+ // there is nothing a separate ignored-env-file branch would add.
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
+ }
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.` };
303
+ }
304
+ else if (ex === 'no-git') {
305
+ 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.` };
306
+ }
307
+ }
308
+ findings.push(out);
309
+ };
310
+ }
311
+ /** Vendor-shaped keys (OpenAI, AWS, Stripe, a DB URL password, a PEM block, …). */
312
+ function scanVendorPatterns(content, rel, env, push) {
313
+ for (const p of PATTERNS) {
314
+ for (const m of content.matchAll(p.re)) {
315
+ const hit = m[0];
316
+ if (looksLikePlaceholder(hit))
317
+ continue; // YOUR_KEY / EXAMPLE / xxxxx / <...>
318
+ if (p.validate && !p.validate(hit))
319
+ continue;
320
+ // A secret in a server env/config file is expected — the risk is
321
+ // committing it (env-git flags that), so it is a warning, not a leak.
322
+ const severity = env && p.severity === 'critical' ? 'warning' : p.severity;
323
+ const proven = HIGH_CONFIDENCE.has(p.id) && looksLikeRealMaterial(p.id, hit, content, m.index ?? 0, m[1]);
324
+ push({
325
+ id: p.id,
326
+ severity,
327
+ title: env ? `${p.title} (in env/config file)` : p.title,
328
+ detail: env
329
+ ? `${p.title} in ${rel} (${redact(hit)}). Normal for server env — keep this file gitignored and out of client bundles.`
330
+ : `${p.title} found in source: ${redact(hit)}`,
331
+ fix: env ? 'Keep this file out of git and out of client bundles; rotate the value if it may have been committed.' : p.fix,
332
+ checker: 'secrets',
333
+ level: 0,
334
+ file: rel,
335
+ line: lineAt(content, m.index ?? 0),
336
+ evidence: redact(hit),
337
+ }, proven, hit);
338
+ }
339
+ }
340
+ }
341
+ /**
342
+ * Supabase service_role key (a JWT whose payload role is service_role). The
343
+ * decoded role proves what it is, so it is never a docs-only info.
344
+ */
345
+ function scanServiceRoleJwt(content, rel, env, push) {
346
+ for (const m of content.matchAll(JWT)) {
347
+ const payload = decodeJwtPayload(m[0]);
348
+ if (payload && payload['role'] === 'service_role') {
349
+ push({
350
+ id: 'supabase_service_role_key',
351
+ severity: env ? 'warning' : 'critical',
352
+ title: env ? 'Supabase service_role key (in env/config file)' : 'Supabase service_role key in source',
353
+ detail: env
354
+ ? `A service_role JWT is in ${rel}. Fine for server env only — never commit it or ship it to the client; keep the file gitignored.`
355
+ : 'A service_role JWT bypasses Row Level Security entirely and is in source/client code. It must never ship to the client or the repo.',
356
+ fix: env
357
+ ? 'Keep it server-side only, ensure the file is gitignored, and rotate it if it may have been committed.'
358
+ : 'Remove it, rotate the service_role key in Supabase settings, and use it only in trusted server code.',
359
+ checker: 'secrets',
360
+ level: 0,
361
+ file: rel,
362
+ line: lineAt(content, m.index ?? 0),
363
+ evidence: redact(m[0]),
364
+ }, true, m[0]);
365
+ }
366
+ }
367
+ }
368
+ /** Quoted key/secret assignments in code, filtered by placeholder + entropy. */
369
+ function scanGenericAssignments(content, rel, push) {
370
+ for (const m of content.matchAll(GENERIC)) {
371
+ const name = m[1] ?? '';
372
+ const value = m[2] ?? '';
373
+ if (NON_SECRET_NAME.test(name) || looksLikeBlob(value))
374
+ continue;
375
+ // Credentials have no whitespace ("Show password" is UI text) and carry
376
+ // digits or real length ("build-time-secret" is a label).
377
+ if (/\s/.test(value) || (!/[0-9]/.test(value) && value.length < 24))
378
+ continue;
379
+ if (looksLikePlaceholder(value) || shannonEntropy(value) < 3.2)
380
+ continue;
381
+ push({
382
+ id: 'generic_secret',
383
+ severity: 'warning',
384
+ title: 'Possible hardcoded secret',
385
+ detail: `A high-entropy value is assigned to a secret-looking name: ${redact(value)}`,
386
+ fix: 'If this is a real credential, move it to a server-side env var and rotate it. If not, rename the variable or add `// easyvibegate-ignore`.',
387
+ checker: 'secrets',
388
+ level: 0,
389
+ file: rel,
390
+ line: lineAt(content, m.index ?? 0),
391
+ evidence: redact(value),
392
+ }, false, value);
393
+ }
394
+ }
395
+ /**
396
+ * name=value / key: value assignments (env, config, Dockerfile ENV/ARG). Runs
397
+ * before scanInlineEnv so a plain, single-assignment line keeps this rule's
398
+ * more specific title/fix on the dedup tie in dedupeFindings, rather than the
399
+ * inline-assignment rule's generic one.
400
+ */
401
+ function scanConfigAssignments(content, rel, env, push) {
402
+ if (!isConfigish(rel))
403
+ return;
404
+ for (const m of content.matchAll(ASSIGN)) {
405
+ const name = m[1] ?? '';
406
+ if (!SECRET_NAME.test(name))
407
+ continue;
408
+ const value = (m[2] ?? '').trim().replace(/^["']|["']$/g, '').replace(/["'].*$/, '');
409
+ if (value.length < 8 || looksLikePlaceholder(value) || shannonEntropy(value) < 3.0)
410
+ continue;
411
+ // "build-time-secret" is a label, not a credential: real values carry digits or length.
412
+ if (!/[0-9]/.test(value) && value.length < 24)
413
+ continue;
414
+ push({
415
+ id: 'env_secret',
416
+ severity: 'warning',
417
+ title: env ? 'Secret in env file' : 'Secret in config file',
418
+ detail: `"${name}" holds a high-entropy value in ${rel}: ${redact(value)}`,
419
+ fix: env
420
+ ? 'Fine for server env — keep this file gitignored and out of the client; rotate if it may have leaked.'
421
+ : 'Move this secret out of committed config into a server-side secret store, and rotate it.',
422
+ checker: 'secrets',
423
+ level: 0,
424
+ file: rel,
425
+ line: lineAt(content, m.index ?? 0),
426
+ evidence: redact(value),
427
+ }, false, value);
428
+ }
429
+ }
430
+ /**
431
+ * Inline shell-style assignments mid-line, in every file including
432
+ * config-ish ones. scanConfigAssignments is start-of-line and one-per-line,
433
+ * so it never sees a Compose `environment:` list item (`- TOKEN=…`), a CI
434
+ * step's inline `run: TOKEN=… cmd`, or a second KEY=VALUE later on the same
435
+ * Dockerfile ENV line — all real leaks it silently missed while this ran
436
+ * only for non-config files. Also still needed for the original case: a prod
437
+ * DB password inside a permission rule in .claude/settings.local.json,
438
+ * invisible to every other name-based rule.
439
+ */
440
+ function scanInlineEnv(content, rel, push) {
441
+ for (const m of content.matchAll(INLINE_ENV)) {
442
+ const name = m[1] ?? '';
443
+ const value = m[2] ?? '';
444
+ if (looksLikePlaceholder(value) || shannonEntropy(value) < 3.0)
445
+ continue;
446
+ if (!/[0-9]/.test(value) && value.length < 24)
447
+ continue;
448
+ push({
449
+ id: 'env_secret',
450
+ severity: 'warning',
451
+ title: 'Secret in an inline assignment',
452
+ detail: `"${name}" is assigned a high-entropy value inline in ${rel}: ${redact(value)}`,
453
+ fix: 'Move the value to a gitignored .env and reference it by name; rotate it if the file was ever shared.',
454
+ checker: 'secrets',
455
+ level: 0,
456
+ file: rel,
457
+ line: lineAt(content, m.index ?? 0),
458
+ evidence: redact(value),
459
+ }, false, value);
460
+ }
461
+ }
462
+ /**
463
+ * De-duplicate only true repeats: the same secret, same place, same rule.
464
+ * (Keying on file:line alone hid every extra key on a minified line.)
465
+ */
466
+ function dedupeFindings(findings) {
467
+ const seen = new Map();
468
+ for (const f of findings) {
469
+ const key = `${f.file ?? ''}:${f.line ?? 0}:${f.id}:${f.evidence ?? ''}`;
470
+ const cur = seen.get(key);
471
+ if (!cur || SEVERITY_RANK[f.severity] < SEVERITY_RANK[cur.severity])
472
+ seen.set(key, f);
473
+ }
474
+ return [...seen.values()];
475
+ }
476
+ const NAME_BASED = new Set(['env_secret', 'generic_secret']);
186
477
  /**
187
- * Documentation, examples and test fixtures are where sample keys legitimately
188
- * live. A hit there is worth mentioning but is not a credential leak.
478
+ * A vendor pattern or a decoded JWT already identifies a VALUE on a line; the
479
+ * name-based env_secret / generic_secret finding for that SAME value is the
480
+ * same fact twice. Keyed on file:line:evidence, not just file:line — two
481
+ * different secrets assigned on one line (`const key="sk-proj-…",
482
+ * password="…"`) are two different facts, and dropping the second because
483
+ * the first has a specific pattern hid it entirely.
189
484
  */
190
- function isExampleContext(rel) {
191
- return /\.(md|txt|mdx|rst)$/i.test(rel)
192
- || /\.(example|sample|template|dist)$/i.test(rel)
193
- || /(^|\/)(docs?|examples?|fixtures?|__fixtures__|__tests__|test|tests|spec|__mocks__)(\/|$)/i.test(rel)
194
- || /\.(test|spec)\.[a-z]+$/i.test(rel);
485
+ function dropRedundantNameBased(findings) {
486
+ const specificAt = new Set(findings.filter((f) => !NAME_BASED.has(f.id)).map((f) => `${f.file ?? ''}:${f.line ?? 0}:${f.evidence ?? ''}`));
487
+ return findings.filter((f) => !(NAME_BASED.has(f.id) && specificAt.has(`${f.file ?? ''}:${f.line ?? 0}:${f.evidence ?? ''}`)));
488
+ }
489
+ /**
490
+ * Eleven advisories saying "secret in gitignored .env.local — fine" are one
491
+ * observation printed eleven times. Fold them into one line per file that
492
+ * names the variables; anything above advisory stays line by line.
493
+ *
494
+ * The final `applyIgnores` pass (scan.ts) only sees whatever this checker
495
+ * returns — once N findings become one summary Finding with a single `line`,
496
+ * a `// easyvibegate-ignore` comment placed above any secret but the first in
497
+ * the group can no longer reach it. Filter each candidate through the SAME
498
+ * inline-marker check first (an empty config so only the marker, not
499
+ * project-wide `ignore`/`ignorePaths` rules, applies here — those still run
500
+ * again, correctly, against whatever this returns), so an individually
501
+ * silenced secret is dropped before folding, not after.
502
+ */
503
+ function foldEnvAdvisories(kept, allFiles) {
504
+ const byFile = new Map();
505
+ for (const f of kept) {
506
+ if (f.id !== 'env_secret' || f.severity !== 'advisory' || !isEnvFile(f.file ?? ''))
507
+ continue;
508
+ byFile.set(f.file ?? '', [...(byFile.get(f.file ?? '') ?? []), f]);
509
+ }
510
+ const folded = new Set();
511
+ const summaries = [];
512
+ for (const [file, allCandidates] of byFile) {
513
+ const group = partitionIgnores(allCandidates, EMPTY_CONFIG, allFiles).kept;
514
+ // A member the marker silenced must not reappear individually either —
515
+ // it is done with, not merely "too few left to fold".
516
+ for (const f of allCandidates)
517
+ if (!group.includes(f))
518
+ folded.add(f);
519
+ if (group.length < 2)
520
+ continue;
521
+ for (const f of group)
522
+ folded.add(f);
523
+ const names = group.map((f) => /^"([^"]+)"/.exec(f.detail)?.[1] ?? '?');
524
+ const first = group.reduce((a, b) => ((a.line ?? 0) <= (b.line ?? 0) ? a : b));
525
+ summaries.push({
526
+ ...first,
527
+ title: `${group.length} secrets in ${file} (not committed — where they belong)`,
528
+ detail: `${file} holds ${group.length} secret-looking values (${names.join(', ')}). The file is not committed, so nothing leaks through git; keep it that way and out of client bundles.`,
529
+ fix: 'Nothing to change here. Keep the file gitignored; rotate any value that may ever have been committed or shared.',
530
+ evidence: undefined,
531
+ });
532
+ }
533
+ return [...kept.filter((f) => !folded.has(f)), ...summaries];
195
534
  }
196
535
  export const secretsChecker = {
197
536
  id: 'secrets',
@@ -200,213 +539,21 @@ export const secretsChecker = {
200
539
  run(ctx) {
201
540
  const findings = [];
202
541
  const exposure = createExposure(ctx.root);
203
- // Values from the REAL env files, per directory. A committed .env.example
204
- // that carries the same value as its sibling .env is not an example — it is
205
- // the key, published. (Seen in the wild: OPENAI/ANTHROPIC keys identical
206
- // in .env and a committed .env.example.)
207
- const realEnvValues = new Map();
208
- for (const f of ctx.files) {
209
- if (!isEnvFile(f.rel))
210
- continue;
211
- const dir = f.rel.includes('/') ? f.rel.slice(0, f.rel.lastIndexOf('/')) : '';
212
- const set = realEnvValues.get(dir) ?? new Set();
213
- for (const m of f.content.matchAll(ASSIGN)) {
214
- const v = (m[2] ?? '').trim().replace(/^["']|["']$/g, '').replace(/["'].*$/, '');
215
- if (v.length >= 8)
216
- set.add(v);
217
- }
218
- realEnvValues.set(dir, set);
219
- }
220
- const dirOf = (rel) => (rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : '');
221
- const isExampleEnv = (rel) => /(^|\/)\.env[^/]*\.(example|sample|template|dist)$/i.test(rel) || /(^|\/)(example|sample)\.env$/i.test(rel);
542
+ const realEnvValues = collectRealEnvValues(ctx.files);
222
543
  for (const file of ctx.files) {
223
544
  const { rel } = file;
224
545
  const content = file.content.replace(/^\uFEFF/, ''); // a BOM must not eat line 1
225
546
  const env = isEnvFile(rel);
226
- const example = isExampleContext(rel);
547
+ const example = looksLikeTestOrDocPath(rel);
227
548
  const ex = exposure(rel);
228
- // `proven` = the hit is structurally real key material (see
229
- // looksLikeRealMaterial). A docs/fixture path may only downgrade a
230
- // heuristic or sample-looking hit to info; proven material stays at
231
- // warning there — the path lowers confidence, it does not make it safe.
232
- const rank = { critical: 0, warning: 1, info: 2, advisory: 3 };
233
- const atMost = (sev, cap) => (rank[sev] < rank[cap] ? cap : sev);
234
- /**
235
- * Severity = what the value is × how it can leak. The pattern says what it
236
- * is; the example/docs path lowers confidence; git exposure decides how
237
- * bad it is. `raw` is the unredacted value, only compared, never stored.
238
- */
239
- const push = (f, proven = false, raw) => {
240
- let out = f;
241
- const realValueInExample = !!raw && isExampleEnv(rel) && (realEnvValues.get(dirOf(rel))?.has(raw) ?? false);
242
- if (realValueInExample) {
243
- // Not an example at all: the live value copied into the example file.
244
- out = {
245
- ...f,
246
- severity: ex === 'committed' ? 'critical' : 'warning',
247
- title: `${f.title} — the REAL value from .env, in an example file${ex === 'committed' ? ' that is committed' : ''}`,
248
- detail: `${f.detail} This value is identical to the one in the sibling .env: the example file carries the real credential${ex === 'committed' ? ', and it is committed to git' : ''}.`,
249
- fix: 'Replace the value in the example file with a placeholder, and rotate the credential — it has been published.',
250
- };
251
- }
252
- else if (example && proven) {
253
- out = {
254
- ...f,
255
- severity: f.severity === 'critical' ? 'warning' : f.severity,
256
- title: `${f.title} (in docs/example path — looks real)`,
257
- detail: `${f.detail} The path suggests documentation or a fixture, but the value has the structure of real key material — verify it, and rotate it if it is genuine.`,
258
- };
259
- }
260
- else if (example) {
261
- out = {
262
- ...f,
263
- severity: 'info',
264
- title: `${f.title} (in docs/example file)`,
265
- detail: `${f.detail} This looks like documentation or a fixture — confirm it is not a real credential.`,
266
- };
267
- }
268
- // Git exposure. A committed secret keeps its full severity. Everything
269
- // else cannot leak through the repository right now: an ignored file is
270
- // doing exactly what it should (advisory); an untracked file in a repo
271
- // or a folder that is not a repo is hygiene, capped at warning.
272
- if (!realValueInExample && out.severity !== 'info') {
273
- // A .env that is not committed is doing its job: secrets belong there.
274
- // Whether it WILL be committed (untracked, no .gitignore entry) is the
275
- // env-git check's finding, not this one's — reporting it twice at
276
- // warning is what made people stop reading.
277
- if (env && ex !== 'committed') {
278
- 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.` };
279
- }
280
- else if (ex === 'ignored') {
281
- out = { ...out, severity: 'advisory', detail: `${out.detail} This file is gitignored, so the value cannot leak through git — keep it that way and out of client bundles.` };
282
- }
283
- else if (ex === 'untracked') {
284
- 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.` };
285
- }
286
- else if (ex === 'no-git') {
287
- 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.` };
288
- }
289
- }
290
- findings.push(out);
291
- };
292
- for (const p of PATTERNS) {
293
- for (const m of content.matchAll(p.re)) {
294
- const hit = m[0];
295
- if (looksLikePlaceholder(hit))
296
- continue; // YOUR_KEY / EXAMPLE / xxxxx / <...>
297
- if (p.validate && !p.validate(hit))
298
- continue;
299
- // A secret in a server env/config file is expected — the risk is
300
- // committing it (env-git flags that), so it is a warning, not a leak.
301
- const severity = env && p.severity === 'critical' ? 'warning' : p.severity;
302
- const proven = HIGH_CONFIDENCE.has(p.id) && looksLikeRealMaterial(p.id, hit, content, m.index ?? 0, m[1]);
303
- push({
304
- id: p.id,
305
- severity,
306
- title: env ? `${p.title} (in env/config file)` : p.title,
307
- detail: env
308
- ? `${p.title} in ${rel} (${redact(hit)}). Normal for server env — keep this file gitignored and out of client bundles.`
309
- : `${p.title} found in source: ${redact(hit)}`,
310
- fix: env ? 'Keep this file out of git and out of client bundles; rotate the value if it may have been committed.' : p.fix,
311
- checker: 'secrets',
312
- level: 0,
313
- file: rel,
314
- line: lineAt(content, m.index ?? 0),
315
- evidence: redact(hit),
316
- }, proven, hit);
317
- }
318
- }
319
- // Supabase service_role key (a JWT whose payload role is service_role).
320
- // The decoded role proves what it is, so it is never a docs-only info.
321
- for (const m of content.matchAll(JWT)) {
322
- const payload = decodeJwtPayload(m[0]);
323
- if (payload && payload['role'] === 'service_role') {
324
- push({
325
- id: 'supabase_service_role_key',
326
- severity: env ? 'warning' : 'critical',
327
- title: env ? 'Supabase service_role key (in env/config file)' : 'Supabase service_role key in source',
328
- detail: env
329
- ? `A service_role JWT is in ${rel}. Fine for server env only — never commit it or ship it to the client; keep the file gitignored.`
330
- : 'A service_role JWT bypasses Row Level Security entirely and is in source/client code. It must never ship to the client or the repo.',
331
- fix: env
332
- ? 'Keep it server-side only, ensure the file is gitignored, and rotate it if it may have been committed.'
333
- : 'Remove it, rotate the service_role key in Supabase settings, and use it only in trusted server code.',
334
- checker: 'secrets',
335
- level: 0,
336
- file: rel,
337
- line: lineAt(content, m.index ?? 0),
338
- evidence: redact(m[0]),
339
- }, true, m[0]);
340
- }
341
- }
342
- // Quoted key/secret assignments in code, filtered by placeholder + entropy.
343
- for (const m of content.matchAll(GENERIC)) {
344
- const name = m[1] ?? '';
345
- const value = m[2] ?? '';
346
- if (NON_SECRET_NAME.test(name) || looksLikeBlob(value))
347
- continue;
348
- // Credentials have no whitespace ("Show password" is UI text) and carry
349
- // digits or real length ("build-time-secret" is a label).
350
- if (/\s/.test(value) || (!/[0-9]/.test(value) && value.length < 24))
351
- continue;
352
- if (looksLikePlaceholder(value) || shannonEntropy(value) < 3.2)
353
- continue;
354
- push({
355
- id: 'generic_secret',
356
- severity: 'warning',
357
- title: 'Possible hardcoded secret',
358
- detail: `A high-entropy value is assigned to a secret-looking name: ${redact(value)}`,
359
- fix: 'If this is a real credential, move it to a server-side env var and rotate it. If not, rename the variable or add `// easyvibegate-ignore`.',
360
- checker: 'secrets',
361
- level: 0,
362
- file: rel,
363
- line: lineAt(content, m.index ?? 0),
364
- evidence: redact(value),
365
- }, false, value);
366
- }
367
- // name=value / key: value assignments (env, config, Dockerfile ENV/ARG).
368
- if (isConfigish(rel)) {
369
- for (const m of content.matchAll(ASSIGN)) {
370
- const name = m[1] ?? '';
371
- if (!SECRET_NAME.test(name))
372
- continue;
373
- const value = (m[2] ?? '').trim().replace(/^["']|["']$/g, '').replace(/["'].*$/, '');
374
- if (value.length < 8 || looksLikePlaceholder(value) || shannonEntropy(value) < 3.0)
375
- continue;
376
- // "build-time-secret" is a label, not a credential: real values carry digits or length.
377
- if (!/[0-9]/.test(value) && value.length < 24)
378
- continue;
379
- push({
380
- id: 'env_secret',
381
- severity: 'warning',
382
- title: env ? 'Secret in env file' : 'Secret in config file',
383
- detail: `"${name}" holds a high-entropy value in ${rel}: ${redact(value)}`,
384
- fix: env
385
- ? 'Fine for server env — keep this file gitignored and out of the client; rotate if it may have leaked.'
386
- : 'Move this secret out of committed config into a server-side secret store, and rotate it.',
387
- checker: 'secrets',
388
- level: 0,
389
- file: rel,
390
- line: lineAt(content, m.index ?? 0),
391
- evidence: redact(value),
392
- }, false, value);
393
- }
394
- }
395
- }
396
- // De-duplicate only true repeats: the same secret, same place, same rule.
397
- // (Keying on file:line alone hid every extra key on a minified line.)
398
- const order = { critical: 0, warning: 1, info: 2, advisory: 3 };
399
- const seen = new Map();
400
- for (const f of findings) {
401
- const key = `${f.file ?? ''}:${f.line ?? 0}:${f.id}:${f.evidence ?? ''}`;
402
- const cur = seen.get(key);
403
- if (!cur || order[f.severity] < order[cur.severity])
404
- seen.set(key, f);
549
+ const push = makePush({ rel, env, example, ex, realEnvValues, findings });
550
+ scanVendorPatterns(content, rel, env, push);
551
+ scanServiceRoleJwt(content, rel, env, push);
552
+ scanGenericAssignments(content, rel, push);
553
+ scanConfigAssignments(content, rel, env, push);
554
+ scanInlineEnv(content, rel, push);
405
555
  }
406
- // A vendor pattern or a decoded JWT already identifies the value on a line;
407
- // the name-based env_secret / generic_secret there is the same fact twice.
408
- const NAME_BASED = new Set(['env_secret', 'generic_secret']);
409
- const specificAt = new Set([...seen.values()].filter((f) => !NAME_BASED.has(f.id)).map((f) => `${f.file ?? ''}:${f.line ?? 0}`));
410
- return [...seen.values()].filter((f) => !(NAME_BASED.has(f.id) && specificAt.has(`${f.file ?? ''}:${f.line ?? 0}`)));
556
+ const kept = dropRedundantNameBased(dedupeFindings(findings));
557
+ return foldEnvAdvisories(kept, ctx.files);
411
558
  },
412
559
  };