@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,84 @@
|
|
|
1
|
+
// ── Authenticated Drupal admin audit. Requires the caller to already be logged in (via
|
|
2
|
+
// maybePerformFormLogin against /user/login). Drupal's admin theme/markup varies more across
|
|
3
|
+
// versions/installs than WordPress's, so parsing here leans on visible text (innerText) and Drupal
|
|
4
|
+
// core's default Views field classes rather than brittle theme-specific selectors — best-effort,
|
|
5
|
+
// same "just read what a logged-in admin can already see" philosophy as the WP audit. ──
|
|
6
|
+
|
|
7
|
+
export async function isLoggedIntoDrupalAdmin(page, origin) {
|
|
8
|
+
try {
|
|
9
|
+
const res = await page.goto(new URL('/user', origin).toString(), { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
10
|
+
const url = page.url();
|
|
11
|
+
return !/\/user\/login/i.test(url) && (res?.status?.() ?? 200) < 400;
|
|
12
|
+
} catch { return false; }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function scanDrupalStatusReport(page, origin) {
|
|
16
|
+
const result = { drupalVersion: null, phpVersion: null, dbVersion: null, errorCount: 0, warningCount: 0, errorItems: [] };
|
|
17
|
+
try {
|
|
18
|
+
await page.goto(new URL('/admin/reports/status', origin).toString(), { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
19
|
+
const text = await page.evaluate(() => document.body.innerText);
|
|
20
|
+
const drupalM = /Drupal (?:Version|core version)\s*\n?\s*([\d.]+)/i.exec(text);
|
|
21
|
+
if (drupalM) result.drupalVersion = drupalM[1];
|
|
22
|
+
const phpM = /\bPHP\b\s*\n?\s*([\d.]+)/i.exec(text);
|
|
23
|
+
if (phpM) result.phpVersion = phpM[1];
|
|
24
|
+
const dbM = /Database\s*\n?\s*(MySQL|MariaDB|PostgreSQL|SQLite)?\s*([\d.]+)/i.exec(text);
|
|
25
|
+
if (dbM) result.dbVersion = [dbM[1], dbM[2]].filter(Boolean).join(' ');
|
|
26
|
+
result.errorCount = (text.match(/\bError\b/g) || []).length;
|
|
27
|
+
result.warningCount = (text.match(/\bWarning\b/g) || []).length;
|
|
28
|
+
const lines = text.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
29
|
+
result.errorItems = lines.filter((l) => /^Error\b/.test(l)).slice(0, 20);
|
|
30
|
+
} catch {}
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function scanDrupalModules(page, origin) {
|
|
35
|
+
try {
|
|
36
|
+
await page.goto(new URL('/admin/modules', origin).toString(), { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
37
|
+
return await page.$$eval('input[type=checkbox][name^="modules["]', (checkboxes) => checkboxes.map((cb) => {
|
|
38
|
+
const nameMatch = /^modules\[([^\]]+)\]/.exec(cb.getAttribute('name') || '');
|
|
39
|
+
const machineName = nameMatch ? nameMatch[1] : '';
|
|
40
|
+
const row = cb.closest('tr') || cb.closest('.form-item')?.parentElement || cb.parentElement;
|
|
41
|
+
const rowText = row ? row.innerText : '';
|
|
42
|
+
const versionMatch = /(\d+\.x-\d+\.[\d.]+|\d+\.\d+\.\d+)/.exec(rowText);
|
|
43
|
+
const nameEl = row?.querySelector('label, .module-name, td strong, strong');
|
|
44
|
+
return {
|
|
45
|
+
slug: machineName,
|
|
46
|
+
name: nameEl ? nameEl.textContent.trim().split('\n')[0] : machineName,
|
|
47
|
+
enabled: cb.checked,
|
|
48
|
+
version: versionMatch ? versionMatch[1] : null,
|
|
49
|
+
};
|
|
50
|
+
}).filter((m) => m.slug));
|
|
51
|
+
} catch { return []; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function scanDrupalUsers(page, origin) {
|
|
55
|
+
try {
|
|
56
|
+
await page.goto(new URL('/admin/people', origin).toString(), { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
57
|
+
return await page.$$eval('tr', (rows) => rows.map((row) => {
|
|
58
|
+
const nameEl = row.querySelector('.views-field-name a, td.views-field-name');
|
|
59
|
+
const rolesEl = row.querySelector('.views-field-roles');
|
|
60
|
+
const statusEl = row.querySelector('.views-field-status');
|
|
61
|
+
if (!nameEl) return null;
|
|
62
|
+
return {
|
|
63
|
+
username: nameEl.textContent.trim(),
|
|
64
|
+
roles: rolesEl ? rolesEl.textContent.trim().replace(/\s+/g, ' ') : '',
|
|
65
|
+
status: statusEl ? statusEl.textContent.trim() : '',
|
|
66
|
+
};
|
|
67
|
+
}).filter(Boolean));
|
|
68
|
+
} catch { return []; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const EOL_PHP_MINORS = new Set(['5.6', '7.0', '7.1', '7.2', '7.3', '7.4', '8.0', '8.1']);
|
|
72
|
+
export function checkPhpEol(phpVersion) {
|
|
73
|
+
if (!phpVersion) return null;
|
|
74
|
+
const minor = (phpVersion.match(/^(\d+\.\d+)/) || [])[1];
|
|
75
|
+
return minor ? EOL_PHP_MINORS.has(minor) : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Drupal core major versions no longer receiving security coverage (checked against the major.minor).
|
|
79
|
+
const EOL_DRUPAL_MAJORS = new Set(['6', '7', '8', '9']);
|
|
80
|
+
export function checkDrupalCoreEol(drupalVersion) {
|
|
81
|
+
if (!drupalVersion) return null;
|
|
82
|
+
const major = (drupalVersion.match(/^(\d+)/) || [])[1];
|
|
83
|
+
return major ? EOL_DRUPAL_MAJORS.has(major) : null;
|
|
84
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// ── Mildly-intrusive exposed-path probing: admin/login pages, backup/config leaks, exposed APIs ──
|
|
2
|
+
// Every probe here is a single unauthenticated GET request to a well-known, publicly documented path —
|
|
3
|
+
// equivalent to what any search engine or casual visitor could trigger by guessing a URL. No auth
|
|
4
|
+
// bypass, fuzzing, brute force, or payload injection is performed.
|
|
5
|
+
|
|
6
|
+
export const ADMIN_LOGIN_PATHS = [
|
|
7
|
+
{ path: '/wp-login.php', label: 'WordPress login', platform: 'WordPress' },
|
|
8
|
+
{ path: '/wp-admin/', label: 'WordPress admin', platform: 'WordPress' },
|
|
9
|
+
{ path: '/administrator/', label: 'Joomla admin', platform: 'Joomla' },
|
|
10
|
+
{ path: '/user/login', label: 'Drupal login', platform: 'Drupal' },
|
|
11
|
+
{ path: '/admin/', label: 'Generic admin path', platform: 'Generic' },
|
|
12
|
+
{ path: '/admin/login', label: 'Generic admin login', platform: 'Generic' },
|
|
13
|
+
{ path: '/login', label: 'Generic login', platform: 'Generic' },
|
|
14
|
+
{ path: '/cpanel', label: 'cPanel', platform: 'Hosting panel' },
|
|
15
|
+
{ path: '/phpmyadmin/', label: 'phpMyAdmin', platform: 'DB admin' },
|
|
16
|
+
{ path: '/adminer.php', label: 'Adminer DB admin', platform: 'DB admin' },
|
|
17
|
+
{ path: '/craft/admin', label: 'Craft CMS admin', platform: 'Craft CMS' },
|
|
18
|
+
{ path: '/umbraco/', label: 'Umbraco admin', platform: 'Umbraco' },
|
|
19
|
+
{ path: '/typo3/', label: 'TYPO3 admin', platform: 'TYPO3' },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export const EXPOSED_FILE_PATHS = [
|
|
23
|
+
{ path: '/.env', label: 'Environment file (secrets/credentials)', severity: 'critical' },
|
|
24
|
+
{ path: '/.git/config', label: 'Exposed .git directory', severity: 'critical' },
|
|
25
|
+
{ path: '/.git/HEAD', label: 'Exposed .git directory', severity: 'critical' },
|
|
26
|
+
{ path: '/wp-config.php.bak', label: 'WordPress config backup', severity: 'critical' },
|
|
27
|
+
{ path: '/wp-config.php~', label: 'WordPress config backup (editor swap)', severity: 'critical' },
|
|
28
|
+
{ path: '/wp-config.old', label: 'WordPress config backup', severity: 'critical' },
|
|
29
|
+
{ path: '/config.php.bak', label: 'Config backup file', severity: 'critical' },
|
|
30
|
+
{ path: '/settings.php.bak', label: 'Drupal settings backup', severity: 'critical' },
|
|
31
|
+
{ path: '/.DS_Store', label: 'macOS .DS_Store (reveals directory listing)', severity: 'low' },
|
|
32
|
+
{ path: '/backup.zip', label: 'Backup archive', severity: 'high' },
|
|
33
|
+
{ path: '/backup.sql', label: 'Database backup', severity: 'critical' },
|
|
34
|
+
{ path: '/database.sql', label: 'Database backup', severity: 'critical' },
|
|
35
|
+
{ path: '/dump.sql', label: 'Database dump', severity: 'critical' },
|
|
36
|
+
{ path: '/.htpasswd', label: 'htpasswd credential file', severity: 'critical' },
|
|
37
|
+
{ path: '/composer.json', label: 'Composer manifest (reveals dependency versions)', severity: 'low' },
|
|
38
|
+
{ path: '/package.json', label: 'npm manifest (reveals dependency versions)', severity: 'low' },
|
|
39
|
+
{ path: '/phpinfo.php', label: 'phpinfo() disclosure', severity: 'high' },
|
|
40
|
+
{ path: '/info.php', label: 'phpinfo() disclosure', severity: 'high' },
|
|
41
|
+
{ path: '/server-status', label: 'Apache server-status', severity: 'high' },
|
|
42
|
+
{ path: '/.well-known/security.txt', label: 'security.txt (informational, not a leak)', severity: 'info' },
|
|
43
|
+
{ path: '/error_log', label: 'Exposed PHP error log', severity: 'high' },
|
|
44
|
+
{ path: '/debug.log', label: 'Exposed debug log', severity: 'medium' },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
export const EXPOSED_API_PATHS = [
|
|
48
|
+
{ path: '/wp-json/', label: 'WordPress REST API root', severity: 'info' },
|
|
49
|
+
{ path: '/wp-json/wp/v2/users', label: 'WordPress REST API user enumeration', severity: 'medium' },
|
|
50
|
+
{ path: '/xmlrpc.php', label: 'WordPress XML-RPC (brute-force/amplification vector)', severity: 'medium' },
|
|
51
|
+
{ path: '/graphql', label: 'Exposed GraphQL endpoint (check introspection)', severity: 'medium' },
|
|
52
|
+
{ path: '/api/', label: 'Generic API root', severity: 'info' },
|
|
53
|
+
{ path: '/api/users', label: 'Generic user-listing API', severity: 'medium' },
|
|
54
|
+
{ path: '/.well-known/change-password', label: 'Change-password well-known (informational)', severity: 'info' },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
function isDirectoryListingHtml(html = '') {
|
|
58
|
+
return /index of \//i.test(html) && /<title>index of/i.test(html);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Loads a user-supplied wordlist file (one path per line, '#' comments and blank lines ignored) and
|
|
62
|
+
// turns it into probe entries alongside the curated lists. Larger lists are gated by --intensity
|
|
63
|
+
// concurrency/delay, same as the built-in path sets.
|
|
64
|
+
export function loadWordlistEntries(fs, filePath) {
|
|
65
|
+
const text = fs.readFileSync(filePath, 'utf8');
|
|
66
|
+
return text.split(/\r?\n/g)
|
|
67
|
+
.map((line) => line.trim())
|
|
68
|
+
.filter((line) => line && !line.startsWith('#'))
|
|
69
|
+
.map((line) => ({ path: line.startsWith('/') ? line : `/${line}`, label: `Custom wordlist entry: ${line}`, severity: 'medium' }));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function probePath(origin, entry, { timeoutMs = 8000, userAgent = 'Universal-Security-Audit' } = {}) {
|
|
73
|
+
const url = new URL(entry.path, origin).toString();
|
|
74
|
+
try {
|
|
75
|
+
const controller = new AbortController();
|
|
76
|
+
const t = setTimeout(() => controller.abort(), timeoutMs);
|
|
77
|
+
const res = await fetch(url, { method: 'GET', redirect: 'manual', headers: { 'user-agent': userAgent }, signal: controller.signal });
|
|
78
|
+
clearTimeout(t);
|
|
79
|
+
const status = res.status;
|
|
80
|
+
// Treat redirects to a login/home page as "not exposed" unless it's a 2xx.
|
|
81
|
+
const exists = status >= 200 && status < 300;
|
|
82
|
+
let bodySnippet = '';
|
|
83
|
+
let directoryListing = false;
|
|
84
|
+
if (exists) {
|
|
85
|
+
const text = await res.text().catch(() => '');
|
|
86
|
+
bodySnippet = text.slice(0, 300);
|
|
87
|
+
directoryListing = isDirectoryListingHtml(text);
|
|
88
|
+
}
|
|
89
|
+
return { url, path: entry.path, label: entry.label, status, exists, directoryListing, severity: entry.severity, platform: entry.platform, bodySnippet };
|
|
90
|
+
} catch (e) {
|
|
91
|
+
return { url, path: entry.path, label: entry.label, status: 0, exists: false, error: String(e?.message || e), severity: entry.severity, platform: entry.platform };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function probeAllPaths(origin, entries, { concurrency = 4, delayMs = 150, isAllowedUrl = null, onProgress = null, ...opts } = {}) {
|
|
96
|
+
const results = [];
|
|
97
|
+
const queue = entries.filter((e) => !isAllowedUrl || isAllowedUrl(new URL(e.path, origin).toString()));
|
|
98
|
+
const total = queue.length;
|
|
99
|
+
let idx = 0;
|
|
100
|
+
async function worker() {
|
|
101
|
+
while (idx < queue.length) {
|
|
102
|
+
const entry = queue[idx++];
|
|
103
|
+
const result = await probePath(origin, entry, opts);
|
|
104
|
+
results.push(result);
|
|
105
|
+
if (onProgress) onProgress(results.length, total, result);
|
|
106
|
+
if (delayMs) await new Promise((r) => setTimeout(r, delayMs));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, queue.length) }, worker));
|
|
110
|
+
return results;
|
|
111
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// ── CMS/platform + hosting/CDN fingerprinting, DNS records, TLS cert info ──
|
|
2
|
+
import dns from 'node:dns/promises';
|
|
3
|
+
import tls from 'node:tls';
|
|
4
|
+
|
|
5
|
+
export function detectPlatform({ html = '', headers = {}, scriptSrcs = [], cookieNames = [], siteUrl = '' }) {
|
|
6
|
+
const h = html.toLowerCase();
|
|
7
|
+
const get = (name) => (headers.get ? headers.get(name) : headers[name]) || '';
|
|
8
|
+
const server = String(get('server') || '').toLowerCase();
|
|
9
|
+
const poweredBy = String(get('x-powered-by') || '').toLowerCase();
|
|
10
|
+
const scripts = scriptSrcs.join(' ').toLowerCase();
|
|
11
|
+
const cookies = cookieNames.join(' ').toLowerCase();
|
|
12
|
+
|
|
13
|
+
const signals = [];
|
|
14
|
+
const add = (platform, confidence, reason) => signals.push({ platform, confidence, reason });
|
|
15
|
+
|
|
16
|
+
// wp-content/wp-includes are literal upload/asset paths — they can show up in a page's HTML
|
|
17
|
+
// purely because it links to an image/script hosted on a *different* WordPress site (a blog
|
|
18
|
+
// subdomain, an embedded widget, etc). Only treat them as "this site is WordPress" when the
|
|
19
|
+
// reference is same-origin; a cross-origin match is reported separately so it doesn't get
|
|
20
|
+
// mistaken for this host's own platform (and doesn't trigger false hybrid-architecture alerts).
|
|
21
|
+
let siteHost = '';
|
|
22
|
+
try { siteHost = new URL(siteUrl).hostname.toLowerCase(); } catch { /* no siteUrl given */ }
|
|
23
|
+
const isSameOriginRef = (urlStr) => {
|
|
24
|
+
if (!urlStr) return true;
|
|
25
|
+
if (urlStr.startsWith('/') && !urlStr.startsWith('//')) return true; // root-relative path
|
|
26
|
+
try {
|
|
27
|
+
return !siteHost || new URL(urlStr, `https://${siteHost}`).hostname.toLowerCase() === siteHost;
|
|
28
|
+
} catch { return true; }
|
|
29
|
+
};
|
|
30
|
+
const wpPathMatches = html.match(/[^\s"'<>]*\/wp-(?:content|includes)\/[^\s"'<>]*/gi) || [];
|
|
31
|
+
const wpSameOriginHtml = wpPathMatches.some(isSameOriginRef);
|
|
32
|
+
const wpCrossOriginMatches = wpPathMatches.filter((m) => !isSameOriginRef(m));
|
|
33
|
+
const wpSameOriginScript = scriptSrcs.some((s) => /\/wp-content\//i.test(s) && isSameOriginRef(s));
|
|
34
|
+
const hasGeneratorTag = h.includes('name="generator" content="wordpress');
|
|
35
|
+
|
|
36
|
+
if (wpSameOriginHtml || wpSameOriginScript || hasGeneratorTag) {
|
|
37
|
+
add('WordPress', 'high', 'wp-content/wp-includes paths or generator meta tag found.');
|
|
38
|
+
} else if (wpCrossOriginMatches.length) {
|
|
39
|
+
const externalHosts = [...new Set(wpCrossOriginMatches.map((m) => {
|
|
40
|
+
try { return new URL(m, `https://${siteHost || 'example.com'}`).hostname; } catch { return null; }
|
|
41
|
+
}).filter(Boolean))];
|
|
42
|
+
add('WordPress (external reference)', 'info', `Page links to wp-content/wp-includes assets hosted on a different domain (${externalHosts.join(', ') || 'unknown host'}) — likely a separate WordPress site (e.g. a blog subdomain), not this scanned host.`);
|
|
43
|
+
}
|
|
44
|
+
if (h.includes('/sites/default/files') || h.includes('drupal.settings') || cookies.includes('has_js') || h.includes('drupal-')) {
|
|
45
|
+
add('Drupal', 'high', 'Drupal-specific paths, settings object, or cookie found.');
|
|
46
|
+
}
|
|
47
|
+
if (h.includes('/media/jui/') || h.includes('joomla') || cookies.includes('joomla')) {
|
|
48
|
+
add('Joomla', 'medium', 'Joomla-specific paths or cookie found.');
|
|
49
|
+
}
|
|
50
|
+
if (h.includes('cdn.shopify.com') || scripts.includes('cdn.shopify.com') || cookies.includes('_shopify')) {
|
|
51
|
+
add('Shopify', 'high', 'Shopify CDN asset references or cookie found.');
|
|
52
|
+
}
|
|
53
|
+
if (h.includes('static.wixstatic.com') || scripts.includes('wixstatic') || h.includes('wix.com')) {
|
|
54
|
+
add('Wix', 'high', 'Wix static asset host referenced.');
|
|
55
|
+
}
|
|
56
|
+
if (h.includes('assets.squarespace.com') || scripts.includes('squarespace')) {
|
|
57
|
+
add('Squarespace', 'high', 'Squarespace asset host referenced.');
|
|
58
|
+
}
|
|
59
|
+
if (h.includes('webflow.js') || h.includes('assets.website-files.com') || h.includes('data-wf-page')) {
|
|
60
|
+
add('Webflow', 'high', 'Webflow runtime script or data-wf-page attribute found.');
|
|
61
|
+
}
|
|
62
|
+
if (h.includes('/skin/frontend/') || h.includes('mage/cookies') || cookies.includes('frontend')) {
|
|
63
|
+
add('Magento', 'medium', 'Magento-specific asset paths or cookie found.');
|
|
64
|
+
}
|
|
65
|
+
if (server.includes('cloudflare') && !signals.length) {
|
|
66
|
+
// Cloudflare is hosting/CDN, not a CMS signal — handled separately.
|
|
67
|
+
}
|
|
68
|
+
if (h.includes('__next') || h.includes('/_next/static/')) add('Next.js (React)', 'high', '_next/static asset paths found.');
|
|
69
|
+
if (h.includes('data-reactroot') || h.includes('__nuxt')) add('Nuxt/React SPA', 'medium', 'SPA framework hydration markers found.');
|
|
70
|
+
|
|
71
|
+
const xRuntime = get('x-runtime');
|
|
72
|
+
const hasRailsCsrfTags = /name=["']csrf-param["']/.test(h) && /name=["']csrf-token["']/.test(h);
|
|
73
|
+
const hasAuthenticityTokenField = /name=["']authenticity_token["']/.test(h);
|
|
74
|
+
const hasRailsSessionCookie = cookieNames.some((n) => /^_[\w-]+_session$/i.test(n));
|
|
75
|
+
if (xRuntime || hasRailsCsrfTags || hasAuthenticityTokenField || hasRailsSessionCookie) {
|
|
76
|
+
const strongSignal = xRuntime || hasRailsCsrfTags;
|
|
77
|
+
add('Ruby on Rails', strongSignal ? 'high' : 'medium', [
|
|
78
|
+
xRuntime && 'X-Runtime header present',
|
|
79
|
+
hasRailsCsrfTags && 'csrf-param/csrf-token meta tags found',
|
|
80
|
+
hasAuthenticityTokenField && 'authenticity_token form field found',
|
|
81
|
+
hasRailsSessionCookie && 'Rails-style _appname_session cookie found',
|
|
82
|
+
].filter(Boolean).join('; ') + '.');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
signals.sort((a, b) => (b.confidence === 'high') - (a.confidence === 'high'));
|
|
86
|
+
return signals.length ? signals : [{ platform: 'Unknown/Custom', confidence: 'low', reason: 'No known CMS/platform signature matched.' }];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function detectHosting(headers = {}) {
|
|
90
|
+
const get = (name) => String((headers.get ? headers.get(name) : headers[name]) || '');
|
|
91
|
+
const server = get('server').toLowerCase();
|
|
92
|
+
const signals = [];
|
|
93
|
+
if (get('cf-ray') || server.includes('cloudflare')) signals.push({ provider: 'Cloudflare', via: 'cf-ray header / Server banner' });
|
|
94
|
+
if (get('x-vercel-id')) signals.push({ provider: 'Vercel', via: 'x-vercel-id header' });
|
|
95
|
+
if (get('x-nf-request-id') || get('x-nf-render-mode')) signals.push({ provider: 'Netlify', via: 'x-nf-* header' });
|
|
96
|
+
if (get('x-served-by')?.toLowerCase().includes('fastly') || get('via')?.toLowerCase().includes('fastly')) signals.push({ provider: 'Fastly', via: 'x-served-by/via header' });
|
|
97
|
+
if (server.includes('amazons3')) signals.push({ provider: 'AWS S3', via: 'Server banner' });
|
|
98
|
+
if (get('x-amz-cf-id')) signals.push({ provider: 'AWS CloudFront', via: 'x-amz-cf-id header' });
|
|
99
|
+
if (server.includes('apache')) signals.push({ provider: 'Apache (origin server)', via: 'Server banner' });
|
|
100
|
+
if (server.includes('nginx')) signals.push({ provider: 'nginx (origin server)', via: 'Server banner' });
|
|
101
|
+
if (get('x-github-request-id')) signals.push({ provider: 'GitHub Pages', via: 'x-github-request-id header' });
|
|
102
|
+
if (get('x-pantheon-styx-hostname')) signals.push({ provider: 'Pantheon', via: 'x-pantheon header' });
|
|
103
|
+
if (get('x-wpe-token') || server.includes('wpengine')) signals.push({ provider: 'WP Engine', via: 'header/Server banner' });
|
|
104
|
+
return signals;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function lookupDnsRecords(hostname) {
|
|
108
|
+
const out = { a: [], aaaa: [], mx: [], ns: [], txt: [], error: null };
|
|
109
|
+
try { out.a = await dns.resolve4(hostname); } catch {}
|
|
110
|
+
try { out.aaaa = await dns.resolve6(hostname); } catch {}
|
|
111
|
+
try { out.mx = (await dns.resolveMx(hostname)).map((m) => `${m.exchange} (pri ${m.priority})`); } catch {}
|
|
112
|
+
try { out.ns = await dns.resolveNs(hostname); } catch {}
|
|
113
|
+
try { out.txt = (await dns.resolveTxt(hostname)).map((t) => t.join('')); } catch {}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function getTlsCertInfo(hostname, port = 443, timeoutMs = 8000) {
|
|
118
|
+
return new Promise((resolve) => {
|
|
119
|
+
const socket = tls.connect({ host: hostname, port, servername: hostname, timeout: timeoutMs, rejectUnauthorized: false }, () => {
|
|
120
|
+
const cert = socket.getPeerCertificate?.();
|
|
121
|
+
const protocol = socket.getProtocol?.();
|
|
122
|
+
const authorized = socket.authorized;
|
|
123
|
+
const authError = socket.authorizationError;
|
|
124
|
+
socket.end();
|
|
125
|
+
if (!cert || !Object.keys(cert).length) {
|
|
126
|
+
resolve({ ok: false, error: 'No certificate returned' });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const now = new Date();
|
|
130
|
+
const validTo = new Date(cert.valid_to);
|
|
131
|
+
const daysRemaining = Math.round((validTo - now) / (1000 * 60 * 60 * 24));
|
|
132
|
+
resolve({
|
|
133
|
+
ok: true,
|
|
134
|
+
protocol,
|
|
135
|
+
authorized,
|
|
136
|
+
authError: authorized ? null : String(authError || ''),
|
|
137
|
+
issuer: cert.issuer?.O || cert.issuer?.CN || 'unknown',
|
|
138
|
+
subject: cert.subject?.CN || hostname,
|
|
139
|
+
validFrom: cert.valid_from,
|
|
140
|
+
validTo: cert.valid_to,
|
|
141
|
+
daysRemaining,
|
|
142
|
+
isExpired: daysRemaining < 0,
|
|
143
|
+
isExpiringSoon: daysRemaining >= 0 && daysRemaining < 21,
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
socket.on('error', (e) => resolve({ ok: false, error: String(e?.message || e) }));
|
|
147
|
+
socket.setTimeout(timeoutMs, () => { socket.destroy(); resolve({ ok: false, error: 'timeout' }); });
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const WEAK_TLS_PROTOCOLS = new Set(['TLSv1', 'TLSv1.1', 'SSLv3', 'SSLv2']);
|
|
152
|
+
export function isWeakTlsProtocol(protocol) {
|
|
153
|
+
return WEAK_TLS_PROTOCOLS.has(protocol);
|
|
154
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// ── Live GraphQL introspection: a single, standard, read-only introspection query (the same
|
|
2
|
+
// query GraphiQL/Apollo DevTools send on load) — not a crafted exploit, just asking the API "what's
|
|
3
|
+
// your schema", which the GraphQL spec defines as an optional but common capability. If it's on,
|
|
4
|
+
// the response itself is the finding: it hands over every type, field, and mutation name for free. ──
|
|
5
|
+
|
|
6
|
+
const INTROSPECTION_QUERY = `
|
|
7
|
+
query IntrospectionQuery {
|
|
8
|
+
__schema {
|
|
9
|
+
queryType { name }
|
|
10
|
+
mutationType { name }
|
|
11
|
+
types { name kind fields { name } }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
`;
|
|
15
|
+
|
|
16
|
+
const SENSITIVE_NAME_RE = /user|admin|password|token|secret|apikey|api_key|credential|session|delete|remove|destroy|payment|card|billing/i;
|
|
17
|
+
|
|
18
|
+
export async function introspectGraphQL(url, { timeoutMs = 10000 } = {}) {
|
|
19
|
+
const controller = new AbortController();
|
|
20
|
+
const t = setTimeout(() => controller.abort(), timeoutMs);
|
|
21
|
+
try {
|
|
22
|
+
const res = await fetch(url, {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
headers: { 'content-type': 'application/json', accept: 'application/json', 'user-agent': 'Universal-Security-Audit' },
|
|
25
|
+
body: JSON.stringify({ query: INTROSPECTION_QUERY, operationName: 'IntrospectionQuery' }),
|
|
26
|
+
signal: controller.signal,
|
|
27
|
+
});
|
|
28
|
+
clearTimeout(t);
|
|
29
|
+
if (!res.ok) return { checked: true, introspectionEnabled: false, status: res.status };
|
|
30
|
+
const data = await res.json().catch(() => null);
|
|
31
|
+
const schema = data?.data?.__schema;
|
|
32
|
+
if (!schema) return { checked: true, introspectionEnabled: false, status: res.status };
|
|
33
|
+
|
|
34
|
+
const types = schema.types || [];
|
|
35
|
+
const typeCount = types.length;
|
|
36
|
+
const mutationTypeName = schema.mutationType?.name || null;
|
|
37
|
+
const mutationType = mutationTypeName ? types.find((t) => t.name === mutationTypeName) : null;
|
|
38
|
+
const mutationFieldNames = (mutationType?.fields || []).map((f) => f.name);
|
|
39
|
+
const sensitiveTypeNames = types.filter((t) => t.name && SENSITIVE_NAME_RE.test(t.name)).map((t) => t.name);
|
|
40
|
+
const sensitiveMutations = mutationFieldNames.filter((n) => SENSITIVE_NAME_RE.test(n));
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
checked: true,
|
|
44
|
+
introspectionEnabled: true,
|
|
45
|
+
typeCount,
|
|
46
|
+
hasMutations: !!mutationTypeName,
|
|
47
|
+
mutationFieldNames,
|
|
48
|
+
sensitiveTypeNames: [...new Set(sensitiveTypeNames)],
|
|
49
|
+
sensitiveMutations: [...new Set(sensitiveMutations)],
|
|
50
|
+
};
|
|
51
|
+
} catch (e) {
|
|
52
|
+
clearTimeout(t);
|
|
53
|
+
return { checked: false, error: String(e?.message || e) };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// ── Multi-server / reverse-proxy topology signals. Everything here is either read from headers
|
|
2
|
+
// already being collected elsewhere, or a handful of repeated plain GETs to the homepage (the same
|
|
3
|
+
// request a browser reload does) — no scanning beyond what's already in scope. ──
|
|
4
|
+
|
|
5
|
+
// Fires a few repeated requests to the same URL and compares response headers that commonly differ
|
|
6
|
+
// between backend instances behind a load balancer (Server, X-Powered-By, Via, X-Served-By,
|
|
7
|
+
// X-Request-Id-style instance identifiers). Consistent headers don't prove a single server (a
|
|
8
|
+
// well-configured fleet looks identical on purpose), but *inconsistent* headers are a solid signal
|
|
9
|
+
// of multiple distinct backends answering.
|
|
10
|
+
const INSTANCE_HEADER_NAMES = ['server', 'x-powered-by', 'via', 'x-served-by', 'x-backend-server', 'x-instance-id'];
|
|
11
|
+
|
|
12
|
+
export async function checkResponseConsistency(url, { attempts = 4, delayMs = 300, timeoutMs = 8000 } = {}) {
|
|
13
|
+
const samples = [];
|
|
14
|
+
for (let i = 0; i < attempts; i++) {
|
|
15
|
+
try {
|
|
16
|
+
const controller = new AbortController();
|
|
17
|
+
const t = setTimeout(() => controller.abort(), timeoutMs);
|
|
18
|
+
const res = await fetch(url, { headers: { 'user-agent': 'Universal-Security-Audit', 'cache-control': 'no-cache' }, signal: controller.signal });
|
|
19
|
+
clearTimeout(t);
|
|
20
|
+
const sample = {};
|
|
21
|
+
for (const name of INSTANCE_HEADER_NAMES) sample[name] = res.headers.get(name) || '';
|
|
22
|
+
samples.push(sample);
|
|
23
|
+
} catch { /* one failed attempt doesn't invalidate the others */ }
|
|
24
|
+
if (i < attempts - 1 && delayMs) await new Promise((r) => setTimeout(r, delayMs));
|
|
25
|
+
}
|
|
26
|
+
if (samples.length < 2) return { checked: false, samples };
|
|
27
|
+
|
|
28
|
+
const variations = [];
|
|
29
|
+
for (const name of INSTANCE_HEADER_NAMES) {
|
|
30
|
+
const values = new Set(samples.map((s) => s[name]).filter(Boolean));
|
|
31
|
+
if (values.size > 1) variations.push({ header: name, values: [...values] });
|
|
32
|
+
}
|
|
33
|
+
return { checked: true, attempts: samples.length, variations, multiServerLikely: variations.length > 0 };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// A single response can hint at a reverse proxy: a `Via` header is the standard, explicit signal;
|
|
37
|
+
// seeing both a CDN/edge signal (Cloudflare, Fastly, etc.) AND an origin-server signal (nginx/Apache
|
|
38
|
+
// in the same Server header, or a distinct one) in one response also implies a proxy layer in front
|
|
39
|
+
// of an origin, even without a Via header (many CDNs don't add one by default).
|
|
40
|
+
export function checkReverseProxyIndicators(headers = {}, hostingSignals = []) {
|
|
41
|
+
const get = (name) => String((headers.get ? headers.get(name) : headers[name]) || '');
|
|
42
|
+
const via = get('via');
|
|
43
|
+
const cdnProviders = new Set(['Cloudflare', 'Vercel', 'Netlify', 'Fastly', 'AWS CloudFront', 'GitHub Pages']);
|
|
44
|
+
const originProviders = new Set(['Apache (origin server)', 'nginx (origin server)']);
|
|
45
|
+
const hasCdnSignal = hostingSignals.some((h) => cdnProviders.has(h.provider));
|
|
46
|
+
const hasOriginSignal = hostingSignals.some((h) => originProviders.has(h.provider));
|
|
47
|
+
const likelyProxied = !!via || (hasCdnSignal && hasOriginSignal);
|
|
48
|
+
return {
|
|
49
|
+
hasVia: !!via,
|
|
50
|
+
viaValue: via || null,
|
|
51
|
+
hasCdnSignal,
|
|
52
|
+
hasOriginSignal,
|
|
53
|
+
likelyProxied,
|
|
54
|
+
note: likelyProxied
|
|
55
|
+
? `Traffic passes through a reverse proxy/CDN before reaching the origin${via ? ` (Via: ${via})` : hasCdnSignal && hasOriginSignal ? ` (${[...hostingSignals.map((h) => h.provider)].join(' + ')})` : ''}.`
|
|
56
|
+
: 'No reverse-proxy indicator found in this response (many proxies are transparent and won\'t show up this way).',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// More than one A record with no recognized CDN in front is a reasonable hint of DNS-level load
|
|
61
|
+
// balancing across the site's own servers (CDNs also return multiple IPs, but those are edge nodes,
|
|
62
|
+
// not the client's own infrastructure — already accounted for separately via hostingSignals).
|
|
63
|
+
export function checkDnsLoadBalancingHint(dnsRecords = {}, hostingSignals = []) {
|
|
64
|
+
const aCount = (dnsRecords.a || []).length;
|
|
65
|
+
const hasCdn = hostingSignals.some((h) => ['Cloudflare', 'Vercel', 'Netlify', 'Fastly', 'AWS CloudFront'].includes(h.provider));
|
|
66
|
+
return {
|
|
67
|
+
multipleARecords: aCount > 1,
|
|
68
|
+
likelyOwnLoadBalancing: aCount > 1 && !hasCdn,
|
|
69
|
+
aRecordCount: aCount,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// ── Single dial controlling concurrency/delay/probe-list-size across every active check, so the
|
|
2
|
+
// speed-vs-footprint tradeoff is one explicit flag (--intensity) instead of a dozen separate ones. ──
|
|
3
|
+
import { INTENSITY_PORT_PROFILES } from './port-scan.mjs';
|
|
4
|
+
|
|
5
|
+
const PROFILES = {
|
|
6
|
+
light: {
|
|
7
|
+
exposedPathConcurrency: 3, exposedPathDelayMs: 400,
|
|
8
|
+
portScan: INTENSITY_PORT_PROFILES.light,
|
|
9
|
+
subdomainLivenessLimit: 5,
|
|
10
|
+
},
|
|
11
|
+
normal: {
|
|
12
|
+
exposedPathConcurrency: 4, exposedPathDelayMs: 150,
|
|
13
|
+
portScan: INTENSITY_PORT_PROFILES.normal,
|
|
14
|
+
subdomainLivenessLimit: 15,
|
|
15
|
+
},
|
|
16
|
+
aggressive: {
|
|
17
|
+
exposedPathConcurrency: 10, exposedPathDelayMs: 50,
|
|
18
|
+
portScan: INTENSITY_PORT_PROFILES.aggressive,
|
|
19
|
+
subdomainLivenessLimit: 40,
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function resolveIntensity(name) {
|
|
24
|
+
const key = String(name || 'normal').toLowerCase();
|
|
25
|
+
if (!PROFILES[key]) {
|
|
26
|
+
console.error(`Warning: unknown --intensity "${name}", falling back to "normal". Valid values: light, normal, aggressive.`);
|
|
27
|
+
return PROFILES.normal;
|
|
28
|
+
}
|
|
29
|
+
return PROFILES[key];
|
|
30
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// ── CORS misconfig, mixed-content, and public AI/SEO-crawler exposure checks ──
|
|
2
|
+
|
|
3
|
+
export async function checkCorsMisconfig(url, { timeoutMs = 8000 } = {}) {
|
|
4
|
+
const probeOrigin = 'https://security-audit-cors-probe.invalid';
|
|
5
|
+
try {
|
|
6
|
+
const controller = new AbortController();
|
|
7
|
+
const t = setTimeout(() => controller.abort(), timeoutMs);
|
|
8
|
+
const res = await fetch(url, { headers: { origin: probeOrigin, 'user-agent': 'Universal-Security-Audit' }, signal: controller.signal });
|
|
9
|
+
clearTimeout(t);
|
|
10
|
+
const allowOrigin = res.headers.get('access-control-allow-origin');
|
|
11
|
+
const allowCreds = res.headers.get('access-control-allow-credentials');
|
|
12
|
+
const reflectsArbitraryOrigin = allowOrigin === probeOrigin || allowOrigin === '*';
|
|
13
|
+
const misconfigured = reflectsArbitraryOrigin && String(allowCreds).toLowerCase() === 'true';
|
|
14
|
+
return {
|
|
15
|
+
checked: true,
|
|
16
|
+
allowOrigin,
|
|
17
|
+
allowCreds,
|
|
18
|
+
reflectsArbitraryOrigin,
|
|
19
|
+
misconfigured,
|
|
20
|
+
note: misconfigured
|
|
21
|
+
? 'Reflects arbitrary Origin AND allows credentials — any site can make authenticated cross-origin requests on behalf of visiting users.'
|
|
22
|
+
: reflectsArbitraryOrigin
|
|
23
|
+
? 'Reflects/wildcards Origin but does not allow credentials (lower risk).'
|
|
24
|
+
: 'No permissive CORS reflection detected.',
|
|
25
|
+
};
|
|
26
|
+
} catch (e) {
|
|
27
|
+
return { checked: false, error: String(e?.message || e) };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function findMixedContent(html = '', pageUrl = '') {
|
|
32
|
+
if (!/^https:/i.test(pageUrl)) return { applicable: false, refs: [] };
|
|
33
|
+
const refs = new Set();
|
|
34
|
+
for (const m of html.matchAll(/(?:src|href)=["']http:\/\/([^"']+)["']/gi)) refs.add('http://' + m[1]);
|
|
35
|
+
return { applicable: true, refs: [...refs] };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// `sitemapInfo` (optional) is the result already computed by Phase 1's URL discovery
|
|
39
|
+
// ({ sitemapUrl, robotsDeclaredSitemaps, sitemapDeclaredInRobots }) — passed in so this function
|
|
40
|
+
// doesn't redundantly re-guess sitemap paths and risk reporting a different answer than what the
|
|
41
|
+
// rest of the run actually used.
|
|
42
|
+
export async function summarizeCrawlerExposure(origin, sitemapInfo = null) {
|
|
43
|
+
const fetchText = async (p) => {
|
|
44
|
+
try {
|
|
45
|
+
const res = await fetch(new URL(p, origin).toString(), { headers: { 'user-agent': 'Universal-Security-Audit' } });
|
|
46
|
+
return res.ok ? await res.text() : null;
|
|
47
|
+
} catch { return null; }
|
|
48
|
+
};
|
|
49
|
+
const robots = await fetchText('/robots.txt');
|
|
50
|
+
const llms = await fetchText('/llms.txt');
|
|
51
|
+
|
|
52
|
+
const disallowedPaths = robots
|
|
53
|
+
? [...robots.matchAll(/^disallow:\s*(.+)$/gim)].map((m) => m[1].trim()).filter((p) => p && p !== '/')
|
|
54
|
+
: [];
|
|
55
|
+
const sensitiveLookingDisallows = disallowedPaths.filter((p) => /admin|login|wp-|config|backup|private|internal|staging|test|debug/i.test(p));
|
|
56
|
+
const robotsDeclaredSitemaps = robots ? [...robots.matchAll(/^sitemap:\s*(\S+)$/gim)].map((m) => m[1].trim()) : [];
|
|
57
|
+
|
|
58
|
+
const hasSitemap = sitemapInfo ? !!sitemapInfo.sitemapUrl : null;
|
|
59
|
+
const sitemapDeclaredInRobots = sitemapInfo ? !!sitemapInfo.sitemapDeclaredInRobots : robotsDeclaredSitemaps.length > 0;
|
|
60
|
+
const sitemapUndeclaredButReachable = hasSitemap && robotsDeclaredSitemaps.length > 0 && !sitemapDeclaredInRobots;
|
|
61
|
+
const sitemapDeclaredButUnreachable = robotsDeclaredSitemaps.length > 0 && !hasSitemap;
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
hasRobots: !!robots,
|
|
65
|
+
hasLlmsTxt: !!llms,
|
|
66
|
+
hasSitemap,
|
|
67
|
+
sitemapUrl: sitemapInfo?.sitemapUrl || null,
|
|
68
|
+
robotsDeclaredSitemaps,
|
|
69
|
+
sitemapDeclaredInRobots,
|
|
70
|
+
sitemapUndeclaredButReachable,
|
|
71
|
+
sitemapDeclaredButUnreachable,
|
|
72
|
+
disallowedPathCount: disallowedPaths.length,
|
|
73
|
+
sensitiveLookingDisallows,
|
|
74
|
+
note: sensitiveLookingDisallows.length
|
|
75
|
+
? `robots.txt Disallow rules name ${sensitiveLookingDisallows.length} sensitive-looking path(s) — this is public and tells both search engines AND attackers/AI scrapers where to look, even though it doesn't block direct requests.`
|
|
76
|
+
: 'No obviously sensitive path names found in robots.txt Disallow rules.',
|
|
77
|
+
};
|
|
78
|
+
}
|