@vibe-validate/utils 0.17.5-rc.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/LICENSE +21 -0
- package/README.md +260 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/path-helpers.d.ts +85 -0
- package/dist/path-helpers.d.ts.map +1 -0
- package/dist/path-helpers.js +117 -0
- package/dist/path-helpers.js.map +1 -0
- package/dist/safe-exec.d.ts +153 -0
- package/dist/safe-exec.d.ts.map +1 -0
- package/dist/safe-exec.js +282 -0
- package/dist/safe-exec.js.map +1 -0
- package/package.json +53 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Jeff Dutton
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# @vibe-validate/utils
|
|
2
|
+
|
|
3
|
+
Common utilities for vibe-validate packages - the foundational package with NO dependencies on other vibe-validate packages.
|
|
4
|
+
|
|
5
|
+
## Purpose
|
|
6
|
+
|
|
7
|
+
`@vibe-validate/utils` provides generic, non-domain-specific utilities used across multiple vibe-validate packages. It serves as the foundation layer that other packages can depend on without creating circular dependencies.
|
|
8
|
+
|
|
9
|
+
## When to Use
|
|
10
|
+
|
|
11
|
+
Use `@vibe-validate/utils` for:
|
|
12
|
+
|
|
13
|
+
- **Security-critical command execution** (`safeExec*`) - When you need to spawn processes safely without shell injection vulnerabilities
|
|
14
|
+
- **Cross-platform path normalization** - When working with Windows 8.3 short names that cause path mismatches
|
|
15
|
+
- **Generic utilities** - Functionality needed by multiple packages that doesn't belong to a specific domain
|
|
16
|
+
|
|
17
|
+
## When NOT to Use
|
|
18
|
+
|
|
19
|
+
DO NOT use `@vibe-validate/utils` for:
|
|
20
|
+
|
|
21
|
+
- **Domain-specific utilities** - Use the appropriate domain package instead:
|
|
22
|
+
- Git utilities → `@vibe-validate/git`
|
|
23
|
+
- Config utilities → `@vibe-validate/config`
|
|
24
|
+
- Extractor utilities → `@vibe-validate/extractors`
|
|
25
|
+
- Validation utilities → `@vibe-validate/core`
|
|
26
|
+
|
|
27
|
+
- **Test utilities** - Keep test-specific mocks/helpers in each package's `test/helpers/` directory
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# Production dependency
|
|
33
|
+
pnpm add @vibe-validate/utils
|
|
34
|
+
|
|
35
|
+
# Development dependency (for tests)
|
|
36
|
+
pnpm add -D @vibe-validate/utils
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## API Reference
|
|
40
|
+
|
|
41
|
+
### Safe Command Execution
|
|
42
|
+
|
|
43
|
+
Secure command execution using `spawnSync` + `which` pattern. Prevents command injection by:
|
|
44
|
+
- Resolving PATH once using pure Node.js (`which` package)
|
|
45
|
+
- Executing with absolute path and `shell: false` (except Windows-specific cases)
|
|
46
|
+
- No shell interpreter = no command injection risk
|
|
47
|
+
|
|
48
|
+
#### `safeExecSync(command, args?, options?): Buffer | string`
|
|
49
|
+
|
|
50
|
+
Execute a command synchronously and return output (throws on error).
|
|
51
|
+
|
|
52
|
+
**Parameters:**
|
|
53
|
+
- `command` (string) - Command name (e.g., 'git', 'node', 'pnpm')
|
|
54
|
+
- `args` (string[]) - Array of arguments
|
|
55
|
+
- `options` (SafeExecOptions) - Execution options
|
|
56
|
+
|
|
57
|
+
**Returns:** Buffer (default) or string (if `encoding` specified)
|
|
58
|
+
|
|
59
|
+
**Example:**
|
|
60
|
+
```typescript
|
|
61
|
+
import { safeExecSync } from '@vibe-validate/utils';
|
|
62
|
+
|
|
63
|
+
// Get output as Buffer
|
|
64
|
+
const versionBuffer = safeExecSync('node', ['--version']);
|
|
65
|
+
|
|
66
|
+
// Get output as string
|
|
67
|
+
const version = safeExecSync('node', ['--version'], { encoding: 'utf8' });
|
|
68
|
+
|
|
69
|
+
// Custom environment variables
|
|
70
|
+
safeExecSync('git', ['add', '--all'], {
|
|
71
|
+
env: { ...process.env, GIT_INDEX_FILE: tempFile }
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
#### `safeExecResult(command, args?, options?): SafeExecResult`
|
|
76
|
+
|
|
77
|
+
Execute a command and return detailed result (doesn't throw on error).
|
|
78
|
+
|
|
79
|
+
Use this when you need to handle errors programmatically.
|
|
80
|
+
|
|
81
|
+
**Returns:**
|
|
82
|
+
```typescript
|
|
83
|
+
{
|
|
84
|
+
status: number; // Exit code (0 = success)
|
|
85
|
+
stdout: Buffer | string;
|
|
86
|
+
stderr: Buffer | string;
|
|
87
|
+
error?: Error; // If command failed to spawn
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**Example:**
|
|
92
|
+
```typescript
|
|
93
|
+
import { safeExecResult } from '@vibe-validate/utils';
|
|
94
|
+
|
|
95
|
+
const result = safeExecResult('git', ['status']);
|
|
96
|
+
if (result.status === 0) {
|
|
97
|
+
console.log(result.stdout.toString());
|
|
98
|
+
} else {
|
|
99
|
+
console.error(`Failed: ${result.stderr.toString()}`);
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
#### `safeExecFromString(commandString, options?): Buffer | string`
|
|
104
|
+
|
|
105
|
+
Execute a command from a command string (convenience wrapper).
|
|
106
|
+
|
|
107
|
+
**WARNING:** This function parses command strings using simple whitespace splitting. It does NOT handle shell quoting, escaping, or complex command syntax. Use only for simple commands.
|
|
108
|
+
|
|
109
|
+
**Example:**
|
|
110
|
+
```typescript
|
|
111
|
+
import { safeExecFromString } from '@vibe-validate/utils';
|
|
112
|
+
|
|
113
|
+
// ✅ Simple command
|
|
114
|
+
safeExecFromString('git status --short');
|
|
115
|
+
|
|
116
|
+
// ❌ Complex shell features won't work
|
|
117
|
+
// Use safeExecSync() with explicit args array instead
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
#### `isToolAvailable(toolName): boolean`
|
|
121
|
+
|
|
122
|
+
Check if a command-line tool is available.
|
|
123
|
+
|
|
124
|
+
**Example:**
|
|
125
|
+
```typescript
|
|
126
|
+
import { isToolAvailable } from '@vibe-validate/utils';
|
|
127
|
+
|
|
128
|
+
if (isToolAvailable('gh')) {
|
|
129
|
+
console.log('GitHub CLI is installed');
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
#### `getToolVersion(toolName, versionArg?): string | null`
|
|
134
|
+
|
|
135
|
+
Get tool version if available.
|
|
136
|
+
|
|
137
|
+
**Parameters:**
|
|
138
|
+
- `toolName` (string) - Tool name (e.g., 'node', 'pnpm')
|
|
139
|
+
- `versionArg` (string) - Version argument (default: '--version')
|
|
140
|
+
|
|
141
|
+
**Returns:** Version string or null if not available
|
|
142
|
+
|
|
143
|
+
**Example:**
|
|
144
|
+
```typescript
|
|
145
|
+
import { getToolVersion } from '@vibe-validate/utils';
|
|
146
|
+
|
|
147
|
+
const nodeVersion = getToolVersion('node');
|
|
148
|
+
console.log(nodeVersion); // "v20.11.0"
|
|
149
|
+
|
|
150
|
+
const gitVersion = getToolVersion('git', 'version');
|
|
151
|
+
console.log(gitVersion); // "git version 2.39.2"
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
#### `CommandExecutionError`
|
|
155
|
+
|
|
156
|
+
Error thrown when command execution fails (extends Error).
|
|
157
|
+
|
|
158
|
+
**Properties:**
|
|
159
|
+
- `status` (number) - Exit code
|
|
160
|
+
- `stdout` (Buffer | string) - Standard output
|
|
161
|
+
- `stderr` (Buffer | string) - Standard error
|
|
162
|
+
|
|
163
|
+
### Cross-Platform Path Helpers
|
|
164
|
+
|
|
165
|
+
Windows-safe path utilities that handle 8.3 short names (e.g., `RUNNER~1`). These prevent "works on Mac, fails on Windows CI" bugs.
|
|
166
|
+
|
|
167
|
+
#### `normalizedTmpdir(): string`
|
|
168
|
+
|
|
169
|
+
Get normalized temp directory path.
|
|
170
|
+
|
|
171
|
+
On Windows, `tmpdir()` may return 8.3 short names like `C:\Users\RUNNER~1\AppData\Local\Temp`. This function returns the real (long) path.
|
|
172
|
+
|
|
173
|
+
**Why this matters:**
|
|
174
|
+
- Node.js operations create directories with LONG names
|
|
175
|
+
- Tests using SHORT paths from `tmpdir()` will fail `existsSync()` checks
|
|
176
|
+
|
|
177
|
+
**Example:**
|
|
178
|
+
```typescript
|
|
179
|
+
import { normalizedTmpdir } from '@vibe-validate/utils';
|
|
180
|
+
import { join } from 'node:path';
|
|
181
|
+
|
|
182
|
+
// ❌ WRONG - May return short path on Windows
|
|
183
|
+
const testDir = join(tmpdir(), 'test-dir');
|
|
184
|
+
|
|
185
|
+
// ✅ RIGHT - Always returns real path
|
|
186
|
+
const testDir = join(normalizedTmpdir(), 'test-dir');
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
#### `mkdirSyncReal(path, options?): string`
|
|
190
|
+
|
|
191
|
+
Create directory and return normalized path.
|
|
192
|
+
|
|
193
|
+
Combines `mkdirSync` + `realpathSync` to ensure the returned path matches the actual filesystem path (resolves Windows short names).
|
|
194
|
+
|
|
195
|
+
**Example:**
|
|
196
|
+
```typescript
|
|
197
|
+
import { mkdirSyncReal, normalizedTmpdir } from '@vibe-validate/utils';
|
|
198
|
+
import { join } from 'node:path';
|
|
199
|
+
|
|
200
|
+
// ✅ RIGHT - Normalized path guaranteed
|
|
201
|
+
const testDir = mkdirSyncReal(
|
|
202
|
+
join(normalizedTmpdir(), 'test-dir'),
|
|
203
|
+
{ recursive: true }
|
|
204
|
+
);
|
|
205
|
+
// testDir is now: C:\Users\runneradmin\...\test-dir (real path)
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
#### `normalizePath(path): string`
|
|
209
|
+
|
|
210
|
+
Normalize any path (resolve short names on Windows).
|
|
211
|
+
|
|
212
|
+
Utility to normalize paths without creating directories. Useful when you have an existing path that might contain short names.
|
|
213
|
+
|
|
214
|
+
**Example:**
|
|
215
|
+
```typescript
|
|
216
|
+
import { normalizePath } from '@vibe-validate/utils';
|
|
217
|
+
|
|
218
|
+
const shortPath = 'C:\\PROGRA~1\\nodejs';
|
|
219
|
+
const longPath = normalizePath(shortPath);
|
|
220
|
+
// Result: 'C:\\Program Files\\nodejs'
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## Security
|
|
224
|
+
|
|
225
|
+
### Command Injection Prevention
|
|
226
|
+
|
|
227
|
+
All `safeExec*` functions prevent command injection by:
|
|
228
|
+
|
|
229
|
+
1. **No shell interpreter** - Uses `spawnSync` with `shell: false` (except Windows-specific cases)
|
|
230
|
+
2. **Arguments as array** - Never string interpolation
|
|
231
|
+
3. **PATH resolution via `which`** - Resolved before execution, not during
|
|
232
|
+
|
|
233
|
+
This is more secure than `execSync()` which uses shell by default.
|
|
234
|
+
|
|
235
|
+
**Example of safe execution:**
|
|
236
|
+
```typescript
|
|
237
|
+
import { safeExecSync } from '@vibe-validate/utils';
|
|
238
|
+
|
|
239
|
+
// Malicious input is treated as literal argument, not executed
|
|
240
|
+
const maliciousArg = '; rm -rf / #';
|
|
241
|
+
const result = safeExecSync('echo', [maliciousArg], { encoding: 'utf8' });
|
|
242
|
+
// Output: "; rm -rf / #" (literal string, not executed)
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
## Development
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
# Build
|
|
249
|
+
pnpm build
|
|
250
|
+
|
|
251
|
+
# Test
|
|
252
|
+
pnpm test
|
|
253
|
+
|
|
254
|
+
# Watch mode
|
|
255
|
+
pnpm test:watch
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
## License
|
|
259
|
+
|
|
260
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vibe-validate/utils
|
|
3
|
+
*
|
|
4
|
+
* Common utilities for vibe-validate packages.
|
|
5
|
+
* This is the foundational package with NO dependencies on other vibe-validate packages.
|
|
6
|
+
*
|
|
7
|
+
* @package @vibe-validate/utils
|
|
8
|
+
*/
|
|
9
|
+
export { safeExecSync, safeExecFromString, safeExecResult, isToolAvailable, getToolVersion, CommandExecutionError, type SafeExecOptions, type SafeExecResult } from './safe-exec.js';
|
|
10
|
+
export { normalizedTmpdir, mkdirSyncReal, normalizePath } from './path-helpers.js';
|
|
11
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,EACL,YAAY,EACZ,kBAAkB,EAClB,cAAc,EACd,eAAe,EACf,cAAc,EACd,qBAAqB,EACrB,KAAK,eAAe,EACpB,KAAK,cAAc,EACpB,MAAM,gBAAgB,CAAC;AAGxB,OAAO,EACL,gBAAgB,EAChB,aAAa,EACb,aAAa,EACd,MAAM,mBAAmB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vibe-validate/utils
|
|
3
|
+
*
|
|
4
|
+
* Common utilities for vibe-validate packages.
|
|
5
|
+
* This is the foundational package with NO dependencies on other vibe-validate packages.
|
|
6
|
+
*
|
|
7
|
+
* @package @vibe-validate/utils
|
|
8
|
+
*/
|
|
9
|
+
// Safe command execution (security-critical)
|
|
10
|
+
export { safeExecSync, safeExecFromString, safeExecResult, isToolAvailable, getToolVersion, CommandExecutionError } from './safe-exec.js';
|
|
11
|
+
// Cross-platform path helpers (Windows 8.3 short name handling)
|
|
12
|
+
export { normalizedTmpdir, mkdirSyncReal, normalizePath } from './path-helpers.js';
|
|
13
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,6CAA6C;AAC7C,OAAO,EACL,YAAY,EACZ,kBAAkB,EAClB,cAAc,EACd,eAAe,EACf,cAAc,EACd,qBAAqB,EAGtB,MAAM,gBAAgB,CAAC;AAExB,gEAAgE;AAChE,OAAO,EACL,gBAAgB,EAChB,aAAa,EACb,aAAa,EACd,MAAM,mBAAmB,CAAC"}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path Helpers for Cross-Platform Testing
|
|
3
|
+
*
|
|
4
|
+
* Windows-safe path utilities that handle 8.3 short names (e.g., RUNNER~1).
|
|
5
|
+
* These helpers ensure tests work correctly on both Unix and Windows.
|
|
6
|
+
*
|
|
7
|
+
* @package @vibe-validate/utils
|
|
8
|
+
*/
|
|
9
|
+
import { mkdirSync } from 'node:fs';
|
|
10
|
+
/**
|
|
11
|
+
* Get normalized temp directory path
|
|
12
|
+
*
|
|
13
|
+
* On Windows, tmpdir() may return 8.3 short names like:
|
|
14
|
+
* - C:\Users\RUNNER~1\AppData\Local\Temp
|
|
15
|
+
*
|
|
16
|
+
* This function returns the real (long) path:
|
|
17
|
+
* - C:\Users\runneradmin\AppData\Local\Temp
|
|
18
|
+
*
|
|
19
|
+
* **Why this matters:**
|
|
20
|
+
* - Node.js operations create directories with LONG names
|
|
21
|
+
* - Tests using SHORT paths from tmpdir() will fail existsSync() checks
|
|
22
|
+
* - This is a "works on Mac, fails on Windows CI" bug pattern
|
|
23
|
+
*
|
|
24
|
+
* @returns Normalized temp directory path (resolves short names on Windows)
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```typescript
|
|
28
|
+
* // ❌ WRONG - May return short path on Windows
|
|
29
|
+
* const testDir = join(tmpdir(), 'test-dir');
|
|
30
|
+
*
|
|
31
|
+
* // ✅ RIGHT - Always returns real path
|
|
32
|
+
* const testDir = join(normalizedTmpdir(), 'test-dir');
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export declare function normalizedTmpdir(): string;
|
|
36
|
+
/**
|
|
37
|
+
* Create directory and return normalized path
|
|
38
|
+
*
|
|
39
|
+
* Combines mkdirSync + realpathSync to ensure the returned path
|
|
40
|
+
* matches the actual filesystem path (resolves Windows short names).
|
|
41
|
+
*
|
|
42
|
+
* **Why this matters:**
|
|
43
|
+
* - After mkdirSync(), the path might not match what filesystem uses
|
|
44
|
+
* - On Windows, short path input creates long path output
|
|
45
|
+
* - Subsequent existsSync() checks with original path may fail
|
|
46
|
+
*
|
|
47
|
+
* @param path - Directory path to create
|
|
48
|
+
* @param options - Options for mkdirSync (e.g., recursive: true)
|
|
49
|
+
* @returns Real (normalized) path to the created directory
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```typescript
|
|
53
|
+
* // ❌ WRONG - Path mismatch on Windows
|
|
54
|
+
* const testDir = join(tmpdir(), 'test-dir');
|
|
55
|
+
* mkdirSync(testDir, { recursive: true });
|
|
56
|
+
* // testDir might be: C:\Users\RUNNER~1\...\test-dir
|
|
57
|
+
* // But filesystem created: C:\Users\runneradmin\...\test-dir
|
|
58
|
+
*
|
|
59
|
+
* // ✅ RIGHT - Normalized path guaranteed
|
|
60
|
+
* const testDir = mkdirSyncReal(
|
|
61
|
+
* join(tmpdir(), 'test-dir'),
|
|
62
|
+
* { recursive: true }
|
|
63
|
+
* );
|
|
64
|
+
* // testDir is now: C:\Users\runneradmin\...\test-dir (real path)
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
export declare function mkdirSyncReal(path: string, options?: Parameters<typeof mkdirSync>[1]): string;
|
|
68
|
+
/**
|
|
69
|
+
* Normalize any path (resolve short names on Windows)
|
|
70
|
+
*
|
|
71
|
+
* Utility to normalize paths without creating directories.
|
|
72
|
+
* Useful when you have an existing path that might contain short names.
|
|
73
|
+
*
|
|
74
|
+
* @param path - Path to normalize
|
|
75
|
+
* @returns Real (normalized) path, or original if normalization fails
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* ```typescript
|
|
79
|
+
* const shortPath = 'C:\\PROGRA~1\\nodejs';
|
|
80
|
+
* const longPath = normalizePath(shortPath);
|
|
81
|
+
* // Result: 'C:\\Program Files\\nodejs'
|
|
82
|
+
* ```
|
|
83
|
+
*/
|
|
84
|
+
export declare function normalizePath(path: string): string;
|
|
85
|
+
//# sourceMappingURL=path-helpers.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"path-helpers.d.ts","sourceRoot":"","sources":["../src/path-helpers.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,SAAS,EAAgB,MAAM,SAAS,CAAC;AAGlD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,gBAAgB,IAAI,MAAM,CAUzC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC,CAAC,CAAC,GACxC,MAAM,CAYR;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAOlD"}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path Helpers for Cross-Platform Testing
|
|
3
|
+
*
|
|
4
|
+
* Windows-safe path utilities that handle 8.3 short names (e.g., RUNNER~1).
|
|
5
|
+
* These helpers ensure tests work correctly on both Unix and Windows.
|
|
6
|
+
*
|
|
7
|
+
* @package @vibe-validate/utils
|
|
8
|
+
*/
|
|
9
|
+
import { mkdirSync, realpathSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
/**
|
|
12
|
+
* Get normalized temp directory path
|
|
13
|
+
*
|
|
14
|
+
* On Windows, tmpdir() may return 8.3 short names like:
|
|
15
|
+
* - C:\Users\RUNNER~1\AppData\Local\Temp
|
|
16
|
+
*
|
|
17
|
+
* This function returns the real (long) path:
|
|
18
|
+
* - C:\Users\runneradmin\AppData\Local\Temp
|
|
19
|
+
*
|
|
20
|
+
* **Why this matters:**
|
|
21
|
+
* - Node.js operations create directories with LONG names
|
|
22
|
+
* - Tests using SHORT paths from tmpdir() will fail existsSync() checks
|
|
23
|
+
* - This is a "works on Mac, fails on Windows CI" bug pattern
|
|
24
|
+
*
|
|
25
|
+
* @returns Normalized temp directory path (resolves short names on Windows)
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```typescript
|
|
29
|
+
* // ❌ WRONG - May return short path on Windows
|
|
30
|
+
* const testDir = join(tmpdir(), 'test-dir');
|
|
31
|
+
*
|
|
32
|
+
* // ✅ RIGHT - Always returns real path
|
|
33
|
+
* const testDir = join(normalizedTmpdir(), 'test-dir');
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export function normalizedTmpdir() {
|
|
37
|
+
const temp = tmpdir();
|
|
38
|
+
try {
|
|
39
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Safe: temp is from tmpdir() (OS-provided system temp directory), not user input
|
|
40
|
+
return realpathSync(temp);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Fallback: if realpathSync fails, return original
|
|
44
|
+
// (shouldn't happen, but safety first)
|
|
45
|
+
return temp;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Create directory and return normalized path
|
|
50
|
+
*
|
|
51
|
+
* Combines mkdirSync + realpathSync to ensure the returned path
|
|
52
|
+
* matches the actual filesystem path (resolves Windows short names).
|
|
53
|
+
*
|
|
54
|
+
* **Why this matters:**
|
|
55
|
+
* - After mkdirSync(), the path might not match what filesystem uses
|
|
56
|
+
* - On Windows, short path input creates long path output
|
|
57
|
+
* - Subsequent existsSync() checks with original path may fail
|
|
58
|
+
*
|
|
59
|
+
* @param path - Directory path to create
|
|
60
|
+
* @param options - Options for mkdirSync (e.g., recursive: true)
|
|
61
|
+
* @returns Real (normalized) path to the created directory
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* ```typescript
|
|
65
|
+
* // ❌ WRONG - Path mismatch on Windows
|
|
66
|
+
* const testDir = join(tmpdir(), 'test-dir');
|
|
67
|
+
* mkdirSync(testDir, { recursive: true });
|
|
68
|
+
* // testDir might be: C:\Users\RUNNER~1\...\test-dir
|
|
69
|
+
* // But filesystem created: C:\Users\runneradmin\...\test-dir
|
|
70
|
+
*
|
|
71
|
+
* // ✅ RIGHT - Normalized path guaranteed
|
|
72
|
+
* const testDir = mkdirSyncReal(
|
|
73
|
+
* join(tmpdir(), 'test-dir'),
|
|
74
|
+
* { recursive: true }
|
|
75
|
+
* );
|
|
76
|
+
* // testDir is now: C:\Users\runneradmin\...\test-dir (real path)
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
export function mkdirSyncReal(path, options) {
|
|
80
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Safe: path is function parameter from test setup (tmpdir + test name), not user input
|
|
81
|
+
mkdirSync(path, options);
|
|
82
|
+
try {
|
|
83
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Safe: path is function parameter from test setup (tmpdir + test name), not user input
|
|
84
|
+
return realpathSync(path);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// Fallback: if realpathSync fails, return original
|
|
88
|
+
// (might happen if directory creation failed)
|
|
89
|
+
return path;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Normalize any path (resolve short names on Windows)
|
|
94
|
+
*
|
|
95
|
+
* Utility to normalize paths without creating directories.
|
|
96
|
+
* Useful when you have an existing path that might contain short names.
|
|
97
|
+
*
|
|
98
|
+
* @param path - Path to normalize
|
|
99
|
+
* @returns Real (normalized) path, or original if normalization fails
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* ```typescript
|
|
103
|
+
* const shortPath = 'C:\\PROGRA~1\\nodejs';
|
|
104
|
+
* const longPath = normalizePath(shortPath);
|
|
105
|
+
* // Result: 'C:\\Program Files\\nodejs'
|
|
106
|
+
* ```
|
|
107
|
+
*/
|
|
108
|
+
export function normalizePath(path) {
|
|
109
|
+
try {
|
|
110
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Safe: path is function parameter from test setup (tmpdir + test name), not user input
|
|
111
|
+
return realpathSync(path);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return path;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
//# sourceMappingURL=path-helpers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"path-helpers.js","sourceRoot":"","sources":["../src/path-helpers.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAClD,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAEjC;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,MAAM,UAAU,gBAAgB;IAC9B,MAAM,IAAI,GAAG,MAAM,EAAE,CAAC;IACtB,IAAI,CAAC;QACH,sJAAsJ;QACtJ,OAAO,YAAY,CAAC,IAAI,CAAC,CAAC;IAC5B,CAAC;IAAC,MAAM,CAAC;QACP,mDAAmD;QACnD,uCAAuC;QACvC,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,MAAM,UAAU,aAAa,CAC3B,IAAY,EACZ,OAAyC;IAEzC,4JAA4J;IAC5J,SAAS,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;IAEzB,IAAI,CAAC;QACH,4JAA4J;QAC5J,OAAO,YAAY,CAAC,IAAI,CAAC,CAAC;IAC5B,CAAC;IAAC,MAAM,CAAC;QACP,mDAAmD;QACnD,8CAA8C;QAC9C,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,aAAa,CAAC,IAAY;IACxC,IAAI,CAAC;QACH,4JAA4J;QAC5J,OAAO,YAAY,CAAC,IAAI,CAAC,CAAC;IAC5B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Options for safe command execution
|
|
3
|
+
*/
|
|
4
|
+
export interface SafeExecOptions {
|
|
5
|
+
/** Character encoding for output (default: undefined = Buffer) */
|
|
6
|
+
encoding?: BufferEncoding;
|
|
7
|
+
/** Standard I/O configuration */
|
|
8
|
+
stdio?: 'pipe' | 'ignore' | Array<'pipe' | 'ignore' | 'inherit'>;
|
|
9
|
+
/** Environment variables (merged with process.env if not fully specified) */
|
|
10
|
+
env?: NodeJS.ProcessEnv;
|
|
11
|
+
/** Working directory */
|
|
12
|
+
cwd?: string;
|
|
13
|
+
/** Maximum output buffer size in bytes */
|
|
14
|
+
maxBuffer?: number;
|
|
15
|
+
/** Timeout in milliseconds */
|
|
16
|
+
timeout?: number;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Result of a safe command execution
|
|
20
|
+
*/
|
|
21
|
+
export interface SafeExecResult {
|
|
22
|
+
/** Exit code (0 = success) */
|
|
23
|
+
status: number;
|
|
24
|
+
/** Standard output */
|
|
25
|
+
stdout: Buffer | string;
|
|
26
|
+
/** Standard error */
|
|
27
|
+
stderr: Buffer | string;
|
|
28
|
+
/** Error object if command failed to spawn */
|
|
29
|
+
error?: Error;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Error thrown when command execution fails
|
|
33
|
+
*/
|
|
34
|
+
export declare class CommandExecutionError extends Error {
|
|
35
|
+
readonly status: number;
|
|
36
|
+
readonly stdout: Buffer | string;
|
|
37
|
+
readonly stderr: Buffer | string;
|
|
38
|
+
constructor(message: string, status: number, stdout: Buffer | string, stderr: Buffer | string);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Safe command execution using spawnSync + which pattern
|
|
42
|
+
*
|
|
43
|
+
* More secure than execSync:
|
|
44
|
+
* - Resolves PATH once using pure Node.js (which package)
|
|
45
|
+
* - Executes with absolute path and shell: false
|
|
46
|
+
* - No shell interpreter = no command injection risk
|
|
47
|
+
* - Supports custom env vars (e.g., GIT_INDEX_FILE)
|
|
48
|
+
*
|
|
49
|
+
* @param command - Command name (e.g., 'git', 'gitleaks', 'node')
|
|
50
|
+
* @param args - Array of arguments
|
|
51
|
+
* @param options - Execution options
|
|
52
|
+
* @returns Buffer or string output
|
|
53
|
+
* @throws Error if command not found or execution fails
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* // Tool detection
|
|
57
|
+
* safeExecSync('gitleaks', ['--version'], { stdio: 'ignore' });
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* // Git with custom env
|
|
61
|
+
* safeExecSync('git', ['add', '--all'], {
|
|
62
|
+
* env: { ...process.env, GIT_INDEX_FILE: tempFile }
|
|
63
|
+
* });
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* // Get output as string
|
|
67
|
+
* const version = safeExecSync('node', ['--version'], { encoding: 'utf8' });
|
|
68
|
+
*/
|
|
69
|
+
export declare function safeExecSync(command: string, args?: string[], options?: SafeExecOptions): Buffer | string;
|
|
70
|
+
/**
|
|
71
|
+
* Safe command execution that returns detailed result (doesn't throw)
|
|
72
|
+
*
|
|
73
|
+
* Use this when you need to handle errors programmatically
|
|
74
|
+
* instead of catching exceptions.
|
|
75
|
+
*
|
|
76
|
+
* @param command - Command name (e.g., 'git', 'node')
|
|
77
|
+
* @param args - Array of arguments
|
|
78
|
+
* @param options - Execution options
|
|
79
|
+
* @returns Detailed execution result
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* const result = safeExecResult('git', ['status']);
|
|
83
|
+
* if (result.status === 0) {
|
|
84
|
+
* console.log(result.stdout.toString());
|
|
85
|
+
* } else {
|
|
86
|
+
* console.error(`Failed: ${result.stderr.toString()}`);
|
|
87
|
+
* }
|
|
88
|
+
*/
|
|
89
|
+
export declare function safeExecResult(command: string, args?: string[], options?: SafeExecOptions): SafeExecResult;
|
|
90
|
+
/**
|
|
91
|
+
* Check if a command-line tool is available
|
|
92
|
+
*
|
|
93
|
+
* @param toolName - Name of tool to check (e.g., 'gh', 'gitleaks', 'node')
|
|
94
|
+
* @returns true if tool is available, false otherwise
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* if (isToolAvailable('gh')) {
|
|
98
|
+
* console.log('GitHub CLI is installed');
|
|
99
|
+
* }
|
|
100
|
+
*/
|
|
101
|
+
export declare function isToolAvailable(toolName: string): boolean;
|
|
102
|
+
/**
|
|
103
|
+
* Get tool version if available
|
|
104
|
+
*
|
|
105
|
+
* @param toolName - Name of tool (e.g., 'node', 'pnpm')
|
|
106
|
+
* @param versionArg - Argument to get version (default: '--version')
|
|
107
|
+
* @returns Version string or null if not available
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* const nodeVersion = getToolVersion('node');
|
|
111
|
+
* console.log(nodeVersion); // "v20.11.0"
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* const gitVersion = getToolVersion('git', 'version');
|
|
115
|
+
* console.log(gitVersion); // "git version 2.39.2"
|
|
116
|
+
*/
|
|
117
|
+
export declare function getToolVersion(toolName: string, versionArg?: string): string | null;
|
|
118
|
+
/**
|
|
119
|
+
* Execute a command from a command string (convenience wrapper)
|
|
120
|
+
*
|
|
121
|
+
* **WARNING**: This function parses command strings using simple whitespace splitting.
|
|
122
|
+
* It does NOT handle shell quoting, escaping, or complex command syntax.
|
|
123
|
+
* Use only for simple commands like "gitleaks protect --staged --verbose".
|
|
124
|
+
*
|
|
125
|
+
* For complex commands with quoted arguments, pipes, or shell features,
|
|
126
|
+
* use `safeExecSync()` directly with an explicit args array.
|
|
127
|
+
*
|
|
128
|
+
* @param commandString - Command string to execute (e.g., "git status --short")
|
|
129
|
+
* @param options - Execution options
|
|
130
|
+
* @returns Command output (Buffer or string depending on encoding option)
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ```typescript
|
|
134
|
+
* // Simple command with flags
|
|
135
|
+
* safeExecFromString('gitleaks protect --staged --verbose');
|
|
136
|
+
*
|
|
137
|
+
* // Multiple arguments
|
|
138
|
+
* safeExecFromString('gh pr view --json number,title');
|
|
139
|
+
* ```
|
|
140
|
+
*
|
|
141
|
+
* @example
|
|
142
|
+
* ```typescript
|
|
143
|
+
* // ❌ DON'T: Complex shell features won't work
|
|
144
|
+
* safeExecFromString('cat file.txt | grep "error"'); // Pipe ignored
|
|
145
|
+
* safeExecFromString('echo "hello world"'); // Quotes not parsed correctly
|
|
146
|
+
*
|
|
147
|
+
* // ✅ DO: Use safeExecSync directly for these cases
|
|
148
|
+
* safeExecSync('grep', ['error', 'file.txt']);
|
|
149
|
+
* safeExecSync('echo', ['hello world']);
|
|
150
|
+
* ```
|
|
151
|
+
*/
|
|
152
|
+
export declare function safeExecFromString(commandString: string, options?: SafeExecOptions): Buffer | string;
|
|
153
|
+
//# sourceMappingURL=safe-exec.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"safe-exec.d.ts","sourceRoot":"","sources":["../src/safe-exec.ts"],"names":[],"mappings":"AAIA;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,kEAAkE;IAClE,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B,iCAAiC;IACjC,KAAK,CAAC,EAAE,MAAM,GAAG,QAAQ,GAAG,KAAK,CAAC,MAAM,GAAG,QAAQ,GAAG,SAAS,CAAC,CAAC;IACjE,6EAA6E;IAC7E,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,wBAAwB;IACxB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,0CAA0C;IAC1C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,8BAA8B;IAC9B,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,8BAA8B;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,sBAAsB;IACtB,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,qBAAqB;IACrB,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,8CAA8C;IAC9C,KAAK,CAAC,EAAE,KAAK,CAAC;CACf;AAED;;GAEG;AACH,qBAAa,qBAAsB,SAAQ,KAAK;IAC9C,SAAgB,MAAM,EAAE,MAAM,CAAC;IAC/B,SAAgB,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC;IACxC,SAAgB,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC;gBAGtC,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,GAAG,MAAM,EACvB,MAAM,EAAE,MAAM,GAAG,MAAM;CAQ1B;AAwED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,YAAY,CAC1B,OAAO,EAAE,MAAM,EACf,IAAI,GAAE,MAAM,EAAO,EACnB,OAAO,GAAE,eAAoB,GAC5B,MAAM,GAAG,MAAM,CAsCjB;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,MAAM,EACf,IAAI,GAAE,MAAM,EAAO,EACnB,OAAO,GAAE,eAAoB,GAC5B,cAAc,CAoChB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAOzD;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,MAAM,EAChB,UAAU,GAAE,MAAoB,GAC/B,MAAM,GAAG,IAAI,CAUf;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAgB,kBAAkB,CAChC,aAAa,EAAE,MAAM,EACrB,OAAO,GAAE,eAAoB,GAC5B,MAAM,GAAG,MAAM,CAMjB"}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import which from 'which';
|
|
3
|
+
/**
|
|
4
|
+
* Error thrown when command execution fails
|
|
5
|
+
*/
|
|
6
|
+
export class CommandExecutionError extends Error {
|
|
7
|
+
status;
|
|
8
|
+
stdout;
|
|
9
|
+
stderr;
|
|
10
|
+
constructor(message, status, stdout, stderr) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'CommandExecutionError';
|
|
13
|
+
this.status = status;
|
|
14
|
+
this.stdout = stdout;
|
|
15
|
+
this.stderr = stderr;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Determine if shell should be used for command execution on Windows
|
|
20
|
+
*
|
|
21
|
+
* ## Security Context
|
|
22
|
+
*
|
|
23
|
+
* This package's primary security model is `shell: false` to prevent command injection.
|
|
24
|
+
* However, Windows requires `shell: true` for specific cases where executable resolution
|
|
25
|
+
* fails without a shell interpreter.
|
|
26
|
+
*
|
|
27
|
+
* ## Why shell:true is Required on Windows
|
|
28
|
+
*
|
|
29
|
+
* ### 1. Node.js Executable (CONFIRMED NECESSARY via commit a9902116)
|
|
30
|
+
*
|
|
31
|
+
* **Problem:** Windows CI fails with `spawnSync node.EXE ENOENT` even when using the
|
|
32
|
+
* absolute path returned by `which.sync()`.
|
|
33
|
+
*
|
|
34
|
+
* **What was tried:**
|
|
35
|
+
* - ❌ Using `which.sync('node')` with `shell: false` → ENOENT error
|
|
36
|
+
* - ❌ Using `process.execPath` directly with `shell: false` → Same error
|
|
37
|
+
* - ✅ Using `shell: true` with command name → Works correctly
|
|
38
|
+
*
|
|
39
|
+
* **Root Cause:** Windows executable resolution differs from Unix. The cmd.exe shell
|
|
40
|
+
* is needed to properly resolve and spawn node.exe, even with an absolute path.
|
|
41
|
+
*
|
|
42
|
+
* ### 2. Windows Shell Scripts (.cmd/.bat/.ps1)
|
|
43
|
+
*
|
|
44
|
+
* These require a shell interpreter by design (not executable binaries).
|
|
45
|
+
*
|
|
46
|
+
* ## Why This Is Still Secure
|
|
47
|
+
*
|
|
48
|
+
* Despite using `shell: true`, command injection is prevented through multiple layers:
|
|
49
|
+
*
|
|
50
|
+
* 1. **Command Name Validation:** Only 'node' and known shell script extensions trigger shell mode
|
|
51
|
+
* 2. **Path Validation:** Command paths are resolved and validated via `which.sync()` before execution
|
|
52
|
+
* 3. **Array-Based Arguments:** Arguments are passed as an array (not a string), preventing injection
|
|
53
|
+
* 4. **Controlled Environment:** All commands come from trusted configuration, not user input
|
|
54
|
+
* 5. **No String Interpolation:** We never concatenate user input into command strings
|
|
55
|
+
*
|
|
56
|
+
* ## Compensating Controls
|
|
57
|
+
*
|
|
58
|
+
* - Arguments are validated to not contain null bytes (injection vector)
|
|
59
|
+
* - Command names come from trusted sources (vibe-validate config files)
|
|
60
|
+
* - Shell is NEVER used for arbitrary commands on Windows
|
|
61
|
+
* - Comprehensive security tests in `test/safe-exec.test.ts` (29 test cases)
|
|
62
|
+
*
|
|
63
|
+
* ## References
|
|
64
|
+
*
|
|
65
|
+
* - Investigation: commits d5fb75c4, 3ea731f6, afb360ba, a9902116
|
|
66
|
+
* - Security tests: `packages/utils/test/safe-exec.test.ts` (lines 89-102, 376-389)
|
|
67
|
+
* - Related issue: PR #83 (Windows CI fixes)
|
|
68
|
+
*
|
|
69
|
+
* @param command - Command name (e.g., 'node', 'pnpm')
|
|
70
|
+
* @param commandPath - Resolved absolute path to command
|
|
71
|
+
* @returns true if shell should be used, false otherwise
|
|
72
|
+
*/
|
|
73
|
+
function shouldUseShell(command, commandPath) {
|
|
74
|
+
if (process.platform !== 'win32') {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
// Node command requires shell on Windows (see comprehensive security explanation above)
|
|
78
|
+
if (command === 'node') {
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
// Windows shell scripts require shell by design (case-insensitive check)
|
|
82
|
+
const lowerPath = commandPath.toLowerCase();
|
|
83
|
+
return lowerPath.endsWith('.cmd') || lowerPath.endsWith('.bat') || lowerPath.endsWith('.ps1');
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Safe command execution using spawnSync + which pattern
|
|
87
|
+
*
|
|
88
|
+
* More secure than execSync:
|
|
89
|
+
* - Resolves PATH once using pure Node.js (which package)
|
|
90
|
+
* - Executes with absolute path and shell: false
|
|
91
|
+
* - No shell interpreter = no command injection risk
|
|
92
|
+
* - Supports custom env vars (e.g., GIT_INDEX_FILE)
|
|
93
|
+
*
|
|
94
|
+
* @param command - Command name (e.g., 'git', 'gitleaks', 'node')
|
|
95
|
+
* @param args - Array of arguments
|
|
96
|
+
* @param options - Execution options
|
|
97
|
+
* @returns Buffer or string output
|
|
98
|
+
* @throws Error if command not found or execution fails
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* // Tool detection
|
|
102
|
+
* safeExecSync('gitleaks', ['--version'], { stdio: 'ignore' });
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* // Git with custom env
|
|
106
|
+
* safeExecSync('git', ['add', '--all'], {
|
|
107
|
+
* env: { ...process.env, GIT_INDEX_FILE: tempFile }
|
|
108
|
+
* });
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* // Get output as string
|
|
112
|
+
* const version = safeExecSync('node', ['--version'], { encoding: 'utf8' });
|
|
113
|
+
*/
|
|
114
|
+
export function safeExecSync(command, args = [], options = {}) {
|
|
115
|
+
// Resolve command path using which (pure Node.js, no shell)
|
|
116
|
+
const commandPath = which.sync(command);
|
|
117
|
+
// Determine if shell is needed (Windows-specific logic)
|
|
118
|
+
const useShell = shouldUseShell(command, commandPath);
|
|
119
|
+
const spawnOptions = {
|
|
120
|
+
shell: useShell, // shell:true on Windows for node and shell scripts, shell:false otherwise for security
|
|
121
|
+
stdio: options.stdio ?? 'pipe',
|
|
122
|
+
env: options.env,
|
|
123
|
+
cwd: options.cwd,
|
|
124
|
+
maxBuffer: options.maxBuffer,
|
|
125
|
+
timeout: options.timeout,
|
|
126
|
+
encoding: options.encoding,
|
|
127
|
+
};
|
|
128
|
+
// Execute with absolute path (or command name if using shell on Windows)
|
|
129
|
+
// When shell:true, use command name so shell can resolve it properly
|
|
130
|
+
const execCommand = useShell ? command : commandPath;
|
|
131
|
+
const result = spawnSync(execCommand, args, spawnOptions);
|
|
132
|
+
// Check for spawn errors
|
|
133
|
+
if (result.error) {
|
|
134
|
+
throw result.error;
|
|
135
|
+
}
|
|
136
|
+
// Check exit code
|
|
137
|
+
if (result.status !== 0) {
|
|
138
|
+
throw new CommandExecutionError(`Command failed with exit code ${result.status ?? 'unknown'}: ${command} ${args.join(' ')}`, result.status ?? -1, result.stdout, result.stderr);
|
|
139
|
+
}
|
|
140
|
+
return result.stdout;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Safe command execution that returns detailed result (doesn't throw)
|
|
144
|
+
*
|
|
145
|
+
* Use this when you need to handle errors programmatically
|
|
146
|
+
* instead of catching exceptions.
|
|
147
|
+
*
|
|
148
|
+
* @param command - Command name (e.g., 'git', 'node')
|
|
149
|
+
* @param args - Array of arguments
|
|
150
|
+
* @param options - Execution options
|
|
151
|
+
* @returns Detailed execution result
|
|
152
|
+
*
|
|
153
|
+
* @example
|
|
154
|
+
* const result = safeExecResult('git', ['status']);
|
|
155
|
+
* if (result.status === 0) {
|
|
156
|
+
* console.log(result.stdout.toString());
|
|
157
|
+
* } else {
|
|
158
|
+
* console.error(`Failed: ${result.stderr.toString()}`);
|
|
159
|
+
* }
|
|
160
|
+
*/
|
|
161
|
+
export function safeExecResult(command, args = [], options = {}) {
|
|
162
|
+
try {
|
|
163
|
+
const commandPath = which.sync(command);
|
|
164
|
+
// Determine if shell is needed (Windows-specific logic)
|
|
165
|
+
const useShell = shouldUseShell(command, commandPath);
|
|
166
|
+
const spawnOptions = {
|
|
167
|
+
shell: useShell,
|
|
168
|
+
stdio: options.stdio ?? 'pipe',
|
|
169
|
+
env: options.env,
|
|
170
|
+
cwd: options.cwd,
|
|
171
|
+
maxBuffer: options.maxBuffer,
|
|
172
|
+
timeout: options.timeout,
|
|
173
|
+
encoding: options.encoding,
|
|
174
|
+
};
|
|
175
|
+
// When shell:true, use command name so shell can resolve it properly
|
|
176
|
+
const execCommand = useShell ? command : commandPath;
|
|
177
|
+
const result = spawnSync(execCommand, args, spawnOptions);
|
|
178
|
+
return {
|
|
179
|
+
status: result.status ?? -1,
|
|
180
|
+
stdout: result.stdout ?? Buffer.from(''),
|
|
181
|
+
stderr: result.stderr ?? Buffer.from(''),
|
|
182
|
+
error: result.error,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
// which.sync throws if command not found
|
|
187
|
+
return {
|
|
188
|
+
status: -1,
|
|
189
|
+
stdout: Buffer.from(''),
|
|
190
|
+
stderr: Buffer.from(''),
|
|
191
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Check if a command-line tool is available
|
|
197
|
+
*
|
|
198
|
+
* @param toolName - Name of tool to check (e.g., 'gh', 'gitleaks', 'node')
|
|
199
|
+
* @returns true if tool is available, false otherwise
|
|
200
|
+
*
|
|
201
|
+
* @example
|
|
202
|
+
* if (isToolAvailable('gh')) {
|
|
203
|
+
* console.log('GitHub CLI is installed');
|
|
204
|
+
* }
|
|
205
|
+
*/
|
|
206
|
+
export function isToolAvailable(toolName) {
|
|
207
|
+
try {
|
|
208
|
+
safeExecSync(toolName, ['--version'], { stdio: 'ignore' });
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Get tool version if available
|
|
217
|
+
*
|
|
218
|
+
* @param toolName - Name of tool (e.g., 'node', 'pnpm')
|
|
219
|
+
* @param versionArg - Argument to get version (default: '--version')
|
|
220
|
+
* @returns Version string or null if not available
|
|
221
|
+
*
|
|
222
|
+
* @example
|
|
223
|
+
* const nodeVersion = getToolVersion('node');
|
|
224
|
+
* console.log(nodeVersion); // "v20.11.0"
|
|
225
|
+
*
|
|
226
|
+
* @example
|
|
227
|
+
* const gitVersion = getToolVersion('git', 'version');
|
|
228
|
+
* console.log(gitVersion); // "git version 2.39.2"
|
|
229
|
+
*/
|
|
230
|
+
export function getToolVersion(toolName, versionArg = '--version') {
|
|
231
|
+
try {
|
|
232
|
+
const version = safeExecSync(toolName, [versionArg], {
|
|
233
|
+
encoding: 'utf8',
|
|
234
|
+
stdio: 'pipe',
|
|
235
|
+
});
|
|
236
|
+
return version.trim();
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Execute a command from a command string (convenience wrapper)
|
|
244
|
+
*
|
|
245
|
+
* **WARNING**: This function parses command strings using simple whitespace splitting.
|
|
246
|
+
* It does NOT handle shell quoting, escaping, or complex command syntax.
|
|
247
|
+
* Use only for simple commands like "gitleaks protect --staged --verbose".
|
|
248
|
+
*
|
|
249
|
+
* For complex commands with quoted arguments, pipes, or shell features,
|
|
250
|
+
* use `safeExecSync()` directly with an explicit args array.
|
|
251
|
+
*
|
|
252
|
+
* @param commandString - Command string to execute (e.g., "git status --short")
|
|
253
|
+
* @param options - Execution options
|
|
254
|
+
* @returns Command output (Buffer or string depending on encoding option)
|
|
255
|
+
*
|
|
256
|
+
* @example
|
|
257
|
+
* ```typescript
|
|
258
|
+
* // Simple command with flags
|
|
259
|
+
* safeExecFromString('gitleaks protect --staged --verbose');
|
|
260
|
+
*
|
|
261
|
+
* // Multiple arguments
|
|
262
|
+
* safeExecFromString('gh pr view --json number,title');
|
|
263
|
+
* ```
|
|
264
|
+
*
|
|
265
|
+
* @example
|
|
266
|
+
* ```typescript
|
|
267
|
+
* // ❌ DON'T: Complex shell features won't work
|
|
268
|
+
* safeExecFromString('cat file.txt | grep "error"'); // Pipe ignored
|
|
269
|
+
* safeExecFromString('echo "hello world"'); // Quotes not parsed correctly
|
|
270
|
+
*
|
|
271
|
+
* // ✅ DO: Use safeExecSync directly for these cases
|
|
272
|
+
* safeExecSync('grep', ['error', 'file.txt']);
|
|
273
|
+
* safeExecSync('echo', ['hello world']);
|
|
274
|
+
* ```
|
|
275
|
+
*/
|
|
276
|
+
export function safeExecFromString(commandString, options = {}) {
|
|
277
|
+
const parts = commandString.trim().split(/\s+/);
|
|
278
|
+
const command = parts[0];
|
|
279
|
+
const args = parts.slice(1);
|
|
280
|
+
return safeExecSync(command, args, options);
|
|
281
|
+
}
|
|
282
|
+
//# sourceMappingURL=safe-exec.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"safe-exec.js","sourceRoot":"","sources":["../src/safe-exec.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vibe-validate/utils",
|
|
3
|
+
"version": "0.17.5-rc.1",
|
|
4
|
+
"description": "Common utilities for vibe-validate packages (command execution, path normalization)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"keywords": [
|
|
19
|
+
"utilities",
|
|
20
|
+
"command-execution",
|
|
21
|
+
"path-helpers",
|
|
22
|
+
"cross-platform",
|
|
23
|
+
"windows"
|
|
24
|
+
],
|
|
25
|
+
"author": "Jeff Dutton",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/jdutton/vibe-validate.git",
|
|
30
|
+
"directory": "packages/utils"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=20.0.0"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"which": "^5.0.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "^22.19.2",
|
|
43
|
+
"@types/which": "^3.0.4",
|
|
44
|
+
"typescript": "^5.9.3",
|
|
45
|
+
"vitest": "^2.1.9"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"build": "tsc",
|
|
49
|
+
"clean": "rm -rf dist",
|
|
50
|
+
"test": "vitest run",
|
|
51
|
+
"test:watch": "vitest"
|
|
52
|
+
}
|
|
53
|
+
}
|