@rebasepro/rls-check 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.es.js CHANGED
@@ -464,6 +464,13 @@ var isTable = (r) => TABLE_KINDS$1.includes(r.kind);
464
464
  function scannedTables(snapshot) {
465
465
  return snapshot.relations.filter((r) => isTable(r) && snapshot.schemas.includes(r.schema));
466
466
  }
467
+ /**
468
+ * Foreign tables in the scanned schemas. Postgres cannot put row-level
469
+ * security on one, so a grant is all that stands between it and a caller.
470
+ */
471
+ function scannedForeignTables(snapshot) {
472
+ return snapshot.relations.filter((r) => r.kind === "foreign_table" && snapshot.schemas.includes(r.schema));
473
+ }
467
474
  function relationAt(snapshot, schema, name) {
468
475
  return snapshot.relations.find((r) => r.schema === schema && r.name === name);
469
476
  }
@@ -477,7 +484,29 @@ function rowsPhrase(rel) {
477
484
  if (rel.estimatedRows === 0) return " (the planner currently estimates 0 rows)";
478
485
  return ` (≈${Math.round(rel.estimatedRows).toLocaleString("en-US")} rows)`;
479
486
  }
480
- var qi = (ident) => `"${ident.replace(/"/g, "\"\"")}"`;
487
+ /**
488
+ * Characters that cannot sit inside a fix as themselves: a line break would
489
+ * end the `--` comment a suggestion is printed in and run the rest of the name
490
+ * as SQL, and the other controls are invisible or rewrite the terminal. The
491
+ * control characters are the point, so the lint rule that objects to them is
492
+ * off for this line.
493
+ */
494
+ var UNPRINTABLE = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
495
+ var UNPRINTABLE_ALL = new RegExp(UNPRINTABLE.source, "g");
496
+ /**
497
+ * A quoted identifier that is one printable token wherever a fix puts it.
498
+ *
499
+ * Every name here comes out of the catalog, which means out of whoever could
500
+ * create a table, a role or a policy, and every fix is printed to be pasted
501
+ * into psql. Doubling `"` keeps a name inside its quotes; a name with a line
502
+ * break in it is written as a Unicode-escape identifier (`U&"a\000Ab"`), which
503
+ * names the same object and has no line break to escape a comment with.
504
+ */
505
+ function qi(ident) {
506
+ const doubled = ident.replace(/"/g, "\"\"");
507
+ if (!UNPRINTABLE.test(ident)) return `"${doubled}"`;
508
+ return `U&"${doubled.replace(/\\/g, "\\\\").replace(UNPRINTABLE_ALL, (ch) => `\\${ch.charCodeAt(0).toString(16).toUpperCase().padStart(4, "0")}`)}"`;
509
+ }
481
510
  var qrel = (schema, name) => `${qi(schema)}.${qi(name)}`;
482
511
  /** PUBLIC is a keyword, not an identifier — quoting it changes what it means. */
483
512
  var qrole = (role) => isPublicRole(role) ? "PUBLIC" : qi(role);
@@ -638,7 +667,7 @@ var anonymousWriteAllowed = {
638
667
  },
639
668
  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.`,
640
669
  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(", ")};`
670
+ 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(qrole).join(", ")};`
642
671
  }));
643
672
  }
644
673
  return findings;
@@ -764,7 +793,11 @@ var grantToPublic = {
764
793
  if (!isPublicRole(grant.grantee)) continue;
765
794
  if (!snapshot.schemas.includes(grant.schema)) continue;
766
795
  const rel = relationAt(snapshot, grant.schema, grant.table);
767
- if (!rel || rel.kind !== "table" && rel.kind !== "partitioned_table") continue;
796
+ if (!rel || ![
797
+ "table",
798
+ "partitioned_table",
799
+ "foreign_table"
800
+ ].includes(rel.kind)) continue;
768
801
  const privileges = DML.filter((p) => grant.privileges.includes(p));
769
802
  if (privileges.length === 0) continue;
770
803
  findings.push(finding({
@@ -776,7 +809,7 @@ var grantToPublic = {
776
809
  schema: grant.schema,
777
810
  table: grant.table
778
811
  },
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."),
812
+ 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
813
  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
814
  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
815
  }));
@@ -841,7 +874,7 @@ var junctionTableUnprotected = {
841
874
  },
842
875
  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
876
  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-- ));`
877
+ 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
878
  }));
846
879
  }
847
880
  return findings;
@@ -861,7 +894,7 @@ function isMostlyKeys(rel, keyColumns) {
861
894
  var ID$10 = "view-bypasses-rls";
862
895
  /** Base relations of `view` that have RLS turned on. */
863
896
  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}`);
897
+ return view.dependsOn.map((d) => relationAt(snapshot, d.schema, d.table)).filter((r) => Boolean(r?.rlsEnabled));
865
898
  }
866
899
  /**
867
900
  * A view granted to an untrusted role that reads an RLS-protected table without
@@ -888,8 +921,9 @@ var viewBypassesRls = {
888
921
  const rel = relationAt(snapshot, view.schema, view.name);
889
922
  if (rel && rel.kind !== "view") continue;
890
923
  if (view.securityInvoker === true) continue;
891
- const bases = protectedBaseTables(snapshot, view);
892
- if (bases.length === 0) continue;
924
+ const baseTables = protectedBaseTables(snapshot, view);
925
+ if (baseTables.length === 0) continue;
926
+ const bases = baseTables.map((r) => `${r.schema}.${r.name}`);
893
927
  const exposed = exposedGrantees(snapshot, view.schema, view.name, ["SELECT"]);
894
928
  if (exposed.length === 0) continue;
895
929
  const roles = exposed.map((e) => e.role);
@@ -908,7 +942,7 @@ var viewBypassesRls = {
908
942
  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
943
  fix: legacy ? `-- \`security_invoker\` requires PostgreSQL 15 or newer. On this server, either:
910
944
  -- 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.`
