mpx-scan 1.1.0 → 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 +68 -2
- package/src/reporters/json.js +3 -1
- package/src/reporters/terminal.js +26 -0
- package/src/scanners/cookies.js +13 -1
- package/src/scanners/dns.js +19 -4
- package/src/scanners/exposed-files.js +1 -1
- package/src/scanners/fingerprint.js +9 -2
- package/src/scanners/headers.js +72 -46
- package/src/scanners/redirects.js +9 -9
- package/src/scanners/server.js +16 -5
- package/src/scanners/sri.js +1 -1
- 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,
|
|
@@ -58,7 +113,7 @@ async function scan(url, options = {}) {
|
|
|
58
113
|
};
|
|
59
114
|
|
|
60
115
|
const allScanners = [
|
|
61
|
-
{ name: 'headers', fn: scanHeaders, weight:
|
|
116
|
+
{ name: 'headers', fn: scanHeaders, weight: 15 },
|
|
62
117
|
{ name: 'ssl', fn: scanSSL, weight: 20 },
|
|
63
118
|
{ name: 'cookies', fn: scanCookies, weight: 10 },
|
|
64
119
|
{ name: 'server', fn: scanServer, weight: 8 },
|
|
@@ -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/cookies.js
CHANGED
|
@@ -78,13 +78,25 @@ function fetchCookies(parsedUrl, options = {}) {
|
|
|
78
78
|
const req = protocol.request(parsedUrl.href, {
|
|
79
79
|
method: 'GET',
|
|
80
80
|
timeout,
|
|
81
|
-
headers: { 'User-Agent': '
|
|
81
|
+
headers: { 'User-Agent': 'mpx-scan/1.2.1 Security Scanner (https://github.com/mesaplexdev/mpx-scan)' },
|
|
82
82
|
rejectUnauthorized: false,
|
|
83
83
|
}, (res) => {
|
|
84
84
|
// Consume body
|
|
85
85
|
res.on('data', () => {});
|
|
86
86
|
res.on('end', () => {});
|
|
87
87
|
|
|
88
|
+
// Follow redirects to get cookies from final destination
|
|
89
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
90
|
+
if ((options._redirectCount || 0) >= 5) {
|
|
91
|
+
resolve([]);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const redirectUrl = new URL(res.headers.location, parsedUrl.href);
|
|
95
|
+
fetchCookies(redirectUrl, { ...options, _redirectCount: (options._redirectCount || 0) + 1 })
|
|
96
|
+
.then(resolve).catch(() => resolve([]));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
88
100
|
const setCookies = res.headers['set-cookie'] || [];
|
|
89
101
|
const parsed = setCookies.map(parseCookie);
|
|
90
102
|
resolve(parsed);
|
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(', ') });
|
|
@@ -151,7 +151,7 @@ function checkPath(parsedUrl, pathStr, options = {}) {
|
|
|
151
151
|
const req = protocol.request(url.href, {
|
|
152
152
|
method: 'GET',
|
|
153
153
|
timeout,
|
|
154
|
-
headers: { 'User-Agent': '
|
|
154
|
+
headers: { 'User-Agent': 'mpx-scan/1.2.1 Security Scanner (https://github.com/mesaplexdev/mpx-scan)' },
|
|
155
155
|
rejectUnauthorized: false,
|
|
156
156
|
}, (res) => {
|
|
157
157
|
let body = '';
|
|
@@ -297,16 +297,18 @@ async function scanFingerprint(parsedUrl, options = {}) {
|
|
|
297
297
|
function fetchHeaders(parsedUrl, options = {}) {
|
|
298
298
|
return new Promise((resolve) => {
|
|
299
299
|
const timeout = options.timeout || 10000;
|
|
300
|
+
const method = options._useGet ? 'GET' : 'HEAD';
|
|
300
301
|
const protocol = parsedUrl.protocol === 'https:' ? https : http;
|
|
301
302
|
let resolved = false;
|
|
302
303
|
const done = (val) => { if (!resolved) { resolved = true; resolve(val); } };
|
|
303
304
|
const timer = setTimeout(() => done(null), timeout + 2000);
|
|
304
305
|
|
|
305
306
|
const req = protocol.request(parsedUrl.href, {
|
|
306
|
-
method
|
|
307
|
-
headers: { 'User-Agent': '
|
|
307
|
+
method, timeout,
|
|
308
|
+
headers: { 'User-Agent': 'mpx-scan/1.2.1 Security Scanner (https://github.com/mesaplexdev/mpx-scan)' },
|
|
308
309
|
rejectUnauthorized: false,
|
|
309
310
|
}, (res) => {
|
|
311
|
+
if (method === 'GET') { res.resume(); }
|
|
310
312
|
clearTimeout(timer);
|
|
311
313
|
// Follow redirects
|
|
312
314
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
@@ -314,6 +316,11 @@ function fetchHeaders(parsedUrl, options = {}) {
|
|
|
314
316
|
fetchHeaders(redirectUrl, options).then(done);
|
|
315
317
|
return;
|
|
316
318
|
}
|
|
319
|
+
// HEAD returned error — retry with GET
|
|
320
|
+
if (!options._useGet && res.statusCode >= 400) {
|
|
321
|
+
fetchHeaders(parsedUrl, { ...options, _useGet: true }).then(done);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
317
324
|
done(res.headers);
|
|
318
325
|
});
|
|
319
326
|
req.on('error', () => { clearTimeout(timer); done(null); });
|
package/src/scanners/headers.js
CHANGED
|
@@ -23,8 +23,12 @@ async function scanHeaders(parsedUrl, options = {}) {
|
|
|
23
23
|
let score = 0;
|
|
24
24
|
let maxScore = 0;
|
|
25
25
|
|
|
26
|
-
//
|
|
27
|
-
|
|
26
|
+
// ==============================================
|
|
27
|
+
// CRITICAL HEADERS (fail if missing, full points)
|
|
28
|
+
// ==============================================
|
|
29
|
+
|
|
30
|
+
// --- Strict-Transport-Security (4 pts) ---
|
|
31
|
+
maxScore += 4;
|
|
28
32
|
const hsts = headers['strict-transport-security'];
|
|
29
33
|
if (hsts) {
|
|
30
34
|
const maxAge = parseInt((hsts.match(/max-age=(\d+)/) || [])[1] || '0');
|
|
@@ -33,10 +37,10 @@ async function scanHeaders(parsedUrl, options = {}) {
|
|
|
33
37
|
|
|
34
38
|
if (maxAge >= 31536000 && includesSubs && preload) {
|
|
35
39
|
checks.push({ name: 'Strict-Transport-Security', status: 'pass', message: `Excellent. max-age=${maxAge}, includeSubDomains, preload`, value: hsts });
|
|
36
|
-
score +=
|
|
40
|
+
score += 4;
|
|
37
41
|
} else if (maxAge >= 31536000) {
|
|
38
42
|
checks.push({ name: 'Strict-Transport-Security', status: 'pass', message: `Good. max-age=${maxAge}${includesSubs ? ', includeSubDomains' : ''}${preload ? ', preload' : ''}`, value: hsts });
|
|
39
|
-
score +=
|
|
43
|
+
score += 3;
|
|
40
44
|
} else if (maxAge > 0) {
|
|
41
45
|
checks.push({ name: 'Strict-Transport-Security', status: 'warn', message: `max-age=${maxAge} is low. Recommend >= 31536000 (1 year)`, value: hsts });
|
|
42
46
|
score += 1;
|
|
@@ -45,78 +49,88 @@ async function scanHeaders(parsedUrl, options = {}) {
|
|
|
45
49
|
checks.push({ name: 'Strict-Transport-Security', status: 'fail', message: 'Missing. Allows downgrade attacks to HTTP.', recommendation: 'Add: Strict-Transport-Security: max-age=31536000; includeSubDomains; preload' });
|
|
46
50
|
}
|
|
47
51
|
|
|
48
|
-
// --- Content-
|
|
52
|
+
// --- X-Content-Type-Options (3 pts) ---
|
|
49
53
|
maxScore += 3;
|
|
50
|
-
const csp = headers['content-security-policy'];
|
|
51
|
-
if (csp) {
|
|
52
|
-
const hasDefaultSrc = /default-src/i.test(csp);
|
|
53
|
-
const hasUnsafeInline = /unsafe-inline/i.test(csp);
|
|
54
|
-
const hasUnsafeEval = /unsafe-eval/i.test(csp);
|
|
55
|
-
|
|
56
|
-
if (hasDefaultSrc && !hasUnsafeInline && !hasUnsafeEval) {
|
|
57
|
-
checks.push({ name: 'Content-Security-Policy', status: 'pass', message: 'Strong policy without unsafe directives', value: csp.substring(0, 200) + (csp.length > 200 ? '...' : '') });
|
|
58
|
-
score += 3;
|
|
59
|
-
} else if (hasDefaultSrc) {
|
|
60
|
-
const issues = [];
|
|
61
|
-
if (hasUnsafeInline) issues.push('unsafe-inline');
|
|
62
|
-
if (hasUnsafeEval) issues.push('unsafe-eval');
|
|
63
|
-
checks.push({ name: 'Content-Security-Policy', status: 'warn', message: `Present but uses ${issues.join(', ')}`, value: csp.substring(0, 200) });
|
|
64
|
-
score += 1.5;
|
|
65
|
-
} else {
|
|
66
|
-
checks.push({ name: 'Content-Security-Policy', status: 'warn', message: 'Present but missing default-src directive', value: csp.substring(0, 200) });
|
|
67
|
-
score += 1;
|
|
68
|
-
}
|
|
69
|
-
} else {
|
|
70
|
-
checks.push({ name: 'Content-Security-Policy', status: 'fail', message: 'Missing. Vulnerable to XSS and data injection attacks.', recommendation: "Add: Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'" });
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// --- X-Content-Type-Options ---
|
|
74
|
-
maxScore += 1;
|
|
75
54
|
const xcto = headers['x-content-type-options'];
|
|
76
55
|
if (xcto && xcto.toLowerCase() === 'nosniff') {
|
|
77
56
|
checks.push({ name: 'X-Content-Type-Options', status: 'pass', message: 'nosniff — prevents MIME-type sniffing', value: xcto });
|
|
78
|
-
score +=
|
|
57
|
+
score += 3;
|
|
79
58
|
} else {
|
|
80
59
|
checks.push({ name: 'X-Content-Type-Options', status: 'fail', message: 'Missing or incorrect. Browsers may MIME-sniff responses.', recommendation: 'Add: X-Content-Type-Options: nosniff' });
|
|
81
60
|
}
|
|
82
61
|
|
|
83
|
-
//
|
|
84
|
-
|
|
62
|
+
// ==============================================
|
|
63
|
+
// IMPORTANT HEADERS (fail if missing, fewer pts)
|
|
64
|
+
// ==============================================
|
|
65
|
+
|
|
66
|
+
// --- X-Frame-Options (2 pts) ---
|
|
67
|
+
maxScore += 2;
|
|
68
|
+
const csp = headers['content-security-policy'];
|
|
85
69
|
const xfo = headers['x-frame-options'];
|
|
86
70
|
if (xfo) {
|
|
87
71
|
const val = xfo.toUpperCase();
|
|
88
72
|
if (val === 'DENY' || val === 'SAMEORIGIN') {
|
|
89
73
|
checks.push({ name: 'X-Frame-Options', status: 'pass', message: `${val} — prevents clickjacking`, value: xfo });
|
|
90
|
-
score +=
|
|
74
|
+
score += 2;
|
|
91
75
|
} else {
|
|
92
76
|
checks.push({ name: 'X-Frame-Options', status: 'warn', message: `Unusual value: ${xfo}`, value: xfo });
|
|
93
|
-
score +=
|
|
77
|
+
score += 1;
|
|
94
78
|
}
|
|
95
79
|
} else {
|
|
96
80
|
// Check if CSP has frame-ancestors
|
|
97
81
|
if (csp && /frame-ancestors/i.test(csp)) {
|
|
98
82
|
checks.push({ name: 'X-Frame-Options', status: 'pass', message: 'Not set, but CSP frame-ancestors provides equivalent protection', value: 'via CSP' });
|
|
99
|
-
score +=
|
|
83
|
+
score += 2;
|
|
100
84
|
} else {
|
|
101
85
|
checks.push({ name: 'X-Frame-Options', status: 'fail', message: 'Missing. Page can be embedded in iframes (clickjacking risk).', recommendation: 'Add: X-Frame-Options: DENY (or SAMEORIGIN)' });
|
|
102
86
|
}
|
|
103
87
|
}
|
|
104
88
|
|
|
105
|
-
// --- Referrer-Policy ---
|
|
106
|
-
maxScore +=
|
|
89
|
+
// --- Referrer-Policy (2 pts) ---
|
|
90
|
+
maxScore += 2;
|
|
107
91
|
const rp = headers['referrer-policy'];
|
|
108
92
|
const goodPolicies = ['no-referrer', 'strict-origin-when-cross-origin', 'strict-origin', 'same-origin', 'no-referrer-when-downgrade'];
|
|
109
93
|
if (rp && goodPolicies.some(p => rp.toLowerCase().includes(p))) {
|
|
110
94
|
checks.push({ name: 'Referrer-Policy', status: 'pass', message: `${rp}`, value: rp });
|
|
111
|
-
score +=
|
|
95
|
+
score += 2;
|
|
112
96
|
} else if (rp) {
|
|
113
97
|
checks.push({ name: 'Referrer-Policy', status: 'warn', message: `Set to "${rp}" — may leak referrer data`, value: rp });
|
|
114
|
-
score +=
|
|
98
|
+
score += 1;
|
|
99
|
+
} else {
|
|
100
|
+
checks.push({ name: 'Referrer-Policy', status: 'fail', message: 'Missing. Browser defaults may leak URL paths in referrer.', recommendation: 'Add: Referrer-Policy: strict-origin-when-cross-origin' });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ==============================================
|
|
104
|
+
// NICE-TO-HAVE HEADERS (warn if missing, no pts)
|
|
105
|
+
// ==============================================
|
|
106
|
+
|
|
107
|
+
// --- Content-Security-Policy (bonus: 2 pts if present, warn if missing, no deduction) ---
|
|
108
|
+
maxScore += 2;
|
|
109
|
+
if (csp) {
|
|
110
|
+
const hasDefaultSrc = /default-src/i.test(csp);
|
|
111
|
+
const hasUnsafeInline = /unsafe-inline/i.test(csp);
|
|
112
|
+
const hasUnsafeEval = /unsafe-eval/i.test(csp);
|
|
113
|
+
|
|
114
|
+
if (hasDefaultSrc && !hasUnsafeInline && !hasUnsafeEval) {
|
|
115
|
+
checks.push({ name: 'Content-Security-Policy', status: 'pass', message: 'Strong policy without unsafe directives', value: csp.substring(0, 200) + (csp.length > 200 ? '...' : '') });
|
|
116
|
+
score += 2;
|
|
117
|
+
} else if (hasDefaultSrc) {
|
|
118
|
+
const issues = [];
|
|
119
|
+
if (hasUnsafeInline) issues.push('unsafe-inline');
|
|
120
|
+
if (hasUnsafeEval) issues.push('unsafe-eval');
|
|
121
|
+
checks.push({ name: 'Content-Security-Policy', status: 'warn', message: `Present but uses ${issues.join(', ')}`, value: csp.substring(0, 200) });
|
|
122
|
+
score += 1;
|
|
123
|
+
} else {
|
|
124
|
+
checks.push({ name: 'Content-Security-Policy', status: 'warn', message: 'Present but missing default-src directive', value: csp.substring(0, 200) });
|
|
125
|
+
score += 1;
|
|
126
|
+
}
|
|
115
127
|
} else {
|
|
116
|
-
|
|
128
|
+
// Warn instead of fail — CSP is hard to implement correctly
|
|
129
|
+
checks.push({ name: 'Content-Security-Policy', status: 'warn', message: 'Missing. Consider adding to protect against XSS and data injection.', recommendation: "Add: Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'" });
|
|
130
|
+
score += 0; // No deduction for missing CSP
|
|
117
131
|
}
|
|
118
132
|
|
|
119
|
-
// --- Permissions-Policy ---
|
|
133
|
+
// --- Permissions-Policy (bonus: 1 pt if present) ---
|
|
120
134
|
maxScore += 1;
|
|
121
135
|
const pp = headers['permissions-policy'] || headers['feature-policy'];
|
|
122
136
|
if (pp) {
|
|
@@ -126,7 +140,7 @@ async function scanHeaders(parsedUrl, options = {}) {
|
|
|
126
140
|
checks.push({ name: 'Permissions-Policy', status: 'warn', message: 'Missing. Browser features (camera, mic, geolocation) unrestricted.', recommendation: 'Add: Permissions-Policy: camera=(), microphone=(), geolocation=()' });
|
|
127
141
|
}
|
|
128
142
|
|
|
129
|
-
// --- Cross-Origin-Opener-Policy ---
|
|
143
|
+
// --- Cross-Origin-Opener-Policy (bonus: 0.5 pts if present) ---
|
|
130
144
|
maxScore += 0.5;
|
|
131
145
|
const coop = headers['cross-origin-opener-policy'];
|
|
132
146
|
if (coop) {
|
|
@@ -136,7 +150,7 @@ async function scanHeaders(parsedUrl, options = {}) {
|
|
|
136
150
|
checks.push({ name: 'Cross-Origin-Opener-Policy', status: 'info', message: 'Not set. Consider adding for cross-origin isolation.' });
|
|
137
151
|
}
|
|
138
152
|
|
|
139
|
-
// --- Cross-Origin-Resource-Policy ---
|
|
153
|
+
// --- Cross-Origin-Resource-Policy (bonus: 0.5 pts if present) ---
|
|
140
154
|
maxScore += 0.5;
|
|
141
155
|
const corp = headers['cross-origin-resource-policy'];
|
|
142
156
|
if (corp) {
|
|
@@ -170,16 +184,20 @@ async function scanHeaders(parsedUrl, options = {}) {
|
|
|
170
184
|
function fetchHeaders(parsedUrl, options = {}) {
|
|
171
185
|
return new Promise((resolve, reject) => {
|
|
172
186
|
const timeout = options.timeout || 10000;
|
|
187
|
+
const method = options._useGet ? 'GET' : 'HEAD';
|
|
173
188
|
const protocol = parsedUrl.protocol === 'https:' ? https : http;
|
|
174
189
|
|
|
175
190
|
const req = protocol.request(parsedUrl.href, {
|
|
176
|
-
method
|
|
191
|
+
method,
|
|
177
192
|
timeout,
|
|
178
193
|
headers: {
|
|
179
|
-
'User-Agent': 'mpx-scan/1.
|
|
194
|
+
'User-Agent': 'mpx-scan/1.2.1 Security Scanner (https://github.com/mesaplexdev/mpx-scan)'
|
|
180
195
|
},
|
|
181
196
|
rejectUnauthorized: false // We check SSL separately
|
|
182
197
|
}, (res) => {
|
|
198
|
+
// Consume body for GET requests
|
|
199
|
+
if (method === 'GET') { res.resume(); }
|
|
200
|
+
|
|
183
201
|
// Follow redirects (up to 5)
|
|
184
202
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
185
203
|
const redirectUrl = new URL(res.headers.location, parsedUrl.href);
|
|
@@ -191,6 +209,14 @@ function fetchHeaders(parsedUrl, options = {}) {
|
|
|
191
209
|
.then(resolve).catch(reject);
|
|
192
210
|
return;
|
|
193
211
|
}
|
|
212
|
+
|
|
213
|
+
// If HEAD returned 4xx/5xx, retry with GET (some servers reject HEAD)
|
|
214
|
+
if (!options._useGet && res.statusCode >= 400) {
|
|
215
|
+
fetchHeaders(parsedUrl, { ...options, _useGet: true })
|
|
216
|
+
.then(resolve).catch(reject);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
194
220
|
resolve(res.headers);
|
|
195
221
|
});
|
|
196
222
|
|
|
@@ -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,12 +81,12 @@ 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',
|
|
88
88
|
timeout,
|
|
89
|
-
headers: { 'User-Agent': '
|
|
89
|
+
headers: { 'User-Agent': 'mpx-scan/1.2.1 Security Scanner (https://github.com/mesaplexdev/mpx-scan)' },
|
|
90
90
|
rejectUnauthorized: false,
|
|
91
91
|
}, (res) => {
|
|
92
92
|
res.resume(); // Consume body
|
|
@@ -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/scanners/server.js
CHANGED
|
@@ -84,14 +84,18 @@ async function scanServer(parsedUrl, options = {}) {
|
|
|
84
84
|
function checkRedirectToHttps(httpUrl, options = {}) {
|
|
85
85
|
return new Promise((resolve, reject) => {
|
|
86
86
|
const timeout = options.timeout || 5000;
|
|
87
|
+
const method = options._useGet ? 'GET' : 'HEAD';
|
|
87
88
|
const req = http.request(httpUrl.href, {
|
|
88
|
-
method
|
|
89
|
+
method,
|
|
89
90
|
timeout,
|
|
90
|
-
headers: { 'User-Agent': '
|
|
91
|
+
headers: { 'User-Agent': 'mpx-scan/1.2.1 Security Scanner (https://github.com/mesaplexdev/mpx-scan)' },
|
|
91
92
|
}, (res) => {
|
|
93
|
+
if (method === 'GET') { res.resume(); }
|
|
92
94
|
if (res.statusCode >= 300 && res.statusCode < 400) {
|
|
93
95
|
const location = res.headers.location || '';
|
|
94
96
|
resolve(location.startsWith('https://'));
|
|
97
|
+
} else if (!options._useGet && res.statusCode >= 400) {
|
|
98
|
+
checkRedirectToHttps(httpUrl, { ...options, _useGet: true }).then(resolve).catch(reject);
|
|
95
99
|
} else {
|
|
96
100
|
resolve(false);
|
|
97
101
|
}
|
|
@@ -105,16 +109,23 @@ function checkRedirectToHttps(httpUrl, options = {}) {
|
|
|
105
109
|
function fetchWithOrigin(parsedUrl, options = {}) {
|
|
106
110
|
return new Promise((resolve, reject) => {
|
|
107
111
|
const timeout = options.timeout || 5000;
|
|
112
|
+
const method = options._useGet ? 'GET' : 'HEAD';
|
|
108
113
|
const protocol = parsedUrl.protocol === 'https:' ? https : http;
|
|
109
114
|
const req = protocol.request(parsedUrl.href, {
|
|
110
|
-
method
|
|
115
|
+
method,
|
|
111
116
|
timeout,
|
|
112
117
|
headers: {
|
|
113
|
-
'User-Agent': '
|
|
118
|
+
'User-Agent': 'mpx-scan/1.2.1 Security Scanner (https://github.com/mesaplexdev/mpx-scan)',
|
|
114
119
|
'Origin': 'https://evil.example.com'
|
|
115
120
|
},
|
|
116
121
|
rejectUnauthorized: false,
|
|
117
122
|
}, (res) => {
|
|
123
|
+
if (method === 'GET') { res.resume(); }
|
|
124
|
+
// HEAD returned error — retry with GET
|
|
125
|
+
if (!options._useGet && res.statusCode >= 400) {
|
|
126
|
+
fetchWithOrigin(parsedUrl, { ...options, _useGet: true }).then(resolve).catch(reject);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
118
129
|
resolve(res.headers);
|
|
119
130
|
});
|
|
120
131
|
req.on('error', reject);
|
|
@@ -130,7 +141,7 @@ function checkMethods(parsedUrl, options = {}) {
|
|
|
130
141
|
const req = protocol.request(parsedUrl.href, {
|
|
131
142
|
method: 'OPTIONS',
|
|
132
143
|
timeout,
|
|
133
|
-
headers: { 'User-Agent': '
|
|
144
|
+
headers: { 'User-Agent': 'mpx-scan/1.2.1 Security Scanner (https://github.com/mesaplexdev/mpx-scan)' },
|
|
134
145
|
rejectUnauthorized: false,
|
|
135
146
|
}, (res) => {
|
|
136
147
|
const allow = res.headers.allow || '';
|
package/src/scanners/sri.js
CHANGED
|
@@ -125,7 +125,7 @@ function fetchBody(parsedUrl, options = {}) {
|
|
|
125
125
|
method: 'GET',
|
|
126
126
|
timeout,
|
|
127
127
|
headers: {
|
|
128
|
-
'User-Agent': '
|
|
128
|
+
'User-Agent': 'mpx-scan/1.2.1 Security Scanner (https://github.com/mesaplexdev/mpx-scan)',
|
|
129
129
|
'Accept': 'text/html'
|
|
130
130
|
},
|
|
131
131
|
rejectUnauthorized: false,
|
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 };
|