@rebasepro/rls-check 0.17.3 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +153 -111
- package/dist/checks/policy-anonymous-tautology.d.ts +50 -2
- package/dist/checks/policy-authenticated-tautology.d.ts +27 -0
- package/dist/checks/util.d.ts +28 -0
- package/dist/index.es.js +775 -293
- package/dist/index.es.js.map +1 -1
- package/dist/introspect.d.ts +33 -0
- package/dist/redact.d.ts +17 -1
- package/dist/types.d.ts +19 -4
- package/package.json +33 -21
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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
|
|
981
|
-
*
|
|
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$
|
|
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$
|
|
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",
|
|
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
|
-
*
|
|
1073
|
-
*
|
|
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
|
-
*
|
|
1076
|
-
*
|
|
1077
|
-
*
|
|
1078
|
-
*
|
|
1079
|
-
*
|
|
1080
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1814
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
|
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 = [
|
|
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
|
-
*
|
|
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
|
-
*
|
|
2283
|
-
*
|
|
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
|
|
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) =>
|
|
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` : "")}
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
4055
|
+
DATABASE_URL="postgresql://user:password@host:5432/database" npx @rebasepro/rls-check
|
|
3591
4056
|
|
|
3592
|
-
Or
|
|
3593
|
-
|
|
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
|
|
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,
|