@everystack/cli 0.2.37 → 0.2.40

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.
@@ -0,0 +1,185 @@
1
+ /**
2
+ * `everystack security:audit` — the gate behind the two-surface security model.
3
+ *
4
+ * Goes RED (non-zero exit) on any data-returning SECURITY DEFINER function or any
5
+ * public view that exposes a non-public column, so CI fails before the leak ships.
6
+ * Two instruments over one classifier:
7
+ *
8
+ * everystack security:audit static scan of migration SQL (pre-merge)
9
+ * everystack security:audit --stage <name> live catalog scan of a deployed stage
10
+ *
11
+ * See docs/security.md ("Auditing the surface") and docs/plans/nothing-skips-rls.md.
12
+ */
13
+
14
+ import fs from 'node:fs/promises';
15
+ import path from 'node:path';
16
+ import {
17
+ audit,
18
+ parseFunctionsFromSql,
19
+ parseViewsAndGrantsFromSql,
20
+ type Waivers,
21
+ type AuditReport,
22
+ type FunctionDescriptor,
23
+ type ViewDescriptor,
24
+ } from '../security-audit.js';
25
+ import {
26
+ FUNCTIONS_SQL,
27
+ RELATIONS_SQL,
28
+ catalogFunctionToDescriptor,
29
+ catalogRelationToDescriptor,
30
+ } from '../security-catalog.js';
31
+ import { resolveConfig, opsFunction } from '../config.js';
32
+ import { invokeAction } from '../aws.js';
33
+ import { step, success, fail, info, warn } from '../output.js';
34
+
35
+ const MIGRATION_DIRS = ['drizzle', 'db/migrations', 'migrations', 'db/drizzle'];
36
+ const WAIVER_FILES = ['security-audit.json', '.security-audit.json'];
37
+
38
+ export async function securityAuditCommand(flags: Record<string, string>): Promise<void> {
39
+ const waivers = await loadWaivers(flags.config);
40
+
41
+ const useCatalog = flags.catalog === 'true' || typeof flags.stage === 'string';
42
+
43
+ let report: AuditReport;
44
+ try {
45
+ report = useCatalog
46
+ ? await runCatalogAudit(flags, waivers)
47
+ : await runStaticAudit(flags, waivers);
48
+ } catch (err: any) {
49
+ fail(err.message);
50
+ process.exit(1);
51
+ }
52
+
53
+ printReport(report, useCatalog ? 'catalog' : 'static');
54
+ process.exit(report.red > 0 ? 1 : 0);
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Static instrument — parse migration SQL, no database.
59
+ // ---------------------------------------------------------------------------
60
+
61
+ async function runStaticAudit(flags: Record<string, string>, waivers: Waivers): Promise<AuditReport> {
62
+ const dir = await resolveMigrationDir(flags.dir);
63
+ step(`Scanning migration SQL in ${dir} ...`);
64
+
65
+ const entries = (await fs.readdir(dir)).filter((f) => f.endsWith('.sql')).sort();
66
+ if (entries.length === 0) {
67
+ throw new Error(`No .sql files found in ${dir}. Pass --dir <path> to point at your migrations.`);
68
+ }
69
+
70
+ let sql = '';
71
+ for (const entry of entries) {
72
+ sql += '\n' + (await fs.readFile(path.join(dir, entry), 'utf8'));
73
+ }
74
+
75
+ const functions = parseFunctionsFromSql(sql);
76
+ const views = parseViewsAndGrantsFromSql(sql);
77
+ info(`Found ${functions.length} function(s), ${views.length} view(s) across ${entries.length} migration file(s).`);
78
+ return audit(functions, views, waivers);
79
+ }
80
+
81
+ async function resolveMigrationDir(flag?: string): Promise<string> {
82
+ if (flag) {
83
+ const abs = path.resolve(flag);
84
+ await fs.access(abs);
85
+ return abs;
86
+ }
87
+ for (const candidate of MIGRATION_DIRS) {
88
+ const abs = path.resolve(candidate);
89
+ try {
90
+ const stat = await fs.stat(abs);
91
+ if (stat.isDirectory()) return abs;
92
+ } catch {
93
+ // try next
94
+ }
95
+ }
96
+ throw new Error(`No migration directory found (looked for ${MIGRATION_DIRS.join(', ')}). Pass --dir <path>.`);
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Catalog instrument — introspect the live database via the ops Lambda.
101
+ // ---------------------------------------------------------------------------
102
+
103
+ async function runCatalogAudit(flags: Record<string, string>, waivers: Waivers): Promise<AuditReport> {
104
+ step('Resolving deployed config...');
105
+ const config = await resolveConfig(flags.stage);
106
+ info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
107
+
108
+ step('Introspecting database catalog (functions + relations)...');
109
+ const fnRows = await catalogQuery(config.region, opsFunction(config), FUNCTIONS_SQL);
110
+ const relRows = await catalogQuery(config.region, opsFunction(config), RELATIONS_SQL);
111
+
112
+ const functions: FunctionDescriptor[] = fnRows.map(catalogFunctionToDescriptor);
113
+ const views: ViewDescriptor[] = relRows.map(catalogRelationToDescriptor);
114
+ info(`Catalog: ${functions.length} function(s), ${views.length} relation(s).`);
115
+ return audit(functions, views, waivers);
116
+ }
117
+
118
+ async function catalogQuery(region: string, fn: string, sql: string): Promise<any[]> {
119
+ const result: any = await invokeAction(region, fn, 'db:query', { sql });
120
+ if (result?.error) throw new Error(`Catalog query failed: ${result.error}`);
121
+ return result?.rows ?? [];
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Waivers + reporting.
126
+ // ---------------------------------------------------------------------------
127
+
128
+ async function loadWaivers(flag?: string): Promise<Waivers> {
129
+ const candidates = flag ? [flag] : WAIVER_FILES;
130
+ for (const candidate of candidates) {
131
+ const abs = path.resolve(candidate);
132
+ try {
133
+ const raw = await fs.readFile(abs, 'utf8');
134
+ const parsed = JSON.parse(raw);
135
+ info(`Loaded waivers from ${candidate}.`);
136
+ return { secdef: parsed.secdef ?? {}, publicColumns: parsed.publicColumns ?? {} };
137
+ } catch (err: any) {
138
+ if (err.code !== 'ENOENT') throw new Error(`Could not read waiver config ${candidate}: ${err.message}`);
139
+ }
140
+ }
141
+ return {};
142
+ }
143
+
144
+ function printReport(report: AuditReport, instrument: 'static' | 'catalog'): void {
145
+ const reds = [
146
+ ...report.functions.filter((f) => f.severity === 'red'),
147
+ ...report.views.filter((v) => v.severity === 'red'),
148
+ ];
149
+ const warns = [
150
+ ...report.functions.filter((f) => f.severity === 'warn'),
151
+ ...report.views.filter((v) => v.severity === 'warn'),
152
+ ];
153
+ const waived = report.functions.filter((f) => f.waived);
154
+
155
+ if (reds.length > 0) {
156
+ console.log('');
157
+ info('RED — must fix or waive with justification:');
158
+ for (const f of reds) {
159
+ fail(`${f.key}`);
160
+ for (const r of f.reasons) info(` ${r}`);
161
+ }
162
+ }
163
+
164
+ if (warns.length > 0) {
165
+ console.log('');
166
+ info('WARN — review (does not fail the build):');
167
+ for (const f of warns) {
168
+ warn(`${f.key}`);
169
+ for (const r of f.reasons) info(` ${r}`);
170
+ }
171
+ }
172
+
173
+ if (waived.length > 0) {
174
+ console.log('');
175
+ info(`Waived (${waived.length}): ${waived.map((f) => f.key).join(', ')}`);
176
+ }
177
+
178
+ console.log('');
179
+ const summary = `security:audit (${instrument}) — ${report.red} red, ${report.warn} warn`;
180
+ if (report.red > 0) {
181
+ fail(summary);
182
+ } else {
183
+ success(summary);
184
+ }
185
+ }
package/src/cli/index.ts CHANGED
@@ -6,7 +6,8 @@ import { updateCommand } from './commands/update.js';
6
6
  import { certsCommand } from './commands/certs.js';
7
7
  import { channelsCommand } from './commands/channels.js';
8
8
  import { branchesCommand } from './commands/branches.js';
9
- import { dbMigrateCommand, dbSeedCommand, dbResetCommand, dbPsqlCommand } from './commands/db.js';
9
+ import { dbMigrateCommand, dbSeedCommand, dbResetCommand, dbPsqlCommand, dbDoctorCommand, dbProvisionCommand } from './commands/db.js';
10
+ import { dbAuthzPullCommand, dbAuthzDiffCommand, dbAuthzTestCommand, dbAuthzReportCommand } from './commands/db-authz.js';
10
11
  import { consoleCommand } from './commands/console.js';
11
12
  import { cachePurgeCommand } from './commands/cache.js';
12
13
  import { statusCommand } from './commands/status.js';
@@ -14,6 +15,7 @@ import { diagCommand } from './commands/diag.js';
14
15
  import { analyzeSSRCommand } from './commands/analyze.js';
15
16
  import { logsErrorsCommand, logsTailCommand, logsQueryCommand } from './commands/logs.js';
16
17
  import { secretsCommand } from './commands/secrets.js';
18
+ import { securityAuditCommand } from './commands/security.js';
17
19
  import { fail } from './output.js';
18
20
 
19
21
  const args = process.argv.slice(2);
@@ -114,6 +116,24 @@ async function main() {
114
116
  case 'db:psql':
115
117
  await dbPsqlCommand(flags);
116
118
  break;
119
+ case 'db:doctor':
120
+ await dbDoctorCommand(flags);
121
+ break;
122
+ case 'db:provision':
123
+ await dbProvisionCommand(flags);
124
+ break;
125
+ case 'db:authz:pull':
126
+ await dbAuthzPullCommand(flags);
127
+ break;
128
+ case 'db:authz:diff':
129
+ await dbAuthzDiffCommand(flags);
130
+ break;
131
+ case 'db:authz:test':
132
+ await dbAuthzTestCommand(flags);
133
+ break;
134
+ case 'db:authz:report':
135
+ await dbAuthzReportCommand(flags);
136
+ break;
117
137
  case 'console':
118
138
  await consoleCommand(flags);
119
139
  break;
@@ -143,6 +163,9 @@ async function main() {
143
163
  case 'logs:query':
144
164
  await logsQueryCommand(flags);
145
165
  break;
166
+ case 'security:audit':
167
+ await securityAuditCommand(flags);
168
+ break;
146
169
  case 'secrets': {
147
170
  // secrets <subcommand> [positional...] [--flags]
148
171
  // Extract positional args: everything after subcommand that isn't a flag or flag value
@@ -176,9 +199,18 @@ Usage:
176
199
  everystack db:migrate [--stage <name>] Run database migrations on deployed Lambda
177
200
  everystack db:seed [--stage <name>] Seed database on deployed Lambda (dev only)
178
201
  everystack db:reset [--stage <name>] Drop all schemas + re-run migrations (dev only)
179
- everystack db:psql [--stage <name>] -c <command> Execute SQL query via Lambda (private RDS)
202
+ everystack db:psql --stage <name> Interactive ADMIN psql (IAM-gated; resolves the admin URL in-process)
203
+ everystack db:psql [--stage <name>] -c <command> Run one query via Lambda (works for private RDS)
204
+ everystack db:doctor [--stage <name>] Check the DB is least-privilege + RLS-subject (api vs operator connection)
205
+ everystack db:provision [--stage <name>] Create the least-privilege role chain on an EXISTING database (idempotent; creates no DB)
206
+ everystack db:authz:pull [--stage <name>] [--dir authz] Introspect live authz (rls/grants/policies/secdef) → reviewable contract files
207
+ everystack db:authz:diff [--stage <name>] [--dir authz] Validate the live DB against the committed contract (non-zero exit on drift)
208
+ everystack db:authz:test [--stage <name>] [--dir authz] Red-team enforcement: SET ROLE + attempt per role/table/command (non-zero exit on a hole)
209
+ everystack db:authz:report [--dir authz] Render the committed contract as a human-readable authorization review (no DB)
180
210
  everystack console --stage <name> [--sandbox] Interactive REPL on deployed Lambda
181
211
  everystack status [--stage <name>] [--hours <n>] Platform health: CDN, Lambda, rollup summary
212
+ everystack security:audit [--dir <path>] [--config <file>] Static scan of migration SQL (pre-merge gate)
213
+ everystack security:audit --stage <name> [--config <file>] Live catalog scan of a deployed stage (post-deploy)
182
214
  everystack certs:generate [--output ./certs]
183
215
  everystack certs:configure [--input ./certs] [--keyid main]
184
216
  everystack cache:purge [--stage <name>] Bust all cached content (global epoch)
@@ -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
+ }