@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.
Files changed (37) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +412 -2
  3. package/auth-config.example.json +12 -0
  4. package/bin/universal-security-audit.mjs +129 -0
  5. package/branding.example.json +10 -0
  6. package/package.json +60 -4
  7. package/scripts/compare-security-runs.mjs +94 -0
  8. package/scripts/generate-baseline.mjs +66 -0
  9. package/scripts/generate-security-report.mjs +99 -0
  10. package/scripts/generate-security-sarif.mjs +93 -0
  11. package/scripts/generate-security-tickets.mjs +126 -0
  12. package/scripts/lib/baseline.mjs +37 -0
  13. package/scripts/lib/drupal-admin-audit.mjs +84 -0
  14. package/scripts/lib/exposed-paths.mjs +111 -0
  15. package/scripts/lib/fingerprint.mjs +154 -0
  16. package/scripts/lib/graphql-introspect.mjs +55 -0
  17. package/scripts/lib/infra-topology.mjs +71 -0
  18. package/scripts/lib/intensity.mjs +30 -0
  19. package/scripts/lib/misc-checks.mjs +78 -0
  20. package/scripts/lib/pentest-recon.mjs +215 -0
  21. package/scripts/lib/pii-payment.mjs +79 -0
  22. package/scripts/lib/port-scan.mjs +72 -0
  23. package/scripts/lib/proxy-support.mjs +14 -0
  24. package/scripts/lib/report-builder.mjs +113 -0
  25. package/scripts/lib/scoring.mjs +44 -0
  26. package/scripts/lib/script-inventory.mjs +131 -0
  27. package/scripts/lib/security-headers.mjs +115 -0
  28. package/scripts/lib/security-txt.mjs +69 -0
  29. package/scripts/lib/subdomain-enum.mjs +64 -0
  30. package/scripts/lib/tls-enum.mjs +49 -0
  31. package/scripts/lib/vuln-lookup.mjs +118 -0
  32. package/scripts/lib/whois-lookup.mjs +44 -0
  33. package/scripts/lib/wp-admin-audit.mjs +102 -0
  34. package/scripts/lib/wp-plugin-directory.mjs +58 -0
  35. package/scripts/lib/wp-rest-audit.mjs +59 -0
  36. package/scripts/run-batch-audit.mjs +111 -0
  37. package/scripts/run-security-audit.mjs +1146 -0
@@ -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
+ }