keystone-cli 0.4.4 → 0.5.1
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/README.md +29 -4
- package/package.json +1 -2
- package/src/cli.ts +64 -4
- package/src/db/workflow-db.ts +16 -7
- package/src/expression/evaluator.audit.test.ts +67 -0
- package/src/expression/evaluator.test.ts +15 -2
- package/src/expression/evaluator.ts +102 -29
- package/src/parser/agent-parser.test.ts +6 -2
- package/src/parser/schema.ts +2 -0
- package/src/parser/workflow-parser.test.ts +6 -2
- package/src/parser/workflow-parser.ts +22 -11
- package/src/runner/audit-verification.test.ts +12 -8
- package/src/runner/llm-adapter.ts +49 -12
- package/src/runner/llm-executor.test.ts +75 -13
- package/src/runner/llm-executor.ts +84 -47
- package/src/runner/mcp-client.audit.test.ts +79 -0
- package/src/runner/mcp-client.ts +102 -19
- package/src/runner/shell-executor.test.ts +33 -15
- package/src/runner/shell-executor.ts +110 -39
- package/src/runner/step-executor.test.ts +30 -2
- package/src/runner/timeout.ts +2 -2
- package/src/runner/tool-integration.test.ts +8 -2
- package/src/runner/workflow-runner.ts +95 -29
- package/src/templates/agents/keystone-architect.md +5 -3
- package/src/types/status.ts +25 -0
- package/src/ui/dashboard.tsx +3 -1
- package/src/utils/auth-manager.test.ts +3 -1
- package/src/utils/auth-manager.ts +12 -2
- package/src/utils/config-loader.test.ts +2 -17
- package/src/utils/mermaid.ts +0 -8
- package/src/utils/redactor.ts +115 -22
- package/src/utils/sandbox.test.ts +9 -13
- package/src/utils/sandbox.ts +40 -53
- package/src/utils/workflow-registry.test.ts +6 -2
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
|
2
|
+
import * as child_process from 'node:child_process';
|
|
3
|
+
import { MCPClient } from './mcp-client';
|
|
4
|
+
|
|
5
|
+
import { Readable, Writable } from 'node:stream';
|
|
6
|
+
|
|
7
|
+
describe('MCPClient Audit Fixes', () => {
|
|
8
|
+
let spawnSpy: ReturnType<typeof spyOn>;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
spawnSpy = spyOn(child_process, 'spawn').mockReturnValue({
|
|
12
|
+
stdout: new Readable({ read() {} }),
|
|
13
|
+
stdin: new Writable({
|
|
14
|
+
write(c, e, cb) {
|
|
15
|
+
cb();
|
|
16
|
+
},
|
|
17
|
+
}),
|
|
18
|
+
kill: () => {},
|
|
19
|
+
on: () => {},
|
|
20
|
+
// biome-ignore lint/suspicious/noExplicitAny: Mocking complex object
|
|
21
|
+
} as any);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
spawnSpy.mockRestore();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should filter sensitive environment variables', async () => {
|
|
29
|
+
// Set temp environment variables
|
|
30
|
+
process.env.TEST_API_KEY_LEAK = 'secret_value';
|
|
31
|
+
process.env.TEST_SAFE_VAR = 'safe_value';
|
|
32
|
+
process.env.TEST_TOKEN_XYZ = 'secret_token';
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
await MCPClient.createLocal('node', [], { EXPLICIT_SECRET: 'allowed' });
|
|
36
|
+
|
|
37
|
+
// Assert spawn arguments
|
|
38
|
+
// args: [0]=command, [1]=args, [2]=options
|
|
39
|
+
const call = spawnSpy.mock.lastCall;
|
|
40
|
+
if (!call) throw new Error('spawn not called');
|
|
41
|
+
|
|
42
|
+
const envArg = call[2].env;
|
|
43
|
+
|
|
44
|
+
// Safe vars should remain
|
|
45
|
+
expect(envArg.TEST_SAFE_VAR).toBe('safe_value');
|
|
46
|
+
|
|
47
|
+
// Explicitly passed vars should remain
|
|
48
|
+
expect(envArg.EXPLICIT_SECRET).toBe('allowed');
|
|
49
|
+
|
|
50
|
+
// Sensitive vars should be filtered
|
|
51
|
+
expect(envArg.TEST_API_KEY_LEAK).toBeUndefined();
|
|
52
|
+
expect(envArg.TEST_TOKEN_XYZ).toBeUndefined();
|
|
53
|
+
} finally {
|
|
54
|
+
// Cleanup
|
|
55
|
+
process.env.TEST_API_KEY_LEAK = undefined;
|
|
56
|
+
process.env.TEST_SAFE_VAR = undefined;
|
|
57
|
+
process.env.TEST_TOKEN_XYZ = undefined;
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should allow whitelisted sensitive vars if explicitly provided', async () => {
|
|
62
|
+
process.env.TEST_API_KEY_LEAK = 'secret_value';
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
// User explicitly asks to pass this env var
|
|
66
|
+
await MCPClient.createLocal('node', [], {
|
|
67
|
+
TEST_API_KEY_LEAK: process.env.TEST_API_KEY_LEAK as string,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const call = spawnSpy.mock.lastCall;
|
|
71
|
+
if (!call) throw new Error('spawn not called');
|
|
72
|
+
const envArg = call[2].env;
|
|
73
|
+
|
|
74
|
+
expect(envArg.TEST_API_KEY_LEAK).toBe('secret_value');
|
|
75
|
+
} finally {
|
|
76
|
+
process.env.TEST_API_KEY_LEAK = undefined;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
});
|
package/src/runner/mcp-client.ts
CHANGED
|
@@ -2,6 +2,12 @@ import { type ChildProcess, spawn } from 'node:child_process';
|
|
|
2
2
|
import { type Interface, createInterface } from 'node:readline';
|
|
3
3
|
import pkg from '../../package.json' with { type: 'json' };
|
|
4
4
|
|
|
5
|
+
// MCP Protocol version - update when upgrading to newer MCP spec
|
|
6
|
+
export const MCP_PROTOCOL_VERSION = '2024-11-05';
|
|
7
|
+
|
|
8
|
+
// Maximum buffer size for incoming messages (10MB) to prevent memory exhaustion
|
|
9
|
+
const MAX_BUFFER_SIZE = 10 * 1024 * 1024;
|
|
10
|
+
|
|
5
11
|
interface MCPTool {
|
|
6
12
|
name: string;
|
|
7
13
|
description?: string;
|
|
@@ -33,8 +39,19 @@ class StdConfigTransport implements MCPTransport {
|
|
|
33
39
|
private rl: Interface;
|
|
34
40
|
|
|
35
41
|
constructor(command: string, args: string[] = [], env: Record<string, string> = {}) {
|
|
42
|
+
// Filter out sensitive environment variables from the host process
|
|
43
|
+
// unless they are explicitly provided in the 'env' argument
|
|
44
|
+
const safeEnv: Record<string, string> = {};
|
|
45
|
+
const sensitivePattern = /(?:key|token|secret|password|credential|auth|private)/i;
|
|
46
|
+
|
|
47
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
48
|
+
if (value && !sensitivePattern.test(key)) {
|
|
49
|
+
safeEnv[key] = value;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
36
53
|
this.process = spawn(command, args, {
|
|
37
|
-
env: { ...
|
|
54
|
+
env: { ...safeEnv, ...env },
|
|
38
55
|
stdio: ['pipe', 'pipe', 'inherit'],
|
|
39
56
|
});
|
|
40
57
|
|
|
@@ -53,6 +70,14 @@ class StdConfigTransport implements MCPTransport {
|
|
|
53
70
|
|
|
54
71
|
onMessage(callback: (message: MCPResponse) => void): void {
|
|
55
72
|
this.rl.on('line', (line) => {
|
|
73
|
+
// Safety check for extremely long lines that might have bypassed readline's internal limits
|
|
74
|
+
if (line.length > MAX_BUFFER_SIZE) {
|
|
75
|
+
process.stderr.write(
|
|
76
|
+
`[MCP Error] Received line exceeding maximum size (${line.length} bytes), ignoring.\n`
|
|
77
|
+
);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
56
81
|
try {
|
|
57
82
|
const response = JSON.parse(line) as MCPResponse;
|
|
58
83
|
callback(response);
|
|
@@ -78,6 +103,7 @@ class SSETransport implements MCPTransport {
|
|
|
78
103
|
private onMessageCallback?: (message: MCPResponse) => void;
|
|
79
104
|
private abortController: AbortController | null = null;
|
|
80
105
|
private sessionId?: string;
|
|
106
|
+
private activeReaders: Set<ReadableStreamDefaultReader<Uint8Array>> = new Set();
|
|
81
107
|
|
|
82
108
|
constructor(url: string, headers: Record<string, string> = {}) {
|
|
83
109
|
this.url = url;
|
|
@@ -140,6 +166,9 @@ class SSETransport implements MCPTransport {
|
|
|
140
166
|
return;
|
|
141
167
|
}
|
|
142
168
|
|
|
169
|
+
// Track reader for cleanup
|
|
170
|
+
this.activeReaders.add(reader);
|
|
171
|
+
|
|
143
172
|
// Process the stream in the background
|
|
144
173
|
(async () => {
|
|
145
174
|
let buffer = '';
|
|
@@ -160,14 +189,26 @@ class SSETransport implements MCPTransport {
|
|
|
160
189
|
resolve();
|
|
161
190
|
}
|
|
162
191
|
} else if (!currentEvent.event || currentEvent.event === 'message') {
|
|
163
|
-
// If we get a message before an endpoint,
|
|
192
|
+
// If we get a message before an endpoint, verify it's a valid JSON-RPC message
|
|
193
|
+
// before assuming the URL itself is the endpoint.
|
|
164
194
|
// (Common in some MCP over SSE implementations like GitHub's)
|
|
165
195
|
if (!this.endpoint) {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
196
|
+
try {
|
|
197
|
+
const msg = JSON.parse(currentEvent.data || '{}');
|
|
198
|
+
if (
|
|
199
|
+
msg &&
|
|
200
|
+
typeof msg === 'object' &&
|
|
201
|
+
(msg.jsonrpc === '2.0' || msg.id !== undefined)
|
|
202
|
+
) {
|
|
203
|
+
this.endpoint = this.url;
|
|
204
|
+
if (!isResolved) {
|
|
205
|
+
isResolved = true;
|
|
206
|
+
clearTimeout(timeoutId);
|
|
207
|
+
resolve();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
} catch (e) {
|
|
211
|
+
// Not a valid JSON message, ignore for endpoint discovery
|
|
171
212
|
}
|
|
172
213
|
}
|
|
173
214
|
|
|
@@ -193,7 +234,12 @@ class SSETransport implements MCPTransport {
|
|
|
193
234
|
break;
|
|
194
235
|
}
|
|
195
236
|
|
|
196
|
-
|
|
237
|
+
const decoded = decoder.decode(value, { stream: true });
|
|
238
|
+
if (buffer.length + decoded.length > MAX_BUFFER_SIZE) {
|
|
239
|
+
throw new Error(`SSE buffer size limit exceeded (${MAX_BUFFER_SIZE} bytes)`);
|
|
240
|
+
}
|
|
241
|
+
buffer += decoded;
|
|
242
|
+
|
|
197
243
|
const lines = buffer.split(/\r\n|\r|\n/);
|
|
198
244
|
buffer = lines.pop() || '';
|
|
199
245
|
|
|
@@ -207,6 +253,12 @@ class SSETransport implements MCPTransport {
|
|
|
207
253
|
currentEvent.event = line.substring(6).trim();
|
|
208
254
|
} else if (line.startsWith('data:')) {
|
|
209
255
|
const data = line.substring(5).trim();
|
|
256
|
+
if (
|
|
257
|
+
currentEvent.data &&
|
|
258
|
+
currentEvent.data.length + data.length > MAX_BUFFER_SIZE
|
|
259
|
+
) {
|
|
260
|
+
throw new Error(`SSE data size limit exceeded (${MAX_BUFFER_SIZE} bytes)`);
|
|
261
|
+
}
|
|
210
262
|
currentEvent.data = currentEvent.data ? `${currentEvent.data}\n${data}` : data;
|
|
211
263
|
}
|
|
212
264
|
}
|
|
@@ -229,6 +281,8 @@ class SSETransport implements MCPTransport {
|
|
|
229
281
|
clearTimeout(timeoutId);
|
|
230
282
|
reject(err);
|
|
231
283
|
}
|
|
284
|
+
} finally {
|
|
285
|
+
this.activeReaders.delete(reader);
|
|
232
286
|
}
|
|
233
287
|
})();
|
|
234
288
|
} catch (err) {
|
|
@@ -244,7 +298,7 @@ class SSETransport implements MCPTransport {
|
|
|
244
298
|
throw new Error('SSE transport not connected or endpoint not received');
|
|
245
299
|
}
|
|
246
300
|
|
|
247
|
-
const headers = {
|
|
301
|
+
const headers: Record<string, string> = {
|
|
248
302
|
'Content-Type': 'application/json',
|
|
249
303
|
...this.headers,
|
|
250
304
|
};
|
|
@@ -273,6 +327,8 @@ class SSETransport implements MCPTransport {
|
|
|
273
327
|
if (contentType?.includes('text/event-stream')) {
|
|
274
328
|
const reader = response.body?.getReader();
|
|
275
329
|
if (reader) {
|
|
330
|
+
// Track reader for cleanup
|
|
331
|
+
this.activeReaders.add(reader);
|
|
276
332
|
(async () => {
|
|
277
333
|
let buffer = '';
|
|
278
334
|
const decoder = new TextDecoder();
|
|
@@ -302,7 +358,12 @@ class SSETransport implements MCPTransport {
|
|
|
302
358
|
break;
|
|
303
359
|
}
|
|
304
360
|
|
|
305
|
-
|
|
361
|
+
const decoded = decoder.decode(value, { stream: true });
|
|
362
|
+
if (buffer.length + decoded.length > MAX_BUFFER_SIZE) {
|
|
363
|
+
throw new Error(`SSE buffer size limit exceeded (${MAX_BUFFER_SIZE} bytes)`);
|
|
364
|
+
}
|
|
365
|
+
buffer += decoded;
|
|
366
|
+
|
|
306
367
|
const lines = buffer.split(/\r\n|\r|\n/);
|
|
307
368
|
buffer = lines.pop() || '';
|
|
308
369
|
|
|
@@ -316,12 +377,20 @@ class SSETransport implements MCPTransport {
|
|
|
316
377
|
currentEvent.event = line.substring(6).trim();
|
|
317
378
|
} else if (line.startsWith('data:')) {
|
|
318
379
|
const data = line.substring(5).trim();
|
|
380
|
+
if (
|
|
381
|
+
currentEvent.data &&
|
|
382
|
+
currentEvent.data.length + data.length > MAX_BUFFER_SIZE
|
|
383
|
+
) {
|
|
384
|
+
throw new Error(`SSE data size limit exceeded (${MAX_BUFFER_SIZE} bytes)`);
|
|
385
|
+
}
|
|
319
386
|
currentEvent.data = currentEvent.data ? `${currentEvent.data}\n${data}` : data;
|
|
320
387
|
}
|
|
321
388
|
}
|
|
322
389
|
}
|
|
323
390
|
} catch (e) {
|
|
324
391
|
// Ignore stream errors
|
|
392
|
+
} finally {
|
|
393
|
+
this.activeReaders.delete(reader);
|
|
325
394
|
}
|
|
326
395
|
})();
|
|
327
396
|
}
|
|
@@ -333,6 +402,11 @@ class SSETransport implements MCPTransport {
|
|
|
333
402
|
}
|
|
334
403
|
|
|
335
404
|
close(): void {
|
|
405
|
+
// Cancel all active readers to prevent memory leaks
|
|
406
|
+
for (const reader of this.activeReaders) {
|
|
407
|
+
reader.cancel().catch(() => {});
|
|
408
|
+
}
|
|
409
|
+
this.activeReaders.clear();
|
|
336
410
|
this.abortController?.abort();
|
|
337
411
|
}
|
|
338
412
|
}
|
|
@@ -401,25 +475,29 @@ export class MCPClient {
|
|
|
401
475
|
};
|
|
402
476
|
|
|
403
477
|
return new Promise((resolve, reject) => {
|
|
404
|
-
|
|
405
|
-
this.transport.send(message).catch((err) => {
|
|
406
|
-
this.pendingRequests.delete(id);
|
|
407
|
-
reject(err);
|
|
408
|
-
});
|
|
409
|
-
|
|
410
|
-
// Add a timeout
|
|
411
|
-
setTimeout(() => {
|
|
478
|
+
const timeoutId = setTimeout(() => {
|
|
412
479
|
if (this.pendingRequests.has(id)) {
|
|
413
480
|
this.pendingRequests.delete(id);
|
|
414
481
|
reject(new Error(`MCP request timeout: ${method}`));
|
|
415
482
|
}
|
|
416
483
|
}, this.timeout);
|
|
484
|
+
|
|
485
|
+
this.pendingRequests.set(id, (response) => {
|
|
486
|
+
clearTimeout(timeoutId);
|
|
487
|
+
resolve(response);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
this.transport.send(message).catch((err) => {
|
|
491
|
+
clearTimeout(timeoutId);
|
|
492
|
+
this.pendingRequests.delete(id);
|
|
493
|
+
reject(err);
|
|
494
|
+
});
|
|
417
495
|
});
|
|
418
496
|
}
|
|
419
497
|
|
|
420
498
|
async initialize() {
|
|
421
499
|
return this.request('initialize', {
|
|
422
|
-
protocolVersion:
|
|
500
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
423
501
|
capabilities: {},
|
|
424
502
|
clientInfo: {
|
|
425
503
|
name: 'keystone-cli',
|
|
@@ -445,6 +523,11 @@ export class MCPClient {
|
|
|
445
523
|
}
|
|
446
524
|
|
|
447
525
|
stop() {
|
|
526
|
+
// Reject all pending requests to prevent hanging callers
|
|
527
|
+
for (const [id, resolve] of this.pendingRequests) {
|
|
528
|
+
resolve({ id, error: { code: -1, message: 'MCP client stopped' } });
|
|
529
|
+
}
|
|
530
|
+
this.pendingRequests.clear();
|
|
448
531
|
this.transport.close();
|
|
449
532
|
}
|
|
450
533
|
}
|
|
@@ -25,6 +25,7 @@ describe('shell-executor', () => {
|
|
|
25
25
|
const step: ShellStep = {
|
|
26
26
|
id: 'test',
|
|
27
27
|
type: 'shell',
|
|
28
|
+
needs: [],
|
|
28
29
|
run: 'echo "hello world"',
|
|
29
30
|
};
|
|
30
31
|
|
|
@@ -37,6 +38,7 @@ describe('shell-executor', () => {
|
|
|
37
38
|
const step: ShellStep = {
|
|
38
39
|
id: 'test',
|
|
39
40
|
type: 'shell',
|
|
41
|
+
needs: [],
|
|
40
42
|
run: 'echo "${{ inputs.name }}"',
|
|
41
43
|
};
|
|
42
44
|
const customContext: ExpressionContext = {
|
|
@@ -52,6 +54,7 @@ describe('shell-executor', () => {
|
|
|
52
54
|
const step: ShellStep = {
|
|
53
55
|
id: 'test',
|
|
54
56
|
type: 'shell',
|
|
57
|
+
needs: [],
|
|
55
58
|
run: 'echo $TEST_VAR',
|
|
56
59
|
env: {
|
|
57
60
|
TEST_VAR: 'env-value',
|
|
@@ -66,6 +69,7 @@ describe('shell-executor', () => {
|
|
|
66
69
|
const step: ShellStep = {
|
|
67
70
|
id: 'test',
|
|
68
71
|
type: 'shell',
|
|
72
|
+
needs: [],
|
|
69
73
|
run: 'pwd',
|
|
70
74
|
dir: '/tmp',
|
|
71
75
|
};
|
|
@@ -78,6 +82,7 @@ describe('shell-executor', () => {
|
|
|
78
82
|
const step: ShellStep = {
|
|
79
83
|
id: 'test',
|
|
80
84
|
type: 'shell',
|
|
85
|
+
needs: [],
|
|
81
86
|
run: 'echo "error" >&2',
|
|
82
87
|
};
|
|
83
88
|
|
|
@@ -89,6 +94,7 @@ describe('shell-executor', () => {
|
|
|
89
94
|
const step: ShellStep = {
|
|
90
95
|
id: 'test',
|
|
91
96
|
type: 'shell',
|
|
97
|
+
needs: [],
|
|
92
98
|
run: 'exit 1',
|
|
93
99
|
};
|
|
94
100
|
|
|
@@ -96,28 +102,40 @@ describe('shell-executor', () => {
|
|
|
96
102
|
expect(result.exitCode).toBe(1);
|
|
97
103
|
});
|
|
98
104
|
|
|
99
|
-
it('should
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
typeof msg === 'string' &&
|
|
106
|
-
msg.includes('WARNING: Command contains shell metacharacters')
|
|
107
|
-
) {
|
|
108
|
-
warned = true;
|
|
109
|
-
}
|
|
105
|
+
it('should throw error on shell injection risk', async () => {
|
|
106
|
+
const step: ShellStep = {
|
|
107
|
+
id: 'test',
|
|
108
|
+
type: 'shell',
|
|
109
|
+
needs: [],
|
|
110
|
+
run: 'echo "hello" ; rm -rf /tmp/foo',
|
|
110
111
|
};
|
|
111
112
|
|
|
113
|
+
await expect(executeShell(step, context)).rejects.toThrow(/Security Error/);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should allow legitimate shell variable expansion like ${HOME}', async () => {
|
|
112
117
|
const step: ShellStep = {
|
|
113
118
|
id: 'test',
|
|
114
119
|
type: 'shell',
|
|
115
|
-
|
|
120
|
+
needs: [],
|
|
121
|
+
run: 'echo ${HOME}',
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// Should NOT throw - ${HOME} is legitimate
|
|
125
|
+
const result = await executeShell(step, context);
|
|
126
|
+
expect(result.exitCode).toBe(0);
|
|
127
|
+
expect(result.stdout.trim()).toBe(Bun.env.HOME || '');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should still block dangerous parameter expansion like ${IFS}', async () => {
|
|
131
|
+
const step: ShellStep = {
|
|
132
|
+
id: 'test',
|
|
133
|
+
type: 'shell',
|
|
134
|
+
needs: [],
|
|
135
|
+
run: 'echo ${IFS}',
|
|
116
136
|
};
|
|
117
137
|
|
|
118
|
-
await executeShell(step, context);
|
|
119
|
-
expect(warned).toBe(true);
|
|
120
|
-
console.warn = spy; // Restore
|
|
138
|
+
await expect(executeShell(step, context)).rejects.toThrow(/Security Error/);
|
|
121
139
|
});
|
|
122
140
|
});
|
|
123
141
|
});
|
|
@@ -60,23 +60,47 @@ export interface ShellResult {
|
|
|
60
60
|
* Check if a command contains potentially dangerous shell metacharacters
|
|
61
61
|
* Returns true if the command looks like it might contain unescaped user input
|
|
62
62
|
*/
|
|
63
|
+
// Pre-compiled dangerous patterns for performance
|
|
64
|
+
// These patterns are designed to detect likely injection attempts while minimizing false positives
|
|
65
|
+
const DANGEROUS_PATTERNS: RegExp[] = [
|
|
66
|
+
/;\s*\w/, // Command chaining with semicolon (e.g., `; rm -rf /`)
|
|
67
|
+
/\|\s*(?:sh|bash|zsh|ksh|dash|csh|python|python[23]?|node|ruby|perl|php|lua)\b/, // Piping to shell/interpreter (download-and-execute pattern)
|
|
68
|
+
/\|\s*(?:sudo|su)\b/, // Piping to privilege escalation
|
|
69
|
+
/&&\s*(?:rm|chmod|chown|mkfs|dd)\b/, // AND chaining with destructive commands
|
|
70
|
+
/\|\|\s*(?:rm|chmod|chown|mkfs|dd)\b/, // OR chaining with destructive commands
|
|
71
|
+
/`[^`]+`/, // Command substitution with backticks
|
|
72
|
+
/\$\([^)]+\)/, // Command substitution with $()
|
|
73
|
+
/>\s*\/dev\/null\s*2>&1\s*&/, // Backgrounding with hidden output (often malicious)
|
|
74
|
+
/rm\s+(-rf?|--recursive)\s+[\/~]/, // Dangerous recursive deletion
|
|
75
|
+
/>\s*\/etc\//, // Writing to /etc
|
|
76
|
+
/curl\s+.*\|\s*(?:sh|bash)/, // Download and execute pattern
|
|
77
|
+
/wget\s+.*\|\s*(?:sh|bash)/, // Download and execute pattern
|
|
78
|
+
// Additional patterns for more comprehensive detection
|
|
79
|
+
/base64\s+(-d|--decode)\s*\|/, // Base64 decode piped to another command
|
|
80
|
+
/\beval\s+["'\$]/, // eval with variable/string (likely injection)
|
|
81
|
+
/\bexec\s+\d+[<>]/, // exec with file descriptor redirection
|
|
82
|
+
/python[23]?\s+-c\s*["']/, // Python one-liner with quoted code
|
|
83
|
+
/node\s+(-e|--eval)\s*["']/, // Node.js one-liner with quoted code
|
|
84
|
+
/perl\s+-e\s*["']/, // Perl one-liner with quoted code
|
|
85
|
+
/ruby\s+-e\s*["']/, // Ruby one-liner with quoted code
|
|
86
|
+
/\bdd\s+.*\bof=\//, // dd write operation to root paths
|
|
87
|
+
/chmod\s+[0-7]{3,4}\s+\/(?!tmp)/, // chmod on root paths (except /tmp)
|
|
88
|
+
/mkfs\./, // Filesystem formatting commands
|
|
89
|
+
// Targeted parameter expansion patterns (not all ${} usage)
|
|
90
|
+
/\$\{IFS[}:]/, // IFS manipulation (common injection technique)
|
|
91
|
+
/\$\{[^}]*\$\([^}]*\}/, // Command substitution inside parameter expansion
|
|
92
|
+
/\$\{[^}]*:-[^}]*\$\(/, // Default value with command substitution
|
|
93
|
+
/\$\{[^}]*[`][^}]*\}/, // Backtick inside parameter expansion
|
|
94
|
+
/\\x[0-9a-fA-F]{2}/, // Hex escaping attempts
|
|
95
|
+
/\\[0-7]{3}/, // Octal escaping attempts
|
|
96
|
+
/<<<\s*/, // Here-strings (can be used for injection)
|
|
97
|
+
/\d*<&\s*\d*/, // File descriptor duplication
|
|
98
|
+
/\d*>&-\s*/, // Closing file descriptors
|
|
99
|
+
];
|
|
100
|
+
|
|
63
101
|
export function detectShellInjectionRisk(command: string): boolean {
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
/;[\s]*\w/, // Command chaining with semicolon
|
|
67
|
-
/\|[\s]*\w/, // Piping (legitimate uses exist, but worth warning)
|
|
68
|
-
/&&[\s]*\w/, // AND chaining
|
|
69
|
-
/\|\|[\s]*\w/, // OR chaining
|
|
70
|
-
/`[^`]+`/, // Command substitution with backticks
|
|
71
|
-
/\$\([^)]+\)/, // Command substitution with $()
|
|
72
|
-
/>\s*\/dev\/null/, // Output redirection (common in attacks)
|
|
73
|
-
/rm\s+-rf/, // Dangerous deletion command
|
|
74
|
-
/>\s*[~\/]/, // File redirection to suspicious paths
|
|
75
|
-
/curl\s+.*\|\s*sh/, // Download and execute pattern
|
|
76
|
-
/wget\s+.*\|\s*sh/, // Download and execute pattern
|
|
77
|
-
];
|
|
78
|
-
|
|
79
|
-
return dangerousPatterns.some((pattern) => pattern.test(command));
|
|
102
|
+
// Check against pre-compiled patterns
|
|
103
|
+
return DANGEROUS_PATTERNS.some((pattern) => pattern.test(command));
|
|
80
104
|
}
|
|
81
105
|
|
|
82
106
|
/**
|
|
@@ -91,9 +115,9 @@ export async function executeShell(
|
|
|
91
115
|
const command = ExpressionEvaluator.evaluateString(step.run, context);
|
|
92
116
|
|
|
93
117
|
// Check for potential shell injection risks
|
|
94
|
-
if (detectShellInjectionRisk(command)) {
|
|
95
|
-
|
|
96
|
-
|
|
118
|
+
if (!step.allowInsecure && detectShellInjectionRisk(command)) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`Security Error: Command contains shell metacharacters that may indicate injection risk:\n Command: ${command.substring(0, 100)}${command.length > 100 ? '...' : ''}\n To execute this command safely, ensure all user inputs are wrapped in \${{ escape(input) }}.\n\n If you trust this workflow and its inputs, you may need to refactor the step to avoid complex shell chains or use a stricter input validation.\n Or, if you really trust this command, you can set 'allowInsecure: true' in the step definition.`
|
|
97
121
|
);
|
|
98
122
|
}
|
|
99
123
|
|
|
@@ -107,31 +131,78 @@ export async function executeShell(
|
|
|
107
131
|
|
|
108
132
|
// Set working directory if specified
|
|
109
133
|
const cwd = step.dir ? ExpressionEvaluator.evaluateString(step.dir, context) : undefined;
|
|
134
|
+
const mergedEnv = Object.keys(env).length > 0 ? { ...Bun.env, ...env } : Bun.env;
|
|
135
|
+
|
|
136
|
+
// Safe Fast Path: If command contains only safe characters (alphanumeric, -, _, ., /) and spaces,
|
|
137
|
+
// we can split it and execute directly without a shell.
|
|
138
|
+
// This completely eliminates shell injection risks for simple commands.
|
|
139
|
+
const isSimpleCommand = /^[a-zA-Z0-9_\-./]+(?: [a-zA-Z0-9_\-./]+)*$/.test(command);
|
|
140
|
+
|
|
141
|
+
// Common shell builtins that must run in a shell
|
|
142
|
+
const splitArgs = command.split(/\s+/);
|
|
143
|
+
const cmd = splitArgs[0];
|
|
144
|
+
const isBuiltin = [
|
|
145
|
+
'exit',
|
|
146
|
+
'cd',
|
|
147
|
+
'export',
|
|
148
|
+
'unset',
|
|
149
|
+
'source',
|
|
150
|
+
'.',
|
|
151
|
+
'alias',
|
|
152
|
+
'unalias',
|
|
153
|
+
'eval',
|
|
154
|
+
'set',
|
|
155
|
+
].includes(cmd);
|
|
110
156
|
|
|
111
157
|
try {
|
|
112
|
-
|
|
113
|
-
let
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
158
|
+
let stdoutString = '';
|
|
159
|
+
let stderrString = '';
|
|
160
|
+
let exitCode = 0;
|
|
161
|
+
|
|
162
|
+
if (isSimpleCommand && !isBuiltin) {
|
|
163
|
+
// split by spaces
|
|
164
|
+
const args = splitArgs.slice(1);
|
|
165
|
+
if (!cmd) throw new Error('Empty command');
|
|
166
|
+
|
|
167
|
+
const proc = Bun.spawn([cmd, ...args], {
|
|
168
|
+
cwd,
|
|
169
|
+
env: mergedEnv,
|
|
170
|
+
stdout: 'pipe',
|
|
171
|
+
stderr: 'pipe',
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const stdoutText = await new Response(proc.stdout).text();
|
|
175
|
+
const stderrText = await new Response(proc.stderr).text();
|
|
176
|
+
|
|
177
|
+
// Wait for exit
|
|
178
|
+
exitCode = await proc.exited;
|
|
179
|
+
stdoutString = stdoutText;
|
|
180
|
+
stderrString = stderrText;
|
|
181
|
+
} else {
|
|
182
|
+
// Fallback to sh -c for complex commands (pipes, redirects, quotes)
|
|
183
|
+
// Execute command using sh -c to allow shell parsing
|
|
184
|
+
let proc = $`sh -c ${command}`.quiet();
|
|
185
|
+
|
|
186
|
+
// Apply environment variables - merge with Bun.env to preserve system PATH and other variables
|
|
187
|
+
if (Object.keys(env).length > 0) {
|
|
188
|
+
proc = proc.env({ ...Bun.env, ...env });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Apply working directory
|
|
192
|
+
if (cwd) {
|
|
193
|
+
proc = proc.cwd(cwd);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Execute and capture result
|
|
197
|
+
const result = await proc;
|
|
198
|
+
stdoutString = await result.text();
|
|
199
|
+
stderrString = result.stderr ? result.stderr.toString() : '';
|
|
200
|
+
exitCode = result.exitCode;
|
|
123
201
|
}
|
|
124
202
|
|
|
125
|
-
// Execute and capture result
|
|
126
|
-
const result = await proc;
|
|
127
|
-
|
|
128
|
-
const stdout = await result.text();
|
|
129
|
-
const stderr = result.stderr ? result.stderr.toString() : '';
|
|
130
|
-
const exitCode = result.exitCode;
|
|
131
|
-
|
|
132
203
|
return {
|
|
133
|
-
stdout,
|
|
134
|
-
stderr,
|
|
204
|
+
stdout: stdoutString,
|
|
205
|
+
stderr: stderrString,
|
|
135
206
|
exitCode,
|
|
136
207
|
};
|
|
137
208
|
} catch (error) {
|