@everystack/cli 0.2.37 → 0.2.40

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,170 @@
1
+ /**
2
+ * Catalog instrument for security:audit — the post-deploy half.
3
+ *
4
+ * Static parsing gates the PR from the migration SQL; this introspects the *live*
5
+ * database (pg_proc / pg_class / pg_attribute, with role inheritance resolved by
6
+ * `has_table_privilege`) to catch deployed-state drift and anything created
7
+ * out-of-band that the migration files never described.
8
+ *
9
+ * The SQL runs through the ops Lambda (`db:query`); the mappers here are pure and
10
+ * unit-tested without a database.
11
+ */
12
+
13
+ import type {
14
+ FunctionDescriptor,
15
+ ViewDescriptor,
16
+ ReturnClass,
17
+ } from './security-audit.js';
18
+
19
+ /**
20
+ * Enumerate every function with its security context. Excludes system schemas and
21
+ * extension-owned functions (pgcrypto, PostGIS) via pg_depend, so vendor functions
22
+ * never need a manual waiver.
23
+ */
24
+ export const FUNCTIONS_SQL = `
25
+ SELECT
26
+ n.nspname AS schema,
27
+ p.proname AS name,
28
+ pg_get_function_result(p.oid) AS return_type,
29
+ p.prosecdef AS security_definer,
30
+ p.proretset AS returns_set,
31
+ t.typtype AS return_typtype,
32
+ EXISTS (
33
+ SELECT 1 FROM unnest(coalesce(p.proconfig, '{}'::text[])) cfg
34
+ WHERE lower(cfg) LIKE 'search_path=%'
35
+ ) AS has_search_path,
36
+ pg_get_userbyid(p.proowner) AS owner,
37
+ -- Does the owner run above RLS? A SECURITY DEFINER function owned by such a role is
38
+ -- the maximum blast radius — search_path pinning is necessary but not sufficient.
39
+ -- RDS has no true superuser (rolsuper=false), so also check rds_superuser membership.
40
+ (
41
+ SELECT r.rolsuper OR r.rolbypassrls OR EXISTS (
42
+ SELECT 1 FROM pg_auth_members m JOIN pg_roles g ON g.oid = m.roleid
43
+ WHERE m.member = p.proowner AND g.rolname = 'rds_superuser'
44
+ )
45
+ FROM pg_roles r WHERE r.oid = p.proowner
46
+ ) AS owner_bypasses_rls
47
+ FROM pg_proc p
48
+ JOIN pg_namespace n ON n.oid = p.pronamespace
49
+ JOIN pg_type t ON t.oid = p.prorettype
50
+ WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
51
+ AND n.nspname NOT LIKE 'pg_%'
52
+ AND NOT EXISTS (
53
+ SELECT 1 FROM pg_depend d
54
+ WHERE d.objid = p.oid AND d.deptype = 'e'
55
+ )
56
+ ORDER BY n.nspname, p.proname;
57
+ `.trim();
58
+
59
+ /**
60
+ * Enumerate relations (tables, views, matviews) with their columns and whether
61
+ * SELECT is reachable from a broad role. `has_table_privilege` resolves role
62
+ * inheritance and PUBLIC grants, so a view granted to an intermediate role that
63
+ * `authenticated` inherits is correctly flagged broadly-exposed.
64
+ */
65
+ export const RELATIONS_SQL = `
66
+ WITH targets AS (
67
+ SELECT rolname FROM pg_roles WHERE rolname IN ('anon', 'authenticated')
68
+ )
69
+ SELECT
70
+ n.nspname AS schema,
71
+ c.relname AS name,
72
+ c.relkind AS relkind,
73
+ (
74
+ SELECT array_agg(a.attname ORDER BY a.attnum)
75
+ FROM pg_attribute a
76
+ WHERE a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
77
+ ) AS columns,
78
+ EXISTS (
79
+ SELECT 1 FROM targets tg
80
+ WHERE has_table_privilege(tg.rolname, c.oid, 'SELECT')
81
+ ) AS broadly_exposed
82
+ FROM pg_class c
83
+ JOIN pg_namespace n ON n.oid = c.relnamespace
84
+ WHERE c.relkind IN ('r', 'v', 'm')
85
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
86
+ AND n.nspname NOT LIKE 'pg_%'
87
+ AND NOT EXISTS (
88
+ SELECT 1 FROM pg_depend d
89
+ WHERE d.objid = c.oid AND d.deptype = 'e'
90
+ )
91
+ ORDER BY n.nspname, c.relname;
92
+ `.trim();
93
+
94
+ /** Coerce a Postgres boolean (true | 't' | 'true' | 1) into a JS boolean. */
95
+ function truthy(v: unknown): boolean {
96
+ return v === true || v === 't' || v === 'true' || v === 1 || v === '1';
97
+ }
98
+
99
+ /** Parse a text[] that may arrive as a JS array or a `{a,b,c}` literal string. */
100
+ export function parsePgArray(v: unknown): string[] | null {
101
+ if (v == null) return null;
102
+ if (Array.isArray(v)) return v.map(String);
103
+ if (typeof v === 'string') {
104
+ const trimmed = v.trim();
105
+ if (trimmed === '{}' || trimmed === '') return [];
106
+ if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
107
+ return trimmed
108
+ .slice(1, -1)
109
+ .split(',')
110
+ .map((s) => s.replace(/^"|"$/g, '').trim())
111
+ .filter((s) => s.length > 0);
112
+ }
113
+ }
114
+ return null;
115
+ }
116
+
117
+ interface FunctionRow {
118
+ schema: string;
119
+ name: string;
120
+ return_type: string;
121
+ security_definer: unknown;
122
+ returns_set: unknown;
123
+ return_typtype: unknown;
124
+ has_search_path: unknown;
125
+ owner?: unknown;
126
+ owner_bypasses_rls?: unknown;
127
+ }
128
+
129
+ /** Map a pg_proc row to a descriptor, computing a precise return class. */
130
+ export function catalogFunctionToDescriptor(row: FunctionRow): FunctionDescriptor {
131
+ const text = String(row.return_type ?? '').trim().toLowerCase();
132
+ let returnClassHint: ReturnClass;
133
+ if (text === 'void' || text === 'trigger' || text === '') {
134
+ returnClassHint = 'void';
135
+ } else if (truthy(row.returns_set) || row.return_typtype === 'c' || text === 'record' || text.startsWith('setof') || text.startsWith('table(')) {
136
+ returnClassHint = 'rows';
137
+ } else {
138
+ returnClassHint = 'scalar';
139
+ }
140
+
141
+ return {
142
+ schema: row.schema,
143
+ name: row.name,
144
+ returnType: row.return_type,
145
+ returnClassHint,
146
+ securityDefiner: truthy(row.security_definer),
147
+ hasSearchPath: truthy(row.has_search_path),
148
+ owner: row.owner != null ? String(row.owner) : undefined,
149
+ ownerBypassesRls: row.owner_bypasses_rls != null ? truthy(row.owner_bypasses_rls) : undefined,
150
+ };
151
+ }
152
+
153
+ interface RelationRow {
154
+ schema: string;
155
+ name: string;
156
+ relkind: string;
157
+ columns: unknown;
158
+ broadly_exposed: unknown;
159
+ }
160
+
161
+ /** Map a pg_class row to a view/table descriptor. */
162
+ export function catalogRelationToDescriptor(row: RelationRow): ViewDescriptor {
163
+ return {
164
+ schema: row.schema,
165
+ name: row.name,
166
+ kind: row.relkind === 'r' ? 'table' : 'view',
167
+ columns: parsePgArray(row.columns),
168
+ broadlyExposed: truthy(row.broadly_exposed),
169
+ };
170
+ }
@@ -1,3 +1,5 @@
1
+ import type { S3StorageOptions } from './s3';
2
+
1
3
  export interface StorageAdapter {
2
4
  put(key: string, data: Buffer | Uint8Array, contentType?: string): Promise<void>;
3
5
  get(key: string): Promise<{ data: Buffer; contentType: string } | null>;
@@ -32,7 +34,18 @@ export interface StorageAdapter {
32
34
 
33
35
  export type StorageOptions =
34
36
  | { type: 'filesystem'; directory: string }
35
- | { type: 's3'; bucket: string; region?: string; endpoint?: string };
37
+ | {
38
+ type: 's3';
39
+ bucket: string;
40
+ region?: string;
41
+ endpoint?: string;
42
+ /**
43
+ * Bound the S3 client's failure modes (connect/request timeouts, retries,
44
+ * list caps). Every field defaults to a safe bound; env vars override too.
45
+ * See {@link S3StorageOptions}.
46
+ */
47
+ options?: S3StorageOptions;
48
+ };
36
49
 
37
50
  export async function createStorage(options: StorageOptions): Promise<StorageAdapter> {
38
51
  if (options.type === 'filesystem') {
@@ -40,8 +53,8 @@ export async function createStorage(options: StorageOptions): Promise<StorageAda
40
53
  return new FilesystemStorageAdapter(options.directory);
41
54
  }
42
55
  const { S3StorageAdapter } = await import('./s3');
43
- return new S3StorageAdapter(options.bucket, options.region, options.endpoint);
56
+ return new S3StorageAdapter(options.bucket, options.region, options.endpoint, options.options);
44
57
  }
45
58
 
46
59
  export { FilesystemStorageAdapter } from './filesystem';
47
- export { S3StorageAdapter } from './s3';
60
+ export { S3StorageAdapter, type S3StorageOptions } from './s3';
package/src/storage/s3.ts CHANGED
@@ -1,15 +1,58 @@
1
1
  import type { StorageAdapter } from './index';
2
2
 
3
+ /**
4
+ * Tuning for the S3 client's failure modes. Every value is bounded by default so
5
+ * a single call can never hang: under an S3 503 SlowDown storm an unbounded
6
+ * client blocks until the Lambda's own timeout (60s), and because each hung
7
+ * invocation pins a concurrency slot, the observability rollup can drain the
8
+ * whole account pool and starve the app it observes. Bounded here, a stuck call
9
+ * fails fast instead. Overridable per-field, then by env (so ops can tighten a
10
+ * live Lambda without a code redeploy), then these defaults.
11
+ */
12
+ export interface S3StorageOptions {
13
+ /** TCP connect timeout, ms. Env: EVERYSTACK_S3_CONNECT_TIMEOUT_MS. */
14
+ connectionTimeoutMs?: number;
15
+ /** Socket-idle timeout for a single request, ms. Env: EVERYSTACK_S3_REQUEST_TIMEOUT_MS. */
16
+ requestTimeoutMs?: number;
17
+ /** Total attempts per call (1 + retries). Env: EVERYSTACK_S3_MAX_ATTEMPTS. */
18
+ maxAttempts?: number;
19
+ /** Keys per ListObjectsV2 page (S3 caps this at 1000). */
20
+ listMaxKeys?: number;
21
+ /** Hard backstop on pages a single list() will walk before truncating (warns). */
22
+ listMaxPages?: number;
23
+ }
24
+
25
+ function envInt(name: string): number | undefined {
26
+ const raw = process.env[name];
27
+ if (raw === undefined) return undefined;
28
+ const n = Number(raw);
29
+ return Number.isFinite(n) && n > 0 ? n : undefined;
30
+ }
31
+
3
32
  export class S3StorageAdapter implements StorageAdapter {
4
33
  private bucket: string;
5
34
  private region: string;
6
35
  private endpoint?: string;
7
36
  private client: any;
8
37
 
9
- constructor(bucket: string, region?: string, endpoint?: string) {
38
+ private readonly connectionTimeoutMs: number;
39
+ private readonly requestTimeoutMs: number;
40
+ private readonly maxAttempts: number;
41
+ private readonly listMaxKeys: number;
42
+ private readonly listMaxPages: number;
43
+
44
+ constructor(bucket: string, region?: string, endpoint?: string, options: S3StorageOptions = {}) {
10
45
  this.bucket = bucket;
11
46
  this.region = region || 'us-east-1';
12
47
  this.endpoint = endpoint;
48
+
49
+ this.connectionTimeoutMs =
50
+ options.connectionTimeoutMs ?? envInt('EVERYSTACK_S3_CONNECT_TIMEOUT_MS') ?? 3000;
51
+ this.requestTimeoutMs =
52
+ options.requestTimeoutMs ?? envInt('EVERYSTACK_S3_REQUEST_TIMEOUT_MS') ?? 10000;
53
+ this.maxAttempts = options.maxAttempts ?? envInt('EVERYSTACK_S3_MAX_ATTEMPTS') ?? 3;
54
+ this.listMaxKeys = options.listMaxKeys ?? 1000;
55
+ this.listMaxPages = options.listMaxPages ?? 1000;
13
56
  }
14
57
 
15
58
  private async getClient(): Promise<any> {
@@ -17,6 +60,15 @@ export class S3StorageAdapter implements StorageAdapter {
17
60
  const { S3Client } = await import('@aws-sdk/client-s3');
18
61
  this.client = new S3Client({
19
62
  region: this.region,
63
+ // Bounded retries — the SDK default (3) is fine, but pin it so a config
64
+ // drift can't reintroduce unbounded blocking under throttling.
65
+ maxAttempts: this.maxAttempts,
66
+ // Connect + per-request timeouts. Without these a call can block forever
67
+ // on a stalled socket (the 60s-hang class the rollup outage rode in on).
68
+ requestHandler: {
69
+ connectionTimeout: this.connectionTimeoutMs,
70
+ requestTimeout: this.requestTimeoutMs,
71
+ },
20
72
  ...(this.endpoint ? { endpoint: this.endpoint, forcePathStyle: true } : {}),
21
73
  });
22
74
  }
@@ -136,11 +188,13 @@ export class S3StorageAdapter implements StorageAdapter {
136
188
  const client = await this.getClient();
137
189
  const keys: string[] = [];
138
190
  let continuationToken: string | undefined;
191
+ let pages = 0;
139
192
 
140
193
  do {
141
194
  const response = await client.send(new ListObjectsV2Command({
142
195
  Bucket: this.bucket,
143
196
  Prefix: prefix,
197
+ MaxKeys: this.listMaxKeys,
144
198
  ContinuationToken: continuationToken,
145
199
  }));
146
200
  if (response.Contents) {
@@ -149,6 +203,17 @@ export class S3StorageAdapter implements StorageAdapter {
149
203
  }
150
204
  }
151
205
  continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined;
206
+ pages += 1;
207
+ // Backstop: a prefix that walks past the page cap is almost always a sign
208
+ // the rollup's per-hour debounce isn't engaged (one object → one invoke).
209
+ // Truncate loudly rather than let a single list() run unbounded.
210
+ if (continuationToken && pages >= this.listMaxPages) {
211
+ console.warn(
212
+ `[s3] list('${prefix}') hit the ${this.listMaxPages}-page cap at ${keys.length} keys; truncating. ` +
213
+ `A prefix this large usually means the observability rollup is being invoked per-object instead of per-hour.`,
214
+ );
215
+ break;
216
+ }
152
217
  } while (continuationToken);
153
218
 
154
219
  return keys;