Supabase rls audit
@shipcheck/supabase-rls-audit
Audit row level security in your Supabase migrations before you launch. Finds tables with RLS off, policies that let anyone write, policies that trust user_metadata, views that skip RLS, and risky SECURITY DEFINER functions, then drafts a fix migration for you to review. Reads .sql files only and never touches your database.
- Activity
- New: be the first to try it
- Installs
- 0 installs
- Likes
- 0 likes
- Clones
- 0 clones
- 1#!/usr/bin/env node
- 2// Audit Supabase row-level security from migration files.
- 3// Reads every .sql file in a folder (default supabase/migrations) in name order,
- 4// replays CREATE TABLE, ALTER TABLE ... ROW LEVEL SECURITY, CREATE/DROP POLICY,
- 5// CREATE VIEW and SECURITY DEFINER functions, and reports risky end states.
- 6// Node built-ins only. Never connects to a database.
- 7//
- 8// Usage:
- 9// node scripts/rls-audit.mjs [migrations-dir] [--json out/rls-audit.json]
- 10// [--md out/rls-audit.md] [--fail-on high|medium|low]
- 12import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
- 13import { dirname, join } from "node:path";
- 15const SEVERITY_ORDER = ["high", "medium", "low", "info"];
- 16const SKIP_SCHEMAS = new Set([
- 17 "auth",
- 18 "storage",
- 19 "extensions",
- 20 "realtime",
- 21 "supabase_functions",
- 22 "graphql",
- 23 "graphql_public",
- 24 "pgsodium",
- 25 "vault",
- 26 "net",
- 27 "cron",
- 28]);
- 29const CLIENT_ROLES = new Set(["public", "anon", "authenticated"]);
- 31function parseArgs(argv) {
- 32 const args = { dir: "supabase/migrations", json: null, md: null, failOn: null };
- 33 const rest = [];
- 34 for (let i = 0; i < argv.length; i++) {
- 35 const a = argv[i];
- 36 if (a === "--json") args.json = argv[++i];
- 37 else if (a === "--md") args.md = argv[++i];
- 38 else if (a === "--fail-on") args.failOn = argv[++i];
- 39 else if (a === "-h" || a === "--help") {
- 40 console.log(
- 41 "Usage: node scripts/rls-audit.mjs [migrations-dir] [--json file] [--md file] [--fail-on high|medium|low]",
- 42 );
- 43 process.exit(0);
- 44 } else rest.push(a);
- 45 }
- 46 if (rest[0]) args.dir = rest[0];
- 47 if (args.failOn && !SEVERITY_ORDER.includes(args.failOn)) {
- 48 console.error(`--fail-on must be one of high, medium, low (got "${args.failOn}")`);
- 49 process.exit(2);
- 50 }
- 51 return args;
- 52}
- 54/** Remove comments and split into statements, respecting quotes and $tag$ bodies. */
- 55export function splitStatements(sql) {
- 56 const out = [];
- 57 let cur = "";
- 58 let i = 0;
- 59 while (i < sql.length) {
- 60 const ch = sql[i];
- 61 const next = sql[i + 1];
- 62 if (ch === "-" && next === "-") {
- 63 while (i < sql.length && sql[i] !== "\n") i++;
- 64 continue;
- 65 }
- 66 if (ch === "/" && next === "*") {
- 67 const end = sql.indexOf("*/", i + 2);
- 68 i = end === -1 ? sql.length : end + 2;
- 69 cur += " ";
- 70 continue;
- 71 }
- 72 if (ch === "'") {
- 73 let j = i + 1;
- 74 while (j < sql.length) {
- 75 if (sql[j] === "'" && sql[j + 1] === "'") j += 2;
- 76 else if (sql[j] === "'") break;
- 77 else j++;
- 78 }
- 79 cur += sql.slice(i, j + 1);
- 80 i = j + 1;
- 81 continue;
- 82 }
- 83 if (ch === "$") {
- 84 const m = /^\$[A-Za-z_]*\$/.exec(sql.slice(i));
- 85 if (m) {
- 86 const tag = m[0];
- 87 const end = sql.indexOf(tag, i + tag.length);
- 88 const stop = end === -1 ? sql.length : end + tag.length;
- 89 cur += sql.slice(i, stop);
- 90 i = stop;
- 91 continue;
- 92 }
- 93 }
- 94 if (ch === ";") {
- 95 if (cur.trim()) out.push(cur.trim());
- 96 cur = "";
- 97 i++;
- 98 continue;
- 99 }
- 100 cur += ch;
- 101 i++;
- 102 }
- 103 if (cur.trim()) out.push(cur.trim());
- 104 return out;
- 105}
- 107const IDENT = String.raw`(?:"[^"]+"|[A-Za-z_][A-Za-z0-9_$]*)`;
- 108const QNAME = String.raw`(${IDENT}(?:\s*\.\s*${IDENT})?)`;
- 110function normName(raw) {
- 111 const parts = raw.split(".").map((p) => p.trim().replace(/^"|"$/g, ""));
- 112 const [schema, name] = parts.length === 2 ? parts : ["public", parts[0]];
- 113 return { schema: schema.toLowerCase(), name: name.toLowerCase(), key: `${schema.toLowerCase()}.${name.toLowerCase()}` };
- 114}
- 116/** Text inside the parenthesis that starts at or after `from`, balanced. */
- 117function balanced(text, from) {
- 118 const start = text.indexOf("(", from);
- 119 if (start === -1) return null;
- 120 let depth = 0;
- 121 for (let i = start; i < text.length; i++) {
- 122 if (text[i] === "(") depth++;
- 123 else if (text[i] === ")") {
- 124 depth--;
- 125 if (depth === 0) return text.slice(start + 1, i).trim();
- 126 }
- 127 }
- 128 return text.slice(start + 1).trim();
- 129}
- 131function clauseExpr(stmt, keyword) {
- 132 const re = new RegExp(String.raw`\b${keyword}\s*\(`, "i");
- 133 const m = re.exec(stmt);
- 134 return m ? balanced(stmt, m.index) : null;
- 135}
- 137function simplify(expr) {
- 138 if (expr === null) return null;
- 139 let e = expr.replace(/\s+/g, " ").trim().toLowerCase();
- 140 while (e.startsWith("(") && balanced(e, 0) === e.slice(1, -1).trim()) e = e.slice(1, -1).trim();
- 141 return e;
- 142}
- 144const ALWAYS_TRUE = new Set(["true", "1 = 1", "1=1", "'t'", "true = true"]);
- 145const isAlwaysTrue = (e) => e !== null && ALWAYS_TRUE.has(e);
- 146const isOnlySignedIn = (e) =>
- 147 e !== null &&
- 148 /^(?:auth\.role\(\) ?= ?'authenticated'|auth\.uid\(\) is not null|\(select auth\.uid\(\)\) is not null|auth\.jwt\(\) is not null)$/.test(
- 149 e,
- 150 );
- 151const readsUserMetadata = (e) => e !== null && /user_metadata/.test(e);
- 153export function audit(files) {
- 154 const tables = new Map();
- 155 const views = [];
- 156 const functions = [];
- 157 const policies = new Map(); // key: table|policy
- 159 const table = (key, file) => {
- 160 if (!tables.has(key)) tables.set(key, { key, rls: false, forced: false, file, rlsFile: null });
- 161 return tables.get(key);
- 162 };
- 164 for (const { name: file, sql } of files) {
- 165 for (const stmt of splitStatements(sql)) {
- 166 const s = stmt.replace(/\s+/g, " ");
- 167 let m;
- 168 if ((m = new RegExp(String.raw`^create (?:unlogged )?table (?:if not exists )?${QNAME}`, "i").exec(s))) {
- 169 const n = normName(m[1]);
- 170 if (!SKIP_SCHEMAS.has(n.schema)) table(n.key, file);
- 171 continue;
- 172 }
- 173 if ((m = new RegExp(String.raw`^drop table (?:if exists )?${QNAME}`, "i").exec(s))) {
- 174 const n = normName(m[1]);
- 175 tables.delete(n.key);
- 176 for (const k of [...policies.keys()]) if (k.startsWith(`${n.key}|`)) policies.delete(k);
- 177 continue;
- 178 }
- 179 if ((m = new RegExp(String.raw`^alter table (?:if exists )?(?:only )?${QNAME} rename to (${IDENT})`, "i").exec(s))) {
- 180 const from = normName(m[1]);
- 181 const to = normName(`${from.schema}.${m[2]}`);
- 182 const t = tables.get(from.key);
- 183 if (t) {
- 184 tables.delete(from.key);
- 185 t.key = to.key;
- 186 tables.set(to.key, t);
- 187 }
- 188 for (const [k, p] of [...policies.entries()]) {
- 189 if (k.startsWith(`${from.key}|`)) {
- 190 policies.delete(k);
- 191 p.table = to.key;
- 192 policies.set(`${to.key}|${p.name}`, p);
- 193 }
- 194 }
- 195 continue;
- 196 }
- 197 if ((m = new RegExp(String.raw`^alter table (?:if exists )?(?:only )?${QNAME} (enable|disable|force|no force) row level security`, "i").exec(s))) {
- 198 const n = normName(m[1]);
- 199 if (SKIP_SCHEMAS.has(n.schema)) continue;
- 200 const t = table(n.key, file);
- 201 const action = m[2].toLowerCase();
- 202 if (action === "enable") {
- 203 t.rls = true;
- 204 t.rlsFile = file;
- 205 } else if (action === "disable") {
- 206 t.rls = false;
- 207 t.rlsFile = file;
- 208 } else if (action === "force") t.forced = true;
- 209 else t.forced = false;
- 210 continue;
- 211 }
- 212 if ((m = new RegExp(String.raw`^create policy (${IDENT}) on ${QNAME}`, "i").exec(s))) {
- 213 const pname = m[1].replace(/^"|"$/g, "");
- 214 const n = normName(m[2]);
- 215 if (SKIP_SCHEMAS.has(n.schema)) continue;
- 216 const after = s.slice(m.index + m[0].length);
- 217 const cmd = (/\bfor (all|select|insert|update|delete)\b/i.exec(after)?.[1] ?? "all").toLowerCase();
- 218 const restrictive = /\bas restrictive\b/i.test(after);
- 219 const toMatch = /\bto ((?:[A-Za-z_"][\w"]*\s*,\s*)*[A-Za-z_"][\w"]*)/i.exec(after);
- 220 const roles = toMatch
- 221 ? toMatch[1].split(",").map((r) => r.trim().replace(/"/g, "").toLowerCase())
- 222 : ["public"];
- 223 policies.set(`${n.key}|${pname}`, {
- 224 table: n.key,
- 225 name: pname,
- 226 cmd,
- 227 roles,
- 228 restrictive,
- 229 using: simplify(clauseExpr(after, "using")),
- 230 check: simplify(clauseExpr(after, "with check")),
- 231 file,
- 232 });
- 233 continue;
- 234 }
- 235 if ((m = new RegExp(String.raw`^drop policy (?:if exists )?(${IDENT}) on ${QNAME}`, "i").exec(s))) {
- 236 const pname = m[1].replace(/^"|"$/g, "");
- 237 const n = normName(m[2]);
- 238 policies.delete(`${n.key}|${pname}`);
- 239 continue;
- 240 }
- 241 if ((m = new RegExp(String.raw`^create (?:or replace )?(?:materialized )?view (?:if not exists )?${QNAME}`, "i").exec(s))) {
- 242 const n = normName(m[1]);
- 243 if (SKIP_SCHEMAS.has(n.schema)) continue;
- 244 const invoker = /security_invoker\s*=\s*(?:true|on|1)/i.test(s);
- 245 const idx = views.findIndex((v) => v.key === n.key);
- 246 const v = { key: n.key, invoker, materialized: /materialized view/i.test(s), file };
- 247 if (idx === -1) views.push(v);
- 248 else views[idx] = v;
- 249 continue;
- 250 }
- 251 if ((m = new RegExp(String.raw`^create (?:or replace )?function ${QNAME}`, "i").exec(s))) {
- 252 const n = normName(m[1]);
- 253 if (SKIP_SCHEMAS.has(n.schema)) continue;
- 254 if (/\bsecurity definer\b/i.test(s)) {
- 255 const pinned = /\bset search_path\b/i.test(s);
- 256 const idx = functions.findIndex((f) => f.key === n.key);
- 257 const f = { key: n.key, pinned, file };
- 258 if (idx === -1) functions.push(f);
- 259 else functions[idx] = f;
- 260 }
- 261 continue;
- 262 }
- 263 }
- 264 }
- 266 const findings = [];
- 267 const add = (severity, rule, target, message, fix, file) =>
- 268 findings.push({ severity, rule, target, message, fix, file });
- 270 const byTable = new Map();
- 271 for (const p of policies.values()) {
- 272 if (!byTable.has(p.table)) byTable.set(p.table, []);
- 273 byTable.get(p.table).push(p);
- 274 }
- 276 for (const t of tables.values()) {
- 277 const ps = byTable.get(t.key) ?? [];
- 278 if (!t.rls) {
- 279 add(
- 280 "high",
- 281 "rls-disabled",
- 282 t.key,
- 283 "Row level security is off. Anyone with your public anon key can read and change every row through the API.",
- 284 `alter table ${t.key} enable row level security;`,
- 285 t.rlsFile ?? t.file,
- 286 );
- 287 } else if (ps.length === 0) {
- 288 add(
- 289 "info",
- 290 "rls-no-policies",
- 291 t.key,
- 292 "RLS is on with no policies, so only the service role can reach it. Fine for server-only tables; add policies if the app reads it from the browser.",
- 293 null,
- 294 t.rlsFile ?? t.file,
- 295 );
- 296 }
- 297 }
- 299 for (const p of policies.values()) {
- 300 const target = `${p.table} / ${p.name}`;
- 301 const clientRoles = p.roles.filter((r) => CLIENT_ROLES.has(r));
- 302 if (clientRoles.length === 0 || p.restrictive) continue;
- 303 const anyone = clientRoles.includes("public") || clientRoles.includes("anon");
- 304 const who = anyone ? "anyone, signed in or not," : "any signed-in user";
- 305 const writes = p.cmd !== "select";
- 306 const openUsing = isAlwaysTrue(p.using);
- 307 const openCheck = isAlwaysTrue(p.check);
- 308 if (p.cmd === "insert" && openCheck) {
- 309 add(
- 310 "medium",
- 311 "policy-open-insert",
- 312 target,
- 313 `FOR INSERT with WITH CHECK (true) lets ${who} insert any row, including rows that claim to belong to someone else. Fine for a public form; risky if the table has an owner or status column.`,
- 314 "If rows have an owner, use with check ((select auth.uid()) = user_id). Add a rate limit for public forms.",
- 315 p.file,
- 316 );
- 317 } else if (writes && (openUsing || openCheck)) {
- 318 add(
- 319 "high",
- 320 "policy-open-write",
- 321 target,
- 322 `FOR ${p.cmd.toUpperCase()} with ${openCheck ? "WITH CHECK (true)" : "USING (true)"} lets ${who} ${p.cmd === "all" ? "read and change" : p.cmd} every row.`,
- 323 "Scope it to the owner, for example using ((select auth.uid()) = user_id) with check ((select auth.uid()) = user_id).",
- 324 p.file,
- 325 );
- 326 } else if (writes && (isOnlySignedIn(p.using) || isOnlySignedIn(p.check))) {
- 327 add(
- 328 "high",
- 329 "policy-any-user-write",
- 330 target,
- 331 `FOR ${p.cmd.toUpperCase()} only checks that someone is signed in, so any user can ${p.cmd === "all" ? "read and change" : p.cmd} other users' rows.`,
- 332 "Compare the row's owner column with (select auth.uid()) instead.",
- 333 p.file,
- 334 );
- 335 } else if (!writes && openUsing && anyone) {
- 336 add(
- 337 "medium",
- 338 "policy-public-read",
- 339 target,
- 340 "USING (true) for anon or public: the whole table is readable by anyone on the internet. Fine for truly public data (blog posts, pricing), a leak for anything else.",
- 341 "If it is not meant to be public, restrict to authenticated and scope to the owner.",
- 342 p.file,
- 343 );
- 344 } else if (!writes && (openUsing || isOnlySignedIn(p.using))) {
- 345 add(
- 346 "low",
- 347 "policy-all-users-read",
- 348 target,
- 349 "Every signed-in user can read every row. Check that no row holds another user's private data.",
- 350 "Scope to the owner or to a team membership table if rows are private.",
- 351 p.file,
- 352 );
- 353 }
- 354 if (readsUserMetadata(p.using) || readsUserMetadata(p.check)) {
- 355 add(
- 356 "high",
- 357 "policy-user-metadata",
- 358 target,
- 359 "The policy trusts user_metadata, which users can edit themselves. Anyone can grant themselves this access.",
- 360 "Use app_metadata (set only by the server) or a roles table instead.",
- 361 p.file,
- 362 );
- 363 }
- 364 }
- 366 for (const v of views) {
- 367 if (v.invoker) continue;
- 368 add(
- 369 "medium",
- 370 v.materialized ? "materialized-view-no-rls" : "view-security-definer",
- 371 v.key,
- 372 v.materialized
- 373 ? "Materialized views ignore RLS. If it is in an exposed schema, anyone with the anon key can read it."
- 374 : "This view runs with its creator's rights, so it skips the RLS of the tables it reads.",
- 375 v.materialized
- 376 ? `revoke select on ${v.key} from anon, authenticated; (or move it to a private schema)`
- 377 : `alter view ${v.key} set (security_invoker = true);`,
- 378 v.file,
- 379 );
- 380 }
- 382 for (const f of functions) {
- 383 add(
- 384 f.pinned ? "low" : "medium",
- 385 "security-definer-function",
- 386 f.key,
- 387 f.pinned
- 388 ? "SECURITY DEFINER function: it skips RLS. Check it only touches rows the caller may touch."
- 389 : "SECURITY DEFINER function without a fixed search_path: it skips RLS and can be hijacked through the search path.",
- 390 f.pinned ? null : "Add set search_path = '' and schema-qualify every name in the body.",
- 391 f.file,
- 392 );
- 393 }
- 395 findings.sort(
- 396 (a, b) => SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity) || a.target.localeCompare(b.target),
- 397 );
- 398 const counts = Object.fromEntries(SEVERITY_ORDER.map((s) => [s, findings.filter((f) => f.severity === s).length]));
- 399 const tableList = [...tables.values()]
- 400 .map((t) => ({ table: t.key, rls: t.rls, policies: (byTable.get(t.key) ?? []).length }))
- 401 .sort((a, b) => a.table.localeCompare(b.table));
- 402 return {
- 403 summary: {
- 404 files: files.length,
- 405 tables: tableList.length,
- 406 rls_enabled: tableList.filter((t) => t.rls).length,
- 407 ...counts,
- 408 },
- 409 tables: tableList,
- 410 findings,
- 411 };
- 412}
- 414function toMarkdown(report, dir) {
- 415 const s = report.summary;
- 416 const lines = [
- 417 `# Supabase RLS audit: ${dir}`,
- 418 "",
- 419 `${s.files} migration files, ${s.tables} tables, ${s.rls_enabled} with RLS on.`,
- 420 `High: ${s.high} | Medium: ${s.medium} | Low: ${s.low} | Info: ${s.info}`,
- 421 "",
- 422 "## Tables",
- 423 "",
- 424 "| Table | RLS | Policies |",
- 425 "| --- | --- | --- |",
- 426 ...report.tables.map((t) => `| ${t.table} | ${t.rls ? "on" : "**OFF**"} | ${t.policies} |`),
- 427 "",
- 428 ];
- 429 for (const sev of SEVERITY_ORDER) {
- 430 const list = report.findings.filter((f) => f.severity === sev);
- 431 if (list.length === 0) continue;
- 432 lines.push(`## ${sev[0].toUpperCase()}${sev.slice(1)}`, "");
- 433 for (const f of list) {
- 434 lines.push(`- **${f.target}** (${f.rule}, ${f.file}): ${f.message}`);
- 435 if (f.fix) lines.push(/^(?:alter|revoke|create|drop) /.test(f.fix) ? ` - Fix: \`${f.fix}\`` : ` - Fix: ${f.fix}`);
- 436 }
- 437 lines.push("");
- 438 }
- 439 if (report.findings.length === 0) lines.push("No findings. Nice.", "");
- 440 return lines.join("\n");
- 441}
- 443function write(path, text) {
- 444 mkdirSync(dirname(path), { recursive: true });
- 445 writeFileSync(path, text);
- 446}
- 448function main() {
- 449 const args = parseArgs(process.argv.slice(2));
- 450 if (!existsSync(args.dir) || !statSync(args.dir).isDirectory()) {
- 451 console.error(`No migrations folder at ${args.dir}. Pass the folder that holds your .sql migrations, for example supabase/migrations.`);
- 452 process.exit(2);
- 453 }
- 454 const names = readdirSync(args.dir).filter((f) => f.endsWith(".sql")).sort();
- 455 if (names.length === 0) {
- 456 console.error(`No .sql files in ${args.dir}.`);
- 457 process.exit(2);
- 458 }
- 459 const files = names.map((name) => ({ name, sql: readFileSync(join(args.dir, name), "utf8") }));
- 460 const report = audit(files);
- 461 const md = toMarkdown(report, args.dir);
- 462 if (args.json) write(args.json, JSON.stringify(report, null, 2) + "\n");
- 463 if (args.md) write(args.md, md);
- 464 if (!args.json && !args.md) console.log(md);
- 465 else {
- 466 const s = report.summary;
- 467 console.log(`${s.tables} tables, ${s.rls_enabled} with RLS. High ${s.high}, medium ${s.medium}, low ${s.low}, info ${s.info}.`);
- 468 }
- 469 if (args.failOn) {
- 470 const limit = SEVERITY_ORDER.indexOf(args.failOn);
- 471 if (report.findings.some((f) => SEVERITY_ORDER.indexOf(f.severity) <= limit)) process.exit(1);
- 472 }
- 473}
- 475main();
