@everystack/cli 0.3.3 → 0.3.5
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 +15 -10
- package/src/cli/audit-api.ts +97 -0
- package/src/cli/bundle-audit.ts +225 -0
- package/src/cli/bundle-weight.ts +356 -0
- package/src/cli/bundle.ts +146 -0
- package/src/cli/commands/audit.ts +134 -0
- package/src/cli/commands/bundle.ts +461 -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/commands/ui-audit.ts +76 -0
- package/src/cli/component-audit.ts +487 -0
- package/src/cli/component-model.ts +304 -0
- package/src/cli/index.ts +35 -0
- package/src/cli/lighthouse.ts +155 -0
- package/src/cli/security-probe.ts +243 -0
- package/src/cli/ui-bloat.ts +198 -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'),
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `everystack ui:audit [dir]` — source-side UI design-bloat audit.
|
|
3
|
+
*
|
|
4
|
+
* The local, actionable half of the design-bloat loop (the remote half rides in
|
|
5
|
+
* `bundle:audit`). It parses the app's `.tsx` source into the component model and
|
|
6
|
+
* runs the source-side checks: styles copy-pasted across call sites, hardcoded
|
|
7
|
+
* colors that should be tokens, raw primitives that reimplement a ui component,
|
|
8
|
+
* oversized components, and duplicated JSX. Every finding carries `file:line`
|
|
9
|
+
* occurrences and a fix the agent can apply, then verifies by re-running.
|
|
10
|
+
*
|
|
11
|
+
* Advisory by design — refactoring is taste, so this never gates (exit 0 even with
|
|
12
|
+
* findings). Config-free: defaults derived from the model, no per-app setup.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { discoverComponents } from '../component-model.js';
|
|
18
|
+
import { auditComponents } from '../component-audit.js';
|
|
19
|
+
import { resolveUiBloatBudget } from '../ui-bloat.js';
|
|
20
|
+
import { summarize, type AuditFinding } from '../bundle-audit.js';
|
|
21
|
+
import { fail, info, step, success, warn } from '../output.js';
|
|
22
|
+
|
|
23
|
+
export interface UiAuditResult {
|
|
24
|
+
findings: AuditFinding[];
|
|
25
|
+
componentCount: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Result-producing core, shared by the command and the `audit` capstone. */
|
|
29
|
+
export function runUiAudit(dir: string, flags: Record<string, string> = {}): UiAuditResult {
|
|
30
|
+
const root = path.resolve(dir);
|
|
31
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
32
|
+
throw new Error(`${dir} is not a directory — pass the app source dir (defaults to .).`);
|
|
33
|
+
}
|
|
34
|
+
const components = discoverComponents(root);
|
|
35
|
+
const findings = auditComponents(components, resolveUiBloatBudget(flags));
|
|
36
|
+
return { findings, componentCount: components.length };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Print ui:audit findings; returns the number of advisory warnings. */
|
|
40
|
+
export function reportUiAudit(result: UiAuditResult): number {
|
|
41
|
+
const { findings, componentCount } = result;
|
|
42
|
+
if (findings.length === 0) {
|
|
43
|
+
success(`ui:audit — clean. No design bloat across ${componentCount} component(s).`);
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
for (const f of findings) {
|
|
47
|
+
warn(`[${f.check}] ${f.source}: ${f.detail}`);
|
|
48
|
+
if (f.fix) info(` fix: ${f.fix.summary}`);
|
|
49
|
+
if (f.occurrences && f.occurrences.length > 1) {
|
|
50
|
+
info(` at: ${f.occurrences.length} sites`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
console.log('');
|
|
54
|
+
const { warn: warns } = summarize(findings);
|
|
55
|
+
warn(`ui:audit — ${warns} advisory finding(s) across ${componentCount} component(s) (advisory; does not gate)`);
|
|
56
|
+
return warns;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function uiAuditCommand(
|
|
60
|
+
positional: string | undefined,
|
|
61
|
+
flags: Record<string, string>,
|
|
62
|
+
): Promise<void> {
|
|
63
|
+
const dir = positional || flags.dir || '.';
|
|
64
|
+
step(`Scanning components under ${dir} ...`);
|
|
65
|
+
let result: UiAuditResult;
|
|
66
|
+
try {
|
|
67
|
+
result = runUiAudit(dir, flags);
|
|
68
|
+
} catch (err: any) {
|
|
69
|
+
fail(err.message);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
console.log('');
|
|
73
|
+
reportUiAudit(result);
|
|
74
|
+
// Advisory only — refactoring is taste, so this never gates the build.
|
|
75
|
+
process.exit(0);
|
|
76
|
+
}
|