easyvibegate 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -106,6 +106,10 @@ jobs:
106
106
 
107
107
  Плюс: `postgres://app:app@localhost` в docker-compose / CI — дефолтный логин локального контейнера, не секрет; `tracking_token` и `pagination_token` из кэша API — не секреты; папки `cache/`, `tmp/` не сканируются.
108
108
 
109
+ Секрет в gitignored-файле, который **не** `.env` (например, пароль от прода в `.claude/settings.local.json`), — предупреждение: через git не утечёт, но лежит не там. Папка `easyvibegate-report/` сама пишет себе `.gitignore` — отчёт содержит префиксы секретов и карту эндпоинтов, коммитить его нельзя.
110
+
111
+ Статическая проверка RLS видит только `CREATE TABLE` из миграций в репозитории. Таблицы, созданные из дашборда или из другого репо, перечисляет только живая проверка — если отказаться от неё, в отчёте будет часть картины, и он об этом скажет.
112
+
109
113
  Оценка — это отметка, а не счётчик: после пяти предупреждений балл перестаёт падать (число всё равно показано), критичные могут обнулить его по-прежнему.
110
114
 
111
115
  ## Оценка и статусы
package/dist/cli/index.js CHANGED
@@ -262,6 +262,17 @@ 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
+ // The report directory gets its OWN `.gitignore` (see prepareOutputDir) and
266
+ // has its stale report files deleted on every run. Both are safe for a
267
+ // directory that exists only to hold reports — neither is safe for the
268
+ // scanned project itself: `--output .` from the project root would replace
269
+ // the project's real .gitignore with a bare `*`, silently un-tracking the
270
+ // whole repo.
271
+ if (args.output !== undefined && outDir === root) {
272
+ process.stderr.write(`easyvibegate: --output must not be the scanned project itself (${root}) — pick a subdirectory, e.g. --output ${join(root, 'easyvibegate-report')}
273
+ `);
274
+ process.exit(2);
275
+ }
265
276
  // Use the friendly wizard when a human runs it in a terminal without
266
277
  // automation flags; --wizard forces it. Either way the pipeline below is shared.
267
278
  // --ci is a non-interactive contract: never ask questions there.
