@pbpyrojust/universal-security-audit 0.0.0-stage → 0.2.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 +412 -2
- package/auth-config.example.json +12 -0
- package/bin/universal-security-audit.mjs +129 -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/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 +215 -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/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 +1146 -0
|
@@ -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
|
+
}
|