-
-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathbinaryResolver.ts
More file actions
483 lines (431 loc) · 14.4 KB
/
Copy pathbinaryResolver.ts
File metadata and controls
483 lines (431 loc) · 14.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
/**
* Binary Path Resolver
*
* Resolves absolute paths for allowed commands (git, ssh-add, ssh-keygen)
* to prevent PATH pollution attacks.
*
* Security features:
* - Uses 'which' command to resolve absolute paths
* - Caches resolved paths to avoid repeated lookups
* - Validates paths exist and are executable
* - Prioritizes VS Code git.path setting for git command
*
* @see https://owasp.org/www-community/attacks/Command_Injection
*/
import { execFile } from 'node:child_process';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { promisify } from 'node:util';
import { getWindow, getWorkspace } from '../core/vscodeLoader';
import { securityLogger } from './securityLogger';
const execFilePromise = promisify(execFile);
/**
* List of commands that this extension is allowed to execute.
* Only these commands will have their paths resolved and cached.
*/
const ALLOWED_COMMANDS = ['git', 'ssh-add', 'ssh-keygen'] as const;
type AllowedCommand = (typeof ALLOWED_COMMANDS)[number];
/**
* Cache entry with TTL support.
* Stores the resolved path and the timestamp when it was resolved.
* defense-in-depth: TTL prevents stale cache from masking binary replacement attacks
* during long-running VS Code sessions.
*/
interface CacheEntry {
readonly path: string | null;
readonly resolvedAt: number;
}
/**
* TTL for cached binary paths (30 minutes).
* VS Code sessions can last days; this limits the window for binary replacement attacks.
*/
const BINARY_CACHE_TTL_MS = 1_800_000;
/**
* Cache for resolved binary paths.
* Key: command name, Value: cache entry with path and timestamp
*/
const pathCache = new Map<string, CacheEntry>();
/**
* Timeout for path resolution (5 seconds)
*/
const RESOLUTION_TIMEOUT = 5000;
/**
* Flag to prevent repeated which-fallback warnings within a session.
* Set to true after the first warning is shown to the user.
*/
let whichFallbackWarningShown = false;
/**
* Error thrown when a binary path cannot be resolved
*/
export class BinaryResolutionError extends Error {
public readonly command: string;
public readonly code = 'ENOENT_BINARY';
constructor(command: string, reason: string) {
super(`Failed to resolve path for '${command}': ${reason}`);
this.name = 'BinaryResolutionError';
this.command = command;
/* c8 ignore start - Error.captureStackTrace availability depends on JS engine */
if (Error.captureStackTrace) {
Error.captureStackTrace(this, BinaryResolutionError);
}
/* c8 ignore stop */
}
}
/**
* Validate that a path points to an existing, executable file.
*
* Security: Uses lstat to detect symlinks, then validates the target.
*
* @param binaryPath - Path to validate
* @returns true if path is a valid executable
*/
async function isValidExecutable(binaryPath: string): Promise<boolean> {
try {
// Get file stats (follows symlinks)
const stats = await fs.stat(binaryPath);
// Must be a regular file
if (!stats.isFile()) {
return false;
}
// On Unix, check execute permission
/* c8 ignore start - Unix execute permission check (requires non-executable file setup) */
if (process.platform !== 'win32') {
// Check if any execute bit is set (owner, group, or others)
const executableBits = 0o111;
// eslint-disable-next-line no-bitwise -- file permission check requires bitwise AND
if ((stats.mode & executableBits) === 0) {
return false;
}
}
/* c8 ignore stop */
return true;
} catch /* c8 ignore start */ {
return false;
} /* c8 ignore stop */
}
/**
* Known paths for 'which' command on different platforms.
*
* Using absolute paths prevents PATH pollution attacks on the resolver itself.
* Falls back to command name if absolute path doesn't exist.
*/
const WHICH_PATHS: Readonly<Record<string, readonly string[]>> = {
darwin: ['/usr/bin/which'],
linux: ['/usr/bin/which', '/bin/which'],
win32: [String.raw`C:\Windows\System32\where.exe`],
} as const;
/**
* Get the path to 'which' or 'where' command.
*
* Tries known absolute paths first, falls back to command name.
* This prevents PATH pollution attacks on the resolver itself.
*
* @returns Path to which/where command
*/
async function getWhichCommand(): Promise<string> {
const platform = process.platform;
const knownPaths = WHICH_PATHS[platform] ?? WHICH_PATHS['linux'];
for (const knownPath of knownPaths) {
if (await isValidExecutable(knownPath)) {
return knownPath;
}
}
/* c8 ignore start - Fallback when known system paths don't exist (rare) */
// Fallback: use command name (less secure, but better than failing)
// This is logged for security audit
securityLogger.logValidationFailure(
'binary-resolution',
`No known absolute path for which/where on ${platform}, falling back to PATH`
);
// defense-in-depth: warn user about degraded security (once per session)
if (!whichFallbackWarningShown) {
whichFallbackWarningShown = true;
const window = getWindow();
if (window) {
window.showWarningMessage(
'Git ID Switcher: The "which" command was not found at known system paths. ' +
'Falling back to PATH resolution, which may reduce security.'
);
}
}
return platform === 'win32' ? 'where' : 'which';
/* c8 ignore stop */
}
/**
* Resolve binary path using platform-specific 'which' command.
*
* Uses absolute paths for 'which'/'where' when possible to prevent
* PATH pollution attacks on the resolver itself.
*
* @param command - Command name to resolve
* @returns Absolute path or null if not found
*/
async function resolveWithWhich(command: string): Promise<string | null> {
try {
const whichCommand = await getWhichCommand();
const { stdout } = await execFilePromise(whichCommand, [command], {
timeout: RESOLUTION_TIMEOUT,
maxBuffer: 1024 * 10, // 10KB should be enough for paths
});
// 'which' returns the path on stdout
// 'where' on Windows may return multiple paths, take the first
const resolvedPath = stdout.trim().split(/[\r\n]/, 1)[0];
/* c8 ignore next 3 - Empty which output */
if (!resolvedPath) {
return null;
}
// Normalize the path
const normalizedPath = path.normalize(resolvedPath);
/* c8 ignore start - Invalid executable validation */
// Validate the resolved path
if (!(await isValidExecutable(normalizedPath))) {
securityLogger.logValidationFailure(
'binary-resolution',
`Resolved path is not a valid executable: ${command}`
);
return null;
}
/* c8 ignore stop */
return normalizedPath;
} catch /* c8 ignore start */ {
// Command not found or other error
return null;
} /* c8 ignore stop */
}
/**
* Get VS Code's configured git.path setting.
*
* This setting takes priority over PATH-resolved paths for git command.
*
* @returns Configured git path or null
*/
function getVSCodeGitPath(): string | null {
try {
const workspace = getWorkspace();
if (!workspace) {
return null;
}
const config = workspace.getConfiguration('git');
const gitPath = config.get<string>('path');
if (!gitPath || typeof gitPath !== 'string' || gitPath.trim().length === 0) {
return null;
}
return gitPath.trim();
} catch /* c8 ignore start */ {
return null;
} /* c8 ignore stop */
}
/**
* Verify that a binary at the given path is actually a git binary.
*
* Executes `<path> --version` and checks that the output starts with "git version".
* Uses execFile directly (not secureExec) to avoid circular dependency:
* secureExec → getBinaryPath → resolveCommandPath → verifyGitBinary
*
* @param absolutePath - Absolute path to the binary to verify
* @returns true if the binary produces valid git version output
*/
async function verifyGitBinary(absolutePath: string): Promise<boolean> {
try {
const { stdout } = await execFilePromise(absolutePath, ['--version'], {
timeout: RESOLUTION_TIMEOUT,
maxBuffer: 1024,
});
return stdout.trim().startsWith('git version');
} catch /* c8 ignore start */ {
return false;
} /* c8 ignore stop */
}
/**
* Resolve the absolute path for a command.
*
* Resolution priority:
* 1. For git: VS Code git.path setting (if configured and valid)
* 2. System PATH via 'which'/'where' command
*
* Security: Validates that resolved paths are executable files.
*
* @param command - Command name to resolve
* @returns Absolute path to the binary
* @throws BinaryResolutionError if path cannot be resolved
*/
async function resolveCommandPath(command: AllowedCommand): Promise<string> {
// For git, check VS Code setting first
if (command === 'git') {
const vscodeGitPath = getVSCodeGitPath();
if (vscodeGitPath) {
// Normalize and validate VS Code configured path
const normalizedPath = path.normalize(vscodeGitPath);
// Security: reject relative paths to prevent cwd-based ambiguity
if (!path.isAbsolute(normalizedPath)) {
securityLogger.logValidationFailure(
'binary-resolution',
'VS Code git.path is not an absolute path, falling back to PATH'
);
} else if (await isValidExecutable(normalizedPath)) {
// defense-in-depth: verify this is actually a git binary, not a masquerading executable
// Uses execFile directly (not secureExec) to avoid circular dependency:
// secureExec → getBinaryPath → resolveCommandPath → (here)
if (await verifyGitBinary(normalizedPath)) {
return normalizedPath;
}
securityLogger.logValidationFailure(
'binary-resolution',
`VS Code git.path (${normalizedPath}) is executable but not a valid git binary, falling back to PATH`
);
} else {
// Log warning but continue to try PATH resolution
securityLogger.logValidationFailure(
'binary-resolution',
'VS Code git.path is not a valid executable, falling back to PATH'
);
}
}
}
// Resolve via which/where
const resolvedPath = await resolveWithWhich(command);
if (resolvedPath) {
return resolvedPath;
}
/* c8 ignore start - Command not found fallback */
throw new BinaryResolutionError(
command,
'Command not found in PATH or not executable'
);
} /* c8 ignore stop */
/**
* Check if a command is in the allowed list.
*
* @param command - Command name to check
* @returns true if command is allowed
*/
function isAllowedCommand(command: string): command is AllowedCommand {
return ALLOWED_COMMANDS.includes(command as AllowedCommand);
}
/**
* Get the absolute path for a command.
*
* Uses caching to avoid repeated path resolution.
* Cache can be invalidated using clearPathCache().
*
* Security considerations:
* - Only resolves paths for allowed commands
* - Validates that resolved paths are executable
* - Caches results to prevent TOCTOU issues within a session
* - VS Code git.path takes priority for git command
*
* @param command - Command name (must be in ALLOWED_COMMANDS)
* @returns Absolute path to the binary
* @throws BinaryResolutionError if path cannot be resolved
* @throws Error if command is not in allowed list
*/
export async function getBinaryPath(command: string): Promise<string> {
// Validate command is allowed
if (!isAllowedCommand(command)) {
throw new Error(`Command '${command}' is not in the allowed list`);
}
// Check cache first (with TTL validation)
const cached = pathCache.get(command);
if (cached !== undefined) {
const age = Date.now() - cached.resolvedAt;
if (age < BINARY_CACHE_TTL_MS) {
if (cached.path === null) {
throw new BinaryResolutionError(command, 'Previously failed to resolve');
}
return cached.path;
}
// TTL expired — evict and re-resolve
pathCache.delete(command);
}
try {
const resolvedPath = await resolveCommandPath(command);
// Cache successful resolution with timestamp
pathCache.set(command, { path: resolvedPath, resolvedAt: Date.now() });
return resolvedPath;
} catch (error) /* c8 ignore start */ {
// Cache failure to avoid repeated resolution attempts
pathCache.set(command, { path: null, resolvedAt: Date.now() });
if (error instanceof BinaryResolutionError) {
throw error;
}
throw new BinaryResolutionError(
command,
error instanceof Error ? error.message : 'Unknown error'
);
} /* c8 ignore stop */
}
/**
* Clear the path cache.
*
* Useful when:
* - User changes git.path setting
* - Testing
* - After system PATH changes
*/
export function clearPathCache(): void {
pathCache.clear();
}
/**
* Pre-resolve all allowed command paths.
*
* Call this at extension activation to:
* - Fail early if required commands are not available
* - Populate cache for faster subsequent calls
*
* @returns Object mapping commands to their resolved paths
* @throws BinaryResolutionError if any command cannot be resolved
*/
export async function resolveAllBinaryPaths(): Promise<
Record<AllowedCommand, string>
> {
const results: Partial<Record<AllowedCommand, string>> = {};
for (const command of ALLOWED_COMMANDS) {
results[command] = await getBinaryPath(command);
}
return results as Record<AllowedCommand, string>;
}
/**
* Check if all required binaries are available.
*
* Non-throwing version of resolveAllBinaryPaths.
*
* @returns Object with availability status for each command
*/
export async function checkBinaryAvailability(): Promise<
Record<AllowedCommand, { available: boolean; path?: string; error?: string }>
> {
const results: Record<
AllowedCommand,
{ available: boolean; path?: string; error?: string }
> = {} as Record<AllowedCommand, { available: boolean; path?: string; error?: string }>;
for (const command of ALLOWED_COMMANDS) {
try {
const resolvedPath = await getBinaryPath(command);
results[command] = { available: true, path: resolvedPath };
} catch (error) /* c8 ignore start */ {
results[command] = {
available: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
} /* c8 ignore stop */
}
return results;
}
/**
* Test-only exports
*/
export const __testExports = {
ALLOWED_COMMANDS,
BINARY_CACHE_TTL_MS,
WHICH_PATHS,
pathCache,
isValidExecutable,
resolveWithWhich,
verifyGitBinary,
getVSCodeGitPath,
getWhichCommand,
isAllowedCommand,
resetWhichFallbackWarning: (): void => {
whichFallbackWarningShown = false;
},
};