easyvibegate 0.6.0 → 0.6.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -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,23 @@ 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] : [];
271
+ // The report directory gets its OWN `.gitignore` (see prepareOutputDir) and
272
+ // has its stale report files deleted on every run. Both are safe for a
273
+ // directory that exists only to hold reports — neither is safe for the
274
+ // scanned project itself: `--output .` from the project root would replace
275
+ // the project's real .gitignore with a bare `*`, silently un-tracking the
276
+ // whole repo.
277
+ if (args.output !== undefined && outDir === root) {
278
+ process.stderr.write(`easyvibegate: --output must not be the scanned project itself (${root}) — pick a subdirectory, e.g. --output ${join(root, 'easyvibegate-report')}
279
+ `);
280
+ process.exit(2);
281
+ }
265
282
  // Use the friendly wizard when a human runs it in a terminal without
266
283
  // automation flags; --wizard forces it. Either way the pipeline below is shared.
267
284
  // --ci is a non-interactive contract: never ask questions there.
@@ -281,7 +298,7 @@ async function main() {
281
298
  let result;
282
299
  if (useWizard) {
283
300
  result = await runWizard({
284
- excludeAbs: [outDir],
301
+ excludeAbs: excludeOutDir,
285
302
  path: args.path,
286
303
  config: args.config,
287
304
  lang,
@@ -309,7 +326,7 @@ async function main() {
309
326
  };
310
327
  // Same contract as the wizard: plan the concrete targets once, ask about
311
328
  // exactly those, execute exactly those.
312
- const staticResult = await scanStatic(root, { configPath: args.config, excludeAbs: [outDir] });
329
+ const staticResult = await scanStatic(root, { configPath: args.config, excludeAbs: excludeOutDir });
313
330
  const plan = planTargets(staticResult.files, loadConfig(root, args.config), {
314
331
  appUrl: args.appUrl,
315
332
  supabaseUrl: args.supabaseUrl,
@@ -362,12 +379,33 @@ async function main() {
362
379
  // Same contract in every mode: 2 critical, 1 warning, 3 incomplete, 0 clean.
363
380
  process.exit(exitCodeFor(summary));
364
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';
365
388
  /** Create the report dir and clear our own stale files, or explain why we cannot. */
366
389
  function prepareOutputDir(dir) {
367
390
  try {
368
- if (existsSync(dir) && !statSync(dir).isDirectory())
391
+ const existed = existsSync(dir);
392
+ if (existed && !statSync(dir).isDirectory())
369
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
+ }
370
400
  mkdirSync(dir, { recursive: true });
401
+ // The report names secret prefixes, database hosts and every endpoint —
402
+ // exactly what must not be committed. A `.gitignore` containing `*` inside
403
+ // the directory makes git ignore it wherever the project's own .gitignore
404
+ // stands (the trick node_modules-style caches use); the user's files are
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');
371
409
  // Old report.md next to a fresh report.json told two different stories.
372
410
  for (const name of ['report.md', 'report.json', 'ai-fix-prompt.md']) {
373
411
  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) {
@@ -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,27 +592,33 @@ 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 [];
597
595
  // RLS is a PostgreSQL feature. The engine is decided per FILE, not per
598
596
  // project: a monorepo can hold a Postgres schema next to a Cloudflare D1
599
597
  // (SQLite) one, and a SQLite schema must never be told to enable RLS — that
600
598
  // engine has no such thing. A real D1 project once got five "critical" RLS
601
599
  // findings and a 0/100 because the old gate only knew MySQL's
602
600
  // `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');
601
+ // A monorepo's dependencies often live in apps/*/package.json, not the
602
+ // workspace root. Reading only the root file missed a D1/SQLite worker's
603
+ // `wrangler`/`better-sqlite3` signal whenever the SQL itself carried no
604
+ // dialect marker either, and defaulted it to Postgres — a phantom critical
605
+ // on a database that has no RLS to enable.
606
+ const pkg = ctx.files.filter((f) => f.rel.endsWith('package.json')).map((f) => f.content).join('\n');
607
+ const reqs = ctx.files.filter((f) => f.rel === 'requirements.txt' || f.rel.endsWith('/requirements.txt')).map((f) => f.content).join('\n');
608
+ // Checked with .some() against the files themselves, not a pre-joined
609
+ // string: joining nearly the whole project's non-doc/non-SQL content into
610
+ // one string on every scan doubled peak memory for the full scanned corpus
611
+ // to answer what is, per project, a handful of existence checks that can
612
+ // each stop at the first matching file.
613
+ const codeFiles = ctx.files.filter((f) => !/\.(md|mdx|txt|rst|sql)$/i.test(f.rel));
614
+ const matchesAnyCodeFile = (re) => codeFiles.some((f) => re.test(f.content));
609
615
  const pgProject = ctx.detection.backends.includes('supabase') ||
610
616
  /\b(pg|postgres|postgresql|@supabase\/|postgres\.js|node-postgres|pg-promise)\b/i.test(pkg) ||
611
617
  /\b(psycopg|asyncpg|sqlalchemy\+postgres)\b/i.test(reqs);
612
618
  const hasD1 = ctx.files.some((f) => /(^|\/)wrangler\.(toml|jsonc?)$/.test(f.rel) && /d1_databases/.test(f.content));
613
619
  const sqliteProject = hasD1 ||
614
620
  /"(better-sqlite3|sqlite3|@libsql\/client)"/i.test(pkg) ||
615
- /from\s+['"](bun:sqlite|drizzle-orm\/(d1|better-sqlite3|libsql))['"]/.test(codeAndEnv);
621
+ matchesAnyCodeFile(/from\s+['"](bun:sqlite|drizzle-orm\/(d1|better-sqlite3|libsql))['"]/);
616
622
  const mysqlProject = /"(mysql2?|mariadb)"/i.test(pkg);
617
623
  const sqliteLabel = hasD1 ? 'Cloudflare D1 (SQLite)' : 'SQLite';
618
624
  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;
@@ -655,8 +661,14 @@ export const rlsMigrationsChecker = {
655
661
  file: skipped[0]?.file ?? '',
656
662
  line: 1,
657
663
  }];
658
- if (analyzed.length === 0)
659
- 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.
660
672
  // "No RLS" is a hole only when untrusted clients reach the database directly
661
673
  // (Supabase/PostgREST with the anon key, Hasura). A Postgres that only server
662
674
  // code talks to has no RLS by default and is not exposed by that — a critical
@@ -664,11 +676,14 @@ export const rlsMigrationsChecker = {
664
676
  // the tool's audience is mostly Supabase, and unknown must not read as safe.
665
677
  const exposed = ctx.detection.backends.includes('supabase') ||
666
678
  /@supabase\//i.test(pkg) ||
667
- /(\/rest\/v1\b|postgrest|hasura|SUPABASE_(?:ANON|PUBLISHABLE)_KEY|sb_publishable_)/i.test(codeAndEnv);
679
+ matchesAnyCodeFile(/(\/rest\/v1\b|postgrest|hasura|SUPABASE_(?:ANON|PUBLISHABLE)_KEY|sb_publishable_)/i);
668
680
  const serverOnly = !exposed &&
669
681
  (/"(pg|postgres|pg-promise|@prisma\/client|drizzle-orm|knex|kysely|typeorm|sequelize)"/i.test(pkg) ||
670
682
  /\b(psycopg2?|asyncpg|sqlalchemy)\b/i.test(reqs));
671
- 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';
672
687
  const events = [];
673
688
  const notUnderstood = [];
674
689
  analyzed.forEach((f, fileIdx) => {
@@ -682,7 +697,10 @@ export const rlsMigrationsChecker = {
682
697
  events.sort((a, b) => a.fileIdx - b.fileIdx || a.offset - b.offset);
683
698
  const state = new Map();
684
699
  for (const e of events) {
685
- 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
+ };
686
704
  if (e.kind === 'rename') {
687
705
  const to = e.toKey;
688
706
  cur.keys.add(to);
@@ -690,13 +708,14 @@ export const rlsMigrationsChecker = {
690
708
  // Which name the table ends up under is unknown: keep it under both, both in doubt.
691
709
  cur.guarded = true;
692
710
  cur.stateFile = e.file;
711
+ cur.stateLine = e.line;
693
712
  state.set(e.key, cur);
694
713
  if (!state.has(to))
695
714
  state.set(to, { ...cur, keys: new Set(cur.keys), display: e.toDisplay ?? cur.display });
696
715
  continue;
697
716
  }
698
717
  state.delete(e.key);
699
- 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 });
700
719
  continue;
701
720
  }
702
721
  // Doubt is a property of ANY guarded statement, not just a guarded ENABLE.
@@ -708,6 +727,7 @@ export const rlsMigrationsChecker = {
708
727
  // (the outcome that leaves data exposed), and record the uncertainty.
709
728
  if (e.kind === 'create' && !cur.created) {
710
729
  cur.created = true;
730
+ cur.everCreated = true;
711
731
  cur.enabled = false;
712
732
  cur.file = e.file;
713
733
  cur.line = e.line;
@@ -719,6 +739,7 @@ export const rlsMigrationsChecker = {
719
739
  cur.enabled = false;
720
740
  cur.guarded = true;
721
741
  cur.stateFile = e.file;
742
+ cur.stateLine = e.line;
722
743
  state.set(e.key, cur);
723
744
  continue;
724
745
  }
@@ -727,27 +748,33 @@ export const rlsMigrationsChecker = {
727
748
  if (e.ifNotExists && cur.created)
728
749
  break; // existing table: no-op, keep RLS state
729
750
  cur.created = true;
751
+ cur.everCreated = true;
730
752
  cur.enabled = false;
731
753
  cur.file = e.file;
732
754
  cur.line = e.line;
733
755
  cur.display = e.display;
734
756
  cur.stateFile = e.file;
757
+ cur.stateLine = e.line;
735
758
  cur.guarded = false;
736
759
  break;
737
760
  case 'enable':
738
761
  cur.enabled = true;
739
762
  cur.stateFile = e.file;
763
+ cur.stateLine = e.line;
740
764
  cur.guarded = false;
741
765
  break;
742
766
  case 'disable':
743
767
  cur.enabled = false;
744
768
  cur.stateFile = e.file;
769
+ cur.stateLine = e.line;
745
770
  cur.guarded = false;
746
771
  break;
747
772
  case 'drop':
748
773
  cur.created = false;
774
+ cur.everDropped = true;
749
775
  cur.enabled = false;
750
776
  cur.stateFile = e.file;
777
+ cur.stateLine = e.line;
751
778
  cur.guarded = false;
752
779
  break;
753
780
  }
@@ -765,40 +792,107 @@ export const rlsMigrationsChecker = {
765
792
  const turnsOff = (k) => k === 'disable' || k === 'create';
766
793
  const findings = [];
767
794
  for (const [key, s] of state) {
768
- if (!s.created || isTempKey(key))
795
+ if (isTempKey(key))
769
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;
770
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'));
771
817
  if (s.enabled && !ambiguous && !s.guarded)
772
- continue; // provably protected
818
+ continue; // provably protected (or provably fine, if external)
773
819
  // `guarded` means a statement we had to GUESS about decided this table's
774
820
  // state — a conditional ENABLE, DISABLE, CREATE, DROP or RENAME. Calling
775
821
  // that "critical" would be the same false confidence as calling it clean,
776
822
  // so it is reported as unconfirmed. A warning still fails CI (exit 1); it
777
823
  // just does not claim to know what only the database can tell.
778
- const kind = ambiguous ? 'order' : s.guarded ? 'guarded' : 'missing';
824
+ const kind = external ? 'external' : ambiguous ? 'order' : s.guarded ? 'guarded' : 'missing';
779
825
  findings.push({
780
826
  id: 'rls_missing',
781
827
  severity: kind === 'missing' ? missingSeverity : 'warning',
782
- title: kind === 'missing'
783
- ? (missingSeverity === 'critical'
784
- ? `Table "${s.display}" created without RLS`
785
- : `Table "${s.display}" has no RLS (server-only database)`)
786
- : kind === 'order' ? `Table "${s.display}" may end up without RLS (migration order unclear)`
787
- : `Table "${s.display}" has an unconfirmed RLS state (conditional block)`,
788
- detail: kind === 'missing'
789
- ? (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.`
791
- : `"${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
- : kind === 'order'
793
- ? `"${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
- : `"${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.`,
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.`,
796
857
  checker: 'rls-migrations',
797
858
  level: 0,
798
- file: s.file,
799
- line: s.line,
859
+ file: external ? s.stateFile : s.file,
860
+ line: external ? s.stateLine : s.line,
800
861
  });
801
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
+ }
802
896
  const all = [...findings, ...notApplicable];
803
897
  if (notUnderstood.length === 0)
804
898
  return all;