@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.
- package/LICENSE +681 -0
- package/README.md +32 -1
- package/package.json +11 -1
- package/src/cli/authz-contract-io.ts +92 -0
- package/src/cli/authz-contract.ts +589 -0
- package/src/cli/authz-redteam.ts +251 -0
- package/src/cli/authz-render.ts +248 -0
- package/src/cli/commands/db-authz.ts +206 -0
- 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 +34 -2
- package/src/cli/security-audit.ts +405 -0
- package/src/cli/security-catalog.ts +170 -0
- package/src/storage/index.ts +16 -3
- package/src/storage/s3.ts +66 -1
|
@@ -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
|
+
}
|
package/src/storage/index.ts
CHANGED
|
@@ -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
|
-
| {
|
|
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
|
-
|
|
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;
|