@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.
- package/dist/checks/util.d.ts +14 -0
- package/dist/index.es.js +60 -10
- package/dist/index.es.js.map +1 -1
- package/dist/introspect.d.ts +8 -0
- package/package.json +1 -1
package/dist/checks/util.d.ts
CHANGED
|
@@ -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 =
|
|
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") ?
|
|
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 =
|
|
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: () =>
|
|
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\.
|
|
1032
|
-
label: () =>
|
|
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 =
|
|
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 =
|
|
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) {
|