@everystack/cli 0.2.37 → 0.3.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 +681 -0
- package/README.md +33 -6
- package/package.json +28 -9
- package/src/cli/authz-compile.ts +212 -0
- package/src/cli/authz-contract-io.ts +92 -0
- package/src/cli/authz-contract.ts +589 -0
- package/src/cli/authz-owner-probe.ts +200 -0
- package/src/cli/authz-reconcile.ts +127 -0
- package/src/cli/authz-redteam.ts +251 -0
- package/src/cli/authz-render.ts +248 -0
- package/src/cli/aws.ts +100 -0
- package/src/cli/backup.ts +99 -0
- package/src/cli/commands/db-authz.ts +292 -0
- package/src/cli/commands/db-backup.ts +218 -0
- package/src/cli/commands/db-generate.ts +133 -0
- package/src/cli/commands/db-pull.ts +83 -0
- package/src/cli/commands/db-snapshot.ts +113 -0
- package/src/cli/commands/db.ts +215 -5
- package/src/cli/commands/deploy.ts +46 -0
- package/src/cli/commands/logs.ts +67 -33
- package/src/cli/commands/security.ts +185 -0
- package/src/cli/config.ts +8 -0
- package/src/cli/index.ts +85 -2
- package/src/cli/migration-compile.ts +69 -0
- package/src/cli/migration-generate.ts +175 -0
- package/src/cli/model-api.ts +35 -0
- package/src/cli/model-render.ts +262 -0
- package/src/cli/pg-ident.ts +59 -0
- package/src/cli/rds-snapshot.ts +47 -0
- package/src/cli/schema-compile.ts +439 -0
- package/src/cli/schema-diff.ts +605 -0
- package/src/cli/schema-introspect.ts +425 -0
- package/src/cli/security-audit.ts +405 -0
- package/src/cli/security-catalog.ts +170 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* security:audit — the gate behind the two-surface security model.
|
|
3
|
+
*
|
|
4
|
+
* everystack's automatic authorization floor is RLS, but RLS only self-enforces on
|
|
5
|
+
* the table/REST surface. A `SECURITY DEFINER` function runs as its owner, *above*
|
|
6
|
+
* RLS, gated only by its EXECUTE grant and whatever its body hand-writes — that is
|
|
7
|
+
* where the `get_user`-returns-someone-else's-email class of leak lives. This module
|
|
8
|
+
* is the pure classifier that makes the dangerous shape impossible to mis-author by
|
|
9
|
+
* accident: it goes RED on any data-returning definer function (and on a public view
|
|
10
|
+
* that exposes a non-public column), so CI fails before the leak ships.
|
|
11
|
+
*
|
|
12
|
+
* See docs/plans/nothing-skips-rls.md and docs/security.md ("Auditing the surface").
|
|
13
|
+
*
|
|
14
|
+
* Pure and side-effect-free. Two inputs feed the same classifier:
|
|
15
|
+
* - static: parsed from migration SQL (pre-merge, no database)
|
|
16
|
+
* - catalog: introspected from pg_proc / pg_views / pg_auth_members (post-deploy)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type Severity = 'red' | 'warn' | 'ok';
|
|
20
|
+
|
|
21
|
+
/** How a function's return type maps onto the leak model. */
|
|
22
|
+
export type ReturnClass = 'void' | 'scalar' | 'rows';
|
|
23
|
+
|
|
24
|
+
export interface FunctionDescriptor {
|
|
25
|
+
schema: string;
|
|
26
|
+
name: string;
|
|
27
|
+
/** Raw return type text, for display (e.g. "jsonb", "SETOF users", "void"). */
|
|
28
|
+
returnType: string;
|
|
29
|
+
/**
|
|
30
|
+
* Precise return class when the source knows it (the catalog distinguishes a
|
|
31
|
+
* composite rowtype return from a scalar; static parsing cannot). When absent the
|
|
32
|
+
* class is derived from `returnType`.
|
|
33
|
+
*/
|
|
34
|
+
returnClassHint?: ReturnClass;
|
|
35
|
+
/** True for SECURITY DEFINER; false for INVOKER (the default). */
|
|
36
|
+
securityDefiner: boolean;
|
|
37
|
+
/** True when the function pins `SET search_path`. */
|
|
38
|
+
hasSearchPath: boolean;
|
|
39
|
+
/** Owning role (catalog path only — static SQL parsing can't know it). */
|
|
40
|
+
owner?: string;
|
|
41
|
+
/**
|
|
42
|
+
* True when the owner runs above RLS (superuser, BYPASSRLS, or rds_superuser member).
|
|
43
|
+
* A SECURITY DEFINER function owned by such a role is the maximum blast radius.
|
|
44
|
+
*/
|
|
45
|
+
ownerBypassesRls?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface FunctionFinding {
|
|
49
|
+
/** `schema.name` — the waiver-match key. */
|
|
50
|
+
key: string;
|
|
51
|
+
schema: string;
|
|
52
|
+
name: string;
|
|
53
|
+
returnType: string;
|
|
54
|
+
returnClass: ReturnClass;
|
|
55
|
+
severity: Severity;
|
|
56
|
+
reasons: string[];
|
|
57
|
+
waived: boolean;
|
|
58
|
+
waiverReason?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ViewDescriptor {
|
|
62
|
+
schema: string;
|
|
63
|
+
name: string;
|
|
64
|
+
/** Relation kind. Tables are only checked when declared; views are nudged to declare. */
|
|
65
|
+
kind: 'view' | 'table';
|
|
66
|
+
/** Selected columns, or null when they can't be determined (e.g. `SELECT *`). */
|
|
67
|
+
columns: string[] | null;
|
|
68
|
+
/**
|
|
69
|
+
* True when SELECT is granted (directly or through role inheritance) to a broadly
|
|
70
|
+
* reachable role: anon, authenticated, or PUBLIC. The catalog path resolves
|
|
71
|
+
* inheritance via pg_auth_members; the static path sees only direct grants.
|
|
72
|
+
*/
|
|
73
|
+
broadlyExposed: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ViewFinding {
|
|
77
|
+
key: string;
|
|
78
|
+
schema: string;
|
|
79
|
+
name: string;
|
|
80
|
+
severity: Severity;
|
|
81
|
+
reasons: string[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface Waivers {
|
|
85
|
+
/** `schema.name` -> justification. Empty/missing justification is invalid. */
|
|
86
|
+
secdef?: Record<string, string>;
|
|
87
|
+
/** `schema.view` -> declared public column allowlist. */
|
|
88
|
+
publicColumns?: Record<string, string[]>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface AuditReport {
|
|
92
|
+
functions: FunctionFinding[];
|
|
93
|
+
views: ViewFinding[];
|
|
94
|
+
/** Counts across both invariants, excluding waived. */
|
|
95
|
+
red: number;
|
|
96
|
+
warn: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const RETURN_TERMINATORS = [
|
|
100
|
+
'language',
|
|
101
|
+
'security',
|
|
102
|
+
'set',
|
|
103
|
+
'stable',
|
|
104
|
+
'immutable',
|
|
105
|
+
'volatile',
|
|
106
|
+
'as',
|
|
107
|
+
'cost',
|
|
108
|
+
'rows',
|
|
109
|
+
'parallel',
|
|
110
|
+
'strict',
|
|
111
|
+
'leakproof',
|
|
112
|
+
'called',
|
|
113
|
+
'window',
|
|
114
|
+
'support',
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
const BROAD_ROLES = new Set(['anon', 'authenticated', 'public']);
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Map a raw SQL return type onto the leak model.
|
|
121
|
+
* - `void` / `trigger` -> 'void' (writes; nothing returned to a caller)
|
|
122
|
+
* - `SETOF …` / `RETURNS TABLE…` / `record` -> 'rows'
|
|
123
|
+
* - everything else (text, jsonb, uuid, boolean, a composite type) -> 'scalar'
|
|
124
|
+
*
|
|
125
|
+
* Scalar is deliberately treated as data-returning: a `jsonb` or `text` return can
|
|
126
|
+
* carry another user's email. That is the exact incident this gate exists for.
|
|
127
|
+
*/
|
|
128
|
+
export function classifyReturnType(returnType: string): ReturnClass {
|
|
129
|
+
const t = returnType.trim().toLowerCase().replace(/\s+/g, ' ');
|
|
130
|
+
if (t === 'void' || t === 'trigger' || t === '') return 'void';
|
|
131
|
+
if (t.startsWith('setof ')) return 'rows';
|
|
132
|
+
if (t.startsWith('table(') || t.startsWith('table (')) return 'rows';
|
|
133
|
+
if (t === 'record' || t.startsWith('record')) return 'rows';
|
|
134
|
+
return 'scalar';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Apply Invariant A to a single function. INVOKER functions are never flagged. */
|
|
138
|
+
export function classifyFunction(
|
|
139
|
+
fn: FunctionDescriptor,
|
|
140
|
+
waivers: Waivers = {},
|
|
141
|
+
): FunctionFinding {
|
|
142
|
+
const key = `${fn.schema}.${fn.name}`;
|
|
143
|
+
const returnClass = fn.returnClassHint ?? classifyReturnType(fn.returnType);
|
|
144
|
+
const reasons: string[] = [];
|
|
145
|
+
let severity: Severity = 'ok';
|
|
146
|
+
|
|
147
|
+
// INVOKER functions run under RLS — not part of Invariant A.
|
|
148
|
+
if (!fn.securityDefiner) {
|
|
149
|
+
return { key, schema: fn.schema, name: fn.name, returnType: fn.returnType, returnClass, severity: 'ok', reasons, waived: false };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (returnClass === 'rows') {
|
|
153
|
+
reasons.push('SECURITY DEFINER returns rows (table/SETOF/composite/record) above RLS — convert to SECURITY INVOKER over RLS policies');
|
|
154
|
+
severity = 'red';
|
|
155
|
+
} else if (returnClass === 'scalar') {
|
|
156
|
+
reasons.push(`SECURITY DEFINER returns a value (${fn.returnType}) above RLS — a scalar can leak another user's data (e.g. email); convert to SECURITY INVOKER`);
|
|
157
|
+
severity = 'red';
|
|
158
|
+
} else {
|
|
159
|
+
// void / trigger — a write that runs above RLS. The audit can't prove the body's
|
|
160
|
+
// authorization check is current, so it warns rather than passing silently.
|
|
161
|
+
reasons.push('SECURITY DEFINER write runs above RLS — its body authorization check is not verifiable by this audit');
|
|
162
|
+
severity = raise(severity, 'warn');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!fn.hasSearchPath) {
|
|
166
|
+
reasons.push('missing SET search_path — an unpinned definer function is a schema-shadowing escalation vector');
|
|
167
|
+
severity = raise(severity, 'red');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Waivers downgrade a reviewed-and-justified function to OK. A waiver with no
|
|
171
|
+
// justification is itself a finding — opt-out must always carry a reason.
|
|
172
|
+
const waiverReason = waivers.secdef?.[key];
|
|
173
|
+
if (waiverReason !== undefined) {
|
|
174
|
+
if (typeof waiverReason === 'string' && waiverReason.trim().length > 0) {
|
|
175
|
+
return { key, schema: fn.schema, name: fn.name, returnType: fn.returnType, returnClass, severity: 'ok', reasons, waived: true, waiverReason };
|
|
176
|
+
}
|
|
177
|
+
reasons.push('waiver present but missing a justification string — a waiver must explain why the function is safe');
|
|
178
|
+
severity = raise(severity, 'red');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { key, schema: fn.schema, name: fn.name, returnType: fn.returnType, returnClass, severity, reasons, waived: false };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Apply Invariant B to a single view/projection. */
|
|
185
|
+
export function classifyView(view: ViewDescriptor, waivers: Waivers = {}): ViewFinding {
|
|
186
|
+
const key = `${view.schema}.${view.name}`;
|
|
187
|
+
const reasons: string[] = [];
|
|
188
|
+
let severity: Severity = 'ok';
|
|
189
|
+
|
|
190
|
+
if (!view.broadlyExposed) {
|
|
191
|
+
return { key, schema: view.schema, name: view.name, severity, reasons };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const allowlist = waivers.publicColumns?.[key];
|
|
195
|
+
|
|
196
|
+
if (!allowlist) {
|
|
197
|
+
// A broadly-exposed *view* is a deliberate public projection — nudge it to declare
|
|
198
|
+
// its public column set so drift can be caught. Base tables are the normal REST
|
|
199
|
+
// surface (governed by RLS + hiddenColumns); don't warn on every one.
|
|
200
|
+
if (view.kind === 'view') {
|
|
201
|
+
reasons.push('broadly-granted view with no declared public column set — declare its public columns so drift can be caught');
|
|
202
|
+
severity = 'warn';
|
|
203
|
+
}
|
|
204
|
+
return { key, schema: view.schema, name: view.name, severity, reasons };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (view.columns === null) {
|
|
208
|
+
reasons.push('cannot determine projected columns (e.g. SELECT *) — a public projection must select an explicit column list');
|
|
209
|
+
severity = 'red';
|
|
210
|
+
return { key, schema: view.schema, name: view.name, severity, reasons };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const allowed = new Set(allowlist);
|
|
214
|
+
const leaked = view.columns.filter((c) => !allowed.has(c));
|
|
215
|
+
if (leaked.length > 0) {
|
|
216
|
+
reasons.push(`exposes non-public column(s) beyond the declared public set: ${leaked.join(', ')}`);
|
|
217
|
+
severity = 'red';
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return { key, schema: view.schema, name: view.name, severity, reasons };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Run both invariants over a set of descriptors and tally the result. */
|
|
224
|
+
export function audit(
|
|
225
|
+
functions: FunctionDescriptor[],
|
|
226
|
+
views: ViewDescriptor[],
|
|
227
|
+
waivers: Waivers = {},
|
|
228
|
+
): AuditReport {
|
|
229
|
+
const fnFindings = functions.map((f) => classifyFunction(f, waivers));
|
|
230
|
+
const viewFindings = views.map((v) => classifyView(v, waivers));
|
|
231
|
+
const all: { severity: Severity }[] = [...fnFindings, ...viewFindings];
|
|
232
|
+
return {
|
|
233
|
+
functions: fnFindings,
|
|
234
|
+
views: viewFindings,
|
|
235
|
+
red: all.filter((f) => f.severity === 'red').length,
|
|
236
|
+
warn: all.filter((f) => f.severity === 'warn').length,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function raise(current: Severity, next: Severity): Severity {
|
|
241
|
+
const rank: Record<Severity, number> = { ok: 0, warn: 1, red: 2 };
|
|
242
|
+
return rank[next] > rank[current] ? next : current;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
// Static parsing — extract descriptors from migration SQL (pre-merge, no DB).
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
/** Strip `--` line comments and `/* *\/` block comments so they don't confuse parsing. */
|
|
250
|
+
function stripComments(sql: string): string {
|
|
251
|
+
return sql
|
|
252
|
+
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
|
253
|
+
.replace(/--[^\n]*/g, ' ');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function unquoteIdent(raw: string): string {
|
|
257
|
+
return raw.replace(/"/g, '').trim();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Split a qualified identifier `schema.name` (defaulting schema to `public`). */
|
|
261
|
+
function splitQualified(qualified: string): { schema: string; name: string } {
|
|
262
|
+
const parts = unquoteIdent(qualified).split('.');
|
|
263
|
+
if (parts.length >= 2) return { schema: parts[parts.length - 2], name: parts[parts.length - 1] };
|
|
264
|
+
return { schema: 'public', name: parts[0] };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Parse every `CREATE FUNCTION` in a SQL string into descriptors. Paren-aware over
|
|
269
|
+
* the parameter list, so default expressions with commas/quotes don't break it.
|
|
270
|
+
*/
|
|
271
|
+
export function parseFunctionsFromSql(sql: string): FunctionDescriptor[] {
|
|
272
|
+
const src = stripComments(sql);
|
|
273
|
+
const out: FunctionDescriptor[] = [];
|
|
274
|
+
const headerRe = /CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+([a-zA-Z0-9_."]+)\s*\(/gi;
|
|
275
|
+
let m: RegExpExecArray | null;
|
|
276
|
+
|
|
277
|
+
while ((m = headerRe.exec(src)) !== null) {
|
|
278
|
+
const { schema, name } = splitQualified(m[1]);
|
|
279
|
+
|
|
280
|
+
// Walk the parameter list to its matching close paren.
|
|
281
|
+
let depth = 1;
|
|
282
|
+
let i = headerRe.lastIndex;
|
|
283
|
+
for (; i < src.length && depth > 0; i++) {
|
|
284
|
+
if (src[i] === '(') depth++;
|
|
285
|
+
else if (src[i] === ')') depth--;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Options span from after the params to the body delimiter `AS $tag$`
|
|
289
|
+
// (or the statement terminator for a declaration without a body).
|
|
290
|
+
const rest = src.slice(i);
|
|
291
|
+
const bodyMatch = /\bAS\s+\$[A-Za-z0-9_]*\$/i.exec(rest);
|
|
292
|
+
const optionsEnd = bodyMatch ? bodyMatch.index : (rest.indexOf(';') === -1 ? rest.length : rest.indexOf(';'));
|
|
293
|
+
const options = rest.slice(0, optionsEnd);
|
|
294
|
+
|
|
295
|
+
out.push({
|
|
296
|
+
schema,
|
|
297
|
+
name,
|
|
298
|
+
returnType: extractReturnType(options),
|
|
299
|
+
securityDefiner: /\bSECURITY\s+DEFINER\b/i.test(options),
|
|
300
|
+
hasSearchPath: /\bSET\s+search_path\b/i.test(options),
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
headerRe.lastIndex = i;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Pull the return type out of a function's options block. */
|
|
310
|
+
function extractReturnType(options: string): string {
|
|
311
|
+
const m = /\bRETURNS\s+([\s\S]+)/i.exec(options);
|
|
312
|
+
if (!m) return 'void';
|
|
313
|
+
const after = m[1].trim();
|
|
314
|
+
const lower = after.toLowerCase();
|
|
315
|
+
|
|
316
|
+
// SETOF / TABLE(...) / record are recognized as row-returning verbatim.
|
|
317
|
+
// Otherwise capture up to the next function-option keyword.
|
|
318
|
+
let end = after.length;
|
|
319
|
+
for (const kw of RETURN_TERMINATORS) {
|
|
320
|
+
const re = new RegExp(`\\b${kw}\\b`, 'i');
|
|
321
|
+
const idx = lower.search(re);
|
|
322
|
+
if (idx !== -1 && idx < end) end = idx;
|
|
323
|
+
}
|
|
324
|
+
return after.slice(0, end).trim().replace(/\s+/g, ' ');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Parse views and their broad grants from SQL. Static analysis sees only direct
|
|
329
|
+
* grants to anon/authenticated/PUBLIC (no role-inheritance graph).
|
|
330
|
+
*/
|
|
331
|
+
export function parseViewsAndGrantsFromSql(sql: string): ViewDescriptor[] {
|
|
332
|
+
const src = stripComments(sql);
|
|
333
|
+
|
|
334
|
+
// Collect objects granted SELECT/ALL to a broad role.
|
|
335
|
+
const broad = new Set<string>();
|
|
336
|
+
const grantRe = /GRANT\s+([\s\S]+?)\s+ON\s+(?:TABLE\s+)?([a-zA-Z0-9_."]+)\s+TO\s+([a-zA-Z0-9_,"\s]+?)\s*;/gi;
|
|
337
|
+
let g: RegExpExecArray | null;
|
|
338
|
+
while ((g = grantRe.exec(src)) !== null) {
|
|
339
|
+
const privs = g[1].toLowerCase();
|
|
340
|
+
const grantsRead = /\bselect\b/.test(privs) || /\ball\b/.test(privs);
|
|
341
|
+
if (!grantsRead) continue;
|
|
342
|
+
const { schema, name } = splitQualified(g[2]);
|
|
343
|
+
const roles = g[3].split(',').map((r) => unquoteIdent(r).toLowerCase());
|
|
344
|
+
if (roles.some((r) => BROAD_ROLES.has(r))) broad.add(`${schema}.${name}`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const out: ViewDescriptor[] = [];
|
|
348
|
+
const viewRe = /CREATE\s+(?:OR\s+REPLACE\s+)?VIEW\s+([a-zA-Z0-9_."]+)\s+AS\s+([\s\S]*?);/gi;
|
|
349
|
+
let v: RegExpExecArray | null;
|
|
350
|
+
while ((v = viewRe.exec(src)) !== null) {
|
|
351
|
+
const { schema, name } = splitQualified(v[1]);
|
|
352
|
+
out.push({
|
|
353
|
+
schema,
|
|
354
|
+
name,
|
|
355
|
+
kind: 'view',
|
|
356
|
+
columns: extractSelectColumns(v[2]),
|
|
357
|
+
broadlyExposed: broad.has(`${schema}.${name}`),
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return out;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Extract an explicit top-level column list from a SELECT, or null if not determinable. */
|
|
365
|
+
function extractSelectColumns(selectBody: string): string[] | null {
|
|
366
|
+
const m = /SELECT\s+([\s\S]+?)\s+FROM\b/i.exec(selectBody);
|
|
367
|
+
if (!m) return null;
|
|
368
|
+
const list = m[1].trim();
|
|
369
|
+
if (list === '*' || list.includes('*')) return null;
|
|
370
|
+
|
|
371
|
+
// Split on top-level commas (ignore commas inside parens, e.g. coalesce(a, b)).
|
|
372
|
+
const cols: string[] = [];
|
|
373
|
+
let depth = 0;
|
|
374
|
+
let cur = '';
|
|
375
|
+
for (const ch of list) {
|
|
376
|
+
if (ch === '(') depth++;
|
|
377
|
+
else if (ch === ')') depth--;
|
|
378
|
+
if (ch === ',' && depth === 0) {
|
|
379
|
+
cols.push(cur);
|
|
380
|
+
cur = '';
|
|
381
|
+
} else {
|
|
382
|
+
cur += ch;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (cur.trim()) cols.push(cur);
|
|
386
|
+
|
|
387
|
+
// Resolve each item to its output column name: prefer `AS alias`, else a bare column,
|
|
388
|
+
// else null (an unaliased expression has no stable public name → unverifiable).
|
|
389
|
+
const names: string[] = [];
|
|
390
|
+
for (const raw of cols) {
|
|
391
|
+
const item = raw.trim();
|
|
392
|
+
const asMatch = /\bAS\s+([a-zA-Z0-9_"]+)\s*$/i.exec(item);
|
|
393
|
+
if (asMatch) {
|
|
394
|
+
names.push(unquoteIdent(asMatch[1]));
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (/^[a-zA-Z0-9_"]+(\.[a-zA-Z0-9_"]+)?$/.test(item)) {
|
|
398
|
+
const parts = item.split('.');
|
|
399
|
+
names.push(unquoteIdent(parts[parts.length - 1]));
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
return null; // bare expression without alias — can't name the public column
|
|
403
|
+
}
|
|
404
|
+
return names;
|
|
405
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog instrument for security:audit — the post-deploy half.
|
|
3
|
+
*
|
|
4
|
+
* Static parsing gates the PR from the migration SQL; this introspects the *live*
|
|
5
|
+
* database (pg_proc / pg_class / pg_attribute, with role inheritance resolved by
|
|
6
|
+
* `has_table_privilege`) to catch deployed-state drift and anything created
|
|
7
|
+
* out-of-band that the migration files never described.
|
|
8
|
+
*
|
|
9
|
+
* The SQL runs through the ops Lambda (`db:query`); the mappers here are pure and
|
|
10
|
+
* unit-tested without a database.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
FunctionDescriptor,
|
|
15
|
+
ViewDescriptor,
|
|
16
|
+
ReturnClass,
|
|
17
|
+
} from './security-audit.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Enumerate every function with its security context. Excludes system schemas and
|
|
21
|
+
* extension-owned functions (pgcrypto, PostGIS) via pg_depend, so vendor functions
|
|
22
|
+
* never need a manual waiver.
|
|
23
|
+
*/
|
|
24
|
+
export const FUNCTIONS_SQL = `
|
|
25
|
+
SELECT
|
|
26
|
+
n.nspname AS schema,
|
|
27
|
+
p.proname AS name,
|
|
28
|
+
pg_get_function_result(p.oid) AS return_type,
|
|
29
|
+
p.prosecdef AS security_definer,
|
|
30
|
+
p.proretset AS returns_set,
|
|
31
|
+
t.typtype AS return_typtype,
|
|
32
|
+
EXISTS (
|
|
33
|
+
SELECT 1 FROM unnest(coalesce(p.proconfig, '{}'::text[])) cfg
|
|
34
|
+
WHERE lower(cfg) LIKE 'search_path=%'
|
|
35
|
+
) AS has_search_path,
|
|
36
|
+
pg_get_userbyid(p.proowner) AS owner,
|
|
37
|
+
-- Does the owner run above RLS? A SECURITY DEFINER function owned by such a role is
|
|
38
|
+
-- the maximum blast radius — search_path pinning is necessary but not sufficient.
|
|
39
|
+
-- RDS has no true superuser (rolsuper=false), so also check rds_superuser membership.
|
|
40
|
+
(
|
|
41
|
+
SELECT r.rolsuper OR r.rolbypassrls OR EXISTS (
|
|
42
|
+
SELECT 1 FROM pg_auth_members m JOIN pg_roles g ON g.oid = m.roleid
|
|
43
|
+
WHERE m.member = p.proowner AND g.rolname = 'rds_superuser'
|
|
44
|
+
)
|
|
45
|
+
FROM pg_roles r WHERE r.oid = p.proowner
|
|
46
|
+
) AS owner_bypasses_rls
|
|
47
|
+
FROM pg_proc p
|
|
48
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
49
|
+
JOIN pg_type t ON t.oid = p.prorettype
|
|
50
|
+
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
51
|
+
AND n.nspname NOT LIKE 'pg_%'
|
|
52
|
+
AND NOT EXISTS (
|
|
53
|
+
SELECT 1 FROM pg_depend d
|
|
54
|
+
WHERE d.objid = p.oid AND d.deptype = 'e'
|
|
55
|
+
)
|
|
56
|
+
ORDER BY n.nspname, p.proname;
|
|
57
|
+
`.trim();
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Enumerate relations (tables, views, matviews) with their columns and whether
|
|
61
|
+
* SELECT is reachable from a broad role. `has_table_privilege` resolves role
|
|
62
|
+
* inheritance and PUBLIC grants, so a view granted to an intermediate role that
|
|
63
|
+
* `authenticated` inherits is correctly flagged broadly-exposed.
|
|
64
|
+
*/
|
|
65
|
+
export const RELATIONS_SQL = `
|
|
66
|
+
WITH targets AS (
|
|
67
|
+
SELECT rolname FROM pg_roles WHERE rolname IN ('anon', 'authenticated')
|
|
68
|
+
)
|
|
69
|
+
SELECT
|
|
70
|
+
n.nspname AS schema,
|
|
71
|
+
c.relname AS name,
|
|
72
|
+
c.relkind AS relkind,
|
|
73
|
+
(
|
|
74
|
+
SELECT array_agg(a.attname ORDER BY a.attnum)
|
|
75
|
+
FROM pg_attribute a
|
|
76
|
+
WHERE a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
|
|
77
|
+
) AS columns,
|
|
78
|
+
EXISTS (
|
|
79
|
+
SELECT 1 FROM targets tg
|
|
80
|
+
WHERE has_table_privilege(tg.rolname, c.oid, 'SELECT')
|
|
81
|
+
) AS broadly_exposed
|
|
82
|
+
FROM pg_class c
|
|
83
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
84
|
+
WHERE c.relkind IN ('r', 'v', 'm')
|
|
85
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
86
|
+
AND n.nspname NOT LIKE 'pg_%'
|
|
87
|
+
AND NOT EXISTS (
|
|
88
|
+
SELECT 1 FROM pg_depend d
|
|
89
|
+
WHERE d.objid = c.oid AND d.deptype = 'e'
|
|
90
|
+
)
|
|
91
|
+
ORDER BY n.nspname, c.relname;
|
|
92
|
+
`.trim();
|
|
93
|
+
|
|
94
|
+
/** Coerce a Postgres boolean (true | 't' | 'true' | 1) into a JS boolean. */
|
|
95
|
+
function truthy(v: unknown): boolean {
|
|
96
|
+
return v === true || v === 't' || v === 'true' || v === 1 || v === '1';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Parse a text[] that may arrive as a JS array or a `{a,b,c}` literal string. */
|
|
100
|
+
export function parsePgArray(v: unknown): string[] | null {
|
|
101
|
+
if (v == null) return null;
|
|
102
|
+
if (Array.isArray(v)) return v.map(String);
|
|
103
|
+
if (typeof v === 'string') {
|
|
104
|
+
const trimmed = v.trim();
|
|
105
|
+
if (trimmed === '{}' || trimmed === '') return [];
|
|
106
|
+
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
|
107
|
+
return trimmed
|
|
108
|
+
.slice(1, -1)
|
|
109
|
+
.split(',')
|
|
110
|
+
.map((s) => s.replace(/^"|"$/g, '').trim())
|
|
111
|
+
.filter((s) => s.length > 0);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface FunctionRow {
|
|
118
|
+
schema: string;
|
|
119
|
+
name: string;
|
|
120
|
+
return_type: string;
|
|
121
|
+
security_definer: unknown;
|
|
122
|
+
returns_set: unknown;
|
|
123
|
+
return_typtype: unknown;
|
|
124
|
+
has_search_path: unknown;
|
|
125
|
+
owner?: unknown;
|
|
126
|
+
owner_bypasses_rls?: unknown;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Map a pg_proc row to a descriptor, computing a precise return class. */
|
|
130
|
+
export function catalogFunctionToDescriptor(row: FunctionRow): FunctionDescriptor {
|
|
131
|
+
const text = String(row.return_type ?? '').trim().toLowerCase();
|
|
132
|
+
let returnClassHint: ReturnClass;
|
|
133
|
+
if (text === 'void' || text === 'trigger' || text === '') {
|
|
134
|
+
returnClassHint = 'void';
|
|
135
|
+
} else if (truthy(row.returns_set) || row.return_typtype === 'c' || text === 'record' || text.startsWith('setof') || text.startsWith('table(')) {
|
|
136
|
+
returnClassHint = 'rows';
|
|
137
|
+
} else {
|
|
138
|
+
returnClassHint = 'scalar';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
schema: row.schema,
|
|
143
|
+
name: row.name,
|
|
144
|
+
returnType: row.return_type,
|
|
145
|
+
returnClassHint,
|
|
146
|
+
securityDefiner: truthy(row.security_definer),
|
|
147
|
+
hasSearchPath: truthy(row.has_search_path),
|
|
148
|
+
owner: row.owner != null ? String(row.owner) : undefined,
|
|
149
|
+
ownerBypassesRls: row.owner_bypasses_rls != null ? truthy(row.owner_bypasses_rls) : undefined,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
interface RelationRow {
|
|
154
|
+
schema: string;
|
|
155
|
+
name: string;
|
|
156
|
+
relkind: string;
|
|
157
|
+
columns: unknown;
|
|
158
|
+
broadly_exposed: unknown;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Map a pg_class row to a view/table descriptor. */
|
|
162
|
+
export function catalogRelationToDescriptor(row: RelationRow): ViewDescriptor {
|
|
163
|
+
return {
|
|
164
|
+
schema: row.schema,
|
|
165
|
+
name: row.name,
|
|
166
|
+
kind: row.relkind === 'r' ? 'table' : 'view',
|
|
167
|
+
columns: parsePgArray(row.columns),
|
|
168
|
+
broadlyExposed: truthy(row.broadly_exposed),
|
|
169
|
+
};
|
|
170
|
+
}
|