@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/dist/index.es.js
CHANGED
|
@@ -398,6 +398,42 @@ var ANONYMOUS_ROLES = [
|
|
|
398
398
|
];
|
|
399
399
|
var isPublicRole = (role) => role.toLowerCase() === "public";
|
|
400
400
|
var sameRole = (a, b) => a.toLowerCase() === b.toLowerCase();
|
|
401
|
+
function grouped(cache, items, key) {
|
|
402
|
+
const hit = cache.get(items);
|
|
403
|
+
if (hit && hit.length === items.length) return hit.groups;
|
|
404
|
+
const groups = /* @__PURE__ */ new Map();
|
|
405
|
+
for (const item of items) {
|
|
406
|
+
const k = key(item);
|
|
407
|
+
const list = groups.get(k);
|
|
408
|
+
if (list) list.push(item);
|
|
409
|
+
else groups.set(k, [item]);
|
|
410
|
+
}
|
|
411
|
+
cache.set(items, {
|
|
412
|
+
length: items.length,
|
|
413
|
+
groups
|
|
414
|
+
});
|
|
415
|
+
return groups;
|
|
416
|
+
}
|
|
417
|
+
/** A NUL cannot appear in a Postgres identifier, so it cannot make two pairs collide. */
|
|
418
|
+
var pairKey = (schema, name) => `${schema}\u0000${name}`;
|
|
419
|
+
var RELATIONS = /* @__PURE__ */ new WeakMap();
|
|
420
|
+
var GRANTS = /* @__PURE__ */ new WeakMap();
|
|
421
|
+
var POLICIES = /* @__PURE__ */ new WeakMap();
|
|
422
|
+
var FOREIGN_KEYS = /* @__PURE__ */ new WeakMap();
|
|
423
|
+
var ROLES = /* @__PURE__ */ new WeakMap();
|
|
424
|
+
var SCHEMA_USAGE = /* @__PURE__ */ new WeakMap();
|
|
425
|
+
/** Every grant on one relation, in snapshot order. */
|
|
426
|
+
function grantsOn(snapshot, schema, table) {
|
|
427
|
+
return grouped(GRANTS, snapshot.grants, (g) => pairKey(g.schema, g.table)).get(pairKey(schema, table)) ?? [];
|
|
428
|
+
}
|
|
429
|
+
/** The foreign keys declared on one table, in snapshot order. */
|
|
430
|
+
function foreignKeysOf(snapshot, schema, table) {
|
|
431
|
+
return grouped(FOREIGN_KEYS, snapshot.foreignKeys, (fk) => pairKey(fk.schema, fk.table)).get(pairKey(schema, table)) ?? [];
|
|
432
|
+
}
|
|
433
|
+
/** A role by name, compared the way {@link sameRole} compares. */
|
|
434
|
+
function roleNamed(snapshot, name) {
|
|
435
|
+
return grouped(ROLES, snapshot.roles, (r) => r.name.toLowerCase()).get(name.toLowerCase())?.[0];
|
|
436
|
+
}
|
|
401
437
|
/**
|
|
402
438
|
* Every role whose grants `role` actually receives: itself, PUBLIC, and the
|
|
403
439
|
* transitive closure of its memberships.
|
|
@@ -409,16 +445,34 @@ var sameRole = (a, b) => a.toLowerCase() === b.toLowerCase();
|
|
|
409
445
|
*/
|
|
410
446
|
function rolesUsableBy(snapshot, role) {
|
|
411
447
|
const out = /* @__PURE__ */ new Set(["public", role.toLowerCase()]);
|
|
412
|
-
const def = snapshot
|
|
448
|
+
const def = roleNamed(snapshot, role);
|
|
413
449
|
for (const m of def?.memberOf ?? []) out.add(m.toLowerCase());
|
|
414
450
|
return out;
|
|
415
451
|
}
|
|
416
|
-
/**
|
|
417
|
-
|
|
452
|
+
/**
|
|
453
|
+
* Can `role` name objects in `schema` at all?
|
|
454
|
+
*
|
|
455
|
+
* Without USAGE on the schema a role gets "permission denied for schema" before
|
|
456
|
+
* any table privilege is looked at, so a grant on a table there reaches
|
|
457
|
+
* nothing. Unknown — the read failed, or the schema has no record — counts as
|
|
458
|
+
* yes: a scanner that cannot tell must report, not stay quiet.
|
|
459
|
+
*/
|
|
460
|
+
function hasSchemaUsage(snapshot, schema, role) {
|
|
461
|
+
if (!snapshot.schemaUsage) return true;
|
|
462
|
+
const holders = grouped(SCHEMA_USAGE, snapshot.schemaUsage, (u) => u.schema).get(schema);
|
|
463
|
+
if (!holders) return true;
|
|
418
464
|
const via = rolesUsableBy(snapshot, role);
|
|
465
|
+
return holders.some((u) => via.has(u.grantee.toLowerCase()));
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Privileges `role` effectively holds on a relation, memberships included —
|
|
469
|
+
* and none at all when it cannot use the relation's schema.
|
|
470
|
+
*/
|
|
471
|
+
function effectivePrivileges(snapshot, schema, table, role) {
|
|
419
472
|
const out = /* @__PURE__ */ new Set();
|
|
420
|
-
|
|
421
|
-
|
|
473
|
+
if (!hasSchemaUsage(snapshot, schema, role)) return out;
|
|
474
|
+
const via = rolesUsableBy(snapshot, role);
|
|
475
|
+
for (const g of grantsOn(snapshot, schema, table)) {
|
|
422
476
|
if (!via.has(g.grantee.toLowerCase())) continue;
|
|
423
477
|
for (const p of g.privileges) out.add(p);
|
|
424
478
|
}
|
|
@@ -459,16 +513,45 @@ function policyTargetsExposedRole(snapshot, policy) {
|
|
|
459
513
|
}
|
|
460
514
|
return [...new Set(hits)];
|
|
461
515
|
}
|
|
516
|
+
/** The privileges a policy's command is exercised with. */
|
|
517
|
+
var COMMAND_PRIVILEGES = {
|
|
518
|
+
ALL: DML,
|
|
519
|
+
SELECT: ["SELECT"],
|
|
520
|
+
INSERT: ["INSERT"],
|
|
521
|
+
UPDATE: ["UPDATE"],
|
|
522
|
+
DELETE: ["DELETE"]
|
|
523
|
+
};
|
|
524
|
+
/**
|
|
525
|
+
* The exposed callers a policy applies to *and* that can reach its table for
|
|
526
|
+
* its command: {@link policyTargetsExposedRole}, kept to the roles holding a
|
|
527
|
+
* privilege the command needs (and USAGE on the schema).
|
|
528
|
+
*
|
|
529
|
+
* A policy nobody can reach the table through is a latent problem, not an
|
|
530
|
+
* exposure, and every check claims an exposure: "a caller can read every row"
|
|
531
|
+
* is false for a role Postgres answers "permission denied". `PUBLIC` stays when
|
|
532
|
+
* the policy is TO PUBLIC and any exposed role reaches the table.
|
|
533
|
+
*/
|
|
534
|
+
function policyReachedBy(snapshot, policy) {
|
|
535
|
+
const wanted = COMMAND_PRIVILEGES[policy.command] ?? DML;
|
|
536
|
+
return policyTargetsExposedRole(snapshot, policy).filter((target) => target === "PUBLIC" ? exposedGrantees(snapshot, policy.schema, policy.table, wanted).length > 0 : wanted.some((p) => effectivePrivileges(snapshot, policy.schema, policy.table, target).has(p)));
|
|
537
|
+
}
|
|
462
538
|
var TABLE_KINDS$1 = ["table", "partitioned_table"];
|
|
463
539
|
var isTable = (r) => TABLE_KINDS$1.includes(r.kind);
|
|
464
540
|
function scannedTables(snapshot) {
|
|
465
541
|
return snapshot.relations.filter((r) => isTable(r) && snapshot.schemas.includes(r.schema));
|
|
466
542
|
}
|
|
543
|
+
/**
|
|
544
|
+
* Foreign tables in the scanned schemas. Postgres cannot put row-level
|
|
545
|
+
* security on one, so a grant is all that stands between it and a caller.
|
|
546
|
+
*/
|
|
547
|
+
function scannedForeignTables(snapshot) {
|
|
548
|
+
return snapshot.relations.filter((r) => r.kind === "foreign_table" && snapshot.schemas.includes(r.schema));
|
|
549
|
+
}
|
|
467
550
|
function relationAt(snapshot, schema, name) {
|
|
468
|
-
return snapshot.relations
|
|
551
|
+
return grouped(RELATIONS, snapshot.relations, (r) => pairKey(r.schema, r.name)).get(pairKey(schema, name))?.[0];
|
|
469
552
|
}
|
|
470
553
|
function policiesFor(snapshot, schema, table) {
|
|
471
|
-
return snapshot.policies
|
|
554
|
+
return [...grouped(POLICIES, snapshot.policies, (p) => pairKey(p.schema, p.table)).get(pairKey(schema, table)) ?? []];
|
|
472
555
|
}
|
|
473
556
|
var hasColumn = (r, name) => r.columns.some((c) => c.name.toLowerCase() === name.toLowerCase());
|
|
474
557
|
/** `≈ 12,000 rows` — only ever used to convey blast radius, never severity. */
|
|
@@ -477,10 +560,49 @@ function rowsPhrase(rel) {
|
|
|
477
560
|
if (rel.estimatedRows === 0) return " (the planner currently estimates 0 rows)";
|
|
478
561
|
return ` (≈${Math.round(rel.estimatedRows).toLocaleString("en-US")} rows)`;
|
|
479
562
|
}
|
|
480
|
-
|
|
563
|
+
/**
|
|
564
|
+
* Characters that cannot sit inside a fix as themselves: a line break would
|
|
565
|
+
* end the `--` comment a suggestion is printed in and run the rest of the name
|
|
566
|
+
* as SQL, and the other controls are invisible or rewrite the terminal. The
|
|
567
|
+
* control characters are the point, so the lint rule that objects to them is
|
|
568
|
+
* off for this line.
|
|
569
|
+
*/
|
|
570
|
+
var UNPRINTABLE = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
|
|
571
|
+
var UNPRINTABLE_ALL = new RegExp(UNPRINTABLE.source, "g");
|
|
572
|
+
/**
|
|
573
|
+
* A quoted identifier that is one printable token wherever a fix puts it.
|
|
574
|
+
*
|
|
575
|
+
* Every name here comes out of the catalog, which means out of whoever could
|
|
576
|
+
* create a table, a role or a policy, and every fix is printed to be pasted
|
|
577
|
+
* into psql. Doubling `"` keeps a name inside its quotes; a name with a line
|
|
578
|
+
* break in it is written as a Unicode-escape identifier (`U&"a\000Ab"`), which
|
|
579
|
+
* names the same object and has no line break to escape a comment with.
|
|
580
|
+
*/
|
|
581
|
+
function qi(ident) {
|
|
582
|
+
const doubled = ident.replace(/"/g, "\"\"");
|
|
583
|
+
if (!UNPRINTABLE.test(ident)) return `"${doubled}"`;
|
|
584
|
+
return `U&"${doubled.replace(/\\/g, "\\\\").replace(UNPRINTABLE_ALL, (ch) => `\\${ch.charCodeAt(0).toString(16).toUpperCase().padStart(4, "0")}`)}"`;
|
|
585
|
+
}
|
|
481
586
|
var qrel = (schema, name) => `${qi(schema)}.${qi(name)}`;
|
|
482
587
|
/** PUBLIC is a keyword, not an identifier — quoting it changes what it means. */
|
|
483
588
|
var qrole = (role) => isPublicRole(role) ? "PUBLIC" : qi(role);
|
|
589
|
+
/**
|
|
590
|
+
* The REVOKEs that take `privileges` on a relation away from the exposed roles:
|
|
591
|
+
* one per grant that reaches them, made to the role the grant names.
|
|
592
|
+
*
|
|
593
|
+
* Revoking from the exposed role by name is not the same thing. A grant to
|
|
594
|
+
* `app_reader` that `anon` inherits survives `REVOKE … FROM anon`, which is a
|
|
595
|
+
* no-op, and a relation granted to `anon` and `authenticated` keeps the second
|
|
596
|
+
* grant when the fix names only the first. Every fix that takes a grant away
|
|
597
|
+
* builds it here, so applying it and rescanning clears the finding.
|
|
598
|
+
*/
|
|
599
|
+
function revokesReaching(snapshot, schema, table, exposed, privileges) {
|
|
600
|
+
const reaching = new Set(exposed.flatMap((role) => [...rolesUsableBy(snapshot, role)]));
|
|
601
|
+
return grantsOn(snapshot, schema, table).filter((g) => reaching.has(g.grantee.toLowerCase())).map((g) => ({
|
|
602
|
+
grantee: g.grantee,
|
|
603
|
+
held: privileges.filter((p) => g.privileges.includes(p))
|
|
604
|
+
})).filter((g) => g.held.length > 0).map((g) => `REVOKE ${g.held.join(", ")} ON ${qrel(schema, table)} FROM ${qrole(g.grantee)};`);
|
|
605
|
+
}
|
|
484
606
|
function finding(f) {
|
|
485
607
|
return {
|
|
486
608
|
...f,
|
|
@@ -594,8 +716,9 @@ var COMMANDS_FOR = {
|
|
|
594
716
|
* correct one — satisfies condition 1 on almost every project out there.
|
|
595
717
|
* Flagging it would make this check fire on the whole ecosystem.
|
|
596
718
|
*
|
|
597
|
-
* So what is reported is the narrow, certain case: the
|
|
598
|
-
*
|
|
719
|
+
* So what is reported is the narrow, certain case: the clause that decides the
|
|
720
|
+
* command is constant-true, which in Postgres means "accept any row". An
|
|
721
|
+
* *absent* clause is the opposite — see {@link writesAcceptingAnyRow}. Policies
|
|
599
722
|
* whose expression is an anonymous *tautology* rather than a constant are the
|
|
600
723
|
* business of `policy-anonymous-tautology`, which can weigh the platform.
|
|
601
724
|
*/
|
|
@@ -609,11 +732,11 @@ var anonymousWriteAllowed = {
|
|
|
609
732
|
for (const policy of snapshot.policies) {
|
|
610
733
|
if (!snapshot.schemas.includes(policy.schema)) continue;
|
|
611
734
|
if (!policy.permissive) continue;
|
|
612
|
-
const
|
|
735
|
+
const open = writesAcceptingAnyRow(policy);
|
|
736
|
+
const wanted = (COMMANDS_FOR[policy.command] ?? []).filter((p) => open.includes(p));
|
|
613
737
|
if (wanted.length === 0) continue;
|
|
614
738
|
const identities = anonymousIdentities(snapshot, policy.roles);
|
|
615
739
|
if (identities.length === 0) continue;
|
|
616
|
-
if (!acceptsAnyRow(policy)) continue;
|
|
617
740
|
const granted = /* @__PURE__ */ new Set();
|
|
618
741
|
const grantedTo = [];
|
|
619
742
|
for (const role of identities) {
|
|
@@ -636,9 +759,9 @@ var anonymousWriteAllowed = {
|
|
|
636
759
|
table: policy.table,
|
|
637
760
|
policy: policy.name
|
|
638
761
|
},
|
|
639
|
-
detail: `Policy "${policy.name}" is a permissive ${policy.command} policy for ${listAnd(grantedTo)}, and
|
|
762
|
+
detail: `Policy "${policy.name}" is a permissive ${policy.command} policy for ${listAnd(grantedTo)}, and the expression that decides ${listAnd(commands)} is a constant truth, so every row satisfies it. ${listAnd(grantedTo)} also ${grantedTo.length > 1 ? "hold" : "holds"} ${listAnd(commands)} on the table, so both the privilege check and the row check pass for a request that carries no credentials.`,
|
|
640
763
|
impact: `An unauthenticated caller reaching this database over an API can ${listAnd(verbs)} rows in ${policy.schema}.${policy.table} at will — inserting records attributed to other users, or ${commands.includes("DELETE") ? "deleting the table's contents" : "modifying rows they do not own"}.`,
|
|
641
|
-
fix: isRebaseManagedPolicy(snapshot, policy) ? managedPolicyFix(policy, "scope the write rule to the caller (an `ownerField`) or restrict it to `roles` — and if anonymous writes are never intended, say so there rather than by revoking the grant, which boot re-makes") : `-- Scope the write to the caller, or take the privilege away entirely:\nALTER POLICY ${qi(policy.name)} ON ${qrel(policy.schema, policy.table)}\n WITH CHECK (user_id = ${uidCall});\n-- and if anonymous writes are never intended:\
|
|
764
|
+
fix: isRebaseManagedPolicy(snapshot, policy) ? managedPolicyFix(policy, "scope the write rule to the caller (an `ownerField`) or restrict it to `roles` — and if anonymous writes are never intended, say so there rather than by revoking the grant, which boot re-makes") : `-- Scope the write to the caller, or take the privilege away entirely:\nALTER POLICY ${qi(policy.name)} ON ${qrel(policy.schema, policy.table)}\n WITH CHECK (user_id = ${uidCall});\n-- and if anonymous writes are never intended:\n` + revokesReaching(snapshot, policy.schema, policy.table, grantedTo, commands).join("\n")
|
|
642
765
|
}));
|
|
643
766
|
}
|
|
644
767
|
return findings;
|
|
@@ -659,16 +782,30 @@ function anonymousIdentities(snapshot, policyRoles) {
|
|
|
659
782
|
return ["public", ...snapshot.roles.map((r) => r.name).filter((name) => ANONYMOUS_ROLES.includes(name.toLowerCase()) && !isPublicRole(name))];
|
|
660
783
|
}
|
|
661
784
|
/**
|
|
662
|
-
*
|
|
785
|
+
* The writes this policy admits for any row whatsoever.
|
|
663
786
|
*
|
|
664
|
-
*
|
|
665
|
-
*
|
|
666
|
-
*
|
|
787
|
+
* Postgres ORs together the expressions the permissive policies for a command
|
|
788
|
+
* *have*, and denies when there are none — so a clause a policy lacks admits
|
|
789
|
+
* nothing, it does not admit everything. Reading an absent clause as true
|
|
790
|
+
* reported `FOR INSERT TO anon` with no WITH CHECK (the insert is refused),
|
|
791
|
+
* `FOR DELETE TO anon` with no USING (no row is deleted) and `FOR UPDATE TO anon
|
|
792
|
+
* WITH CHECK (true)` with no USING (no row is reached) as open writes.
|
|
793
|
+
*
|
|
794
|
+
* - INSERT is decided by WITH CHECK (USING is not allowed on an INSERT policy,
|
|
795
|
+
* and a `FOR ALL` policy's USING stands in for a missing WITH CHECK);
|
|
796
|
+
* - UPDATE needs USING to reach the row and the check — WITH CHECK, or USING
|
|
797
|
+
* again — to accept the new one;
|
|
798
|
+
* - DELETE is decided by USING.
|
|
667
799
|
*/
|
|
668
|
-
function
|
|
669
|
-
const
|
|
670
|
-
|
|
671
|
-
|
|
800
|
+
function writesAcceptingAnyRow(policy) {
|
|
801
|
+
const using = policy.using != null && isUnconditionalTrue(policy.using);
|
|
802
|
+
const checkExpr = policy.withCheck ?? policy.using;
|
|
803
|
+
const check = checkExpr != null && isUnconditionalTrue(checkExpr);
|
|
804
|
+
const out = [];
|
|
805
|
+
if (check) out.push("INSERT");
|
|
806
|
+
if (using && check) out.push("UPDATE");
|
|
807
|
+
if (using) out.push("DELETE");
|
|
808
|
+
return out;
|
|
672
809
|
}
|
|
673
810
|
//#endregion
|
|
674
811
|
//#region src/checks/current-setting-throws.ts
|
|
@@ -764,7 +901,11 @@ var grantToPublic = {
|
|
|
764
901
|
if (!isPublicRole(grant.grantee)) continue;
|
|
765
902
|
if (!snapshot.schemas.includes(grant.schema)) continue;
|
|
766
903
|
const rel = relationAt(snapshot, grant.schema, grant.table);
|
|
767
|
-
if (!rel ||
|
|
904
|
+
if (!rel || ![
|
|
905
|
+
"table",
|
|
906
|
+
"partitioned_table",
|
|
907
|
+
"foreign_table"
|
|
908
|
+
].includes(rel.kind)) continue;
|
|
768
909
|
const privileges = DML.filter((p) => grant.privileges.includes(p));
|
|
769
910
|
if (privileges.length === 0) continue;
|
|
770
911
|
findings.push(finding({
|
|
@@ -776,7 +917,7 @@ var grantToPublic = {
|
|
|
776
917
|
schema: grant.schema,
|
|
777
918
|
table: grant.table
|
|
778
919
|
},
|
|
779
|
-
detail: "PUBLIC is every role in this cluster, including roles created after this grant was made. " + (rel.rlsEnabled ? "Row-level security is enabled on the table, so rows are still filtered by policy — but this grant decides *whose* policies get evaluated, and it answers \"everyone's\". A permissive policy added later for any role applies immediately, with no separate grant needed." : "Row-level security is not enabled on this table, so nothing filters the rows this grant exposes."),
|
|
920
|
+
detail: "PUBLIC is every role in this cluster, including roles created after this grant was made. " + (rel.rlsEnabled ? "Row-level security is enabled on the table, so rows are still filtered by policy — but this grant decides *whose* policies get evaluated, and it answers \"everyone's\". A permissive policy added later for any role applies immediately, with no separate grant needed." : rel.kind === "foreign_table" ? "This is a foreign table, which cannot have row-level security, so nothing filters the rows this grant exposes." : "Row-level security is not enabled on this table, so nothing filters the rows this grant exposes."),
|
|
780
921
|
impact: rel.rlsEnabled ? `Every role in the database can attempt ${listAnd(privileges)} on this table${rowsPhrase(rel)}; what they get back depends entirely on the policies, including any added in future.` : `Every role in the database, including any role an API connects as, can ${listAnd(privileges.map((p) => p.toLowerCase()))} every row of this table${rowsPhrase(rel)}.`,
|
|
781
922
|
fix: `REVOKE ${privileges.join(", ")} ON ${qrel(grant.schema, grant.table)} FROM PUBLIC;\n-- then grant explicitly to the roles that need it:\n-- GRANT ${privileges.join(", ")} ON ${qrel(grant.schema, grant.table)} TO "your_app_role";`
|
|
782
923
|
}));
|
|
@@ -823,13 +964,14 @@ var junctionTableUnprotected = {
|
|
|
823
964
|
const findings = [];
|
|
824
965
|
for (const rel of scannedTables(snapshot)) {
|
|
825
966
|
if (rel.rlsEnabled) continue;
|
|
826
|
-
const fks = snapshot
|
|
967
|
+
const fks = foreignKeysOf(snapshot, rel.schema, rel.name);
|
|
827
968
|
if (fks.length !== 2) continue;
|
|
828
969
|
const endpoints = fks.map((fk) => `${fk.refSchema}.${fk.refTable}`);
|
|
829
970
|
if (endpoints[0] === endpoints[1]) continue;
|
|
830
971
|
if (endpoints.some((e) => e === `${rel.schema}.${rel.name}`)) continue;
|
|
831
972
|
if (!fks.map((fk) => relationAt(snapshot, fk.refSchema, fk.refTable)).every((t) => t?.rlsEnabled)) continue;
|
|
832
973
|
if (!isMostlyKeys(rel, fks.flatMap((fk) => fk.columns))) continue;
|
|
974
|
+
if (exposedGrantees(snapshot, rel.schema, rel.name, DML).length === 0) continue;
|
|
833
975
|
findings.push(finding({
|
|
834
976
|
id: ID$11,
|
|
835
977
|
severity: "high",
|
|
@@ -841,7 +983,7 @@ var junctionTableUnprotected = {
|
|
|
841
983
|
},
|
|
842
984
|
detail: `This table looks like a many-to-many join table: its columns are the foreign keys to ${endpoints[0]} and ${endpoints[1]} plus bookkeeping. Both of those tables have row-level security enabled; this one does not, so no policy is consulted when it is read or written.`,
|
|
843
985
|
impact: `If this table is reachable over an API, a caller reads the full membership graph between ${endpoints[0]} and ${endpoints[1]} — which rows relate to which — even though neither endpoint table can be read directly. Where the join table is also writable, a caller can grant themselves a relationship that the policies on the endpoint tables then honour.`,
|
|
844
|
-
fix: `ALTER TABLE ${qrel(rel.schema, rel.name)} ENABLE ROW LEVEL SECURITY;\n-- A join table's policy normally follows its endpoints, e.g.:\n-- CREATE POLICY ${
|
|
986
|
+
fix: `ALTER TABLE ${qrel(rel.schema, rel.name)} ENABLE ROW LEVEL SECURITY;\n-- A join table's policy normally follows its endpoints, e.g.:\n-- CREATE POLICY ${qi(`${rel.name}_select`)} ON ${qrel(rel.schema, rel.name)}\n-- FOR SELECT USING (EXISTS (\n-- SELECT 1 FROM ${qrel(fks[0].refSchema, fks[0].refTable)} e\n-- WHERE e.id = ${qrel(rel.schema, rel.name)}.${qi(fks[0].columns[0])}\n-- ));`
|
|
845
987
|
}));
|
|
846
988
|
}
|
|
847
989
|
return findings;
|
|
@@ -861,7 +1003,7 @@ function isMostlyKeys(rel, keyColumns) {
|
|
|
861
1003
|
var ID$10 = "view-bypasses-rls";
|
|
862
1004
|
/** Base relations of `view` that have RLS turned on. */
|
|
863
1005
|
function protectedBaseTables(snapshot, view) {
|
|
864
|
-
return view.dependsOn.map((d) => relationAt(snapshot, d.schema, d.table)).filter((r) => Boolean(r?.rlsEnabled))
|
|
1006
|
+
return view.dependsOn.map((d) => relationAt(snapshot, d.schema, d.table)).filter((r) => Boolean(r?.rlsEnabled));
|
|
865
1007
|
}
|
|
866
1008
|
/**
|
|
867
1009
|
* A view granted to an untrusted role that reads an RLS-protected table without
|
|
@@ -888,8 +1030,9 @@ var viewBypassesRls = {
|
|
|
888
1030
|
const rel = relationAt(snapshot, view.schema, view.name);
|
|
889
1031
|
if (rel && rel.kind !== "view") continue;
|
|
890
1032
|
if (view.securityInvoker === true) continue;
|
|
891
|
-
const
|
|
892
|
-
if (
|
|
1033
|
+
const baseTables = protectedBaseTables(snapshot, view);
|
|
1034
|
+
if (baseTables.length === 0) continue;
|
|
1035
|
+
const bases = baseTables.map((r) => `${r.schema}.${r.name}`);
|
|
893
1036
|
const exposed = exposedGrantees(snapshot, view.schema, view.name, ["SELECT"]);
|
|
894
1037
|
if (exposed.length === 0) continue;
|
|
895
1038
|
const roles = exposed.map((e) => e.role);
|
|
@@ -908,7 +1051,7 @@ var viewBypassesRls = {
|
|
|
908
1051
|
impact: `If this view is reachable over an API as ${listAnd(roles)}, a caller reads rows from ${listAnd(bases)} that the policies on ${bases.length > 1 ? "those tables" : "that table"} were written to withhold — the view is an unfiltered path around them.`,
|
|
909
1052
|
fix: legacy ? `-- \`security_invoker\` requires PostgreSQL 15 or newer. On this server, either:
|
|
910
1053
|
-- 1. revoke access and let callers query the base table directly:
|
|
911
|
-
|
|
1054
|
+
${revokesReaching(snapshot, view.schema, view.name, roles, ["SELECT"]).join("\n")}\n-- 2. or set FORCE ROW LEVEL SECURITY on the base tables and add policies\n-- that apply to ${qi(view.owner)}, so the view's own execution is filtered.` : `ALTER VIEW ${qrel(view.schema, view.name)} SET (security_invoker = true);\n-- Callers then need their own SELECT privilege on ${listAnd(baseTables.map((r) => qrel(r.schema, r.name)))}, and the\n-- policies there apply to them.`
|
|
912
1055
|
}));
|
|
913
1056
|
}
|
|
914
1057
|
return findings;
|
|
@@ -935,8 +1078,9 @@ var matviewBypassesRls = {
|
|
|
935
1078
|
for (const view of snapshot.views) {
|
|
936
1079
|
if (!snapshot.schemas.includes(view.schema)) continue;
|
|
937
1080
|
if (relationAt(snapshot, view.schema, view.name)?.kind !== "materialized_view") continue;
|
|
938
|
-
const
|
|
939
|
-
if (
|
|
1081
|
+
const baseTables = protectedBaseTables(snapshot, view);
|
|
1082
|
+
if (baseTables.length === 0) continue;
|
|
1083
|
+
const bases = baseTables.map((r) => `${r.schema}.${r.name}`);
|
|
940
1084
|
const exposed = exposedGrantees(snapshot, view.schema, view.name, ["SELECT"]);
|
|
941
1085
|
if (exposed.length === 0) continue;
|
|
942
1086
|
const roles = exposed.map((e) => e.role);
|
|
@@ -952,7 +1096,7 @@ var matviewBypassesRls = {
|
|
|
952
1096
|
},
|
|
953
1097
|
detail: `This materialized view reads ${listAnd(bases)}, which ${bases.length > 1 ? "have" : "has"} row-level security enabled. Materialized views cannot have policies of their own, and \`security_invoker\` does not apply to them — the rows were computed once, by ${view.owner}, and are stored unfiltered. Every caller with SELECT reads the same stored rows.`,
|
|
954
1098
|
impact: `If this materialized view is reachable over an API as ${listAnd(roles)}, a caller reads a full copy of the protected data in ${listAnd(bases)} as of the last REFRESH. No policy can restrict this; only the grant can.`,
|
|
955
|
-
fix: `-- There is no RLS for materialized views. Restrict the grant:\
|
|
1099
|
+
fix: `-- There is no RLS for materialized views. Restrict the grant:\n${revokesReaching(snapshot, view.schema, view.name, roles, ["SELECT"]).join("\n")}\n-- and, if callers need this data, expose it through a view that applies the\n-- caller's own privileges:\n-- CREATE VIEW ${qrel(view.schema, `${view.name}_scoped`)} WITH (security_invoker = true)\n-- AS SELECT * FROM ${qrel(baseTables[0].schema, baseTables[0].name)} WHERE ...;`
|
|
956
1100
|
}));
|
|
957
1101
|
}
|
|
958
1102
|
return findings;
|
|
@@ -970,8 +1114,8 @@ var ID$8 = "policy-always-true";
|
|
|
970
1114
|
* substring-matching version of this check would flag both.
|
|
971
1115
|
*
|
|
972
1116
|
* The one thing that legitimately rescues `USING (true)` is a RESTRICTIVE
|
|
973
|
-
* policy on the same command
|
|
974
|
-
* PERMISSIVE ones are ORed. "Permissive default, restrictive gate" is a real
|
|
1117
|
+
* policy on the same command that applies to the same callers, because
|
|
1118
|
+
* RESTRICTIVE clauses are ANDed after the PERMISSIVE ones are ORed. "Permissive default, restrictive gate" is a real
|
|
975
1119
|
* pattern, so when one is present the finding degrades to a question instead of
|
|
976
1120
|
* an accusation rather than disappearing (the restrictive policy may well not
|
|
977
1121
|
* cover the same rows).
|
|
@@ -986,16 +1130,24 @@ var policyAlwaysTrue = {
|
|
|
986
1130
|
for (const policy of snapshot.policies) {
|
|
987
1131
|
if (!snapshot.schemas.includes(policy.schema)) continue;
|
|
988
1132
|
if (!policy.permissive) continue;
|
|
989
|
-
const exposed =
|
|
1133
|
+
const exposed = policyReachedBy(snapshot, policy);
|
|
990
1134
|
if (exposed.length === 0) continue;
|
|
991
1135
|
const clauses = [];
|
|
992
1136
|
if (isUnconditionalTrue(policy.using)) clauses.push("USING");
|
|
993
1137
|
if (isUnconditionalTrue(policy.withCheck)) clauses.push("WITH CHECK");
|
|
994
1138
|
if (clauses.length === 0) continue;
|
|
995
|
-
const
|
|
1139
|
+
const gates = restrictiveGate(snapshot, policy);
|
|
1140
|
+
const gate = gates && gates.length === 1 ? {
|
|
1141
|
+
name: `RESTRICTIVE policy "${gates[0]}"`,
|
|
1142
|
+
verb: "applies"
|
|
1143
|
+
} : gates ? {
|
|
1144
|
+
name: `RESTRICTIVE policies ${listAnd(gates.map((g) => `"${g}"`))}`,
|
|
1145
|
+
verb: "apply"
|
|
1146
|
+
} : null;
|
|
996
1147
|
const rel = relationAt(snapshot, policy.schema, policy.table);
|
|
997
1148
|
const verb = policy.command === "SELECT" ? "read" : "act on";
|
|
998
|
-
const
|
|
1149
|
+
const checkOnly = !clauses.includes("USING") && (policy.command === "UPDATE" || policy.command === "ALL");
|
|
1150
|
+
const severity = gate ? "medium" : checkOnly && policy.command === "UPDATE" ? "high" : "critical";
|
|
999
1151
|
findings.push(finding({
|
|
1000
1152
|
id: ID$8,
|
|
1001
1153
|
severity,
|
|
@@ -1006,17 +1158,58 @@ var policyAlwaysTrue = {
|
|
|
1006
1158
|
table: policy.table,
|
|
1007
1159
|
policy: policy.name
|
|
1008
1160
|
},
|
|
1009
|
-
detail: `This permissive ${policy.command} policy's ${listAnd(clauses)} expression is a constant truth, so it matches every row for ${listAnd(exposed)}. Permissive policies are ORed together, so this one alone satisfies the table's row filter no matter how strict the others are.` + (gate ? `
|
|
1010
|
-
impact: gate ? `Row filtering on this table rests entirely on the
|
|
1161
|
+
detail: (checkOnly ? `This permissive ${policy.command} policy's WITH CHECK expression is a constant truth, so any new row value passes it for ${listAnd(exposed)}. ` + (policy.using == null ? "It has no USING clause, so on its own it lets no row be updated — but permissive policies' checks are ORed, so every UPDATE another policy lets through may write any values at all." : "Its USING clause still decides which rows can be touched; the check decides what they may become, and it accepts anything. Permissive policies' checks are ORed, so this one overrides any stricter check beside it.") + (policy.command === "ALL" ? " On a FOR ALL policy the same check governs INSERT, so any row at all can be inserted." : "") : `This permissive ${policy.command} policy's ${listAnd(clauses)} expression is a constant truth, so it matches every row for ${listAnd(exposed)}. Permissive policies are ORed together, so this one alone satisfies the table's row filter no matter how strict the others are.`) + (gate ? ` The ${gate.name} also ${gate.verb} to this command and to every role it reaches, ANDed after it, so access may still be gated — verify that the restrictive side covers the rows you expect, because nothing else here does.` : ""),
|
|
1162
|
+
impact: gate ? `Row filtering on this table rests entirely on the ${gate.name}. If it does not cover a case, ${listAnd(exposed)} can ${verb} every row${rowsPhrase(rel)}.` : checkOnly ? `If this table is reachable over an API as ${listAnd(exposed)}, a caller can rewrite any row it is allowed to update into anything — reassign it to another user, move it to another tenant, set any flag it carries` + (policy.command === "ALL" ? ` — and insert rows with any values, attributed to anyone.` : `. Which rows it can reach is still scoped by USING.`) : `If this table is reachable over an API as ${listAnd(exposed)}, a caller can ${verb} every row${rowsPhrase(rel)} — the policy applies no scoping whatsoever.`,
|
|
1011
1163
|
fix: isRebaseManagedPolicy(snapshot, policy) ? managedPolicyFix(policy, `replace the rule that grants unconditional ${policy.command === "SELECT" ? "reads" : "access"} with one that scopes the rows — \`ownerField\`, \`roles\`, or a \`condition\``) : `-- Replace the constant with the scoping you intended, e.g.:\nALTER POLICY ${qi(policy.name)} ON ${qrel(policy.schema, policy.table)}\n ${clauses.includes("USING") ? `USING (user_id = ${uidCall})` : `WITH CHECK (user_id = ${uidCall})`};\n-- or, if unconditional access really is intended, drop the policy and say so\n-- with an explicit grant instead:\n-- DROP POLICY ${qi(policy.name)} ON ${qrel(policy.schema, policy.table)};`
|
|
1012
1164
|
}));
|
|
1013
1165
|
}
|
|
1014
1166
|
return findings;
|
|
1015
1167
|
}
|
|
1016
1168
|
};
|
|
1017
|
-
/**
|
|
1169
|
+
/**
|
|
1170
|
+
* Does `policy` apply to a caller arriving as `role`?
|
|
1171
|
+
*
|
|
1172
|
+
* `PUBLIC` in `role` stands for "some role nothing else here names", which is a
|
|
1173
|
+
* member of nothing: only a policy TO PUBLIC applies to it.
|
|
1174
|
+
*/
|
|
1175
|
+
function policyAppliesTo(snapshot, policy, role) {
|
|
1176
|
+
if (policy.roles.some(isPublicRole)) return true;
|
|
1177
|
+
if (isPublicRole(role)) return false;
|
|
1178
|
+
const usable = rolesUsableBy(snapshot, role);
|
|
1179
|
+
return policy.roles.some((target) => usable.has(target.toLowerCase()));
|
|
1180
|
+
}
|
|
1181
|
+
/**
|
|
1182
|
+
* The callers a permissive policy lets in: every named exposed role it applies
|
|
1183
|
+
* to, or `PUBLIC` when it applies to no named one and is TO PUBLIC.
|
|
1184
|
+
*
|
|
1185
|
+
* The `PUBLIC` pseudo-caller is left out whenever a named role is there,
|
|
1186
|
+
* because it would otherwise demand a gate TO PUBLIC for a policy that every
|
|
1187
|
+
* real caller already has to pass a gate for — on Supabase, a restrictive
|
|
1188
|
+
* policy TO anon and authenticated covers every request the API makes.
|
|
1189
|
+
*/
|
|
1190
|
+
function callersOf(snapshot, policy) {
|
|
1191
|
+
const named = snapshot.exposedRoles.filter((role) => !isPublicRole(role) && policyAppliesTo(snapshot, policy, role));
|
|
1192
|
+
if (named.length > 0) return named;
|
|
1193
|
+
return policy.roles.some(isPublicRole) ? ["PUBLIC"] : [];
|
|
1194
|
+
}
|
|
1195
|
+
/**
|
|
1196
|
+
* The RESTRICTIVE policies that gate this one, or `null` when they do not gate
|
|
1197
|
+
* every caller it lets in.
|
|
1198
|
+
*
|
|
1199
|
+
* A restrictive policy is ANDed only for the roles in its own TO list and only
|
|
1200
|
+
* for its own command. Any restrictive policy on the table used to count, so
|
|
1201
|
+
* Supabase's documented MFA gate — `AS RESTRICTIVE TO authenticated` — next to
|
|
1202
|
+
* `USING (true) TO anon` softened the finding to medium while anon read every
|
|
1203
|
+
* row, and the default `--fail-on high` passed it. So a gate now has to apply
|
|
1204
|
+
* to every command the permissive policy covers (`ALL` needs `ALL`), and the
|
|
1205
|
+
* restrictive policies together have to apply to every caller it lets in.
|
|
1206
|
+
*/
|
|
1018
1207
|
function restrictiveGate(snapshot, policy) {
|
|
1019
|
-
|
|
1208
|
+
const gates = policiesFor(snapshot, policy.schema, policy.table).filter((p) => !p.permissive && (p.command === "ALL" || p.command === policy.command));
|
|
1209
|
+
const callers = callersOf(snapshot, policy);
|
|
1210
|
+
if (callers.length === 0) return null;
|
|
1211
|
+
const covering = gates.filter((gate) => callers.some((role) => policyAppliesTo(snapshot, gate, role)));
|
|
1212
|
+
return callers.every((role) => covering.some((gate) => policyAppliesTo(snapshot, gate, role))) ? covering.map((gate) => gate.name) : null;
|
|
1020
1213
|
}
|
|
1021
1214
|
//#endregion
|
|
1022
1215
|
//#region src/types.ts
|
|
@@ -1053,7 +1246,10 @@ var ID$7 = "policy-anonymous-tautology";
|
|
|
1053
1246
|
* - Supabase: `auth.uid()` reads a JWT claim and returns NULL for an anonymous
|
|
1054
1247
|
* caller, so the expression is a legitimate "signed in" test. Its only real
|
|
1055
1248
|
* failing is that it does not scope rows to their owner — worth `low`, worded
|
|
1056
|
-
* as a design observation rather than a vulnerability.
|
|
1249
|
+
* as a design observation rather than a vulnerability. That is a fact about
|
|
1250
|
+
* `auth.uid()`, not about Supabase: an anonymous caller there still carries
|
|
1251
|
+
* the anon key's JWT, so `auth.role()` and `auth.jwt()` are non-null for it,
|
|
1252
|
+
* and the same shape built on them is a bypass — see {@link CallerCall}.
|
|
1057
1253
|
* - Rebase / PostgREST-style stacks that coerce a missing id to a sentinel
|
|
1058
1254
|
* (`'anonymous'`, `''`): the expression is true for signed-out callers, so it
|
|
1059
1255
|
* is a straight authentication bypass. This exact policy shipped in this
|
|
@@ -1072,18 +1268,19 @@ var policyAnonymousTautology = {
|
|
|
1072
1268
|
run(snapshot) {
|
|
1073
1269
|
const uidCall = callerIdCall(snapshot);
|
|
1074
1270
|
const findings = [];
|
|
1075
|
-
const { severity: baseSeverity, meaning, impactSuffix } = platformReading(snapshot.platform);
|
|
1076
1271
|
for (const policy of snapshot.policies) {
|
|
1077
1272
|
if (!snapshot.schemas.includes(policy.schema)) continue;
|
|
1078
1273
|
if (!policy.permissive) continue;
|
|
1079
|
-
if (
|
|
1274
|
+
if (policyReachedBy(snapshot, policy).length === 0) continue;
|
|
1080
1275
|
const clauses = [];
|
|
1081
1276
|
const usingMatch = matchTautology(policy.using);
|
|
1082
1277
|
const checkMatch = matchTautology(policy.withCheck);
|
|
1083
1278
|
if (usingMatch) clauses.push("USING");
|
|
1084
1279
|
if (checkMatch) clauses.push("WITH CHECK");
|
|
1085
1280
|
if (clauses.length === 0) continue;
|
|
1086
|
-
const
|
|
1281
|
+
const matched = worstMatch(snapshot.platform, usingMatch, checkMatch);
|
|
1282
|
+
const { severity: baseSeverity, meaning, impactSuffix } = platformReading(snapshot.platform, matched);
|
|
1283
|
+
const shape = matched.shape;
|
|
1087
1284
|
const decoys = [.../* @__PURE__ */ new Set([...usingMatch?.decoyGuards ?? [], ...checkMatch?.decoyGuards ?? []])];
|
|
1088
1285
|
const severity = forCommand(baseSeverity, policy.command);
|
|
1089
1286
|
const written = decoys.map((d) => `\`${shape} <> '${d}'\``);
|
|
@@ -1097,7 +1294,7 @@ var policyAnonymousTautology = {
|
|
|
1097
1294
|
table: policy.table,
|
|
1098
1295
|
policy: policy.name
|
|
1099
1296
|
},
|
|
1100
|
-
detail: (decoys.length > 0 ? `The ${listAnd(clauses)} expression of this ${policy.command} policy reads as "signed in": it tests that ${shape} is non-null and excludes ${listAnd(written)}. But the
|
|
1297
|
+
detail: (decoys.length > 0 ? `The ${listAnd(clauses)} expression of this ${policy.command} policy reads as "signed in": it tests that ${shape} is non-null and excludes ${listAnd(written)}. But ` + (matched.call.sentinels.length > 0 ? `the ${matched.call.noun} a signed-out caller actually arrives with is ${listAnd(matched.call.sentinels.map(describeSentinel))}, and neither is ${listAnd(decoys.map((d) => `'${d}'`))}` : `no literal stands for a signed-out caller in ${shape}`) + ` — so the guard excludes nobody and the null test stands on its own. ` : `The ${listAnd(clauses)} expression of this ${policy.command} policy tests only that ${shape} is non-null. `) + `It does not compare anything to a column, so every row of the table satisfies it equally — the policy distinguishes signed-in from signed-out callers and nothing else. ${meaning}` + (policy.command === "ALL" || policy.command === "UPDATE" || policy.command === "DELETE" ? ` This policy governs ${policy.command === "ALL" ? "every command, writes included" : policy.command}, so the same expression decides who may change rows, not only who may read them.` : ""),
|
|
1101
1298
|
impact: `Any caller for whom ${shape} is non-null can reach every row this policy covers, including rows belonging to other users or tenants. ${impactSuffix}` + (decoys.length > 0 ? " A policy in this shape reads as safe on review, which is why it survives: the guard is present, spelled plausibly, and matches nothing." : ""),
|
|
1102
1299
|
fix: isRebaseManagedPolicy(snapshot, policy) ? managedPolicyFix(policy, "scope the rule to the row's owner (an `ownerField`) rather than to the existence of an id — `access: \"authenticated\"` compiled to exactly this shape before 1.0, so a database pushed then still carries it") : `-- Scope the policy to the row's owner rather than to the existence of an id:\nALTER POLICY ${qi(policy.name)} ON ${qrel(policy.schema, policy.table)}\n USING (user_id = ${uidCall});\n-- If the intent really is "any signed-in user", exclude every id your stack has\n-- ever used for "nobody" — one literal is not enough:\n-- USING (${uidCall} IS NOT NULL AND ${uidCall} <> ALL (ARRAY['anonymous', 'anon']));`
|
|
1103
1300
|
}));
|
|
@@ -1120,26 +1317,55 @@ function forCommand(base, command) {
|
|
|
1120
1317
|
return SEVERITIES[Math.min(SEVERITIES.indexOf(base) + 1, SEVERITIES.length - 1)];
|
|
1121
1318
|
}
|
|
1122
1319
|
var describeSentinel = (s) => s === "" ? "the empty string" : `'${s}'`;
|
|
1123
|
-
|
|
1320
|
+
/** Of the USING and WITH CHECK clauses that matched, the one whose reading is worse. */
|
|
1321
|
+
function worstMatch(platform, using, check) {
|
|
1322
|
+
if (!using) return check;
|
|
1323
|
+
if (!check) return using;
|
|
1324
|
+
const rank = (m) => SEVERITIES.indexOf(platformReading(platform, m).severity);
|
|
1325
|
+
return rank(check) > rank(using) ? check : using;
|
|
1326
|
+
}
|
|
1327
|
+
/**
|
|
1328
|
+
* What the expression means on this platform, for the call it is built on.
|
|
1329
|
+
*
|
|
1330
|
+
* The platform alone does not decide it. On Supabase `auth.uid()` is NULL for a
|
|
1331
|
+
* signed-out request, so a null test on it is a working "signed in" check — but
|
|
1332
|
+
* a signed-out request there still carries a JWT, the project's public anon
|
|
1333
|
+
* key, so `auth.role()` is `'anon'` and `auth.jwt()` is that key's claims. A
|
|
1334
|
+
* null test on either is true for everyone. This used to be read per platform
|
|
1335
|
+
* only, which graded `USING (auth.jwt() IS NOT NULL)` low and told the reader
|
|
1336
|
+
* that anonymous callers were correctly excluded.
|
|
1337
|
+
*/
|
|
1338
|
+
function platformReading(platform, match) {
|
|
1339
|
+
const { shape, call } = match;
|
|
1124
1340
|
switch (platform) {
|
|
1125
|
-
case "supabase":
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1341
|
+
case "supabase":
|
|
1342
|
+
if (call.onSupabase === "null") return {
|
|
1343
|
+
severity: "low",
|
|
1344
|
+
meaning: `On Supabase, \`${shape}\` returns NULL for an anonymous request, so this is a working authenticated-only check rather than a bypass. It is listed because it only distinguishes signed-in from signed-out; it does not scope rows to their owner.`,
|
|
1345
|
+
impactSuffix: "Anonymous callers are correctly excluded on Supabase, so this is a data-scoping gap between signed-in users, not an anonymous-access hole."
|
|
1346
|
+
};
|
|
1347
|
+
if (call.onSupabase === "present") return {
|
|
1348
|
+
severity: "critical",
|
|
1349
|
+
meaning: `On Supabase a signed-out request still carries a JWT — the project's anon key, which ships in every client — and for it \`${shape}\` is ${call.signedOutValue}, not NULL. So this expression is true for signed-out callers too: it authorises everyone.`,
|
|
1350
|
+
impactSuffix: "That includes unauthenticated callers: the anon key is public, and a request carrying nothing else satisfies the expression."
|
|
1351
|
+
};
|
|
1352
|
+
return unknownReading(shape);
|
|
1130
1353
|
case "rebase":
|
|
1131
1354
|
case "postgrest": return {
|
|
1132
1355
|
severity: "critical",
|
|
1133
1356
|
meaning: "On this stack a request without a session is given a sentinel id (an 'anonymous' string rather than NULL), so this expression is true for signed-out callers too — it authorises everyone.",
|
|
1134
1357
|
impactSuffix: "Because signed-out requests arrive with a sentinel id rather than NULL, this includes unauthenticated callers."
|
|
1135
1358
|
};
|
|
1136
|
-
default: return
|
|
1137
|
-
severity: "medium",
|
|
1138
|
-
meaning: "Whether this excludes anonymous callers depends on the layer in front of the database: if it leaves the setting unset for signed-out requests the expression is false and this is merely loose; if it coerces them to a sentinel id (an empty string or 'anonymous') the expression is true and this authorises everyone.",
|
|
1139
|
-
impactSuffix: "Whether unauthenticated callers are included depends on whether your stack coerces a missing caller id to a sentinel value — check that before judging the severity."
|
|
1140
|
-
};
|
|
1359
|
+
default: return unknownReading(shape);
|
|
1141
1360
|
}
|
|
1142
1361
|
}
|
|
1362
|
+
function unknownReading(shape) {
|
|
1363
|
+
return {
|
|
1364
|
+
severity: "medium",
|
|
1365
|
+
meaning: `Whether this excludes anonymous callers depends on the layer in front of the database: if it leaves \`${shape}\` unset for signed-out requests the expression is false and this is merely loose; if it gives them a value — a sentinel id such as an empty string or 'anonymous', or the claims of a public anon key — the expression is true and this authorises everyone.`,
|
|
1366
|
+
impactSuffix: "Whether unauthenticated callers are included depends on whether your stack coerces a missing caller id to a sentinel value — check that before judging the severity."
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1143
1369
|
/**
|
|
1144
1370
|
* Both schema spellings, deliberately.
|
|
1145
1371
|
*
|
|
@@ -1152,23 +1378,28 @@ function platformReading(platform) {
|
|
|
1152
1378
|
var CALLER_ID_CALLS = [
|
|
1153
1379
|
{
|
|
1154
1380
|
re: /(?:rebase|auth)\.uid\s*\(\s*\)/g,
|
|
1155
|
-
label: (m) => m[0].replace(/\s+/g, "")
|
|
1381
|
+
label: (m) => m[0].replace(/\s+/g, ""),
|
|
1382
|
+
call: () => CALLER_ID
|
|
1156
1383
|
},
|
|
1157
1384
|
{
|
|
1158
1385
|
re: /auth\.role\s*\(\s*\)/g,
|
|
1159
|
-
label: () => "auth.role()"
|
|
1386
|
+
label: () => "auth.role()",
|
|
1387
|
+
call: () => CALLER_ROLE
|
|
1160
1388
|
},
|
|
1161
1389
|
{
|
|
1162
1390
|
re: /(?:rebase|auth)\.roles\s*\(\s*\)/g,
|
|
1163
|
-
label: (m) => m[0].replace(/\s+/g, "")
|
|
1391
|
+
label: (m) => m[0].replace(/\s+/g, ""),
|
|
1392
|
+
call: () => CALLER_ROLE_LIST
|
|
1164
1393
|
},
|
|
1165
1394
|
{
|
|
1166
1395
|
re: /(?:rebase|auth)\.jwt\s*\(\s*\)/g,
|
|
1167
|
-
label: (m) => m[0].replace(/\s+/g, "")
|
|
1396
|
+
label: (m) => m[0].replace(/\s+/g, ""),
|
|
1397
|
+
call: () => CALLER_CLAIMS
|
|
1168
1398
|
},
|
|
1169
1399
|
{
|
|
1170
1400
|
re: /current_setting\s*\(\s*('[^']*')\s*(?:,\s*[a-z]+\s*)?\)/g,
|
|
1171
|
-
label: (m) => `current_setting(${m[1]})
|
|
1401
|
+
label: (m) => `current_setting(${m[1]})`,
|
|
1402
|
+
call: (m) => settingCall(m[1].slice(1, -1))
|
|
1172
1403
|
}
|
|
1173
1404
|
];
|
|
1174
1405
|
/**
|
|
@@ -1183,6 +1414,73 @@ var CALLER_ID_CALLS = [
|
|
|
1183
1414
|
* `'anon'` as clearing is exactly the mistake this check now exists to catch.
|
|
1184
1415
|
*/
|
|
1185
1416
|
var CLEARING_SENTINELS = ["anonymous", ""];
|
|
1417
|
+
var CALLER_ID = {
|
|
1418
|
+
onSupabase: "null",
|
|
1419
|
+
noun: "id",
|
|
1420
|
+
sentinels: CLEARING_SENTINELS
|
|
1421
|
+
};
|
|
1422
|
+
/**
|
|
1423
|
+
* `auth.role()`, and the claim it reads. `'anon'` is a decoy for an *id* — no
|
|
1424
|
+
* signed-out caller arrives with that id — but it is precisely the *role* one
|
|
1425
|
+
* arrives as on Supabase (`web_anon` on a stock PostgREST), so excluding it is
|
|
1426
|
+
* the real guard here.
|
|
1427
|
+
*/
|
|
1428
|
+
var CALLER_ROLE = {
|
|
1429
|
+
onSupabase: "present",
|
|
1430
|
+
signedOutValue: "'anon'",
|
|
1431
|
+
noun: "role",
|
|
1432
|
+
sentinels: ["anon", "web_anon"]
|
|
1433
|
+
};
|
|
1434
|
+
/** Rebase's `roles()`, which coalesces an unset list to the empty string. */
|
|
1435
|
+
var CALLER_ROLE_LIST = {
|
|
1436
|
+
onSupabase: "present",
|
|
1437
|
+
signedOutValue: "the empty string",
|
|
1438
|
+
noun: "role list",
|
|
1439
|
+
sentinels: CLEARING_SENTINELS
|
|
1440
|
+
};
|
|
1441
|
+
var CALLER_CLAIMS = {
|
|
1442
|
+
onSupabase: "present",
|
|
1443
|
+
signedOutValue: "the anon key's claims",
|
|
1444
|
+
noun: "claim set",
|
|
1445
|
+
sentinels: []
|
|
1446
|
+
};
|
|
1447
|
+
/** A setting nothing on Supabase defines: whatever sets it decides. */
|
|
1448
|
+
var CALLER_SETTING = {
|
|
1449
|
+
onSupabase: "unknown",
|
|
1450
|
+
noun: "id",
|
|
1451
|
+
sentinels: CLEARING_SENTINELS
|
|
1452
|
+
};
|
|
1453
|
+
/** The claims Supabase's anon key carries. Every other claim is unset for it. */
|
|
1454
|
+
var ANON_KEY_CLAIMS = [
|
|
1455
|
+
"role",
|
|
1456
|
+
"iss",
|
|
1457
|
+
"ref",
|
|
1458
|
+
"iat",
|
|
1459
|
+
"exp"
|
|
1460
|
+
];
|
|
1461
|
+
/**
|
|
1462
|
+
* `current_setting('…')` by the setting it reads. PostgREST publishes the JWT as
|
|
1463
|
+
* `request.jwt.claims`, and older releases also as one `request.jwt.claim.<name>`
|
|
1464
|
+
* per claim — so the claim name decides whether the anon key fills it.
|
|
1465
|
+
*/
|
|
1466
|
+
function settingCall(name) {
|
|
1467
|
+
if (name === "request.jwt.claims") return CALLER_CLAIMS;
|
|
1468
|
+
const claim = /^request\.jwt\.claim\.(.+)$/.exec(name)?.[1];
|
|
1469
|
+
if (claim === void 0) return CALLER_SETTING;
|
|
1470
|
+
if (claim === "sub") return CALLER_ID;
|
|
1471
|
+
if (claim === "role") return CALLER_ROLE;
|
|
1472
|
+
if (ANON_KEY_CLAIMS.includes(claim)) return {
|
|
1473
|
+
onSupabase: "present",
|
|
1474
|
+
signedOutValue: `the anon key's \`${claim}\` claim`,
|
|
1475
|
+
noun: "claim",
|
|
1476
|
+
sentinels: []
|
|
1477
|
+
};
|
|
1478
|
+
return {
|
|
1479
|
+
onSupabase: "null",
|
|
1480
|
+
noun: "claim",
|
|
1481
|
+
sentinels: CLEARING_SENTINELS
|
|
1482
|
+
};
|
|
1483
|
+
}
|
|
1186
1484
|
/**
|
|
1187
1485
|
* Recognise "the policy tests that a caller id exists, and nothing that narrows
|
|
1188
1486
|
* which rows" — and report which no-op guards it wears while doing so.
|
|
@@ -1210,9 +1508,10 @@ var CLEARING_SENTINELS = ["anonymous", ""];
|
|
|
1210
1508
|
function callerIdOnlyClause(clause) {
|
|
1211
1509
|
if (!clause) return null;
|
|
1212
1510
|
const flat = clause.toLowerCase().replace(/::\s*[a-z0-9_]+(?:\s*\[\s*\])*/g, "").replace(/\s+/g, " ").trim();
|
|
1213
|
-
for (const { re, label } of CALLER_ID_CALLS) {
|
|
1511
|
+
for (const { re, label, call: callFor } of CALLER_ID_CALLS) {
|
|
1214
1512
|
const first = new RegExp(re.source).exec(flat);
|
|
1215
1513
|
if (!first) continue;
|
|
1514
|
+
const call = callFor(first);
|
|
1216
1515
|
const substituted = flat.replace(new RegExp(re.source, "g"), " callerid ");
|
|
1217
1516
|
if (/\bor\b/.test(substituted)) return null;
|
|
1218
1517
|
let sawNullTest = false;
|
|
@@ -1227,19 +1526,15 @@ function callerIdOnlyClause(clause) {
|
|
|
1227
1526
|
}
|
|
1228
1527
|
const excluded = excludedLiterals(conjunct);
|
|
1229
1528
|
if (!excluded) return null;
|
|
1230
|
-
if (excluded.some((lit) =>
|
|
1529
|
+
if (excluded.some((lit) => call.sentinels.includes(lit))) cleared = true;
|
|
1231
1530
|
decoys.push(...excluded);
|
|
1232
1531
|
}
|
|
1233
1532
|
if (!sawNullTest) continue;
|
|
1234
|
-
if (cleared) return {
|
|
1235
|
-
shape: label(first),
|
|
1236
|
-
decoyGuards: [...new Set(decoys)],
|
|
1237
|
-
guardsSentinel: true
|
|
1238
|
-
};
|
|
1239
1533
|
return {
|
|
1240
1534
|
shape: label(first),
|
|
1535
|
+
call,
|
|
1241
1536
|
decoyGuards: [...new Set(decoys)],
|
|
1242
|
-
guardsSentinel:
|
|
1537
|
+
guardsSentinel: cleared
|
|
1243
1538
|
};
|
|
1244
1539
|
}
|
|
1245
1540
|
return null;
|
|
@@ -1386,7 +1681,7 @@ var policyAuthenticatedTautology = {
|
|
|
1386
1681
|
for (const policy of snapshot.policies) {
|
|
1387
1682
|
if (!snapshot.schemas.includes(policy.schema)) continue;
|
|
1388
1683
|
if (!policy.permissive) continue;
|
|
1389
|
-
if (
|
|
1684
|
+
if (policyReachedBy(snapshot, policy).length === 0) continue;
|
|
1390
1685
|
const usingMatch = callerIdOnlyClause(policy.using);
|
|
1391
1686
|
const checkMatch = callerIdOnlyClause(policy.withCheck);
|
|
1392
1687
|
const clauses = [];
|
|
@@ -1409,7 +1704,7 @@ var policyAuthenticatedTautology = {
|
|
|
1409
1704
|
fix: isRebaseManagedPolicy(snapshot, policy) ? managedPolicyFix(policy, "scope the rule to the row rather than to the existence of a session — an `ownerField`, or a `condition` naming the group whose members may share rows") : `-- Scope the policy to the row, rather than to the existence of a session:
|
|
1410
1705
|
ALTER POLICY ${qi(policy.name)} ON ${qrel(policy.schema, policy.table)}\n USING (user_id = ${uidCall});\n-- Or, where members of a shared group really may see each other's rows, say
|
|
1411
1706
|
-- which group:
|
|
1412
|
-
-- USING (EXISTS (SELECT 1 FROM memberships m\n-- WHERE m.org_id = ${policy.table}.org_id AND m.user_id = ${uidCall}));\n-- If the table genuinely is readable by every account, keep this policy and
|
|
1707
|
+
-- USING (EXISTS (SELECT 1 FROM memberships m\n-- WHERE m.org_id = ${qi(policy.table)}.org_id AND m.user_id = ${uidCall}));\n-- If the table genuinely is readable by every account, keep this policy and
|
|
1413
1708
|
-- skip the finding: rls-check --skip ${ID$6}`
|
|
1414
1709
|
}));
|
|
1415
1710
|
}
|
|
@@ -1462,7 +1757,7 @@ var policyRoleUnreachable = {
|
|
|
1462
1757
|
impact: `Reads of this table return zero rows for every application role, and writes are rejected. Nothing is exposed — the data is invisible instead, and it looks identical to an empty table, which is why this usually goes unnoticed for a long time. The owner (${rel.owner}) still sees everything${rel.rlsForced ? " unless FORCE ROW LEVEL SECURITY changes that" : ", since FORCE ROW LEVEL SECURITY is not set"}.`,
|
|
1463
1758
|
fix: policies.every((p) => isRebaseManagedPolicy(snapshot, p)) ? managedPolicyFix(policies[0], "name the role your requests actually arrive as in the rules' `roles` — confirm it with `SELECT current_user` from the application's own connection") : `-- Point the policies at the role your requests actually arrive as. Confirm it with:
|
|
1464
1759
|
-- SELECT current_user; -- run this from your application's connection
|
|
1465
|
-
ALTER POLICY ${qi(policies[0].name)} ON ${qrel(rel.schema, rel.name)} TO <that role>;\n-- Alternatively, if ${named[0]} is meant to be reachable, grant membership:\n-- GRANT ${qi(named[0])} TO <your login role>;`
|
|
1760
|
+
ALTER POLICY ${qi(policies[0].name)} ON ${qrel(rel.schema, rel.name)} TO <that role>;\n-- Alternatively, if ${qi(named[0])} is meant to be reachable, grant membership:\n-- GRANT ${qi(named[0])} TO <your login role>;`
|
|
1466
1761
|
}));
|
|
1467
1762
|
}
|
|
1468
1763
|
return findings;
|
|
@@ -1486,6 +1781,10 @@ var ID$4 = "rls-disabled";
|
|
|
1486
1781
|
* lookup tables reachable only by the service role. Flagging those is how a
|
|
1487
1782
|
* scanner produces forty findings on a healthy database and gets ignored, so a
|
|
1488
1783
|
* table nobody exposed produces no finding at all.
|
|
1784
|
+
*
|
|
1785
|
+
* Foreign tables are included, and are always "RLS off": Postgres refuses
|
|
1786
|
+
* `ENABLE ROW LEVEL SECURITY` on one, so a grant to an exposed role hands over
|
|
1787
|
+
* whatever the remote server returns, and the fix is the grant, not a policy.
|
|
1489
1788
|
*/
|
|
1490
1789
|
var rlsDisabled = {
|
|
1491
1790
|
id: ID$4,
|
|
@@ -1494,8 +1793,9 @@ var rlsDisabled = {
|
|
|
1494
1793
|
run(snapshot) {
|
|
1495
1794
|
const uidCall = callerIdCall(snapshot);
|
|
1496
1795
|
const findings = [];
|
|
1497
|
-
for (const rel of scannedTables(snapshot)) {
|
|
1796
|
+
for (const rel of [...scannedTables(snapshot), ...scannedForeignTables(snapshot)]) {
|
|
1498
1797
|
if (rel.rlsEnabled) continue;
|
|
1798
|
+
const foreign = rel.kind === "foreign_table";
|
|
1499
1799
|
const exposed = exposedGrantees(snapshot, rel.schema, rel.name, DML);
|
|
1500
1800
|
if (exposed.length === 0) continue;
|
|
1501
1801
|
const roles = exposed.map((e) => e.role);
|
|
@@ -1509,19 +1809,23 @@ var rlsDisabled = {
|
|
|
1509
1809
|
id: ID$4,
|
|
1510
1810
|
severity: "critical",
|
|
1511
1811
|
confidence: "certain",
|
|
1512
|
-
title: `${rel.schema}.${rel.name} has row-level security disabled and is granted to ${listAnd(roles)}`,
|
|
1812
|
+
title: foreign ? `${rel.schema}.${rel.name} is a foreign table, which cannot have row-level security, and is granted to ${listAnd(roles)}` : `${rel.schema}.${rel.name} has row-level security disabled and is granted to ${listAnd(roles)}`,
|
|
1513
1813
|
target: {
|
|
1514
1814
|
schema: rel.schema,
|
|
1515
1815
|
table: rel.name
|
|
1516
1816
|
},
|
|
1517
|
-
detail:
|
|
1817
|
+
detail: (foreign ? "This is a foreign table. Postgres cannot enable row-level security on one, so no per-row filter applies to it and every row the remote server returns is returned. " : "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. ") + `${listAnd(roles)} ${roles.length > 1 ? "hold" : "holds"} ${listAnd(privileges)} on it.`,
|
|
1518
1818
|
impact: `If this table is reachable over an API that connects as ${listAnd(roles)}, a caller can ${listAnd(reach)}, with no tenant or owner scoping.`,
|
|
1519
|
-
fix: `ALTER TABLE ${qrel(rel.schema, rel.name)} 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 ${
|
|
1819
|
+
fix: foreign ? foreignTableFix(snapshot, rel.schema, rel.name, roles) : `ALTER TABLE ${qrel(rel.schema, rel.name)} 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 ${qi(`${rel.name}_owner_select`)} ON ${qrel(rel.schema, rel.name)}\n-- FOR SELECT TO ${qrole(roles[0])} USING (user_id = ${uidCall});`
|
|
1520
1820
|
}));
|
|
1521
1821
|
}
|
|
1522
1822
|
return findings;
|
|
1523
1823
|
}
|
|
1524
1824
|
};
|
|
1825
|
+
/** Take a foreign table away from the exposed roles: it cannot have row-level security. */
|
|
1826
|
+
function foreignTableFix(snapshot, schema, table, exposed) {
|
|
1827
|
+
return `-- A foreign table cannot have row-level security, so take the grant away:\n${revokesReaching(snapshot, schema, table, exposed, DML).join("\n")}\n-- and keep it in a schema your API does not expose, reading it through a\n-- function that checks the caller.`;
|
|
1828
|
+
}
|
|
1525
1829
|
//#endregion
|
|
1526
1830
|
//#region src/checks/rls-enabled-no-policies.ts
|
|
1527
1831
|
var ID$3 = "rls-enabled-no-policies";
|
|
@@ -1555,7 +1859,7 @@ var rlsEnabledNoPolicies = {
|
|
|
1555
1859
|
},
|
|
1556
1860
|
detail: `With RLS enabled and no policy defined, Postgres denies every row to every role. ${ownerNote}`,
|
|
1557
1861
|
impact: "Reads of this table return zero rows and writes are rejected for every non-owner role. No data is exposed; data is silently missing instead, and an empty result is indistinguishable from a correctly filtered one.",
|
|
1558
|
-
fix: `-- Either define the policy you intended:\nCREATE POLICY ${
|
|
1862
|
+
fix: `-- Either define the policy you intended:\nCREATE POLICY ${qi(`${rel.name}_select`)} ON ${qrel(rel.schema, rel.name)}\n FOR SELECT TO authenticated USING (user_id = ${uidCall});\n-- or, if this table is genuinely meant to be unreadable, drop the grants\n-- instead of relying on an empty policy set:\n-- REVOKE ALL ON ${qrel(rel.schema, rel.name)} FROM PUBLIC;`
|
|
1559
1863
|
}));
|
|
1560
1864
|
}
|
|
1561
1865
|
return findings;
|
|
@@ -1568,11 +1872,18 @@ var ID$2 = "rls-enabled-not-forced";
|
|
|
1568
1872
|
* RLS on, FORCE off — the table owner is exempt from its own policies.
|
|
1569
1873
|
*
|
|
1570
1874
|
* How much that matters is entirely a question of *who the owner is*, and the
|
|
1571
|
-
* severity has to follow that or the check becomes noise
|
|
1875
|
+
* severity has to follow that or the check becomes noise. "The owner" means
|
|
1876
|
+
* every role with the owner's privileges: Postgres decides the exemption with
|
|
1877
|
+
* `has_privs_of_role`, so a member of the owning role is exempt exactly as the
|
|
1878
|
+
* owner is.
|
|
1572
1879
|
*
|
|
1573
|
-
* -
|
|
1574
|
-
*
|
|
1575
|
-
*
|
|
1880
|
+
* - A role an untrusted caller arrives as is, or is a member of, the owner:
|
|
1881
|
+
* every request made as it skips the policies. Critical.
|
|
1882
|
+
* - Owner can log in, or a login role is a member of it, and it is otherwise
|
|
1883
|
+
* ordinary: anything using that connection string reads the whole table.
|
|
1884
|
+
* This is the case worth waking up for.
|
|
1885
|
+
* - Owner cannot log in and nothing that can is a member of it: a
|
|
1886
|
+
* provisioning role nothing connects as. Informational.
|
|
1576
1887
|
* - Owner is a superuser or has BYPASSRLS: FORCE would not help either way,
|
|
1577
1888
|
* because those attributes skip RLS before ownership is even considered.
|
|
1578
1889
|
* Reporting `high` here would be misleading — the fix is "do not connect as
|
|
@@ -1589,6 +1900,9 @@ var rlsEnabledNotForced = {
|
|
|
1589
1900
|
const owner = snapshot.roles.find((r) => sameRole(r.name, rel.owner));
|
|
1590
1901
|
const bypasses = Boolean(owner?.superuser || owner?.bypassRls);
|
|
1591
1902
|
const canLogin = Boolean(owner?.canLogin);
|
|
1903
|
+
const holdsOwner = (role) => rolesUsableBy(snapshot, role).has(rel.owner.toLowerCase());
|
|
1904
|
+
const exposedOwners = snapshot.exposedRoles.filter((role) => !isPublicRole(role) && holdsOwner(role));
|
|
1905
|
+
const loginMembers = snapshot.roles.filter((r) => r.canLogin && !sameRole(r.name, rel.owner) && holdsOwner(r.name)).map((r) => r.name);
|
|
1592
1906
|
let severity = "medium";
|
|
1593
1907
|
let detail;
|
|
1594
1908
|
let impact;
|
|
@@ -1597,11 +1911,18 @@ var rlsEnabledNotForced = {
|
|
|
1597
1911
|
severity = "medium";
|
|
1598
1912
|
detail = `Policies on this table do not apply to its owner, ${rel.owner}, because FORCE ROW LEVEL SECURITY is not set. That role is additionally ${owner?.superuser ? "a superuser" : "marked BYPASSRLS"}, so it skips row-level security on every table regardless of this setting — FORCE would not constrain it.`;
|
|
1599
1913
|
impact = `Any connection made as ${rel.owner} reads and writes every row of this table, ignoring all policies. This is expected for an administrative role and dangerous only if an application connects with it.`;
|
|
1600
|
-
fix = `-- FORCE cannot constrain this role. Connect your application as a role that is\n-- neither the owner nor BYPASSRLS, and keep ${rel.owner} for migrations only.\nALTER TABLE ${qrel(rel.schema, rel.name)} FORCE ROW LEVEL SECURITY; -- still worth setting`;
|
|
1601
|
-
} else if (
|
|
1914
|
+
fix = `-- FORCE cannot constrain this role. Connect your application as a role that is\n-- neither the owner nor BYPASSRLS, and keep ${qi(rel.owner)} for migrations only.\nALTER TABLE ${qrel(rel.schema, rel.name)} FORCE ROW LEVEL SECURITY; -- still worth setting`;
|
|
1915
|
+
} else if (exposedOwners.length > 0) {
|
|
1916
|
+
severity = "critical";
|
|
1917
|
+
const members = exposedOwners.filter((role) => !sameRole(role, rel.owner));
|
|
1918
|
+
detail = `FORCE ROW LEVEL SECURITY is not set, so the owning role ${rel.owner} is exempt from every policy on this table, and Postgres extends that exemption to every member of the owning role. ${listAnd(exposedOwners)} — ${exposedOwners.length > 1 ? "roles" : "a role"} untrusted callers arrive as — ${exposedOwners.length > 1 ? "are" : "is"} ${members.length === exposedOwners.length ? "a member of it" : "the owner or a member of it"}.`;
|
|
1919
|
+
impact = `A caller arriving as ${listAnd(exposedOwners)} reads and writes every row of this table, ignoring all policies on it.`;
|
|
1920
|
+
fix = `ALTER TABLE ${qrel(rel.schema, rel.name)} FORCE ROW LEVEL SECURITY;` + (members.length > 0 ? `\n-- and unless callers are meant to hold the owner's privileges:\n` + members.map((role) => `REVOKE ${qi(rel.owner)} FROM ${qi(role)};`).join("\n") : "");
|
|
1921
|
+
} else if (canLogin || loginMembers.length > 0) {
|
|
1602
1922
|
severity = "high";
|
|
1603
|
-
|
|
1604
|
-
|
|
1923
|
+
const who = canLogin ? [rel.owner, ...loginMembers] : loginMembers;
|
|
1924
|
+
detail = `FORCE ROW LEVEL SECURITY is not set, so the owning role ${rel.owner} is exempt from every policy on this table. ` + (canLogin ? `${rel.owner} can log in directly, which means a connection string for it bypasses all row filtering.` : `${rel.owner} cannot log in, but ${listAnd(loginMembers)} can, and Postgres exempts a member of the owning role exactly as it exempts the owner.`) + (canLogin && loginMembers.length > 0 ? ` So ${loginMembers.length > 1 ? "do" : "does"} ${listAnd(loginMembers)}, which can log in and ${loginMembers.length > 1 ? "are members" : "is a member"} of it.` : "");
|
|
1925
|
+
impact = `Anything connecting as ${listAnd(who)} — including an application that was handed the owner's connection string, which is the default in most quick-start setups — reads and writes every row, ignoring the policies on this table.`;
|
|
1605
1926
|
fix = `ALTER TABLE ${qrel(rel.schema, rel.name)} FORCE ROW LEVEL SECURITY;`;
|
|
1606
1927
|
} else {
|
|
1607
1928
|
severity = "medium";
|
|
@@ -1663,20 +1984,31 @@ var securityDefinerMutableSearchPath = {
|
|
|
1663
1984
|
},
|
|
1664
1985
|
detail: `This routine runs with the privileges of its owner (${routine.owner}) but resolves unqualified names using the *caller's* search_path, because no \`SET search_path\` is attached to it. A caller who can create objects in any schema on that path can shadow a table or function the routine uses, and their object then runs as ${routine.owner}.`,
|
|
1665
1986
|
impact: `Any role that can both execute this routine and create objects in a schema on its search_path can have arbitrary SQL run as ${routine.owner} — which bypasses row-level security on every table ${routine.owner} can reach. Without CREATE on some schema this is not directly exploitable, so check who holds CREATE (commonly PUBLIC on \`public\` in older databases).`,
|
|
1666
|
-
fix: `-- Pin the search_path on every overload of this routine
|
|
1667
|
-
DO \$\$
|
|
1668
|
-
DECLARE r record;
|
|
1987
|
+
fix: `-- Pin the search_path on every overload of this routine:\n` + dollarQuotedDo(`DECLARE r record;
|
|
1669
1988
|
BEGIN
|
|
1670
1989
|
FOR r IN
|
|
1671
1990
|
SELECT p.oid::regprocedure AS sig
|
|
1672
1991
|
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
1673
|
-
WHERE n.nspname = ${literal(routine.schema)} AND p.proname = ${literal(routine.name)}\n LOOP\n EXECUTE format('ALTER ROUTINE %s SET search_path = pg_catalog, pg_temp', r.sig);\n END LOOP;\nEND
|
|
1992
|
+
WHERE n.nspname = ${literal(routine.schema)} AND p.proname = ${literal(routine.name)}\n LOOP\n EXECUTE format('ALTER ROUTINE %s SET search_path = pg_catalog, pg_temp', r.sig);\n END LOOP;\nEND`) + "\n-- Then schema-qualify every identifier in the body, since nothing but\n-- pg_catalog is on the path any more."
|
|
1674
1993
|
}));
|
|
1675
1994
|
}
|
|
1676
1995
|
return findings;
|
|
1677
1996
|
}
|
|
1678
1997
|
};
|
|
1679
1998
|
var literal = (value) => `'${value.replace(/'/g, "''")}'`;
|
|
1999
|
+
/**
|
|
2000
|
+
* `DO $$ … $$;`, with a dollar-quote tag the body does not contain.
|
|
2001
|
+
*
|
|
2002
|
+
* The body carries the routine's schema and name as string literals, and a
|
|
2003
|
+
* routine can be named anything, `$$` included. A `$$` in the name ended the
|
|
2004
|
+
* body there and ran the rest of the name as SQL, whatever the quotes around
|
|
2005
|
+
* it said: nothing inside a dollar-quoted string is special except its tag.
|
|
2006
|
+
*/
|
|
2007
|
+
function dollarQuotedDo(body) {
|
|
2008
|
+
let tag = "$$";
|
|
2009
|
+
for (let i = 0; body.includes(tag); i++) tag = `$fix${i === 0 ? "" : i}$`;
|
|
2010
|
+
return `DO ${tag}\n${body} ${tag};`;
|
|
2011
|
+
}
|
|
1680
2012
|
//#endregion
|
|
1681
2013
|
//#region src/checks/unqualified-column-in-subquery.ts
|
|
1682
2014
|
var ID = "unqualified-column-in-subquery";
|
|
@@ -1705,11 +2037,21 @@ var ID = "unqualified-column-in-subquery";
|
|
|
1705
2037
|
* flagging it merely because the outer table also has a `user_id` would fire
|
|
1706
2038
|
* on a large fraction of correct policies.
|
|
1707
2039
|
*
|
|
1708
|
-
*
|
|
1709
|
-
*
|
|
1710
|
-
* it
|
|
1711
|
-
*
|
|
1712
|
-
*
|
|
2040
|
+
* What a live database hands this check is not what anyone typed.
|
|
2041
|
+
* `pg_policies.qual` is Postgres's re-rendering of the parse tree, and inside a
|
|
2042
|
+
* subquery it qualifies *every* column — so the bare name is never in the text.
|
|
2043
|
+
* The bare-name scan below only ever fires on text that did not come out of the
|
|
2044
|
+
* catalog. Against a real database the mistake shows up as its effect: the bare
|
|
2045
|
+
* `organization_id` bound to the inner relation, so the stored comparison reads
|
|
2046
|
+
* `m.organization_id = m.organization_id` — a column compared with itself. That
|
|
2047
|
+
* shape is what {@link selfComparisons} looks for, and it is the half of this
|
|
2048
|
+
* check that can fire on a scan. It was the half missing until it was noticed
|
|
2049
|
+
* that the check could not fire at all; the e2e suite now holds it to that.
|
|
2050
|
+
*
|
|
2051
|
+
* Confidence is always heuristic, and an absence still proves nothing: a bare
|
|
2052
|
+
* name compared with a *different* inner column (`organization_id = id`) comes
|
|
2053
|
+
* back as `m.organization_id = m.id`, which is indistinguishable from a
|
|
2054
|
+
* comparison somebody meant.
|
|
1713
2055
|
*/
|
|
1714
2056
|
var unqualifiedColumnInSubquery = {
|
|
1715
2057
|
id: ID,
|
|
@@ -1784,6 +2126,12 @@ function scanExpression(snapshot, outer, expr) {
|
|
|
1784
2126
|
}
|
|
1785
2127
|
const from = parseFrom(tokens, own);
|
|
1786
2128
|
if (from.length === 0) continue;
|
|
2129
|
+
for (const hit of selfComparisons(snapshot, outer, tokens, own, from)) {
|
|
2130
|
+
const key = `${hit.column}|${hit.inner}`;
|
|
2131
|
+
if (seen.has(key)) continue;
|
|
2132
|
+
seen.add(key);
|
|
2133
|
+
out.push(hit);
|
|
2134
|
+
}
|
|
1787
2135
|
const relations = from.map((item) => resolveRelation(snapshot, outer.schema, item)).filter((r) => Boolean(r)).filter((r) => !(r.schema === outer.schema && r.name === outer.name));
|
|
1788
2136
|
if (relations.length === 0) continue;
|
|
1789
2137
|
const aliases = new Set(from.map((f) => f.alias).filter((a) => Boolean(a)));
|
|
@@ -1801,14 +2149,67 @@ function scanExpression(snapshot, outer, expr) {
|
|
|
1801
2149
|
if (seen.has(key)) continue;
|
|
1802
2150
|
seen.add(key);
|
|
1803
2151
|
out.push({
|
|
2152
|
+
kind: "bare",
|
|
1804
2153
|
column: token.value,
|
|
1805
2154
|
inner: `${inner.schema}.${inner.name}`,
|
|
2155
|
+
innerRelation: {
|
|
2156
|
+
schema: inner.schema,
|
|
2157
|
+
name: inner.name
|
|
2158
|
+
},
|
|
1806
2159
|
comparedTo: partner
|
|
1807
2160
|
});
|
|
1808
2161
|
}
|
|
1809
2162
|
}
|
|
1810
2163
|
return out;
|
|
1811
2164
|
}
|
|
2165
|
+
/**
|
|
2166
|
+
* `q.col <op> q.col` in this subquery's predicate, where `q` is one of the
|
|
2167
|
+
* subquery's own FROM items and the policy's table has a `col` too.
|
|
2168
|
+
*
|
|
2169
|
+
* Nobody writes a column compared with itself; Postgres does, when it stores
|
|
2170
|
+
* `WHERE m.user_id = auth.uid() AND org_id = org_id` and the second `org_id`
|
|
2171
|
+
* bound to `m`. The outer table having the column is what makes it this bug
|
|
2172
|
+
* rather than some other one: it is the column the author could have meant.
|
|
2173
|
+
*/
|
|
2174
|
+
function selfComparisons(snapshot, outer, tokens, own, from) {
|
|
2175
|
+
const inPredicate = new Set(predicateIndices(tokens, own));
|
|
2176
|
+
const out = [];
|
|
2177
|
+
const at = (k) => tokens[own[k]];
|
|
2178
|
+
const isDot = (k) => at(k)?.kind === "punct" && at(k)?.value === ".";
|
|
2179
|
+
const isIdent = (k) => at(k)?.kind === "ident";
|
|
2180
|
+
for (let k = 0; k + 6 < own.length; k++) {
|
|
2181
|
+
if (!inPredicate.has(own[k])) continue;
|
|
2182
|
+
if (isDot(k - 1)) continue;
|
|
2183
|
+
if (!isIdent(k) || !isDot(k + 1) || !isIdent(k + 2)) continue;
|
|
2184
|
+
const op = at(k + 3);
|
|
2185
|
+
if (!op || !COMPARISONS.has(op.value) || op.value === "in" || op.value === "is") continue;
|
|
2186
|
+
if (!isIdent(k + 4) || !isDot(k + 5) || !isIdent(k + 6)) continue;
|
|
2187
|
+
if (isDot(k + 7) || at(k + 7)?.kind === "punct" && at(k + 7)?.value === "(") continue;
|
|
2188
|
+
const qualifier = at(k).value;
|
|
2189
|
+
const column = at(k + 2).value;
|
|
2190
|
+
if (at(k + 4).value !== qualifier || at(k + 6).value !== column) continue;
|
|
2191
|
+
if (!hasColumn(outer, column)) continue;
|
|
2192
|
+
const item = from.find((f) => (f.alias ?? f.name) === qualifier);
|
|
2193
|
+
if (!item) continue;
|
|
2194
|
+
const resolved = resolveRelation(snapshot, outer.schema, item);
|
|
2195
|
+
const innerRelation = resolved ? {
|
|
2196
|
+
schema: resolved.schema,
|
|
2197
|
+
name: resolved.name
|
|
2198
|
+
} : {
|
|
2199
|
+
schema: item.schema ?? outer.schema,
|
|
2200
|
+
name: item.name
|
|
2201
|
+
};
|
|
2202
|
+
out.push({
|
|
2203
|
+
kind: "self",
|
|
2204
|
+
column,
|
|
2205
|
+
inner: `${innerRelation.schema}.${innerRelation.name}`,
|
|
2206
|
+
innerRelation,
|
|
2207
|
+
comparedTo: `${qualifier}.${column}`,
|
|
2208
|
+
operator: op.value
|
|
2209
|
+
});
|
|
2210
|
+
}
|
|
2211
|
+
return out;
|
|
2212
|
+
}
|
|
1812
2213
|
/** Relation references in the subquery's FROM list, with their aliases. */
|
|
1813
2214
|
function parseFrom(tokens, own) {
|
|
1814
2215
|
const start = own.findIndex((i) => tokens[i].kind === "ident" && tokens[i].value === "from");
|
|
@@ -1922,20 +2323,30 @@ function resolveRelation(snapshot, policySchema, item) {
|
|
|
1922
2323
|
return candidates.length === 1 ? candidates[0] : void 0;
|
|
1923
2324
|
}
|
|
1924
2325
|
function buildFinding(snapshot, policy, outer, clause, hit) {
|
|
2326
|
+
const innerSql = qrel(hit.innerRelation.schema, hit.innerRelation.name);
|
|
2327
|
+
const self = hit.kind === "self";
|
|
2328
|
+
const admits = !self || [
|
|
2329
|
+
"=",
|
|
2330
|
+
"<=",
|
|
2331
|
+
">=",
|
|
2332
|
+
"~~",
|
|
2333
|
+
"like",
|
|
2334
|
+
"ilike"
|
|
2335
|
+
].includes(hit.operator ?? "=");
|
|
1925
2336
|
return finding({
|
|
1926
2337
|
id: ID,
|
|
1927
2338
|
severity: "high",
|
|
1928
2339
|
confidence: "heuristic",
|
|
1929
|
-
title: `Policy "${policy.name}" on ${policy.schema}.${policy.table}: does \`${hit.column}\` in the subquery mean ${outer.name}.${hit.column} or ${hit.inner}.${hit.column}?`,
|
|
2340
|
+
title: self ? `Policy "${policy.name}" on ${policy.schema}.${policy.table}: the subquery compares \`${hit.comparedTo}\` with itself — was one side meant to be ${outer.name}.${hit.column}?` : `Policy "${policy.name}" on ${policy.schema}.${policy.table}: does \`${hit.column}\` in the subquery mean ${outer.name}.${hit.column} or ${hit.inner}.${hit.column}?`,
|
|
1930
2341
|
target: {
|
|
1931
2342
|
schema: policy.schema,
|
|
1932
2343
|
table: policy.table,
|
|
1933
2344
|
policy: policy.name,
|
|
1934
2345
|
column: hit.column
|
|
1935
2346
|
},
|
|
1936
|
-
detail: `In the ${clause} expression, \`${hit.column}\` is written unqualified inside a subquery over ${hit.inner}, compared against \`${hit.comparedTo}\`. Both ${hit.inner} and ${policy.schema}.${policy.table} have a column named \`${hit.column}\`, and Postgres resolves the bare name against the innermost scope that has it — so it binds to ${hit.inner}.${hit.column}, not to the outer row. If the intent was to correlate the subquery with the row being checked, that correlation is not happening.\n\nNote that \`pg_policies\`
|
|
1937
|
-
impact: "The predicate does not mean what it reads like. Depending on the data it either matches far more rows than intended — exposing other users' or tenants' rows to anyone the policy applies to — or, if the inner comparison is never satisfiable, matches none, and the table silently returns empty results.",
|
|
1938
|
-
fix: isRebaseManagedPolicy(snapshot, policy) ? managedPolicyFix(policy, `qualify every reference in the rule's condition so the binding is explicit — \`${hit.inner}.${hit.column}\` and \`${policy.table}.${hit.column}\` are different columns, and the bare name binds to the inner one`) : `-- Qualify every reference so the binding is explicit:\nALTER POLICY ${qi(policy.name)} ON ${qrel(policy.schema, policy.table)}\n ${clause === "USING" ? "USING" : "WITH CHECK"} (EXISTS (\n SELECT 1 FROM ${
|
|
2347
|
+
detail: self ? `In the ${clause} expression, the subquery over ${hit.inner} compares \`${hit.comparedTo}\` with itself, so that comparison does not depend on the row being checked. It is what Postgres stores for a comparison written with a bare \`${hit.column}\` meant for the outer row: ${hit.inner} has a \`${hit.column}\` too, Postgres resolves a bare name against the innermost scope that has it, and \`pg_policies\` shows the result with both sides qualified. If the intent was to correlate the subquery with ${policy.schema}.${policy.table}.${hit.column}, that correlation is not happening.` : `In the ${clause} expression, \`${hit.column}\` is written unqualified inside a subquery over ${hit.inner}, compared against \`${hit.comparedTo}\`. Both ${hit.inner} and ${policy.schema}.${policy.table} have a column named \`${hit.column}\`, and Postgres resolves the bare name against the innermost scope that has it — so it binds to ${hit.inner}.${hit.column}, not to the outer row. If the intent was to correlate the subquery with the row being checked, that correlation is not happening.\n\nNote that \`pg_policies\` qualifies every column inside a subquery, so a bare name never appears in text read from a live database: there the same mistake reads as a column compared with itself, which this check also reports. A comparison of two different columns looks deliberate either way, so an absence of this finding is not proof.`,
|
|
2348
|
+
impact: self ? admits ? `The subquery is true for every row of ${policy.schema}.${policy.table} as soon as it is true for one: anyone it admits to one row is admitted to all of them, other users' and other tenants' rows included.` : "The comparison is never true, so the subquery matches nothing and the policy admits no row — the table silently returns empty results to everyone it covers." : "The predicate does not mean what it reads like. Depending on the data it either matches far more rows than intended — exposing other users' or tenants' rows to anyone the policy applies to — or, if the inner comparison is never satisfiable, matches none, and the table silently returns empty results.",
|
|
2349
|
+
fix: isRebaseManagedPolicy(snapshot, policy) ? managedPolicyFix(policy, `qualify every reference in the rule's condition so the binding is explicit — \`${hit.inner}.${hit.column}\` and \`${policy.table}.${hit.column}\` are different columns, and the bare name binds to the inner one`) : `-- Qualify every reference so the binding is explicit:\nALTER POLICY ${qi(policy.name)} ON ${qrel(policy.schema, policy.table)}\n ${clause === "USING" ? "USING" : "WITH CHECK"} (EXISTS (\n SELECT 1 FROM ${innerSql}\n WHERE ${innerSql}.${qi(hit.comparedTo.split(".").pop() ?? hit.comparedTo)}\n = ${qi(policy.table)}.${qi(hit.column)}\n ));\n-- Verify the intended direction first — this rewrite assumes the outer row was meant.`
|
|
1939
2350
|
});
|
|
1940
2351
|
}
|
|
1941
2352
|
//#endregion
|
|
@@ -2257,6 +2668,23 @@ var UnknownRoleError = class extends Error {
|
|
|
2257
2668
|
this.roles = roles;
|
|
2258
2669
|
}
|
|
2259
2670
|
};
|
|
2671
|
+
/**
|
|
2672
|
+
* A `--schema` that is not in `pg_namespace`.
|
|
2673
|
+
*
|
|
2674
|
+
* The same failure as an unknown `--role`, and worse: a typo such as
|
|
2675
|
+
* `--schema pubic`, or `--schema Public` for a schema named `public`, scanned
|
|
2676
|
+
* nothing at all. Zero tables, "No findings", exit 0, for as long as the typo
|
|
2677
|
+
* sat in a CI job. A name that matches nothing only ever narrows the scan, so
|
|
2678
|
+
* it is an error.
|
|
2679
|
+
*/
|
|
2680
|
+
var UnknownSchemaError = class extends Error {
|
|
2681
|
+
schemas;
|
|
2682
|
+
constructor(schemas) {
|
|
2683
|
+
super(`Unknown schema${schemas.length === 1 ? "" : "s"}: ${schemas.join(", ")}.`);
|
|
2684
|
+
this.name = "UnknownSchemaError";
|
|
2685
|
+
this.schemas = schemas;
|
|
2686
|
+
}
|
|
2687
|
+
};
|
|
2260
2688
|
var SYSTEM_SCHEMAS = [
|
|
2261
2689
|
"pg_catalog",
|
|
2262
2690
|
"information_schema",
|
|
@@ -2419,6 +2847,49 @@ function clientConfigFromKeywords(keywords) {
|
|
|
2419
2847
|
return config;
|
|
2420
2848
|
}
|
|
2421
2849
|
/**
|
|
2850
|
+
* The TLS postures to try, in order, for an `sslmode`. Each entry says whether
|
|
2851
|
+
* certificate verification had to be given up to get there.
|
|
2852
|
+
*/
|
|
2853
|
+
function connectionAttempts(sslmode) {
|
|
2854
|
+
switch (sslmode) {
|
|
2855
|
+
case "disable": return [{
|
|
2856
|
+
ssl: false,
|
|
2857
|
+
downgraded: false
|
|
2858
|
+
}];
|
|
2859
|
+
case "require":
|
|
2860
|
+
case "no-verify": return [{
|
|
2861
|
+
ssl: { rejectUnauthorized: false },
|
|
2862
|
+
downgraded: false
|
|
2863
|
+
}];
|
|
2864
|
+
case "verify-ca":
|
|
2865
|
+
case "verify-full": return [{
|
|
2866
|
+
ssl: { rejectUnauthorized: true },
|
|
2867
|
+
downgraded: false
|
|
2868
|
+
}];
|
|
2869
|
+
case "allow": return [{
|
|
2870
|
+
ssl: false,
|
|
2871
|
+
downgraded: false
|
|
2872
|
+
}, {
|
|
2873
|
+
ssl: { rejectUnauthorized: false },
|
|
2874
|
+
downgraded: false
|
|
2875
|
+
}];
|
|
2876
|
+
default: return [
|
|
2877
|
+
{
|
|
2878
|
+
ssl: { rejectUnauthorized: true },
|
|
2879
|
+
downgraded: false
|
|
2880
|
+
},
|
|
2881
|
+
{
|
|
2882
|
+
ssl: { rejectUnauthorized: false },
|
|
2883
|
+
downgraded: true
|
|
2884
|
+
},
|
|
2885
|
+
{
|
|
2886
|
+
ssl: false,
|
|
2887
|
+
downgraded: false
|
|
2888
|
+
}
|
|
2889
|
+
];
|
|
2890
|
+
}
|
|
2891
|
+
}
|
|
2892
|
+
/**
|
|
2422
2893
|
* Connect, negotiating TLS the way libpq would.
|
|
2423
2894
|
*
|
|
2424
2895
|
* `pg` lets the connection string override an explicit `ssl` option, so the
|
|
@@ -2434,43 +2905,7 @@ async function connect(connectionString) {
|
|
|
2434
2905
|
const keywords = parseKeywordConnectionString(connectionString);
|
|
2435
2906
|
const sslmode = keywords ? keywords.get("sslmode")?.toLowerCase() : readParam(connectionString, "sslmode");
|
|
2436
2907
|
const base = keywords ? clientConfigFromKeywords(keywords) : { connectionString: stripParam(connectionString, "sslmode") };
|
|
2437
|
-
|
|
2438
|
-
switch (sslmode) {
|
|
2439
|
-
case "disable":
|
|
2440
|
-
attempts = [{
|
|
2441
|
-
ssl: false,
|
|
2442
|
-
downgraded: false
|
|
2443
|
-
}];
|
|
2444
|
-
break;
|
|
2445
|
-
case "require":
|
|
2446
|
-
case "no-verify":
|
|
2447
|
-
attempts = [{
|
|
2448
|
-
ssl: { rejectUnauthorized: false },
|
|
2449
|
-
downgraded: false
|
|
2450
|
-
}];
|
|
2451
|
-
break;
|
|
2452
|
-
case "verify-ca":
|
|
2453
|
-
case "verify-full":
|
|
2454
|
-
attempts = [{
|
|
2455
|
-
ssl: { rejectUnauthorized: true },
|
|
2456
|
-
downgraded: false
|
|
2457
|
-
}];
|
|
2458
|
-
break;
|
|
2459
|
-
default: attempts = [
|
|
2460
|
-
{
|
|
2461
|
-
ssl: false,
|
|
2462
|
-
downgraded: false
|
|
2463
|
-
},
|
|
2464
|
-
{
|
|
2465
|
-
ssl: { rejectUnauthorized: true },
|
|
2466
|
-
downgraded: false
|
|
2467
|
-
},
|
|
2468
|
-
{
|
|
2469
|
-
ssl: { rejectUnauthorized: false },
|
|
2470
|
-
downgraded: true
|
|
2471
|
-
}
|
|
2472
|
-
];
|
|
2473
|
-
}
|
|
2908
|
+
const attempts = connectionAttempts(sslmode);
|
|
2474
2909
|
let lastError;
|
|
2475
2910
|
for (const attempt of attempts) {
|
|
2476
2911
|
const client = new Client({
|
|
@@ -2545,9 +2980,11 @@ async function readSnapshot(client, opts, diagnostics) {
|
|
|
2545
2980
|
const relations = await readRelations(db, schemas);
|
|
2546
2981
|
const policies = await readPolicies(db, schemas);
|
|
2547
2982
|
const grants = await readGrants(db, schemas);
|
|
2548
|
-
const
|
|
2983
|
+
const schemaUsage = await readSchemaUsage(db, diagnostics);
|
|
2984
|
+
const views = await readViews(db, schemas, serverVersionNum);
|
|
2549
2985
|
const foreignKeys = await readForeignKeys(db, schemas);
|
|
2550
2986
|
const routines = await readRoutines(db, schemas);
|
|
2987
|
+
const referenced = await readReferencedRelations(db, outOfScopeReferences(relations, views, foreignKeys));
|
|
2551
2988
|
const scannerIsPrivileged = isPrivileged(currentRole, server, roles, relations);
|
|
2552
2989
|
const connectingRole = !scannerIsPrivileged && currentRole !== "unknown" ? currentRole : void 0;
|
|
2553
2990
|
const exposedRoles = exposedRolesFor(roles, opts.roles, connectingRole);
|
|
@@ -2561,10 +2998,11 @@ async function readSnapshot(client, opts, diagnostics) {
|
|
|
2561
2998
|
schemas,
|
|
2562
2999
|
exposedRoles,
|
|
2563
3000
|
platform: detectPlatform(allSchemas, roles),
|
|
2564
|
-
relations,
|
|
3001
|
+
relations: [...relations, ...referenced],
|
|
2565
3002
|
policies,
|
|
2566
3003
|
roles,
|
|
2567
3004
|
grants,
|
|
3005
|
+
schemaUsage,
|
|
2568
3006
|
views,
|
|
2569
3007
|
foreignKeys,
|
|
2570
3008
|
routines
|
|
@@ -2580,6 +3018,11 @@ async function readSnapshot(client, opts, diagnostics) {
|
|
|
2580
3018
|
function selectSchemas(all, requested, diagnostics) {
|
|
2581
3019
|
const isSystem = (s) => SYSTEM_SCHEMAS.includes(s) || /^pg_temp(_\d+)?$/.test(s) || /^pg_toast_temp(_\d+)?$/.test(s);
|
|
2582
3020
|
if (requested && requested.length > 0) {
|
|
3021
|
+
if (!diagnostics.degraded.some((entry) => entry.what === "schema list")) {
|
|
3022
|
+
const present = new Set(all);
|
|
3023
|
+
const unknown = [...new Set(requested.filter((schema) => !present.has(schema)))];
|
|
3024
|
+
if (unknown.length > 0) throw new UnknownSchemaError(unknown);
|
|
3025
|
+
}
|
|
2583
3026
|
const wanted = new Set(requested);
|
|
2584
3027
|
const kept = [];
|
|
2585
3028
|
for (const schema of all) if (wanted.has(schema)) kept.push(schema);
|
|
@@ -2650,16 +3093,66 @@ async function readRelations(db, schemas) {
|
|
|
2650
3093
|
WHERE c.relkind::text = ANY($2) AND n.nspname = ANY($1)
|
|
2651
3094
|
ORDER BY n.nspname, c.relname`, [schemas, SCANNED_RELKINDS]);
|
|
2652
3095
|
const columns = await readColumns(db, schemas);
|
|
2653
|
-
return rows.map((r) => ({
|
|
3096
|
+
return rows.map((r) => toRelation(r, columns.get(`${r.schema}.${r.name}`) ?? []));
|
|
3097
|
+
}
|
|
3098
|
+
function toRelation(r, columns) {
|
|
3099
|
+
return {
|
|
2654
3100
|
schema: r.schema,
|
|
2655
3101
|
name: r.name,
|
|
2656
3102
|
kind: RELATION_KINDS[r.kind] ?? "table",
|
|
2657
3103
|
owner: r.owner,
|
|
2658
3104
|
rlsEnabled: Boolean(r.rls_enabled),
|
|
2659
3105
|
rlsForced: Boolean(r.rls_forced),
|
|
2660
|
-
columns
|
|
3106
|
+
columns,
|
|
2661
3107
|
estimatedRows: Number(r.estimated_rows ?? -1)
|
|
2662
|
-
}
|
|
3108
|
+
};
|
|
3109
|
+
}
|
|
3110
|
+
/**
|
|
3111
|
+
* Relations a scanned view reads or a scanned foreign key points at that the
|
|
3112
|
+
* scan did not read, each named once.
|
|
3113
|
+
*
|
|
3114
|
+
* View dependencies are read across schemas on purpose, and foreign keys
|
|
3115
|
+
* always were — but a dependency on a relation that is not in the snapshot
|
|
3116
|
+
* resolves to nothing. `CREATE VIEW public.files AS SELECT * FROM
|
|
3117
|
+
* storage.objects`, granted to anon, produced no finding on a default scan
|
|
3118
|
+
* (which leaves `storage` out), and a junction table pointing at `auth.users`
|
|
3119
|
+
* was never considered.
|
|
3120
|
+
*/
|
|
3121
|
+
function outOfScopeReferences(relations, views, foreignKeys) {
|
|
3122
|
+
const key = (schema, name) => JSON.stringify([schema, name]);
|
|
3123
|
+
const known = new Set(relations.map((r) => key(r.schema, r.name)));
|
|
3124
|
+
const out = /* @__PURE__ */ new Map();
|
|
3125
|
+
const add = (schema, name) => {
|
|
3126
|
+
const k = key(schema, name);
|
|
3127
|
+
if (!known.has(k) && !out.has(k)) out.set(k, {
|
|
3128
|
+
schema,
|
|
3129
|
+
name
|
|
3130
|
+
});
|
|
3131
|
+
};
|
|
3132
|
+
for (const view of views) for (const dep of view.dependsOn) add(dep.schema, dep.table);
|
|
3133
|
+
for (const fk of foreignKeys) add(fk.refSchema, fk.refTable);
|
|
3134
|
+
return [...out.values()];
|
|
3135
|
+
}
|
|
3136
|
+
/**
|
|
3137
|
+
* Read the relations {@link outOfScopeReferences} names. Columns are not read:
|
|
3138
|
+
* nothing judges a referenced relation by its columns, only by whether it has
|
|
3139
|
+
* row-level security.
|
|
3140
|
+
*/
|
|
3141
|
+
async function readReferencedRelations(db, refs) {
|
|
3142
|
+
if (refs.length === 0) return [];
|
|
3143
|
+
return (await db.query("referenced relations", `SELECT n.nspname AS schema, c.relname AS name, c.relkind::text AS kind,
|
|
3144
|
+
pg_get_userbyid(c.relowner) AS owner,
|
|
3145
|
+
c.relrowsecurity AS rls_enabled,
|
|
3146
|
+
c.relforcerowsecurity AS rls_forced,
|
|
3147
|
+
c.reltuples::float8 AS estimated_rows
|
|
3148
|
+
FROM unnest($1::text[], $2::text[]) AS ref(schema_name, rel_name)
|
|
3149
|
+
JOIN pg_namespace n ON n.nspname = ref.schema_name
|
|
3150
|
+
JOIN pg_class c ON c.relnamespace = n.oid AND c.relname = ref.rel_name
|
|
3151
|
+
WHERE c.relkind::text = ANY($3)`, [
|
|
3152
|
+
refs.map((r) => r.schema),
|
|
3153
|
+
refs.map((r) => r.name),
|
|
3154
|
+
SCANNED_RELKINDS
|
|
3155
|
+
])).map((r) => toRelation(r, []));
|
|
2663
3156
|
}
|
|
2664
3157
|
async function readColumns(db, schemas) {
|
|
2665
3158
|
const rows = await db.query("columns", `SELECT n.nspname AS schema, c.relname AS table_name, a.attname AS name,
|
|
@@ -2703,14 +3196,33 @@ async function readPolicies(db, schemas) {
|
|
|
2703
3196
|
withCheck: r.with_check
|
|
2704
3197
|
}));
|
|
2705
3198
|
}
|
|
3199
|
+
/**
|
|
3200
|
+
* Privileges on each scanned relation, from the relation's ACL and from its
|
|
3201
|
+
* columns' ACLs.
|
|
3202
|
+
*
|
|
3203
|
+
* A column privilege is a privilege on the relation for what these checks ask:
|
|
3204
|
+
* `GRANT SELECT (id, email) ON users TO anon` on a table with RLS off hands
|
|
3205
|
+
* anon those columns of every row, and a column-level UPDATE grant next to a
|
|
3206
|
+
* `USING (true)` policy lets anon rewrite them. Reading `relacl` alone missed
|
|
3207
|
+
* both, so `rls-disabled` and `anonymous-write-allowed` said nothing.
|
|
3208
|
+
*/
|
|
2706
3209
|
async function readGrants(db, schemas) {
|
|
2707
|
-
const
|
|
3210
|
+
const tableRows = await db.query("table privileges", `SELECT n.nspname AS schema, c.relname AS table_name,
|
|
2708
3211
|
CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee) END AS grantee,
|
|
2709
3212
|
a.privilege_type
|
|
2710
3213
|
FROM pg_class c
|
|
2711
3214
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
2712
3215
|
CROSS JOIN LATERAL aclexplode(COALESCE(c.relacl, acldefault('r'::"char", c.relowner))) a
|
|
2713
3216
|
WHERE c.relkind::text = ANY($2) AND n.nspname = ANY($1)`, [schemas, SCANNED_RELKINDS]);
|
|
3217
|
+
const columnRows = await db.query("column privileges", `SELECT DISTINCT n.nspname AS schema, c.relname AS table_name,
|
|
3218
|
+
CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee) END AS grantee,
|
|
3219
|
+
a.privilege_type
|
|
3220
|
+
FROM pg_class c
|
|
3221
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
3222
|
+
JOIN pg_attribute att ON att.attrelid = c.oid
|
|
3223
|
+
CROSS JOIN LATERAL aclexplode(att.attacl) a
|
|
3224
|
+
WHERE att.attnum > 0 AND NOT att.attisdropped AND att.attacl IS NOT NULL
|
|
3225
|
+
AND c.relkind::text = ANY($2) AND n.nspname = ANY($1)`, [schemas, SCANNED_RELKINDS]);
|
|
2714
3226
|
const known = /* @__PURE__ */ new Set([
|
|
2715
3227
|
"SELECT",
|
|
2716
3228
|
"INSERT",
|
|
@@ -2721,46 +3233,115 @@ async function readGrants(db, schemas) {
|
|
|
2721
3233
|
"TRIGGER"
|
|
2722
3234
|
]);
|
|
2723
3235
|
const map = /* @__PURE__ */ new Map();
|
|
2724
|
-
for (const row of
|
|
3236
|
+
for (const row of [...tableRows, ...columnRows]) {
|
|
2725
3237
|
if (!known.has(row.privilege_type)) continue;
|
|
2726
|
-
const key =
|
|
3238
|
+
const key = JSON.stringify([
|
|
3239
|
+
row.schema,
|
|
3240
|
+
row.table_name,
|
|
3241
|
+
row.grantee
|
|
3242
|
+
]);
|
|
2727
3243
|
if (!map.has(key)) map.set(key, {
|
|
2728
3244
|
schema: row.schema,
|
|
2729
3245
|
table: row.table_name,
|
|
2730
3246
|
grantee: row.grantee,
|
|
2731
3247
|
privileges: []
|
|
2732
3248
|
});
|
|
2733
|
-
map.get(key).privileges
|
|
3249
|
+
const privileges = map.get(key).privileges;
|
|
3250
|
+
const privilege = row.privilege_type;
|
|
3251
|
+
if (!privileges.includes(privilege)) privileges.push(privilege);
|
|
2734
3252
|
}
|
|
2735
3253
|
return [...map.values()];
|
|
2736
3254
|
}
|
|
2737
|
-
|
|
3255
|
+
/**
|
|
3256
|
+
* Who holds USAGE on each schema. Every schema, not only the scanned ones: a
|
|
3257
|
+
* view or a foreign key can point outside the scan.
|
|
3258
|
+
*
|
|
3259
|
+
* `undefined`, not `[]`, when the read failed — an empty list would say no role
|
|
3260
|
+
* can use any schema and silence every check that gates on a grant, which is
|
|
3261
|
+
* the wrong direction for a scanner to fail in.
|
|
3262
|
+
*/
|
|
3263
|
+
async function readSchemaUsage(db, diagnostics) {
|
|
3264
|
+
const what = "schema privileges";
|
|
3265
|
+
const rows = await db.query(what, `SELECT n.nspname AS schema,
|
|
3266
|
+
CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee) END AS grantee
|
|
3267
|
+
FROM pg_namespace n
|
|
3268
|
+
CROSS JOIN LATERAL aclexplode(COALESCE(n.nspacl, acldefault('n'::"char", n.nspowner))) a
|
|
3269
|
+
WHERE a.privilege_type = 'USAGE'`);
|
|
3270
|
+
if (diagnostics.degraded.some((entry) => entry.what === what)) return void 0;
|
|
3271
|
+
return rows.map((r) => ({
|
|
3272
|
+
schema: r.schema,
|
|
3273
|
+
grantee: r.grantee
|
|
3274
|
+
}));
|
|
3275
|
+
}
|
|
3276
|
+
async function readViews(db, schemas, serverVersionNum) {
|
|
2738
3277
|
const rows = await db.query("views", `SELECT n.nspname AS schema, c.relname AS name, pg_get_userbyid(c.relowner) AS owner,
|
|
2739
3278
|
c.relkind::text AS kind, c.reloptions
|
|
2740
3279
|
FROM pg_class c
|
|
2741
3280
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
2742
3281
|
WHERE c.relkind = ANY(ARRAY['v','m']) AND n.nspname = ANY($1)
|
|
2743
3282
|
ORDER BY n.nspname, c.relname`, [schemas]);
|
|
2744
|
-
const dependencies = await readViewDependencies(db
|
|
3283
|
+
const dependencies = await readViewDependencies(db);
|
|
2745
3284
|
return rows.map((r) => ({
|
|
2746
3285
|
schema: r.schema,
|
|
2747
3286
|
name: r.name,
|
|
2748
3287
|
owner: r.owner,
|
|
2749
|
-
securityInvoker: serverVersionNum < 15e4 || r.kind === "m" ? null : (r.reloptions
|
|
2750
|
-
dependsOn: dependencies.get(
|
|
3288
|
+
securityInvoker: serverVersionNum < 15e4 || r.kind === "m" ? null : reloptionIsOn(r.reloptions, "security_invoker"),
|
|
3289
|
+
dependsOn: dependencies.get(JSON.stringify([r.schema, r.name])) ?? []
|
|
2751
3290
|
}));
|
|
2752
3291
|
}
|
|
2753
3292
|
/**
|
|
3293
|
+
* Whether a boolean reloption is set on, read the way Postgres reads it.
|
|
3294
|
+
*
|
|
3295
|
+
* Postgres stores a reloption as the text it was given, after checking it with
|
|
3296
|
+
* `parse_bool`, so `WITH (security_invoker = 1)` is stored as
|
|
3297
|
+
* `security_invoker=1` and means on. Matching only `true` and `on` reported such
|
|
3298
|
+
* a view as a critical bypass. A value `parse_bool` would refuse cannot be
|
|
3299
|
+
* stored, so anything unrecognised here is read as off — the direction that
|
|
3300
|
+
* reports rather than hides.
|
|
3301
|
+
*/
|
|
3302
|
+
function reloptionIsOn(reloptions, name) {
|
|
3303
|
+
for (const option of reloptions ?? []) {
|
|
3304
|
+
const eq = option.indexOf("=");
|
|
3305
|
+
if (eq === -1 || option.slice(0, eq).toLowerCase() !== name) continue;
|
|
3306
|
+
return parsePgBool(option.slice(eq + 1)) === true;
|
|
3307
|
+
}
|
|
3308
|
+
return false;
|
|
3309
|
+
}
|
|
3310
|
+
/**
|
|
3311
|
+
* Postgres's `parse_bool`: `true`/`false`, `yes`/`no`, `on`/`off`, `1`/`0`,
|
|
3312
|
+
* case-insensitively, and any unambiguous prefix of the words — `t`, `ye`,
|
|
3313
|
+
* `of` — but not `o`, which could be either. `null` for anything else.
|
|
3314
|
+
*/
|
|
3315
|
+
function parsePgBool(raw) {
|
|
3316
|
+
const value = raw.trim().toLowerCase();
|
|
3317
|
+
if (value.length === 0) return null;
|
|
3318
|
+
const prefixOf = (word) => word.startsWith(value);
|
|
3319
|
+
switch (value[0]) {
|
|
3320
|
+
case "t": return prefixOf("true") ? true : null;
|
|
3321
|
+
case "f": return prefixOf("false") ? false : null;
|
|
3322
|
+
case "y": return prefixOf("yes") ? true : null;
|
|
3323
|
+
case "n": return prefixOf("no") ? false : null;
|
|
3324
|
+
case "o":
|
|
3325
|
+
if (value.length < 2) return null;
|
|
3326
|
+
if (prefixOf("on")) return true;
|
|
3327
|
+
return prefixOf("off") ? false : null;
|
|
3328
|
+
case "1": return value.length === 1 ? true : null;
|
|
3329
|
+
case "0": return value.length === 1 ? false : null;
|
|
3330
|
+
default: return null;
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3333
|
+
/**
|
|
2754
3334
|
* Base relations of each view, resolved through `pg_depend` → `pg_rewrite`.
|
|
2755
3335
|
*
|
|
2756
3336
|
* Deliberately transitive: a view over a view over a protected table is still a
|
|
2757
3337
|
* path to that table, and the intermediate view's own options do not save it
|
|
2758
|
-
* (the outer view still executes as its own owner).
|
|
2759
|
-
*
|
|
2760
|
-
* the case worth catching
|
|
3338
|
+
* (the outer view still executes as its own owner). Neither side is filtered to
|
|
3339
|
+
* the scanned schemas — a view in `public` reading a hidden schema is exactly
|
|
3340
|
+
* the case worth catching, and so is one reading it through a view that lives
|
|
3341
|
+
* there. Only the system catalogs' own views are left out.
|
|
2761
3342
|
*/
|
|
2762
|
-
async function readViewDependencies(db
|
|
2763
|
-
|
|
3343
|
+
async function readViewDependencies(db) {
|
|
3344
|
+
return resolveViewDependencies(await db.query("view dependencies", `SELECT DISTINCT dn.nspname AS view_schema, dc.relname AS view_name,
|
|
2764
3345
|
rn.nspname AS ref_schema, rc.relname AS ref_name
|
|
2765
3346
|
FROM pg_depend d
|
|
2766
3347
|
JOIN pg_rewrite rw ON rw.oid = d.objid
|
|
@@ -2772,32 +3353,39 @@ async function readViewDependencies(db, schemas, relations) {
|
|
|
2772
3353
|
AND d.refclassid = 'pg_class'::regclass
|
|
2773
3354
|
AND dc.oid <> rc.oid
|
|
2774
3355
|
AND dc.relkind = ANY(ARRAY['v','m'])
|
|
2775
|
-
AND rc.relkind::text = ANY($
|
|
2776
|
-
AND dn.nspname
|
|
3356
|
+
AND rc.relkind::text = ANY($1)
|
|
3357
|
+
AND dn.nspname <> ALL(ARRAY['pg_catalog', 'information_schema'])`, [SCANNED_RELKINDS]));
|
|
3358
|
+
}
|
|
3359
|
+
/**
|
|
3360
|
+
* The transitive closure of direct view dependencies, keyed by
|
|
3361
|
+
* `JSON.stringify([schema, name])`.
|
|
3362
|
+
*
|
|
3363
|
+
* Every view with a dependency of its own is expanded, wherever it lives, and
|
|
3364
|
+
* names are kept as pairs: `schema.name` split at the first dot is wrong for a
|
|
3365
|
+
* schema whose name has a dot in it.
|
|
3366
|
+
*/
|
|
3367
|
+
function resolveViewDependencies(rows) {
|
|
3368
|
+
const key = (schema, name) => JSON.stringify([schema, name]);
|
|
2777
3369
|
const direct = /* @__PURE__ */ new Map();
|
|
2778
3370
|
for (const row of rows) {
|
|
2779
|
-
const
|
|
2780
|
-
if (!direct.has(
|
|
2781
|
-
direct.get(
|
|
3371
|
+
const k = key(row.view_schema, row.view_name);
|
|
3372
|
+
if (!direct.has(k)) direct.set(k, /* @__PURE__ */ new Map());
|
|
3373
|
+
direct.get(k).set(key(row.ref_schema, row.ref_name), {
|
|
3374
|
+
schema: row.ref_schema,
|
|
3375
|
+
table: row.ref_name
|
|
3376
|
+
});
|
|
2782
3377
|
}
|
|
2783
|
-
const isView = new Set(relations.filter((r) => r.kind === "view" || r.kind === "materialized_view").map((r) => `${r.schema}.${r.name}`));
|
|
2784
3378
|
const out = /* @__PURE__ */ new Map();
|
|
2785
|
-
for (const
|
|
2786
|
-
const seen = /* @__PURE__ */ new
|
|
2787
|
-
const queue = [...
|
|
3379
|
+
for (const [view, refs] of direct) {
|
|
3380
|
+
const seen = /* @__PURE__ */ new Map();
|
|
3381
|
+
const queue = [...refs];
|
|
2788
3382
|
while (queue.length > 0) {
|
|
2789
|
-
const next = queue.shift();
|
|
2790
|
-
if (next ===
|
|
2791
|
-
seen.
|
|
2792
|
-
|
|
3383
|
+
const [next, ref] = queue.shift();
|
|
3384
|
+
if (next === view || seen.has(next)) continue;
|
|
3385
|
+
seen.set(next, ref);
|
|
3386
|
+
queue.push(...direct.get(next) ?? []);
|
|
2793
3387
|
}
|
|
2794
|
-
out.set(
|
|
2795
|
-
const dot = ref.indexOf(".");
|
|
2796
|
-
return {
|
|
2797
|
-
schema: ref.slice(0, dot),
|
|
2798
|
-
table: ref.slice(dot + 1)
|
|
2799
|
-
};
|
|
2800
|
-
}));
|
|
3388
|
+
out.set(view, [...seen.values()]);
|
|
2801
3389
|
}
|
|
2802
3390
|
return out;
|
|
2803
3391
|
}
|
|
@@ -2991,6 +3579,58 @@ function exceedsThreshold(findings, failOn) {
|
|
|
2991
3579
|
const threshold = severityRank(failOn);
|
|
2992
3580
|
return findings.some((finding) => severityRank(finding.severity) >= threshold);
|
|
2993
3581
|
}
|
|
3582
|
+
/** Clean, or nothing at or above `--fail-on`. */
|
|
3583
|
+
var EXIT_OK = 0;
|
|
3584
|
+
/** Findings at or above `--fail-on`. */
|
|
3585
|
+
var EXIT_FINDINGS = 1;
|
|
3586
|
+
/** The scan did not happen: bad arguments, bad connection, timeout. */
|
|
3587
|
+
var EXIT_ERROR = 2;
|
|
3588
|
+
/**
|
|
3589
|
+
* The verdict, as an exit code.
|
|
3590
|
+
*
|
|
3591
|
+
* Pulled out of `runCli` so it can be tested: `runCli` needs a database, and
|
|
3592
|
+
* the one line that decides whether CI goes red had no coverage at all —
|
|
3593
|
+
* deleting it broke no test.
|
|
3594
|
+
*
|
|
3595
|
+
* A degraded scan exits 2, the same code a crash uses, rather than 0. Checks
|
|
3596
|
+
* whose catalogue reads failed return no findings, which is indistinguishable
|
|
3597
|
+
* from finding none, so exiting 0 would have the scanner answer "no problems"
|
|
3598
|
+
* to a question it never managed to ask. Both codes mean the same thing here:
|
|
3599
|
+
* no verdict.
|
|
3600
|
+
*/
|
|
3601
|
+
function exitCodeFor(result, failOn) {
|
|
3602
|
+
if ((result.diagnostics?.degraded.length ?? 0) > 0) return 2;
|
|
3603
|
+
return exceedsThreshold(result.findings, failOn) ? 1 : 0;
|
|
3604
|
+
}
|
|
3605
|
+
/**
|
|
3606
|
+
* The summary's last word: the code the process exits with, and why.
|
|
3607
|
+
*
|
|
3608
|
+
* Both reports print it, and both used to work it out for themselves from the
|
|
3609
|
+
* findings alone, so a degraded scan printed "Exit code 0" or "Exit code 1"
|
|
3610
|
+
* and then exited 2. It comes from {@link exitCodeFor} now, the same function
|
|
3611
|
+
* the CLI exits with.
|
|
3612
|
+
*/
|
|
3613
|
+
function exitVerdict(result, failOn) {
|
|
3614
|
+
const code = exitCodeFor(result, failOn);
|
|
3615
|
+
if (code === 2) {
|
|
3616
|
+
const failed = result.diagnostics?.degraded.length ?? 0;
|
|
3617
|
+
return {
|
|
3618
|
+
code,
|
|
3619
|
+
reason: `the scan was incomplete (${failed} catalogue ${failed === 1 ? "read" : "reads"} failed), so there is no verdict.`
|
|
3620
|
+
};
|
|
3621
|
+
}
|
|
3622
|
+
if (failOn === "none") return {
|
|
3623
|
+
code,
|
|
3624
|
+
reason: "--fail-on none, so findings never fail the run."
|
|
3625
|
+
};
|
|
3626
|
+
return code === 1 ? {
|
|
3627
|
+
code,
|
|
3628
|
+
reason: `at least one finding is "${failOn}" or worse (--fail-on ${failOn}).`
|
|
3629
|
+
} : {
|
|
3630
|
+
code,
|
|
3631
|
+
reason: `nothing at or above "${failOn}" (--fail-on ${failOn}).`
|
|
3632
|
+
};
|
|
3633
|
+
}
|
|
2994
3634
|
var identity = (value) => value;
|
|
2995
3635
|
var code = (open, close) => (value) => `\u001B[${open}m${value}\u001B[${close}m`;
|
|
2996
3636
|
var PLAIN = {
|
|
@@ -3259,10 +3899,8 @@ function renderSummary(result, certain, heuristic, style, width, options) {
|
|
|
3259
3899
|
out.push(` ${style.dim(`${certain.length} confirmed · ${heuristic.length} worth checking · ${result.stats.checksRun} ${plural$1(result.stats.checksRun, "check", "checks")} run against ${result.stats.tables} ${plural$1(result.stats.tables, "table", "tables")} in ${result.stats.schemas} ${plural$1(result.stats.schemas, "schema", "schemas")}`)}`);
|
|
3260
3900
|
if (result.stats.tablesWithoutRls > 0) out.push(` ${style.dim(`${result.stats.tablesWithoutRls} of ${result.stats.tables} ${plural$1(result.stats.tables, "table has", "tables have")} row-level security disabled`)}`);
|
|
3261
3901
|
out.push("");
|
|
3262
|
-
const
|
|
3263
|
-
|
|
3264
|
-
else if (failing) out.push(` ${style.bold("Exit code 1")} ${style.dim(`— at least one finding is "${options.failOn}" or worse (--fail-on ${options.failOn}).`)}`);
|
|
3265
|
-
else out.push(` ${style.bold("Exit code 0")} ${style.dim(`— nothing at or above "${options.failOn}" (--fail-on ${options.failOn}).`)}`);
|
|
3902
|
+
const verdict = exitVerdict(result, options.failOn);
|
|
3903
|
+
out.push(` ${style.bold(`Exit code ${verdict.code}`)} ${style.dim(`— ${verdict.reason}`)}`);
|
|
3266
3904
|
out.push(` ${style.dim(`Scanned ${result.scannedAt} · read-only, and nothing left this machine.`)}`);
|
|
3267
3905
|
out.push("");
|
|
3268
3906
|
out.push(style.dim("rls-check is free and maintained by the team behind Rebase — https://rebase.pro"));
|
|
@@ -3537,8 +4175,8 @@ function renderFindingGroups(findings, opts = {}) {
|
|
|
3537
4175
|
return out.join("");
|
|
3538
4176
|
}
|
|
3539
4177
|
function renderFooter(result, options) {
|
|
3540
|
-
const
|
|
3541
|
-
return `<footer><p>${escapeHtml(
|
|
4178
|
+
const verdict = exitVerdict(result, options.failOn);
|
|
4179
|
+
return `<footer><p>${escapeHtml(`Exit code ${verdict.code} — ${verdict.reason}`)}</p><p>${escapeHtml(`Scanned ${result.scannedAt} · read-only, and nothing left this machine. This file makes no network requests.`)}</p><p>rls-check is free and maintained by the team behind Rebase — <a href="https://rebase.pro">rebase.pro</a></p></footer>`;
|
|
3542
4180
|
}
|
|
3543
4181
|
/**
|
|
3544
4182
|
* The whole report as one HTML document, ready to write to a file.
|
|
@@ -3610,12 +4248,6 @@ function renderHtml(result, options) {
|
|
|
3610
4248
|
* could have come from `pg`, Node or the user goes through `redactSecrets`
|
|
3611
4249
|
* on its way out.
|
|
3612
4250
|
*/
|
|
3613
|
-
/** Clean, or nothing at or above `--fail-on`. */
|
|
3614
|
-
var EXIT_OK = 0;
|
|
3615
|
-
/** Findings at or above `--fail-on`. */
|
|
3616
|
-
var EXIT_FINDINGS = 1;
|
|
3617
|
-
/** The scan did not happen: bad arguments, bad connection, timeout. */
|
|
3618
|
-
var EXIT_ERROR = 2;
|
|
3619
4251
|
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
3620
4252
|
var DEFAULT_FAIL_ON = "high";
|
|
3621
4253
|
var TABLE_KINDS = /* @__PURE__ */ new Set(["table", "partitioned_table"]);
|
|
@@ -3657,26 +4289,9 @@ function selectCheckIds(options) {
|
|
|
3657
4289
|
const only = options.only && options.only.length > 0 ? new Set(options.only) : null;
|
|
3658
4290
|
return CHECKS.filter((check) => (only === null || only.has(check.id)) && !skip.has(check.id)).map((check) => check.id);
|
|
3659
4291
|
}
|
|
3660
|
-
/**
|
|
3661
|
-
* The verdict, as an exit code.
|
|
3662
|
-
*
|
|
3663
|
-
* Pulled out of `runCli` so it can be tested: `runCli` needs a database, and
|
|
3664
|
-
* the one line that decides whether CI goes red had no coverage at all —
|
|
3665
|
-
* deleting it broke no test.
|
|
3666
|
-
*
|
|
3667
|
-
* A degraded scan exits 2, the same code a crash uses, rather than 0. Checks
|
|
3668
|
-
* whose catalogue reads failed return no findings, which is indistinguishable
|
|
3669
|
-
* from finding none, so exiting 0 would have the scanner answer "no problems"
|
|
3670
|
-
* to a question it never managed to ask. Both codes mean the same thing here:
|
|
3671
|
-
* no verdict.
|
|
3672
|
-
*/
|
|
3673
|
-
function exitCodeFor(result, failOn) {
|
|
3674
|
-
if (result.diagnostics.degraded.length > 0) return 2;
|
|
3675
|
-
return exceedsThreshold(result.findings, failOn) ? 1 : 0;
|
|
3676
|
-
}
|
|
3677
4292
|
function buildScanResult(snapshot, findings, meta) {
|
|
3678
4293
|
const target = parseConnectionString(meta.connectionString);
|
|
3679
|
-
const tables = snapshot.relations.filter((relation) => TABLE_KINDS.has(relation.kind));
|
|
4294
|
+
const tables = snapshot.relations.filter((relation) => TABLE_KINDS.has(relation.kind) && snapshot.schemas.includes(relation.schema));
|
|
3680
4295
|
return {
|
|
3681
4296
|
scannedAt: meta.scannedAt,
|
|
3682
4297
|
database: {
|
|
@@ -4188,6 +4803,13 @@ async function runCli(argv, io = defaultIo()) {
|
|
|
4188
4803
|
}, color));
|
|
4189
4804
|
return 2;
|
|
4190
4805
|
}
|
|
4806
|
+
if (error instanceof UnknownSchemaError) {
|
|
4807
|
+
io.stderr(formatFriendlyError({
|
|
4808
|
+
headline: `No such schema on this database: ${error.schemas.join(", ")}.`,
|
|
4809
|
+
hint: "Schema names are case-sensitive; check the spelling against `SELECT nspname FROM pg_namespace`. A --schema that matches nothing would scan nothing and report it as clean, so it is refused."
|
|
4810
|
+
}, color));
|
|
4811
|
+
return 2;
|
|
4812
|
+
}
|
|
4191
4813
|
io.stderr(formatFriendlyError(explainError(error, {
|
|
4192
4814
|
endpoint,
|
|
4193
4815
|
timeoutMs: options.timeoutMs,
|