@vellumai/credential-executor 0.10.7 → 0.10.8-dev.202607102228.5945895
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/Dockerfile +1 -1
- package/node_modules/@vellumai/service-contracts/package.json +1 -2
- package/node_modules/@vellumai/service-contracts/src/__tests__/attachment-naming.test.ts +104 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/contracts.test.ts +0 -2
- package/node_modules/@vellumai/service-contracts/src/attachment-naming.ts +118 -0
- package/node_modules/@vellumai/service-contracts/src/credential-rpc.ts +3 -5
- package/node_modules/@vellumai/service-contracts/src/index.ts +2 -4
- package/node_modules/@vellumai/service-contracts/src/rpc.ts +4 -447
- package/package.json +2 -3
- package/src/__tests__/bulk-set-credentials.test.ts +1 -1
- package/src/__tests__/local-standalone.test.ts +5 -36
- package/src/__tests__/managed-integration.test.ts +112 -91
- package/src/__tests__/managed-reconnect.test.ts +2 -2
- package/src/__tests__/transport.test.ts +23 -27
- package/src/cli.ts +1 -1
- package/src/index.ts +8 -88
- package/src/main.ts +228 -340
- package/src/paths.ts +4 -20
- package/src/server.ts +52 -469
- package/node_modules/@vellumai/service-contracts/src/__tests__/grants.test.ts +0 -686
- package/node_modules/@vellumai/service-contracts/src/grants.ts +0 -184
- package/node_modules/@vellumai/service-contracts/src/rendering.ts +0 -135
- package/src/__tests__/command-executor.test.ts +0 -1879
- package/src/__tests__/command-validator.test.ts +0 -1405
- package/src/__tests__/command-workspace.test.ts +0 -1050
- package/src/__tests__/grant-store.test.ts +0 -689
- package/src/__tests__/http-executor.test.ts +0 -1336
- package/src/__tests__/http-policy.test.ts +0 -1069
- package/src/__tests__/local-materializers.test.ts +0 -860
- package/src/__tests__/local-token-refresh.test.ts +0 -361
- package/src/__tests__/manage-secure-command-tool.test.ts +0 -134
- package/src/__tests__/managed-lazy-getters.test.ts +0 -359
- package/src/__tests__/managed-materializers.test.ts +0 -1028
- package/src/__tests__/managed-rejection.test.ts +0 -43
- package/src/__tests__/toolstore.test.ts +0 -773
- package/src/audit/store.ts +0 -188
- package/src/commands/auth-adapters.ts +0 -169
- package/src/commands/egress-hooks.ts +0 -203
- package/src/commands/executor.ts +0 -1155
- package/src/commands/output-scan.ts +0 -157
- package/src/commands/profiles.ts +0 -286
- package/src/commands/validator.ts +0 -702
- package/src/commands/workspace.ts +0 -550
- package/src/grants/index.ts +0 -17
- package/src/grants/persistent-store.ts +0 -309
- package/src/grants/rpc-handlers.ts +0 -293
- package/src/grants/temporary-store.ts +0 -289
- package/src/http/audit.ts +0 -84
- package/src/http/executor.ts +0 -684
- package/src/http/path-template.ts +0 -245
- package/src/http/policy.ts +0 -238
- package/src/http/response-filter.ts +0 -233
- package/src/managed-errors.ts +0 -9
- package/src/managed-lazy-getters.ts +0 -106
- package/src/managed-main.ts +0 -822
- package/src/materializers/local-oauth-lookup.ts +0 -98
- package/src/materializers/local-token-refresh.ts +0 -287
- package/src/materializers/local.ts +0 -316
- package/src/materializers/managed-platform.ts +0 -295
- package/src/subjects/local.ts +0 -177
- package/src/subjects/managed.ts +0 -311
- package/src/subjects/policy.ts +0 -79
- package/src/toolstore/integrity.ts +0 -94
- package/src/toolstore/manifest.ts +0 -154
- package/src/toolstore/publish.ts +0 -571
package/src/audit/store.ts
DELETED
|
@@ -1,188 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* CES audit record persistence.
|
|
3
|
-
*
|
|
4
|
-
* Persists token-free audit record summaries to `audit.jsonl` inside
|
|
5
|
-
* the CES-private data root. Each line is a self-contained JSON object
|
|
6
|
-
* conforming to the `AuditRecordSummary` schema from `@vellumai/service-contracts`.
|
|
7
|
-
*
|
|
8
|
-
* Design principles:
|
|
9
|
-
* - **Append-only**: Records are appended one per line. The file is never
|
|
10
|
-
* rewritten or truncated during normal operation.
|
|
11
|
-
* - **Token-free**: Audit records must never contain raw secrets, auth
|
|
12
|
-
* tokens, raw headers, or raw response bodies. Only sanitized summaries
|
|
13
|
-
* (method, URL template, status code, credential handle, grant ID) are
|
|
14
|
-
* persisted.
|
|
15
|
-
* - **Fail-open for reads**: If the file is corrupt or missing, reads
|
|
16
|
-
* return an empty array rather than throwing. Writes still throw on I/O
|
|
17
|
-
* failure so callers know when persistence is broken.
|
|
18
|
-
* - **Bounded reads**: The `list` method supports limit and cursor-based
|
|
19
|
-
* pagination to avoid reading the entire log into memory.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import {
|
|
23
|
-
appendFileSync,
|
|
24
|
-
existsSync,
|
|
25
|
-
mkdirSync,
|
|
26
|
-
readFileSync,
|
|
27
|
-
} from "node:fs";
|
|
28
|
-
import { dirname, join } from "node:path";
|
|
29
|
-
|
|
30
|
-
import type { AuditRecordSummary } from "@vellumai/service-contracts/credential-rpc";
|
|
31
|
-
|
|
32
|
-
import { getCesAuditDir } from "../paths.js";
|
|
33
|
-
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
// Constants
|
|
36
|
-
// ---------------------------------------------------------------------------
|
|
37
|
-
|
|
38
|
-
const AUDIT_FILENAME = "audit.jsonl";
|
|
39
|
-
|
|
40
|
-
// ---------------------------------------------------------------------------
|
|
41
|
-
// Store implementation
|
|
42
|
-
// ---------------------------------------------------------------------------
|
|
43
|
-
|
|
44
|
-
export class AuditStore {
|
|
45
|
-
private readonly filePath: string;
|
|
46
|
-
|
|
47
|
-
constructor(auditDir?: string) {
|
|
48
|
-
const dir = auditDir ?? getCesAuditDir();
|
|
49
|
-
this.filePath = join(dir, AUDIT_FILENAME);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// -----------------------------------------------------------------------
|
|
53
|
-
// Public API
|
|
54
|
-
// -----------------------------------------------------------------------
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Ensure the parent directory exists. Safe to call multiple times.
|
|
58
|
-
*/
|
|
59
|
-
init(): void {
|
|
60
|
-
const dir = dirname(this.filePath);
|
|
61
|
-
if (!existsSync(dir)) {
|
|
62
|
-
mkdirSync(dir, { recursive: true });
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Append a token-free audit record summary to the log.
|
|
68
|
-
*
|
|
69
|
-
* Throws on I/O failure (callers should handle gracefully — audit
|
|
70
|
-
* persistence failure must not block the execution pipeline).
|
|
71
|
-
*/
|
|
72
|
-
append(record: AuditRecordSummary): void {
|
|
73
|
-
const dir = dirname(this.filePath);
|
|
74
|
-
if (!existsSync(dir)) {
|
|
75
|
-
mkdirSync(dir, { recursive: true });
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const line = JSON.stringify(record) + "\n";
|
|
79
|
-
appendFileSync(this.filePath, line, { mode: 0o600 });
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* List audit records with optional filtering and pagination.
|
|
84
|
-
*
|
|
85
|
-
* Records are returned in reverse-chronological order (newest first).
|
|
86
|
-
*
|
|
87
|
-
* @param options.sessionId - Filter by session ID.
|
|
88
|
-
* @param options.credentialHandle - Filter by credential handle.
|
|
89
|
-
* @param options.grantId - Filter by grant ID.
|
|
90
|
-
* @param options.limit - Maximum number of records to return (default: 50).
|
|
91
|
-
* @param options.cursor - Opaque cursor from a previous response to
|
|
92
|
-
* continue pagination. The cursor is the 0-based line offset encoded
|
|
93
|
-
* as a string.
|
|
94
|
-
*
|
|
95
|
-
* @returns An object with `records` and `nextCursor`. `nextCursor` is
|
|
96
|
-
* null when there are no more results.
|
|
97
|
-
*/
|
|
98
|
-
list(options?: {
|
|
99
|
-
sessionId?: string;
|
|
100
|
-
credentialHandle?: string;
|
|
101
|
-
grantId?: string;
|
|
102
|
-
limit?: number;
|
|
103
|
-
cursor?: string;
|
|
104
|
-
}): { records: AuditRecordSummary[]; nextCursor: string | null } {
|
|
105
|
-
const limit = options?.limit ?? 50;
|
|
106
|
-
|
|
107
|
-
const allRecords = this.readAll();
|
|
108
|
-
|
|
109
|
-
// Reverse for newest-first ordering
|
|
110
|
-
allRecords.reverse();
|
|
111
|
-
|
|
112
|
-
// Apply filters
|
|
113
|
-
let filtered = allRecords;
|
|
114
|
-
if (options?.sessionId) {
|
|
115
|
-
filtered = filtered.filter((r) => r.sessionId === options.sessionId);
|
|
116
|
-
}
|
|
117
|
-
if (options?.credentialHandle) {
|
|
118
|
-
filtered = filtered.filter(
|
|
119
|
-
(r) => r.credentialHandle === options.credentialHandle,
|
|
120
|
-
);
|
|
121
|
-
}
|
|
122
|
-
if (options?.grantId) {
|
|
123
|
-
filtered = filtered.filter((r) => r.grantId === options.grantId);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Apply cursor-based pagination
|
|
127
|
-
const offset = options?.cursor ? parseInt(options.cursor, 10) : 0;
|
|
128
|
-
const startIdx = isNaN(offset) ? 0 : offset;
|
|
129
|
-
|
|
130
|
-
const page = filtered.slice(startIdx, startIdx + limit);
|
|
131
|
-
const hasMore = startIdx + limit < filtered.length;
|
|
132
|
-
const nextCursor = hasMore ? String(startIdx + limit) : null;
|
|
133
|
-
|
|
134
|
-
return { records: page, nextCursor };
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Return the total number of records in the log.
|
|
139
|
-
*
|
|
140
|
-
* Returns 0 if the file does not exist or is unreadable.
|
|
141
|
-
*/
|
|
142
|
-
count(): number {
|
|
143
|
-
return this.readAll().length;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// -----------------------------------------------------------------------
|
|
147
|
-
// Internals
|
|
148
|
-
// -----------------------------------------------------------------------
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Read all records from the JSONL file, skipping malformed lines.
|
|
152
|
-
*
|
|
153
|
-
* Returns an empty array if the file is missing or unreadable.
|
|
154
|
-
*/
|
|
155
|
-
private readAll(): AuditRecordSummary[] {
|
|
156
|
-
if (!existsSync(this.filePath)) return [];
|
|
157
|
-
|
|
158
|
-
let raw: string;
|
|
159
|
-
try {
|
|
160
|
-
raw = readFileSync(this.filePath, "utf-8");
|
|
161
|
-
} catch {
|
|
162
|
-
return [];
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const records: AuditRecordSummary[] = [];
|
|
166
|
-
const lines = raw.split("\n");
|
|
167
|
-
|
|
168
|
-
for (const line of lines) {
|
|
169
|
-
const trimmed = line.trim();
|
|
170
|
-
if (trimmed.length === 0) continue;
|
|
171
|
-
|
|
172
|
-
try {
|
|
173
|
-
const parsed = JSON.parse(trimmed) as AuditRecordSummary;
|
|
174
|
-
// Minimal validation — must have auditId and timestamp
|
|
175
|
-
if (
|
|
176
|
-
typeof parsed.auditId === "string" &&
|
|
177
|
-
typeof parsed.timestamp === "string"
|
|
178
|
-
) {
|
|
179
|
-
records.push(parsed);
|
|
180
|
-
}
|
|
181
|
-
} catch {
|
|
182
|
-
// Skip malformed lines
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
return records;
|
|
187
|
-
}
|
|
188
|
-
}
|
|
@@ -1,169 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* CES auth adapter definitions for secure command profiles.
|
|
3
|
-
*
|
|
4
|
-
* Auth adapters describe how credentials are materialised into a command's
|
|
5
|
-
* execution environment. Each adapter type has different security properties
|
|
6
|
-
* and cleanup requirements.
|
|
7
|
-
*
|
|
8
|
-
* v1 adapter set:
|
|
9
|
-
*
|
|
10
|
-
* - `env_var` — Inject credential as an environment variable.
|
|
11
|
-
* Lifetime: process scope. Cleaned up on exit.
|
|
12
|
-
* - `temp_file` — Write credential to a temporary file and pass
|
|
13
|
-
* the path via an env var. File is deleted after
|
|
14
|
-
* command exits.
|
|
15
|
-
* - `credential_process` — Spawn a helper process that prints the credential
|
|
16
|
-
* to stdout (AWS credential_process pattern). The
|
|
17
|
-
* helper runs inside CES and is never exposed to the
|
|
18
|
-
* subprocess directly.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
// ---------------------------------------------------------------------------
|
|
22
|
-
// Auth adapter type discriminator
|
|
23
|
-
// ---------------------------------------------------------------------------
|
|
24
|
-
|
|
25
|
-
export const AuthAdapterType = {
|
|
26
|
-
/** Inject credential value as an environment variable. */
|
|
27
|
-
EnvVar: "env_var",
|
|
28
|
-
/** Write credential to a temp file and set a path env var. */
|
|
29
|
-
TempFile: "temp_file",
|
|
30
|
-
/**
|
|
31
|
-
* Spawn a credential helper process (AWS credential_process-style).
|
|
32
|
-
* The helper stdout is captured and injected as an env var.
|
|
33
|
-
*/
|
|
34
|
-
CredentialProcess: "credential_process",
|
|
35
|
-
} as const;
|
|
36
|
-
|
|
37
|
-
export type AuthAdapterType =
|
|
38
|
-
(typeof AuthAdapterType)[keyof typeof AuthAdapterType];
|
|
39
|
-
|
|
40
|
-
/** All valid auth adapter type strings. */
|
|
41
|
-
export const AUTH_ADAPTER_TYPES: readonly AuthAdapterType[] = Object.values(
|
|
42
|
-
AuthAdapterType,
|
|
43
|
-
) as AuthAdapterType[];
|
|
44
|
-
|
|
45
|
-
// ---------------------------------------------------------------------------
|
|
46
|
-
// Auth adapter config shapes
|
|
47
|
-
// ---------------------------------------------------------------------------
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Inject a credential directly as an environment variable.
|
|
51
|
-
*
|
|
52
|
-
* Example: `GH_TOKEN=<secret>` with `envVarName: "GH_TOKEN"`.
|
|
53
|
-
*/
|
|
54
|
-
export interface EnvVarAdapterConfig {
|
|
55
|
-
type: typeof AuthAdapterType.EnvVar;
|
|
56
|
-
/** Environment variable name where the credential value is injected. */
|
|
57
|
-
envVarName: string;
|
|
58
|
-
/**
|
|
59
|
-
* Optional prefix prepended to the raw credential value before injection
|
|
60
|
-
* (e.g. "Bearer " for OAuth tokens).
|
|
61
|
-
*/
|
|
62
|
-
valuePrefix?: string;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Write the credential to a temporary file and set an env var to the path.
|
|
67
|
-
*
|
|
68
|
-
* Example: `GOOGLE_APPLICATION_CREDENTIALS=/tmp/ces-xxx/svc.json`.
|
|
69
|
-
* The temp file is created in a CES-managed ephemeral directory and deleted
|
|
70
|
-
* after the command exits.
|
|
71
|
-
*/
|
|
72
|
-
export interface TempFileAdapterConfig {
|
|
73
|
-
type: typeof AuthAdapterType.TempFile;
|
|
74
|
-
/** Environment variable name pointing to the temp file path. */
|
|
75
|
-
envVarName: string;
|
|
76
|
-
/** File extension for the temp file (e.g. ".json", ".pem"). */
|
|
77
|
-
fileExtension?: string;
|
|
78
|
-
/**
|
|
79
|
-
* File mode (octal) for the temp file. Defaults to 0o600 (owner-only
|
|
80
|
-
* read/write). Must be <= 0o600.
|
|
81
|
-
*/
|
|
82
|
-
fileMode?: number;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Spawn a credential helper process, capture its stdout, and inject the
|
|
87
|
-
* result as an env var.
|
|
88
|
-
*
|
|
89
|
-
* Example: AWS `credential_process` that emits JSON with temporary keys.
|
|
90
|
-
* The helper command runs inside the CES process and is never exposed to
|
|
91
|
-
* the child command.
|
|
92
|
-
*/
|
|
93
|
-
export interface CredentialProcessAdapterConfig {
|
|
94
|
-
type: typeof AuthAdapterType.CredentialProcess;
|
|
95
|
-
/** The helper command to run (e.g. "aws-vault exec <profile> --json"). */
|
|
96
|
-
helperCommand: string;
|
|
97
|
-
/** Environment variable name where the helper's stdout is injected. */
|
|
98
|
-
envVarName: string;
|
|
99
|
-
/** Timeout in milliseconds for the helper process. Defaults to 10000. */
|
|
100
|
-
timeoutMs?: number;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Discriminated union of all auth adapter configurations.
|
|
105
|
-
*/
|
|
106
|
-
export type AuthAdapterConfig =
|
|
107
|
-
| EnvVarAdapterConfig
|
|
108
|
-
| TempFileAdapterConfig
|
|
109
|
-
| CredentialProcessAdapterConfig;
|
|
110
|
-
|
|
111
|
-
// ---------------------------------------------------------------------------
|
|
112
|
-
// Validation helpers
|
|
113
|
-
// ---------------------------------------------------------------------------
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Returns true if the given string is a valid auth adapter type.
|
|
117
|
-
*/
|
|
118
|
-
export function isValidAuthAdapterType(value: string): value is AuthAdapterType {
|
|
119
|
-
return (AUTH_ADAPTER_TYPES as readonly string[]).includes(value);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Validate an auth adapter config shape. Returns a list of error messages
|
|
124
|
-
* (empty array = valid).
|
|
125
|
-
*/
|
|
126
|
-
export function validateAuthAdapterConfig(
|
|
127
|
-
config: AuthAdapterConfig,
|
|
128
|
-
): string[] {
|
|
129
|
-
const errors: string[] = [];
|
|
130
|
-
|
|
131
|
-
if (!isValidAuthAdapterType(config.type)) {
|
|
132
|
-
errors.push(
|
|
133
|
-
`Unknown auth adapter type "${config.type}". Valid types: ${AUTH_ADAPTER_TYPES.join(", ")}`,
|
|
134
|
-
);
|
|
135
|
-
return errors;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
if (!config.envVarName || config.envVarName.trim().length === 0) {
|
|
139
|
-
errors.push(`Auth adapter "${config.type}" requires a non-empty envVarName`);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
switch (config.type) {
|
|
143
|
-
case AuthAdapterType.TempFile:
|
|
144
|
-
if (
|
|
145
|
-
config.fileMode !== undefined &&
|
|
146
|
-
(config.fileMode > 0o600 || (config.fileMode & 0o077) !== 0)
|
|
147
|
-
) {
|
|
148
|
-
errors.push(
|
|
149
|
-
`temp_file adapter fileMode must be <= 0600 (owner-only) with no group/other bits, got ${config.fileMode.toString(8)}`,
|
|
150
|
-
);
|
|
151
|
-
}
|
|
152
|
-
break;
|
|
153
|
-
|
|
154
|
-
case AuthAdapterType.CredentialProcess:
|
|
155
|
-
if (!config.helperCommand || config.helperCommand.trim().length === 0) {
|
|
156
|
-
errors.push(
|
|
157
|
-
`credential_process adapter requires a non-empty helperCommand`,
|
|
158
|
-
);
|
|
159
|
-
}
|
|
160
|
-
if (config.timeoutMs !== undefined && config.timeoutMs <= 0) {
|
|
161
|
-
errors.push(
|
|
162
|
-
`credential_process adapter timeoutMs must be positive, got ${config.timeoutMs}`,
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
break;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
return errors;
|
|
169
|
-
}
|
|
@@ -1,203 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* CES egress proxy hooks.
|
|
3
|
-
*
|
|
4
|
-
* Provides a lightweight `SessionStartHooks` implementation for the
|
|
5
|
-
* credential execution service. Unlike the assistant's session-manager
|
|
6
|
-
* (which wires credential resolution, MITM interception, and policy
|
|
7
|
-
* decisions), CES only needs to enforce the manifest's
|
|
8
|
-
* `allowedNetworkTargets` allowlist. No credential injection or CA
|
|
9
|
-
* setup happens at the proxy layer — CES injects credentials through
|
|
10
|
-
* auth adapters in the command environment.
|
|
11
|
-
*
|
|
12
|
-
* The proxy server is a plain HTTP CONNECT proxy that:
|
|
13
|
-
* - Allows connections matching the session's `allowedTargets` (host, port, protocol)
|
|
14
|
-
* - Blocks all other outbound connections
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import { request as httpRequest, createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
18
|
-
import { request as httpsRequest } from "node:https";
|
|
19
|
-
import { connect, type Socket } from "node:net";
|
|
20
|
-
|
|
21
|
-
import type { AllowedTarget, ManagedSession, SessionStartHooks } from "@vellumai/egress-proxy";
|
|
22
|
-
|
|
23
|
-
// ---------------------------------------------------------------------------
|
|
24
|
-
// Host-pattern matching
|
|
25
|
-
// ---------------------------------------------------------------------------
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Match a hostname against a glob pattern.
|
|
29
|
-
*
|
|
30
|
-
* Supported patterns:
|
|
31
|
-
* - Exact match: `"api.github.com"` matches `"api.github.com"`
|
|
32
|
-
* - Wildcard subdomain: `"*.github.com"` matches `"api.github.com"`,
|
|
33
|
-
* `"foo.bar.github.com"`, and also `"github.com"` (apex)
|
|
34
|
-
*
|
|
35
|
-
* Note: `"*"` (match-everything) is intentionally NOT supported. The
|
|
36
|
-
* manifest validator rejects overbroad patterns at registration time.
|
|
37
|
-
*/
|
|
38
|
-
function matchesHostPattern(hostname: string, pattern: string): boolean {
|
|
39
|
-
if (pattern === hostname) return true;
|
|
40
|
-
if (pattern.startsWith("*.")) {
|
|
41
|
-
const suffix = pattern.slice(1); // e.g. ".github.com"
|
|
42
|
-
const apex = pattern.slice(2); // e.g. "github.com"
|
|
43
|
-
return hostname.endsWith(suffix) || hostname === apex;
|
|
44
|
-
}
|
|
45
|
-
return false;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Check if a request target is allowed by any of the provided allowed targets.
|
|
50
|
-
*
|
|
51
|
-
* Validates host, port, and protocol against each allowed target entry.
|
|
52
|
-
* - Host must match the glob pattern.
|
|
53
|
-
* - If the target specifies `ports`, the request port must be in the list.
|
|
54
|
-
* - If the target specifies `protocols`, the request protocol must be in the list.
|
|
55
|
-
*/
|
|
56
|
-
function isTargetAllowed(
|
|
57
|
-
hostname: string,
|
|
58
|
-
port: number,
|
|
59
|
-
protocol: "http" | "https",
|
|
60
|
-
allowedTargets: AllowedTarget[],
|
|
61
|
-
): boolean {
|
|
62
|
-
for (const target of allowedTargets) {
|
|
63
|
-
if (!matchesHostPattern(hostname, target.host)) continue;
|
|
64
|
-
if (target.ports && target.ports.length > 0 && !target.ports.includes(port)) continue;
|
|
65
|
-
if (target.protocols && target.protocols.length > 0 && !target.protocols.includes(protocol)) continue;
|
|
66
|
-
return true;
|
|
67
|
-
}
|
|
68
|
-
return false;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// ---------------------------------------------------------------------------
|
|
72
|
-
// CONNECT tunnel handler
|
|
73
|
-
// ---------------------------------------------------------------------------
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Parse a CONNECT target of the form `host:port`.
|
|
77
|
-
*/
|
|
78
|
-
function parseConnectTarget(
|
|
79
|
-
url: string | undefined,
|
|
80
|
-
): { host: string; port: number } | null {
|
|
81
|
-
if (!url) return null;
|
|
82
|
-
const colonIdx = url.lastIndexOf(":");
|
|
83
|
-
if (colonIdx <= 0) return null;
|
|
84
|
-
let host = url.slice(0, colonIdx);
|
|
85
|
-
const portStr = url.slice(colonIdx + 1);
|
|
86
|
-
if (!host || !portStr) return null;
|
|
87
|
-
const port = Number(portStr);
|
|
88
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
|
|
89
|
-
if (host.startsWith("[") && host.endsWith("]")) {
|
|
90
|
-
host = host.slice(1, -1);
|
|
91
|
-
if (!host) return null;
|
|
92
|
-
}
|
|
93
|
-
return { host, port };
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// ---------------------------------------------------------------------------
|
|
97
|
-
// Hooks factory
|
|
98
|
-
// ---------------------------------------------------------------------------
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Build `SessionStartHooks` for CES egress enforcement.
|
|
102
|
-
*
|
|
103
|
-
* The created proxy server enforces the `allowedTargets` from the
|
|
104
|
-
* session's config. If no `allowedTargets` are configured, all
|
|
105
|
-
* connections are blocked (fail-closed).
|
|
106
|
-
*/
|
|
107
|
-
export function buildCesEgressHooks(): SessionStartHooks {
|
|
108
|
-
return {
|
|
109
|
-
// No CA setup needed — CES does not do MITM interception
|
|
110
|
-
createServer: async (managed: ManagedSession) => {
|
|
111
|
-
const allowedTargets = managed.config.allowedTargets ?? [];
|
|
112
|
-
|
|
113
|
-
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
114
|
-
// Plain HTTP proxy requests — parse the absolute URL and check host/port/protocol
|
|
115
|
-
if (req.url && req.method) {
|
|
116
|
-
try {
|
|
117
|
-
const target = new URL(req.url);
|
|
118
|
-
const protocol = target.protocol === "https:" ? "https" : "http" as const;
|
|
119
|
-
const port = target.port
|
|
120
|
-
? Number(target.port)
|
|
121
|
-
: protocol === "https" ? 443 : 80;
|
|
122
|
-
if (!isTargetAllowed(target.hostname, port, protocol, allowedTargets)) {
|
|
123
|
-
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
124
|
-
res.end(`Blocked by CES egress policy: ${target.hostname}:${port} (${protocol}) is not in the allowed targets list`);
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// Forward the request using the appropriate protocol
|
|
129
|
-
const doRequest = target.protocol === "https:" ? httpsRequest : httpRequest;
|
|
130
|
-
const proxyReq = doRequest(
|
|
131
|
-
req.url,
|
|
132
|
-
{
|
|
133
|
-
method: req.method,
|
|
134
|
-
headers: { ...req.headers, host: target.host },
|
|
135
|
-
},
|
|
136
|
-
(proxyRes) => {
|
|
137
|
-
res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
|
|
138
|
-
proxyRes.pipe(res);
|
|
139
|
-
},
|
|
140
|
-
);
|
|
141
|
-
|
|
142
|
-
proxyReq.on("error", () => {
|
|
143
|
-
if (!res.headersSent) {
|
|
144
|
-
res.writeHead(502, { "Content-Type": "text/plain" });
|
|
145
|
-
}
|
|
146
|
-
res.end("Proxy connection error");
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
req.pipe(proxyReq);
|
|
150
|
-
} catch {
|
|
151
|
-
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
152
|
-
res.end("Bad request");
|
|
153
|
-
}
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
158
|
-
res.end("Bad request");
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
// Handle CONNECT for HTTPS tunnelling
|
|
162
|
-
server.on("connect", (req: IncomingMessage, clientSocket: Socket, head: Buffer) => {
|
|
163
|
-
const target = parseConnectTarget(req.url);
|
|
164
|
-
if (!target) {
|
|
165
|
-
clientSocket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
166
|
-
clientSocket.destroy();
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// CONNECT is used for HTTPS tunnelling — assume "https" protocol
|
|
171
|
-
if (!isTargetAllowed(target.host, target.port, "https", allowedTargets)) {
|
|
172
|
-
clientSocket.write(
|
|
173
|
-
"HTTP/1.1 403 Forbidden\r\n" +
|
|
174
|
-
"Content-Type: text/plain\r\n\r\n" +
|
|
175
|
-
`Blocked by CES egress policy: ${target.host}:${target.port} (https) is not in the allowed targets list`,
|
|
176
|
-
);
|
|
177
|
-
clientSocket.destroy();
|
|
178
|
-
return;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// Tunnel to the allowed target
|
|
182
|
-
const upstream = connect(target.port, target.host, () => {
|
|
183
|
-
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
184
|
-
if (head.length > 0) {
|
|
185
|
-
upstream.write(head);
|
|
186
|
-
}
|
|
187
|
-
upstream.pipe(clientSocket);
|
|
188
|
-
clientSocket.pipe(upstream);
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
upstream.on("error", () => {
|
|
192
|
-
clientSocket.destroy();
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
clientSocket.on("error", () => {
|
|
196
|
-
upstream.destroy();
|
|
197
|
-
});
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
return server;
|
|
201
|
-
},
|
|
202
|
-
};
|
|
203
|
-
}
|