@everystack/cli 0.3.0 → 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.
- package/package.json +6 -2
- package/src/cli/audit-api.ts +54 -0
- package/src/cli/authz-compile.ts +186 -34
- package/src/cli/authz-owner-probe.ts +22 -4
- package/src/cli/authz-reconcile.ts +25 -0
- package/src/cli/bundle-audit.ts +185 -0
- package/src/cli/bundle-weight.ts +356 -0
- package/src/cli/bundle.ts +146 -0
- package/src/cli/commands/audit.ts +118 -0
- package/src/cli/commands/bundle.ts +452 -0
- package/src/cli/commands/db-authz.ts +13 -5
- package/src/cli/commands/db-generate.ts +79 -1
- package/src/cli/commands/lighthouse.ts +104 -0
- package/src/cli/commands/security-probe.ts +213 -0
- package/src/cli/commands/security.ts +12 -4
- package/src/cli/index.ts +28 -0
- package/src/cli/lighthouse.ts +155 -0
- package/src/cli/migration-compile.ts +71 -7
- package/src/cli/migration-generate.ts +4 -1
- package/src/cli/model-api.ts +1 -0
- package/src/cli/schema-compile.ts +66 -9
- package/src/cli/schema-diff.ts +4 -1
- package/src/cli/schema-source.ts +416 -0
- package/src/cli/security-probe.ts +243 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `everystack audit:lighthouse [--stage <name>] [--host <url>] [--runs N] [--path /]`
|
|
3
|
+
*
|
|
4
|
+
* Mobile Lighthouse audit (Perf / A11y / Best-Practices / SEO) reported as a
|
|
5
|
+
* distribution over N runs, because the Performance score swings run-to-run with
|
|
6
|
+
* LCP. Warms the CloudFront edge first, then measures the warm-HTML path.
|
|
7
|
+
*
|
|
8
|
+
* Config-free: the target URL is the deployed router URL from SST outputs
|
|
9
|
+
* (resolveConfig), or --host. Requires `npx lighthouse` and a headless Chrome.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { execFileSync } from 'node:child_process';
|
|
13
|
+
import { readFileSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import {
|
|
17
|
+
CATEGORIES,
|
|
18
|
+
extractRun,
|
|
19
|
+
aggregate,
|
|
20
|
+
formatLighthouseReport,
|
|
21
|
+
type ExtractedRun,
|
|
22
|
+
type LhReport,
|
|
23
|
+
} from '../lighthouse.js';
|
|
24
|
+
import { resolveConfig } from '../config.js';
|
|
25
|
+
import { step, info, fail, warn } from '../output.js';
|
|
26
|
+
|
|
27
|
+
function runLighthouse(url: string, outPath: string): LhReport {
|
|
28
|
+
// Warm the edge so we measure the real-user (cached-HTML) experience.
|
|
29
|
+
try {
|
|
30
|
+
execFileSync('curl', ['-s', '-o', '/dev/null', url], { timeout: 30_000 });
|
|
31
|
+
} catch {
|
|
32
|
+
// warming is best-effort
|
|
33
|
+
}
|
|
34
|
+
execFileSync(
|
|
35
|
+
'npx',
|
|
36
|
+
[
|
|
37
|
+
'lighthouse',
|
|
38
|
+
url,
|
|
39
|
+
`--only-categories=${CATEGORIES.join(',')}`,
|
|
40
|
+
'--chrome-flags=--headless=new --no-sandbox',
|
|
41
|
+
'--output=json',
|
|
42
|
+
`--output-path=${outPath}`,
|
|
43
|
+
'--quiet',
|
|
44
|
+
],
|
|
45
|
+
{ stdio: ['ignore', 'ignore', 'ignore'], timeout: 120_000 },
|
|
46
|
+
);
|
|
47
|
+
return JSON.parse(readFileSync(outPath, 'utf8'));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Run Lighthouse N times and return the per-run extracts. Shared with the
|
|
51
|
+
* `audit` capstone. */
|
|
52
|
+
export async function collectLighthouseRuns(url: string, runs: number): Promise<ExtractedRun[]> {
|
|
53
|
+
const tmp = mkdtempSync(join(tmpdir(), 'everystack-lh-'));
|
|
54
|
+
const extracts: ExtractedRun[] = [];
|
|
55
|
+
for (let i = 0; i < runs; i++) {
|
|
56
|
+
step(`Lighthouse run ${i + 1}/${runs} on ${url} ...`);
|
|
57
|
+
try {
|
|
58
|
+
const report = runLighthouse(url, join(tmp, `lh-${i}.json`));
|
|
59
|
+
extracts.push(extractRun(report));
|
|
60
|
+
} catch (err: any) {
|
|
61
|
+
warn(`run ${i + 1} failed: ${err.message?.split('\n')[0] ?? err}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return extracts;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function auditLighthouseCommand(flags: Record<string, string>): Promise<void> {
|
|
68
|
+
let host = flags.host;
|
|
69
|
+
if (!host) {
|
|
70
|
+
try {
|
|
71
|
+
const config = await resolveConfig(flags.stage);
|
|
72
|
+
host = config.baseUrl;
|
|
73
|
+
} catch (err: any) {
|
|
74
|
+
fail(err.message);
|
|
75
|
+
info('Pass --host <url> to audit a specific URL without resolving a stage.');
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!host) {
|
|
80
|
+
fail('No URL to audit. Pass --host <url> or --stage <name>.');
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const runs = flags.runs ? parseInt(flags.runs, 10) : 3;
|
|
85
|
+
const pathName = flags.path || '/';
|
|
86
|
+
const url = host.replace(/\/+$/, '') + (pathName.startsWith('/') ? pathName : '/' + pathName);
|
|
87
|
+
|
|
88
|
+
const extracts = await collectLighthouseRuns(url, runs);
|
|
89
|
+
|
|
90
|
+
if (extracts.length === 0) {
|
|
91
|
+
fail('All Lighthouse runs failed. Is `npx lighthouse` installed and Chrome available?');
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const agg = aggregate(extracts);
|
|
96
|
+
console.log('');
|
|
97
|
+
console.log(formatLighthouseReport(url, extracts.length, agg).join('\n'));
|
|
98
|
+
|
|
99
|
+
if (flags.json) {
|
|
100
|
+
writeFileSync(flags.json, JSON.stringify(agg, null, 2));
|
|
101
|
+
info(`Raw results → ${flags.json}`);
|
|
102
|
+
}
|
|
103
|
+
process.exit(0);
|
|
104
|
+
}
|
|
@@ -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(
|
|
110
|
-
const relRows = await catalogQuery(
|
|
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
|
+
}
|
|
@@ -19,9 +19,9 @@
|
|
|
19
19
|
* lands with whole-DB introspection. The emission is identical either way.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import type { ModelDescriptor } from '@everystack/model';
|
|
22
|
+
import type { ModelDescriptor, Module } from '@everystack/model';
|
|
23
23
|
import type { AuthzContract, TableContract } from './authz-contract.js';
|
|
24
|
-
import { compileCreateTable, compileEnums, foreignKeySql, modelConstraintSpecs, type CompileTableOptions } from './schema-compile.js';
|
|
24
|
+
import { compileCreateTable, compileEnums, foreignKeySql, modelConstraintSpecs, qualifiedTable, type CompileTableOptions } from './schema-compile.js';
|
|
25
25
|
import { compileTableContract } from './authz-compile.js';
|
|
26
26
|
import { emitReconcileSql } from './authz-reconcile.js';
|
|
27
27
|
import { emitSchemaSql, type SchemaChange } from './schema-diff.js';
|
|
@@ -38,8 +38,36 @@ function emptyTable(table: string): TableContract {
|
|
|
38
38
|
*/
|
|
39
39
|
export function compileMigration(models: ModelDescriptor[], opts: CompileTableOptions = {}): string[] {
|
|
40
40
|
const sql: string[] = [];
|
|
41
|
+
const schemaOf = (m: ModelDescriptor): string => m.schema ?? 'public';
|
|
41
42
|
|
|
42
|
-
//
|
|
43
|
+
// 0a. Non-public schemas — `CREATE SCHEMA` for each distinct one a model lives in,
|
|
44
|
+
// before any table is created in it. public is the implicit default, never emitted.
|
|
45
|
+
const schemas = [...new Set(models.map(schemaOf))].filter((s) => s !== 'public').sort();
|
|
46
|
+
sql.push(...schemas.map((s) => `CREATE SCHEMA IF NOT EXISTS "${s}";`));
|
|
47
|
+
|
|
48
|
+
// The authz contracts — computed once, used for the schema USAGE grants here and the
|
|
49
|
+
// table-level reconcile below.
|
|
50
|
+
const contracts = models.map((m) => compileTableContract(m));
|
|
51
|
+
|
|
52
|
+
// 0a-bis. Schema USAGE — a role can't reach a table in a non-public schema without USAGE on
|
|
53
|
+
// it, so a table/column grant there is dead without this. Derived from the grants: every
|
|
54
|
+
// role that holds any privilege on a table in a non-public schema gets USAGE on that schema
|
|
55
|
+
// (public USAGE is granted to PUBLIC by default, so it is never emitted). This replaces the
|
|
56
|
+
// hand-written `GRANT USAGE ON SCHEMA auth/ops/dist …` the old bootstrap carried.
|
|
57
|
+
for (const schema of schemas) {
|
|
58
|
+
const roles = new Set<string>();
|
|
59
|
+
for (const c of contracts) {
|
|
60
|
+
if (!c.table.startsWith(`${schema}.`)) continue;
|
|
61
|
+
for (const r of Object.keys(c.grants)) roles.add(r);
|
|
62
|
+
for (const r of Object.keys(c.columnGrants ?? {})) roles.add(r);
|
|
63
|
+
}
|
|
64
|
+
if (roles.size) {
|
|
65
|
+
const targets = [...roles].sort().map((r) => (r.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : r)).join(', ');
|
|
66
|
+
sql.push(`GRANT USAGE ON SCHEMA "${schema}" TO ${targets};`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 0b. Enum types — `CREATE TYPE … AS ENUM`, before any table/column references them.
|
|
43
71
|
const enumCreates: SchemaChange[] = compileEnums(models).map((e) => ({ kind: 'createType', name: e.name, values: e.values }));
|
|
44
72
|
sql.push(...emitSchemaSql(enumCreates));
|
|
45
73
|
|
|
@@ -49,11 +77,12 @@ export function compileMigration(models: ModelDescriptor[], opts: CompileTableOp
|
|
|
49
77
|
// 2. Foreign keys — after every table exists.
|
|
50
78
|
for (const model of models) sql.push(...foreignKeySql(model));
|
|
51
79
|
|
|
52
|
-
// 2b. Standalone indexes — after the columns they cover exist.
|
|
80
|
+
// 2b. Standalone indexes — after the columns they cover exist. The ON clause is the
|
|
81
|
+
// schema-qualified table, so a non-public index targets the right table.
|
|
53
82
|
const indexCreates: SchemaChange[] = [];
|
|
54
83
|
for (const model of models) {
|
|
55
84
|
for (const ix of modelConstraintSpecs(model).indexes) {
|
|
56
|
-
indexCreates.push({ kind: 'createIndex', table:
|
|
85
|
+
indexCreates.push({ kind: 'createIndex', table: qualifiedTable(model), name: ix.name, columns: ix.columns, unique: ix.unique, ...(ix.where ? { where: ix.where } : {}) });
|
|
57
86
|
}
|
|
58
87
|
}
|
|
59
88
|
sql.push(...emitSchemaSql(indexCreates));
|
|
@@ -61,9 +90,44 @@ export function compileMigration(models: ModelDescriptor[], opts: CompileTableOp
|
|
|
61
90
|
// 3. Authz — RLS + policies + grants, after the columns they reference. Reuse
|
|
62
91
|
// the reconcile emitter against the empty baseline: every policy/grant is a
|
|
63
92
|
// pure creation, in the same dependency-correct order reconcile already uses.
|
|
64
|
-
|
|
65
|
-
|
|
93
|
+
// The baseline keys on each model's resolved schema, so a non-public table
|
|
94
|
+
// diffs against its own empty state, not a phantom public one.
|
|
95
|
+
const desired: AuthzContract = { tables: contracts, functions: [] };
|
|
96
|
+
const baseline: AuthzContract = { tables: models.map((m) => emptyTable(`${schemaOf(m)}.${m.table}`)), functions: [] };
|
|
66
97
|
sql.push(...emitReconcileSql(desired, baseline));
|
|
67
98
|
|
|
68
99
|
return sql;
|
|
69
100
|
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Compile a set of Modules into one COMPLETE greenfield migration — the whole deploy, no
|
|
104
|
+
* hand-written bootstrap left:
|
|
105
|
+
*
|
|
106
|
+
* 1. CREATE EXTENSION (deduped, sorted) — the bootstrap a package's SQL needs
|
|
107
|
+
* 2. compileMigration(flattened models) — schemas, tables, FKs, indexes, RLS, authz
|
|
108
|
+
* 3. package functions/triggers (each thunk) — imperative package SQL, AFTER the tables
|
|
109
|
+
*
|
|
110
|
+
* Roles are NOT emitted — they are cluster-global (`db:provision`), and the GRANTs in step 2
|
|
111
|
+
* assume they exist (as the dogfood pre-creates them). `compileMigration` is left untouched as
|
|
112
|
+
* the schema+authz core the contract round-trips through; the functions are emitted verbatim
|
|
113
|
+
* here, outside that round-trip (they are imperative, package-owned — not modeled).
|
|
114
|
+
*/
|
|
115
|
+
export function compileModuleMigration(modules: Module[], opts: CompileTableOptions = {}): string[] {
|
|
116
|
+
const sql: string[] = [];
|
|
117
|
+
|
|
118
|
+
// 1. Extensions — deduped + sorted, quoted so a hyphenated name (`uuid-ossp`) is valid.
|
|
119
|
+
const extensions = [...new Set(modules.flatMap((m) => m.extensions))].sort();
|
|
120
|
+
sql.push(...extensions.map((e) => `CREATE EXTENSION IF NOT EXISTS "${e}";`));
|
|
121
|
+
|
|
122
|
+
// 2. Schema + authz for every modeled table.
|
|
123
|
+
sql.push(...compileMigration(modules.flatMap((m) => m.models), opts));
|
|
124
|
+
|
|
125
|
+
// 3. Package SQL — functions + triggers, after the tables they reference. Each thunk's
|
|
126
|
+
// output is a self-contained multi-statement block applied as one unit.
|
|
127
|
+
for (const fn of modules.flatMap((m) => m.functions)) {
|
|
128
|
+
const text = fn().trim();
|
|
129
|
+
if (text) sql.push(text);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return sql;
|
|
133
|
+
}
|