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/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 per AI-native spec
29
+ // Exit codes
29
30
  const EXIT = {
30
31
  SUCCESS: 0, // Success, no issues found
31
- ISSUES_FOUND: 1, // Success, issues found
32
- BAD_ARGS: 2, // Invalid arguments
33
- CONFIG_ERROR: 3, // Configuration error
34
- NETWORK_ERROR: 4 // Network/connectivity 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, -q', 'Minimal output (results only, no banners)')
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 (default: 70)', '70')
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.help();
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.bold('\n❌ Daily scan limit reached'));
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.bold('\n❌ --full flag requires Pro license'));
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.bold('\n❌ Error:'), err.message);
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.bold('\n❌ Activation failed:'), err.message);
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 Success, issues found
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
- program.parse();
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.2.1",
4
- "description": "Professional website security scanner CLI. Check headers, SSL, cookies, DNS, and get actionable fix suggestions. Part of the Mesaplex developer toolchain.",
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": "https://github.com/mesaplexdev/mpx-scan/issues",
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
- const result = await scanner.fn(parsedUrl, options);
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 {
@@ -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: '1.0.0',
12
+ version: pkg.version,
11
13
  scannedAt: results.scannedAt,
12
14
  scanDuration: results.scanDuration
13
15
  },