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.
- package/README.en.md +10 -2
- package/README.md +11 -2
- package/dist/cli/index.js +27 -6
- package/dist/engine/checkers/deep/deps.js +198 -54
- package/dist/engine/checkers/index.js +2 -0
- package/dist/engine/checkers/static/client-exposure.js +16 -3
- package/dist/engine/checkers/static/config-risks.js +12 -2
- package/dist/engine/checkers/static/next-image-ssrf.js +137 -0
- package/dist/engine/checkers/static/rls-migrations.js +112 -44
- package/dist/engine/checkers/static/secrets.js +372 -291
- package/dist/engine/detect.js +17 -3
- package/dist/engine/util/git-exposure.js +41 -0
- package/dist/engine/util/text.js +25 -1
- package/dist/engine/walk.js +22 -12
- package/package.json +1 -1
|
@@ -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
|
-
|
|
685
|
-
|
|
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
|
-
|
|
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) ?? {
|
|
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 (
|
|
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 === '
|
|
809
|
-
|
|
810
|
-
?
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
: `Table "${s.display}"
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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;
|