@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/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.roles.find((r) => sameRole(r.name, role));
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
- /** Privileges `role` effectively holds on a relation, memberships included. */
417
- function effectivePrivileges(snapshot, schema, table, role) {
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
- for (const g of snapshot.grants) {
421
- if (g.schema !== schema || g.table !== table) continue;
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.find((r) => r.schema === schema && r.name === name);
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.filter((p) => p.schema === schema && p.table === table);
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
- var qi = (ident) => `"${ident.replace(/"/g, "\"\"")}"`;
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 check expression is
598
- * absent or constant-true, which in Postgres means "accept any row". Policies
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 wanted = COMMANDS_FOR[policy.command] ?? [];
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 its check expression ${policy.using == null && policy.withCheck == null ? "is absent, which Postgres treats as accepting every row" : "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.`,
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:\nREVOKE ${commands.join(", ")} ON ${qrel(policy.schema, policy.table)} FROM ${grantedTo.map((r) => r === "PUBLIC" ? "PUBLIC" : `"${r}"`).join(", ")};`
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
- * Does this policy impose no row condition at all?
785
+ * The writes this policy admits for any row whatsoever.
663
786
  *
664
- * For INSERT, Postgres falls back to USING when WITH CHECK is absent, and a
665
- * policy with neither clause admits every row. For UPDATE/DELETE the USING
666
- * clause selects the rows that may be touched.
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 acceptsAnyRow(policy) {
669
- const present = (policy.command === "INSERT" ? [policy.withCheck ?? policy.using] : [policy.using, policy.command === "DELETE" ? null : policy.withCheck]).filter((c) => c != null);
670
- if (present.length === 0) return true;
671
- return present.every((c) => isUnconditionalTrue(c));
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 || rel.kind !== "table" && rel.kind !== "partitioned_table") continue;
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.foreignKeys.filter((fk) => fk.schema === rel.schema && fk.table === rel.name);
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 ${JSON.stringify(`${rel.name}_select`)} ON ${qrel(rel.schema, rel.name)}\n-- FOR SELECT USING (EXISTS (\n-- SELECT 1 FROM ${endpoints[0]} e\n-- WHERE e.id = ${qrel(rel.schema, rel.name)}.${fks[0].columns[0]}\n-- ));`
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)).map((r) => `${r.schema}.${r.name}`);
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 bases = protectedBaseTables(snapshot, view);
892
- if (bases.length === 0) continue;
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
- REVOKE SELECT ON ${qrel(view.schema, view.name)} FROM ${qrole(roles[0])};\n-- 2. or set FORCE ROW LEVEL SECURITY on the base tables and add policies\n-- that apply to ${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(bases)}, and the\n-- policies there apply to them.`
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 bases = protectedBaseTables(snapshot, view);
939
- if (bases.length === 0) continue;
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:\nREVOKE SELECT ON ${qrel(view.schema, view.name)} FROM ${qrole(roles[0])};\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 ${bases[0].split(".").map((p) => `"${p}"`).join(".")} WHERE ...;`
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, because RESTRICTIVE clauses are ANDed after the
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 = policyTargetsExposedRole(snapshot, policy);
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 gate = restrictiveGate(snapshot, policy);
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 severity = gate ? "medium" : "critical";
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 ? ` A RESTRICTIVE policy ("${gate}") also applies to this command and is ANDed after it, so access may still be gated — verify that restrictive policy covers the rows and roles you expect, because nothing else here does.` : ""),
1010
- impact: gate ? `Row filtering on this table rests entirely on the RESTRICTIVE policy "${gate}". If it does not cover a case, ${listAnd(exposed)} can ${verb} every row${rowsPhrase(rel)}.` : `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.`,
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
- /** Name of a RESTRICTIVE policy that is ANDed with this one, if any. */
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
- return snapshot.policies.find((p) => !p.permissive && p.schema === policy.schema && p.table === policy.table && (p.command === "ALL" || policy.command === "ALL" || p.command === policy.command))?.name ?? null;
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 (policyTargetsExposedRole(snapshot, policy).length === 0) continue;
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 shape = usingMatch?.shape ?? checkMatch?.shape ?? "the caller id";
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 id a signed-out caller actually arrives with is ${listAnd(CLEARING_SENTINELS.map(describeSentinel))}, and neither is ${listAnd(decoys.map((d) => `'${d}'`))} — 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.` : ""),
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
- function platformReading(platform) {
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": return {
1126
- severity: "low",
1127
- meaning: "On Supabase, `auth.uid()` 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.",
1128
- impactSuffix: "Anonymous callers are correctly excluded on Supabase, so this is a data-scoping gap between signed-in users, not an anonymous-access hole."
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) => CLEARING_SENTINELS.includes(lit))) cleared = true;
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: false
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 (policyTargetsExposedRole(snapshot, policy).length === 0) continue;
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: `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.`,
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 ${JSON.stringify(`${rel.name}_owner_select`)} ON ${qrel(rel.schema, rel.name)}\n-- FOR SELECT TO ${qrole(roles[0])} USING (user_id = ${uidCall});`
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 ${JSON.stringify(`${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;`
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
- * - Owner cannot log in: a provisioning role nothing connects as. Informational.
1574
- * - Owner can log in and is otherwise ordinary: anything using that connection
1575
- * string reads the whole table. This is the case worth waking up for.
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 (canLogin) {
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
- detail = `FORCE ROW LEVEL SECURITY is not set, so the owning role ${rel.owner} is exempt from every policy on this table. ${rel.owner} can log in directly, which means a connection string for it bypasses all row filtering.`;
1604
- impact = `Anything connecting as ${rel.owner} — 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.`;
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 $$;\n-- Then schema-qualify every identifier in the body, since nothing but\n-- pg_catalog is on the path any more.`
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
- * Confidence is always heuristic, and the detail says why an absence proves
1709
- * nothing: `pg_policies.qual` is Postgres's re-rendering of the parse tree, and
1710
- * it normally re-qualifies references. A finding here therefore means the
1711
- * ambiguity survived that rewrite, which is strong evidence — but a clean scan
1712
- * is not proof that the original SQL was unambiguous.
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\` shows Postgres's own re-rendering of the policy, which usually re-qualifies column references. A match here means the ambiguity survived that rewrite, so it is strong evidence — but the absence of a match on other policies is not proof that they are unambiguous.`,
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 ${hit.inner}\n WHERE ${hit.inner}.${hit.comparedTo.includes(".") ? hit.comparedTo.split(".").pop() : hit.comparedTo}\n = ${policy.table}.${hit.column}\n ));\n-- Verify the intended direction first — this rewrite assumes the outer row was meant.`
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
- let attempts;
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 views = await readViews(db, schemas, serverVersionNum, relations);
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: columns.get(`${r.schema}.${r.name}`) ?? [],
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 rows = await db.query("table privileges", `SELECT n.nspname AS schema, c.relname AS table_name,
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 rows) {
3236
+ for (const row of [...tableRows, ...columnRows]) {
2725
3237
  if (!known.has(row.privilege_type)) continue;
2726
- const key = `${row.schema}.${row.table_name}.${row.grantee}`;
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.push(row.privilege_type);
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
- async function readViews(db, schemas, serverVersionNum, relations) {
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, schemas, relations);
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 ?? []).some((o) => /^security_invoker=(true|on)$/i.test(o)),
2750
- dependsOn: dependencies.get(`${r.schema}.${r.name}`) ?? []
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). Base tables are not filtered
2759
- * to the scanned schemas — a view in `public` reading a hidden schema is exactly
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, schemas, relations) {
2763
- const rows = await db.query("view dependencies", `SELECT DISTINCT dn.nspname AS view_schema, dc.relname AS view_name,
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($2)
2776
- AND dn.nspname = ANY($1)`, [schemas, SCANNED_RELKINDS]);
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 key = `${row.view_schema}.${row.view_name}`;
2780
- if (!direct.has(key)) direct.set(key, /* @__PURE__ */ new Set());
2781
- direct.get(key).add(`${row.ref_schema}.${row.ref_name}`);
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 key of direct.keys()) {
2786
- const seen = /* @__PURE__ */ new Set();
2787
- const queue = [...direct.get(key) ?? []];
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 === key || seen.has(next)) continue;
2791
- seen.add(next);
2792
- if (isView.has(next)) queue.push(...direct.get(next) ?? []);
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(key, [...seen].map((ref) => {
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 failing = exceedsThreshold(result.findings, options.failOn);
3263
- if (options.failOn === "none") out.push(` ${style.dim("Exit code 0 — --fail-on none, so findings never fail the run.")}`);
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 failing = options.failOn !== "none" && result.findings.some((finding) => severityRank(finding.severity) >= severityRank(options.failOn));
3541
- return `<footer><p>${escapeHtml(options.failOn === "none" ? "Exit code 0 — --fail-on none, so findings never fail the run." : failing ? `Exit code 1 — at least one finding is "${options.failOn}" or worse (--fail-on ${options.failOn}).` : `Exit code 0 — nothing at or above "${options.failOn}" (--fail-on ${options.failOn}).`)}</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>`;
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,