@everystack/cli 0.2.36 → 0.2.39
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/LICENSE +681 -0
- package/README.md +5 -1
- package/package.json +17 -8
- package/src/cli/commands/db.ts +215 -5
- package/src/cli/commands/logs.ts +67 -33
- package/src/cli/commands/security.ts +185 -0
- package/src/cli/index.ts +17 -2
- package/src/cli/observability.ts +2 -0
- package/src/cli/security-audit.ts +398 -0
- package/src/cli/security-catalog.ts +155 -0
- package/src/storage/filesystem.ts +26 -0
- package/src/storage/index.ts +18 -0
- package/src/storage/s3.ts +51 -0
package/README.md
CHANGED
|
@@ -286,4 +286,8 @@ The handler implements the full Expo Updates manifest protocol:
|
|
|
286
286
|
|
|
287
287
|
## License
|
|
288
288
|
|
|
289
|
-
|
|
289
|
+
[AGPL-3.0-only](https://www.gnu.org/licenses/agpl-3.0.html) © Scalable Technology, Inc.
|
|
290
|
+
|
|
291
|
+
A commercial license is available for organizations that cannot or do not wish to
|
|
292
|
+
comply with the AGPL-3.0 terms. For commercial licensing, contact
|
|
293
|
+
licensing@scalable.technology.
|
package/package.json
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.39",
|
|
4
4
|
"description": "CLI and OTA updates for Expo apps on everystack",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
|
+
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/scalable-technology/everystack.git",
|
|
10
|
+
"directory": "packages/cli"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/scalable-technology/everystack#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/scalable-technology/everystack/issues"
|
|
15
|
+
},
|
|
6
16
|
"publishConfig": {
|
|
7
17
|
"access": "public"
|
|
8
18
|
},
|
|
@@ -43,12 +53,6 @@
|
|
|
43
53
|
"bin": {
|
|
44
54
|
"everystack": "./src/cli/index.ts"
|
|
45
55
|
},
|
|
46
|
-
"scripts": {
|
|
47
|
-
"test": "jest",
|
|
48
|
-
"build": "tsc --build",
|
|
49
|
-
"prepublishOnly": "tsc --module commonjs --moduleResolution node --target ES2022 --esModuleInterop --skipLibCheck --declaration false --outDir src src/env.ts",
|
|
50
|
-
"lint": "tsc --noEmit"
|
|
51
|
-
},
|
|
52
56
|
"dependencies": {
|
|
53
57
|
"@aws-sdk/client-cloudfront": "3.1053.0",
|
|
54
58
|
"@aws-sdk/client-cloudfront-keyvaluestore": "3.1053.0",
|
|
@@ -119,5 +123,10 @@
|
|
|
119
123
|
"react": "19.2.6",
|
|
120
124
|
"ts-jest": "29.4.9",
|
|
121
125
|
"typescript": "5.9.3"
|
|
126
|
+
},
|
|
127
|
+
"scripts": {
|
|
128
|
+
"test": "jest",
|
|
129
|
+
"build": "tsc --build",
|
|
130
|
+
"lint": "tsc --noEmit"
|
|
122
131
|
}
|
|
123
|
-
}
|
|
132
|
+
}
|
package/src/cli/commands/db.ts
CHANGED
|
@@ -9,6 +9,34 @@ import { resolveConfig, opsFunction } from '../config.js';
|
|
|
9
9
|
import { invokeAction } from '../aws.js';
|
|
10
10
|
import { step, success, fail, info, warn } from '../output.js';
|
|
11
11
|
|
|
12
|
+
/** Secret-store keys that may hold the privileged (operator) connection URL,
|
|
13
|
+
* in precedence order. Names vary by deployment; --secret-name overrides. */
|
|
14
|
+
const ADMIN_URL_KEYS = ['ADMIN_DATABASE_URL', 'AdminDatabaseUrl', 'ADMIN_URL'];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Parse a postgres connection URL into psql's PG* environment variables.
|
|
18
|
+
*
|
|
19
|
+
* Passing the credential via env (not argv) keeps the password out of the
|
|
20
|
+
* process table and shell history. Pure and side-effect free — it never logs;
|
|
21
|
+
* callers must never log the result either (it contains PGPASSWORD).
|
|
22
|
+
*/
|
|
23
|
+
export function pgEnvFromUrl(url: string): Record<string, string> {
|
|
24
|
+
const u = new URL(url);
|
|
25
|
+
if (!/^postgres(ql)?:$/.test(u.protocol)) {
|
|
26
|
+
throw new Error(`Not a postgres connection URL (protocol: ${u.protocol})`);
|
|
27
|
+
}
|
|
28
|
+
const env: Record<string, string> = {};
|
|
29
|
+
if (u.hostname) env.PGHOST = decodeURIComponent(u.hostname);
|
|
30
|
+
if (u.port) env.PGPORT = u.port;
|
|
31
|
+
if (u.username) env.PGUSER = decodeURIComponent(u.username);
|
|
32
|
+
if (u.password) env.PGPASSWORD = decodeURIComponent(u.password);
|
|
33
|
+
const database = u.pathname.replace(/^\//, '');
|
|
34
|
+
if (database) env.PGDATABASE = decodeURIComponent(database);
|
|
35
|
+
const sslmode = u.searchParams.get('sslmode');
|
|
36
|
+
if (sslmode) env.PGSSLMODE = sslmode;
|
|
37
|
+
return env;
|
|
38
|
+
}
|
|
39
|
+
|
|
12
40
|
export async function dbMigrateCommand(flags: Record<string, string>): Promise<void> {
|
|
13
41
|
step('Resolving deployed config...');
|
|
14
42
|
let config;
|
|
@@ -115,6 +143,130 @@ export async function dbResetCommand(flags: Record<string, string>): Promise<voi
|
|
|
115
143
|
}
|
|
116
144
|
}
|
|
117
145
|
|
|
146
|
+
export async function dbProvisionCommand(flags: Record<string, string>): Promise<void> {
|
|
147
|
+
if (!flags.stage) {
|
|
148
|
+
fail('--stage is required for db:provision (it writes the DATABASE_URL secret for that stage)');
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
step('Resolving deployed config...');
|
|
153
|
+
let config;
|
|
154
|
+
try {
|
|
155
|
+
config = await resolveConfig(flags.stage);
|
|
156
|
+
} catch (err: any) {
|
|
157
|
+
fail(err.message);
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
162
|
+
info('Creating the least-privilege role chain on your EXISTING database (no database is created).');
|
|
163
|
+
step('Provisioning roles...');
|
|
164
|
+
|
|
165
|
+
// Generate the login password and keep it in memory only — it is written straight into
|
|
166
|
+
// the secret store and is NEVER printed, logged, or returned to a human.
|
|
167
|
+
const { randomBytes } = await import('node:crypto');
|
|
168
|
+
const authPassword = randomBytes(24).toString('hex');
|
|
169
|
+
|
|
170
|
+
let result: any;
|
|
171
|
+
try {
|
|
172
|
+
result = await invokeAction(config.region, opsFunction(config), 'db:provision', { authPassword });
|
|
173
|
+
} catch (err: any) {
|
|
174
|
+
fail(`db:provision failed: ${err.message}`);
|
|
175
|
+
info('Ensure your IAM user/role has lambda:InvokeFunction permission.');
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
if (result?.error) {
|
|
179
|
+
fail(`db:provision failed: ${result.error}`);
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
success(`Role chain ready: ${result.loginRole} (LOGIN, NOINHERIT, no BYPASSRLS) → can SET ROLE to ${result.appRoles.join(', ')}`);
|
|
183
|
+
|
|
184
|
+
// Resolve host/port/db (not secret) to build the connection string in memory.
|
|
185
|
+
let conn: any;
|
|
186
|
+
try {
|
|
187
|
+
conn = await invokeAction(config.region, opsFunction(config), 'db:psql', {});
|
|
188
|
+
} catch {
|
|
189
|
+
/* handled below */
|
|
190
|
+
}
|
|
191
|
+
if (!conn?.host) {
|
|
192
|
+
fail('Could not resolve the database host to build DATABASE_URL.');
|
|
193
|
+
info('Provide a db:psql connectionInfo callback, or set the DATABASE_URL secret yourself.');
|
|
194
|
+
process.exit(1);
|
|
195
|
+
}
|
|
196
|
+
const url = `postgresql://${result.loginRole}:${authPassword}@${conn.host}:${conn.port ?? 5432}/${conn.database}`;
|
|
197
|
+
|
|
198
|
+
// Write the secret directly to the SST secret store — never display it.
|
|
199
|
+
step('Writing the DATABASE_URL secret (not displayed)...');
|
|
200
|
+
try {
|
|
201
|
+
const { loadSstSecrets, putSstSecrets } = await import('../utils/secrets.js');
|
|
202
|
+
const { parseAppName } = await import('../discover.js');
|
|
203
|
+
const ctx = { appName: await parseAppName(), stage: flags.stage, region: config.region };
|
|
204
|
+
const secrets = await loadSstSecrets(ctx);
|
|
205
|
+
secrets.DATABASE_URL = url;
|
|
206
|
+
await putSstSecrets(ctx, secrets);
|
|
207
|
+
} catch (err: any) {
|
|
208
|
+
fail(`Failed to write the DATABASE_URL secret: ${err.message}`);
|
|
209
|
+
info('Ensure your IAM role can write the SST secret store (s3:PutObject, kms:Encrypt).');
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
success(`DATABASE_URL secret set for stage "${flags.stage}" → role ${result.loginRole}. No credential was displayed.`);
|
|
214
|
+
console.log('');
|
|
215
|
+
info('Finish the wiring in sst.config.ts (one-time): declare `new sst.Secret("DatabaseUrl")`,');
|
|
216
|
+
info('link it to the Api function, and remove the raw Postgres component from the Api link');
|
|
217
|
+
info('(keep it on the ops/worker functions). Then redeploy.');
|
|
218
|
+
info(`Verify: everystack db:doctor --stage ${flags.stage}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function dbDoctorCommand(flags: Record<string, string>): Promise<void> {
|
|
222
|
+
step('Resolving deployed config...');
|
|
223
|
+
let config;
|
|
224
|
+
try {
|
|
225
|
+
config = await resolveConfig(flags.stage);
|
|
226
|
+
} catch (err: any) {
|
|
227
|
+
fail(err.message);
|
|
228
|
+
process.exit(1);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
232
|
+
step('Probing database connections (api + operator)...');
|
|
233
|
+
|
|
234
|
+
let report: any;
|
|
235
|
+
try {
|
|
236
|
+
report = await invokeAction(config.region, opsFunction(config), 'db:doctor', {});
|
|
237
|
+
} catch (err: any) {
|
|
238
|
+
fail(`db:doctor failed: ${err.message}`);
|
|
239
|
+
info('Ensure your IAM user/role has lambda:InvokeFunction permission.');
|
|
240
|
+
process.exit(1);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (report?.error) {
|
|
244
|
+
fail(`db:doctor failed: ${report.error}`);
|
|
245
|
+
process.exit(1);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
console.log('');
|
|
249
|
+
info(`api connection (DATABASE_URL): role=${report.api.role} superuser=${report.api.isSuperuser} bypassrls=${report.api.bypassRls}`);
|
|
250
|
+
info(`operator connection (ADMIN_URL): role=${report.admin.role} superuser=${report.admin.isSuperuser} bypassrls=${report.admin.bypassRls}`);
|
|
251
|
+
console.log('');
|
|
252
|
+
|
|
253
|
+
for (const c of report.checks as Array<{ name: string; status: string; detail: string; remediation?: string }>) {
|
|
254
|
+
if (c.status === 'pass') success(`${c.name} — ${c.detail}`);
|
|
255
|
+
else if (c.status === 'warn') warn(`${c.name} — ${c.detail}`);
|
|
256
|
+
else fail(`${c.name} — ${c.detail}`);
|
|
257
|
+
if (c.status === 'fail' && c.remediation) info(` fix: ${c.remediation}`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
console.log('');
|
|
261
|
+
if (report.ok) {
|
|
262
|
+
success('db:doctor — database is least-privilege and RLS-subject');
|
|
263
|
+
process.exit(0);
|
|
264
|
+
} else {
|
|
265
|
+
fail('db:doctor — the api connection is NOT correctly least-privilege (see failures above)');
|
|
266
|
+
process.exit(1);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
118
270
|
export async function dbPsqlCommand(flags: Record<string, string>): Promise<void> {
|
|
119
271
|
step('Resolving deployed config...');
|
|
120
272
|
let config;
|
|
@@ -180,9 +332,67 @@ export async function dbPsqlCommand(flags: Record<string, string>): Promise<void
|
|
|
180
332
|
}
|
|
181
333
|
}
|
|
182
334
|
|
|
183
|
-
// Interactive mode
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
335
|
+
// Interactive mode — break-glass admin psql.
|
|
336
|
+
//
|
|
337
|
+
// Authorization IS your AWS identity: resolving the admin URL requires reading
|
|
338
|
+
// the SST secret store (SSM passphrase + S3 blob + KMS decrypt). If your
|
|
339
|
+
// profile can't, you don't get a session — the permission is the policy, no
|
|
340
|
+
// confirmation prompt. CloudTrail records the decrypt, so the access is
|
|
341
|
+
// audited for free. The URL is resolved in-process and handed to psql via PG*
|
|
342
|
+
// env vars: it never touches your terminal, argv, clipboard, or psql history.
|
|
343
|
+
if (!flags.stage) {
|
|
344
|
+
fail('--stage is required for an interactive session. Example: everystack db:psql --stage production');
|
|
345
|
+
info('Or run a one-off query via Lambda: everystack db:psql -c "SELECT 1" --stage production');
|
|
346
|
+
process.exit(1);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
step('Resolving the admin connection from the secret store...');
|
|
350
|
+
let secrets: Record<string, string>;
|
|
351
|
+
try {
|
|
352
|
+
const { loadSstSecrets } = await import('../utils/secrets.js');
|
|
353
|
+
const { parseAppName } = await import('../discover.js');
|
|
354
|
+
const appName = await parseAppName();
|
|
355
|
+
secrets = await loadSstSecrets({ appName, stage: flags.stage, region: config.region });
|
|
356
|
+
} catch (err: any) {
|
|
357
|
+
fail('Could not read the secret store — your AWS profile lacks secrets access.');
|
|
358
|
+
info('db:psql opens an ADMIN session; it requires SSM + S3 + KMS access to the SST state.');
|
|
359
|
+
info('If you should have it, check your IAM permissions; otherwise you should not be in psql.');
|
|
360
|
+
process.exit(1);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const keyName = flags['secret-name'] || ADMIN_URL_KEYS.find((k) => secrets[k]);
|
|
364
|
+
const adminUrl = keyName ? secrets[keyName] : undefined;
|
|
365
|
+
if (!adminUrl) {
|
|
366
|
+
fail(`No admin database URL found for stage "${flags.stage}".`);
|
|
367
|
+
// Names only — never values.
|
|
368
|
+
info(`Looked for: ${ADMIN_URL_KEYS.join(', ')}. Available secret names: ${Object.keys(secrets).join(', ') || '(none)'}.`);
|
|
369
|
+
info('Provision one with `everystack db:provision`, or point at an existing key with --secret-name <KEY>.');
|
|
370
|
+
process.exit(1);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
let pgEnv: Record<string, string>;
|
|
374
|
+
try {
|
|
375
|
+
pgEnv = pgEnvFromUrl(adminUrl);
|
|
376
|
+
} catch {
|
|
377
|
+
fail(`The resolved ${keyName} is not a valid postgres connection URL.`);
|
|
378
|
+
process.exit(1);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Confirm the target (host/user/db are infra identifiers, not the credential);
|
|
382
|
+
// the password is in PGPASSWORD and is never displayed.
|
|
383
|
+
success(
|
|
384
|
+
`Admin psql → ${flags.stage} as ${pgEnv.PGUSER ?? '?'}@${pgEnv.PGHOST ?? '?'}/${pgEnv.PGDATABASE ?? ''} (credential not displayed)`,
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
const { spawn } = await import('node:child_process');
|
|
388
|
+
const child = spawn('psql', [], { stdio: 'inherit', env: { ...process.env, ...pgEnv } });
|
|
389
|
+
child.on('error', (err: any) => {
|
|
390
|
+
if (err.code === 'ENOENT') {
|
|
391
|
+
fail('psql not found on PATH. Install the PostgreSQL client to use interactive mode.');
|
|
392
|
+
} else {
|
|
393
|
+
fail(`Failed to launch psql: ${err.message}`);
|
|
394
|
+
}
|
|
395
|
+
process.exit(1);
|
|
396
|
+
});
|
|
397
|
+
child.on('close', (code) => process.exit(code ?? 0));
|
|
188
398
|
}
|
package/src/cli/commands/logs.ts
CHANGED
|
@@ -133,6 +133,62 @@ function resolveFunctionForOrigin(
|
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/** One FilterLogEvents page (the subset of the AWS shape this command reads). */
|
|
137
|
+
interface FilterLogEventsPage {
|
|
138
|
+
events?: Array<{ eventId?: string; timestamp?: number; message?: string }>;
|
|
139
|
+
nextToken?: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Carries the tail window across polls so a re-query can't reprint events. */
|
|
143
|
+
export interface TailState {
|
|
144
|
+
/** Highest event timestamp printed so far. Only moves forward. */
|
|
145
|
+
lastTimestamp: number;
|
|
146
|
+
/** eventId → timestamp for events at the boundary millisecond — the only ones
|
|
147
|
+
* an inclusive-startTime re-query can return again. Pruned each drain so it
|
|
148
|
+
* never grows past one millisecond's worth of events. */
|
|
149
|
+
seen: Map<string, number>;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Drain one poll's worth of pages, printing only events not seen before and
|
|
154
|
+
* advancing the window forward.
|
|
155
|
+
*
|
|
156
|
+
* The loop bug this fixes: FilterLogEvents `startTime` is INCLUSIVE, so a poll
|
|
157
|
+
* with `startTime = lastTimestamp` re-returns every event sharing that newest
|
|
158
|
+
* millisecond. The old code advanced `lastTimestamp` only on a strictly-greater
|
|
159
|
+
* comparison and never deduped, so the boundary batch reprinted on every 2s poll
|
|
160
|
+
* forever. Here we dedup by `eventId` and advance to the max timestamp, then
|
|
161
|
+
* prune `seen` to just the boundary millisecond (the only ids that can recur).
|
|
162
|
+
*
|
|
163
|
+
* Exported for unit testing without the AWS SDK or the setTimeout poll loop.
|
|
164
|
+
*/
|
|
165
|
+
export async function drainLogEvents(
|
|
166
|
+
send: (params: { startTime: number; nextToken?: string }) => Promise<FilterLogEventsPage>,
|
|
167
|
+
state: TailState,
|
|
168
|
+
emit: (line: string) => void,
|
|
169
|
+
): Promise<void> {
|
|
170
|
+
let nextToken: string | undefined;
|
|
171
|
+
do {
|
|
172
|
+
const page = await send({ startTime: state.lastTimestamp, nextToken });
|
|
173
|
+
for (const event of page.events ?? []) {
|
|
174
|
+
const id = event.eventId;
|
|
175
|
+
const ts = event.timestamp ?? state.lastTimestamp;
|
|
176
|
+
if (id && state.seen.has(id)) continue; // already printed this one
|
|
177
|
+
const timestamp = event.timestamp ? new Date(event.timestamp).toISOString() : '';
|
|
178
|
+
emit(`[${timestamp}] ${(event.message || '').trimEnd()}`);
|
|
179
|
+
if (id) state.seen.set(id, ts);
|
|
180
|
+
if (ts > state.lastTimestamp) state.lastTimestamp = ts;
|
|
181
|
+
}
|
|
182
|
+
nextToken = page.nextToken;
|
|
183
|
+
} while (nextToken);
|
|
184
|
+
|
|
185
|
+
// Only events at exactly lastTimestamp can be returned again (startTime is
|
|
186
|
+
// inclusive); drop everything older so `seen` stays bounded.
|
|
187
|
+
for (const [id, ts] of state.seen) {
|
|
188
|
+
if (ts < state.lastTimestamp) state.seen.delete(id);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
136
192
|
export async function logsTailCommand(flags: Record<string, string>): Promise<void> {
|
|
137
193
|
step('Resolving deployed config...');
|
|
138
194
|
let config;
|
|
@@ -174,42 +230,20 @@ export async function logsTailCommand(flags: Record<string, string>): Promise<vo
|
|
|
174
230
|
|
|
175
231
|
success(`Streaming logs from ${logGroupName}...\n`);
|
|
176
232
|
|
|
177
|
-
// Poll for new logs every 2 seconds
|
|
178
|
-
|
|
179
|
-
|
|
233
|
+
// Poll for new logs every 2 seconds. State carries the window + dedup set
|
|
234
|
+
// across polls — see drainLogEvents for why both are required.
|
|
235
|
+
const state: TailState = { lastTimestamp: startTime, seen: new Map() };
|
|
236
|
+
const send = (params: { startTime: number; nextToken?: string }) =>
|
|
237
|
+
client.send(new FilterLogEventsCommand({
|
|
238
|
+
logGroupName,
|
|
239
|
+
startTime: params.startTime,
|
|
240
|
+
filterPattern,
|
|
241
|
+
...(params.nextToken ? { nextToken: params.nextToken } : {}),
|
|
242
|
+
}));
|
|
180
243
|
|
|
181
244
|
const poll = async () => {
|
|
182
245
|
try {
|
|
183
|
-
|
|
184
|
-
logGroupName,
|
|
185
|
-
startTime: lastTimestamp,
|
|
186
|
-
filterPattern,
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
if (nextToken) {
|
|
190
|
-
params.nextToken = nextToken;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
const response = await client.send(new FilterLogEventsCommand(params));
|
|
194
|
-
|
|
195
|
-
if (response.events && response.events.length > 0) {
|
|
196
|
-
for (const event of response.events) {
|
|
197
|
-
if (event.timestamp && event.timestamp > lastTimestamp) {
|
|
198
|
-
lastTimestamp = event.timestamp;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
const timestamp = event.timestamp
|
|
202
|
-
? new Date(event.timestamp).toISOString()
|
|
203
|
-
: '';
|
|
204
|
-
const message = event.message || '';
|
|
205
|
-
|
|
206
|
-
console.log(`[${timestamp}] ${message.trimEnd()}`);
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
nextToken = response.nextToken;
|
|
211
|
-
|
|
212
|
-
// Continue polling
|
|
246
|
+
await drainLogEvents(send, state, (line) => console.log(line));
|
|
213
247
|
setTimeout(poll, 2000);
|
|
214
248
|
} catch (err: any) {
|
|
215
249
|
if (err.name === 'ResourceNotFoundException') {
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `everystack security:audit` — the gate behind the two-surface security model.
|
|
3
|
+
*
|
|
4
|
+
* Goes RED (non-zero exit) on any data-returning SECURITY DEFINER function or any
|
|
5
|
+
* public view that exposes a non-public column, so CI fails before the leak ships.
|
|
6
|
+
* Two instruments over one classifier:
|
|
7
|
+
*
|
|
8
|
+
* everystack security:audit static scan of migration SQL (pre-merge)
|
|
9
|
+
* everystack security:audit --stage <name> live catalog scan of a deployed stage
|
|
10
|
+
*
|
|
11
|
+
* See docs/security.md ("Auditing the surface") and docs/plans/nothing-skips-rls.md.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs/promises';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import {
|
|
17
|
+
audit,
|
|
18
|
+
parseFunctionsFromSql,
|
|
19
|
+
parseViewsAndGrantsFromSql,
|
|
20
|
+
type Waivers,
|
|
21
|
+
type AuditReport,
|
|
22
|
+
type FunctionDescriptor,
|
|
23
|
+
type ViewDescriptor,
|
|
24
|
+
} from '../security-audit.js';
|
|
25
|
+
import {
|
|
26
|
+
FUNCTIONS_SQL,
|
|
27
|
+
RELATIONS_SQL,
|
|
28
|
+
catalogFunctionToDescriptor,
|
|
29
|
+
catalogRelationToDescriptor,
|
|
30
|
+
} from '../security-catalog.js';
|
|
31
|
+
import { resolveConfig, opsFunction } from '../config.js';
|
|
32
|
+
import { invokeAction } from '../aws.js';
|
|
33
|
+
import { step, success, fail, info, warn } from '../output.js';
|
|
34
|
+
|
|
35
|
+
const MIGRATION_DIRS = ['drizzle', 'db/migrations', 'migrations', 'db/drizzle'];
|
|
36
|
+
const WAIVER_FILES = ['security-audit.json', '.security-audit.json'];
|
|
37
|
+
|
|
38
|
+
export async function securityAuditCommand(flags: Record<string, string>): Promise<void> {
|
|
39
|
+
const waivers = await loadWaivers(flags.config);
|
|
40
|
+
|
|
41
|
+
const useCatalog = flags.catalog === 'true' || typeof flags.stage === 'string';
|
|
42
|
+
|
|
43
|
+
let report: AuditReport;
|
|
44
|
+
try {
|
|
45
|
+
report = useCatalog
|
|
46
|
+
? await runCatalogAudit(flags, waivers)
|
|
47
|
+
: await runStaticAudit(flags, waivers);
|
|
48
|
+
} catch (err: any) {
|
|
49
|
+
fail(err.message);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
printReport(report, useCatalog ? 'catalog' : 'static');
|
|
54
|
+
process.exit(report.red > 0 ? 1 : 0);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Static instrument — parse migration SQL, no database.
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
async function runStaticAudit(flags: Record<string, string>, waivers: Waivers): Promise<AuditReport> {
|
|
62
|
+
const dir = await resolveMigrationDir(flags.dir);
|
|
63
|
+
step(`Scanning migration SQL in ${dir} ...`);
|
|
64
|
+
|
|
65
|
+
const entries = (await fs.readdir(dir)).filter((f) => f.endsWith('.sql')).sort();
|
|
66
|
+
if (entries.length === 0) {
|
|
67
|
+
throw new Error(`No .sql files found in ${dir}. Pass --dir <path> to point at your migrations.`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let sql = '';
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
sql += '\n' + (await fs.readFile(path.join(dir, entry), 'utf8'));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const functions = parseFunctionsFromSql(sql);
|
|
76
|
+
const views = parseViewsAndGrantsFromSql(sql);
|
|
77
|
+
info(`Found ${functions.length} function(s), ${views.length} view(s) across ${entries.length} migration file(s).`);
|
|
78
|
+
return audit(functions, views, waivers);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function resolveMigrationDir(flag?: string): Promise<string> {
|
|
82
|
+
if (flag) {
|
|
83
|
+
const abs = path.resolve(flag);
|
|
84
|
+
await fs.access(abs);
|
|
85
|
+
return abs;
|
|
86
|
+
}
|
|
87
|
+
for (const candidate of MIGRATION_DIRS) {
|
|
88
|
+
const abs = path.resolve(candidate);
|
|
89
|
+
try {
|
|
90
|
+
const stat = await fs.stat(abs);
|
|
91
|
+
if (stat.isDirectory()) return abs;
|
|
92
|
+
} catch {
|
|
93
|
+
// try next
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
throw new Error(`No migration directory found (looked for ${MIGRATION_DIRS.join(', ')}). Pass --dir <path>.`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Catalog instrument — introspect the live database via the ops Lambda.
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
async function runCatalogAudit(flags: Record<string, string>, waivers: Waivers): Promise<AuditReport> {
|
|
104
|
+
step('Resolving deployed config...');
|
|
105
|
+
const config = await resolveConfig(flags.stage);
|
|
106
|
+
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
107
|
+
|
|
108
|
+
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);
|
|
111
|
+
|
|
112
|
+
const functions: FunctionDescriptor[] = fnRows.map(catalogFunctionToDescriptor);
|
|
113
|
+
const views: ViewDescriptor[] = relRows.map(catalogRelationToDescriptor);
|
|
114
|
+
info(`Catalog: ${functions.length} function(s), ${views.length} relation(s).`);
|
|
115
|
+
return audit(functions, views, waivers);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function catalogQuery(region: string, fn: string, sql: string): Promise<any[]> {
|
|
119
|
+
const result: any = await invokeAction(region, fn, 'db:query', { sql });
|
|
120
|
+
if (result?.error) throw new Error(`Catalog query failed: ${result.error}`);
|
|
121
|
+
return result?.rows ?? [];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Waivers + reporting.
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
async function loadWaivers(flag?: string): Promise<Waivers> {
|
|
129
|
+
const candidates = flag ? [flag] : WAIVER_FILES;
|
|
130
|
+
for (const candidate of candidates) {
|
|
131
|
+
const abs = path.resolve(candidate);
|
|
132
|
+
try {
|
|
133
|
+
const raw = await fs.readFile(abs, 'utf8');
|
|
134
|
+
const parsed = JSON.parse(raw);
|
|
135
|
+
info(`Loaded waivers from ${candidate}.`);
|
|
136
|
+
return { secdef: parsed.secdef ?? {}, publicColumns: parsed.publicColumns ?? {} };
|
|
137
|
+
} catch (err: any) {
|
|
138
|
+
if (err.code !== 'ENOENT') throw new Error(`Could not read waiver config ${candidate}: ${err.message}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return {};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function printReport(report: AuditReport, instrument: 'static' | 'catalog'): void {
|
|
145
|
+
const reds = [
|
|
146
|
+
...report.functions.filter((f) => f.severity === 'red'),
|
|
147
|
+
...report.views.filter((v) => v.severity === 'red'),
|
|
148
|
+
];
|
|
149
|
+
const warns = [
|
|
150
|
+
...report.functions.filter((f) => f.severity === 'warn'),
|
|
151
|
+
...report.views.filter((v) => v.severity === 'warn'),
|
|
152
|
+
];
|
|
153
|
+
const waived = report.functions.filter((f) => f.waived);
|
|
154
|
+
|
|
155
|
+
if (reds.length > 0) {
|
|
156
|
+
console.log('');
|
|
157
|
+
info('RED — must fix or waive with justification:');
|
|
158
|
+
for (const f of reds) {
|
|
159
|
+
fail(`${f.key}`);
|
|
160
|
+
for (const r of f.reasons) info(` ${r}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (warns.length > 0) {
|
|
165
|
+
console.log('');
|
|
166
|
+
info('WARN — review (does not fail the build):');
|
|
167
|
+
for (const f of warns) {
|
|
168
|
+
warn(`${f.key}`);
|
|
169
|
+
for (const r of f.reasons) info(` ${r}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (waived.length > 0) {
|
|
174
|
+
console.log('');
|
|
175
|
+
info(`Waived (${waived.length}): ${waived.map((f) => f.key).join(', ')}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
console.log('');
|
|
179
|
+
const summary = `security:audit (${instrument}) — ${report.red} red, ${report.warn} warn`;
|
|
180
|
+
if (report.red > 0) {
|
|
181
|
+
fail(summary);
|
|
182
|
+
} else {
|
|
183
|
+
success(summary);
|
|
184
|
+
}
|
|
185
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { updateCommand } from './commands/update.js';
|
|
|
6
6
|
import { certsCommand } from './commands/certs.js';
|
|
7
7
|
import { channelsCommand } from './commands/channels.js';
|
|
8
8
|
import { branchesCommand } from './commands/branches.js';
|
|
9
|
-
import { dbMigrateCommand, dbSeedCommand, dbResetCommand, dbPsqlCommand } from './commands/db.js';
|
|
9
|
+
import { dbMigrateCommand, dbSeedCommand, dbResetCommand, dbPsqlCommand, dbDoctorCommand, dbProvisionCommand } from './commands/db.js';
|
|
10
10
|
import { consoleCommand } from './commands/console.js';
|
|
11
11
|
import { cachePurgeCommand } from './commands/cache.js';
|
|
12
12
|
import { statusCommand } from './commands/status.js';
|
|
@@ -14,6 +14,7 @@ import { diagCommand } from './commands/diag.js';
|
|
|
14
14
|
import { analyzeSSRCommand } from './commands/analyze.js';
|
|
15
15
|
import { logsErrorsCommand, logsTailCommand, logsQueryCommand } from './commands/logs.js';
|
|
16
16
|
import { secretsCommand } from './commands/secrets.js';
|
|
17
|
+
import { securityAuditCommand } from './commands/security.js';
|
|
17
18
|
import { fail } from './output.js';
|
|
18
19
|
|
|
19
20
|
const args = process.argv.slice(2);
|
|
@@ -114,6 +115,12 @@ async function main() {
|
|
|
114
115
|
case 'db:psql':
|
|
115
116
|
await dbPsqlCommand(flags);
|
|
116
117
|
break;
|
|
118
|
+
case 'db:doctor':
|
|
119
|
+
await dbDoctorCommand(flags);
|
|
120
|
+
break;
|
|
121
|
+
case 'db:provision':
|
|
122
|
+
await dbProvisionCommand(flags);
|
|
123
|
+
break;
|
|
117
124
|
case 'console':
|
|
118
125
|
await consoleCommand(flags);
|
|
119
126
|
break;
|
|
@@ -143,6 +150,9 @@ async function main() {
|
|
|
143
150
|
case 'logs:query':
|
|
144
151
|
await logsQueryCommand(flags);
|
|
145
152
|
break;
|
|
153
|
+
case 'security:audit':
|
|
154
|
+
await securityAuditCommand(flags);
|
|
155
|
+
break;
|
|
146
156
|
case 'secrets': {
|
|
147
157
|
// secrets <subcommand> [positional...] [--flags]
|
|
148
158
|
// Extract positional args: everything after subcommand that isn't a flag or flag value
|
|
@@ -176,9 +186,14 @@ Usage:
|
|
|
176
186
|
everystack db:migrate [--stage <name>] Run database migrations on deployed Lambda
|
|
177
187
|
everystack db:seed [--stage <name>] Seed database on deployed Lambda (dev only)
|
|
178
188
|
everystack db:reset [--stage <name>] Drop all schemas + re-run migrations (dev only)
|
|
179
|
-
everystack db:psql
|
|
189
|
+
everystack db:psql --stage <name> Interactive ADMIN psql (IAM-gated; resolves the admin URL in-process)
|
|
190
|
+
everystack db:psql [--stage <name>] -c <command> Run one query via Lambda (works for private RDS)
|
|
191
|
+
everystack db:doctor [--stage <name>] Check the DB is least-privilege + RLS-subject (api vs operator connection)
|
|
192
|
+
everystack db:provision [--stage <name>] Create the least-privilege role chain on an EXISTING database (idempotent; creates no DB)
|
|
180
193
|
everystack console --stage <name> [--sandbox] Interactive REPL on deployed Lambda
|
|
181
194
|
everystack status [--stage <name>] [--hours <n>] Platform health: CDN, Lambda, rollup summary
|
|
195
|
+
everystack security:audit [--dir <path>] [--config <file>] Static scan of migration SQL (pre-merge gate)
|
|
196
|
+
everystack security:audit --stage <name> [--config <file>] Live catalog scan of a deployed stage (post-deploy)
|
|
182
197
|
everystack certs:generate [--output ./certs]
|
|
183
198
|
everystack certs:configure [--input ./certs] [--keyid main]
|
|
184
199
|
everystack cache:purge [--stage <name>] Bust all cached content (global epoch)
|
package/src/cli/observability.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* {prefix}/events/YYYY/MM/DD/HH/{ts}-{id}.ndjson (one JSON LogEvent per line)
|
|
11
11
|
* Hour partitions are UTC. Canonical writer + query semantics:
|
|
12
12
|
* packages/logging/src/service/s3-log-storage.ts
|
|
13
|
+
* A contract test fails loudly if the writer drifts from what this reader assumes:
|
|
14
|
+
* packages/logging/__tests__/service/cli-reader-format.contract.test.ts
|
|
13
15
|
*
|
|
14
16
|
* We read the format rather than import the class because @everystack/logging
|
|
15
17
|
* pulls React Native (admin/ui) through its peer graph — wrong for a standalone CLI.
|