@pbpyrojust/universal-security-audit 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +435 -2
- package/auth-config.example.json +12 -0
- package/bin/universal-security-audit.mjs +134 -0
- package/branding.example.json +10 -0
- package/package.json +60 -4
- package/scripts/compare-security-runs.mjs +94 -0
- package/scripts/generate-baseline.mjs +66 -0
- package/scripts/generate-security-report.mjs +99 -0
- package/scripts/generate-security-sarif.mjs +93 -0
- package/scripts/generate-security-tickets.mjs +126 -0
- package/scripts/lib/authorization.mjs +54 -0
- package/scripts/lib/baseline.mjs +37 -0
- package/scripts/lib/drupal-admin-audit.mjs +84 -0
- package/scripts/lib/exposed-paths.mjs +111 -0
- package/scripts/lib/fingerprint.mjs +154 -0
- package/scripts/lib/graphql-introspect.mjs +55 -0
- package/scripts/lib/infra-topology.mjs +71 -0
- package/scripts/lib/intensity.mjs +30 -0
- package/scripts/lib/misc-checks.mjs +78 -0
- package/scripts/lib/pentest-recon.mjs +220 -0
- package/scripts/lib/pii-payment.mjs +79 -0
- package/scripts/lib/port-scan.mjs +72 -0
- package/scripts/lib/proxy-support.mjs +14 -0
- package/scripts/lib/report-builder.mjs +113 -0
- package/scripts/lib/scoring.mjs +44 -0
- package/scripts/lib/script-inventory.mjs +131 -0
- package/scripts/lib/security-headers.mjs +115 -0
- package/scripts/lib/security-txt.mjs +69 -0
- package/scripts/lib/subdomain-enum.mjs +64 -0
- package/scripts/lib/tls-enum.mjs +49 -0
- package/scripts/lib/vuln-lookup.mjs +118 -0
- package/scripts/lib/whois-lookup.mjs +44 -0
- package/scripts/lib/wordfence.mjs +112 -0
- package/scripts/lib/wp-admin-audit.mjs +102 -0
- package/scripts/lib/wp-plugin-directory.mjs +58 -0
- package/scripts/lib/wp-rest-audit.mjs +59 -0
- package/scripts/run-batch-audit.mjs +111 -0
- package/scripts/run-security-audit.mjs +1192 -0
|
@@ -0,0 +1,1192 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { chromium } from 'playwright';
|
|
5
|
+
|
|
6
|
+
import { gradeSecurityHeaders, gradeCookies } from './lib/security-headers.mjs';
|
|
7
|
+
import { detectPlatform, detectHosting, lookupDnsRecords, getTlsCertInfo, isWeakTlsProtocol } from './lib/fingerprint.mjs';
|
|
8
|
+
import { checkResponseConsistency, checkReverseProxyIndicators, checkDnsLoadBalancingHint } from './lib/infra-topology.mjs';
|
|
9
|
+
import { ADMIN_LOGIN_PATHS, EXPOSED_FILE_PATHS, EXPOSED_API_PATHS, probeAllPaths } from './lib/exposed-paths.mjs';
|
|
10
|
+
import {
|
|
11
|
+
extractInventoryFromHtml, identifyLibraries, extractWpPluginsAndThemes,
|
|
12
|
+
detectWpCoreVersion, fetchWpPluginReadmeVersion, detectDrupalCoreVersion,
|
|
13
|
+
} from './lib/script-inventory.mjs';
|
|
14
|
+
import { npmPackageNameFor, osvLookup, wpscanCoreLookup, wpscanPluginLookup, wpscanThemeLookup } from './lib/vuln-lookup.mjs';
|
|
15
|
+
import { scanForPii, detectPaymentProcessors } from './lib/pii-payment.mjs';
|
|
16
|
+
import { checkCorsMisconfig, findMixedContent, summarizeCrawlerExposure } from './lib/misc-checks.mjs';
|
|
17
|
+
import { checkSecurityTxt } from './lib/security-txt.mjs';
|
|
18
|
+
import { computeRiskGrade, sortFindingsBySeverity, isGradeBelow, hasFindingAtOrAbove } from './lib/scoring.mjs';
|
|
19
|
+
import {
|
|
20
|
+
checkSubdomainTakeover, checkHttpMethods, checkOpenRedirect, checkMissingSri,
|
|
21
|
+
checkExposedSourceMaps, checkEmailAuthRecords, enumerateWpAuthors,
|
|
22
|
+
checkInsecureLoginForms, checkVerboseErrors, scanCookiesForJwt,
|
|
23
|
+
} from './lib/pentest-recon.mjs';
|
|
24
|
+
import { loadBranding, buildSecurityDashboardHtml } from './lib/report-builder.mjs';
|
|
25
|
+
import { loadWordlistEntries } from './lib/exposed-paths.mjs';
|
|
26
|
+
import { resolveIntensity } from './lib/intensity.mjs';
|
|
27
|
+
import { scanPorts, SENSITIVE_PORTS } from './lib/port-scan.mjs';
|
|
28
|
+
import { enumerateSubdomainsCrtSh, checkLiveness } from './lib/subdomain-enum.mjs';
|
|
29
|
+
import { rdapLookup } from './lib/whois-lookup.mjs';
|
|
30
|
+
import { configureFetchProxy, playwrightProxyOption } from './lib/proxy-support.mjs';
|
|
31
|
+
import {
|
|
32
|
+
isLoggedIntoWpAdmin, scanWpPlugins, scanWpThemes, scanWpCoreAndPhp, scanWpUsers,
|
|
33
|
+
detectSecurityPlugins, checkPhpEol,
|
|
34
|
+
} from './lib/wp-admin-audit.mjs';
|
|
35
|
+
import { verifyWpAppCredentials, wpRestPlugins, wpRestThemes, wpRestUsers } from './lib/wp-rest-audit.mjs';
|
|
36
|
+
import { lookupLatestPluginVersion, lookupLatestThemeVersion, checkOutdatedAgainstDirectory } from './lib/wp-plugin-directory.mjs';
|
|
37
|
+
import {
|
|
38
|
+
isLoggedIntoDrupalAdmin, scanDrupalStatusReport, scanDrupalModules, scanDrupalUsers,
|
|
39
|
+
checkPhpEol as checkDrupalPhpEol, checkDrupalCoreEol,
|
|
40
|
+
} from './lib/drupal-admin-audit.mjs';
|
|
41
|
+
import { enumerateTlsProtocols, getWeakProtocolFindings } from './lib/tls-enum.mjs';
|
|
42
|
+
import { introspectGraphQL } from './lib/graphql-introspect.mjs';
|
|
43
|
+
import { loadBaseline, applyBaseline } from './lib/baseline.mjs';
|
|
44
|
+
import { resolveAuthorization, ACTIVE_CHECKS } from './lib/authorization.mjs';
|
|
45
|
+
import { loadIndex as loadWordfenceIndex, lookup as wordfenceLookup, severityOf as wordfenceSeverity, WORDFENCE_ATTRIBUTION } from './lib/wordfence.mjs';
|
|
46
|
+
|
|
47
|
+
// ── CLI / generic helpers ───────────────────────────────────────────────
|
|
48
|
+
function parseArgs(argv) {
|
|
49
|
+
const args = {};
|
|
50
|
+
for (let i = 2; i < argv.length; i++) {
|
|
51
|
+
const a = argv[i];
|
|
52
|
+
if (!a.startsWith('--')) continue;
|
|
53
|
+
const key = a.replace(/^--/, '');
|
|
54
|
+
const next = argv[i + 1];
|
|
55
|
+
if (!next || next.startsWith('--')) args[key] = true;
|
|
56
|
+
else { args[key] = next; i++; }
|
|
57
|
+
}
|
|
58
|
+
return args;
|
|
59
|
+
}
|
|
60
|
+
function slugifySite(site) { try { return new URL(site).hostname.replace(/^www\./, ''); } catch { return 'site'; } }
|
|
61
|
+
function runId(site) {
|
|
62
|
+
const d = new Date();
|
|
63
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
64
|
+
return `${slugifySite(site)}-${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
|
65
|
+
}
|
|
66
|
+
function normalizeUrl(u) {
|
|
67
|
+
try {
|
|
68
|
+
const url = new URL(u);
|
|
69
|
+
url.hash = '';
|
|
70
|
+
if (url.pathname !== '/' && url.pathname.endsWith('/')) url.pathname = url.pathname.slice(0, -1);
|
|
71
|
+
return url.toString();
|
|
72
|
+
} catch { return String(u || '').trim(); }
|
|
73
|
+
}
|
|
74
|
+
function sameOrigin(a, b) { try { return new URL(a).origin === new URL(b).origin; } catch { return false; } }
|
|
75
|
+
function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
|
|
76
|
+
function escapeCsv(value) {
|
|
77
|
+
const s = String(value ?? '');
|
|
78
|
+
if (/[",\n\r]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
|
|
79
|
+
return s;
|
|
80
|
+
}
|
|
81
|
+
function writeCsv(filePath, columns, rows) {
|
|
82
|
+
const lines = [columns.join(',')];
|
|
83
|
+
for (const row of rows) lines.push(columns.map((col) => escapeCsv(row[col])).join(','));
|
|
84
|
+
fs.writeFileSync(filePath, lines.join('\n') + '\n', 'utf8');
|
|
85
|
+
}
|
|
86
|
+
function semverParts(v) { return String(v || '0').split('.').map((n) => parseInt(n, 10) || 0); }
|
|
87
|
+
function semverLt(a, b) {
|
|
88
|
+
const pa = semverParts(a), pb = semverParts(b);
|
|
89
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
90
|
+
const x = pa[i] || 0, y = pb[i] || 0;
|
|
91
|
+
if (x < y) return true;
|
|
92
|
+
if (x > y) return false;
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function fetchWithHeaders(url, { timeoutMs = 15000 } = {}) {
|
|
98
|
+
const controller = new AbortController();
|
|
99
|
+
const t = setTimeout(() => controller.abort(), timeoutMs);
|
|
100
|
+
try {
|
|
101
|
+
const res = await fetch(url, { redirect: 'follow', headers: { 'user-agent': 'Universal-Security-Audit' }, signal: controller.signal });
|
|
102
|
+
clearTimeout(t);
|
|
103
|
+
const setCookies = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : (res.headers.get('set-cookie') ? [res.headers.get('set-cookie')] : []);
|
|
104
|
+
return { ok: true, status: res.status, headers: res.headers, setCookies, finalUrl: res.url };
|
|
105
|
+
} catch (e) {
|
|
106
|
+
clearTimeout(t);
|
|
107
|
+
return { ok: false, error: String(e?.message || e) };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function buildRobotsMatcher(startUrl) {
|
|
112
|
+
try {
|
|
113
|
+
const robotsUrl = new URL('/robots.txt', startUrl).toString();
|
|
114
|
+
const res = await fetch(robotsUrl, { headers: { 'user-agent': 'Universal-Security-Audit' } });
|
|
115
|
+
if (!res.ok) throw new Error('no robots.txt');
|
|
116
|
+
const text = await res.text();
|
|
117
|
+
const disallows = [];
|
|
118
|
+
for (const line of text.split(/\r?\n/g)) {
|
|
119
|
+
const m = /^disallow:\s*(.+)$/i.exec(line.trim());
|
|
120
|
+
if (m) disallows.push(m[1].trim());
|
|
121
|
+
}
|
|
122
|
+
function isAllowedUrl(url) {
|
|
123
|
+
try {
|
|
124
|
+
const u = new URL(url);
|
|
125
|
+
const p = `${u.pathname}${u.search || ''}`;
|
|
126
|
+
for (const rule of disallows) {
|
|
127
|
+
if (!rule || rule === '/') continue;
|
|
128
|
+
const norm = rule.replace(/\*$/, '');
|
|
129
|
+
if (p.startsWith(norm) || p.includes(norm.replace(/\*/g, ''))) return false;
|
|
130
|
+
}
|
|
131
|
+
return true;
|
|
132
|
+
} catch { return true; }
|
|
133
|
+
}
|
|
134
|
+
return { isAllowedUrl };
|
|
135
|
+
} catch { return { isAllowedUrl: null }; }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── Authenticated-scan support (HTTP basic + form login) ────────────────
|
|
139
|
+
function loadAuthConfig(filePath) {
|
|
140
|
+
try { return JSON.parse(fs.readFileSync(path.resolve(process.cwd(), filePath), 'utf8')); }
|
|
141
|
+
catch (e) { throw new Error(`Could not read auth config at ${filePath}: ${String(e?.message || e)}`); }
|
|
142
|
+
}
|
|
143
|
+
function getAuthSettings(args) {
|
|
144
|
+
let cfg = {};
|
|
145
|
+
if (args['auth-config']) cfg = loadAuthConfig(args['auth-config']);
|
|
146
|
+
const httpUsername = args['http-username'] || process.env.USA_HTTP_USERNAME || cfg.httpUsername || '';
|
|
147
|
+
const httpPassword = args['http-password'] || process.env.USA_HTTP_PASSWORD || cfg.httpPassword || '';
|
|
148
|
+
const loginUrl = args['login-url'] || cfg.loginUrl || '';
|
|
149
|
+
const username = args['username'] || process.env.USA_LOGIN_USERNAME || cfg.username || '';
|
|
150
|
+
const password = args['password'] || process.env.USA_LOGIN_PASSWORD || cfg.password || '';
|
|
151
|
+
const usernameSelector = args['username-selector'] || cfg.usernameSelector || "input[name='log'], input[name='username'], input[type='email']";
|
|
152
|
+
const passwordSelector = args['password-selector'] || cfg.passwordSelector || "input[name='pwd'], input[name='password'], input[type='password']";
|
|
153
|
+
const submitSelector = args['submit-selector'] || cfg.submitSelector || "button[type='submit'], input[type='submit']";
|
|
154
|
+
const readySelector = args['ready-selector'] || cfg.readySelector || '';
|
|
155
|
+
const postLoginWaitMs = Number(args['post-login-wait-ms'] || cfg.postLoginWaitMs || 2000);
|
|
156
|
+
return {
|
|
157
|
+
httpCredentials: httpUsername || httpPassword ? { username: httpUsername, password: httpPassword } : null,
|
|
158
|
+
formAuth: loginUrl && username ? { loginUrl, username, password, usernameSelector, passwordSelector, submitSelector, readySelector, postLoginWaitMs } : null,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
async function maybePerformFormLogin(page, formAuth, slowMode = false) {
|
|
162
|
+
if (!formAuth) return false;
|
|
163
|
+
statusMsg('🔐', c.cyan, `Attempting form login at ${formAuth.loginUrl}`);
|
|
164
|
+
try {
|
|
165
|
+
await page.goto(formAuth.loginUrl, { waitUntil: slowMode ? 'domcontentloaded' : 'networkidle', timeout: 45000 });
|
|
166
|
+
await page.locator(formAuth.usernameSelector).first().fill(formAuth.username);
|
|
167
|
+
await page.locator(formAuth.passwordSelector).first().fill(formAuth.password || '');
|
|
168
|
+
if (formAuth.submitSelector) {
|
|
169
|
+
await Promise.allSettled([
|
|
170
|
+
page.waitForLoadState(slowMode ? 'domcontentloaded' : 'networkidle', { timeout: 20000 }),
|
|
171
|
+
page.locator(formAuth.submitSelector).first().click(),
|
|
172
|
+
]);
|
|
173
|
+
} else {
|
|
174
|
+
await page.keyboard.press('Enter');
|
|
175
|
+
await page.waitForLoadState(slowMode ? 'domcontentloaded' : 'networkidle', { timeout: 20000 }).catch(() => {});
|
|
176
|
+
}
|
|
177
|
+
if (formAuth.readySelector) await page.locator(formAuth.readySelector).first().waitFor({ state: 'visible', timeout: 20000 });
|
|
178
|
+
else await page.waitForTimeout(formAuth.postLoginWaitMs || 2000);
|
|
179
|
+
statusMsg('🔐', c.brightGreen, 'Form login step completed.');
|
|
180
|
+
return true;
|
|
181
|
+
} catch (e) {
|
|
182
|
+
statusMsg('⚠', c.brightYellow, `Form login failed: ${String(e?.message || e)}`);
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── Terminal UI helpers (shared style with universal-seo-audit / universal-accessibility-audit) ──
|
|
188
|
+
const c = {
|
|
189
|
+
reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
|
|
190
|
+
red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m',
|
|
191
|
+
brightRed: '\x1b[91m', brightGreen: '\x1b[92m', brightYellow: '\x1b[93m',
|
|
192
|
+
brightCyan: '\x1b[96m', brightMagenta: '\x1b[95m', brightBlue: '\x1b[94m',
|
|
193
|
+
};
|
|
194
|
+
const rainbowColors = [c.brightRed, c.brightYellow, c.brightGreen, c.brightCyan, c.brightBlue, c.brightMagenta];
|
|
195
|
+
function rainbow(text) { return [...text].map((ch, i) => ch === ' ' ? ch : `${rainbowColors[i % rainbowColors.length]}${ch}`).join('') + c.reset; }
|
|
196
|
+
function formatDuration(ms) {
|
|
197
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
198
|
+
const s = Math.round(ms / 1000);
|
|
199
|
+
if (s < 60) return `${s}s`;
|
|
200
|
+
const m = Math.floor(s / 60), rem = s % 60;
|
|
201
|
+
return rem > 0 ? `${m}m ${rem}s` : `${m}m`;
|
|
202
|
+
}
|
|
203
|
+
// When --json is set, stdout is reserved for the final JSON document (so it can be piped to jq/etc);
|
|
204
|
+
// all progress/decorative output moves to stderr instead of being suppressed outright.
|
|
205
|
+
function out(line) { (jsonOut ? console.error : console.log)(line); }
|
|
206
|
+
function phaseHeader(label, icon = '▸') {
|
|
207
|
+
out('');
|
|
208
|
+
out(` ${rainbow(icon)} ${c.bold}${c.brightCyan}${label}${c.reset} ${c.dim}${'─'.repeat(Math.max(0, 52 - label.length))}${c.reset}`);
|
|
209
|
+
}
|
|
210
|
+
function phaseDone(label, elapsed) { out(` ${c.brightGreen}✔${c.reset} ${label} ${c.dim}in${c.reset} ${c.brightYellow}${formatDuration(elapsed)}${c.reset}`); }
|
|
211
|
+
function statusMsg(icon, color, msg) { out(` ${color}${icon}${c.reset} ${msg}`); }
|
|
212
|
+
|
|
213
|
+
// Live in-place progress (carriage-return updates, no trailing newline) — same pattern as
|
|
214
|
+
// universal-seo-audit/universal-accessibility-audit. Writes to stderr instead of stdout when
|
|
215
|
+
// --json is set, same reasoning as out() above: keep stdout reserved for the final JSON document.
|
|
216
|
+
function progressWrite(str) { (jsonOut ? process.stderr : process.stdout).write(str); }
|
|
217
|
+
function clearProgressLine() { progressWrite('\r\x1b[K'); }
|
|
218
|
+
const spinnerFrames = ['◐', '◓', '◑', '◒'];
|
|
219
|
+
let spinnerIdx = 0;
|
|
220
|
+
function spinner() { return rainbowColors[spinnerIdx % rainbowColors.length] + spinnerFrames[spinnerIdx++ % spinnerFrames.length] + c.reset; }
|
|
221
|
+
function rainbowBar(filled, empty, width = 30) {
|
|
222
|
+
const chars = [];
|
|
223
|
+
for (let i = 0; i < filled; i++) chars.push(`${rainbowColors[i % rainbowColors.length]}█`);
|
|
224
|
+
return chars.join('') + `${c.dim}${'░'.repeat(Math.max(0, empty))}${c.reset}`;
|
|
225
|
+
}
|
|
226
|
+
// `total` may be Infinity/0/undefined for indeterminate work — falls back to a spinner + count with
|
|
227
|
+
// no bar/percentage in that case, rather than dividing by a non-finite/zero total.
|
|
228
|
+
function progressLine(current, total, label, extra = '') {
|
|
229
|
+
const hasTotal = Number.isFinite(total) && total > 0;
|
|
230
|
+
const done = hasTotal && current >= total;
|
|
231
|
+
const spin = done ? `${c.brightGreen}✔${c.reset}` : spinner();
|
|
232
|
+
if (!hasTotal) {
|
|
233
|
+
progressWrite(`\r ${spin} ${c.bold}${current}${c.reset} ${c.dim}${label}${c.reset}${extra ? ' ' + extra : ''}\x1b[K`);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const pct = Math.min(100, Math.round((current / total) * 100));
|
|
237
|
+
const barWidth = 30;
|
|
238
|
+
const filled = Math.min(barWidth, Math.round((current / total) * barWidth));
|
|
239
|
+
const bar = rainbowBar(filled, barWidth - filled, barWidth);
|
|
240
|
+
const pctStr = pct === 100 ? `${c.brightGreen}${pct}%${c.reset}` : `${c.brightCyan}${pct}%${c.reset}`;
|
|
241
|
+
progressWrite(`\r ${spin} ${bar} ${c.bold}${current}${c.reset}${c.dim}/${total}${c.reset} ${pctStr} ${c.dim}${label}${c.reset}${extra ? ' ' + extra : ''}\x1b[K`);
|
|
242
|
+
}
|
|
243
|
+
// For a single indeterminate wait (e.g. one slow external API call) rather than an iterable loop —
|
|
244
|
+
// ticks a spinner + elapsed time every 250ms until `promise` settles, then clears the line.
|
|
245
|
+
async function withTicker(promise, label) {
|
|
246
|
+
const start = Date.now();
|
|
247
|
+
const timer = setInterval(() => progressWrite(`\r ${spinner()} ${c.dim}${label}...${c.reset} ${c.brightYellow}${formatDuration(Date.now() - start)}${c.reset}\x1b[K`), 250);
|
|
248
|
+
try {
|
|
249
|
+
return await promise;
|
|
250
|
+
} finally {
|
|
251
|
+
clearInterval(timer);
|
|
252
|
+
clearProgressLine();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
function sevColor(sev) {
|
|
256
|
+
const map = { critical: c.brightRed, high: c.red, medium: c.brightYellow, low: c.yellow, info: c.dim };
|
|
257
|
+
return `${map[sev] || c.dim}${sev.toUpperCase()}${c.reset}`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── Findings collection ─────────────────────────────────────────────────
|
|
261
|
+
const findings = [];
|
|
262
|
+
function addFinding({ category, severity, title, detail, url = '', evidence = '' }) {
|
|
263
|
+
findings.push({ category, severity, title, detail, url, evidence });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── Main ─────────────────────────────────────────────────────────────────
|
|
267
|
+
const args = parseArgs(process.argv);
|
|
268
|
+
if (!args.site) { console.error('Missing --site'); process.exit(1); }
|
|
269
|
+
const site = normalizeUrl(args.site);
|
|
270
|
+
const origin = new URL(site).origin;
|
|
271
|
+
const hostname = new URL(site).hostname;
|
|
272
|
+
// No --max-pages means no limit: crawl/sitemap discovery runs until it exhausts reachable
|
|
273
|
+
// pages/sitemap entries rather than silently capping at some default.
|
|
274
|
+
const maxPages = args['max-pages'] ? Number(args['max-pages']) : Infinity;
|
|
275
|
+
// Caps how many pages matching the same URL "shape" (e.g. /participants/mypage/:id/:id) get
|
|
276
|
+
// crawled — without this, sites with per-record pages (fundraiser pages, product pages, forum
|
|
277
|
+
// threads, etc.) turn an uncapped --crawl into a crawl trap of thousands of near-duplicate pages.
|
|
278
|
+
const maxPerTemplate = args['max-per-url-pattern'] ? Number(args['max-per-url-pattern']) : 3;
|
|
279
|
+
// How many link "hops" from the homepage --crawl will follow: 0 = homepage only, 1 = homepage +
|
|
280
|
+
// whatever it links to, 2 = plus one more layer of children, etc. No flag = no limit (full crawl).
|
|
281
|
+
const maxDepth = args['max-depth'] ? Number(args['max-depth']) : Infinity;
|
|
282
|
+
const slowMode = Boolean(args['slow']);
|
|
283
|
+
const respectRobots = Boolean(args['respect-robots']);
|
|
284
|
+
// Active checks only run with the owner's written permission confirmed (lib/authorization.mjs).
|
|
285
|
+
const authorization = await resolveAuthorization(args, site);
|
|
286
|
+
const activeAllowed = Boolean(authorization);
|
|
287
|
+
const skipExposedPaths = Boolean(args['skip-exposed-paths']) || !activeAllowed;
|
|
288
|
+
const skipCve = Boolean(args['skip-cve']);
|
|
289
|
+
const skipRecon = Boolean(args['skip-recon']) || !activeAllowed;
|
|
290
|
+
const skipPortScan = Boolean(args['skip-port-scan']) || !activeAllowed;
|
|
291
|
+
const skipSubdomainEnum = Boolean(args['skip-subdomain-enum']) || !activeAllowed;
|
|
292
|
+
const skipWhois = Boolean(args['skip-whois']);
|
|
293
|
+
const skipAdminAudit = Boolean(args['skip-admin-audit']) || !activeAllowed;
|
|
294
|
+
const wpscanApiKey = args['wpscan-key'] || process.env.WPSCAN_API_KEY || '';
|
|
295
|
+
const wordfenceApiKey = args['wordfence-key'] || process.env.WORDFENCE_API_KEY || '';
|
|
296
|
+
const branding = loadBranding(fs, path, args['brand-config']);
|
|
297
|
+
const intensity = resolveIntensity(args['intensity']);
|
|
298
|
+
const proxyUrl = args['proxy'] || '';
|
|
299
|
+
if (proxyUrl) configureFetchProxy(proxyUrl);
|
|
300
|
+
const failOn = args['fail-on'] ? String(args['fail-on']).toLowerCase() : '';
|
|
301
|
+
const minGrade = args['min-grade'] ? String(args['min-grade']).toUpperCase() : '';
|
|
302
|
+
const jsonOut = Boolean(args['json']);
|
|
303
|
+
const authSettings = getAuthSettings(args);
|
|
304
|
+
const wpAppUser = args['wp-app-user'] || process.env.USA_WP_APP_USER || '';
|
|
305
|
+
const wpAppPassword = args['wp-app-password'] || process.env.USA_WP_APP_PASSWORD || '';
|
|
306
|
+
const baseline = loadBaseline(fs, path, args['baseline']);
|
|
307
|
+
const outDir = path.resolve('reports/' + runId(site));
|
|
308
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
309
|
+
|
|
310
|
+
const auditStart = Date.now();
|
|
311
|
+
out('');
|
|
312
|
+
out(` ${rainbow('╔══════════════════════════════════════════════════════════╗')}`);
|
|
313
|
+
out(` ${rainbow('║')} ${c.bold}${c.brightRed}🛡 Universal Security Audit${c.reset} ${rainbow('║')}`);
|
|
314
|
+
out(` ${rainbow('║')} ${c.dim}Attack Surface · Headers · CVEs · PII · Payments${c.reset} ${rainbow('║')}`);
|
|
315
|
+
out(` ${rainbow('╚══════════════════════════════════════════════════════════╝')}`);
|
|
316
|
+
out('');
|
|
317
|
+
out(` ${c.brightMagenta}🎯${c.reset} ${c.bold}Target:${c.reset} ${c.brightCyan}${site}${c.reset}`);
|
|
318
|
+
out(` ${c.brightMagenta}📁${c.reset} ${c.bold}Output:${c.reset} ${c.dim}${outDir}${c.reset}`);
|
|
319
|
+
out(` ${c.brightMagenta}🔑${c.reset} ${c.bold}WordPress CVE data:${c.reset} ${[wordfenceApiKey && `${c.brightGreen}Wordfence${c.reset}`, wpscanApiKey && `${c.brightGreen}WPScan${c.reset}`].filter(Boolean).join(' + ') || `${c.dim}no key (set WORDFENCE_API_KEY or WPSCAN_API_KEY to look up WordPress vulnerabilities)${c.reset}`}`);
|
|
320
|
+
out(` ${c.brightMagenta}⚡${c.reset} ${c.bold}Intensity:${c.reset} ${args['intensity'] || 'normal'}${proxyUrl ? ` ${c.dim}·${c.reset} proxying via ${proxyUrl}` : ''}`);
|
|
321
|
+
if (authSettings.httpCredentials || authSettings.formAuth) console.log(` ${c.brightMagenta}🔐${c.reset} ${c.bold}Auth:${c.reset} ${authSettings.formAuth ? 'form login configured' : 'HTTP basic credentials configured'}`);
|
|
322
|
+
if (activeAllowed) {
|
|
323
|
+
statusMsg('⚠', c.brightYellow, `Active checks ON (permission confirmed ${authorization.how === 'flag' ? 'with --authorized' : 'at the prompt'}; recorded in the report). Only run them on sites you own or have the owner's written permission to test.`);
|
|
324
|
+
} else {
|
|
325
|
+
statusMsg('🔒', c.brightYellow, `Passive checks only. These need the owner's written permission and were skipped: ${ACTIVE_CHECKS.join('; ')}. Confirm with --authorized to run them.`);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
let robotsCfg = { isAllowedUrl: null };
|
|
329
|
+
if (respectRobots) robotsCfg = await buildRobotsMatcher(site);
|
|
330
|
+
|
|
331
|
+
// ── Phase 1: Setup / URL discovery ──────────────────────────────────────
|
|
332
|
+
const NON_PAGE_EXTENSION_RE = /\.(jpg|jpeg|png|gif|webp|avif|bmp|ico|svg|tiff?|heic|pdf|docx?|xlsx?|pptx?|zip|rar|7z|tar|gz|dmg|exe|mp3|mp4|m4a|mov|avi|wav|ogg|ogv|webm|flac|woff2?|ttf|eot|otf|csv|xml|json|rss|css|js|mjs)$/i;
|
|
333
|
+
function isCrawlablePage(u) { try { return !NON_PAGE_EXTENSION_RE.test(new URL(u).pathname); } catch { return true; } }
|
|
334
|
+
async function fetchXml(url) {
|
|
335
|
+
const res = await fetch(url, { headers: { 'user-agent': 'Universal-Security-Audit' } });
|
|
336
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
337
|
+
return res.text();
|
|
338
|
+
}
|
|
339
|
+
function parseSitemapLocs(xml) { return [...xml.matchAll(/<loc>(.*?)<\/loc>/gsi)].map((m) => normalizeUrl(m[1].trim())).filter(Boolean); }
|
|
340
|
+
function parseRobotsSitemapDirectives(robotsText = '') {
|
|
341
|
+
return [...robotsText.matchAll(/^sitemap:\s*(\S+)$/gim)].map((m) => m[1].trim());
|
|
342
|
+
}
|
|
343
|
+
async function discoverUrlsFromSitemap(site) {
|
|
344
|
+
const base = new URL(site).origin;
|
|
345
|
+
let robotsDeclaredSitemaps = [];
|
|
346
|
+
try { robotsDeclaredSitemaps = parseRobotsSitemapDirectives(await fetchXml(new URL('/robots.txt', base).toString())); } catch {}
|
|
347
|
+
// robots.txt-declared sitemap URLs take priority — a site can put its sitemap anywhere it wants
|
|
348
|
+
// and robots.txt is the standard way to tell crawlers where; only fall back to guessing common
|
|
349
|
+
// paths (sitemap_index.xml/wp-sitemap.xml/sitemap.xml) if nothing is declared.
|
|
350
|
+
const candidates = [...new Set([...robotsDeclaredSitemaps, `${base}/sitemap_index.xml`, `${base}/wp-sitemap.xml`, `${base}/sitemap.xml`])];
|
|
351
|
+
let xml = null, sitemapUrl = null;
|
|
352
|
+
for (const cand of candidates) {
|
|
353
|
+
try { xml = await fetchXml(cand); sitemapUrl = cand; break; } catch {}
|
|
354
|
+
}
|
|
355
|
+
if (!xml) throw new Error('Could not fetch sitemap');
|
|
356
|
+
let urls = [];
|
|
357
|
+
if (/<sitemapindex/i.test(xml)) {
|
|
358
|
+
const childSitemaps = parseSitemapLocs(xml);
|
|
359
|
+
for (const su of childSitemaps.slice(0, 20)) {
|
|
360
|
+
try { urls.push(...parseSitemapLocs(await fetchXml(su))); } catch {}
|
|
361
|
+
}
|
|
362
|
+
} else {
|
|
363
|
+
urls = parseSitemapLocs(xml);
|
|
364
|
+
}
|
|
365
|
+
return {
|
|
366
|
+
sitemapUrl,
|
|
367
|
+
urls: [...new Set(urls)].filter((u) => sameOrigin(u, site) && isCrawlablePage(u)),
|
|
368
|
+
robotsDeclaredSitemaps,
|
|
369
|
+
sitemapDeclaredInRobots: !!sitemapUrl && robotsDeclaredSitemaps.includes(sitemapUrl),
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
// Collapses path segments that look like IDs/hashes/years into a placeholder so URLs that are
|
|
373
|
+
// really "the same page template with a different record" (e.g. /participants/mypage/1174017/2026)
|
|
374
|
+
// get recognized as one pattern rather than each counting as a distinct page to crawl.
|
|
375
|
+
function urlTemplate(url) {
|
|
376
|
+
try {
|
|
377
|
+
const u = new URL(url);
|
|
378
|
+
const segments = u.pathname.split('/').map((seg) => {
|
|
379
|
+
if (!seg) return seg;
|
|
380
|
+
if (/^\d+$/.test(seg)) return ':id';
|
|
381
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(seg)) return ':uuid';
|
|
382
|
+
if (seg.length > 12 && /[a-z]/i.test(seg) && /\d/.test(seg)) return ':hash';
|
|
383
|
+
return seg;
|
|
384
|
+
});
|
|
385
|
+
return segments.join('/');
|
|
386
|
+
} catch { return url; }
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function crawlForUrls(site, { maxPages, isAllowedUrl, maxPerTemplate = 3, maxDepth = Infinity }) {
|
|
390
|
+
const browser = await chromium.launch({ headless: true });
|
|
391
|
+
const page = await browser.newPage();
|
|
392
|
+
const seen = new Set([site]);
|
|
393
|
+
const queue = [{ url: site, depth: 0 }];
|
|
394
|
+
const templateCounts = new Map();
|
|
395
|
+
let skippedByTemplate = 0;
|
|
396
|
+
let skippedByDepth = 0;
|
|
397
|
+
const crawlStart = Date.now();
|
|
398
|
+
while (queue.length && seen.size < maxPages) {
|
|
399
|
+
const { url, depth } = queue.shift();
|
|
400
|
+
try {
|
|
401
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
402
|
+
if (depth < maxDepth) {
|
|
403
|
+
const hrefs = await page.$$eval('a[href]', (as) => as.map((a) => a.href));
|
|
404
|
+
for (const href of hrefs) {
|
|
405
|
+
const abs = normalizeUrl(href);
|
|
406
|
+
if (!sameOrigin(abs, site) || !isCrawlablePage(abs) || seen.has(abs)) continue;
|
|
407
|
+
if (isAllowedUrl && !isAllowedUrl(abs)) continue;
|
|
408
|
+
if (seen.size >= maxPages) break;
|
|
409
|
+
const template = urlTemplate(abs);
|
|
410
|
+
const count = templateCounts.get(template) || 0;
|
|
411
|
+
if (count >= maxPerTemplate) { skippedByTemplate++; continue; }
|
|
412
|
+
templateCounts.set(template, count + 1);
|
|
413
|
+
seen.add(abs);
|
|
414
|
+
queue.push({ url: abs, depth: depth + 1 });
|
|
415
|
+
}
|
|
416
|
+
} else {
|
|
417
|
+
skippedByDepth++;
|
|
418
|
+
}
|
|
419
|
+
} catch {}
|
|
420
|
+
const shortUrl = url.length > 60 ? url.slice(0, 57) + '...' : url;
|
|
421
|
+
const capNotes = [
|
|
422
|
+
skippedByTemplate ? `skipped ${c.brightYellow}${skippedByTemplate}${c.reset} repeat-template` : '',
|
|
423
|
+
skippedByDepth ? `${c.brightYellow}${skippedByDepth}${c.reset} beyond max depth` : '',
|
|
424
|
+
].filter(Boolean).join(', ');
|
|
425
|
+
const extra = `${c.dim}queue:${c.reset} ${c.brightYellow}${queue.length}${c.reset} ${c.dim}·${c.reset} elapsed ${c.brightYellow}${formatDuration(Date.now() - crawlStart)}${c.reset}${capNotes ? ` ${c.dim}·${c.reset} ${capNotes}` : ''} ${c.dim}·${c.reset} ${shortUrl}`;
|
|
426
|
+
progressLine(seen.size, maxPages, 'pages found', extra);
|
|
427
|
+
}
|
|
428
|
+
clearProgressLine();
|
|
429
|
+
await browser.close();
|
|
430
|
+
if (skippedByTemplate) statusMsg('🪤', c.dim, `Skipped ${skippedByTemplate} additional page(s) that matched an already-sampled URL template (e.g. paginated/per-record pages) — capped at ${maxPerTemplate} per template to avoid a crawl trap. Use --max-per-url-pattern to change this.`);
|
|
431
|
+
if (skippedByDepth) statusMsg('🌳', c.dim, `Did not follow links from ${skippedByDepth} page(s) beyond --max-depth ${maxDepth}.`);
|
|
432
|
+
return [...seen];
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
phaseHeader('Phase 1: URL discovery', '🗺️');
|
|
436
|
+
const discStart = Date.now();
|
|
437
|
+
let pageUrls = [site];
|
|
438
|
+
let sitemapDiscovery = { sitemapUrl: null, robotsDeclaredSitemaps: [], sitemapDeclaredInRobots: false };
|
|
439
|
+
if (args['crawl']) {
|
|
440
|
+
if (!Number.isFinite(maxPages)) statusMsg('🕷️', c.dim, 'No --max-pages set — crawling until every reachable internal link is exhausted (pass --max-pages to cap this on large sites).');
|
|
441
|
+
pageUrls = await crawlForUrls(site, { maxPages, isAllowedUrl: robotsCfg.isAllowedUrl, maxPerTemplate, maxDepth });
|
|
442
|
+
statusMsg('🕷️', c.brightGreen, `Crawled ${pageUrls.length} page(s) via link-following.`);
|
|
443
|
+
} else {
|
|
444
|
+
try {
|
|
445
|
+
sitemapDiscovery = await discoverUrlsFromSitemap(site);
|
|
446
|
+
const { sitemapUrl, urls, sitemapDeclaredInRobots } = sitemapDiscovery;
|
|
447
|
+
pageUrls = urls.length ? [...new Set([site, ...urls])].slice(0, maxPages) : [site];
|
|
448
|
+
statusMsg('🌐', c.brightGreen, `Found ${urls.length} URL(s) from ${sitemapUrl}${sitemapDeclaredInRobots ? ' (declared in robots.txt)' : ' (not declared in robots.txt — found by guessing the path)'}; scanning ${pageUrls.length} page(s).`);
|
|
449
|
+
} catch {
|
|
450
|
+
statusMsg('⚠', c.brightYellow, 'No sitemap found at any declared or common path. Scanning homepage only — pass --crawl to discover more pages by following links.');
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (robotsCfg.isAllowedUrl) pageUrls = pageUrls.filter((u) => robotsCfg.isAllowedUrl(u));
|
|
454
|
+
phaseDone('URL discovery', Date.now() - discStart);
|
|
455
|
+
|
|
456
|
+
// ── Phase 2: Browser setup ──────────────────────────────────────────────
|
|
457
|
+
phaseHeader('Phase 2: Browser setup', '🚀');
|
|
458
|
+
const setupStart = Date.now();
|
|
459
|
+
const browser = await chromium.launch({ headless: true, proxy: playwrightProxyOption(proxyUrl) });
|
|
460
|
+
const context = await browser.newContext({
|
|
461
|
+
userAgent: 'Mozilla/5.0 (compatible; Universal-Security-Audit/1.0)',
|
|
462
|
+
httpCredentials: authSettings.httpCredentials || undefined,
|
|
463
|
+
});
|
|
464
|
+
phaseDone('Browser ready', Date.now() - setupStart);
|
|
465
|
+
|
|
466
|
+
// ── Phase 3: Site-wide checks (headers, cookies, TLS, hosting, CORS, crawler exposure) ──
|
|
467
|
+
phaseHeader('Phase 3: Site-wide checks', '🔍');
|
|
468
|
+
const siteWideStart = Date.now();
|
|
469
|
+
const homepageFetch = await fetchWithHeaders(site);
|
|
470
|
+
let homepageHeaders = {};
|
|
471
|
+
if (homepageFetch.ok) {
|
|
472
|
+
homepageHeaders = homepageFetch.headers;
|
|
473
|
+
const grade = gradeSecurityHeaders(homepageHeaders, { isHttps: site.startsWith('https:') });
|
|
474
|
+
statusMsg('📋', grade.score >= 75 ? c.brightGreen : grade.score >= 50 ? c.brightYellow : c.brightRed, `Security header grade: ${c.bold}${grade.grade}${c.reset} (${grade.score}/100)`);
|
|
475
|
+
for (const check of grade.checks) {
|
|
476
|
+
if (check.severity === 'ok') continue;
|
|
477
|
+
addFinding({ category: 'headers', severity: check.severity, title: `${check.header}: ${check.status}`, detail: check.note, url: site });
|
|
478
|
+
}
|
|
479
|
+
const cookieIssues = gradeCookies(homepageFetch.setCookies);
|
|
480
|
+
for (const ci of cookieIssues) {
|
|
481
|
+
addFinding({ category: 'cookies', severity: 'medium', title: `Cookie "${ci.name}" missing flags`, detail: ci.issues.join(', '), url: site });
|
|
482
|
+
}
|
|
483
|
+
} else {
|
|
484
|
+
statusMsg('✖', c.brightRed, `Could not fetch homepage headers: ${homepageFetch.error}`);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const hostingSignals = detectHosting(homepageHeaders);
|
|
488
|
+
if (hostingSignals.length) statusMsg('☁️', c.brightCyan, `Hosting/CDN: ${hostingSignals.map((s) => s.provider).join(', ')}`);
|
|
489
|
+
|
|
490
|
+
let dnsRecords = { a: [], mx: [], ns: [], txt: [] };
|
|
491
|
+
try {
|
|
492
|
+
dnsRecords = await lookupDnsRecords(hostname);
|
|
493
|
+
statusMsg('🌐', c.dim, `DNS: ${dnsRecords.a.length} A record(s), ${dnsRecords.ns.length} NS, ${dnsRecords.mx.length} MX`);
|
|
494
|
+
} catch {}
|
|
495
|
+
|
|
496
|
+
const reverseProxyCheck = checkReverseProxyIndicators(homepageHeaders, hostingSignals);
|
|
497
|
+
if (reverseProxyCheck.likelyProxied) {
|
|
498
|
+
statusMsg('🔀', c.dim, reverseProxyCheck.note);
|
|
499
|
+
addFinding({ category: 'infra-topology', severity: 'info', title: 'Site is served through a reverse proxy/CDN', detail: reverseProxyCheck.note, url: site });
|
|
500
|
+
}
|
|
501
|
+
const dnsLbHint = checkDnsLoadBalancingHint(dnsRecords, hostingSignals);
|
|
502
|
+
if (dnsLbHint.likelyOwnLoadBalancing) {
|
|
503
|
+
statusMsg('🖥️', c.dim, `${dnsLbHint.aRecordCount} A records with no recognized CDN in front — possible DNS-level load balancing across your own servers.`);
|
|
504
|
+
addFinding({ category: 'infra-topology', severity: 'info', title: `${dnsLbHint.aRecordCount} DNS A records, no recognized CDN in front`, detail: 'Multiple IP addresses without a CDN suggests DNS round-robin load balancing across your own servers rather than a CDN edge network. Verify all backend servers are equally patched/configured — inconsistent servers behind a load balancer is a common source of hard-to-reproduce security gaps.', url: `dns:${hostname}` });
|
|
505
|
+
}
|
|
506
|
+
const responseConsistency = await checkResponseConsistency(site);
|
|
507
|
+
if (responseConsistency.checked && responseConsistency.multiServerLikely) {
|
|
508
|
+
const variationSummary = responseConsistency.variations.map((v) => `${v.header}: ${v.values.join(' / ')}`).join('; ');
|
|
509
|
+
statusMsg('🖥️', c.brightYellow, `Repeated requests returned inconsistent headers — likely multiple backend servers: ${variationSummary}`);
|
|
510
|
+
addFinding({ category: 'infra-topology', severity: 'info', title: 'Inconsistent response headers across repeated requests', detail: `${variationSummary}. Different values for the same header on repeated requests to the same URL typically means multiple backend instances are answering behind a load balancer — make sure they're all running the same (patched) software version.`, url: site });
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
let tlsInfo = { ok: false };
|
|
514
|
+
if (site.startsWith('https:')) {
|
|
515
|
+
tlsInfo = await getTlsCertInfo(hostname);
|
|
516
|
+
if (tlsInfo.ok) {
|
|
517
|
+
statusMsg('🔒', tlsInfo.isExpired ? c.brightRed : tlsInfo.isExpiringSoon ? c.brightYellow : c.brightGreen, `TLS: ${tlsInfo.protocol}, cert expires in ${tlsInfo.daysRemaining}d (issuer: ${tlsInfo.issuer})`);
|
|
518
|
+
if (tlsInfo.isExpired) addFinding({ category: 'tls', severity: 'critical', title: 'TLS certificate expired', detail: `Expired ${-tlsInfo.daysRemaining} day(s) ago.`, url: site });
|
|
519
|
+
else if (tlsInfo.isExpiringSoon) addFinding({ category: 'tls', severity: 'medium', title: 'TLS certificate expiring soon', detail: `Expires in ${tlsInfo.daysRemaining} day(s).`, url: site });
|
|
520
|
+
if (isWeakTlsProtocol(tlsInfo.protocol)) addFinding({ category: 'tls', severity: 'high', title: `Weak TLS protocol negotiated: ${tlsInfo.protocol}`, detail: 'Server should disable protocols older than TLS 1.2.', url: site });
|
|
521
|
+
if (!tlsInfo.authorized) addFinding({ category: 'tls', severity: 'high', title: 'TLS certificate not trusted', detail: tlsInfo.authError || 'Certificate validation failed.', url: site });
|
|
522
|
+
} else {
|
|
523
|
+
statusMsg('⚠', c.brightYellow, `TLS check failed: ${tlsInfo.error}`);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const tlsProtocolResults = await enumerateTlsProtocols(hostname);
|
|
527
|
+
const weakProtocolsAccepted = getWeakProtocolFindings(tlsProtocolResults);
|
|
528
|
+
if (weakProtocolsAccepted.length) {
|
|
529
|
+
statusMsg('🔓', c.brightRed, `Server still accepts legacy TLS: ${weakProtocolsAccepted.map((p) => p.protocol).join(', ')}`);
|
|
530
|
+
addFinding({ category: 'tls', severity: 'high', title: `Legacy TLS protocol(s) still accepted: ${weakProtocolsAccepted.map((p) => p.protocol).join(', ')}`, detail: 'Confirmed by directly handshaking with each protocol version — not just reporting the default negotiated one. Legacy TLS lacks modern cipher/MAC protections and should be disabled server-side.', url: site });
|
|
531
|
+
} else {
|
|
532
|
+
statusMsg('🔒', c.dim, 'Legacy TLS 1.0/1.1 correctly rejected by the server.');
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const corsResult = await checkCorsMisconfig(site);
|
|
537
|
+
if (corsResult.checked && corsResult.misconfigured) {
|
|
538
|
+
addFinding({ category: 'cors', severity: 'high', title: 'Permissive CORS with credentials', detail: corsResult.note, url: site });
|
|
539
|
+
statusMsg('⚠', c.brightRed, 'CORS misconfiguration detected (reflects arbitrary Origin + allows credentials).');
|
|
540
|
+
} else if (corsResult.checked && corsResult.reflectsArbitraryOrigin) {
|
|
541
|
+
addFinding({ category: 'cors', severity: 'low', title: 'CORS reflects/wildcards Origin', detail: corsResult.note, url: site });
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// security.txt (RFC 9116), added 0.2.0.
|
|
545
|
+
const securityTxt = await checkSecurityTxt(origin);
|
|
546
|
+
if (!securityTxt.found) {
|
|
547
|
+
statusMsg('🛡️', c.dim, 'security.txt: absent');
|
|
548
|
+
addFinding({ category: 'security-txt', severity: 'low', title: 'No security.txt (RFC 9116)', detail: 'Researchers who find a vulnerability have no published way to report it. Add /.well-known/security.txt with at least a Contact and an Expires field (generator: https://securitytxt.org).', url: securityTxt.url });
|
|
549
|
+
} else {
|
|
550
|
+
statusMsg('🛡️', c.dim, `security.txt: present${securityTxt.legacyLocation ? ' (legacy /security.txt location)' : ''}${securityTxt.signed ? ', PGP-signed' : ''}${securityTxt.problems.length ? `, ${securityTxt.problems.length} problem(s)` : ', valid'}`);
|
|
551
|
+
for (const p of securityTxt.problems) addFinding({ category: 'security-txt', severity: /Expired/.test(p) ? 'medium' : 'low', title: `security.txt: ${p.split(':')[0].split('.')[0]}`, detail: p, url: securityTxt.url });
|
|
552
|
+
for (const w of securityTxt.warnings) addFinding({ category: 'security-txt', severity: 'info', title: `security.txt: ${w.split(':')[0].split(';')[0].split('.')[0]}`, detail: w, url: securityTxt.url });
|
|
553
|
+
if (securityTxt.legacyLocation) addFinding({ category: 'security-txt', severity: 'info', title: 'security.txt is only at the legacy location', detail: 'RFC 9116 puts it at /.well-known/security.txt; /security.txt is a legacy fallback. Serve it (or a redirect) from /.well-known/.', url: securityTxt.url });
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const crawlerExposure = await summarizeCrawlerExposure(origin, sitemapDiscovery);
|
|
557
|
+
statusMsg('🤖', c.dim, `robots.txt: ${crawlerExposure.hasRobots ? 'present' : 'absent'} · llms.txt: ${crawlerExposure.hasLlmsTxt ? 'present' : 'absent'} · sitemap: ${crawlerExposure.hasSitemap ? 'present' : crawlerExposure.hasSitemap === false ? 'absent' : 'not checked'}`);
|
|
558
|
+
if (crawlerExposure.sensitiveLookingDisallows.length) {
|
|
559
|
+
addFinding({ category: 'crawler-exposure', severity: 'info', title: 'robots.txt names sensitive-looking paths', detail: `${crawlerExposure.note} Paths: ${crawlerExposure.sensitiveLookingDisallows.join(', ')}`, url: new URL('/robots.txt', origin).toString() });
|
|
560
|
+
}
|
|
561
|
+
if (crawlerExposure.sitemapUndeclaredButReachable) {
|
|
562
|
+
statusMsg('🗺️', c.dim, `Sitemap found at ${crawlerExposure.sitemapUrl} but robots.txt declares a different one.`);
|
|
563
|
+
addFinding({ category: 'crawler-exposure', severity: 'info', title: 'A working sitemap was found, but robots.txt declares a different sitemap URL', detail: `Found and used: ${crawlerExposure.sitemapUrl}. robots.txt declares: ${crawlerExposure.robotsDeclaredSitemaps.join(', ')}. Search engines that only read robots.txt for sitemap discovery may miss the one actually in use, or vice versa.`, url: new URL('/robots.txt', origin).toString() });
|
|
564
|
+
}
|
|
565
|
+
if (crawlerExposure.sitemapDeclaredButUnreachable) {
|
|
566
|
+
statusMsg('⚠', c.brightYellow, `robots.txt declares a sitemap that could not be fetched: ${crawlerExposure.robotsDeclaredSitemaps.join(', ')}`);
|
|
567
|
+
addFinding({ category: 'crawler-exposure', severity: 'low', title: 'robots.txt declares a sitemap URL that is not reachable', detail: `Declared: ${crawlerExposure.robotsDeclaredSitemaps.join(', ')}. This returns an error or isn't valid XML — crawlers following robots.txt will fail to discover pages via this sitemap.`, url: new URL('/robots.txt', origin).toString() });
|
|
568
|
+
}
|
|
569
|
+
phaseDone('Site-wide checks', Date.now() - siteWideStart);
|
|
570
|
+
|
|
571
|
+
const apexDomain = hostname.split('.').slice(-2).join('.');
|
|
572
|
+
|
|
573
|
+
// ── Phase 4: WHOIS / domain registration ─────────────────────────────────
|
|
574
|
+
let whois = { checked: false };
|
|
575
|
+
if (!skipWhois) {
|
|
576
|
+
phaseHeader('Phase 4: WHOIS / domain registration', '📇');
|
|
577
|
+
const whoisStart = Date.now();
|
|
578
|
+
whois = await withTicker(rdapLookup(apexDomain), 'Querying RDAP for domain registration');
|
|
579
|
+
if (whois.checked) {
|
|
580
|
+
statusMsg('📇', c.dim, `Registrar: ${whois.registrar || 'unknown'} · expires ${whois.expiration ? new Date(whois.expiration).toISOString().slice(0, 10) : 'unknown'}`);
|
|
581
|
+
if (whois.isExpired) addFinding({ category: 'domain-registration', severity: 'critical', title: 'Domain registration has expired', detail: `Registrar: ${whois.registrar || 'unknown'}. Expired ${-whois.daysUntilExpiration} day(s) ago — the domain can be lost/hijacked.`, url: `whois:${apexDomain}` });
|
|
582
|
+
else if (whois.isExpiringSoon) addFinding({ category: 'domain-registration', severity: 'high', title: 'Domain registration expiring soon', detail: `Registrar: ${whois.registrar || 'unknown'}. Expires in ${whois.daysUntilExpiration} day(s) — renew to avoid an expiration-based takeover.`, url: `whois:${apexDomain}` });
|
|
583
|
+
} else {
|
|
584
|
+
statusMsg('⚠', c.brightYellow, `WHOIS/RDAP lookup failed: ${whois.error}`);
|
|
585
|
+
}
|
|
586
|
+
phaseDone('WHOIS / domain registration', Date.now() - whoisStart);
|
|
587
|
+
} else {
|
|
588
|
+
statusMsg('⏭', c.dim, 'Skipping WHOIS/RDAP lookup (--skip-whois).');
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ── Phase 5: Subdomain enumeration (passive, crt.sh) ─────────────────────
|
|
592
|
+
let discoveredSubdomains = [];
|
|
593
|
+
if (!skipSubdomainEnum) {
|
|
594
|
+
phaseHeader('Phase 5: Subdomain enumeration', '🌐');
|
|
595
|
+
const subStart = Date.now();
|
|
596
|
+
const enumResult = await withTicker(enumerateSubdomainsCrtSh(apexDomain), 'Querying crt.sh certificate-transparency logs');
|
|
597
|
+
if (enumResult.checked) {
|
|
598
|
+
discoveredSubdomains = enumResult.subdomains;
|
|
599
|
+
statusMsg('🌐', c.brightGreen, `Found ${discoveredSubdomains.length} subdomain(s) via certificate-transparency logs.`);
|
|
600
|
+
if (discoveredSubdomains.length) {
|
|
601
|
+
const liveness = await checkLiveness(discoveredSubdomains, {
|
|
602
|
+
limit: intensity.subdomainLivenessLimit,
|
|
603
|
+
onProgress: (done, total) => progressLine(done, total, 'subdomains checked'),
|
|
604
|
+
});
|
|
605
|
+
clearProgressLine();
|
|
606
|
+
const alive = liveness.filter((l) => l.alive);
|
|
607
|
+
statusMsg('🌐', c.dim, `${alive.length}/${liveness.length} checked subdomain(s) responded (out of ${discoveredSubdomains.length} discovered total).`);
|
|
608
|
+
addFinding({ category: 'subdomain-enum', severity: 'info', title: `${discoveredSubdomains.length} subdomain(s) discovered via certificate-transparency logs`, detail: `Expands the known attack surface beyond the scanned host. Live examples: ${alive.slice(0, 8).map((a) => a.host).join(', ') || 'none checked'}`, url: `https://crt.sh/?q=%25.${apexDomain}` });
|
|
609
|
+
}
|
|
610
|
+
} else {
|
|
611
|
+
statusMsg('⚠', c.brightYellow, `Subdomain enumeration failed: ${enumResult.error} (crt.sh is a free community service and is sometimes slow/unavailable — this isn't necessarily a problem with the target)`);
|
|
612
|
+
}
|
|
613
|
+
phaseDone('Subdomain enumeration', Date.now() - subStart);
|
|
614
|
+
} else {
|
|
615
|
+
statusMsg('⏭', c.dim, 'Skipping subdomain enumeration (--skip-subdomain-enum).');
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// ── Phase 6: Port scan (TCP connect scan, common service ports) ─────────
|
|
619
|
+
let portResults = [];
|
|
620
|
+
if (!skipPortScan) {
|
|
621
|
+
phaseHeader('Phase 6: Port scan', '🔌');
|
|
622
|
+
const portStart = Date.now();
|
|
623
|
+
portResults = await scanPorts(hostname, {
|
|
624
|
+
...intensity.portScan,
|
|
625
|
+
onProgress: (done, total, result) => progressLine(done, total, 'ports scanned', result.state === 'open' ? `${c.brightYellow}open: ${result.port} (${result.name})${c.reset}` : ''),
|
|
626
|
+
});
|
|
627
|
+
clearProgressLine();
|
|
628
|
+
const open = portResults.filter((r) => r.state === 'open');
|
|
629
|
+
statusMsg('🔌', open.length ? c.brightYellow : c.brightGreen, `${open.length}/${portResults.length} scanned port(s) open.`);
|
|
630
|
+
for (const p of open) {
|
|
631
|
+
const sensitive = SENSITIVE_PORTS.has(p.port);
|
|
632
|
+
addFinding({
|
|
633
|
+
category: 'port-scan',
|
|
634
|
+
severity: sensitive ? 'high' : 'info',
|
|
635
|
+
title: `Port ${p.port} (${p.name}) is open`,
|
|
636
|
+
detail: sensitive ? `${p.name} is not normally expected to be reachable from the public internet — verify this is intentional and firewalled to trusted IPs only.${p.banner ? ` Banner: "${p.banner}"` : ''}` : `${p.banner ? `Banner: "${p.banner}"` : 'No banner volunteered.'}`,
|
|
637
|
+
url: `${hostname}:${p.port}`,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
phaseDone('Port scan', Date.now() - portStart);
|
|
641
|
+
} else {
|
|
642
|
+
statusMsg('⏭', c.dim, 'Skipping port scan (--skip-port-scan).');
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// ── Phase 7: Exposed path / admin / API discovery ────────────────────────
|
|
646
|
+
let pathResults = [];
|
|
647
|
+
if (!skipExposedPaths) {
|
|
648
|
+
phaseHeader('Phase 7: Exposed path & admin/API discovery', '🔎');
|
|
649
|
+
const pathStart = Date.now();
|
|
650
|
+
let allPaths = [...ADMIN_LOGIN_PATHS, ...EXPOSED_FILE_PATHS, ...EXPOSED_API_PATHS];
|
|
651
|
+
if (args['wordlist']) {
|
|
652
|
+
try {
|
|
653
|
+
const wordlistEntries = loadWordlistEntries(fs, path.resolve(process.cwd(), args['wordlist']));
|
|
654
|
+
allPaths = [...allPaths, ...wordlistEntries];
|
|
655
|
+
statusMsg('📜', c.dim, `Loaded ${wordlistEntries.length} additional path(s) from --wordlist.`);
|
|
656
|
+
} catch (e) {
|
|
657
|
+
statusMsg('⚠', c.brightYellow, `Could not read --wordlist: ${String(e?.message || e)}`);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
pathResults = await probeAllPaths(origin, allPaths, {
|
|
661
|
+
isAllowedUrl: respectRobots ? robotsCfg.isAllowedUrl : null,
|
|
662
|
+
delayMs: slowMode ? 400 : intensity.exposedPathDelayMs,
|
|
663
|
+
concurrency: intensity.exposedPathConcurrency,
|
|
664
|
+
onProgress: (done, total, result) => progressLine(done, total, 'paths probed', result.exists ? `${c.brightYellow}found: ${result.path}${c.reset}` : ''),
|
|
665
|
+
});
|
|
666
|
+
clearProgressLine();
|
|
667
|
+
const exposed = pathResults.filter((r) => r.exists);
|
|
668
|
+
for (const r of exposed) {
|
|
669
|
+
const isAdminLogin = ADMIN_LOGIN_PATHS.some((p) => p.path === r.path);
|
|
670
|
+
const sev = isAdminLogin ? 'info' : (r.severity || 'medium');
|
|
671
|
+
addFinding({
|
|
672
|
+
category: isAdminLogin ? 'admin-login' : (EXPOSED_API_PATHS.some((p) => p.path === r.path) ? 'exposed-api' : 'exposed-path'),
|
|
673
|
+
severity: r.directoryListing ? 'high' : sev,
|
|
674
|
+
title: `${r.label} reachable (HTTP ${r.status})`,
|
|
675
|
+
detail: r.directoryListing ? 'Directory listing is enabled — full file listing exposed.' : 'Publicly reachable at this path.',
|
|
676
|
+
url: r.url,
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
statusMsg('🔎', exposed.length ? c.brightYellow : c.brightGreen, `${exposed.length}/${pathResults.length} probed path(s) responded publicly.`);
|
|
680
|
+
|
|
681
|
+
const graphqlPath = exposed.find((r) => r.path === '/graphql');
|
|
682
|
+
if (graphqlPath) {
|
|
683
|
+
const introspection = await introspectGraphQL(graphqlPath.url);
|
|
684
|
+
if (introspection.checked && introspection.introspectionEnabled) {
|
|
685
|
+
statusMsg('🔮', c.brightYellow, `GraphQL introspection is enabled — schema (${introspection.typeCount} types${introspection.hasMutations ? ', mutations present' : ''}) is fully readable.`);
|
|
686
|
+
addFinding({
|
|
687
|
+
category: 'graphql',
|
|
688
|
+
severity: introspection.sensitiveMutations.length ? 'high' : 'medium',
|
|
689
|
+
title: 'GraphQL introspection is enabled',
|
|
690
|
+
detail: `${introspection.typeCount} type(s) exposed.${introspection.hasMutations ? ` Mutations: ${introspection.mutationFieldNames.slice(0, 10).join(', ')}.` : ' No mutations exposed.'}${introspection.sensitiveMutations.length ? ` Sensitive-sounding mutations: ${introspection.sensitiveMutations.join(', ')}.` : ''} Introspection should generally be disabled in production.`,
|
|
691
|
+
url: graphqlPath.url,
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
phaseDone('Exposed path discovery', Date.now() - pathStart);
|
|
696
|
+
} else {
|
|
697
|
+
statusMsg('⏭', c.dim, 'Skipping exposed-path discovery (--skip-exposed-paths).');
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// ── Phase 8: Page scanning (platform fingerprint, script inventory, PII, payments) ──
|
|
701
|
+
phaseHeader('Phase 8: Page scanning', '📄');
|
|
702
|
+
const scanStart = Date.now();
|
|
703
|
+
let allScriptUrls = [];
|
|
704
|
+
let allStyleUrls = [];
|
|
705
|
+
let siteGenerator = '';
|
|
706
|
+
let siteHtmlSample = '';
|
|
707
|
+
const pageResults = [];
|
|
708
|
+
const pageHeaderSets = [];
|
|
709
|
+
for (let i = 0; i < pageUrls.length; i++) {
|
|
710
|
+
const url = pageUrls[i];
|
|
711
|
+
const page = await context.newPage();
|
|
712
|
+
try {
|
|
713
|
+
const response = await page.goto(url, { waitUntil: slowMode ? 'domcontentloaded' : 'networkidle', timeout: 45000 });
|
|
714
|
+
await page.waitForTimeout(slowMode ? 1500 : 500);
|
|
715
|
+
const html = await page.content();
|
|
716
|
+
const cookies = await context.cookies();
|
|
717
|
+
const pageHeaders = response ? response.headers() : {};
|
|
718
|
+
const inv = extractInventoryFromHtml(html, url);
|
|
719
|
+
allScriptUrls.push(...inv.scriptUrls);
|
|
720
|
+
allStyleUrls.push(...inv.styleUrls);
|
|
721
|
+
if (!siteGenerator && inv.generator) siteGenerator = inv.generator;
|
|
722
|
+
if (i === 0) siteHtmlSample = html;
|
|
723
|
+
|
|
724
|
+
const platformSignals = detectPlatform({ html, headers: pageHeaders, scriptSrcs: inv.scriptUrls, cookieNames: cookies.map((ck) => ck.name), siteUrl: url });
|
|
725
|
+
pageHeaderSets.push({ url, server: pageHeaders['server'] || '', poweredBy: pageHeaders['x-powered-by'] || '', via: pageHeaders['via'] || '' });
|
|
726
|
+
const pii = scanForPii(html);
|
|
727
|
+
const payments = detectPaymentProcessors(html, inv.scriptUrls);
|
|
728
|
+
const mixed = findMixedContent(html, url);
|
|
729
|
+
const missingSri = checkMissingSri(html, origin);
|
|
730
|
+
const insecureLoginForms = checkInsecureLoginForms(html, url);
|
|
731
|
+
const jwtIssues = scanCookiesForJwt(cookies);
|
|
732
|
+
|
|
733
|
+
if (pii.secrets.length) {
|
|
734
|
+
for (const s of pii.secrets) addFinding({ category: 'pii', severity: 'critical', title: `Exposed ${s.type} in page source`, detail: `${s.count} occurrence(s), e.g. "${s.sample}"`, url });
|
|
735
|
+
}
|
|
736
|
+
if (pii.ssnLike.length) addFinding({ category: 'pii', severity: 'high', title: 'SSN-like number(s) found in page content', detail: `${pii.ssnLike.length} match(es).`, url });
|
|
737
|
+
if (pii.cardLike.length) addFinding({ category: 'pii', severity: 'critical', title: 'Credit-card-like number(s) found in page content', detail: `${pii.cardLike.length} Luhn-valid match(es): ${pii.cardLike.slice(0, 3).join(', ')}`, url });
|
|
738
|
+
if (pii.emails.length) addFinding({ category: 'pii', severity: 'info', title: `${pii.emails.length} email address(es) exposed on page`, detail: pii.emails.slice(0, 5).join(', '), url });
|
|
739
|
+
if (mixed.applicable && mixed.refs.length) addFinding({ category: 'mixed-content', severity: 'medium', title: `${mixed.refs.length} mixed-content (http://) resource(s) on HTTPS page`, detail: mixed.refs.slice(0, 5).join(', '), url });
|
|
740
|
+
if (missingSri.length) addFinding({ category: 'sri', severity: 'low', title: `${missingSri.length} cross-origin script/style tag(s) missing Subresource Integrity`, detail: missingSri.slice(0, 5).join(', '), url });
|
|
741
|
+
for (const f of insecureLoginForms) addFinding({ category: 'insecure-login-form', severity: 'critical', title: 'Login form submits credentials over plaintext HTTP', detail: `${f.reason} (action: ${f.action})`, url });
|
|
742
|
+
for (const j of jwtIssues) addFinding({ category: 'jwt', severity: j.alg && String(j.alg).toLowerCase() === 'none' ? 'critical' : 'medium', title: `JWT cookie "${j.cookieName}" has weak claims`, detail: j.issues.join('; '), url });
|
|
743
|
+
|
|
744
|
+
pageResults.push({ url, platformSignals, pii, payments, mixed });
|
|
745
|
+
statusMsg('✔', c.brightGreen, `[${i + 1}/${pageUrls.length}] ${url}`);
|
|
746
|
+
} catch (e) {
|
|
747
|
+
statusMsg('✖', c.brightRed, `[${i + 1}/${pageUrls.length}] ${url} — ${String(e?.message || e)}`);
|
|
748
|
+
} finally {
|
|
749
|
+
await page.close();
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
phaseDone('Page scanning', Date.now() - scanStart);
|
|
753
|
+
|
|
754
|
+
// ── Phase 9: Platform + third-party library inventory ──────────────────
|
|
755
|
+
phaseHeader('Phase 9: Platform & library inventory', '📦');
|
|
756
|
+
const invStart = Date.now();
|
|
757
|
+
const platformSignals = detectPlatform({ html: siteHtmlSample, headers: homepageHeaders, scriptSrcs: allScriptUrls, cookieNames: [], siteUrl: site });
|
|
758
|
+
const primaryPlatform = platformSignals[0]?.platform || 'Unknown/Custom';
|
|
759
|
+
statusMsg('🧩', c.brightCyan, `Detected platform: ${c.bold}${primaryPlatform}${c.reset} (${platformSignals[0]?.confidence || 'low'} confidence)`);
|
|
760
|
+
|
|
761
|
+
// Cross-reference platform signals across every scanned page (not just the homepage) — a
|
|
762
|
+
// different high-confidence platform showing up on a different page/subsection is a real signal
|
|
763
|
+
// of a hybrid architecture (e.g. a WordPress marketing site with a separate Rails donation app).
|
|
764
|
+
const highConfidencePlatformsByPage = new Map();
|
|
765
|
+
for (const pr of pageResults) {
|
|
766
|
+
for (const sig of pr.platformSignals) {
|
|
767
|
+
if (sig.confidence !== 'high') continue;
|
|
768
|
+
if (!highConfidencePlatformsByPage.has(sig.platform)) highConfidencePlatformsByPage.set(sig.platform, []);
|
|
769
|
+
highConfidencePlatformsByPage.get(sig.platform).push(pr.url);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
const distinctHighConfidencePlatforms = [...highConfidencePlatformsByPage.keys()];
|
|
773
|
+
if (distinctHighConfidencePlatforms.length > 1) {
|
|
774
|
+
statusMsg('🧬', c.brightYellow, `Multiple platforms detected across scanned pages: ${distinctHighConfidencePlatforms.join(', ')}`);
|
|
775
|
+
addFinding({
|
|
776
|
+
category: 'multi-platform',
|
|
777
|
+
severity: 'info',
|
|
778
|
+
title: `Multiple distinct platforms detected: ${distinctHighConfidencePlatforms.join(', ')}`,
|
|
779
|
+
detail: distinctHighConfidencePlatforms.map((p) => `${p}: ${highConfidencePlatformsByPage.get(p).slice(0, 3).join(', ')}`).join(' | ') + '. This suggests a hybrid architecture (e.g. a CMS-driven marketing site alongside a separately-built app) — each platform has its own patching/CVE surface and should be tracked independently. Scan with --crawl and a higher --max-pages for a more complete picture; a single-page scan can only see whatever platform served that one page.',
|
|
780
|
+
url: site,
|
|
781
|
+
});
|
|
782
|
+
} else if (pageResults.length > 1) {
|
|
783
|
+
statusMsg('🧬', c.dim, 'Consistent platform across scanned pages — no hybrid architecture detected.');
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// Distinct Server/X-Powered-By headers across different pages — a complementary signal to the
|
|
787
|
+
// same-URL repeated-request check above: this one catches "different sections of the site are
|
|
788
|
+
// served by different backends," not "this one URL is behind a load-balanced fleet."
|
|
789
|
+
const distinctServersAcrossPages = new Set(pageHeaderSets.map((p) => p.server).filter(Boolean));
|
|
790
|
+
const distinctPoweredByAcrossPages = new Set(pageHeaderSets.map((p) => p.poweredBy).filter(Boolean));
|
|
791
|
+
if (distinctServersAcrossPages.size > 1 || distinctPoweredByAcrossPages.size > 1) {
|
|
792
|
+
const breakdown = pageHeaderSets.map((p) => `${p.url} → Server: ${p.server || 'n/a'}${p.poweredBy ? `, X-Powered-By: ${p.poweredBy}` : ''}`).join(' | ');
|
|
793
|
+
statusMsg('🖥️', c.brightYellow, `Different pages report different backend headers — likely multiple servers/apps behind the same domain.`);
|
|
794
|
+
addFinding({ category: 'infra-topology', severity: 'info', title: 'Different scanned pages report different backend server headers', detail: `${breakdown}. Combined with the platform signals above, this points to different sections of the site being served by different applications/servers rather than one uniform backend.`, url: site });
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
const libraries = identifyLibraries([...allScriptUrls, ...allStyleUrls]);
|
|
798
|
+
statusMsg('📚', c.dim, `${libraries.length} known front-end librar${libraries.length === 1 ? 'y' : 'ies'} detected.`);
|
|
799
|
+
|
|
800
|
+
let wpCore = { version: null }, drupalCore = { version: null };
|
|
801
|
+
let wpPlugins = [], wpThemes = [];
|
|
802
|
+
if (primaryPlatform === 'WordPress') {
|
|
803
|
+
wpCore = await detectWpCoreVersion(origin, siteHtmlSample, siteGenerator);
|
|
804
|
+
const extracted = extractWpPluginsAndThemes([...allScriptUrls, ...allStyleUrls]);
|
|
805
|
+
wpPlugins = extracted.plugins;
|
|
806
|
+
wpThemes = extracted.themes;
|
|
807
|
+
for (const p of wpPlugins) {
|
|
808
|
+
if (!p.version) {
|
|
809
|
+
const readmeVer = await fetchWpPluginReadmeVersion(origin, p.slug);
|
|
810
|
+
if (readmeVer) p.version = readmeVer;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
statusMsg('🔧', c.dim, `WordPress core: ${wpCore.version || 'unknown'} · ${wpPlugins.length} plugin(s), ${wpThemes.length} theme(s) detected.`);
|
|
814
|
+
} else if (primaryPlatform === 'Drupal') {
|
|
815
|
+
drupalCore = await detectDrupalCoreVersion(origin, siteHtmlSample, siteGenerator);
|
|
816
|
+
statusMsg('🔧', c.dim, `Drupal core: ${drupalCore.version || 'unknown'}`);
|
|
817
|
+
}
|
|
818
|
+
phaseDone('Platform & library inventory', Date.now() - invStart);
|
|
819
|
+
|
|
820
|
+
// ── Phase 10: CVE / known-vulnerability lookups (OSV.dev + WPScan) ───────
|
|
821
|
+
const vulnResults = [];
|
|
822
|
+
if (!skipCve) {
|
|
823
|
+
phaseHeader('Phase 10: CVE lookups (OSV.dev + WPScan)', '🛡️');
|
|
824
|
+
const cveStart = Date.now();
|
|
825
|
+
|
|
826
|
+
for (const lib of libraries) {
|
|
827
|
+
if (!lib.version) continue;
|
|
828
|
+
const pkgName = npmPackageNameFor(lib.name);
|
|
829
|
+
if (!pkgName) continue;
|
|
830
|
+
const result = await osvLookup(pkgName, lib.version);
|
|
831
|
+
if (result.vulns?.length) {
|
|
832
|
+
vulnResults.push({ component: `${lib.name}@${lib.version}`, source: 'OSV.dev', vulns: result.vulns });
|
|
833
|
+
for (const v of result.vulns) addFinding({ category: 'cve', severity: 'high', title: `Known vulnerability in ${lib.name} ${lib.version}: ${v.id}`, detail: v.summary || v.url, url: v.url });
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
if (primaryPlatform === 'WordPress') {
|
|
838
|
+
if (!wpscanApiKey) {
|
|
839
|
+
if (!wordfenceApiKey) statusMsg('⏭', c.dim, 'No WORDFENCE_API_KEY or WPSCAN_API_KEY set — skipping live WordPress CVE lookups (core/plugin/theme version + EOL heuristics still reported).');
|
|
840
|
+
} else {
|
|
841
|
+
let wpscanRateLimited = false;
|
|
842
|
+
const warnRateLimit = () => {
|
|
843
|
+
if (wpscanRateLimited) return;
|
|
844
|
+
wpscanRateLimited = true;
|
|
845
|
+
statusMsg('⚠', c.brightYellow, 'WPScan free-tier daily limit (25 requests/day) reached — remaining WordPress CVE lookups skipped for this run. Limit resets daily; see https://wpscan.com/api for paid tiers.');
|
|
846
|
+
};
|
|
847
|
+
// A refused key (WPScan answers 403 for a missing or invalid token) fails every lookup the
|
|
848
|
+
// same way, so stop after the first one instead of repeating the warning per plugin/theme.
|
|
849
|
+
const stopOnRefusal = (res, what) => {
|
|
850
|
+
if (!res.error || res.rateLimited) return false;
|
|
851
|
+
if (!/unauthorized/.test(res.error)) { statusMsg('⚠', c.brightYellow, `WPScan ${what} lookup: ${res.error}`); return false; }
|
|
852
|
+
wpscanRateLimited = true;
|
|
853
|
+
statusMsg('⚠', c.brightYellow, 'WPScan refused the API key (missing, mistyped or revoked) — WordPress CVE lookups skipped for this run. Check WPSCAN_API_KEY / --wpscan-key.');
|
|
854
|
+
return true;
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
if (wpCore.version) {
|
|
858
|
+
const coreRes = await wpscanCoreLookup(wpCore.version, wpscanApiKey);
|
|
859
|
+
if (coreRes.vulns?.length) {
|
|
860
|
+
vulnResults.push({ component: `WordPress core ${wpCore.version}`, source: 'WPScan', vulns: coreRes.vulns });
|
|
861
|
+
for (const v of coreRes.vulns) addFinding({ category: 'cve', severity: 'critical', title: `WordPress core ${wpCore.version}: ${v.title}`, detail: v.fixedIn ? `Fixed in ${v.fixedIn}.` : 'No fix version listed.', url: Array.isArray(v.references) ? v.references[0] : String(v.references || '') });
|
|
862
|
+
}
|
|
863
|
+
if (coreRes.rateLimited) warnRateLimit();
|
|
864
|
+
else stopOnRefusal(coreRes, 'core');
|
|
865
|
+
}
|
|
866
|
+
for (const p of wpPlugins) {
|
|
867
|
+
if (wpscanRateLimited) break;
|
|
868
|
+
const res = await wpscanPluginLookup(p.slug, wpscanApiKey);
|
|
869
|
+
if (res.vulns?.length) {
|
|
870
|
+
const applicable = p.version ? res.vulns.filter((v) => !v.fixedIn || semverLt(p.version, v.fixedIn)) : res.vulns;
|
|
871
|
+
if (applicable.length) {
|
|
872
|
+
vulnResults.push({ component: `plugin:${p.slug}@${p.version || 'unknown'}`, source: 'WPScan', vulns: applicable });
|
|
873
|
+
for (const v of applicable) addFinding({ category: 'cve', severity: p.version ? 'critical' : 'medium', title: `WP plugin "${p.slug}": ${v.title}`, detail: `${p.version ? `Installed: ${p.version}. ` : 'Version undetected — verify manually. '}${v.fixedIn ? `Fixed in ${v.fixedIn}.` : ''}`, url: Array.isArray(v.references) ? v.references[0] : String(v.references || '') });
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
if (res.rateLimited) { warnRateLimit(); break; }
|
|
877
|
+
if (stopOnRefusal(res, `plugin (${p.slug})`)) break;
|
|
878
|
+
await sleep(250); // stay well under WPScan free-tier rate limit
|
|
879
|
+
}
|
|
880
|
+
for (const t of wpThemes) {
|
|
881
|
+
if (wpscanRateLimited) break;
|
|
882
|
+
const res = await wpscanThemeLookup(t.slug, wpscanApiKey);
|
|
883
|
+
if (res.vulns?.length) {
|
|
884
|
+
const applicable = t.version ? res.vulns.filter((v) => !v.fixedIn || semverLt(t.version, v.fixedIn)) : res.vulns;
|
|
885
|
+
if (applicable.length) {
|
|
886
|
+
vulnResults.push({ component: `theme:${t.slug}@${t.version || 'unknown'}`, source: 'WPScan', vulns: applicable });
|
|
887
|
+
for (const v of applicable) addFinding({ category: 'cve', severity: t.version ? 'high' : 'medium', title: `WP theme "${t.slug}": ${v.title}`, detail: `${t.version ? `Installed: ${t.version}. ` : 'Version undetected — verify manually. '}${v.fixedIn ? `Fixed in ${v.fixedIn}.` : ''}`, url: Array.isArray(v.references) ? v.references[0] : String(v.references || '') });
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
if (res.rateLimited) { warnRateLimit(); break; }
|
|
891
|
+
if (stopOnRefusal(res, `theme (${t.slug})`)) break;
|
|
892
|
+
await sleep(250);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// Wordfence Intelligence, with the user's own key (free, commercial use allowed with attribution).
|
|
898
|
+
if (primaryPlatform === 'WordPress' && wordfenceApiKey) {
|
|
899
|
+
const { index, error } = await loadWordfenceIndex(wordfenceApiKey);
|
|
900
|
+
if (error) statusMsg('⚠', c.brightYellow, `Wordfence: ${error}.`);
|
|
901
|
+
if (index) {
|
|
902
|
+
// Components WPScan already reported aren't repeated.
|
|
903
|
+
const seen = new Set(vulnResults.map((r) => r.component.replace(/@.*$/, '')));
|
|
904
|
+
const components = [
|
|
905
|
+
...(wpCore.version ? [{ kind: 'core', slug: 'wordpress', version: wpCore.version, label: `WordPress core ${wpCore.version}`, key: `WordPress core ${wpCore.version}` }] : []),
|
|
906
|
+
...wpPlugins.map((p) => ({ kind: 'plugin', slug: p.slug, version: p.version, label: `WP plugin "${p.slug}"`, key: `plugin:${p.slug}` })),
|
|
907
|
+
...wpThemes.map((t) => ({ kind: 'theme', slug: t.slug, version: t.version, label: `WP theme "${t.slug}"`, key: `theme:${t.slug}` })),
|
|
908
|
+
];
|
|
909
|
+
let wfFound = 0;
|
|
910
|
+
for (const comp of components) {
|
|
911
|
+
if (!comp.version || seen.has(comp.key.replace(/@.*$/, ''))) continue;
|
|
912
|
+
const vulns = wordfenceLookup(index, comp.kind, comp.slug, comp.version);
|
|
913
|
+
if (!vulns.length) continue;
|
|
914
|
+
wfFound += 1;
|
|
915
|
+
vulnResults.push({ component: comp.kind === 'core' ? comp.key : `${comp.key}@${comp.version}`, source: 'Wordfence', vulns: vulns.map((v) => ({ id: v.id, title: v.title, fixedIn: v.fixedIn, references: [v.url] })) });
|
|
916
|
+
for (const v of vulns) addFinding({ category: 'cve', severity: wordfenceSeverity(v), title: `${comp.label}: ${v.title}`, detail: `Installed: ${comp.version}. ${v.fixedIn ? `Fixed in ${v.fixedIn}.` : 'No fix yet.'}${v.cve ? ` ${v.cve}.` : ''} ${WORDFENCE_ATTRIBUTION}`, url: v.url });
|
|
917
|
+
}
|
|
918
|
+
statusMsg('🛡️', c.dim, `Wordfence: ${wfFound} component(s) with known vulnerabilities.`);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
statusMsg('🛡️', c.dim, `${vulnResults.length} component(s) with known vulnerabilities found.`);
|
|
922
|
+
phaseDone('CVE lookups', Date.now() - cveStart);
|
|
923
|
+
} else {
|
|
924
|
+
statusMsg('⏭', c.dim, 'Skipping CVE lookups (--skip-cve).');
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// ── Phase 11: Authenticated admin audit (WordPress or Drupal, when credentials are supplied) ──
|
|
928
|
+
let wpAdminAudit = null;
|
|
929
|
+
let drupalAdminAudit = null;
|
|
930
|
+
const hasAnyAuth = authSettings.formAuth || (wpAppUser && wpAppPassword);
|
|
931
|
+
if (!skipAdminAudit && hasAnyAuth && (primaryPlatform === 'WordPress' || primaryPlatform === 'Drupal')) {
|
|
932
|
+
phaseHeader('Phase 11: Authenticated admin audit', '🔑');
|
|
933
|
+
const adminStart = Date.now();
|
|
934
|
+
|
|
935
|
+
if (primaryPlatform === 'WordPress' && wpAppUser && wpAppPassword) {
|
|
936
|
+
// Preferred path: WP Application Passwords via the REST API — no Playwright login needed,
|
|
937
|
+
// stable across admin-theme/UI changes, and scoped to whatever the account can already do.
|
|
938
|
+
const verify = await verifyWpAppCredentials(origin, wpAppUser, wpAppPassword);
|
|
939
|
+
if (verify.ok) {
|
|
940
|
+
const [pluginsRes, themesRes, usersRes] = await Promise.all([
|
|
941
|
+
wpRestPlugins(origin, wpAppUser, wpAppPassword),
|
|
942
|
+
wpRestThemes(origin, wpAppUser, wpAppPassword),
|
|
943
|
+
wpRestUsers(origin, wpAppUser, wpAppPassword),
|
|
944
|
+
]);
|
|
945
|
+
const plugins = pluginsRes.ok ? pluginsRes.plugins : [];
|
|
946
|
+
const themes = themesRes.ok ? themesRes.themes : [];
|
|
947
|
+
const users = usersRes.ok ? usersRes.users : [];
|
|
948
|
+
const [pluginVersionChecks, themeVersionChecks] = await Promise.all([
|
|
949
|
+
checkOutdatedAgainstDirectory(plugins, lookupLatestPluginVersion),
|
|
950
|
+
checkOutdatedAgainstDirectory(themes, lookupLatestThemeVersion),
|
|
951
|
+
]);
|
|
952
|
+
const securityPlugins = detectSecurityPlugins(plugins);
|
|
953
|
+
const admins = users.filter((u) => /administrator/i.test(u.role));
|
|
954
|
+
|
|
955
|
+
statusMsg('🔑', c.brightGreen, `Authenticated via Application Password — ${plugins.length} plugin(s), ${themes.length} theme(s), ${users.length} user(s) (via REST API).`);
|
|
956
|
+
|
|
957
|
+
const outdatedPlugins = pluginVersionChecks.filter((p) => p.outdated);
|
|
958
|
+
for (const p of outdatedPlugins) addFinding({ category: 'admin-audit', severity: p.active ? 'high' : 'medium', title: `Plugin "${p.name}" is outdated (${p.version} installed, ${p.latestVersion} available)`, detail: p.active ? 'Active plugin behind the latest wordpress.org release — check the changelog for security fixes.' : 'Inactive but still present on disk; outdated inactive plugins are still a risk if reactivated or directly reachable.', url: `${origin}/wp-admin/plugins.php` });
|
|
959
|
+
const outdatedThemes = themeVersionChecks.filter((t) => t.outdated);
|
|
960
|
+
for (const t of outdatedThemes) addFinding({ category: 'admin-audit', severity: t.active ? 'medium' : 'low', title: `Theme "${t.name}" is outdated (${t.version} installed, ${t.latestVersion} available)`, detail: t.active ? 'Active theme behind the latest wordpress.org release.' : 'Inactive theme, lower priority but still worth updating or removing.', url: `${origin}/wp-admin/themes.php` });
|
|
961
|
+
if (!securityPlugins.length) addFinding({ category: 'admin-audit', severity: 'medium', title: 'No recognized security plugin is active', detail: 'No active plugin matched known security-plugin signatures (Wordfence, Sucuri, iThemes/SolidWP Security, All In One WP Security, Shield Security, etc.) — consider adding a WAF/hardening plugin.', url: `${origin}/wp-admin/plugins.php` });
|
|
962
|
+
if (admins.length > 3) addFinding({ category: 'admin-audit', severity: 'low', title: `${admins.length} users hold the Administrator role`, detail: `Administrators: ${admins.map((u) => u.username).join(', ')}. Review whether all of these need full admin access (principle of least privilege).`, url: `${origin}/wp-admin/users.php` });
|
|
963
|
+
if (users.some((u) => u.username.toLowerCase() === 'admin')) addFinding({ category: 'admin-audit', severity: 'medium', title: 'A user account is literally named "admin"', detail: 'A username of "admin" is the first guess in any credential-stuffing/brute-force attempt — rename or remove this account.', url: `${origin}/wp-admin/users.php` });
|
|
964
|
+
|
|
965
|
+
wpAdminAudit = { authMethod: 'rest-api', plugins: pluginVersionChecks, themes: themeVersionChecks, users, securityPlugins };
|
|
966
|
+
} else {
|
|
967
|
+
statusMsg('⚠', c.brightYellow, `WP Application Password verification failed: ${verify.error || 'unknown error'}. Skipping authenticated admin audit.`);
|
|
968
|
+
}
|
|
969
|
+
} else if (primaryPlatform === 'WordPress' && authSettings.formAuth) {
|
|
970
|
+
// Fallback: form login + wp-admin HTML scraping, for sites where an Application Password
|
|
971
|
+
// hasn't been created.
|
|
972
|
+
const adminPage = await context.newPage();
|
|
973
|
+
const loggedIn = await maybePerformFormLogin(adminPage, authSettings.formAuth, slowMode);
|
|
974
|
+
if (loggedIn && await isLoggedIntoWpAdmin(adminPage, origin)) {
|
|
975
|
+
const [plugins, themes, coreInfo, users] = await Promise.all([
|
|
976
|
+
scanWpPlugins(adminPage, origin),
|
|
977
|
+
scanWpThemes(adminPage, origin),
|
|
978
|
+
scanWpCoreAndPhp(adminPage, origin),
|
|
979
|
+
scanWpUsers(adminPage, origin),
|
|
980
|
+
]);
|
|
981
|
+
const securityPlugins = detectSecurityPlugins(plugins);
|
|
982
|
+
const phpIsEol = checkPhpEol(coreInfo.phpVersion);
|
|
983
|
+
const admins = users.filter((u) => /administrator/i.test(u.role));
|
|
984
|
+
|
|
985
|
+
statusMsg('🔑', c.brightGreen, `Logged in — WP ${coreInfo.wpVersion || 'unknown'}, PHP ${coreInfo.phpVersion || 'unknown'}, ${plugins.length} plugin(s), ${themes.length} theme(s), ${users.length} user(s).`);
|
|
986
|
+
|
|
987
|
+
const outdatedPlugins = plugins.filter((p) => p.updateAvailable);
|
|
988
|
+
for (const p of outdatedPlugins) addFinding({ category: 'admin-audit', severity: p.active ? 'high' : 'medium', title: `Plugin "${p.name}" has an update available (${p.version || '?'} → ${p.updateToVersion || 'newer'})`, detail: p.active ? 'Active plugin running an outdated version — check the changelog for security fixes.' : 'Inactive but still present on disk; outdated inactive plugins are still a risk if reactivated or directly reachable.', url: `${origin}/wp-admin/plugins.php` });
|
|
989
|
+
const outdatedThemes = themes.filter((t) => t.updateAvailable);
|
|
990
|
+
for (const t of outdatedThemes) addFinding({ category: 'admin-audit', severity: t.active ? 'medium' : 'low', title: `Theme "${t.name}" has an update available (${t.version || '?'} → ${t.updateToVersion || 'newer'})`, detail: t.active ? 'Active theme running an outdated version.' : 'Inactive theme, lower priority but still worth updating or removing.', url: `${origin}/wp-admin/themes.php` });
|
|
991
|
+
if (!securityPlugins.length) addFinding({ category: 'admin-audit', severity: 'medium', title: 'No recognized security plugin is active', detail: 'No active plugin matched known security-plugin signatures (Wordfence, Sucuri, iThemes/SolidWP Security, All In One WP Security, Shield Security, etc.) — consider adding a WAF/hardening plugin.', url: `${origin}/wp-admin/plugins.php` });
|
|
992
|
+
if (phpIsEol) addFinding({ category: 'admin-audit', severity: 'high', title: `PHP ${coreInfo.phpVersion} is end-of-life`, detail: 'This PHP version no longer receives security patches from php.net — upgrade to a supported version.', url: `${origin}/wp-admin/site-health.php?tab=debug` });
|
|
993
|
+
if (admins.length > 3) addFinding({ category: 'admin-audit', severity: 'low', title: `${admins.length} users hold the Administrator role`, detail: `Administrators: ${admins.map((u) => u.username).join(', ')}. Review whether all of these need full admin access (principle of least privilege).`, url: `${origin}/wp-admin/users.php` });
|
|
994
|
+
if (users.some((u) => u.username.toLowerCase() === 'admin')) addFinding({ category: 'admin-audit', severity: 'medium', title: 'A user account is literally named "admin"', detail: 'A username of "admin" is the first guess in any credential-stuffing/brute-force attempt — rename or remove this account.', url: `${origin}/wp-admin/users.php` });
|
|
995
|
+
|
|
996
|
+
wpAdminAudit = { authMethod: 'form-login', coreVersion: coreInfo.wpVersion, phpVersion: coreInfo.phpVersion, phpIsEol, mysqlVersion: coreInfo.mysqlVersion, plugins, themes, users, securityPlugins };
|
|
997
|
+
} else {
|
|
998
|
+
statusMsg('⚠', c.brightYellow, 'Form login did not reach wp-admin — check credentials/selectors. Skipping authenticated admin audit.');
|
|
999
|
+
}
|
|
1000
|
+
await adminPage.close();
|
|
1001
|
+
} else if (primaryPlatform === 'Drupal' && authSettings.formAuth) {
|
|
1002
|
+
const adminPage = await context.newPage();
|
|
1003
|
+
const loggedIn = await maybePerformFormLogin(adminPage, authSettings.formAuth, slowMode);
|
|
1004
|
+
if (loggedIn && await isLoggedIntoDrupalAdmin(adminPage, origin)) {
|
|
1005
|
+
const [statusReport, modules, users] = await Promise.all([
|
|
1006
|
+
scanDrupalStatusReport(adminPage, origin),
|
|
1007
|
+
scanDrupalModules(adminPage, origin),
|
|
1008
|
+
scanDrupalUsers(adminPage, origin),
|
|
1009
|
+
]);
|
|
1010
|
+
const phpIsEol = checkDrupalPhpEol(statusReport.phpVersion);
|
|
1011
|
+
const coreIsEol = checkDrupalCoreEol(statusReport.drupalVersion);
|
|
1012
|
+
const admins = users.filter((u) => /administrator/i.test(u.roles));
|
|
1013
|
+
const enabledModuleCount = modules.filter((m) => m.enabled).length;
|
|
1014
|
+
|
|
1015
|
+
statusMsg('🔑', c.brightGreen, `Logged in — Drupal ${statusReport.drupalVersion || 'unknown'}, PHP ${statusReport.phpVersion || 'unknown'}, ${enabledModuleCount} enabled module(s), ${users.length} user(s).`);
|
|
1016
|
+
|
|
1017
|
+
if (statusReport.errorCount > 0) addFinding({ category: 'admin-audit', severity: 'high', title: `${statusReport.errorCount} error-level item(s) on the Drupal status report`, detail: statusReport.errorItems.slice(0, 5).join(' | ') || 'See /admin/reports/status for details.', url: `${origin}/admin/reports/status` });
|
|
1018
|
+
if (statusReport.warningCount > 0) addFinding({ category: 'admin-audit', severity: 'medium', title: `${statusReport.warningCount} warning-level item(s) on the Drupal status report`, detail: 'See /admin/reports/status for details.', url: `${origin}/admin/reports/status` });
|
|
1019
|
+
if (phpIsEol) addFinding({ category: 'admin-audit', severity: 'high', title: `PHP ${statusReport.phpVersion} is end-of-life`, detail: 'This PHP version no longer receives security patches from php.net — upgrade to a supported version.', url: `${origin}/admin/reports/status` });
|
|
1020
|
+
if (coreIsEol) addFinding({ category: 'admin-audit', severity: 'critical', title: `Drupal ${statusReport.drupalVersion} core is end-of-life`, detail: 'This major Drupal version no longer receives security releases — upgrade is overdue.', url: `${origin}/admin/reports/status` });
|
|
1021
|
+
if (admins.length > 3) addFinding({ category: 'admin-audit', severity: 'low', title: `${admins.length} users hold an Administrator role`, detail: `Review whether all of these need full admin access (principle of least privilege).`, url: `${origin}/admin/people` });
|
|
1022
|
+
if (users.some((u) => u.username.toLowerCase() === 'admin')) addFinding({ category: 'admin-audit', severity: 'medium', title: 'A user account is literally named "admin"', detail: 'A username of "admin" is the first guess in any credential-stuffing/brute-force attempt — rename or remove this account.', url: `${origin}/admin/people` });
|
|
1023
|
+
|
|
1024
|
+
drupalAdminAudit = { coreVersion: statusReport.drupalVersion, phpVersion: statusReport.phpVersion, phpIsEol, coreIsEol, dbVersion: statusReport.dbVersion, modules, users };
|
|
1025
|
+
} else {
|
|
1026
|
+
statusMsg('⚠', c.brightYellow, 'Form login did not reach the Drupal admin area — check credentials/selectors. Skipping authenticated admin audit.');
|
|
1027
|
+
}
|
|
1028
|
+
await adminPage.close();
|
|
1029
|
+
}
|
|
1030
|
+
phaseDone('Authenticated admin audit', Date.now() - adminStart);
|
|
1031
|
+
} else if (hasAnyAuth && primaryPlatform !== 'WordPress' && primaryPlatform !== 'Drupal') {
|
|
1032
|
+
statusMsg('⏭', c.dim, `Authenticated admin audit currently supports WordPress and Drupal only; detected platform is "${primaryPlatform}".`);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// ── Phase 12: Payment/donation summary ───────────────────────────────────
|
|
1036
|
+
const allPaymentProcessors = new Set();
|
|
1037
|
+
let anyCardInputForm = false;
|
|
1038
|
+
for (const pr of pageResults) {
|
|
1039
|
+
for (const proc of pr.payments.processors) allPaymentProcessors.add(proc);
|
|
1040
|
+
if (pr.payments.hasCardInputForm) anyCardInputForm = true;
|
|
1041
|
+
}
|
|
1042
|
+
if (anyCardInputForm && ![...allPaymentProcessors].some((p) => ['Stripe', 'Square', 'Braintree', 'Authorize.Net'].includes(p))) {
|
|
1043
|
+
addFinding({ category: 'payment', severity: 'high', title: 'Card-number input field found without a recognized PCI-tokenizing processor', detail: 'Verify this form is not collecting/submitting raw card numbers to your own server.', url: site });
|
|
1044
|
+
}
|
|
1045
|
+
if (allPaymentProcessors.size) {
|
|
1046
|
+
addFinding({ category: 'payment', severity: 'info', title: `Payment/donation processor(s) detected: ${[...allPaymentProcessors].join(', ')}`, detail: 'Confirm PCI-DSS scope and that no raw card data touches your own servers.', url: site });
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// ── Phase 13: Recon-phase pentest checks ──────────────────────────────────
|
|
1050
|
+
let reconFindings = [];
|
|
1051
|
+
if (!skipRecon) {
|
|
1052
|
+
phaseHeader('Phase 13: Recon-phase checks', '🕵️');
|
|
1053
|
+
const reconStart = Date.now();
|
|
1054
|
+
const reconSteps = primaryPlatform === 'WordPress' ? 7 : 6;
|
|
1055
|
+
let reconStep = 0;
|
|
1056
|
+
const tickRecon = (label) => progressLine(++reconStep, reconSteps, label);
|
|
1057
|
+
|
|
1058
|
+
tickRecon('subdomain takeover');
|
|
1059
|
+
const takeovers = await checkSubdomainTakeover(hostname);
|
|
1060
|
+
for (const t of takeovers) addFinding({ category: 'subdomain-takeover', severity: 'critical', title: `Possible subdomain takeover: ${t.host}`, detail: t.note, url: `http://${t.host}/` });
|
|
1061
|
+
|
|
1062
|
+
tickRecon('HTTP method exposure');
|
|
1063
|
+
const httpMethods = await checkHttpMethods(site);
|
|
1064
|
+
if (httpMethods.checked && httpMethods.risky.length) addFinding({ category: 'http-methods', severity: 'medium', title: `Risky HTTP method(s) allowed: ${httpMethods.risky.join(', ')}`, detail: `Full Allow list: ${httpMethods.methods.join(', ') || '(none advertised)'}`, url: site });
|
|
1065
|
+
|
|
1066
|
+
tickRecon('open redirect probing');
|
|
1067
|
+
const openRedirects = await checkOpenRedirect(origin);
|
|
1068
|
+
for (const r of openRedirects) addFinding({ category: 'open-redirect', severity: 'medium', title: `Open redirect via "${r.param}" parameter`, detail: `Redirects to attacker-controlled URL: ${r.location}`, url: r.testUrl });
|
|
1069
|
+
|
|
1070
|
+
tickRecon('exposed source maps');
|
|
1071
|
+
const sourceMaps = await checkExposedSourceMaps(allScriptUrls);
|
|
1072
|
+
if (sourceMaps.length) addFinding({ category: 'source-map', severity: 'low', title: `${sourceMaps.length} exposed source map(s) (.js.map)`, detail: `Source maps can reveal original (unminified) source code. Examples: ${sourceMaps.slice(0, 3).join(', ')}`, url: sourceMaps[0] });
|
|
1073
|
+
|
|
1074
|
+
tickRecon('SPF/DMARC records');
|
|
1075
|
+
const emailAuth = await checkEmailAuthRecords(hostname, dnsRecords.txt || []);
|
|
1076
|
+
if (!emailAuth.hasSpf) addFinding({ category: 'email-auth', severity: 'low', title: 'No SPF record found', detail: 'Without SPF, attackers can more easily spoof email "From" addresses on this domain.', url: `dns:${hostname}` });
|
|
1077
|
+
if (!emailAuth.hasDmarc) addFinding({ category: 'email-auth', severity: 'low', title: 'No DMARC record found', detail: 'Without DMARC, spoofed/phishing email using this domain is not rejected or reported.', url: `dns:_dmarc.${hostname}` });
|
|
1078
|
+
else if (emailAuth.dmarcPolicy === 'none') addFinding({ category: 'email-auth', severity: 'info', title: 'DMARC policy is "p=none" (monitor-only)', detail: 'DMARC is present but not enforcing rejection/quarantine of spoofed mail.', url: `dns:_dmarc.${hostname}` });
|
|
1079
|
+
|
|
1080
|
+
if (primaryPlatform === 'WordPress') {
|
|
1081
|
+
tickRecon('WordPress user enumeration');
|
|
1082
|
+
const authors = await enumerateWpAuthors(origin);
|
|
1083
|
+
if (authors.length) addFinding({ category: 'user-enum', severity: 'medium', title: `${authors.length} WordPress username(s) enumerable via ?author=N`, detail: authors.map((a) => `id ${a.id} → ${a.username}`).join(', '), url: `${origin}/?author=1` });
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
tickRecon('verbose error disclosure');
|
|
1087
|
+
const verboseErrors = await checkVerboseErrors(origin);
|
|
1088
|
+
if (verboseErrors.checked && verboseErrors.leaksStackTrace) addFinding({ category: 'verbose-errors', severity: 'medium', title: 'Error pages leak stack traces / framework debug info', detail: `Sample: "${verboseErrors.sample.replace(/\s+/g, ' ').slice(0, 200)}"`, url: origin });
|
|
1089
|
+
clearProgressLine();
|
|
1090
|
+
|
|
1091
|
+
reconFindings = [...takeovers, ...openRedirects, ...sourceMaps];
|
|
1092
|
+
statusMsg('🕵️', c.dim, `Recon checks complete: ${takeovers.length} takeover signal(s), ${openRedirects.length} open redirect(s), ${sourceMaps.length} exposed source map(s).`);
|
|
1093
|
+
phaseDone('Recon-phase checks', Date.now() - reconStart);
|
|
1094
|
+
} else {
|
|
1095
|
+
statusMsg('⏭', c.dim, 'Skipping recon-phase checks (--skip-recon).');
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// ── Phase 14: Risk scoring + report generation ───────────────────────────
|
|
1099
|
+
phaseHeader('Phase 14: Report generation', '📝');
|
|
1100
|
+
const reportStart = Date.now();
|
|
1101
|
+
const sortedRaw = sortFindingsBySeverity(findings);
|
|
1102
|
+
const sorted = applyBaseline(sortedRaw, baseline);
|
|
1103
|
+
const activeFindings = sorted.filter((f) => !f.suppressed);
|
|
1104
|
+
const suppressedCount = sorted.length - activeFindings.length;
|
|
1105
|
+
if (suppressedCount) statusMsg('🗂️', c.dim, `${suppressedCount} finding(s) suppressed by --baseline.`);
|
|
1106
|
+
const risk = computeRiskGrade(activeFindings);
|
|
1107
|
+
|
|
1108
|
+
writeCsv(path.join(outDir, 'findings-summary.csv'), ['category', 'severity', 'title', 'detail', 'url', 'suppressed', 'suppressedReason'], sorted);
|
|
1109
|
+
writeCsv(path.join(outDir, 'exposed-paths.csv'), ['path', 'label', 'status', 'exists', 'directoryListing', 'severity', 'url'], pathResults);
|
|
1110
|
+
writeCsv(path.join(outDir, 'script-inventory.csv'), ['name', 'version', 'sourceUrl'], libraries);
|
|
1111
|
+
if (primaryPlatform === 'WordPress') {
|
|
1112
|
+
writeCsv(path.join(outDir, 'wordpress-components.csv'), ['type', 'slug', 'version', 'sourceUrl'], [
|
|
1113
|
+
...wpPlugins.map((p) => ({ type: 'plugin', ...p })),
|
|
1114
|
+
...wpThemes.map((t) => ({ type: 'theme', ...t })),
|
|
1115
|
+
]);
|
|
1116
|
+
}
|
|
1117
|
+
writeCsv(path.join(outDir, 'vulnerabilities.csv'), ['component', 'source', 'id', 'title'], vulnResults.flatMap((r) => r.vulns.map((v) => ({ component: r.component, source: r.source, id: v.id, title: v.title || v.summary || '' }))));
|
|
1118
|
+
writeCsv(path.join(outDir, 'port-scan.csv'), ['port', 'name', 'state', 'banner'], portResults);
|
|
1119
|
+
if (discoveredSubdomains.length) writeCsv(path.join(outDir, 'subdomains.csv'), ['subdomain'], discoveredSubdomains.map((s) => ({ subdomain: s })));
|
|
1120
|
+
if (wpAdminAudit) {
|
|
1121
|
+
writeCsv(path.join(outDir, 'wp-admin-plugins.csv'), ['slug', 'name', 'active', 'version', 'updateAvailable', 'updateToVersion', 'latestVersion', 'outdated'], wpAdminAudit.plugins);
|
|
1122
|
+
writeCsv(path.join(outDir, 'wp-admin-themes.csv'), ['slug', 'name', 'active', 'version', 'updateAvailable', 'updateToVersion', 'latestVersion', 'outdated'], wpAdminAudit.themes);
|
|
1123
|
+
writeCsv(path.join(outDir, 'wp-admin-users.csv'), ['username', 'role'], wpAdminAudit.users);
|
|
1124
|
+
}
|
|
1125
|
+
if (drupalAdminAudit) {
|
|
1126
|
+
writeCsv(path.join(outDir, 'drupal-admin-modules.csv'), ['slug', 'name', 'enabled', 'version'], drupalAdminAudit.modules);
|
|
1127
|
+
writeCsv(path.join(outDir, 'drupal-admin-users.csv'), ['username', 'roles', 'status'], drupalAdminAudit.users);
|
|
1128
|
+
}
|
|
1129
|
+
const generatedAt = new Date().toISOString();
|
|
1130
|
+
const summaryData = {
|
|
1131
|
+
site, generatedAt, risk, primaryPlatform, platformSignals, hostingSignals, dnsRecords,
|
|
1132
|
+
tls: tlsInfo, crawlerExposure, paymentProcessors: [...allPaymentProcessors],
|
|
1133
|
+
whois: whois.checked ? whois : null,
|
|
1134
|
+
subdomains: discoveredSubdomains,
|
|
1135
|
+
openPorts: portResults.filter((r) => r.state === 'open'),
|
|
1136
|
+
wpAdminAudit,
|
|
1137
|
+
drupalAdminAudit,
|
|
1138
|
+
suppressedFindingsCount: suppressedCount,
|
|
1139
|
+
activeChecks: activeAllowed,
|
|
1140
|
+
authorization,
|
|
1141
|
+
};
|
|
1142
|
+
fs.writeFileSync(path.join(outDir, 'summary.json'), JSON.stringify(summaryData, null, 2), 'utf8');
|
|
1143
|
+
|
|
1144
|
+
const fullReport = { ...summaryData, findings: sorted, vulnerabilities: vulnResults, exposedPaths: pathResults, libraries };
|
|
1145
|
+
fs.writeFileSync(path.join(outDir, 'full-report.json'), JSON.stringify(fullReport, null, 2), 'utf8');
|
|
1146
|
+
if (jsonOut) console.log(JSON.stringify(fullReport, null, 2));
|
|
1147
|
+
|
|
1148
|
+
const dashboardHtml = buildSecurityDashboardHtml({ site, risk, sorted, primaryPlatform, platformSignals, hostingSignals, tlsInfo, dnsRecords, libraries, vulnResults, crawlerExposure, paymentProcessors: [...allPaymentProcessors], generatedAt }, branding);
|
|
1149
|
+
fs.writeFileSync(path.join(outDir, 'security-dashboard.html'), dashboardHtml, 'utf8');
|
|
1150
|
+
try {
|
|
1151
|
+
const reportPage = await context.newPage();
|
|
1152
|
+
await reportPage.goto('file://' + path.join(outDir, 'security-dashboard.html'));
|
|
1153
|
+
await reportPage.pdf({ path: path.join(outDir, 'security-dashboard.pdf'), format: 'A4', printBackground: true, margin: { top: '20px', bottom: '20px', left: '20px', right: '20px' } });
|
|
1154
|
+
await reportPage.close();
|
|
1155
|
+
out(`Wrote: ${path.join(outDir, 'security-dashboard.pdf')}`);
|
|
1156
|
+
} catch (e) {
|
|
1157
|
+
statusMsg('⚠', c.brightYellow, `PDF generation failed: ${String(e?.message || e)}`);
|
|
1158
|
+
}
|
|
1159
|
+
out(`Wrote: ${path.join(outDir, 'security-dashboard.html')}`);
|
|
1160
|
+
phaseDone('Report generation', Date.now() - reportStart);
|
|
1161
|
+
|
|
1162
|
+
await context.close();
|
|
1163
|
+
await browser.close();
|
|
1164
|
+
|
|
1165
|
+
out('');
|
|
1166
|
+
out(` ${rainbow('╔══════════════════════════════════════════════════════════╗')}`);
|
|
1167
|
+
out(` ${rainbow('║')} ${c.bold}✨ Security Audit Complete!${c.reset} ${rainbow('║')}`);
|
|
1168
|
+
out(` ${rainbow('╚══════════════════════════════════════════════════════════╝')}`);
|
|
1169
|
+
out('');
|
|
1170
|
+
out(` 🎯 Site ${site}`);
|
|
1171
|
+
out(` 🧩 Platform ${primaryPlatform}`);
|
|
1172
|
+
out(` 🏆 Risk grade ${risk.grade} (${risk.score}/100)`);
|
|
1173
|
+
out(` 🔴 Critical ${risk.counts.critical}`);
|
|
1174
|
+
out(` 🟠 High ${risk.counts.high}`);
|
|
1175
|
+
out(` 🟡 Medium ${risk.counts.medium}`);
|
|
1176
|
+
out(` 🔵 Low ${risk.counts.low}`);
|
|
1177
|
+
out(` ⚪ Info ${risk.counts.info}`);
|
|
1178
|
+
out(` ⏱️ Time ${formatDuration(Date.now() - auditStart)}`);
|
|
1179
|
+
out(` 📁 Output ${outDir}`);
|
|
1180
|
+
out('');
|
|
1181
|
+
|
|
1182
|
+
// ── CI/CD gating: exit non-zero if requested severity/grade thresholds are breached ──
|
|
1183
|
+
let ciFailed = false;
|
|
1184
|
+
if (failOn && hasFindingAtOrAbove(activeFindings, failOn)) {
|
|
1185
|
+
statusMsg('✖', c.brightRed, `--fail-on ${failOn}: at least one finding at or above "${failOn}" severity was found.`);
|
|
1186
|
+
ciFailed = true;
|
|
1187
|
+
}
|
|
1188
|
+
if (minGrade && isGradeBelow(risk.grade, minGrade)) {
|
|
1189
|
+
statusMsg('✖', c.brightRed, `--min-grade ${minGrade}: risk grade ${risk.grade} is below the required threshold.`);
|
|
1190
|
+
ciFailed = true;
|
|
1191
|
+
}
|
|
1192
|
+
if (ciFailed) process.exitCode = 1;
|