@rebasepro/rls-check 0.22.0 → 0.24.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 +9 -9
- package/dist/checks/anonymous-write-allowed.d.ts +3 -2
- package/dist/checks/policy-always-true.d.ts +2 -2
- package/dist/checks/policy-anonymous-tautology.d.ts +29 -1
- package/dist/checks/rls-disabled.d.ts +4 -0
- package/dist/checks/rls-enabled-not-forced.d.ts +11 -4
- package/dist/checks/sql.d.ts +2 -1
- package/dist/checks/unqualified-column-in-subquery.d.ts +15 -5
- package/dist/checks/util.d.ts +57 -3
- package/dist/checks/view-bypasses-rls.d.ts +2 -2
- package/dist/cli.d.ts +8 -21
- package/dist/index.es.js +827 -205
- package/dist/index.es.js.map +1 -1
- package/dist/introspect.d.ts +101 -1
- package/dist/report.d.ts +32 -0
- package/dist/types.d.ts +12 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ The failures that make a Postgres database leak in practice, rather than the one
|
|
|
28
28
|
- policies that look like access control but evaluate to `true` for every row;
|
|
29
29
|
- `auth.uid() IS NOT NULL`-shaped policies, which separate signed-in from signed-out callers and scope nothing;
|
|
30
30
|
- views and materialized views that read past the RLS on their base tables because they run as their owner;
|
|
31
|
-
- a bare column inside an `EXISTS` subquery that Postgres silently binds to the *inner* table, turning a tenant filter into a tautology;
|
|
31
|
+
- a bare column inside an `EXISTS` subquery that Postgres silently binds to the *inner* table, turning a tenant filter into a tautology — which the catalog stores as the column compared with itself;
|
|
32
32
|
- many-to-many join tables left unprotected between two protected endpoints — the whole edge list, readable;
|
|
33
33
|
- `SECURITY DEFINER` routines with an unpinned `search_path`;
|
|
34
34
|
- `GRANT`s to `PUBLIC`, and policies pointed at roles nothing can connect as.
|
|
@@ -144,7 +144,7 @@ rls-check is free and maintained by the team behind Rebase — https://rebase.pr
|
|
|
144
144
|
|
|
145
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
146
|
|
|
147
|
-
The `Exposed` line is worth reading before the findings:
|
|
147
|
+
The `Exposed` line is worth reading before the findings: a check that calls a table exposed does so only when one of those roles can reach it — holds a privilege on it, and `USAGE` on its schema — so if the role your application connects as is not listed, name it with `--role` and run again.
|
|
148
148
|
|
|
149
149
|
## The checks
|
|
150
150
|
|
|
@@ -152,16 +152,16 @@ 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. High when only an UPDATE policy's `WITH CHECK` is constant: `USING` still scopes which rows it touches, and the check lets them become anything. 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
|
-
| `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
|
|
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 when a signed-out caller still has a value there — Rebase's and PostgREST's sentinel id, or `auth.role()` / `auth.jwt()` on Supabase, which the anon key fills; low for `auth.uid()` on Supabase, which is NULL for a signed-out caller; medium where the scan cannot tell. |
|
|
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. |
|
|
160
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. |
|
|
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
|
-
| `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. |
|
|
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. Postgres stores it with every column qualified, so on a live database it is found as the inner column compared with itself (`m.org_id = m.org_id`). |
|
|
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. |
|
|
@@ -329,7 +329,7 @@ A stock Rebase scaffold reports three `policy-always-true` criticals on its firs
|
|
|
329
329
|
|
|
330
330
|
Findings are sorted worst-first and then by schema, object and id, so two scans of an unchanged database produce an identical file.
|
|
331
331
|
|
|
332
|
-
`exposedRoles` and `diagnostics` are part of the contract, not decoration.
|
|
332
|
+
`exposedRoles` and `diagnostics` are part of the contract, not decoration. A check that calls a table exposed does so 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
333
|
|
|
334
334
|
## What this tool does not do
|
|
335
335
|
|
|
@@ -338,7 +338,7 @@ Being clear about this is the point of the tool. It is a **static audit of the c
|
|
|
338
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.
|
|
339
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.
|
|
340
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.
|
|
341
|
-
- **It recognises app roles by name, and yours may not be one of them.**
|
|
341
|
+
- **It recognises app roles by name, and yours may not be one of them.** A check calls a table exposed only when a role an untrusted caller can arrive as holds privileges on it and `USAGE` on its schema. 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.
|
|
342
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.
|
|
343
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.
|
|
344
344
|
- **Heuristic checks produce false positives by design.** Junction-table inference and unqualified-column detection match a shape, not a proof. They are reported in a separate section for exactly that reason.
|
|
@@ -13,8 +13,9 @@ import type { Check } from "../types.js";
|
|
|
13
13
|
* correct one — satisfies condition 1 on almost every project out there.
|
|
14
14
|
* Flagging it would make this check fire on the whole ecosystem.
|
|
15
15
|
*
|
|
16
|
-
* So what is reported is the narrow, certain case: the
|
|
17
|
-
*
|
|
16
|
+
* So what is reported is the narrow, certain case: the clause that decides the
|
|
17
|
+
* command is constant-true, which in Postgres means "accept any row". An
|
|
18
|
+
* *absent* clause is the opposite — see {@link writesAcceptingAnyRow}. Policies
|
|
18
19
|
* whose expression is an anonymous *tautology* rather than a constant are the
|
|
19
20
|
* business of `policy-anonymous-tautology`, which can weigh the platform.
|
|
20
21
|
*/
|
|
@@ -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).
|
|
@@ -9,7 +9,10 @@ import type { Check } from "../types.js";
|
|
|
9
9
|
* - Supabase: `auth.uid()` reads a JWT claim and returns NULL for an anonymous
|
|
10
10
|
* caller, so the expression is a legitimate "signed in" test. Its only real
|
|
11
11
|
* failing is that it does not scope rows to their owner — worth `low`, worded
|
|
12
|
-
* as a design observation rather than a vulnerability.
|
|
12
|
+
* as a design observation rather than a vulnerability. That is a fact about
|
|
13
|
+
* `auth.uid()`, not about Supabase: an anonymous caller there still carries
|
|
14
|
+
* the anon key's JWT, so `auth.role()` and `auth.jwt()` are non-null for it,
|
|
15
|
+
* and the same shape built on them is a bypass — see {@link CallerCall}.
|
|
13
16
|
* - Rebase / PostgREST-style stacks that coerce a missing id to a sentinel
|
|
14
17
|
* (`'anonymous'`, `''`): the expression is true for signed-out callers, so it
|
|
15
18
|
* is a straight authentication bypass. This exact policy shipped in this
|
|
@@ -22,9 +25,34 @@ import type { Check } from "../types.js";
|
|
|
22
25
|
* {@link matchTautology}.
|
|
23
26
|
*/
|
|
24
27
|
export declare const policyAnonymousTautology: Check;
|
|
28
|
+
/**
|
|
29
|
+
* What a caller call hands a signed-out request, which is what decides whether
|
|
30
|
+
* `<call> IS NOT NULL` keeps that request out.
|
|
31
|
+
*/
|
|
32
|
+
export interface CallerCall {
|
|
33
|
+
/**
|
|
34
|
+
* On Supabase, a signed-out request carries the project's anon key — a real
|
|
35
|
+
* JWT with `role: "anon"` and no user in it. So:
|
|
36
|
+
*
|
|
37
|
+
* - `null`: the call reads something that JWT does not carry (`auth.uid()`
|
|
38
|
+
* reads `sub`), and a null test on it does exclude signed-out callers;
|
|
39
|
+
* - `present`: the call reads something it does carry, and a null test on
|
|
40
|
+
* it excludes nobody;
|
|
41
|
+
* - `unknown`: Supabase does not set it, so whatever does decides.
|
|
42
|
+
*/
|
|
43
|
+
onSupabase: "null" | "present" | "unknown";
|
|
44
|
+
/** What the call returns for that request, in prose, when it is `present`. */
|
|
45
|
+
signedOutValue?: string;
|
|
46
|
+
/** What the call names — "id", "role" — for the sentence about its sentinels. */
|
|
47
|
+
noun: string;
|
|
48
|
+
/** Values a signed-out caller arrives with, so a guard excluding one is real. */
|
|
49
|
+
sentinels: string[];
|
|
50
|
+
}
|
|
25
51
|
export interface TautologyMatch {
|
|
26
52
|
/** How to name the caller-id expression in prose, e.g. `auth.uid()`. */
|
|
27
53
|
shape: string;
|
|
54
|
+
/** What that call hands a signed-out request. */
|
|
55
|
+
call: CallerCall;
|
|
28
56
|
/** Literals the policy excludes that exclude nobody. Empty for a bare null test. */
|
|
29
57
|
decoyGuards: string[];
|
|
30
58
|
/**
|
|
@@ -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/sql.d.ts
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* Everything it reads comes from `pg_policies.qual` / `with_check`, which is
|
|
10
10
|
* Postgres's own re-rendering of the parse tree rather than the SQL anyone
|
|
11
11
|
* typed: parenthesised more heavily, casts made explicit, and — importantly for
|
|
12
|
-
* the unqualified-column check —
|
|
12
|
+
* the unqualified-column check — every column inside a subquery qualified, so a
|
|
13
|
+
* bare name written there never reaches this scanner from a live catalog. A hit
|
|
13
14
|
* is strong evidence and a miss proves nothing, which is what the checks say.
|
|
14
15
|
*/
|
|
15
16
|
export type TokenKind = "ident" | "string" | "number" | "op" | "punct";
|
|
@@ -24,10 +24,20 @@ import type { Check } from "../types.js";
|
|
|
24
24
|
* flagging it merely because the outer table also has a `user_id` would fire
|
|
25
25
|
* on a large fraction of correct policies.
|
|
26
26
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* it
|
|
30
|
-
*
|
|
31
|
-
*
|
|
27
|
+
* What a live database hands this check is not what anyone typed.
|
|
28
|
+
* `pg_policies.qual` is Postgres's re-rendering of the parse tree, and inside a
|
|
29
|
+
* subquery it qualifies *every* column — so the bare name is never in the text.
|
|
30
|
+
* The bare-name scan below only ever fires on text that did not come out of the
|
|
31
|
+
* catalog. Against a real database the mistake shows up as its effect: the bare
|
|
32
|
+
* `organization_id` bound to the inner relation, so the stored comparison reads
|
|
33
|
+
* `m.organization_id = m.organization_id` — a column compared with itself. That
|
|
34
|
+
* shape is what {@link selfComparisons} looks for, and it is the half of this
|
|
35
|
+
* check that can fire on a scan. It was the half missing until it was noticed
|
|
36
|
+
* that the check could not fire at all; the e2e suite now holds it to that.
|
|
37
|
+
*
|
|
38
|
+
* Confidence is always heuristic, and an absence still proves nothing: a bare
|
|
39
|
+
* name compared with a *different* inner column (`organization_id = id`) comes
|
|
40
|
+
* back as `m.organization_id = m.id`, which is indistinguishable from a
|
|
41
|
+
* comparison somebody meant.
|
|
32
42
|
*/
|
|
33
43
|
export declare const unqualifiedColumnInSubquery: Check;
|
package/dist/checks/util.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* noise, and noise is what gets a tool like this uninstalled — so the helpers
|
|
7
7
|
* below err towards "no" whenever the catalog is ambiguous.
|
|
8
8
|
*/
|
|
9
|
-
import type { DbGrant, DbPolicy, DbRelation, DbSnapshot, Finding, Severity } from "../types.js";
|
|
9
|
+
import type { DbForeignKey, DbGrant, DbPolicy, DbRelation, DbRole, DbSnapshot, Finding, Severity } from "../types.js";
|
|
10
10
|
export declare const SEVERITY_ORDER: Severity[];
|
|
11
11
|
export declare const docsFor: (id: string) => string;
|
|
12
12
|
export type Privilege = DbGrant["privileges"][number];
|
|
@@ -17,6 +17,12 @@ export declare const WRITE_PRIVILEGES: Privilege[];
|
|
|
17
17
|
export declare const ANONYMOUS_ROLES: string[];
|
|
18
18
|
export declare const isPublicRole: (role: string) => boolean;
|
|
19
19
|
export declare const sameRole: (a: string, b: string) => boolean;
|
|
20
|
+
/** Every grant on one relation, in snapshot order. */
|
|
21
|
+
export declare function grantsOn(snapshot: DbSnapshot, schema: string, table: string): readonly DbGrant[];
|
|
22
|
+
/** The foreign keys declared on one table, in snapshot order. */
|
|
23
|
+
export declare function foreignKeysOf(snapshot: DbSnapshot, schema: string, table: string): readonly DbForeignKey[];
|
|
24
|
+
/** A role by name, compared the way {@link sameRole} compares. */
|
|
25
|
+
export declare function roleNamed(snapshot: DbSnapshot, name: string): DbRole | undefined;
|
|
20
26
|
/**
|
|
21
27
|
* Every role whose grants `role` actually receives: itself, PUBLIC, and the
|
|
22
28
|
* transitive closure of its memberships.
|
|
@@ -27,7 +33,19 @@ export declare const sameRole: (a: string, b: string) => boolean;
|
|
|
27
33
|
* checker that only compares grantee names.
|
|
28
34
|
*/
|
|
29
35
|
export declare function rolesUsableBy(snapshot: DbSnapshot, role: string): Set<string>;
|
|
30
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* Can `role` name objects in `schema` at all?
|
|
38
|
+
*
|
|
39
|
+
* Without USAGE on the schema a role gets "permission denied for schema" before
|
|
40
|
+
* any table privilege is looked at, so a grant on a table there reaches
|
|
41
|
+
* nothing. Unknown — the read failed, or the schema has no record — counts as
|
|
42
|
+
* yes: a scanner that cannot tell must report, not stay quiet.
|
|
43
|
+
*/
|
|
44
|
+
export declare function hasSchemaUsage(snapshot: DbSnapshot, schema: string, role: string): boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Privileges `role` effectively holds on a relation, memberships included —
|
|
47
|
+
* and none at all when it cannot use the relation's schema.
|
|
48
|
+
*/
|
|
31
49
|
export declare function effectivePrivileges(snapshot: DbSnapshot, schema: string, table: string, role: string): Set<Privilege>;
|
|
32
50
|
/**
|
|
33
51
|
* Which exposed roles hold at least one of `wanted` on this relation, and which.
|
|
@@ -40,18 +58,54 @@ export declare function exposedGrantees(snapshot: DbSnapshot, schema: string, ta
|
|
|
40
58
|
}[];
|
|
41
59
|
/** Does this policy's TO list name a role an untrusted caller arrives as? */
|
|
42
60
|
export declare function policyTargetsExposedRole(snapshot: DbSnapshot, policy: DbPolicy): string[];
|
|
61
|
+
/**
|
|
62
|
+
* The exposed callers a policy applies to *and* that can reach its table for
|
|
63
|
+
* its command: {@link policyTargetsExposedRole}, kept to the roles holding a
|
|
64
|
+
* privilege the command needs (and USAGE on the schema).
|
|
65
|
+
*
|
|
66
|
+
* A policy nobody can reach the table through is a latent problem, not an
|
|
67
|
+
* exposure, and every check claims an exposure: "a caller can read every row"
|
|
68
|
+
* is false for a role Postgres answers "permission denied". `PUBLIC` stays when
|
|
69
|
+
* the policy is TO PUBLIC and any exposed role reaches the table.
|
|
70
|
+
*/
|
|
71
|
+
export declare function policyReachedBy(snapshot: DbSnapshot, policy: DbPolicy): string[];
|
|
43
72
|
export declare const TABLE_KINDS: DbRelation["kind"][];
|
|
44
73
|
export declare const isTable: (r: DbRelation) => boolean;
|
|
45
74
|
export declare function scannedTables(snapshot: DbSnapshot): DbRelation[];
|
|
75
|
+
/**
|
|
76
|
+
* Foreign tables in the scanned schemas. Postgres cannot put row-level
|
|
77
|
+
* security on one, so a grant is all that stands between it and a caller.
|
|
78
|
+
*/
|
|
79
|
+
export declare function scannedForeignTables(snapshot: DbSnapshot): DbRelation[];
|
|
46
80
|
export declare function relationAt(snapshot: DbSnapshot, schema: string, name: string): DbRelation | undefined;
|
|
47
81
|
export declare function policiesFor(snapshot: DbSnapshot, schema: string, table: string): DbPolicy[];
|
|
48
82
|
export declare const hasColumn: (r: DbRelation, name: string) => boolean;
|
|
49
83
|
/** `≈ 12,000 rows` — only ever used to convey blast radius, never severity. */
|
|
50
84
|
export declare function rowsPhrase(rel: DbRelation | undefined): string;
|
|
51
|
-
|
|
85
|
+
/**
|
|
86
|
+
* A quoted identifier that is one printable token wherever a fix puts it.
|
|
87
|
+
*
|
|
88
|
+
* Every name here comes out of the catalog, which means out of whoever could
|
|
89
|
+
* create a table, a role or a policy, and every fix is printed to be pasted
|
|
90
|
+
* into psql. Doubling `"` keeps a name inside its quotes; a name with a line
|
|
91
|
+
* break in it is written as a Unicode-escape identifier (`U&"a\000Ab"`), which
|
|
92
|
+
* names the same object and has no line break to escape a comment with.
|
|
93
|
+
*/
|
|
94
|
+
export declare function qi(ident: string): string;
|
|
52
95
|
export declare const qrel: (schema: string, name: string) => string;
|
|
53
96
|
/** PUBLIC is a keyword, not an identifier — quoting it changes what it means. */
|
|
54
97
|
export declare const qrole: (role: string) => string;
|
|
98
|
+
/**
|
|
99
|
+
* The REVOKEs that take `privileges` on a relation away from the exposed roles:
|
|
100
|
+
* one per grant that reaches them, made to the role the grant names.
|
|
101
|
+
*
|
|
102
|
+
* Revoking from the exposed role by name is not the same thing. A grant to
|
|
103
|
+
* `app_reader` that `anon` inherits survives `REVOKE … FROM anon`, which is a
|
|
104
|
+
* no-op, and a relation granted to `anon` and `authenticated` keeps the second
|
|
105
|
+
* grant when the fix names only the first. Every fix that takes a grant away
|
|
106
|
+
* builds it here, so applying it and rescanning clears the finding.
|
|
107
|
+
*/
|
|
108
|
+
export declare function revokesReaching(snapshot: DbSnapshot, schema: string, table: string, exposed: string[], privileges: Privilege[]): string[];
|
|
55
109
|
export declare function finding(f: Omit<Finding, "docs"> & {
|
|
56
110
|
docs?: string;
|
|
57
111
|
}): Finding;
|
|
@@ -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;
|