@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.
@@ -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
- /** Counts across both invariants, excluding waived. */
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
- const all: { severity: Severity }[] = [...fnFindings, ...viewFindings];
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 {
@@ -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
- if (/\bDROP\s+(TABLE|COLUMN|TYPE)\b/.test(statement)) drops.push(statement);
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