@everystack/cli 0.3.3 → 0.3.4

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,213 @@
1
+ /**
2
+ * `everystack security:probe --stage <name>` — live black-box security probe.
3
+ *
4
+ * Config-free. Two instruments meeting in the middle:
5
+ * 1. White-box introspection: reads the live DB catalog (grants, columns, RLS)
6
+ * via the ops-Lambda `db:query` path to learn what the deployed app exposes.
7
+ * 2. Black-box probing: hits the deployed router URL and asserts the surface
8
+ * behaves safely against that discovered truth.
9
+ *
10
+ * No fixtures, no config — the plan is derived from the database itself. When the
11
+ * v3 Model lands, the plan source moves from catalog to the Model's abilities and
12
+ * this command becomes the generated `diff(declared, live)` red-team.
13
+ */
14
+
15
+ import {
16
+ buildProbePlan,
17
+ assessRlsCoverage,
18
+ assessColumnExposure,
19
+ assessLimitCap,
20
+ assessInjection,
21
+ assessEmbedDepth,
22
+ summarizeProbe,
23
+ type Catalog,
24
+ type CatalogTable,
25
+ type CatalogColumn,
26
+ type CatalogGrant,
27
+ type ProbeResult,
28
+ } from '../security-probe.js';
29
+ import { resolveConfig, opsFunction } from '../config.js';
30
+ import { invokeAction } from '../aws.js';
31
+ import { step, info, fail, success, warn } from '../output.js';
32
+
33
+ const SYS_SCHEMAS = "('pg_catalog','information_schema','pg_toast')";
34
+
35
+ const TABLES_SQL = `
36
+ SELECT n.nspname AS schema, c.relname AS "table", c.relrowsecurity AS rls_enabled
37
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
38
+ WHERE c.relkind = 'r' AND n.nspname NOT IN ${SYS_SCHEMAS}`;
39
+
40
+ const COLUMNS_SQL = `
41
+ SELECT table_schema AS schema, table_name AS "table", column_name AS "column", data_type
42
+ FROM information_schema.columns
43
+ WHERE table_schema NOT IN ${SYS_SCHEMAS}`;
44
+
45
+ const GRANTS_SQL = `
46
+ SELECT table_schema AS schema, table_name AS "table", grantee, privilege_type AS privilege
47
+ FROM information_schema.role_table_grants
48
+ WHERE table_schema NOT IN ${SYS_SCHEMAS}`;
49
+
50
+ async function catalogQuery(region: string, fn: string, sql: string): Promise<any[]> {
51
+ const result: any = await invokeAction(region, fn, 'db:query', { sql });
52
+ if (result?.error) throw new Error(`Catalog query failed: ${result.error}`);
53
+ return result?.rows ?? [];
54
+ }
55
+
56
+ function toCatalog(tables: any[], columns: any[], grants: any[]): Catalog {
57
+ return {
58
+ tables: tables.map(
59
+ (r): CatalogTable => ({ schema: r.schema, table: r.table, rlsEnabled: r.rls_enabled === true }),
60
+ ),
61
+ columns: columns.map(
62
+ (r): CatalogColumn => ({ schema: r.schema, table: r.table, column: r.column, dataType: r.data_type }),
63
+ ),
64
+ grants: grants.map(
65
+ (r): CatalogGrant => ({ schema: r.schema, table: r.table, grantee: r.grantee, privilege: r.privilege }),
66
+ ),
67
+ };
68
+ }
69
+
70
+ interface GetResult {
71
+ status: number;
72
+ rows: Record<string, unknown>[];
73
+ }
74
+
75
+ async function apiGet(base: string, pathAndQuery: string): Promise<GetResult> {
76
+ const res = await fetch(`${base}${pathAndQuery}`, { redirect: 'follow' });
77
+ let rows: Record<string, unknown>[] = [];
78
+ try {
79
+ const body = await res.json();
80
+ if (Array.isArray(body)) rows = body;
81
+ else if (Array.isArray((body as any)?.data)) rows = (body as any).data;
82
+ } catch {
83
+ // non-JSON body
84
+ }
85
+ return { status: res.status, rows };
86
+ }
87
+
88
+ export interface ProbeRunOptions {
89
+ region: string;
90
+ opsFn: string;
91
+ base: string;
92
+ maxLimit?: number;
93
+ }
94
+
95
+ /** Result-producing core, shared by the command wrapper and the `audit` capstone. */
96
+ export async function runSecurityProbe(opts: ProbeRunOptions): Promise<ProbeResult[]> {
97
+ const { region, opsFn, base } = opts;
98
+
99
+ step('Introspecting database catalog (grants, columns, RLS)...');
100
+ const [t, c, g] = await Promise.all([
101
+ catalogQuery(region, opsFn, TABLES_SQL),
102
+ catalogQuery(region, opsFn, COLUMNS_SQL),
103
+ catalogQuery(region, opsFn, GRANTS_SQL),
104
+ ]);
105
+ const plan = buildProbePlan(toCatalog(t, c, g));
106
+ info(
107
+ `Surface: ${plan.anonReadableTables.length} anon-readable, ${plan.authReadableTables.length} auth-readable table(s).`,
108
+ );
109
+
110
+ const results: ProbeResult[] = [];
111
+ const maxLimit = opts.maxLimit ?? 10000;
112
+
113
+ // 1. RLS coverage — pure, from the catalog (no HTTP).
114
+ results.push(...assessRlsCoverage(plan));
115
+
116
+ // 2. Sensitive-column exposure — anon GET each readable table that has them.
117
+ step('Probing anon column exposure...');
118
+ for (const table of plan.anonReadableTables) {
119
+ const sensitive = plan.sensitiveColumns[table];
120
+ if (!sensitive?.length) continue;
121
+ try {
122
+ const { status, rows } = await apiGet(base, `/api/${table}?limit=5`);
123
+ if (status === 404) {
124
+ warn(`/api/${table} → 404 (route naming differs from DB table; skipped)`);
125
+ continue;
126
+ }
127
+ results.push(assessColumnExposure(table, rows, sensitive));
128
+ } catch (err: any) {
129
+ warn(`column-exposure ${table} inconclusive: ${err.message}`);
130
+ }
131
+ }
132
+
133
+ // 3/4/5 — pick a representative anon-readable table for the protocol probes.
134
+ const probeTable = plan.anonReadableTables[0];
135
+ if (probeTable) {
136
+ step(`Probing query protocol on /api/${probeTable}...`);
137
+ try {
138
+ const cap = await apiGet(base, `/api/${probeTable}?limit=999999999`);
139
+ results.push(assessLimitCap(cap.status, cap.rows.length, maxLimit));
140
+ } catch (err: any) {
141
+ warn(`limit-cap inconclusive: ${err.message}`);
142
+ }
143
+
144
+ const jsonCol = plan.jsonbColumns[probeTable]?.[0];
145
+ if (jsonCol) {
146
+ try {
147
+ const payload = encodeURIComponent("' OR '1'='1");
148
+ const inj = await apiGet(base, `/api/${probeTable}?${jsonCol}->>x=eq.${payload}`);
149
+ results.push(assessInjection(inj.status));
150
+ } catch (err: any) {
151
+ warn(`json-path-injection inconclusive: ${err.message}`);
152
+ }
153
+ }
154
+
155
+ try {
156
+ const deep = 'select=*,a(*,b(*,c(*,d(*,e(*)))))';
157
+ const depth = await apiGet(base, `/api/${probeTable}?${deep}`);
158
+ results.push(assessEmbedDepth(depth.status));
159
+ } catch (err: any) {
160
+ warn(`embed-depth inconclusive: ${err.message}`);
161
+ }
162
+ }
163
+
164
+ return results;
165
+ }
166
+
167
+ /** Print probe results; returns the number of failures (for exit-code logic). */
168
+ export function reportProbeResults(results: ProbeResult[]): number {
169
+ for (const r of results) {
170
+ const tag = `[${r.severity}]`;
171
+ if (r.pass) success(`${tag} ${r.name} — ${r.detail}`);
172
+ else fail(`${tag} ${r.name} — ${r.detail}`);
173
+ }
174
+ const s = summarizeProbe(results);
175
+ console.log('');
176
+ const summary = `security:probe — ${s.pass} passed, ${s.fail} failed (${s.critical} critical)`;
177
+ if (s.fail > 0) fail(summary);
178
+ else success(summary);
179
+ return s.fail;
180
+ }
181
+
182
+ export async function securityProbeCommand(flags: Record<string, string>): Promise<void> {
183
+ step('Resolving deployed config...');
184
+ let config;
185
+ try {
186
+ config = await resolveConfig(flags.stage);
187
+ } catch (err: any) {
188
+ fail(err.message);
189
+ process.exit(1);
190
+ }
191
+ const base = (flags.host || config.baseUrl || '').replace(/\/+$/, '');
192
+ if (!base) {
193
+ fail('No router URL to probe. Pass --host or ensure the stage has a deployed URL.');
194
+ process.exit(1);
195
+ }
196
+ info(`Region: ${config.region}, Function: ${opsFunction(config)}, URL: ${base}`);
197
+
198
+ let results: ProbeResult[];
199
+ try {
200
+ results = await runSecurityProbe({
201
+ region: config.region,
202
+ opsFn: opsFunction(config),
203
+ base,
204
+ maxLimit: flags.maxLimit ? Number(flags.maxLimit) : undefined,
205
+ });
206
+ } catch (err: any) {
207
+ fail(err.message);
208
+ process.exit(1);
209
+ }
210
+
211
+ console.log('');
212
+ process.exit(reportProbeResults(results) > 0 ? 1 : 0);
213
+ }
@@ -104,10 +104,18 @@ async function runCatalogAudit(flags: Record<string, string>, waivers: Waivers):
104
104
  step('Resolving deployed config...');
