@rebasepro/rls-check 0.17.3 → 0.18.0
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 +153 -111
- package/dist/checks/policy-anonymous-tautology.d.ts +50 -2
- package/dist/checks/policy-authenticated-tautology.d.ts +27 -0
- package/dist/checks/util.d.ts +28 -0
- package/dist/index.es.js +775 -293
- package/dist/index.es.js.map +1 -1
- package/dist/introspect.d.ts +33 -0
- package/dist/redact.d.ts +17 -1
- package/dist/types.d.ts +19 -4
- package/package.json +33 -21
package/README.md
CHANGED
|
@@ -3,9 +3,17 @@
|
|
|
3
3
|
Audit Row-Level Security on any PostgreSQL database. One command, no configuration, no account, nothing to install.
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
|
-
npx @rebasepro/rls-check
|
|
6
|
+
npx @rebasepro/rls-check
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
+
ESM-only: `"type": "module"` with no CommonJS build. It is a command, so that
|
|
10
|
+
matters only if you import it — `require()` of it resolves on Node 22.12+,
|
|
11
|
+
which supports `require(esm)`.
|
|
12
|
+
|
|
13
|
+
Run it in your project directory and it finds the database itself: `DATABASE_URL`, then `POSTGRES_URL`, then a `.env` beside you. Point it somewhere else with `DATABASE_URL="postgresql://user:password@host:5432/database" npx @rebasepro/rls-check`.
|
|
14
|
+
|
|
15
|
+
It also takes the connection string as an argument, but prefer not to: npm echoes the command line before the program starts and your shell records it, so the password lands in two places `rls-check` cannot redact. Writing `$DATABASE_URL` there does not help — the shell expands it before npm sees it.
|
|
16
|
+
|
|
9
17
|
It works against Supabase, Neon, RDS, Cloud SQL, Postgres in a container on your laptop, or anything else that speaks the wire protocol. It knows nothing about your framework and asks nothing of your codebase.
|
|
10
18
|
|
|
11
19
|
**It is read-only.** It issues `SELECT`s against the system catalogs — `pg_class`, `pg_policies`, `pg_proc`, `information_schema` — and nothing else. It writes nothing, changes no setting, and sends nothing anywhere. There is no telemetry, no upload, and no network access beyond the connection you give it.
|
|
@@ -30,133 +38,113 @@ The failures that make a Postgres database leak in practice, rather than the one
|
|
|
30
38
|
A scan of a Supabase project, `--no-color`:
|
|
31
39
|
|
|
32
40
|
```
|
|
33
|
-
rls-check 0.
|
|
34
|
-
|
|
41
|
+
rls-check 0.17.3 · read-only Row-Level Security audit
|
|
42
|
+
────────────────────────────────────────────────────────────────────────────────────────
|
|
35
43
|
|
|
36
44
|
Database db.hjklqwertyuiop.supabase.co:5432/postgres
|
|
37
45
|
Server PostgreSQL 15.8 on aarch64-unknown-linux-gnu
|
|
38
46
|
Platform Supabase
|
|
39
|
-
|
|
47
|
+
Exposed PUBLIC, anon, authenticated (add yours with --role)
|
|
48
|
+
Scanned 1 schema · 3 tables · 2 policies · 15 checks
|
|
40
49
|
|
|
41
50
|
Note This scan connected as a role that row-level security cannot constrain — a
|
|
42
|
-
superuser, a table owner, or a role with BYPASSRLS. That is why it can read
|
|
43
|
-
|
|
44
|
-
|
|
51
|
+
superuser, a table owner, or a role with BYPASSRLS. That is why it can read the
|
|
52
|
+
true catalog, and it is also why nothing below describes what this connection
|
|
53
|
+
experiences. The findings are about what OTHER roles get.
|
|
45
54
|
|
|
46
|
-
CRITICAL ·
|
|
47
|
-
|
|
55
|
+
CRITICAL · 3 findings
|
|
56
|
+
────────────────────────────────────────────────────────────────────────────────────────
|
|
48
57
|
|
|
49
|
-
[critical]
|
|
50
|
-
public.
|
|
58
|
+
[critical] policy-always-true public.contact_messages · policy "anyone can write"
|
|
59
|
+
Policy "anyone can write" on public.contact_messages is WITH CHECK (true) for anon
|
|
51
60
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
Impact
|
|
56
|
-
|
|
61
|
+
This permissive INSERT policy's WITH CHECK expression is a constant truth, so it
|
|
62
|
+
matches every row for anon. Permissive policies are ORed together, so this one
|
|
63
|
+
alone satisfies the table's row filter no matter how strict the others are.
|
|
64
|
+
Impact If this table is reachable over an API as anon, a caller can act on every
|
|
65
|
+
row — the policy applies no scoping whatsoever.
|
|
57
66
|
Fix
|
|
58
|
-
|
|
59
|
-
ALTER
|
|
67
|
+
-- Replace the constant with the scoping you intended, e.g.:
|
|
68
|
+
ALTER POLICY "anyone can write" ON "public"."contact_messages"
|
|
69
|
+
WITH CHECK (user_id = auth.uid());
|
|
70
|
+
-- or, if unconditional access really is intended, drop the policy and say so
|
|
71
|
+
-- with an explicit grant instead:
|
|
72
|
+
-- DROP POLICY "anyone can write" ON "public"."contact_messages";
|
|
73
|
+
Docs https://rebase.pro/docs/rls-check#policy-always-true
|
|
60
74
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
75
|
+
[critical] rls-disabled public.profiles
|
|
76
|
+
public.profiles has row-level security disabled and is granted to anon and
|
|
77
|
+
authenticated
|
|
78
|
+
|
|
79
|
+
Row-level security is not enabled on this table, so Postgres applies no per-row
|
|
80
|
+
filter at all — policies, if any exist, are never consulted. anon and
|
|
81
|
+
authenticated hold DELETE, INSERT, SELECT and UPDATE on it.
|
|
82
|
+
Impact If this table is reachable over an API that connects as anon and
|
|
83
|
+
authenticated, a caller can read every row and delete, insert and update
|
|
84
|
+
any row, with no tenant or owner scoping.
|
|
85
|
+
Fix
|
|
86
|
+
ALTER TABLE "public"."profiles" ENABLE ROW LEVEL SECURITY;
|
|
87
|
+
-- Enabling RLS with no policies denies every row to everyone but the owner,
|
|
88
|
+
-- so add the policy you intend in the same migration, for example:
|
|
89
|
+
-- CREATE POLICY "profiles_owner_select" ON "public"."profiles"
|
|
90
|
+
-- FOR SELECT TO "anon" USING (user_id = auth.uid());
|
|
64
91
|
Docs https://rebase.pro/docs/rls-check#rls-disabled
|
|
65
92
|
|
|
66
93
|
[critical] view-bypasses-rls public.user_stats
|
|
67
|
-
public.user_stats reads
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
94
|
+
View public.user_stats reads public.orders without security_invoker and is
|
|
95
|
+
readable by anon
|
|
96
|
+
|
|
97
|
+
The view is owned by postgres and `security_invoker` is not set, so its query
|
|
98
|
+
executes with postgres's privileges rather than the caller's. Row-level security
|
|
99
|
+
on public.orders is evaluated for postgres, not for the role that selected from
|
|
100
|
+
the view.
|
|
101
|
+
Impact If this view is reachable over an API as anon, a caller reads rows from
|
|
102
|
+
public.orders that the policies on that table were written to withhold —
|
|
103
|
+
the view is an unfiltered path around them.
|
|
74
104
|
Fix
|
|
75
|
-
ALTER VIEW public.user_stats SET (security_invoker = true);
|
|
105
|
+
ALTER VIEW "public"."user_stats" SET (security_invoker = true);
|
|
106
|
+
-- Callers then need their own SELECT privilege on public.orders, and the
|
|
107
|
+
-- policies there apply to them.
|
|
76
108
|
Docs https://rebase.pro/docs/rls-check#view-bypasses-rls
|
|
77
109
|
|
|
78
110
|
HIGH · 1 finding
|
|
79
|
-
|
|
111
|
+
────────────────────────────────────────────────────────────────────────────────────────
|
|
80
112
|
|
|
81
113
|
[high] anonymous-write-allowed public.contact_messages · policy "anyone can write"
|
|
82
|
-
public.contact_messages accepts
|
|
114
|
+
public.contact_messages accepts unauthenticated insert via policy "anyone can
|
|
83
115
|
write"
|
|
84
116
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
117
|
+
Policy "anyone can write" is a permissive INSERT policy for anon, and its check
|
|
118
|
+
expression is a constant truth, so every row satisfies it. anon also holds INSERT
|
|
119
|
+
on the table, so both the privilege check and the row check pass for a request
|
|
120
|
+
that carries no credentials.
|
|
121
|
+
Impact An unauthenticated caller reaching this database over an API can insert
|
|
122
|
+
rows in public.contact_messages at will — inserting records attributed to
|
|
123
|
+
other users, or modifying rows they do not own.
|
|
89
124
|
Fix
|
|
90
|
-
|
|
91
|
-
|
|
125
|
+
-- Scope the write to the caller, or take the privilege away entirely:
|
|
126
|
+
ALTER POLICY "anyone can write" ON "public"."contact_messages"
|
|
127
|
+
WITH CHECK (user_id = auth.uid());
|
|
128
|
+
-- and if anonymous writes are never intended:
|
|
129
|
+
REVOKE INSERT ON "public"."contact_messages" FROM "anon";
|
|
92
130
|
Docs https://rebase.pro/docs/rls-check#anonymous-write-allowed
|
|
93
131
|
|
|
94
|
-
|
|
95
|
-
────────────────────────────────────────────────────────────────────────────
|
|
96
|
-
|
|
97
|
-
[medium] grant-to-public public.feature_flags
|
|
98
|
-
public.feature_flags grants SELECT to PUBLIC
|
|
99
|
-
|
|
100
|
-
PUBLIC is every role in the cluster, including roles created after this grant.
|
|
101
|
-
The grant survives changes to anon and authenticated.
|
|
102
|
-
Impact Any role that can connect can read this table, whether or not you
|
|
103
|
-
intended it to be reachable.
|
|
104
|
-
Fix
|
|
105
|
-
REVOKE SELECT ON public.feature_flags FROM PUBLIC;
|
|
106
|
-
GRANT SELECT ON public.feature_flags TO authenticated;
|
|
107
|
-
Docs https://rebase.pro/docs/rls-check#grant-to-public
|
|
108
|
-
|
|
109
|
-
[medium] rls-enabled-not-forced public.orders
|
|
110
|
-
public.orders does not force row-level security for its owner
|
|
111
|
-
|
|
112
|
-
RLS is enabled but not FORCEd, so the table's owner (postgres) is exempt from
|
|
113
|
-
its own policies. Any SECURITY DEFINER function owned by that role reads the
|
|
114
|
-
table unfiltered.
|
|
115
|
-
Impact A trigger, a scheduled job, or an RPC running as the owner sees every
|
|
116
|
-
tenant's rows even though the policies say otherwise.
|
|
117
|
-
Fix
|
|
118
|
-
ALTER TABLE public.orders FORCE ROW LEVEL SECURITY;
|
|
119
|
-
Docs https://rebase.pro/docs/rls-check#rls-enabled-not-forced
|
|
120
|
-
|
|
121
|
-
WORTH CHECKING
|
|
122
|
-
These are heuristics, not proofs. They match a shape that is usually a mistake,
|
|
123
|
-
but each one may be deliberate in your schema — read them and decide. They are
|
|
124
|
-
listed separately so nothing above needs a second opinion.
|
|
125
|
-
|
|
126
|
-
[high] junction-table-unprotected public.project_members
|
|
127
|
-
public.project_members looks like a join table between two protected tables
|
|
128
|
-
and has no RLS
|
|
129
|
-
|
|
130
|
-
The table is two foreign keys and a primary key over both, pointing at
|
|
131
|
-
public.projects and public.profiles — both of which have row-level security.
|
|
132
|
-
This one does not.
|
|
133
|
-
Impact If this table is exposed over an API, the full membership graph is
|
|
134
|
-
readable: who belongs to which project, for every project.
|
|
135
|
-
Fix
|
|
136
|
-
ALTER TABLE public.project_members ENABLE ROW LEVEL SECURITY;
|
|
137
|
-
ALTER TABLE public.project_members FORCE ROW LEVEL SECURITY;
|
|
138
|
-
|
|
139
|
-
CREATE POLICY project_members_follows_project ON public.project_members
|
|
140
|
-
FOR SELECT TO authenticated USING (EXISTS (
|
|
141
|
-
SELECT 1 FROM public.projects p
|
|
142
|
-
WHERE p.id = project_members.project_id AND p.owner_id = auth.uid()
|
|
143
|
-
));
|
|
144
|
-
Docs https://rebase.pro/docs/rls-check#junction-table-unprotected
|
|
145
|
-
|
|
146
|
-
────────────────────────────────────────────────────────────────────────────
|
|
132
|
+
────────────────────────────────────────────────────────────────────────────────────────
|
|
147
133
|
Summary
|
|
148
134
|
|
|
149
|
-
critical
|
|
150
|
-
|
|
151
|
-
|
|
135
|
+
critical 3 high 1 medium 0 low 0 info 0
|
|
136
|
+
4 confirmed · 0 worth checking · 15 checks run against 3 tables in 1 schema
|
|
137
|
+
1 of 3 tables have row-level security disabled
|
|
152
138
|
|
|
153
139
|
Exit code 1 — at least one finding is "high" or worse (--fail-on high).
|
|
154
|
-
Scanned 2026-
|
|
140
|
+
Scanned 2026-09-05T09:14:02.881Z · read-only, and nothing left this machine.
|
|
155
141
|
|
|
156
142
|
rls-check is free and maintained by the team behind Rebase — https://rebase.pro
|
|
157
143
|
```
|
|
158
144
|
|
|
159
|
-
|
|
145
|
+
That run found nothing heuristic. When it does, the heuristic findings go in a `WORTH CHECKING` section of their own, after the confident ones — mixing "this table is public" with "this might be a join table" is how a scanner teaches people to ignore it.
|
|
146
|
+
|
|
147
|
+
The `Exposed` line is worth reading before the findings: every check reports a table only when one of those roles can reach it, so if the role your application connects as is not listed, name it with `--role` and run again.
|
|
160
148
|
|
|
161
149
|
## The checks
|
|
162
150
|
|
|
@@ -168,6 +156,7 @@ Run `npx @rebasepro/rls-check --list-checks` for the catalog on your installed v
|
|
|
168
156
|
| `policy-always-true` | critical | certain | A permissive policy whose `USING` or `WITH CHECK` expression is always true. Downgraded to medium, and to heuristic, when the policy sits behind an authentication gate. |
|
|
169
157
|
| `view-bypasses-rls` | critical | certain | A view granted to an untrusted role that selects from an RLS-protected table and runs with its owner's privileges. Heuristic on servers before PG15, where `security_invoker` does not exist. |
|
|
170
158
|
| `policy-anonymous-tautology` | varies | heuristic | An `auth.uid() IS NOT NULL`-shaped policy: it separates signed-in from signed-out callers and scopes no rows. Critical on Supabase-shaped databases, lower elsewhere. |
|
|
159
|
+
| `policy-authenticated-tautology` | high | heuristic | The corrected form of the above — `auth.uid() IS NOT NULL AND auth.uid() <> 'anonymous'` — which excludes signed-out callers and still scopes no rows. Every account reads every row; with open registration that is everybody. |
|
|
171
160
|
| `anonymous-write-allowed` | high | certain | A permissive INSERT/UPDATE/DELETE policy reachable without authentication whose check expression accepts any row, backed by a matching grant. |
|
|
172
161
|
| `matview-bypasses-rls` | high | certain | A materialized view granted to an untrusted role whose defining query reads an RLS-protected table. Materialized views have no `security_invoker`. |
|
|
173
162
|
| `unqualified-column-in-subquery` | high | heuristic | A bare column name in an `EXISTS`/`IN` subquery that exists on both the inner relation and the policy's own table, so Postgres binds it to the inner one. |
|
|
@@ -184,12 +173,16 @@ Ids are stable. They go into `--skip` lists and CI baselines, so a rename is tre
|
|
|
184
173
|
## Usage
|
|
185
174
|
|
|
186
175
|
```
|
|
176
|
+
DATABASE_URL="postgresql://..." npx @rebasepro/rls-check [options]
|
|
187
177
|
npx @rebasepro/rls-check [connection-string] [options]
|
|
188
178
|
|
|
189
179
|
--json Machine-readable ScanResult on stdout, and nothing else.
|
|
180
|
+
--html <path> Also write a self-contained HTML report to <path>. One file,
|
|
181
|
+
no network requests, safe to attach to a ticket.
|
|
190
182
|
--schema <name> Restrict the scan to a schema. Repeatable or comma-separated.
|
|
191
183
|
--role <name> Treat this role as one an untrusted caller arrives as, in
|
|
192
184
|
addition to anon, authenticated, web_anon and rebase_user.
|
|
185
|
+
A name that is not in pg_roles is an error, not a no-op.
|
|
193
186
|
Repeatable or comma-separated.
|
|
194
187
|
--fail-on <severity> Exit 1 at or above this severity: info, low, medium, high,
|
|
195
188
|
critical, or none to never fail. Default: high.
|
|
@@ -213,7 +206,9 @@ The connection string is taken from, in order:
|
|
|
213
206
|
|
|
214
207
|
The connection string never appears in the output — not in the report, not in an error, not in a log line. Host, port and database name are shown; the user and password are replaced with `***`.
|
|
215
208
|
|
|
216
|
-
If your password contains
|
|
209
|
+
If your password contains `/`, `?` or `#`, percent-encode it. Those three end the URL's authority section, so the split lands inside the credential — and rather than print fragments of a password, `rls-check` refuses the string and says so.
|
|
210
|
+
|
|
211
|
+
`@` and `:` need no encoding here: the userinfo is split at the **last** `@` and the user at the **first** `:`, which is what `pg` does too, so `postgresql://user:pa@ss@host:5432/db` connects to `host` with the password `pa@ss`. Encoding them anyway is never wrong.
|
|
217
212
|
|
|
218
213
|
## Exit codes
|
|
219
214
|
|
|
@@ -253,49 +248,96 @@ To keep the machine-readable result as an artifact:
|
|
|
253
248
|
run: npx --yes @rebasepro/rls-check --json > rls-report.json
|
|
254
249
|
```
|
|
255
250
|
|
|
251
|
+
### On a Rebase database, change the rule, not the policy
|
|
252
|
+
|
|
253
|
+
Every policy on a Rebase deployment is compiled from a collection's `securityRules`, and the runtime re-applies them on every boot — it drops each generated policy and creates it again from the config. SQL run against one of them survives exactly until the next restart, so `rls-check` recognises those policies (a `<table>_<operation>_<hash>` name, or a call to `rebase.uid()` / `rebase.roles()` in the expression) and prescribes the rule instead: edit the collection under `config/collections/`, or `defaultSecurityRules` in `config/collections/index.ts` when the collection declares none of its own, then redeploy. A policy you wrote by hand in a migration is untouched by this, and its fix is still the SQL.
|
|
254
|
+
|
|
255
|
+
A stock Rebase scaffold reports three `policy-always-true` criticals on its first scan — `posts`, `authors` and `tags` inherit a `{ operation: "select", access: "public" }` rule — so decide whether to replace those rules or to `--skip policy-always-true` before you make this a gate.
|
|
256
|
+
|
|
256
257
|
## JSON output
|
|
257
258
|
|
|
258
259
|
`--json` writes a single `ScanResult` object to stdout and nothing else. Errors still go to stderr.
|
|
259
260
|
|
|
260
261
|
```json
|
|
261
262
|
{
|
|
262
|
-
"scannedAt": "2026-
|
|
263
|
-
"database": {
|
|
263
|
+
"scannedAt": "2026-09-05T09:14:02.881Z",
|
|
264
|
+
"database": {
|
|
265
|
+
"host": "db.hjklqwertyuiop.supabase.co",
|
|
266
|
+
"name": "postgres"
|
|
267
|
+
},
|
|
264
268
|
"serverVersion": "PostgreSQL 15.8 on aarch64-unknown-linux-gnu",
|
|
265
269
|
"platform": "supabase",
|
|
266
270
|
"scannerIsPrivileged": true,
|
|
271
|
+
"exposedRoles": [
|
|
272
|
+
"PUBLIC",
|
|
273
|
+
"anon",
|
|
274
|
+
"authenticated"
|
|
275
|
+
],
|
|
267
276
|
"stats": {
|
|
268
277
|
"schemas": 1,
|
|
269
|
-
"tables":
|
|
270
|
-
"policies":
|
|
271
|
-
"tablesWithoutRls":
|
|
272
|
-
"checksRun":
|
|
278
|
+
"tables": 1,
|
|
279
|
+
"policies": 0,
|
|
280
|
+
"tablesWithoutRls": 1,
|
|
281
|
+
"checksRun": 15
|
|
273
282
|
},
|
|
274
283
|
"findings": [
|
|
275
284
|
{
|
|
276
285
|
"id": "rls-disabled",
|
|
277
286
|
"severity": "critical",
|
|
278
|
-
"
|
|
279
|
-
"
|
|
280
|
-
"
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
"
|
|
287
|
+
"confidence": "certain",
|
|
288
|
+
"title": "public.profiles has row-level security disabled and is granted to anon",
|
|
289
|
+
"target": {
|
|
290
|
+
"schema": "public",
|
|
291
|
+
"table": "profiles"
|
|
292
|
+
},
|
|
293
|
+
"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. anon holds DELETE, INSERT, SELECT and UPDATE on it.",
|
|
294
|
+
"impact": "If this table is reachable over an API that connects as anon, a caller can read every row and delete, insert and update any row, with no tenant or owner scoping.",
|
|
295
|
+
"fix": "ALTER TABLE \"public\".\"profiles\" 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 \"profiles_owner_select\" ON \"public\".\"profiles\"\n-- FOR SELECT TO \"anon\" USING (user_id = auth.uid());",
|
|
296
|
+
"docs": "https://rebase.pro/docs/rls-check#rls-disabled"
|
|
285
297
|
}
|
|
286
|
-
]
|
|
298
|
+
],
|
|
299
|
+
"diagnostics": {
|
|
300
|
+
"tlsVerificationDisabled": false,
|
|
301
|
+
"excludedSchemas": [
|
|
302
|
+
{
|
|
303
|
+
"schema": "auth",
|
|
304
|
+
"reason": "platform"
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
"schema": "information_schema",
|
|
308
|
+
"reason": "system"
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
"schema": "pg_catalog",
|
|
312
|
+
"reason": "system"
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
"schema": "pg_toast",
|
|
316
|
+
"reason": "system"
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
"schema": "storage",
|
|
320
|
+
"reason": "platform"
|
|
321
|
+
}
|
|
322
|
+
],
|
|
323
|
+
"degraded": [],
|
|
324
|
+
"unrecognizedGrantees": [],
|
|
325
|
+
"scanningAsExposedRole": null
|
|
326
|
+
}
|
|
287
327
|
}
|
|
288
328
|
```
|
|
289
329
|
|
|
290
330
|
Findings are sorted worst-first and then by schema, object and id, so two scans of an unchanged database produce an identical file.
|
|
291
331
|
|
|
332
|
+
`exposedRoles` and `diagnostics` are part of the contract, not decoration. Every check reports a table only when one of the exposed roles can reach it, and `diagnostics.degraded` is how a consumer tells "nothing was wrong" from "the scan could not look" — `findings: []` without both is half an answer.
|
|
333
|
+
|
|
292
334
|
## What this tool does not do
|
|
293
335
|
|
|
294
336
|
Being clear about this is the point of the tool. It is a **static audit of the catalog**, not a penetration test.
|
|
295
337
|
|
|
296
338
|
- **It does not execute queries as other roles.** It never connects as `anon`, never sets a JWT claim, and never tries to read a row it should not be able to read. Everything it reports is inferred from what the catalogs say, not observed.
|
|
297
339
|
- **It cannot prove a policy is correct.** Deciding whether `owner_id = auth.uid()` is the right rule for your application requires knowing your application. `rls-check` can only tell you that certain *shapes* are wrong — a policy that is always true, a view that runs as its owner, a table with RLS switched off.
|
|
298
|
-
- **A clean report is not a security certification.** It means these
|
|
340
|
+
- **A clean report is not a security certification.** It means these fifteen checks found nothing. It does not mean your authorization model is sound, your API layer enforces what it should, or your data is safe.
|
|
299
341
|
- **It recognises app roles by name, and yours may not be one of them.** Every check reports a table as exposed only when a role an untrusted caller can arrive as holds privileges on it. Out of the box that means `PUBLIC`, Supabase's `anon` and `authenticated`, PostgREST's `web_anon`, and Rebase's `rebase_user`. If your application connects as `app_user`, `api` or anything else, name it — `--role app_user` — or the checks have nothing to gate on. A scan that finds a write-holding role it cannot account for says so in a `Note` rather than printing a clean report.
|
|
300
342
|
- **It does not model your API layer.** Whether a table is actually reachable depends on PostgREST, your server, or your gateway. Findings say "if this table is exposed over an API" when reachability depends on something outside the database — believe that qualifier.
|
|
301
343
|
- **It does not see what your connection sees.** Almost every connection string handed to a tool like this belongs to a superuser or a table owner, which RLS cannot constrain. That is what lets it read the true catalog; it also means the findings describe what *other* roles get. The report says so, prominently, every time it applies.
|
|
@@ -17,7 +17,55 @@ import type { Check } from "../types.js";
|
|
|
17
17
|
* - Anything else: it comes down to whether the stack coerces, which the
|
|
18
18
|
* database cannot tell us. `medium`, and say so out loud.
|
|
19
19
|
*
|
|
20
|
-
* A
|
|
21
|
-
*
|
|
20
|
+
* A guard that excludes the sentinel is the corrected form and clears the policy.
|
|
21
|
+
* A guard that excludes *something else* is the interesting case — see
|
|
22
|
+
* {@link matchTautology}.
|
|
22
23
|
*/
|
|
23
24
|
export declare const policyAnonymousTautology: Check;
|
|
25
|
+
export interface TautologyMatch {
|
|
26
|
+
/** How to name the caller-id expression in prose, e.g. `auth.uid()`. */
|
|
27
|
+
shape: string;
|
|
28
|
+
/** Literals the policy excludes that exclude nobody. Empty for a bare null test. */
|
|
29
|
+
decoyGuards: string[];
|
|
30
|
+
/**
|
|
31
|
+
* Whether the clause excludes an id a signed-out caller can actually arrive
|
|
32
|
+
* with — the *corrected* form of this shape.
|
|
33
|
+
*
|
|
34
|
+
* This is the fork between two findings. `false` is
|
|
35
|
+
* {@link policyAnonymousTautology}: the null test stands on its own, so
|
|
36
|
+
* signed-out callers satisfy it. `true` is `policy-authenticated-tautology`:
|
|
37
|
+
* signed-out callers really are excluded, and still no row is scoped, so
|
|
38
|
+
* every registered account reads every row — the shape that left a
|
|
39
|
+
* customer's `users` table readable by anyone who could sign up.
|
|
40
|
+
*
|
|
41
|
+
* Returning the distinction rather than treating the guarded form as a clean
|
|
42
|
+
* bill of health is the whole point: the previous version answered `null`
|
|
43
|
+
* here, which is why the policy that actually shipped passed silently.
|
|
44
|
+
*/
|
|
45
|
+
guardsSentinel: boolean;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Recognise "the policy tests that a caller id exists, and nothing that narrows
|
|
49
|
+
* which rows" — and report which no-op guards it wears while doing so.
|
|
50
|
+
*
|
|
51
|
+
* Two rules keep this honest.
|
|
52
|
+
*
|
|
53
|
+
* **Every conjunct has to be accounted for.** `auth.uid() IS NOT NULL AND user_id
|
|
54
|
+
* = auth.uid()` contains the shape and is a perfectly scoped policy; a substring
|
|
55
|
+
* match would flag it, and flagging correct Supabase policies is the fastest way
|
|
56
|
+
* to get this tool deleted. So the expression is split on `AND`, each conjunct is
|
|
57
|
+
* classified, and a single conjunct this function does not recognise means it
|
|
58
|
+
* stays quiet. An `OR` anywhere means the same — the shape no longer describes
|
|
59
|
+
* what the policy admits.
|
|
60
|
+
*
|
|
61
|
+
* **A guard only clears if it excludes an id somebody can actually arrive with.**
|
|
62
|
+
* The version before this one bailed on the literal string `<> 'anonymous'`, which
|
|
63
|
+
* meant `<> 'anon'` fell past the bail and then failed to match the bare-null-test
|
|
64
|
+
* shape, so the function returned null and the check said nothing at all. That is
|
|
65
|
+
* not a near miss: it is the precise predicate that left a production `users`
|
|
66
|
+
* table — password hashes included — readable by the entire internet for three and
|
|
67
|
+
* a half weeks, and this tool was run against that database and reported clean.
|
|
68
|
+
* A guard naming the wrong literal is now the *loudest* case, not the silent one,
|
|
69
|
+
* because it is the one that survives code review.
|
|
70
|
+
*/
|
|
71
|
+
export declare function callerIdOnlyClause(clause: string | null | undefined): TautologyMatch | null;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Check } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* `auth.uid() IS NOT NULL AND auth.uid() <> 'anonymous'` — and nothing else.
|
|
4
|
+
*
|
|
5
|
+
* This is the *corrected* form of the anonymous tautology, and correcting that
|
|
6
|
+
* one is where people stop. It genuinely does exclude signed-out callers. What
|
|
7
|
+
* it does not do is scope any rows: what remains is "every registered account
|
|
8
|
+
* may read every row of this table", which is a different sentence from the one
|
|
9
|
+
* the person writing it usually means.
|
|
10
|
+
*
|
|
11
|
+
* It is the shape that leaked a customer's `users` table — every email address
|
|
12
|
+
* on the platform, and the columns beside them, readable by anyone who could
|
|
13
|
+
* sign up, which on a product with open registration is anyone at all. The
|
|
14
|
+
* scanner watched for the anonymous form and treated the sentinel guard as a
|
|
15
|
+
* clean bill of health, so the policy that actually shipped passed silently.
|
|
16
|
+
*
|
|
17
|
+
* `high`, not `critical`: it costs an account. On a table with open
|
|
18
|
+
* registration that is a formality, and the wording says so rather than
|
|
19
|
+
* pretending the distinction is comforting.
|
|
20
|
+
*
|
|
21
|
+
* Not folded into {@link policyAnonymousTautology}: check ids appear in
|
|
22
|
+
* `--skip`, in CI baselines and in people's runbooks, so two findings with
|
|
23
|
+
* different fixes and different severities have to be two ids. Someone who has
|
|
24
|
+
* decided their `countries` table really is world-readable should be able to
|
|
25
|
+
* silence that without also silencing "signed-out callers can read it".
|
|
26
|
+
*/
|
|
27
|
+
export declare const policyAuthenticatedTautology: Check;
|
package/dist/checks/util.d.ts
CHANGED
|
@@ -70,3 +70,31 @@ export declare const listAnd: (items: string[]) => string;
|
|
|
70
70
|
* plenty of databases Rebase did not create, and that is the wider convention.
|
|
71
71
|
*/
|
|
72
72
|
export declare function callerIdCall(snapshot: DbSnapshot): string;
|
|
73
|
+
/**
|
|
74
|
+
* Is this policy one Rebase derives from a collection's `securityRules` and
|
|
75
|
+
* re-applies at every boot?
|
|
76
|
+
*
|
|
77
|
+
* It matters because the ordinary remediation — edit the policy in the database
|
|
78
|
+
* — is *silently undone* on such a policy: boot drops and recreates every
|
|
79
|
+
* generated policy from the collection config, so a fix applied with SQL
|
|
80
|
+
* survives exactly until the next restart. Prescribing it is worse than
|
|
81
|
+
* prescribing nothing, because the operator watches the finding disappear and
|
|
82
|
+
* files it as done.
|
|
83
|
+
*
|
|
84
|
+
* Gated on the platform as well as the shape: this scanner is pointed at plenty
|
|
85
|
+
* of databases Rebase did not create, and a hash-suffixed policy name on one of
|
|
86
|
+
* those is a coincidence, not a contract.
|
|
87
|
+
*/
|
|
88
|
+
export declare function isRebaseManagedPolicy(snapshot: DbSnapshot, policy: DbPolicy): boolean;
|
|
89
|
+
/** Where the rule that produces a managed policy is documented. */
|
|
90
|
+
export declare const SECURITY_RULES_DOCS = "https://rebase.pro/docs/collections/security-rules";
|
|
91
|
+
/**
|
|
92
|
+
* The remediation for a Rebase-managed policy: change the rule it is compiled
|
|
93
|
+
* from. `intent` is one clause saying what to change the rule to, in the
|
|
94
|
+
* vocabulary of the check that found it.
|
|
95
|
+
*
|
|
96
|
+
* Deliberately contains no SQL. Every other fix in this tool is copy-pasteable
|
|
97
|
+
* because pasting it works; here it would not, and an example that is reverted
|
|
98
|
+
* on the next deploy teaches the wrong model of where access control lives.
|
|
99
|
+
*/
|
|
100
|
+
export declare function managedPolicyFix(policy: DbPolicy, intent: string): string;
|