@rebasepro/rls-check 0.13.0 → 0.13.1-canary.g18cfeb7

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.
@@ -56,3 +56,17 @@ export declare function finding(f: Omit<Finding, "docs"> & {
56
56
  docs?: string;
57
57
  }): Finding;
58
58
  export declare const listAnd: (items: string[]) => string;
59
+ /**
60
+ * How to spell "the id of the current caller" in a fix suggestion, for the
61
+ * platform this database actually is.
62
+ *
63
+ * Every remediation in this tool prints example SQL, and example SQL that calls
64
+ * a function the reader's database does not have is worse than no example — it
65
+ * looks authoritative and fails on paste. Rebase's helpers live in `rebase`
66
+ * (they were in `auth` before 1.0, which is Supabase's schema and the reason
67
+ * they moved); Supabase and PostgREST stacks have `auth.uid()`.
68
+ *
69
+ * `unknown` falls back to the Supabase spelling: this scanner is pointed at
70
+ * plenty of databases Rebase did not create, and that is the wider convention.
71
+ */
72
+ export declare function callerIdCall(snapshot: DbSnapshot): string;
package/dist/index.es.js CHANGED
@@ -488,6 +488,22 @@ function finding(f) {
488
488
  };
489
489
  }
490
490
  var listAnd = (items) => items.length <= 1 ? items[0] ?? "" : `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
491
+ /**
492
+ * How to spell "the id of the current caller" in a fix suggestion, for the
493
+ * platform this database actually is.
494
+ *
495
+ * Every remediation in this tool prints example SQL, and example SQL that calls
496
+ * a function the reader's database does not have is worse than no example — it
497
+ * looks authoritative and fails on paste. Rebase's helpers live in `rebase`
498
+ * (they were in `auth` before 1.0, which is Supabase's schema and the reason
499
+ * they moved); Supabase and PostgREST stacks have `auth.uid()`.
500
+ *
501
+ * `unknown` falls back to the Supabase spelling: this scanner is pointed at
502
+ * plenty of databases Rebase did not create, and that is the wider convention.
503
+ */
504
+ function callerIdCall(snapshot) {
505
+ return snapshot.platform === "rebase" ? "rebase.uid()" : "auth.uid()";
506
+ }
491
507
  //#endregion
492
508
  //#region src/checks/anonymous-write-allowed.ts
493
509
  var ID$13 = "anonymous-write-allowed";
@@ -526,6 +542,7 @@ var anonymousWriteAllowed = {
526
542
  title: "Unauthenticated callers can write",
527
543
  description: "A permissive INSERT/UPDATE/DELETE policy reachable without authentication whose check expression accepts any row, backed by a matching grant.",
528
544
  run(snapshot) {
545
+ const uidCall = callerIdCall(snapshot);
529
546
  const findings = [];
530
547
  for (const policy of snapshot.policies) {
531
548
  if (!snapshot.schemas.includes(policy.schema)) continue;
@@ -559,7 +576,7 @@ var anonymousWriteAllowed = {
559
576
  },
560
577
  detail: `Policy "${policy.name}" is a permissive ${policy.command} policy for ${listAnd(grantedTo)}, and its check expression ${policy.using == null && policy.withCheck == null ? "is absent, which Postgres treats as accepting every row" : "is a constant truth, so every row satisfies it"}. ${listAnd(grantedTo)} also ${grantedTo.length > 1 ? "hold" : "holds"} ${listAnd(commands)} on the table, so both the privilege check and the row check pass for a request that carries no credentials.`,
561
578
  impact: `An unauthenticated caller reaching this database over an API can ${listAnd(verbs)} rows in ${policy.schema}.${policy.table} at will — inserting records attributed to other users, or ${commands.includes("DELETE") ? "deleting the table's contents" : "modifying rows they do not own"}.`,
562
- fix: `-- Scope the write to the caller, or take the privilege away entirely:\nALTER POLICY ${qi(policy.name)} ON ${qrel(policy.schema, policy.table)}\n WITH CHECK (user_id = auth.uid());\n-- and if anonymous writes are never intended:\nREVOKE ${commands.join(", ")} ON ${qrel(policy.schema, policy.table)} FROM ${grantedTo.map((r) => r === "PUBLIC" ? "PUBLIC" : `"${r}"`).join(", ")};`
579
+ fix: `-- Scope the write to the caller, or take the privilege away entirely:\nALTER POLICY ${qi(policy.name)} ON ${qrel(policy.schema, policy.table)}\n WITH CHECK (user_id = ${uidCall});\n-- and if anonymous writes are never intended:\nREVOKE ${commands.join(", ")} ON ${qrel(policy.schema, policy.table)} FROM ${grantedTo.map((r) => r === "PUBLIC" ? "PUBLIC" : `"${r}"`).join(", ")};`
563
580
  }));
564
581
  }
565
582
  return findings;
@@ -902,6 +919,7 @@ var policyAlwaysTrue = {
902
919
  title: "Policy grants unconditional access",
903
920
  description: "A permissive policy whose USING or WITH CHECK expression is always true.",
904
921
  run(snapshot) {
922
+ const uidCall = callerIdCall(snapshot);
905
923
  const findings = [];
906
924
  for (const policy of snapshot.policies) {
907
925
  if (!snapshot.schemas.includes(policy.schema)) continue;
@@ -928,7 +946,7 @@ var policyAlwaysTrue = {
928
946
  },
929
947
  detail: `This permissive ${policy.command} policy's ${listAnd(clauses)} expression is a constant truth, so it matches every row for ${listAnd(exposed)}. Permissive policies are ORed together, so this one alone satisfies the table's row filter no matter how strict the others are.` + (gate ? ` A RESTRICTIVE policy ("${gate}") also applies to this command and is ANDed after it, so access may still be gated — verify that restrictive policy covers the rows and roles you expect, because nothing else here does.` : ""),
930
948
  impact: gate ? `Row filtering on this table rests entirely on the RESTRICTIVE policy "${gate}". If it does not cover a case, ${listAnd(exposed)} can ${verb} every row${rowsPhrase(rel)}.` : `If this table is reachable over an API as ${listAnd(exposed)}, a caller can ${verb} every row${rowsPhrase(rel)} — the policy applies no scoping whatsoever.`,
931
- fix: `-- Replace the constant with the scoping you intended, e.g.:\nALTER POLICY ${qi(policy.name)} ON ${qrel(policy.schema, policy.table)}\n ${clauses.includes("USING") ? "USING (user_id = auth.uid())" : "WITH CHECK (user_id = auth.uid())"};\n-- or, if unconditional access really is intended, drop the policy and say so\n-- with an explicit grant instead:\n-- DROP POLICY ${qi(policy.name)} ON ${qrel(policy.schema, policy.table)};`
949
+ fix: `-- Replace the constant with the scoping you intended, e.g.:\nALTER POLICY ${qi(policy.name)} ON ${qrel(policy.schema, policy.table)}\n ${clauses.includes("USING") ? `USING (user_id = ${uidCall})` : `WITH CHECK (user_id = ${uidCall})`};\n-- or, if unconditional access really is intended, drop the policy and say so\n-- with an explicit grant instead:\n-- DROP POLICY ${qi(policy.name)} ON ${qrel(policy.schema, policy.table)};`
932
950
  }));
933
951
  }
934
952
  return findings;
@@ -967,6 +985,7 @@ var policyAnonymousTautology = {
967
985
  title: "Policy only checks that a caller id exists",
968
986
  description: "A policy whose expression is `auth.uid() IS NOT NULL`-shaped: it separates signed-in from signed-out callers but scopes no rows.",
969
987
  run(snapshot) {
988
+ const uidCall = callerIdCall(snapshot);
970
989
  const findings = [];
971
990
  const { severity, meaning, impactSuffix } = platformReading(snapshot.platform);
972
991
  for (const policy of snapshot.policies) {
@@ -992,7 +1011,7 @@ var policyAnonymousTautology = {
992
1011
  },
993
1012
  detail: `The ${listAnd(clauses)} expression of this ${policy.command} policy tests only that ${shape} is non-null. It does not compare anything to a column, so every row of the table satisfies it equally — the policy distinguishes signed-in from signed-out callers and nothing else. ${meaning}`,
994
1013
  impact: `Any caller for whom ${shape} is non-null can reach every row this policy covers, including rows belonging to other users or tenants. ${impactSuffix}`,
995
- fix: `-- Scope the policy to the row's owner rather than to the existence of an id:\nALTER POLICY ${qi(policy.name)} ON ${qrel(policy.schema, policy.table)}\n USING (user_id = auth.uid());\n-- If the intent really is "any signed-in user", make that explicit and make it\n-- reject the anonymous sentinel your stack may use:\n-- USING (auth.uid() IS NOT NULL AND auth.uid() <> 'anonymous');`
1014
+ fix: `-- Scope the policy to the row's owner rather than to the existence of an id:\nALTER POLICY ${qi(policy.name)} ON ${qrel(policy.schema, policy.table)}\n USING (user_id = ${uidCall});\n-- If the intent really is "any signed-in user", make that explicit and make it\n-- reject the anonymous sentinel your stack may use:\n-- USING (${uidCall} IS NOT NULL AND ${uidCall} <> 'anonymous');`
996
1015
  }));