105
105
  const config = await resolveConfig(flags.stage);
106
106
  info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
107
+ return auditDeployedSql(config.region, opsFunction(config), waivers);
108
+ }
107
109
 
110
+ /** Catalog-audit core, shared by the command and the `audit` capstone. */
111
+ export async function auditDeployedSql(
112
+ region: string,
113
+ opsFn: string,
114
+ waivers: Waivers = {},
115
+ ): Promise<AuditReport> {
108
116
  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);
117
+ const fnRows = await catalogQuery(region, opsFn, FUNCTIONS_SQL);
118
+ const relRows = await catalogQuery(region, opsFn, RELATIONS_SQL);
111
119
 
112
120
  const functions: FunctionDescriptor[] = fnRows.map(catalogFunctionToDescriptor);
113
121
  const views: ViewDescriptor[] = relRows.map(catalogRelationToDescriptor);
@@ -125,7 +133,7 @@ async function catalogQuery(region: string, fn: string, sql: string): Promise<an
125
133
  // Waivers + reporting.
126
134
  // ---------------------------------------------------------------------------
127
135
 
128
- async function loadWaivers(flag?: string): Promise<Waivers> {
136
+ export async function loadWaivers(flag?: string): Promise<Waivers> {
129
137
  const candidates = flag ? [flag] : WAIVER_FILES;
130
138
  for (const candidate of candidates) {
131
139
  const abs = path.resolve(candidate);
@@ -141,7 +149,7 @@ async function loadWaivers(flag?: string): Promise<Waivers> {
141
149
  return {};
142
150
  }
143
151
 
144
- function printReport(report: AuditReport, instrument: 'static' | 'catalog'): void {
152
+ export function printReport(report: AuditReport, instrument: 'static' | 'catalog'): void {
145
153
  const reds = [
146
154
  ...report.functions.filter((f) => f.severity === 'red'),
147
155
  ...report.views.filter((v) => v.severity === 'red'),
package/src/cli/index.ts CHANGED
@@ -21,6 +21,10 @@ import { analyzeSSRCommand } from './commands/analyze.js';
21
21
  import { logsErrorsCommand, logsTailCommand, logsQueryCommand } from './commands/logs.js';
22
22
  import { secretsCommand } from './commands/secrets.js';
23
23
  import { securityAuditCommand } from './commands/security.js';
24
+ import { securityProbeCommand } from './commands/security-probe.js';
25
+ import { bundleAnalyzeCommand, bundleAuditCommand } from './commands/bundle.js';
26
+ import { auditLighthouseCommand } from './commands/lighthouse.js';
27
+ import { auditCommand } from './commands/audit.js';
24
28
  import { fail } from './output.js';
25
29
 
26
30
  const args = process.argv.slice(2);
@@ -206,6 +210,25 @@ async function main() {
206
210
  case 'security:audit':
207
211
  await securityAuditCommand(flags);
208
212
  break;
213
+ case 'security:probe':
214
+ await securityProbeCommand(flags);
215
+ break;
216
+ case 'bundle:analyze': {
217
+ const positional = args[1] && !args[1].startsWith('--') ? args[1] : undefined;
218
+ await bundleAnalyzeCommand(positional, flags);
219
+ break;
220
+ }
221
+ case 'audit':
222
+ await auditCommand(flags);
223
+ break;
224
+ case 'audit:lighthouse':
225
+ await auditLighthouseCommand(flags);
226
+ break;
227
+ case 'bundle:audit': {
228
+ const positional = args[1] && !args[1].startsWith('--') ? args[1] : undefined;
229
+ await bundleAuditCommand(positional, flags);
230
+ break;
231
+ }
209
232
  case 'secrets': {
210
233
  // secrets <subcommand> [positional...] [--flags]
211
234
  // Extract positional args: everything after subcommand that isn't a flag or flag value
@@ -262,6 +285,11 @@ Usage:
262
285
  everystack status [--stage <name>] [--hours <n>] Platform health: CDN, Lambda, rollup summary
263
286
  everystack security:audit [--dir <path>] [--config <file>] Static scan of migration SQL (pre-merge gate)
264
287
  everystack security:audit --stage <name> [--config <file>] Live catalog scan of a deployed stage (post-deploy)
288
+ everystack security:probe --stage <name> [--host <url>] Live black-box probe: introspects RLS/grants, then tests the deployed surface (RLS coverage, column exposure, injection, limit cap, embed depth)
289
+ everystack audit --stage <name> [--host <url>] [--dir <export>] [--lighthouse] Capstone: security:audit + security:probe + bundle:audit (+ optional lighthouse), combined report + exit code (CI gate); --dir adds the weight/size gate over a local export
290
+ everystack bundle:analyze [dir] Client web-bundle size + composition (run after \`expo export -p web --source-maps\`)
291
+ everystack audit:lighthouse [--stage <name>] [--host <url>] [--runs 3] [--path /] [--json out.json] Mobile Lighthouse (perf/a11y/bp/seo), median over N runs
292
+ everystack bundle:audit [url|--dir <export>] [--stage <name>] [--values-from <.env>] [--leak-only] [--max-file-mb N] [--max-total-mb N] [--max-js-mb N] [--max-data-mb N] Audit a bundle for leaked secrets AND weight (oversized assets, data that belongs in the DB, JS bloat). A URL/stage weighs the remote bundle (assets sized via HEAD, no download); --dir scans a local export; --leak-only skips the weight gate
265
293
  everystack certs:generate [--output ./certs]
266
294
  everystack certs:configure [--input ./certs] [--keyid main]
267
295
  everystack cache:purge [--stage <name>] Bust all cached content (global epoch)
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Lighthouse audit — pure, Lighthouse-free parsing/aggregation logic for
3
+ * `everystack audit:lighthouse`.
4
+ *
5
+ * Why N runs: the category scores are stable but Performance swings run-to-run
6
+ * because LCP does under throttled-mobile simulation. A single run can read 92
7
+ * or 65. We run each host several times and report the DISTRIBUTION (median +
8
+ * min/max) — the median is the honest number, the spread shows how noisy LCP is.
9
+ *
10
+ * Measurement rule (in the command): warm the CloudFront edge first, then
11
+ * measure WITHOUT a cache-buster, so we score the real-user warm-HTML path, not
12
+ * a pessimistic cold-origin SSR render.
13
+ *
14
+ * Config-free: the target URL comes from the deployed SST outputs (or --host),
15
+ * never a hand-maintained env map.
16
+ */
17
+
18
+ export const CATEGORIES = ['performance', 'accessibility', 'best-practices', 'seo'] as const;
19
+ export const METRICS = [
20
+ 'first-contentful-paint',
21
+ 'largest-contentful-paint',
22
+ 'total-blocking-time',
23
+ 'cumulative-layout-shift',
24
+ 'speed-index',
25
+ ] as const;
26
+
27
+ export type Category = (typeof CATEGORIES)[number];
28
+ export type Metric = (typeof METRICS)[number];
29
+
30
+ export interface LhAudit {
31
+ title: string;
32
+ score: number | null;
33
+ numericValue?: number;
34
+ scoreDisplayMode?: string;
35
+ }
36
+
37
+ export interface LhReport {
38
+ categories: Record<string, { score: number; auditRefs: { id: string }[] }>;
39
+ audits: Record<string, LhAudit>;
40
+ }
41
+
42
+ export interface ExtractedRun {
43
+ scores: Record<string, number>; // category → 0..100
44
+ metrics: Record<string, number>; // metric → numericValue
45
+ imperfect: { id: string; title: string; cat: string }[];
46
+ }
47
+
48
+ export interface Aggregate {
49
+ scores: Record<string, number[]>;
50
+ metrics: Record<string, number[]>;
51
+ failing: { id: string; title: string; cat: string; count: number }[];
52
+ noindex: boolean;
53
+ }
54
+
55
+ export function median(xs: number[]): number {
56
+ const s = [...xs].sort((a, b) => a - b);
57
+ const m = Math.floor(s.length / 2);
58
+ return s.length % 2 ? s[m] : Math.round((s[m - 1] + s[m]) / 2);
59
+ }
60
+
61
+ /** Pull scores, metrics, and imperfect audits out of one Lighthouse report. */
62
+ export function extractRun(report: LhReport): ExtractedRun {
63
+ const scores: Record<string, number> = {};
64
+ const metrics: Record<string, number> = {};
65
+ const imperfect: { id: string; title: string; cat: string }[] = [];
66
+
67
+ for (const c of CATEGORIES) {
68
+ scores[c] = Math.round((report.categories[c]?.score ?? 0) * 100);
69
+ }
70
+ for (const m of METRICS) {
71
+ metrics[m] = report.audits[m]?.numericValue ?? 0;
72
+ }
73
+ for (const c of CATEGORIES) {
74
+ for (const ref of report.categories[c]?.auditRefs ?? []) {
75
+ const au = report.audits[ref.id];
76
+ if (au && au.score !== null && au.score < 1 && au.scoreDisplayMode !== 'informative') {
77
+ imperfect.push({ id: ref.id, title: au.title, cat: c });
78
+ }
79
+ }
80
+ }
81
+ return { scores, metrics, imperfect };
82
+ }
83
+
84
+ /** Aggregate the per-run extracts into score/metric distributions + flags. */
85
+ export function aggregate(runs: ExtractedRun[]): Aggregate {
86
+ const scores: Record<string, number[]> = Object.fromEntries(CATEGORIES.map((c) => [c, []]));
87
+ const metrics: Record<string, number[]> = Object.fromEntries(METRICS.map((m) => [m, []]));
88
+ const failingMap = new Map<string, { title: string; cat: string; count: number }>();
89
+ let noindex = false;
90
+
91
+ for (const run of runs) {
92
+ for (const c of CATEGORIES) scores[c].push(run.scores[c] ?? 0);
93
+ for (const m of METRICS) metrics[m].push(run.metrics[m] ?? 0);
94
+ for (const imp of run.imperfect) {
95
+ if (imp.id === 'is-crawlable') noindex = true;
96
+ const e = failingMap.get(imp.id) || { title: imp.title, cat: imp.cat, count: 0 };
97
+ e.count++;
98
+ failingMap.set(imp.id, e);
99
+ }
100
+ }
101
+
102
+ const failing = [...failingMap.entries()]
103
+ .map(([id, e]) => ({ id, ...e }))
104
+ .sort((a, b) => b.count - a.count);
105
+ return { scores, metrics, failing, noindex };
106
+ }
107
+
108
+ /** extractRun + aggregate over a set of raw reports. */
109
+ export function summarize(reports: LhReport[]): Aggregate {
110
+ return aggregate(reports.map(extractRun));
111
+ }
112
+
113
+ function fmtMs(v: number): string {
114
+ return v >= 1000 ? `${(v / 1000).toFixed(1)}s` : `${Math.round(v)}ms`;
115
+ }
116
+
117
+ export function formatLighthouseReport(url: string, runs: number, agg: Aggregate): string[] {
118
+ const lines: string[] = [];
119
+ lines.push('='.repeat(64));
120
+ lines.push(`${url} (${runs} run${runs === 1 ? '' : 's'})`);
121
+ lines.push('='.repeat(64));
122
+ lines.push('Category median runs');
123
+ for (const c of CATEGORIES) {
124
+ const xs = agg.scores[c];
125
+ const spread = xs.length > 1 ? ` [${Math.min(...xs)}–${Math.max(...xs)}]` : '';
126
+ lines.push(` ${c.padEnd(14)} ${String(median(xs)).padStart(3)} ${xs.join(' ')}${spread}`);
127
+ }
128
+ lines.push('');
129
+ lines.push('Metric median spread');
130
+ for (const m of METRICS) {
131
+ const xs = agg.metrics[m];
132
+ const cls = m === 'cumulative-layout-shift';
133
+ const f = cls ? (v: number) => v.toFixed(3) : fmtMs;
134
+ lines.push(` ${m.padEnd(24)} ${f(median(xs)).padStart(7)} ${f(Math.min(...xs))}–${f(Math.max(...xs))}`);
135
+ }
136
+ if (agg.failing.length) {
137
+ lines.push('');
138
+ lines.push('Imperfect audits (count = runs flagged):');
139
+ for (const e of agg.failing) {
140
+ const note =
141
+ e.id === 'is-crawlable'
142
+ ? ' ← expected when the stage is noindex (true SEO only on the indexable domain)'
143
+ : '';
144
+ lines.push(` [${e.cat}] ${e.id} (${e.count}/${runs}) — ${e.title}${note}`);
145
+ }
146
+ }
147
+ if (agg.noindex) {
148
+ lines.push('');
149
+ lines.push(
150
+ 'NOTE: is-crawlable fails because this stage is noindex (robots Disallow: /).',
151
+ );
152
+ lines.push(' That suppresses SEO ~20pts. Re-measure the indexable production domain for the true score.');
153
+ }
154
+ return lines;
155
+ }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Live security probe — pure plan-builder + assertion engine for
3
+ * `everystack security:probe`.
4
+ *
5
+ * Config-free by INTROSPECTION: the command reads the live DB catalog (grants,
6
+ * columns, RLS) through the ops-Lambda `db:query` path that `security:audit
7
+ * --stage` already uses, and this module turns that catalog into a ProbePlan —
8
+ * which tables are anon/auth-readable, which columns are sensitive, where RLS is
9
+ * off. The HTTP probes then test the deployed surface against that discovered
10
+ * truth. Nothing is hand-declared.
11
+ *
12
+ * Forward path: when the v3 Model lands, the Model's `abilities` become the
13
+ * oracle and the same assertions become `diff(declared, live)` — this engine is
14
+ * reused unchanged; only the source of the plan moves from catalog to Model.
15
+ */
16
+
17
+ // --- Catalog shapes (rows returned by the introspection SQL) ----------------
18
+
19
+ export interface CatalogColumn {
20
+ schema: string;
21
+ table: string;
22
+ column: string;
23
+ dataType: string;
24
+ }
25
+
26
+ export interface CatalogGrant {
27
+ schema: string;
28
+ table: string;
29
+ grantee: string; // 'anon' | 'authenticated' | 'PUBLIC' | ...
30
+ privilege: string; // 'SELECT' | 'INSERT' | ...
31
+ }
32
+
33
+ export interface CatalogTable {
34
+ schema: string;
35
+ table: string;
36
+ rlsEnabled: boolean;
37
+ }
38
+
39
+ export interface Catalog {
40
+ tables: CatalogTable[];
41
+ columns: CatalogColumn[];
42
+ grants: CatalogGrant[];
43
+ }
44
+
45
+ // --- Universal sensitive-column heuristic (app-agnostic) --------------------
46
+
47
+ const SENSITIVE_PATTERNS: RegExp[] = [
48
+ /pass(word)?/i,
49
+ /secret/i,
50
+ /(^|_)token$/i,
51
+ /(^|_)hash$/i,
52
+ /salt/i,
53
+ /(^|_)key$/i,
54
+ /api[_-]?key/i,
55
+ /private[_-]?key/i,
56
+ /stripe/i,
57
+ /session[_-]?id/i,
58
+ /refresh[_-]?token/i,
59
+ /reset[_-]?password/i,
60
+ /(^|_)ip(_address)?$/i,
61
+ /sign_in_ip/i,
62
+ /distinct_id/i,
63
+ /(^|_)otp(_|$)/i,
64
+ ];
65
+
66
+ export function isSensitiveColumn(name: string): boolean {
67
+ return SENSITIVE_PATTERNS.some((re) => re.test(name));
68
+ }
69
+
70
+ // --- Probe plan -------------------------------------------------------------
71
+
72
+ export interface ProbePlan {
73
+ anonReadableTables: string[];
74
+ authReadableTables: string[];
75
+ sensitiveColumns: Record<string, string[]>; // table → sensitive columns
76
+ jsonbColumns: Record<string, string[]>;
77
+ tablesWithoutRls: string[]; // readable tables with RLS disabled
78
+ userTable: string | null; // most-sensitive table (heuristic target)
79
+ }
80
+
81
+ const ANON_GRANTEES = new Set(['anon', 'PUBLIC', 'public']);
82
+
83
+ export function buildProbePlan(catalog: Catalog): ProbePlan {
84
+ const selectGrant = (grantee: (g: string) => boolean): Set<string> => {
85
+ const s = new Set<string>();
86
+ for (const g of catalog.grants) {
87
+ if (g.privilege.toUpperCase() === 'SELECT' && grantee(g.grantee)) {
88
+ s.add(g.table);
89
+ }
90
+ }
91
+ return s;
92
+ };
93
+ const anonReadable = selectGrant((g) => ANON_GRANTEES.has(g));
94
+ const authReadable = selectGrant((g) => g === 'authenticated');
95
+
96
+ const sensitiveColumns: Record<string, string[]> = {};
97
+ const jsonbColumns: Record<string, string[]> = {};
98
+ for (const c of catalog.columns) {
99
+ if (isSensitiveColumn(c.column)) {
100
+ (sensitiveColumns[c.table] ||= []).push(c.column);
101
+ }
102
+ if (/json/i.test(c.dataType)) {
103
+ (jsonbColumns[c.table] ||= []).push(c.column);
104
+ }
105
+ }
106
+
107
+ const rlsByTable = new Map(catalog.tables.map((t) => [t.table, t.rlsEnabled]));
108
+ const readable = new Set([...anonReadable, ...authReadable]);
109
+ const tablesWithoutRls = [...readable].filter((t) => rlsByTable.get(t) === false).sort();
110
+
111
+ // Heuristic user-table target: the readable table with the most sensitive cols.
112
+ let userTable: string | null = null;
113
+ let max = 0;
114
+ for (const t of readable) {
115
+ const n = (sensitiveColumns[t] || []).length;
116
+ if (n > max) {
117
+ max = n;
118
+ userTable = t;
119
+ }
120
+ }
121
+
122
+ return {
123
+ anonReadableTables: [...anonReadable].sort(),
124
+ authReadableTables: [...authReadable].sort(),
125
+ sensitiveColumns,
126
+ jsonbColumns,
127
+ tablesWithoutRls,
128
+ userTable,
129
+ };
130
+ }
131
+
132
+ // --- Probe assertions (pure; over HTTP responses) ---------------------------
133
+
134
+ export type Severity = 'critical' | 'high' | 'medium';
135
+
136
+ export interface ProbeResult {
137
+ id: string;
138
+ name: string;
139
+ severity: Severity;
140
+ pass: boolean;
141
+ detail: string;
142
+ }
143
+
144
+ /** Readable tables MUST have RLS — without it, the JS role map is the only guard. */
145
+ export function assessRlsCoverage(plan: ProbePlan): ProbeResult[] {
146
+ if (plan.tablesWithoutRls.length === 0) {
147
+ return [
148
+ {
149
+ id: 'rls-coverage',
150
+ name: 'RLS enabled on all client-readable tables',
151
+ severity: 'critical',
152
+ pass: true,
153
+ detail: `${plan.anonReadableTables.length + plan.authReadableTables.length} readable table(s), all RLS-protected`,
154
+ },
155
+ ];
156
+ }
157
+ return plan.tablesWithoutRls.map((t) => ({
158
+ id: `rls-coverage:${t}`,
159
+ name: `RLS on ${t}`,
160
+ severity: 'critical' as const,
161
+ pass: false,
162
+ detail: `${t} is client-readable but has ROW LEVEL SECURITY disabled — every row is exposed`,
163
+ }));
164
+ }
165
+
166
+ /** An anon GET response must not contain sensitive column values. */
167
+ export function assessColumnExposure(
168
+ table: string,
169
+ rows: Record<string, unknown>[],
170
+ sensitiveCols: string[],
171
+ ): ProbeResult {
172
+ const leaked = new Set<string>();
173
+ for (const row of rows) {
174
+ for (const col of sensitiveCols) {
175
+ if (col in row && row[col] !== null && row[col] !== undefined) leaked.add(col);
176
+ }
177
+ }
178
+ return {
179
+ id: `column-exposure:${table}`,
180
+ name: `No sensitive columns exposed on ${table}`,
181
+ severity: 'critical',
182
+ pass: leaked.size === 0,
183
+ detail:
184
+ leaked.size === 0
185
+ ? `${sensitiveCols.length} sensitive column(s) withheld`
186
+ : `exposed sensitive column(s): ${[...leaked].join(', ')}`,
187
+ };
188
+ }
189
+
190
+ /** ?limit must be capped — an unbounded result set is a DoS / exfil amplifier. */
191
+ export function assessLimitCap(
192
+ status: number,
193
+ returnedCount: number,
194
+ maxLimit = 10000,
195
+ ): ProbeResult {
196
+ const pass = status === 400 || returnedCount <= maxLimit;
197
+ return {
198
+ id: 'limit-cap',
199
+ name: 'Unbounded ?limit is rejected or clamped',
200
+ severity: 'medium',
201
+ pass,
202
+ detail: pass
203
+ ? `capped (status ${status}, ${returnedCount} rows)`
204
+ : `returned ${returnedCount} rows (> ${maxLimit}) — no cap`,
205
+ };
206
+ }
207
+
208
+ /** A crafted JSON-path filter must not reach the DB (500) — 400/empty-200 is safe. */
209
+ export function assessInjection(status: number): ProbeResult {
210
+ const pass = status < 500;
211
+ return {
212
+ id: 'json-path-injection',
213
+ name: 'JSON-path injection payload is rejected, not executed',
214
+ severity: 'high',
215
+ pass,
216
+ detail: pass ? `safe (status ${status})` : `status ${status} — payload reached the database`,
217
+ };
218
+ }
219
+
220
+ /** A deeply nested embed must be rejected (400), not executed or timed out. */
221
+ export function assessEmbedDepth(status: number): ProbeResult {
222
+ const pass = status === 400 || status === 414;
223
+ return {
224
+ id: 'embed-depth',
225
+ name: 'Over-deep relation embed is rejected',
226
+ severity: 'medium',
227
+ pass,
228
+ detail: pass ? `rejected (status ${status})` : `status ${status} — deep embed not capped`,
229
+ };
230
+ }
231
+
232
+ export function summarizeProbe(results: ProbeResult[]): {
233
+ pass: number;
234
+ fail: number;
235
+ critical: number;
236
+ } {
237
+ const failed = results.filter((r) => !r.pass);
238
+ return {
239
+ pass: results.filter((r) => r.pass).length,
240
+ fail: failed.length,
241
+ critical: failed.filter((r) => r.severity === 'critical').length,
242
+ };
243
+ }