@rebasepro/rls-check 0.17.3 → 0.18.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
@@ -504,9 +504,71 @@ var listAnd = (items) => items.length <= 1 ? items[0] ?? "" : `${items.slice(0,
504
504
  function callerIdCall(snapshot) {
505
505
  return snapshot.platform === "rebase" ? "rebase.uid()" : "auth.uid()";
506
506
  }
507
+ /**
508
+ * The helper functions a Rebase deployment creates in its `rebase` schema, as
509
+ * they appear inside a policy expression. A call to one of these is the
510
+ * strongest single signal that the policy was compiled from a collection's
511
+ * `securityRules` rather than written by hand.
512
+ */
513
+ var REBASE_HELPER_CALL = /\brebase\.(uid|roles|jwt|is_anonymous)\s*\(/i;
514
+ /**
515
+ * `<table>_<operation>_<7 hex>` — the name Rebase derives for a rule that does
516
+ * not carry one of its own. The hash is of the rule's *semantics*, which is why
517
+ * editing a rule abandons the old policy instead of updating it.
518
+ *
519
+ * Kept in step with `isGeneratedPolicyName` in
520
+ * `packages/server-postgres/src/security/policy-drift.ts`, which is the
521
+ * predicate the product itself uses to decide what it owns. This package cannot
522
+ * import it — it ships with `pg` and nothing else so that `npx` is fast — so the
523
+ * shape is restated here and anchored to the table for the same reason it is
524
+ * there: a name that merely looks similar is not ours to reason about.
525
+ */
526
+ function hasGeneratedPolicyName(policy) {
527
+ const table = policy.table.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
528
+ return new RegExp(`^${table}_(select|insert|update|delete|all)_[0-9a-f]{7}(_\\d+)?$`).test(policy.name);
529
+ }
530
+ /**
531
+ * Is this policy one Rebase derives from a collection's `securityRules` and
532
+ * re-applies at every boot?
533
+ *
534
+ * It matters because the ordinary remediation — edit the policy in the database
535
+ * — is *silently undone* on such a policy: boot drops and recreates every
536
+ * generated policy from the collection config, so a fix applied with SQL
537
+ * survives exactly until the next restart. Prescribing it is worse than
538
+ * prescribing nothing, because the operator watches the finding disappear and
539
+ * files it as done.
540
+ *
541
+ * Gated on the platform as well as the shape: this scanner is pointed at plenty
542
+ * of databases Rebase did not create, and a hash-suffixed policy name on one of
543
+ * those is a coincidence, not a contract.
544
+ */
545
+ function isRebaseManagedPolicy(snapshot, policy) {
546
+ if (snapshot.platform !== "rebase") return false;
547
+ if (REBASE_HELPER_CALL.test(policy.using ?? "")) return true;
548
+ if (REBASE_HELPER_CALL.test(policy.withCheck ?? "")) return true;
549
+ return hasGeneratedPolicyName(policy);
550
+ }
551
+ /** Where the rule that produces a managed policy is documented. */
552
+ var SECURITY_RULES_DOCS = "https://rebase.pro/docs/collections/security-rules";
553
+ /**
554
+ * The remediation for a Rebase-managed policy: change the rule it is compiled
555
+ * from. `intent` is one clause saying what to change the rule to, in the
556
+ * vocabulary of the check that found it.
557
+ *
558
+ * Deliberately contains no SQL. Every other fix in this tool is copy-pasteable
559
+ * because pasting it works; here it would not, and an example that is reverted
560
+ * on the next deploy teaches the wrong model of where access control lives.
561
+ */
562
+ function managedPolicyFix(policy, intent) {
563
+ return `This policy is not hand-written: Rebase compiles it from the collection's
564
+ \`securityRules\` and re-applies it (drop, then create) on every boot, so a
565
+ change made to the policy in the database is undone the next time the runtime
566
+ starts. Change the rule instead:
567
+ 1. find the collection whose table is ${qrel(policy.schema, policy.table)} — in a scaffold,\n under config/collections/;\n 2. ${intent};\n 3. a collection that declares no \`securityRules\` of its own inherits\n \`defaultSecurityRules\` from config/collections/index.ts — a stock scaffold's\n unfiltered read rule lives there, not on the collection;\n 4. redeploy (boot re-applies the policies), or run \`rebase db push\`.\n${SECURITY_RULES_DOCS}`;
568
+ }
507
569
  //#endregion
508
570
  //#region src/checks/anonymous-write-allowed.ts
