@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.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +435 -2
  3. package/auth-config.example.json +12 -0
  4. package/bin/universal-security-audit.mjs +134 -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/authorization.mjs +54 -0
  13. package/scripts/lib/baseline.mjs +37 -0
  14. package/scripts/lib/drupal-admin-audit.mjs +84 -0
  15. package/scripts/lib/exposed-paths.mjs +111 -0
  16. package/scripts/lib/fingerprint.mjs +154 -0
  17. package/scripts/lib/graphql-introspect.mjs +55 -0
  18. package/scripts/lib/infra-topology.mjs +71 -0
  19. package/scripts/lib/intensity.mjs +30 -0
  20. package/scripts/lib/misc-checks.mjs +78 -0
  21. package/scripts/lib/pentest-recon.mjs +220 -0
  22. package/scripts/lib/pii-payment.mjs +79 -0
  23. package/scripts/lib/port-scan.mjs +72 -0
  24. package/scripts/lib/proxy-support.mjs +14 -0
  25. package/scripts/lib/report-builder.mjs +113 -0
  26. package/scripts/lib/scoring.mjs +44 -0
  27. package/scripts/lib/script-inventory.mjs +131 -0
  28. package/scripts/lib/security-headers.mjs +115 -0
  29. package/scripts/lib/security-txt.mjs +69 -0
  30. package/scripts/lib/subdomain-enum.mjs +64 -0
  31. package/scripts/lib/tls-enum.mjs +49 -0
  32. package/scripts/lib/vuln-lookup.mjs +118 -0
  33. package/scripts/lib/whois-lookup.mjs +44 -0
  34. package/scripts/lib/wordfence.mjs +112 -0
  35. package/scripts/lib/wp-admin-audit.mjs +102 -0
  36. package/scripts/lib/wp-plugin-directory.mjs +58 -0
  37. package/scripts/lib/wp-rest-audit.mjs +59 -0
  38. package/scripts/run-batch-audit.mjs +111 -0
  39. package/scripts/run-security-audit.mjs +1192 -0