945
+ 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 ${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
946
  }));
913
947
  }
914
948
  return findings;
@@ -935,8 +969,9 @@ var matviewBypassesRls = {
935
969
  for (const view of snapshot.views) {
936
970
  if (!snapshot.schemas.includes(view.schema)) continue;
937
971
  if (relationAt(snapshot, view.schema, view.name)?.kind !== "materialized_view") continue;
938
- const bases = protectedBaseTables(snapshot, view);
939
- if (bases.length === 0) continue;
972
+ const baseTables = protectedBaseTables(snapshot, view);
973
+ if (baseTables.length === 0) continue;
974
+ const bases = baseTables.map((r) => `${r.schema}.${r.name}`);
940
975
  const exposed = exposedGrantees(snapshot, view.schema, view.name, ["SELECT"]);
941
976
  if (exposed.length === 0) continue;
942
977
  const roles = exposed.map((e) => e.role);
@@ -952,7 +987,7 @@ var matviewBypassesRls = {
952
987
  },
953
988
  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
989
  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 ...;`
990
+ 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 ${qrel(baseTables[0].schema, baseTables[0].name)} WHERE ...;`
956
991
  }));
957
992
  }
958
993
  return findings;
@@ -970,8 +1005,8 @@ var ID$8 = "policy-always-true";
970
1005
  * substring-matching version of this check would flag both.
971
1006
  *
972
1007
  * 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
1008
+ * policy on the same command that applies to the same callers, because
1009
+ * RESTRICTIVE clauses are ANDed after the PERMISSIVE ones are ORed. "Permissive default, restrictive gate" is a real
975
1010
  * pattern, so when one is present the finding degrades to a question instead of
976
1011
  * an accusation rather than disappearing (the restrictive policy may well not
977
1012
  * cover the same rows).
@@ -992,7 +1027,14 @@ var policyAlwaysTrue = {
992
1027
  if (isUnconditionalTrue(policy.using)) clauses.push("USING");
993
1028
  if (isUnconditionalTrue(policy.withCheck)) clauses.push("WITH CHECK");
994
1029
  if (clauses.length === 0) continue;
995
- const gate = restrictiveGate(snapshot, policy);
1030
+ const gates = restrictiveGate(snapshot, policy);
1031
+ const gate = gates && gates.length === 1 ? {
1032
+ name: `RESTRICTIVE policy "${gates[0]}"`,
1033
+ verb: "applies"
1034
+ } : gates ? {
1035
+ name: `RESTRICTIVE policies ${listAnd(gates.map((g) => `"${g}"`))}`,
1036
+ verb: "apply"
1037
+ } : null;
996
1038
  const rel = relationAt(snapshot, policy.schema, policy.table);
997
1039
  const verb = policy.command === "SELECT" ? "read" : "act on";
998
1040
  const severity = gate ? "medium" : "critical";
@@ -1006,17 +1048,58 @@ var policyAlwaysTrue = {
1006
1048
  table: policy.table,
1007
1049
  policy: policy.name
1008
1050
  },
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.`,
1051
+ 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 ? ` 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.` : ""),
1052
+ 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)}.` : `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
1053
  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
1054
  }));
1013
1055
  }
1014
1056
  return findings;
1015
1057
  }
1016
1058
  };
1017
- /** Name of a RESTRICTIVE policy that is ANDed with this one, if any. */
1059
+ /**
1060
+ * Does `policy` apply to a caller arriving as `role`?
1061
+ *
1062
+ * `PUBLIC` in `role` stands for "some role nothing else here names", which is a
1063
+ * member of nothing: only a policy TO PUBLIC applies to it.
1064
+ */
1065
+ function policyAppliesTo(snapshot, policy, role) {
1066
+ if (policy.roles.some(isPublicRole)) return true;
1067
+ if (isPublicRole(role)) return false;
1068
+ const usable = rolesUsableBy(snapshot, role);
1069
+ return policy.roles.some((target) => usable.has(target.toLowerCase()));
1070
+ }
1071
+ /**
1072
+ * The callers a permissive policy lets in: every named exposed role it applies
1073
+ * to, or `PUBLIC` when it applies to no named one and is TO PUBLIC.
1074
+ *
1075
+ * The `PUBLIC` pseudo-caller is left out whenever a named role is there,
1076
+ * because it would otherwise demand a gate TO PUBLIC for a policy that every
1077
+ * real caller already has to pass a gate for — on Supabase, a restrictive
1078
+ * policy TO anon and authenticated covers every request the API makes.
1079
+ */
1080
+ function callersOf(snapshot, policy) {
1081
+ const named = snapshot.exposedRoles.filter((role) => !isPublicRole(role) && policyAppliesTo(snapshot, policy, role));
1082
+ if (named.length > 0) return named;
1083
+ return policy.roles.some(isPublicRole) ? ["PUBLIC"] : [];
1084
+ }
1085
+ /**
1086
+ * The RESTRICTIVE policies that gate this one, or `null` when they do not gate
1087
+ * every caller it lets in.
1088
+ *
1089
+ * A restrictive policy is ANDed only for the roles in its own TO list and only
1090
+ * for its own command. Any restrictive policy on the table used to count, so
1091
+ * Supabase's documented MFA gate — `AS RESTRICTIVE TO authenticated` — next to
1092
+ * `USING (true) TO anon` softened the finding to medium while anon read every
1093
+ * row, and the default `--fail-on high` passed it. So a gate now has to apply
1094
+ * to every command the permissive policy covers (`ALL` needs `ALL`), and the
1095
+ * restrictive policies together have to apply to every caller it lets in.
1096
+ */
1018
1097
  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;
1098
+ const gates = snapshot.policies.filter((p) => !p.permissive && p.schema === policy.schema && p.table === policy.table && (p.command === "ALL" || p.command === policy.command));
1099
+ const callers = callersOf(snapshot, policy);
1100
+ if (callers.length === 0) return null;
1101
+ const covering = gates.filter((gate) => callers.some((role) => policyAppliesTo(snapshot, gate, role)));
1102
+ return callers.every((role) => covering.some((gate) => policyAppliesTo(snapshot, gate, role))) ? covering.map((gate) => gate.name) : null;
1020
1103
  }
1021
1104
  //#endregion
1022
1105
  //#region src/types.ts
@@ -1409,7 +1492,7 @@ var policyAuthenticatedTautology = {
1409
1492
  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
1493
  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
1494
  -- 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
1495
+ -- 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
1496
  -- skip the finding: rls-check --skip ${ID$6}`
1414
1497
  }));
