@rebasepro/rls-check 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.es.js CHANGED
@@ -398,6 +398,42 @@ var ANONYMOUS_ROLES = [
398
398
  ];
399
399
  var isPublicRole = (role) => role.toLowerCase() === "public";
400
400
  var sameRole = (a, b) => a.toLowerCase() === b.toLowerCase();
401
+ function grouped(cache, items, key) {
402
+ const hit = cache.get(items);
403
+ if (hit && hit.length === items.length) return hit.groups;
404
+ const groups = /* @__PURE__ */ new Map();
405
+ for (const item of items) {
406
+ const k = key(item);
407
+ const list = groups.get(k);
408
+ if (list) list.push(item);
409
+ else groups.set(k, [item]);
410
+ }
411
+ cache.set(items, {
412
+ length: items.length,
413
+ groups
414
+ });
415
+ return groups;
416
+ }
417
+ /** A NUL cannot appear in a Postgres identifier, so it cannot make two pairs collide. */
418
+ var pairKey = (schema, name) => `${schema}\u0000${name}`;
419
+ var RELATIONS = /* @__PURE__ */ new WeakMap();
420
+ var GRANTS = /* @__PURE__ */ new WeakMap();
421
+ var POLICIES = /* @__PURE__ */ new WeakMap();
422
+ var FOREIGN_KEYS = /* @__PURE__ */ new WeakMap();
423
+ var ROLES = /* @__PURE__ */ new WeakMap();
424
+ var SCHEMA_USAGE = /* @__PURE__ */ new WeakMap();
425
+ /** Every grant on one relation, in snapshot order. */
426
+ function grantsOn(snapshot, schema, table) {
427
+ return grouped(GRANTS, snapshot.grants, (g) => pairKey(g.schema, g.table)).get(pairKey(schema, table)) ?? [];
428
+ }
429
+ /** The foreign keys declared on one table, in snapshot order. */
430
+ function foreignKeysOf(snapshot, schema, table) {
431
+ return grouped(FOREIGN_KEYS, snapshot.foreignKeys, (fk) => pairKey(fk.schema, fk.table)).get(pairKey(schema, table)) ?? [];
432
+ }
433
+ /** A role by name, compared the way {@link sameRole} compares. */
434
+ function roleNamed(snapshot, name) {
435
+ return grouped(ROLES, snapshot.roles, (r) => r.name.toLowerCase()).get(name.toLowerCase())?.[0];
436
+ }
401
437
  /**
402
438
  * Every role whose grants `role` actually receives: itself, PUBLIC, and the
403
439
  * transitive closure of its memberships.
@@ -409,16 +445,34 @@ var sameRole = (a, b) => a.toLowerCase() === b.toLowerCase();
409
445
  */
410
446
  function rolesUsableBy(snapshot, role) {
411
447
  const out = /* @__PURE__ */ new Set(["public", role.toLowerCase()]);
412
- const def = snapshot.roles.find((r) => sameRole(r.name, role));
448
+ const def = roleNamed(snapshot, role);
413
449
  for (const m of def?.memberOf ?? []) out.add(m.toLowerCase());
414
450
  return out;
415
451
  }
