@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 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 policy sits behind an authentication gate. |
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, because RESTRICTIVE clauses are ANDed after the
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
- * - Owner cannot log in: a provisioning role nothing connects as. Informational.
9
- * - Owner can log in and is otherwise ordinary: anything using that connection
10
- * string reads the whole table. This is the case worth waking up for.
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
@@ -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
- export declare const qi: (ident: string) => string;
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): string[];
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
- /** Clean, or nothing at or above `--fail-on`. */
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
- * The verdict, as an exit code.
69
- *
70
- * Pulled out of `runCli` so it can be tested: `runCli` needs a database, and
71
- * the one line that decides whether CI goes red had no coverage at all —
72
- * deleting it broke no test.
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;