@everystack/cli 0.4.55 → 0.4.57
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/package.json +4 -3
- package/src/cli/authz-contract.ts +155 -3
- package/src/cli/authz-derive.ts +46 -5
- package/src/cli/authz-reconcile.ts +146 -1
- package/src/cli/authz-render.ts +26 -4
- package/src/cli/commands/db-apply.ts +11 -0
- package/src/cli/commands/db-check.ts +4 -1
- package/src/cli/commands/db-fingerprint.ts +16 -2
- package/src/cli/commands/db-plan.ts +20 -3
- package/src/cli/commands/db-pull.ts +6 -1
- package/src/cli/commands/db-reconcile.ts +41 -2
- package/src/cli/commands/security.ts +24 -3
- package/src/cli/db-build.ts +36 -1
- package/src/cli/derived-apply.ts +249 -18
- package/src/cli/derived-compile.ts +25 -7
- package/src/cli/derived-grants.ts +96 -3
- package/src/cli/derived-introspect.ts +98 -1
- package/src/cli/derived-lint.ts +56 -0
- package/src/cli/derived-plan.ts +48 -1
- package/src/cli/derived-render.ts +153 -3
- package/src/cli/derived-source.ts +32 -2
- package/src/cli/git-descent.ts +91 -19
- package/src/cli/migration-generate.ts +3 -0
- package/src/cli/model-api.ts +24 -0
- package/src/cli/model-render.ts +100 -11
- package/src/cli/schema-compile.ts +66 -9
- package/src/cli/schema-diff.ts +133 -14
- package/src/cli/schema-fingerprint.ts +58 -5
- package/src/cli/schema-introspect.ts +40 -0
- package/src/cli/schema-source.ts +19 -1
- package/src/cli/security-audit.ts +131 -2
- package/src/cli/security-catalog.ts +18 -3
- package/src/cli/state-apply.ts +7 -1
|
@@ -43,6 +43,12 @@ export interface FunctionDescriptor {
|
|
|
43
43
|
* A SECURITY DEFINER function owned by such a role is the maximum blast radius.
|
|
44
44
|
*/
|
|
45
45
|
ownerBypassesRls?: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* SUPERUSER (or rds_superuser member) specifically — the portable half of ownership.
|
|
48
|
+
* A scoped operator is often BYPASSRLS by design, so `ownerBypassesRls` alone cannot tell
|
|
49
|
+
* a correct design from a function left owned by the build identity. This can.
|
|
50
|
+
*/
|
|
51
|
+
ownerIsSuperuser?: boolean;
|
|
46
52
|
}
|
|
47
53
|
|
|
48
54
|
export interface FunctionFinding {
|
|
@@ -86,12 +92,40 @@ export interface Waivers {
|
|
|
86
92
|
secdef?: Record<string, string>;
|
|
87
93
|
/** `schema.view` -> declared public column allowlist. */
|
|
88
94
|
publicColumns?: Record<string, string[]>;
|
|
95
|
+
/** schema name -> justification for leaving it broadly writable. Same rule: no bare opt-out. */
|
|
96
|
+
schema?: Record<string, string>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A schema and the two facts that decide whether it is a shadowing surface: who can CREATE in
|
|
101
|
+
* it, and what privileged code resolves through it.
|
|
102
|
+
*/
|
|
103
|
+
export interface SchemaDescriptor {
|
|
104
|
+
schema: string;
|
|
105
|
+
/** Grantees holding CREATE on the schema. `PUBLIC` is the pseudo-role entry. */
|
|
106
|
+
createGrantees: string[];
|
|
107
|
+
/** SECURITY DEFINER functions living in it. */
|
|
108
|
+
definerFunctions: number;
|
|
109
|
+
/** …of which carry no `SET search_path`. */
|
|
110
|
+
unpinnedDefinerFunctions: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface SchemaFinding {
|
|
114
|
+
/** The schema name — the waiver-match key. */
|
|
115
|
+
key: string;
|
|
116
|
+
schema: string;
|
|
117
|
+
severity: Severity;
|
|
118
|
+
reasons: string[];
|
|
119
|
+
waived: boolean;
|
|
120
|
+
waiverReason?: string;
|
|
89
121
|
}
|
|
90
122
|
|
|
91
123
|
export interface AuditReport {
|
|
92
124
|
functions: FunctionFinding[];
|
|
93
125
|
views: ViewFinding[];
|
|
94
|
-
/**
|
|
126
|
+
/** Writable-schema findings — absent when no schema ACLs were supplied (static path). */
|
|
127
|
+
schemas: SchemaFinding[];
|
|
128
|
+
/** Counts across every invariant, excluding waived. */
|
|
95
129
|
red: number;
|
|
96
130
|
warn: number;
|
|
97
131
|
}
|
|
@@ -181,6 +215,68 @@ export function classifyFunction(
|
|
|
181
215
|
return { key, schema: fn.schema, name: fn.name, returnType: fn.returnType, returnClass, severity, reasons, waived: false };
|
|
182
216
|
}
|
|
183
217
|
|
|
218
|
+
/**
|
|
219
|
+
* A10(ii) — the PRECONDITION leg: can an untrusted role CREATE in a schema that privileged code
|
|
220
|
+
* resolves through?
|
|
221
|
+
*
|
|
222
|
+
* The audit already flags an unpinned SECURITY DEFINER function and an owner that bypasses RLS.
|
|
223
|
+
* Both are about the privileged code. Neither says whether an attacker can actually plant
|
|
224
|
+
* anything for it to resolve to, and that is what separates latent debt from a live chain. A
|
|
225
|
+
* consumer asked for exactly this after finding `PUBLIC` holding CREATE on `public` across every
|
|
226
|
+
* dump-adopted database (the pre-PG15 default; PG15 removed it, so a database everystack BUILDS
|
|
227
|
+
* is hardened and one it ADOPTS is not).
|
|
228
|
+
*
|
|
229
|
+
* Tiering, and the reason it is not simply "red when SECDEF is present":
|
|
230
|
+
*
|
|
231
|
+
* - writable by a broad role, with an UNPINNED definer function in the same schema → RED.
|
|
232
|
+
* That is the whole chain: plant a shadow object, the unpinned function resolves through it
|
|
233
|
+
* and runs it with the owner's rights.
|
|
234
|
+
* - writable by a broad role, everything else → WARN. Pinning the definer functions defends
|
|
235
|
+
* that specific chain and does NOT make the schema safe: a PUBLIC-writable schema on a
|
|
236
|
+
* search path threatens EVERY privileged session resolving through it, including
|
|
237
|
+
* everystack's own build/reconcile credential executing DDL with `public` on its path. A
|
|
238
|
+
* shadow object planted there hijacks the builder, which outranks any SECDEF owner. This is
|
|
239
|
+
* why the finding does not require a definer function to fire.
|
|
240
|
+
*
|
|
241
|
+
* Only BROAD grantees count. A named operator role holding CREATE is how schemas get built.
|
|
242
|
+
*/
|
|
243
|
+
export function classifySchema(schema: SchemaDescriptor, waivers: Waivers = {}): SchemaFinding {
|
|
244
|
+
const key = schema.schema;
|
|
245
|
+
const reasons: string[] = [];
|
|
246
|
+
let severity: Severity = 'ok';
|
|
247
|
+
const broad = schema.createGrantees.filter((g) => BROAD_ROLES.has(g.toLowerCase())).sort();
|
|
248
|
+
|
|
249
|
+
if (broad.length > 0) {
|
|
250
|
+
const who = broad.join(', ');
|
|
251
|
+
if (schema.unpinnedDefinerFunctions > 0) {
|
|
252
|
+
reasons.push(
|
|
253
|
+
`${who} can CREATE in schema "${key}", which holds ${schema.unpinnedDefinerFunctions} unpinned SECURITY DEFINER function(s) — `
|
|
254
|
+
+ `that is a complete search_path hijack chain: plant a shadow object, the unpinned function resolves through it and runs it with the owner's rights. `
|
|
255
|
+
+ `REVOKE CREATE ON SCHEMA ${key} FROM ${who}, and pin those functions.`,
|
|
256
|
+
);
|
|
257
|
+
severity = 'red';
|
|
258
|
+
} else {
|
|
259
|
+
reasons.push(
|
|
260
|
+
`${who} can CREATE in schema "${key}" — the pre-PG15 default, which PG15 removed. No unpinned SECURITY DEFINER function is here, `
|
|
261
|
+
+ `but a writable schema on a search path threatens every privileged session that resolves through it, including the build credential `
|
|
262
|
+
+ `running DDL. REVOKE CREATE ON SCHEMA ${key} FROM ${who}.`,
|
|
263
|
+
);
|
|
264
|
+
severity = raise(severity, 'warn');
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const waiverReason = waivers.schema?.[key];
|
|
269
|
+
if (waiverReason !== undefined) {
|
|
270
|
+
if (typeof waiverReason === 'string' && waiverReason.trim().length > 0) {
|
|
271
|
+
return { key, schema: key, severity: 'ok', reasons, waived: true, waiverReason };
|
|
272
|
+
}
|
|
273
|
+
reasons.push('waiver present but missing a justification string — a waiver must explain why the schema is safe');
|
|
274
|
+
severity = raise(severity, 'red');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return { key, schema: key, severity, reasons, waived: false };
|
|
278
|
+
}
|
|
279
|
+
|
|
184
280
|
/** Apply Invariant B to a single view/projection. */
|
|
185
281
|
export function classifyView(view: ViewDescriptor, waivers: Waivers = {}): ViewFinding {
|
|
186
282
|
const key = `${view.schema}.${view.name}`;
|
|
@@ -225,18 +321,51 @@ export function audit(
|
|
|
225
321
|
functions: FunctionDescriptor[],
|
|
226
322
|
views: ViewDescriptor[],
|
|
227
323
|
waivers: Waivers = {},
|
|
324
|
+
schemas: SchemaDescriptor[] = [],
|
|
228
325
|
): AuditReport {
|
|
229
326
|
const fnFindings = functions.map((f) => classifyFunction(f, waivers));
|
|
230
327
|
const viewFindings = views.map((v) => classifyView(v, waivers));
|
|
231
|
-
|
|
328
|
+
// Empty by default: the STATIC path parses migration SQL and cannot know a schema ACL, and a
|
|
329
|
+
// silent `ok` there would be a claim the parser is not entitled to make.
|
|
330
|
+
const schemaFindings = schemas.map((s) => classifySchema(s, waivers));
|
|
331
|
+
const all: { severity: Severity }[] = [...fnFindings, ...viewFindings, ...schemaFindings];
|
|
232
332
|
return {
|
|
233
333
|
functions: fnFindings,
|
|
234
334
|
views: viewFindings,
|
|
335
|
+
schemas: schemaFindings,
|
|
235
336
|
red: all.filter((f) => f.severity === 'red').length,
|
|
236
337
|
warn: all.filter((f) => f.severity === 'warn').length,
|
|
237
338
|
};
|
|
238
339
|
}
|
|
239
340
|
|
|
341
|
+
/**
|
|
342
|
+
* Build the schema descriptors from a live authz contract — the catalog path's adapter.
|
|
343
|
+
*
|
|
344
|
+
* `schemaAcls` absent means the read never ran, and the audit must then say nothing about
|
|
345
|
+
* schemas rather than report every one of them as clean.
|
|
346
|
+
*/
|
|
347
|
+
export function schemaDescriptorsFromContract(
|
|
348
|
+
schemaAcls: Record<string, Record<string, string[]>> | undefined,
|
|
349
|
+
functions: readonly { name: string; securityDefiner: boolean; hasSearchPath: boolean }[],
|
|
350
|
+
): SchemaDescriptor[] {
|
|
351
|
+
if (!schemaAcls) return [];
|
|
352
|
+
const schemaOf = (qualified: string): string => qualified.split('.').slice(0, -1).join('.') || 'public';
|
|
353
|
+
return Object.entries(schemaAcls)
|
|
354
|
+
.map(([schema, grants]) => {
|
|
355
|
+
const here = functions.filter((f) => f.securityDefiner && schemaOf(f.name) === schema);
|
|
356
|
+
return {
|
|
357
|
+
schema,
|
|
358
|
+
createGrantees: Object.entries(grants)
|
|
359
|
+
.filter(([, privileges]) => privileges.includes('CREATE'))
|
|
360
|
+
.map(([grantee]) => grantee)
|
|
361
|
+
.sort(),
|
|
362
|
+
definerFunctions: here.length,
|
|
363
|
+
unpinnedDefinerFunctions: here.filter((f) => !f.hasSearchPath).length,
|
|
364
|
+
};
|
|
365
|
+
})
|
|
366
|
+
.sort((a, b) => a.schema.localeCompare(b.schema));
|
|
367
|
+
}
|
|
368
|
+
|
|
240
369
|
function raise(current: Severity, next: Severity): Severity {
|
|
241
370
|
const rank: Record<Severity, number> = { ok: 0, warn: 1, red: 2 };
|
|
242
371
|
return rank[next] > rank[current] ? next : current;
|
|
@@ -43,7 +43,20 @@ SELECT
|
|
|
43
43
|
WHERE m.member = p.proowner AND g.rolname = 'rds_superuser'
|
|
44
44
|
)
|
|
45
45
|
FROM pg_roles r WHERE r.oid = p.proowner
|
|
46
|
-
) AS owner_bypasses_rls
|
|
46
|
+
) AS owner_bypasses_rls,
|
|
47
|
+
-- SUPERUSER specifically, split out from owner_bypasses_rls. A deliberately-scoped
|
|
48
|
+
-- operator role is commonly BYPASSRLS by design, so the combined flag reads true for
|
|
49
|
+
-- the correct design AND for a function accidentally left owned by the build identity --
|
|
50
|
+
-- identical value, wildly different blast radius. This is the portable half: it separates
|
|
51
|
+
-- a scoped operator from a superuser without comparing a role NAME, which legitimately
|
|
52
|
+
-- differs between a dev machine, a deployed master and a normalized operator.
|
|
53
|
+
(
|
|
54
|
+
SELECT r.rolsuper OR EXISTS (
|
|
55
|
+
SELECT 1 FROM pg_auth_members m JOIN pg_roles g ON g.oid = m.roleid
|
|
56
|
+
WHERE m.member = p.proowner AND g.rolname = 'rds_superuser'
|
|
57
|
+
)
|
|
58
|
+
FROM pg_roles r WHERE r.oid = p.proowner
|
|
59
|
+
) AS owner_is_superuser
|
|
47
60
|
FROM pg_proc p
|
|
48
61
|
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
49
62
|
JOIN pg_type t ON t.oid = p.prorettype
|
|
@@ -124,6 +137,7 @@ interface FunctionRow {
|
|
|
124
137
|
has_search_path: unknown;
|
|
125
138
|
owner?: unknown;
|
|
126
139
|
owner_bypasses_rls?: unknown;
|
|
140
|
+
owner_is_superuser?: unknown;
|
|
127
141
|
}
|
|
128
142
|
|
|
129
143
|
/** Map a pg_proc row to a descriptor, computing a precise return class. */
|
|
@@ -147,6 +161,7 @@ export function catalogFunctionToDescriptor(row: FunctionRow): FunctionDescripto
|
|
|
147
161
|
hasSearchPath: truthy(row.has_search_path),
|
|
148
162
|
owner: row.owner != null ? String(row.owner) : undefined,
|
|
149
163
|
ownerBypassesRls: row.owner_bypasses_rls != null ? truthy(row.owner_bypasses_rls) : undefined,
|
|
164
|
+
ownerIsSuperuser: row.owner_is_superuser != null ? truthy(row.owner_is_superuser) : undefined,
|
|
150
165
|
};
|
|
151
166
|
}
|
|
152
167
|
|
|
@@ -155,9 +170,9 @@ export function catalogFunctionToDescriptor(row: FunctionRow): FunctionDescripto
|
|
|
155
170
|
* every `introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL)` call
|
|
156
171
|
* site uses this instead of keeping its own copy.
|
|
157
172
|
*/
|
|
158
|
-
export const contractFunctionRow = (row: any): { schema: string; name: string; securityDefiner: boolean; hasSearchPath: boolean; owner?: string; ownerBypassesRls?: boolean } => {
|
|
173
|
+
export const contractFunctionRow = (row: any): { schema: string; name: string; securityDefiner: boolean; hasSearchPath: boolean; owner?: string; ownerBypassesRls?: boolean; ownerIsSuperuser?: boolean } => {
|
|
159
174
|
const d = catalogFunctionToDescriptor(row);
|
|
160
|
-
return { schema: d.schema, name: d.name, securityDefiner: d.securityDefiner, hasSearchPath: d.hasSearchPath, owner: d.owner, ownerBypassesRls: d.ownerBypassesRls };
|
|
175
|
+
return { schema: d.schema, name: d.name, securityDefiner: d.securityDefiner, hasSearchPath: d.hasSearchPath, owner: d.owner, ownerBypassesRls: d.ownerBypassesRls, ownerIsSuperuser: d.ownerIsSuperuser };
|
|
161
176
|
};
|
|
162
177
|
|
|
163
178
|
interface RelationRow {
|
package/src/cli/state-apply.ts
CHANGED
|
@@ -115,6 +115,10 @@ const ADDITIVE_MATCHERS: RegExp[] = [
|
|
|
115
115
|
// Relaxing nullability and setting a default add capability; they remove nothing.
|
|
116
116
|
/^ALTER\s+TABLE\s+[\s\S]+?\sALTER\s+COLUMN\s+[\s\S]+?\sDROP\s+NOT\s+NULL\b/,
|
|
117
117
|
/^ALTER\s+TABLE\s+[\s\S]+?\sALTER\s+COLUMN\s+[\s\S]+?\sSET\s+DEFAULT\b/,
|
|
118
|
+
// Giving a column an auto-value adds capability and removes nothing. Its sibling
|
|
119
|
+
// `SET GENERATED ALWAYS` is deliberately NOT here — it starts REFUSING client-supplied
|
|
120
|
+
// values, so a human reads it as `unclassified` rather than being told it is safe.
|
|
121
|
+
/^ALTER\s+TABLE\s+[\s\S]+?\sALTER\s+COLUMN\s+[\s\S]+?\sADD\s+GENERATED\b/,
|
|
118
122
|
];
|
|
119
123
|
|
|
120
124
|
/**
|
|
@@ -195,7 +199,9 @@ export function classifyDestructive(
|
|
|
195
199
|
const narrowings: string[] = [];
|
|
196
200
|
const strips: string[] = [];
|
|
197
201
|
for (const statement of executable) {
|
|
198
|
-
|
|
202
|
+
// DROP IDENTITY is a drop: it takes the backing sequence and its counter with it, and a
|
|
203
|
+
// counter is state no re-run reconstructs. The column's rows survive; its auto-value does not.
|
|
204
|
+
if (/\bDROP\s+(TABLE|COLUMN|TYPE|IDENTITY)\b/.test(statement)) drops.push(statement);
|
|
199
205
|
else if (/\bSET DATA TYPE\b/.test(statement) && /\bUSING\b/.test(statement)) narrowings.push(statement);
|
|
200
206
|
else if (opts.declaredGrantees) {
|
|
201
207
|
// Only classified when the caller supplied the declared side — without it there is
|