@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
|
@@ -1,702 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Secure command manifest validator.
|
|
3
|
-
*
|
|
4
|
-
* Validates that a {@link SecureCommandManifest} meets the CES security
|
|
5
|
-
* invariants before it can be registered. Validation is fail-closed: any
|
|
6
|
-
* structural issue, missing field, or policy violation results in rejection.
|
|
7
|
-
*
|
|
8
|
-
* Invariants enforced:
|
|
9
|
-
*
|
|
10
|
-
* 1. The entrypoint and bundleId must not be a denied binary.
|
|
11
|
-
* 2. At least one command profile must be declared (no empty manifests).
|
|
12
|
-
* 3. Each profile must have at least one allowed argv pattern.
|
|
13
|
-
* 4. Denied subcommands and denied flags lists are checked for consistency.
|
|
14
|
-
* 5. Auth adapter config must be structurally valid.
|
|
15
|
-
* 6. `egressMode` must be explicitly declared.
|
|
16
|
-
* 7. When `egressMode` is `proxy_required`, each profile must declare at
|
|
17
|
-
* least one allowed network target.
|
|
18
|
-
* 8. When `egressMode` is `no_network`, profiles must not declare network
|
|
19
|
-
* targets (contradictory).
|
|
20
|
-
* 9. Overbroad patterns (e.g. a single `<param...>` that matches anything)
|
|
21
|
-
* are rejected.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
import {
|
|
25
|
-
validateAuthAdapterConfig,
|
|
26
|
-
AuthAdapterType,
|
|
27
|
-
} from "./auth-adapters.js";
|
|
28
|
-
import {
|
|
29
|
-
type SecureCommandManifest,
|
|
30
|
-
type CommandProfile,
|
|
31
|
-
type AllowedArgvPattern,
|
|
32
|
-
type AllowedNetworkTarget,
|
|
33
|
-
MANIFEST_SCHEMA_VERSION,
|
|
34
|
-
EGRESS_MODES,
|
|
35
|
-
EgressMode,
|
|
36
|
-
isDeniedBinary,
|
|
37
|
-
pathBasename,
|
|
38
|
-
} from "./profiles.js";
|
|
39
|
-
|
|
40
|
-
// ---------------------------------------------------------------------------
|
|
41
|
-
// Validation result
|
|
42
|
-
// ---------------------------------------------------------------------------
|
|
43
|
-
|
|
44
|
-
export interface ValidationResult {
|
|
45
|
-
/** Whether the manifest passed all checks. */
|
|
46
|
-
valid: boolean;
|
|
47
|
-
/** List of human-readable error messages (empty when valid). */
|
|
48
|
-
errors: string[];
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// ---------------------------------------------------------------------------
|
|
52
|
-
// Top-level validator
|
|
53
|
-
// ---------------------------------------------------------------------------
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Validate a secure command manifest against all CES security invariants.
|
|
57
|
-
*
|
|
58
|
-
* Returns a {@link ValidationResult} with `valid: false` and a list of
|
|
59
|
-
* error messages if any check fails. Validation is exhaustive — all
|
|
60
|
-
* violations are reported, not just the first.
|
|
61
|
-
*/
|
|
62
|
-
export function validateManifest(
|
|
63
|
-
manifest: SecureCommandManifest,
|
|
64
|
-
): ValidationResult {
|
|
65
|
-
const errors: string[] = [];
|
|
66
|
-
|
|
67
|
-
// -- Schema version
|
|
68
|
-
if (manifest.schemaVersion !== MANIFEST_SCHEMA_VERSION) {
|
|
69
|
-
errors.push(
|
|
70
|
-
`Unsupported schema version "${manifest.schemaVersion}". Expected "${MANIFEST_SCHEMA_VERSION}".`,
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// -- Required string fields
|
|
75
|
-
if (!manifest.bundleDigest || manifest.bundleDigest.trim().length === 0) {
|
|
76
|
-
errors.push("bundleDigest is required and must be non-empty.");
|
|
77
|
-
}
|
|
78
|
-
if (!manifest.bundleId || manifest.bundleId.trim().length === 0) {
|
|
79
|
-
errors.push("bundleId is required and must be non-empty.");
|
|
80
|
-
}
|
|
81
|
-
if (!manifest.version || manifest.version.trim().length === 0) {
|
|
82
|
-
errors.push("version is required and must be non-empty.");
|
|
83
|
-
}
|
|
84
|
-
if (!manifest.entrypoint || manifest.entrypoint.trim().length === 0) {
|
|
85
|
-
errors.push("entrypoint is required and must be non-empty.");
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// -- Denied binary check (entrypoint basename and bundleId)
|
|
89
|
-
if (manifest.entrypoint && isDeniedBinary(manifest.entrypoint)) {
|
|
90
|
-
errors.push(
|
|
91
|
-
`Entrypoint "${manifest.entrypoint}" (basename: "${pathBasename(manifest.entrypoint)}") is a structurally denied binary. ` +
|
|
92
|
-
`Generic HTTP clients, interpreters, and shell trampolines cannot be secure command profiles.`,
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
if (manifest.bundleId && isDeniedBinary(manifest.bundleId)) {
|
|
96
|
-
errors.push(
|
|
97
|
-
`bundleId "${manifest.bundleId}" matches a structurally denied binary name. ` +
|
|
98
|
-
`Generic HTTP clients, interpreters, and shell trampolines cannot be secure command profiles.`,
|
|
99
|
-
);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// -- Egress mode
|
|
103
|
-
if (!manifest.egressMode) {
|
|
104
|
-
errors.push(
|
|
105
|
-
`egressMode is required. Valid values: ${EGRESS_MODES.join(", ")}.`,
|
|
106
|
-
);
|
|
107
|
-
} else if (!(EGRESS_MODES as readonly string[]).includes(manifest.egressMode)) {
|
|
108
|
-
errors.push(
|
|
109
|
-
`Invalid egressMode "${manifest.egressMode}". Valid values: ${EGRESS_MODES.join(", ")}.`,
|
|
110
|
-
);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// -- Auth adapter
|
|
114
|
-
if (!manifest.authAdapter) {
|
|
115
|
-
errors.push("authAdapter is required.");
|
|
116
|
-
} else {
|
|
117
|
-
const adapterErrors = validateAuthAdapterConfig(manifest.authAdapter);
|
|
118
|
-
for (const e of adapterErrors) {
|
|
119
|
-
errors.push(`authAdapter: ${e}`);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// -- credential_process helperCommand denied binary check
|
|
123
|
-
if (manifest.authAdapter.type === AuthAdapterType.CredentialProcess) {
|
|
124
|
-
const helper = manifest.authAdapter.helperCommand;
|
|
125
|
-
if (helper && helper.trim().length > 0) {
|
|
126
|
-
// Reject shell metacharacters that could chain a denied binary
|
|
127
|
-
// after an allowed one (e.g. "aws-vault exec ; curl ...").
|
|
128
|
-
// Since helperCommand is executed via `sh -c`, these operators
|
|
129
|
-
// allow arbitrary command chaining that bypasses the denylist.
|
|
130
|
-
if (containsShellMetacharacters(helper)) {
|
|
131
|
-
errors.push(
|
|
132
|
-
`authAdapter: credential_process helperCommand contains shell metacharacters. ` +
|
|
133
|
-
`Command chaining operators (;, &&, ||, |) and subshell expansion ($()) ` +
|
|
134
|
-
`are not allowed in helperCommand because they can bypass the denied binary check.`,
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const firstWord = extractShellBinary(helper);
|
|
139
|
-
const basename = pathBasename(firstWord);
|
|
140
|
-
if (isDeniedBinary(firstWord)) {
|
|
141
|
-
errors.push(
|
|
142
|
-
`authAdapter: credential_process helperCommand starts with denied binary "${basename}". ` +
|
|
143
|
-
`Generic HTTP clients, interpreters, and shell trampolines cannot be used as credential helpers.`,
|
|
144
|
-
);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// -- cleanConfigDirs key validation (defense-in-depth against path traversal)
|
|
151
|
-
if (manifest.cleanConfigDirs) {
|
|
152
|
-
for (const key of Object.keys(manifest.cleanConfigDirs)) {
|
|
153
|
-
if (key.includes("..")) {
|
|
154
|
-
errors.push(
|
|
155
|
-
`cleanConfigDirs key "${key}" contains path traversal sequence "..". ` +
|
|
156
|
-
`This is not allowed.`,
|
|
157
|
-
);
|
|
158
|
-
}
|
|
159
|
-
if (key.trim().length === 0) {
|
|
160
|
-
errors.push(
|
|
161
|
-
`cleanConfigDirs contains an empty key.`,
|
|
162
|
-
);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// -- Command profiles (must have at least one)
|
|
168
|
-
if (
|
|
169
|
-
!manifest.commandProfiles ||
|
|
170
|
-
Object.keys(manifest.commandProfiles).length === 0
|
|
171
|
-
) {
|
|
172
|
-
errors.push(
|
|
173
|
-
"At least one command profile must be declared. " +
|
|
174
|
-
"Secure command profiles cannot default to 'run any subcommand on this binary.'",
|
|
175
|
-
);
|
|
176
|
-
} else {
|
|
177
|
-
for (const [profileName, profile] of Object.entries(
|
|
178
|
-
manifest.commandProfiles,
|
|
179
|
-
)) {
|
|
180
|
-
const profileErrors = validateProfile(
|
|
181
|
-
profileName,
|
|
182
|
-
profile,
|
|
183
|
-
manifest.egressMode,
|
|
184
|
-
);
|
|
185
|
-
errors.push(...profileErrors);
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
return {
|
|
190
|
-
valid: errors.length === 0,
|
|
191
|
-
errors,
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// ---------------------------------------------------------------------------
|
|
196
|
-
// Profile-level validation
|
|
197
|
-
// ---------------------------------------------------------------------------
|
|
198
|
-
|
|
199
|
-
function validateProfile(
|
|
200
|
-
profileName: string,
|
|
201
|
-
profile: CommandProfile,
|
|
202
|
-
egressMode: EgressMode | undefined,
|
|
203
|
-
): string[] {
|
|
204
|
-
const errors: string[] = [];
|
|
205
|
-
const prefix = `Profile "${profileName}"`;
|
|
206
|
-
|
|
207
|
-
// -- Description
|
|
208
|
-
if (!profile.description || profile.description.trim().length === 0) {
|
|
209
|
-
errors.push(`${prefix}: description is required and must be non-empty.`);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// -- Allowed argv patterns (must have at least one)
|
|
213
|
-
if (
|
|
214
|
-
!profile.allowedArgvPatterns ||
|
|
215
|
-
profile.allowedArgvPatterns.length === 0
|
|
216
|
-
) {
|
|
217
|
-
errors.push(
|
|
218
|
-
`${prefix}: at least one allowedArgvPattern is required. ` +
|
|
219
|
-
"Profiles must explicitly declare what invocations are allowed.",
|
|
220
|
-
);
|
|
221
|
-
} else {
|
|
222
|
-
for (const pattern of profile.allowedArgvPatterns) {
|
|
223
|
-
const patternErrors = validateArgvPattern(prefix, pattern);
|
|
224
|
-
errors.push(...patternErrors);
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// -- Denied subcommands (required — runtime iterates unconditionally)
|
|
229
|
-
if (!profile.deniedSubcommands || !Array.isArray(profile.deniedSubcommands)) {
|
|
230
|
-
errors.push(
|
|
231
|
-
`${prefix}: deniedSubcommands is required and must be an array. ` +
|
|
232
|
-
"Use an empty array if no subcommands need to be denied.",
|
|
233
|
-
);
|
|
234
|
-
} else {
|
|
235
|
-
for (const sub of profile.deniedSubcommands) {
|
|
236
|
-
if (!sub || sub.trim().length === 0) {
|
|
237
|
-
errors.push(
|
|
238
|
-
`${prefix}: deniedSubcommands contains an empty string.`,
|
|
239
|
-
);
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// -- Denied flags (optional)
|
|
245
|
-
if (profile.deniedFlags) {
|
|
246
|
-
for (const flag of profile.deniedFlags) {
|
|
247
|
-
if (!flag || flag.trim().length === 0) {
|
|
248
|
-
errors.push(`${prefix}: deniedFlags contains an empty string.`);
|
|
249
|
-
}
|
|
250
|
-
if (flag && !flag.startsWith("-")) {
|
|
251
|
-
errors.push(
|
|
252
|
-
`${prefix}: deniedFlags entry "${flag}" does not start with "-". ` +
|
|
253
|
-
"Flags must start with a dash.",
|
|
254
|
-
);
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// -- Network targets vs egress mode consistency
|
|
260
|
-
if (egressMode === EgressMode.ProxyRequired) {
|
|
261
|
-
if (
|
|
262
|
-
!profile.allowedNetworkTargets ||
|
|
263
|
-
profile.allowedNetworkTargets.length === 0
|
|
264
|
-
) {
|
|
265
|
-
errors.push(
|
|
266
|
-
`${prefix}: egressMode is "proxy_required" but no allowedNetworkTargets are declared. ` +
|
|
267
|
-
"Commands with network egress must declare their allowed network targets.",
|
|
268
|
-
);
|
|
269
|
-
} else {
|
|
270
|
-
for (let i = 0; i < profile.allowedNetworkTargets.length; i++) {
|
|
271
|
-
const target = profile.allowedNetworkTargets[i]!;
|
|
272
|
-
const targetErrors = validateNetworkTarget(
|
|
273
|
-
`${prefix}: allowedNetworkTargets[${i}]`,
|
|
274
|
-
target,
|
|
275
|
-
);
|
|
276
|
-
errors.push(...targetErrors);
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
if (egressMode === EgressMode.NoNetwork) {
|
|
282
|
-
if (
|
|
283
|
-
profile.allowedNetworkTargets &&
|
|
284
|
-
profile.allowedNetworkTargets.length > 0
|
|
285
|
-
) {
|
|
286
|
-
errors.push(
|
|
287
|
-
`${prefix}: egressMode is "no_network" but allowedNetworkTargets are declared. ` +
|
|
288
|
-
"This is contradictory — remove network targets or change egressMode.",
|
|
289
|
-
);
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
return errors;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
// ---------------------------------------------------------------------------
|
|
297
|
-
// Network target validation
|
|
298
|
-
// ---------------------------------------------------------------------------
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* Overbroad host patterns that effectively match everything.
|
|
302
|
-
* These defeat the purpose of declaring allowed network targets.
|
|
303
|
-
*/
|
|
304
|
-
const OVERBROAD_HOST_PATTERNS: ReadonlySet<string> = new Set([
|
|
305
|
-
"*",
|
|
306
|
-
"*.*",
|
|
307
|
-
"*.*.*",
|
|
308
|
-
"*.*.*.*",
|
|
309
|
-
]);
|
|
310
|
-
|
|
311
|
-
/**
|
|
312
|
-
* Validate a single {@link AllowedNetworkTarget} entry.
|
|
313
|
-
*
|
|
314
|
-
* Returns an array of error messages (empty if valid). Checks:
|
|
315
|
-
* - `hostPattern` is non-empty
|
|
316
|
-
* - `hostPattern` is not overbroad (e.g. `"*"`, `"*.*"`)
|
|
317
|
-
* - `hostPattern` is either an exact hostname or a wildcard-subdomain pattern (`*.domain.tld`)
|
|
318
|
-
* - `ports` (if specified) are valid (1–65535)
|
|
319
|
-
* - `protocols` (if specified) are `"http"` or `"https"` only
|
|
320
|
-
*/
|
|
321
|
-
function validateNetworkTarget(
|
|
322
|
-
prefix: string,
|
|
323
|
-
target: AllowedNetworkTarget,
|
|
324
|
-
): string[] {
|
|
325
|
-
const errors: string[] = [];
|
|
326
|
-
|
|
327
|
-
// -- hostPattern must be non-empty
|
|
328
|
-
if (!target.hostPattern || target.hostPattern.trim().length === 0) {
|
|
329
|
-
errors.push(`${prefix}: hostPattern is required and must be non-empty.`);
|
|
330
|
-
return errors; // Can't validate further without a pattern
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
const pattern = target.hostPattern;
|
|
334
|
-
|
|
335
|
-
// -- Reject overbroad patterns
|
|
336
|
-
if (OVERBROAD_HOST_PATTERNS.has(pattern)) {
|
|
337
|
-
errors.push(
|
|
338
|
-
`${prefix}: hostPattern "${pattern}" is overbroad and matches effectively any host. ` +
|
|
339
|
-
"Use exact hostnames (e.g. \"api.github.com\") or wildcard-subdomain patterns (e.g. \"*.github.com\").",
|
|
340
|
-
);
|
|
341
|
-
return errors;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
// -- Validate pattern shape: exact hostname or *.domain.tld
|
|
345
|
-
if (pattern.includes("*")) {
|
|
346
|
-
// Only *.domain.tld form is allowed
|
|
347
|
-
if (!pattern.startsWith("*.") || pattern.indexOf("*", 1) !== -1) {
|
|
348
|
-
errors.push(
|
|
349
|
-
`${prefix}: hostPattern "${pattern}" uses an unsupported wildcard format. ` +
|
|
350
|
-
"Only wildcard-subdomain patterns (\"*.domain.tld\") are allowed. " +
|
|
351
|
-
"Wildcards in the middle or end of a hostname are not supported.",
|
|
352
|
-
);
|
|
353
|
-
} else {
|
|
354
|
-
// Ensure the domain part after *. is non-empty and looks like a domain
|
|
355
|
-
const domain = pattern.slice(2);
|
|
356
|
-
if (!domain || domain.trim().length === 0) {
|
|
357
|
-
errors.push(
|
|
358
|
-
`${prefix}: hostPattern "${pattern}" has an empty domain after the wildcard prefix.`,
|
|
359
|
-
);
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
// -- Validate ports
|
|
365
|
-
if (target.ports) {
|
|
366
|
-
for (const port of target.ports) {
|
|
367
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
368
|
-
errors.push(
|
|
369
|
-
`${prefix}: port ${port} is invalid. Ports must be integers between 1 and 65535.`,
|
|
370
|
-
);
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
// -- Validate protocols
|
|
376
|
-
if (target.protocols) {
|
|
377
|
-
const validProtocols = new Set(["http", "https"]);
|
|
378
|
-
for (const proto of target.protocols) {
|
|
379
|
-
if (!validProtocols.has(proto)) {
|
|
380
|
-
errors.push(
|
|
381
|
-
`${prefix}: protocol "${proto}" is invalid. Only "http" and "https" are allowed.`,
|
|
382
|
-
);
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
return errors;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
// ---------------------------------------------------------------------------
|
|
391
|
-
// Argv pattern validation
|
|
392
|
-
// ---------------------------------------------------------------------------
|
|
393
|
-
|
|
394
|
-
function validateArgvPattern(
|
|
395
|
-
profilePrefix: string,
|
|
396
|
-
pattern: AllowedArgvPattern,
|
|
397
|
-
): string[] {
|
|
398
|
-
const errors: string[] = [];
|
|
399
|
-
|
|
400
|
-
if (!pattern.name || pattern.name.trim().length === 0) {
|
|
401
|
-
errors.push(
|
|
402
|
-
`${profilePrefix}: argv pattern has no name. Each pattern must be named for audit logging.`,
|
|
403
|
-
);
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
if (!pattern.tokens || pattern.tokens.length === 0) {
|
|
407
|
-
errors.push(
|
|
408
|
-
`${profilePrefix}: argv pattern "${pattern.name}" has no tokens. ` +
|
|
409
|
-
"Empty patterns would match any invocation.",
|
|
410
|
-
);
|
|
411
|
-
return errors;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
// Check for overbroad patterns: a single rest placeholder matches anything
|
|
415
|
-
if (
|
|
416
|
-
pattern.tokens.length === 1 &&
|
|
417
|
-
isRestPlaceholder(pattern.tokens[0]!)
|
|
418
|
-
) {
|
|
419
|
-
errors.push(
|
|
420
|
-
`${profilePrefix}: argv pattern "${pattern.name}" contains only a rest placeholder ` +
|
|
421
|
-
`("${pattern.tokens[0]}"). This would match any invocation and is too broad.`,
|
|
422
|
-
);
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
// Rest placeholder must be last token
|
|
426
|
-
for (let i = 0; i < pattern.tokens.length; i++) {
|
|
427
|
-
const token = pattern.tokens[i]!;
|
|
428
|
-
if (isRestPlaceholder(token) && i < pattern.tokens.length - 1) {
|
|
429
|
-
errors.push(
|
|
430
|
-
`${profilePrefix}: argv pattern "${pattern.name}" has a rest placeholder ` +
|
|
431
|
-
`("${token}") at position ${i}, but rest placeholders must be the last token.`,
|
|
432
|
-
);
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
// Only check denied binaries in executable positions — the first token
|
|
437
|
-
// (index 0) is the subcommand position for multi-call umbrella binaries
|
|
438
|
-
// (e.g. busybox wget). Tokens at other positions are argument values and
|
|
439
|
-
// may legitimately use names that overlap with denied binaries (e.g.
|
|
440
|
-
// "--scheme https" where "https" is an httpie alias in DENIED_BINARIES).
|
|
441
|
-
const firstToken = pattern.tokens[0];
|
|
442
|
-
if (firstToken && !isPlaceholder(firstToken) && !isRestPlaceholder(firstToken) && isDeniedBinary(firstToken)) {
|
|
443
|
-
errors.push(
|
|
444
|
-
`${profilePrefix}: argv pattern "${pattern.name}" token "${firstToken}" matches a denied binary. ` +
|
|
445
|
-
`Multi-call umbrella binaries and shell trampolines cannot appear in executable argv positions.`,
|
|
446
|
-
);
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
return errors;
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
// ---------------------------------------------------------------------------
|
|
453
|
-
// Shell metacharacter detection (for helperCommand safety)
|
|
454
|
-
// ---------------------------------------------------------------------------
|
|
455
|
-
|
|
456
|
-
/**
|
|
457
|
-
* Shell metacharacters that enable command chaining or subshell expansion.
|
|
458
|
-
* Since helperCommand is executed via `sh -c`, these operators allow an
|
|
459
|
-
* attacker to chain a denied binary after an allowed one, bypassing the
|
|
460
|
-
* denylist check on the first token.
|
|
461
|
-
*
|
|
462
|
-
* Detected patterns:
|
|
463
|
-
* - `;` — command separator
|
|
464
|
-
* - `&&` — logical AND
|
|
465
|
-
* - `||` — logical OR
|
|
466
|
-
* - `|` — pipe (but not `||`)
|
|
467
|
-
* - `$(` — command substitution
|
|
468
|
-
* - `` ` `` — backtick command substitution
|
|
469
|
-
* - `\n` — newline (POSIX command separator, equivalent to `;`)
|
|
470
|
-
* - `\r` — carriage return
|
|
471
|
-
*/
|
|
472
|
-
const SHELL_METACHAR_RE = /;|&&|\|\||(?<!\|)\|(?!\|)|\$\(|`|\n|\r/;
|
|
473
|
-
|
|
474
|
-
/**
|
|
475
|
-
* Returns true if the command string contains shell metacharacters that
|
|
476
|
-
* could be used for command chaining or subshell expansion.
|
|
477
|
-
*/
|
|
478
|
-
export function containsShellMetacharacters(command: string): boolean {
|
|
479
|
-
return SHELL_METACHAR_RE.test(command);
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
// ---------------------------------------------------------------------------
|
|
483
|
-
// Shell binary extraction (for helperCommand denylist checks)
|
|
484
|
-
// ---------------------------------------------------------------------------
|
|
485
|
-
|
|
486
|
-
/**
|
|
487
|
-
* Regex matching shell variable assignments (KEY=VALUE) at the start of a
|
|
488
|
-
* command. These are environment overrides and not the binary. Handles
|
|
489
|
-
* bare values, single-quoted values, and double-quoted values.
|
|
490
|
-
*/
|
|
491
|
-
const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=(?:'[^']*'|"[^"]*"|(?:\\.|[^\s])*)\s+/;
|
|
492
|
-
|
|
493
|
-
/**
|
|
494
|
-
* Extract the actual binary name from a shell command string, accounting for
|
|
495
|
-
* leading env-var assignments (KEY=VALUE prefixes) and shell quoting around
|
|
496
|
-
* the binary token. This is necessary because helperCommand is executed via
|
|
497
|
-
* `sh -c`, so the shell resolves assignments and quotes before execution.
|
|
498
|
-
*
|
|
499
|
-
* Examples:
|
|
500
|
-
* "curl https://..." → "curl"
|
|
501
|
-
* "'curl' https://..." → "curl"
|
|
502
|
-
* "AWS_PROFILE=x curl ..." → "curl"
|
|
503
|
-
* "AWS_PROFILE=x FOO=bar curl ..." → "curl"
|
|
504
|
-
* "/usr/bin/python3 script.py" → "/usr/bin/python3"
|
|
505
|
-
*/
|
|
506
|
-
export function extractShellBinary(command: string): string {
|
|
507
|
-
let remaining = command.trim();
|
|
508
|
-
|
|
509
|
-
// Strip leading KEY=VALUE assignments
|
|
510
|
-
let match: RegExpExecArray | null;
|
|
511
|
-
while ((match = ENV_ASSIGNMENT_RE.exec(remaining)) !== null) {
|
|
512
|
-
remaining = remaining.slice(match[0].length);
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
// Extract the first whitespace-delimited token
|
|
516
|
-
const firstToken = remaining.split(/\s+/)[0] ?? remaining;
|
|
517
|
-
|
|
518
|
-
// Strip surrounding quotes (single or double)
|
|
519
|
-
return stripShellQuotes(firstToken);
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
/**
|
|
523
|
-
* Remove surrounding single or double quotes from a token.
|
|
524
|
-
* Only strips matching pairs at the boundaries (e.g., `'curl'` → `curl`).
|
|
525
|
-
*/
|
|
526
|
-
function stripShellQuotes(token: string): string {
|
|
527
|
-
if (token.length >= 2) {
|
|
528
|
-
if (
|
|
529
|
-
(token.startsWith("'") && token.endsWith("'")) ||
|
|
530
|
-
(token.startsWith('"') && token.endsWith('"'))
|
|
531
|
-
) {
|
|
532
|
-
return token.slice(1, -1);
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
return token;
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
// ---------------------------------------------------------------------------
|
|
539
|
-
// Argv matching (used by the runtime to check commands against profiles)
|
|
540
|
-
// ---------------------------------------------------------------------------
|
|
541
|
-
|
|
542
|
-
/**
|
|
543
|
-
* Returns true if the token is a single-value placeholder like `<name>`.
|
|
544
|
-
*/
|
|
545
|
-
function isPlaceholder(token: string): boolean {
|
|
546
|
-
return token.startsWith("<") && token.endsWith(">") && !token.endsWith("...>");
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
/**
|
|
550
|
-
* Returns true if the token is a rest placeholder like `<name...>`.
|
|
551
|
-
*/
|
|
552
|
-
function isRestPlaceholder(token: string): boolean {
|
|
553
|
-
return token.startsWith("<") && token.endsWith("...>");
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
/**
|
|
557
|
-
* Check if a concrete argv array matches an allowed argv pattern.
|
|
558
|
-
*
|
|
559
|
-
* Matching rules:
|
|
560
|
-
* - Literal tokens must match exactly.
|
|
561
|
-
* - `<name>` matches exactly one argument.
|
|
562
|
-
* - `<name...>` matches one or more remaining arguments (must be last token).
|
|
563
|
-
*/
|
|
564
|
-
export function matchesArgvPattern(
|
|
565
|
-
argv: readonly string[],
|
|
566
|
-
pattern: AllowedArgvPattern,
|
|
567
|
-
): boolean {
|
|
568
|
-
const { tokens } = pattern;
|
|
569
|
-
|
|
570
|
-
for (let i = 0; i < tokens.length; i++) {
|
|
571
|
-
const token = tokens[i]!;
|
|
572
|
-
|
|
573
|
-
if (isRestPlaceholder(token)) {
|
|
574
|
-
// Rest placeholder: must have at least one remaining arg
|
|
575
|
-
return argv.length > i;
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
// No more args but still have pattern tokens
|
|
579
|
-
if (i >= argv.length) return false;
|
|
580
|
-
|
|
581
|
-
if (isPlaceholder(token)) {
|
|
582
|
-
// Single placeholder: matches any single value
|
|
583
|
-
continue;
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
// Literal: must match exactly
|
|
587
|
-
if (argv[i] !== token) return false;
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
// All pattern tokens consumed — argv must also be fully consumed
|
|
591
|
-
return argv.length === tokens.length;
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
// ---------------------------------------------------------------------------
|
|
595
|
-
// Full command validation against a manifest
|
|
596
|
-
// ---------------------------------------------------------------------------
|
|
597
|
-
|
|
598
|
-
export interface CommandValidationResult {
|
|
599
|
-
/** Whether the command is allowed. */
|
|
600
|
-
allowed: boolean;
|
|
601
|
-
/** The profile name that matched (undefined when rejected). */
|
|
602
|
-
matchedProfile?: string;
|
|
603
|
-
/** The pattern name that matched (undefined when rejected). */
|
|
604
|
-
matchedPattern?: string;
|
|
605
|
-
/** Human-readable reason for rejection (undefined when allowed). */
|
|
606
|
-
reason?: string;
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
/**
|
|
610
|
-
* Validate a concrete command invocation (argv array) against a manifest.
|
|
611
|
-
*
|
|
612
|
-
* Checks:
|
|
613
|
-
* 1. The argv is non-empty.
|
|
614
|
-
* 2. The argv does not contain any denied subcommands (across all profiles).
|
|
615
|
-
* 3. The argv does not contain any denied flags (across all profiles).
|
|
616
|
-
* 4. At least one profile's allowed argv patterns matches.
|
|
617
|
-
*
|
|
618
|
-
* This function does NOT re-validate the manifest itself — call
|
|
619
|
-
* {@link validateManifest} separately during registration.
|
|
620
|
-
*/
|
|
621
|
-
export function validateCommand(
|
|
622
|
-
manifest: SecureCommandManifest,
|
|
623
|
-
argv: readonly string[],
|
|
624
|
-
): CommandValidationResult {
|
|
625
|
-
if (argv.length === 0) {
|
|
626
|
-
return {
|
|
627
|
-
allowed: false,
|
|
628
|
-
reason: "Empty argv — no command to validate.",
|
|
629
|
-
};
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
// Collect all denied subcommands and flags across profiles
|
|
633
|
-
const allDeniedSubcommands = new Set<string>();
|
|
634
|
-
const allDeniedFlags = new Set<string>();
|
|
635
|
-
|
|
636
|
-
for (const profile of Object.values(manifest.commandProfiles)) {
|
|
637
|
-
for (const sub of profile.deniedSubcommands) {
|
|
638
|
-
allDeniedSubcommands.add(sub);
|
|
639
|
-
}
|
|
640
|
-
if (profile.deniedFlags) {
|
|
641
|
-
for (const flag of profile.deniedFlags) {
|
|
642
|
-
allDeniedFlags.add(flag);
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
// Check denied subcommands (match against first N tokens of argv)
|
|
648
|
-
for (const denied of allDeniedSubcommands) {
|
|
649
|
-
const deniedParts = denied.split(/\s+/);
|
|
650
|
-
if (deniedParts.length <= argv.length) {
|
|
651
|
-
const match = deniedParts.every((part, i) => argv[i] === part);
|
|
652
|
-
if (match) {
|
|
653
|
-
return {
|
|
654
|
-
allowed: false,
|
|
655
|
-
reason: `Subcommand "${denied}" is explicitly denied.`,
|
|
656
|
-
};
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
// Check denied flags — also handle --flag=value combined tokens
|
|
662
|
-
for (const arg of argv) {
|
|
663
|
-
if (allDeniedFlags.has(arg)) {
|
|
664
|
-
return {
|
|
665
|
-
allowed: false,
|
|
666
|
-
reason: `Flag "${arg}" is explicitly denied.`,
|
|
667
|
-
};
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
// Handle --flag=value form: extract the flag prefix before '='
|
|
671
|
-
if (arg.startsWith("-") && arg.includes("=")) {
|
|
672
|
-
const flagPrefix = arg.slice(0, arg.indexOf("="));
|
|
673
|
-
if (allDeniedFlags.has(flagPrefix)) {
|
|
674
|
-
return {
|
|
675
|
-
allowed: false,
|
|
676
|
-
reason: `Flag "${flagPrefix}" is explicitly denied (via "${arg}").`,
|
|
677
|
-
};
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
// Try to match against allowed argv patterns in each profile
|
|
683
|
-
for (const [profileName, profile] of Object.entries(
|
|
684
|
-
manifest.commandProfiles,
|
|
685
|
-
)) {
|
|
686
|
-
for (const pattern of profile.allowedArgvPatterns) {
|
|
687
|
-
if (matchesArgvPattern(argv, pattern)) {
|
|
688
|
-
return {
|
|
689
|
-
allowed: true,
|
|
690
|
-
matchedProfile: profileName,
|
|
691
|
-
matchedPattern: pattern.name,
|
|
692
|
-
};
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
return {
|
|
698
|
-
allowed: false,
|
|
699
|
-
reason:
|
|
700
|
-
"Command argv does not match any allowed pattern in any profile.",
|
|
701
|
-
};
|
|
702
|
-
}
|