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 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.1.0",
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: 25 },
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
- 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 };
@@ -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': 'SiteGuard/0.1 Security Scanner' },
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);
@@ -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': 'SiteGuard/0.1 Security Scanner' },
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: 'HEAD', timeout,
307
- headers: { 'User-Agent': 'SiteGuard/0.1 Security Scanner' },
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); });
@@ -23,8 +23,12 @@ async function scanHeaders(parsedUrl, options = {}) {
23
23
  let score = 0;
24
24
  let maxScore = 0;
25
25
 
26
- // --- Strict-Transport-Security ---
27
- maxScore += 3;
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 += 3;
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 += 2;
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-Security-Policy ---
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 += 1;
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
- // --- X-Frame-Options ---
84
- maxScore += 1;
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 += 1;
74
+ score += 2;
91
75
  } else {
92
76
  checks.push({ name: 'X-Frame-Options', status: 'warn', message: `Unusual value: ${xfo}`, value: xfo });
93
- score += 0.5;
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 += 1;
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 += 1;
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 += 1;
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 += 0.5;
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
- checks.push({ name: 'Referrer-Policy', status: 'warn', message: 'Missing. Browser defaults may leak URL paths in referrer.', recommendation: 'Add: Referrer-Policy: strict-origin-when-cross-origin' });
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: 'HEAD',
191
+ method,
177
192
  timeout,
178
193
  headers: {
179
- 'User-Agent': 'mpx-scan/1.0.1 Security Scanner (https://github.com/mesaplexdev/mpx-scan)'
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 + 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',
88
88
  timeout,
89
- headers: { 'User-Agent': 'SiteGuard/0.1 Security Scanner' },
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
- 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
  }
@@ -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: 'HEAD',
89
+ method,
89
90
  timeout,
90
- headers: { 'User-Agent': 'SiteGuard/0.1 Security Scanner' },
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: 'HEAD',
115
+ method,
111
116
  timeout,
112
117
  headers: {
113
- 'User-Agent': 'SiteGuard/0.1 Security Scanner',
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': 'SiteGuard/0.1 Security Scanner' },
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 || '';
@@ -125,7 +125,7 @@ function fetchBody(parsedUrl, options = {}) {
125
125
  method: 'GET',
126
126
  timeout,
127
127
  headers: {
128
- 'User-Agent': 'SiteGuard/0.1 Security Scanner',
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 };