@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 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 "postgresql://user:password@host:5432/database"
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.10.0 · read-only Row-Level Security audit
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
- Scanned 1 schema · 23 tables · 31 policies · 14 checks
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
- the true catalog, and it is also why nothing below describes what this
44
- connection experiences. The findings are about what OTHER roles get.
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 · 2 findings
47
- ────────────────────────────────────────────────────────────────────────────
55
+ CRITICAL · 3 findings
56
+ ────────────────────────────────────────────────────────────────────────────────────────
48
57
 
49
- [critical] rls-disabled public.profiles
50
- public.profiles is exposed to anon without row-level security
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
- Row-level security is disabled on this table, and anon holds SELECT, INSERT,
53
- UPDATE and DELETE on it. With RLS off, policies are not consulted at all — a
54
- policy defined on this table would have no effect.
55
- Impact Anyone with the project's anon key, which ships in your client bundle,
56
- can read and modify every row.
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
- ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
59
- ALTER TABLE public.profiles FORCE ROW LEVEL SECURITY;
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
- -- Then add at least one policy, or the table denies everything:
62
- CREATE POLICY profiles_select_own ON public.profiles
63
- FOR SELECT TO authenticated USING (id = auth.uid());
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 past the row-level security on public.profiles
68
-
69
- The view is granted to anon and does not set security_invoker, so it executes
70
- with the privileges of its owner (postgres) rather than the caller's. The
71
- policies on public.profiles never run.
72
- Impact An anon caller selecting from the view receives every row of
73
- public.profiles, whatever its policies say.
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 inserts from anon under policy "anyone can
114
+ public.contact_messages accepts unauthenticated insert via policy "anyone can
83
115
  write"
84
116
 
85
- The policy is PERMISSIVE, applies to INSERT, names anon in its TO clause, and
86
- its WITH CHECK expression is `true`, so every proposed row passes.
87
- Impact Anyone holding the anon key can insert unlimited rows. There is no
88
- rate limit at the database layer.
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
- ALTER POLICY "anyone can write" ON public.contact_messages
91
- WITH CHECK (created_by = auth.uid() AND length(body) < 4000);
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
- MEDIUM · 2 findings
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 2 high 2 medium 2 low 1 info 0
150
- 5 confirmed · 2 worth checking · 14 checks run against 23 tables in 1 schema
151
- 3 of 23 tables have row-level security disabled
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-07-26T09:14:02.881Z · read-only, and nothing left this machine.
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
- Heuristic findings are always kept in their own section, 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.
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 `@`, `:`, `/`, `?` or `#`, percent-encode it. An unencoded one makes the URL ambiguous, and `rls-check` refuses to guess rather than risk connecting somewhere unintended.
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-07-26T09:14:02.881Z",
263
- "database": { "host": "db.hjklqwertyuiop.supabase.co", "name": "postgres" },
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": 23,
270
- "policies": 31,
271
- "tablesWithoutRls": 3,
272
- "checksRun": 14
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
- "title": "public.profiles is exposed to anon without row-level security",
279
- "target": { "schema": "public", "table": "profiles" },
280
- "detail": "…",
281
- "impact": "",
282
- "fix": "ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;",
283
- "docs": "https://rebase.pro/docs/rls-check#rls-disabled",
284
- "confidence": "certain"
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 fourteen checks found nothing. It does not mean your authorization model is sound, your API layer enforces what it should, or your data is safe.
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 `<> 'anonymous'` guard alongside the null test is the corrected form and
21
- * clears the policy entirely.
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;
@@ -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;