mpx-scan 1.2.1 → 1.3.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/bin/cli.js +121 -2
- package/package.json +1 -1
- package/src/index.js +67 -1
- package/src/reporters/json.js +3 -1
- package/src/reporters/terminal.js +26 -0
- package/src/scanners/dns.js +19 -4
- package/src/scanners/redirects.js +8 -8
- package/src/schema.js +13 -0
- package/src/update.js +68 -0
package/bin/cli.js
CHANGED
|
@@ -47,14 +47,14 @@ program
|
|
|
47
47
|
.option('--full', 'Run all checks (Pro only)')
|
|
48
48
|
.option('--json', 'Output as JSON (machine-readable)')
|
|
49
49
|
.option('--brief', 'Brief output (one-line summary)')
|
|
50
|
-
.option('--quiet
|
|
50
|
+
.option('-q, --quiet', 'Minimal output (results only, no banners)')
|
|
51
51
|
.option('--no-color', 'Disable colored output')
|
|
52
52
|
.option('--batch', 'Batch mode: read URLs from stdin (one per line)')
|
|
53
53
|
.option('--schema', 'Output JSON schema describing all commands and flags')
|
|
54
54
|
.option('--fix <platform>', `Generate fix config for platform (${PLATFORMS.join(', ')})`)
|
|
55
55
|
.option('--timeout <seconds>', 'Connection timeout', '10')
|
|
56
56
|
.option('--ci', 'CI/CD mode: exit 1 if score below threshold')
|
|
57
|
-
.option('--min-score <score>', 'Minimum score for CI mode
|
|
57
|
+
.option('--min-score <score>', 'Minimum score for CI mode', '70')
|
|
58
58
|
.action(async (url, options) => {
|
|
59
59
|
// Handle --schema flag
|
|
60
60
|
if (options.schema) {
|
|
@@ -89,6 +89,31 @@ async function runSingleScan(url, options) {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
try {
|
|
92
|
+
// BUG-03: Validate --fix platform BEFORE scanning (exits 2 for invalid platforms)
|
|
93
|
+
if (options.fix) {
|
|
94
|
+
if (!PLATFORMS.includes(options.fix)) {
|
|
95
|
+
if (jsonMode) {
|
|
96
|
+
console.log(JSON.stringify({ error: `Invalid platform: "${options.fix}". Valid platforms: ${PLATFORMS.join(', ')}`, code: 'ERR_BAD_ARGS' }, null, 2));
|
|
97
|
+
} else {
|
|
98
|
+
console.error(chalk.red.bold(`\n❌ Invalid platform: "${options.fix}"`));
|
|
99
|
+
console.error(chalk.yellow(`Valid platforms: ${PLATFORMS.join(', ')}`));
|
|
100
|
+
console.error('');
|
|
101
|
+
}
|
|
102
|
+
return EXIT.BAD_ARGS;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Validate timeout
|
|
107
|
+
const timeoutVal = parseInt(options.timeout);
|
|
108
|
+
if (isNaN(timeoutVal) || timeoutVal < 0) {
|
|
109
|
+
if (jsonMode) {
|
|
110
|
+
console.log(JSON.stringify({ error: 'Invalid --timeout value. Must be a non-negative number.', code: 'ERR_BAD_ARGS' }, null, 2));
|
|
111
|
+
} else {
|
|
112
|
+
console.error(chalk.red.bold('\n❌ Invalid --timeout value. Must be a non-negative number.'));
|
|
113
|
+
}
|
|
114
|
+
return EXIT.BAD_ARGS;
|
|
115
|
+
}
|
|
116
|
+
|
|
92
117
|
// Check license and rate limits
|
|
93
118
|
const license = getLicense();
|
|
94
119
|
const rateLimit = checkRateLimit();
|
|
@@ -159,9 +184,29 @@ async function runSingleScan(url, options) {
|
|
|
159
184
|
console.log(formatReport(results, { ...options, quiet: quietMode }));
|
|
160
185
|
}
|
|
161
186
|
|
|
187
|
+
// Check if core scanners errored with network issues (DNS failure, connection refused, etc.)
|
|
188
|
+
const coreScanners = ['headers', 'ssl'];
|
|
189
|
+
const coreErrored = coreScanners.every(name => {
|
|
190
|
+
const section = results.sections[name];
|
|
191
|
+
if (!section) return false;
|
|
192
|
+
return section.checks.some(c => c.status === 'error' &&
|
|
193
|
+
/ENOTFOUND|ECONNREFUSED|ETIMEDOUT|ECONNRESET|network/i.test(c.message || ''));
|
|
194
|
+
});
|
|
195
|
+
if (coreErrored) {
|
|
196
|
+
return EXIT.NETWORK_ERROR;
|
|
197
|
+
}
|
|
198
|
+
|
|
162
199
|
// Determine exit code based on findings
|
|
163
200
|
if (options.ci) {
|
|
164
201
|
const minScore = parseInt(options.minScore);
|
|
202
|
+
if (isNaN(minScore)) {
|
|
203
|
+
if (jsonMode) {
|
|
204
|
+
console.log(JSON.stringify({ error: 'Invalid --min-score value', code: 'ERR_BAD_ARGS' }, null, 2));
|
|
205
|
+
} else {
|
|
206
|
+
console.error(chalk.red.bold('\n❌ Invalid --min-score value. Must be a number.'));
|
|
207
|
+
}
|
|
208
|
+
return EXIT.BAD_ARGS;
|
|
209
|
+
}
|
|
165
210
|
const percentage = Math.round((results.score / results.maxScore) * 100);
|
|
166
211
|
if (percentage < minScore) {
|
|
167
212
|
if (!jsonMode && !options.brief && !quietMode) {
|
|
@@ -169,6 +214,7 @@ async function runSingleScan(url, options) {
|
|
|
169
214
|
}
|
|
170
215
|
return EXIT.ISSUES_FOUND;
|
|
171
216
|
}
|
|
217
|
+
return EXIT.SUCCESS;
|
|
172
218
|
}
|
|
173
219
|
|
|
174
220
|
// Exit 1 if there are failures, 0 if clean
|
|
@@ -362,6 +408,79 @@ program
|
|
|
362
408
|
console.log('');
|
|
363
409
|
});
|
|
364
410
|
|
|
411
|
+
// Update subcommand
|
|
412
|
+
program
|
|
413
|
+
.command('update')
|
|
414
|
+
.description('Check for updates and optionally install the latest version')
|
|
415
|
+
.option('--check', 'Only check for updates (do not install)')
|
|
416
|
+
.option('--json', 'Machine-readable JSON output')
|
|
417
|
+
.action(async (options, cmd) => {
|
|
418
|
+
const { checkForUpdate, performUpdate } = require('../src/update');
|
|
419
|
+
const jsonMode = options.json || cmd.parent?.opts()?.json;
|
|
420
|
+
|
|
421
|
+
try {
|
|
422
|
+
const info = checkForUpdate();
|
|
423
|
+
|
|
424
|
+
if (jsonMode) {
|
|
425
|
+
const output = {
|
|
426
|
+
current: info.current,
|
|
427
|
+
latest: info.latest,
|
|
428
|
+
updateAvailable: info.updateAvailable,
|
|
429
|
+
isGlobal: info.isGlobal
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
if (!options.check && info.updateAvailable) {
|
|
433
|
+
try {
|
|
434
|
+
const result = performUpdate(info.isGlobal);
|
|
435
|
+
output.updated = true;
|
|
436
|
+
output.newVersion = result.version;
|
|
437
|
+
} catch (err) {
|
|
438
|
+
output.updated = false;
|
|
439
|
+
output.error = err.message;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
console.log(JSON.stringify(output, null, 2));
|
|
444
|
+
process.exit(EXIT.SUCCESS);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Human-readable output
|
|
449
|
+
if (!info.updateAvailable) {
|
|
450
|
+
console.log('');
|
|
451
|
+
console.log(chalk.green.bold(`✓ mpx-scan v${info.current} is up to date`));
|
|
452
|
+
console.log('');
|
|
453
|
+
process.exit(EXIT.SUCCESS);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
console.log('');
|
|
458
|
+
console.log(chalk.yellow.bold(`⬆ Update available: v${info.current} → v${info.latest}`));
|
|
459
|
+
|
|
460
|
+
if (options.check) {
|
|
461
|
+
console.log(chalk.gray(`Run ${chalk.cyan('mpx-scan update')} to install`));
|
|
462
|
+
console.log('');
|
|
463
|
+
process.exit(EXIT.SUCCESS);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
console.log(chalk.gray(`Installing v${info.latest}${info.isGlobal ? ' (global)' : ''}...`));
|
|
468
|
+
|
|
469
|
+
const result = performUpdate(info.isGlobal);
|
|
470
|
+
console.log(chalk.green.bold(`✓ Updated to v${result.version}`));
|
|
471
|
+
console.log('');
|
|
472
|
+
process.exit(EXIT.SUCCESS);
|
|
473
|
+
} catch (err) {
|
|
474
|
+
if (jsonMode) {
|
|
475
|
+
console.log(JSON.stringify({ error: err.message, code: 'ERR_UPDATE' }, null, 2));
|
|
476
|
+
} else {
|
|
477
|
+
console.error(chalk.red.bold('\n❌ Update check failed:'), err.message);
|
|
478
|
+
console.error('');
|
|
479
|
+
}
|
|
480
|
+
process.exit(EXIT.NETWORK_ERROR);
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
|
|
365
484
|
// MCP subcommand
|
|
366
485
|
program
|
|
367
486
|
.command('mcp')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mpx-scan",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Professional website security scanner CLI. Check headers, SSL, cookies, DNS, and get actionable fix suggestions. Part of the Mesaplex developer toolchain.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
package/src/index.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* Zero external dependencies for scanning (only chalk/commander for CLI)
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
const https = require('https');
|
|
11
|
+
const http = require('http');
|
|
10
12
|
const { scanHeaders } = require('./scanners/headers');
|
|
11
13
|
const { scanSSL } = require('./scanners/ssl');
|
|
12
14
|
const { scanCookies } = require('./scanners/cookies');
|
|
@@ -30,8 +32,58 @@ const SCANNER_TIERS = {
|
|
|
30
32
|
* @param {object} options - Scan options
|
|
31
33
|
* @returns {object} Structured scan report
|
|
32
34
|
*/
|
|
35
|
+
/**
|
|
36
|
+
* Quick connectivity check — throws a network error if the host is unreachable.
|
|
37
|
+
* Used to provide exit code 4 (NETWORK_ERROR) early instead of silently returning error checks.
|
|
38
|
+
*/
|
|
39
|
+
function checkConnectivity(parsedUrl, timeoutMs) {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const protocol = parsedUrl.protocol === 'https:' ? https : http;
|
|
42
|
+
const timer = setTimeout(() => {
|
|
43
|
+
req && req.destroy();
|
|
44
|
+
const err = new Error(`ETIMEDOUT: Connection to ${parsedUrl.hostname} timed out`);
|
|
45
|
+
err.code = 'ETIMEDOUT';
|
|
46
|
+
reject(err);
|
|
47
|
+
}, timeoutMs);
|
|
48
|
+
|
|
49
|
+
const req = protocol.request({
|
|
50
|
+
hostname: parsedUrl.hostname,
|
|
51
|
+
port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
|
|
52
|
+
path: parsedUrl.pathname || '/',
|
|
53
|
+
method: 'HEAD',
|
|
54
|
+
timeout: timeoutMs,
|
|
55
|
+
headers: { 'User-Agent': 'mpx-scan/1.2.1 Security Scanner' },
|
|
56
|
+
rejectUnauthorized: false,
|
|
57
|
+
}, (res) => {
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
res.resume();
|
|
60
|
+
resolve();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
req.on('error', (err) => {
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
if (err.code === 'ENOTFOUND' || err.code === 'ECONNREFUSED' || err.code === 'ETIMEDOUT' || err.code === 'ECONNRESET') {
|
|
66
|
+
reject(err);
|
|
67
|
+
} else {
|
|
68
|
+
resolve(); // Other errors (like SSL) are fine — host is reachable
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
req.on('timeout', () => {
|
|
73
|
+
req.destroy();
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
const err = new Error(`ETIMEDOUT: Connection to ${parsedUrl.hostname} timed out`);
|
|
76
|
+
err.code = 'ETIMEDOUT';
|
|
77
|
+
reject(err);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
req.end();
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
33
84
|
async function scan(url, options = {}) {
|
|
34
85
|
const startTime = Date.now();
|
|
86
|
+
const timeoutMs = options.timeout || 10000;
|
|
35
87
|
|
|
36
88
|
// Normalize URL
|
|
37
89
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
|
@@ -39,6 +91,9 @@ async function scan(url, options = {}) {
|
|
|
39
91
|
}
|
|
40
92
|
|
|
41
93
|
const parsedUrl = new URL(url);
|
|
94
|
+
|
|
95
|
+
// BUG-02: Pre-scan connectivity check — throws network error → CLI maps to exit 4
|
|
96
|
+
await checkConnectivity(parsedUrl, Math.min(timeoutMs, 10000));
|
|
42
97
|
const results = {
|
|
43
98
|
url: parsedUrl.href,
|
|
44
99
|
hostname: parsedUrl.hostname,
|
|
@@ -75,10 +130,21 @@ async function scan(url, options = {}) {
|
|
|
75
130
|
|
|
76
131
|
const enabledScanners = allScanners.filter(s => allowedScanners.includes(s.name));
|
|
77
132
|
|
|
133
|
+
// BUG-05: Wrap each scanner in a timeout race to prevent hanging on closed ports
|
|
134
|
+
const scannerTimeout = timeoutMs + 5000; // Give scanners a bit extra beyond the connect timeout
|
|
135
|
+
|
|
78
136
|
// Run scanners concurrently
|
|
79
137
|
const scanPromises = enabledScanners.map(async (scanner) => {
|
|
80
138
|
try {
|
|
81
|
-
|
|
139
|
+
// BUG-05: Race scanner against timeout to prevent indefinite hang on closed ports
|
|
140
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
141
|
+
const t = setTimeout(() => {
|
|
142
|
+
reject(new Error(`Scanner timed out after ${scannerTimeout}ms`));
|
|
143
|
+
}, scannerTimeout);
|
|
144
|
+
// Allow process to exit even if timer is pending
|
|
145
|
+
if (t.unref) t.unref();
|
|
146
|
+
});
|
|
147
|
+
const result = await Promise.race([scanner.fn(parsedUrl, options), timeoutPromise]);
|
|
82
148
|
return { name: scanner.name, weight: scanner.weight, result };
|
|
83
149
|
} catch (err) {
|
|
84
150
|
return {
|
package/src/reporters/json.js
CHANGED
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
* Machine-readable output for CI/CD pipelines
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
const pkg = require('../../package.json');
|
|
8
|
+
|
|
7
9
|
function formatJSON(results, pretty = false) {
|
|
8
10
|
const output = {
|
|
9
11
|
mpxScan: {
|
|
10
|
-
version:
|
|
12
|
+
version: pkg.version,
|
|
11
13
|
scannedAt: results.scannedAt,
|
|
12
14
|
scanDuration: results.scanDuration
|
|
13
15
|
},
|
|
@@ -34,6 +34,11 @@ const GRADE_COLORS = {
|
|
|
34
34
|
function formatReport(results, options = {}) {
|
|
35
35
|
const lines = [];
|
|
36
36
|
|
|
37
|
+
// Quiet mode: results only, no banners or decoration
|
|
38
|
+
if (options.quiet) {
|
|
39
|
+
return formatQuiet(results);
|
|
40
|
+
}
|
|
41
|
+
|
|
37
42
|
// Header
|
|
38
43
|
lines.push('');
|
|
39
44
|
lines.push(chalk.bold.cyan('┌─────────────────────────────────────────────────────────────┐'));
|
|
@@ -137,4 +142,25 @@ function capitalize(str) {
|
|
|
137
142
|
.trim();
|
|
138
143
|
}
|
|
139
144
|
|
|
145
|
+
function formatQuiet(results) {
|
|
146
|
+
const lines = [];
|
|
147
|
+
const gradeColor = GRADE_COLORS[results.grade] || 'gray';
|
|
148
|
+
const percentage = Math.round((results.score / results.maxScore) * 100);
|
|
149
|
+
|
|
150
|
+
lines.push(chalk.bold[gradeColor](results.grade) + chalk.gray(` (${percentage}/100)`) +
|
|
151
|
+
` — ${chalk.green(results.summary.passed + ' passed')} ${chalk.yellow(results.summary.warnings + ' warnings')} ${chalk.red(results.summary.failed + ' failed')}`);
|
|
152
|
+
|
|
153
|
+
for (const [name, section] of Object.entries(results.sections)) {
|
|
154
|
+
for (const check of section.checks) {
|
|
155
|
+
if (check.status === 'fail' || check.status === 'warn') {
|
|
156
|
+
const icon = STATUS_ICONS[check.status];
|
|
157
|
+
const color = STATUS_COLORS[check.status];
|
|
158
|
+
lines.push(chalk[color](` ${icon} ${check.name}: ${check.message || ''}`));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return lines.join('\n');
|
|
164
|
+
}
|
|
165
|
+
|
|
140
166
|
module.exports = { formatReport, formatBrief };
|
package/src/scanners/dns.js
CHANGED
|
@@ -13,6 +13,19 @@
|
|
|
13
13
|
const dns = require('dns');
|
|
14
14
|
const { Resolver } = dns.promises;
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* BUG-12: Wrap a DNS promise in a race against a timeout to prevent indefinite hangs.
|
|
18
|
+
*/
|
|
19
|
+
function withTimeout(promise, ms, label) {
|
|
20
|
+
return Promise.race([
|
|
21
|
+
promise,
|
|
22
|
+
new Promise((_, reject) => {
|
|
23
|
+
const t = setTimeout(() => reject(new Error(`DNS timeout for ${label}`)), ms);
|
|
24
|
+
if (t.unref) t.unref();
|
|
25
|
+
})
|
|
26
|
+
]);
|
|
27
|
+
}
|
|
28
|
+
|
|
16
29
|
async function scanDNS(parsedUrl, options = {}) {
|
|
17
30
|
const checks = [];
|
|
18
31
|
let score = 0;
|
|
@@ -25,11 +38,13 @@ async function scanDNS(parsedUrl, options = {}) {
|
|
|
25
38
|
|
|
26
39
|
const resolver = new Resolver();
|
|
27
40
|
resolver.setServers(['8.8.8.8', '1.1.1.1']); // Use public DNS
|
|
41
|
+
|
|
42
|
+
const dnsTimeout = options.timeout ? Math.min(options.timeout, 10000) : 10000;
|
|
28
43
|
|
|
29
44
|
// --- SPF Record ---
|
|
30
45
|
maxScore += 1;
|
|
31
46
|
try {
|
|
32
|
-
const txtRecords = await resolver.resolveTxt(rootDomain);
|
|
47
|
+
const txtRecords = await withTimeout(resolver.resolveTxt(rootDomain), dnsTimeout, `TXT ${rootDomain}`);
|
|
33
48
|
const spf = txtRecords.flat().find(r => r.startsWith('v=spf1'));
|
|
34
49
|
if (spf) {
|
|
35
50
|
const hasAll = /[-~?+]all/.test(spf);
|
|
@@ -54,7 +69,7 @@ async function scanDNS(parsedUrl, options = {}) {
|
|
|
54
69
|
// --- DMARC Record ---
|
|
55
70
|
maxScore += 1;
|
|
56
71
|
try {
|
|
57
|
-
const dmarcRecords = await resolver.resolveTxt(`_dmarc.${rootDomain}`);
|
|
72
|
+
const dmarcRecords = await withTimeout(resolver.resolveTxt(`_dmarc.${rootDomain}`), dnsTimeout, `DMARC ${rootDomain}`);
|
|
58
73
|
const dmarc = dmarcRecords.flat().find(r => r.startsWith('v=DMARC1'));
|
|
59
74
|
if (dmarc) {
|
|
60
75
|
const policy = (dmarc.match(/p=(\w+)/) || [])[1] || 'none';
|
|
@@ -82,7 +97,7 @@ async function scanDNS(parsedUrl, options = {}) {
|
|
|
82
97
|
// --- CAA Records ---
|
|
83
98
|
maxScore += 0.5;
|
|
84
99
|
try {
|
|
85
|
-
const caaRecords = await resolver.resolveCaa(rootDomain);
|
|
100
|
+
const caaRecords = await withTimeout(resolver.resolveCaa(rootDomain), dnsTimeout, `CAA ${rootDomain}`);
|
|
86
101
|
if (caaRecords && caaRecords.length > 0) {
|
|
87
102
|
const issuers = caaRecords.filter(r => r.tag === 'issue').map(r => r.value);
|
|
88
103
|
checks.push({ name: 'CAA Records', status: 'pass', message: `Restricts certificate issuance to: ${issuers.join(', ')}`, value: issuers.join(', ') });
|
|
@@ -100,7 +115,7 @@ async function scanDNS(parsedUrl, options = {}) {
|
|
|
100
115
|
|
|
101
116
|
// --- MX Records (informational) ---
|
|
102
117
|
try {
|
|
103
|
-
const mxRecords = await resolver.resolveMx(rootDomain);
|
|
118
|
+
const mxRecords = await withTimeout(resolver.resolveMx(rootDomain), dnsTimeout, `MX ${rootDomain}`);
|
|
104
119
|
if (mxRecords && mxRecords.length > 0) {
|
|
105
120
|
const mxList = mxRecords.sort((a, b) => a.priority - b.priority).map(r => `${r.exchange} (${r.priority})`);
|
|
106
121
|
checks.push({ name: 'MX Records', status: 'info', message: `Mail servers: ${mxList.join(', ')}`, value: mxList.join(', ') });
|
|
@@ -38,7 +38,7 @@ async function scanRedirects(parsedUrl, options = {}) {
|
|
|
38
38
|
|
|
39
39
|
const result = await followRedirect(testUrl, options);
|
|
40
40
|
|
|
41
|
-
if (result.redirectsToExternal) {
|
|
41
|
+
if (result.redirectsToExternal && !result.isSameDomain) {
|
|
42
42
|
vulnParams.push({ param, redirectedTo: result.location });
|
|
43
43
|
return { param, vulnerable: true };
|
|
44
44
|
} else {
|
|
@@ -81,7 +81,7 @@ function followRedirect(testUrl, options = {}) {
|
|
|
81
81
|
const protocol = testUrl.protocol === 'https:' ? https : http;
|
|
82
82
|
let resolved = false;
|
|
83
83
|
const done = (val) => { if (!resolved) { resolved = true; resolve(val); } };
|
|
84
|
-
const timer = setTimeout(() => done({ redirectsToExternal: false }), timeout
|
|
84
|
+
const timer = setTimeout(() => { req && req.destroy(); done({ redirectsToExternal: false }); }, timeout);
|
|
85
85
|
|
|
86
86
|
const req = protocol.request(testUrl.href, {
|
|
87
87
|
method: 'GET',
|
|
@@ -96,13 +96,13 @@ function followRedirect(testUrl, options = {}) {
|
|
|
96
96
|
const location = res.headers.location;
|
|
97
97
|
try {
|
|
98
98
|
const redirectTarget = new URL(location, testUrl.href);
|
|
99
|
-
// Check if redirect goes to our test domain OR any external domain
|
|
100
|
-
// that wasn't the original host (indicates the param controls redirect target)
|
|
101
99
|
const originalHost = testUrl.hostname;
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
100
|
+
// Only flag if redirect target is exactly the evil test domain
|
|
101
|
+
const isExternal = redirectTarget.hostname === 'evil.example.com';
|
|
102
|
+
// Skip flagging apex→www redirects (same root domain)
|
|
103
|
+
const getRootDomain = h => h.split('.').slice(-2).join('.');
|
|
104
|
+
const isSameDomain = getRootDomain(redirectTarget.hostname) === getRootDomain(originalHost);
|
|
105
|
+
done({ redirectsToExternal: isExternal, isSameDomain, location });
|
|
106
106
|
} catch {
|
|
107
107
|
done({ redirectsToExternal: false });
|
|
108
108
|
}
|
package/src/schema.js
CHANGED
|
@@ -175,6 +175,19 @@ function getSchema() {
|
|
|
175
175
|
deactivate: {
|
|
176
176
|
description: 'Deactivate Pro license and return to free tier',
|
|
177
177
|
usage: 'mpx-scan deactivate'
|
|
178
|
+
},
|
|
179
|
+
update: {
|
|
180
|
+
description: 'Check for updates and optionally install the latest version',
|
|
181
|
+
usage: 'mpx-scan update [--check] [--json]',
|
|
182
|
+
flags: {
|
|
183
|
+
'--check': { description: 'Only check for updates (do not install)', default: false },
|
|
184
|
+
'--json': { description: 'Machine-readable JSON output', default: false }
|
|
185
|
+
},
|
|
186
|
+
examples: [
|
|
187
|
+
{ command: 'mpx-scan update', description: 'Check and install updates' },
|
|
188
|
+
{ command: 'mpx-scan update --check', description: 'Just check for updates' },
|
|
189
|
+
{ command: 'mpx-scan update --check --json', description: 'Check for updates (JSON output)' }
|
|
190
|
+
]
|
|
178
191
|
}
|
|
179
192
|
},
|
|
180
193
|
scanners: {
|
package/src/update.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update Command
|
|
3
|
+
*
|
|
4
|
+
* Checks npm for the latest version of mpx-scan and offers to update.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { execSync } = require('child_process');
|
|
8
|
+
const pkg = require('../package.json');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Check npm registry for latest version
|
|
12
|
+
* @returns {object} { current, latest, updateAvailable, isGlobal }
|
|
13
|
+
*/
|
|
14
|
+
function checkForUpdate() {
|
|
15
|
+
const current = pkg.version;
|
|
16
|
+
|
|
17
|
+
let latest;
|
|
18
|
+
try {
|
|
19
|
+
latest = execSync('npm view mpx-scan version', { encoding: 'utf8', timeout: 10000 }).trim();
|
|
20
|
+
} catch (err) {
|
|
21
|
+
throw new Error('Failed to check npm registry: ' + (err.message || 'unknown error'));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const updateAvailable = latest !== current && compareVersions(latest, current) > 0;
|
|
25
|
+
|
|
26
|
+
// Detect if installed globally
|
|
27
|
+
let isGlobal = false;
|
|
28
|
+
try {
|
|
29
|
+
const globalDir = execSync('npm root -g', { encoding: 'utf8', timeout: 5000 }).trim();
|
|
30
|
+
isGlobal = __dirname.startsWith(globalDir) || process.argv[1]?.includes('node_modules/.bin');
|
|
31
|
+
} catch {
|
|
32
|
+
// Can't determine, assume local
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { current, latest, updateAvailable, isGlobal };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Perform the update
|
|
40
|
+
* @param {boolean} isGlobal - Install globally
|
|
41
|
+
* @returns {object} { success, version }
|
|
42
|
+
*/
|
|
43
|
+
function performUpdate(isGlobal) {
|
|
44
|
+
const cmd = isGlobal ? 'npm install -g mpx-scan@latest' : 'npm install mpx-scan@latest';
|
|
45
|
+
try {
|
|
46
|
+
execSync(cmd, { encoding: 'utf8', timeout: 60000, stdio: 'pipe' });
|
|
47
|
+
// Verify
|
|
48
|
+
const newVersion = execSync('npm view mpx-scan version', { encoding: 'utf8', timeout: 10000 }).trim();
|
|
49
|
+
return { success: true, version: newVersion };
|
|
50
|
+
} catch (err) {
|
|
51
|
+
throw new Error('Update failed: ' + (err.message || 'unknown error'));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Compare semver strings. Returns >0 if a > b, <0 if a < b, 0 if equal.
|
|
57
|
+
*/
|
|
58
|
+
function compareVersions(a, b) {
|
|
59
|
+
const pa = a.split('.').map(Number);
|
|
60
|
+
const pb = b.split('.').map(Number);
|
|
61
|
+
for (let i = 0; i < 3; i++) {
|
|
62
|
+
if ((pa[i] || 0) > (pb[i] || 0)) return 1;
|
|
63
|
+
if ((pa[i] || 0) < (pb[i] || 0)) return -1;
|
|
64
|
+
}
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { checkForUpdate, performUpdate, compareVersions };
|