easyvibegate 0.6.2 → 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.
package/dist/cli/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
3
  import { join, resolve } from 'node:path';
4
4
  import { createInterface } from 'node:readline';
5
5
  import { planTargets, runFlow } from '../orchestrator/flow.js';
@@ -262,6 +262,12 @@ async function main() {
262
262
  // Reports land next to the scanned project by default, so scanning several
263
263
  // projects from one shell never overwrites another project's report.
264
264
  const outDir = args.output !== undefined ? resolve(args.output) : join(root, 'easyvibegate-report');
265
+ // Excluding outDir from the scan only makes sense when something is
266
+ // actually going to be written there. With --no-report (format 'none'),
267
+ // nothing is ever written to it — so a `--no-report --output <project>/src`
268
+ // run must not exclude real source just because it shares a path with an
269
+ // output directory nothing will use.
270
+ const excludeOutDir = args.format !== 'none' ? [outDir] : [];
265
271
  // The report directory gets its OWN `.gitignore` (see prepareOutputDir) and
266
272
  // has its stale report files deleted on every run. Both are safe for a
267
273
  // directory that exists only to hold reports — neither is safe for the
@@ -292,7 +298,7 @@ async function main() {
292
298
  let result;
293
299
  if (useWizard) {
294
300
  result = await runWizard({
295
- excludeAbs: [outDir],
301
+ excludeAbs: excludeOutDir,
296
302
  path: args.path,
297
303
  config: args.config,
298
304
  lang,
@@ -320,7 +326,7 @@ async function main() {
320
326
  };
321
327
  // Same contract as the wizard: plan the concrete targets once, ask about
322
328
  // exactly those, execute exactly those.
323
- const staticResult = await scanStatic(root, { configPath: args.config, excludeAbs: [outDir] });
329
+ const staticResult = await scanStatic(root, { configPath: args.config, excludeAbs: excludeOutDir });
324
330
  const plan = planTargets(staticResult.files, loadConfig(root, args.config), {
325
331
  appUrl: args.appUrl,
326
332
  supabaseUrl: args.supabaseUrl,
@@ -373,18 +379,33 @@ async function main() {
373
379
  // Same contract in every mode: 2 critical, 1 warning, 3 incomplete, 0 clean.
374
380
  process.exit(exitCodeFor(summary));
375
381
  }
382
+ // Written verbatim (never partially, never appended-to) so an exact match on
383
+ // disk is proof WE created this directory, not the user re-pointing --output
384
+ // at a folder of their own. Checking "does outDir === project root" only was
385
+ // not enough: `--output docs` on a real docs/ folder replaced its real
386
+ // .gitignore (losing rules like `drafts/`) and deleted a real docs/report.md.
387
+ const OUTPUT_DIR_MARKER = '# Written by EasyVibeGate: this report can contain secret prefixes and hosts. Never commit it.\n*\n';
376
388
  /** Create the report dir and clear our own stale files, or explain why we cannot. */
377
389
  function prepareOutputDir(dir) {
378
390
  try {
379
- if (existsSync(dir) && !statSync(dir).isDirectory())
391
+ const existed = existsSync(dir);
392
+ if (existed && !statSync(dir).isDirectory())
380
393
  return 'exists and is not a directory';
394
+ if (existed) {
395
+ const ownsIt = existsSync(join(dir, '.gitignore')) && readFileSync(join(dir, '.gitignore'), 'utf8') === OUTPUT_DIR_MARKER;
396
+ if (!ownsIt && readdirSync(dir).length > 0) {
397
+ return 'already exists, is not empty, and was not created by a previous EasyVibeGate run — refusing to overwrite its .gitignore or delete files in it; point --output at an empty or dedicated directory';
398
+ }
399
+ }
381
400
  mkdirSync(dir, { recursive: true });
382
401
  // The report names secret prefixes, database hosts and every endpoint —
383
402
  // exactly what must not be committed. A `.gitignore` containing `*` inside
384
403
  // the directory makes git ignore it wherever the project's own .gitignore
385
404
  // stands (the trick node_modules-style caches use); the user's files are
386
- // never edited. Rewritten every run so a stray edit cannot un-ignore it.
387
- writeFileSync(join(dir, '.gitignore'), '# Written by EasyVibeGate: this report can contain secret prefixes and hosts. Never commit it.\n*\n', 'utf8');
405
+ // never edited. Rewritten every run so a stray edit cannot un-ignore it —
406
+ // safe now because we only ever reach this line for a directory that was
407
+ // either empty or already marked as ours.
408
+ writeFileSync(join(dir, '.gitignore'), OUTPUT_DIR_MARKER, 'utf8');
388
409
  // Old report.md next to a fresh report.json told two different stories.
389
410
  for (const name of ['report.md', 'report.json', 'ai-fix-prompt.md']) {
390
411
  const p = join(dir, name);
@@ -1,10 +1,20 @@
1
1
  import { lineAt, looksLikePlaceholder, redact } from '../../util/text.js';
2
+ import { maskCode } from '../../util/mask.js';
2
3
  import { classifyKey } from '../backend/supabase.js';
3
4
  // Public env prefixes are inlined into the browser bundle by the bundler.
4
5
  const PUBLIC_PREFIX = '(?:NEXT_PUBLIC_|VITE_|REACT_APP_|EXPO_PUBLIC_|GATSBY_|PUBLIC_)';
5
6
  // Every public-prefixed assignment; what it carries is decided by the VALUE
6
- // first and the NAME second (below).
7
- const PUBLIC_ASSIGN = new RegExp(`\\b(${PUBLIC_PREFIX}[A-Z0-9_]*)\\s*[:=]\\s*["']?([^"'\\s]{6,})`, 'gi');
7
+ // first and the NAME second (below). The name gets an optional closing quote
8
+ // before the `:`/`=` — a JSON/object literal's `"NEXT_PUBLIC_KEY": "value"`
9
+ // otherwise never matched at all, since nothing after the bare name expected
10
+ // that stray `"`. The gap between `:`/`=` and the value is same-line
11
+ // whitespace only (`[ \t]*`, not `\s*`) — an EMPTY-valued var followed by
12
+ // blank/commented lines otherwise let the value group skip straight past
13
+ // them onto the NEXT var's name, reporting that name as a leaked secret
14
+ // value. Once comments started getting masked to blank space (below), a
15
+ // `.env.example`'s usual "KEY=\n\n# comment\nNEXT_KEY=" shape hit this on
16
+ // real projects.
17
+ const PUBLIC_ASSIGN = new RegExp(`\\b(${PUBLIC_PREFIX}[A-Z0-9_]*)["']?\\s*[:=][ \\t]*["']?([^"'\\s]{6,})`, 'gi');
8
18
  // A public var whose NAME implies a real secret (not an anon/publishable key).
9
19
  const SECRET_NAME = /(SERVICE_ROLE|SECRET|PRIVATE|PASSWORD|PASSWD|TOKEN|CREDENTIAL|API_KEY|ACCESS_KEY)/i;
10
20
  // Value shapes that are server secrets no matter what the variable is called.
@@ -40,7 +50,10 @@ export const clientExposureChecker = {
40
50
  const advised = new Set();
41
51
  for (const file of ctx.files) {
42
52
  const { content, rel } = file;
43
- for (const m of content.matchAll(PUBLIC_ASSIGN)) {
53
+ // Commented-out code is not a live exposure; masking preserves length
54
+ // and newlines so match offsets/line numbers still point at the source.
55
+ const scan = maskCode(content, { file: rel });
56
+ for (const m of scan.matchAll(PUBLIC_ASSIGN)) {
44
57
  const name = m[1] ?? '';
45
58
  const value = m[2] ?? '';
46
59
  if (looksLikePlaceholder(value))
@@ -37,7 +37,13 @@ const RULES = [
37
37
  // inside one string literal — so prose mentioning "insert" won't match.
38
38
  // Quantifiers are length-bounded ({0,200}) to prevent catastrophic
39
39
  // backtracking (ReDoS) on very long / minified lines.
40
- re: /`\s*(?:SELECT|INSERT|UPDATE|DELETE)\b[^`]{0,200}\b(?:FROM|INTO|WHERE|VALUES|SET|JOIN)\b[^`]{0,200}\$\{|f["']\s*(?:SELECT|INSERT|UPDATE|DELETE)\b[^"'\n]{0,200}\b(?:FROM|INTO|WHERE|VALUES|SET|JOIN)\b[^"'\n]{0,200}\{/gi,
40
+ //
41
+ // The f-string branch tracks its OWN opening quote via a backreference
42
+ // (`(?:(?!\1)[^\n])` — any char that isn't a newline or that quote) rather
43
+ // than excluding both quote characters outright: `f"SELECT … WHERE name =
44
+ // '{x}'"` nests a single-quoted SQL string value inside a double-quoted
45
+ // f-string, and excluding `'` too made the whole pattern never match.
46
+ re: /`\s*(?:SELECT|INSERT|UPDATE|DELETE)\b[^`]{0,200}\b(?:FROM|INTO|WHERE|VALUES|SET|JOIN)\b[^`]{0,200}\$\{|f(["'])\s*(?:SELECT|INSERT|UPDATE|DELETE)\b(?:(?!\1)[^\n]){0,200}\b(?:FROM|INTO|WHERE|VALUES|SET|JOIN)\b(?:(?!\1)[^\n]){0,200}\{/gi,
41
47
  severity: 'warning',
42
48
  detail: 'Interpolating values into SQL invites SQL injection.',
43
49
  fix: 'Use parameterized queries / prepared statements instead of string interpolation.',
@@ -73,7 +79,11 @@ function isParameterizedTemplate(scan, backtickAt) {
73
79
  * from request data, so it is reported as info, not as an injection. About half
74
80
  * of the real-world hits were structural.
75
81
  */
76
- const VALUE_POSITION = /(=|<>|!=|<=|>=|<|>|\bLIKE|\bILIKE|\bIN\s*\(|\bVALUES\s*\([^)]*|\bBETWEEN|\bAND|\bOR|\bLIMIT|\bOFFSET|\bTHEN|\bELSE)\s*$/i;
82
+ // A trailing `["']?` handles the equally common quoted-value shape
83
+ // (`name = '${name}'`, `WHERE id = "${id}"`) — the SQL literal's own quote
84
+ // sits directly before the hole, between the operator and `${`/`{`, and
85
+ // without it that shape read as unquoted structure, not a value.
86
+ const VALUE_POSITION = /(=|<>|!=|<=|>=|<|>|\bLIKE|\bILIKE|\bIN\s*\(|\bVALUES\s*\([^)]*|\bBETWEEN|\bAND|\bOR|\bLIMIT|\bOFFSET|\bTHEN|\bELSE)\s*["']?\s*$/i;
77
87
  const PLACEHOLDER_EXPR = /^\s*(?:\w+\.)?(placeholders?|params?|marks|questions|qs|values|binds?)\b/i;
78
88
  function onlyStructuralInterpolation(scan, at) {
79
89
  // The literal: from the opening delimiter to its close (bounded).
@@ -592,26 +592,6 @@ 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
- // 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
- }
615
595
  // RLS is a PostgreSQL feature. The engine is decided per FILE, not per
616
596
  // project: a monorepo can hold a Postgres schema next to a Cloudflare D1
617
597
  // (SQLite) one, and a SQLite schema must never be told to enable RLS — that
@@ -681,8 +661,14 @@ export const rlsMigrationsChecker = {
681
661
  file: skipped[0]?.file ?? '',
682
662
  line: 1,
683
663
  }];
684
- if (analyzed.length === 0)
685
- return notApplicable;
664
+ // No early return here even when every SQL file present was a foreign
665
+ // engine (`analyzed.length === 0 && skipped.length > 0`): that settles
666
+ // those FILES (SQLite/MySQL truly have no RLS), but not the PROJECT — a
667
+ // Supabase project whose only local .sql happens to be a SQLite cache
668
+ // file has learned nothing about its real Postgres schema either. Both
669
+ // this case and zero SQL files at all fall through to the checks below,
670
+ // which decide from what was actually parsed (nothing, here) whether
671
+ // anything about the applicable backend's schema was learned.
686
672
  // "No RLS" is a hole only when untrusted clients reach the database directly
687
673
  // (Supabase/PostgREST with the anon key, Hasura). A Postgres that only server
688
674
  // code talks to has no RLS by default and is not exposed by that — a critical
@@ -694,7 +680,10 @@ export const rlsMigrationsChecker = {
694
680
  const serverOnly = !exposed &&
695
681
  (/"(pg|postgres|pg-promise|@prisma\/client|drizzle-orm|knex|kysely|typeorm|sequelize)"/i.test(pkg) ||
696
682
  /\b(psycopg2?|asyncpg|sqlalchemy)\b/i.test(reqs));
697
- const missingSeverity = exposed || !serverOnly ? 'critical' : 'warning';
683
+ // Server-only is 'advisory', not 'warning': the fix text for this exact
684
+ // case says "no action needed unless X" — a warning (−8 points, shown as
685
+ // a problem to look into) contradicted its own explanation of itself.
686
+ const missingSeverity = exposed || !serverOnly ? 'critical' : 'advisory';
698
687
  const events = [];
699
688
  const notUnderstood = [];
700
689
  analyzed.forEach((f, fileIdx) => {
@@ -708,7 +697,10 @@ export const rlsMigrationsChecker = {
708
697
  events.sort((a, b) => a.fileIdx - b.fileIdx || a.offset - b.offset);
709
698
  const state = new Map();
710
699
  for (const e of events) {
711
- const cur = state.get(e.key) ?? { created: false, enabled: false, file: e.file, line: e.line, display: e.display, stateFile: e.file, guarded: false, keys: new Set([e.key]) };
700
+ const cur = state.get(e.key) ?? {
701
+ created: false, everCreated: false, everDropped: false, enabled: false, file: e.file, line: e.line,
702
+ display: e.display, stateFile: e.file, stateLine: e.line, guarded: false, keys: new Set([e.key]),
703
+ };
712
704
  if (e.kind === 'rename') {
713
705
  const to = e.toKey;
714
706
  cur.keys.add(to);
@@ -716,13 +708,14 @@ export const rlsMigrationsChecker = {
716
708
  // Which name the table ends up under is unknown: keep it under both, both in doubt.
717
709
  cur.guarded = true;
718
710
  cur.stateFile = e.file;
711
+ cur.stateLine = e.line;
719
712
  state.set(e.key, cur);
720
713
  if (!state.has(to))
721
714
  state.set(to, { ...cur, keys: new Set(cur.keys), display: e.toDisplay ?? cur.display });
722
715
  continue;
723
716
  }
724
717
  state.delete(e.key);
725
- state.set(to, { ...cur, display: e.toDisplay ?? cur.display, stateFile: e.file });
718
+ state.set(to, { ...cur, display: e.toDisplay ?? cur.display, stateFile: e.file, stateLine: e.line });
726
719
  continue;
727
720
  }
728
721
  // Doubt is a property of ANY guarded statement, not just a guarded ENABLE.
@@ -734,6 +727,7 @@ export const rlsMigrationsChecker = {
734
727
  // (the outcome that leaves data exposed), and record the uncertainty.
735
728
  if (e.kind === 'create' && !cur.created) {
736
729
  cur.created = true;
730
+ cur.everCreated = true;
737
731
  cur.enabled = false;
738
732
  cur.file = e.file;
739
733
  cur.line = e.line;
@@ -745,6 +739,7 @@ export const rlsMigrationsChecker = {
745
739
  cur.enabled = false;
746
740
  cur.guarded = true;
747
741
  cur.stateFile = e.file;
742
+ cur.stateLine = e.line;
748
743
  state.set(e.key, cur);
749
744
  continue;
750
745
  }
@@ -753,27 +748,33 @@ export const rlsMigrationsChecker = {
753
748
  if (e.ifNotExists && cur.created)
754
749
  break; // existing table: no-op, keep RLS state
755
750
  cur.created = true;
751
+ cur.everCreated = true;
756
752
  cur.enabled = false;
757
753
  cur.file = e.file;
758
754
  cur.line = e.line;
759
755
  cur.display = e.display;
760
756
  cur.stateFile = e.file;
757
+ cur.stateLine = e.line;
761
758
  cur.guarded = false;
762
759
  break;
763
760
  case 'enable':
764
761
  cur.enabled = true;
765
762
  cur.stateFile = e.file;
763
+ cur.stateLine = e.line;
766
764
  cur.guarded = false;
767
765
  break;
768
766
  case 'disable':
769
767
  cur.enabled = false;
770
768
  cur.stateFile = e.file;
769
+ cur.stateLine = e.line;
771
770
  cur.guarded = false;
772
771
  break;
773
772
  case 'drop':
774
773
  cur.created = false;
774
+ cur.everDropped = true;
775
775
  cur.enabled = false;
776
776
  cur.stateFile = e.file;
777
+ cur.stateLine = e.line;
777
778
  cur.guarded = false;
778
779
  break;
779
780
  }
@@ -791,40 +792,107 @@ export const rlsMigrationsChecker = {
791
792
  const turnsOff = (k) => k === 'disable' || k === 'create';
792
793
  const findings = [];
793
794
  for (const [key, s] of state) {
794
- if (!s.created || isTempKey(key))
795
+ if (isTempKey(key))
795
796
  continue; // temp tables are session-only: PostgREST never sees them
797
+ // Confirmed gone: created here then legitimately DROPped (the original,
798
+ // well-tested case), OR never created here at all but still reached by
799
+ // an unconditional DROP this scan DID see — an external table whose
800
+ // removal this repo's migrations confirm, not merely reference. Either
801
+ // way there is nothing left to hold RLS state, on or off.
802
+ if (!s.created && s.everDropped)
803
+ continue;
804
+ // Reaching here means one of two things: `s.created` (created here and
805
+ // still exists — the original, well-tested case), or `!s.everCreated`
806
+ // (this key is only ever the target of ALTER/RENAME — an EXTERNAL
807
+ // table this repo never creates, managed from the dashboard or a
808
+ // migration outside this scan). Both get the SAME ambiguity/guard
809
+ // evaluation below: a DISABLE right there in the scanned file is not
810
+ // "no information" just because we cannot confirm the table exists,
811
+ // and an unconditional ENABLE on either is equally provably safe.
812
+ // Reusing one model — instead of a second, separate one for external
813
+ // tables that has to relearn DROP/RENAME/guard/cross-directory order
814
+ // on its own — is what keeps them from disagreeing with each other.
815
+ const external = !s.everCreated;
796
816
  const ambiguous = events.some((e) => s.keys.has(e.key) && dirOf(e.file) !== dirOf(s.stateFile) && (s.enabled ? turnsOff(e.kind) : e.kind === 'enable'));
797
817
  if (s.enabled && !ambiguous && !s.guarded)
798
- continue; // provably protected
818
+ continue; // provably protected (or provably fine, if external)
799
819
  // `guarded` means a statement we had to GUESS about decided this table's
800
820
  // state — a conditional ENABLE, DISABLE, CREATE, DROP or RENAME. Calling
801
821
  // that "critical" would be the same false confidence as calling it clean,
802
822
  // so it is reported as unconfirmed. A warning still fails CI (exit 1); it
803
823
  // just does not claim to know what only the database can tell.
804
- const kind = ambiguous ? 'order' : s.guarded ? 'guarded' : 'missing';
824
+ const kind = external ? 'external' : ambiguous ? 'order' : s.guarded ? 'guarded' : 'missing';
805
825
  findings.push({
806
826
  id: 'rls_missing',
807
827
  severity: kind === 'missing' ? missingSeverity : 'warning',
808
- title: kind === 'missing'
809
- ? (missingSeverity === 'critical'
810
- ? `Table "${s.display}" created without RLS`
811
- : `Table "${s.display}" has no RLS (server-only database)`)
812
- : kind === 'order' ? `Table "${s.display}" may end up without RLS (migration order unclear)`
813
- : `Table "${s.display}" has an unconfirmed RLS state (conditional block)`,
814
- detail: kind === 'missing'
815
- ? (missingSeverity === 'critical'
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.`
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.`)
818
- : kind === 'order'
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.`
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.`,
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.`,
828
+ title: kind === 'external' ? `Table "${s.display}" may have RLS disabled here, but is never created in this repo`
829
+ : kind === 'missing'
830
+ ? (missingSeverity === 'critical'
831
+ ? `Table "${s.display}" created without RLS`
832
+ : `Table "${s.display}" has no RLS (server-only database)`)
833
+ : kind === 'order' ? `Table "${s.display}" may end up without RLS (migration order unclear)`
834
+ : `Table "${s.display}" has an unconfirmed RLS state (conditional block)`,
835
+ detail: kind === 'external'
836
+ ? `"${s.display}" is not created by any migration this scan can see, but this project's SQL leaves it with Row Level Security off — or its state cannot be confirmed statically (a conditional branch, or statements in different directories with no reliable apply order). Either the table is real — created from the dashboard, or in a migration outside this repo — and RLS on it may now be off, or it does not exist and this is a no-op. This cannot be told apart statically — check the deployed state.`
837
+ : kind === 'missing'
838
+ ? (missingSeverity === 'critical'
839
+ ? `"${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.`
840
+ : `"${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.`)
841
+ : kind === 'order'
842
+ ? `"${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.`
843
+ : `"${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.`,
844
+ // The REVOKE-from-anon step only makes sense where an `anon` role is a
845
+ // real thing to revoke from — the same condition `missingSeverity`
846
+ // itself uses. Recommending it unconditionally told a server-only
847
+ // Postgres user (no Supabase, no anon role) to revoke privileges from
848
+ // a role that does not exist in their database, right next to a
849
+ // detail explaining that RLS is not needed there at all. An external
850
+ // table gets its own fix: we do not even know it exists, so neither
851
+ // the anon-revoke step nor the server-only "nothing to do" fits.
852
+ fix: kind === 'external'
853
+ ? `Confirm whether "${s.display}" exists in the deployed database. If it does, re-enable RLS: ALTER TABLE ${s.display} ENABLE ROW LEVEL SECURITY; and add an owner/tenant policy.`
854
+ : exposed || !serverOnly
855
+ ? `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.`
856
+ : `No action needed unless a client-facing data API (Supabase/PostgREST, Hasura) is ever put in front of this database — if it is, first ALTER TABLE ${s.display} ENABLE ROW LEVEL SECURITY and add an owner/tenant policy, then REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON ${s.display} FROM the role that API uses to connect.`,
822
857
  checker: 'rls-migrations',
823
858
  level: 0,
824
- file: s.file,
825
- line: s.line,
859
+ file: external ? s.stateFile : s.file,
860
+ line: external ? s.stateLine : s.line,
826
861
  });
827
862
  }
863
+ // Zero findings can mean two very different things: every table here has
864
+ // RLS handled correctly, or no table definition was ever found in the SQL
865
+ // at all — a maintenance script (`SELECT now();`), a seed file with no
866
+ // CREATE TABLE (schema itself made from the dashboard), or literally zero
867
+ // .sql files. `sqlFiles.length === 0` alone used to gate this and missed
868
+ // the first two: presence of a .sql file is not presence of schema
869
+ // information. `events` tells the two apart directly — a table that was
870
+ // found and IS clean still produced a 'create' event, just no finding.
871
+ //
872
+ // A TEMP table's own 'create' does NOT count: it is session-only and
873
+ // PostgREST never sees it (the same reason it is excluded from findings
874
+ // below), so a migration that creates only a staging TEMP table — even
875
+ // one that also DISABLEs RLS on a persistent table this repo never
876
+ // defines — learned nothing about the real, permanent schema either.
877
+ // `findings.length > 0` also counts as "learned something": an external
878
+ // table (no local CREATE at all) can still produce a real finding — an
879
+ // explicit DISABLE sitting right in the scanned file — and that finding
880
+ // must not be thrown away just because no CREATE ever ran anywhere.
881
+ const sawAnyTable = findings.length > 0 || events.some((e) => e.kind === 'create' && !isTempKey(e.key));
882
+ if (!sawAnyTable && notUnderstood.length === 0 && ctx.detection.backends.includes('supabase')) {
883
+ return {
884
+ findings: [...notApplicable, {
885
+ id: 'rls_unverifiable_no_migrations',
886
+ severity: 'info',
887
+ title: 'No table definitions found in SQL — RLS could not be checked statically',
888
+ detail: 'This project talks to Supabase, but no CREATE TABLE (or SELECT ... INTO) was found in any .sql file here — either there are no migrations, or the ones present (a seed script, a maintenance query) do not define a schema. The schema may be managed from the dashboard, or migrations may 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.',
889
+ 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.',
890
+ checker: 'rls-migrations',
891
+ level: 0,
892
+ }],
893
+ partial: 'no table definitions found in SQL for a Supabase project — RLS could not be checked for any table',
894
+ };
895
+ }
828
896
  const all = [...findings, ...notApplicable];
829
897
  if (notUnderstood.length === 0)
830
898
  return all;
@@ -1,4 +1,4 @@
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
3
  import { partitionIgnores } from '../../config.js';
4
4
  /** No project-wide rules — used to run ONLY the inline-marker half of partitionIgnores. */
@@ -130,24 +130,29 @@ const PATTERNS = [
130
130
  {
131
131
  id: 'db_url_password',
132
132
  title: 'Database URL with an inline password',
133
- 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,
134
139
  severity: 'critical',
135
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.',
136
141
  // `postgres://opencut:opencut@localhost` in docker-compose / CI / a Dockerfile
137
142
  // is a local container's default login, not a credential. A password that
138
- // equals the user name, or is a well-known default, is never reported; a
139
- // weak password on a local/single-label host (a compose service name) is
140
- // 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.
141
147
  validate: (hit) => {
142
- const m = /^[a-z+]+:\/\/([^\s:/@"']+):([^\s:/@"']+)@([^/\s:"']+)/i.exec(hit);
148
+ const m = /^[a-z+]+:\/\/([^\s:/@"'`)\];,]+):([^\s:/@"'`)\];,]+)@([^/\s:"'`)\];,]+)/i.exec(hit);
143
149
  if (!m)
144
150
  return true;
145
151
  const [, user = '', pw = '', host = ''] = m;
146
- if (pw.toLowerCase() === user.toLowerCase() || DEFAULT_PASSWORDS.has(pw.toLowerCase()))
147
- return false;
148
152
  const local = /^(localhost|127\.0\.0\.1|0\.0\.0\.0|host\.docker\.internal|[a-z0-9_-]+)$/i.test(host);
149
153
  const strong = /[0-9]/.test(pw) && /[A-Za-z]/.test(pw) && shannonEntropy(pw) >= 3.0;
150
- return !(local && !strong);
154
+ const weak = pw.toLowerCase() === user.toLowerCase() || DEFAULT_PASSWORDS.has(pw.toLowerCase()) || !strong;
155
+ return !(local && weak);
151
156
  },
152
157
  },
153
158
  {
@@ -171,8 +176,14 @@ const GENERIC = /\b([A-Za-z0-9_-]*(?:api[_-]?key|secret|token|passwd|password|pw
171
176
  * tracking ids, CSRF nonces, push tokens. 559 of 569 generic hits in one real
172
177
  * project were `tracking_token` / `pagination_token` inside cached API
173
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.
174
185
  */
175
- 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;
176
187
  /** Base64 blobs (thumbnails, binary) and anything longer than a real token. */
177
188
  const looksLikeBlob = (v) => v.length > 200 || /^(\/9j\/|iVBOR|data:|R0lGOD|UklGR)/.test(v);
178
189
  const JWT = /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g;
@@ -206,15 +217,320 @@ function isConfigish(rel) {
206
217
  return /(^|\/)\.env($|\.)/.test(rel) || /\.(ya?ml|toml|ini|conf|properties|npmrc|netrc)$/.test(rel)
207
218
  || /(^|\/)(Dockerfile|\.npmrc|\.netrc)$/.test(rel) || /docker-compose\.ya?ml$/.test(rel);
208
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);
209
222
  /**
210
- * Documentation, examples and test fixtures are where sample keys legitimately
211
- * live. A hit there is worth mentioning but is not a credential leak.
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.)
212
227
  */
213
- function isExampleContext(rel) {
214
- return /\.(md|txt|mdx|rst)$/i.test(rel)
215
- || /\.(example|sample|template|dist)$/i.test(rel)
216
- || /(^|\/)(docs?|examples?|fixtures?|__fixtures__|__tests__|test|tests|spec|__mocks__)(\/|$)/i.test(rel)
217
- || /\.(test|spec)\.[a-z]+$/i.test(rel);
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']);
477
+ /**
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.
484
+ */
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];
218
534
  }
219
535
  export const secretsChecker = {
220
536
  id: 'secrets',
@@ -223,284 +539,21 @@ export const secretsChecker = {
223
539
  run(ctx) {
224
540
  const findings = [];
225
541
  const exposure = createExposure(ctx.root);
226
- // Values from the REAL env files, per directory. A committed .env.example
227
- // that carries the same value as its sibling .env is not an example — it is
228
- // the key, published. (Seen in the wild: OPENAI/ANTHROPIC keys identical
229
- // in .env and a committed .env.example.)
230
- const realEnvValues = new Map();
231
- for (const f of ctx.files) {
232
- if (!isEnvFile(f.rel))
233
- continue;
234
- const dir = f.rel.includes('/') ? f.rel.slice(0, f.rel.lastIndexOf('/')) : '';
235
- const set = realEnvValues.get(dir) ?? new Set();
236
- for (const m of f.content.matchAll(ASSIGN)) {
237
- const v = (m[2] ?? '').trim().replace(/^["']|["']$/g, '').replace(/["'].*$/, '');
238
- if (v.length >= 8)
239
- set.add(v);
240
- }
241
- realEnvValues.set(dir, set);
242
- }
243
- const dirOf = (rel) => (rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : '');
244
- const isExampleEnv = (rel) => /(^|\/)\.env[^/]*\.(example|sample|template|dist)$/i.test(rel) || /(^|\/)(example|sample)\.env$/i.test(rel);
542
+ const realEnvValues = collectRealEnvValues(ctx.files);
245
543
  for (const file of ctx.files) {
246
544
  const { rel } = file;
247
545
  const content = file.content.replace(/^\uFEFF/, ''); // a BOM must not eat line 1
248
546
  const env = isEnvFile(rel);
249
- const example = isExampleContext(rel);
547
+ const example = looksLikeTestOrDocPath(rel);
250
548
  const ex = exposure(rel);
251
- // `proven` = the hit is structurally real key material (see
252
- // looksLikeRealMaterial). A docs/fixture path may only downgrade a
253
- // heuristic or sample-looking hit to info; proven material stays at
254
- // warning there — the path lowers confidence, it does not make it safe.
255
- /**
256
- * Severity = what the value is × how it can leak. The pattern says what it
257
- * is; the example/docs path lowers confidence; git exposure decides how
258
- * bad it is. `raw` is the unredacted value, only compared, never stored.
259
- */
260
- const push = (f, proven = false, raw) => {
261
- let out = f;
262
- const realValueInExample = !!raw && isExampleEnv(rel) && (realEnvValues.get(dirOf(rel))?.has(raw) ?? false);
263
- if (realValueInExample) {
264
- // Not an example at all: the live value copied into the example file.
265
- out = {
266
- ...f,
267
- severity: ex === 'committed' ? 'critical' : 'warning',
268
- title: `${f.title} — the REAL value from .env, in an example file${ex === 'committed' ? ' that is committed' : ''}`,
269
- 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' : ''}.`,
270
- fix: 'Replace the value in the example file with a placeholder, and rotate the credential — it has been published.',
271
- };
272
- }
273
- else if (example && proven) {
274
- out = {
275
- ...f,
276
- severity: f.severity === 'critical' ? 'warning' : f.severity,
277
- title: `${f.title} (in docs/example path — looks real)`,
278
- 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.`,
279
- };
280
- }
281
- else if (example) {
282
- out = {
283
- ...f,
284
- severity: 'info',
285
- title: `${f.title} (in docs/example file)`,
286
- detail: `${f.detail} This looks like documentation or a fixture — confirm it is not a real credential.`,
287
- };
288
- }
289
- // Git exposure. A committed secret keeps its full severity. Everything
290
- // else cannot leak through the repository right now: an ignored file is
291
- // doing exactly what it should (advisory); an untracked file in a repo
292
- // or a folder that is not a repo is hygiene, capped at warning.
293
- if (!realValueInExample && out.severity !== 'info') {
294
- // A .env that is not committed is doing its job: secrets belong there.
295
- // Whether it WILL be committed (untracked, no .gitignore entry) is the
296
- // env-git check's finding, not this one's — reporting it twice at
297
- // warning is what made people stop reading.
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.
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.` };
303
- }
304
- else if (ex === 'ignored') {
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.` };
310
- }
311
- else if (ex === 'untracked') {
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.` };
313
- }
314
- else if (ex === 'no-git') {
315
- 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.` };
316
- }
317
- }
318
- findings.push(out);
319
- };
320
- for (const p of PATTERNS) {
321
- for (const m of content.matchAll(p.re)) {
322
- const hit = m[0];
323
- if (looksLikePlaceholder(hit))
324
- continue; // YOUR_KEY / EXAMPLE / xxxxx / <...>
325
- if (p.validate && !p.validate(hit))
326
- continue;
327
- // A secret in a server env/config file is expected — the risk is
328
- // committing it (env-git flags that), so it is a warning, not a leak.
329
- const severity = env && p.severity === 'critical' ? 'warning' : p.severity;
330
- const proven = HIGH_CONFIDENCE.has(p.id) && looksLikeRealMaterial(p.id, hit, content, m.index ?? 0, m[1]);
331
- push({
332
- id: p.id,
333
- severity,
334
- title: env ? `${p.title} (in env/config file)` : p.title,
335
- detail: env
336
- ? `${p.title} in ${rel} (${redact(hit)}). Normal for server env — keep this file gitignored and out of client bundles.`
337
- : `${p.title} found in source: ${redact(hit)}`,
338
- fix: env ? 'Keep this file out of git and out of client bundles; rotate the value if it may have been committed.' : p.fix,
339
- checker: 'secrets',
340
- level: 0,
341
- file: rel,
342
- line: lineAt(content, m.index ?? 0),
343
- evidence: redact(hit),
344
- }, proven, hit);
345
- }
346
- }
347
- // Supabase service_role key (a JWT whose payload role is service_role).
348
- // The decoded role proves what it is, so it is never a docs-only info.
349
- for (const m of content.matchAll(JWT)) {
350
- const payload = decodeJwtPayload(m[0]);
351
- if (payload && payload['role'] === 'service_role') {
352
- push({
353
- id: 'supabase_service_role_key',
354
- severity: env ? 'warning' : 'critical',
355
- title: env ? 'Supabase service_role key (in env/config file)' : 'Supabase service_role key in source',
356
- detail: env
357
- ? `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.`
358
- : '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.',
359
- fix: env
360
- ? 'Keep it server-side only, ensure the file is gitignored, and rotate it if it may have been committed.'
361
- : 'Remove it, rotate the service_role key in Supabase settings, and use it only in trusted server code.',
362
- checker: 'secrets',
363
- level: 0,
364
- file: rel,
365
- line: lineAt(content, m.index ?? 0),
366
- evidence: redact(m[0]),
367
- }, true, m[0]);
368
- }
369
- }
370
- // Quoted key/secret assignments in code, filtered by placeholder + entropy.
371
- for (const m of content.matchAll(GENERIC)) {
372
- const name = m[1] ?? '';
373
- const value = m[2] ?? '';
374
- if (NON_SECRET_NAME.test(name) || looksLikeBlob(value))
375
- continue;
376
- // Credentials have no whitespace ("Show password" is UI text) and carry
377
- // digits or real length ("build-time-secret" is a label).
378
- if (/\s/.test(value) || (!/[0-9]/.test(value) && value.length < 24))
379
- continue;
380
- if (looksLikePlaceholder(value) || shannonEntropy(value) < 3.2)
381
- continue;
382
- push({
383
- id: 'generic_secret',
384
- severity: 'warning',
385
- title: 'Possible hardcoded secret',
386
- detail: `A high-entropy value is assigned to a secret-looking name: ${redact(value)}`,
387
- 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`.',
388
- checker: 'secrets',
389
- level: 0,
390
- file: rel,
391
- line: lineAt(content, m.index ?? 0),
392
- evidence: redact(value),
393
- }, false, value);
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
- }
420
- // name=value / key: value assignments (env, config, Dockerfile ENV/ARG).
421
- if (isConfigish(rel)) {
422
- for (const m of content.matchAll(ASSIGN)) {
423
- const name = m[1] ?? '';
424
- if (!SECRET_NAME.test(name))
425
- continue;
426
- const value = (m[2] ?? '').trim().replace(/^["']|["']$/g, '').replace(/["'].*$/, '');
427
- if (value.length < 8 || looksLikePlaceholder(value) || shannonEntropy(value) < 3.0)
428
- continue;
429
- // "build-time-secret" is a label, not a credential: real values carry digits or length.
430
- if (!/[0-9]/.test(value) && value.length < 24)
431
- continue;
432
- push({
433
- id: 'env_secret',
434
- severity: 'warning',
435
- title: env ? 'Secret in env file' : 'Secret in config file',
436
- detail: `"${name}" holds a high-entropy value in ${rel}: ${redact(value)}`,
437
- fix: env
438
- ? 'Fine for server env — keep this file gitignored and out of the client; rotate if it may have leaked.'
439
- : 'Move this secret out of committed config into a server-side secret store, and rotate it.',
440
- checker: 'secrets',
441
- level: 0,
442
- file: rel,
443
- line: lineAt(content, m.index ?? 0),
444
- evidence: redact(value),
445
- }, false, value);
446
- }
447
- }
448
- }
449
- // De-duplicate only true repeats: the same secret, same place, same rule.
450
- // (Keying on file:line alone hid every extra key on a minified line.)
451
- const seen = new Map();
452
- for (const f of findings) {
453
- const key = `${f.file ?? ''}:${f.line ?? 0}:${f.id}:${f.evidence ?? ''}`;
454
- const cur = seen.get(key);
455
- if (!cur || SEVERITY_RANK[f.severity] < SEVERITY_RANK[cur.severity])
456
- seen.set(key, f);
457
- }
458
- // A vendor pattern or a decoded JWT already identifies the value on a line;
459
- // the name-based env_secret / generic_secret there is the same fact twice.
460
- const NAME_BASED = new Set(['env_secret', 'generic_secret']);
461
- const specificAt = new Set([...seen.values()].filter((f) => !NAME_BASED.has(f.id)).map((f) => `${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
- });
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);
503
555
  }
504
- return [...kept.filter((f) => !folded.has(f)), ...summaries];
556
+ const kept = dropRedundantNameBased(dedupeFindings(findings));
557
+ return foldEnvAdvisories(kept, ctx.files);
505
558
  },
506
559
  };
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { DNS_LABEL } from './util/text.js';
3
+ import { DNS_LABEL, looksLikeTestOrDocPath } from './util/text.js';
4
+ import { maskCode } from './util/mask.js';
4
5
  const SUPABASE_URL_FALLBACK = new RegExp(`https?://${DNS_LABEL}\\.supabase\\.co`, 'i');
5
6
  /** Infer the project's frameworks, backends, languages and package managers. */
6
7
  export function detect(root, files) {
@@ -104,10 +105,23 @@ export function detect(root, files) {
104
105
  // false positives from code that merely mentions a backend by name. Uses the
105
106
  // shared bounded DNS_LABEL (see util/text.ts) so this cannot go quadratic on
106
107
  // a long run of matching characters in a large file.
107
- if (!backends.has('supabase') && files.some((f) => SUPABASE_URL_FALLBACK.test(f.content))) {
108
+ //
109
+ // Two independent guards against a project's own code making itself look
110
+ // like it uses a backend it only mentions: test/fixture files are excluded
111
+ // (a test suite for code that talks to Supabase routinely contains
112
+ // synthetic mock URLs shaped like a real project's own), and comments are
113
+ // masked before matching (an explanatory comment describing that exact URL
114
+ // shape is otherwise itself a match). Both were needed: this fallback once
115
+ // made EasyVibeGate's own test suite, and separately its own source
116
+ // comments, detect EasyVibeGate itself as a Supabase project — a security
117
+ // scanner flagging itself is exactly what dogfooding exists to catch.
118
+ // Strings are NOT masked: a real URL/call normally appears in one.
119
+ const nonTestFiles = files.filter((f) => !looksLikeTestOrDocPath(f.rel));
120
+ const codeOnly = (f) => maskCode(f.content, { file: f.rel });
121
+ if (!backends.has('supabase') && nonTestFiles.some((f) => SUPABASE_URL_FALLBACK.test(codeOnly(f)))) {
108
122
  backends.add('supabase');
109
123
  }
110
- if (!backends.has('firebase') && files.some((f) => /\binitializeApp\s*\(/.test(f.content) && /firebase/i.test(f.content))) {
124
+ if (!backends.has('firebase') && nonTestFiles.some((f) => { const c = codeOnly(f); return /\binitializeApp\s*\(/.test(c) && /firebase/i.test(c); })) {
111
125
  backends.add('firebase');
112
126
  }
113
127
  return {
@@ -60,3 +60,17 @@ export function decodeJwtPayload(token) {
60
60
  * milliseconds — and no real hostname label is longer than this anyway.
61
61
  */
62
62
  export const DNS_LABEL = '[a-z0-9-]{1,63}';
63
+ /**
64
+ * Documentation, examples and test/fixture paths — where a sample config
65
+ * value (a fake Supabase URL, a placeholder key) legitimately lives without
66
+ * describing anything about the real project. A test suite for code that
67
+ * TALKS to a backend routinely contains literal-looking URLs/keys for that
68
+ * backend as mock data; without this, scanning a project's own tests can
69
+ * make the project appear to use a backend it merely tests against.
70
+ */
71
+ export function looksLikeTestOrDocPath(rel) {
72
+ return /\.(md|txt|mdx|rst)$/i.test(rel)
73
+ || /\.(example|sample|template|dist)$/i.test(rel)
74
+ || /(^|\/)(docs?|examples?|fixtures?|__fixtures__|__tests__|test|tests|spec|__mocks__)(\/|$)/i.test(rel)
75
+ || /\.(test|spec)\.[a-z]+$/i.test(rel);
76
+ }
@@ -15,22 +15,32 @@ const SKIP_DIRS = new Set([
15
15
  ]);
16
16
  /**
17
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.
18
+ * "cache", a package called "tmp") and sometimes pure data. Path depth alone
19
+ * does not tell them apart: `src/cache` and `data/cache` are both 2 segments,
20
+ * but the first is exactly as likely to be a project's own source directory
21
+ * (a caching module) as the second is to be scan output. What DOES tell them
22
+ * apart is the parent: a data-ish parent name (`data`, `var`, `storage`) means
23
+ * generated/fetched content; an ordinary source-tree parent does not. Skipped
24
+ * only when the ambiguous name is the project root's own child (`cache/`,
25
+ * `tmp/` at the top level), or its immediate parent is one of those data-ish
26
+ * names (`data/cache`, `var/tmp`). The real-world evidence for this was
27
+ * `data/cache/*.json` full of API pagination tokens, 559 of 569 "generic
28
+ * secrets" in one real project; blanket name-matching at any depth once made
29
+ * a source directory invisible to every check with no visible coverage gap,
30
+ * and matching by depth alone reopened that exact gap for `src/cache`.
27
31
  */
28
32
  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;
33
+ const DATA_ISH_PARENTS = new Set(['data', 'var', 'storage']);
34
+ const isShallowDataDir = (name, relDir) => {
35
+ if (!AMBIGUOUS_DATA_DIRS.has(name))
36
+ return false;
37
+ const segs = relDir.split('/');
38
+ return segs.length === 1 || (segs.length === 2 && DATA_ISH_PARENTS.has((segs[0] ?? '').toLowerCase()));
39
+ };
30
40
  const TEXT_EXT = new Set([
31
- '.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.vue', '.svelte',
41
+ '.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.mts', '.cts', '.vue', '.svelte',
32
42
  '.py', '.rb', '.php', '.go', '.rs', '.java', '.kt', '.cs',
33
- '.html', '.css', '.scss', '.json', '.yml', '.yaml', '.toml',
43
+ '.html', '.css', '.scss', '.json', '.jsonc', '.json5', '.yml', '.yaml', '.toml',
34
44
  '.env', '.sh', '.sql', '.md', '.txt', '.ini', '.conf', '.tf',
35
45
  '.pem', '.key', '.crt', '.cert', '.pkcs8',
36
46
  '.astro', '.properties', '.plist', '.swift', '.dart', '.ipynb', '.bash', '.zsh', '.mdx',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "easyvibegate",
3
- "version": "0.6.2",
3
+ "version": "0.6.8",
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": {