1415
1498
  }
@@ -1462,7 +1545,7 @@ var policyRoleUnreachable = {
1462
1545
  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
1546
  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
1547
  -- 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>;`
1548
+ 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
1549
  }));
1467
1550
  }
1468
1551
  return findings;
@@ -1486,6 +1569,10 @@ var ID$4 = "rls-disabled";
1486
1569
  * lookup tables reachable only by the service role. Flagging those is how a
1487
1570
  * scanner produces forty findings on a healthy database and gets ignored, so a
1488
1571
  * table nobody exposed produces no finding at all.
1572
+ *
1573
+ * Foreign tables are included, and are always "RLS off": Postgres refuses
1574
+ * `ENABLE ROW LEVEL SECURITY` on one, so a grant to an exposed role hands over
1575
+ * whatever the remote server returns, and the fix is the grant, not a policy.
1489
1576
  */
1490
1577
  var rlsDisabled = {
1491
1578
  id: ID$4,
@@ -1494,8 +1581,9 @@ var rlsDisabled = {
1494
1581
  run(snapshot) {
1495
1582
  const uidCall = callerIdCall(snapshot);
1496
1583
  const findings = [];
1497
- for (const rel of scannedTables(snapshot)) {
1584
+ for (const rel of [...scannedTables(snapshot), ...scannedForeignTables(snapshot)]) {
1498
1585
  if (rel.rlsEnabled) continue;
1586
+ const foreign = rel.kind === "foreign_table";
1499
1587
  const exposed = exposedGrantees(snapshot, rel.schema, rel.name, DML);
1500
1588
  if (exposed.length === 0) continue;
1501
1589
  const roles = exposed.map((e) => e.role);
@@ -1509,19 +1597,31 @@ var rlsDisabled = {
1509
1597
  id: ID$4,
1510
1598
  severity: "critical",
1511
1599
  confidence: "certain",
1512
- title: `${rel.schema}.${rel.name} has row-level security disabled and is granted to ${listAnd(roles)}`,
1600
+ 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
1601
  target: {
1514
1602
  schema: rel.schema,
1515
1603
  table: rel.name
1516
1604
  },
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.`,
1605
+ 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
1606
  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});`
1607
+ 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
1608
  }));
1521
1609
  }
1522
1610
  return findings;
1523
1611
  }
1524
1612
  };
1613
+ /**
1614
+ * The REVOKEs that take a foreign table away from the exposed roles: one per
1615
+ * grant that reaches them, made to the role the grant names, which is not
1616
+ * always the exposed role itself.
1617
+ */
1618
+ function foreignTableFix(snapshot, schema, table, exposed) {
1619
+ const reaching = new Set(exposed.flatMap((role) => [...rolesUsableBy(snapshot, role)]));
1620
+ return `-- A foreign table cannot have row-level security, so take the grant away:\n${snapshot.grants.filter((g) => g.schema === schema && g.table === table && reaching.has(g.grantee.toLowerCase())).map((g) => ({
1621
+ grantee: g.grantee,
1622
+ privileges: DML.filter((p) => g.privileges.includes(p))
1623
+ })).filter((g) => g.privileges.length > 0).map((g) => `REVOKE ${g.privileges.join(", ")} ON ${qrel(schema, table)} FROM ${qrole(g.grantee)};`).join("\n")}\n-- and keep it in a schema your API does not expose, reading it through a\n-- function that checks the caller.`;
1624
+ }
1525
1625
  //#endregion
1526
1626
  //#region src/checks/rls-enabled-no-policies.ts
1527
1627
  var ID$3 = "rls-enabled-no-policies";
@@ -1555,7 +1655,7 @@ var rlsEnabledNoPolicies = {
1555
1655
  },
1556
1656
  detail: `With RLS enabled and no policy defined, Postgres denies every row to every role. ${ownerNote}`,
1557
1657
  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;`
1658
+ 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
1659
  }));
1560
1660
  }
1561
1661
  return findings;
@@ -1568,11 +1668,18 @@ var ID$2 = "rls-enabled-not-forced";
1568
1668
  * RLS on, FORCE off — the table owner is exempt from its own policies.
1569
1669
  *
1570
1670
  * 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:
1671
+ * severity has to follow that or the check becomes noise. "The owner" means
1672
+ * every role with the owner's privileges: Postgres decides the exemption with
1673
+ * `has_privs_of_role`, so a member of the owning role is exempt exactly as the
1674
+ * owner is.
1572
1675
  *
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.
1676
+ * - A role an untrusted caller arrives as is, or is a member of, the owner:
1677
+ * every request made as it skips the policies. Critical.
1678
+ * - Owner can log in, or a login role is a member of it, and it is otherwise
1679
+ * ordinary: anything using that connection string reads the whole table.
1680
+ * This is the case worth waking up for.
1681
+ * - Owner cannot log in and nothing that can is a member of it: a
1682
+ * provisioning role nothing connects as. Informational.
1576
1683
  * - Owner is a superuser or has BYPASSRLS: FORCE would not help either way,
