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 +4 -0
- package/dist/cli/index.js +42 -4
- package/dist/engine/checkers/backend/firebase.js +14 -4
- package/dist/engine/checkers/backend/supabase.js +5 -1
- 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/rls-migrations.js +128 -34
- package/dist/engine/checkers/static/secrets.js +366 -219
- package/dist/engine/detect.js +21 -3
- package/dist/engine/util/text.js +26 -0
- package/dist/engine/walk.js +34 -8
- package/package.json +1 -1
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:
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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))['"]
|
|
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
|
-
|
|
659
|
-
|
|
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
|
|
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
|
-
|
|
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) ?? {
|
|
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 (
|
|
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 === '
|
|
783
|
-
|
|
784
|
-
?
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
: `Table "${s.display}"
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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;
|