1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6const DEFAULT_DKIM_SELECTORS = ['google', 'selector1', 'selector2', 'default', 's1', 's2', 'k1'];
7const DEFAULT_TIMEOUT_SECONDS = 10;
8
9function isPrivateIPv4(ip) {
10 const parts = ip.split('.').map(Number);
11 if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return false;
12 const [a, b] = parts;
13 return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a === 127 || a === 0 || (a === 169 && b === 254);
14}
15
16function isPrivateIPv6(ip) {
17 const normalized = ip.toLowerCase();
18 return normalized === '::1' || normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe80:');
19}
20
21export async function normalizeAndValidateUrl(rawUrl) {
22 if (!rawUrl || typeof rawUrl !== 'string') throw new Error('startUrl is required');
23 if (/^[a-z][a-z0-9+.-]*:/i.test(rawUrl) && !/^https?:\/\//i.test(rawUrl)) throw new Error('Only HTTP and HTTPS URLs are supported');
24 const withScheme = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`;
25 const url = new URL(withScheme);
26 if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported');
27 if (!url.hostname || url.username || url.password) throw new Error('URL must be public and must not include credentials');
28
29 const literalType = net.isIP(url.hostname);
30 if (literalType === 4 && isPrivateIPv4(url.hostname)) throw new Error('Private IPv4 targets are blocked');
31 if (literalType === 6 && isPrivateIPv6(url.hostname)) throw new Error('Private IPv6 targets are blocked');
32
33 const records = literalType ? [{ address: url.hostname, family: literalType }] : await dns.lookup(url.hostname, { all: true });
34 for (const record of records) {
35 if (record.family === 4 && isPrivateIPv4(record.address)) throw new Error('DNS resolves to a private IPv4 address; blocked for SSRF safety');
36 if (record.family === 6 && isPrivateIPv6(record.address)) throw new Error('DNS resolves to a private IPv6 address; blocked for SSRF safety');
37 }
38 return url;
39}
40
41function clampInteger(value, fallback, min, max) {
42 const parsed = Number(value);
43 if (!Number.isFinite(parsed)) return fallback;
44 return Math.min(Math.max(Math.trunc(parsed), min), max);
45}
46
47function joinTxt(records) {
48 return records.map((r) => (Array.isArray(r) ? r.join('') : r));
49}
50
51async function resolveTxt(hostname, timeoutSeconds) {
52 try {
53 const result = await Promise.race([
54 dns.resolveTxt(hostname),
55 new Promise((_, reject) => setTimeout(() => reject(new Error(`DNS timeout for ${hostname}`)), timeoutSeconds * 1000)),
56 ]);
57 return joinTxt(result);
58 } catch {
59 return [];
60 }
61}
62
63export function parseSpf(txtRecords) {
64 const spfRecord = txtRecords.find((r) => /^v=spf1/i.test(r));
65 if (!spfRecord) return { found: false, record: null, allPolicy: null, dnsLookups: 0, lookupLimitExceeded: false, mechanisms: [], issues: ['No SPF record found'] };
66
67 const tokens = spfRecord.split(/\s+/).filter(Boolean);
68 const mechanisms = tokens.slice(1);
69 const allMech = mechanisms.find((m) => /^[?+~-]?all$/i.test(m));
70 let allPolicy = 'none';
71 if (allMech) {
72 if (allMech.startsWith('-')) allPolicy = 'hardfail';
73 else if (allMech.startsWith('~')) allPolicy = 'softfail';
74 else if (allMech.startsWith('?')) allPolicy = 'neutral';
75 else allPolicy = 'pass';
76 }
77
78 const dnsLookups = mechanisms.filter((m) => /^(include:|a\b|mx\b|exists:|redirect=)/i.test(m)).length;
79 const issues = [];
80 if (allPolicy === 'pass') issues.push('SPF uses +all which allows any sender — severe security risk');
81 else if (allPolicy === 'neutral') issues.push('SPF uses ?all (neutral) which provides no enforcement');
82 else if (allPolicy === 'none') issues.push('SPF record has no all mechanism — no enforcement policy');
83 else if (allPolicy === 'softfail') issues.push('SPF uses ~all (softfail) instead of -all (hardfail) for stronger enforcement');
84 if (dnsLookups > 10) issues.push(`SPF has ${dnsLookups} DNS lookups, exceeding the 10-lookup limit`);
85 return { found: true, record: spfRecord, allPolicy, dnsLookups, lookupLimitExceeded: dnsLookups > 10, mechanisms, issues };
86}
87
88export function parseDmarc(txtRecords) {
89 const dmarcRecord = txtRecords.find((r) => /^v=DMARC1/i.test(r));
90 if (!dmarcRecord) return { found: false, record: null, policy: null, pct: null, rua: null, ruf: null, adkim: null, aspf: null, sp: null, issues: ['No DMARC record found'] };
91
92 const tags = {};
93 dmarcRecord.split(';').forEach((tag) => {
94 const idx = tag.indexOf('=');
95 if (idx > 0) tags[tag.slice(0, idx).trim()] = tag.slice(idx + 1).trim();
96 });
97 const issues = [];
98 const policy = tags.p || null;
99 const pct = tags.pct ? parseInt(tags.pct, 10) : 100;
100 if (!policy) issues.push('DMARC record missing p= policy tag');
101 else if (policy === 'none') issues.push('DMARC policy is p=none (monitoring only, no enforcement)');
102 else if (policy === 'quarantine') issues.push('DMARC policy is p=quarantine — consider p=reject for stronger enforcement');
103 if (!tags.rua) issues.push('DMARC record missing rua= (aggregate report destination)');
104 if (pct < 100 && policy !== 'none') issues.push(`DMARC pct=${pct} applies policy to only ${pct}% of mail`);
105 return { found: true, record: dmarcRecord, policy, pct, rua: tags.rua || null, ruf: tags.ruf || null, adkim: tags.adkim || null, aspf: tags.aspf || null, sp: tags.sp || null, issues };
106}
107
108export function parseDkim(txtRecord) {
109 if (!txtRecord) return null;
110 const tags = {};
111 txtRecord.split(';').forEach((tag) => {
112 const idx = tag.indexOf('=');
113 if (idx > 0) tags[tag.slice(0, idx).trim()] = tag.slice(idx + 1).trim();
114 });
115 return { version: tags.v || null, keyType: tags.k || 'rsa', publicKey: tags.p ? 'present' : null, hasPublicKey: Boolean(tags.p), notes: tags.n || null };
116}
117
118export async function auditEmailAuth(input) {
119 const timeoutSeconds = clampInteger(input.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS, 3, 30);
120 const selectors = input.dkimSelectors?.length ? input.dkimSelectors : DEFAULT_DKIM_SELECTORS;
121
122 let domain;
123 try {
124 const target = await normalizeAndValidateUrl(input.startUrl);
125 domain = target.hostname;
126 } catch (err) {
127 return {
128 inputUrl: input.startUrl, domain: null, checkedAt: new Date().toISOString(),
129 spf: { found: false, record: null, allPolicy: null, dnsLookups: 0, lookupLimitExceeded: false, mechanisms: [], issues: [] },
130 dmarc: { found: false, record: null, policy: null, pct: null, rua: null, ruf: null, adkim: null, aspf: null, sp: null, issues: [] },
131 dkim: { selectorsChecked: selectors, foundSelectors: [], records: [], issues: [] },
132 score: 0, grade: 'F', issues: [err.message], recommendations: [], error: err.message,
133 };
134 }
135
136 const [domainTxt, dmarcTxt] = await Promise.all([
137 resolveTxt(domain, timeoutSeconds),
138 resolveTxt(`_dmarc.${domain}`, timeoutSeconds),
139 ]);
140 const spf = parseSpf(domainTxt);
141 const dmarc = parseDmarc(dmarcTxt);
142
143 const dkimResults = await Promise.all(
144 selectors.map(async (selector) => {
145 const records = await resolveTxt(`${selector}._domainkey.${domain}`, timeoutSeconds);
146 const dkimRecord = records.find((r) => /v=DKIM1/i.test(r) || /k=rsa/i.test(r) || /\bp=/i.test(r));
147 return { selector, found: Boolean(dkimRecord), record: dkimRecord, parsed: parseDkim(dkimRecord) };
148 }),
149 );
150 const foundDkim = dkimResults.filter((r) => r.found);
151
152 let value = 100;
153 const issues = [];
154 const recommendations = [];
155
156 if (!spf.found) {
157 value -= 40;
158 issues.push('No SPF record found');
159 recommendations.push('Publish an SPF record (v=spf1) listing authorized mail servers, ending with -all.');
160 } else {
161 issues.push(...spf.issues);
162 if (spf.allPolicy === 'pass') { value -= 30; recommendations.push('Replace +all with -all to prevent unauthorized senders.'); }
163 else if (spf.allPolicy === 'neutral') { value -= 20; recommendations.push('Replace ?all with -all to enforce sender authorization.'); }
164 else if (spf.allPolicy === 'none') { value -= 20; recommendations.push('Add -all to the SPF record to enforce sender authorization.'); }
165 else if (spf.allPolicy === 'softfail') { value -= 10; recommendations.push('Consider upgrading ~all to -all once all legitimate senders are covered.'); }
166 if (spf.lookupLimitExceeded) { value -= 15; recommendations.push('Reduce SPF DNS lookups to 10 or fewer by flattening include chains.'); }
167 }
168
169 if (!dmarc.found) {
170 value -= 30;
171 issues.push('No DMARC record found');
172 recommendations.push('Publish a DMARC record at _dmarc.<domain> with at least p=quarantine and a rua= report address.');
173 } else {
174 issues.push(...dmarc.issues);
175 if (dmarc.policy === 'none') { value -= 10; recommendations.push('Upgrade DMARC from p=none to p=quarantine or p=reject once monitoring is stable.'); }
176 else if (dmarc.policy === 'quarantine') { value -= 5; recommendations.push('Consider upgrading DMARC from p=quarantine to p=reject.'); }
177 if (!dmarc.rua) value -= 5;
178 if (dmarc.pct < 100 && dmarc.policy !== 'none') value -= 3;
179 }
180
181 if (foundDkim.length === 0) {
182 value -= 10;
183 issues.push('No DKIM records found for common selectors');
184 recommendations.push('Enable DKIM signing in your email provider and publish the public key at <selector>._domainkey.<domain>.');
185 }
186
187 const bounded = Math.max(0, value);
188 const grade = bounded >= 90 ? 'A' : bounded >= 75 ? 'B' : bounded >= 60 ? 'C' : bounded >= 45 ? 'D' : 'F';
189
190 return {
191 inputUrl: input.startUrl, domain, checkedAt: new Date().toISOString(),
192 spf, dmarc,
193 dkim: {
194 selectorsChecked: selectors,
195 foundSelectors: foundDkim.map((r) => r.selector),
196 records: foundDkim.map((r) => ({ selector: r.selector, record: r.record, parsed: r.parsed })),
197 issues: foundDkim.length === 0 ? ['No DKIM records found for checked selectors'] : [],
198 },
199 score: bounded, grade, issues, recommendations, error: null,
200 };
201}
202
203const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
204
205if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
206 await Actor.init();
207 try {
208 const result = await auditEmailAuth(await Actor.getInput() || {});
209 await Actor.pushData(result);
210 await Actor.setValue('OUTPUT', result);
211 Actor.log.info('Email auth audit complete', { domain: result.domain, score: result.score, grade: result.grade });
212 } finally {
213 await Actor.exit();
214 }
215}