db-firewall 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +106 -3
- package/dist/checks.d.ts +19 -0
- package/dist/checks.js +55 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +204 -0
- package/dist/key.d.ts +5 -0
- package/dist/key.js +15 -0
- package/dist/migrate.d.ts +12 -0
- package/dist/migrate.js +43 -0
- package/dist/scan.d.ts +55 -0
- package/dist/scan.js +199 -0
- package/dist/verify.d.ts +40 -0
- package/dist/verify.js +271 -0
- package/package.json +33 -6
- package/sql/001_postgresql.sql +1402 -0
- package/sql/checks_postgresql.sql +532 -0
package/dist/scan.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// PostgreSQL scan: the queries are in the shared checks file, which
|
|
2
|
+
// hooks/scan_engines.py (_scan_postgres) runs too, so TypeScript teams need no
|
|
3
|
+
// Python. The logic around them is kept in step with the Python scanner.
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { CHECKS, q } from "./checks.js";
|
|
6
|
+
import { MIGRATION } from "./migrate.js";
|
|
7
|
+
/** Exactly the tables migrations/001_postgresql.sql skips (@excluded in the
|
|
8
|
+
* checks file), so a table the migration protects is never skipped here.
|
|
9
|
+
* Skipped tables are reported. */
|
|
10
|
+
export const DEFAULT_EXCLUDED = CHECKS().excluded;
|
|
11
|
+
/** The migration's predicate for dbfw.context signed (default) or plain. */
|
|
12
|
+
export function predicate(column, context) {
|
|
13
|
+
return context === "plain"
|
|
14
|
+
? `${ident(column)} = NULLIF(current_setting('app.current_tenant', true), '') ` +
|
|
15
|
+
"OR db_firewall._firewall_is_bypass_role(current_user::text)"
|
|
16
|
+
: `${ident(column)} = (SELECT db_firewall._firewall_tenant()) ` +
|
|
17
|
+
"OR (SELECT db_firewall._firewall_is_bypass_role(current_user::text))";
|
|
18
|
+
}
|
|
19
|
+
// The tables and their policies: @query scan_tables.
|
|
20
|
+
const CMD = { sel: "SELECT", ins: "INSERT", upd: "UPDATE", del: "DELETE" };
|
|
21
|
+
export const ident = (name) => `"${name.replace(/"/g, '""')}"`;
|
|
22
|
+
/** fnmatch-style glob (*, ?, [abc], [!abc]), as the Python scanner matches. */
|
|
23
|
+
export function globMatch(pattern, name) {
|
|
24
|
+
let re = "";
|
|
25
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
26
|
+
const ch = pattern[i];
|
|
27
|
+
if (ch === "*")
|
|
28
|
+
re += ".*";
|
|
29
|
+
else if (ch === "?")
|
|
30
|
+
re += ".";
|
|
31
|
+
else if (ch === "[") {
|
|
32
|
+
// As fnmatch: a leading "!" negates, then a leading "]" is literal.
|
|
33
|
+
let j = i + 1;
|
|
34
|
+
if (pattern[j] === "!")
|
|
35
|
+
j++;
|
|
36
|
+
if (pattern[j] === "]")
|
|
37
|
+
j++;
|
|
38
|
+
const end = pattern.indexOf("]", j);
|
|
39
|
+
if (end < 0) {
|
|
40
|
+
re += "\\[";
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
// fnmatch drops a reversed range ([z-a]) instead of failing.
|
|
44
|
+
let body = pattern.slice(i + 1, end).replace(/(.)-(.)/g, (m, a, b) => (a > b ? "" : m)).replace(/\\/g, "\\\\");
|
|
45
|
+
if (body.startsWith("!"))
|
|
46
|
+
body = "^" + body.slice(1).replace(/[[\]]/g, "\\$&");
|
|
47
|
+
else
|
|
48
|
+
body = (body.startsWith("^") ? "\\" : "") + body.replace(/[[\]]/g, "\\$&"); // "^": a literal, as fnmatch reads it
|
|
49
|
+
re += `[${body}]`;
|
|
50
|
+
i = end;
|
|
51
|
+
}
|
|
52
|
+
else
|
|
53
|
+
re += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
54
|
+
}
|
|
55
|
+
// "s": * and ? match a newline too, as in fnmatch.
|
|
56
|
+
try {
|
|
57
|
+
return new RegExp(`^${re}$`, "s").test(name);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The firewall predicate exactly as PostgreSQL deparses it for this table: a
|
|
65
|
+
* policy with the migration's predicate on a temporary copy of the table's
|
|
66
|
+
* columns, read back, then rolled back. Exact comparison catches a changed
|
|
67
|
+
* cast, constant or function overload.
|
|
68
|
+
*/
|
|
69
|
+
async function reference(client, rel, column, context) {
|
|
70
|
+
const pred = predicate(column, context);
|
|
71
|
+
await client.query("BEGIN");
|
|
72
|
+
try {
|
|
73
|
+
await client.query(`CREATE TEMP TABLE _fw_ref (LIKE ${rel})`);
|
|
74
|
+
await client.query(`CREATE POLICY _fw_ref_p ON pg_temp._fw_ref USING (${pred}) WITH CHECK (${pred})`);
|
|
75
|
+
const { rows } = await client.query(q("reference_qual"));
|
|
76
|
+
return rows[0] ? [rows[0].qual, null] : [null, "no result"];
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
return [null, String(err.message).split("\n")[0] ?? "error"];
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
await client.query("ROLLBACK");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const norm = (x) => x.replace(/\s+/g, " ").trim();
|
|
86
|
+
/**
|
|
87
|
+
* Every function the migration creates must match it (body, SECURITY DEFINER,
|
|
88
|
+
* settings, volatility, language, return type), with no extra db_firewall._firewall_*
|
|
89
|
+
* function: the policies call some of them, others sign, and a changed body
|
|
90
|
+
* (say, a session-setting "maintenance switch") opens every table while the
|
|
91
|
+
* policies still look exact. Every db_firewall._firewall_* object must have one
|
|
92
|
+
* owner, or only superusers: an object another role planted keeps its owner.
|
|
93
|
+
*/
|
|
94
|
+
export async function firewallObjects(client) {
|
|
95
|
+
const sql = readFileSync(MIGRATION, "utf8");
|
|
96
|
+
const want = [...sql.matchAll(/CREATE OR REPLACE FUNCTION db_firewall\.(_firewall_\w+)\(([^)]*)\)\s*RETURNS (\w+) AS \$\$([\s\S]*?)\$\$ LANGUAGE (\w+)([^;]*);/g)]
|
|
97
|
+
.map(([, name, args, ret, body, lang, tail]) => ({
|
|
98
|
+
name: name, sig: args.split(",").map((a) => a.trim().split(/\s+/)[1]?.toLowerCase()).filter(Boolean).join(", "),
|
|
99
|
+
ret: ret.toLowerCase(), body: body, lang: lang.toLowerCase(), tail: tail,
|
|
100
|
+
}));
|
|
101
|
+
const diff = [];
|
|
102
|
+
for (const w of want) {
|
|
103
|
+
const { rows: [f] } = await client.query(q("fw_function"), [`db_firewall.${w.name}(${w.sig})`]);
|
|
104
|
+
if (!f) {
|
|
105
|
+
diff.push(`${w.name} (missing)`);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const path = /SET search_path = ([^\n;]+?)\s*$/m.exec(w.tail)?.[1];
|
|
109
|
+
const what = [
|
|
110
|
+
norm(f.prosrc) !== norm(w.body) && "body",
|
|
111
|
+
f.prosecdef !== /SECURITY DEFINER/.test(w.tail) && "SECURITY DEFINER",
|
|
112
|
+
JSON.stringify(f.proconfig) !== JSON.stringify(path ? [`search_path=${path}`] : null) && "settings",
|
|
113
|
+
f.provolatile !== (/STABLE/.test(w.tail) ? "s" : /IMMUTABLE/.test(w.tail) ? "i" : "v") && "volatility",
|
|
114
|
+
(f.lanname !== w.lang || f.ret !== w.ret) && "language or return type",
|
|
115
|
+
].filter(Boolean);
|
|
116
|
+
if (what.length)
|
|
117
|
+
diff.push(`${w.name} (${what.join(", ")})`);
|
|
118
|
+
}
|
|
119
|
+
const { rows: extra } = await client.query(q("fw_extra"), [want.map((w) => `${w.name}(${w.sig})`)]);
|
|
120
|
+
if (extra.length)
|
|
121
|
+
diff.push(`extra ${extra.map((e) => e.fn).join(", ")}`);
|
|
122
|
+
const { rows: owners } = await client.query(q("fw_owners"));
|
|
123
|
+
const mixed = owners.length > 1 && owners.some((o) => !o.super);
|
|
124
|
+
if (mixed) {
|
|
125
|
+
diff.push(`more than one owner (${owners.map((o) => `${o.owner}: ${o.objects}`).join("; ")}): ` +
|
|
126
|
+
"objects another role planted? drop those and re-run the migration");
|
|
127
|
+
}
|
|
128
|
+
return { ok: diff.length === 0, detail: diff.length
|
|
129
|
+
? `differ from the migration: ${diff.join("; ")}${mixed ? "" : ": re-run the migration"}`
|
|
130
|
+
: "match the migration, one owner" };
|
|
131
|
+
}
|
|
132
|
+
/** Run fn with search_path = pg_catalog, pg_temp, so a planted operator in
|
|
133
|
+
* another schema cannot hide in how a policy reads, then put it back. */
|
|
134
|
+
export async function withCatalogPath(client, fn) {
|
|
135
|
+
const { rows: [r] } = await client.query("SELECT current_setting('search_path') AS old, set_config('search_path', 'pg_catalog, pg_temp', false)");
|
|
136
|
+
try {
|
|
137
|
+
return await fn();
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
await client.query("SELECT set_config('search_path', $1, false)", [r.old]);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** Scan every table in the schemas that has the tenant column. */
|
|
144
|
+
export function scan(client, opts = {}) {
|
|
145
|
+
return withCatalogPath(client, () => scanTables(client, opts));
|
|
146
|
+
}
|
|
147
|
+
async function scanTables(client, opts) {
|
|
148
|
+
const context = opts.context ?? "signed";
|
|
149
|
+
// The predicate calls these: when they differ, no table is protected.
|
|
150
|
+
const objects = await firewallObjects(client).catch((e) => ({ ok: false, detail: e.message }));
|
|
151
|
+
const schemas = opts.schemas?.length ? opts.schemas : ["public"];
|
|
152
|
+
const column = opts.column ?? "tenant_id";
|
|
153
|
+
const exclude = opts.exclude ?? DEFAULT_EXCLUDED;
|
|
154
|
+
const out = [], excluded = [];
|
|
155
|
+
for (const schema of schemas) {
|
|
156
|
+
const { rows } = await client.query(q("scan_tables"), [schema, column]);
|
|
157
|
+
for (const r of rows) {
|
|
158
|
+
if (exclude.some((p) => globMatch(p, r.table))) {
|
|
159
|
+
excluded.push(`${schema}.${r.table}`);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const policies = r.policies;
|
|
163
|
+
const [ref, why] = policies.length ? await reference(client, r.ref_rel, column, context) : [null, null];
|
|
164
|
+
const unverified = policies.length > 0 && ref === null;
|
|
165
|
+
const alteredFrom = (want) => policies.filter(([name, cmd, roles, qual, check]) => {
|
|
166
|
+
const op = name.slice(name.lastIndexOf("_") + 1);
|
|
167
|
+
const wantQual = ["sel", "upd", "del"].includes(op) ? want : null;
|
|
168
|
+
const wantCheck = ["ins", "upd"].includes(op) ? want : null;
|
|
169
|
+
return !(roles.length === 1 && roles[0] === "public") || cmd !== CMD[op]
|
|
170
|
+
|| qual !== wantQual || check !== wantCheck;
|
|
171
|
+
}).map(([name]) => name).sort();
|
|
172
|
+
const altered = ref === null ? [] : alteredFrom(ref);
|
|
173
|
+
// Policies in the other context count as altered; the report names it.
|
|
174
|
+
let found = policies.length && !unverified && !altered.length ? context : null;
|
|
175
|
+
if (policies.length && !unverified && altered.length) {
|
|
176
|
+
const other = context === "signed" ? "plain" : "signed";
|
|
177
|
+
const [ref2] = await reference(client, r.ref_rel, column, other);
|
|
178
|
+
found = ref2 !== null && !alteredFrom(ref2).length ? other : "altered";
|
|
179
|
+
}
|
|
180
|
+
const extra = [...r.extra].sort();
|
|
181
|
+
out.push({
|
|
182
|
+
schema, table: r.table, has_tenant_col: true, has_rls_policy: r.missing.length === 0, owner: r.owner,
|
|
183
|
+
rls_enabled: r.enabled, rls_forced: r.forced,
|
|
184
|
+
policy_mode: r.restrictive ? "restrictive" : "permissive",
|
|
185
|
+
missing_policies: [...r.missing].sort(),
|
|
186
|
+
extra_permissive_policies: extra,
|
|
187
|
+
altered_policies: altered,
|
|
188
|
+
tenant_context: found,
|
|
189
|
+
stale_base_policy: r.base && extra.some((e) => !e.endsWith("_base")),
|
|
190
|
+
predicate_check: unverified
|
|
191
|
+
? `unavailable: ${why} (run outside a read-only transaction, with the TEMP privilege and SELECT on the table)`
|
|
192
|
+
: !objects.ok ? `firewall functions ${objects.detail}` : "ok",
|
|
193
|
+
protected: r.enabled && r.forced && r.missing.length === 0 && altered.length === 0 && !unverified
|
|
194
|
+
&& objects.ok && (r.restrictive || extra.length === 0),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return { tables: out, excluded };
|
|
199
|
+
}
|
package/dist/verify.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type pg from "pg";
|
|
2
|
+
import { type GlobalCheck } from "./checks.js";
|
|
3
|
+
import { type TableReport } from "./scan.js";
|
|
4
|
+
/** ok: true pass, false fail, null inconclusive (counted separately, never as a pass). */
|
|
5
|
+
export interface Check {
|
|
6
|
+
table: string;
|
|
7
|
+
check: string;
|
|
8
|
+
ok: boolean | null;
|
|
9
|
+
detail: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The global checks of the shared checks file (@check and @info blocks), in
|
|
13
|
+
* file order, then the firewall functions. The "strict:" ones run only with
|
|
14
|
+
* --strict, after the table checks. One client runs one query at a time.
|
|
15
|
+
*/
|
|
16
|
+
export declare function roleChecks(client: pg.ClientBase): Promise<Check[]>;
|
|
17
|
+
/** One @check or @info block: ok only when the row says true (NULL fails). */
|
|
18
|
+
export declare function fileCheck(client: pg.ClientBase, c: GlobalCheck): Promise<Check>;
|
|
19
|
+
type VerifyOpts = {
|
|
20
|
+
tenant: string;
|
|
21
|
+
key: Buffer;
|
|
22
|
+
schemas?: string[];
|
|
23
|
+
column?: string;
|
|
24
|
+
exclude?: string[];
|
|
25
|
+
writeProbe?: boolean;
|
|
26
|
+
/** Allowlist mode: see strictChecks(). */
|
|
27
|
+
strict?: boolean;
|
|
28
|
+
allowGlobal?: string[];
|
|
29
|
+
/** --strict: accept that the role reads other sessions' SQL (an info line instead). */
|
|
30
|
+
allowQueryText?: boolean;
|
|
31
|
+
};
|
|
32
|
+
type VerifyResult = {
|
|
33
|
+
checks: Check[];
|
|
34
|
+
tables: TableReport[];
|
|
35
|
+
excluded: string[];
|
|
36
|
+
};
|
|
37
|
+
/** Every check runs with search_path = pg_catalog, pg_temp: a planted "="
|
|
38
|
+
* must not make "tenant sees only its rows" count another tenant's rows as own. */
|
|
39
|
+
export declare function verify(client: pg.ClientBase, opts: VerifyOpts): Promise<VerifyResult>;
|
|
40
|
+
export {};
|
package/dist/verify.js
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { sign } from "./key.js";
|
|
2
|
+
import { CHECKS, q } from "./checks.js";
|
|
3
|
+
import { firewallObjects, globMatch, ident, scan, withCatalogPath } from "./scan.js";
|
|
4
|
+
const RLS_VIOLATION = "42501"; // insufficient_privilege: what an RLS refusal raises
|
|
5
|
+
const MARK = "__dbfw_verify_other__";
|
|
6
|
+
/**
|
|
7
|
+
* The global checks of the shared checks file (@check and @info blocks), in
|
|
8
|
+
* file order, then the firewall functions. The "strict:" ones run only with
|
|
9
|
+
* --strict, after the table checks. One client runs one query at a time.
|
|
10
|
+
*/
|
|
11
|
+
export async function roleChecks(client) {
|
|
12
|
+
const out = [];
|
|
13
|
+
for (const c of CHECKS().checks.filter((x) => !x.name.startsWith("strict:")))
|
|
14
|
+
out.push(await fileCheck(client, c));
|
|
15
|
+
// The policies call these (and they sign): see firewallObjects().
|
|
16
|
+
out.push(await guard("firewall functions unchanged", () => firewallObjects(client)));
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
/** One @check or @info block: ok only when the row says true (NULL fails). */
|
|
20
|
+
export async function fileCheck(client, c) {
|
|
21
|
+
const run = async () => {
|
|
22
|
+
const { rows: [r] } = await client.query(c.sql);
|
|
23
|
+
return { ok: r.ok === true, detail: String(r.detail) };
|
|
24
|
+
};
|
|
25
|
+
return c.info ? info(c.name, run) : guard(c.name, run);
|
|
26
|
+
}
|
|
27
|
+
/** An information line: always ok, also when its query fails (say, an
|
|
28
|
+
* unreadable catalog), so it can never change verify's result. */
|
|
29
|
+
async function info(check, fn) {
|
|
30
|
+
try {
|
|
31
|
+
return { table: "-", check, ...await fn(), ok: true };
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
return { table: "-", check, ok: true, detail: `could not read: ${msg(err)}` };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** A global check; a query error fails it (a precondition that cannot be checked is not met). */
|
|
38
|
+
async function guard(check, fn) {
|
|
39
|
+
try {
|
|
40
|
+
return { table: "-", check, ...await fn() };
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
return { table: "-", check, ok: false, detail: `could not check (is the migration installed?): ${msg(err)}` };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Run fn in a transaction with that context, and always roll back. */
|
|
47
|
+
async function inTx(client, ctx, fn) {
|
|
48
|
+
await client.query("BEGIN");
|
|
49
|
+
try {
|
|
50
|
+
if (ctx?.key) {
|
|
51
|
+
const { rows: [r] } = await client.query(q("nonce"));
|
|
52
|
+
await client.query("SELECT set_config('app.current_tenant', $1, true), set_config('app.tenant_sig', $2, true)", [ctx.tenant, sign(ctx.key, r.n, ctx.tenant)]);
|
|
53
|
+
}
|
|
54
|
+
else if (ctx) {
|
|
55
|
+
await client.query("SELECT set_config('app.current_tenant', $1, true)", [ctx.tenant]);
|
|
56
|
+
}
|
|
57
|
+
return await fn();
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
await client.query("ROLLBACK");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Every check runs with search_path = pg_catalog, pg_temp: a planted "="
|
|
64
|
+
* must not make "tenant sees only its rows" count another tenant's rows as own. */
|
|
65
|
+
export function verify(client, opts) {
|
|
66
|
+
return withCatalogPath(client, () => verifyTables(client, opts));
|
|
67
|
+
}
|
|
68
|
+
async function verifyTables(client, opts) {
|
|
69
|
+
const signed = { tenant: opts.tenant, key: opts.key };
|
|
70
|
+
const schemas = opts.schemas?.length ? opts.schemas : ["public"];
|
|
71
|
+
const column = opts.column ?? "tenant_id";
|
|
72
|
+
const checks = await roleChecks(client);
|
|
73
|
+
const { tables, excluded } = await scan(client, { schemas, column, exclude: opts.exclude, context: "signed" });
|
|
74
|
+
if (!tables.length) {
|
|
75
|
+
checks.push({ table: "-", check: "tables found", ok: false,
|
|
76
|
+
detail: `no table with column ${column} in ${schemas.join(", ")} (wrong --column or --schema?)` });
|
|
77
|
+
}
|
|
78
|
+
let ownRows = 0;
|
|
79
|
+
for (const t of tables) {
|
|
80
|
+
const label = `${t.schema}.${t.table}`, name = `${ident(t.schema)}.${ident(t.table)}`;
|
|
81
|
+
// Privileges RLS does not cover: the owner can turn RLS off; TRUNCATE ignores it.
|
|
82
|
+
const { rows: [priv] } = await client.query(q("table_privileges"), [name, column]);
|
|
83
|
+
checks.push({ table: label, check: "role does not own the table", ok: !priv.owns,
|
|
84
|
+
detail: priv.owns ? `owned by ${t.owner}, which this role can act as: it can disable RLS`
|
|
85
|
+
: `owned by ${t.owner}` });
|
|
86
|
+
checks.push({ table: label, check: "no TRUNCATE", ok: !priv.truncate,
|
|
87
|
+
detail: priv.truncate ? "TRUNCATE is granted and RLS does not apply to it" : "not granted" });
|
|
88
|
+
checks.push({ table: label, check: "policies", ok: t.protected,
|
|
89
|
+
detail: t.protected ? `${t.policy_mode} policies in place` : summary(t) });
|
|
90
|
+
if (!priv.can_select) {
|
|
91
|
+
// No direct reads (e.g. a partition read through its parent): the policies
|
|
92
|
+
// above and the view and function checks below cover the other paths.
|
|
93
|
+
checks.push(priv.some_role_selects
|
|
94
|
+
? { table: label, check: "row checks", ok: null,
|
|
95
|
+
detail: `readable only after SET ROLE to another role: run verify as that role` }
|
|
96
|
+
: { table: label, check: "row checks", ok: true, detail: `no role it can use can SELECT ${column}: no direct reads` });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
// No tenant: no rows at all (fail closed).
|
|
101
|
+
const count = async () => Number((await client.query(`SELECT count(*) AS n FROM ${name}`)).rows[0].n);
|
|
102
|
+
const none = await inTx(client, null, count);
|
|
103
|
+
checks.push({ table: label, check: "no tenant sees nothing", ok: none === 0, detail: `${none} rows visible` });
|
|
104
|
+
// The tenant set without a signature, as SQL the app (or an agent) runs could set it.
|
|
105
|
+
const unsigned = await inTx(client, { tenant: opts.tenant }, count);
|
|
106
|
+
checks.push({ table: label, check: "unsigned tenant sees nothing", ok: unsigned === 0,
|
|
107
|
+
detail: unsigned ? `${unsigned} rows visible: SQL the app runs can switch tenants` : "0 rows" });
|
|
108
|
+
// As the tenant: every visible row is the tenant's.
|
|
109
|
+
const [foreign, own] = await inTx(client, signed, async () => {
|
|
110
|
+
const { rows: [r] } = await client.query(`SELECT count(*) FILTER (WHERE ${ident(column)} IS DISTINCT FROM $1) AS foreign,
|
|
111
|
+
count(*) FILTER (WHERE ${ident(column)} = $1) AS own FROM ${name}`, [opts.tenant]);
|
|
112
|
+
return [Number(r.foreign), Number(r.own)];
|
|
113
|
+
});
|
|
114
|
+
ownRows += own;
|
|
115
|
+
checks.push({ table: label, check: "tenant sees only its rows", ok: foreign === 0,
|
|
116
|
+
detail: `${own} own rows, ${foreign} rows of another tenant visible` });
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
checks.push({ table: label, check: "row checks", ok: null, detail: `query failed: ${msg(err)}` });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (opts.writeProbe)
|
|
123
|
+
checks.push(await writeProbe(client, label, name, column, signed));
|
|
124
|
+
}
|
|
125
|
+
if (tables.length && ownRows === 0) {
|
|
126
|
+
checks.push({ table: "-", check: "tenant has rows", ok: false,
|
|
127
|
+
detail: `tenant ${opts.tenant} sees no rows in any table, so the row checks prove nothing: use a tenant that has data, and check that ` +
|
|
128
|
+
`DB_FIREWALL_TENANT_KEY is the database's key (db-firewall key)` });
|
|
129
|
+
}
|
|
130
|
+
checks.push(await bypassObjects(client, column, opts.allowGlobal ?? []));
|
|
131
|
+
checks.push(await otherSchemas(client, schemas, column));
|
|
132
|
+
checks.push(await keysCheck(client, tables.filter((t) => t.protected), column, !!opts.strict));
|
|
133
|
+
if (opts.strict) {
|
|
134
|
+
checks.push(...await strictChecks(client, tables.filter((t) => t.protected), opts.allowGlobal ?? [], !!opts.allowQueryText));
|
|
135
|
+
}
|
|
136
|
+
else
|
|
137
|
+
checks.push(await queryText(client, false));
|
|
138
|
+
return { checks, tables, excluded };
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Opt-in (--write-probe): insert a row for another tenant as --tenant, which
|
|
142
|
+
* RLS must refuse. Rolled back, but it fires BEFORE INSERT triggers and uses
|
|
143
|
+
* up a sequence value even when refused.
|
|
144
|
+
*/
|
|
145
|
+
async function writeProbe(client, label, name, column, ctx) {
|
|
146
|
+
try {
|
|
147
|
+
await inTx(client, ctx, () => client.query(`INSERT INTO ${name} (${ident(column)}) VALUES ($1)`, [MARK]));
|
|
148
|
+
return { table: label, check: "cross-tenant insert refused", ok: false,
|
|
149
|
+
detail: "a row for another tenant was accepted (rolled back)" };
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
const code = err.code;
|
|
153
|
+
return code === RLS_VIOLATION && /row-level security/i.test(msg(err))
|
|
154
|
+
? { table: label, check: "cross-tenant insert refused", ok: true, detail: "refused by RLS" }
|
|
155
|
+
: { table: label, check: "cross-tenant insert refused", ok: null, detail: `inconclusive: ${msg(err)}` };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Read paths RLS does not guard, in any schema, that the role can use: the
|
|
160
|
+
* @query bypass_* blocks of the checks file (views and materialized views,
|
|
161
|
+
* foreign tables, rules, functions that run as their owner or LEAKPROOF).
|
|
162
|
+
*/
|
|
163
|
+
function bypassObjects(client, column, allow) {
|
|
164
|
+
return guard("no RLS-bypassing views or functions", async () => {
|
|
165
|
+
const { rows: views } = await client.query(q("bypass_views"), [column]);
|
|
166
|
+
const { rows: foreign } = await client.query(q("bypass_foreign"), [column]);
|
|
167
|
+
const { rows: rules } = await client.query(q("bypass_rules"));
|
|
168
|
+
const { rows: fns } = await client.query(q("bypass_functions"));
|
|
169
|
+
// --allow-global clears only what may hold shared data by design: a function
|
|
170
|
+
// (named with its arguments) or a foreign table without the tenant column.
|
|
171
|
+
// Never a view over a tenant table or a rule.
|
|
172
|
+
const list = [
|
|
173
|
+
...views.map((v) => `${v.matview ? "materialized view" : "view"} ${v.name} (owner ${v.owner})`),
|
|
174
|
+
...foreign.filter((f) => f.tenant || !allowedRel(allow, f.name)).map((f) => `foreign table ${f.name}`),
|
|
175
|
+
...rules.map((r) => `rule ${r.name} (use a trigger)`),
|
|
176
|
+
...fns.filter((f) => !allowedFn(allow, f.name))
|
|
177
|
+
.map((f) => `${f.leakproof ? "LEAKPROOF" : "SECURITY DEFINER"} function ${f.name} (owner ${f.owner})`),
|
|
178
|
+
];
|
|
179
|
+
return { ok: list.length === 0,
|
|
180
|
+
detail: list.length ? `the role can read through: ${list.join("; ")}` : "none found" };
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
// --allow-global: an entry with "(" names functions with their arguments
|
|
184
|
+
// (schema.name(argtypes), as verify prints them; "app.f(*)" for every
|
|
185
|
+
// overload); one without names relations. "app.*" never clears a function.
|
|
186
|
+
const allowedRel = (globs, name) => globs.some((g) => !g.includes("(") && globMatch(g, name));
|
|
187
|
+
const allowedFn = (globs, sig) => globs.some((g) => g.includes("(") && globMatch(g, sig));
|
|
188
|
+
const listed = (names) => names.slice(0, 25).join(", ") + (names.length > 25 ? `, and ${names.length - 25} more` : "");
|
|
189
|
+
/** @query query_text. --strict fails on it (unless --allow-query-text); else an information line. */
|
|
190
|
+
function queryText(client, strict) {
|
|
191
|
+
const check = async () => {
|
|
192
|
+
const { rows: [r] } = await client.query(q("query_text"));
|
|
193
|
+
return { ok: !r.can, detail: r.can
|
|
194
|
+
? "yes: SQL one tenant runs can read the query text of another tenant's session (pg_stat_activity, " +
|
|
195
|
+
"pg_stat_statements), literal values included (send values as parameters; migrate --harden revokes it, " +
|
|
196
|
+
"or --allow-query-text where only the provider can)" : "no (pg_stat_activity and pg_stat_statements revoked)" };
|
|
197
|
+
};
|
|
198
|
+
return strict ? guard("strict: cannot read other sessions' SQL", check)
|
|
199
|
+
: info("reads other sessions' SQL (info)", check);
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* --strict: an allowlist instead of a list of known leak paths. Everything a
|
|
203
|
+
* role the app can use can read must be protected tenant data (see @query
|
|
204
|
+
* strict_readable); anything else needs --allow-global schema.name. The
|
|
205
|
+
* "strict:" @check blocks of the file, no SECURITY DEFINER function outside
|
|
206
|
+
* the firewall's own unless allowlisted, and no other sessions' SQL.
|
|
207
|
+
*/
|
|
208
|
+
async function strictChecks(client, protectedTables, allow, allowQueryText) {
|
|
209
|
+
const readable = () => guard("strict: only tenant data readable", async () => {
|
|
210
|
+
const { rows } = await client.query(q("strict_readable"), [JSON.stringify(protectedTables.map((t) => [t.schema, t.table]))]);
|
|
211
|
+
// --allow-query-text also accepts the views of the pg_stat_statements extension.
|
|
212
|
+
const bad = rows.filter((r) => !allowedRel(allow, r.name) && !(allowQueryText && r.extension === "pg_stat_statements") && !(r.safe || (r.invoker && !r.broken && r.blockers.every((b) => allowedRel(allow, b)))))
|
|
213
|
+
.map((r) => r.name).sort();
|
|
214
|
+
return { ok: bad.length === 0, detail: bad.length
|
|
215
|
+
? `readable but not protected tenant data: ${listed(bad)} (protect them, revoke, or --allow-global schema.name)`
|
|
216
|
+
: "everything readable is protected tenant data or allowlisted" };
|
|
217
|
+
});
|
|
218
|
+
const definer = () => guard("strict: no SECURITY DEFINER functions", async () => {
|
|
219
|
+
const { rows } = await client.query(q("strict_definer"));
|
|
220
|
+
const bad = rows.filter((r) => !allowedFn(allow, r.sig)).map((r) => r.sig);
|
|
221
|
+
return { ok: bad.length === 0, detail: bad.length
|
|
222
|
+
? `runs as its owner: ${listed(bad)} (drop it or make it SECURITY INVOKER, or --allow-global 'schema.name(args)')`
|
|
223
|
+
: "none outside the firewall's own (or allowlisted)" };
|
|
224
|
+
});
|
|
225
|
+
const out = [await readable()];
|
|
226
|
+
for (const c of CHECKS().checks.filter((x) => x.name.startsWith("strict:")))
|
|
227
|
+
out.push(await fileCheck(client, c));
|
|
228
|
+
out.push(await definer(), await queryText(client, !allowQueryText));
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
/** @query keys_without_tenant: a side channel, so --strict fails on it; else an information line. */
|
|
232
|
+
function keysCheck(client, protectedTables, column, strict) {
|
|
233
|
+
const check = async () => {
|
|
234
|
+
const { rows } = await client.query(q("keys_without_tenant"), [JSON.stringify(protectedTables.map((t) => [t.schema, t.table])), column]);
|
|
235
|
+
const items = rows.map((r) => r.item);
|
|
236
|
+
return { ok: items.length === 0, detail: items.length
|
|
237
|
+
? `without the tenant column: ${listed(items)} (a duplicate, or a missing parent, tells the app that another ` +
|
|
238
|
+
`tenant has the value: put ${column} in the key)`
|
|
239
|
+
: `every key includes ${column}` };
|
|
240
|
+
};
|
|
241
|
+
return strict ? guard("strict: keys include the tenant", check) : info("keys include the tenant (info)", check);
|
|
242
|
+
}
|
|
243
|
+
/** Tables with the tenant column outside the scanned schemas that the role can read. */
|
|
244
|
+
function otherSchemas(client, schemas, column) {
|
|
245
|
+
return guard("no tenant tables in other schemas", async () => {
|
|
246
|
+
const { rows } = await client.query(q("other_schemas"), [schemas, column]);
|
|
247
|
+
return { ok: rows.length === 0,
|
|
248
|
+
detail: rows.length ? `not checked, readable: ${rows.map((r) => r.name).join(", ")} (add --schema)`
|
|
249
|
+
: "none readable" };
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
function summary(t) {
|
|
253
|
+
if (t.tenant_context === "plain")
|
|
254
|
+
return "plain tenant context: SQL the app runs can switch tenants (re-run migrate)";
|
|
255
|
+
const parts = [];
|
|
256
|
+
if (!t.rls_enabled)
|
|
257
|
+
parts.push("RLS off");
|
|
258
|
+
else if (!t.rls_forced)
|
|
259
|
+
parts.push("RLS not forced");
|
|
260
|
+
if (t.missing_policies.length)
|
|
261
|
+
parts.push(`missing ${t.missing_policies.join(",")}`);
|
|
262
|
+
if (t.altered_policies.length)
|
|
263
|
+
parts.push(`altered ${t.altered_policies.join(",")}`);
|
|
264
|
+
if (t.policy_mode === "permissive" && t.extra_permissive_policies.length) {
|
|
265
|
+
parts.push(`extra permissive ${t.extra_permissive_policies.join(",")}`);
|
|
266
|
+
}
|
|
267
|
+
if (t.predicate_check !== "ok")
|
|
268
|
+
parts.push(t.predicate_check);
|
|
269
|
+
return parts.join("; ") || "not protected";
|
|
270
|
+
}
|
|
271
|
+
const msg = (err) => String(err.message).split("\n")[0] ?? "error";
|
package/package.json
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "db-firewall",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "db-firewall",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DB Firewall CLI for PostgreSQL: install the tenant RLS policies, scan them, and verify isolation as your app's role.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"db-firewall": "./dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": ["dist", "sql", "README.md"],
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=20"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "node scripts/copy-sql.mjs && tsc -p tsconfig.json",
|
|
16
|
+
"test": "npm run build && node --test --test-concurrency=1 \"test/**/*.test.mjs\"",
|
|
17
|
+
"prepack": "npm run build"
|
|
18
|
+
},
|
|
19
|
+
"keywords": ["postgres", "rls", "row-level-security", "multi-tenant", "ai-agent", "cli", "security-scanner"],
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/zackbear/DB-firewall.git",
|
|
23
|
+
"directory": "packages/cli"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"pg": "8.23.1"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "22.20.5",
|
|
30
|
+
"@types/pg": "8.23.1",
|
|
31
|
+
"typescript": "5.9.3"
|
|
32
|
+
}
|
|
33
|
+
}
|