@@ -0,0 +1,131 @@
1
+ // ── Third-party script/library inventory + CMS core & plugin version detection ──
2
+
3
+ // Recognize common CDN/library filename conventions, e.g. "jquery-3.6.0.min.js", "bootstrap.min.js?ver=4.6.0"
4
+ const VERSION_IN_FILENAME_RE = /([a-z][a-z0-9.\-]*?)[-.]v?(\d+\.\d+(?:\.\d+)?)(?:\.min)?\.js/i;
5
+ const KNOWN_LIB_HOST_HINTS = [
6
+ { re: /jquery(?!-ui)/i, name: 'jquery' },
7
+ { re: /jquery-ui/i, name: 'jquery-ui' },
8
+ { re: /bootstrap/i, name: 'bootstrap' },
9
+ { re: /lodash/i, name: 'lodash' },
10
+ { re: /underscore/i, name: 'underscore' },
11
+ { re: /angular(?!js)/i, name: 'angular' },
12
+ { re: /angular(?:js)?[.\-]/i, name: 'angularjs' },
13
+ { re: /react-dom/i, name: 'react-dom' },
14
+ { re: /react(?!-dom)/i, name: 'react' },
15
+ { re: /vue(?:\.runtime)?[.\-]/i, name: 'vue' },
16
+ { re: /moment/i, name: 'moment' },
17
+ { re: /handlebars/i, name: 'handlebars' },
18
+ { re: /swiper/i, name: 'swiper' },
19
+ { re: /slick/i, name: 'slick' },
20
+ { re: /fancybox/i, name: 'fancybox' },
21
+ { re: /font-awesome|fontawesome/i, name: 'font-awesome' },
22
+ { re: /gsap|tweenmax|tweenlite/i, name: 'gsap' },
23
+ { re: /d3(?:\.v\d+)?[.\-]/i, name: 'd3' },
24
+ { re: /chart(?:\.min)?\.js/i, name: 'chart.js' },
25
+ { re: /select2/i, name: 'select2' },
26
+ { re: /axios/i, name: 'axios' },
27
+ ];
28
+
29
+ export function extractInventoryFromHtml(html = '', baseUrl) {
30
+ const scripts = [...html.matchAll(/<script[^>]+src=["']([^"']+)["']/gi)].map((m) => m[1]);
31
+ const styles = [...html.matchAll(/<link[^>]+href=["']([^"']+)["'][^>]*rel=["']stylesheet["']/gi)].map((m) => m[1]);
32
+ const generator = /<meta[^>]+name=["']generator["'][^>]+content=["']([^"']+)["']/i.exec(html)?.[1] || '';
33
+ const absolutize = (u) => { try { return new URL(u, baseUrl).toString(); } catch { return u; } };
34
+ return {
35
+ scriptUrls: scripts.map(absolutize),
36
+ styleUrls: styles.map(absolutize),
37
+ generator,
38
+ };
39
+ }
40
+
41
+ function versionFromPathSegment(url) {
42
+ // Catches CDN conventions like cdnjs.cloudflare.com/ajax/libs/bootstrap/4.6.0/js/bootstrap.min.js
43
+ // where the version is its own path segment rather than embedded in the filename or query string.
44
+ try {
45
+ const segments = new URL(url).pathname.split('/').filter(Boolean);
46
+ const semverSegment = segments.find((s) => /^\d+\.\d+(?:\.\d+)?$/.test(s));
47
+ return semverSegment || null;
48
+ } catch { return null; }
49
+ }
50
+
51
+ export function identifyLibraries(urls = []) {
52
+ const found = new Map();
53
+ for (const url of urls) {
54
+ let filename;
55
+ try { filename = new URL(url).pathname.split('/').pop() || ''; } catch { filename = url; }
56
+ const verMatch = VERSION_IN_FILENAME_RE.exec(filename);
57
+ const queryVer = /[?&](?:ver|version|v)=([\d.]+)/i.exec(url)?.[1];
58
+ for (const hint of KNOWN_LIB_HOST_HINTS) {
59
+ if (hint.re.test(filename) || hint.re.test(url)) {
60
+ const version = verMatch?.[2] || queryVer || versionFromPathSegment(url) || null;
61
+ const key = hint.name;
62
+ if (!found.has(key) || (version && !found.get(key).version)) {
63
+ found.set(key, { name: hint.name, version, sourceUrl: url });
64
+ }
65
+ break;
66
+ }
67
+ }
68
+ }
69
+ return [...found.values()];
70
+ }
71
+
72
+ export function extractWpPluginsAndThemes(urls = []) {
73
+ const plugins = new Map();
74
+ const themes = new Map();
75
+ for (const url of urls) {
76
+ const pluginMatch = /\/wp-content\/plugins\/([^/]+)\//i.exec(url);
77
+ const themeMatch = /\/wp-content\/themes\/([^/]+)\//i.exec(url);
78
+ const queryVer = /[?&](?:ver|version)=([\d.]+)/i.exec(url)?.[1] || null;
79
+ if (pluginMatch) {
80
+ const slug = pluginMatch[1];
81
+ if (!plugins.has(slug) || (queryVer && !plugins.get(slug).version)) plugins.set(slug, { slug, version: queryVer, sourceUrl: url });
82
+ }
83
+ if (themeMatch) {
84
+ const slug = themeMatch[1];
85
+ if (!themes.has(slug) || (queryVer && !themes.get(slug).version)) themes.set(slug, { slug, version: queryVer, sourceUrl: url });
86
+ }
87
+ }
88
+ return { plugins: [...plugins.values()], themes: [...themes.values()] };
89
+ }
90
+
91
+ async function safeFetchText(url, timeoutMs = 6000) {
92
+ try {
93
+ const controller = new AbortController();
94
+ const t = setTimeout(() => controller.abort(), timeoutMs);
95
+ const res = await fetch(url, { signal: controller.signal, headers: { 'user-agent': 'Universal-Security-Audit' } });
96
+ clearTimeout(t);
97
+ if (!res.ok) return null;
98
+ return await res.text();
99
+ } catch { return null; }
100
+ }
101
+
102
+ export async function detectWpCoreVersion(origin, html, generator) {
103
+ const genMatch = /WordPress\s+([\d.]+)/i.exec(generator || '');
104
+ if (genMatch) return { version: genMatch[1], source: 'generator meta tag' };
105
+ const emojiMatch = /wp-emoji-release\.min\.js\?ver=([\d.]+)/i.exec(html || '');
106
+ if (emojiMatch) return { version: emojiMatch[1], source: 'wp-emoji script query param' };
107
+ const readme = await safeFetchText(new URL('/readme.html', origin).toString());
108
+ if (readme) {
109
+ const m = /<br\s*\/?>\s*Version\s+([\d.]+)/i.exec(readme) || /Version\s+([\d.]+)/i.exec(readme);
110
+ if (m) return { version: m[1], source: '/readme.html' };
111
+ }
112
+ return { version: null, source: null };
113
+ }
114
+
115
+ export async function fetchWpPluginReadmeVersion(origin, slug) {
116
+ const readme = await safeFetchText(new URL(`/wp-content/plugins/${slug}/readme.txt`, origin).toString());
117
+ if (!readme) return null;
118
+ const m = /Stable tag:\s*([\d.]+)/i.exec(readme);
119
+ return m ? m[1] : null;
120
+ }
121
+
122
+ export async function detectDrupalCoreVersion(origin, html, generator) {
123
+ const genMatch = /Drupal\s+([\d.]+)/i.exec(generator || '');
124
+ if (genMatch) return { version: genMatch[1], source: 'generator meta tag' };
125
+ const changelog = await safeFetchText(new URL('/CHANGELOG.txt', origin).toString());
126
+ if (changelog) {
127
+ const m = /^Drupal\s+([\d.]+),/im.exec(changelog);
128
+ if (m) return { version: m[1], source: '/CHANGELOG.txt' };
129
+ }
130
+ return { version: null, source: null };
131
+ }
@@ -0,0 +1,115 @@
1
+ // ── Security response-header grading, modeled on securityheaders.com / Mozilla Observatory methodology ──
2
+
3
+ function headerVal(headers, name) {
4
+ const v = headers.get?.(name) ?? headers[name] ?? headers[name.toLowerCase()];
5
+ return v == null ? null : String(v);
6
+ }
7
+
8
+ export function gradeCookies(setCookieHeaders = []) {
9
+ const findings = [];
10
+ for (const raw of setCookieHeaders) {
11
+ const name = raw.split('=')[0]?.trim() || 'cookie';
12
+ const lower = raw.toLowerCase();
13
+ const issues = [];
14
+ if (!lower.includes('secure')) issues.push('missing Secure');
15
+ if (!lower.includes('httponly')) issues.push('missing HttpOnly');
16
+ if (!lower.includes('samesite')) issues.push('missing SameSite');
17
+ if (issues.length) findings.push({ name, issues });
18
+ }
19
+ return findings;
20
+ }
21
+
22
+ export function gradeSecurityHeaders(headers, { isHttps = true } = {}) {
23
+ const get = (name) => headerVal(headers, name);
24
+ const checks = [];
25
+ let score = 100;
26
+
27
+ const csp = get('content-security-policy');
28
+ if (!csp) {
29
+ checks.push({ header: 'Content-Security-Policy', status: 'missing', severity: 'high', note: 'No CSP set — no defense-in-depth against XSS/injection.' });
30
+ score -= 20;
31
+ } else if (/unsafe-inline|unsafe-eval/i.test(csp)) {
32
+ checks.push({ header: 'Content-Security-Policy', status: 'weak', severity: 'medium', note: `Present but allows 'unsafe-inline'/'unsafe-eval'.` });
33
+ score -= 8;
34
+ } else {
35
+ checks.push({ header: 'Content-Security-Policy', status: 'present', severity: 'ok', note: 'Present.' });
36
+ }
37
+
38
+ const hsts = get('strict-transport-security');
39
+ if (isHttps && !hsts) {
40
+ checks.push({ header: 'Strict-Transport-Security', status: 'missing', severity: 'high', note: 'HTTPS site without HSTS — vulnerable to protocol downgrade/SSL-stripping.' });
41
+ score -= 15;
42
+ } else if (hsts) {
43
+ const maxAge = Number(/max-age=(\d+)/i.exec(hsts)?.[1] || 0);
44
+ if (maxAge < 15552000) {
45
+ checks.push({ header: 'Strict-Transport-Security', status: 'weak', severity: 'low', note: `max-age is only ${maxAge}s (recommended >= 6 months).` });
46
+ score -= 4;
47
+ } else {
48
+ checks.push({ header: 'Strict-Transport-Security', status: 'present', severity: 'ok', note: 'Present with adequate max-age.' });
49
+ }
50
+ }
51
+
52
+ const xfo = get('x-frame-options');
53
+ const cspFrameAncestors = /frame-ancestors/i.test(csp || '');
54
+ if (!xfo && !cspFrameAncestors) {
55
+ checks.push({ header: 'X-Frame-Options', status: 'missing', severity: 'medium', note: 'No clickjacking protection (no X-Frame-Options or CSP frame-ancestors).' });
56
+ score -= 10;
57
+ } else {
58
+ checks.push({ header: 'X-Frame-Options / frame-ancestors', status: 'present', severity: 'ok', note: 'Clickjacking protection present.' });
59
+ }
60
+
61
+ const xcto = get('x-content-type-options');
62
+ if (!xcto || !/nosniff/i.test(xcto)) {
63
+ checks.push({ header: 'X-Content-Type-Options', status: 'missing', severity: 'low', note: 'MIME-sniffing not disabled.' });
64
+ score -= 6;
65
+ } else {
66
+ checks.push({ header: 'X-Content-Type-Options', status: 'present', severity: 'ok', note: 'nosniff set.' });
67
+ }
68
+
69
+ const refPol = get('referrer-policy');
70
+ if (!refPol) {
71
+ checks.push({ header: 'Referrer-Policy', status: 'missing', severity: 'low', note: 'No referrer policy — full URLs (possibly with tokens) may leak to third parties.' });
72
+ score -= 6;
73
+ } else {
74
+ checks.push({ header: 'Referrer-Policy', status: 'present', severity: 'ok', note: `Set to "${refPol}".` });
75
+ }
76
+
77
+ const permPol = get('permissions-policy');
78
+ if (!permPol) {
79
+ checks.push({ header: 'Permissions-Policy', status: 'missing', severity: 'low', note: 'No restriction on powerful browser features (camera, mic, geolocation, etc).' });
80
+ score -= 5;
81
+ } else {
82
+ checks.push({ header: 'Permissions-Policy', status: 'present', severity: 'ok', note: 'Present.' });
83
+ }
84
+
85
+ const coop = get('cross-origin-opener-policy');
86
+ if (!coop) {
87
+ checks.push({ header: 'Cross-Origin-Opener-Policy', status: 'missing', severity: 'low', note: 'No COOP — window.opener-based attacks (e.g. tabnabbing) not mitigated.' });
88
+ score -= 4;
89
+ } else {
90
+ checks.push({ header: 'Cross-Origin-Opener-Policy', status: 'present', severity: 'ok', note: 'Present.' });
91
+ }
92
+
93
+ const corp = get('cross-origin-resource-policy');
94
+ if (!corp) {
95
+ checks.push({ header: 'Cross-Origin-Resource-Policy', status: 'missing', severity: 'info', note: 'Not set (defense-in-depth for cross-origin resource embedding).' });
96
+ score -= 2;
97
+ } else {
98
+ checks.push({ header: 'Cross-Origin-Resource-Policy', status: 'present', severity: 'ok', note: 'Present.' });
99
+ }
100
+
101
+ const serverHeader = get('server');
102
+ const poweredBy = get('x-powered-by');
103
+ if (serverHeader && /\d/.test(serverHeader)) {
104
+ checks.push({ header: 'Server', status: 'disclosure', severity: 'low', note: `Reveals version info: "${serverHeader}".` });
105
+ score -= 3;
106
+ }
107
+ if (poweredBy) {
108
+ checks.push({ header: 'X-Powered-By', status: 'disclosure', severity: 'low', note: `Reveals stack info: "${poweredBy}".` });
109
+ score -= 3;
110
+ }
111
+
112
+ score = Math.max(0, Math.min(100, score));
113
+ const grade = score >= 95 ? 'A+' : score >= 85 ? 'A' : score >= 75 ? 'B' : score >= 60 ? 'C' : score >= 40 ? 'D' : 'F';
114
+ return { score, grade, checks };
115
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * security.txt (RFC 9116): how researchers report vulnerabilities to you.
3
+ *
4
+ * Looks for /.well-known/security.txt (the RFC's location), falling back to
5
+ * /security.txt (legacy), and validates what the RFC requires:
6
+ * - Contact: at least one (mailto:, https: or tel: URI)
7
+ * - Expires: exactly one, an ISO 8601 date in the future (the RFC
8
+ * recommends less than a year ahead)
9
+ * - served over HTTPS as text/plain
10
+ * - Canonical, if present, should include the URL it was fetched from
11
+ * and notes the optional fields (Policy, Encryption, Acknowledgments,
12
+ * Preferred-Languages, Hiring) and whether it's PGP-signed.
13
+ */
14
+ export async function checkSecurityTxt(origin, { timeoutMs = 8000, now = new Date() } = {}) {
15
+ const candidates = ['/.well-known/security.txt', '/security.txt'];
16
+ for (const path of candidates) {
17
+ const url = new URL(path, origin).toString();
18
+ let res;
19
+ let text = '';
20
+ try {
21
+ const ctrl = new AbortController();
22
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
23
+ res = await fetch(url, { redirect: 'follow', signal: ctrl.signal, headers: { 'user-agent': 'Universal-Security-Audit security.txt check' } });
24
+ clearTimeout(timer);
25
+ text = res.ok ? await res.text() : '';
26
+ } catch {
27
+ continue;
28
+ }
29
+ if (!res.ok || !text.trim() || /<html|<!doctype/i.test(text.slice(0, 200))) continue;
30
+ return { found: true, url, legacyLocation: path === '/security.txt', ...validate(text, url, res, now) };
31
+ }
32
+ return { found: false, url: new URL(candidates[0], origin).toString(), problems: [], warnings: [], fields: {} };
33
+ }
34
+
35
+ function validate(text, url, res, now) {
36
+ const signed = text.includes('-----BEGIN PGP SIGNED MESSAGE-----');
37
+ const fields = {};
38
+ for (const raw of text.split(/\r?\n/)) {
39
+ const line = raw.trim();
40
+ if (!line || line.startsWith('#') || line.startsWith('-----')) continue;
41
+ const i = line.indexOf(':');
42
+ if (i < 1) continue;
43
+ const key = line.slice(0, i).trim().toLowerCase();
44
+ (fields[key] ||= []).push(line.slice(i + 1).trim());
45
+ }
46
+ const problems = [];
47
+ const warnings = [];
48
+ const contacts = fields.contact || [];
49
+ if (!contacts.length) problems.push('No Contact field (required): add at least one, e.g. "Contact: mailto:security@example.com".');
50
+ for (const c of contacts) {
51
+ if (!/^(mailto:|https:|tel:)/i.test(c)) problems.push(`Contact "${c}" isn't a mailto:, https: or tel: URI.`);
52
+ }
53
+ const expires = fields.expires || [];
54
+ if (!expires.length) problems.push('No Expires field (required): add one, e.g. "Expires: 2027-06-30T00:00:00Z".');
55
+ else if (expires.length > 1) problems.push('More than one Expires field (only one is allowed).');
56
+ else {
57
+ const when = new Date(expires[0]);
58
+ if (Number.isNaN(when.getTime())) problems.push(`Expires "${expires[0]}" isn't a valid ISO 8601 date.`);
59
+ else if (when <= now) problems.push(`Expired on ${when.toISOString().slice(0, 10)}: researchers are told to treat the file as stale. Update Expires.`);
60
+ else if (when - now > 366 * 86400000) warnings.push(`Expires is more than a year ahead (${when.toISOString().slice(0, 10)}); the RFC recommends under a year so the file gets reviewed.`);
61
+ }
62
+ if (!url.startsWith('https:')) problems.push('Served over plain HTTP; security.txt must be served over HTTPS.');
63
+ const type = res.headers.get('content-type') || '';
64
+ if (!/^text\/plain/i.test(type)) warnings.push(`Content-Type is "${type || 'missing'}"; it should be text/plain; charset=utf-8.`);
65
+ const canonical = fields.canonical || [];
66
+ if (canonical.length && !canonical.some((c) => c.replace(/\/$/, '') === url.replace(/\/$/, ''))) warnings.push(`Canonical (${canonical.join(', ')}) doesn't list the URL it was served from (${url}).`);
67
+ if (!fields.policy) warnings.push('No Policy field: link to a vulnerability disclosure policy so researchers know the rules.');
68
+ return { problems, warnings, signed, fields: Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, v.join(' | ')])) };
69
+ }
@@ -0,0 +1,64 @@
1
+ // ── Passive subdomain enumeration via crt.sh (certificate-transparency log search). Purely reads
2
+ // public CT log data — no active DNS brute force. Optional liveness check is a single HEAD request
3
+ // per discovered host, capped by --intensity. ──
4
+ import dns from 'node:dns/promises';
5
+
6
+ // crt.sh is a free community service that frequently returns 502/503 under load or takes 10-20s+ to
7
+ // respond — this is normal for it, not a sign anything is wrong. Retry with backoff before giving up.
8
+ export async function enumerateSubdomainsCrtSh(domain, { timeoutMs = 20000, retries = 2 } = {}) {
9
+ let lastError = null;
10
+ for (let attempt = 0; attempt <= retries; attempt++) {
11
+ const controller = new AbortController();
12
+ const t = setTimeout(() => controller.abort(), timeoutMs);
13
+ try {
14
+ const res = await fetch(`https://crt.sh/?q=%25.${encodeURIComponent(domain)}&output=json`, {
15
+ headers: { 'user-agent': 'Universal-Security-Audit', accept: 'application/json' },
16
+ signal: controller.signal,
17
+ });
18
+ clearTimeout(t);
19
+ if (!res.ok) { lastError = `HTTP ${res.status}`; }
20
+ else {
21
+ const data = await res.json().catch(() => []);
22
+ const names = new Set();
23
+ for (const entry of data) {
24
+ for (const raw of String(entry.name_value || '').split('\n')) {
25
+ const name = raw.trim().toLowerCase().replace(/^\*\./, '');
26
+ if (name.endsWith(domain) && name !== domain && !name.includes(' ')) names.add(name);
27
+ }
28
+ }
29
+ return { checked: true, subdomains: [...names].sort() };
30
+ }
31
+ } catch (e) {
32
+ clearTimeout(t);
33
+ lastError = String(e?.message || e);
34
+ }
35
+ if (attempt < retries) await new Promise((r) => setTimeout(r, 2000 * (attempt + 1)));
36
+ }
37
+ return { checked: false, error: lastError, subdomains: [] };
38
+ }
39
+
40
+ export async function checkLiveness(hostnames = [], { limit = 15, concurrency = 8, timeoutMs = 5000, onProgress = null } = {}) {
41
+ const queue = hostnames.slice(0, limit);
42
+ const total = queue.length;
43
+ const results = [];
44
+ async function worker() {
45
+ while (queue.length) {
46
+ const host = queue.shift();
47
+ let resolved = false;
48
+ try { await dns.resolve(host); resolved = true; } catch {}
49
+ if (!resolved) { results.push({ host, alive: false, resolved: false }); if (onProgress) onProgress(results.length, total, { host, alive: false }); continue; }
50
+ try {
51
+ const controller = new AbortController();
52
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
53
+ const res = await fetch(`https://${host}/`, { method: 'HEAD', redirect: 'manual', signal: controller.signal, headers: { 'user-agent': 'Universal-Security-Audit' } });
54
+ clearTimeout(timer);
55
+ results.push({ host, alive: true, resolved: true, status: res.status });
56
+ } catch {
57
+ results.push({ host, alive: false, resolved: true });
58
+ }
59
+ if (onProgress) onProgress(results.length, total, results[results.length - 1]);
60
+ }
61
+ }
62
+ await Promise.all(Array.from({ length: Math.min(concurrency, queue.length || 1) }, worker));
63
+ return results;
64
+ }
@@ -0,0 +1,49 @@
1
+ // ── TLS protocol-version enumeration: actually attempts a handshake pinned to each protocol
2
+ // version, rather than only reporting whatever the server negotiated on one default connection.
3
+ // Node's bundled OpenSSL refuses to even attempt TLSv1/TLSv1.1 by default (SECLEVEL restriction) —
4
+ // `@SECLEVEL=0` in the cipher string lifts that CLIENT-side restriction so the server's real answer
5
+ // comes through. Forcing individually weak cipher suites (RC4/3DES/NULL/export-grade) is NOT
6
+ // included: modern Node's OpenSSL build has those compiled out entirely ("no cipher match" even
7
+ // with SECLEVEL=0), so we cannot reliably ask the server that question from this client at all —
8
+ // claiming to test for them would risk a false "not vulnerable" reading. ──
9
+ import tls from 'node:tls';
10
+
11
+ const PROTOCOLS = ['TLSv1', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3'];
12
+ const LEGACY_PROTOCOLS = new Set(['TLSv1', 'TLSv1.1']);
13
+
14
+ function probeProtocol(host, port, protocol, timeoutMs) {
15
+ return new Promise((resolve) => {
16
+ let settled = false;
17
+ let socket;
18
+ try {
19
+ socket = tls.connect({
20
+ host, port, servername: host, minVersion: protocol, maxVersion: protocol,
21
+ rejectUnauthorized: false, timeout: timeoutMs, ciphers: 'DEFAULT@SECLEVEL=0',
22
+ });
23
+ } catch (e) {
24
+ resolve({ protocol, supported: false, testable: true, error: String(e?.message || e) });
25
+ return;
26
+ }
27
+ const finish = (supported, error) => {
28
+ if (settled) return;
29
+ settled = true;
30
+ socket.destroy();
31
+ resolve({ protocol, supported, testable: true, error: error || null });
32
+ };
33
+ socket.setTimeout(timeoutMs, () => finish(false, 'timeout'));
34
+ socket.once('secureConnect', () => finish(true, null));
35
+ socket.once('error', (e) => finish(false, String(e?.message || e)));
36
+ });
37
+ }
38
+
39
+ export async function enumerateTlsProtocols(host, { port = 443, timeoutMs = 6000 } = {}) {
40
+ const results = [];
41
+ for (const protocol of PROTOCOLS) {
42
+ results.push(await probeProtocol(host, port, protocol, timeoutMs));
43
+ }
44
+ return results;
45
+ }
46
+
47
+ export function getWeakProtocolFindings(protocolResults = []) {
48
+ return protocolResults.filter((r) => r.supported && LEGACY_PROTOCOLS.has(r.protocol));
49
+ }
@@ -0,0 +1,118 @@
1
+ // ── Live vulnerability data sources: OSV.dev (npm-ecosystem JS libs) + WPScan (WordPress core/plugins/themes) ──
2
+
3
+ const osvCache = new Map();
4
+ const wpscanCache = new Map();
5
+
6
+ // Map detected front-end library names to their npm package name for OSV lookups.
7
+ const NPM_ECOSYSTEM_NAMES = {
8
+ jquery: 'jquery',
9
+ 'jquery-ui': 'jquery-ui',
10
+ bootstrap: 'bootstrap',
11
+ lodash: 'lodash',
12
+ underscore: 'underscore',
13
+ angular: '@angular/core',
14
+ angularjs: 'angular',
15
+ react: 'react',
16
+ 'react-dom': 'react-dom',
17
+ vue: 'vue',
18
+ moment: 'moment',
19
+ handlebars: 'handlebars',
20
+ swiper: 'swiper',
21
+ slick: 'slick-carousel',
22
+ fancybox: '@fancyapps/fancybox',
23
+ 'font-awesome': 'font-awesome',
24
+ gsap: 'gsap',
25
+ d3: 'd3',
26
+ chartjs: 'chart.js',
27
+ 'chart.js': 'chart.js',
28
+ select2: 'select2',
29
+ axios: 'axios',
30
+ };
31
+
32
+ export function npmPackageNameFor(libName) {
33
+ return NPM_ECOSYSTEM_NAMES[String(libName || '').toLowerCase()] || null;
34
+ }
35
+
36
+ export async function osvLookup(packageName, version) {
37
+ if (!packageName || !version) return { checked: false, vulns: [] };
38
+ const key = `${packageName}@${version}`;
39
+ if (osvCache.has(key)) return osvCache.get(key);
40
+ const result = { checked: true, vulns: [], error: null };
41
+ try {
42
+ const res = await fetch('https://api.osv.dev/v1/query', {
43
+ method: 'POST',
44
+ headers: { 'content-type': 'application/json' },
45
+ body: JSON.stringify({ version, package: { name: packageName, ecosystem: 'npm' } }),
46
+ });
47
+ if (res.ok) {
48
+ const data = await res.json();
49
+ result.vulns = (data.vulns || []).map((v) => ({
50
+ id: v.id,
51
+ summary: v.summary || v.details?.slice(0, 200) || '',
52
+ severity: (v.severity || []).map((s) => s.score).join(', ') || (v.database_specific?.severity || ''),
53
+ url: v.references?.find((r) => r.type === 'ADVISORY' || r.type === 'WEB')?.url || `https://osv.dev/vulnerability/${v.id}`,
54
+ }));
55
+ } else {
56
+ result.error = `HTTP ${res.status}`;
57
+ }
58
+ } catch (e) {
59
+ result.error = String(e?.message || e);
60
+ }
61
+ osvCache.set(key, result);
62
+ return result;
63
+ }
64
+
65
+ function wpscanHeaders(apiKey) {
66
+ return apiKey ? { Authorization: `Token token=${apiKey}` } : {};
67
+ }
68
+
69
+ async function wpscanGet(pathSegment, apiKey) {
70
+ const key = pathSegment;
71
+ if (wpscanCache.has(key)) return wpscanCache.get(key);
72
+ const result = { checked: true, vulns: [], error: null, rateLimited: false };
73
+ try {
74
+ const res = await fetch(`https://wpscan.com/api/v3/${pathSegment}`, { headers: wpscanHeaders(apiKey) });
75
+ if (res.status === 429) {
76
+ result.error = 'rate_limited';
77
+ result.rateLimited = true;
78
+ } else if (res.status === 401 || res.status === 403) {
79
+ result.error = 'unauthorized (check WPSCAN_API_KEY)';
80
+ } else if (res.status === 404) {
81
+ // Not found = no known record for this slug/version; not an error.
82
+ } else if (res.ok) {
83
+ const data = await res.json();
84
+ for (const entry of Object.values(data)) {
85
+ for (const v of entry.vulnerabilities || []) {
86
+ result.vulns.push({
87
+ id: v.id,
88
+ title: v.title,
89
+ fixedIn: v.fixed_in || null,
90
+ references: v.references?.url || v.references || [],
91
+ });
92
+ }
93
+ }
94
+ } else {
95
+ result.error = `HTTP ${res.status}`;
96
+ }
97
+ } catch (e) {
98
+ result.error = String(e?.message || e);
99
+ }
100
+ wpscanCache.set(key, result);
101
+ return result;
102
+ }
103
+
104
+ export async function wpscanCoreLookup(version, apiKey) {
105
+ if (!version) return { checked: false, vulns: [] };
106
+ const normalized = version.replace(/\./g, '');
107
+ return wpscanGet(`wordpresses/${normalized}`, apiKey);
108
+ }
109
+
110
+ export async function wpscanPluginLookup(slug, apiKey) {
111
+ if (!slug) return { checked: false, vulns: [] };
112
+ return wpscanGet(`plugins/${encodeURIComponent(slug)}`, apiKey);
113
+ }
114
+
115
+ export async function wpscanThemeLookup(slug, apiKey) {
116
+ if (!slug) return { checked: false, vulns: [] };
117
+ return wpscanGet(`themes/${encodeURIComponent(slug)}`, apiKey);
118
+ }
@@ -0,0 +1,44 @@
1
+ // ── Domain registration lookup via RDAP (the modern, HTTP/JSON successor to WHOIS). Uses rdap.org's
2
+ // bootstrap redirector so we never need to know which registry serves a given TLD, and never need
3
+ // to shell out to a system `whois` binary that may not exist on the host (containers, Windows, etc). ──
4
+
5
+ function findValue(entities, role, key) {
6
+ const entity = (entities || []).find((e) => (e.roles || []).includes(role));
7
+ return entity?.vcardArray?.[1]?.find((f) => f[0] === key)?.[3] || null;
8
+ }
9
+
10
+ export async function rdapLookup(domain, { timeoutMs = 10000 } = {}) {
11
+ const controller = new AbortController();
12
+ const t = setTimeout(() => controller.abort(), timeoutMs);
13
+ try {
14
+ const res = await fetch(`https://rdap.org/domain/${encodeURIComponent(domain)}`, {
15
+ headers: { 'user-agent': 'Universal-Security-Audit', accept: 'application/rdap+json' },
16
+ signal: controller.signal,
17
+ redirect: 'follow',
18
+ });
19
+ clearTimeout(t);
20
+ if (!res.ok) return { checked: false, error: `HTTP ${res.status}` };
21
+ const data = await res.json();
22
+ const events = data.events || [];
23
+ const registration = events.find((e) => e.eventAction === 'registration')?.eventDate || null;
24
+ const expiration = events.find((e) => e.eventAction === 'expiration')?.eventDate || null;
25
+ const lastChanged = events.find((e) => e.eventAction === 'last changed')?.eventDate || null;
26
+ const registrar = findValue(data.entities, 'registrar', 'fn') || data.entities?.find((e) => (e.roles || []).includes('registrar'))?.handle || null;
27
+ const registrantOrg = findValue(data.entities, 'registrant', 'org') || findValue(data.entities, 'registrant', 'fn');
28
+ const nameservers = (data.nameservers || []).map((ns) => ns.ldhName).filter(Boolean);
29
+ const statuses = data.status || [];
30
+
31
+ let daysUntilExpiration = null;
32
+ if (expiration) daysUntilExpiration = Math.round((new Date(expiration) - Date.now()) / (1000 * 60 * 60 * 24));
33
+
34
+ return {
35
+ checked: true, registrar, registrantOrg: registrantOrg || null, registration, expiration, lastChanged,
36
+ nameservers, statuses, daysUntilExpiration,
37
+ isExpiringSoon: daysUntilExpiration != null && daysUntilExpiration >= 0 && daysUntilExpiration < 30,
38
+ isExpired: daysUntilExpiration != null && daysUntilExpiration < 0,
39
+ };
40
+ } catch (e) {
41
+ clearTimeout(t);
42
+ return { checked: false, error: String(e?.message || e) };
43
+ }
44
+ }