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,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open Redirect Scanner
|
|
3
|
+
*
|
|
4
|
+
* Tests for open redirect vulnerabilities by checking if the site
|
|
5
|
+
* redirects to arbitrary external domains via URL parameters.
|
|
6
|
+
*
|
|
7
|
+
* Open redirects are used in phishing attacks — attackers craft URLs
|
|
8
|
+
* that look legitimate but redirect to malicious sites.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const https = require('https');
|
|
12
|
+
const http = require('http');
|
|
13
|
+
|
|
14
|
+
// Common parameter names used for redirects
|
|
15
|
+
const REDIRECT_PARAMS = [
|
|
16
|
+
'url', 'redirect', 'redirect_url', 'redirect_uri', 'return', 'return_url',
|
|
17
|
+
'returnTo', 'return_to', 'next', 'goto', 'dest', 'destination', 'redir',
|
|
18
|
+
'out', 'continue', 'target', 'path', 'callback', 'cb', 'ref',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const EVIL_DOMAIN = 'https://evil.example.com';
|
|
22
|
+
|
|
23
|
+
async function scanRedirects(parsedUrl, options = {}) {
|
|
24
|
+
const checks = [];
|
|
25
|
+
let score = 0;
|
|
26
|
+
let maxScore = 0;
|
|
27
|
+
|
|
28
|
+
// Test each redirect parameter
|
|
29
|
+
const vulnParams = [];
|
|
30
|
+
const safeParams = [];
|
|
31
|
+
const testedCount = Math.min(REDIRECT_PARAMS.length, options.maxRedirectTests || 10);
|
|
32
|
+
maxScore = testedCount;
|
|
33
|
+
|
|
34
|
+
const testPromises = REDIRECT_PARAMS.slice(0, testedCount).map(async (param) => {
|
|
35
|
+
try {
|
|
36
|
+
const testUrl = new URL(parsedUrl.href);
|
|
37
|
+
testUrl.searchParams.set(param, EVIL_DOMAIN);
|
|
38
|
+
|
|
39
|
+
const result = await followRedirect(testUrl, options);
|
|
40
|
+
|
|
41
|
+
if (result.redirectsToExternal) {
|
|
42
|
+
vulnParams.push({ param, redirectedTo: result.location });
|
|
43
|
+
return { param, vulnerable: true };
|
|
44
|
+
} else {
|
|
45
|
+
safeParams.push(param);
|
|
46
|
+
return { param, vulnerable: false };
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
return { param, vulnerable: false };
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const results = await Promise.all(testPromises);
|
|
54
|
+
|
|
55
|
+
// Score
|
|
56
|
+
const vulnCount = results.filter(r => r.vulnerable).length;
|
|
57
|
+
score = testedCount - vulnCount;
|
|
58
|
+
|
|
59
|
+
if (vulnCount === 0) {
|
|
60
|
+
checks.push({ name: 'Open Redirects', status: 'pass', message: `Tested ${testedCount} common redirect parameters — none vulnerable` });
|
|
61
|
+
} else {
|
|
62
|
+
checks.push({ name: 'Open Redirects', status: 'fail',
|
|
63
|
+
message: `${vulnCount} open redirect(s) found! Attackers can craft phishing URLs using your domain.`,
|
|
64
|
+
recommendation: 'Validate redirect destinations against an allowlist of trusted domains. Never redirect to user-supplied URLs without validation.'
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
for (const vuln of vulnParams) {
|
|
68
|
+
checks.push({ name: `Redirect: ?${vuln.param}=`, status: 'fail',
|
|
69
|
+
message: `Redirects to external domain via "${vuln.param}" parameter`,
|
|
70
|
+
value: vuln.redirectedTo
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { score, maxScore, checks };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function followRedirect(testUrl, options = {}) {
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
const timeout = options.timeout || 5000;
|
|
81
|
+
const protocol = testUrl.protocol === 'https:' ? https : http;
|
|
82
|
+
let resolved = false;
|
|
83
|
+
const done = (val) => { if (!resolved) { resolved = true; resolve(val); } };
|
|
84
|
+
const timer = setTimeout(() => done({ redirectsToExternal: false }), timeout + 1000);
|
|
85
|
+
|
|
86
|
+
const req = protocol.request(testUrl.href, {
|
|
87
|
+
method: 'GET',
|
|
88
|
+
timeout,
|
|
89
|
+
headers: { 'User-Agent': 'SiteGuard/0.1 Security Scanner' },
|
|
90
|
+
rejectUnauthorized: false,
|
|
91
|
+
}, (res) => {
|
|
92
|
+
res.resume(); // Consume body
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
|
|
95
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
96
|
+
const location = res.headers.location;
|
|
97
|
+
try {
|
|
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
|
+
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 });
|
|
106
|
+
} catch {
|
|
107
|
+
done({ redirectsToExternal: false });
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
done({ redirectsToExternal: false });
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
req.on('error', () => { clearTimeout(timer); done({ redirectsToExternal: false }); });
|
|
115
|
+
req.on('timeout', () => { clearTimeout(timer); req.destroy(); done({ redirectsToExternal: false }); });
|
|
116
|
+
req.end();
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = { scanRedirects };
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server Configuration Scanner
|
|
3
|
+
*
|
|
4
|
+
* Checks server-level security:
|
|
5
|
+
* - HTTP to HTTPS redirect
|
|
6
|
+
* - Server information leakage
|
|
7
|
+
* - CORS configuration
|
|
8
|
+
* - HTTP methods allowed
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const https = require('https');
|
|
12
|
+
const http = require('http');
|
|
13
|
+
|
|
14
|
+
async function scanServer(parsedUrl, options = {}) {
|
|
15
|
+
const checks = [];
|
|
16
|
+
let score = 0;
|
|
17
|
+
let maxScore = 0;
|
|
18
|
+
|
|
19
|
+
// --- HTTP to HTTPS redirect ---
|
|
20
|
+
maxScore += 2;
|
|
21
|
+
if (parsedUrl.protocol === 'https:') {
|
|
22
|
+
try {
|
|
23
|
+
const httpUrl = new URL(parsedUrl.href);
|
|
24
|
+
httpUrl.protocol = 'http:';
|
|
25
|
+
const redirectsToHttps = await checkRedirectToHttps(httpUrl, options);
|
|
26
|
+
if (redirectsToHttps) {
|
|
27
|
+
checks.push({ name: 'HTTP → HTTPS Redirect', status: 'pass', message: 'HTTP requests redirect to HTTPS' });
|
|
28
|
+
score += 2;
|
|
29
|
+
} else {
|
|
30
|
+
checks.push({ name: 'HTTP → HTTPS Redirect', status: 'warn', message: 'HTTP does not redirect to HTTPS. Users can access insecure version.', recommendation: 'Configure server to redirect all HTTP traffic to HTTPS (301 redirect)' });
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
checks.push({ name: 'HTTP → HTTPS Redirect', status: 'info', message: 'Could not test HTTP redirect (port 80 may be closed)' });
|
|
34
|
+
score += 1; // Benefit of doubt — port 80 closed is actually fine
|
|
35
|
+
}
|
|
36
|
+
} else {
|
|
37
|
+
checks.push({ name: 'HTTPS', status: 'fail', message: 'Site served over HTTP. All traffic is unencrypted.' });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// --- CORS headers ---
|
|
41
|
+
maxScore += 1;
|
|
42
|
+
try {
|
|
43
|
+
const corsHeaders = await fetchWithOrigin(parsedUrl, options);
|
|
44
|
+
const acao = corsHeaders['access-control-allow-origin'];
|
|
45
|
+
if (!acao) {
|
|
46
|
+
checks.push({ name: 'CORS Policy', status: 'pass', message: 'No Access-Control-Allow-Origin header (default same-origin policy)' });
|
|
47
|
+
score += 1;
|
|
48
|
+
} else if (acao === '*') {
|
|
49
|
+
checks.push({ name: 'CORS Policy', status: 'warn', message: 'Access-Control-Allow-Origin: * — allows any origin to read responses', value: acao });
|
|
50
|
+
score += 0.25;
|
|
51
|
+
} else {
|
|
52
|
+
checks.push({ name: 'CORS Policy', status: 'pass', message: `CORS restricted to: ${acao}`, value: acao });
|
|
53
|
+
score += 1;
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
checks.push({ name: 'CORS Policy', status: 'info', message: 'Could not test CORS configuration' });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// --- Allowed HTTP methods ---
|
|
60
|
+
maxScore += 1;
|
|
61
|
+
try {
|
|
62
|
+
const methods = await checkMethods(parsedUrl, options);
|
|
63
|
+
if (methods.length === 0) {
|
|
64
|
+
checks.push({ name: 'HTTP Methods', status: 'info', message: 'Server did not disclose allowed methods (OPTIONS returned no Allow header)' });
|
|
65
|
+
score += 0.5; // Benefit of doubt
|
|
66
|
+
} else {
|
|
67
|
+
const dangerous = methods.filter(m => ['PUT', 'DELETE', 'TRACE', 'TRACK'].includes(m));
|
|
68
|
+
if (dangerous.length > 0) {
|
|
69
|
+
checks.push({ name: 'HTTP Methods', status: 'warn', message: `Potentially dangerous methods enabled: ${dangerous.join(', ')}`, value: methods.join(', ') });
|
|
70
|
+
score += 0.5;
|
|
71
|
+
} else {
|
|
72
|
+
checks.push({ name: 'HTTP Methods', status: 'pass', message: `Allowed: ${methods.join(', ')}`, value: methods.join(', ') });
|
|
73
|
+
score += 1;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
checks.push({ name: 'HTTP Methods', status: 'info', message: 'Could not determine allowed methods' });
|
|
78
|
+
score += 0.5;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { score, maxScore, checks };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function checkRedirectToHttps(httpUrl, options = {}) {
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
const timeout = options.timeout || 5000;
|
|
87
|
+
const req = http.request(httpUrl.href, {
|
|
88
|
+
method: 'HEAD',
|
|
89
|
+
timeout,
|
|
90
|
+
headers: { 'User-Agent': 'SiteGuard/0.1 Security Scanner' },
|
|
91
|
+
}, (res) => {
|
|
92
|
+
if (res.statusCode >= 300 && res.statusCode < 400) {
|
|
93
|
+
const location = res.headers.location || '';
|
|
94
|
+
resolve(location.startsWith('https://'));
|
|
95
|
+
} else {
|
|
96
|
+
resolve(false);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
req.on('error', reject);
|
|
100
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); });
|
|
101
|
+
req.end();
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function fetchWithOrigin(parsedUrl, options = {}) {
|
|
106
|
+
return new Promise((resolve, reject) => {
|
|
107
|
+
const timeout = options.timeout || 5000;
|
|
108
|
+
const protocol = parsedUrl.protocol === 'https:' ? https : http;
|
|
109
|
+
const req = protocol.request(parsedUrl.href, {
|
|
110
|
+
method: 'HEAD',
|
|
111
|
+
timeout,
|
|
112
|
+
headers: {
|
|
113
|
+
'User-Agent': 'SiteGuard/0.1 Security Scanner',
|
|
114
|
+
'Origin': 'https://evil.example.com'
|
|
115
|
+
},
|
|
116
|
+
rejectUnauthorized: false,
|
|
117
|
+
}, (res) => {
|
|
118
|
+
resolve(res.headers);
|
|
119
|
+
});
|
|
120
|
+
req.on('error', reject);
|
|
121
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); });
|
|
122
|
+
req.end();
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function checkMethods(parsedUrl, options = {}) {
|
|
127
|
+
return new Promise((resolve, reject) => {
|
|
128
|
+
const timeout = options.timeout || 5000;
|
|
129
|
+
const protocol = parsedUrl.protocol === 'https:' ? https : http;
|
|
130
|
+
const req = protocol.request(parsedUrl.href, {
|
|
131
|
+
method: 'OPTIONS',
|
|
132
|
+
timeout,
|
|
133
|
+
headers: { 'User-Agent': 'SiteGuard/0.1 Security Scanner' },
|
|
134
|
+
rejectUnauthorized: false,
|
|
135
|
+
}, (res) => {
|
|
136
|
+
const allow = res.headers.allow || '';
|
|
137
|
+
const methods = allow ? allow.split(',').map(m => m.trim().toUpperCase()) : [];
|
|
138
|
+
resolve(methods);
|
|
139
|
+
});
|
|
140
|
+
req.on('error', reject);
|
|
141
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); });
|
|
142
|
+
req.end();
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = { scanServer };
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subresource Integrity (SRI) Scanner
|
|
3
|
+
*
|
|
4
|
+
* Checks that external scripts and stylesheets use integrity attributes
|
|
5
|
+
* to prevent supply-chain attacks (e.g., compromised CDNs).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const https = require('https');
|
|
9
|
+
const http = require('http');
|
|
10
|
+
|
|
11
|
+
async function scanSRI(parsedUrl, options = {}) {
|
|
12
|
+
const checks = [];
|
|
13
|
+
let score = 0;
|
|
14
|
+
let maxScore = 0;
|
|
15
|
+
|
|
16
|
+
const html = await fetchBody(parsedUrl, options);
|
|
17
|
+
|
|
18
|
+
if (!html) {
|
|
19
|
+
checks.push({ name: 'Subresource Integrity', status: 'error', message: 'Could not fetch page HTML' });
|
|
20
|
+
return { score: 0, maxScore: 1, checks };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Find external scripts
|
|
24
|
+
const scriptRegex = /<script[^>]+src\s*=\s*["']([^"']+)["'][^>]*>/gi;
|
|
25
|
+
const linkRegex = /<link[^>]+href\s*=\s*["']([^"']+)["'][^>]*rel\s*=\s*["']stylesheet["'][^>]*>|<link[^>]*rel\s*=\s*["']stylesheet["'][^>]*href\s*=\s*["']([^"']+)["'][^>]*>/gi;
|
|
26
|
+
|
|
27
|
+
const externalScripts = [];
|
|
28
|
+
const externalStyles = [];
|
|
29
|
+
let match;
|
|
30
|
+
|
|
31
|
+
while ((match = scriptRegex.exec(html)) !== null) {
|
|
32
|
+
const src = match[1];
|
|
33
|
+
const fullTag = match[0];
|
|
34
|
+
if (isExternal(src, parsedUrl.hostname)) {
|
|
35
|
+
externalScripts.push({ src, tag: fullTag, hasIntegrity: /integrity\s*=\s*["']/i.test(fullTag) });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
while ((match = linkRegex.exec(html)) !== null) {
|
|
40
|
+
const href = match[1] || match[2];
|
|
41
|
+
const fullTag = match[0];
|
|
42
|
+
if (href && isExternal(href, parsedUrl.hostname)) {
|
|
43
|
+
externalStyles.push({ src: href, tag: fullTag, hasIntegrity: /integrity\s*=\s*["']/i.test(fullTag) });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const allExternal = [...externalScripts, ...externalStyles];
|
|
48
|
+
|
|
49
|
+
if (allExternal.length === 0) {
|
|
50
|
+
checks.push({ name: 'Subresource Integrity', status: 'pass', message: 'No external scripts or stylesheets found (self-hosted resources)' });
|
|
51
|
+
return { score: 1, maxScore: 1, checks };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
maxScore = allExternal.length;
|
|
55
|
+
let withSRI = 0;
|
|
56
|
+
let withoutSRI = 0;
|
|
57
|
+
|
|
58
|
+
// Group by domain for cleaner output
|
|
59
|
+
const byDomain = {};
|
|
60
|
+
for (const resource of [...externalScripts, ...externalStyles]) {
|
|
61
|
+
const isScript = externalScripts.includes(resource);
|
|
62
|
+
let domain;
|
|
63
|
+
try { domain = new URL(resource.src.startsWith('//') ? 'https:' + resource.src : resource.src).hostname; }
|
|
64
|
+
catch { domain = 'unknown'; }
|
|
65
|
+
if (!byDomain[domain]) byDomain[domain] = { scripts: 0, styles: 0, withSRI: 0, withoutSRI: 0 };
|
|
66
|
+
if (isScript) byDomain[domain].scripts++; else byDomain[domain].styles++;
|
|
67
|
+
if (resource.hasIntegrity) { byDomain[domain].withSRI++; withSRI++; score += 1; }
|
|
68
|
+
else { byDomain[domain].withoutSRI++; withoutSRI++; if (!isScript) score += 0.25; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Output per-domain summary (max 10 domains shown)
|
|
72
|
+
const domains = Object.entries(byDomain).sort((a, b) => b[1].withoutSRI - a[1].withoutSRI);
|
|
73
|
+
for (const [domain, counts] of domains.slice(0, 10)) {
|
|
74
|
+
const total = counts.withSRI + counts.withoutSRI;
|
|
75
|
+
if (counts.withoutSRI === 0) {
|
|
76
|
+
checks.push({ name: `SRI: ${domain}`, status: 'pass', message: `All ${total} resources have integrity attributes` });
|
|
77
|
+
} else if (counts.scripts > 0 && counts.withoutSRI > 0) {
|
|
78
|
+
checks.push({ name: `SRI: ${domain}`, status: 'fail', message: `${counts.withoutSRI} of ${total} resources missing integrity (${counts.scripts} scripts)`, recommendation: 'Add integrity="sha384-..." and crossorigin="anonymous" to external script/link tags' });
|
|
79
|
+
} else {
|
|
80
|
+
checks.push({ name: `SRI: ${domain}`, status: 'warn', message: `${counts.withoutSRI} of ${total} stylesheets missing integrity`, recommendation: 'Add integrity="sha384-..." attribute' });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (domains.length > 10) {
|
|
84
|
+
checks.push({ name: 'SRI', status: 'info', message: `...and ${domains.length - 10} more external domains` });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Summary check at top
|
|
88
|
+
if (withoutSRI === 0) {
|
|
89
|
+
checks.unshift({ name: 'Subresource Integrity', status: 'pass', message: `All ${allExternal.length} external resources have integrity attributes` });
|
|
90
|
+
} else {
|
|
91
|
+
checks.unshift({ name: 'Subresource Integrity', status: withoutSRI > withSRI ? 'fail' : 'warn', message: `${withoutSRI} of ${allExternal.length} external resources missing integrity (${Object.keys(byDomain).length} domains)` });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { score, maxScore, checks };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isExternal(url, hostname) {
|
|
98
|
+
if (url.startsWith('//')) url = 'https:' + url;
|
|
99
|
+
if (url.startsWith('http://') || url.startsWith('https://')) {
|
|
100
|
+
try {
|
|
101
|
+
const parsed = new URL(url);
|
|
102
|
+
return parsed.hostname !== hostname;
|
|
103
|
+
} catch { return false; }
|
|
104
|
+
}
|
|
105
|
+
return false; // Relative URLs are same-origin
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function shortenUrl(url) {
|
|
109
|
+
try {
|
|
110
|
+
const parsed = new URL(url.startsWith('//') ? 'https:' + url : url);
|
|
111
|
+
const path = parsed.pathname.split('/').pop() || parsed.pathname;
|
|
112
|
+
return `${parsed.hostname}/.../${path}`.substring(0, 60);
|
|
113
|
+
} catch { return url.substring(0, 60); }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function fetchBody(parsedUrl, options = {}) {
|
|
117
|
+
return new Promise((resolve) => {
|
|
118
|
+
const timeout = options.timeout || 10000;
|
|
119
|
+
const protocol = parsedUrl.protocol === 'https:' ? https : http;
|
|
120
|
+
let resolved = false;
|
|
121
|
+
const done = (val) => { if (!resolved) { resolved = true; resolve(val); } };
|
|
122
|
+
const timer = setTimeout(() => done(''), timeout + 2000);
|
|
123
|
+
|
|
124
|
+
const req = protocol.request(parsedUrl.href, {
|
|
125
|
+
method: 'GET',
|
|
126
|
+
timeout,
|
|
127
|
+
headers: {
|
|
128
|
+
'User-Agent': 'SiteGuard/0.1 Security Scanner',
|
|
129
|
+
'Accept': 'text/html'
|
|
130
|
+
},
|
|
131
|
+
rejectUnauthorized: false,
|
|
132
|
+
}, (res) => {
|
|
133
|
+
// Follow redirects
|
|
134
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
const redirectUrl = new URL(res.headers.location, parsedUrl.href);
|
|
137
|
+
fetchBody(redirectUrl, options).then(done);
|
|
138
|
+
res.resume();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let body = '';
|
|
143
|
+
res.setEncoding('utf-8');
|
|
144
|
+
res.on('data', (chunk) => {
|
|
145
|
+
body += chunk;
|
|
146
|
+
if (body.length > 500000) { // 500KB max
|
|
147
|
+
res.destroy();
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
done(body);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
res.on('end', () => { clearTimeout(timer); done(body); });
|
|
153
|
+
res.on('error', () => { clearTimeout(timer); done(body); });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
req.on('error', () => { clearTimeout(timer); done(''); });
|
|
157
|
+
req.on('timeout', () => { clearTimeout(timer); req.destroy(); done(''); });
|
|
158
|
+
req.end();
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = { scanSRI, fetchBody };
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSL/TLS Scanner
|
|
3
|
+
*
|
|
4
|
+
* Checks certificate validity, protocol support, and configuration.
|
|
5
|
+
* Uses Node.js tls module — zero external dependencies.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const tls = require('tls');
|
|
9
|
+
const https = require('https');
|
|
10
|
+
|
|
11
|
+
async function scanSSL(parsedUrl, options = {}) {
|
|
12
|
+
const checks = [];
|
|
13
|
+
let score = 0;
|
|
14
|
+
let maxScore = 0;
|
|
15
|
+
|
|
16
|
+
if (parsedUrl.protocol !== 'https:') {
|
|
17
|
+
checks.push({ name: 'HTTPS', status: 'fail', message: 'Site does not use HTTPS. All data transmitted in cleartext.' });
|
|
18
|
+
return { score: 0, maxScore: 5, checks };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const hostname = parsedUrl.hostname;
|
|
22
|
+
const port = parsedUrl.port || 443;
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const certInfo = await getCertificateInfo(hostname, port, options);
|
|
26
|
+
|
|
27
|
+
// --- Certificate validity ---
|
|
28
|
+
maxScore += 2;
|
|
29
|
+
const now = new Date();
|
|
30
|
+
const validFrom = new Date(certInfo.valid_from);
|
|
31
|
+
const validTo = new Date(certInfo.valid_to);
|
|
32
|
+
const daysRemaining = Math.floor((validTo - now) / (1000 * 60 * 60 * 24));
|
|
33
|
+
|
|
34
|
+
if (daysRemaining < 0) {
|
|
35
|
+
checks.push({ name: 'Certificate Validity', status: 'fail', message: `EXPIRED ${Math.abs(daysRemaining)} days ago`, value: validTo.toISOString().split('T')[0] });
|
|
36
|
+
} else if (daysRemaining < 7) {
|
|
37
|
+
checks.push({ name: 'Certificate Validity', status: 'fail', message: `Expires in ${daysRemaining} days!`, value: validTo.toISOString().split('T')[0] });
|
|
38
|
+
score += 0.5;
|
|
39
|
+
} else if (daysRemaining < 30) {
|
|
40
|
+
checks.push({ name: 'Certificate Validity', status: 'warn', message: `Expires in ${daysRemaining} days`, value: validTo.toISOString().split('T')[0] });
|
|
41
|
+
score += 1;
|
|
42
|
+
} else {
|
|
43
|
+
checks.push({ name: 'Certificate Validity', status: 'pass', message: `Valid for ${daysRemaining} more days (expires ${validTo.toISOString().split('T')[0]})`, value: `${daysRemaining} days` });
|
|
44
|
+
score += 2;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// --- Certificate issuer ---
|
|
48
|
+
maxScore += 0.5;
|
|
49
|
+
const issuer = certInfo.issuer?.O || certInfo.issuer?.CN || 'Unknown';
|
|
50
|
+
const isSelfSigned = certInfo.issuer?.CN === certInfo.subject?.CN && !certInfo.issuer?.O;
|
|
51
|
+
if (isSelfSigned) {
|
|
52
|
+
checks.push({ name: 'Certificate Issuer', status: 'warn', message: `Self-signed certificate (${issuer})`, value: issuer });
|
|
53
|
+
} else {
|
|
54
|
+
checks.push({ name: 'Certificate Issuer', status: 'pass', message: `Issued by ${issuer}`, value: issuer });
|
|
55
|
+
score += 0.5;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// --- Subject match ---
|
|
59
|
+
maxScore += 1;
|
|
60
|
+
const altNames = (certInfo.subjectaltname || '').split(',').map(s => s.trim().replace('DNS:', ''));
|
|
61
|
+
const subjectCN = certInfo.subject?.CN || '';
|
|
62
|
+
const matchesHostname = altNames.some(name => matchesDomain(name, hostname)) || matchesDomain(subjectCN, hostname);
|
|
63
|
+
|
|
64
|
+
if (matchesHostname) {
|
|
65
|
+
checks.push({ name: 'Hostname Match', status: 'pass', message: `Certificate covers ${hostname}`, value: altNames.slice(0, 5).join(', ') });
|
|
66
|
+
score += 1;
|
|
67
|
+
} else {
|
|
68
|
+
checks.push({ name: 'Hostname Match', status: 'fail', message: `Certificate does NOT match ${hostname}. Subject: ${subjectCN}`, value: altNames.slice(0, 5).join(', ') });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// --- TLS version ---
|
|
72
|
+
maxScore += 1.5;
|
|
73
|
+
const tlsVersion = certInfo.protocol;
|
|
74
|
+
if (tlsVersion === 'TLSv1.3') {
|
|
75
|
+
checks.push({ name: 'TLS Version', status: 'pass', message: 'TLS 1.3 — latest and most secure', value: tlsVersion });
|
|
76
|
+
score += 1.5;
|
|
77
|
+
} else if (tlsVersion === 'TLSv1.2') {
|
|
78
|
+
checks.push({ name: 'TLS Version', status: 'pass', message: 'TLS 1.2 — acceptable', value: tlsVersion });
|
|
79
|
+
score += 1;
|
|
80
|
+
} else {
|
|
81
|
+
checks.push({ name: 'TLS Version', status: 'fail', message: `${tlsVersion || 'Unknown'} — outdated and insecure`, value: tlsVersion });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// --- Cipher suite ---
|
|
85
|
+
maxScore += 1;
|
|
86
|
+
const cipher = certInfo.cipher;
|
|
87
|
+
if (cipher) {
|
|
88
|
+
const isStrong = /AES.*(256|GCM)|CHACHA20/i.test(cipher.name || '');
|
|
89
|
+
if (isStrong) {
|
|
90
|
+
checks.push({ name: 'Cipher Suite', status: 'pass', message: cipher.name, value: `${cipher.name} (${cipher.standardName || ''})` });
|
|
91
|
+
score += 1;
|
|
92
|
+
} else {
|
|
93
|
+
checks.push({ name: 'Cipher Suite', status: 'warn', message: `${cipher.name} — consider stronger cipher`, value: cipher.name });
|
|
94
|
+
score += 0.5;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
} catch (err) {
|
|
99
|
+
maxScore = 5;
|
|
100
|
+
if (err.code === 'CERT_HAS_EXPIRED') {
|
|
101
|
+
checks.push({ name: 'Certificate', status: 'fail', message: 'Certificate has expired' });
|
|
102
|
+
} else if (err.code === 'ERR_TLS_CERT_ALTNAME_INVALID') {
|
|
103
|
+
checks.push({ name: 'Certificate', status: 'fail', message: 'Certificate hostname mismatch' });
|
|
104
|
+
} else {
|
|
105
|
+
checks.push({ name: 'SSL/TLS Connection', status: 'error', message: `Failed: ${err.message}` });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { score, maxScore, checks };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function getCertificateInfo(hostname, port, options = {}) {
|
|
113
|
+
return new Promise((resolve, reject) => {
|
|
114
|
+
const timeout = options.timeout || 10000;
|
|
115
|
+
|
|
116
|
+
const socket = tls.connect({
|
|
117
|
+
host: hostname,
|
|
118
|
+
port,
|
|
119
|
+
servername: hostname,
|
|
120
|
+
rejectUnauthorized: false, // We want to inspect even bad certs
|
|
121
|
+
timeout,
|
|
122
|
+
}, () => {
|
|
123
|
+
const cert = socket.getPeerCertificate(true);
|
|
124
|
+
const protocol = socket.getProtocol();
|
|
125
|
+
const cipher = socket.getCipher();
|
|
126
|
+
|
|
127
|
+
socket.destroy();
|
|
128
|
+
resolve({
|
|
129
|
+
...cert,
|
|
130
|
+
protocol,
|
|
131
|
+
cipher,
|
|
132
|
+
authorized: socket.authorized
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
socket.on('error', (err) => {
|
|
137
|
+
socket.destroy();
|
|
138
|
+
reject(err);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
socket.on('timeout', () => {
|
|
142
|
+
socket.destroy();
|
|
143
|
+
reject(new Error('TLS connection timeout'));
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function matchesDomain(pattern, hostname) {
|
|
149
|
+
if (!pattern) return false;
|
|
150
|
+
pattern = pattern.toLowerCase();
|
|
151
|
+
hostname = hostname.toLowerCase();
|
|
152
|
+
if (pattern === hostname) return true;
|
|
153
|
+
if (pattern.startsWith('*.')) {
|
|
154
|
+
const suffix = pattern.slice(2);
|
|
155
|
+
return hostname.endsWith(suffix) && hostname.split('.').length === pattern.split('.').length;
|
|
156
|
+
}
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = { scanSSL };
|