Skip to content
SlashClone
Made by@shipcheck

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.

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