@@ -368,6 +379,12 @@ function prepareOutputDir(dir) {
368
379
  if (existsSync(dir) && !statSync(dir).isDirectory())
369
380
  return 'exists and is not a directory';
370
381
  mkdirSync(dir, { recursive: true });
382
+ // The report names secret prefixes, database hosts and every endpoint —
383
+ // exactly what must not be committed. A `.gitignore` containing `*` inside
384
+ // the directory makes git ignore it wherever the project's own .gitignore
385
+ // stands (the trick node_modules-style caches use); the user's files are
386
+ // never edited. Rewritten every run so a stray edit cannot un-ignore it.
387
+ writeFileSync(join(dir, '.gitignore'), '# Written by EasyVibeGate: this report can contain secret prefixes and hosts. Never commit it.\n*\n', 'utf8');
371
388
  // Old report.md next to a fresh report.json told two different stories.
372
389
  for (const name of ['report.md', 'report.json', 'ai-fix-prompt.md']) {
373
390
  const p = join(dir, name);
@@ -1,5 +1,8 @@
1
1
  import { classifyBody, request, sleep } from '../../net/http.js';
2
- import { looksLikePlaceholder } from '../../util/text.js';
2
+ import { DNS_LABEL, looksLikePlaceholder } from '../../util/text.js';
3
+ const FIREBASEIO_URL = new RegExp(`https://${DNS_LABEL}(?:-default-rtdb)?\\.firebaseio\\.com`);
4
+ const FIREBASEAPP_DOMAIN = new RegExp(`(${DNS_LABEL})\\.firebaseapp\\.com`);
5
+ const FIREBASEDATABASE_APP = new RegExp(`^${DNS_LABEL}\\.firebasedatabase\\.app$`);
3
6
  const COMMON_COLLECTIONS = [
4
7
  'users', 'user', 'profiles', 'accounts', 'messages', 'chats', 'posts',
5
8
  'orders', 'payments', 'products', 'items', 'settings', 'admin', 'config',
@@ -11,13 +14,16 @@ export function discoverFirebase(all) {
11
14
  let projectId;
12
15
  let databaseURL;
13
16
  let storageBucket;
17
+ // This loop runs against every project file's raw content, including large
18
+ // ones — see `DNS_LABEL` in util/text.ts for why the two host regexes below
19
+ // are bounded instead of `[a-z0-9-]+`.
14
20
  for (const f of files) {
15
21
  projectId ??= f.content.match(/projectId\s*:\s*["']([^"']+)["']/)?.[1];
16
22
  databaseURL ??= f.content.match(/databaseURL\s*:\s*["']([^"']+)["']/)?.[1]
17
- ?? f.content.match(/https:\/\/[a-z0-9-]+(?:-default-rtdb)?\.firebaseio\.com/)?.[0];
23
+ ?? f.content.match(FIREBASEIO_URL)?.[0];
18
24
  storageBucket ??= f.content.match(/storageBucket\s*:\s*["']([^"']+)["']/)?.[1];
19
25
  if (!projectId) {
20
- const dom = f.content.match(/([a-z0-9-]+)\.firebaseapp\.com/)?.[1];
26
+ const dom = f.content.match(FIREBASEAPP_DOMAIN)?.[1];
21
27
  if (dom)
22
28
  projectId = dom;
23
29
  }
@@ -37,7 +43,11 @@ function isOwnRtdbHost(host, projectId) {
37
43
  const regional = `${p}-default-rtdb.`; // <project>-default-rtdb.<region>.firebasedatabase.app
38
44
  return host === `${p}.firebaseio.com`
39
45
  || host === `${p}-default-rtdb.firebaseio.com`
40
- || (host.startsWith(regional) && /^[a-z0-9-]+\.firebasedatabase\.app$/.test(host.slice(regional.length)))
46
+ // `host` here is already `new URL(...).hostname` (bounded by the caller,
47
+ // ownDatabaseURL below), not raw file content — but it's bounded via the
48
+ // shared constant anyway rather than `+`, so this stays safe even if that
49
+ // ever changes and nothing here looks unlike its two siblings above.
50
+ || (host.startsWith(regional) && FIREBASEDATABASE_APP.test(host.slice(regional.length)))
41
51
  || host === `${p}.firebaseapp.com`;
42
52
  }
43
53
  /**
@@ -21,7 +21,11 @@ export function classifyKey(key) {
21
21
  return 'jwt-service';
22
22
  return 'unknown';
23
23
  }
24
- const BARE_URL = /https:\/\/[a-z0-9]{16,}\.supabase\.co/;
24
+ // Bounded for the same reason as the shared `DNS_LABEL` in util/text.ts (a
25
+ // quadratic-time regex on raw file content) — but a Supabase project ref is
26
+ // its own format (no hyphens, {16,63} not {1,63}), so this stays a literal
27
+ // rather than reusing that constant.
28
+ const BARE_URL = /https:\/\/[a-z0-9]{16,63}\.supabase\.co/;
25
29
  const BARE_PUBLISHABLE = /\bsb_publishable_[A-Za-z0-9_-]{10,}\b/;
26
30
  const BARE_JWT = /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g;
27
31
  function isPublicKey(key) {
@@ -592,27 +592,53 @@ export const rlsMigrationsChecker = {
592
592
  level: 0,
593
593
  run(ctx) {
594
594
  const sqlFiles = ctx.files.filter((f) => f.ext === '.sql').sort((a, b) => a.rel.localeCompare(b.rel));
595
- if (sqlFiles.length === 0)
596
- return [];
595
+ if (sqlFiles.length === 0) {
596
+ // A project that clearly talks to Supabase but ships no .sql migrations
597
+ // (schema managed from the dashboard, or migrations live in another repo)
598
+ // used to just say nothing — a silent, undeserved clean score on the exact
599
+ // check the tool exists for. Say what could not be checked instead.
600
+ const looksSupabase = ctx.detection.backends.includes('supabase') ||
601
+ ctx.files.some((f) => !/\.(md|mdx|txt|rst)$/i.test(f.rel) &&
602
+ /(SUPABASE_(?:ANON|PUBLISHABLE)_KEY|sb_publishable_|\/rest\/v1\b|postgrest)/i.test(f.content));
603
+ if (!looksSupabase)
604
+ return [];
605
+ return [{
606
+ id: 'rls_unverifiable_no_migrations',
607
+ severity: 'info',
608
+ title: 'No SQL migrations found — RLS could not be checked statically',
609
+ detail: 'This project talks to Supabase, but no .sql migration files exist in this repository (the schema may be managed from the dashboard, or migrations live in a different repo). A static scan can only see table/RLS state from files it can read, so no table here could be verified this way — this is missing information, not a clean result.',
610
+ fix: 'Run the live probe (consent + --i-own-this) to enumerate which tables the anon key can read, or check pg_class.relrowsecurity and pg_policies directly in the Supabase SQL editor.',
611
+ checker: 'rls-migrations',
612
+ level: 0,
613
+ }];
614
+ }
597
615
  // RLS is a PostgreSQL feature. The engine is decided per FILE, not per
598
616
  // project: a monorepo can hold a Postgres schema next to a Cloudflare D1
599
617
  // (SQLite) one, and a SQLite schema must never be told to enable RLS — that
600
618
  // engine has no such thing. A real D1 project once got five "critical" RLS
601
619
  // findings and a 0/100 because the old gate only knew MySQL's
602
620
  // `auto_increment` (with the underscore) and never heard of D1/wrangler.
603
- const pkg = ctx.files.find((f) => f.rel === 'package.json')?.content ?? '';
604
- const reqs = ctx.files.find((f) => f.rel === 'requirements.txt')?.content ?? '';
605
- const codeAndEnv = ctx.files
606
- .filter((f) => !/\.(md|mdx|txt|rst|sql)$/i.test(f.rel))
607
- .map((f) => f.content)
608
- .join('\n');
621
+ // A monorepo's dependencies often live in apps/*/package.json, not the
622
+ // workspace root. Reading only the root file missed a D1/SQLite worker's
623
+ // `wrangler`/`better-sqlite3` signal whenever the SQL itself carried no
624
+ // dialect marker either, and defaulted it to Postgres — a phantom critical
625
+ // on a database that has no RLS to enable.
626
+ const pkg = ctx.files.filter((f) => f.rel.endsWith('package.json')).map((f) => f.content).join('\n');
627
+ const reqs = ctx.files.filter((f) => f.rel === 'requirements.txt' || f.rel.endsWith('/requirements.txt')).map((f) => f.content).join('\n');
628
+ // Checked with .some() against the files themselves, not a pre-joined
629
+ // string: joining nearly the whole project's non-doc/non-SQL content into
630
+ // one string on every scan doubled peak memory for the full scanned corpus
631
+ // to answer what is, per project, a handful of existence checks that can
632
+ // each stop at the first matching file.
633
+ const codeFiles = ctx.files.filter((f) => !/\.(md|mdx|txt|rst|sql)$/i.test(f.rel));
634
+ const matchesAnyCodeFile = (re) => codeFiles.some((f) => re.test(f.content));
609
635
  const pgProject = ctx.detection.backends.includes('supabase') ||
610
636
  /\b(pg|postgres|postgresql|@supabase\/|postgres\.js|node-postgres|pg-promise)\b/i.test(pkg) ||
611
637
  /\b(psycopg|asyncpg|sqlalchemy\+postgres)\b/i.test(reqs);
612
638
  const hasD1 = ctx.files.some((f) => /(^|\/)wrangler\.(toml|jsonc?)$/.test(f.rel) && /d1_databases/.test(f.content));
613
639
  const sqliteProject = hasD1 ||
614
640
  /"(better-sqlite3|sqlite3|@libsql\/client)"/i.test(pkg) ||
615
- /from\s+['"](bun:sqlite|drizzle-orm\/(d1|better-sqlite3|libsql))['"]/.test(codeAndEnv);
641
+ matchesAnyCodeFile(/from\s+['"](bun:sqlite|drizzle-orm\/(d1|better-sqlite3|libsql))['"]/);
616
642
  const mysqlProject = /"(mysql2?|mariadb)"/i.test(pkg);
617
643
  const sqliteLabel = hasD1 ? 'Cloudflare D1 (SQLite)' : 'SQLite';
618
644
  const PG_MARK = /(row\s+level\s+security|gen_random_uuid|\b(?:big)?serial\b|::\s*\w+|\bjsonb\b|\bplpgsql\b|create\s+policy|uuid_generate)/i;
@@ -664,7 +690,7 @@ export const rlsMigrationsChecker = {
664
690
  // the tool's audience is mostly Supabase, and unknown must not read as safe.
665
691
  const exposed = ctx.detection.backends.includes('supabase') ||
666
692
  /@supabase\//i.test(pkg) ||
667
- /(\/rest\/v1\b|postgrest|hasura|SUPABASE_(?:ANON|PUBLISHABLE)_KEY|sb_publishable_)/i.test(codeAndEnv);
693
+ matchesAnyCodeFile(/(\/rest\/v1\b|postgrest|hasura|SUPABASE_(?:ANON|PUBLISHABLE)_KEY|sb_publishable_)/i);
668
694
  const serverOnly = !exposed &&
669
695
  (/"(pg|postgres|pg-promise|@prisma\/client|drizzle-orm|knex|kysely|typeorm|sequelize)"/i.test(pkg) ||
670
696
  /\b(psycopg2?|asyncpg|sqlalchemy)\b/i.test(reqs));
@@ -787,12 +813,12 @@ export const rlsMigrationsChecker = {
787
813
  : `Table "${s.display}" has an unconfirmed RLS state (conditional block)`,
788
814
  detail: kind === 'missing'
789
815
  ? (missingSeverity === 'critical'
790
- ? `"${s.display}" is created in a migration and its latest state does not enable Row Level Security. If this table holds user data on Supabase, the anon key can read every row.`
816
+ ? `"${s.display}" is created in a migration and its latest state does not enable Row Level Security. On Supabase the anon role has full table privileges by default, so with RLS off the public key can READ, INSERT, UPDATE and DELETE every row through the REST API. Static view only: this covers tables created by migrations in this repository — tables created from the dashboard or elsewhere are not listed here; the live probe enumerates all of them.`
791
817
  : `"${s.display}" is created without Row Level Security. Only server code (pg/Prisma/…) appears to talk to this database, so nothing hands its rows to clients directly — that is normal, not a hole. It becomes CRITICAL the moment a client-facing data API (Supabase/PostgREST anon key, Hasura) is put in front of the same database.`)
792
818
  : kind === 'order'
793
819
  ? `"${s.display}" has statements in directories other than "${s.stateFile}" that contradict its final RLS state. Files in separate directories have no reliable apply order, so this cannot be decided statically — check the deployed state.`
794
820
  : `"${s.display}" has a table or RLS statement whose execution cannot be confirmed statically: inside an IF/LOOP branch or a block with an EXCEPTION handler, after a RETURN, in a rolled-back or unterminated transaction, or on a name a TEMP table may shadow. Whether it took effect cannot be decided without running the migration, so its RLS state is NOT confirmed — check the deployed state.`,
795
- fix: `ALTER TABLE ${s.display} ENABLE ROW LEVEL SECURITY; then add an owner/tenant policy, and drop any permissive "USING (true)" policy (policies are OR-ed). This is a static hint — confirm the deployed state.`,
821
+ fix: `ALTER TABLE ${s.display} ENABLE ROW LEVEL SECURITY; then add an owner/tenant policy, and drop any permissive "USING (true)" policy (policies are OR-ed). Until policies exist, stop the bleeding without breaking reads: REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON ${s.display} FROM anon; This is a static hint — confirm the deployed state.`,
796
822
  checker: 'rls-migrations',
797
823
  level: 0,
798
824
  file: s.file,
@@ -1,5 +1,11 @@
1
1
  import { decodeJwtPayload, lineAt, looksLikePlaceholder, 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, '');
@@ -173,6 +179,23 @@ const JWT = /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g
173
179
  // KEY=value / key: value / Dockerfile ENV|ARG KEY=value, with a secret-looking NAME.
174
180
  const ASSIGN = /^[ \t]*(?:export[ \t]+|ENV[ \t]+|ARG[ \t]+)?([A-Za-z_][A-Za-z0-9_.-]*)[ \t]*[:=][ \t]*(.+)$/gm;
175
181
  const SECRET_NAME = /(secret|token|password|passwd|private[_-]?key|api[_-]?key|access[_-]?key|credential)/i;
182
+ /**
183
+ * Shell-style `NAME=value` anywhere in a line, in any file: `PGPASSWORD=… psql`,
184
+ * `TOKEN=… curl`, a permission rule in .claude/settings.local.json. Upper-case
185
+ * names only, so prose like "password=…" in docs does not count.
186
+ *
187
+ * The leading class is `*` (zero or more), not `+`: a bare name that IS one of
188
+ * the keywords (`SECRET=…`, `TOKEN=…`, `API_KEY=…`, no prefix at all — an
189
+ * extremely common shape in real scripts) needs zero characters before the
190
+ * keyword, and requiring at least one meant those never matched at all. There
191
+ * is deliberately no trailing lookahead: `{8,}` on a fixed character class
192
+ * already stops at the first character outside it, so a value followed by
193
+ * `;`, `)`, `,` or `]` (`export TOKEN=abc123;`, `foo(SECRET=abc123)`) still
194
+ * matches correctly — an earlier version's trailing negative lookahead
195
+ * rejected exactly those common shell/call-site endings.
196
+ */
197
+ // Values are ASCII token characters: `PASSWORD=та_же_что_и_выше` in a README is prose.
198
+ 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
199
  /** Only .env* files are "server env by design" — a secret there is a warning
177
200
  * (env-git flags committing it). In real code/config it stays a source leak. */
178
201
  function isEnvFile(rel) {
@@ -229,8 +252,6 @@ export const secretsChecker = {
229
252
  // looksLikeRealMaterial). A docs/fixture path may only downgrade a
230
253
  // heuristic or sample-looking hit to info; proven material stays at
231
254
  // 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
255
  /**
235
256
  * Severity = what the value is × how it can leak. The pattern says what it
236
257
  * is; the example/docs path lowers confidence; git exposure decides how
@@ -275,10 +296,17 @@ export const secretsChecker = {
275
296
  // env-git check's finding, not this one's — reporting it twice at
276
297
  // warning is what made people stop reading.
277
298
  if (env && ex !== 'committed') {
299
+ // This also covers `ex === 'ignored'`: an env file is always the
300
+ // right place for a secret whether or not it's ALSO gitignored, so
301
+ // there is nothing a separate ignored-env-file branch would add.
278
302
  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
303
  }
280
304
  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.` };
305
+ // Gitignored, but not an env file: a credential pasted into a tool
306
+ // config or a script (a prod DB password inside a permission rule in
307
+ // .claude/settings.local.json was reported as a mere advisory). It
308
+ // cannot leak through git today; it still does not belong there.
309
+ 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.` };
282
310
  }
283
311
  else if (ex === 'untracked') {
284
312
  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.` };
@@ -364,6 +392,31 @@ export const secretsChecker = {
364
392
  evidence: redact(value),
365
393
  }, false, value);
366
394
  }
395
+ // Inline shell-style assignments mid-line, outside env/config files (those
396
+ // go through ASSIGN below). A prod DB password inside a permission rule in
397
+ // .claude/settings.local.json was invisible to both other name-based rules.
398
+ if (!isConfigish(rel)) {
399
+ for (const m of content.matchAll(INLINE_ENV)) {
400
+ const name = m[1] ?? '';
401
+ const value = m[2] ?? '';
402
+ if (looksLikePlaceholder(value) || shannonEntropy(value) < 3.0)
403
+ continue;
404
+ if (!/[0-9]/.test(value) && value.length < 24)
405
+ continue;
406
+ push({
407
+ id: 'env_secret',
408
+ severity: 'warning',
409
+ title: 'Secret in an inline assignment',
410
+ detail: `"${name}" is assigned a high-entropy value inline in ${rel}: ${redact(value)}`,
411
+ fix: 'Move the value to a gitignored .env and reference it by name; rotate it if the file was ever shared.',
412
+ checker: 'secrets',
413
+ level: 0,
414
+ file: rel,
415
+ line: lineAt(content, m.index ?? 0),
416
+ evidence: redact(value),
417
+ }, false, value);
418
+ }
419
+ }
367
420
  // name=value / key: value assignments (env, config, Dockerfile ENV/ARG).
368
421
  if (isConfigish(rel)) {
369
422
  for (const m of content.matchAll(ASSIGN)) {
@@ -395,18 +448,59 @@ export const secretsChecker = {
395
448
  }
396
449
  // De-duplicate only true repeats: the same secret, same place, same rule.
397
450
  // (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
451
  const seen = new Map();
400
452
  for (const f of findings) {
401
453
  const key = `${f.file ?? ''}:${f.line ?? 0}:${f.id}:${f.evidence ?? ''}`;
402
454
  const cur = seen.get(key);
403
- if (!cur || order[f.severity] < order[cur.severity])
455
+ if (!cur || SEVERITY_RANK[f.severity] < SEVERITY_RANK[cur.severity])
404
456
  seen.set(key, f);
405
457
  }
406
458
  // A vendor pattern or a decoded JWT already identifies the value on a line;
407
459
  // the name-based env_secret / generic_secret there is the same fact twice.
408
460
  const NAME_BASED = new Set(['env_secret', 'generic_secret']);
409
461
  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}`)));
462
+ const kept = [...seen.values()].filter((f) => !(NAME_BASED.has(f.id) && specificAt.has(`${f.file ?? ''}:${f.line ?? 0}`)));
463
+ // Eleven advisories saying "secret in gitignored .env.local — fine" are one
464
+ // observation printed eleven times. Fold them into one line per file that
465
+ // names the variables; anything above advisory stays line by line.
466
+ //
467
+ // The final `applyIgnores` pass (scan.ts) only sees whatever this checker
468
+ // returns — once N findings become one summary Finding with a single
469
+ // `line`, a `// easyvibegate-ignore` comment placed above any secret but
470
+ // the first in the group can no longer reach it. Filter each candidate
471
+ // through the SAME inline-marker check first (an empty config so only the
472
+ // marker, not project-wide `ignore`/`ignorePaths` rules, applies here —
473
+ // those still run again, correctly, against whatever this returns), so an
474
+ // individually silenced secret is dropped before folding, not after.
475
+ const byFile = new Map();
476
+ for (const f of kept) {
477
+ if (f.id !== 'env_secret' || f.severity !== 'advisory' || !isEnvFile(f.file ?? ''))
478
+ continue;
479
+ byFile.set(f.file ?? '', [...(byFile.get(f.file ?? '') ?? []), f]);
480
+ }
481
+ const folded = new Set();
482
+ const summaries = [];
483
+ for (const [file, allCandidates] of byFile) {
484
+ const group = partitionIgnores(allCandidates, EMPTY_CONFIG, ctx.files).kept;
485
+ // A member the marker silenced must not reappear individually either —
486
+ // it is done with, not merely "too few left to fold".
487
+ for (const f of allCandidates)
488
+ if (!group.includes(f))
489
+ folded.add(f);
490
+ if (group.length < 2)
491
+ continue;
492
+ for (const f of group)
493
+ folded.add(f);
494
+ const names = group.map((f) => /^"([^"]+)"/.exec(f.detail)?.[1] ?? '?');
495
+ const first = group.reduce((a, b) => ((a.line ?? 0) <= (b.line ?? 0) ? a : b));
496
+ summaries.push({
497
+ ...first,
498
+ title: `${group.length} secrets in ${file} (not committed — where they belong)`,
499
+ 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.`,
500
+ fix: 'Nothing to change here. Keep the file gitignored; rotate any value that may ever have been committed or shared.',
501
+ evidence: undefined,
502
+ });
503
+ }
504
+ return [...kept.filter((f) => !folded.has(f)), ...summaries];
411
505
  },
412
506
  };
@@ -1,5 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import { DNS_LABEL } from './util/text.js';
4
+ const SUPABASE_URL_FALLBACK = new RegExp(`https?://${DNS_LABEL}\\.supabase\\.co`, 'i');
3
5
  /** Infer the project's frameworks, backends, languages and package managers. */
4
6
  export function detect(root, files) {
5
7
  const relSet = new Set(files.map((f) => f.rel));
@@ -99,8 +101,10 @@ export function detect(root, files) {
99
101
  frameworks.add('rails');
100
102
  }
101
103
  // Content fallback for projects without a package.json — kept strict to avoid
102
- // false positives from code that merely mentions a backend by name.
103
- if (!backends.has('supabase') && files.some((f) => /https?:\/\/[a-z0-9-]+\.supabase\.co/i.test(f.content))) {
104
+ // false positives from code that merely mentions a backend by name. Uses the
105
+ // shared bounded DNS_LABEL (see util/text.ts) so this cannot go quadratic on
106
+ // a long run of matching characters in a large file.
107
+ if (!backends.has('supabase') && files.some((f) => SUPABASE_URL_FALLBACK.test(f.content))) {
104
108
  backends.add('supabase');
105
109
  }
106
110
  if (!backends.has('firebase') && files.some((f) => /\binitializeApp\s*\(/.test(f.content) && /firebase/i.test(f.content))) {
@@ -48,3 +48,15 @@ export function decodeJwtPayload(token) {
48
48
  return null;
49
49
  }
50
50
  }
51
+ /**
52
+ * A DNS label, bounded to its real maximum (RFC 1035, 63 octets) — never `+`.
53
+ * Used right before a literal host suffix (`.firebaseapp.com`, `.supabase.co`)
54
+ * in a regex run against raw, unbounded file content. An open `[a-z0-9-]+` in
55
+ * that position is a classic quadratic-time regex: at every position inside a
56
+ * long run of matching characters (a minified bundle, a base64 blob, a lockfile
57
+ * hash) the engine greedily consumes to the end and backtracks one character at
58
+ * a time looking for a literal that never comes. Measured: a 500KB matching run
59
+ * took over two minutes unbounded; bounded, the same file scans in single-digit
60
+ * milliseconds — and no real hostname label is longer than this anyway.
61
+ */
62
+ export const DNS_LABEL = '[a-z0-9-]{1,63}';
@@ -8,11 +8,25 @@ const SKIP_DIRS = new Set([
8
8
  // folder names above misses a venv called anything else (tools/ytenv/...),
9
9
  // and then every key inside a vendored library is reported as the user's leak.
10
10
  'site-packages', '__pypackages__', 'bower_components', 'Pods',
11
- // Cached API responses and scratch output are data, not the project's code.
12
- // 559 of 569 "generic secrets" in one real project were pagination and
13
- // tracking tokens inside data/cache/*.json.
14
- 'cache', 'caches', 'tmp', 'temp', '.tmp',
11
+ // Yarn Berry vendors its own release script and zips dependencies here —
12
+ // tool-managed, not the user's code (and often full of high-entropy blobs
13
+ // that would otherwise read as secrets).
14
+ '.yarn',
15
15
  ]);
16
+ /**
17
+ * Names ambiguous enough that they are sometimes real source (a module named
18
+ * "cache", a package called "tmp") and sometimes pure data. Skipped only when
19
+ * `relDir` (the ambiguous directory's own path, relative to the project root,
20
+ * e.g. "cache" or "data/cache") has at most 2 path segments — i.e. the
21
+ * directory IS the root's own child, or is nested exactly one level below it.
22
+ * The real-world evidence for this was `data/cache/*.json` full of API
23
+ * pagination tokens, 559 of 569 "generic secrets" in one real project. Deeper
24
+ * nesting (`apps/api/src/lib/cache/`, 4 segments) is ordinary source and stays
25
+ * scanned; blanket name-matching at any depth once made a source directory
26
+ * invisible to every check with no visible coverage gap.
27
+ */
28
+ const AMBIGUOUS_DATA_DIRS = new Set(['cache', 'caches', 'tmp', 'temp', '.tmp']);
29
+ const isShallowDataDir = (name, relDir) => AMBIGUOUS_DATA_DIRS.has(name) && relDir.split('/').length <= 2;
16
30
  const TEXT_EXT = new Set([
17
31
  '.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.vue', '.svelte',
18
32
  '.py', '.rb', '.php', '.go', '.rs', '.java', '.kt', '.cs',
@@ -63,7 +77,8 @@ export function walk(root, opts = {}) {
63
77
  // link to a directory or a scannable file is unchecked content, and
64
78
  // silently dropping it let a project whose `migrations` was a symlink
65
79
  // PASS with full coverage. Count it so the walk is reported partial.
66
- if (SKIP_DIRS.has(ent.name))
80
+ const relLink = relative(root, full).split(sep).join('/');
81
+ if (SKIP_DIRS.has(ent.name) || isShallowDataDir(ent.name, relLink))
67
82
  continue;
68
83
  try {
69
84
  const target = statSync(full); // follows the link
@@ -76,7 +91,8 @@ export function walk(root, opts = {}) {
76
91
  continue;
77
92
  }
78
93
  if (ent.isDirectory()) {
79
- if (!SKIP_DIRS.has(ent.name) && !excluded.has(resolve(full)))
94
+ const relDir = relative(root, full).split(sep).join('/');
95
+ if (!SKIP_DIRS.has(ent.name) && !isShallowDataDir(ent.name, relDir) && !excluded.has(resolve(full)))
80
96
  stack.push(full);
81
97
  continue;
82
98
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "easyvibegate",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
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": {