Skip to content

Commit 83b6a4e

Browse files
authored
Merge pull request #64 from KryptSec/fix/windows-shell-escape
fix: bypass shell entirely for cross-platform Docker commands
2 parents a636736 + c17122a commit 83b6a4e

5 files changed

Lines changed: 228 additions & 220 deletions

File tree

src/lib/docker.ts

Lines changed: 35 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,8 @@
44
* health-checking, and cleanup for both registry and local modes.
55
*/
66

7-
import { execSync } from 'child_process';
8-
import { shellEscape } from './shell.js';
9-
import { DOCKER_WAIT_TIMEOUT, DOCKER_POLL_INTERVAL } from './constants.js';
7+
import { execFileSync } from 'child_process';
8+
import { DOCKER_WAIT_TIMEOUT } from './constants.js';
109

1110
export interface ContainerSpec {
1211
challengeId: string;
@@ -17,6 +16,11 @@ export interface ContainerSpec {
1716
targetContainerName: string;
1817
}
1918

19+
/** Synchronous sleep that works cross-platform (no shell, no `sleep` binary). */
20+
function sleepSync(ms: number): void {
21+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
22+
}
23+
2024
/**
2125
* Pull a Docker image. Tries native platform first, falls back to linux/amd64
2226
* if the image has no matching manifest (common for challenge images on Apple Silicon).
@@ -28,7 +32,7 @@ export function pullImage(image: string, onProgress?: (line: string) => void): b
2832
}
2933

3034
try {
31-
execSync(`docker pull ${shellEscape(image)}`, {
35+
execFileSync('docker', ['pull', image], {
3236
stdio: onProgress ? 'inherit' : 'pipe',
3337
encoding: 'utf-8',
3438
});
@@ -45,7 +49,7 @@ export function pullImage(image: string, onProgress?: (line: string) => void): b
4549
if (onProgress) {
4650
onProgress(`Pulling ${image} (linux/amd64 fallback)...`);
4751
}
48-
execSync(`docker pull --platform linux/amd64 ${shellEscape(image)}`, {
52+
execFileSync('docker', ['pull', '--platform', 'linux/amd64', image], {
4953
stdio: onProgress ? 'inherit' : 'pipe',
5054
encoding: 'utf-8',
5155
});
@@ -57,12 +61,12 @@ export function pullImage(image: string, onProgress?: (line: string) => void): b
5761
*/
5862
export function ensureNetwork(name: string): void {
5963
try {
60-
execSync(`docker network inspect ${shellEscape(name)}`, {
64+
execFileSync('docker', ['network', 'inspect', name], {
6165
stdio: 'pipe',
6266
encoding: 'utf-8',
6367
});
6468
} catch {
65-
execSync(`docker network create ${shellEscape(name)}`, {
69+
execFileSync('docker', ['network', 'create', name], {
6670
stdio: 'pipe',
6771
encoding: 'utf-8',
6872
});
@@ -83,24 +87,23 @@ export function startContainers(spec: ContainerSpec, platforms?: PlatformOverrid
8387
cleanupStale(spec);
8488
ensureNetwork(spec.network);
8589

86-
const targetPlatformFlag = platforms?.target ? `--platform ${shellEscape(platforms.target)} ` : '';
87-
const kaliPlatformFlag = platforms?.kali ? `--platform ${shellEscape(platforms.kali)} ` : '';
88-
8990
// Start target container
90-
execSync(
91-
`docker run -d ${targetPlatformFlag}--name ${shellEscape(spec.targetContainerName)} ` +
92-
`--hostname target --network ${shellEscape(spec.network)} ` +
93-
`${shellEscape(spec.targetImage)}`,
94-
{ stdio: 'pipe', encoding: 'utf-8' }
95-
);
91+
const targetArgs = ['run', '-d'];
92+
if (platforms?.target) targetArgs.push('--platform', platforms.target);
93+
targetArgs.push('--name', spec.targetContainerName);
94+
targetArgs.push('--hostname', 'target');
95+
targetArgs.push('--network', spec.network);
96+
targetArgs.push(spec.targetImage);
97+
execFileSync('docker', targetArgs, { stdio: 'pipe', encoding: 'utf-8' });
9698

9799
// Start kali container
98-
execSync(
99-
`docker run -d ${kaliPlatformFlag}--name ${shellEscape(spec.kaliContainerName)} ` +
100-
`--hostname kali --network ${shellEscape(spec.network)} ` +
101-
`${shellEscape(spec.kaliImage)} sleep infinity`,
102-
{ stdio: 'pipe', encoding: 'utf-8' }
103-
);
100+
const kaliArgs = ['run', '-d'];
101+
if (platforms?.kali) kaliArgs.push('--platform', platforms.kali);
102+
kaliArgs.push('--name', spec.kaliContainerName);
103+
kaliArgs.push('--hostname', 'kali');
104+
kaliArgs.push('--network', spec.network);
105+
kaliArgs.push(spec.kaliImage, 'sleep', 'infinity');
106+
execFileSync('docker', kaliArgs, { stdio: 'pipe', encoding: 'utf-8' });
104107
}
105108

106109
/**
@@ -134,18 +137,17 @@ export function waitForTarget(
134137
timeoutMs = DOCKER_WAIT_TIMEOUT
135138
): void {
136139
const start = Date.now();
137-
const pollInterval = DOCKER_POLL_INTERVAL;
138140

139141
while (Date.now() - start < timeoutMs) {
140142
try {
141-
execSync(
142-
`docker exec ${shellEscape(kaliContainer)} curl -sf ${shellEscape(targetUrl)}`,
143+
execFileSync(
144+
'docker', ['exec', kaliContainer, 'curl', '-sf', targetUrl],
143145
{ stdio: 'pipe', encoding: 'utf-8', timeout: 5000 }
144146
);
145147
return; // Success
146148
} catch {
147149
// Not ready yet — wait and retry
148-
execSync(`sleep 2`, { stdio: 'pipe' });
150+
sleepSync(2000);
149151
}
150152
}
151153

@@ -159,16 +161,16 @@ export function waitForTarget(
159161
*/
160162
export function cleanup(spec: ContainerSpec): void {
161163
try {
162-
execSync(
163-
`docker rm -f ${shellEscape(spec.targetContainerName)} ${shellEscape(spec.kaliContainerName)}`,
164+
execFileSync(
165+
'docker', ['rm', '-f', spec.targetContainerName, spec.kaliContainerName],
164166
{ stdio: 'pipe', encoding: 'utf-8' }
165167
);
166168
} catch {
167169
// Containers may not exist
168170
}
169171

170172
try {
171-
execSync(`docker network rm ${shellEscape(spec.network)}`, {
173+
execFileSync('docker', ['network', 'rm', spec.network], {
172174
stdio: 'pipe',
173175
encoding: 'utf-8',
174176
});
@@ -182,8 +184,8 @@ export function cleanup(spec: ContainerSpec): void {
182184
*/
183185
export function cleanupStale(spec: ContainerSpec): void {
184186
try {
185-
execSync(
186-
`docker rm -f ${shellEscape(spec.targetContainerName)} ${shellEscape(spec.kaliContainerName)} 2>/dev/null`,
187+
execFileSync(
188+
'docker', ['rm', '-f', spec.targetContainerName, spec.kaliContainerName],
187189
{ stdio: 'pipe', encoding: 'utf-8' }
188190
);
189191
} catch {
@@ -195,7 +197,7 @@ export function cleanupStale(spec: ContainerSpec): void {
195197
* Start containers from a docker-compose.yml in the given directory.
196198
*/
197199
export function startFromCompose(challengeDir: string): void {
198-
execSync(`docker compose -f ${shellEscape(challengeDir)}/docker-compose.yml up -d --build`, {
200+
execFileSync('docker', ['compose', '-f', `${challengeDir}/docker-compose.yml`, 'up', '-d', '--build'], {
199201
stdio: 'inherit',
200202
encoding: 'utf-8',
201203
});
@@ -205,7 +207,7 @@ export function startFromCompose(challengeDir: string): void {
205207
* Stop and remove containers from a docker-compose.yml in the given directory.
206208
*/
207209
export function stopFromCompose(challengeDir: string): void {
208-
execSync(`docker compose -f ${shellEscape(challengeDir)}/docker-compose.yml down`, {
210+
execFileSync('docker', ['compose', '-f', `${challengeDir}/docker-compose.yml`, 'down'], {
209211
stdio: 'pipe',
210212
encoding: 'utf-8',
211213
});

src/lib/env-check.ts

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@
33
* Validates Docker, containers, and connectivity before running benchmarks.
44
*/
55

6-
import { execSync } from 'child_process';
7-
import { shellEscape } from './shell.js';
6+
import { execFileSync } from 'child_process';
87
import { DOCKER_STARTUP_POLL } from './constants.js';
98
import { ConfigError } from './errors.js';
109
import { getErrorStatus } from './retry.js';
@@ -32,7 +31,7 @@ export function checkDockerRunning(): EnvCheckResult {
3231
const hints: string[] = [];
3332

3433
try {
35-
execSync('docker ps', { encoding: 'utf-8', stdio: 'pipe' });
34+
execFileSync('docker', ['ps'], { encoding: 'utf-8', stdio: 'pipe' });
3635
return { ok: true, errors: [], hints: [] };
3736
} catch {
3837
errors.push('Docker is not running or not accessible');
@@ -51,7 +50,7 @@ export async function ensureDocker(
5150
): Promise<DockerStartResult> {
5251
// Check if docker is already running
5352
try {
54-
execSync('docker ps', { encoding: 'utf-8', stdio: 'pipe' });
53+
execFileSync('docker', ['ps'], { encoding: 'utf-8', stdio: 'pipe' });
5554
return { ok: true, autoStarted: false, errors: [], hints: [] };
5655
} catch {
5756
// Docker not running — try to auto-start on macOS
@@ -72,7 +71,7 @@ export async function ensureDocker(
7271
// macOS: try to launch Docker Desktop
7372
onStatus?.('Starting Docker Desktop...');
7473
try {
75-
execSync('open --background -a Docker', { stdio: 'pipe' });
74+
execFileSync('open', ['--background', '-a', 'Docker'], { stdio: 'pipe' });
7675
} catch {
7776
return {
7877
ok: false,
@@ -95,7 +94,7 @@ export async function ensureDocker(
9594
onStatus?.(`Waiting for Docker to be ready... (${elapsed}s)`);
9695

9796
try {
98-
execSync('docker ps', { encoding: 'utf-8', stdio: 'pipe' });
97+
execFileSync('docker', ['ps'], { encoding: 'utf-8', stdio: 'pipe' });
9998
return { ok: true, autoStarted: true, errors: [], hints: [] };
10099
} catch {
101100
// Not ready yet
@@ -128,8 +127,8 @@ export function checkContainersRunning(
128127
const kaliContainer = containerName; // e.g. gatekeeper-kali-1
129128

130129
try {
131-
const out = execSync(
132-
`docker ps -a --format "{{.Names}}\t{{.Status}}"`,
130+
const out = execFileSync(
131+
'docker', ['ps', '-a', '--format', '{{.Names}}\t{{.Status}}'],
133132
{ encoding: 'utf-8', stdio: 'pipe' }
134133
);
135134

@@ -184,14 +183,16 @@ export function checkTargetReachable(
184183
const hints: string[] = [];
185184

186185
try {
187-
// Extract host:port from URL (e.g. http://target:5000 -> target:5000)
188-
const urlMatch = targetUrl.match(/^(?:https?:\/\/)?([^/]+)/);
189-
const hostPort = urlMatch ? urlMatch[1] : targetUrl.replace(/^https?:\/\//, '');
190-
191-
const result = execSync(
192-
`docker exec ${shellEscape(containerName)} curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 ${shellEscape(targetUrl)} 2>/dev/null || echo "FAIL"`,
193-
{ encoding: 'utf-8', stdio: 'pipe' }
194-
).trim();
186+
let result: string;
187+
try {
188+
result = execFileSync(
189+
'docker',
190+
['exec', containerName, 'curl', '-s', '-o', '/dev/null', '-w', '%{http_code}', '--connect-timeout', '5', targetUrl],
191+
{ encoding: 'utf-8', stdio: 'pipe' }
192+
).trim();
193+
} catch {
194+
result = 'FAIL';
195+
}
195196

196197
if (result === 'FAIL' || result === '000') {
197198
errors.push(`Target ${targetUrl} is not reachable from Kali container`);
@@ -224,8 +225,8 @@ export function checkKaliTools(containerName: string): EnvCheckResult {
224225

225226
for (const tool of REQUIRED_KALI_TOOLS) {
226227
try {
227-
execSync(
228-
`docker exec ${shellEscape(containerName)} which ${tool} 2>/dev/null`,
228+
execFileSync(
229+
'docker', ['exec', containerName, 'which', tool],
229230
{ encoding: 'utf-8', stdio: 'pipe' }
230231
);
231232
} catch {
@@ -351,7 +352,7 @@ export async function checkApiKey(
351352
*/
352353
export function runPreflightChecks(
353354
challengeId: string,
354-
challengeDir: string,
355+
_challengeDir: string,
355356
containerName: string,
356357
targetUrl: string
357358
): EnvCheckResult {

src/lib/shell.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
// Shared shell utility
22

3-
/** Escape a string for safe inclusion in a shell command (single-quote wrapping). */
3+
/**
4+
* Escape a string for safe inclusion in a POSIX shell command (single-quote wrapping).
5+
*
6+
* NOTE: This function is POSIX-only (bash/zsh/sh). It does NOT handle Windows
7+
* cmd.exe quoting. For cross-platform subprocess calls, use execFileSync/spawnSync
8+
* with argument arrays instead of shell strings — this bypasses the shell entirely
9+
* and avoids escaping issues on all platforms.
10+
*/
411
export function shellEscape(s: string): string {
512
return "'" + s.replace(/'/g, "'\\''") + "'";
613
}

0 commit comments

Comments
 (0)