@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,112 @@
|
|
|
1
|
+
// ── Wordfence Intelligence: known WordPress core/plugin/theme vulnerabilities ──
|
|
2
|
+
// Free for commercial use with attribution; each user brings their own API key (wordfence.com →
|
|
3
|
+
// Integrations). The feed is one ~160 MB JSON object, and the key's download limit is small and
|
|
4
|
+
// unpublished, so it's fetched at most once a day and kept as a compact index (slug → ranges) in
|
|
5
|
+
// the user's cache folder; later runs read only the index. Only used for sites detected as
|
|
6
|
+
// WordPress, and only when a key is given.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
const FEED = 'https://www.wordfence.com/api/intelligence/v3/vulnerabilities/production';
|
|
12
|
+
const MAX_AGE_MS = 24 * 3600_000;
|
|
13
|
+
|
|
14
|
+
export const WORDFENCE_ATTRIBUTION = 'Vulnerability data: Wordfence Intelligence (https://www.wordfence.com/threat-intel/). Copyright Defiant Inc., used under the Wordfence Intelligence license (https://www.wordfence.com/wordfence-intelligence-terms-and-conditions/).';
|
|
15
|
+
|
|
16
|
+
function cacheFile() {
|
|
17
|
+
const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
|
18
|
+
return path.join(base, 'universal-security-audit', 'wordfence-index.json');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** -1, 0 or 1, comparing dotted versions number by number ("6.10.2" > "6.9.10"). */
|
|
22
|
+
export function compareVersions(a, b) {
|
|
23
|
+
const pa = String(a).split(/[.\-+]/).map((n) => parseInt(n, 10) || 0);
|
|
24
|
+
const pb = String(b).split(/[.\-+]/).map((n) => parseInt(n, 10) || 0);
|
|
25
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
26
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
27
|
+
if (d) return d < 0 ? -1 : 1;
|
|
28
|
+
}
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isAffected(version, ranges) {
|
|
33
|
+
return ranges.some((r) => {
|
|
34
|
+
const above = r.from === '*' || (r.fi ? compareVersions(version, r.from) >= 0 : compareVersions(version, r.from) > 0);
|
|
35
|
+
const below = r.to === '*' || (r.ti ? compareVersions(version, r.to) <= 0 : compareVersions(version, r.to) < 0);
|
|
36
|
+
return above && below;
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Feed records → { "plugin:slug": [{ id, title, cve, rating, score, ranges, patched, url }] }. Exported for tests. */
|
|
41
|
+
export function buildIndex(feed) {
|
|
42
|
+
const index = {};
|
|
43
|
+
for (const [id, v] of Object.entries(feed)) {
|
|
44
|
+
if (v.informational) continue;
|
|
45
|
+
for (const s of v.software ?? []) {
|
|
46
|
+
const kind = ['core', 'plugin', 'theme'].includes(s.type) ? s.type : null;
|
|
47
|
+
const slug = kind === 'core' ? 'wordpress' : String(s.slug ?? '').toLowerCase();
|
|
48
|
+
if (!kind || !slug) continue;
|
|
49
|
+
const ranges = Object.values(s.affected_versions ?? {}).map((r) => ({ from: String(r.from_version ?? '*'), fi: r.from_inclusive !== false, to: String(r.to_version ?? '*'), ti: r.to_inclusive !== false }));
|
|
50
|
+
if (!ranges.length) continue;
|
|
51
|
+
const key = `${kind}:${slug}`;
|
|
52
|
+
const list = (index[key] ??= []);
|
|
53
|
+
const same = list.find((x) => x.id === id);
|
|
54
|
+
if (same) {
|
|
55
|
+
// Core vulnerabilities list WordPress once per release branch.
|
|
56
|
+
same.ranges.push(...ranges);
|
|
57
|
+
same.patched = [...new Set([...same.patched, ...(s.patched_versions ?? []).map(String)])];
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
list.push({
|
|
61
|
+
id,
|
|
62
|
+
title: String(v.title ?? ''),
|
|
63
|
+
cve: v.cve ?? null,
|
|
64
|
+
rating: v.cvss?.rating ? String(v.cvss.rating).toLowerCase() : null,
|
|
65
|
+
score: v.cvss?.score ?? null,
|
|
66
|
+
ranges,
|
|
67
|
+
patched: (s.patched_versions ?? []).map(String),
|
|
68
|
+
url: (v.references ?? []).find((u) => /wordfence\.com/.test(u)) ?? `https://www.wordfence.com/threat-intel/vulnerabilities/id/${id}`,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return index;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The cached index, refreshed when older than a day. Returns { index, error }. */
|
|
76
|
+
export async function loadIndex(apiKey, { file = cacheFile(), now = Date.now() } = {}) {
|
|
77
|
+
let cached = null;
|
|
78
|
+
try {
|
|
79
|
+
const stat = fs.statSync(file);
|
|
80
|
+
cached = { index: JSON.parse(fs.readFileSync(file, 'utf8')), fresh: now - stat.mtimeMs < MAX_AGE_MS };
|
|
81
|
+
} catch {}
|
|
82
|
+
if (cached?.fresh) return { index: cached.index, error: null };
|
|
83
|
+
try {
|
|
84
|
+
const res = await fetch(FEED, { headers: { Authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout(300_000) });
|
|
85
|
+
if (!res.ok) {
|
|
86
|
+
const why = res.status === 401 ? 'Wordfence refused the API key (check WORDFENCE_API_KEY)' : res.status === 429 ? 'the Wordfence key\'s download limit was reached; try again in a few hours' : `Wordfence answered HTTP ${res.status}`;
|
|
87
|
+
// An older copy is better than nothing.
|
|
88
|
+
return cached ? { index: cached.index, error: `${why}; used the copy from ${new Date(fs.statSync(file).mtimeMs).toISOString().slice(0, 10)}` } : { index: null, error: why };
|
|
89
|
+
}
|
|
90
|
+
const index = buildIndex(await res.json());
|
|
91
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
92
|
+
fs.writeFileSync(file, JSON.stringify(index));
|
|
93
|
+
return { index, error: null };
|
|
94
|
+
} catch (e) {
|
|
95
|
+
return cached ? { index: cached.index, error: `couldn't refresh the Wordfence feed (${e?.message || e}); used the cached copy` } : { index: null, error: `couldn't download the Wordfence feed (${e?.message || e})` };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Vulnerabilities affecting one component at this exact version, each with the first fixed version above it. */
|
|
100
|
+
export function lookup(index, kind, slug, version) {
|
|
101
|
+
if (!index || !version) return [];
|
|
102
|
+
return (index[`${kind}:${String(slug).toLowerCase()}`] ?? [])
|
|
103
|
+
.filter((v) => isAffected(version, v.ranges))
|
|
104
|
+
.map((v) => ({ ...v, fixedIn: v.patched.filter((p) => compareVersions(p, version) > 0).sort(compareVersions)[0] ?? null }));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Finding severity from Wordfence's rating, or the CVSS score. */
|
|
108
|
+
export function severityOf(v) {
|
|
109
|
+
if (['critical', 'high', 'medium', 'low'].includes(v.rating)) return v.rating;
|
|
110
|
+
const s = Number(v.score);
|
|
111
|
+
return s >= 9 ? 'critical' : s >= 7 ? 'high' : s >= 4 ? 'medium' : 'low';
|
|
112
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// ── Authenticated WordPress wp-admin audit. Requires the caller to already be logged in
|
|
2
|
+
// (via maybePerformFormLogin against /wp-login.php). Every function here just navigates to a
|
|
3
|
+
// standard wp-admin screen the logged-in user already has access to and reads the rendered DOM —
|
|
4
|
+
// no privilege escalation, no writes, nothing the user couldn't see by clicking around themselves. ──
|
|
5
|
+
|
|
6
|
+
const KNOWN_SECURITY_PLUGIN_SLUGS = [
|
|
7
|
+
'wordfence', 'better-wp-security', 'sucuri-scanner', 'all-in-one-wp-security-and-firewall',
|
|
8
|
+
'wp-fail2ban', 'wp-simple-firewall', 'bulletproof-security', 'wp-cerber', 'wp-simple-firewall-pro',
|
|
9
|
+
'ithemes-security-pro', 'malcare-security', 'shield-security', 'limit-login-attempts-reloaded',
|
|
10
|
+
'two-factor', 'wp-2fa', 'defender-security',
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
export async function isLoggedIntoWpAdmin(page, origin) {
|
|
14
|
+
try {
|
|
15
|
+
const res = await page.goto(new URL('/wp-admin/', origin).toString(), { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
16
|
+
const url = page.url();
|
|
17
|
+
return !/wp-login\.php/i.test(url) && (res?.status?.() ?? 200) < 400;
|
|
18
|
+
} catch { return false; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function scanWpPlugins(page, origin) {
|
|
22
|
+
try {
|
|
23
|
+
await page.goto(new URL('/wp-admin/plugins.php', origin).toString(), { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
24
|
+
return await page.$$eval('tr[data-slug]', (rows) => rows.map((row) => {
|
|
25
|
+
const slug = row.getAttribute('data-slug') || '';
|
|
26
|
+
const isActive = row.className.includes('active') && !row.className.includes('inactive');
|
|
27
|
+
const nameEl = row.querySelector('.plugin-title strong');
|
|
28
|
+
const name = nameEl ? nameEl.textContent.trim() : slug;
|
|
29
|
+
const descText = row.querySelector('.plugin-version-author-uri')?.textContent || row.textContent || '';
|
|
30
|
+
const verMatch = /Version\s+([\d.]+)/i.exec(descText);
|
|
31
|
+
const updateRow = row.nextElementSibling;
|
|
32
|
+
const updateAvailable = !!(updateRow && updateRow.className.includes('update'));
|
|
33
|
+
let updateToVersion = null;
|
|
34
|
+
if (updateAvailable) {
|
|
35
|
+
const m = /view version\s+([\d.]+)/i.exec(updateRow.textContent || '');
|
|
36
|
+
updateToVersion = m ? m[1] : null;
|
|
37
|
+
}
|
|
38
|
+
return { slug, name, active: isActive, version: verMatch ? verMatch[1] : null, updateAvailable, updateToVersion };
|
|
39
|
+
}));
|
|
40
|
+
} catch { return []; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function scanWpThemes(page, origin) {
|
|
44
|
+
try {
|
|
45
|
+
await page.goto(new URL('/wp-admin/themes.php', origin).toString(), { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
46
|
+
const html = await page.content();
|
|
47
|
+
const m = /_wpThemeSettings\s*=\s*(\{[\s\S]*?\});/.exec(html);
|
|
48
|
+
if (!m) return [];
|
|
49
|
+
const settings = JSON.parse(m[1]);
|
|
50
|
+
const themes = settings.themes || [];
|
|
51
|
+
return themes.map((t) => ({
|
|
52
|
+
slug: t.id, name: t.name, version: t.version, active: !!t.active,
|
|
53
|
+
updateAvailable: !!(t.update && t.update.new_version), updateToVersion: t.update?.new_version || null,
|
|
54
|
+
}));
|
|
55
|
+
} catch { return []; }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function scanWpCoreAndPhp(page, origin) {
|
|
59
|
+
const result = { wpVersion: null, phpVersion: null, mysqlVersion: null, source: null };
|
|
60
|
+
try {
|
|
61
|
+
await page.goto(new URL('/wp-admin/index.php', origin).toString(), { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
62
|
+
const footerHtml = await page.content();
|
|
63
|
+
const wpM = /Version\s+([\d.]+(?:-[\w.]+)?)/i.exec(footerHtml);
|
|
64
|
+
if (wpM) { result.wpVersion = wpM[1]; result.source = 'admin footer'; }
|
|
65
|
+
} catch {}
|
|
66
|
+
try {
|
|
67
|
+
await page.goto(new URL('/wp-admin/site-health.php?tab=debug', origin).toString(), { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
68
|
+
const html = await page.content();
|
|
69
|
+
const phpM = /PHP [Vv]ersion<\/td>\s*<td[^>]*>\s*([\d.]+)/.exec(html) || /"php_version"[^"]*"\s*:\s*"([\d.]+)"/.exec(html);
|
|
70
|
+
if (phpM) result.phpVersion = phpM[1];
|
|
71
|
+
const mysqlM = /(?:MySQL|Database) [Vv]ersion<\/td>\s*<td[^>]*>\s*([\d.a-zA-Z-]+)/.exec(html);
|
|
72
|
+
if (mysqlM) result.mysqlVersion = mysqlM[1];
|
|
73
|
+
const wpHealthM = /WordPress [Vv]ersion<\/td>\s*<td[^>]*>\s*([\d.]+)/.exec(html);
|
|
74
|
+
if (wpHealthM && !result.wpVersion) { result.wpVersion = wpHealthM[1]; result.source = 'site health'; }
|
|
75
|
+
} catch {}
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function scanWpUsers(page, origin) {
|
|
80
|
+
try {
|
|
81
|
+
await page.goto(new URL('/wp-admin/users.php', origin).toString(), { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
82
|
+
return await page.$$eval('#the-list tr', (rows) => rows.map((row) => {
|
|
83
|
+
const username = row.querySelector('.username strong, .column-username strong, .column-username a')?.textContent?.trim() || '';
|
|
84
|
+
const role = row.querySelector('.role')?.textContent?.trim() || '';
|
|
85
|
+
return { username, role };
|
|
86
|
+
}).filter((u) => u.username));
|
|
87
|
+
} catch { return []; }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function detectSecurityPlugins(plugins = []) {
|
|
91
|
+
const found = plugins.filter((p) => KNOWN_SECURITY_PLUGIN_SLUGS.includes(p.slug) && p.active);
|
|
92
|
+
return found.map((p) => p.name);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// End-of-life minor versions per php.net/supported-versions.php. PHP 8.2's security support runs
|
|
96
|
+
// through Dec 2026 and is intentionally left off this list until that date passes.
|
|
97
|
+
const EOL_PHP_MINORS = new Set(['5.6', '7.0', '7.1', '7.2', '7.3', '7.4', '8.0', '8.1']);
|
|
98
|
+
export function checkPhpEol(phpVersion) {
|
|
99
|
+
if (!phpVersion) return null;
|
|
100
|
+
const minor = (phpVersion.match(/^(\d+\.\d+)/) || [])[1];
|
|
101
|
+
return minor ? EOL_PHP_MINORS.has(minor) : null;
|
|
102
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// ── Official wordpress.org Plugin/Theme Directory API — used to determine whether an installed
|
|
2
|
+
// version is outdated. This replaces scraping wp-admin's "View version X details" update-nag markup
|
|
3
|
+
// (which is UI text, not a stable contract) with a public, versioned API endpoint. ──
|
|
4
|
+
|
|
5
|
+
async function safeFetchJson(url, { timeoutMs = 8000 } = {}) {
|
|
6
|
+
const controller = new AbortController();
|
|
7
|
+
const t = setTimeout(() => controller.abort(), timeoutMs);
|
|
8
|
+
try {
|
|
9
|
+
const res = await fetch(url, { headers: { 'user-agent': 'Universal-Security-Audit' }, signal: controller.signal });
|
|
10
|
+
clearTimeout(t);
|
|
11
|
+
if (!res.ok) return null;
|
|
12
|
+
return await res.json();
|
|
13
|
+
} catch { clearTimeout(t); return null; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function lookupLatestPluginVersion(slug) {
|
|
17
|
+
const data = await safeFetchJson(`https://api.wordpress.org/plugins/info/1.0/${encodeURIComponent(slug)}.json`);
|
|
18
|
+
if (!data || !data.version) return null;
|
|
19
|
+
return { latestVersion: data.version, name: data.name, lastUpdated: data.last_updated || null, activeInstalls: data.active_installs ?? null };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function lookupLatestThemeVersion(slug) {
|
|
23
|
+
const url = `https://api.wordpress.org/themes/info/1.2/?action=theme_information&request[slug]=${encodeURIComponent(slug)}`;
|
|
24
|
+
const data = await safeFetchJson(url);
|
|
25
|
+
if (!data || !data.version) return null;
|
|
26
|
+
return { latestVersion: data.version, name: data.name };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function semverLt(a, b) {
|
|
30
|
+
const pa = String(a || '0').split('.').map((n) => parseInt(n, 10) || 0);
|
|
31
|
+
const pb = String(b || '0').split('.').map((n) => parseInt(n, 10) || 0);
|
|
32
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
33
|
+
const x = pa[i] || 0, y = pb[i] || 0;
|
|
34
|
+
if (x < y) return true;
|
|
35
|
+
if (x > y) return false;
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Cross-references a list of {slug, version} against the plugin directory API, with modest
|
|
41
|
+
// concurrency since this is one HTTP call per installed plugin/theme.
|
|
42
|
+
export async function checkOutdatedAgainstDirectory(items, lookupFn, { concurrency = 4 } = {}) {
|
|
43
|
+
const results = [];
|
|
44
|
+
const queue = [...items];
|
|
45
|
+
async function worker() {
|
|
46
|
+
while (queue.length) {
|
|
47
|
+
const item = queue.shift();
|
|
48
|
+
const info = await lookupFn(item.slug);
|
|
49
|
+
if (info && item.version) {
|
|
50
|
+
results.push({ ...item, latestVersion: info.latestVersion, outdated: semverLt(item.version, info.latestVersion) });
|
|
51
|
+
} else {
|
|
52
|
+
results.push({ ...item, latestVersion: info?.latestVersion ?? null, outdated: null });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, queue.length || 1) }, worker));
|
|
57
|
+
return results;
|
|
58
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// ── Authenticated WordPress audit via the official REST API + Application Passwords, instead of
|
|
2
|
+
// scraping wp-admin HTML. This is the preferred path when an Application Password is supplied: it's
|
|
3
|
+
// stable across WP core/admin-theme changes, doesn't need Playwright at all, and only reads data the
|
|
4
|
+
// authenticated user already has REST capability to see (edit_plugins/list_users/etc). ──
|
|
5
|
+
|
|
6
|
+
function authHeader(appUser, appPassword) {
|
|
7
|
+
const token = Buffer.from(`${appUser}:${appPassword}`).toString('base64');
|
|
8
|
+
return { Authorization: `Basic ${token}`, 'user-agent': 'Universal-Security-Audit' };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function restGet(origin, path, appUser, appPassword, { timeoutMs = 15000 } = {}) {
|
|
12
|
+
const controller = new AbortController();
|
|
13
|
+
const t = setTimeout(() => controller.abort(), timeoutMs);
|
|
14
|
+
try {
|
|
15
|
+
const res = await fetch(new URL(path, origin).toString(), { headers: authHeader(appUser, appPassword), signal: controller.signal });
|
|
16
|
+
clearTimeout(t);
|
|
17
|
+
if (!res.ok) return { ok: false, status: res.status, error: await res.text().catch(() => '') };
|
|
18
|
+
return { ok: true, status: res.status, data: await res.json() };
|
|
19
|
+
} catch (e) {
|
|
20
|
+
clearTimeout(t);
|
|
21
|
+
return { ok: false, status: 0, error: String(e?.message || e) };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function verifyWpAppCredentials(origin, appUser, appPassword) {
|
|
26
|
+
const res = await restGet(origin, '/wp-json/wp/v2/users/me?context=edit', appUser, appPassword);
|
|
27
|
+
return { ok: res.ok, roles: res.ok ? res.data.roles || [] : [], error: res.error };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function wpRestPlugins(origin, appUser, appPassword) {
|
|
31
|
+
const res = await restGet(origin, '/wp-json/wp/v2/plugins', appUser, appPassword);
|
|
32
|
+
if (!res.ok) return { ok: false, error: res.error, plugins: [] };
|
|
33
|
+
const plugins = (res.data || []).map((p) => ({
|
|
34
|
+
slug: String(p.plugin || '').split('/')[0],
|
|
35
|
+
name: p.name?.replace(/<[^>]+>/g, '') || p.plugin,
|
|
36
|
+
active: p.status === 'active',
|
|
37
|
+
version: p.version || null,
|
|
38
|
+
}));
|
|
39
|
+
return { ok: true, plugins };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function wpRestThemes(origin, appUser, appPassword) {
|
|
43
|
+
const res = await restGet(origin, '/wp-json/wp/v2/themes?status=active,inactive', appUser, appPassword);
|
|
44
|
+
if (!res.ok) return { ok: false, error: res.error, themes: [] };
|
|
45
|
+
const themes = (res.data || []).map((t) => ({
|
|
46
|
+
slug: t.stylesheet,
|
|
47
|
+
name: t.name?.rendered || t.name?.raw || t.stylesheet,
|
|
48
|
+
active: t.status === 'active',
|
|
49
|
+
version: t.version || null,
|
|
50
|
+
}));
|
|
51
|
+
return { ok: true, themes };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function wpRestUsers(origin, appUser, appPassword) {
|
|
55
|
+
const res = await restGet(origin, '/wp-json/wp/v2/users?context=edit&per_page=100', appUser, appPassword);
|
|
56
|
+
if (!res.ok) return { ok: false, error: res.error, users: [] };
|
|
57
|
+
const users = (res.data || []).map((u) => ({ username: u.slug || u.name, role: (u.roles || []).join(', ') }));
|
|
58
|
+
return { ok: true, users };
|
|
59
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Runs the full security audit across a list of sites, aggregating a summary table. Each site still
|
|
3
|
+
// gets its own normal reports/<site>-<timestamp>/ directory with all the usual CSVs/HTML/PDF — this
|
|
4
|
+
// just adds a rollup on top by running each child with --json and parsing its clean stdout.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
function parseArgs(argv) {
|
|
11
|
+
const args = {};
|
|
12
|
+
for (let i = 2; i < argv.length; i++) {
|
|
13
|
+
const a = argv[i];
|
|
14
|
+
if (!a.startsWith('--')) continue;
|
|
15
|
+
const key = a.replace(/^--/, '');
|
|
16
|
+
const next = argv[i + 1];
|
|
17
|
+
if (!next || next.startsWith('--')) args[key] = true;
|
|
18
|
+
else { args[key] = next; i++; }
|
|
19
|
+
}
|
|
20
|
+
return args;
|
|
21
|
+
}
|
|
22
|
+
function esc(s) { s = String(s ?? ''); return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; }
|
|
23
|
+
function writeCsv(rows, columns, outPath) {
|
|
24
|
+
const body = [columns.join(','), ...rows.map((r) => columns.map((c) => esc(r[c])).join(','))].join('\n') + '\n';
|
|
25
|
+
fs.writeFileSync(outPath, body, 'utf8');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const args = parseArgs(process.argv);
|
|
29
|
+
if (!args['sites-file']) { console.error('Missing --sites-file'); process.exit(1); }
|
|
30
|
+
const sitesFile = path.resolve(process.cwd(), args['sites-file']);
|
|
31
|
+
const sites = fs.readFileSync(sitesFile, 'utf8').split(/\r?\n/g).map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
|
|
32
|
+
if (!sites.length) { console.error('No sites found in --sites-file'); process.exit(1); }
|
|
33
|
+
|
|
34
|
+
const concurrency = args.concurrency ? Number(args.concurrency) : 1;
|
|
35
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
36
|
+
const auditScript = path.join(__dirname, 'run-security-audit.mjs');
|
|
37
|
+
|
|
38
|
+
// Everything except our own batch-only flags gets forwarded to each child audit run.
|
|
39
|
+
const OWN_FLAGS = new Set(['sites-file', 'concurrency', 'out-dir']);
|
|
40
|
+
const passthrough = [];
|
|
41
|
+
for (const [key, val] of Object.entries(args)) {
|
|
42
|
+
if (OWN_FLAGS.has(key)) continue;
|
|
43
|
+
passthrough.push(`--${key}`);
|
|
44
|
+
if (val !== true) passthrough.push(String(val));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function runOne(site) {
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
const child = spawn(process.execPath, [auditScript, '--site', site, '--json', ...passthrough], { stdio: ['ignore', 'pipe', 'inherit'] });
|
|
50
|
+
let stdout = '';
|
|
51
|
+
child.stdout.on('data', (d) => { stdout += d; });
|
|
52
|
+
child.on('close', (code) => {
|
|
53
|
+
let report = null;
|
|
54
|
+
try { report = JSON.parse(stdout); } catch {}
|
|
55
|
+
resolve({ site, exitCode: code, report });
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
console.log(`Running batch audit across ${sites.length} site(s) (concurrency ${concurrency})...`);
|
|
61
|
+
const results = [];
|
|
62
|
+
const queue = [...sites];
|
|
63
|
+
async function worker() {
|
|
64
|
+
while (queue.length) {
|
|
65
|
+
const site = queue.shift();
|
|
66
|
+
console.log(`→ ${site}`);
|
|
67
|
+
results.push(await runOne(site));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, sites.length) }, worker));
|
|
71
|
+
|
|
72
|
+
const outDir = args['out-dir'] ? path.resolve(process.cwd(), args['out-dir']) : path.resolve('reports');
|
|
73
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
74
|
+
const batchId = Date.now();
|
|
75
|
+
|
|
76
|
+
const rows = results.map((r) => ({
|
|
77
|
+
site: r.site,
|
|
78
|
+
status: r.report ? 'completed' : 'failed',
|
|
79
|
+
platform: r.report?.primaryPlatform || '',
|
|
80
|
+
grade: r.report?.risk?.grade || '',
|
|
81
|
+
score: r.report?.risk?.score ?? '',
|
|
82
|
+
critical: r.report?.risk?.counts?.critical ?? '',
|
|
83
|
+
high: r.report?.risk?.counts?.high ?? '',
|
|
84
|
+
medium: r.report?.risk?.counts?.medium ?? '',
|
|
85
|
+
low: r.report?.risk?.counts?.low ?? '',
|
|
86
|
+
info: r.report?.risk?.counts?.info ?? '',
|
|
87
|
+
exitCode: r.exitCode,
|
|
88
|
+
}));
|
|
89
|
+
rows.sort((a, b) => (a.score === '' ? 999 : a.score) - (b.score === '' ? 999 : b.score));
|
|
90
|
+
|
|
91
|
+
const csvPath = path.join(outDir, `batch-summary-${batchId}.csv`);
|
|
92
|
+
writeCsv(rows, ['site', 'status', 'platform', 'grade', 'score', 'critical', 'high', 'medium', 'low', 'info', 'exitCode'], csvPath);
|
|
93
|
+
|
|
94
|
+
const failed = rows.filter((r) => r.status === 'failed');
|
|
95
|
+
const md = [
|
|
96
|
+
'# Batch Security Audit Summary', '',
|
|
97
|
+
`Sites scanned: ${sites.length} · Completed: ${rows.length - failed.length} · Failed: ${failed.length}`, '',
|
|
98
|
+
'| Site | Grade | Score | Critical | High | Medium | Low | Info |',
|
|
99
|
+
'| --- | --- | --- | --- | --- | --- | --- | --- |',
|
|
100
|
+
...rows.map((r) => `| ${r.site} | ${r.grade} | ${r.score} | ${r.critical} | ${r.high} | ${r.medium} | ${r.low} | ${r.info} |`),
|
|
101
|
+
].join('\n');
|
|
102
|
+
const mdPath = path.join(outDir, `batch-summary-${batchId}.md`);
|
|
103
|
+
fs.writeFileSync(mdPath, md, 'utf8');
|
|
104
|
+
|
|
105
|
+
console.log('');
|
|
106
|
+
console.log(`Wrote: ${csvPath}`);
|
|
107
|
+
console.log(`Wrote: ${mdPath}`);
|
|
108
|
+
if (failed.length) {
|
|
109
|
+
console.error(`${failed.length} site(s) failed: ${failed.map((f) => f.site).join(', ')}`);
|
|
110
|
+
process.exitCode = 1;
|
|
111
|
+
}
|