@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.
@@ -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'),
@@ -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
+ }