@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,215 @@
1
+ // ── Lightweight recon-phase checks: everything here is a single passive DNS lookup or a single
2
+ // unauthenticated GET/OPTIONS request (redirect: 'manual' where relevant so nothing is actually
3
+ // followed/executed). No brute force, no destructive HTTP verbs (PUT/DELETE are never sent — only
4
+ // introspected via OPTIONS), no fuzzing. This is the same class of recon a search engine crawler,
5
+ // browser, or automated security scanner (Mozilla Observatory, Qualys, etc.) performs routinely. ──
6
+ import dns from 'node:dns/promises';
7
+
8
+ const UA = 'Universal-Security-Audit';
9
+
10
+ async function safeGet(url, opts = {}) {
11
+ const controller = new AbortController();
12
+ const t = setTimeout(() => controller.abort(), opts.timeoutMs || 8000);
13
+ try {
14
+ const res = await fetch(url, { redirect: 'manual', headers: { 'user-agent': UA }, ...opts, signal: controller.signal });
15
+ clearTimeout(t);
16
+ return res;
17
+ } catch (e) {
18
+ clearTimeout(t);
19
+ return null;
20
+ }
21
+ }
22
+
23
+ // ── Subdomain takeover: dangling CNAMEs pointing at unclaimed cloud resources ──
24
+ const TAKEOVER_FINGERPRINTS = [
25
+ { host: /\.github\.io$/i, service: 'GitHub Pages', probe: /there isn.t a github pages site here/i },
26
+ { host: /\.herokuapp\.com$/i, service: 'Heroku', probe: /no such app/i },
27
+ { host: /\.s3[.-][\w-]*\.amazonaws\.com$/i, service: 'AWS S3', probe: /nosuchbucket/i },
28
+ { host: /\.azurewebsites\.net$/i, service: 'Azure App Service', probe: /404 web site not found/i },
29
+ { host: /\.surge\.sh$/i, service: 'Surge.sh', probe: /project not found/i },
30
+ { host: /\.bitbucket\.io$/i, service: 'Bitbucket Pages', probe: /repository not found/i },
31
+ { host: /\.ghost\.io$/i, service: 'Ghost', probe: /the thing you were looking for is no longer here/i },
32
+ { host: /\.pantheonsite\.io$/i, service: 'Pantheon', probe: /404 error unknown site/i },
33
+ { host: /\.wpengine\.com$/i, service: 'WP Engine', probe: /this site does not exist/i },
34
+ { host: /\.fastly\.net$/i, service: 'Fastly', probe: /fastly error: unknown domain/i },
35
+ { host: /\.myshopify\.com$/i, service: 'Shopify', probe: /sorry, this shop is currently unavailable/i },
36
+ { host: /\.zendesk\.com$/i, service: 'Zendesk', probe: /help center closed/i },
37
+ { host: /\.webflow\.io$/i, service: 'Webflow', probe: /the page you are looking for doesn.t exist/i },
38
+ { host: /\.netlify\.app$/i, service: 'Netlify', probe: /not found - request id/i },
39
+ ];
40
+
41
+ export async function checkSubdomainTakeover(apexOrHostname) {
42
+ const findings = [];
43
+ const candidates = new Set([apexOrHostname]);
44
+ const apex = apexOrHostname.split('.').slice(-2).join('.');
45
+ for (const sub of ['www', 'dev', 'staging', 'test', 'api', 'cdn', 'shop', 'blog', 'mail', 'admin', 'app']) {
46
+ candidates.add(`${sub}.${apex}`);
47
+ }
48
+ for (const host of candidates) {
49
+ let cname;
50
+ try { cname = (await dns.resolveCname(host))[0]; } catch { continue; }
51
+ if (!cname) continue;
52
+ const fp = TAKEOVER_FINGERPRINTS.find((f) => f.host.test(cname));
53
+ if (!fp) continue;
54
+ const res = await safeGet(`http://${host}/`, { redirect: 'follow' });
55
+ if (!res) continue;
56
+ const body = await res.text().catch(() => '');
57
+ if (fp.probe.test(body)) {
58
+ findings.push({ host, cname, service: fp.service, note: `${host} CNAMEs to ${cname} (${fp.service}) which returns an "unclaimed resource" page — possible subdomain takeover.` });
59
+ }
60
+ }
61
+ return findings;
62
+ }
63
+
64
+ // ── HTTP methods introspection (no PUT/DELETE/TRACE actually sent — OPTIONS only) ──
65
+ const RISKY_METHODS = ['PUT', 'DELETE', 'TRACE', 'CONNECT'];
66
+ export async function checkHttpMethods(url) {
67
+ const res = await safeGet(url, { method: 'OPTIONS' });
68
+ if (!res) return { checked: false };
69
+ const allow = res.headers.get('allow') || '';
70
+ const methods = allow.split(',').map((m) => m.trim().toUpperCase()).filter(Boolean);
71
+ const risky = methods.filter((m) => RISKY_METHODS.includes(m));
72
+ return { checked: true, methods, risky };
73
+ }
74
+
75
+ // ── Open redirect: canary param probing (Location header inspected, never followed) ──
76
+ const REDIRECT_PARAMS = ['redirect', 'redirect_uri', 'redirect_url', 'url', 'next', 'return', 'returnTo', 'return_to', 'dest', 'destination', 'continue', 'r'];
77
+ const CANARY = 'https://open-redirect-canary.invalid/';
78
+ export async function checkOpenRedirect(origin) {
79
+ const findings = [];
80
+ for (const param of REDIRECT_PARAMS) {
81
+ const testUrl = `${origin}/?${param}=${encodeURIComponent(CANARY)}`;
82
+ const res = await safeGet(testUrl);
83
+ if (!res) continue;
84
+ if (res.status >= 300 && res.status < 400) {
85
+ const loc = res.headers.get('location') || '';
86
+ if (loc.startsWith(CANARY) || loc.includes('open-redirect-canary.invalid')) {
87
+ findings.push({ param, testUrl, location: loc });
88
+ }
89
+ }
90
+ }
91
+ return findings;
92
+ }
93
+
94
+ // ── Missing Subresource Integrity on cross-origin scripts/styles ──
95
+ export function checkMissingSri(html = '', pageOrigin) {
96
+ const missing = [];
97
+ const scriptTags = [...html.matchAll(/<script\b([^>]*)>/gi)];
98
+ const linkTags = [...html.matchAll(/<link\b([^>]*rel=["']stylesheet["'][^>]*)>/gi)];
99
+ const check = (attrs, urlAttr) => {
100
+ const srcMatch = new RegExp(`${urlAttr}=["']([^"']+)["']`, 'i').exec(attrs);
101
+ if (!srcMatch) return;
102
+ const url = srcMatch[1];
103
+ let isCrossOrigin = false;
104
+ try { isCrossOrigin = new URL(url, pageOrigin).origin !== pageOrigin; } catch { return; }
105
+ if (!isCrossOrigin) return;
106
+ if (!/integrity=/i.test(attrs)) missing.push(url);
107
+ };
108
+ for (const [, attrs] of scriptTags) check(attrs, 'src');
109
+ for (const [, attrs] of linkTags) check(attrs, 'href');
110
+ return [...new Set(missing)];
111
+ }
112
+
113
+ // ── Exposed .js.map source maps (source-code disclosure) ──
114
+ export async function checkExposedSourceMaps(scriptUrls = [], { limit = 8 } = {}) {
115
+ const found = [];
116
+ const jsUrls = [...new Set(scriptUrls.filter((u) => /\.js(\?|$)/i.test(u)))].slice(0, limit);
117
+ for (const url of jsUrls) {
118
+ const mapUrl = url.replace(/(\?.*)?$/, '').replace(/\.js$/i, '.js.map');
119
+ const res = await safeGet(mapUrl, { method: 'GET' });
120
+ if (res && res.status >= 200 && res.status < 300) {
121
+ const text = await res.text().catch(() => '');
122
+ if (text.trim().startsWith('{') && /"sources"/.test(text)) found.push(mapUrl);
123
+ }
124
+ }
125
+ return found;
126
+ }
127
+
128
+ // ── Email-spoofing defense: SPF (from already-fetched TXT records) + DMARC (separate DNS query) ──
129
+ export async function checkEmailAuthRecords(hostname, txtRecords = []) {
130
+ const hasSpf = txtRecords.some((t) => /^v=spf1/i.test(t));
131
+ let hasDmarc = false, dmarcPolicy = null;
132
+ try {
133
+ const dmarcTxt = (await dns.resolveTxt(`_dmarc.${hostname}`)).map((t) => t.join(''));
134
+ const rec = dmarcTxt.find((t) => /^v=DMARC1/i.test(t));
135
+ if (rec) {
136
+ hasDmarc = true;
137
+ dmarcPolicy = /p=(\w+)/i.exec(rec)?.[1] || null;
138
+ }
139
+ } catch {}
140
+ return { hasSpf, hasDmarc, dmarcPolicy };
141
+ }
142
+
143
+ // ── WordPress user enumeration via ?author=N (classic recon technique, in addition to wp-json) ──
144
+ export async function enumerateWpAuthors(origin, { maxId = 5 } = {}) {
145
+ const found = [];
146
+ for (let id = 1; id <= maxId; id++) {
147
+ const res = await safeGet(`${origin}/?author=${id}`);
148
+ if (!res) continue;
149
+ const loc = res.headers.get('location') || '';
150
+ const m = /\/author\/([^/]+)\/?$/i.exec(loc);
151
+ if (m) found.push({ id, username: decodeURIComponent(m[1]) });
152
+ }
153
+ return found;
154
+ }
155
+
156
+ // ── Login forms that submit over plaintext HTTP (credentials sent in cleartext) ──
157
+ export function checkInsecureLoginForms(html = '', pageUrl = '') {
158
+ const findings = [];
159
+ const pageIsHttp = /^http:\/\//i.test(pageUrl);
160
+ const forms = [...html.matchAll(/<form\b([^>]*)>([\s\S]*?)<\/form>/gi)];
161
+ for (const [, attrs, body] of forms) {
162
+ if (!/type=["']password["']/i.test(body)) continue;
163
+ const actionMatch = /action=["']([^"']*)["']/i.exec(attrs);
164
+ const action = actionMatch?.[1] || '';
165
+ const actionIsHttp = /^http:\/\//i.test(action);
166
+ if (pageIsHttp || actionIsHttp) {
167
+ findings.push({ pageUrl, action: action || '(same page)', reason: pageIsHttp ? 'page served over plain HTTP' : 'form action is an http:// URL' });
168
+ }
169
+ }
170
+ return findings;
171
+ }
172
+
173
+ // ── Verbose error / stack-trace disclosure ──
174
+ const STACK_TRACE_PATTERNS = [
175
+ /Fatal error:.*on line \d+/i,
176
+ /Warning:.*in <b>.*<\/b> on line/i,
177
+ /Stack trace:/i,
178
+ /at System\.\w+/,
179
+ /Microsoft \.NET Framework/i,
180
+ /ORA-\d{5}/,
181
+ /SQLSTATE\[/i,
182
+ /Unhandled Exception/i,
183
+ /Traceback \(most recent call last\)/i,
184
+ /org\.apache\.catalina/i,
185
+ /Whoops,? looks like something went wrong/i,
186
+ ];
187
+ export async function checkVerboseErrors(origin) {
188
+ const probePath = `/__usa-nonexistent-${Date.now()}__`;
189
+ const res = await safeGet(`${origin}${probePath}`, { redirect: 'follow' });
190
+ if (!res) return { checked: false };
191
+ const body = await res.text().catch(() => '');
192
+ const matched = STACK_TRACE_PATTERNS.filter((re) => re.test(body));
193
+ return { checked: true, status: res.status, leaksStackTrace: matched.length > 0, sample: matched.length ? body.slice(0, 300) : '' };
194
+ }
195
+
196
+ // ── JWT cookies with weak/no signature enforcement or no expiry ──
197
+ function base64UrlDecode(str) {
198
+ const s = str.replace(/-/g, '+').replace(/_/g, '/').padEnd(str.length + (4 - (str.length % 4 || 4)) % 4, '=');
199
+ return Buffer.from(s, 'base64').toString('utf8');
200
+ }
201
+ export function scanCookiesForJwt(cookies = []) {
202
+ const findings = [];
203
+ for (const ck of cookies) {
204
+ const val = ck.value || '';
205
+ if (!/^[\w-]+\.[\w-]+\.[\w-]*$/.test(val)) continue;
206
+ const [headerB64, payloadB64] = val.split('.');
207
+ let header, payload;
208
+ try { header = JSON.parse(base64UrlDecode(headerB64)); payload = JSON.parse(base64UrlDecode(payloadB64)); } catch { continue; }
209
+ const issues = [];
210
+ if (String(header.alg || '').toLowerCase() === 'none') issues.push('alg is "none" — signature not enforced');
211
+ if (!payload.exp) issues.push('no "exp" claim — token never expires');
212
+ if (issues.length) findings.push({ cookieName: ck.name, alg: header.alg, issues });
213
+ }
214
+ return findings;
215
+ }
@@ -0,0 +1,79 @@
1
+ // ── PII exposure scanning + payment/donation processor detection ──
2
+
3
+ const EMAIL_RE = /[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/gi;
4
+ const PHONE_RE = /(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/g;
5
+ const SSN_RE = /\b\d{3}-\d{2}-\d{4}\b/g;
6
+ // Loose 13-19 digit sequences with optional separators, then Luhn-validated to cut false positives.
7
+ const CC_CANDIDATE_RE = /\b(?:\d[ -]?){13,19}\b/g;
8
+
9
+ const SECRET_PATTERNS = [
10
+ { name: 'AWS Access Key ID', re: /\bAKIA[0-9A-Z]{16}\b/g },
11
+ { name: 'Stripe live secret key', re: /\bsk_live_[0-9a-zA-Z]{16,}\b/g },
12
+ { name: 'Stripe live publishable key', re: /\bpk_live_[0-9a-zA-Z]{16,}\b/g },
13
+ { name: 'Google API key', re: /\bAIza[0-9A-Za-z\-_]{35}\b/g },
14
+ { name: 'Slack token', re: /\bxox[baprs]-[0-9A-Za-z-]{10,}\b/g },
15
+ { name: 'Generic private key block', re: /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/g },
16
+ { name: 'GitHub token', re: /\bgh[pousr]_[0-9A-Za-z]{36,}\b/g },
17
+ ];
18
+
19
+ function luhnValid(digits) {
20
+ let sum = 0;
21
+ let alt = false;
22
+ for (let i = digits.length - 1; i >= 0; i--) {
23
+ let n = parseInt(digits[i], 10);
24
+ if (alt) { n *= 2; if (n > 9) n -= 9; }
25
+ sum += n;
26
+ alt = !alt;
27
+ }
28
+ return sum % 10 === 0;
29
+ }
30
+
31
+ export function scanForPii(text = '') {
32
+ const findings = { emails: new Set(), phones: new Set(), ssnLike: new Set(), cardLike: new Set(), secrets: [] };
33
+ for (const m of text.matchAll(EMAIL_RE)) findings.emails.add(m[0].toLowerCase());
34
+ for (const m of text.matchAll(PHONE_RE)) findings.phones.add(m[0]);
35
+ for (const m of text.matchAll(SSN_RE)) findings.ssnLike.add(m[0]);
36
+ for (const m of text.matchAll(CC_CANDIDATE_RE)) {
37
+ const digits = m[0].replace(/[ -]/g, '');
38
+ if (digits.length >= 13 && digits.length <= 19 && luhnValid(digits)) {
39
+ findings.cardLike.add(digits.replace(/\d(?=\d{4})/g, '*'));
40
+ }
41
+ }
42
+ for (const pattern of SECRET_PATTERNS) {
43
+ const matches = [...text.matchAll(pattern.re)];
44
+ if (matches.length) findings.secrets.push({ type: pattern.name, count: matches.length, sample: matches[0][0].slice(0, 12) + '…' });
45
+ }
46
+ return {
47
+ emails: [...findings.emails],
48
+ phones: [...findings.phones],
49
+ ssnLike: [...findings.ssnLike],
50
+ cardLike: [...findings.cardLike],
51
+ secrets: findings.secrets,
52
+ };
53
+ }
54
+
55
+ const PAYMENT_SIGNATURES = [
56
+ { name: 'Stripe', re: /js\.stripe\.com|api\.stripe\.com|checkout\.stripe\.com/i },
57
+ { name: 'PayPal', re: /paypal(?:objects)?\.com\/sdk|paypalobjects\.com/i },
58
+ { name: 'Square', re: /squareup\.com|square\.site|js\.squarecdn\.com/i },
59
+ { name: 'Donorbox', re: /donorbox\.org/i },
60
+ { name: 'Classy', re: /classy\.org|classydonation/i },
61
+ { name: 'Network for Good', re: /networkforgood\.com/i },
62
+ { name: 'GiveWP', re: /give-\w+\.js|givewp/i },
63
+ { name: 'WooCommerce', re: /woocommerce/i },
64
+ { name: 'Shopify Checkout', re: /checkout\.shopify\.com|cdn\.shopify\.com\/s\/checkout/i },
65
+ { name: 'Venmo', re: /venmo\.com/i },
66
+ { name: 'Braintree', re: /braintreegateway\.com|js\.braintreegateway\.com/i },
67
+ { name: 'Authorize.Net', re: /authorize\.net/i },
68
+ { name: 'Kindful', re: /kindful\.com/i },
69
+ { name: 'Bloomerang', re: /bloomerang\.co/i },
70
+ { name: 'Salesforce/Luminate', re: /convio\.net|luminateonline\.com/i },
71
+ ];
72
+
73
+ export function detectPaymentProcessors(html = '', scriptSrcs = []) {
74
+ const haystack = html + ' ' + scriptSrcs.join(' ');
75
+ const found = PAYMENT_SIGNATURES.filter((sig) => sig.re.test(haystack)).map((sig) => sig.name);
76
+ const hasCardInputForm = /<input[^>]+(?:name|id|autocomplete)=["'][^"']*(?:card[-_]?number|cardnumber|cc-number)/i.test(html);
77
+ const hasDonationLanguage = /\b(donate|donation|give now|make a gift)\b/i.test(html);
78
+ return { processors: [...new Set(found)], hasCardInputForm, hasDonationLanguage };
79
+ }
@@ -0,0 +1,72 @@
1
+ // ── Lightweight TCP connect-scan (node:net only, no nmap dependency). A "connect scan" just
2
+ // completes the standard TCP handshake and closes — the same thing any browser/client does when it
3
+ // connects to a service. No SYN-stealth tricks, no payloads sent beyond an optional passive banner read. ──
4
+ import net from 'node:net';
5
+
6
+ export const COMMON_SERVICE_PORTS = [
7
+ { port: 21, name: 'FTP' }, { port: 22, name: 'SSH' }, { port: 23, name: 'Telnet' },
8
+ { port: 25, name: 'SMTP' }, { port: 53, name: 'DNS' }, { port: 110, name: 'POP3' },
9
+ { port: 111, name: 'RPCbind' }, { port: 135, name: 'MSRPC' }, { port: 139, name: 'NetBIOS' },
10
+ { port: 143, name: 'IMAP' }, { port: 389, name: 'LDAP' }, { port: 443, name: 'HTTPS' },
11
+ { port: 445, name: 'SMB' }, { port: 465, name: 'SMTPS' }, { port: 587, name: 'SMTP-Submission' },
12
+ { port: 993, name: 'IMAPS' }, { port: 995, name: 'POP3S' }, { port: 1433, name: 'MSSQL' },
13
+ { port: 1521, name: 'Oracle DB' }, { port: 2049, name: 'NFS' }, { port: 2181, name: 'ZooKeeper' },
14
+ { port: 27017, name: 'MongoDB' }, { port: 3000, name: 'Dev server (Node/Grafana)' },
15
+ { port: 3306, name: 'MySQL' }, { port: 3389, name: 'RDP' }, { port: 5000, name: 'Dev server (Flask/UPnP)' },
16
+ { port: 5432, name: 'PostgreSQL' }, { port: 5601, name: 'Kibana' }, { port: 5672, name: 'RabbitMQ' },
17
+ { port: 5900, name: 'VNC' }, { port: 5984, name: 'CouchDB' }, { port: 6379, name: 'Redis' },
18
+ { port: 7001, name: 'WebLogic' }, { port: 8000, name: 'Dev server (generic)' },
19
+ { port: 8080, name: 'HTTP-alt' }, { port: 8443, name: 'HTTPS-alt' }, { port: 8500, name: 'Consul' },
20
+ { port: 8888, name: 'Dev server (Jupyter/generic)' }, { port: 9000, name: 'PHP-FPM / SonarQube' },
21
+ { port: 9042, name: 'Cassandra' }, { port: 9200, name: 'Elasticsearch' }, { port: 9300, name: 'Elasticsearch-transport' },
22
+ { port: 11211, name: 'Memcached' }, { port: 15672, name: 'RabbitMQ-mgmt' }, { port: 27018, name: 'MongoDB-shard' },
23
+ ];
24
+
25
+ export const INTENSITY_PORT_PROFILES = {
26
+ light: { ports: COMMON_SERVICE_PORTS.slice(0, 15), concurrency: 10, timeoutMs: 1200 },
27
+ normal: { ports: COMMON_SERVICE_PORTS.slice(0, 35), concurrency: 20, timeoutMs: 1800 },
28
+ aggressive: { ports: COMMON_SERVICE_PORTS, concurrency: 40, timeoutMs: 2500 },
29
+ };
30
+
31
+ // Sensitive services that should almost never be reachable from the public internet.
32
+ export const SENSITIVE_PORTS = new Set([21, 22, 23, 111, 135, 139, 445, 1433, 1521, 27017, 3306, 3389, 5432, 5900, 5984, 6379, 9200, 11211]);
33
+
34
+ function connectScan(host, port, timeoutMs) {
35
+ return new Promise((resolve) => {
36
+ const socket = new net.Socket();
37
+ let settled = false;
38
+ let banner = '';
39
+ const finish = (state) => {
40
+ if (settled) return;
41
+ settled = true;
42
+ clearTimeout(timer);
43
+ socket.destroy();
44
+ resolve({ port, state, banner: banner.trim() });
45
+ };
46
+ const timer = setTimeout(() => finish('filtered'), timeoutMs);
47
+ socket.once('connect', () => {
48
+ // Passive read only — give the service up to 400ms to volunteer a banner (SSH/FTP/SMTP do this
49
+ // unprompted). Never send bytes first.
50
+ socket.once('data', (chunk) => { banner = chunk.toString('utf8', 0, 120).replace(/[^\x20-\x7e]/g, ''); });
51
+ setTimeout(() => finish('open'), 400);
52
+ });
53
+ socket.once('error', () => finish('closed'));
54
+ socket.connect(port, host);
55
+ });
56
+ }
57
+
58
+ export async function scanPorts(host, { ports = COMMON_SERVICE_PORTS, concurrency = 20, timeoutMs = 1800, onProgress = null } = {}) {
59
+ const results = [];
60
+ const queue = [...ports];
61
+ const total = queue.length;
62
+ async function worker() {
63
+ while (queue.length) {
64
+ const entry = queue.shift();
65
+ const r = await connectScan(host, entry.port, timeoutMs);
66
+ results.push({ ...entry, ...r });
67
+ if (onProgress) onProgress(results.length, total, { ...entry, ...r });
68
+ }
69
+ }
70
+ await Promise.all(Array.from({ length: Math.min(concurrency, queue.length || 1) }, worker));
71
+ return results.sort((a, b) => a.port - b.port);
72
+ }
@@ -0,0 +1,14 @@
1
+ // ── Route all fetch() traffic through an upstream proxy (e.g. Burp Suite / OWASP ZAP) so a human
2
+ // can inspect/replay requests for manual follow-up testing. Node's global fetch is powered by
3
+ // undici, so setting a global ProxyAgent dispatcher covers every fetch() call in the process —
4
+ // Playwright's browser traffic is proxied separately via its own native `proxy` launch option. ──
5
+ import { ProxyAgent, setGlobalDispatcher } from 'undici';
6
+
7
+ export function configureFetchProxy(proxyUrl) {
8
+ if (!proxyUrl) return;
9
+ setGlobalDispatcher(new ProxyAgent(proxyUrl));
10
+ }
11
+
12
+ export function playwrightProxyOption(proxyUrl) {
13
+ return proxyUrl ? { server: proxyUrl } : undefined;
14
+ }
@@ -0,0 +1,113 @@
1
+ // ── Shared HTML dashboard builder, used both inline during a live audit run and by the
2
+ // standalone scripts/generate-security-report.mjs (--run-dir) regenerator. Branding-aware so
3
+ // reports can be white-labeled the same way universal-seo-audit's generate-visual-report.mjs is. ──
4
+
5
+ export const DEFAULT_BRANDING = {
6
+ companyName: 'Universal Security Audit',
7
+ logo: '',
8
+ primaryColor: '#ef4444',
9
+ secondaryColor: '#0f172a',
10
+ accentColor: '#22c55e',
11
+ reportTitle: 'Security & Attack Surface Audit',
12
+ author: '',
13
+ footerText: '',
14
+ };
15
+
16
+ export function loadBranding(fs, path, brandConfigPath) {
17
+ let branding = { ...DEFAULT_BRANDING };
18
+ if (brandConfigPath) {
19
+ try {
20
+ const custom = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), brandConfigPath), 'utf8'));
21
+ branding = { ...branding, ...custom };
22
+ } catch (e) {
23
+ console.error(`Warning: could not read --brand-config (${String(e?.message || e)}); using defaults.`);
24
+ }
25
+ }
26
+ return branding;
27
+ }
28
+
29
+ function esc(s) { return String(s ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch])); }
30
+
31
+ export function buildSecurityDashboardHtml({
32
+ site, risk, sorted, primaryPlatform, platformSignals, hostingSignals, tlsInfo, dnsRecords,
33
+ libraries, vulnResults, crawlerExposure, paymentProcessors, generatedAt = new Date(),
34
+ }, branding = DEFAULT_BRANDING) {
35
+ const gradeColor = { 'A+': '#16a34a', A: '#22c55e', B: '#84cc16', C: '#eab308', D: '#f97316', F: '#ef4444' }[risk.grade] || '#94a3b8';
36
+ const sevColorMap = { critical: '#ef4444', high: '#f97316', medium: '#eab308', low: '#3b82f6', info: '#94a3b8' };
37
+ const rows = sorted.map((f) => `
38
+ <tr${f.suppressed ? ' style="opacity:.5"' : ''}>
39
+ <td><span class="badge" style="background:${sevColorMap[f.severity]}22;color:${sevColorMap[f.severity]}">${esc(f.severity)}</span>${f.suppressed ? ' <span class="badge" style="background:#64748b22;color:#64748b" title="' + esc(f.suppressedReason || '') + '">suppressed</span>' : ''}</td>
40
+ <td>${esc(f.category)}</td>
41
+ <td>${esc(f.title)}</td>
42
+ <td class="detail">${esc(f.detail)}</td>
43
+ <td class="url">${f.url ? `<a href="${esc(f.url)}" target="_blank" rel="noopener">${esc(f.url)}</a>` : ''}</td>
44
+ </tr>`).join('');
45
+ const vulnRows = vulnResults.flatMap((r) => r.vulns.map((v) => `
46
+ <tr><td>${esc(r.component)}</td><td>${esc(r.source)}</td><td>${esc(v.id || '')}</td><td>${esc(v.title || v.summary || '')}</td></tr>`)).join('');
47
+ const logoHtml = branding.logo ? `<img src="${esc(branding.logo)}" alt="${esc(branding.companyName)} logo" class="logo">` : '';
48
+
49
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${esc(branding.reportTitle)} — ${esc(site)}</title>
50
+ <style>
51
+ :root { color-scheme: light dark; --primary: ${branding.primaryColor}; --accent: ${branding.accentColor}; }
52
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; margin: 0; padding: 2rem; background: #0b0f16; color: #e2e8f0; }
53
+ .logo { max-height: 44px; max-width: 200px; object-fit: contain; display: block; margin-bottom: .75rem; }
54
+ .masthead { background: linear-gradient(135deg, var(--primary), #1e293b); padding: 1.5rem 1.75rem; border-radius: 16px; margin-bottom: 1.5rem; box-shadow: 0 10px 30px rgba(0,0,0,.35); }
55
+ h1 { font-size: 1.5rem; margin: 0 0 .25rem; color: #fff; }
56
+ .sub { color: #e2e8f0cc; font-size: .85rem; }
57
+ .grade-card { display: inline-flex; align-items: center; gap: 1rem; background: #131a24; border: 1px solid #1f2937; border-radius: 12px; padding: 1rem 1.5rem; margin-bottom: 1.5rem; }
58
+ .grade { font-size: 2.5rem; font-weight: 800; color: ${gradeColor}; }
59
+ .stats { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
60
+ .stat { background: #131a24; border: 1px solid #1f2937; border-radius: 10px; padding: .75rem 1rem; min-width: 110px; }
61
+ .stat .n { font-size: 1.4rem; font-weight: 700; }
62
+ .stat .l { font-size: .75rem; color: #94a3b8; text-transform: uppercase; letter-spacing: .05em; }
63
+ section { margin-bottom: 2rem; }
64
+ table { width: 100%; border-collapse: collapse; background: #131a24; border: 1px solid #1f2937; border-radius: 10px; overflow: hidden; }
65
+ th, td { text-align: left; padding: .6rem .8rem; border-bottom: 1px solid #1f2937; font-size: .85rem; vertical-align: top; }
66
+ th { background: #1a2230; color: #94a3b8; text-transform: uppercase; font-size: .7rem; letter-spacing: .05em; }
67
+ .badge { padding: .15rem .5rem; border-radius: 999px; font-size: .7rem; font-weight: 700; text-transform: uppercase; }
68
+ .detail { color: #cbd5e1; max-width: 420px; }
69
+ .url a { color: var(--accent); word-break: break-all; }
70
+ .meta-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px,1fr)); gap: 1rem; }
71
+ .meta-card { background: #131a24; border: 1px solid #1f2937; border-radius: 10px; padding: 1rem; }
72
+ .meta-card h3 { margin: 0 0 .5rem; font-size: .8rem; color: #94a3b8; text-transform: uppercase; letter-spacing: .05em; }
73
+ .footer { margin-top: 2rem; color: #64748b; font-size: .75rem; text-align: center; }
74
+ @media (prefers-color-scheme: light) {
75
+ body { background: #f8fafc; color: #0f172a; }
76
+ .grade-card, .stat, table, .meta-card { background: #fff; border-color: #e2e8f0; }
77
+ th { background: #f1f5f9; }
78
+ .detail { color: #334155; }
79
+ }
80
+ @media print { body { background: #fff; color: #0f172a; } .grade-card, .stat, table, .meta-card { background: #fff; border: 1px solid #cbd5e1; } th { background: #f1f5f9; } .detail { color: #334155; } section { break-inside: avoid-page; } }
81
+ </style></head><body>
82
+ <div class="masthead">
83
+ ${logoHtml}
84
+ <h1>🛡️ ${esc(branding.reportTitle)}</h1>
85
+ <div class="sub">${esc(site)} · ${esc(branding.companyName)}${branding.author ? ` · Prepared by ${esc(branding.author)}` : ''} · generated ${esc(new Date(generatedAt).toLocaleString())}</div>
86
+ </div>
87
+ <div class="grade-card"><div class="grade">${risk.grade}</div><div><div style="font-weight:600">${risk.score}/100 risk score</div><div style="color:#94a3b8;font-size:.85rem">${risk.total} finding(s) across all checks</div></div></div>
88
+ <div class="stats">
89
+ <div class="stat"><div class="n" style="color:${sevColorMap.critical}">${risk.counts.critical}</div><div class="l">Critical</div></div>
90
+ <div class="stat"><div class="n" style="color:${sevColorMap.high}">${risk.counts.high}</div><div class="l">High</div></div>
91
+ <div class="stat"><div class="n" style="color:${sevColorMap.medium}">${risk.counts.medium}</div><div class="l">Medium</div></div>
92
+ <div class="stat"><div class="n" style="color:${sevColorMap.low}">${risk.counts.low}</div><div class="l">Low</div></div>
93
+ <div class="stat"><div class="n" style="color:${sevColorMap.info}">${risk.counts.info}</div><div class="l">Info</div></div>
94
+ </div>
95
+ <section>
96
+ <div class="meta-grid">
97
+ <div class="meta-card"><h3>Platform</h3>${platformSignals.map((s) => `<div>${esc(s.platform)} <span style="color:#94a3b8">(${esc(s.confidence)})</span></div>`).join('')}</div>
98
+ <div class="meta-card"><h3>Hosting / CDN</h3>${hostingSignals.length ? hostingSignals.map((h) => `<div>${esc(h.provider)}</div>`).join('') : '<div style="color:#94a3b8">No signals detected</div>'}</div>
99
+ <div class="meta-card"><h3>TLS</h3>${tlsInfo.ok ? `<div>${esc(tlsInfo.protocol)}</div><div>Expires in ${tlsInfo.daysRemaining}d</div><div>Issuer: ${esc(tlsInfo.issuer)}</div>` : '<div style="color:#94a3b8">Not checked / unavailable</div>'}</div>
100
+ <div class="meta-card"><h3>DNS</h3><div>A: ${esc((dnsRecords.a || []).join(', ') || '—')}</div><div>NS: ${esc((dnsRecords.ns || []).join(', ') || '—')}</div><div>MX: ${esc((dnsRecords.mx || []).join(', ') || '—')}</div></div>
101
+ <div class="meta-card"><h3>Crawler exposure</h3><div>robots.txt: ${crawlerExposure.hasRobots ? 'yes' : 'no'}</div><div>llms.txt: ${crawlerExposure.hasLlmsTxt ? 'yes' : 'no'}</div><div>sitemap: ${crawlerExposure.hasSitemap ? 'yes' : 'no'}</div></div>
102
+ <div class="meta-card"><h3>Payment/donation</h3>${paymentProcessors.length ? paymentProcessors.map((p) => `<div>${esc(p)}</div>`).join('') : '<div style="color:#94a3b8">None detected</div>'}</div>
103
+ </div>
104
+ </section>
105
+ <section>
106
+ <h2>Findings</h2>
107
+ <table><thead><tr><th>Severity</th><th>Category</th><th>Title</th><th>Detail</th><th>URL</th></tr></thead><tbody>${rows || '<tr><td colspan="5" style="text-align:center;color:#94a3b8">No findings 🎉</td></tr>'}</tbody></table>
108
+ </section>
109
+ ${vulnRows ? `<section><h2>Known Vulnerabilities (OSV.dev / WPScan)</h2><table><thead><tr><th>Component</th><th>Source</th><th>ID</th><th>Title</th></tr></thead><tbody>${vulnRows}</tbody></table></section>` : ''}
110
+ <section><h3>Detected libraries</h3><table><thead><tr><th>Name</th><th>Version</th><th>Source</th></tr></thead><tbody>${libraries.map((l) => `<tr><td>${esc(l.name)}</td><td>${esc(l.version || 'unknown')}</td><td class="url">${esc(l.sourceUrl)}</td></tr>`).join('') || '<tr><td colspan="3" style="text-align:center;color:#94a3b8">None detected</td></tr>'}</tbody></table></section>
111
+ <div class="footer">${esc(branding.footerText || '')}</div>
112
+ </body></html>`;
113
+ }
@@ -0,0 +1,44 @@
1
+ // ── Aggregate findings into severity buckets + an overall A-F risk grade ──
2
+
3
+ export const SEVERITY_WEIGHTS = { critical: 25, high: 12, medium: 5, low: 2, info: 0 };
4
+ export const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low', 'info'];
5
+
6
+ export function tallyBySeverity(findings = []) {
7
+ const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
8
+ for (const f of findings) {
9
+ const sev = SEVERITY_WEIGHTS[f.severity] != null ? f.severity : 'info';
10
+ counts[sev]++;
11
+ }
12
+ return counts;
13
+ }
14
+
15
+ export function computeRiskGrade(findings = []) {
16
+ const counts = tallyBySeverity(findings);
17
+ let score = 100;
18
+ for (const sev of SEVERITY_ORDER) score -= counts[sev] * SEVERITY_WEIGHTS[sev];
19
+ score = Math.max(0, Math.min(100, score));
20
+ const grade = score >= 95 ? 'A+' : score >= 85 ? 'A' : score >= 70 ? 'B' : score >= 55 ? 'C' : score >= 35 ? 'D' : 'F';
21
+ return { score, grade, counts, total: findings.length };
22
+ }
23
+
24
+ export function sortFindingsBySeverity(findings = []) {
25
+ return [...findings].sort((a, b) => SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity));
26
+ }
27
+
28
+ // ── CI/CD gating helpers ──
29
+ export const GRADE_ORDER = ['F', 'D', 'C', 'B', 'A', 'A+'];
30
+
31
+ // True if `grade` is strictly worse than `threshold` (e.g. isGradeBelow('C', 'B') === true).
32
+ export function isGradeBelow(grade, threshold) {
33
+ const g = GRADE_ORDER.indexOf(String(grade || '').toUpperCase());
34
+ const t = GRADE_ORDER.indexOf(String(threshold || '').toUpperCase());
35
+ if (g === -1 || t === -1) return false;
36
+ return g < t;
37
+ }
38
+
39
+ // True if any finding is at or above `minSeverity` on the critical>high>medium>low>info scale.
40
+ export function hasFindingAtOrAbove(findings = [], minSeverity) {
41
+ const idx = SEVERITY_ORDER.indexOf(String(minSeverity || '').toLowerCase());
42
+ if (idx === -1) return false;
43
+ return findings.some((f) => SEVERITY_ORDER.indexOf(f.severity) <= idx);
44
+ }