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,297 @@
|
|
|
1
|
+
import { createWriteStream } from 'fs';
|
|
2
|
+
import { pipeline } from 'stream/promises';
|
|
3
|
+
import { createHash } from 'crypto';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { mkdir, rm, readFile } from 'fs/promises';
|
|
7
|
+
import ora from 'ora';
|
|
8
|
+
import { CONFIG } from './config.js';
|
|
9
|
+
import { logger } from './logger.js';
|
|
10
|
+
|
|
11
|
+
const GITHUB_API = 'https://api.github.com';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Allowed URL hosts for SSRF protection
|
|
15
|
+
* Only URLs from these domains are permitted for downloads
|
|
16
|
+
*/
|
|
17
|
+
const ALLOWED_HOSTS = [
|
|
18
|
+
'api.github.com',
|
|
19
|
+
'github.com',
|
|
20
|
+
'objects.githubusercontent.com',
|
|
21
|
+
'github-releases.githubusercontent.com',
|
|
22
|
+
'codeload.github.com'
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Validates a URL against the allowed hosts to prevent SSRF attacks
|
|
27
|
+
* @param {string} urlString - The URL to validate
|
|
28
|
+
* @returns {{ valid: boolean, error?: string }}
|
|
29
|
+
*/
|
|
30
|
+
function validateDownloadUrl(urlString) {
|
|
31
|
+
try {
|
|
32
|
+
const url = new URL(urlString);
|
|
33
|
+
|
|
34
|
+
// Must be HTTPS
|
|
35
|
+
if (url.protocol !== 'https:') {
|
|
36
|
+
return {
|
|
37
|
+
valid: false,
|
|
38
|
+
error: `Only HTTPS URLs are allowed. Received: ${url.protocol}`
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Must be from allowed host
|
|
43
|
+
const hostname = url.hostname.toLowerCase();
|
|
44
|
+
const isAllowed = ALLOWED_HOSTS.some(allowed =>
|
|
45
|
+
hostname === allowed || hostname.endsWith('.' + allowed)
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
if (!isAllowed) {
|
|
49
|
+
return {
|
|
50
|
+
valid: false,
|
|
51
|
+
error: `URL host "${hostname}" is not in the allowed list: ${ALLOWED_HOSTS.join(', ')}`
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { valid: true };
|
|
56
|
+
} catch {
|
|
57
|
+
return {
|
|
58
|
+
valid: false,
|
|
59
|
+
error: `Invalid URL format: ${urlString}`
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Validates and fetches from a URL with SSRF protection
|
|
66
|
+
* @param {string} url - URL to fetch
|
|
67
|
+
* @param {Object} options - Fetch options
|
|
68
|
+
* @returns {Promise<Response>}
|
|
69
|
+
* @throws {Error} If URL validation fails
|
|
70
|
+
*/
|
|
71
|
+
async function safeFetch(url, options = {}) {
|
|
72
|
+
const validation = validateDownloadUrl(url);
|
|
73
|
+
if (!validation.valid) {
|
|
74
|
+
throw new Error(`Security: ${validation.error}`);
|
|
75
|
+
}
|
|
76
|
+
return fetch(url, options);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Downloads a release tarball from GitHub
|
|
81
|
+
* @description Fetches release information from GitHub API, downloads the tarball asset,
|
|
82
|
+
* and optionally verifies the checksum. Falls back to source tarball if no release asset found.
|
|
83
|
+
* @param {Object} [options={}] - Download options
|
|
84
|
+
* @param {string} [options.version='latest'] - Release version tag (e.g., 'v2.0.0' or 'latest')
|
|
85
|
+
* @param {string|null} [options.branch=null] - Branch name for development builds (currently unused)
|
|
86
|
+
* @returns {Promise<string>} Path to the downloaded tarball in the temp directory
|
|
87
|
+
* @throws {Error} If release not found, download fails, or checksum verification fails
|
|
88
|
+
* @example
|
|
89
|
+
* // Download latest release
|
|
90
|
+
* const tarballPath = await downloadRelease();
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
93
|
+
* // Download specific version
|
|
94
|
+
* const tarballPath = await downloadRelease({ version: 'v2.0.0' });
|
|
95
|
+
*/
|
|
96
|
+
export async function downloadRelease(options = {}) {
|
|
97
|
+
const { version = 'latest', branch = null } = options;
|
|
98
|
+
const spinner = ora();
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
// 1. Get release info from GitHub API
|
|
102
|
+
spinner.start('Fetching release information...');
|
|
103
|
+
const release = await fetchReleaseInfo(version);
|
|
104
|
+
spinner.succeed(`Found release: ${release.tag_name}`);
|
|
105
|
+
|
|
106
|
+
// 2. Find tarball and checksum assets
|
|
107
|
+
const tarballAsset = release.assets.find(a => a.name.endsWith('.tar.gz'));
|
|
108
|
+
const checksumAsset = release.assets.find(a => a.name.endsWith('.sha256'));
|
|
109
|
+
|
|
110
|
+
if (!tarballAsset) {
|
|
111
|
+
// Fallback to source tarball
|
|
112
|
+
logger.info('No release tarball found, using source archive');
|
|
113
|
+
return await downloadSourceTarball(release.tarball_url, release.tag_name);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 3. Download with progress
|
|
117
|
+
spinner.start(`Downloading ${tarballAsset.name}...`);
|
|
118
|
+
const tarballPath = await downloadWithProgress(
|
|
119
|
+
tarballAsset.browser_download_url,
|
|
120
|
+
tarballAsset.name,
|
|
121
|
+
tarballAsset.size
|
|
122
|
+
);
|
|
123
|
+
spinner.succeed('Download complete');
|
|
124
|
+
|
|
125
|
+
// 4. Verify checksum - MANDATORY for release assets
|
|
126
|
+
if (checksumAsset) {
|
|
127
|
+
spinner.start('Verifying checksum...');
|
|
128
|
+
await verifyChecksum(tarballPath, checksumAsset.browser_download_url);
|
|
129
|
+
spinner.succeed('Checksum verified');
|
|
130
|
+
} else {
|
|
131
|
+
// Security: Checksum verification is MANDATORY for release tarballs
|
|
132
|
+
spinner.fail('Security: No checksum file found');
|
|
133
|
+
await rm(tarballPath, { force: true });
|
|
134
|
+
throw new Error(
|
|
135
|
+
'Security: Checksum verification is mandatory for release downloads. ' +
|
|
136
|
+
'The release is missing a .sha256 checksum file. ' +
|
|
137
|
+
'Use --from-git to clone directly if this is intentional.'
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return tarballPath;
|
|
142
|
+
|
|
143
|
+
} catch (error) {
|
|
144
|
+
spinner.fail(`Download failed: ${error.message}`);
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function fetchReleaseInfo(version) {
|
|
150
|
+
const endpoint = version === 'latest'
|
|
151
|
+
? `${GITHUB_API}/repos/${CONFIG.GITHUB_OWNER}/${CONFIG.GITHUB_REPO}/releases/latest`
|
|
152
|
+
: `${GITHUB_API}/repos/${CONFIG.GITHUB_OWNER}/${CONFIG.GITHUB_REPO}/releases/tags/${version}`;
|
|
153
|
+
|
|
154
|
+
const headers = {
|
|
155
|
+
'Accept': 'application/vnd.github.v3+json',
|
|
156
|
+
'User-Agent': 'bmad-cyber-installer'
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// Add auth token if available
|
|
160
|
+
if (process.env.GITHUB_TOKEN) {
|
|
161
|
+
headers['Authorization'] = `token ${process.env.GITHUB_TOKEN}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const response = await fetchWithRetry(endpoint, { headers });
|
|
165
|
+
|
|
166
|
+
if (!response.ok) {
|
|
167
|
+
if (response.status === 404) {
|
|
168
|
+
throw new Error(`Release ${version} not found`);
|
|
169
|
+
}
|
|
170
|
+
if (response.status === 403) {
|
|
171
|
+
throw new Error('GitHub API rate limit exceeded. Set GITHUB_TOKEN or try again later.');
|
|
172
|
+
}
|
|
173
|
+
throw new Error(`GitHub API error: ${response.status}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return response.json();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function downloadSourceTarball(url, tagName) {
|
|
180
|
+
const tempDir = join(tmpdir(), CONFIG.TEMP_DIR_PREFIX);
|
|
181
|
+
await mkdir(tempDir, { recursive: true });
|
|
182
|
+
|
|
183
|
+
const filename = `${tagName}.tar.gz`;
|
|
184
|
+
const filePath = join(tempDir, filename);
|
|
185
|
+
|
|
186
|
+
const headers = {
|
|
187
|
+
'User-Agent': 'bmad-cyber-installer'
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
if (process.env.GITHUB_TOKEN) {
|
|
191
|
+
headers['Authorization'] = `token ${process.env.GITHUB_TOKEN}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const response = await fetchWithRetry(url, { headers });
|
|
195
|
+
|
|
196
|
+
if (!response.ok) {
|
|
197
|
+
throw new Error(`Download failed: ${response.status}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const fileStream = createWriteStream(filePath);
|
|
201
|
+
await pipeline(response.body, fileStream);
|
|
202
|
+
|
|
203
|
+
return filePath;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function downloadWithProgress(url, filename, expectedSize) {
|
|
207
|
+
const tempDir = join(tmpdir(), CONFIG.TEMP_DIR_PREFIX);
|
|
208
|
+
await mkdir(tempDir, { recursive: true });
|
|
209
|
+
|
|
210
|
+
const filePath = join(tempDir, filename);
|
|
211
|
+
|
|
212
|
+
const headers = {
|
|
213
|
+
'User-Agent': 'bmad-cyber-installer'
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
if (process.env.GITHUB_TOKEN) {
|
|
217
|
+
headers['Authorization'] = `token ${process.env.GITHUB_TOKEN}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const response = await fetchWithRetry(url, { headers });
|
|
221
|
+
|
|
222
|
+
if (!response.ok) {
|
|
223
|
+
throw new Error(`Download failed: ${response.status}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const fileStream = createWriteStream(filePath);
|
|
227
|
+
await pipeline(response.body, fileStream);
|
|
228
|
+
|
|
229
|
+
return filePath;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function verifyChecksum(filePath, checksumUrl) {
|
|
233
|
+
const headers = {
|
|
234
|
+
'User-Agent': 'bmad-cyber-installer'
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
if (process.env.GITHUB_TOKEN) {
|
|
238
|
+
headers['Authorization'] = `token ${process.env.GITHUB_TOKEN}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Download checksum file (with SSRF protection)
|
|
242
|
+
const response = await safeFetch(checksumUrl, { headers });
|
|
243
|
+
if (!response.ok) {
|
|
244
|
+
throw new Error(`Failed to download checksum file: ${response.status}`);
|
|
245
|
+
}
|
|
246
|
+
const checksumContent = await response.text();
|
|
247
|
+
const expectedHash = checksumContent.split(' ')[0].trim();
|
|
248
|
+
|
|
249
|
+
// Validate hash format (should be 64 hex characters for SHA256)
|
|
250
|
+
if (!/^[a-fA-F0-9]{64}$/.test(expectedHash)) {
|
|
251
|
+
throw new Error('Invalid checksum format in checksum file');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Calculate actual hash
|
|
255
|
+
const fileBuffer = await readFile(filePath);
|
|
256
|
+
const hash = createHash('sha256').update(fileBuffer).digest('hex');
|
|
257
|
+
|
|
258
|
+
if (hash.toLowerCase() !== expectedHash.toLowerCase()) {
|
|
259
|
+
await rm(filePath);
|
|
260
|
+
throw new Error('Checksum verification failed. File may be corrupted or tampered with.');
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function fetchWithRetry(url, options = {}, retries = 3) {
|
|
265
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
266
|
+
try {
|
|
267
|
+
// Use safeFetch for SSRF protection
|
|
268
|
+
return await safeFetch(url, options);
|
|
269
|
+
} catch (error) {
|
|
270
|
+
// Don't retry security validation errors
|
|
271
|
+
if (error.message.startsWith('Security:')) {
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
if (attempt === retries) throw error;
|
|
275
|
+
const delay = Math.pow(2, attempt) * 1000; // Exponential backoff
|
|
276
|
+
logger.info(`Retry ${attempt}/${retries} after ${delay}ms...`);
|
|
277
|
+
await new Promise(r => setTimeout(r, delay));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Cleans up temporary download files
|
|
284
|
+
* @description Removes the temporary directory used for storing downloaded tarballs.
|
|
285
|
+
* Silently ignores any cleanup errors.
|
|
286
|
+
* @returns {Promise<void>}
|
|
287
|
+
* @example
|
|
288
|
+
* await cleanup();
|
|
289
|
+
*/
|
|
290
|
+
export async function cleanup() {
|
|
291
|
+
const tempDir = join(tmpdir(), CONFIG.TEMP_DIR_PREFIX);
|
|
292
|
+
try {
|
|
293
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
294
|
+
} catch {
|
|
295
|
+
// Ignore cleanup errors
|
|
296
|
+
}
|
|
297
|
+
}
|
package/lib/extractor.js
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import tar from 'tar';
|
|
2
|
+
import { existsSync, promises as fs, realpathSync } from 'fs';
|
|
3
|
+
import { join, dirname, basename, resolve, relative, normalize } from 'path';
|
|
4
|
+
import inquirer from 'inquirer';
|
|
5
|
+
import ora from 'ora';
|
|
6
|
+
import { logger } from './logger.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Validates that a path does not escape the target directory (zip-slip protection)
|
|
10
|
+
* @param {string} targetDir - The target extraction directory (absolute path)
|
|
11
|
+
* @param {string} entryPath - The path from the archive entry
|
|
12
|
+
* @returns {{ safe: boolean, resolvedPath?: string, error?: string }}
|
|
13
|
+
*/
|
|
14
|
+
function validatePathSafety(targetDir, entryPath) {
|
|
15
|
+
// Normalize and resolve the full path
|
|
16
|
+
const normalizedEntry = normalize(entryPath).replace(/\\/g, '/');
|
|
17
|
+
|
|
18
|
+
// Reject paths with suspicious patterns BEFORE resolution
|
|
19
|
+
if (normalizedEntry.includes('..') ||
|
|
20
|
+
normalizedEntry.startsWith('/') ||
|
|
21
|
+
normalizedEntry.includes('//')) {
|
|
22
|
+
return {
|
|
23
|
+
safe: false,
|
|
24
|
+
error: `Path traversal detected: "${entryPath}"`
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Resolve to absolute path
|
|
29
|
+
const resolvedPath = resolve(targetDir, normalizedEntry);
|
|
30
|
+
|
|
31
|
+
// Ensure the resolved path is within the target directory
|
|
32
|
+
const relativePath = relative(targetDir, resolvedPath);
|
|
33
|
+
if (relativePath.startsWith('..') || resolve(targetDir, relativePath) !== resolvedPath) {
|
|
34
|
+
return {
|
|
35
|
+
safe: false,
|
|
36
|
+
error: `Path escapes target directory: "${entryPath}" resolves to "${resolvedPath}"`
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { safe: true, resolvedPath };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Validates that a symlink target stays within the extraction directory
|
|
45
|
+
* @param {string} targetDir - The target extraction directory
|
|
46
|
+
* @param {string} linkPath - The path where the symlink will be created
|
|
47
|
+
* @param {string} linkTarget - The target of the symlink
|
|
48
|
+
* @returns {{ safe: boolean, error?: string }}
|
|
49
|
+
*/
|
|
50
|
+
function validateSymlinkSafety(targetDir, linkPath, linkTarget) {
|
|
51
|
+
// Resolve the symlink target relative to the link's directory
|
|
52
|
+
const linkDir = dirname(linkPath);
|
|
53
|
+
const resolvedTarget = resolve(linkDir, linkTarget);
|
|
54
|
+
|
|
55
|
+
// Ensure the target stays within the extraction directory
|
|
56
|
+
const relativePath = relative(targetDir, resolvedTarget);
|
|
57
|
+
if (relativePath.startsWith('..')) {
|
|
58
|
+
return {
|
|
59
|
+
safe: false,
|
|
60
|
+
error: `Symlink "${linkPath}" points outside target directory to "${linkTarget}"`
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { safe: true };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const ALWAYS_SKIP = [
|
|
68
|
+
'.git/',
|
|
69
|
+
'.github/',
|
|
70
|
+
'node_modules/',
|
|
71
|
+
'*.test.js',
|
|
72
|
+
'*.test.ts',
|
|
73
|
+
'*.spec.js',
|
|
74
|
+
'*.spec.ts',
|
|
75
|
+
'coverage/',
|
|
76
|
+
'.nyc_output/',
|
|
77
|
+
'__tests__/'
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
const PRIORITY_FILES = [
|
|
81
|
+
'_bmad/',
|
|
82
|
+
'.claude/',
|
|
83
|
+
'src/utility/tools/',
|
|
84
|
+
'CLAUDE.md'
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
const OPTIONAL_FILES = {
|
|
88
|
+
withDocs: ['Docs/'],
|
|
89
|
+
withDev: ['dev-tools/']
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Extracts framework files from a tarball to the target directory
|
|
94
|
+
* @description Extracts files from a downloaded tarball, filtering out unnecessary files
|
|
95
|
+
* (tests, node_modules, etc.) and optionally handling file conflicts with existing files.
|
|
96
|
+
* @param {string} tarballPath - Path to the tarball file to extract
|
|
97
|
+
* @param {string} targetDir - Target directory to extract files into
|
|
98
|
+
* @param {Object} [options={}] - Extraction options
|
|
99
|
+
* @param {boolean} [options.overwrite=false] - Whether to overwrite existing files without prompting
|
|
100
|
+
* @param {boolean} [options.force=false] - Force extraction without conflict checking
|
|
101
|
+
* @param {boolean} [options.withDocs=false] - Include documentation files in extraction
|
|
102
|
+
* @param {boolean} [options.withDev=false] - Include development tools in extraction
|
|
103
|
+
* @param {boolean} [options.dryRun=false] - List files without extracting
|
|
104
|
+
* @returns {Promise<Object>} Extraction result object
|
|
105
|
+
* @returns {boolean} [returns.success] - True if extraction completed successfully
|
|
106
|
+
* @returns {boolean} [returns.cancelled] - True if user cancelled the operation
|
|
107
|
+
* @returns {boolean} [returns.dryRun] - True if this was a dry run
|
|
108
|
+
* @returns {number} returns.filesExtracted - Number of files extracted (0 for dry run or cancel)
|
|
109
|
+
* @returns {string[]} [returns.files] - List of files (only in dry run mode)
|
|
110
|
+
* @throws {Error} If tarball extraction fails
|
|
111
|
+
* @example
|
|
112
|
+
* // Basic extraction
|
|
113
|
+
* const result = await extractFramework('./release.tar.gz', './my-project');
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* // Dry run to preview files
|
|
117
|
+
* const result = await extractFramework('./release.tar.gz', './my-project', { dryRun: true });
|
|
118
|
+
* console.log(result.files);
|
|
119
|
+
*/
|
|
120
|
+
export async function extractFramework(tarballPath, targetDir, options = {}) {
|
|
121
|
+
const {
|
|
122
|
+
overwrite = false,
|
|
123
|
+
force = false,
|
|
124
|
+
withDocs = false,
|
|
125
|
+
withDev = false,
|
|
126
|
+
dryRun = false
|
|
127
|
+
} = options;
|
|
128
|
+
|
|
129
|
+
const spinner = ora();
|
|
130
|
+
|
|
131
|
+
// 1. Build file filter
|
|
132
|
+
const filter = buildFilter({ withDocs, withDev });
|
|
133
|
+
|
|
134
|
+
// 2. If not force, check for conflicts
|
|
135
|
+
if (!force && !dryRun) {
|
|
136
|
+
spinner.start('Checking for existing files...');
|
|
137
|
+
const conflicts = await findConflicts(tarballPath, targetDir, filter);
|
|
138
|
+
spinner.stop();
|
|
139
|
+
|
|
140
|
+
if (conflicts.length > 0) {
|
|
141
|
+
const action = await promptOverwrite(conflicts);
|
|
142
|
+
if (action === 'cancel') {
|
|
143
|
+
return { cancelled: true, filesExtracted: 0 };
|
|
144
|
+
}
|
|
145
|
+
if (action === 'skip') {
|
|
146
|
+
// Add conflicts to skip list
|
|
147
|
+
filter.skipFiles = conflicts;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 3. Dry run - just list files
|
|
153
|
+
if (dryRun) {
|
|
154
|
+
spinner.start('Analyzing tarball contents...');
|
|
155
|
+
const files = await listTarballContents(tarballPath, filter);
|
|
156
|
+
spinner.stop();
|
|
157
|
+
|
|
158
|
+
logger.info('\nFiles that would be extracted:');
|
|
159
|
+
files.forEach(f => logger.info(` ${f}`));
|
|
160
|
+
logger.info(`\nTotal: ${files.length} files`);
|
|
161
|
+
|
|
162
|
+
return { dryRun: true, files, filesExtracted: 0 };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 4. Extract with security validations
|
|
166
|
+
spinner.start('Extracting framework files...');
|
|
167
|
+
let fileCount = 0;
|
|
168
|
+
const securityViolations = [];
|
|
169
|
+
const absoluteTargetDir = resolve(targetDir);
|
|
170
|
+
|
|
171
|
+
await tar.extract({
|
|
172
|
+
file: tarballPath,
|
|
173
|
+
cwd: targetDir,
|
|
174
|
+
strip: 1, // Remove top-level directory
|
|
175
|
+
filter: (path, entry) => {
|
|
176
|
+
// First check normal filtering
|
|
177
|
+
if (!shouldExtract(path, filter)) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Security: Validate path does not escape target directory (zip-slip protection)
|
|
182
|
+
// After strip:1, we need to check the resulting path
|
|
183
|
+
const parts = path.split('/');
|
|
184
|
+
parts.shift(); // Remove top-level directory (strip: 1)
|
|
185
|
+
const strippedPath = parts.join('/');
|
|
186
|
+
|
|
187
|
+
if (strippedPath) {
|
|
188
|
+
const pathValidation = validatePathSafety(absoluteTargetDir, strippedPath);
|
|
189
|
+
if (!pathValidation.safe) {
|
|
190
|
+
securityViolations.push(pathValidation.error);
|
|
191
|
+
logger.warn(`Security: Skipping unsafe path - ${pathValidation.error}`);
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Security: Validate symlinks don't point outside target directory
|
|
196
|
+
if (entry.type === 'SymbolicLink' && entry.linkpath) {
|
|
197
|
+
const symlinkValidation = validateSymlinkSafety(
|
|
198
|
+
absoluteTargetDir,
|
|
199
|
+
pathValidation.resolvedPath,
|
|
200
|
+
entry.linkpath
|
|
201
|
+
);
|
|
202
|
+
if (!symlinkValidation.safe) {
|
|
203
|
+
securityViolations.push(symlinkValidation.error);
|
|
204
|
+
logger.warn(`Security: Skipping unsafe symlink - ${symlinkValidation.error}`);
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
fileCount++;
|
|
211
|
+
return true;
|
|
212
|
+
},
|
|
213
|
+
onentry: (entry) => {
|
|
214
|
+
// Preserve permissions
|
|
215
|
+
if (entry.mode) {
|
|
216
|
+
entry.mode = entry.mode;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
if (securityViolations.length > 0) {
|
|
222
|
+
logger.warn(`\nSecurity: ${securityViolations.length} potentially malicious entries were blocked during extraction.`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
spinner.succeed(`Extracted ${fileCount} files`);
|
|
226
|
+
|
|
227
|
+
return { success: true, filesExtracted: fileCount };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function buildFilter({ withDocs, withDev }) {
|
|
231
|
+
const skip = [...ALWAYS_SKIP];
|
|
232
|
+
const include = [...PRIORITY_FILES];
|
|
233
|
+
|
|
234
|
+
if (withDocs) {
|
|
235
|
+
include.push(...OPTIONAL_FILES.withDocs);
|
|
236
|
+
} else {
|
|
237
|
+
skip.push('Docs/');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (withDev) {
|
|
241
|
+
include.push(...OPTIONAL_FILES.withDev);
|
|
242
|
+
} else {
|
|
243
|
+
skip.push('dev-tools/');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return { skip, include, skipFiles: [] };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function shouldExtract(path, filter) {
|
|
250
|
+
// Normalize path
|
|
251
|
+
const normalizedPath = path.replace(/\\/g, '/');
|
|
252
|
+
|
|
253
|
+
// Check explicit skip files
|
|
254
|
+
if (filter.skipFiles.includes(normalizedPath)) {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Check always-skip patterns
|
|
259
|
+
for (const pattern of filter.skip) {
|
|
260
|
+
if (pattern.endsWith('/')) {
|
|
261
|
+
if (normalizedPath.includes(pattern) || normalizedPath.startsWith(pattern)) {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
} else if (pattern.startsWith('*')) {
|
|
265
|
+
const ext = pattern.slice(1);
|
|
266
|
+
if (normalizedPath.endsWith(ext)) {
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
} else if (normalizedPath === pattern || normalizedPath.includes(`/${pattern}`)) {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function findConflicts(tarballPath, targetDir, filter) {
|
|
278
|
+
const conflicts = [];
|
|
279
|
+
|
|
280
|
+
// List tarball contents
|
|
281
|
+
const entries = [];
|
|
282
|
+
await tar.list({
|
|
283
|
+
file: tarballPath,
|
|
284
|
+
onentry: (entry) => {
|
|
285
|
+
if (entry.type === 'File' && shouldExtract(entry.path, filter)) {
|
|
286
|
+
// Remove top-level directory from path
|
|
287
|
+
const parts = entry.path.split('/');
|
|
288
|
+
parts.shift();
|
|
289
|
+
const relativePath = parts.join('/');
|
|
290
|
+
if (relativePath) {
|
|
291
|
+
entries.push(relativePath);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// Check for existing files
|
|
298
|
+
for (const entry of entries) {
|
|
299
|
+
const fullPath = join(targetDir, entry);
|
|
300
|
+
if (existsSync(fullPath)) {
|
|
301
|
+
conflicts.push(entry);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return conflicts;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function promptOverwrite(conflicts) {
|
|
309
|
+
console.log('\n');
|
|
310
|
+
logger.warn(`Found ${conflicts.length} existing files that would be overwritten:`);
|
|
311
|
+
|
|
312
|
+
// Show first 10 conflicts
|
|
313
|
+
const shown = conflicts.slice(0, 10);
|
|
314
|
+
shown.forEach(f => logger.info(` - ${f}`));
|
|
315
|
+
if (conflicts.length > 10) {
|
|
316
|
+
logger.info(` ... and ${conflicts.length - 10} more`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const { action } = await inquirer.prompt([
|
|
320
|
+
{
|
|
321
|
+
type: 'list',
|
|
322
|
+
name: 'action',
|
|
323
|
+
message: 'How would you like to handle existing files?',
|
|
324
|
+
choices: [
|
|
325
|
+
{ name: 'Overwrite all', value: 'overwrite' },
|
|
326
|
+
{ name: 'Skip existing files', value: 'skip' },
|
|
327
|
+
{ name: 'Cancel installation', value: 'cancel' }
|
|
328
|
+
]
|
|
329
|
+
}
|
|
330
|
+
]);
|
|
331
|
+
|
|
332
|
+
return action;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function listTarballContents(tarballPath, filter) {
|
|
336
|
+
const files = [];
|
|
337
|
+
|
|
338
|
+
await tar.list({
|
|
339
|
+
file: tarballPath,
|
|
340
|
+
onentry: (entry) => {
|
|
341
|
+
if (entry.type === 'File' && shouldExtract(entry.path, filter)) {
|
|
342
|
+
const parts = entry.path.split('/');
|
|
343
|
+
parts.shift();
|
|
344
|
+
const relativePath = parts.join('/');
|
|
345
|
+
if (relativePath) {
|
|
346
|
+
files.push(relativePath);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
return files.sort();
|
|
353
|
+
}
|