1577
1684
  * because those attributes skip RLS before ownership is even considered.
1578
1685
  * Reporting `high` here would be misleading — the fix is "do not connect as
@@ -1589,6 +1696,9 @@ var rlsEnabledNotForced = {
1589
1696
  const owner = snapshot.roles.find((r) => sameRole(r.name, rel.owner));
1590
1697
  const bypasses = Boolean(owner?.superuser || owner?.bypassRls);
1591
1698
  const canLogin = Boolean(owner?.canLogin);
1699
+ const holdsOwner = (role) => rolesUsableBy(snapshot, role).has(rel.owner.toLowerCase());
1700
+ const exposedOwners = snapshot.exposedRoles.filter((role) => !isPublicRole(role) && holdsOwner(role));
1701
+ const loginMembers = snapshot.roles.filter((r) => r.canLogin && !sameRole(r.name, rel.owner) && holdsOwner(r.name)).map((r) => r.name);
1592
1702
  let severity = "medium";
1593
1703
  let detail;
1594
1704
  let impact;
@@ -1597,11 +1707,18 @@ var rlsEnabledNotForced = {
1597
1707
  severity = "medium";
1598
1708
  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
1709
  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) {
1710
+ 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`;
1711
+ } else if (exposedOwners.length > 0) {
1712
+ severity = "critical";
1713
+ const members = exposedOwners.filter((role) => !sameRole(role, rel.owner));
1714
+ 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"}.`;
1715
+ impact = `A caller arriving as ${listAnd(exposedOwners)} reads and writes every row of this table, ignoring all policies on it.`;
1716
+ 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") : "");
1717
+ } else if (canLogin || loginMembers.length > 0) {
1602
1718
  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.`;
1719
+ const who = canLogin ? [rel.owner, ...loginMembers] : loginMembers;
1720
+ 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.` : "");
1721
+ 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
1722
  fix = `ALTER TABLE ${qrel(rel.schema, rel.name)} FORCE ROW LEVEL SECURITY;`;
1606
1723
  } else {
1607
1724
  severity = "medium";
@@ -1663,20 +1780,31 @@ var securityDefinerMutableSearchPath = {
1663
1780
  },
1664
1781
  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
1782
  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;
1783
+ fix: `-- Pin the search_path on every overload of this routine:\n` + dollarQuotedDo(`DECLARE r record;
1669
1784
  BEGIN
1670
1785
  FOR r IN
1671
1786
  SELECT p.oid::regprocedure AS sig
1672
1787
  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.`
1788
+ 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
1789
  }));
1675
1790
  }
1676
1791
  return findings;
1677
1792
  }
1678
1793
  };
1679
1794
  var literal = (value) => `'${value.replace(/'/g, "''")}'`;
1795
+ /**
1796
+ * `DO $$ … $$;`, with a dollar-quote tag the body does not contain.
1797
+ *
1798
+ * The body carries the routine's schema and name as string literals, and a
1799
+ * routine can be named anything, `$$` included. A `$$` in the name ended the
1800
+ * body there and ran the rest of the name as SQL, whatever the quotes around
1801
+ * it said: nothing inside a dollar-quoted string is special except its tag.
1802
+ */
1803
+ function dollarQuotedDo(body) {
1804
+ let tag = "$$";
1805
+ for (let i = 0; body.includes(tag); i++) tag = `$fix${i === 0 ? "" : i}$`;
1806
+ return `DO ${tag}\n${body} ${tag};`;
1807
+ }
1680
1808
  //#endregion
1681
1809
  //#region src/checks/unqualified-column-in-subquery.ts
1682
1810
  var ID = "unqualified-column-in-subquery";
@@ -1803,6 +1931,10 @@ function scanExpression(snapshot, outer, expr) {
1803
1931
  out.push({
1804
1932
  column: token.value,
1805
1933
  inner: `${inner.schema}.${inner.name}`,
1934
+ innerRelation: {
1935
+ schema: inner.schema,
1936
+ name: inner.name
1937
+ },
1806
1938
  comparedTo: partner
1807
1939
  });
1808
1940
  }
@@ -1922,6 +2054,7 @@ function resolveRelation(snapshot, policySchema, item) {
1922
2054
  return candidates.length === 1 ? candidates[0] : void 0;
1923
2055
  }