997
1016
  }
998
1017
  return findings;
@@ -1018,18 +1037,31 @@ function platformReading(platform) {
1018
1037
  };
1019
1038
  }
1020
1039
  }
1040
+ /**
1041
+ * Both schema spellings, deliberately.
1042
+ *
1043
+ * This runs over policy bodies read back from a live database, and those outlive
1044
+ * the release that wrote them: Rebase moved its helpers from `auth` to `rebase`
1045
+ * at 1.0, so a database mid-migration holds both, and a Supabase database holds
1046
+ * `auth.*` for good. A security check that stops recognising a dangerous clause
1047
+ * because a schema was renamed is a check that silently turned itself off.
1048
+ */
1021
1049
  var CALLER_ID_CALLS = [
1022
1050
  {
1023
- re: /auth\.uid\s*\(\s*\)/g,
1024
- label: () => "auth.uid()"
1051
+ re: /(?:rebase|auth)\.uid\s*\(\s*\)/g,
1052
+ label: (m) => m[0].replace(/\s+/g, "")
1025
1053
  },
1026
1054
  {
1027
1055
  re: /auth\.role\s*\(\s*\)/g,
1028
1056
  label: () => "auth.role()"
1029
1057
  },
1030
1058
  {
1031
- re: /auth\.jwt\s*\(\s*\)/g,
1032
- label: () => "auth.jwt()"
1059
+ re: /(?:rebase|auth)\.roles\s*\(\s*\)/g,
1060
+ label: (m) => m[0].replace(/\s+/g, "")
1061
+ },
1062
+ {
1063
+ re: /(?:rebase|auth)\.jwt\s*\(\s*\)/g,
1064
+ label: (m) => m[0].replace(/\s+/g, "")
1033
1065
  },
1034
1066
  {
1035
1067
  re: /current_setting\s*\(\s*('[^']*')\s*(?:,\s*[a-z]+\s*)?\)/g,
@@ -1134,6 +1166,7 @@ var rlsDisabled = {
1134
1166
  title: "Table exposed without row-level security",
1135
1167
  description: "A table with RLS disabled that also grants SELECT/INSERT/UPDATE/DELETE to a role an unauthenticated or untrusted caller can reach.",
1136
1168
  run(snapshot) {
1169
+ const uidCall = callerIdCall(snapshot);
1137
1170
  const findings = [];
1138
1171
  for (const rel of scannedTables(snapshot)) {
1139
1172
  if (rel.rlsEnabled) continue;
@@ -1157,7 +1190,7 @@ var rlsDisabled = {
1157
1190
  },
1158
1191
  detail: `Row-level security is not enabled on this table, so Postgres applies no per-row filter at all — policies, if any exist, are never consulted. ${listAnd(roles)} ${roles.length > 1 ? "hold" : "holds"} ${listAnd(privileges)} on it.`,
1159
1192
  impact: `If this table is reachable over an API that connects as ${listAnd(roles)}, a caller can ${listAnd(reach)}, with no tenant or owner scoping.`,
1160
- fix: `ALTER TABLE ${qrel(rel.schema, rel.name)} ENABLE ROW LEVEL SECURITY;\n-- Enabling RLS with no policies denies every row to everyone but the owner,\n-- so add the policy you intend in the same migration, for example:\n-- CREATE POLICY ${JSON.stringify(`${rel.name}_owner_select`)} ON ${qrel(rel.schema, rel.name)}\n-- FOR SELECT TO ${qrole(roles[0])} USING (user_id = auth.uid());`
1193
+ fix: `ALTER TABLE ${qrel(rel.schema, rel.name)} ENABLE ROW LEVEL SECURITY;\n-- Enabling RLS with no policies denies every row to everyone but the owner,\n-- so add the policy you intend in the same migration, for example:\n-- CREATE POLICY ${JSON.stringify(`${rel.name}_owner_select`)} ON ${qrel(rel.schema, rel.name)}\n-- FOR SELECT TO ${qrole(roles[0])} USING (user_id = ${uidCall});`
1161
1194
  }));
1162
1195
  }
1163
1196
  return findings;
@@ -1179,6 +1212,7 @@ var rlsEnabledNoPolicies = {
1179
1212
  title: "RLS enabled with no policies (denies everything)",
1180
1213
  description: "A table with row-level security enabled but not a single policy defined.",
1181
1214
  run(snapshot) {
1215
+ const uidCall = callerIdCall(snapshot);
1182
1216
  const findings = [];
1183
1217
  for (const rel of scannedTables(snapshot)) {
1184
1218
  if (!rel.rlsEnabled) continue;
@@ -1195,7 +1229,7 @@ var rlsEnabledNoPolicies = {
1195
1229
  },
1196
1230
  detail: `With RLS enabled and no policy defined, Postgres denies every row to every role. ${ownerNote}`,
1197
1231
  impact: "Reads of this table return zero rows and writes are rejected for every non-owner role. No data is exposed; data is silently missing instead, and an empty result is indistinguishable from a correctly filtered one.",
1198
- fix: `-- Either define the policy you intended:\nCREATE POLICY ${JSON.stringify(`${rel.name}_select`)} ON ${qrel(rel.schema, rel.name)}\n FOR SELECT TO authenticated USING (user_id = auth.uid());\n-- or, if this table is genuinely meant to be unreadable, drop the grants\n-- instead of relying on an empty policy set:\n-- REVOKE ALL ON ${qrel(rel.schema, rel.name)} FROM PUBLIC;`
1232
+ fix: `-- Either define the policy you intended:\nCREATE POLICY ${JSON.stringify(`${rel.name}_select`)} ON ${qrel(rel.schema, rel.name)}\n FOR SELECT TO authenticated USING (user_id = ${uidCall});\n-- or, if this table is genuinely meant to be unreadable, drop the grants\n-- instead of relying on an empty policy set:\n-- REVOKE ALL ON ${qrel(rel.schema, rel.name)} FROM PUBLIC;`
1199
1233
  }));
1200
1234
  }
1201
1235
  return findings;
@@ -1673,6 +1707,16 @@ var SYSTEM_SCHEMAS = [
1673
1707
  * by design; a scan that reports forty findings inside them buries the one
1674
1708
  * finding in `public` that actually matters, and the reader concludes the tool
1675
1709
  * is wrong. They are still scannable — `--schema auth` opts back in.
1710
+ *
1711
+ * `rebase` is deliberately NOT on this list, though it is as much "ours" as
1712
+ * `auth` is Supabase's. It held refresh-token hashes, TOTP secrets and API-key
1713
+ * rows with RLS off and full DML granted to `rebase_user` — the exact shape
1714
+ * `rls-disabled` exists to catch — and this exclusion is why no scan ever said
1715
+ * so. Those grants are revoked at creation now (`revokeInternalTableSql`), so
1716
+ * scanning the schema is quiet; if a new internal table ships without its
1717
+ * revoke, `pnpm rls:check` fails instead of a reviewer having to notice. The
1718
+ * checks all gate on a grant to a reachable role, so an unexposed table still
1719
+ * produces nothing.
1676
1720
  */
1677
1721
  var PLATFORM_SCHEMAS = [
1678
1722
  "auth",
@@ -1689,7 +1733,6 @@ var PLATFORM_SCHEMAS = [
1689
1733
  "cron",
1690
1734
  "pgsodium",
1691
1735
  "pgsodium_masks",
1692
- "rebase",
1693
1736
  "drizzle"
1694
1737
  ];
1695
1738
  var RELATION_KINDS = {
@@ -1884,6 +1927,13 @@ async function readSnapshot(client, opts, diagnostics) {
1884
1927
  routines
1885
1928
  };
1886
1929
  }
1930
+ /**
1931
+ * Which of the database's schemas this scan covers.
1932
+ *
1933
+ * Exported for its own test: which schemas are in scope is a security decision,
1934
+ * not an implementation detail — a schema quietly added to
1935
+ * {@link PLATFORM_SCHEMAS} silently stops being checked.
1936
+ */
1887
1937
  function selectSchemas(all, requested, diagnostics) {
1888
1938
  const isSystem = (s) => SYSTEM_SCHEMAS.includes(s) || /^pg_temp(_\d+)?$/.test(s) || /^pg_toast_temp(_\d+)?$/.test(s);
1889
1939
  if (requested && requested.length > 0) {