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,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal Reporter
|
|
3
|
+
*
|
|
4
|
+
* Beautiful colored output for CLI with chalk
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const chalk = require('chalk');
|
|
8
|
+
|
|
9
|
+
const STATUS_ICONS = {
|
|
10
|
+
pass: '✓',
|
|
11
|
+
warn: '⚠',
|
|
12
|
+
fail: '✗',
|
|
13
|
+
error: '⚠',
|
|
14
|
+
info: 'ℹ'
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const STATUS_COLORS = {
|
|
18
|
+
pass: 'green',
|
|
19
|
+
warn: 'yellow',
|
|
20
|
+
fail: 'red',
|
|
21
|
+
error: 'red',
|
|
22
|
+
info: 'blue'
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const GRADE_COLORS = {
|
|
26
|
+
'A+': 'greenBright',
|
|
27
|
+
'A': 'green',
|
|
28
|
+
'B': 'cyan',
|
|
29
|
+
'C': 'yellow',
|
|
30
|
+
'D': 'magenta',
|
|
31
|
+
'F': 'red'
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function formatReport(results, options = {}) {
|
|
35
|
+
const lines = [];
|
|
36
|
+
|
|
37
|
+
// Header
|
|
38
|
+
lines.push('');
|
|
39
|
+
lines.push(chalk.bold.cyan('┌─────────────────────────────────────────────────────────────┐'));
|
|
40
|
+
lines.push(chalk.bold.cyan('│') + chalk.bold(' mpx-scan — Website Security Report ') + chalk.bold.cyan('│'));
|
|
41
|
+
lines.push(chalk.bold.cyan('└─────────────────────────────────────────────────────────────┘'));
|
|
42
|
+
lines.push('');
|
|
43
|
+
|
|
44
|
+
// URL and basic info
|
|
45
|
+
lines.push(chalk.bold('Target: ') + chalk.cyan(results.url));
|
|
46
|
+
lines.push(chalk.bold('Scanned: ') + chalk.gray(new Date(results.scannedAt).toLocaleString()));
|
|
47
|
+
lines.push(chalk.bold('Duration: ') + chalk.gray(`${results.scanDuration}ms`));
|
|
48
|
+
lines.push('');
|
|
49
|
+
|
|
50
|
+
// Overall score
|
|
51
|
+
const gradeColor = GRADE_COLORS[results.grade] || 'gray';
|
|
52
|
+
const percentage = Math.round((results.score / results.maxScore) * 100);
|
|
53
|
+
const barLength = 40;
|
|
54
|
+
const filledLength = Math.round((percentage / 100) * barLength);
|
|
55
|
+
const bar = '█'.repeat(filledLength) + '░'.repeat(barLength - filledLength);
|
|
56
|
+
|
|
57
|
+
lines.push(chalk.bold('Overall Score:'));
|
|
58
|
+
lines.push(' ' + chalk[gradeColor](bar));
|
|
59
|
+
lines.push(' ' + chalk.bold[gradeColor](`${results.grade}`) + chalk.gray(` (${percentage}/100)`));
|
|
60
|
+
lines.push('');
|
|
61
|
+
|
|
62
|
+
// Summary
|
|
63
|
+
lines.push(chalk.bold('Summary:'));
|
|
64
|
+
lines.push(' ' + chalk.green(`${STATUS_ICONS.pass} ${results.summary.passed} passed`) +
|
|
65
|
+
chalk.gray(' │ ') +
|
|
66
|
+
chalk.yellow(`${STATUS_ICONS.warn} ${results.summary.warnings} warnings`) +
|
|
67
|
+
chalk.gray(' │ ') +
|
|
68
|
+
chalk.red(`${STATUS_ICONS.fail} ${results.summary.failed} failed`));
|
|
69
|
+
lines.push('');
|
|
70
|
+
|
|
71
|
+
// Sections
|
|
72
|
+
const sections = Object.entries(results.sections);
|
|
73
|
+
|
|
74
|
+
for (const [name, section] of sections) {
|
|
75
|
+
const sectionGrade = section.grade;
|
|
76
|
+
const sectionColor = GRADE_COLORS[sectionGrade] || 'gray';
|
|
77
|
+
const sectionPercentage = Math.round((section.score / section.maxScore) * 100);
|
|
78
|
+
|
|
79
|
+
lines.push(chalk.bold.underline(`\n${capitalize(name)}`));
|
|
80
|
+
lines.push(chalk.gray(` Score: ${section.score}/${section.maxScore} (${sectionPercentage}%) — Grade: `) + chalk.bold[sectionColor](sectionGrade));
|
|
81
|
+
lines.push('');
|
|
82
|
+
|
|
83
|
+
for (const check of section.checks) {
|
|
84
|
+
const icon = STATUS_ICONS[check.status] || '•';
|
|
85
|
+
const color = STATUS_COLORS[check.status] || 'white';
|
|
86
|
+
|
|
87
|
+
lines.push(' ' + chalk[color](`${icon} ${check.name}`));
|
|
88
|
+
|
|
89
|
+
if (check.message) {
|
|
90
|
+
lines.push(' ' + chalk.gray(check.message));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (check.value && !options.brief) {
|
|
94
|
+
const value = String(check.value).length > 100
|
|
95
|
+
? String(check.value).substring(0, 100) + '...'
|
|
96
|
+
: check.value;
|
|
97
|
+
lines.push(' ' + chalk.dim(`→ ${value}`));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (check.recommendation && !options.brief) {
|
|
101
|
+
lines.push(' ' + chalk.cyan(`💡 ${check.recommendation}`));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Free tier upgrade message
|
|
107
|
+
if (results.tier === 'free') {
|
|
108
|
+
lines.push('');
|
|
109
|
+
lines.push(chalk.yellow('─'.repeat(63)));
|
|
110
|
+
lines.push(chalk.yellow.bold('🔓 Upgrade to Pro for:'));
|
|
111
|
+
lines.push(chalk.yellow(' • Unlimited scans (free tier: 3/day)'));
|
|
112
|
+
lines.push(chalk.yellow(' • All security checks (DNS, cookies, SRI, exposed files)'));
|
|
113
|
+
lines.push(chalk.yellow(' • JSON/CSV export for CI/CD integration'));
|
|
114
|
+
lines.push(chalk.yellow(' • Batch scanning'));
|
|
115
|
+
lines.push('');
|
|
116
|
+
lines.push(chalk.blue('Learn more: https://mesaplex.com/mpx-scan'));
|
|
117
|
+
lines.push(chalk.yellow('─'.repeat(63)));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
lines.push('');
|
|
121
|
+
|
|
122
|
+
return lines.join('\n');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function formatBrief(results) {
|
|
126
|
+
const gradeColor = GRADE_COLORS[results.grade] || 'gray';
|
|
127
|
+
const percentage = Math.round((results.score / results.maxScore) * 100);
|
|
128
|
+
|
|
129
|
+
return `${results.url} — ${chalk.bold[gradeColor](results.grade)} (${percentage}/100) — ` +
|
|
130
|
+
`${chalk.green(results.summary.passed + ' ✓')} ${chalk.yellow(results.summary.warnings + ' ⚠')} ${chalk.red(results.summary.failed + ' ✗')}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function capitalize(str) {
|
|
134
|
+
return str
|
|
135
|
+
.replace(/([A-Z])/g, ' $1')
|
|
136
|
+
.replace(/^./, s => s.toUpperCase())
|
|
137
|
+
.trim();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = { formatReport, formatBrief };
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cookie Security Scanner
|
|
3
|
+
*
|
|
4
|
+
* Checks Set-Cookie headers for security flags:
|
|
5
|
+
* - Secure flag (HTTPS only)
|
|
6
|
+
* - HttpOnly flag (no JS access)
|
|
7
|
+
* - SameSite attribute
|
|
8
|
+
* - Path scope
|
|
9
|
+
* - Expiration
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const https = require('https');
|
|
13
|
+
const http = require('http');
|
|
14
|
+
|
|
15
|
+
async function scanCookies(parsedUrl, options = {}) {
|
|
16
|
+
const checks = [];
|
|
17
|
+
let score = 0;
|
|
18
|
+
let maxScore = 0;
|
|
19
|
+
|
|
20
|
+
const cookies = await fetchCookies(parsedUrl, options);
|
|
21
|
+
|
|
22
|
+
if (cookies.length === 0) {
|
|
23
|
+
checks.push({ name: 'Cookies', status: 'info', message: 'No cookies set on initial page load' });
|
|
24
|
+
return { score: 1, maxScore: 1, checks };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
checks.push({ name: 'Cookie Count', status: 'info', message: `${cookies.length} cookie(s) found`, value: cookies.length.toString() });
|
|
28
|
+
|
|
29
|
+
for (const cookie of cookies) {
|
|
30
|
+
const name = cookie.name || 'unnamed';
|
|
31
|
+
const isSession = /session|sid|token|auth|jwt|csrf/i.test(name);
|
|
32
|
+
const weight = isSession ? 2 : 1;
|
|
33
|
+
|
|
34
|
+
// --- Secure flag ---
|
|
35
|
+
maxScore += weight;
|
|
36
|
+
if (cookie.secure) {
|
|
37
|
+
checks.push({ name: `${name}: Secure`, status: 'pass', message: 'Cookie sent only over HTTPS' });
|
|
38
|
+
score += weight;
|
|
39
|
+
} else if (parsedUrl.protocol === 'https:') {
|
|
40
|
+
checks.push({ name: `${name}: Secure`, status: isSession ? 'fail' : 'warn', message: 'Missing Secure flag — cookie can be sent over HTTP', recommendation: 'Add Secure flag to Set-Cookie header' });
|
|
41
|
+
if (!isSession) score += weight * 0.5;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// --- HttpOnly flag ---
|
|
45
|
+
maxScore += weight;
|
|
46
|
+
if (cookie.httpOnly) {
|
|
47
|
+
checks.push({ name: `${name}: HttpOnly`, status: 'pass', message: 'Cookie inaccessible to JavaScript' });
|
|
48
|
+
score += weight;
|
|
49
|
+
} else {
|
|
50
|
+
checks.push({ name: `${name}: HttpOnly`, status: isSession ? 'fail' : 'warn', message: 'Missing HttpOnly — cookie accessible via document.cookie (XSS risk)', recommendation: 'Add HttpOnly flag to prevent JavaScript access' });
|
|
51
|
+
if (!isSession) score += weight * 0.25;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// --- SameSite ---
|
|
55
|
+
maxScore += weight * 0.5;
|
|
56
|
+
if (cookie.sameSite) {
|
|
57
|
+
const val = cookie.sameSite.toLowerCase();
|
|
58
|
+
if (val === 'strict' || val === 'lax') {
|
|
59
|
+
checks.push({ name: `${name}: SameSite`, status: 'pass', message: `SameSite=${cookie.sameSite}`, value: cookie.sameSite });
|
|
60
|
+
score += weight * 0.5;
|
|
61
|
+
} else if (val === 'none') {
|
|
62
|
+
checks.push({ name: `${name}: SameSite`, status: 'warn', message: 'SameSite=None — cookie sent on all cross-site requests' });
|
|
63
|
+
score += weight * 0.25;
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
checks.push({ name: `${name}: SameSite`, status: 'warn', message: 'Missing SameSite attribute', recommendation: 'Add SameSite=Lax or SameSite=Strict' });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { score, maxScore, checks };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function fetchCookies(parsedUrl, options = {}) {
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
const timeout = options.timeout || 10000;
|
|
76
|
+
const protocol = parsedUrl.protocol === 'https:' ? https : http;
|
|
77
|
+
|
|
78
|
+
const req = protocol.request(parsedUrl.href, {
|
|
79
|
+
method: 'GET',
|
|
80
|
+
timeout,
|
|
81
|
+
headers: { 'User-Agent': 'SiteGuard/0.1 Security Scanner' },
|
|
82
|
+
rejectUnauthorized: false,
|
|
83
|
+
}, (res) => {
|
|
84
|
+
// Consume body
|
|
85
|
+
res.on('data', () => {});
|
|
86
|
+
res.on('end', () => {});
|
|
87
|
+
|
|
88
|
+
const setCookies = res.headers['set-cookie'] || [];
|
|
89
|
+
const parsed = setCookies.map(parseCookie);
|
|
90
|
+
resolve(parsed);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
req.on('error', reject);
|
|
94
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); });
|
|
95
|
+
req.end();
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function parseCookie(setCookieStr) {
|
|
100
|
+
const parts = setCookieStr.split(';').map(p => p.trim());
|
|
101
|
+
const [nameValue, ...attrs] = parts;
|
|
102
|
+
const eqIndex = nameValue.indexOf('=');
|
|
103
|
+
const name = eqIndex > -1 ? nameValue.substring(0, eqIndex) : nameValue;
|
|
104
|
+
|
|
105
|
+
const cookie = { name, raw: setCookieStr };
|
|
106
|
+
|
|
107
|
+
for (const attr of attrs) {
|
|
108
|
+
const [key, val] = attr.split('=').map(s => s.trim());
|
|
109
|
+
const lower = key.toLowerCase();
|
|
110
|
+
if (lower === 'secure') cookie.secure = true;
|
|
111
|
+
else if (lower === 'httponly') cookie.httpOnly = true;
|
|
112
|
+
else if (lower === 'samesite') cookie.sameSite = val || 'Lax';
|
|
113
|
+
else if (lower === 'path') cookie.path = val;
|
|
114
|
+
else if (lower === 'domain') cookie.domain = val;
|
|
115
|
+
else if (lower === 'expires') cookie.expires = val;
|
|
116
|
+
else if (lower === 'max-age') cookie.maxAge = parseInt(val);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return cookie;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports = { scanCookies };
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DNS Security Scanner
|
|
3
|
+
*
|
|
4
|
+
* Checks DNS configuration for security features:
|
|
5
|
+
* - SPF records (email spoofing protection)
|
|
6
|
+
* - DMARC records (email authentication)
|
|
7
|
+
* - DKIM (if discoverable)
|
|
8
|
+
* - DNSSEC (via DO flag)
|
|
9
|
+
* - CAA records (certificate authority authorization)
|
|
10
|
+
* - MX records (mail configuration)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const dns = require('dns');
|
|
14
|
+
const { Resolver } = dns.promises;
|
|
15
|
+
|
|
16
|
+
async function scanDNS(parsedUrl, options = {}) {
|
|
17
|
+
const checks = [];
|
|
18
|
+
let score = 0;
|
|
19
|
+
let maxScore = 0;
|
|
20
|
+
|
|
21
|
+
const hostname = parsedUrl.hostname;
|
|
22
|
+
// Get root domain for DNS checks
|
|
23
|
+
const parts = hostname.split('.');
|
|
24
|
+
const rootDomain = parts.length > 2 ? parts.slice(-2).join('.') : hostname;
|
|
25
|
+
|
|
26
|
+
const resolver = new Resolver();
|
|
27
|
+
resolver.setServers(['8.8.8.8', '1.1.1.1']); // Use public DNS
|
|
28
|
+
|
|
29
|
+
// --- SPF Record ---
|
|
30
|
+
maxScore += 1;
|
|
31
|
+
try {
|
|
32
|
+
const txtRecords = await resolver.resolveTxt(rootDomain);
|
|
33
|
+
const spf = txtRecords.flat().find(r => r.startsWith('v=spf1'));
|
|
34
|
+
if (spf) {
|
|
35
|
+
const hasAll = /[-~?+]all/.test(spf);
|
|
36
|
+
const isStrict = /-all/.test(spf);
|
|
37
|
+
if (isStrict) {
|
|
38
|
+
checks.push({ name: 'SPF Record', status: 'pass', message: 'Strict SPF (-all) — rejects unauthorized senders', value: spf.substring(0, 150) });
|
|
39
|
+
score += 1;
|
|
40
|
+
} else if (hasAll) {
|
|
41
|
+
checks.push({ name: 'SPF Record', status: 'warn', message: 'SPF present but uses soft fail (~all). Consider -all', value: spf.substring(0, 150) });
|
|
42
|
+
score += 0.5;
|
|
43
|
+
} else {
|
|
44
|
+
checks.push({ name: 'SPF Record', status: 'warn', message: 'SPF present but may not reject unauthorized senders', value: spf.substring(0, 150) });
|
|
45
|
+
score += 0.5;
|
|
46
|
+
}
|
|
47
|
+
} else {
|
|
48
|
+
checks.push({ name: 'SPF Record', status: 'fail', message: 'No SPF record found. Domain vulnerable to email spoofing.', recommendation: 'Add TXT record: v=spf1 include:_spf.google.com -all (adjust for your email provider)' });
|
|
49
|
+
}
|
|
50
|
+
} catch (err) {
|
|
51
|
+
checks.push({ name: 'SPF Record', status: 'info', message: `Could not query TXT records: ${err.code || err.message}` });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// --- DMARC Record ---
|
|
55
|
+
maxScore += 1;
|
|
56
|
+
try {
|
|
57
|
+
const dmarcRecords = await resolver.resolveTxt(`_dmarc.${rootDomain}`);
|
|
58
|
+
const dmarc = dmarcRecords.flat().find(r => r.startsWith('v=DMARC1'));
|
|
59
|
+
if (dmarc) {
|
|
60
|
+
const policy = (dmarc.match(/p=(\w+)/) || [])[1] || 'none';
|
|
61
|
+
if (policy === 'reject') {
|
|
62
|
+
checks.push({ name: 'DMARC Record', status: 'pass', message: 'DMARC policy=reject — strongest protection', value: dmarc.substring(0, 150) });
|
|
63
|
+
score += 1;
|
|
64
|
+
} else if (policy === 'quarantine') {
|
|
65
|
+
checks.push({ name: 'DMARC Record', status: 'pass', message: 'DMARC policy=quarantine — good protection', value: dmarc.substring(0, 150) });
|
|
66
|
+
score += 0.75;
|
|
67
|
+
} else {
|
|
68
|
+
checks.push({ name: 'DMARC Record', status: 'warn', message: `DMARC policy=${policy} — monitoring only, not enforcing`, value: dmarc.substring(0, 150) });
|
|
69
|
+
score += 0.25;
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
checks.push({ name: 'DMARC Record', status: 'fail', message: 'No DMARC record. Email spoofing protection incomplete.', recommendation: 'Add TXT record at _dmarc.yourdomain.com: v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.com' });
|
|
73
|
+
}
|
|
74
|
+
} catch (err) {
|
|
75
|
+
if (err.code === 'ENOTFOUND' || err.code === 'ENODATA') {
|
|
76
|
+
checks.push({ name: 'DMARC Record', status: 'fail', message: 'No DMARC record found', recommendation: 'Add DMARC TXT record at _dmarc.yourdomain.com' });
|
|
77
|
+
} else {
|
|
78
|
+
checks.push({ name: 'DMARC Record', status: 'info', message: `Could not query DMARC: ${err.code || err.message}` });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// --- CAA Records ---
|
|
83
|
+
maxScore += 0.5;
|
|
84
|
+
try {
|
|
85
|
+
const caaRecords = await resolver.resolveCaa(rootDomain);
|
|
86
|
+
if (caaRecords && caaRecords.length > 0) {
|
|
87
|
+
const issuers = caaRecords.filter(r => r.tag === 'issue').map(r => r.value);
|
|
88
|
+
checks.push({ name: 'CAA Records', status: 'pass', message: `Restricts certificate issuance to: ${issuers.join(', ')}`, value: issuers.join(', ') });
|
|
89
|
+
score += 0.5;
|
|
90
|
+
} else {
|
|
91
|
+
checks.push({ name: 'CAA Records', status: 'warn', message: 'No CAA records. Any CA can issue certificates for this domain.', recommendation: 'Add CAA records to restrict which CAs can issue certificates' });
|
|
92
|
+
}
|
|
93
|
+
} catch (err) {
|
|
94
|
+
if (err.code === 'ENODATA' || err.code === 'ENOTFOUND') {
|
|
95
|
+
checks.push({ name: 'CAA Records', status: 'warn', message: 'No CAA records found' });
|
|
96
|
+
} else {
|
|
97
|
+
checks.push({ name: 'CAA Records', status: 'info', message: `Could not query CAA: ${err.code || err.message}` });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- MX Records (informational) ---
|
|
102
|
+
try {
|
|
103
|
+
const mxRecords = await resolver.resolveMx(rootDomain);
|
|
104
|
+
if (mxRecords && mxRecords.length > 0) {
|
|
105
|
+
const mxList = mxRecords.sort((a, b) => a.priority - b.priority).map(r => `${r.exchange} (${r.priority})`);
|
|
106
|
+
checks.push({ name: 'MX Records', status: 'info', message: `Mail servers: ${mxList.join(', ')}`, value: mxList.join(', ') });
|
|
107
|
+
}
|
|
108
|
+
} catch { /* MX is informational only */ }
|
|
109
|
+
|
|
110
|
+
return { score, maxScore, checks };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = { scanDNS };
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exposed Files Scanner
|
|
3
|
+
*
|
|
4
|
+
* Checks for files/paths that should not be publicly accessible:
|
|
5
|
+
* - .env, .git, .htaccess
|
|
6
|
+
* - wp-admin, phpinfo
|
|
7
|
+
* - Backup files, config files
|
|
8
|
+
* - Directory listings
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const https = require('https');
|
|
12
|
+
const http = require('http');
|
|
13
|
+
|
|
14
|
+
const SENSITIVE_PATHS = [
|
|
15
|
+
{ path: '/.env', name: '.env file', severity: 'critical', description: 'Environment variables (may contain secrets, API keys, passwords)' },
|
|
16
|
+
{ path: '/.git/HEAD', name: '.git directory', severity: 'critical', description: 'Git repository exposed — full source code and history accessible' },
|
|
17
|
+
{ path: '/.git/config', name: '.git config', severity: 'critical', description: 'Git configuration with potential remote URLs and credentials' },
|
|
18
|
+
{ path: '/.svn/entries', name: '.svn directory', severity: 'high', description: 'Subversion repository exposed' },
|
|
19
|
+
{ path: '/.htaccess', name: '.htaccess', severity: 'medium', description: 'Apache configuration file — reveals server setup' },
|
|
20
|
+
{ path: '/wp-admin/', name: 'WordPress Admin', severity: 'medium', description: 'WordPress admin panel exposed' },
|
|
21
|
+
{ path: '/wp-login.php', name: 'WordPress Login', severity: 'low', description: 'WordPress login page' },
|
|
22
|
+
{ path: '/phpinfo.php', name: 'PHP Info', severity: 'high', description: 'PHP configuration disclosure' },
|
|
23
|
+
{ path: '/server-status', name: 'Server Status', severity: 'high', description: 'Apache server status page' },
|
|
24
|
+
{ path: '/elmah.axd', name: 'ELMAH Log', severity: 'high', description: '.NET error log viewer' },
|
|
25
|
+
{ path: '/backup.sql', name: 'SQL Backup', severity: 'critical', description: 'Database backup file' },
|
|
26
|
+
{ path: '/dump.sql', name: 'SQL Dump', severity: 'critical', description: 'Database dump file' },
|
|
27
|
+
{ path: '/db.sql', name: 'Database File', severity: 'critical', description: 'Database file' },
|
|
28
|
+
{ path: '/.DS_Store', name: '.DS_Store', severity: 'low', description: 'macOS directory metadata — reveals file/folder names' },
|
|
29
|
+
{ path: '/crossdomain.xml', name: 'crossdomain.xml', severity: 'low', description: 'Flash cross-domain policy (may be overly permissive)' },
|
|
30
|
+
{ path: '/composer.json', name: 'composer.json', severity: 'high', description: 'PHP dependency manifest — reveals packages and versions' },
|
|
31
|
+
{ path: '/package.json', name: 'package.json', severity: 'medium', description: 'Node.js dependency manifest — reveals packages and versions' },
|
|
32
|
+
{ path: '/Gruntfile.js', name: 'Gruntfile.js', severity: 'medium', description: 'Build tool configuration exposed' },
|
|
33
|
+
{ path: '/Dockerfile', name: 'Dockerfile', severity: 'high', description: 'Docker configuration — reveals infrastructure details' },
|
|
34
|
+
{ path: '/docker-compose.yml', name: 'docker-compose.yml', severity: 'critical', description: 'Docker Compose — may contain service passwords and configs' },
|
|
35
|
+
{ path: '/.dockerenv', name: '.dockerenv', severity: 'medium', description: 'Running inside Docker container' },
|
|
36
|
+
{ path: '/web.config', name: 'web.config', severity: 'high', description: 'IIS configuration — reveals server setup and potential credentials' },
|
|
37
|
+
{ path: '/config.php', name: 'config.php', severity: 'critical', description: 'PHP configuration file — likely contains database credentials' },
|
|
38
|
+
{ path: '/wp-config.php.bak', name: 'wp-config.php.bak', severity: 'critical', description: 'WordPress config backup — contains database credentials in plaintext' },
|
|
39
|
+
{ path: '/.npmrc', name: '.npmrc', severity: 'critical', description: 'npm config — may contain auth tokens' },
|
|
40
|
+
{ path: '/.aws/credentials', name: 'AWS credentials', severity: 'critical', description: 'AWS credential file exposed' },
|
|
41
|
+
{ path: '/debug.log', name: 'debug.log', severity: 'high', description: 'Debug log — may contain stack traces and sensitive data' },
|
|
42
|
+
{ path: '/error.log', name: 'error.log', severity: 'high', description: 'Error log — may contain stack traces and paths' },
|
|
43
|
+
{ path: '/access.log', name: 'access.log', severity: 'medium', description: 'Access log — reveals visitor IPs and paths' },
|
|
44
|
+
{ path: '/.vscode/settings.json', name: 'VS Code settings', severity: 'low', description: 'IDE settings exposed — reveals development environment' },
|
|
45
|
+
{ path: '/adminer.php', name: 'Adminer', severity: 'critical', description: 'Database admin tool exposed to the internet' },
|
|
46
|
+
{ path: '/phpmyadmin/', name: 'phpMyAdmin', severity: 'critical', description: 'Database admin panel exposed to the internet' },
|
|
47
|
+
{ path: '/.well-known/security.txt', name: 'security.txt', severity: 'info', description: 'Security contact information' },
|
|
48
|
+
{ path: '/robots.txt', name: 'robots.txt', severity: 'info', description: 'Robots exclusion — may reveal hidden paths' },
|
|
49
|
+
{ path: '/sitemap.xml', name: 'sitemap.xml', severity: 'info', description: 'Site map — reveals site structure' },
|
|
50
|
+
{ path: '/humans.txt', name: 'humans.txt', severity: 'info', description: 'Team information file' },
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
async function scanExposedFiles(parsedUrl, options = {}) {
|
|
54
|
+
const checks = [];
|
|
55
|
+
let score = 0;
|
|
56
|
+
let maxScore = 0;
|
|
57
|
+
|
|
58
|
+
// Score critical, high, and medium severity paths (not low/info)
|
|
59
|
+
const scoredPaths = SENSITIVE_PATHS.filter(p => ['critical', 'high', 'medium'].includes(p.severity));
|
|
60
|
+
maxScore = scoredPaths.length;
|
|
61
|
+
// Note: low severity items are reported but NOT scored
|
|
62
|
+
|
|
63
|
+
// Check paths concurrently (in batches to be polite)
|
|
64
|
+
const batchSize = options.concurrency || 5;
|
|
65
|
+
const results = [];
|
|
66
|
+
|
|
67
|
+
for (let i = 0; i < SENSITIVE_PATHS.length; i += batchSize) {
|
|
68
|
+
const batch = SENSITIVE_PATHS.slice(i, i + batchSize);
|
|
69
|
+
const batchResults = await Promise.all(
|
|
70
|
+
batch.map(async (entry) => {
|
|
71
|
+
try {
|
|
72
|
+
const status = await checkPath(parsedUrl, entry.path, options);
|
|
73
|
+
return { ...entry, status };
|
|
74
|
+
} catch {
|
|
75
|
+
return { ...entry, status: 0 };
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
);
|
|
79
|
+
results.push(...batchResults);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let exposedCritical = 0;
|
|
83
|
+
let exposedHigh = 0;
|
|
84
|
+
|
|
85
|
+
// Track connection failures to detect unreachable hosts
|
|
86
|
+
let connectionFailures = 0;
|
|
87
|
+
|
|
88
|
+
for (const result of results) {
|
|
89
|
+
// Only 200-299 is truly exposed. 3xx redirects are NOT exposures.
|
|
90
|
+
// Status 0 = connection failed
|
|
91
|
+
const isExposed = result.status >= 200 && result.status < 300;
|
|
92
|
+
if (result.status === 0) connectionFailures++;
|
|
93
|
+
|
|
94
|
+
if (result.severity === 'info') {
|
|
95
|
+
if (isExposed) {
|
|
96
|
+
checks.push({ name: result.name, status: 'info', message: `Found (${result.status}) — ${result.description}`, value: result.path });
|
|
97
|
+
}
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (result.severity === 'low') {
|
|
102
|
+
// Low severity: report if found, but don't affect score
|
|
103
|
+
if (isExposed) {
|
|
104
|
+
checks.push({ name: result.name, status: 'info', message: `Found (${result.status}) — ${result.description}`, value: result.path });
|
|
105
|
+
}
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (isExposed) {
|
|
110
|
+
if (result.severity === 'critical') {
|
|
111
|
+
checks.push({ name: result.name, status: 'fail', message: `🚨 EXPOSED (${result.status}) — ${result.description}`, value: result.path });
|
|
112
|
+
exposedCritical++;
|
|
113
|
+
} else if (result.severity === 'high') {
|
|
114
|
+
checks.push({ name: result.name, status: 'fail', message: `⚠️ EXPOSED (${result.status}) — ${result.description}`, value: result.path });
|
|
115
|
+
exposedHigh++;
|
|
116
|
+
} else if (result.severity === 'medium') {
|
|
117
|
+
checks.push({ name: result.name, status: 'warn', message: `Accessible (${result.status}) — ${result.description}`, value: result.path });
|
|
118
|
+
score += 0.5;
|
|
119
|
+
}
|
|
120
|
+
} else if (result.status > 0) {
|
|
121
|
+
score += 1;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// If most checks failed to connect, report as error
|
|
126
|
+
const totalScored = results.filter(r => !['info'].includes(r.severity)).length;
|
|
127
|
+
if (connectionFailures > totalScored * 0.8) {
|
|
128
|
+
checks.unshift({ name: 'Exposed Files', status: 'error', message: 'Most path checks failed to connect — results may be unreliable' });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (exposedCritical === 0 && exposedHigh === 0) {
|
|
132
|
+
checks.unshift({ name: 'Sensitive Files', status: 'pass', message: 'No critical or high-severity files exposed' });
|
|
133
|
+
} else {
|
|
134
|
+
checks.unshift({ name: 'Sensitive Files', status: 'fail', message: `${exposedCritical} critical, ${exposedHigh} high-severity files exposed!` });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { score, maxScore, checks };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function checkPath(parsedUrl, pathStr, options = {}) {
|
|
141
|
+
return new Promise((resolve) => {
|
|
142
|
+
const timeout = options.timeout || 5000;
|
|
143
|
+
const url = new URL(pathStr, parsedUrl.href);
|
|
144
|
+
const protocol = url.protocol === 'https:' ? https : http;
|
|
145
|
+
let resolved = false;
|
|
146
|
+
const done = (val) => { if (!resolved) { resolved = true; resolve(val); } };
|
|
147
|
+
|
|
148
|
+
// Hard timeout fallback
|
|
149
|
+
const timer = setTimeout(() => done(0), timeout + 2000);
|
|
150
|
+
|
|
151
|
+
const req = protocol.request(url.href, {
|
|
152
|
+
method: 'GET',
|
|
153
|
+
timeout,
|
|
154
|
+
headers: { 'User-Agent': 'SiteGuard/0.1 Security Scanner' },
|
|
155
|
+
rejectUnauthorized: false,
|
|
156
|
+
}, (res) => {
|
|
157
|
+
let body = '';
|
|
158
|
+
res.setEncoding('utf-8');
|
|
159
|
+
res.on('data', (chunk) => {
|
|
160
|
+
body += chunk;
|
|
161
|
+
if (body.length > 2000) {
|
|
162
|
+
res.destroy();
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
done(validateResponse(res.statusCode, body, pathStr));
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
res.on('end', () => {
|
|
168
|
+
clearTimeout(timer);
|
|
169
|
+
done(validateResponse(res.statusCode, body, pathStr));
|
|
170
|
+
});
|
|
171
|
+
res.on('error', () => { clearTimeout(timer); done(0); });
|
|
172
|
+
res.on('close', () => { clearTimeout(timer); done(res.statusCode); });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
req.on('error', () => { clearTimeout(timer); done(0); });
|
|
176
|
+
req.on('timeout', () => { clearTimeout(timer); req.destroy(); done(0); });
|
|
177
|
+
req.end();
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Validate if a response is actually the sensitive file or a generic page.
|
|
183
|
+
* Returns the effective status code (404 for false positives).
|
|
184
|
+
*/
|
|
185
|
+
function validateResponse(statusCode, body, pathStr) {
|
|
186
|
+
if (statusCode < 200 || statusCode >= 300) return statusCode;
|
|
187
|
+
if (!body || body.length < 5) return 404; // Empty response
|
|
188
|
+
|
|
189
|
+
// Soft 404 detection — generic error pages
|
|
190
|
+
if (/<title>.*(?:404|not found|page not found|error|oops|doesn't exist).*<\/title>/i.test(body)) return 404;
|
|
191
|
+
if (/<h1>.*(?:404|not found|page not found).*<\/h1>/i.test(body)) return 404;
|
|
192
|
+
|
|
193
|
+
// If the body is HTML but the file shouldn't be HTML, it's likely a catch-all route
|
|
194
|
+
const isHtmlResponse = /<(!DOCTYPE|html|head|body)/i.test(body);
|
|
195
|
+
const nonHtmlFiles = ['/.env', '/.git/HEAD', '/.git/config', '/.htaccess', '/backup.sql',
|
|
196
|
+
'/dump.sql', '/db.sql', '/.DS_Store', '/composer.json', '/package.json', '/Gruntfile.js',
|
|
197
|
+
'/Dockerfile', '/docker-compose.yml', '/.dockerenv', '/config.php', '/wp-config.php.bak',
|
|
198
|
+
'/.npmrc', '/.aws/credentials', '/debug.log', '/error.log', '/access.log',
|
|
199
|
+
'/.vscode/settings.json'];
|
|
200
|
+
|
|
201
|
+
if (isHtmlResponse && nonHtmlFiles.includes(pathStr)) {
|
|
202
|
+
// HTML response for a non-HTML file = probably a catch-all/SPA router
|
|
203
|
+
return 404;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// For admin panel paths that return HTML, check if it's actually the admin panel
|
|
207
|
+
// or just a generic page / catch-all route
|
|
208
|
+
const adminPaths = ['/wp-admin/', '/wp-login.php', '/phpmyadmin/', '/adminer.php', '/server-status', '/elmah.axd'];
|
|
209
|
+
if (isHtmlResponse && adminPaths.includes(pathStr)) {
|
|
210
|
+
// Check for actual admin panel indicators
|
|
211
|
+
if (pathStr.includes('wp-admin') && !/wp-login|wordpress/i.test(body)) return 404;
|
|
212
|
+
if (pathStr.includes('wp-login') && !/<form[^>]*wp-login/i.test(body)) return 404;
|
|
213
|
+
if (pathStr.includes('phpmyadmin') && !/phpMyAdmin|pma_/i.test(body)) return 404;
|
|
214
|
+
if (pathStr.includes('adminer') && !/adminer/i.test(body)) return 404;
|
|
215
|
+
if (pathStr.includes('server-status') && !/Server Version|Apache/i.test(body)) return 404;
|
|
216
|
+
if (pathStr.includes('elmah') && !/ELMAH|Error Log/i.test(body)) return 404;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Content validation for specific files
|
|
220
|
+
if (pathStr === '/.env' && !/[A-Z_]+=/.test(body)) return 404;
|
|
221
|
+
if (pathStr === '/.git/HEAD' && !/ref:/.test(body)) return 404;
|
|
222
|
+
if (pathStr === '/.git/config' && !/\[core\]|\[remote/.test(body)) return 404;
|
|
223
|
+
if (pathStr === '/composer.json' && !/"require"/.test(body)) return 404;
|
|
224
|
+
if (pathStr === '/package.json' && !/"name"|"version"/.test(body)) return 404;
|
|
225
|
+
if (pathStr === '/Dockerfile' && !/FROM |RUN |CMD /i.test(body)) return 404;
|
|
226
|
+
if (pathStr === '/docker-compose.yml' && !/services:|version:/i.test(body)) return 404;
|
|
227
|
+
|
|
228
|
+
return statusCode;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
module.exports = { scanExposedFiles };
|