1924
2056
  function buildFinding(snapshot, policy, outer, clause, hit) {
2057
+ const innerSql = qrel(hit.innerRelation.schema, hit.innerRelation.name);
1925
2058
  return finding({
1926
2059
  id: ID,
1927
2060
  severity: "high",
@@ -1935,7 +2068,7 @@ function buildFinding(snapshot, policy, outer, clause, hit) {
1935
2068
  },
1936
2069
  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
2070
  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.`
2071
+ 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
2072
  });
1940
2073
  }
1941
2074
  //#endregion
@@ -2257,6 +2390,23 @@ var UnknownRoleError = class extends Error {
2257
2390
  this.roles = roles;
2258
2391
  }
2259
2392
  };
2393
+ /**
2394
+ * A `--schema` that is not in `pg_namespace`.
2395
+ *
2396
+ * The same failure as an unknown `--role`, and worse: a typo such as
2397
+ * `--schema pubic`, or `--schema Public` for a schema named `public`, scanned
2398
+ * nothing at all. Zero tables, "No findings", exit 0, for as long as the typo
2399
+ * sat in a CI job. A name that matches nothing only ever narrows the scan, so
2400
+ * it is an error.
2401
+ */
2402
+ var UnknownSchemaError = class extends Error {
2403
+ schemas;
2404
+ constructor(schemas) {
2405
+ super(`Unknown schema${schemas.length === 1 ? "" : "s"}: ${schemas.join(", ")}.`);
2406
+ this.name = "UnknownSchemaError";
2407
+ this.schemas = schemas;
2408
+ }
2409
+ };
2260
2410
  var SYSTEM_SCHEMAS = [
2261
2411
  "pg_catalog",
2262
2412
  "information_schema",
@@ -2419,6 +2569,49 @@ function clientConfigFromKeywords(keywords) {
2419
2569
  return config;
2420
2570
  }
2421
2571
  /**
2572
+ * The TLS postures to try, in order, for an `sslmode`. Each entry says whether
2573
+ * certificate verification had to be given up to get there.
2574
+ */
2575
+ function connectionAttempts(sslmode) {
2576
+ switch (sslmode) {
2577
+ case "disable": return [{
2578
+ ssl: false,
2579
+ downgraded: false
2580
+ }];
2581
+ case "require":
2582
+ case "no-verify": return [{
2583
+ ssl: { rejectUnauthorized: false },
2584
+ downgraded: false
2585
+ }];
2586
+ case "verify-ca":
2587
+ case "verify-full": return [{
2588
+ ssl: { rejectUnauthorized: true },
2589
+ downgraded: false
2590
+ }];
2591
+ case "allow": return [{
2592
+ ssl: false,
2593
+ downgraded: false
2594
+ }, {
2595
+ ssl: { rejectUnauthorized: false },
2596
+ downgraded: false
2597
+ }];
2598
+ default: return [
2599
+ {
2600
+ ssl: { rejectUnauthorized: true },
2601
+ downgraded: false
2602
+ },
2603
+ {
2604
+ ssl: { rejectUnauthorized: false },
2605
+ downgraded: true
2606
+ },
2607
+ {
2608
+ ssl: false,
2609
+ downgraded: false
2610
+ }
2611
+ ];
2612
+ }
2613
+ }
2614
+ /**
2422
2615
  * Connect, negotiating TLS the way libpq would.
2423
2616
  *
2424
2617
  * `pg` lets the connection string override an explicit `ssl` option, so the
@@ -2434,43 +2627,7 @@ async function connect(connectionString) {
2434
2627
  const keywords = parseKeywordConnectionString(connectionString);
2435
2628
  const sslmode = keywords ? keywords.get("sslmode")?.toLowerCase() : readParam(connectionString, "sslmode");
2436
2629
  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
- }
2630
+ const attempts = connectionAttempts(sslmode);
2474
2631
  let lastError;
2475
2632
  for (const attempt of attempts) {
2476
2633
  const client = new Client({
@@ -2545,9 +2702,10 @@ async function readSnapshot(client, opts, diagnostics) {
2545
2702
  const relations = await readRelations(db, schemas);
2546
2703
  const policies = await readPolicies(db, schemas);
2547
2704
  const grants = await readGrants(db, schemas);
2548
- const views = await readViews(db, schemas, serverVersionNum, relations);
2705
+ const views = await readViews(db, schemas, serverVersionNum);
2549
2706
  const foreignKeys = await readForeignKeys(db, schemas);
2550
2707
  const routines = await readRoutines(db, schemas);
2708
+ const referenced = await readReferencedRelations(db, outOfScopeReferences(relations, views, foreignKeys));
2551
2709
  const scannerIsPrivileged = isPrivileged(currentRole, server, roles, relations);
2552
2710
  const connectingRole = !scannerIsPrivileged && currentRole !== "unknown" ? currentRole : void 0;
2553
2711
  const exposedRoles = exposedRolesFor(roles, opts.roles, connectingRole);
@@ -2561,7 +2719,7 @@ async function readSnapshot(client, opts, diagnostics) {
2561
2719
  schemas,
2562
2720
  exposedRoles,
2563
2721
  platform: detectPlatform(allSchemas, roles),
2564
- relations,
2722
+ relations: [...relations, ...referenced],
2565
2723
  policies,
2566
2724
  roles,
2567
2725
  grants,
@@ -2580,6 +2738,11 @@ async function readSnapshot(client, opts, diagnostics) {
2580
2738
  function selectSchemas(all, requested, diagnostics) {
2581
2739
  const isSystem = (s) => SYSTEM_SCHEMAS.includes(s) || /^pg_temp(_\d+)?$/.test(s) || /^pg_toast_temp(_\d+)?$/.test(s);
2582
2740
  if (requested && requested.length > 0) {
2741
+ if (!diagnostics.degraded.some((entry) => entry.what === "schema list")) {
2742
+ const present = new Set(all);
2743
+ const unknown = [...new Set(requested.filter((schema) => !present.has(schema)))];
2744
+ if (unknown.length > 0) throw new UnknownSchemaError(unknown);
2745
+ }
2583
2746
  const wanted = new Set(requested);
2584
2747
  const kept = [];
2585
2748
  for (const schema of all) if (wanted.has(schema)) kept.push(schema);
@@ -2650,16 +2813,66 @@ async function readRelations(db, schemas) {
2650
2813
  WHERE c.relkind::text = ANY($2) AND n.nspname = ANY($1)
2651
2814
  ORDER BY n.nspname, c.relname`, [schemas, SCANNED_RELKINDS]);
2652
2815
  const columns = await readColumns(db, schemas);
2653
- return rows.map((r) => ({
2816
+ return rows.map((r) => toRelation(r, columns.get(`${r.schema}.${r.name}`) ?? []));
2817
+ }
2818
+ function toRelation(r, columns) {
2819
+ return {
2654
2820
  schema: r.schema,
2655
2821
  name: r.name,
2656
2822
  kind: RELATION_KINDS[r.kind] ?? "table",
2657
2823
  owner: r.owner,
2658
2824
  rlsEnabled: Boolean(r.rls_enabled),
2659
2825
  rlsForced: Boolean(r.rls_forced),
2660
- columns: columns.get(`${r.schema}.${r.name}`) ?? [],
2826
+ columns,
2661
2827
  estimatedRows: Number(r.estimated_rows ?? -1)
2662
- }));
2828
+ };
2829
+ }
2830
+ /**
2831
+ * Relations a scanned view reads or a scanned foreign key points at that the
2832
+ * scan did not read, each named once.
2833
+ *
2834
+ * View dependencies are read across schemas on purpose, and foreign keys
2835
+ * always were — but a dependency on a relation that is not in the snapshot
2836
+ * resolves to nothing. `CREATE VIEW public.files AS SELECT * FROM
2837
+ * storage.objects`, granted to anon, produced no finding on a default scan
2838
+ * (which leaves `storage` out), and a junction table pointing at `auth.users`
2839
+ * was never considered.
2840
+ */
2841
+ function outOfScopeReferences(relations, views, foreignKeys) {
2842
+ const key = (schema, name) => JSON.stringify([schema, name]);
2843
+ const known = new Set(relations.map((r) => key(r.schema, r.name)));
2844
+ const out = /* @__PURE__ */ new Map();
2845
+ const add = (schema, name) => {
2846
+ const k = key(schema, name);
2847
+ if (!known.has(k) && !out.has(k)) out.set(k, {
2848
+ schema,
2849
+ name
2850
+ });
2851
+ };
2852
+ for (const view of views) for (const dep of view.dependsOn) add(dep.schema, dep.table);
2853
+ for (const fk of foreignKeys) add(fk.refSchema, fk.refTable);
2854
+ return [...out.values()];
2855
+ }
2856
+ /**
2857
+ * Read the relations {@link outOfScopeReferences} names. Columns are not read:
2858
+ * nothing judges a referenced relation by its columns, only by whether it has
2859
+ * row-level security.
2860
+ */
2861
+ async function readReferencedRelations(db, refs) {
2862
+ if (refs.length === 0) return [];
2863
+ return (await db.query("referenced relations", `SELECT n.nspname AS schema, c.relname AS name, c.relkind::text AS kind,
2864
+ pg_get_userbyid(c.relowner) AS owner,
2865
+ c.relrowsecurity AS rls_enabled,
2866
+ c.relforcerowsecurity AS rls_forced,
2867
+ c.reltuples::float8 AS estimated_rows
2868
+ FROM unnest($1::text[], $2::text[]) AS ref(schema_name, rel_name)
2869
+ JOIN pg_namespace n ON n.nspname = ref.schema_name
2870
+ JOIN pg_class c ON c.relnamespace = n.oid AND c.relname = ref.rel_name
2871
+ WHERE c.relkind::text = ANY($3)`, [
2872
+ refs.map((r) => r.schema),
2873
+ refs.map((r) => r.name),
2874
+ SCANNED_RELKINDS
2875
+ ])).map((r) => toRelation(r, []));
2663
2876
  }
2664
2877
  async function readColumns(db, schemas) {
2665
2878
  const rows = await db.query("columns", `SELECT n.nspname AS schema, c.relname AS table_name, a.attname AS name,
@@ -2703,14 +2916,33 @@ async function readPolicies(db, schemas) {
2703
2916
  withCheck: r.with_check
2704
2917
  }));
2705
2918
  }
2919
+ /**
2920
+ * Privileges on each scanned relation, from the relation's ACL and from its
2921
+ * columns' ACLs.
2922
+ *
2923
+ * A column privilege is a privilege on the relation for what these checks ask:
2924
+ * `GRANT SELECT (id, email) ON users TO anon` on a table with RLS off hands
2925
+ * anon those columns of every row, and a column-level UPDATE grant next to a
2926
+ * `USING (true)` policy lets anon rewrite them. Reading `relacl` alone missed
2927
+ * both, so `rls-disabled` and `anonymous-write-allowed` said nothing.
2928
+ */
2706
2929
  async function readGrants(db, schemas) {
2707
- const rows = await db.query("table privileges", `SELECT n.nspname AS schema, c.relname AS table_name,
2930
+ const tableRows = await db.query("table privileges", `SELECT n.nspname AS schema, c.relname AS table_name,
2708
2931
  CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee) END AS grantee,
2709
2932
  a.privilege_type
2710
2933
  FROM pg_class c
2711
2934
  JOIN pg_namespace n ON n.oid = c.relnamespace
2712
2935
  CROSS JOIN LATERAL aclexplode(COALESCE(c.relacl, acldefault('r'::"char", c.relowner))) a
2713
2936
  WHERE c.relkind::text = ANY($2) AND n.nspname = ANY($1)`, [schemas, SCANNED_RELKINDS]);
2937
+ const columnRows = await db.query("column privileges", `SELECT DISTINCT n.nspname AS schema, c.relname AS table_name,
2938
+ CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee) END AS grantee,
2939
+ a.privilege_type
2940
+ FROM pg_class c
2941
+ JOIN pg_namespace n ON n.oid = c.relnamespace
2942
+ JOIN pg_attribute att ON att.attrelid = c.oid
2943
+ CROSS JOIN LATERAL aclexplode(att.attacl) a
2944
+ WHERE att.attnum > 0 AND NOT att.attisdropped AND att.attacl IS NOT NULL
2945
+ AND c.relkind::text = ANY($2) AND n.nspname = ANY($1)`, [schemas, SCANNED_RELKINDS]);
2714
2946
  const known = /* @__PURE__ */ new Set([
2715
2947
  "SELECT",
2716
2948
  "INSERT",
@@ -2721,33 +2953,39 @@ async function readGrants(db, schemas) {
2721
2953
  "TRIGGER"
2722
2954
  ]);
2723
2955
  const map = /* @__PURE__ */ new Map();
2724
- for (const row of rows) {
2956
+ for (const row of [...tableRows, ...columnRows]) {
2725
2957
  if (!known.has(row.privilege_type)) continue;
2726
- const key = `${row.schema}.${row.table_name}.${row.grantee}`;
2958
+ const key = JSON.stringify([
2959
+ row.schema,
2960
+ row.table_name,
2961
+ row.grantee
2962
+ ]);
2727
2963
  if (!map.has(key)) map.set(key, {
2728
2964
  schema: row.schema,
2729
2965
  table: row.table_name,
2730
2966
  grantee: row.grantee,
2731
2967
  privileges: []
2732
2968
  });
2733
- map.get(key).privileges.push(row.privilege_type);
2969
+ const privileges = map.get(key).privileges;
2970
+ const privilege = row.privilege_type;
2971
+ if (!privileges.includes(privilege)) privileges.push(privilege);
2734
2972
  }
2735
2973
  return [...map.values()];
2736
2974
  }
2737
- async function readViews(db, schemas, serverVersionNum, relations) {
2975
+ async function readViews(db, schemas, serverVersionNum) {
2738
2976
  const rows = await db.query("views", `SELECT n.nspname AS schema, c.relname AS name, pg_get_userbyid(c.relowner) AS owner,
2739
2977
  c.relkind::text AS kind, c.reloptions
2740
2978
  FROM pg_class c
2741
2979
  JOIN pg_namespace n ON n.oid = c.relnamespace
2742
2980
  WHERE c.relkind = ANY(ARRAY['v','m']) AND n.nspname = ANY($1)
2743
2981
  ORDER BY n.nspname, c.relname`, [schemas]);
2744
- const dependencies = await readViewDependencies(db, schemas, relations);
2982
+ const dependencies = await readViewDependencies(db);
2745
2983
  return rows.map((r) => ({
2746
2984
  schema: r.schema,
2747
2985
  name: r.name,
2748
2986
  owner: r.owner,
2749
2987
  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}`) ?? []
2988
+ dependsOn: dependencies.get(JSON.stringify([r.schema, r.name])) ?? []
2751
2989
  }));
2752
2990
  }
2753
2991
  /**
@@ -2755,12 +2993,13 @@ async function readViews(db, schemas, serverVersionNum, relations) {
2755
2993
  *
2756
2994
  * Deliberately transitive: a view over a view over a protected table is still a
2757
2995
  * 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.
2996
+ * (the outer view still executes as its own owner). Neither side is filtered to
2997
+ * the scanned schemas — a view in `public` reading a hidden schema is exactly
2998
+ * the case worth catching, and so is one reading it through a view that lives
2999
+ * there. Only the system catalogs' own views are left out.
2761
3000
  */
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,
3001
+ async function readViewDependencies(db) {
3002
+ return resolveViewDependencies(await db.query("view dependencies", `SELECT DISTINCT dn.nspname AS view_schema, dc.relname AS view_name,
2764
3003
  rn.nspname AS ref_schema, rc.relname AS ref_name
2765
3004
  FROM pg_depend d
2766
3005
  JOIN pg_rewrite rw ON rw.oid = d.objid
@@ -2772,32 +3011,39 @@ async function readViewDependencies(db, schemas, relations) {
2772
3011
  AND d.refclassid = 'pg_class'::regclass
2773
3012
  AND dc.oid <> rc.oid
2774
3013
  AND dc.relkind = ANY(ARRAY['v','m'])
2775
- AND rc.relkind::text = ANY($2)
2776
- AND dn.nspname = ANY($1)`, [schemas, SCANNED_RELKINDS]);
3014
+ AND rc.relkind::text = ANY($1)
3015
+ AND dn.nspname <> ALL(ARRAY['pg_catalog', 'information_schema'])`, [SCANNED_RELKINDS]));
3016
+ }
3017
+ /**
3018
+ * The transitive closure of direct view dependencies, keyed by
3019
+ * `JSON.stringify([schema, name])`.
3020
+ *
3021
+ * Every view with a dependency of its own is expanded, wherever it lives, and
3022
+ * names are kept as pairs: `schema.name` split at the first dot is wrong for a
3023
+ * schema whose name has a dot in it.
3024
+ */
3025
+ function resolveViewDependencies(rows) {
3026
+ const key = (schema, name) => JSON.stringify([schema, name]);
2777
3027
  const direct = /* @__PURE__ */ new Map();
2778
3028
  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}`);
3029
+ const k = key(row.view_schema, row.view_name);
3030
+ if (!direct.has(k)) direct.set(k, /* @__PURE__ */ new Map());
3031
+ direct.get(k).set(key(row.ref_schema, row.ref_name), {
3032
+ schema: row.ref_schema,
3033
+ table: row.ref_name
3034
+ });
2782
3035
  }
2783
- const isView = new Set(relations.filter((r) => r.kind === "view" || r.kind === "materialized_view").map((r) => `${r.schema}.${r.name}`));
2784
3036
  const out = /* @__PURE__ */ new Map();
2785
- for (const key of direct.keys()) {
2786
- const seen = /* @__PURE__ */ new Set();
2787
- const queue = [...direct.get(key) ?? []];
3037
+ for (const [view, refs] of direct) {
3038
+ const seen = /* @__PURE__ */ new Map();
3039
+ const queue = [...refs];
2788
3040
  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) ?? []);
3041
+ const [next, ref] = queue.shift();
3042
+ if (next === view || seen.has(next)) continue;
3043
+ seen.set(next, ref);
3044
+ queue.push(...direct.get(next) ?? []);
2793
3045
  }
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
- }));
3046
+ out.set(view, [...seen.values()]);
2801
3047
  }
2802
3048
  return out;
2803
3049
  }
@@ -2991,6 +3237,58 @@ function exceedsThreshold(findings, failOn) {
2991
3237
  const threshold = severityRank(failOn);
2992
3238
  return findings.some((finding) => severityRank(finding.severity) >= threshold);
2993
3239
  }
3240
+ /** Clean, or nothing at or above `--fail-on`. */
3241
+ var EXIT_OK = 0;
3242
+ /** Findings at or above `--fail-on`. */
3243
+ var EXIT_FINDINGS = 1;
3244
+ /** The scan did not happen: bad arguments, bad connection, timeout. */
3245
+ var EXIT_ERROR = 2;
3246
+ /**
3247
+ * The verdict, as an exit code.
3248
+ *
3249
+ * Pulled out of `runCli` so it can be tested: `runCli` needs a database, and
3250
+ * the one line that decides whether CI goes red had no coverage at all —
3251
+ * deleting it broke no test.
3252
+ *
3253
+ * A degraded scan exits 2, the same code a crash uses, rather than 0. Checks
3254
+ * whose catalogue reads failed return no findings, which is indistinguishable
3255
+ * from finding none, so exiting 0 would have the scanner answer "no problems"
3256
+ * to a question it never managed to ask. Both codes mean the same thing here:
3257
+ * no verdict.
3258
+ */
3259
+ function exitCodeFor(result, failOn) {
3260
+ if ((result.diagnostics?.degraded.length ?? 0) > 0) return 2;
3261
+ return exceedsThreshold(result.findings, failOn) ? 1 : 0;
3262
+ }
3263
+ /**
3264
+ * The summary's last word: the code the process exits with, and why.
3265
+ *
3266
+ * Both reports print it, and both used to work it out for themselves from the
3267
+ * findings alone, so a degraded scan printed "Exit code 0" or "Exit code 1"
3268
+ * and then exited 2. It comes from {@link exitCodeFor} now, the same function
3269
+ * the CLI exits with.
3270
+ */
3271
+ function exitVerdict(result, failOn) {
3272
+ const code = exitCodeFor(result, failOn);
3273
+ if (code === 2) {
3274
+ const failed = result.diagnostics?.degraded.length ?? 0;
3275
+ return {
3276
+ code,
3277
+ reason: `the scan was incomplete (${failed} catalogue ${failed === 1 ? "read" : "reads"} failed), so there is no verdict.`
3278
+ };
3279
+ }
3280
+ if (failOn === "none") return {
3281
+ code,
3282
+ reason: "--fail-on none, so findings never fail the run."
3283
+ };
3284
+ return code === 1 ? {
3285
+ code,
3286
+ reason: `at least one finding is "${failOn}" or worse (--fail-on ${failOn}).`
3287
+ } : {
3288
+ code,
3289
+ reason: `nothing at or above "${failOn}" (--fail-on ${failOn}).`
3290
+ };
3291
+ }
2994
3292
  var identity = (value) => value;
2995
3293
  var code = (open, close) => (value) => `\u001B[${open}m${value}\u001B[${close}m`;
2996
3294
  var PLAIN = {
@@ -3259,10 +3557,8 @@ function renderSummary(result, certain, heuristic, style, width, options) {
3259
3557
  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
3558
  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
3559
  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}).`)}`);
3560
+ const verdict = exitVerdict(result, options.failOn);
3561
+ out.push(` ${style.bold(`Exit code ${verdict.code}`)} ${style.dim(`— ${verdict.reason}`)}`);
3266
3562
  out.push(` ${style.dim(`Scanned ${result.scannedAt} · read-only, and nothing left this machine.`)}`);
3267
3563
  out.push("");
3268
3564
  out.push(style.dim("rls-check is free and maintained by the team behind Rebase — https://rebase.pro"));
@@ -3537,8 +3833,8 @@ function renderFindingGroups(findings, opts = {}) {
3537
3833
  return out.join("");
3538
3834
  }
3539
3835
  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>`;
3836
+ const verdict = exitVerdict(result, options.failOn);
3837
+ 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
3838
  }
3543
3839
  /**
3544
3840
  * The whole report as one HTML document, ready to write to a file.
@@ -3610,12 +3906,6 @@ function renderHtml(result, options) {
3610
3906
  * could have come from `pg`, Node or the user goes through `redactSecrets`
3611
3907
  * on its way out.
3612
3908
  */
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
3909
  var DEFAULT_TIMEOUT_MS = 15e3;
3620
3910
  var DEFAULT_FAIL_ON = "high";
3621
3911
  var TABLE_KINDS = /* @__PURE__ */ new Set(["table", "partitioned_table"]);
@@ -3657,26 +3947,9 @@ function selectCheckIds(options) {
3657
3947
  const only = options.only && options.only.length > 0 ? new Set(options.only) : null;
3658
3948
  return CHECKS.filter((check) => (only === null || only.has(check.id)) && !skip.has(check.id)).map((check) => check.id);
3659
3949
  }
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
3950
  function buildScanResult(snapshot, findings, meta) {
3678
3951
  const target = parseConnectionString(meta.connectionString);
3679
- const tables = snapshot.relations.filter((relation) => TABLE_KINDS.has(relation.kind));
3952
+ const tables = snapshot.relations.filter((relation) => TABLE_KINDS.has(relation.kind) && snapshot.schemas.includes(relation.schema));
3680
3953
  return {
3681
3954
  scannedAt: meta.scannedAt,
3682
3955
  database: {
@@ -4188,6 +4461,13 @@ async function runCli(argv, io = defaultIo()) {
4188
4461
  }, color));
4189
4462
  return 2;
4190
4463
  }
4464
+ if (error instanceof UnknownSchemaError) {
4465
+ io.stderr(formatFriendlyError({
4466
+ headline: `No such schema on this database: ${error.schemas.join(", ")}.`,
4467
+ 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."
4468
+ }, color));
4469
+ return 2;
4470
+ }
4191
4471
  io.stderr(formatFriendlyError(explainError(error, {
4192
4472
  endpoint,
4193
4473
  timeoutMs: options.timeoutMs,