bmad-cybersec 2.0.0
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/README.md +483 -0
- package/cli.js +77 -0
- package/commands/install.js +301 -0
- package/commands/update.js +417 -0
- package/commands/version.js +18 -0
- package/index.js +2 -0
- package/lib/config.js +21 -0
- package/lib/downloader.js +297 -0
- package/lib/extractor.js +353 -0
- package/lib/git-clone.js +207 -0
- package/lib/logger.js +34 -0
- package/lib/package-merger.js +480 -0
- package/lib/url-validator.js +109 -0
- package/lib/utils.js +44 -0
- package/package.json +55 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
import { existsSync, promises as fs } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import inquirer from 'inquirer';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { logger } from './logger.js';
|
|
6
|
+
|
|
7
|
+
// Security: Keys that could enable prototype pollution attacks
|
|
8
|
+
const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
9
|
+
|
|
10
|
+
// Security: Pattern to detect shell metacharacters in scripts (for script injection prevention)
|
|
11
|
+
const SHELL_METACHAR_PATTERN = /[`$|;&<>(){}[\]\n\r\\]/;
|
|
12
|
+
|
|
13
|
+
// Security: Pattern to detect path traversal in package names
|
|
14
|
+
const PATH_TRAVERSAL_PATTERN = /\.\.|^\/|^\\/;
|
|
15
|
+
|
|
16
|
+
// Security: Pattern to detect typosquatting (common package name variations)
|
|
17
|
+
const TYPOSQUATTING_PATTERNS = [
|
|
18
|
+
/^(@.*\/)?lodash[^a-z]/, // lodash-es is fine, lodash. is suspicious
|
|
19
|
+
/^(@.*\/)?react[^a-z-](?!native|dom|router)/i, // react with unusual suffix
|
|
20
|
+
/^(@.*\/)?express[^a-z-]/i, // express with unusual suffix
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Sanitizes an object by removing dangerous prototype pollution keys
|
|
25
|
+
* @param {Object} obj - Object to sanitize
|
|
26
|
+
* @returns {Object} Sanitized object without dangerous keys
|
|
27
|
+
*/
|
|
28
|
+
function sanitizeObject(obj) {
|
|
29
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
30
|
+
return obj;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const sanitized = {};
|
|
34
|
+
// Use Object.getOwnPropertyNames to catch ALL own properties including __proto__
|
|
35
|
+
// JSON.parse can create objects with __proto__ as an own property
|
|
36
|
+
for (const key of Object.getOwnPropertyNames(obj)) {
|
|
37
|
+
// Skip dangerous keys that could enable prototype pollution
|
|
38
|
+
if (DANGEROUS_KEYS.has(key)) {
|
|
39
|
+
logger.warn(`Blocked dangerous key "${key}" in package.json (prototype pollution prevention)`);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
sanitized[key] = obj[key];
|
|
43
|
+
}
|
|
44
|
+
return sanitized;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Validates a package name for path traversal attacks
|
|
49
|
+
* @param {string} name - Package name to validate
|
|
50
|
+
* @returns {{ valid: boolean, error?: string }}
|
|
51
|
+
*/
|
|
52
|
+
function validatePackageName(name) {
|
|
53
|
+
if (!name || typeof name !== 'string') {
|
|
54
|
+
return { valid: false, error: 'Package name must be a non-empty string' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Check for path traversal patterns
|
|
58
|
+
if (PATH_TRAVERSAL_PATTERN.test(name)) {
|
|
59
|
+
return {
|
|
60
|
+
valid: false,
|
|
61
|
+
error: `Package name "${name}" contains path traversal characters`
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Check for absolute paths (Windows style)
|
|
66
|
+
if (/^[a-zA-Z]:/.test(name)) {
|
|
67
|
+
return {
|
|
68
|
+
valid: false,
|
|
69
|
+
error: `Package name "${name}" appears to be an absolute Windows path`
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Check for null bytes (could be used for injection)
|
|
74
|
+
if (name.includes('\0')) {
|
|
75
|
+
return {
|
|
76
|
+
valid: false,
|
|
77
|
+
error: `Package name "${name}" contains null bytes`
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { valid: true };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Validates dependency names for potential typosquatting
|
|
86
|
+
* @param {Object} dependencies - Dependencies object
|
|
87
|
+
* @returns {string[]} Array of suspicious package names
|
|
88
|
+
*/
|
|
89
|
+
function detectSuspiciousDependencies(dependencies) {
|
|
90
|
+
if (!dependencies || typeof dependencies !== 'object') {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const suspicious = [];
|
|
95
|
+
for (const name of Object.keys(dependencies)) {
|
|
96
|
+
// Skip dangerous keys
|
|
97
|
+
if (DANGEROUS_KEYS.has(name)) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Check for typosquatting patterns
|
|
102
|
+
for (const pattern of TYPOSQUATTING_PATTERNS) {
|
|
103
|
+
if (pattern.test(name)) {
|
|
104
|
+
suspicious.push(name);
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return suspicious;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Validates scripts for potential shell injection
|
|
114
|
+
* @param {Object} scripts - Scripts object from package.json
|
|
115
|
+
* @returns {string[]} Array of script names with suspicious content
|
|
116
|
+
*/
|
|
117
|
+
function detectSuspiciousScripts(scripts) {
|
|
118
|
+
if (!scripts || typeof scripts !== 'object') {
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const suspicious = [];
|
|
123
|
+
for (const [name, command] of Object.entries(scripts)) {
|
|
124
|
+
// Skip dangerous keys
|
|
125
|
+
if (DANGEROUS_KEYS.has(name)) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Check for shell metacharacters that could enable injection
|
|
130
|
+
if (typeof command === 'string' && SHELL_METACHAR_PATTERN.test(command)) {
|
|
131
|
+
// Allow common safe patterns
|
|
132
|
+
const safePatterns = [
|
|
133
|
+
/^node\s+[\w./-]+\.js$/, // Simple node commands
|
|
134
|
+
/^npm\s+(run|test|start|build)/, // npm commands
|
|
135
|
+
/^tsc(\s|$)/, // TypeScript compiler
|
|
136
|
+
/^vitest(\s|$)/, // Vitest
|
|
137
|
+
/^jest(\s|$)/, // Jest
|
|
138
|
+
/^eslint(\s|$)/, // ESLint
|
|
139
|
+
/&&/, // Command chaining is common
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
// If it contains metacharacters but doesn't match safe patterns, flag it
|
|
143
|
+
const isSafe = safePatterns.some(p => p.test(command));
|
|
144
|
+
if (!isSafe && /[`$|;&<>(){}[\]]/.test(command)) {
|
|
145
|
+
suspicious.push(name);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return suspicious;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Detects path traversal attempts in dependency names
|
|
154
|
+
* @param {Object} dependencies - Dependencies object
|
|
155
|
+
* @returns {string[]} Array of package names with path traversal
|
|
156
|
+
*/
|
|
157
|
+
function detectPathTraversalInDependencies(dependencies) {
|
|
158
|
+
if (!dependencies || typeof dependencies !== 'object') {
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const malicious = [];
|
|
163
|
+
for (const name of Object.keys(dependencies)) {
|
|
164
|
+
// Skip dangerous keys
|
|
165
|
+
if (DANGEROUS_KEYS.has(name)) {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const validation = validatePackageName(name);
|
|
170
|
+
if (!validation.valid) {
|
|
171
|
+
malicious.push(name);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return malicious;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// BMAD scripts to add (with bmad: prefix)
|
|
178
|
+
const BMAD_SCRIPTS = {
|
|
179
|
+
'bmad:modules': 'node src/utility/tools/module-selector/index.js',
|
|
180
|
+
'bmad:security': 'node src/utility/tools/security-config/index.js',
|
|
181
|
+
'bmad:llm': 'node src/utility/tools/llm-setup/index.js',
|
|
182
|
+
'bmad:health': 'node src/utility/tools/health-check/index.js',
|
|
183
|
+
'bmad:setup': 'node src/utility/tools/setup-wizard/index.js'
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// BMAD dependencies to add
|
|
187
|
+
const BMAD_DEPENDENCIES = {
|
|
188
|
+
'chalk': '^5.3.0',
|
|
189
|
+
'inquirer': '^9.2.0',
|
|
190
|
+
'zod': '^3.22.0',
|
|
191
|
+
'commander': '^12.0.0',
|
|
192
|
+
'ora': '^8.0.0'
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const BMAD_DEV_DEPENDENCIES = {
|
|
196
|
+
'typescript': '^5.3.0',
|
|
197
|
+
'@types/node': '^20.0.0',
|
|
198
|
+
'vitest': '^1.0.0'
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Merges BMAD framework dependencies and scripts into existing package.json
|
|
203
|
+
* @description Creates a new package.json if none exists, or merges BMAD-specific
|
|
204
|
+
* scripts (prefixed with 'bmad:'), dependencies, and devDependencies into an existing one.
|
|
205
|
+
* Creates a backup before modifying existing files.
|
|
206
|
+
* @param {string} targetDir - Target directory containing or to contain package.json
|
|
207
|
+
* @param {Object} [options={}] - Merge options
|
|
208
|
+
* @param {boolean} [options.yes=false] - Skip confirmation prompts and apply changes automatically
|
|
209
|
+
* @param {boolean} [options.dryRun=false] - Preview changes without writing to disk
|
|
210
|
+
* @returns {Promise<Object>} Merge result object
|
|
211
|
+
* @returns {boolean} [returns.success] - True if merge completed successfully
|
|
212
|
+
* @returns {boolean} [returns.cancelled] - True if user cancelled the operation
|
|
213
|
+
* @returns {boolean} [returns.created] - True if a new package.json was created
|
|
214
|
+
* @returns {boolean} [returns.noChanges] - True if no changes were needed
|
|
215
|
+
* @returns {boolean} [returns.dryRun] - True if this was a dry run
|
|
216
|
+
* @returns {Object} [returns.diff] - Object containing added and modified entries
|
|
217
|
+
* @returns {string} [returns.backupPath] - Path to backup file (if existing file was modified)
|
|
218
|
+
* @throws {Error} If file operations fail
|
|
219
|
+
* @example
|
|
220
|
+
* // Interactive merge
|
|
221
|
+
* const result = await mergePackageJson('./my-project');
|
|
222
|
+
*
|
|
223
|
+
* @example
|
|
224
|
+
* // Non-interactive merge
|
|
225
|
+
* const result = await mergePackageJson('./my-project', { yes: true });
|
|
226
|
+
*
|
|
227
|
+
* @example
|
|
228
|
+
* // Preview changes
|
|
229
|
+
* const result = await mergePackageJson('./my-project', { dryRun: true });
|
|
230
|
+
*/
|
|
231
|
+
export async function mergePackageJson(targetDir, options = {}) {
|
|
232
|
+
const { yes = false, dryRun = false } = options;
|
|
233
|
+
|
|
234
|
+
const targetPath = join(targetDir, 'package.json');
|
|
235
|
+
const hasExisting = existsSync(targetPath);
|
|
236
|
+
|
|
237
|
+
if (!hasExisting) {
|
|
238
|
+
// No existing package.json - create new one
|
|
239
|
+
logger.info('No existing package.json found. Creating new one...');
|
|
240
|
+
|
|
241
|
+
const newPackage = createNewPackageJson(targetDir);
|
|
242
|
+
|
|
243
|
+
if (dryRun) {
|
|
244
|
+
logger.info('\nWould create package.json:');
|
|
245
|
+
console.log(JSON.stringify(newPackage, null, 2));
|
|
246
|
+
return { dryRun: true, created: true };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
await fs.writeFile(targetPath, JSON.stringify(newPackage, null, 2) + '\n');
|
|
250
|
+
logger.success('Created package.json');
|
|
251
|
+
|
|
252
|
+
return { success: true, created: true };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Merge with existing package.json
|
|
256
|
+
logger.info('Merging with existing package.json...');
|
|
257
|
+
|
|
258
|
+
const existing = JSON.parse(await fs.readFile(targetPath, 'utf-8'));
|
|
259
|
+
|
|
260
|
+
// Security: Check for suspicious patterns in existing package.json
|
|
261
|
+
const suspiciousDeps = detectSuspiciousDependencies(existing.dependencies);
|
|
262
|
+
const suspiciousDevDeps = detectSuspiciousDependencies(existing.devDependencies);
|
|
263
|
+
const suspiciousScripts = detectSuspiciousScripts(existing.scripts);
|
|
264
|
+
|
|
265
|
+
// Security: Check for path traversal in package names
|
|
266
|
+
const pathTraversalDeps = detectPathTraversalInDependencies(existing.dependencies);
|
|
267
|
+
const pathTraversalDevDeps = detectPathTraversalInDependencies(existing.devDependencies);
|
|
268
|
+
|
|
269
|
+
if (pathTraversalDeps.length > 0) {
|
|
270
|
+
logger.error(`SECURITY: Path traversal detected in dependencies: ${pathTraversalDeps.join(', ')}`);
|
|
271
|
+
throw new Error(`Security: Refusing to process package.json with path traversal in dependency names: ${pathTraversalDeps.join(', ')}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (pathTraversalDevDeps.length > 0) {
|
|
275
|
+
logger.error(`SECURITY: Path traversal detected in devDependencies: ${pathTraversalDevDeps.join(', ')}`);
|
|
276
|
+
throw new Error(`Security: Refusing to process package.json with path traversal in devDependency names: ${pathTraversalDevDeps.join(', ')}`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (suspiciousDeps.length > 0) {
|
|
280
|
+
logger.warn(`Potentially suspicious dependencies detected: ${suspiciousDeps.join(', ')}`);
|
|
281
|
+
logger.warn('Please verify these packages are legitimate before proceeding.');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (suspiciousDevDeps.length > 0) {
|
|
285
|
+
logger.warn(`Potentially suspicious devDependencies detected: ${suspiciousDevDeps.join(', ')}`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (suspiciousScripts.length > 0) {
|
|
289
|
+
logger.warn(`Scripts with potentially dangerous shell commands: ${suspiciousScripts.join(', ')}`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const merged = mergePackages(existing);
|
|
293
|
+
|
|
294
|
+
// Calculate diff
|
|
295
|
+
const diff = calculateDiff(existing, merged);
|
|
296
|
+
|
|
297
|
+
if (Object.keys(diff.added).length === 0 &&
|
|
298
|
+
Object.keys(diff.modified).length === 0) {
|
|
299
|
+
logger.info('No changes needed to package.json');
|
|
300
|
+
return { success: true, noChanges: true };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Show diff
|
|
304
|
+
if (!yes) {
|
|
305
|
+
showDiff(diff);
|
|
306
|
+
|
|
307
|
+
const { proceed } = await inquirer.prompt([
|
|
308
|
+
{
|
|
309
|
+
type: 'confirm',
|
|
310
|
+
name: 'proceed',
|
|
311
|
+
message: 'Apply these changes?',
|
|
312
|
+
default: true
|
|
313
|
+
}
|
|
314
|
+
]);
|
|
315
|
+
|
|
316
|
+
if (!proceed) {
|
|
317
|
+
return { cancelled: true };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (dryRun) {
|
|
322
|
+
logger.info('\nDry run - no changes made');
|
|
323
|
+
return { dryRun: true, diff };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Create backup
|
|
327
|
+
const backupPath = `${targetPath}.backup.${Date.now()}`;
|
|
328
|
+
await fs.copyFile(targetPath, backupPath);
|
|
329
|
+
logger.info(`Backup created: ${backupPath}`);
|
|
330
|
+
|
|
331
|
+
// Write merged package.json
|
|
332
|
+
await fs.writeFile(targetPath, JSON.stringify(merged, null, 2) + '\n');
|
|
333
|
+
logger.success('Package.json updated');
|
|
334
|
+
|
|
335
|
+
return { success: true, diff, backupPath };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function createNewPackageJson(targetDir) {
|
|
339
|
+
const dirName = targetDir.split('/').pop() || 'my-project';
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
name: dirName,
|
|
343
|
+
version: '1.0.0',
|
|
344
|
+
type: 'module',
|
|
345
|
+
scripts: {
|
|
346
|
+
...BMAD_SCRIPTS,
|
|
347
|
+
'start': 'node index.js',
|
|
348
|
+
'build': 'tsc',
|
|
349
|
+
'test': 'vitest'
|
|
350
|
+
},
|
|
351
|
+
dependencies: {
|
|
352
|
+
...BMAD_DEPENDENCIES
|
|
353
|
+
},
|
|
354
|
+
devDependencies: {
|
|
355
|
+
...BMAD_DEV_DEPENDENCIES
|
|
356
|
+
},
|
|
357
|
+
engines: {
|
|
358
|
+
node: '>=18.0.0'
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function mergePackages(existing) {
|
|
364
|
+
// Security: Sanitize the existing package.json to remove prototype pollution vectors
|
|
365
|
+
const sanitizedExisting = sanitizeObject(existing);
|
|
366
|
+
|
|
367
|
+
const merged = { ...sanitizedExisting };
|
|
368
|
+
|
|
369
|
+
// Security: Sanitize dependencies before merging
|
|
370
|
+
const sanitizedDeps = sanitizeObject(existing.dependencies);
|
|
371
|
+
const sanitizedDevDeps = sanitizeObject(existing.devDependencies);
|
|
372
|
+
const sanitizedScripts = sanitizeObject(existing.scripts);
|
|
373
|
+
|
|
374
|
+
// Merge dependencies (don't override existing)
|
|
375
|
+
merged.dependencies = {
|
|
376
|
+
...BMAD_DEPENDENCIES,
|
|
377
|
+
...sanitizedDeps
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
// Merge devDependencies (don't override existing)
|
|
381
|
+
merged.devDependencies = {
|
|
382
|
+
...BMAD_DEV_DEPENDENCIES,
|
|
383
|
+
...sanitizedDevDeps
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
// Merge scripts (add bmad: prefixed scripts)
|
|
387
|
+
merged.scripts = {
|
|
388
|
+
...sanitizedScripts,
|
|
389
|
+
...BMAD_SCRIPTS
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
// Update engines if needed
|
|
393
|
+
if (!merged.engines) {
|
|
394
|
+
merged.engines = {};
|
|
395
|
+
}
|
|
396
|
+
if (!merged.engines.node || !meetsMinVersion(merged.engines.node, '18.0.0')) {
|
|
397
|
+
merged.engines.node = '>=18.0.0';
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Ensure type is module if not set
|
|
401
|
+
if (!merged.type) {
|
|
402
|
+
merged.type = 'module';
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return merged;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function meetsMinVersion(versionSpec, minVersion) {
|
|
409
|
+
// Simple check - extract version number
|
|
410
|
+
const match = versionSpec.match(/(\d+)/);
|
|
411
|
+
if (!match) return false;
|
|
412
|
+
const majorVersion = parseInt(match[1], 10);
|
|
413
|
+
const minMajor = parseInt(minVersion.split('.')[0], 10);
|
|
414
|
+
return majorVersion >= minMajor;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function calculateDiff(original, merged) {
|
|
418
|
+
const diff = {
|
|
419
|
+
added: {},
|
|
420
|
+
modified: {},
|
|
421
|
+
unchanged: {}
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
// Compare scripts
|
|
425
|
+
for (const [key, value] of Object.entries(merged.scripts || {})) {
|
|
426
|
+
if (!original.scripts?.[key]) {
|
|
427
|
+
diff.added[`scripts.${key}`] = value;
|
|
428
|
+
} else if (original.scripts[key] !== value) {
|
|
429
|
+
diff.modified[`scripts.${key}`] = { from: original.scripts[key], to: value };
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Compare dependencies
|
|
434
|
+
for (const [key, value] of Object.entries(merged.dependencies || {})) {
|
|
435
|
+
if (!original.dependencies?.[key]) {
|
|
436
|
+
diff.added[`dependencies.${key}`] = value;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Compare devDependencies
|
|
441
|
+
for (const [key, value] of Object.entries(merged.devDependencies || {})) {
|
|
442
|
+
if (!original.devDependencies?.[key]) {
|
|
443
|
+
diff.added[`devDependencies.${key}`] = value;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Check engines
|
|
448
|
+
if (merged.engines?.node !== original.engines?.node) {
|
|
449
|
+
diff.modified['engines.node'] = {
|
|
450
|
+
from: original.engines?.node || 'not set',
|
|
451
|
+
to: merged.engines.node
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return diff;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function showDiff(diff) {
|
|
459
|
+
console.log('\n');
|
|
460
|
+
logger.info('Changes to package.json:');
|
|
461
|
+
console.log('');
|
|
462
|
+
|
|
463
|
+
if (Object.keys(diff.added).length > 0) {
|
|
464
|
+
console.log(chalk.green('+ Added:'));
|
|
465
|
+
for (const [key, value] of Object.entries(diff.added)) {
|
|
466
|
+
console.log(chalk.green(` + ${key}: ${JSON.stringify(value)}`));
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (Object.keys(diff.modified).length > 0) {
|
|
471
|
+
console.log(chalk.yellow('~ Modified:'));
|
|
472
|
+
for (const [key, change] of Object.entries(diff.modified)) {
|
|
473
|
+
console.log(chalk.yellow(` ~ ${key}:`));
|
|
474
|
+
console.log(chalk.red(` - ${change.from}`));
|
|
475
|
+
console.log(chalk.green(` + ${change.to}`));
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
console.log('');
|
|
480
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL Validator for Git Repository URLs
|
|
3
|
+
*
|
|
4
|
+
* Security module to prevent command injection via malicious repository URLs.
|
|
5
|
+
* Only allows HTTPS URLs from trusted Git hosting providers.
|
|
6
|
+
*
|
|
7
|
+
* @module url-validator
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Trusted domains for git repositories
|
|
11
|
+
const TRUSTED_DOMAINS = [
|
|
12
|
+
'github.com',
|
|
13
|
+
'gitlab.com',
|
|
14
|
+
'bitbucket.org'
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
// Shell metacharacters that could enable command injection
|
|
18
|
+
const DANGEROUS_CHARS = /[$`|;&(){}<>\n\r\\]/;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Validates a git repository URL for security
|
|
22
|
+
*
|
|
23
|
+
* @param {string} repoUrl - The repository URL to validate
|
|
24
|
+
* @returns {{ valid: boolean, error?: string }} Validation result
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* const result = validateRepoUrl('https://github.com/user/repo.git');
|
|
28
|
+
* if (!result.valid) {
|
|
29
|
+
* throw new Error(result.error);
|
|
30
|
+
* }
|
|
31
|
+
*/
|
|
32
|
+
export function validateRepoUrl(repoUrl) {
|
|
33
|
+
// Must be a non-empty string
|
|
34
|
+
if (!repoUrl || typeof repoUrl !== 'string') {
|
|
35
|
+
return {
|
|
36
|
+
valid: false,
|
|
37
|
+
error: 'Repository URL must be a non-empty string'
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Check for shell metacharacters BEFORE any other processing
|
|
42
|
+
if (DANGEROUS_CHARS.test(repoUrl)) {
|
|
43
|
+
return {
|
|
44
|
+
valid: false,
|
|
45
|
+
error: 'Repository URL contains invalid characters. URLs must not contain shell metacharacters.'
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Parse the URL to validate format
|
|
50
|
+
let url;
|
|
51
|
+
try {
|
|
52
|
+
url = new URL(repoUrl);
|
|
53
|
+
} catch {
|
|
54
|
+
return {
|
|
55
|
+
valid: false,
|
|
56
|
+
error: 'Invalid URL format. Please provide a valid HTTPS URL.'
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Only allow HTTPS protocol
|
|
61
|
+
if (url.protocol !== 'https:') {
|
|
62
|
+
return {
|
|
63
|
+
valid: false,
|
|
64
|
+
error: `Only HTTPS URLs are allowed. Received protocol: ${url.protocol}`
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Check if hostname is in trusted domains
|
|
69
|
+
const hostname = url.hostname.toLowerCase();
|
|
70
|
+
const isTrusted = TRUSTED_DOMAINS.some(domain =>
|
|
71
|
+
hostname === domain || hostname.endsWith('.' + domain)
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
if (!isTrusted) {
|
|
75
|
+
return {
|
|
76
|
+
valid: false,
|
|
77
|
+
error: `Repository URL must be from a trusted domain: ${TRUSTED_DOMAINS.join(', ')}`
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Additional path validation - must look like a valid repo path
|
|
82
|
+
// e.g., /owner/repo or /owner/repo.git
|
|
83
|
+
const pathPattern = /^\/[\w.-]+\/[\w.-]+(\.git)?$/;
|
|
84
|
+
if (!pathPattern.test(url.pathname)) {
|
|
85
|
+
return {
|
|
86
|
+
valid: false,
|
|
87
|
+
error: 'Invalid repository path format. Expected format: https://github.com/owner/repo'
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { valid: true };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Validates a git repository URL and throws if invalid
|
|
96
|
+
*
|
|
97
|
+
* @param {string} repoUrl - The repository URL to validate
|
|
98
|
+
* @throws {Error} If the URL is invalid or from an untrusted source
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* assertValidRepoUrl('https://github.com/user/repo.git'); // OK
|
|
102
|
+
* assertValidRepoUrl('$(malicious)'); // throws Error
|
|
103
|
+
*/
|
|
104
|
+
export function assertValidRepoUrl(repoUrl) {
|
|
105
|
+
const result = validateRepoUrl(repoUrl);
|
|
106
|
+
if (!result.valid) {
|
|
107
|
+
throw new Error(result.error);
|
|
108
|
+
}
|
|
109
|
+
}
|
package/lib/utils.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Checks if BMAD framework is installed in the target directory
|
|
6
|
+
* @description Determines if BMAD is installed by checking for the presence
|
|
7
|
+
* of the _bmad directory in the target location.
|
|
8
|
+
* @param {string} [targetDir=process.cwd()] - Directory to check for BMAD installation
|
|
9
|
+
* @returns {boolean} True if _bmad directory exists, false otherwise
|
|
10
|
+
* @example
|
|
11
|
+
* if (isBmadInstalled()) {
|
|
12
|
+
* console.log('BMAD is already installed');
|
|
13
|
+
* }
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* // Check specific directory
|
|
17
|
+
* const isInstalled = isBmadInstalled('/path/to/project');
|
|
18
|
+
*/
|
|
19
|
+
export function isBmadInstalled(targetDir = process.cwd()) {
|
|
20
|
+
return existsSync(join(targetDir, '_bmad'));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Gets the installed version of BMAD framework
|
|
25
|
+
* @description Attempts to read the version from the BMAD config file.
|
|
26
|
+
* Note: Version parsing is not yet implemented and currently returns null.
|
|
27
|
+
* @param {string} [targetDir=process.cwd()] - Directory to check for BMAD installation
|
|
28
|
+
* @returns {string|null} Version string if found, null otherwise (currently always null)
|
|
29
|
+
* @example
|
|
30
|
+
* const version = getInstalledVersion();
|
|
31
|
+
* if (version) {
|
|
32
|
+
* console.log(`Installed version: ${version}`);
|
|
33
|
+
* } else {
|
|
34
|
+
* console.log('Version not found or BMAD not installed');
|
|
35
|
+
* }
|
|
36
|
+
*/
|
|
37
|
+
export function getInstalledVersion(targetDir = process.cwd()) {
|
|
38
|
+
const configPath = join(targetDir, '_bmad', 'core', 'config.yaml');
|
|
39
|
+
if (!existsSync(configPath)) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
// Return null for now - version parsing to be implemented
|
|
43
|
+
return null;
|
|
44
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "bmad-cybersec",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Install BMAD-CYBERSEC operations framework",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"bmad-cybersec": "cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"cli.js",
|
|
11
|
+
"index.js",
|
|
12
|
+
"commands/",
|
|
13
|
+
"lib/",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "vitest run",
|
|
18
|
+
"test:watch": "vitest",
|
|
19
|
+
"test:coverage": "vitest run --coverage"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"commander": "^12.0.0",
|
|
23
|
+
"chalk": "^5.3.0",
|
|
24
|
+
"ora": "^8.0.0",
|
|
25
|
+
"inquirer": "^9.2.0",
|
|
26
|
+
"tar": "^6.2.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"vitest": "^2.1.0",
|
|
30
|
+
"@vitest/coverage-v8": "^2.1.0"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18.0.0"
|
|
34
|
+
},
|
|
35
|
+
"keywords": [
|
|
36
|
+
"bmad",
|
|
37
|
+
"cyber",
|
|
38
|
+
"security",
|
|
39
|
+
"ai",
|
|
40
|
+
"agents",
|
|
41
|
+
"claude"
|
|
42
|
+
],
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/SchenLong/BMAD-CYBERSEC.git"
|
|
46
|
+
},
|
|
47
|
+
"homepage": "https://github.com/SchenLong/BMAD-CYBERSEC#readme",
|
|
48
|
+
"bugs": {
|
|
49
|
+
"url": "https://github.com/SchenLong/BMAD-CYBERSEC/issues"
|
|
50
|
+
},
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public"
|
|
53
|
+
},
|
|
54
|
+
"license": "MIT"
|
|
55
|
+
}
|