mpx-scan 1.1.0 → 1.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mpx-scan",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
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
@@ -58,7 +58,7 @@ async function scan(url, options = {}) {
58
58
  };
59
59
 
60
60
  const allScanners = [
61
- { name: 'headers', fn: scanHeaders, weight: 25 },
61
+ { name: 'headers', fn: scanHeaders, weight: 15 },
62
62
  { name: 'ssl', fn: scanSSL, weight: 20 },
63
63
  { name: 'cookies', fn: scanCookies, weight: 10 },
64
64
  { name: 'server', fn: scanServer, weight: 8 },
@@ -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);
@@ -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
 
@@ -86,7 +86,7 @@ function followRedirect(testUrl, options = {}) {
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
@@ -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,