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 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, -q', 'Minimal output (results only, no banners)')
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 (default: 70)', '70')
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.2.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
- 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
  },
@@ -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 };
@@ -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 + 1000);
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
- const isExternal = redirectTarget.hostname !== originalHost &&
103
- (redirectTarget.hostname.includes('evil.example.com') ||
104
- redirectTarget.href.includes('evil.example.com'));
105
- done({ redirectsToExternal: isExternal, location });
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 };