mpx-scan 1.2.1 → 1.3.1
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 +110 -147
- package/bin/cli.js +177 -19
- package/package.json +19 -15
- package/src/index.js +67 -1
- package/src/reporters/json.js +3 -1
- package/src/reporters/pdf.js +218 -0
- package/src/reporters/terminal.js +26 -0
- package/src/scanners/cookies.js +1 -1
- package/src/scanners/dns.js +27 -6
- package/src/scanners/exposed-files.js +1 -1
- package/src/scanners/fingerprint.js +1 -1
- package/src/scanners/headers.js +1 -1
- package/src/scanners/redirects.js +9 -9
- package/src/scanners/server.js +3 -3
- package/src/scanners/sri.js +1 -1
- package/src/schema.js +47 -0
- package/src/update.js +68 -0
package/bin/cli.js
CHANGED
|
@@ -12,6 +12,7 @@ const chalk = require('chalk');
|
|
|
12
12
|
const { scan } = require('../src/index');
|
|
13
13
|
const { formatReport, formatBrief } = require('../src/reporters/terminal');
|
|
14
14
|
const { formatJSON } = require('../src/reporters/json');
|
|
15
|
+
const { generatePDF, getDefaultPDFFilename } = require('../src/reporters/pdf');
|
|
15
16
|
const { generateFixes, PLATFORMS } = require('../src/generators/fixes');
|
|
16
17
|
const { getSchema } = require('../src/schema');
|
|
17
18
|
const {
|
|
@@ -25,13 +26,13 @@ const {
|
|
|
25
26
|
|
|
26
27
|
const pkg = require('../package.json');
|
|
27
28
|
|
|
28
|
-
// Exit codes
|
|
29
|
+
// Exit codes
|
|
29
30
|
const EXIT = {
|
|
30
31
|
SUCCESS: 0, // Success, no issues found
|
|
31
|
-
ISSUES_FOUND: 1, //
|
|
32
|
-
BAD_ARGS: 2, // Invalid arguments
|
|
33
|
-
CONFIG_ERROR:
|
|
34
|
-
NETWORK_ERROR:
|
|
32
|
+
ISSUES_FOUND: 1, // Issues found or error occurred
|
|
33
|
+
BAD_ARGS: 2, // Invalid arguments / bad usage
|
|
34
|
+
CONFIG_ERROR: 1, // Configuration error
|
|
35
|
+
NETWORK_ERROR: 1 // Network/connectivity error
|
|
35
36
|
};
|
|
36
37
|
|
|
37
38
|
// Auto-detect non-interactive mode
|
|
@@ -39,6 +40,12 @@ const isInteractive = process.stdout.isTTY && !process.env.CI;
|
|
|
39
40
|
|
|
40
41
|
const program = new Command();
|
|
41
42
|
|
|
43
|
+
// Error handling — set before any command/option registration
|
|
44
|
+
program.exitOverride();
|
|
45
|
+
program.configureOutput({
|
|
46
|
+
writeErr: () => {} // Suppress Commander's own error output; we handle it in the catch below
|
|
47
|
+
});
|
|
48
|
+
|
|
42
49
|
program
|
|
43
50
|
.name('mpx-scan')
|
|
44
51
|
.description('Professional website security scanner — check headers, SSL, DNS, and more')
|
|
@@ -47,14 +54,15 @@ program
|
|
|
47
54
|
.option('--full', 'Run all checks (Pro only)')
|
|
48
55
|
.option('--json', 'Output as JSON (machine-readable)')
|
|
49
56
|
.option('--brief', 'Brief output (one-line summary)')
|
|
50
|
-
.option('--quiet
|
|
57
|
+
.option('-q, --quiet', 'Minimal output (results only, no banners)')
|
|
51
58
|
.option('--no-color', 'Disable colored output')
|
|
52
59
|
.option('--batch', 'Batch mode: read URLs from stdin (one per line)')
|
|
53
60
|
.option('--schema', 'Output JSON schema describing all commands and flags')
|
|
54
61
|
.option('--fix <platform>', `Generate fix config for platform (${PLATFORMS.join(', ')})`)
|
|
62
|
+
.option('--pdf [filename]', 'Export results as a PDF report')
|
|
55
63
|
.option('--timeout <seconds>', 'Connection timeout', '10')
|
|
56
64
|
.option('--ci', 'CI/CD mode: exit 1 if score below threshold')
|
|
57
|
-
.option('--min-score <score>', 'Minimum score for CI mode
|
|
65
|
+
.option('--min-score <score>', 'Minimum score for CI mode', '70')
|
|
58
66
|
.action(async (url, options) => {
|
|
59
67
|
// Handle --schema flag
|
|
60
68
|
if (options.schema) {
|
|
@@ -71,7 +79,8 @@ program
|
|
|
71
79
|
|
|
72
80
|
// Show help if no URL provided
|
|
73
81
|
if (!url) {
|
|
74
|
-
program.
|
|
82
|
+
program.outputHelp();
|
|
83
|
+
process.exit(EXIT.BAD_ARGS);
|
|
75
84
|
return;
|
|
76
85
|
}
|
|
77
86
|
|
|
@@ -89,6 +98,31 @@ async function runSingleScan(url, options) {
|
|
|
89
98
|
}
|
|
90
99
|
|
|
91
100
|
try {
|
|
101
|
+
// BUG-03: Validate --fix platform BEFORE scanning (exits 2 for invalid platforms)
|
|
102
|
+
if (options.fix) {
|
|
103
|
+
if (!PLATFORMS.includes(options.fix)) {
|
|
104
|
+
if (jsonMode) {
|
|
105
|
+
console.log(JSON.stringify({ error: `Invalid platform: "${options.fix}". Valid platforms: ${PLATFORMS.join(', ')}`, code: 'ERR_BAD_ARGS' }, null, 2));
|
|
106
|
+
} else {
|
|
107
|
+
console.error(chalk.red(`Error: Invalid platform: "${options.fix}"`));
|
|
108
|
+
console.error(chalk.yellow(`Valid platforms: ${PLATFORMS.join(', ')}`));
|
|
109
|
+
console.error('');
|
|
110
|
+
}
|
|
111
|
+
return EXIT.BAD_ARGS;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Validate timeout
|
|
116
|
+
const timeoutVal = parseInt(options.timeout);
|
|
117
|
+
if (isNaN(timeoutVal) || timeoutVal < 0) {
|
|
118
|
+
if (jsonMode) {
|
|
119
|
+
console.log(JSON.stringify({ error: 'Invalid --timeout value. Must be a non-negative number.', code: 'ERR_BAD_ARGS' }, null, 2));
|
|
120
|
+
} else {
|
|
121
|
+
console.error(chalk.red('Error: Invalid --timeout value. Must be a non-negative number.'));
|
|
122
|
+
}
|
|
123
|
+
return EXIT.BAD_ARGS;
|
|
124
|
+
}
|
|
125
|
+
|
|
92
126
|
// Check license and rate limits
|
|
93
127
|
const license = getLicense();
|
|
94
128
|
const rateLimit = checkRateLimit();
|
|
@@ -104,7 +138,7 @@ async function runSingleScan(url, options) {
|
|
|
104
138
|
upgrade: 'https://mesaplex.com/mpx-scan'
|
|
105
139
|
}, null, 2));
|
|
106
140
|
} else {
|
|
107
|
-
console.error(chalk.red
|
|
141
|
+
console.error(chalk.red('Error: Daily scan limit reached'));
|
|
108
142
|
console.error(chalk.yellow(`Free tier: ${FREE_DAILY_LIMIT} scans/day`));
|
|
109
143
|
console.error(chalk.gray(`Resets: ${new Date(rateLimit.resetsAt).toLocaleString()}\n`));
|
|
110
144
|
console.error(chalk.blue('Upgrade to Pro for unlimited scans:'));
|
|
@@ -122,7 +156,7 @@ async function runSingleScan(url, options) {
|
|
|
122
156
|
upgrade: 'https://mesaplex.com/mpx-scan'
|
|
123
157
|
}, null, 2));
|
|
124
158
|
} else {
|
|
125
|
-
console.error(chalk.red
|
|
159
|
+
console.error(chalk.red('Error: --full flag requires Pro license'));
|
|
126
160
|
console.error(chalk.yellow('Free tier includes: headers, SSL, server checks'));
|
|
127
161
|
console.error(chalk.yellow('Pro includes: all checks (DNS, cookies, SRI, exposed files, etc.)\n'));
|
|
128
162
|
console.error(chalk.blue('Upgrade: https://mesaplex.com/mpx-scan\n'));
|
|
@@ -159,9 +193,48 @@ async function runSingleScan(url, options) {
|
|
|
159
193
|
console.log(formatReport(results, { ...options, quiet: quietMode }));
|
|
160
194
|
}
|
|
161
195
|
|
|
196
|
+
// Generate PDF if requested
|
|
197
|
+
if (options.pdf !== undefined) {
|
|
198
|
+
const pdfPath = (typeof options.pdf === 'string' && options.pdf)
|
|
199
|
+
? options.pdf
|
|
200
|
+
: getDefaultPDFFilename(results.hostname);
|
|
201
|
+
try {
|
|
202
|
+
await generatePDF(results, pdfPath);
|
|
203
|
+
if (!jsonMode && !options.brief) {
|
|
204
|
+
console.error(chalk.green(`📄 PDF report saved: ${pdfPath}`));
|
|
205
|
+
}
|
|
206
|
+
} catch (pdfErr) {
|
|
207
|
+
if (jsonMode) {
|
|
208
|
+
console.error(JSON.stringify({ warning: `PDF generation failed: ${pdfErr.message}` }));
|
|
209
|
+
} else {
|
|
210
|
+
console.error(chalk.yellow(`Warning: PDF generation failed: ${pdfErr.message}`));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Check if core scanners errored with network issues (DNS failure, connection refused, etc.)
|
|
216
|
+
const coreScanners = ['headers', 'ssl'];
|
|
217
|
+
const coreErrored = coreScanners.every(name => {
|
|
218
|
+
const section = results.sections[name];
|
|
219
|
+
if (!section) return false;
|
|
220
|
+
return section.checks.some(c => c.status === 'error' &&
|
|
221
|
+
/ENOTFOUND|ECONNREFUSED|ETIMEDOUT|ECONNRESET|network/i.test(c.message || ''));
|
|
222
|
+
});
|
|
223
|
+
if (coreErrored) {
|
|
224
|
+
return EXIT.NETWORK_ERROR;
|
|
225
|
+
}
|
|
226
|
+
|
|
162
227
|
// Determine exit code based on findings
|
|
163
228
|
if (options.ci) {
|
|
164
229
|
const minScore = parseInt(options.minScore);
|
|
230
|
+
if (isNaN(minScore)) {
|
|
231
|
+
if (jsonMode) {
|
|
232
|
+
console.log(JSON.stringify({ error: 'Invalid --min-score value', code: 'ERR_BAD_ARGS' }, null, 2));
|
|
233
|
+
} else {
|
|
234
|
+
console.error(chalk.red('Error: Invalid --min-score value. Must be a number.'));
|
|
235
|
+
}
|
|
236
|
+
return EXIT.BAD_ARGS;
|
|
237
|
+
}
|
|
165
238
|
const percentage = Math.round((results.score / results.maxScore) * 100);
|
|
166
239
|
if (percentage < minScore) {
|
|
167
240
|
if (!jsonMode && !options.brief && !quietMode) {
|
|
@@ -169,6 +242,7 @@ async function runSingleScan(url, options) {
|
|
|
169
242
|
}
|
|
170
243
|
return EXIT.ISSUES_FOUND;
|
|
171
244
|
}
|
|
245
|
+
return EXIT.SUCCESS;
|
|
172
246
|
}
|
|
173
247
|
|
|
174
248
|
// Exit 1 if there are failures, 0 if clean
|
|
@@ -182,7 +256,7 @@ async function runSingleScan(url, options) {
|
|
|
182
256
|
const code = isNetworkError(err) ? 'ERR_NETWORK' : 'ERR_SCAN';
|
|
183
257
|
console.log(JSON.stringify({ error: err.message, code }, null, 2));
|
|
184
258
|
} else {
|
|
185
|
-
console.error(chalk.red
|
|
259
|
+
console.error(chalk.red('Error:'), err.message);
|
|
186
260
|
console.error('');
|
|
187
261
|
}
|
|
188
262
|
return isNetworkError(err) ? EXIT.NETWORK_ERROR : EXIT.ISSUES_FOUND;
|
|
@@ -198,7 +272,7 @@ async function runBatchMode(options) {
|
|
|
198
272
|
if (jsonMode) {
|
|
199
273
|
console.log(JSON.stringify({ error: 'No URLs provided on stdin', code: 'ERR_NO_INPUT' }, null, 2));
|
|
200
274
|
} else {
|
|
201
|
-
console.error(chalk.red('No URLs provided. Pipe URLs via stdin:'));
|
|
275
|
+
console.error(chalk.red('Error: No URLs provided. Pipe URLs via stdin:'));
|
|
202
276
|
console.error(chalk.gray(' cat urls.txt | mpx-scan --batch --json'));
|
|
203
277
|
}
|
|
204
278
|
process.exit(EXIT.BAD_ARGS);
|
|
@@ -211,7 +285,7 @@ async function runBatchMode(options) {
|
|
|
211
285
|
if (jsonMode) {
|
|
212
286
|
console.log(JSON.stringify({ error: 'No valid URLs found in input', code: 'ERR_NO_INPUT' }, null, 2));
|
|
213
287
|
} else {
|
|
214
|
-
console.error(chalk.red('No valid URLs found in input.'));
|
|
288
|
+
console.error(chalk.red('Error: No valid URLs found in input.'));
|
|
215
289
|
}
|
|
216
290
|
process.exit(EXIT.BAD_ARGS);
|
|
217
291
|
return;
|
|
@@ -345,7 +419,7 @@ program
|
|
|
345
419
|
console.log(chalk.gray(' • Batch scanning'));
|
|
346
420
|
console.log('');
|
|
347
421
|
} catch (err) {
|
|
348
|
-
console.error(chalk.red
|
|
422
|
+
console.error(chalk.red('Error:'), err.message);
|
|
349
423
|
console.error('');
|
|
350
424
|
process.exit(EXIT.CONFIG_ERROR);
|
|
351
425
|
}
|
|
@@ -362,6 +436,79 @@ program
|
|
|
362
436
|
console.log('');
|
|
363
437
|
});
|
|
364
438
|
|
|
439
|
+
// Update subcommand
|
|
440
|
+
program
|
|
441
|
+
.command('update')
|
|
442
|
+
.description('Check for updates and optionally install the latest version')
|
|
443
|
+
.option('--check', 'Only check for updates (do not install)')
|
|
444
|
+
.option('--json', 'Machine-readable JSON output')
|
|
445
|
+
.action(async (options, cmd) => {
|
|
446
|
+
const { checkForUpdate, performUpdate } = require('../src/update');
|
|
447
|
+
const jsonMode = options.json || cmd.parent?.opts()?.json;
|
|
448
|
+
|
|
449
|
+
try {
|
|
450
|
+
const info = checkForUpdate();
|
|
451
|
+
|
|
452
|
+
if (jsonMode) {
|
|
453
|
+
const output = {
|
|
454
|
+
current: info.current,
|
|
455
|
+
latest: info.latest,
|
|
456
|
+
updateAvailable: info.updateAvailable,
|
|
457
|
+
isGlobal: info.isGlobal
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
if (!options.check && info.updateAvailable) {
|
|
461
|
+
try {
|
|
462
|
+
const result = performUpdate(info.isGlobal);
|
|
463
|
+
output.updated = true;
|
|
464
|
+
output.newVersion = result.version;
|
|
465
|
+
} catch (err) {
|
|
466
|
+
output.updated = false;
|
|
467
|
+
output.error = err.message;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
console.log(JSON.stringify(output, null, 2));
|
|
472
|
+
process.exit(EXIT.SUCCESS);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Human-readable output
|
|
477
|
+
if (!info.updateAvailable) {
|
|
478
|
+
console.log('');
|
|
479
|
+
console.log(chalk.green.bold(`✓ mpx-scan v${info.current} is up to date`));
|
|
480
|
+
console.log('');
|
|
481
|
+
process.exit(EXIT.SUCCESS);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
console.log('');
|
|
486
|
+
console.log(chalk.yellow.bold(`⬆ Update available: v${info.current} → v${info.latest}`));
|
|
487
|
+
|
|
488
|
+
if (options.check) {
|
|
489
|
+
console.log(chalk.gray(`Run ${chalk.cyan('mpx-scan update')} to install`));
|
|
490
|
+
console.log('');
|
|
491
|
+
process.exit(EXIT.SUCCESS);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
console.log(chalk.gray(`Installing v${info.latest}${info.isGlobal ? ' (global)' : ''}...`));
|
|
496
|
+
|
|
497
|
+
const result = performUpdate(info.isGlobal);
|
|
498
|
+
console.log(chalk.green.bold(`✓ Updated to v${result.version}`));
|
|
499
|
+
console.log('');
|
|
500
|
+
process.exit(EXIT.SUCCESS);
|
|
501
|
+
} catch (err) {
|
|
502
|
+
if (jsonMode) {
|
|
503
|
+
console.log(JSON.stringify({ error: err.message, code: 'ERR_UPDATE' }, null, 2));
|
|
504
|
+
} else {
|
|
505
|
+
console.error(chalk.red('Error:'), err.message);
|
|
506
|
+
console.error('');
|
|
507
|
+
}
|
|
508
|
+
process.exit(EXIT.NETWORK_ERROR);
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
|
|
365
512
|
// MCP subcommand
|
|
366
513
|
program
|
|
367
514
|
.command('mcp')
|
|
@@ -384,6 +531,8 @@ ${chalk.bold('Examples:')}
|
|
|
384
531
|
${chalk.cyan('mpx-scan example.com --json')} JSON output
|
|
385
532
|
${chalk.cyan('mpx-scan example.com --fix nginx')} Generate nginx config
|
|
386
533
|
${chalk.cyan('mpx-scan example.com --brief')} One-line summary
|
|
534
|
+
${chalk.cyan('mpx-scan example.com --pdf')} Export PDF report
|
|
535
|
+
${chalk.cyan('mpx-scan example.com --pdf report.pdf')} Export PDF to specific file
|
|
387
536
|
${chalk.cyan('mpx-scan --schema')} Show tool schema (JSON)
|
|
388
537
|
${chalk.cyan('cat urls.txt | mpx-scan --batch --json')} Batch scan from stdin
|
|
389
538
|
${chalk.cyan('mpx-scan mcp')} Start MCP server
|
|
@@ -391,10 +540,8 @@ ${chalk.bold('Examples:')}
|
|
|
391
540
|
|
|
392
541
|
${chalk.bold('Exit Codes:')}
|
|
393
542
|
0 Success, no issues found
|
|
394
|
-
1
|
|
395
|
-
2 Invalid arguments
|
|
396
|
-
3 Configuration error (license, rate limit)
|
|
397
|
-
4 Network/connectivity error
|
|
543
|
+
1 Error or issues found
|
|
544
|
+
2 Invalid usage or bad arguments
|
|
398
545
|
|
|
399
546
|
${chalk.bold('Free vs Pro:')}
|
|
400
547
|
${chalk.yellow('Free:')} 3 scans/day, basic checks (headers, SSL, server)
|
|
@@ -403,4 +550,15 @@ ${chalk.bold('Free vs Pro:')}
|
|
|
403
550
|
${chalk.blue('Upgrade: https://mesaplex.com/mpx-scan')}
|
|
404
551
|
`);
|
|
405
552
|
|
|
406
|
-
|
|
553
|
+
try {
|
|
554
|
+
program.parse();
|
|
555
|
+
} catch (err) {
|
|
556
|
+
if (err.code === 'commander.version') {
|
|
557
|
+
process.exit(0);
|
|
558
|
+
}
|
|
559
|
+
if (err.code !== 'commander.help' && err.code !== 'commander.helpDisplayed') {
|
|
560
|
+
const msg = err.message.startsWith('error:') ? `Error: ${err.message.slice(7)}` : `Error: ${err.message}`;
|
|
561
|
+
console.error(chalk.red(msg));
|
|
562
|
+
process.exit(EXIT.BAD_ARGS);
|
|
563
|
+
}
|
|
564
|
+
}
|
package/package.json
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mpx-scan",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.3.1",
|
|
4
|
+
"description": "Website security scanner CLI. Headers, SSL, cookies, and DNS auditing. AI-native with JSON output and MCP server.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"mpx-scan": "bin/cli.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
-
"test": "node test/run.js"
|
|
11
|
-
"start": "node bin/cli.js"
|
|
10
|
+
"test": "node test/run.js"
|
|
12
11
|
},
|
|
12
|
+
"funding": "https://mesaplex.com/pricing",
|
|
13
13
|
"keywords": [
|
|
14
|
+
"cli",
|
|
15
|
+
"devtools",
|
|
16
|
+
"mesaplex",
|
|
17
|
+
"ai-native",
|
|
18
|
+
"mcp",
|
|
19
|
+
"model-context-protocol",
|
|
20
|
+
"automation",
|
|
21
|
+
"json-output",
|
|
14
22
|
"security",
|
|
15
23
|
"scanner",
|
|
16
24
|
"headers",
|
|
@@ -20,16 +28,10 @@
|
|
|
20
28
|
"owasp",
|
|
21
29
|
"devops",
|
|
22
30
|
"ci-cd",
|
|
23
|
-
"mesaplex",
|
|
24
|
-
"devtools",
|
|
25
31
|
"security-headers",
|
|
26
32
|
"ssl-check",
|
|
27
33
|
"dns-security",
|
|
28
|
-
"cors"
|
|
29
|
-
"mcp",
|
|
30
|
-
"ai-native",
|
|
31
|
-
"model-context-protocol",
|
|
32
|
-
"automation"
|
|
34
|
+
"cors"
|
|
33
35
|
],
|
|
34
36
|
"author": "Mesaplex <support@mesaplex.com>",
|
|
35
37
|
"license": "SEE LICENSE IN LICENSE",
|
|
@@ -38,20 +40,22 @@
|
|
|
38
40
|
"url": "git+https://github.com/mesaplexdev/mpx-scan.git"
|
|
39
41
|
},
|
|
40
42
|
"homepage": "https://github.com/mesaplexdev/mpx-scan#readme",
|
|
41
|
-
"bugs":
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/mesaplexdev/mpx-scan/issues"
|
|
45
|
+
},
|
|
42
46
|
"engines": {
|
|
43
47
|
"node": ">=18.0.0"
|
|
44
48
|
},
|
|
45
49
|
"dependencies": {
|
|
46
50
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
47
51
|
"chalk": "^4.1.2",
|
|
48
|
-
"commander": "^12.0.0"
|
|
52
|
+
"commander": "^12.0.0",
|
|
53
|
+
"pdfkit": "^0.17.2"
|
|
49
54
|
},
|
|
50
55
|
"files": [
|
|
51
56
|
"src/",
|
|
52
57
|
"bin/",
|
|
53
58
|
"README.md",
|
|
54
|
-
"LICENSE"
|
|
55
|
-
"package.json"
|
|
59
|
+
"LICENSE"
|
|
56
60
|
]
|
|
57
61
|
}
|
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.3.0 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 === 'EAI_AGAIN' || 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
|
},
|