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