1
2
3import { Actor, log } from 'apify';
4import dns from 'node:dns/promises';
5import tls from 'node:tls';
6
7const UA = 'Mozilla/5.0 (compatible; WebsiteSecurityCheck/1.0; +https://apify.com/mrdexter4)';
8const DEFAULT_DKIM_SELECTORS = ['default', 'google', 'selector1', 'selector2', 'k1', 'k2', 's1', 's2', 'mail', 'dkim', 'smtp', 'mxvault', 'zoho', 'protonmail', 'fm1', 'mandrill', 'sendgrid', 'mailjet'];
9
10
11
12export function normalizeDomain(raw) {
13 if (!raw || typeof raw !== 'string') return null;
14 let s = raw.trim().toLowerCase();
15 if (!s) return null;
16 if (!/^[a-z]+:\/\//.test(s)) s = `https://${s}`;
17 try {
18 const host = new URL(s).hostname.replace(/\.$/, '');
19 if (!/^[a-z0-9.-]+\.[a-z0-9-]{2,}$/.test(host)) return null;
20 return host;
21 } catch {
22 return null;
23 }
24}
25
26
27export function mailDomainOf(host) {
28 return host.startsWith('www.') ? host.slice(4) : host;
29}
30
31export function parseSpf(record) {
32 const terms = record.trim().split(/\s+/).slice(1);
33 let all = null;
34 const includes = [];
35 let redirect = null;
36 let directLookups = 0;
37 for (const t of terms) {
38 const m = /^([+\-~?]?)(.*)$/.exec(t);
39 const q = m[1] || '+';
40 const body = m[2].toLowerCase();
41 if (body === 'all') all = q;
42 else if (body.startsWith('include:')) { includes.push(body.slice(8)); directLookups++; }
43 else if (body.startsWith('redirect=')) { redirect = body.slice(9); directLookups++; }
44 else if (body === 'a' || body.startsWith('a:') || body.startsWith('a/')) directLookups++;
45 else if (body === 'mx' || body.startsWith('mx:') || body.startsWith('mx/')) directLookups++;
46 else if (body === 'ptr' || body.startsWith('ptr:')) directLookups++;
47 else if (body.startsWith('exists:')) directLookups++;
48 }
49 return { all, includes, redirect, directLookups };
50}
51
52export function parseTags(record) {
53 const out = {};
54 for (const part of record.split(';')) {
55 const i = part.indexOf('=');
56 if (i < 0) continue;
57 const k = part.slice(0, i).trim().toLowerCase();
58 const v = part.slice(i + 1).trim();
59 if (k) out[k] = v;
60 }
61 return out;
62}
63
64export function isDkimKey(record) {
65 return /v=DKIM1/i.test(record) || /(^|;)\s*p=[A-Za-z0-9+/=]{40,}/.test(record);
66}
67
68export function parseHsts(value) {
69 if (!value) return null;
70 const m = /max-age\s*=\s*"?(\d+)"?/i.exec(value);
71 return {
72 maxAge: m ? Number(m[1]) : null,
73 includeSubDomains: /includesubdomains/i.test(value),
74 preload: /preload/i.test(value),
75 };
76}
77
78export function parseSecurityTxt(text, now = new Date()) {
79 const lines = text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
80 const fields = {};
81 for (const l of lines) {
82 const i = l.indexOf(':');
83 if (i < 0) continue;
84 const k = l.slice(0, i).trim().toLowerCase();
85 const v = l.slice(i + 1).trim();
86 (fields[k] ??= []).push(v);
87 }
88 const contact = fields.contact ?? [];
89 const expiresRaw = fields.expires?.[0] ?? null;
90 const expires = expiresRaw ? new Date(expiresRaw) : null;
91 return {
92 hasContact: contact.length > 0,
93 contactCount: contact.length,
94 expires: expires && !Number.isNaN(expires.getTime()) ? expires.toISOString() : null,
95 expired: expires && !Number.isNaN(expires.getTime()) ? expires < now : null,
96 hasExpires: Boolean(expiresRaw),
97 };
98}
99
100
101export function looksLikeSecurityTxt(text, contentType) {
102 if (contentType && /text\/html/i.test(contentType)) return false;
103 return /^\s*contact\s*:/im.test(text);
104}
105
106export const STATUS_POINTS = { pass: 1, warn: 0.5, fail: 0 };
107
108export function scoreChecks(checks) {
109 const scored = checks.filter((c) => c.status in STATUS_POINTS);
110 if (!scored.length) return null;
111 const total = scored.reduce((s, c) => s + STATUS_POINTS[c.status] * (c.weight ?? 1), 0);
112 const max = scored.reduce((s, c) => s + (c.weight ?? 1), 0);
113 return Math.round((total / max) * 100);
114}
115
116export function gradeOf(score) {
117 if (score === null || score === undefined) return null;
118 if (score >= 90) return 'A';
119 if (score >= 75) return 'B';
120 if (score >= 60) return 'C';
121 if (score >= 40) return 'D';
122 return 'F';
123}
124
125export function evaluateHeaders(headers) {
126 const h = (name) => headers[name.toLowerCase()] ?? null;
127 const checks = [];
128 const hsts = parseHsts(h('strict-transport-security'));
129 if (!hsts) {
130 checks.push(ck('web', 'hsts', 'fail', 'HSTS header', 'No Strict-Transport-Security header on the HTTPS homepage.', 'Add "Strict-Transport-Security: max-age=31536000; includeSubDomains" once all subdomains support HTTPS.', 'RFC 6797', 2));
131 } else if ((hsts.maxAge ?? 0) >= 31536000) {
132 checks.push(ck('web', 'hsts', 'pass', 'HSTS header', `max-age=${hsts.maxAge}${hsts.includeSubDomains ? ', includeSubDomains' : ''}${hsts.preload ? ', preload' : ''}.`, null, 'RFC 6797', 2));
133 } else {
134 checks.push(ck('web', 'hsts', 'warn', 'HSTS header', `max-age=${hsts.maxAge ?? 'missing'} is shorter than one year.`, 'Raise max-age to at least 31536000 (one year).', 'RFC 6797', 2));
135 }
136 const csp = h('content-security-policy');
137 const cspRo = h('content-security-policy-report-only');
138 if (csp) {
139 const weak = /script-src[^;]*'unsafe-inline'/i.test(csp) && !/'nonce-|'sha(256|384|512)-|'strict-dynamic'/i.test(csp);
140 checks.push(ck('web', 'csp', weak ? 'warn' : 'pass', 'Content-Security-Policy', weak ? "CSP present but script-src allows 'unsafe-inline' without nonces or hashes." : 'CSP header present.', weak ? "Replace 'unsafe-inline' in script-src with nonces or hashes." : null, 'W3C CSP Level 3'));
141 } else {
142 checks.push(ck('web', 'csp', 'warn', 'Content-Security-Policy', cspRo ? 'Only a report-only CSP is set (not enforced).' : 'No Content-Security-Policy header.', 'Add a Content-Security-Policy; start with default-src \'self\' and frame-ancestors \'self\'.', 'W3C CSP Level 3'));
143 }
144 const xcto = h('x-content-type-options');
145 checks.push(ck('web', 'x-content-type-options', xcto && /nosniff/i.test(xcto) ? 'pass' : 'warn', 'X-Content-Type-Options', xcto ? `Value: ${xcto}` : 'Header missing.', xcto && /nosniff/i.test(xcto) ? null : 'Add "X-Content-Type-Options: nosniff".', 'Fetch Standard'));
146 const xfo = h('x-frame-options');
147 const frameAncestors = csp && /frame-ancestors/i.test(csp);
148 checks.push(ck('web', 'frame-protection', xfo || frameAncestors ? 'pass' : 'warn', 'Clickjacking protection', xfo ? `X-Frame-Options: ${xfo}` : frameAncestors ? 'CSP frame-ancestors set.' : 'Neither X-Frame-Options nor CSP frame-ancestors is set.', xfo || frameAncestors ? null : "Add CSP \"frame-ancestors 'self'\" (or X-Frame-Options: SAMEORIGIN).", 'RFC 7034 / CSP'));
149 const rp = h('referrer-policy');
150 checks.push(ck('web', 'referrer-policy', rp ? (/unsafe-url/i.test(rp) ? 'warn' : 'pass') : 'warn', 'Referrer-Policy', rp ? `Value: ${rp}` : 'Header missing.', !rp || /unsafe-url/i.test(rp) ? 'Add "Referrer-Policy: strict-origin-when-cross-origin" (or stricter).' : null, 'W3C Referrer Policy'));
151 const pp = h('permissions-policy');
152 checks.push(ck('web', 'permissions-policy', pp ? 'pass' : 'info', 'Permissions-Policy', pp ? 'Header present.' : 'Header missing (optional hardening).', pp ? null : 'Optionally restrict powerful features, e.g. "Permissions-Policy: camera=(), microphone=(), geolocation=()".', 'W3C Permissions Policy'));
153 const server = h('server');
154 const xpb = h('x-powered-by');
155 const leaks = [server && /\d/.test(server) ? `Server: ${server}` : null, xpb ? `X-Powered-By: ${xpb}` : null].filter(Boolean);
156 checks.push(ck('web', 'version-disclosure', leaks.length ? 'warn' : 'pass', 'Software version disclosure', leaks.length ? leaks.join('; ') : 'No version numbers in Server / X-Powered-By headers.', leaks.length ? 'Hide software versions in Server and X-Powered-By headers.' : null, 'OWASP Secure Headers'));
157 return checks;
158}
159
160export function evaluateSpf(records, lookups) {
161 const spf = records.filter((r) => /^v=spf1(\s|$)/i.test(r));
162 if (spf.length === 0) return [ck('email', 'spf', 'fail', 'SPF record', 'No SPF (v=spf1) TXT record found.', 'Publish an SPF record listing your mail senders, ending in "-all" (or "v=spf1 -all" if the domain sends no email).', 'RFC 7208', 2)];
163 if (spf.length > 1) return [ck('email', 'spf', 'fail', 'SPF record', `${spf.length} SPF records found; receivers treat this as a permanent error.`, 'Merge them into a single v=spf1 record.', 'RFC 7208 §4.5', 2)];
164 const p = parseSpf(spf[0]);
165 const out = [];
166 if (p.all === '+') out.push(ck('email', 'spf', 'fail', 'SPF record', `"+all" lets anyone send as this domain. Record: ${spf[0]}`, 'Replace "+all" with "-all" or "~all".', 'RFC 7208', 2));
167 else if (p.all === '-' || p.all === '~') out.push(ck('email', 'spf', 'pass', 'SPF record', `Ends in "${p.all}all". Record: ${spf[0]}`, null, 'RFC 7208', 2));
168 else if (p.all === '?') out.push(ck('email', 'spf', 'warn', 'SPF record', `"?all" (neutral) gives no protection. Record: ${spf[0]}`, 'End the record with "-all" or "~all".', 'RFC 7208', 2));
169 else if (p.redirect) out.push(ck('email', 'spf', 'pass', 'SPF record', `Uses redirect=${p.redirect}. Record: ${spf[0]}`, null, 'RFC 7208', 2));
170 else out.push(ck('email', 'spf', 'warn', 'SPF record', `No "all" mechanism; the default result is neutral. Record: ${spf[0]}`, 'End the record with "-all" or "~all".', 'RFC 7208', 2));
171 if (lookups !== null && lookups !== undefined) {
172 out.push(ck('email', 'spf-lookups', lookups > 10 ? 'fail' : 'pass', 'SPF DNS lookup limit', `${lookups} DNS-querying mechanisms (limit 10).`, lookups > 10 ? 'Flatten or remove includes so the record needs at most 10 DNS lookups.' : null, 'RFC 7208 §4.6.4'));
173 }
174 return out;
175}
176
177export function evaluateDmarc(records) {
178 const d = records.filter((r) => /^v=DMARC1/i.test(r));
179 if (!d.length) return [ck('email', 'dmarc', 'fail', 'DMARC policy', 'No DMARC record at _dmarc.', 'Publish "v=DMARC1; p=none; rua=mailto:…" to start monitoring, then move to p=quarantine or p=reject.', 'RFC 7489', 2)];
180 if (d.length > 1) return [ck('email', 'dmarc', 'fail', 'DMARC policy', 'More than one DMARC record; receivers ignore DMARC.', 'Keep a single DMARC record.', 'RFC 7489 §6.6.3', 2)];
181 const t = parseTags(d[0]);
182 const policy = (t.p ?? '').toLowerCase();
183 const pct = t.pct ? Number(t.pct) : 100;
184 const out = [];
185 if (policy === 'reject' || policy === 'quarantine') {
186 out.push(ck('email', 'dmarc', pct < 100 ? 'warn' : 'pass', 'DMARC policy', `p=${policy}${pct < 100 ? `, but only pct=${pct}` : ''}.`, pct < 100 ? 'Raise pct to 100.' : null, 'RFC 7489', 2));
187 } else if (policy === 'none') {
188 out.push(ck('email', 'dmarc', 'warn', 'DMARC policy', 'p=none only monitors; spoofed mail is still delivered.', 'After reviewing reports, move to p=quarantine and then p=reject.', 'RFC 7489', 2));
189 } else {
190 out.push(ck('email', 'dmarc', 'fail', 'DMARC policy', `Invalid or missing p= tag. Record: ${d[0]}`, 'Set p=none, p=quarantine or p=reject.', 'RFC 7489', 2));
191 }
192 out.push(ck('email', 'dmarc-reporting', t.rua ? 'pass' : 'warn', 'DMARC aggregate reports', t.rua ? 'rua= reporting address set.' : 'No rua= address, so the owner gets no reports.', t.rua ? null : 'Add rua=mailto:… to receive aggregate reports.', 'RFC 7489 §6.3'));
193 return out;
194}
195
196function ck(category, id, status, title, detail, recommendation = null, reference = null, weight = 1) {
197 return { category, id, status, title, detail, recommendation, reference, weight };
198}
199
200
201
202async function withTimeout(promise, ms, label) {
203 let t;
204 const timeout = new Promise((_, rej) => { t = setTimeout(() => rej(new Error(`${label} timed out after ${ms} ms`)), ms); });
205 try { return await Promise.race([promise, timeout]); } finally { clearTimeout(t); }
206}
207
208async function txt(name, ms) {
209 try {
210 const r = await withTimeout(dns.resolveTxt(name), ms, `TXT ${name}`);
211 return r.map((chunks) => chunks.join(''));
212 } catch (e) {
213 if (['ENODATA', 'ENOTFOUND', 'ESERVFAIL', 'NXDOMAIN'].includes(e.code)) return [];
214 throw e;
215 }
216}
217
218async function countSpfLookups(domain, ms, depth = 0, seen = new Set()) {
219 if (depth > 10 || seen.has(domain)) return 0;
220 seen.add(domain);
221 const rec = (await txt(domain, ms).catch(() => [])).find((r) => /^v=spf1(\s|$)/i.test(r));
222 if (!rec) return 0;
223 const p = parseSpf(rec);
224 let n = p.directLookups;
225 for (const inc of [...p.includes, ...(p.redirect ? [p.redirect] : [])]) {
226 if (inc.includes('%{')) continue;
227 n += await countSpfLookups(inc, ms, depth + 1, seen);
228 if (n > 20) break;
229 }
230 return n;
231}
232
233async function httpGet(url, ms, redirect = 'follow') {
234 const controller = new AbortController();
235 const timer = setTimeout(() => controller.abort(), ms);
236 try {
237 return await fetch(url, { redirect, signal: controller.signal, headers: { 'user-agent': UA, accept: 'text/html,text/plain,*/*' } });
238 } finally {
239 clearTimeout(timer);
240 }
241}
242
243async function followToHttps(host, ms) {
244 let url = `http://${host}/`;
245 for (let i = 0; i < 6; i++) {
246 const res = await httpGet(url, ms, 'manual');
247 const loc = res.headers.get('location');
248 if (res.status >= 300 && res.status < 400 && loc) {
249 const next = new URL(loc, url);
250 if (next.protocol === 'https:') return { redirectsToHttps: true, firstHttpsUrl: next.href, hops: i + 1, sameHost: next.hostname === host || next.hostname === `www.${host}` || `www.${next.hostname}` === host };
251 url = next.href;
252 continue;
253 }
254 return { redirectsToHttps: false, finalStatus: res.status, hops: i };
255 }
256 return { redirectsToHttps: false, hops: 6 };
257}
258
259function tlsProbe(host, ms) {
260 return new Promise((resolve) => {
261 const socket = tls.connect({ host, port: 443, servername: host, rejectUnauthorized: false, timeout: ms, ALPNProtocols: ['h2', 'http/1.1'] }, () => {
262 const cert = socket.getPeerCertificate();
263 resolve({
264 ok: true,
265 protocol: socket.getProtocol(),
266 authorized: socket.authorized,
267 authorizationError: socket.authorizationError ? String(socket.authorizationError) : null,
268 validTo: cert?.valid_to ? new Date(cert.valid_to).toISOString() : null,
269 issuer: cert?.issuer?.O ?? cert?.issuer?.CN ?? null,
270 alpn: socket.alpnProtocol || null,
271 });
272 socket.end();
273 });
274 socket.on('timeout', () => { socket.destroy(); resolve({ ok: false, error: 'TLS timeout' }); });
275 socket.on('error', (e) => resolve({ ok: false, error: e.message }));
276 });
277}
278
279async function dnssecViaDoh(name, ms) {
280 const urls = [
281 `https://dns.google/resolve?name=${encodeURIComponent(name)}&type=SOA&do=1`,
282 `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(name)}&type=SOA&do=1`,
283 ];
284 for (const u of urls) {
285 try {
286 const controller = new AbortController();
287 const t = setTimeout(() => controller.abort(), ms);
288 const res = await fetch(u, { headers: { accept: 'application/dns-json' }, signal: controller.signal });
289 clearTimeout(t);
290 if (!res.ok) continue;
291 const j = await res.json();
292 return { validated: Boolean(j.AD), status: j.Status };
293 } catch { }
294 }
295 return null;
296}
297
298
299
300export async function auditDomain(input, opts) {
301 const host = normalizeDomain(input);
302 if (!host) return { input, domain: null, error: 'Not a valid domain or URL.' };
303 const mailDomain = mailDomainOf(host);
304 const ms = opts.timeoutMs;
305 const checks = [];
306 const facts = {};
307 let reachable = false;
308 let dnsExists = false;
309
310
311 let httpsHost = host;
312 let homepage = null;
313 try {
314 homepage = await httpGet(`https://${host}/`, ms);
315 reachable = true;
316 } catch {
317 if (!host.startsWith('www.')) {
318 try { homepage = await httpGet(`https://www.${host}/`, ms); httpsHost = `www.${host}`; reachable = true; } catch { }
319 }
320 }
321 facts.httpsReachable = reachable;
322 if (homepage) {
323 facts.finalUrl = homepage.url;
324 facts.httpStatus = homepage.status;
325 const headers = {};
326 homepage.headers.forEach((v, k) => { headers[k.toLowerCase()] = v; });
327 await homepage.body?.cancel().catch(() => {});
328 checks.push(...evaluateHeaders(headers));
329 facts.hsts = Boolean(headers['strict-transport-security']);
330 facts.csp = Boolean(headers['content-security-policy']);
331 } else {
332 checks.push(ck('web', 'https', 'fail', 'HTTPS available', `Could not load https://${host}/ (or www).`, 'Serve the site over HTTPS with a valid certificate.', 'internet.nl: HTTPS', 3));
333 }
334
335 try {
336 const r = await followToHttps(host, ms);
337 facts.httpRedirectsToHttps = r.redirectsToHttps;
338 checks.push(ck('web', 'https-redirect', r.redirectsToHttps ? 'pass' : 'fail', 'HTTP → HTTPS redirect', r.redirectsToHttps ? `http://${host}/ redirects to ${r.firstHttpsUrl}` : `http://${host}/ does not redirect to HTTPS${r.finalStatus ? ` (status ${r.finalStatus})` : ''}.`, r.redirectsToHttps ? null : 'Redirect all HTTP requests to HTTPS with a 301.', 'internet.nl: HTTPS redirect', 2));
339 } catch (e) {
340 facts.httpRedirectsToHttps = null;
341 checks.push(ck('web', 'https-redirect', 'info', 'HTTP → HTTPS redirect', `Port 80 not reachable (${e.cause?.code ?? e.message}). Fine if HTTP is disabled entirely.`, null, 'internet.nl: HTTPS redirect'));
342 }
343
344 if (reachable) {
345 const t = await tlsProbe(httpsHost, ms);
346 if (t.ok) {
347 facts.tlsVersion = t.protocol;
348 facts.certIssuer = t.issuer;
349 facts.certValidTo = t.validTo;
350 const daysLeft = t.validTo ? Math.floor((new Date(t.validTo) - Date.now()) / 86400000) : null;
351 facts.certDaysLeft = daysLeft;
352 facts.http2 = t.alpn === 'h2';
353 checks.push(ck('web', 'tls-version', ['TLSv1.3', 'TLSv1.2'].includes(t.protocol) ? 'pass' : 'fail', 'TLS version', `Negotiated ${t.protocol}.`, ['TLSv1.3', 'TLSv1.2'].includes(t.protocol) ? null : 'Enable TLS 1.2 and 1.3 and disable older versions.', 'NCSC-NL TLS guidelines', 2));
354 if (!t.authorized) checks.push(ck('web', 'certificate', 'fail', 'Certificate validity', `Certificate not trusted: ${t.authorizationError}.`, 'Install a certificate from a trusted CA that covers this host name.', 'internet.nl: certificate', 3));
355 else if (daysLeft !== null && daysLeft < 14) checks.push(ck('web', 'certificate', 'warn', 'Certificate validity', `Certificate expires in ${daysLeft} days (${t.validTo}).`, 'Renew the certificate or check auto-renewal.', 'internet.nl: certificate', 3));
356 else checks.push(ck('web', 'certificate', 'pass', 'Certificate validity', `Trusted certificate, ${daysLeft} days left${t.issuer ? `, issued by ${t.issuer}` : ''}.`, null, 'internet.nl: certificate', 3));
357 } else {
358 checks.push(ck('web', 'certificate', 'fail', 'Certificate validity', `TLS handshake failed: ${t.error}`, null, 'internet.nl: certificate', 3));
359 }
360
361 try {
362 const res = await httpGet(`https://${httpsHost}/.well-known/security.txt`, ms);
363 const text = res.ok ? (await res.text()).slice(0, 20000) : '';
364 if (res.ok && looksLikeSecurityTxt(text, res.headers.get('content-type'))) {
365 const s = parseSecurityTxt(text);
366 facts.securityTxt = true;
367 const status = !s.hasContact ? 'warn' : s.expired ? 'warn' : !s.hasExpires ? 'warn' : 'pass';
368 const detail = !s.hasContact ? 'security.txt found but has no Contact field.' : s.expired ? `security.txt expired on ${s.expires}.` : !s.hasExpires ? 'security.txt has no Expires field (required).' : `security.txt valid until ${s.expires}.`;
369 checks.push(ck('web', 'security-txt', status, 'security.txt', detail, status === 'pass' ? null : 'Publish /.well-known/security.txt with Contact and a future Expires date.', 'RFC 9116'));
370 } else {
371 facts.securityTxt = false;
372 checks.push(ck('web', 'security-txt', 'warn', 'security.txt', 'No /.well-known/security.txt found.', 'Publish /.well-known/security.txt with Contact and Expires so researchers can report issues.', 'RFC 9116'));
373 }
374 } catch {
375 facts.securityTxt = false;
376 checks.push(ck('web', 'security-txt', 'warn', 'security.txt', 'No /.well-known/security.txt found.', 'Publish /.well-known/security.txt with Contact and Expires.', 'RFC 9116'));
377 }
378 }
379
380
381 try {
382 const mx = await withTimeout(dns.resolveMx(mailDomain), ms, 'MX').catch((e) => (['ENODATA', 'ENOTFOUND'].includes(e.code) ? [] : Promise.reject(e)));
383 if (mx.length) dnsExists = true;
384 const nullMx = mx.length === 1 && (mx[0].exchange === '' || mx[0].exchange === '.');
385 facts.mx = mx.map((m) => m.exchange).filter(Boolean).slice(0, 5);
386 facts.acceptsEmail = mx.length > 0 && !nullMx;
387 checks.push(ck('email', 'mx', 'info', 'Mail servers (MX)', nullMx ? 'Null MX: domain declares it accepts no email.' : mx.length ? `MX: ${facts.mx.join(', ')}` : 'No MX records.', null, 'RFC 5321 / RFC 7505'));
388 } catch (e) {
389 facts.mx = null;
390 checks.push(ck('email', 'mx', 'info', 'Mail servers (MX)', `MX lookup failed: ${e.message}`));
391 }
392
393 try {
394 const rootTxt = await txt(mailDomain, ms);
395 if (rootTxt.length) dnsExists = true;
396 const lookups = rootTxt.some((r) => /^v=spf1/i.test(r)) ? await countSpfLookups(mailDomain, ms) : null;
397 const spfChecks = evaluateSpf(rootTxt, lookups);
398 checks.push(...spfChecks);
399 facts.spf = rootTxt.find((r) => /^v=spf1/i.test(r)) ?? null;
400 facts.spfLookups = lookups;
401 } catch (e) {
402 checks.push(ck('email', 'spf', 'info', 'SPF record', `Lookup failed: ${e.message}`));
403 }
404
405 try {
406 const d = await txt(`_dmarc.${mailDomain}`, ms);
407 checks.push(...evaluateDmarc(d));
408 const rec = d.find((r) => /^v=DMARC1/i.test(r));
409 facts.dmarcPolicy = rec ? (parseTags(rec).p ?? null) : null;
410 } catch (e) {
411 checks.push(ck('email', 'dmarc', 'info', 'DMARC policy', `Lookup failed: ${e.message}`));
412 }
413
414
415 if (opts.dkimSelectors.length) {
416 const found = [];
417
418 const wildcard = new Set(await txt(`zz-nonexistent-${Date.now().toString(36)}._domainkey.${mailDomain}`, ms).catch(() => []));
419 await Promise.all(opts.dkimSelectors.map(async (sel) => {
420 const r = await txt(`${sel}._domainkey.${mailDomain}`, ms).catch(() => []);
421 if (r.some((x) => !wildcard.has(x) && isDkimKey(x))) found.push(sel);
422 }));
423 found.sort();
424 facts.dkimSelectorsFound = found;
425 checks.push(ck('email', 'dkim', found.length ? 'pass' : 'info', 'DKIM key', found.length ? `DKIM key found for selector(s): ${found.join(', ')}.` : `No DKIM key at ${opts.dkimSelectors.length} common selectors. Selectors cannot be listed via DNS, so a custom selector may still exist.`, found.length ? null : 'Check that outgoing mail is DKIM-signed (look at a received message header).', 'RFC 6376'));
426 }
427
428 if (facts.acceptsEmail) {
429 const sts = await txt(`_mta-sts.${mailDomain}`, ms).catch(() => []);
430 let mode = null;
431 if (sts.some((r) => /^v=STSv1/i.test(r))) {
432 try {
433 const res = await httpGet(`https://mta-sts.${mailDomain}/.well-known/mta-sts.txt`, ms);
434 if (res.ok) mode = /mode:\s*(\w+)/i.exec(await res.text())?.[1]?.toLowerCase() ?? null;
435 } catch { }
436 }
437 facts.mtaStsMode = mode;
438 checks.push(ck('email', 'mta-sts', mode === 'enforce' ? 'pass' : mode === 'testing' ? 'warn' : 'info', 'MTA-STS', mode ? `MTA-STS policy mode: ${mode}.` : sts.length ? 'MTA-STS TXT record found but policy file not readable.' : 'No MTA-STS policy (optional, protects inbound mail in transit).', mode === 'enforce' ? null : 'Publish an MTA-STS policy (mode: enforce) to require TLS for inbound mail.', 'RFC 8461'));
439 const rpt = await txt(`_smtp._tls.${mailDomain}`, ms).catch(() => []);
440 facts.tlsRpt = rpt.some((r) => /^v=TLSRPTv1/i.test(r));
441 checks.push(ck('email', 'tls-rpt', facts.tlsRpt ? 'pass' : 'info', 'SMTP TLS reporting', facts.tlsRpt ? 'TLS-RPT record found.' : 'No TLS-RPT record (optional).', facts.tlsRpt ? null : 'Publish _smtp._tls TXT "v=TLSRPTv1; rua=mailto:…".', 'RFC 8460'));
442 }
443
444 const sec = await dnssecViaDoh(mailDomain, ms);
445 facts.dnssec = sec ? sec.validated : null;
446 if (sec && sec.status === 0) {
447 dnsExists = true;
448 checks.push(ck('dns', 'dnssec', sec.validated ? 'pass' : 'warn', 'DNSSEC', sec.validated ? 'Answers are DNSSEC-validated.' : 'Domain is not DNSSEC-signed (or validation failed).', sec.validated ? null : 'Enable DNSSEC at your DNS host and publish the DS record at the registrar.', 'internet.nl: DNSSEC', 2));
449 }
450
451 const web = checks.filter((c) => c.category === 'web');
452 const email = checks.filter((c) => c.category === 'email');
453 const score = scoreChecks(checks);
454 return {
455 input,
456 domain: host,
457 mailDomain,
458 checkedAt: new Date().toISOString(),
459 score,
460 grade: gradeOf(score),
461 webScore: scoreChecks(web),
462 emailScore: scoreChecks(email),
463 failCount: checks.filter((c) => c.status === 'fail').length,
464 warnCount: checks.filter((c) => c.status === 'warn').length,
465 passCount: checks.filter((c) => c.status === 'pass').length,
466 topFixes: checks.filter((c) => c.status === 'fail' && c.recommendation).sort((a, b) => b.weight - a.weight).slice(0, 5).map((c) => c.recommendation),
467 ...facts,
468 checks,
469 error: !reachable && !dnsExists ? 'Domain not reachable over HTTPS and no DNS records found.' : null,
470 billable: reachable || dnsExists,
471 };
472}
473
474
475
476const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
477
478export function renderReport(rows) {
479 const color = { pass: '#1a7f37', warn: '#9a6700', fail: '#cf222e', info: '#57606a' };
480 const sections = rows.map((r) => `
481<section>
482 <h2>${esc(r.domain ?? r.input)} ${r.grade ? `<span class="grade g${esc(r.grade)}">${esc(r.grade)}</span>` : ''}</h2>
483 ${r.error ? `<p class="err">${esc(r.error)}</p>` : `<p>Score <b>${r.score}</b>/100 · web ${r.webScore ?? '–'} · email ${r.emailScore ?? '–'} · ${r.failCount} fail, ${r.warnCount} warn, ${r.passCount} pass</p>`}
484 <table><thead><tr><th>Status</th><th>Check</th><th>Finding</th><th>How to fix</th><th>Reference</th></tr></thead><tbody>
485 ${(r.checks ?? []).map((c) => `<tr><td style="color:${color[c.status]};font-weight:600">${esc(c.status.toUpperCase())}</td><td>${esc(c.title)}</td><td>${esc(c.detail)}</td><td>${esc(c.recommendation ?? '')}</td><td>${esc(c.reference ?? '')}</td></tr>`).join('')}
486 </tbody></table>
487</section>`).join('');
488 return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Website security & email trust report</title>
489<style>body{font:14px/1.5 system-ui,sans-serif;margin:24px;color:#1f2328;max-width:1100px}h1{font-size:22px}h2{font-size:18px;margin-top:32px}table{border-collapse:collapse;width:100%}th,td{border-bottom:1px solid #d0d7de;padding:6px 8px;text-align:left;vertical-align:top}th{background:#f6f8fa}.grade{display:inline-block;padding:0 10px;border-radius:6px;color:#fff;background:#57606a}.gA{background:#1a7f37}.gB{background:#4c8b2b}.gC{background:#9a6700}.gD{background:#bc4c00}.gF{background:#cf222e}.err{color:#cf222e}footer{margin-top:32px;color:#57606a;font-size:12px}</style></head>
490<body><h1>Website security & email trust report</h1><p>Generated ${esc(new Date().toISOString())}. Checks use public HTTP, TLS and DNS data only.</p>${sections}
491<footer>Automated technical checks based on public standards (RFCs, internet.nl test categories, NCSC-NL TLS guidelines). Indicative only; not a penetration test and not legal advice.</footer></body></html>`;
492}
493
494
495
496async function run() {
497 await Actor.init();
498 const input = (await Actor.getInput()) ?? {};
499 const raw = [...(input.domains ?? []), ...(input.startUrls ?? []).map((u) => (typeof u === 'string' ? u : u?.url))].filter(Boolean);
500 const unique = [...new Map(raw.map((d) => [normalizeDomain(d) ?? d, d])).values()].slice(0, input.maxDomains ?? 1000);
501 if (!unique.length) {
502 await Actor.fail('Add at least one domain or URL in "domains".');
503 return;
504 }
505 const opts = {
506 timeoutMs: Math.max(3, Math.min(60, input.timeoutSecs ?? 15)) * 1000,
507 dkimSelectors: input.checkDkim === false ? [] : (input.dkimSelectors?.length ? input.dkimSelectors : DEFAULT_DKIM_SELECTORS),
508 };
509 const concurrency = Math.max(1, Math.min(20, input.maxConcurrency ?? 5));
510 log.info(`Checking ${unique.length} domain(s) with concurrency ${concurrency}.`);
511
512 const rows = [];
513 let limitReached = false;
514 let idx = 0;
515 const worker = async () => {
516 while (idx < unique.length && !limitReached) {
517 const d = unique[idx++];
518 let row;
519 try {
520 row = await auditDomain(d, opts);
521 } catch (e) {
522 row = { input: d, domain: normalizeDomain(d), error: `Unexpected error: ${e.message}`, billable: false };
523 }
524 const { billable, ...clean } = row;
525 if (billable) {
526 try {
527 const r = await Actor.charge({ eventName: 'domain-checked', count: 1 });
528 if (r?.eventChargeLimitReached) limitReached = true;
529 } catch (e) {
530 log.warning(`Charging failed: ${e.message}`);
531 }
532 }
533 await Actor.pushData(clean);
534 rows.push(clean);
535 log.info(`${clean.domain ?? d}: ${clean.error ? `error (${clean.error})` : `score ${clean.score} (${clean.grade}), ${clean.failCount} fail`}`);
536 }
537 };
538 await Promise.all(Array.from({ length: concurrency }, worker));
539
540 if (input.generateReport !== false) {
541 await Actor.setValue('report.html', renderReport(rows), { contentType: 'text/html; charset=utf-8' });
542 }
543 const summary = {
544 domainsChecked: rows.filter((r) => !r.error).length,
545 domainsFailed: rows.filter((r) => r.error).length,
546 averageScore: rows.filter((r) => r.score !== null && r.score !== undefined).length ? Math.round(rows.filter((r) => r.score != null).reduce((s, r) => s + r.score, 0) / rows.filter((r) => r.score != null).length) : null,
547 stoppedAtChargeLimit: limitReached,
548 results: rows.map((r) => ({ domain: r.domain ?? r.input, score: r.score ?? null, grade: r.grade ?? null, failCount: r.failCount ?? null, topFixes: r.topFixes ?? [], error: r.error ?? null })),
549 };
550 await Actor.setValue('OUTPUT', summary);
551 if (limitReached) log.warning('Stopped early: the run reached your maximum charge limit.');
552 await Actor.exit();
553}
554
555if (!process.env.UNIT_TEST) await run();