@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.
- package/package.json +5 -1
- package/src/cli/audit-api.ts +54 -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/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/security-probe.ts +243 -0
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|