@rebasepro/rls-check 0.22.0 → 0.23.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 +3 -3
- package/dist/checks/policy-always-true.d.ts +2 -2
- package/dist/checks/rls-disabled.d.ts +4 -0
- package/dist/checks/rls-enabled-not-forced.d.ts +11 -4
- package/dist/checks/util.d.ts +15 -1
- package/dist/checks/view-bypasses-rls.d.ts +2 -2
- package/dist/cli.d.ts +8 -21
- package/dist/index.es.js +423 -143
- package/dist/index.es.js.map +1 -1
- package/dist/introspect.d.ts +82 -1
- package/dist/report.d.ts +32 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -152,8 +152,8 @@ Run `npx @rebasepro/rls-check --list-checks` for the catalog on your installed v
|
|
|
152
152
|
|
|
153
153
|
| id | typical severity | confidence | what it looks for |
|
|
154
154
|
| --- | --- | --- | --- |
|
|
155
|
-
| `rls-disabled` | critical | certain | A table with RLS off that grants SELECT/INSERT/UPDATE/DELETE to a role an untrusted caller can reach. |
|
|
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
|
|
155
|
+
| `rls-disabled` | critical | certain | A table with RLS off that grants SELECT/INSERT/UPDATE/DELETE to a role an untrusted caller can reach, or a foreign table (which cannot have RLS) with such a grant. |
|
|
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 `RESTRICTIVE` policies on the same command apply to every role it reaches. |
|
|
157
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. |
|
|
158
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
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. |
|
|
@@ -161,7 +161,7 @@ Run `npx @rebasepro/rls-check --list-checks` for the catalog on your installed v
|
|
|
161
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`. |
|
|
162
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. |
|
|
163
163
|
| `junction-table-unprotected` | high | heuristic | A table that is essentially two foreign keys pointing at RLS-protected tables, with no row-level security of its own. |
|
|
164
|
-
| `grant-to-public` | medium | certain | A table privilege granted to `PUBLIC`, which includes roles that do not exist yet. |
|
|
164
|
+
| `grant-to-public` | medium | certain | A table or foreign-table privilege granted to `PUBLIC`, which includes roles that do not exist yet. |
|
|
165
165
|
| `rls-enabled-no-policies` | medium | certain | RLS enabled and not a single policy defined, so the table denies everything. |
|
|
166
166
|
| `rls-enabled-not-forced` | medium | certain | RLS enabled without `FORCE`, so the owning role is exempt from its own policies. |
|
|
167
167
|
| `policy-role-unreachable` | medium | certain | Every policy on a table names roles that do not exist, cannot log in, and that no login role inherits. |
|
|
@@ -8,8 +8,8 @@ import type { Check } from "../types.js";
|
|
|
8
8
|
* substring-matching version of this check would flag both.
|
|
9
9
|
*
|
|
10
10
|
* The one thing that legitimately rescues `USING (true)` is a RESTRICTIVE
|
|
11
|
-
* policy on the same command
|
|
12
|
-
* PERMISSIVE ones are ORed. "Permissive default, restrictive gate" is a real
|
|
11
|
+
* policy on the same command that applies to the same callers, because
|
|
12
|
+
* RESTRICTIVE clauses are ANDed after the PERMISSIVE ones are ORed. "Permissive default, restrictive gate" is a real
|
|
13
13
|
* pattern, so when one is present the finding degrades to a question instead of
|
|
14
14
|
* an accusation rather than disappearing (the restrictive policy may well not
|
|
15
15
|
* cover the same rows).
|
|
@@ -7,5 +7,9 @@ import type { Check } from "../types.js";
|
|
|
7
7
|
* lookup tables reachable only by the service role. Flagging those is how a
|
|
8
8
|
* scanner produces forty findings on a healthy database and gets ignored, so a
|
|
9
9
|
* table nobody exposed produces no finding at all.
|
|
10
|
+
*
|
|
11
|
+
* Foreign tables are included, and are always "RLS off": Postgres refuses
|
|
12
|
+
* `ENABLE ROW LEVEL SECURITY` on one, so a grant to an exposed role hands over
|
|
13
|
+
* whatever the remote server returns, and the fix is the grant, not a policy.
|
|
10
14
|
*/
|
|
11
15
|
export declare const rlsDisabled: Check;
|
|
@@ -3,11 +3,18 @@ import type { Check } from "../types.js";
|
|
|
3
3
|
* RLS on, FORCE off — the table owner is exempt from its own policies.
|
|
4
4
|
*
|
|
5
5
|
* How much that matters is entirely a question of *who the owner is*, and the
|
|
6
|
-
* severity has to follow that or the check becomes noise
|
|
6
|
+
* severity has to follow that or the check becomes noise. "The owner" means
|
|
7
|
+
* every role with the owner's privileges: Postgres decides the exemption with
|
|
8
|
+
* `has_privs_of_role`, so a member of the owning role is exempt exactly as the
|
|
9
|
+
* owner is.
|
|
7
10
|
*
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
+
* - A role an untrusted caller arrives as is, or is a member of, the owner:
|
|
12
|
+
* every request made as it skips the policies. Critical.
|
|
13
|
+
* - Owner can log in, or a login role is a member of it, and it is otherwise
|
|
14
|
+
* ordinary: anything using that connection string reads the whole table.
|
|
15
|
+
* This is the case worth waking up for.
|
|
16
|
+
* - Owner cannot log in and nothing that can is a member of it: a
|
|
17
|
+
* provisioning role nothing connects as. Informational.
|
|
11
18
|
* - Owner is a superuser or has BYPASSRLS: FORCE would not help either way,
|
|
12
19
|
* because those attributes skip RLS before ownership is even considered.
|
|
13
20
|
* Reporting `high` here would be misleading — the fix is "do not connect as
|
package/dist/checks/util.d.ts
CHANGED
|
@@ -43,12 +43,26 @@ export declare function policyTargetsExposedRole(snapshot: DbSnapshot, policy: D
|
|
|
43
43
|
export declare const TABLE_KINDS: DbRelation["kind"][];
|
|
44
44
|
export declare const isTable: (r: DbRelation) => boolean;
|
|
45
45
|
export declare function scannedTables(snapshot: DbSnapshot): DbRelation[];
|
|
46
|
+
/**
|
|
47
|
+
* Foreign tables in the scanned schemas. Postgres cannot put row-level
|
|
48
|
+
* security on one, so a grant is all that stands between it and a caller.
|
|
49
|
+
*/
|
|
50
|
+
export declare function scannedForeignTables(snapshot: DbSnapshot): DbRelation[];
|
|
46
51
|
export declare function relationAt(snapshot: DbSnapshot, schema: string, name: string): DbRelation | undefined;
|
|
47
52
|
export declare function policiesFor(snapshot: DbSnapshot, schema: string, table: string): DbPolicy[];
|
|
48
53
|
export declare const hasColumn: (r: DbRelation, name: string) => boolean;
|
|
49
54
|
/** `≈ 12,000 rows` — only ever used to convey blast radius, never severity. */
|
|
50
55
|
export declare function rowsPhrase(rel: DbRelation | undefined): string;
|
|
51
|
-
|
|
56
|
+
/**
|
|
57
|
+
* A quoted identifier that is one printable token wherever a fix puts it.
|
|
58
|
+
*
|
|
59
|
+
* Every name here comes out of the catalog, which means out of whoever could
|
|
60
|
+
* create a table, a role or a policy, and every fix is printed to be pasted
|
|
61
|
+
* into psql. Doubling `"` keeps a name inside its quotes; a name with a line
|
|
62
|
+
* break in it is written as a Unicode-escape identifier (`U&"a\000Ab"`), which
|
|
63
|
+
* names the same object and has no line break to escape a comment with.
|
|
64
|
+
*/
|
|
65
|
+
export declare function qi(ident: string): string;
|
|
52
66
|
export declare const qrel: (schema: string, name: string) => string;
|
|
53
67
|
/** PUBLIC is a keyword, not an identifier — quoting it changes what it means. */
|
|
54
68
|
export declare const qrole: (role: string) => string;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { Check, DbSnapshot, DbView } from "../types.js";
|
|
1
|
+
import type { Check, DbRelation, DbSnapshot, DbView } from "../types.js";
|
|
2
2
|
/** Base relations of `view` that have RLS turned on. */
|
|
3
|
-
export declare function protectedBaseTables(snapshot: DbSnapshot, view: DbView):
|
|
3
|
+
export declare function protectedBaseTables(snapshot: DbSnapshot, view: DbView): DbRelation[];
|
|
4
4
|
/**
|
|
5
5
|
* A view granted to an untrusted role that reads an RLS-protected table without
|
|
6
6
|
* `security_invoker`.
|
package/dist/cli.d.ts
CHANGED
|
@@ -18,13 +18,8 @@
|
|
|
18
18
|
* could have come from `pg`, Node or the user goes through `redactSecrets`
|
|
19
19
|
* on its way out.
|
|
20
20
|
*/
|
|
21
|
-
import type { ScanResult, Severity } from "./types.js";
|
|
22
|
-
|
|
23
|
-
export declare const EXIT_OK = 0;
|
|
24
|
-
/** Findings at or above `--fail-on`. */
|
|
25
|
-
export declare const EXIT_FINDINGS = 1;
|
|
26
|
-
/** The scan did not happen: bad arguments, bad connection, timeout. */
|
|
27
|
-
export declare const EXIT_ERROR = 2;
|
|
21
|
+
import type { DbSnapshot, Finding, ScanResult, Severity } from "./types.js";
|
|
22
|
+
export { EXIT_OK, EXIT_FINDINGS, EXIT_ERROR, exitCodeFor } from "./report.js";
|
|
28
23
|
export interface ScanOptions {
|
|
29
24
|
connectionString: string;
|
|
30
25
|
/** Restrict to these schemas. Empty or omitted means "every user schema". */
|
|
@@ -64,20 +59,12 @@ export declare function selectCheckIds(options: {
|
|
|
64
59
|
only?: string[];
|
|
65
60
|
skip?: string[];
|
|
66
61
|
}): string[];
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
*
|
|
74
|
-
* A degraded scan exits 2, the same code a crash uses, rather than 0. Checks
|
|
75
|
-
* whose catalogue reads failed return no findings, which is indistinguishable
|
|
76
|
-
* from finding none, so exiting 0 would have the scanner answer "no problems"
|
|
77
|
-
* to a question it never managed to ask. Both codes mean the same thing here:
|
|
78
|
-
* no verdict.
|
|
79
|
-
*/
|
|
80
|
-
export declare function exitCodeFor(result: ScanResult, failOn: Severity | "none"): number;
|
|
62
|
+
export declare function buildScanResult(snapshot: DbSnapshot, findings: Finding[], meta: {
|
|
63
|
+
connectionString: string;
|
|
64
|
+
checksRun: number;
|
|
65
|
+
scannedAt: string;
|
|
66
|
+
diagnostics: ScanResult["diagnostics"];
|
|
67
|
+
}): ScanResult;
|
|
81
68
|
export interface CliOptions {
|
|
82
69
|
connectionString: string | null;
|
|
83
70
|
json: boolean;
|