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