509
- var ID$13 = "anonymous-write-allowed";
571
+ var ID$14 = "anonymous-write-allowed";
510
572
  var COMMANDS_FOR = {
511
573
  ALL: [
512
574
  "INSERT",
@@ -538,7 +600,7 @@ var COMMANDS_FOR = {
538
600
  * business of `policy-anonymous-tautology`, which can weigh the platform.
539
601
  */
540
602
  var anonymousWriteAllowed = {
541
- id: ID$13,
603
+ id: ID$14,
542
604
  title: "Unauthenticated callers can write",
543
605
  description: "A permissive INSERT/UPDATE/DELETE policy reachable without authentication whose check expression accepts any row, backed by a matching grant.",
544
606
  run(snapshot) {
@@ -565,7 +627,7 @@ var anonymousWriteAllowed = {
565
627
  const commands = WRITE_PRIVILEGES.filter((p) => granted.has(p));
566
628
  const verbs = commands.map((c) => c.toLowerCase());
567
629
  findings.push(finding({
568
- id: ID$13,
630
+ id: ID$14,
569
631
  severity: "high",
570
632
  confidence: "certain",
571
633
  title: `${policy.schema}.${policy.table} accepts unauthenticated ${listAnd(verbs)} via policy "${policy.name}"`,
@@ -576,7 +638,7 @@ var anonymousWriteAllowed = {
576
638
  },
577
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.`,
578
640
  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"}.`,
579
- fix: `-- 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(", ")};`
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(", ")};`
580
642
  }));
581
643
  }
582
644
  return findings;
@@ -610,7 +672,7 @@ function acceptsAnyRow(policy) {
610
672
  }
611
673
  //#endregion
612
674
  //#region src/checks/current-setting-throws.ts
613
- var ID$12 = "current-setting-throws";
675
+ var ID$13 = "current-setting-throws";
614
676
  /**
615
677
  * `current_setting('x')` in a policy, without the `missing_ok` second argument.
616
678
  *
@@ -625,7 +687,7 @@ var ID$12 = "current-setting-throws";
625
687
  * the check cannot see the session that will run the query.
626
688
  */
627
689
  var currentSettingThrows = {
628
- id: ID$12,
690
+ id: ID$13,
629
691
  title: "Policy calls current_setting() without missing_ok",
630
692
  description: "A policy expression calling current_setting('x') with one argument, which raises rather than returning NULL when the setting is unset.",
631
693
  run(snapshot) {
@@ -643,7 +705,7 @@ var currentSettingThrows = {
643
705
  if (settings.size === 0) continue;
644
706
  const names = [...settings];
645
707
  findings.push(finding({
646
- id: ID$12,
708
+ id: ID$13,
647
709
  severity: "low",
648
710
  confidence: "heuristic",
649
711
  title: `Policy "${policy.name}" on ${policy.schema}.${policy.table} calls current_setting(${names.map((n) => `'${n}'`).join(", ")}) without missing_ok`,
@@ -654,7 +716,7 @@ var currentSettingThrows = {
654
716
  },
655
717
  detail: `The ${clauses.join(" and ")} expression calls \`current_setting\` with a single argument. When ${names.length === 1 ? "that setting has" : "those settings have"} not been set in the session, the call raises \`unrecognized configuration parameter\` instead of returning NULL — so the policy cannot evaluate to false, it errors. Passing \`true\` as the second argument makes it return NULL, which the policy then treats as "no match" and denies the row, as intended.`,
656
718
  impact: `A request that reaches this table without ${names.join(" / ")} set fails with a database error rather than being denied. The caller sees a 500 rather than an empty result, and any middleware that retries 5xx responses will retry a request that can never succeed.`,
657
- fix: `-- Add the missing_ok argument so an unset value denies instead of raising:\nALTER POLICY ${qi(policy.name)} ON ${qrel(policy.schema, policy.table)}\n USING (tenant_id = current_setting('${names[0]}', true)::uuid);`
719
+ fix: isRebaseManagedPolicy(snapshot, policy) ? managedPolicyFix(policy, `pass the missing_ok argument in the rule's raw SQL — \`current_setting('${names[0]}', true)\` — so an unset value denies instead of raising`) : `-- Add the missing_ok argument so an unset value denies instead of raising:\nALTER POLICY ${qi(policy.name)} ON ${qrel(policy.schema, policy.table)}\n USING (tenant_id = current_setting('${names[0]}', true)::uuid);`
658
720
  }));
659
721
  }
660
722
  return findings;
@@ -681,7 +743,7 @@ function singleArgumentSettings(expr) {
681
743
  }
682
744
  //#endregion
683
745
  //#region src/checks/grant-to-public.ts
684
- var ID$11 = "grant-to-public";
746
+ var ID$12 = "grant-to-public";
685
747
  /**
686
748
  * A DML privilege granted to PUBLIC.
687
749
  *
@@ -693,7 +755,7 @@ var ID$11 = "grant-to-public";
693
755
  * separately and with more force.
694
756
  */
695
757
  var grantToPublic = {
696
- id: ID$11,
758
+ id: ID$12,
697
759
  title: "Table privileges granted to PUBLIC",
698
760
  description: "A SELECT/INSERT/UPDATE/DELETE privilege granted to PUBLIC on a table.",
699
761
  run(snapshot) {
@@ -706,7 +768,7 @@ var grantToPublic = {
706
768
  const privileges = DML.filter((p) => grant.privileges.includes(p));
707
769
  if (privileges.length === 0) continue;
708
770
  findings.push(finding({
709
- id: ID$11,
771
+ id: ID$12,
710
772
  severity: "medium",
711
773
  confidence: "certain",
712
774
  title: `${grant.schema}.${grant.table} grants ${listAnd(privileges)} to PUBLIC`,
@@ -724,7 +786,7 @@ var grantToPublic = {
724
786
  };
725
787
  //#endregion
726
788
  //#region src/checks/junction-table-unprotected.ts
727
- var ID$10 = "junction-table-unprotected";
789
+ var ID$11 = "junction-table-unprotected";
728
790
  /** Bookkeeping columns a join table is allowed to carry without ceasing to be one. */
729
791
  var INCIDENTAL_COLUMNS = /* @__PURE__ */ new Set([
730
792
  "id",
@@ -754,7 +816,7 @@ var INCIDENTAL_COLUMNS = /* @__PURE__ */ new Set([
754
816
  * that happens to have two foreign keys, and is not reported here.
755
817
  */
756
818
  var junctionTableUnprotected = {
757
- id: ID$10,
819
+ id: ID$11,
758
820
  title: "Many-to-many join table without RLS",
759
821
  description: "A table that is essentially just two foreign keys, both pointing at RLS-protected tables, that has no row-level security of its own.",
760
822
  run(snapshot) {
@@ -769,7 +831,7 @@ var junctionTableUnprotected = {
769
831
  if (!fks.map((fk) => relationAt(snapshot, fk.refSchema, fk.refTable)).every((t) => t?.rlsEnabled)) continue;
770
832
  if (!isMostlyKeys(rel, fks.flatMap((fk) => fk.columns))) continue;
771
833
  findings.push(finding({
772
- id: ID$10,
834
+ id: ID$11,
773
835
  severity: "high",
774
836
  confidence: "heuristic",
775
837
  title: `${rel.schema}.${rel.name} joins ${endpoints[0]} to ${endpoints[1]}, and is the only one of the three without RLS`,
@@ -796,7 +858,7 @@ function isMostlyKeys(rel, keyColumns) {
796
858
  }
797
859
  //#endregion
798
860
  //#region src/checks/view-bypasses-rls.ts
799
- var ID$9 = "view-bypasses-rls";
861
+ var ID$10 = "view-bypasses-rls";
800
862
  /** Base relations of `view` that have RLS turned on. */
801
863
  function protectedBaseTables(snapshot, view) {
802
864
  return view.dependsOn.map((d) => relationAt(snapshot, d.schema, d.table)).filter((r) => Boolean(r?.rlsEnabled)).map((r) => `${r.schema}.${r.name}`);
@@ -816,7 +878,7 @@ function protectedBaseTables(snapshot, view) {
816
878
  * mistake, so those findings are marked heuristic.
817
879
  */
818
880
  var viewBypassesRls = {
819
- id: ID$9,
881
+ id: ID$10,
820
882
  title: "View reads past its base table's RLS",
821
883
  description: "A view granted to an untrusted role that selects from an RLS-protected table and runs with its owner's privileges instead of the caller's.",
822
884
  run(snapshot) {
@@ -833,7 +895,7 @@ var viewBypassesRls = {
833
895
  const roles = exposed.map((e) => e.role);
834
896
  const legacy = view.securityInvoker === null;
835
897
  findings.push(finding({
836
- id: ID$9,
898
+ id: ID$10,
837
899
  severity: "critical",
838
900
  confidence: legacy ? "heuristic" : "certain",
839
901
  title: `View ${view.schema}.${view.name} reads ${listAnd(bases)} without security_invoker and is readable by ${listAnd(roles)}`,
@@ -854,7 +916,7 @@ REVOKE SELECT ON ${qrel(view.schema, view.name)} FROM ${qrole(roles[0])};\n--
854
916
  };
855
917
  //#endregion
856
918
  //#region src/checks/matview-bypasses-rls.ts
857
- var ID$8 = "matview-bypasses-rls";
919
+ var ID$9 = "matview-bypasses-rls";
858
920
  /**
859
921
  * A materialized view over an RLS-protected table, readable by an untrusted role.
860
922
  *
@@ -865,7 +927,7 @@ var ID$8 = "matview-bypasses-rls";
865
927
  * only controls are the grant and what the defining query selects.
866
928
  */
867
929
  var matviewBypassesRls = {
868
- id: ID$8,
930
+ id: ID$9,
869
931
  title: "Materialized view exposes RLS-protected data",
870
932
  description: "A materialized view granted to an untrusted role whose defining query reads a table with row-level security enabled.",
871
933
  run(snapshot) {
@@ -879,7 +941,7 @@ var matviewBypassesRls = {
879
941
  if (exposed.length === 0) continue;
880
942
  const roles = exposed.map((e) => e.role);
881
943
  findings.push(finding({
882
- id: ID$8,
944
+ id: ID$9,
883
945
  severity: "high",
884
946
  confidence: "certain",
885
947
  title: `Materialized view ${view.schema}.${view.name} snapshots ${listAnd(bases)} and is readable by ${listAnd(roles)}`,
@@ -898,7 +960,7 @@ var matviewBypassesRls = {
898
960
  };
899
961
  //#endregion
900
962
  //#region src/checks/policy-always-true.ts
901
- var ID$7 = "policy-always-true";
963
+ var ID$8 = "policy-always-true";
902
964
  /**
903
965
  * A PERMISSIVE policy whose expression is a constant truth, targeted at a role
904
966
  * an untrusted caller reaches.
@@ -915,7 +977,7 @@ var ID$7 = "policy-always-true";
915
977
  * cover the same rows).
916
978
  */
917
979
  var policyAlwaysTrue = {
918
- id: ID$7,
980
+ id: ID$8,
919
981
  title: "Policy grants unconditional access",
920
982
  description: "A permissive policy whose USING or WITH CHECK expression is always true.",
921
983
  run(snapshot) {
@@ -935,7 +997,7 @@ var policyAlwaysTrue = {
935
997
  const verb = policy.command === "SELECT" ? "read" : "act on";
936
998
  const severity = gate ? "medium" : "critical";
937
999
  findings.push(finding({
938
- id: ID$7,
1000
+ id: ID$8,
939
1001
  severity,
940
1002
  confidence: gate ? "heuristic" : "certain",
941
1003
  title: `Policy "${policy.name}" on ${policy.schema}.${policy.table} is ${listAnd(clauses)} (true) for ${listAnd(exposed)}`,
@@ -946,7 +1008,7 @@ var policyAlwaysTrue = {
946
1008
  },
947
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.` : ""),
948
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.`,
949
- fix: `-- 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)};`
1011
+ 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)};`
950
1012
  }));
951
1013
  }
952
1014
  return findings;
@@ -957,8 +1019,30 @@ function restrictiveGate(snapshot, policy) {
957
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;
958
1020
  }
959
1021
  //#endregion
1022
+ //#region src/types.ts
1023
+ /**
1024
+ * The contract between the three layers of `rls-check`:
1025
+ *
1026
+ * introspect.ts — reads the catalogs into a {@link DbSnapshot}. Talks to Postgres.
1027
+ * checks/*.ts — pure functions, snapshot in, {@link Finding}s out. No I/O.
1028
+ * report.ts — Findings to text or JSON. No knowledge of Postgres.
1029
+ *
1030
+ * Checks being pure is the point: every one of them is unit-testable against a
1031
+ * hand-written snapshot, so the test suite does not need a live database to
1032
+ * cover the interesting cases (it has a Docker-backed suite too, for the
1033
+ * introspection layer, which is the only part that can lie).
1034
+ */
1035
+ /** Ordered least → most severe; the CLI's `--fail-on` compares by index. */
1036
+ var SEVERITIES = [
1037
+ "info",
1038
+ "low",
1039
+ "medium",
1040
+ "high",
1041
+ "critical"
1042
+ ];
1043
+ //#endregion
960
1044
  //#region src/checks/policy-anonymous-tautology.ts
961
- var ID$6 = "policy-anonymous-tautology";
1045
+ var ID$7 = "policy-anonymous-tautology";
962
1046
  /**
963
1047
  * `auth.uid() IS NOT NULL` and its relatives.
964
1048
  *
@@ -977,17 +1061,18 @@ var ID$6 = "policy-anonymous-tautology";
977
1061
  * - Anything else: it comes down to whether the stack coerces, which the
978
1062
  * database cannot tell us. `medium`, and say so out loud.
979
1063
  *
980
- * A `<> 'anonymous'` guard alongside the null test is the corrected form and
981
- * clears the policy entirely.
1064
+ * A guard that excludes the sentinel is the corrected form and clears the policy.
1065
+ * A guard that excludes *something else* is the interesting case — see
1066
+ * {@link matchTautology}.
982
1067
  */
983
1068
  var policyAnonymousTautology = {
984
- id: ID$6,
1069
+ id: ID$7,
985
1070
  title: "Policy only checks that a caller id exists",
986
1071
  description: "A policy whose expression is `auth.uid() IS NOT NULL`-shaped: it separates signed-in from signed-out callers but scopes no rows.",
987
1072
  run(snapshot) {
988
1073
  const uidCall = callerIdCall(snapshot);
989
1074
  const findings = [];
990
- const { severity, meaning, impactSuffix } = platformReading(snapshot.platform);
1075
+ const { severity: baseSeverity, meaning, impactSuffix } = platformReading(snapshot.platform);
991
1076
  for (const policy of snapshot.policies) {
992
1077
  if (!snapshot.schemas.includes(policy.schema)) continue;
993
1078
  if (!policy.permissive) continue;
@@ -998,25 +1083,43 @@ var policyAnonymousTautology = {
998
1083
  if (usingMatch) clauses.push("USING");
999
1084
  if (checkMatch) clauses.push("WITH CHECK");
1000
1085
  if (clauses.length === 0) continue;
1001
- const shape = usingMatch ?? checkMatch ?? "the caller id";
1086
+ const shape = usingMatch?.shape ?? checkMatch?.shape ?? "the caller id";
1087
+ const decoys = [.../* @__PURE__ */ new Set([...usingMatch?.decoyGuards ?? [], ...checkMatch?.decoyGuards ?? []])];
1088
+ const severity = forCommand(baseSeverity, policy.command);
1089
+ const written = decoys.map((d) => `\`${shape} <> '${d}'\``);
1002
1090
  findings.push(finding({
1003
- id: ID$6,
1091
+ id: ID$7,
1004
1092
  severity,
1005
1093
  confidence: "heuristic",
1006
- title: `Policy "${policy.name}" on ${policy.schema}.${policy.table} only checks that ${shape} is not null`,
1094
+ title: decoys.length > 0 ? `Policy "${policy.name}" on ${policy.schema}.${policy.table} excludes ${listAnd(decoys.map((d) => `'${d}'`))}, which is not the anonymous sentinel` : `Policy "${policy.name}" on ${policy.schema}.${policy.table} only checks that ${shape} is not null`,
1007
1095
  target: {
1008
1096
  schema: policy.schema,
1009
1097
  table: policy.table,
1010
1098
  policy: policy.name
1011
1099
  },
1012
- detail: `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}`,
1013
- 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}`,
1014
- fix: `-- 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", make that explicit and make it\n-- reject the anonymous sentinel your stack may use:\n-- USING (${uidCall} IS NOT NULL AND ${uidCall} <> 'anonymous');`
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.` : ""),
1101
+ 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
+ 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']));`
1015
1103
  }));
1016
1104
  }
1017
1105
  return findings;
1018
1106
  }
1019
1107
  };
1108
+ /**
1109
+ * Writes are worse than reads.
1110
+ *
1111
+ * The platform decides whether this expression is a bypass at all; the command
1112
+ * decides what it costs when it is. A `FOR ALL` policy in this shape governed
1113
+ * `UPDATE` and `DELETE` on a live users table — an anonymous PATCH setting
1114
+ * `roles: ["admin"]` was reachable through it — while the same predicate under
1115
+ * `FOR SELECT` would only have leaked. One step, not two: the platform reading
1116
+ * is still the dominant term.
1117
+ */
1118
+ function forCommand(base, command) {
1119
+ if (command !== "ALL" && command !== "UPDATE" && command !== "DELETE") return base;
1120
+ return SEVERITIES[Math.min(SEVERITIES.indexOf(base) + 1, SEVERITIES.length - 1)];
1121
+ }
1122
+ var describeSentinel = (s) => s === "" ? "the empty string" : `'${s}'`;
1020
1123
  function platformReading(platform) {
1021
1124
  switch (platform) {
1022
1125
  case "supabase": return {
@@ -1069,27 +1172,250 @@ var CALLER_ID_CALLS = [
1069
1172
  }
1070
1173
  ];
1071
1174
  /**
1072
- * Recognise `<caller-id expression> IS NOT NULL` and name what was tested but
1073
- * only when that is the *entire* expression.
1175
+ * The ids that a signed-out caller can actually arrive with, so excluding one of
1176
+ * them is a real guard.
1074
1177
  *
1075
- * The "entire" part is what keeps this check honest. `auth.uid() IS NOT NULL AND
1076
- * user_id = auth.uid()` contains the shape and is a perfectly scoped policy; a
1077
- * substring match would flag it, and flagging correct Supabase policies is the
1078
- * fastest way to get this tool deleted. So the caller-id call is substituted for
1079
- * a placeholder, casts, parens, `SELECT` wrappers and whitespace are stripped,
1080
- * and what remains has to be exactly `<placeholder> is not null`.
1178
+ * `'anonymous'` is Rebase's `ANONYMOUS_USER_ID`; the empty string is what a
1179
+ * PostgREST-shaped stack leaves an unset claim as. Deliberately **not** the whole
1180
+ * of Rebase's `ANONYMOUS_USER_IDS`: that list also carries `'anon'`, the id the
1181
+ * request path reported before the sentinel was unified, and a policy excluding
1182
+ * only `'anon'` does not exclude anyone on any server shipping today. Treating
1183
+ * `'anon'` as clearing is exactly the mistake this check now exists to catch.
1081
1184
  */
1082
- function matchTautology(clause) {
1185
+ var CLEARING_SENTINELS = ["anonymous", ""];
1186
+ /**
1187
+ * Recognise "the policy tests that a caller id exists, and nothing that narrows
1188
+ * which rows" — and report which no-op guards it wears while doing so.
1189
+ *
1190
+ * Two rules keep this honest.
1191
+ *
1192
+ * **Every conjunct has to be accounted for.** `auth.uid() IS NOT NULL AND user_id
1193
+ * = auth.uid()` contains the shape and is a perfectly scoped policy; a substring
1194
+ * match would flag it, and flagging correct Supabase policies is the fastest way
1195
+ * to get this tool deleted. So the expression is split on `AND`, each conjunct is
1196
+ * classified, and a single conjunct this function does not recognise means it
1197
+ * stays quiet. An `OR` anywhere means the same — the shape no longer describes
1198
+ * what the policy admits.
1199
+ *
1200
+ * **A guard only clears if it excludes an id somebody can actually arrive with.**
1201
+ * The version before this one bailed on the literal string `<> 'anonymous'`, which
1202
+ * meant `<> 'anon'` fell past the bail and then failed to match the bare-null-test
1203
+ * shape, so the function returned null and the check said nothing at all. That is
1204
+ * not a near miss: it is the precise predicate that left a production `users`
1205
+ * table — password hashes included — readable by the entire internet for three and
1206
+ * a half weeks, and this tool was run against that database and reported clean.
1207
+ * A guard naming the wrong literal is now the *loudest* case, not the silent one,
1208
+ * because it is the one that survives code review.
1209
+ */
1210
+ function callerIdOnlyClause(clause) {
1083
1211
  if (!clause) return null;
1084
- const flat = clause.toLowerCase().replace(/\s+/g, " ");
1085
- if (/(<>|!=)\s*'anonymous'/.test(flat) || /(<>|!=)\s*''/.test(flat)) return null;
1212
+ const flat = clause.toLowerCase().replace(/::\s*[a-z0-9_]+(?:\s*\[\s*\])*/g, "").replace(/\s+/g, " ").trim();
1086
1213
  for (const { re, label } of CALLER_ID_CALLS) {
1087
1214
  const first = new RegExp(re.source).exec(flat);
1088
1215
  if (!first) continue;
1089
- if (flat.replace(new RegExp(re.source, "g"), "callerid").replace(/::[a-z0-9_[\]]+/g, "").replace(/\bselect\b/g, "").replace(/\bas [a-z0-9_]+/g, "").replace(/[()\s]/g, "") === "calleridisnotnull") return label(first);
1216
+ const substituted = flat.replace(new RegExp(re.source, "g"), " callerid ");
1217
+ if (/\bor\b/.test(substituted)) return null;
1218
+ let sawNullTest = false;
1219
+ const decoys = [];
1220
+ let cleared = false;
1221
+ for (const raw of splitTopLevelAnd(substituted)) {
1222
+ const conjunct = canon(raw);
1223
+ if (conjunct === "") continue;
1224
+ if (conjunct === "callerid is not null") {
1225
+ sawNullTest = true;
1226
+ continue;
1227
+ }
1228
+ const excluded = excludedLiterals(conjunct);
1229
+ if (!excluded) return null;
1230
+ if (excluded.some((lit) => CLEARING_SENTINELS.includes(lit))) cleared = true;
1231
+ decoys.push(...excluded);
1232
+ }
1233
+ if (!sawNullTest) continue;
1234
+ if (cleared) return {
1235
+ shape: label(first),
1236
+ decoyGuards: [...new Set(decoys)],
1237
+ guardsSentinel: true
1238
+ };
1239
+ return {
1240
+ shape: label(first),
1241
+ decoyGuards: [...new Set(decoys)],
1242
+ guardsSentinel: false
1243
+ };
1090
1244
  }
1091
1245
  return null;
1092
1246
  }
1247
+ /**
1248
+ * The subset of {@link callerIdOnlyClause} that *this* check reports: a bare
1249
+ * null test wearing no guard that excludes a real signed-out id.
1250
+ *
1251
+ * A clause that does exclude the sentinel is not clean, it is a different
1252
+ * finding with a different severity and a different fix, and
1253
+ * `policy-authenticated-tautology` reports it from the same parse.
1254
+ */
1255
+ function matchTautology(clause) {
1256
+ const matched = callerIdOnlyClause(clause);
1257
+ return matched && !matched.guardsSentinel ? matched : null;
1258
+ }
1259
+ /**
1260
+ * Split on `AND`, counting parens.
1261
+ *
1262
+ * A plain `.split(/\band\b/)` is what the first attempt used and it does not
1263
+ * survive contact with Postgres, which reads an expression back fully
1264
+ * parenthesised: `((uid() IS NOT NULL) AND (uid() <> 'anon'))` splits into two
1265
+ * fragments with unbalanced parens, neither of which matches anything, so the
1266
+ * check goes quiet on precisely the input it exists for. Depth is tracked, quoted
1267
+ * literals are skipped so an `and` inside a string is not a separator, and each
1268
+ * part is re-split so nested conjunctions flatten.
1269
+ */
1270
+ function splitTopLevelAnd(expr) {
1271
+ const s = peel(expr);
1272
+ const parts = [];
1273
+ let depth = 0;
1274
+ let inQuote = false;
1275
+ let start = 0;
1276
+ for (let i = 0; i < s.length; i++) {
1277
+ const ch = s[i];
1278
+ if (ch === "'") {
1279
+ inQuote = !inQuote;
1280
+ continue;
1281
+ }
1282
+ if (inQuote) continue;
1283
+ if (ch === "(") depth++;
1284
+ else if (ch === ")") depth--;
1285
+ else if (depth === 0 && s.startsWith("and", i) && isWord(s, i, 3)) {
1286
+ parts.push(s.slice(start, i));
1287
+ i += 2;
1288
+ start = i + 1;
1289
+ }
1290
+ }
1291
+ parts.push(s.slice(start));
1292
+ return parts.length === 1 ? parts : parts.flatMap(splitTopLevelAnd);
1293
+ }
1294
+ /** `and` as a whole word, not the tail of `brand` or the head of `android`. */
1295
+ function isWord(s, at, length) {
1296
+ const before = at === 0 ? "" : s[at - 1];
1297
+ const after = s[at + length] ?? "";
1298
+ return !/[a-z0-9_]/.test(before) && !/[a-z0-9_]/.test(after);
1299
+ }
1300
+ /** Remove balanced wrapping parens: `((x))` -> `x`, but `(a) and (b)` is left alone. */
1301
+ function peel(fragment) {
1302
+ let s = fragment.trim();
1303
+ while (s.startsWith("(") && s.endsWith(")") && balanced(s.slice(1, -1))) s = s.slice(1, -1).trim();
1304
+ return s;
1305
+ }
1306
+ /** Strip the noise Postgres adds when it rewrites an expression. */
1307
+ function canon(fragment) {
1308
+ let s = peel(fragment.replace(/\bselect\b/g, " ").replace(/\bas [a-z0-9_]+/g, " ").replace(/\s+/g, " "));
1309
+ let previous;
1310
+ do {
1311
+ previous = s;
1312
+ s = peel(s.replace(/\(\s*(callerid)\s*\)/g, "$1").trim());
1313
+ } while (s !== previous);
1314
+ return s.replace(/\s+/g, " ").trim();
1315
+ }
1316
+ function balanced(s) {
1317
+ let depth = 0;
1318
+ for (const ch of s) if (ch === "(") depth++;
1319
+ else if (ch === ")" && --depth < 0) return false;
1320
+ return depth === 0;
1321
+ }
1322
+ /**
1323
+ * The literals a conjunct excludes the caller id from, or `null` if the conjunct
1324
+ * is not an exclusion at all.
1325
+ *
1326
+ * `null` is the conservative answer and the common one: `user_id = callerid`
1327
+ * lands here and silences the whole check, which is correct, because that
1328
+ * conjunct scopes rows.
1329
+ */
1330
+ function excludedLiterals(conjunct) {
1331
+ let m = /^callerid (?:<>|!=) '([^']*)'$/.exec(conjunct);
1332
+ if (m) return [m[1]];
1333
+ m = /^'([^']*)' (?:<>|!=) callerid$/.exec(conjunct);
1334
+ if (m) return [m[1]];
1335
+ m = /^callerid (?:<>|!=) all \( ?array \[(.*)\] ?\)$/.exec(conjunct);
1336
+ if (m) return literalList(m[1]);
1337
+ m = /^callerid not in \((.*)\)$/.exec(conjunct);
1338
+ if (m) return literalList(m[1]);
1339
+ return null;
1340
+ }
1341
+ /** `'a', 'b'` -> `["a", "b"]`; `null` if any element is not a plain literal. */
1342
+ function literalList(inner) {
1343
+ const out = [];
1344
+ for (const part of inner.split(",")) {
1345
+ const m = /^ ?'([^']*)' ?$/.exec(part);
1346
+ if (!m) return null;
1347
+ out.push(m[1]);
1348
+ }
1349
+ return out.length > 0 ? out : null;
1350
+ }
1351
+ //#endregion
1352
+ //#region src/checks/policy-authenticated-tautology.ts
1353
+ var ID$6 = "policy-authenticated-tautology";
1354
+ /**
1355
+ * `auth.uid() IS NOT NULL AND auth.uid() <> 'anonymous'` — and nothing else.
1356
+ *
1357
+ * This is the *corrected* form of the anonymous tautology, and correcting that
1358
+ * one is where people stop. It genuinely does exclude signed-out callers. What
1359
+ * it does not do is scope any rows: what remains is "every registered account
1360
+ * may read every row of this table", which is a different sentence from the one
1361
+ * the person writing it usually means.
1362
+ *
1363
+ * It is the shape that leaked a customer's `users` table — every email address
1364
+ * on the platform, and the columns beside them, readable by anyone who could
1365
+ * sign up, which on a product with open registration is anyone at all. The
1366
+ * scanner watched for the anonymous form and treated the sentinel guard as a
1367
+ * clean bill of health, so the policy that actually shipped passed silently.
1368
+ *
1369
+ * `high`, not `critical`: it costs an account. On a table with open
1370
+ * registration that is a formality, and the wording says so rather than
1371
+ * pretending the distinction is comforting.
1372
+ *
1373
+ * Not folded into {@link policyAnonymousTautology}: check ids appear in
1374
+ * `--skip`, in CI baselines and in people's runbooks, so two findings with
1375
+ * different fixes and different severities have to be two ids. Someone who has
1376
+ * decided their `countries` table really is world-readable should be able to
1377
+ * silence that without also silencing "signed-out callers can read it".
1378
+ */
1379
+ var policyAuthenticatedTautology = {
1380
+ id: ID$6,
1381
+ title: "Policy admits every signed-in caller to every row",
1382
+ description: "A policy whose expression is only \"the caller is signed in and not anonymous\": it excludes signed-out callers correctly and scopes no rows between accounts.",
1383
+ run(snapshot) {
1384
+ const uidCall = callerIdCall(snapshot);
1385
+ const findings = [];
1386
+ for (const policy of snapshot.policies) {
1387
+ if (!snapshot.schemas.includes(policy.schema)) continue;
1388
+ if (!policy.permissive) continue;
1389
+ if (policyTargetsExposedRole(snapshot, policy).length === 0) continue;
1390
+ const usingMatch = callerIdOnlyClause(policy.using);
1391
+ const checkMatch = callerIdOnlyClause(policy.withCheck);
1392
+ const clauses = [];
1393
+ if (usingMatch?.guardsSentinel) clauses.push("USING");
1394
+ if (checkMatch?.guardsSentinel) clauses.push("WITH CHECK");
1395
+ if (clauses.length === 0) continue;
1396
+ const shape = (usingMatch?.guardsSentinel ? usingMatch.shape : checkMatch?.shape) ?? "the caller id";
1397
+ findings.push(finding({
1398
+ id: ID$6,
1399
+ severity: "high",
1400
+ confidence: "heuristic",
1401
+ title: `Policy "${policy.name}" on ${policy.schema}.${policy.table} admits every signed-in caller to every row`,
1402
+ target: {
1403
+ schema: policy.schema,
1404
+ table: policy.table,
1405
+ policy: policy.name
1406
+ },
1407
+ detail: `The ${listAnd(clauses)} expression of this ${policy.command} policy tests that ${shape} exists and is not the anonymous sentinel, and tests nothing else. That correctly excludes signed-out callers — and it compares nothing to a column, so every row of the table satisfies it equally for every account that does sign in.`,
1408
+ impact: "Any user with an account reaches every row this policy covers, including rows belonging to other users and other tenants. Where registration is open, \"any user with an account\" is anybody who fills in a form. This is the shape that makes a `users` table — every address on the platform — readable by its own members.",
1409
+ 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
+ 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
+ -- 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
1413
+ -- skip the finding: rls-check --skip ${ID$6}`
1414
+ }));
1415
+ }
1416
+ return findings;
1417
+ }
1418
+ };
1093
1419
  //#endregion
1094
1420
  //#region src/checks/policy-role-unreachable.ts
1095
1421
  var ID$5 = "policy-role-unreachable";
@@ -1134,7 +1460,7 @@ var policyRoleUnreachable = {
1134
1460
  },
1135
1461
  detail: `Row-level security is enabled on this table and every policy names only ${named.join(", ")}. ` + (missing.length > 0 ? `${missing.join(", ")} ${missing.length === 1 ? "does" : "do"} not exist in pg_roles at all. ` : "") + `None of these roles can log in, and no login role is a member of ${named.length === 1 ? "it" : "any of them"}, so no session ever has these policies applied. RLS with no applicable policy denies every row.`,
1136
1462
  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"}.`,
1137
- fix: `-- Point the policies at the role your requests actually arrive as. Confirm it with:
1463
+ 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:
1138
1464
  -- SELECT current_user; -- run this from your application's connection
1139
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>;`
1140
1466
  }));
@@ -1398,7 +1724,7 @@ var unqualifiedColumnInSubquery = {
1398
1724
  for (const clause of ["USING", "WITH CHECK"]) {
1399
1725
  const expr = clause === "USING" ? policy.using : policy.withCheck;
1400
1726
  if (!expr) continue;
1401
- for (const hit of scanExpression(snapshot, outer, expr)) findings.push(buildFinding(policy, outer, clause, hit));
1727
+ for (const hit of scanExpression(snapshot, outer, expr)) findings.push(buildFinding(snapshot, policy, outer, clause, hit));
1402
1728
  }
1403
1729
  }
1404
1730
  return findings;
@@ -1595,7 +1921,7 @@ function resolveRelation(snapshot, policySchema, item) {
1595
1921
  const candidates = snapshot.relations.filter((r) => r.name === item.name && snapshot.schemas.includes(r.schema));
1596
1922
  return candidates.length === 1 ? candidates[0] : void 0;
1597
1923
  }
1598
- function buildFinding(policy, outer, clause, hit) {
1924
+ function buildFinding(snapshot, policy, outer, clause, hit) {
1599
1925
  return finding({
1600
1926
  id: ID,
1601
1927
  severity: "high",
@@ -1609,7 +1935,7 @@ function buildFinding(policy, outer, clause, hit) {
1609
1935
  },
1610
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.`,
1611
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.",
1612
- fix: `-- 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.`
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.`
1613
1939
  });
1614
1940
  }
1615
1941
  //#endregion
@@ -1618,6 +1944,7 @@ var CHECKS = [
1618
1944
  rlsDisabled,
1619
1945
  policyAlwaysTrue,
1620
1946
  policyAnonymousTautology,
1947
+ policyAuthenticatedTautology,
1621
1948
  viewBypassesRls,
1622
1949
  matviewBypassesRls,
1623
1950
  anonymousWriteAllowed,
@@ -1672,6 +1999,225 @@ function sortFindings(snapshot, findings) {
1672
1999
  return a.target.schema.localeCompare(b.target.schema) || (a.target.table ?? "").localeCompare(b.target.table ?? "") || a.id.localeCompare(b.id) || a.title.localeCompare(b.title);
1673
2000
  });
1674
2001
  }
2002
+ /** Query parameters worth keeping in the redacted form: informative, never secret. */
2003
+ var SAFE_PARAMS = /* @__PURE__ */ new Set([
2004
+ "sslmode",
2005
+ "application_name",
2006
+ "connect_timeout",
2007
+ "target_session_attrs"
2008
+ ]);
2009
+ function decode(value) {
2010
+ try {
2011
+ return decodeURIComponent(value);
2012
+ } catch {
2013
+ return value;
2014
+ }
2015
+ }
2016
+ function splitHostPort(hostport) {
2017
+ if (hostport.startsWith("[")) {
2018
+ const close = hostport.indexOf("]");
2019
+ if (close !== -1) {
2020
+ const host = hostport.slice(0, close + 1);
2021
+ const rest = hostport.slice(close + 1);
2022
+ const port = rest.startsWith(":") ? Number.parseInt(rest.slice(1), 10) : NaN;
2023
+ return {
2024
+ host,
2025
+ port: Number.isFinite(port) ? port : null
2026
+ };
2027
+ }
2028
+ }
2029
+ const colon = hostport.lastIndexOf(":");
2030
+ if (colon === -1) return {
2031
+ host: hostport,
2032
+ port: null
2033
+ };
2034
+ const port = Number.parseInt(hostport.slice(colon + 1), 10);
2035
+ if (!Number.isFinite(port)) return {
2036
+ host: hostport,
2037
+ port: null
2038
+ };
2039
+ return {
2040
+ host: hostport.slice(0, colon),
2041
+ port
2042
+ };
2043
+ }
2044
+ /**
2045
+ * The libpq keyword/value form (`host=… dbname=…`) as a map of lowercased
2046
+ * keyword to unquoted value, or `null` when the string is not that form.
2047
+ *
2048
+ * Exported because `pg` cannot read this form at all: `pg-connection-string`
2049
+ * only understands URLs, so a `Client({ connectionString: "host=127.0.0.1
2050
+ * port=1 …" })` connects to the *default* host and reports a failure against an
2051
+ * endpoint nobody asked for. Whoever opens the connection has to translate
2052
+ * these keywords into `Client` options itself — see `connect()` in
2053
+ * `introspect.ts`.
2054
+ */
2055
+ function parseKeywordConnectionString(raw) {
2056
+ const trimmed = raw.trim();
2057
+ if (trimmed.length === 0) return null;
2058
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)) return null;
2059
+ const pairs = trimmed.match(/(\w+)\s*=\s*('(?:[^']|'')*'|[^\s]+)/g);
2060
+ if (!pairs || pairs.length === 0) return null;
2061
+ const map = /* @__PURE__ */ new Map();
2062
+ for (const pair of pairs) {
2063
+ const eq = pair.indexOf("=");
2064
+ const key = pair.slice(0, eq).trim().toLowerCase();
2065
+ let value = pair.slice(eq + 1).trim();
2066
+ if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) value = value.slice(1, -1).replace(/''/g, "'");
2067
+ map.set(key, value);
2068
+ }
2069
+ if (!map.has("host") && !map.has("dbname") && !map.has("user")) return null;
2070
+ return map;
2071
+ }
2072
+ function parseKeywordString(raw) {
2073
+ const map = parseKeywordConnectionString(raw);
2074
+ if (!map) return null;
2075
+ const port = map.has("port") ? Number.parseInt(map.get("port"), 10) : NaN;
2076
+ return {
2077
+ scheme: "postgresql",
2078
+ host: map.get("host") ?? "localhost",
2079
+ port: Number.isFinite(port) ? port : null,
2080
+ database: map.get("dbname") ?? "",
2081
+ user: map.get("user") ?? null,
2082
+ password: map.get("password") ?? null
2083
+ };
2084
+ }
2085
+ /**
2086
+ * A hostname, an IPv4 literal, a bracketed IPv6 literal, or a Unix socket path.
2087
+ * Anything else means the split went wrong.
2088
+ */
2089
+ var PLAUSIBLE_HOST = /^(\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9._-]+|\/[^\s?#@]*)$/;
2090
+ /** A database name in a URL: no delimiters, because they were not encoded. */
2091
+ var PLAUSIBLE_DATABASE = /^[^\s:@?#/]*$/;
2092
+ /**
2093
+ * Split a connection string into its parts. Returns `null` when the input is
2094
+ * not recognisably a connection string — callers must treat that as "unknown",
2095
+ * never as "safe to print".
2096
+ */
2097
+ function parseConnectionString(raw) {
2098
+ const trimmed = raw.trim();
2099
+ if (trimmed.length === 0) return null;
2100
+ const schemeMatch = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//.exec(trimmed);
2101
+ if (!schemeMatch) return parseKeywordString(trimmed);
2102
+ const scheme = schemeMatch[1];
2103
+ const afterScheme = trimmed.slice(schemeMatch[0].length);
2104
+ const pathStart = afterScheme.search(/[/?#]/);
2105
+ const authority = pathStart === -1 ? afterScheme : afterScheme.slice(0, pathStart);
2106
+ const remainder = pathStart === -1 ? "" : afterScheme.slice(pathStart);
2107
+ let user = null;
2108
+ let password = null;
2109
+ let hostport = authority;
2110
+ const at = authority.lastIndexOf("@");
2111
+ if (at !== -1) {
2112
+ const userinfo = authority.slice(0, at);
2113
+ hostport = authority.slice(at + 1);
2114
+ const colon = userinfo.indexOf(":");
2115
+ if (colon === -1) user = decode(userinfo);
2116
+ else {
2117
+ user = decode(userinfo.slice(0, colon));
2118
+ password = decode(userinfo.slice(colon + 1));
2119
+ }
2120
+ }
2121
+ const { host, port } = splitHostPort(hostport);
2122
+ let database = "";
2123
+ if (remainder.startsWith("/")) {
2124
+ const end = remainder.search(/[?#]/);
2125
+ database = decode(end === -1 ? remainder.slice(1) : remainder.slice(1, end));
2126
+ }
2127
+ const queryStart = remainder.search(/[?#]/);
2128
+ if (queryStart !== -1 && password === null) {
2129
+ const params = new URLSearchParams(remainder.slice(queryStart + 1));
2130
+ const fromQuery = params.get("password");
2131
+ if (fromQuery) password = fromQuery;
2132
+ if (user === null && params.get("user")) user = params.get("user");
2133
+ }
2134
+ const normalisedHost = host.length > 0 ? host : "localhost";
2135
+ if (!PLAUSIBLE_HOST.test(normalisedHost) || !PLAUSIBLE_DATABASE.test(database)) return null;
2136
+ return {
2137
+ scheme,
2138
+ host: normalisedHost,
2139
+ port,
2140
+ database,
2141
+ user,
2142
+ password
2143
+ };
2144
+ }
2145
+ /** `host:port` if a port is known, otherwise just the host. Never a credential. */
2146
+ function formatEndpoint(target) {
2147
+ if (!target) return "unknown host";
2148
+ return target.port === null ? target.host : `${target.host}:${target.port}`;
2149
+ }
2150
+ /**
2151
+ * The display form of a connection string: scheme, host, port and database
2152
+ * kept; user and password replaced. Safe to print anywhere.
2153
+ */
2154
+ function redactConnectionString(raw) {
2155
+ const target = parseConnectionString(raw);
2156
+ if (!target) return "<connection string>";
2157
+ const credential = target.user !== null || target.password !== null ? `***:***@` : "";
2158
+ const endpoint = formatEndpoint(target);
2159
+ const database = target.database.length > 0 ? `/${target.database}` : "";
2160
+ let query = "";
2161
+ const queryStart = raw.indexOf("?");
2162
+ if (queryStart !== -1) {
2163
+ const kept = [];
2164
+ for (const [key, value] of new URLSearchParams(raw.slice(queryStart + 1))) if (SAFE_PARAMS.has(key.toLowerCase())) kept.push(`${key}=${value}`);
2165
+ if (kept.length > 0) query = `?${kept.join("&")}`;
2166
+ }
2167
+ return `${target.scheme}://${credential}${endpoint}${database}${query}`;
2168
+ }
2169
+ function escapeRegExp(value) {
2170
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2171
+ }
2172
+ /**
2173
+ * Scrub credentials out of arbitrary text — driver error messages, stack
2174
+ * traces, anything we did not author. Two passes:
2175
+ *
2176
+ * 1. exact: the connection string we were given, and its password, wherever
2177
+ * they appear, however they were quoted;
2178
+ * 2. generic: any `scheme://user:pass@host` shaped substring, which catches
2179
+ * strings we were never told about (a `pg` error quoting `PGPASSWORD`, a
2180
+ * nested cause carrying another URL).
2181
+ *
2182
+ * The generic pass is what makes this safe by default: forgetting to pass
2183
+ * `connectionString` degrades the redaction, it does not disable it.
2184
+ */
2185
+ function redactSecrets(text, connectionString) {
2186
+ let out = text;
2187
+ if (connectionString && connectionString.trim().length > 0) {
2188
+ const raw = connectionString.trim();
2189
+ out = out.split(raw).join(redactConnectionString(raw));
2190
+ const target = parseConnectionString(raw);
2191
+ if (target?.user && target.user.length > 0) out = out.replace(new RegExp(`"${escapeRegExp(target.user)}"`, "g"), `"***"`);
2192
+ if (target?.password && target.password.length >= 3) {
2193
+ out = out.replace(new RegExp(escapeRegExp(target.password), "g"), "***");
2194
+ const encoded = encodeURIComponent(target.password);
2195
+ if (encoded !== target.password) out = out.replace(new RegExp(escapeRegExp(encoded), "g"), "***");
2196
+ }
2197
+ }
2198
+ out = out.replace(/\b([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^\s"'<>]*?)@/g, (_match, scheme) => `${scheme}://***:***@`);
2199
+ out = out.replace(/\bpassword\s*=\s*('(?:[^']|'')*'|"[^"]*"|\S+)/gi, `password=***`);
2200
+ return out;
2201
+ }
2202
+ /**
2203
+ * Is this endpoint a local proxy or tunnel rather than the database itself?
2204
+ *
2205
+ * Matters for diagnosis: cloud-sql-proxy, an SSH -L forward and a local pooler
2206
+ * all accept the TCP connection and then hang up when *their* upstream auth
2207
+ * fails, which reaches the client as a bare ECONNRESET. Advice about TLS is
2208
+ * wrong there — the proxy terminates TLS itself, and the real cause is only in
2209
+ * its log.
2210
+ *
2211
+ * Takes the display form produced by `formatEndpoint`, so `host`, `host:port`
2212
+ * and `[::1]:5432` all work.
2213
+ */
2214
+ function isLoopbackEndpoint(endpoint) {
2215
+ const { host } = splitHostPort(endpoint.trim());
2216
+ const bare = host.replace(/^\[|]$/g, "").toLowerCase();
2217
+ if (bare === "localhost" || bare.endsWith(".localhost")) return true;
2218
+ if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(bare)) return true;
2219
+ return bare === "::1" || bare === "0:0:0:0:0:0:0:1";
2220
+ }
1675
2221
  //#endregion
1676
2222
  //#region src/introspect.ts
1677
2223
  /**
@@ -1694,6 +2240,23 @@ function sortFindings(snapshot, findings) {
1694
2240
  * - Every optional query is guarded: a server that lacks a catalog degrades
1695
2241
  * that one fact, not the whole scan.
1696
2242
  */
2243
+ /**
2244
+ * A `--role` that is not in `pg_roles`.
2245
+ *
2246
+ * Same reasoning as an unknown `--skip` id: a typo here does not widen the scan
2247
+ * and get noticed, it *narrows* it silently. `exposedRolesFor` drops the name,
2248
+ * every check that gates on a grant to an exposed role stops matching, and the
2249
+ * run prints "No findings" on a database nobody looked at properly. So it is an
2250
+ * error, not a warning.
2251
+ */
2252
+ var UnknownRoleError = class extends Error {
2253
+ roles;
2254
+ constructor(roles) {
2255
+ super(`Unknown role${roles.length === 1 ? "" : "s"}: ${roles.join(", ")}.`);
2256
+ this.name = "UnknownRoleError";
2257
+ this.roles = roles;
2258
+ }
2259
+ };
1697
2260
  var SYSTEM_SCHEMAS = [
1698
2261
  "pg_catalog",
1699
2262
  "information_schema",
@@ -1803,15 +2366,74 @@ async function introspectWithDiagnostics(opts) {
1803
2366
  }
1804
2367
  }
1805
2368
  /**
2369
+ * libpq keywords this tool can express as `pg` `Client` options.
2370
+ *
2371
+ * The list is an allowlist rather than a "map what we know and drop the rest",
2372
+ * because every keyword left out changes where the connection goes or how it is
2373
+ * verified: dropping `sslrootcert` would connect with weaker verification than
2374
+ * was asked for, and dropping `hostaddr` would connect to a different machine.
2375
+ * Silently doing either in a security scanner is worse than refusing.
2376
+ */
2377
+ var SUPPORTED_KEYWORDS = /* @__PURE__ */ new Set([
2378
+ "host",
2379
+ "port",
2380
+ "dbname",
2381
+ "user",
2382
+ "password",
2383
+ "sslmode",
2384
+ "application_name",
2385
+ "connect_timeout",
2386
+ "options"
2387
+ ]);
2388
+ /**
2389
+ * The keywords in a libpq keyword string that this tool cannot honour, in the
2390
+ * order they were written. Empty for a URL, and empty for a keyword string it
2391
+ * can translate in full.
2392
+ */
2393
+ function unsupportedConnectionKeywords(connectionString) {
2394
+ const keywords = parseKeywordConnectionString(connectionString);
2395
+ if (!keywords) return [];
2396
+ return [...keywords.keys()].filter((keyword) => !SUPPORTED_KEYWORDS.has(keyword));
2397
+ }
2398
+ /**
2399
+ * `Client` options for a libpq keyword string. Throws on a keyword it cannot
2400
+ * honour. Exported for the test that pins the translation: getting `host` or
2401
+ * `port` wrong here sends a production scan somewhere nobody asked for, and the
2402
+ * failure would still be reported against the host the user typed.
2403
+ */
2404
+ function clientConfigFromKeywords(keywords) {
2405
+ const unsupported = [...keywords.keys()].filter((keyword) => !SUPPORTED_KEYWORDS.has(keyword));
2406
+ if (unsupported.length > 0) throw new Error(`Unsupported connection keyword${unsupported.length === 1 ? "" : "s"}: ${unsupported.join(", ")}. Use a postgresql:// URL instead.`);
2407
+ const port = keywords.has("port") ? Number.parseInt(keywords.get("port"), 10) : NaN;
2408
+ const timeoutSeconds = keywords.has("connect_timeout") ? Number.parseInt(keywords.get("connect_timeout"), 10) : NaN;
2409
+ const config = {
2410
+ host: keywords.get("host") ?? "localhost",
2411
+ database: keywords.get("dbname"),
2412
+ user: keywords.get("user"),
2413
+ password: keywords.get("password"),
2414
+ application_name: keywords.get("application_name"),
2415
+ options: keywords.get("options")
2416
+ };
2417
+ if (Number.isFinite(port)) config.port = port;
2418
+ if (Number.isFinite(timeoutSeconds)) config.connectionTimeoutMillis = timeoutSeconds * 1e3;
2419
+ return config;
2420
+ }
2421
+ /**
1806
2422
  * Connect, negotiating TLS the way libpq would.
1807
2423
  *
1808
2424
  * `pg` lets the connection string override an explicit `ssl` option, so the
1809
2425
  * `sslmode` parameter is read and removed before the attempts are built —
1810
2426
  * otherwise the retry would silently reuse the setting that just failed.
2427
+ *
2428
+ * `pg` also cannot read the libpq keyword form (`host=… dbname=…`) at all — it
2429
+ * would connect to the default host and then report the failure against the
2430
+ * host the user *did* name, which is the worst of both. So that form is
2431
+ * translated into explicit `Client` options here, or refused.
1811
2432
  */
1812
2433
  async function connect(connectionString) {
1813
- const sslmode = readParam(connectionString, "sslmode");
1814
- const cleaned = stripParam(connectionString, "sslmode");
2434
+ const keywords = parseKeywordConnectionString(connectionString);
2435
+ const sslmode = keywords ? keywords.get("sslmode")?.toLowerCase() : readParam(connectionString, "sslmode");
2436
+ const base = keywords ? clientConfigFromKeywords(keywords) : { connectionString: stripParam(connectionString, "sslmode") };
1815
2437
  let attempts;
1816
2438
  switch (sslmode) {
1817
2439
  case "disable":
@@ -1852,7 +2474,7 @@ async function connect(connectionString) {
1852
2474
  let lastError;
1853
2475
  for (const attempt of attempts) {
1854
2476
  const client = new Client({
1855
- connectionString: cleaned,
2477
+ ...base,
1856
2478
  ssl: attempt.ssl
1857
2479
  });
1858
2480
  try {
@@ -1919,19 +2541,23 @@ async function readSnapshot(client, opts, diagnostics) {
1919
2541
  const allSchemas = (await db.query("schema list", "SELECT nspname FROM pg_namespace ORDER BY nspname")).map((r) => r.nspname);
1920
2542
  const schemas = selectSchemas(allSchemas, opts.schemas, diagnostics);
1921
2543
  const roles = await readRoles(db);
2544
+ assertRolesExist(roles, opts.roles, diagnostics);
1922
2545
  const relations = await readRelations(db, schemas);
1923
2546
  const policies = await readPolicies(db, schemas);
1924
2547
  const grants = await readGrants(db, schemas);
1925
2548
  const views = await readViews(db, schemas, serverVersionNum, relations);
1926
2549
  const foreignKeys = await readForeignKeys(db, schemas);
1927
2550
  const routines = await readRoutines(db, schemas);
1928
- const exposedRoles = exposedRolesFor(roles, opts.roles);
2551
+ const scannerIsPrivileged = isPrivileged(currentRole, server, roles, relations);
2552
+ const connectingRole = !scannerIsPrivileged && currentRole !== "unknown" ? currentRole : void 0;
2553
+ const exposedRoles = exposedRolesFor(roles, opts.roles, connectingRole);
2554
+ diagnostics.scanningAsExposedRole = connectingRole && exposedRoles.includes(connectingRole) ? connectingRole : null;
1929
2555
  diagnostics.unrecognizedGrantees = unrecognizedGranteesFor(grants, roles, relations, exposedRoles, currentRole);
1930
2556
  return {
1931
2557
  serverVersionNum,
1932
2558
  serverVersion,
1933
2559
  currentRole,
1934
- scannerIsPrivileged: isPrivileged(currentRole, server, roles, relations),
2560
+ scannerIsPrivileged,
1935
2561
  schemas,
1936
2562
  exposedRoles,
1937
2563
  platform: detectPlatform(allSchemas, roles),
@@ -2263,14 +2889,37 @@ function detectPlatform(allSchemas, roles) {
2263
2889
  * `service_role` is excluded on purpose. It exists, and it is in `roles`, but it
2264
2890
  * is the *trusted* bypass identity: treating it as exposed would flag every
2265
2891
  * table in every Supabase project as critical, which is both useless and wrong.
2892
+ *
2893
+ * `connecting` is the role the scan came in as, passed only when it is not
2894
+ * privileged — see the call site.
2266
2895
  */
2267
- function exposedRolesFor(roles, explicit) {
2896
+ function exposedRolesFor(roles, explicit, connecting) {
2268
2897
  const present = new Set(roles.map((r) => r.name));
2269
- const named = [...CANDIDATE_EXPOSED_ROLES, ...explicit ?? []];
2898
+ const named = [
2899
+ ...CANDIDATE_EXPOSED_ROLES,
2900
+ ...explicit ?? [],
2901
+ ...connecting ? [connecting] : []
2902
+ ];
2270
2903
  return ["PUBLIC", ...new Set(named.filter((r) => present.has(r)))];
2271
2904
  }
2272
2905
  /**
2273
- * Roles that hold DML on a scanned table and are neither exposed nor trusted.
2906
+ * Refuse a `--role` that is not in `pg_roles`.
2907
+ *
2908
+ * Not a warning, for the reason spelled out on {@link UnknownRoleError}. The
2909
+ * one exception is a degraded `roles` read: with no catalogue to check against,
2910
+ * every name would look unknown, and turning a partial catalogue read into
2911
+ * "your flag is wrong" would send the reader after the wrong problem.
2912
+ */
2913
+ function assertRolesExist(roles, requested, diagnostics) {
2914
+ if (!requested || requested.length === 0) return;
2915
+ if (diagnostics.degraded.some((entry) => entry.what === "roles")) return;
2916
+ const present = new Set(roles.map((r) => r.name));
2917
+ const unknown = [...new Set(requested.filter((role) => !present.has(role)))];
2918
+ if (unknown.length > 0) throw new UnknownRoleError(unknown);
2919
+ }
2920
+ /**
2921
+ * Roles that can read or write a scanned table and are neither exposed nor
2922
+ * trusted.
2274
2923
  *
2275
2924
  * "Trusted" is deliberately generous — every role this can explain is one the
2276
2925
  * user does not have to think about. A grantee is explained when it is a
@@ -2279,8 +2928,15 @@ function exposedRolesFor(roles, explicit) {
2279
2928
  * connected as, or is platform bookkeeping (`pg_*`, `cloudsql*`, Supabase's
2280
2929
  * `service_role`, which is the documented trusted bypass).
2281
2930
  *
2282
- * What is left is the interesting set: a named role, holding write privileges,
2283
- * that this tool has no opinion about. It may be a service account nothing can
2931
+ * SELECT alone counts. It used to be filtered out as "a normal, deliberate
2932
+ * grant on a reference table", which reads the risk backwards: the finding this
2933
+ * whole tool exists for is `rls-disabled`, and what an RLS-disabled table
2934
+ * hands a role holding nothing but SELECT is every row in it. A read-only
2935
+ * reporting role reachable from the internet is the textbook leak, and the
2936
+ * caveat that exists to stop a false negative was skipping exactly that shape.
2937
+ *
2938
+ * What is left is the interesting set: a named role with data access that this
2939
+ * tool has no opinion about. It may be a service account nothing can
2284
2940
  * authenticate as — `rebase_user` is NOLOGIN by design — or it may be exactly
2285
2941
  * the role the API connects as. The scan cannot tell from the catalog, so it
2286
2942
  * names them and lets the reader decide.
@@ -2289,7 +2945,8 @@ function unrecognizedGranteesFor(grants, roles, relations, exposed, currentRole)
2289
2945
  const exposedSet = new Set(exposed.map((r) => r.toLowerCase()));
2290
2946
  const byName = new Map(roles.map((r) => [r.name.toLowerCase(), r]));
2291
2947
  const owners = new Set(relations.map((r) => r.owner.toLowerCase()));
2292
- const writeable = /* @__PURE__ */ new Set([
2948
+ const dataAccess = /* @__PURE__ */ new Set([
2949
+ "SELECT",
2293
2950
  "INSERT",
2294
2951
  "UPDATE",
2295
2952
  "DELETE"
@@ -2304,7 +2961,7 @@ function unrecognizedGranteesFor(grants, roles, relations, exposed, currentRole)
2304
2961
  if (key.startsWith("pg_") || key.startsWith("cloudsql") || key === "service_role") continue;
2305
2962
  const role = byName.get(key);
2306
2963
  if (role?.superuser || role?.bypassRls) continue;
2307
- if (!grant.privileges.some((p) => writeable.has(p))) continue;
2964
+ if (!grant.privileges.some((p) => dataAccess.has(p))) continue;
2308
2965
  out.add(name);
2309
2966
  }
2310
2967
  return [...out].sort();
@@ -2312,228 +2969,6 @@ function unrecognizedGranteesFor(grants, roles, relations, exposed, currentRole)
2312
2969
  function isPublicName(name) {
2313
2970
  return name.toUpperCase() === "PUBLIC";
2314
2971
  }
2315
- /** Query parameters worth keeping in the redacted form: informative, never secret. */
2316
- var SAFE_PARAMS = /* @__PURE__ */ new Set([
2317
- "sslmode",
2318
- "application_name",
2319
- "connect_timeout",
2320
- "target_session_attrs"
2321
- ]);
2322
- function decode(value) {
2323
- try {
2324
- return decodeURIComponent(value);
2325
- } catch {
2326
- return value;
2327
- }
2328
- }
2329
- function splitHostPort(hostport) {
2330
- if (hostport.startsWith("[")) {
2331
- const close = hostport.indexOf("]");
2332
- if (close !== -1) {
2333
- const host = hostport.slice(0, close + 1);
2334
- const rest = hostport.slice(close + 1);
2335
- const port = rest.startsWith(":") ? Number.parseInt(rest.slice(1), 10) : NaN;
2336
- return {
2337
- host,
2338
- port: Number.isFinite(port) ? port : null
2339
- };
2340
- }
2341
- }
2342
- const colon = hostport.lastIndexOf(":");
2343
- if (colon === -1) return {
2344
- host: hostport,
2345
- port: null
2346
- };
2347
- const port = Number.parseInt(hostport.slice(colon + 1), 10);
2348
- if (!Number.isFinite(port)) return {
2349
- host: hostport,
2350
- port: null
2351
- };
2352
- return {
2353
- host: hostport.slice(0, colon),
2354
- port
2355
- };
2356
- }
2357
- function parseKeywordString(raw) {
2358
- const pairs = raw.match(/(\w+)\s*=\s*('(?:[^']|'')*'|[^\s]+)/g);
2359
- if (!pairs || pairs.length === 0) return null;
2360
- const map = /* @__PURE__ */ new Map();
2361
- for (const pair of pairs) {
2362
- const eq = pair.indexOf("=");
2363
- const key = pair.slice(0, eq).trim().toLowerCase();
2364
- let value = pair.slice(eq + 1).trim();
2365
- if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) value = value.slice(1, -1).replace(/''/g, "'");
2366
- map.set(key, value);
2367
- }
2368
- if (!map.has("host") && !map.has("dbname") && !map.has("user")) return null;
2369
- const port = map.has("port") ? Number.parseInt(map.get("port"), 10) : NaN;
2370
- return {
2371
- scheme: "postgresql",
2372
- host: map.get("host") ?? "localhost",
2373
- port: Number.isFinite(port) ? port : null,
2374
- database: map.get("dbname") ?? "",
2375
- user: map.get("user") ?? null,
2376
- password: map.get("password") ?? null
2377
- };
2378
- }
2379
- /**
2380
- * A hostname, an IPv4 literal, a bracketed IPv6 literal, or a Unix socket path.
2381
- * Anything else means the split went wrong.
2382
- */
2383
- var PLAUSIBLE_HOST = /^(\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9._-]+|\/[^\s?#@]*)$/;
2384
- /** A database name in a URL: no delimiters, because they were not encoded. */
2385
- var PLAUSIBLE_DATABASE = /^[^\s:@?#/]*$/;
2386
- /**
2387
- * Split a connection string into its parts. Returns `null` when the input is
2388
- * not recognisably a connection string — callers must treat that as "unknown",
2389
- * never as "safe to print".
2390
- */
2391
- function parseConnectionString(raw) {
2392
- const trimmed = raw.trim();
2393
- if (trimmed.length === 0) return null;
2394
- const schemeMatch = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//.exec(trimmed);
2395
- if (!schemeMatch) return parseKeywordString(trimmed);
2396
- const scheme = schemeMatch[1];
2397
- const afterScheme = trimmed.slice(schemeMatch[0].length);
2398
- const pathStart = afterScheme.search(/[/?#]/);
2399
- const authority = pathStart === -1 ? afterScheme : afterScheme.slice(0, pathStart);
2400
- const remainder = pathStart === -1 ? "" : afterScheme.slice(pathStart);
2401
- let user = null;
2402
- let password = null;
2403
- let hostport = authority;
2404
- const at = authority.lastIndexOf("@");
2405
- if (at !== -1) {
2406
- const userinfo = authority.slice(0, at);
2407
- hostport = authority.slice(at + 1);
2408
- const colon = userinfo.indexOf(":");
2409
- if (colon === -1) user = decode(userinfo);
2410
- else {
2411
- user = decode(userinfo.slice(0, colon));
2412
- password = decode(userinfo.slice(colon + 1));
2413
- }
2414
- }
2415
- const { host, port } = splitHostPort(hostport);
2416
- let database = "";
2417
- if (remainder.startsWith("/")) {
2418
- const end = remainder.search(/[?#]/);
2419
- database = decode(end === -1 ? remainder.slice(1) : remainder.slice(1, end));
2420
- }
2421
- const queryStart = remainder.search(/[?#]/);
2422
- if (queryStart !== -1 && password === null) {
2423
- const params = new URLSearchParams(remainder.slice(queryStart + 1));
2424
- const fromQuery = params.get("password");
2425
- if (fromQuery) password = fromQuery;
2426
- if (user === null && params.get("user")) user = params.get("user");
2427
- }
2428
- const normalisedHost = host.length > 0 ? host : "localhost";
2429
- if (!PLAUSIBLE_HOST.test(normalisedHost) || !PLAUSIBLE_DATABASE.test(database)) return null;
2430
- return {
2431
- scheme,
2432
- host: normalisedHost,
2433
- port,
2434
- database,
2435
- user,
2436
- password
2437
- };
2438
- }
2439
- /** `host:port` if a port is known, otherwise just the host. Never a credential. */
2440
- function formatEndpoint(target) {
2441
- if (!target) return "unknown host";
2442
- return target.port === null ? target.host : `${target.host}:${target.port}`;
2443
- }
2444
- /**
2445
- * The display form of a connection string: scheme, host, port and database
2446
- * kept; user and password replaced. Safe to print anywhere.
2447
- */
2448
- function redactConnectionString(raw) {
2449
- const target = parseConnectionString(raw);
2450
- if (!target) return "<connection string>";
2451
- const credential = target.user !== null || target.password !== null ? `***:***@` : "";
2452
- const endpoint = formatEndpoint(target);
2453
- const database = target.database.length > 0 ? `/${target.database}` : "";
2454
- let query = "";
2455
- const queryStart = raw.indexOf("?");
2456
- if (queryStart !== -1) {
2457
- const kept = [];
2458
- for (const [key, value] of new URLSearchParams(raw.slice(queryStart + 1))) if (SAFE_PARAMS.has(key.toLowerCase())) kept.push(`${key}=${value}`);
2459
- if (kept.length > 0) query = `?${kept.join("&")}`;
2460
- }
2461
- return `${target.scheme}://${credential}${endpoint}${database}${query}`;
2462
- }
2463
- function escapeRegExp(value) {
2464
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2465
- }
2466
- /**
2467
- * Scrub credentials out of arbitrary text — driver error messages, stack
2468
- * traces, anything we did not author. Two passes:
2469
- *
2470
- * 1. exact: the connection string we were given, and its password, wherever
2471
- * they appear, however they were quoted;
2472
- * 2. generic: any `scheme://user:pass@host` shaped substring, which catches
2473
- * strings we were never told about (a `pg` error quoting `PGPASSWORD`, a
2474
- * nested cause carrying another URL).
2475
- *
2476
- * The generic pass is what makes this safe by default: forgetting to pass
2477
- * `connectionString` degrades the redaction, it does not disable it.
2478
- */
2479
- function redactSecrets(text, connectionString) {
2480
- let out = text;
2481
- if (connectionString && connectionString.trim().length > 0) {
2482
- const raw = connectionString.trim();
2483
- out = out.split(raw).join(redactConnectionString(raw));
2484
- const target = parseConnectionString(raw);
2485
- if (target?.user && target.user.length > 0) out = out.replace(new RegExp(`"${escapeRegExp(target.user)}"`, "g"), `"***"`);
2486
- if (target?.password && target.password.length >= 3) {
2487
- out = out.replace(new RegExp(escapeRegExp(target.password), "g"), "***");
2488
- const encoded = encodeURIComponent(target.password);
2489
- if (encoded !== target.password) out = out.replace(new RegExp(escapeRegExp(encoded), "g"), "***");
2490
- }
2491
- }
2492
- out = out.replace(/\b([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^\s"'<>]*?)@/g, (_match, scheme) => `${scheme}://***:***@`);
2493
- out = out.replace(/\bpassword\s*=\s*('(?:[^']|'')*'|"[^"]*"|\S+)/gi, `password=***`);
2494
- return out;
2495
- }
2496
- /**
2497
- * Is this endpoint a local proxy or tunnel rather than the database itself?
2498
- *
2499
- * Matters for diagnosis: cloud-sql-proxy, an SSH -L forward and a local pooler
2500
- * all accept the TCP connection and then hang up when *their* upstream auth
2501
- * fails, which reaches the client as a bare ECONNRESET. Advice about TLS is
2502
- * wrong there — the proxy terminates TLS itself, and the real cause is only in
2503
- * its log.
2504
- *
2505
- * Takes the display form produced by `formatEndpoint`, so `host`, `host:port`
2506
- * and `[::1]:5432` all work.
2507
- */
2508
- function isLoopbackEndpoint(endpoint) {
2509
- const { host } = splitHostPort(endpoint.trim());
2510
- const bare = host.replace(/^\[|]$/g, "").toLowerCase();
2511
- if (bare === "localhost" || bare.endsWith(".localhost")) return true;
2512
- if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(bare)) return true;
2513
- return bare === "::1" || bare === "0:0:0:0:0:0:0:1";
2514
- }
2515
- //#endregion
2516
- //#region src/types.ts
2517
- /**
2518
- * The contract between the three layers of `rls-check`:
2519
- *
2520
- * introspect.ts — reads the catalogs into a {@link DbSnapshot}. Talks to Postgres.
2521
- * checks/*.ts — pure functions, snapshot in, {@link Finding}s out. No I/O.
2522
- * report.ts — Findings to text or JSON. No knowledge of Postgres.
2523
- *
2524
- * Checks being pure is the point: every one of them is unit-testable against a
2525
- * hand-written snapshot, so the test suite does not need a live database to
2526
- * cover the interesting cases (it has a Docker-backed suite too, for the
2527
- * introspection layer, which is the only part that can lie).
2528
- */
2529
- /** Ordered least → most severe; the CLI's `--fail-on` compares by index. */
2530
- var SEVERITIES = [
2531
- "info",
2532
- "low",
2533
- "medium",
2534
- "high",
2535
- "critical"
2536
- ];
2537
2972
  //#endregion
2538
2973
  //#region src/report.ts
2539
2974
  /** Higher is worse. `info` = 0 … `critical` = 4. */
@@ -2675,6 +3110,11 @@ function renderReport(result, options) {
2675
3110
  out.push(...renderPrivilegeCaveat$1(style, width));
2676
3111
  out.push("");
2677
3112
  }
3113
+ const scanningAs = result.diagnostics?.scanningAsExposedRole;
3114
+ if (scanningAs) {
3115
+ out.push(...renderConnectingRoleNote(scanningAs, style, width));
3116
+ out.push("");
3117
+ }
2678
3118
  const degraded = result.diagnostics?.degraded ?? [];
2679
3119
  if (degraded.length > 0) {
2680
3120
  out.push(...renderDegradedCaveat$1(degraded, style, width));
@@ -2707,10 +3147,12 @@ function renderHeader(result, style, width, options) {
2707
3147
  out.push("");
2708
3148
  const endpoint = options.endpoint ?? result.database.host;
2709
3149
  const serverVersion = /^[\d.]/.test(result.serverVersion.trim()) ? `PostgreSQL ${result.serverVersion}` : result.serverVersion;
3150
+ const exposed = result.exposedRoles ?? [];
2710
3151
  const rows = [
2711
3152
  ["Database", `${endpoint}/${result.database.name}`],
2712
3153
  ["Server", serverVersion],
2713
3154
  ["Platform", PLATFORM_LABEL$1[result.platform]],
3155
+ ["Exposed", `${exposed.length > 0 ? exposed.join(", ") : "PUBLIC"} (add yours with --role)`],
2714
3156
  ["Scanned", [
2715
3157
  `${result.stats.schemas} ${plural$1(result.stats.schemas, "schema", "schemas")}`,
2716
3158
  `${result.stats.tables} ${plural$1(result.stats.tables, "table", "tables")}`,
@@ -2730,6 +3172,22 @@ function renderPrivilegeCaveat$1(style, width) {
2730
3172
  return out;
2731
3173
  }
2732
3174
  /**
3175
+ * The scan connected as a role row-level security constrains, so that role was
3176
+ * treated as exposed.
3177
+ *
3178
+ * The counterpart to {@link renderPrivilegeCaveat}: one says "nothing below
3179
+ * describes this connection", the other says "this connection is one of the
3180
+ * things below". Both are disclosures about what the exposed set contains,
3181
+ * which is the only reason any of the findings say what they say.
3182
+ */
3183
+ function renderConnectingRoleNote(role, style, width) {
3184
+ const body = wrap(`This scan connected as "${role}", which row-level security does constrain — no superuser, no BYPASSRLS, no ownership of the scanned tables. It has therefore been treated as a role an untrusted caller can arrive as, and the findings below include what it reaches. Scanning the same database as a privileged role will report a different set.`, width - 8);
3185
+ const out = [];
3186
+ out.push(`${style.yellow("Note")} ${body[0] ?? ""}`);
3187
+ for (const line of body.slice(1)) out.push(` ${line}`);
3188
+ return out;
3189
+ }
3190
+ /**
2733
3191
  * The caveat that keeps a false negative from reading as a pass.
2734
3192
  *
2735
3193
  * Deliberately not a finding: the scan has no evidence that any of these roles
@@ -2740,7 +3198,7 @@ function renderPrivilegeCaveat$1(style, width) {
2740
3198
  function renderUnrecognizedRolesCaveat(roles, style, width) {
2741
3199
  const shown = roles.slice(0, 6);
2742
3200
  const rest = roles.length - shown.length;
2743
- const body = wrap(`${shown.map((r) => `"${r}"`).join(", ") + (rest > 0 ? `, and ${rest} more` : "")} ${plural$1(roles.length, "holds", "hold")} write privileges here, and this scan does not know whether requests arrive as ${plural$1(roles.length, "it", "them")}. The checks only report a table as exposed when an exposed role can reach it, so anything served through ${plural$1(roles.length, "this role", "these roles")} was NOT assessed. If your application connects as one of them, re-run naming it — for example: --role ${shown[0] ?? "app_user"}`, width - 8);
3201
+ const body = wrap(`${shown.map((r) => `"${r}"`).join(", ") + (rest > 0 ? `, and ${rest} more` : "")} can read or write scanned tables here, and this scan does not know whether requests arrive as ${plural$1(roles.length, "it", "them")}. The checks only report a table as exposed when an exposed role can reach it, so anything served through ${plural$1(roles.length, "this role", "these roles")} was NOT assessed. If your application connects as one of them, re-run naming it — for example: --role ${shown[0] ?? "app_user"}`, width - 8);
2744
3202
  const out = [];
2745
3203
  out.push(`${style.yellow("Note")} ${body[0] ?? ""}`);
2746
3204
  for (const line of body.slice(1)) out.push(` ${line}`);
@@ -3228,6 +3686,7 @@ function buildScanResult(snapshot, findings, meta) {
3228
3686
  serverVersion: snapshot.serverVersion,
3229
3687
  platform: snapshot.platform,
3230
3688
  scannerIsPrivileged: snapshot.scannerIsPrivileged,
3689
+ exposedRoles: snapshot.exposedRoles,
3231
3690
  stats: {
3232
3691
  schemas: snapshot.schemas.length,
3233
3692
  tables: tables.length,
@@ -3509,7 +3968,7 @@ function explainError(error, context) {
3509
3968
  case "SELF_SIGNED_CERT_IN_CHAIN":
3510
3969
  case "UNABLE_TO_VERIFY_LEAF_SIGNATURE":
3511
3970
  case "ERR_TLS_CERT_ALTNAME_INVALID": return friendly(`The TLS certificate presented by ${at} could not be verified.`, "Append ?sslmode=no-verify to connect without verifying it, or point sslrootcert at your provider's CA bundle if you would rather keep verification on.");
3512
- case "28P01": return friendly(`Password authentication failed on ${at}.`, "Check the password. If it contains @ : / ? or #, it has to be percent-encoded inside a URL — that is the single most common cause of this error.");
3971
+ case "28P01": return friendly(`Password authentication failed on ${at}.`, "Check the password. If it contains / ? or #, it has to be percent-encoded inside a URL — that is the single most common cause of this error. An @ or a : needs no encoding: the userinfo is split at the last @ and the user at the first :.");
3513
3972
  case "28000": return friendly(`${at} refused the connection for this role.`, "Either the role does not exist, or pg_hba.conf has no rule matching this host, user and database combination.");
3514
3973
  case "3D000": return friendly("That database does not exist on the server.", "The database name is the last path segment of the connection string. On Supabase it is usually `postgres`.");
3515
3974
  case "42501": return friendly("The connected role is not allowed to read the catalogs this scan needs.", "Run it as the database owner or another role that can read pg_policies and pg_class. The scan itself only issues SELECTs.");
@@ -3542,11 +4001,16 @@ function helpText(version) {
3542
4001
  return `rls-check ${version} — audit Row-Level Security on any PostgreSQL database.
3543
4002
 
3544
4003
  Usage
3545
- npx @rebasepro/rls-check [connection-string] [options]
4004
+ DATABASE_URL="postgresql://user:pass@host:5432/db" npx @rebasepro/rls-check
3546
4005
 
3547
4006
  The connection string is taken from, in order: the argument, $DATABASE_URL,
3548
4007
  $POSTGRES_URL, then DATABASE_URL in a .env file in the current directory.
3549
4008
 
4009
+ Prefer the environment. A connection string passed as an argument is echoed back
4010
+ by npm before this program starts, and is written to your shell history — both
4011
+ with the password in it, and neither is something rls-check can redact after the
4012
+ fact. Its own output redacts the password everywhere.
4013
+
3550
4014
  Options
3551
4015
  --json Machine-readable ScanResult on stdout, and nothing else.
3552
4016
  --html <path> Also write a self-contained HTML report to <path>. One file,
@@ -3575,7 +4039,8 @@ Exit codes
3575
4039
  2 The scan did not run: bad arguments, connection refused, auth failed, timeout.
3576
4040
 
3577
4041
  Examples
3578
- npx @rebasepro/rls-check "postgresql://user:pass@db.abcdef.supabase.co:5432/postgres"
4042
+ DATABASE_URL="postgresql://user:pass@db.abcdef.supabase.co:5432/postgres" \\
4043
+ npx @rebasepro/rls-check
3579
4044
  npx @rebasepro/rls-check --schema public --schema billing --fail-on medium
3580
4045
  npx @rebasepro/rls-check --json > rls-report.json
3581
4046
  npx @rebasepro/rls-check --html rls-report.html
@@ -3587,10 +4052,12 @@ and sends nothing anywhere.
3587
4052
  function usageText() {
3588
4053
  return `rls-check needs a PostgreSQL connection string.
3589
4054
 
3590
- npx @rebasepro/rls-check "postgresql://user:password@host:5432/database"
4055
+ DATABASE_URL="postgresql://user:password@host:5432/database" npx @rebasepro/rls-check
3591
4056
 
3592
- Or set DATABASE_URL (or POSTGRES_URL) in the environment, or put DATABASE_URL in
3593
- a .env file in this directory.
4057
+ Or put DATABASE_URL in a .env file in this directory, or set POSTGRES_URL. It
4058
+ also accepts the string as an argument, but npm echoes the command line before
4059
+ this program starts and your shell records it, so the password ends up in two
4060
+ places rls-check cannot reach.
3594
4061
 
3595
4062
  It is read-only — it reads the system catalogs and writes nothing. Run with
3596
4063
  --help for all options.
@@ -3686,7 +4153,15 @@ async function runCli(argv, io = defaultIo()) {
3686
4153
  if (!target) {
3687
4154
  io.stderr(formatFriendlyError({
3688
4155
  headline: "That does not look like a PostgreSQL connection string.",
3689
- hint: "Expected postgresql://user:password@host:5432/database, or a libpq keyword string such as host=… dbname=…. If the password contains @ : / ? or #, percent-encode it otherwise the URL is ambiguous and neither this tool nor libpq can tell where the credential ends."
4156
+ hint: "Expected postgresql://user:password@host:5432/database, or a libpq keyword string carrying at least one of host= dbname= user= (port, password, sslmode, application_name, connect_timeout and options are honoured too). A password containing / ? or # is the usual cause: those end the authority, so the split lands inside the credential and neither this tool nor libpq can tell where it ends — percent-encode them. An @ or a : inside a password is fine, and needs no encoding."
4157
+ }, color));
4158
+ return 2;
4159
+ }
4160
+ const unsupported = unsupportedConnectionKeywords(connectionString);
4161
+ if (unsupported.length > 0) {
4162
+ io.stderr(formatFriendlyError({
4163
+ headline: `This connection string uses ${unsupported.length === 1 ? "a keyword" : "keywords"} the scan cannot honour: ${unsupported.join(", ")}.`,
4164
+ hint: "Rewrite it as a URL — postgresql://user:password@host:5432/database?sslmode=require. Connecting with those keywords dropped would send the scan somewhere you did not ask for, or verify TLS less strictly than you asked for."
3690
4165
  }, color));
3691
4166
  return 2;
3692
4167
  }
@@ -3706,6 +4181,13 @@ async function runCli(argv, io = defaultIo()) {
3706
4181
  statementTimeoutMs: options.timeoutMs
3707
4182
  });
3708
4183
  } catch (error) {
4184
+ if (error instanceof UnknownRoleError) {
4185
+ io.stderr(formatFriendlyError({
4186
+ headline: `No such role on this database: ${error.roles.join(", ")}.`,
4187
+ hint: "Check the spelling against `SELECT rolname FROM pg_roles`. This is an error rather than a warning for the same reason an unknown --skip id is: a name that matches nothing silently narrows the scan, and the run then prints a clean report of a database nobody looked at."
4188
+ }, color));
4189
+ return 2;
4190
+ }
3709
4191
  io.stderr(formatFriendlyError(explainError(error, {
3710
4192
  endpoint,
3711
4193
  timeoutMs: options.timeoutMs,