easyvibegate 0.6.2 → 0.6.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,137 @@
1
+ import { lineAt } from '../../util/text.js';
2
+ import { maskCode } from '../../util/mask.js';
3
+ const NEXT_CONFIG_RE = /(^|\/)next\.config\.(js|mjs|cjs|ts)$/;
4
+ const MIDDLEWARE_RE = /(^|\/)middleware\.(js|mjs|ts)$/;
5
+ /**
6
+ * A `remotePatterns` entry with a wildcard hostname. Next.js's built-in image
7
+ * optimizer (`/_next/image?url=...`) will then fetch ANY https URL a caller
8
+ * gives it and return the decoded result — a textbook SSRF primitive (probe
9
+ * internal services, cloud metadata endpoints, port-scan the private network)
10
+ * independent of whatever RCE a given `next`/`sharp` version does or doesn't
11
+ * carry this month. Flagging only the version misses this: upgrading closes
12
+ * today's CVE but leaves the wildcard open for the next one.
13
+ */
14
+ const WILDCARD_HOSTNAME = /hostname\s*:\s*["'`]\*{1,2}["'`]/;
15
+ const REMOTE_PATTERNS_BLOCK = /remotePatterns\s*:\s*\[/g;
16
+ /** How far past `remotePatterns: [` to look for a wildcard entry inside it. */
17
+ const BLOCK_WINDOW = 2000;
18
+ /**
19
+ * The Next.js docs' own idiom for "run this middleware on everything except
20
+ * X": a negative lookahead inside the matcher regex,
21
+ * `'/((?!_next/static|_next/image|favicon.ico).*)'`. `_next/image` appearing
22
+ * inside a matcher block that also contains a lookahead means the image
23
+ * optimizer is explicitly carved out of whatever auth gate this middleware
24
+ * otherwise enforces — reachable with no session at all.
25
+ */
26
+ const MATCHER_BLOCK = /matcher\s*[:=]/g;
27
+ const MATCHER_WINDOW = 500;
28
+ const dirOf = (rel) => {
29
+ const i = rel.lastIndexOf('/');
30
+ return i === -1 ? '' : rel.slice(0, i);
31
+ };
32
+ /** middleware.ts commonly sits at the app root OR under src/ — normalize both to the app root. */
33
+ const appRootOf = (rel) => {
34
+ const d = dirOf(rel);
35
+ return d.endsWith('/src') ? d.slice(0, -4) : d === 'src' ? '' : d;
36
+ };
37
+ function findWildcardRemotePatterns(content) {
38
+ const hits = [];
39
+ for (const m of content.matchAll(REMOTE_PATTERNS_BLOCK)) {
40
+ const start = (m.index ?? 0) + m[0].length;
41
+ const window = content.slice(start, start + BLOCK_WINDOW);
42
+ // Stop at the block's own closing `]` when it's inside the window, so a
43
+ // SECOND, unrelated remotePatterns array later in a long file is not
44
+ // credited with this one's wildcard.
45
+ const closeAt = window.indexOf(']');
46
+ const scoped = closeAt === -1 ? window : window.slice(0, closeAt);
47
+ if (WILDCARD_HOSTNAME.test(scoped))
48
+ hits.push(m.index ?? 0);
49
+ }
50
+ return hits;
51
+ }
52
+ function findImageExcludedMatchers(content) {
53
+ const hits = [];
54
+ for (const m of content.matchAll(MATCHER_BLOCK)) {
55
+ const start = m.index ?? 0;
56
+ const window = content.slice(start, start + MATCHER_WINDOW);
57
+ if (window.includes('(?!') && window.includes('_next/image'))
58
+ hits.push(start);
59
+ }
60
+ return hits;
61
+ }
62
+ /**
63
+ * Correlates two static signals that no per-file, per-checker rule can see
64
+ * together: an image optimizer open to any host (`next.config.*`), and an
65
+ * auth middleware that explicitly exempts the optimizer's own route
66
+ * (`middleware.*`). Neither file alone shows the hole; grep for either one in
67
+ * isolation reads as routine Next.js configuration.
68
+ */
69
+ export const nextImageSsrfChecker = {
70
+ id: 'next-image-ssrf',
71
+ title: 'Next.js image optimizer open to any host',
72
+ level: 0,
73
+ run(ctx) {
74
+ const configFiles = ctx.files.filter((f) => NEXT_CONFIG_RE.test(f.rel));
75
+ const middlewareFiles = ctx.files.filter((f) => MIDDLEWARE_RE.test(f.rel));
76
+ const wildcardHits = [];
77
+ // Comments never count as config: `// hostname: '**' — SSRF risk, do not
78
+ // do this` in a file that sets a real, specific hostname must not fire.
79
+ for (const f of configFiles) {
80
+ const masked = maskCode(f.content, { file: f.rel });
81
+ for (const index of findWildcardRemotePatterns(masked))
82
+ wildcardHits.push({ file: f.rel, index });
83
+ }
84
+ if (wildcardHits.length === 0)
85
+ return { findings: [] };
86
+ const excludedHits = [];
87
+ for (const f of middlewareFiles) {
88
+ const masked = maskCode(f.content, { file: f.rel });
89
+ for (const index of findImageExcludedMatchers(masked))
90
+ excludedHits.push({ file: f.rel, index });
91
+ }
92
+ // Pair a config hit with a middleware hit when there is only one of each
93
+ // in the whole project (the common single-app case), or when one file's
94
+ // app root is a prefix of the other's (a monorepo with `apps/<name>/`).
95
+ const pairFor = (configFile) => {
96
+ if (excludedHits.length === 1)
97
+ return excludedHits[0];
98
+ const root = appRootOf(configFile);
99
+ return excludedHits.find((h) => {
100
+ const hRoot = appRootOf(h.file);
101
+ return hRoot === root || hRoot.startsWith(root ? `${root}/` : '') || (root.startsWith(hRoot ? `${hRoot}/` : '') && hRoot !== '');
102
+ });
103
+ };
104
+ const findings = [];
105
+ for (const hit of wildcardHits) {
106
+ const file = ctx.files.find((f) => f.rel === hit.file);
107
+ const line = file ? lineAt(file.content, hit.index) : 1;
108
+ const paired = configFiles.length === 1 ? excludedHits[0] : pairFor(hit.file);
109
+ if (paired) {
110
+ findings.push({
111
+ id: 'next_image_ssrf_unauth',
112
+ severity: 'critical',
113
+ title: 'Next.js image optimizer is open to any host AND reachable without auth',
114
+ detail: `${hit.file}:${line} allows the built-in image optimizer (${'/_next/image'}) to fetch an https URL from ANY host (a wildcard \`hostname\` in \`remotePatterns\`). ${paired.file} explicitly excludes \`_next/image\` from its auth matcher, so any unauthenticated caller can make this server fetch an attacker-chosen URL — a classic SSRF (internal services, cloud metadata endpoints, private-network scanning), and a live vector for whatever RCE the installed \`next\`/\`sharp\` version carries in its image decoder. One \`GET\` request against the real path confirms this without needing to read either file in isolation.`,
115
+ fix: 'Restrict `images.remotePatterns` to the specific hosts you actually serve images from — never a bare `\'*\'`/`\'**\'` hostname. If the optimizer must stay open to arbitrary hosts, remove `_next/image` from the middleware matcher exclusion so it is not reachable pre-auth. Also keep `next` and `sharp` on a current, patched version — the wildcard host and the decoder CVEs compound each other.',
116
+ checker: 'next-image-ssrf',
117
+ level: 0,
118
+ file: hit.file,
119
+ line,
120
+ });
121
+ continue;
122
+ }
123
+ findings.push({
124
+ id: 'next_image_ssrf_open',
125
+ severity: 'warning',
126
+ title: 'Next.js image optimizer accepts any remote host',
127
+ detail: `${hit.file}:${line} sets a wildcard \`hostname\` in \`images.remotePatterns\`, so the built-in image optimizer will fetch an https URL from any host a caller supplies. This is an SSRF primitive on its own — internal services, cloud metadata endpoints, and the private network become reachable through this server — independent of whether \`_next/image\` also sits behind auth.`,
128
+ fix: 'List the specific hosts you actually serve images from in `remotePatterns` instead of a bare `\'*\'`/`\'**\'` hostname. If the route is not behind auth, this is worth fixing regardless of the current `next`/`sharp` version.',
129
+ checker: 'next-image-ssrf',
130
+ level: 0,
131
+ file: hit.file,
132
+ line,
133
+ });
134
+ }
135
+ return { findings };
136
+ },
137
+ };
@@ -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;