416
- /** Privileges `role` effectively holds on a relation, memberships included. */
417
- function effectivePrivileges(snapshot, schema, table, role) {
452
+ /**
453
+ * Can `role` name objects in `schema` at all?
454
+ *
455
+ * Without USAGE on the schema a role gets "permission denied for schema" before
456
+ * any table privilege is looked at, so a grant on a table there reaches
457
+ * nothing. Unknown — the read failed, or the schema has no record — counts as
458
+ * yes: a scanner that cannot tell must report, not stay quiet.
459
+ */
460
+ function hasSchemaUsage(snapshot, schema, role) {
461
+ if (!snapshot.schemaUsage) return true;
462
+ const holders = grouped(SCHEMA_USAGE, snapshot.schemaUsage, (u) => u.schema).get(schema);
463
+ if (!holders) return true;
418
464
  const via = rolesUsableBy(snapshot, role);
465
+ return holders.some((u) => via.has(u.grantee.toLowerCase()));
466
+ }
467
+ /**
468
+ * Privileges `role` effectively holds on a relation, memberships included —
469
+ * and none at all when it cannot use the relation's schema.
470
+ */
471
+ function effectivePrivileges(snapshot, schema, table, role) {
419
472
  const out = /* @__PURE__ */ new Set();
420
- for (const g of snapshot.grants) {
421
- if (g.schema !== schema || g.table !== table) continue;
473
+ if (!hasSchemaUsage(snapshot, schema, role)) return out;
474
+ const via = rolesUsableBy(snapshot, role);
475
+ for (const g of grantsOn(snapshot, schema, table)) {
422
476
  if (!via.has(g.grantee.toLowerCase())) continue;
423
477
  for (const p of g.privileges) out.add(p);
424
478
  }
@@ -459,6 +513,28 @@ function policyTargetsExposedRole(snapshot, policy) {
459
513
  }
460
514
  return [...new Set(hits)];
461
515
  }
516
+ /** The privileges a policy's command is exercised with. */
517
+ var COMMAND_PRIVILEGES = {
518
+ ALL: DML,
519
+ SELECT: ["SELECT"],
520
+ INSERT: ["INSERT"],
521
+ UPDATE: ["UPDATE"],
522
+ DELETE: ["DELETE"]
523
+ };
524
+ /**
525
+ * The exposed callers a policy applies to *and* that can reach its table for
526
+ * its command: {@link policyTargetsExposedRole}, kept to the roles holding a
527
+ * privilege the command needs (and USAGE on the schema).
528
+ *
529
+ * A policy nobody can reach the table through is a latent problem, not an
530
+ * exposure, and every check claims an exposure: "a caller can read every row"
531
+ * is false for a role Postgres answers "permission denied". `PUBLIC` stays when
532
+ * the policy is TO PUBLIC and any exposed role reaches the table.
533
+ */
534
+ function policyReachedBy(snapshot, policy) {
535
+ const wanted = COMMAND_PRIVILEGES[policy.command] ?? DML;
536
+ return policyTargetsExposedRole(snapshot, policy).filter((target) => target === "PUBLIC" ? exposedGrantees(snapshot, policy.schema, policy.table, wanted).length > 0 : wanted.some((p) => effectivePrivileges(snapshot, policy.schema, policy.table, target).has(p)));
537
+ }
462
538
  var TABLE_KINDS$1 = ["table", "partitioned_table"];
463
539
  var isTable = (r) => TABLE_KINDS$1.includes(r.kind);
464
540
  function scannedTables(snapshot) {
@@ -472,10 +548,10 @@ function scannedForeignTables(snapshot) {
472
548
  return snapshot.relations.filter((r) => r.kind === "foreign_table" && snapshot.schemas.includes(r.schema));
473
549
  }
474
550
  function relationAt(snapshot, schema, name) {
475
- return snapshot.relations.find((r) => r.schema === schema && r.name === name);
551
+ return grouped(RELATIONS, snapshot.relations, (r) => pairKey(r.schema, r.name)).get(pairKey(schema, name))?.[0];
476
552
  }
477
553
  function policiesFor(snapshot, schema, table) {
478
- return snapshot.policies.filter((p) => p.schema === schema && p.table === table);
554
+ return [...grouped(POLICIES, snapshot.policies, (p) => pairKey(p.schema, p.table)).get(pairKey(schema, table)) ?? []];
479
555
  }
480
556
  var hasColumn = (r, name) => r.columns.some((c) => c.name.toLowerCase() === name.toLowerCase());
481
557
  /** `≈ 12,000 rows` — only ever used to convey blast radius, never severity. */
@@ -510,6 +586,23 @@ function qi(ident) {
510
586
  var qrel = (schema, name) => `${qi(schema)}.${qi(name)}`;
511
587
  /** PUBLIC is a keyword, not an identifier — quoting it changes what it means. */
512
588
  var qrole = (role) => isPublicRole(role) ? "PUBLIC" : qi(role);
589
+ /**
590
+ * The REVOKEs that take `privileges` on a relation away from the exposed roles:
591
+ * one per grant that reaches them, made to the role the grant names.
592
+ *
593
+ * Revoking from the exposed role by name is not the same thing. A grant to
594
+ * `app_reader` that `anon` inherits survives `REVOKE … FROM anon`, which is a
595
+ * no-op, and a relation granted to `anon` and `authenticated` keeps the second
596
+ * grant when the fix names only the first. Every fix that takes a grant away
597
+ * builds it here, so applying it and rescanning clears the finding.
598
+ */
599
+ function revokesReaching(snapshot, schema, table, exposed, privileges) {
600
+ const reaching = new Set(exposed.flatMap((role) => [...rolesUsableBy(snapshot, role)]));
601
+ return grantsOn(snapshot, schema, table).filter((g) => reaching.has(g.grantee.toLowerCase())).map((g) => ({
602
+ grantee: g.grantee,
603
+ held: privileges.filter((p) => g.privileges.includes(p))
604
+ })).filter((g) => g.held.length > 0).map((g) => `REVOKE ${g.held.join(", ")} ON ${qrel(schema, table)} FROM ${qrole(g.grantee)};`);
605
+ }
513
606
  function finding(f) {
514
607
  return {
515
608
  ...f,
@@ -623,8 +716,9 @@ var COMMANDS_FOR = {
623
716
  * correct one — satisfies condition 1 on almost every project out there.
624
717
  * Flagging it would make this check fire on the whole ecosystem.
625
718
  *
626
- * So what is reported is the narrow, certain case: the check expression is
627
- * absent or constant-true, which in Postgres means "accept any row". Policies
719
+ * So what is reported is the narrow, certain case: the clause that decides the
720
+ * command is constant-true, which in Postgres means "accept any row". An
721
+ * *absent* clause is the opposite — see {@link writesAcceptingAnyRow}. Policies
628
722
  * whose expression is an anonymous *tautology* rather than a constant are the
629
723
  * business of `policy-anonymous-tautology`, which can weigh the platform.
630
724
  */
@@ -638,11 +732,11 @@ var anonymousWriteAllowed = {
638
732
  for (const policy of snapshot.policies) {
639
733
  if (!snapshot.schemas.includes(policy.schema)) continue;
640
734
  if (!policy.permissive) continue;
641
- const wanted = COMMANDS_FOR[policy.command] ?? [];
735
+ const open = writesAcceptingAnyRow(policy);
736
+ const wanted = (COMMANDS_FOR[policy.command] ?? []).filter((p) => open.includes(p));
642
737
  if (wanted.length === 0) continue;
643
738
  const identities = anonymousIdentities(snapshot, policy.roles);
644
739
  if (identities.length === 0) continue;
645
- if (!acceptsAnyRow(policy)) continue;
646
740
  const granted = /* @__PURE__ */ new Set();
647
741
  const grantedTo = [];
648
742
  for (const role of identities) {
@@ -665,9 +759,9 @@ var anonymousWriteAllowed = {
665
759
  table: policy.table,
666
760
  policy: policy.name
667
761
  },
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.`,
762
+ detail: `Policy "${policy.name}" is a permissive ${policy.command} policy for ${listAnd(grantedTo)}, and the expression that decides ${listAnd(commands)} is a constant truth, so every row satisfies it. ${listAnd(grantedTo)} also ${grantedTo.length > 1 ? "hold" : "holds"} ${listAnd(commands)} on the table, so both the privilege check and the row check pass for a request that carries no credentials.`,
669
763
  impact: `An unauthenticated caller reaching this database over an API can ${listAnd(verbs)} rows in ${policy.schema}.${policy.table} at will — inserting records attributed to other users, or ${commands.includes("DELETE") ? "deleting the table's contents" : "modifying rows they do not own"}.`,
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(", ")};`
764
+ fix: isRebaseManagedPolicy(snapshot, policy) ? managedPolicyFix(policy, "scope the write rule to the caller (an `ownerField`) or restrict it to `roles` — and if anonymous writes are never intended, say so there rather than by revoking the grant, which boot re-makes") : `-- Scope the write to the caller, or take the privilege away entirely:\nALTER POLICY ${qi(policy.name)} ON ${qrel(policy.schema, policy.table)}\n WITH CHECK (user_id = ${uidCall});\n-- and if anonymous writes are never intended:\n` + revokesReaching(snapshot, policy.schema, policy.table, grantedTo, commands).join("\n")
671
765
  }));
672
766
  }
673
767
  return findings;
@@ -688,16 +782,30 @@ function anonymousIdentities(snapshot, policyRoles) {
688
782
  return ["public", ...snapshot.roles.map((r) => r.name).filter((name) => ANONYMOUS_ROLES.includes(name.toLowerCase()) && !isPublicRole(name))];
689
783
  }
690
784
  /**
691
- * Does this policy impose no row condition at all?
785
+ * The writes this policy admits for any row whatsoever.
786
+ *
787
+ * Postgres ORs together the expressions the permissive policies for a command
788
+ * *have*, and denies when there are none — so a clause a policy lacks admits
789
+ * nothing, it does not admit everything. Reading an absent clause as true
790
+ * reported `FOR INSERT TO anon` with no WITH CHECK (the insert is refused),
791
+ * `FOR DELETE TO anon` with no USING (no row is deleted) and `FOR UPDATE TO anon
792
+ * WITH CHECK (true)` with no USING (no row is reached) as open writes.
692
793
  *
693
- * For INSERT, Postgres falls back to USING when WITH CHECK is absent, and a
694
- * policy with neither clause admits every row. For UPDATE/DELETE the USING
695
- * clause selects the rows that may be touched.
794
+ * - INSERT is decided by WITH CHECK (USING is not allowed on an INSERT policy,
795
+ * and a `FOR ALL` policy's USING stands in for a missing WITH CHECK);
796
+ * - UPDATE needs USING to reach the row and the check — WITH CHECK, or USING
797
+ * again — to accept the new one;
798
+ * - DELETE is decided by USING.
696
799
  */
697
- function acceptsAnyRow(policy) {
698
- const present = (policy.command === "INSERT" ? [policy.withCheck ?? policy.using] : [policy.using, policy.command === "DELETE" ? null : policy.withCheck]).filter((c) => c != null);
699
- if (present.length === 0) return true;
700
- return present.every((c) => isUnconditionalTrue(c));
800
+ function writesAcceptingAnyRow(policy) {
801
+ const using = policy.using != null && isUnconditionalTrue(policy.using);
802
+ const checkExpr = policy.withCheck ?? policy.using;
803
+ const check = checkExpr != null && isUnconditionalTrue(checkExpr);
804
+ const out = [];
805
+ if (check) out.push("INSERT");
806
+ if (using && check) out.push("UPDATE");
807
+ if (using) out.push("DELETE");
808
+ return out;
701
809
  }
702
810
  //#endregion
703
811
  //#region src/checks/current-setting-throws.ts
@@ -856,13 +964,14 @@ var junctionTableUnprotected = {
856
964
  const findings = [];
857
965
  for (const rel of scannedTables(snapshot)) {
858
966
  if (rel.rlsEnabled) continue;
859
- const fks = snapshot.foreignKeys.filter((fk) => fk.schema === rel.schema && fk.table === rel.name);
967
+ const fks = foreignKeysOf(snapshot, rel.schema, rel.name);
860
968
  if (fks.length !== 2) continue;
861
969
  const endpoints = fks.map((fk) => `${fk.refSchema}.${fk.refTable}`);
862
970
  if (endpoints[0] === endpoints[1]) continue;
863
971
  if (endpoints.some((e) => e === `${rel.schema}.${rel.name}`)) continue;
864
972
  if (!fks.map((fk) => relationAt(snapshot, fk.refSchema, fk.refTable)).every((t) => t?.rlsEnabled)) continue;
865
973
  if (!isMostlyKeys(rel, fks.flatMap((fk) => fk.columns))) continue;
974
+ if (exposedGrantees(snapshot, rel.schema, rel.name, DML).length === 0) continue;
866
975
  findings.push(finding({
867
976
  id: ID$11,
868
977
  severity: "high",
@@ -942,7 +1051,7 @@ var viewBypassesRls = {
942
1051
  impact: `If this view is reachable over an API as ${listAnd(roles)}, a caller reads rows from ${listAnd(bases)} that the policies on ${bases.length > 1 ? "those tables" : "that table"} were written to withhold — the view is an unfiltered path around them.`,
943
1052
  fix: legacy ? `-- \`security_invoker\` requires PostgreSQL 15 or newer. On this server, either:
944
1053
  -- 1. revoke access and let callers query the base table directly:
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.`
1054
+ ${revokesReaching(snapshot, view.schema, view.name, roles, ["SELECT"]).join("\n")}\n-- 2. or set FORCE ROW LEVEL SECURITY on the base tables and add policies\n-- that apply to ${qi(view.owner)}, so the view's own execution is filtered.` : `ALTER VIEW ${qrel(view.schema, view.name)} SET (security_invoker = true);\n-- Callers then need their own SELECT privilege on ${listAnd(baseTables.map((r) => qrel(r.schema, r.name)))}, and the\n-- policies there apply to them.`
946
1055
  }));
947
1056
  }
948
1057
  return findings;
@@ -987,7 +1096,7 @@ var matviewBypassesRls = {
987
1096
  },
988
1097
  detail: `This materialized view reads ${listAnd(bases)}, which ${bases.length > 1 ? "have" : "has"} row-level security enabled. Materialized views cannot have policies of their own, and \`security_invoker\` does not apply to them — the rows were computed once, by ${view.owner}, and are stored unfiltered. Every caller with SELECT reads the same stored rows.`,
989
1098
  impact: `If this materialized view is reachable over an API as ${listAnd(roles)}, a caller reads a full copy of the protected data in ${listAnd(bases)} as of the last REFRESH. No policy can restrict this; only the grant can.`,
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 ...;`
1099
+ fix: `-- There is no RLS for materialized views. Restrict the grant:\n${revokesReaching(snapshot, view.schema, view.name, roles, ["SELECT"]).join("\n")}\n-- and, if callers need this data, expose it through a view that applies the\n-- caller's own privileges:\n-- CREATE VIEW ${qrel(view.schema, `${view.name}_scoped`)} WITH (security_invoker = true)\n-- AS SELECT * FROM ${qrel(baseTables[0].schema, baseTables[0].name)} WHERE ...;`
991
1100
  }));
992
1101
  }
993
1102
  return findings;
@@ -1021,7 +1130,7 @@ var policyAlwaysTrue = {
1021
1130
  for (const policy of snapshot.policies) {
1022
1131
  if (!snapshot.schemas.includes(policy.schema)) continue;
1023
1132
  if (!policy.permissive) continue;
1024
- const exposed = policyTargetsExposedRole(snapshot, policy);
1133
+ const exposed = policyReachedBy(snapshot, policy);
1025
1134
  if (exposed.length === 0) continue;
1026
1135
  const clauses = [];
1027
1136
  if (isUnconditionalTrue(policy.using)) clauses.push("USING");
@@ -1037,7 +1146,8 @@ var policyAlwaysTrue = {
1037
1146
  } : null;
1038
1147
  const rel = relationAt(snapshot, policy.schema, policy.table);
1039
1148
  const verb = policy.command === "SELECT" ? "read" : "act on";
1040
- const severity = gate ? "medium" : "critical";
1149
+ const checkOnly = !clauses.includes("USING") && (policy.command === "UPDATE" || policy.command === "ALL");
1150
+ const severity = gate ? "medium" : checkOnly && policy.command === "UPDATE" ? "high" : "critical";
1041
1151
  findings.push(finding({
1042
1152
  id: ID$8,
1043
1153
  severity,
@@ -1048,8 +1158,8 @@ var policyAlwaysTrue = {
1048
1158
  table: policy.table,
1049
1159
  policy: policy.name
1050
1160
  },
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.`,
1161
+ detail: (checkOnly ? `This permissive ${policy.command} policy's WITH CHECK expression is a constant truth, so any new row value passes it for ${listAnd(exposed)}. ` + (policy.using == null ? "It has no USING clause, so on its own it lets no row be updated — but permissive policies' checks are ORed, so every UPDATE another policy lets through may write any values at all." : "Its USING clause still decides which rows can be touched; the check decides what they may become, and it accepts anything. Permissive policies' checks are ORed, so this one overrides any stricter check beside it.") + (policy.command === "ALL" ? " On a FOR ALL policy the same check governs INSERT, so any row at all can be inserted." : "") : `This permissive ${policy.command} policy's ${listAnd(clauses)} expression is a constant truth, so it matches every row for ${listAnd(exposed)}. Permissive policies are ORed together, so this one alone satisfies the table's row filter no matter how strict the others are.`) + (gate ? ` The ${gate.name} also ${gate.verb} to this command and to every role it reaches, ANDed after it, so access may still be gated — verify that the restrictive side covers the rows you expect, because nothing else here does.` : ""),
1162
+ impact: gate ? `Row filtering on this table rests entirely on the ${gate.name}. If it does not cover a case, ${listAnd(exposed)} can ${verb} every row${rowsPhrase(rel)}.` : checkOnly ? `If this table is reachable over an API as ${listAnd(exposed)}, a caller can rewrite any row it is allowed to update into anything — reassign it to another user, move it to another tenant, set any flag it carries` + (policy.command === "ALL" ? ` — and insert rows with any values, attributed to anyone.` : `. Which rows it can reach is still scoped by USING.`) : `If this table is reachable over an API as ${listAnd(exposed)}, a caller can ${verb} every row${rowsPhrase(rel)} — the policy applies no scoping whatsoever.`,
1053
1163
  fix: isRebaseManagedPolicy(snapshot, policy) ? managedPolicyFix(policy, `replace the rule that grants unconditional ${policy.command === "SELECT" ? "reads" : "access"} with one that scopes the rows — \`ownerField\`, \`roles\`, or a \`condition\``) : `-- Replace the constant with the scoping you intended, e.g.:\nALTER POLICY ${qi(policy.name)} ON ${qrel(policy.schema, policy.table)}\n ${clauses.includes("USING") ? `USING (user_id = ${uidCall})` : `WITH CHECK (user_id = ${uidCall})`};\n-- or, if unconditional access really is intended, drop the policy and say so\n-- with an explicit grant instead:\n-- DROP POLICY ${qi(policy.name)} ON ${qrel(policy.schema, policy.table)};`
1054
1164
  }));
1055
1165
  }
@@ -1095,7 +1205,7 @@ function callersOf(snapshot, policy) {
1095
1205
  * restrictive policies together have to apply to every caller it lets in.
1096
1206
  */
1097
1207
  function restrictiveGate(snapshot, policy) {
1098
- const gates = snapshot.policies.filter((p) => !p.permissive && p.schema === policy.schema && p.table === policy.table && (p.command === "ALL" || p.command === policy.command));
1208
+ const gates = policiesFor(snapshot, policy.schema, policy.table).filter((p) => !p.permissive && (p.command === "ALL" || p.command === policy.command));
1099
1209
  const callers = callersOf(snapshot, policy);
1100
1210
  if (callers.length === 0) return null;
1101
1211
  const covering = gates.filter((gate) => callers.some((role) => policyAppliesTo(snapshot, gate, role)));
@@ -1136,7 +1246,10 @@ var ID$7 = "policy-anonymous-tautology";
1136
1246
  * - Supabase: `auth.uid()` reads a JWT claim and returns NULL for an anonymous
1137
1247
  * caller, so the expression is a legitimate "signed in" test. Its only real
1138
1248
  * failing is that it does not scope rows to their owner — worth `low`, worded
1139
- * as a design observation rather than a vulnerability.
1249
+ * as a design observation rather than a vulnerability. That is a fact about
1250
+ * `auth.uid()`, not about Supabase: an anonymous caller there still carries
1251
+ * the anon key's JWT, so `auth.role()` and `auth.jwt()` are non-null for it,
1252
+ * and the same shape built on them is a bypass — see {@link CallerCall}.
1140
1253
  * - Rebase / PostgREST-style stacks that coerce a missing id to a sentinel
1141
1254
  * (`'anonymous'`, `''`): the expression is true for signed-out callers, so it
1142
1255
  * is a straight authentication bypass. This exact policy shipped in this
@@ -1155,18 +1268,19 @@ var policyAnonymousTautology = {
1155
1268
  run(snapshot) {
1156
1269
  const uidCall = callerIdCall(snapshot);
1157
1270
  const findings = [];
1158
- const { severity: baseSeverity, meaning, impactSuffix } = platformReading(snapshot.platform);
1159
1271
  for (const policy of snapshot.policies) {
1160
1272
  if (!snapshot.schemas.includes(policy.schema)) continue;
1161
1273
  if (!policy.permissive) continue;
1162
- if (policyTargetsExposedRole(snapshot, policy).length === 0) continue;
1274
+ if (policyReachedBy(snapshot, policy).length === 0) continue;
1163
1275
  const clauses = [];
1164
1276
  const usingMatch = matchTautology(policy.using);
1165
1277
  const checkMatch = matchTautology(policy.withCheck);
1166
1278
  if (usingMatch) clauses.push("USING");
1167
1279
  if (checkMatch) clauses.push("WITH CHECK");
1168
1280
  if (clauses.length === 0) continue;
1169
- const shape = usingMatch?.shape ?? checkMatch?.shape ?? "the caller id";
1281
+ const matched = worstMatch(snapshot.platform, usingMatch, checkMatch);
1282
+ const { severity: baseSeverity, meaning, impactSuffix } = platformReading(snapshot.platform, matched);
1283
+ const shape = matched.shape;
1170
1284
  const decoys = [.../* @__PURE__ */ new Set([...usingMatch?.decoyGuards ?? [], ...checkMatch?.decoyGuards ?? []])];
1171
1285
  const severity = forCommand(baseSeverity, policy.command);
1172
1286
  const written = decoys.map((d) => `\`${shape} <> '${d}'\``);
@@ -1180,7 +1294,7 @@ var policyAnonymousTautology = {
1180
1294
  table: policy.table,
1181
1295
  policy: policy.name
1182
1296
  },
1183
- detail: (decoys.length > 0 ? `The ${listAnd(clauses)} expression of this ${policy.command} policy reads as "signed in": it tests that ${shape} is non-null and excludes ${listAnd(written)}. But the id a signed-out caller actually arrives with is ${listAnd(CLEARING_SENTINELS.map(describeSentinel))}, and neither is ${listAnd(decoys.map((d) => `'${d}'`))} — so the guard excludes nobody and the null test stands on its own. ` : `The ${listAnd(clauses)} expression of this ${policy.command} policy tests only that ${shape} is non-null. `) + `It does not compare anything to a column, so every row of the table satisfies it equally — the policy distinguishes signed-in from signed-out callers and nothing else. ${meaning}` + (policy.command === "ALL" || policy.command === "UPDATE" || policy.command === "DELETE" ? ` This policy governs ${policy.command === "ALL" ? "every command, writes included" : policy.command}, so the same expression decides who may change rows, not only who may read them.` : ""),
1297
+ detail: (decoys.length > 0 ? `The ${listAnd(clauses)} expression of this ${policy.command} policy reads as "signed in": it tests that ${shape} is non-null and excludes ${listAnd(written)}. But ` + (matched.call.sentinels.length > 0 ? `the ${matched.call.noun} a signed-out caller actually arrives with is ${listAnd(matched.call.sentinels.map(describeSentinel))}, and neither is ${listAnd(decoys.map((d) => `'${d}'`))}` : `no literal stands for a signed-out caller in ${shape}`) + ` — so the guard excludes nobody and the null test stands on its own. ` : `The ${listAnd(clauses)} expression of this ${policy.command} policy tests only that ${shape} is non-null. `) + `It does not compare anything to a column, so every row of the table satisfies it equally — the policy distinguishes signed-in from signed-out callers and nothing else. ${meaning}` + (policy.command === "ALL" || policy.command === "UPDATE" || policy.command === "DELETE" ? ` This policy governs ${policy.command === "ALL" ? "every command, writes included" : policy.command}, so the same expression decides who may change rows, not only who may read them.` : ""),
1184
1298
  impact: `Any caller for whom ${shape} is non-null can reach every row this policy covers, including rows belonging to other users or tenants. ${impactSuffix}` + (decoys.length > 0 ? " A policy in this shape reads as safe on review, which is why it survives: the guard is present, spelled plausibly, and matches nothing." : ""),
1185
1299
  fix: isRebaseManagedPolicy(snapshot, policy) ? managedPolicyFix(policy, "scope the rule to the row's owner (an `ownerField`) rather than to the existence of an id — `access: \"authenticated\"` compiled to exactly this shape before 1.0, so a database pushed then still carries it") : `-- Scope the policy to the row's owner rather than to the existence of an id:\nALTER POLICY ${qi(policy.name)} ON ${qrel(policy.schema, policy.table)}\n USING (user_id = ${uidCall});\n-- If the intent really is "any signed-in user", exclude every id your stack has\n-- ever used for "nobody" — one literal is not enough:\n-- USING (${uidCall} IS NOT NULL AND ${uidCall} <> ALL (ARRAY['anonymous', 'anon']));`
1186
1300
  }));
@@ -1203,26 +1317,55 @@ function forCommand(base, command) {
1203
1317
  return SEVERITIES[Math.min(SEVERITIES.indexOf(base) + 1, SEVERITIES.length - 1)];
1204
1318
  }
1205
1319
  var describeSentinel = (s) => s === "" ? "the empty string" : `'${s}'`;
1206
- function platformReading(platform) {
1320
+ /** Of the USING and WITH CHECK clauses that matched, the one whose reading is worse. */
1321
+ function worstMatch(platform, using, check) {
1322
+ if (!using) return check;
1323
+ if (!check) return using;
1324
+ const rank = (m) => SEVERITIES.indexOf(platformReading(platform, m).severity);
1325
+ return rank(check) > rank(using) ? check : using;
1326
+ }
1327
+ /**
1328
+ * What the expression means on this platform, for the call it is built on.
1329
+ *
1330
+ * The platform alone does not decide it. On Supabase `auth.uid()` is NULL for a
1331
+ * signed-out request, so a null test on it is a working "signed in" check — but
1332
+ * a signed-out request there still carries a JWT, the project's public anon
1333
+ * key, so `auth.role()` is `'anon'` and `auth.jwt()` is that key's claims. A
1334
+ * null test on either is true for everyone. This used to be read per platform
1335
+ * only, which graded `USING (auth.jwt() IS NOT NULL)` low and told the reader
1336
+ * that anonymous callers were correctly excluded.
1337
+ */
1338
+ function platformReading(platform, match) {
1339
+ const { shape, call } = match;
1207
1340
  switch (platform) {
1208
- case "supabase": return {
1209
- severity: "low",
1210
- meaning: "On Supabase, `auth.uid()` returns NULL for an anonymous request, so this is a working authenticated-only check rather than a bypass. It is listed because it only distinguishes signed-in from signed-out; it does not scope rows to their owner.",
1211
- impactSuffix: "Anonymous callers are correctly excluded on Supabase, so this is a data-scoping gap between signed-in users, not an anonymous-access hole."
1212
- };
1341
+ case "supabase":
1342
+ if (call.onSupabase === "null") return {
1343
+ severity: "low",
1344
+ meaning: `On Supabase, \`${shape}\` returns NULL for an anonymous request, so this is a working authenticated-only check rather than a bypass. It is listed because it only distinguishes signed-in from signed-out; it does not scope rows to their owner.`,
1345
+ impactSuffix: "Anonymous callers are correctly excluded on Supabase, so this is a data-scoping gap between signed-in users, not an anonymous-access hole."
1346
+ };
1347
+ if (call.onSupabase === "present") return {
1348
+ severity: "critical",
1349
+ meaning: `On Supabase a signed-out request still carries a JWT — the project's anon key, which ships in every client — and for it \`${shape}\` is ${call.signedOutValue}, not NULL. So this expression is true for signed-out callers too: it authorises everyone.`,
1350
+ impactSuffix: "That includes unauthenticated callers: the anon key is public, and a request carrying nothing else satisfies the expression."
1351
+ };
1352
+ return unknownReading(shape);
1213
1353
  case "rebase":
1214
1354
  case "postgrest": return {
1215
1355
  severity: "critical",
1216
1356
  meaning: "On this stack a request without a session is given a sentinel id (an 'anonymous' string rather than NULL), so this expression is true for signed-out callers too — it authorises everyone.",
1217
1357
  impactSuffix: "Because signed-out requests arrive with a sentinel id rather than NULL, this includes unauthenticated callers."
1218
1358
  };
1219
- default: return {
1220
- severity: "medium",
1221
- meaning: "Whether this excludes anonymous callers depends on the layer in front of the database: if it leaves the setting unset for signed-out requests the expression is false and this is merely loose; if it coerces them to a sentinel id (an empty string or 'anonymous') the expression is true and this authorises everyone.",
1222
- impactSuffix: "Whether unauthenticated callers are included depends on whether your stack coerces a missing caller id to a sentinel value — check that before judging the severity."
1223
- };
1359
+ default: return unknownReading(shape);
1224
1360
  }
1225
1361
  }
1362
+ function unknownReading(shape) {
1363
+ return {
1364
+ severity: "medium",
1365
+ meaning: `Whether this excludes anonymous callers depends on the layer in front of the database: if it leaves \`${shape}\` unset for signed-out requests the expression is false and this is merely loose; if it gives them a value — a sentinel id such as an empty string or 'anonymous', or the claims of a public anon key — the expression is true and this authorises everyone.`,
1366
+ impactSuffix: "Whether unauthenticated callers are included depends on whether your stack coerces a missing caller id to a sentinel value — check that before judging the severity."
1367
+ };
1368
+ }
1226
1369
  /**
1227
1370
  * Both schema spellings, deliberately.
1228
1371
  *
@@ -1235,23 +1378,28 @@ function platformReading(platform) {
1235
1378
  var CALLER_ID_CALLS = [
1236
1379
  {
1237
1380
  re: /(?:rebase|auth)\.uid\s*\(\s*\)/g,
1238
- label: (m) => m[0].replace(/\s+/g, "")
1381
+ label: (m) => m[0].replace(/\s+/g, ""),
1382
+ call: () => CALLER_ID
1239
1383
  },
1240
1384
  {
1241
1385
  re: /auth\.role\s*\(\s*\)/g,
1242
- label: () => "auth.role()"
1386
+ label: () => "auth.role()",
1387
+ call: () => CALLER_ROLE
1243
1388
  },
1244
1389
  {
1245
1390
  re: /(?:rebase|auth)\.roles\s*\(\s*\)/g,
1246
- label: (m) => m[0].replace(/\s+/g, "")
1391
+ label: (m) => m[0].replace(/\s+/g, ""),
1392
+ call: () => CALLER_ROLE_LIST
1247
1393
  },
1248
1394
  {
1249
1395
  re: /(?:rebase|auth)\.jwt\s*\(\s*\)/g,
1250
- label: (m) => m[0].replace(/\s+/g, "")
1396
+ label: (m) => m[0].replace(/\s+/g, ""),
1397
+ call: () => CALLER_CLAIMS
1251
1398
  },
1252
1399
  {
1253
1400
  re: /current_setting\s*\(\s*('[^']*')\s*(?:,\s*[a-z]+\s*)?\)/g,
1254
- label: (m) => `current_setting(${m[1]})`
1401
+ label: (m) => `current_setting(${m[1]})`,
1402
+ call: (m) => settingCall(m[1].slice(1, -1))
1255
1403
  }
1256
1404
  ];
1257
1405
  /**
@@ -1266,6 +1414,73 @@ var CALLER_ID_CALLS = [
1266
1414
  * `'anon'` as clearing is exactly the mistake this check now exists to catch.
1267
1415
  */
1268
1416
  var CLEARING_SENTINELS = ["anonymous", ""];
1417
+ var CALLER_ID = {
1418
+ onSupabase: "null",
1419
+ noun: "id",
1420
+ sentinels: CLEARING_SENTINELS
1421
+ };
1422
+ /**
1423
+ * `auth.role()`, and the claim it reads. `'anon'` is a decoy for an *id* — no
1424
+ * signed-out caller arrives with that id — but it is precisely the *role* one
1425
+ * arrives as on Supabase (`web_anon` on a stock PostgREST), so excluding it is
1426
+ * the real guard here.
1427
+ */
1428
+ var CALLER_ROLE = {
1429
+ onSupabase: "present",
1430
+ signedOutValue: "'anon'",
1431
+ noun: "role",
1432
+ sentinels: ["anon", "web_anon"]
1433
+ };
1434
+ /** Rebase's `roles()`, which coalesces an unset list to the empty string. */
1435
+ var CALLER_ROLE_LIST = {
1436
+ onSupabase: "present",
1437
+ signedOutValue: "the empty string",
1438
+ noun: "role list",
1439
+ sentinels: CLEARING_SENTINELS
1440
+ };
1441
+ var CALLER_CLAIMS = {
1442
+ onSupabase: "present",
1443
+ signedOutValue: "the anon key's claims",
1444
+ noun: "claim set",
1445
+ sentinels: []
1446
+ };
1447
+ /** A setting nothing on Supabase defines: whatever sets it decides. */
1448
+ var CALLER_SETTING = {
1449
+ onSupabase: "unknown",
1450
+ noun: "id",
1451
+ sentinels: CLEARING_SENTINELS
1452
+ };
1453
+ /** The claims Supabase's anon key carries. Every other claim is unset for it. */
1454
+ var ANON_KEY_CLAIMS = [
1455
+ "role",
1456
+ "iss",
1457
+ "ref",
1458
+ "iat",
1459
+ "exp"
1460
+ ];
1461
+ /**
1462
+ * `current_setting('…')` by the setting it reads. PostgREST publishes the JWT as
1463
+ * `request.jwt.claims`, and older releases also as one `request.jwt.claim.<name>`
1464
+ * per claim — so the claim name decides whether the anon key fills it.
1465
+ */
1466
+ function settingCall(name) {
1467
+ if (name === "request.jwt.claims") return CALLER_CLAIMS;
1468
+ const claim = /^request\.jwt\.claim\.(.+)$/.exec(name)?.[1];
1469
+ if (claim === void 0) return CALLER_SETTING;
1470
+ if (claim === "sub") return CALLER_ID;
1471
+ if (claim === "role") return CALLER_ROLE;
1472
+ if (ANON_KEY_CLAIMS.includes(claim)) return {
1473
+ onSupabase: "present",
1474
+ signedOutValue: `the anon key's \`${claim}\` claim`,
1475
+ noun: "claim",
1476
+ sentinels: []
1477
+ };
1478
+ return {
1479
+ onSupabase: "null",
1480
+ noun: "claim",
1481
+ sentinels: CLEARING_SENTINELS
1482
+ };
1483
+ }
1269
1484
  /**
1270
1485
  * Recognise "the policy tests that a caller id exists, and nothing that narrows
1271
1486
  * which rows" — and report which no-op guards it wears while doing so.
@@ -1293,9 +1508,10 @@ var CLEARING_SENTINELS = ["anonymous", ""];
1293
1508
  function callerIdOnlyClause(clause) {
1294
1509
  if (!clause) return null;
1295
1510
  const flat = clause.toLowerCase().replace(/::\s*[a-z0-9_]+(?:\s*\[\s*\])*/g, "").replace(/\s+/g, " ").trim();
1296
- for (const { re, label } of CALLER_ID_CALLS) {
1511
+ for (const { re, label, call: callFor } of CALLER_ID_CALLS) {
1297
1512
  const first = new RegExp(re.source).exec(flat);
1298
1513
  if (!first) continue;
1514
+ const call = callFor(first);
1299
1515
  const substituted = flat.replace(new RegExp(re.source, "g"), " callerid ");
1300
1516
  if (/\bor\b/.test(substituted)) return null;
1301
1517
  let sawNullTest = false;
@@ -1310,19 +1526,15 @@ function callerIdOnlyClause(clause) {
1310
1526
  }
1311
1527
  const excluded = excludedLiterals(conjunct);
1312
1528
  if (!excluded) return null;
1313
- if (excluded.some((lit) => CLEARING_SENTINELS.includes(lit))) cleared = true;
1529
+ if (excluded.some((lit) => call.sentinels.includes(lit))) cleared = true;
1314
1530
  decoys.push(...excluded);
1315
1531
  }
1316
1532
  if (!sawNullTest) continue;
1317
- if (cleared) return {
1318
- shape: label(first),
1319
- decoyGuards: [...new Set(decoys)],
1320
- guardsSentinel: true
1321
- };
1322
1533
  return {
1323
1534
  shape: label(first),
1535
+ call,
1324
1536
  decoyGuards: [...new Set(decoys)],
1325
- guardsSentinel: false
1537
+ guardsSentinel: cleared
1326
1538
  };
1327
1539
  }
1328
1540
  return null;
@@ -1469,7 +1681,7 @@ var policyAuthenticatedTautology = {
1469
1681
  for (const policy of snapshot.policies) {
1470
1682
  if (!snapshot.schemas.includes(policy.schema)) continue;
1471
1683
  if (!policy.permissive) continue;
1472
- if (policyTargetsExposedRole(snapshot, policy).length === 0) continue;
1684
+ if (policyReachedBy(snapshot, policy).length === 0) continue;
1473
1685
  const usingMatch = callerIdOnlyClause(policy.using);
1474
1686
  const checkMatch = callerIdOnlyClause(policy.withCheck);
1475
1687
  const clauses = [];
@@ -1610,17 +1822,9 @@ var rlsDisabled = {
1610
1822
  return findings;
1611
1823
  }
1612
1824
  };
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
- */
1825
+ /** Take a foreign table away from the exposed roles: it cannot have row-level security. */
1618
1826
  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.`;
1827
+ return `-- A foreign table cannot have row-level security, so take the grant away:\n${revokesReaching(snapshot, schema, table, exposed, DML).join("\n")}\n-- and keep it in a schema your API does not expose, reading it through a\n-- function that checks the caller.`;
1624
1828
  }
1625
1829
  //#endregion
1626
1830
  //#region src/checks/rls-enabled-no-policies.ts
@@ -1833,11 +2037,21 @@ var ID = "unqualified-column-in-subquery";
1833
2037
  * flagging it merely because the outer table also has a `user_id` would fire
1834
2038
  * on a large fraction of correct policies.
1835
2039
  *
1836
- * Confidence is always heuristic, and the detail says why an absence proves
1837
- * nothing: `pg_policies.qual` is Postgres's re-rendering of the parse tree, and
1838
- * it normally re-qualifies references. A finding here therefore means the
1839
- * ambiguity survived that rewrite, which is strong evidence — but a clean scan
1840
- * is not proof that the original SQL was unambiguous.
2040
+ * What a live database hands this check is not what anyone typed.
2041
+ * `pg_policies.qual` is Postgres's re-rendering of the parse tree, and inside a
2042
+ * subquery it qualifies *every* column — so the bare name is never in the text.
2043
+ * The bare-name scan below only ever fires on text that did not come out of the
2044
+ * catalog. Against a real database the mistake shows up as its effect: the bare
2045
+ * `organization_id` bound to the inner relation, so the stored comparison reads
2046
+ * `m.organization_id = m.organization_id` — a column compared with itself. That
2047
+ * shape is what {@link selfComparisons} looks for, and it is the half of this
2048
+ * check that can fire on a scan. It was the half missing until it was noticed
2049
+ * that the check could not fire at all; the e2e suite now holds it to that.
2050
+ *
2051
+ * Confidence is always heuristic, and an absence still proves nothing: a bare
2052
+ * name compared with a *different* inner column (`organization_id = id`) comes
2053
+ * back as `m.organization_id = m.id`, which is indistinguishable from a
2054
+ * comparison somebody meant.
1841
2055
  */
1842
2056
  var unqualifiedColumnInSubquery = {
1843
2057
  id: ID,
@@ -1912,6 +2126,12 @@ function scanExpression(snapshot, outer, expr) {
1912
2126
  }
1913
2127
  const from = parseFrom(tokens, own);
1914
2128
  if (from.length === 0) continue;
2129
+ for (const hit of selfComparisons(snapshot, outer, tokens, own, from)) {
2130
+ const key = `${hit.column}|${hit.inner}`;
2131
+ if (seen.has(key)) continue;
2132
+ seen.add(key);
2133
+ out.push(hit);
2134
+ }
1915
2135
  const relations = from.map((item) => resolveRelation(snapshot, outer.schema, item)).filter((r) => Boolean(r)).filter((r) => !(r.schema === outer.schema && r.name === outer.name));
1916
2136
  if (relations.length === 0) continue;
1917
2137
  const aliases = new Set(from.map((f) => f.alias).filter((a) => Boolean(a)));
@@ -1929,6 +2149,7 @@ function scanExpression(snapshot, outer, expr) {
1929
2149
  if (seen.has(key)) continue;
1930
2150
  seen.add(key);
1931
2151
  out.push({
2152
+ kind: "bare",
1932
2153
  column: token.value,
1933
2154
  inner: `${inner.schema}.${inner.name}`,
1934
2155
  innerRelation: {
@@ -1941,6 +2162,54 @@ function scanExpression(snapshot, outer, expr) {
1941
2162
  }
1942
2163
  return out;
1943
2164
  }
2165
+ /**
2166
+ * `q.col <op> q.col` in this subquery's predicate, where `q` is one of the
2167
+ * subquery's own FROM items and the policy's table has a `col` too.
2168
+ *
2169
+ * Nobody writes a column compared with itself; Postgres does, when it stores
2170
+ * `WHERE m.user_id = auth.uid() AND org_id = org_id` and the second `org_id`
2171
+ * bound to `m`. The outer table having the column is what makes it this bug
2172
+ * rather than some other one: it is the column the author could have meant.
2173
+ */
2174
+ function selfComparisons(snapshot, outer, tokens, own, from) {
2175
+ const inPredicate = new Set(predicateIndices(tokens, own));
2176
+ const out = [];
2177
+ const at = (k) => tokens[own[k]];
2178
+ const isDot = (k) => at(k)?.kind === "punct" && at(k)?.value === ".";
2179
+ const isIdent = (k) => at(k)?.kind === "ident";
2180
+ for (let k = 0; k + 6 < own.length; k++) {
2181
+ if (!inPredicate.has(own[k])) continue;
2182
+ if (isDot(k - 1)) continue;
2183
+ if (!isIdent(k) || !isDot(k + 1) || !isIdent(k + 2)) continue;
2184
+ const op = at(k + 3);
2185
+ if (!op || !COMPARISONS.has(op.value) || op.value === "in" || op.value === "is") continue;
2186
+ if (!isIdent(k + 4) || !isDot(k + 5) || !isIdent(k + 6)) continue;
2187
+ if (isDot(k + 7) || at(k + 7)?.kind === "punct" && at(k + 7)?.value === "(") continue;
2188
+ const qualifier = at(k).value;
2189
+ const column = at(k + 2).value;
2190
+ if (at(k + 4).value !== qualifier || at(k + 6).value !== column) continue;
2191
+ if (!hasColumn(outer, column)) continue;
2192
+ const item = from.find((f) => (f.alias ?? f.name) === qualifier);
2193
+ if (!item) continue;
2194
+ const resolved = resolveRelation(snapshot, outer.schema, item);
2195
+ const innerRelation = resolved ? {
2196
+ schema: resolved.schema,
2197
+ name: resolved.name
2198
+ } : {
2199
+ schema: item.schema ?? outer.schema,
2200
+ name: item.name
2201
+ };
2202
+ out.push({
2203
+ kind: "self",
2204
+ column,
2205
+ inner: `${innerRelation.schema}.${innerRelation.name}`,
2206
+ innerRelation,
2207
+ comparedTo: `${qualifier}.${column}`,
2208
+ operator: op.value
2209
+ });
2210
+ }
2211
+ return out;
2212
+ }
1944
2213
  /** Relation references in the subquery's FROM list, with their aliases. */
1945
2214
  function parseFrom(tokens, own) {
1946
2215
  const start = own.findIndex((i) => tokens[i].kind === "ident" && tokens[i].value === "from");
@@ -2055,19 +2324,28 @@ function resolveRelation(snapshot, policySchema, item) {
2055
2324
  }
2056
2325
  function buildFinding(snapshot, policy, outer, clause, hit) {
2057
2326
  const innerSql = qrel(hit.innerRelation.schema, hit.innerRelation.name);
2327
+ const self = hit.kind === "self";
2328
+ const admits = !self || [
2329
+ "=",
2330
+ "<=",
2331
+ ">=",
2332
+ "~~",
2333
+ "like",
2334
+ "ilike"
2335
+ ].includes(hit.operator ?? "=");
2058
2336
  return finding({
2059
2337
  id: ID,
2060
2338
  severity: "high",
2061
2339
  confidence: "heuristic",
2062
- title: `Policy "${policy.name}" on ${policy.schema}.${policy.table}: does \`${hit.column}\` in the subquery mean ${outer.name}.${hit.column} or ${hit.inner}.${hit.column}?`,
2340
+ title: self ? `Policy "${policy.name}" on ${policy.schema}.${policy.table}: the subquery compares \`${hit.comparedTo}\` with itself — was one side meant to be ${outer.name}.${hit.column}?` : `Policy "${policy.name}" on ${policy.schema}.${policy.table}: does \`${hit.column}\` in the subquery mean ${outer.name}.${hit.column} or ${hit.inner}.${hit.column}?`,
2063
2341
  target: {
2064
2342
  schema: policy.schema,
2065
2343
  table: policy.table,
2066
2344
  policy: policy.name,
2067
2345
  column: hit.column
2068
2346
  },
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.`,
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.",
2347
+ detail: self ? `In the ${clause} expression, the subquery over ${hit.inner} compares \`${hit.comparedTo}\` with itself, so that comparison does not depend on the row being checked. It is what Postgres stores for a comparison written with a bare \`${hit.column}\` meant for the outer row: ${hit.inner} has a \`${hit.column}\` too, Postgres resolves a bare name against the innermost scope that has it, and \`pg_policies\` shows the result with both sides qualified. If the intent was to correlate the subquery with ${policy.schema}.${policy.table}.${hit.column}, that correlation is not happening.` : `In the ${clause} expression, \`${hit.column}\` is written unqualified inside a subquery over ${hit.inner}, compared against \`${hit.comparedTo}\`. Both ${hit.inner} and ${policy.schema}.${policy.table} have a column named \`${hit.column}\`, and Postgres resolves the bare name against the innermost scope that has it — so it binds to ${hit.inner}.${hit.column}, not to the outer row. If the intent was to correlate the subquery with the row being checked, that correlation is not happening.\n\nNote that \`pg_policies\` qualifies every column inside a subquery, so a bare name never appears in text read from a live database: there the same mistake reads as a column compared with itself, which this check also reports. A comparison of two different columns looks deliberate either way, so an absence of this finding is not proof.`,
2348
+ impact: self ? admits ? `The subquery is true for every row of ${policy.schema}.${policy.table} as soon as it is true for one: anyone it admits to one row is admitted to all of them, other users' and other tenants' rows included.` : "The comparison is never true, so the subquery matches nothing and the policy admits no row — the table silently returns empty results to everyone it covers." : "The predicate does not mean what it reads like. Depending on the data it either matches far more rows than intended — exposing other users' or tenants' rows to anyone the policy applies to — or, if the inner comparison is never satisfiable, matches none, and the table silently returns empty results.",
2071
2349
  fix: isRebaseManagedPolicy(snapshot, policy) ? managedPolicyFix(policy, `qualify every reference in the rule's condition so the binding is explicit — \`${hit.inner}.${hit.column}\` and \`${policy.table}.${hit.column}\` are different columns, and the bare name binds to the inner one`) : `-- Qualify every reference so the binding is explicit:\nALTER POLICY ${qi(policy.name)} ON ${qrel(policy.schema, policy.table)}\n ${clause === "USING" ? "USING" : "WITH CHECK"} (EXISTS (\n SELECT 1 FROM ${innerSql}\n WHERE ${innerSql}.${qi(hit.comparedTo.split(".").pop() ?? hit.comparedTo)}\n = ${qi(policy.table)}.${qi(hit.column)}\n ));\n-- Verify the intended direction first — this rewrite assumes the outer row was meant.`
2072
2350
  });
2073
2351
  }
@@ -2702,6 +2980,7 @@ async function readSnapshot(client, opts, diagnostics) {
2702
2980
  const relations = await readRelations(db, schemas);
2703
2981
  const policies = await readPolicies(db, schemas);
2704
2982
  const grants = await readGrants(db, schemas);
2983
+ const schemaUsage = await readSchemaUsage(db, diagnostics);
2705
2984
  const views = await readViews(db, schemas, serverVersionNum);
2706
2985
  const foreignKeys = await readForeignKeys(db, schemas);
2707
2986
  const routines = await readRoutines(db, schemas);
@@ -2723,6 +3002,7 @@ async function readSnapshot(client, opts, diagnostics) {
2723
3002
  policies,
2724
3003
  roles,
2725
3004
  grants,
3005
+ schemaUsage,
2726
3006
  views,
2727
3007
  foreignKeys,
2728
3008
  routines
@@ -2972,6 +3252,27 @@ async function readGrants(db, schemas) {
2972
3252
  }
2973
3253
  return [...map.values()];
2974
3254
  }
3255
+ /**
3256
+ * Who holds USAGE on each schema. Every schema, not only the scanned ones: a
3257
+ * view or a foreign key can point outside the scan.
3258
+ *
3259
+ * `undefined`, not `[]`, when the read failed — an empty list would say no role
3260
+ * can use any schema and silence every check that gates on a grant, which is
3261
+ * the wrong direction for a scanner to fail in.
3262
+ */
3263
+ async function readSchemaUsage(db, diagnostics) {
3264
+ const what = "schema privileges";
3265
+ const rows = await db.query(what, `SELECT n.nspname AS schema,
3266
+ CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee) END AS grantee
3267
+ FROM pg_namespace n
3268
+ CROSS JOIN LATERAL aclexplode(COALESCE(n.nspacl, acldefault('n'::"char", n.nspowner))) a
3269
+ WHERE a.privilege_type = 'USAGE'`);
3270
+ if (diagnostics.degraded.some((entry) => entry.what === what)) return void 0;
3271
+ return rows.map((r) => ({
3272
+ schema: r.schema,
3273
+ grantee: r.grantee
3274
+ }));
3275
+ }
2975
3276
  async function readViews(db, schemas, serverVersionNum) {
2976
3277
  const rows = await db.query("views", `SELECT n.nspname AS schema, c.relname AS name, pg_get_userbyid(c.relowner) AS owner,
2977
3278
  c.relkind::text AS kind, c.reloptions
@@ -2984,11 +3285,52 @@ async function readViews(db, schemas, serverVersionNum) {
2984
3285
  schema: r.schema,
2985
3286
  name: r.name,
2986
3287
  owner: r.owner,
2987
- securityInvoker: serverVersionNum < 15e4 || r.kind === "m" ? null : (r.reloptions ?? []).some((o) => /^security_invoker=(true|on)$/i.test(o)),
3288
+ securityInvoker: serverVersionNum < 15e4 || r.kind === "m" ? null : reloptionIsOn(r.reloptions, "security_invoker"),
2988
3289
  dependsOn: dependencies.get(JSON.stringify([r.schema, r.name])) ?? []
2989
3290
  }));
2990
3291
  }
2991
3292
  /**
3293
+ * Whether a boolean reloption is set on, read the way Postgres reads it.
3294
+ *
3295
+ * Postgres stores a reloption as the text it was given, after checking it with
3296
+ * `parse_bool`, so `WITH (security_invoker = 1)` is stored as
3297
+ * `security_invoker=1` and means on. Matching only `true` and `on` reported such
3298
+ * a view as a critical bypass. A value `parse_bool` would refuse cannot be
3299
+ * stored, so anything unrecognised here is read as off — the direction that
3300
+ * reports rather than hides.
3301
+ */
3302
+ function reloptionIsOn(reloptions, name) {
3303
+ for (const option of reloptions ?? []) {
3304
+ const eq = option.indexOf("=");
3305
+ if (eq === -1 || option.slice(0, eq).toLowerCase() !== name) continue;
3306
+ return parsePgBool(option.slice(eq + 1)) === true;
3307
+ }
3308
+ return false;
3309
+ }
3310
+ /**
3311
+ * Postgres's `parse_bool`: `true`/`false`, `yes`/`no`, `on`/`off`, `1`/`0`,
3312
+ * case-insensitively, and any unambiguous prefix of the words — `t`, `ye`,
3313
+ * `of` — but not `o`, which could be either. `null` for anything else.
3314
+ */
3315
+ function parsePgBool(raw) {
3316
+ const value = raw.trim().toLowerCase();
3317
+ if (value.length === 0) return null;
3318
+ const prefixOf = (word) => word.startsWith(value);
3319
+ switch (value[0]) {
3320
+ case "t": return prefixOf("true") ? true : null;
3321
+ case "f": return prefixOf("false") ? false : null;
3322
+ case "y": return prefixOf("yes") ? true : null;
3323
+ case "n": return prefixOf("no") ? false : null;
3324
+ case "o":
3325
+ if (value.length < 2) return null;
3326
+ if (prefixOf("on")) return true;
3327
+ return prefixOf("off") ? false : null;
3328
+ case "1": return value.length === 1 ? true : null;
3329
+ case "0": return value.length === 1 ? false : null;
3330
+ default: return null;
3331
+ }
3332
+ }
3333
+ /**
2992
3334
  * Base relations of each view, resolved through `pg_depend` → `pg_rewrite`.
2993
3335
  *
2994
3336
  * Deliberately transitive: a view over a view over a protected table is still a