mpx-scan 1.0.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/LICENSE +32 -0
- package/README.md +277 -0
- package/bin/cli.js +211 -0
- package/package.json +45 -0
- package/src/generators/fixes.js +256 -0
- package/src/index.js +153 -0
- package/src/license.js +187 -0
- package/src/reporters/json.js +32 -0
- package/src/reporters/terminal.js +140 -0
- package/src/scanners/cookies.js +122 -0
- package/src/scanners/dns.js +113 -0
- package/src/scanners/exposed-files.js +231 -0
- package/src/scanners/fingerprint.js +325 -0
- package/src/scanners/headers.js +203 -0
- package/src/scanners/mixed-content.js +109 -0
- package/src/scanners/redirects.js +120 -0
- package/src/scanners/server.js +146 -0
- package/src/scanners/sri.js +162 -0
- package/src/scanners/ssl.js +160 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Technology Fingerprinting Scanner
|
|
3
|
+
*
|
|
4
|
+
* Identifies the CMS, framework, server software, and libraries
|
|
5
|
+
* used by the target. Useful for:
|
|
6
|
+
* - Understanding attack surface
|
|
7
|
+
* - Checking for known vulnerable versions
|
|
8
|
+
* - General security posture awareness
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const https = require('https');
|
|
12
|
+
const http = require('http');
|
|
13
|
+
const { fetchBody } = require('./sri');
|
|
14
|
+
|
|
15
|
+
// Fingerprint signatures — checked against headers, HTML, and cookies
|
|
16
|
+
const SIGNATURES = {
|
|
17
|
+
// CMS
|
|
18
|
+
'WordPress': {
|
|
19
|
+
html: [/wp-content\//i, /wp-includes\//i, /wp-json\//i, /<meta\s+name="generator"\s+content="WordPress[^"]*"/i],
|
|
20
|
+
headers: { 'x-powered-by': /WordPress/i },
|
|
21
|
+
cookies: ['wordpress_', 'wp-settings'],
|
|
22
|
+
paths: ['/wp-login.php', '/wp-admin/'],
|
|
23
|
+
category: 'CMS',
|
|
24
|
+
risk: 'medium',
|
|
25
|
+
note: 'Popular CMS — ensure plugins and core are updated'
|
|
26
|
+
},
|
|
27
|
+
'Drupal': {
|
|
28
|
+
html: [/Drupal\.settings/i, /sites\/default\/files/i, /<meta\s+name="generator"\s+content="Drupal/i],
|
|
29
|
+
headers: { 'x-generator': /Drupal/i, 'x-drupal-cache': /./ },
|
|
30
|
+
category: 'CMS',
|
|
31
|
+
risk: 'medium',
|
|
32
|
+
note: 'Enterprise CMS — keep core and modules updated'
|
|
33
|
+
},
|
|
34
|
+
'Joomla': {
|
|
35
|
+
html: [/\/media\/jui\//i, /\/components\/com_/i, /<meta\s+name="generator"\s+content="Joomla/i],
|
|
36
|
+
category: 'CMS',
|
|
37
|
+
risk: 'medium',
|
|
38
|
+
note: 'CMS with history of plugin vulnerabilities'
|
|
39
|
+
},
|
|
40
|
+
'Shopify': {
|
|
41
|
+
html: [/cdn\.shopify\.com/i, /Shopify\.theme/i],
|
|
42
|
+
headers: { 'x-shopify-stage': /./ },
|
|
43
|
+
category: 'E-commerce',
|
|
44
|
+
risk: 'low',
|
|
45
|
+
note: 'Managed platform — security handled by Shopify'
|
|
46
|
+
},
|
|
47
|
+
'Squarespace': {
|
|
48
|
+
html: [/squarespace\.com/i, /static\.squarespace\.com/i],
|
|
49
|
+
category: 'CMS',
|
|
50
|
+
risk: 'low',
|
|
51
|
+
note: 'Managed platform'
|
|
52
|
+
},
|
|
53
|
+
'Wix': {
|
|
54
|
+
html: [/wix\.com/i, /static\.parastorage\.com/i, /wixstatic\.com/i],
|
|
55
|
+
category: 'CMS',
|
|
56
|
+
risk: 'low',
|
|
57
|
+
note: 'Managed platform'
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
// Frameworks
|
|
61
|
+
'React': {
|
|
62
|
+
html: [/data-reactroot/i, /react\.production\.min\.js/i, /__NEXT_DATA__/i, /_next\//i],
|
|
63
|
+
category: 'Framework',
|
|
64
|
+
risk: 'info'
|
|
65
|
+
},
|
|
66
|
+
'Next.js': {
|
|
67
|
+
html: [/__NEXT_DATA__/i, /_next\/static/i],
|
|
68
|
+
headers: { 'x-powered-by': /Next\.js/i },
|
|
69
|
+
category: 'Framework',
|
|
70
|
+
risk: 'info'
|
|
71
|
+
},
|
|
72
|
+
'Vue.js': {
|
|
73
|
+
html: [/vue\.min\.js/i, /vue\.runtime/i, /data-v-[a-f0-9]/i, /__vue/i],
|
|
74
|
+
category: 'Framework',
|
|
75
|
+
risk: 'info'
|
|
76
|
+
},
|
|
77
|
+
'Nuxt.js': {
|
|
78
|
+
html: [/__nuxt/i, /_nuxt\//i],
|
|
79
|
+
category: 'Framework',
|
|
80
|
+
risk: 'info'
|
|
81
|
+
},
|
|
82
|
+
'Angular': {
|
|
83
|
+
html: [/ng-version/i, /angular\.min\.js/i, /ng-app/i],
|
|
84
|
+
category: 'Framework',
|
|
85
|
+
risk: 'info'
|
|
86
|
+
},
|
|
87
|
+
'Ruby on Rails': {
|
|
88
|
+
headers: { 'x-powered-by': /Phusion Passenger/i, 'server': /Phusion/i },
|
|
89
|
+
cookies: ['_session_id'],
|
|
90
|
+
html: [/csrf-token/i],
|
|
91
|
+
category: 'Framework',
|
|
92
|
+
risk: 'info'
|
|
93
|
+
},
|
|
94
|
+
'Django': {
|
|
95
|
+
cookies: ['django_language', 'sessionid'],
|
|
96
|
+
html: [/csrfmiddlewaretoken/i, /django/i],
|
|
97
|
+
category: 'Framework',
|
|
98
|
+
risk: 'info'
|
|
99
|
+
},
|
|
100
|
+
'Laravel': {
|
|
101
|
+
cookies: ['laravel_session', 'XSRF-TOKEN'],
|
|
102
|
+
html: [/laravel/i],
|
|
103
|
+
category: 'Framework',
|
|
104
|
+
risk: 'info'
|
|
105
|
+
},
|
|
106
|
+
'Express.js': {
|
|
107
|
+
headers: { 'x-powered-by': /Express/i },
|
|
108
|
+
category: 'Framework',
|
|
109
|
+
risk: 'info',
|
|
110
|
+
note: 'Remove X-Powered-By header in production'
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
// Servers
|
|
114
|
+
'Nginx': {
|
|
115
|
+
headers: { 'server': /nginx/i },
|
|
116
|
+
category: 'Server',
|
|
117
|
+
risk: 'info'
|
|
118
|
+
},
|
|
119
|
+
'Apache': {
|
|
120
|
+
headers: { 'server': /Apache/i },
|
|
121
|
+
category: 'Server',
|
|
122
|
+
risk: 'info'
|
|
123
|
+
},
|
|
124
|
+
'Cloudflare': {
|
|
125
|
+
headers: { 'server': /cloudflare/i, 'cf-ray': /./ },
|
|
126
|
+
category: 'CDN/WAF',
|
|
127
|
+
risk: 'low',
|
|
128
|
+
note: 'Protected by Cloudflare WAF'
|
|
129
|
+
},
|
|
130
|
+
'AWS CloudFront': {
|
|
131
|
+
headers: { 'server': /CloudFront/i, 'x-amz-cf-id': /./ },
|
|
132
|
+
category: 'CDN',
|
|
133
|
+
risk: 'low'
|
|
134
|
+
},
|
|
135
|
+
'Vercel': {
|
|
136
|
+
headers: { 'server': /Vercel/i, 'x-vercel-id': /./ },
|
|
137
|
+
category: 'Platform',
|
|
138
|
+
risk: 'low'
|
|
139
|
+
},
|
|
140
|
+
'Netlify': {
|
|
141
|
+
headers: { 'server': /Netlify/i },
|
|
142
|
+
category: 'Platform',
|
|
143
|
+
risk: 'low'
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
// Security
|
|
147
|
+
'Akamai': {
|
|
148
|
+
headers: { 'server': /AkamaiGHost/i, 'x-akamai-session-info': /./ },
|
|
149
|
+
category: 'CDN/WAF',
|
|
150
|
+
risk: 'low',
|
|
151
|
+
note: 'Protected by Akamai WAF'
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
// Analytics/Libraries
|
|
155
|
+
'jQuery': {
|
|
156
|
+
html: [/jquery[-.][\d.]*\.min\.js/i, /jquery\.min\.js/i],
|
|
157
|
+
category: 'Library',
|
|
158
|
+
risk: 'info',
|
|
159
|
+
note: 'Check for outdated versions with known XSS vulnerabilities'
|
|
160
|
+
},
|
|
161
|
+
'Bootstrap': {
|
|
162
|
+
html: [/bootstrap[-.][\d.]*\.min\.(js|css)/i, /bootstrap\.min\.(js|css)/i],
|
|
163
|
+
category: 'Library',
|
|
164
|
+
risk: 'info'
|
|
165
|
+
},
|
|
166
|
+
'Google Analytics': {
|
|
167
|
+
html: [/google-analytics\.com\/analytics/i, /googletagmanager\.com/i, /gtag\(/i],
|
|
168
|
+
category: 'Analytics',
|
|
169
|
+
risk: 'info'
|
|
170
|
+
},
|
|
171
|
+
'Google Tag Manager': {
|
|
172
|
+
html: [/googletagmanager\.com\/gtm\.js/i],
|
|
173
|
+
category: 'Analytics',
|
|
174
|
+
risk: 'info'
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
async function scanFingerprint(parsedUrl, options = {}) {
|
|
179
|
+
const checks = [];
|
|
180
|
+
let score = 0;
|
|
181
|
+
let maxScore = 3; // Based on information exposure
|
|
182
|
+
|
|
183
|
+
// Fetch headers and body
|
|
184
|
+
const [headers, html] = await Promise.all([
|
|
185
|
+
fetchHeaders(parsedUrl, options),
|
|
186
|
+
fetchBody(parsedUrl, options)
|
|
187
|
+
]);
|
|
188
|
+
|
|
189
|
+
const detected = [];
|
|
190
|
+
|
|
191
|
+
for (const [tech, sig] of Object.entries(SIGNATURES)) {
|
|
192
|
+
let found = false;
|
|
193
|
+
let evidence = '';
|
|
194
|
+
|
|
195
|
+
// Check headers
|
|
196
|
+
if (sig.headers && headers) {
|
|
197
|
+
for (const [header, pattern] of Object.entries(sig.headers)) {
|
|
198
|
+
if (headers[header] && pattern.test(headers[header])) {
|
|
199
|
+
found = true;
|
|
200
|
+
evidence = `Header: ${header}: ${headers[header]}`;
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Check HTML patterns
|
|
207
|
+
if (!found && sig.html && html) {
|
|
208
|
+
for (const pattern of sig.html) {
|
|
209
|
+
if (pattern.test(html)) {
|
|
210
|
+
found = true;
|
|
211
|
+
evidence = 'HTML content match';
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Check cookies
|
|
218
|
+
if (!found && sig.cookies && headers && headers['set-cookie']) {
|
|
219
|
+
const cookieStr = Array.isArray(headers['set-cookie']) ? headers['set-cookie'].join(' ') : headers['set-cookie'];
|
|
220
|
+
for (const cookieName of sig.cookies) {
|
|
221
|
+
if (cookieStr.toLowerCase().includes(cookieName.toLowerCase())) {
|
|
222
|
+
found = true;
|
|
223
|
+
evidence = `Cookie: ${cookieName}`;
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (found) {
|
|
230
|
+
detected.push({ name: tech, ...sig, evidence });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Check for version leaks in Server header
|
|
235
|
+
const serverHeader = headers?.server || '';
|
|
236
|
+
const versionMatch = serverHeader.match(/([\w.-]+)\/([\d.]+)/);
|
|
237
|
+
let leaksVersion = false;
|
|
238
|
+
|
|
239
|
+
if (versionMatch) {
|
|
240
|
+
leaksVersion = true;
|
|
241
|
+
checks.push({ name: 'Server Version Exposure', status: 'warn',
|
|
242
|
+
message: `Server header reveals: ${versionMatch[0]}. Attackers can target version-specific exploits.`,
|
|
243
|
+
value: serverHeader,
|
|
244
|
+
recommendation: 'Remove version numbers from Server header'
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Check X-Powered-By
|
|
249
|
+
const poweredBy = headers?.['x-powered-by'];
|
|
250
|
+
if (poweredBy) {
|
|
251
|
+
leaksVersion = true;
|
|
252
|
+
checks.push({ name: 'X-Powered-By Exposure', status: 'warn',
|
|
253
|
+
message: `Reveals technology: "${poweredBy}". Aids attacker reconnaissance.`,
|
|
254
|
+
value: poweredBy,
|
|
255
|
+
recommendation: 'Remove X-Powered-By header entirely'
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Score based on information exposure
|
|
260
|
+
if (!leaksVersion && detected.filter(d => d.risk !== 'info').length === 0) {
|
|
261
|
+
score = 3;
|
|
262
|
+
checks.unshift({ name: 'Technology Fingerprint', status: 'pass', message: 'Minimal technology exposure — good operational security' });
|
|
263
|
+
} else if (!leaksVersion) {
|
|
264
|
+
score = 2;
|
|
265
|
+
checks.unshift({ name: 'Technology Fingerprint', status: 'pass', message: 'No version information leaked in headers' });
|
|
266
|
+
} else {
|
|
267
|
+
score = 1;
|
|
268
|
+
checks.unshift({ name: 'Technology Fingerprint', status: 'warn', message: 'Server exposes technology/version information' });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// List detected technologies
|
|
272
|
+
if (detected.length > 0) {
|
|
273
|
+
const byCategory = {};
|
|
274
|
+
for (const tech of detected) {
|
|
275
|
+
if (!byCategory[tech.category]) byCategory[tech.category] = [];
|
|
276
|
+
byCategory[tech.category].push(tech);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
for (const [category, techs] of Object.entries(byCategory)) {
|
|
280
|
+
const names = techs.map(t => t.name).join(', ');
|
|
281
|
+
checks.push({ name: `Detected: ${category}`, status: 'info', message: names, value: names });
|
|
282
|
+
|
|
283
|
+
// Add notes for notable detections
|
|
284
|
+
for (const tech of techs) {
|
|
285
|
+
if (tech.note) {
|
|
286
|
+
checks.push({ name: tech.name, status: tech.risk === 'medium' ? 'warn' : 'info', message: tech.note });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
} else {
|
|
291
|
+
checks.push({ name: 'Technologies', status: 'info', message: 'No technologies confidently identified from external scan' });
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return { score, maxScore, checks };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function fetchHeaders(parsedUrl, options = {}) {
|
|
298
|
+
return new Promise((resolve) => {
|
|
299
|
+
const timeout = options.timeout || 10000;
|
|
300
|
+
const protocol = parsedUrl.protocol === 'https:' ? https : http;
|
|
301
|
+
let resolved = false;
|
|
302
|
+
const done = (val) => { if (!resolved) { resolved = true; resolve(val); } };
|
|
303
|
+
const timer = setTimeout(() => done(null), timeout + 2000);
|
|
304
|
+
|
|
305
|
+
const req = protocol.request(parsedUrl.href, {
|
|
306
|
+
method: 'HEAD', timeout,
|
|
307
|
+
headers: { 'User-Agent': 'SiteGuard/0.1 Security Scanner' },
|
|
308
|
+
rejectUnauthorized: false,
|
|
309
|
+
}, (res) => {
|
|
310
|
+
clearTimeout(timer);
|
|
311
|
+
// Follow redirects
|
|
312
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
313
|
+
const redirectUrl = new URL(res.headers.location, parsedUrl.href);
|
|
314
|
+
fetchHeaders(redirectUrl, options).then(done);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
done(res.headers);
|
|
318
|
+
});
|
|
319
|
+
req.on('error', () => { clearTimeout(timer); done(null); });
|
|
320
|
+
req.on('timeout', () => { clearTimeout(timer); req.destroy(); done(null); });
|
|
321
|
+
req.end();
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
module.exports = { scanFingerprint };
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security Headers Scanner
|
|
3
|
+
*
|
|
4
|
+
* Checks for presence and configuration of security headers:
|
|
5
|
+
* - Strict-Transport-Security (HSTS)
|
|
6
|
+
* - Content-Security-Policy (CSP)
|
|
7
|
+
* - X-Content-Type-Options
|
|
8
|
+
* - X-Frame-Options
|
|
9
|
+
* - Referrer-Policy
|
|
10
|
+
* - Permissions-Policy
|
|
11
|
+
* - X-XSS-Protection (deprecated but still checked)
|
|
12
|
+
* - Cross-Origin-Opener-Policy
|
|
13
|
+
* - Cross-Origin-Resource-Policy
|
|
14
|
+
* - Cross-Origin-Embedder-Policy
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const https = require('https');
|
|
18
|
+
const http = require('http');
|
|
19
|
+
|
|
20
|
+
async function scanHeaders(parsedUrl, options = {}) {
|
|
21
|
+
const headers = await fetchHeaders(parsedUrl, options);
|
|
22
|
+
const checks = [];
|
|
23
|
+
let score = 0;
|
|
24
|
+
let maxScore = 0;
|
|
25
|
+
|
|
26
|
+
// --- Strict-Transport-Security ---
|
|
27
|
+
maxScore += 3;
|
|
28
|
+
const hsts = headers['strict-transport-security'];
|
|
29
|
+
if (hsts) {
|
|
30
|
+
const maxAge = parseInt((hsts.match(/max-age=(\d+)/) || [])[1] || '0');
|
|
31
|
+
const includesSubs = /includesubdomains/i.test(hsts);
|
|
32
|
+
const preload = /preload/i.test(hsts);
|
|
33
|
+
|
|
34
|
+
if (maxAge >= 31536000 && includesSubs && preload) {
|
|
35
|
+
checks.push({ name: 'Strict-Transport-Security', status: 'pass', message: `Excellent. max-age=${maxAge}, includeSubDomains, preload`, value: hsts });
|
|
36
|
+
score += 3;
|
|
37
|
+
} else if (maxAge >= 31536000) {
|
|
38
|
+
checks.push({ name: 'Strict-Transport-Security', status: 'pass', message: `Good. max-age=${maxAge}${includesSubs ? ', includeSubDomains' : ''}${preload ? ', preload' : ''}`, value: hsts });
|
|
39
|
+
score += 2;
|
|
40
|
+
} else if (maxAge > 0) {
|
|
41
|
+
checks.push({ name: 'Strict-Transport-Security', status: 'warn', message: `max-age=${maxAge} is low. Recommend >= 31536000 (1 year)`, value: hsts });
|
|
42
|
+
score += 1;
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
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
|
+
}
|
|
47
|
+
|
|
48
|
+
// --- Content-Security-Policy ---
|
|
49
|
+
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
|
+
const xcto = headers['x-content-type-options'];
|
|
76
|
+
if (xcto && xcto.toLowerCase() === 'nosniff') {
|
|
77
|
+
checks.push({ name: 'X-Content-Type-Options', status: 'pass', message: 'nosniff — prevents MIME-type sniffing', value: xcto });
|
|
78
|
+
score += 1;
|
|
79
|
+
} else {
|
|
80
|
+
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
|
+
}
|
|
82
|
+
|
|
83
|
+
// --- X-Frame-Options ---
|
|
84
|
+
maxScore += 1;
|
|
85
|
+
const xfo = headers['x-frame-options'];
|
|
86
|
+
if (xfo) {
|
|
87
|
+
const val = xfo.toUpperCase();
|
|
88
|
+
if (val === 'DENY' || val === 'SAMEORIGIN') {
|
|
89
|
+
checks.push({ name: 'X-Frame-Options', status: 'pass', message: `${val} — prevents clickjacking`, value: xfo });
|
|
90
|
+
score += 1;
|
|
91
|
+
} else {
|
|
92
|
+
checks.push({ name: 'X-Frame-Options', status: 'warn', message: `Unusual value: ${xfo}`, value: xfo });
|
|
93
|
+
score += 0.5;
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
// Check if CSP has frame-ancestors
|
|
97
|
+
if (csp && /frame-ancestors/i.test(csp)) {
|
|
98
|
+
checks.push({ name: 'X-Frame-Options', status: 'pass', message: 'Not set, but CSP frame-ancestors provides equivalent protection', value: 'via CSP' });
|
|
99
|
+
score += 1;
|
|
100
|
+
} else {
|
|
101
|
+
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
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- Referrer-Policy ---
|
|
106
|
+
maxScore += 1;
|
|
107
|
+
const rp = headers['referrer-policy'];
|
|
108
|
+
const goodPolicies = ['no-referrer', 'strict-origin-when-cross-origin', 'strict-origin', 'same-origin', 'no-referrer-when-downgrade'];
|
|
109
|
+
if (rp && goodPolicies.some(p => rp.toLowerCase().includes(p))) {
|
|
110
|
+
checks.push({ name: 'Referrer-Policy', status: 'pass', message: `${rp}`, value: rp });
|
|
111
|
+
score += 1;
|
|
112
|
+
} else if (rp) {
|
|
113
|
+
checks.push({ name: 'Referrer-Policy', status: 'warn', message: `Set to "${rp}" — may leak referrer data`, value: rp });
|
|
114
|
+
score += 0.5;
|
|
115
|
+
} 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' });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// --- Permissions-Policy ---
|
|
120
|
+
maxScore += 1;
|
|
121
|
+
const pp = headers['permissions-policy'] || headers['feature-policy'];
|
|
122
|
+
if (pp) {
|
|
123
|
+
checks.push({ name: 'Permissions-Policy', status: 'pass', message: 'Controls browser feature access', value: pp.substring(0, 200) });
|
|
124
|
+
score += 1;
|
|
125
|
+
} else {
|
|
126
|
+
checks.push({ name: 'Permissions-Policy', status: 'warn', message: 'Missing. Browser features (camera, mic, geolocation) unrestricted.', recommendation: 'Add: Permissions-Policy: camera=(), microphone=(), geolocation=()' });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// --- Cross-Origin-Opener-Policy ---
|
|
130
|
+
maxScore += 0.5;
|
|
131
|
+
const coop = headers['cross-origin-opener-policy'];
|
|
132
|
+
if (coop) {
|
|
133
|
+
checks.push({ name: 'Cross-Origin-Opener-Policy', status: 'pass', message: coop, value: coop });
|
|
134
|
+
score += 0.5;
|
|
135
|
+
} else {
|
|
136
|
+
checks.push({ name: 'Cross-Origin-Opener-Policy', status: 'info', message: 'Not set. Consider adding for cross-origin isolation.' });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// --- Cross-Origin-Resource-Policy ---
|
|
140
|
+
maxScore += 0.5;
|
|
141
|
+
const corp = headers['cross-origin-resource-policy'];
|
|
142
|
+
if (corp) {
|
|
143
|
+
checks.push({ name: 'Cross-Origin-Resource-Policy', status: 'pass', message: corp, value: corp });
|
|
144
|
+
score += 0.5;
|
|
145
|
+
} else {
|
|
146
|
+
checks.push({ name: 'Cross-Origin-Resource-Policy', status: 'info', message: 'Not set. Consider adding to control resource sharing.' });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// --- Deprecated but notable ---
|
|
150
|
+
const xxss = headers['x-xss-protection'];
|
|
151
|
+
if (xxss && xxss !== '0') {
|
|
152
|
+
checks.push({ name: 'X-XSS-Protection', status: 'info', message: `Set to "${xxss}". This header is deprecated; CSP is the modern replacement.`, value: xxss });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// --- Server header leak ---
|
|
156
|
+
const server = headers['server'];
|
|
157
|
+
if (server && /\d/.test(server)) {
|
|
158
|
+
checks.push({ name: 'Server Header', status: 'warn', message: `Leaks version info: "${server}". Remove version numbers.`, value: server });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// --- X-Powered-By leak ---
|
|
162
|
+
const powered = headers['x-powered-by'];
|
|
163
|
+
if (powered) {
|
|
164
|
+
checks.push({ name: 'X-Powered-By', status: 'warn', message: `Leaks technology: "${powered}". Remove this header.`, value: powered });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return { score, maxScore, checks };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function fetchHeaders(parsedUrl, options = {}) {
|
|
171
|
+
return new Promise((resolve, reject) => {
|
|
172
|
+
const timeout = options.timeout || 10000;
|
|
173
|
+
const protocol = parsedUrl.protocol === 'https:' ? https : http;
|
|
174
|
+
|
|
175
|
+
const req = protocol.request(parsedUrl.href, {
|
|
176
|
+
method: 'HEAD',
|
|
177
|
+
timeout,
|
|
178
|
+
headers: {
|
|
179
|
+
'User-Agent': 'SiteGuard/0.1 Security Scanner (https://github.com/persio10/siteguard)'
|
|
180
|
+
},
|
|
181
|
+
rejectUnauthorized: false // We check SSL separately
|
|
182
|
+
}, (res) => {
|
|
183
|
+
// Follow redirects (up to 5)
|
|
184
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
185
|
+
const redirectUrl = new URL(res.headers.location, parsedUrl.href);
|
|
186
|
+
if ((options._redirectCount || 0) >= 5) {
|
|
187
|
+
reject(new Error('Too many redirects'));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
fetchHeaders(redirectUrl, { ...options, _redirectCount: (options._redirectCount || 0) + 1 })
|
|
191
|
+
.then(resolve).catch(reject);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
resolve(res.headers);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
req.on('error', reject);
|
|
198
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('Connection timeout')); });
|
|
199
|
+
req.end();
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
module.exports = { scanHeaders };
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mixed Content Scanner
|
|
3
|
+
*
|
|
4
|
+
* Detects HTTP resources loaded on HTTPS pages.
|
|
5
|
+
* Mixed content can be blocked by browsers and indicates security gaps.
|
|
6
|
+
*
|
|
7
|
+
* Active mixed content (scripts, iframes) = high risk
|
|
8
|
+
* Passive mixed content (images, video) = medium risk
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { fetchBody } = require('./sri');
|
|
12
|
+
|
|
13
|
+
async function scanMixedContent(parsedUrl, options = {}) {
|
|
14
|
+
const checks = [];
|
|
15
|
+
let score = 0;
|
|
16
|
+
let maxScore = 2;
|
|
17
|
+
|
|
18
|
+
if (parsedUrl.protocol !== 'https:') {
|
|
19
|
+
checks.push({ name: 'Mixed Content', status: 'info', message: 'Site served over HTTP — mixed content check not applicable' });
|
|
20
|
+
return { score: 0, maxScore: 0, checks };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const html = await fetchBody(parsedUrl, options);
|
|
24
|
+
|
|
25
|
+
if (!html) {
|
|
26
|
+
checks.push({ name: 'Mixed Content', status: 'error', message: 'Could not fetch page HTML' });
|
|
27
|
+
return { score: 0, maxScore: 2, checks };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Active mixed content (high risk — blocked by most browsers)
|
|
31
|
+
const activePatterns = [
|
|
32
|
+
{ regex: /<script[^>]+src\s*=\s*["'](http:\/\/[^"']+)["']/gi, type: 'script' },
|
|
33
|
+
{ regex: /<iframe[^>]+src\s*=\s*["'](http:\/\/[^"']+)["']/gi, type: 'iframe' },
|
|
34
|
+
{ regex: /<link[^>]+href\s*=\s*["'](http:\/\/[^"']+)["'][^>]*rel\s*=\s*["']stylesheet["']/gi, type: 'stylesheet' },
|
|
35
|
+
{ regex: /<object[^>]+data\s*=\s*["'](http:\/\/[^"']+)["']/gi, type: 'object' },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
// Passive mixed content (medium risk — may show warnings)
|
|
39
|
+
const passivePatterns = [
|
|
40
|
+
{ regex: /<img[^>]+src\s*=\s*["'](http:\/\/[^"']+)["']/gi, type: 'image' },
|
|
41
|
+
{ regex: /<video[^>]+src\s*=\s*["'](http:\/\/[^"']+)["']/gi, type: 'video' },
|
|
42
|
+
{ regex: /<audio[^>]+src\s*=\s*["'](http:\/\/[^"']+)["']/gi, type: 'audio' },
|
|
43
|
+
{ regex: /<source[^>]+src\s*=\s*["'](http:\/\/[^"']+)["']/gi, type: 'media source' },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
// Also check CSS url() references
|
|
47
|
+
const cssUrlPattern = /url\(\s*["']?(http:\/\/[^"')]+)["']?\s*\)/gi;
|
|
48
|
+
|
|
49
|
+
const activeIssues = [];
|
|
50
|
+
const passiveIssues = [];
|
|
51
|
+
|
|
52
|
+
for (const { regex, type } of activePatterns) {
|
|
53
|
+
let match;
|
|
54
|
+
while ((match = regex.exec(html)) !== null) {
|
|
55
|
+
activeIssues.push({ url: match[1], type });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const { regex, type } of passivePatterns) {
|
|
60
|
+
let match;
|
|
61
|
+
while ((match = regex.exec(html)) !== null) {
|
|
62
|
+
passiveIssues.push({ url: match[1], type });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let cssMatch;
|
|
67
|
+
while ((cssMatch = cssUrlPattern.exec(html)) !== null) {
|
|
68
|
+
passiveIssues.push({ url: cssMatch[1], type: 'css-url' });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Score
|
|
72
|
+
if (activeIssues.length === 0 && passiveIssues.length === 0) {
|
|
73
|
+
checks.push({ name: 'Mixed Content', status: 'pass', message: 'No HTTP resources detected on HTTPS page' });
|
|
74
|
+
score = 2;
|
|
75
|
+
} else {
|
|
76
|
+
if (activeIssues.length > 0) {
|
|
77
|
+
checks.push({ name: 'Active Mixed Content', status: 'fail',
|
|
78
|
+
message: `${activeIssues.length} active HTTP resource(s) — scripts/styles loaded over HTTP on HTTPS page. Blocked by modern browsers.`,
|
|
79
|
+
recommendation: 'Change all resource URLs from http:// to https:// or use protocol-relative URLs (//)'
|
|
80
|
+
});
|
|
81
|
+
// List first 5
|
|
82
|
+
activeIssues.slice(0, 5).forEach(issue => {
|
|
83
|
+
checks.push({ name: `HTTP ${issue.type}`, status: 'fail', message: issue.url.substring(0, 100), value: issue.url });
|
|
84
|
+
});
|
|
85
|
+
if (activeIssues.length > 5) {
|
|
86
|
+
checks.push({ name: 'Active Mixed Content', status: 'info', message: `...and ${activeIssues.length - 5} more` });
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
score += 1;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (passiveIssues.length > 0) {
|
|
93
|
+
checks.push({ name: 'Passive Mixed Content', status: 'warn',
|
|
94
|
+
message: `${passiveIssues.length} passive HTTP resource(s) — images/media loaded over HTTP. May show browser warnings.`,
|
|
95
|
+
recommendation: 'Update resource URLs to HTTPS'
|
|
96
|
+
});
|
|
97
|
+
passiveIssues.slice(0, 3).forEach(issue => {
|
|
98
|
+
checks.push({ name: `HTTP ${issue.type}`, status: 'warn', message: issue.url.substring(0, 100), value: issue.url });
|
|
99
|
+
});
|
|
100
|
+
score += 0.5;
|
|
101
|
+
} else {
|
|
102
|
+
score += 1;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { score, maxScore, checks };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = { scanMixedContent };
|