1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6const DEFAULT_TIMEOUT_SECONDS = 10;
7const DEFAULT_DOH_URL = 'https://cloudflare-dns.com/dns-query';
8
9
10const TYPE_CAA = 257;
11const KNOWN_TAGS = ['issue', 'issuewild', 'iodef'];
12const IODEF_SCHEME_ALLOWLIST = ['mailto', 'http', 'https'];
13
14
15
16
17
18
19
20function isPrivateIPv4(ip) {
21 const parts = ip.split('.').map(Number);
22 if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return false;
23 const [a, b] = parts;
24 return a === 10
25 || (a === 172 && b >= 16 && b <= 31)
26 || (a === 192 && b === 168)
27 || a === 127
28 || a === 0
29 || (a === 169 && b === 254);
30}
31
32function isPrivateIPv6(ip) {
33 const normalized = ip.toLowerCase();
34 return normalized === '::1'
35 || normalized.startsWith('fc')
36 || normalized.startsWith('fd')
37 || normalized.startsWith('fe80:');
38}
39
40export async function normalizeAndValidateUrl(rawUrl) {
41 if (!rawUrl || typeof rawUrl !== 'string') throw new Error('startUrl is required');
42 if (/^[a-z][a-z0-9+.-]*:/i.test(rawUrl) && !/^https?:\/\//i.test(rawUrl)) {
43 throw new Error('Only HTTP and HTTPS URLs are supported');
44 }
45 const withScheme = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`;
46 const url = new URL(withScheme);
47 if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported');
48 if (!url.hostname || url.username || url.password) throw new Error('URL must be public and must not include credentials');
49
50 const literalType = net.isIP(url.hostname);
51 if (literalType === 4 && isPrivateIPv4(url.hostname)) throw new Error('Private IPv4 targets are blocked');
52 if (literalType === 6 && isPrivateIPv6(url.hostname)) throw new Error('Private IPv6 targets are blocked');
53
54 const records = literalType
55 ? [{ address: url.hostname, family: literalType }]
56 : await dns.lookup(url.hostname, { all: true });
57 for (const record of records) {
58 if (record.family === 4 && isPrivateIPv4(record.address)) throw new Error('DNS resolves to a private IPv4 address; blocked for SSRF safety');
59 if (record.family === 6 && isPrivateIPv6(record.address)) throw new Error('DNS resolves to a private IPv6 address; blocked for SSRF safety');
60 }
61 return url;
62}
63
64function clampInteger(value, fallback, min, max) {
65 const parsed = Number(value);
66 if (!Number.isFinite(parsed)) return fallback;
67 return Math.min(Math.max(Math.trunc(parsed), min), max);
68}
69
70
71
72
73
74async function dohQuery(name, type, timeoutSeconds, dohUrl) {
75 const controller = new AbortController();
76 const timer = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
77 try {
78 const url = `${dohUrl}?name=${encodeURIComponent(name)}&type=${type}`;
79 const res = await fetch(url, {
80 headers: { accept: 'application/dns-json' },
81 signal: controller.signal,
82 });
83 if (!res.ok) throw new Error(`DoH resolver returned HTTP ${res.status}`);
84 const body = await res.json();
85 if (!body || body.Status === undefined) throw new Error('DoH resolver returned an invalid response');
86 return body;
87 } finally {
88 clearTimeout(timer);
89 }
90}
91
92
93
94
95
96
97
98export function parseCaaRecords(answers) {
99 if (!Array.isArray(answers)) return [];
100 const records = [];
101 for (const answer of answers) {
102 if (!answer || answer.type !== TYPE_CAA) continue;
103
104 const structuredTag = typeof answer.tag === 'string' ? answer.tag.toLowerCase() : null;
105 const structuredValue = typeof answer.value === 'string' ? answer.value : null;
106 const structuredFlags = typeof answer.flags === 'number' ? answer.flags : null;
107
108 const data = structuredTag === null
109 ? (Array.isArray(answer.data)
110 ? answer.data.map((part) => (typeof part === 'string' ? part : '')).join('')
111 : String(answer.data ?? ''))
112 : `${structuredTag} ${structuredValue ?? ''}`;
113
114 const parsed = parseCaaValue(data);
115 records.push({
116 flags: structuredFlags ?? parsed?.flags ?? 0,
117 tag: parsed ? parsed.tag : structuredTag,
118 value: parsed ? parsed.value : structuredValue,
119 raw: data,
120 });
121 }
122 return records;
123}
124
125export function parseCaaValue(data) {
126 if (typeof data !== 'string' || data.trim().length === 0) return null;
127 let s = data.trim();
128
129 const flagsMatch = s.match(/^([01])\s+/);
130 let flags = 0;
131 if (flagsMatch) {
132 flags = Number(flagsMatch[1]);
133 s = s.slice(flagsMatch[0].length).trim();
134 }
135
136 const m = s.match(/^([a-z0-9]+)\s+"(.*)"$/i);
137 if (m) return { flags, tag: m[1].toLowerCase(), value: m[2] };
138
139 const m2 = s.match(/^"([^"]+)"\s+"(.*)"$/);
140 if (m2) return { flags, tag: m2[1].toLowerCase(), value: m2[2] };
141
142 const spaceIdx = s.indexOf(' ');
143 if (spaceIdx === -1) return null;
144 const tag = s.slice(0, spaceIdx).toLowerCase().replace(/"$/, '');
145 const value = s.slice(spaceIdx + 1).trim().replace(/^"|"$/g, '');
146 if (!/^[a-z0-9]+$/.test(tag)) return null;
147 return { flags, tag, value };
148}
149
150
151
152
153
154
155export function analyzeIodef(value) {
156 const result = { valid: false, scheme: null, target: null, issues: [] };
157 if (typeof value !== 'string' || value.length === 0) {
158 result.issues.push('iodef value is empty');
159 return result;
160 }
161 const schemeMatch = value.match(/^([a-z][a-z0-9+.-]*):/i);
162 if (!schemeMatch) {
163 result.issues.push('iodef value is not an absolute URI');
164 return result;
165 }
166 const scheme = schemeMatch[1].toLowerCase();
167 result.scheme = scheme;
168 if (!IODEF_SCHEME_ALLOWLIST.includes(scheme)) {
169 result.issues.push(`iodef scheme '${scheme}:' is not allowed (mailto:, http:, or https: only per RFC 8659)`);
170 return result;
171 }
172 if (scheme === 'mailto') {
173 const rest = value.slice('mailto:'.length);
174 if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(rest)) {
175 result.issues.push('iodef mailto value is not a syntactically valid email address');
176 return result;
177 }
178 result.target = rest;
179 result.valid = true;
180 return result;
181 }
182 try {
183 const url = new URL(value);
184 if (url.username || url.password) {
185 result.issues.push('iodef URL contains credentials');
186 return result;
187 }
188 if (net.isIP(url.hostname) === 4 && isPrivateIPv4(url.hostname)) {
189 result.issues.push('iodef URL targets a private IPv4 address');
190 return result;
191 }
192 if (net.isIP(url.hostname) === 6 && isPrivateIPv6(url.hostname)) {
193 result.issues.push('iodef URL targets a private IPv6 address');
194 return result;
195 }
196 result.target = url.origin + url.pathname + url.search;
197 result.valid = true;
198 return result;
199 } catch {
200 result.issues.push('iodef URL could not be parsed');
201 return result;
202 }
203}
204
205
206
207
208
209
210const KNOWN_CAS = [
211 'letsencrypt.org', 'pki.goog', 'amazontrust.com', 'amazonaws.com', 'digicert.com', 'sectigo.com',
212 'globalsign.com', 'entrust.net', 'godaddy.com', 'ssl.com', 'buypass.com',
213 'quovadisglobal.com', 'trustwave.com', 'identrust.com', 'comodoca.com',
214 'usertrust.com', 'wotrust.org', 'symantec.com', 'geotrust.com', 'thawte.com',
215 'rapidssl.com', 'starfieldtech.com', 'cacert.org', 'isrg.org', 'lencr.org',
216 'apple.com', 'swisssign.com', 'xolphin.com', 'secomtrust.com',
217 'harica.gr', 'netlock.hu', 'firmaprofesional.com', 'vtrus.com',
218 'gdca.com.cn', 'cfca.com.cn', 'cnnic.cn',
219];
220
221export function isKnownCa(domain) {
222 if (typeof domain !== 'string' || domain.length === 0) return false;
223 const lower = domain.toLowerCase();
224 return KNOWN_CAS.some((ca) => lower === ca || lower.endsWith(`.${ca}`));
225}
226
227
228
229
230
231const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
232
233export function analyzeCaaRecord(record) {
234 const issues = [];
235 const rec = {
236 flags: typeof record?.flags === 'number' ? record.flags : 0,
237 tag: typeof record?.tag === 'string' ? record.tag.toLowerCase() : '',
238 value: typeof record?.value === 'string' ? record.value : '',
239 raw: record?.raw ?? '',
240 issues,
241 };
242 if (rec.flags !== 0 && rec.flags !== 128) {
243 issues.push(`unknown flags value ${rec.flags} (RFC 8659 defines flag bit 0 only)`);
244 }
245 if (rec.flags === 128) {
246
247 if (KNOWN_TAGS.includes(rec.tag)) {
248 issues.push('critical flag (128) set on a mandatory tag; CAs must treat the property as unrecognized per RFC 8659 §5 — remove it');
249 }
250 }
251 if (!/^[a-z0-9]+$/.test(rec.tag)) {
252 issues.push(`invalid CAA tag '${rec.tag}'`);
253 return rec;
254 }
255 if (!KNOWN_TAGS.includes(rec.tag)) {
256 issues.push(`unknown CAA tag '${rec.tag}' (only issue, issuewild, and iodef are defined in RFC 8659); CAs must ignore it`);
257 return rec;
258 }
259 if (rec.tag === 'iodef') {
260 const analysis = analyzeIodef(rec.value);
261 rec.iodef = analysis;
262 if (!analysis.valid) issues.push(...analysis.issues);
263 return rec;
264 }
265
266 if (rec.value === ';') {
267 rec.domain = '';
268 rec.parameters = [];
269 return rec;
270 }
271 const semi = rec.value.indexOf(';');
272 const domainPart = semi === -1 ? rec.value : rec.value.slice(0, semi);
273 const paramPart = semi === -1 ? '' : rec.value.slice(semi + 1);
274 const domain = domainPart.trim().toLowerCase();
275 rec.domain = domain;
276 if (!DOMAIN_RE.test(domain)) {
277 issues.push(`issue record domain '${domain}' is not a syntactically valid domain`);
278 } else if (!isKnownCa(domain)) {
279 issues.push(`issue record domain '${domain}' is not a recognized certificate authority`);
280 }
281 const parameters = [];
282 for (const kv of paramPart.split(';')) {
283 const trimmed = kv.trim();
284 if (!trimmed) continue;
285 const eq = trimmed.indexOf('=');
286 if (eq === -1) {
287 parameters.push({ name: trimmed, value: null, issues: [`parameter '${trimmed}' is missing '=' (RFC 8659 parameters are name=value pairs)`] });
288 } else {
289 parameters.push({ name: trimmed.slice(0, eq).trim(), value: trimmed.slice(eq + 1).trim(), issues: [] });
290 }
291 }
292 rec.parameters = parameters;
293 const names = parameters.map((p) => p.name);
294 if (names.filter((n) => n === 'accounturi').length > 1) issues.push('duplicate accounturi parameter');
295 if (names.filter((n) => n === 'validationmethods').length > 1) issues.push('duplicate validationmethods parameter');
296 return rec;
297}
298
299
300
301
302
303export function evaluatePolicy(records) {
304 const issueRecords = records.filter((r) => r.tag === 'issue');
305 const issuewildRecords = records.filter((r) => r.tag === 'issuewild');
306 const iodefRecords = records.filter((r) => r.tag === 'iodef');
307 const policy = {
308 issueTags: issueRecords.length,
309 issuewildTags: issuewildRecords.length,
310 iodefTags: iodefRecords.length,
311 issuers: issueRecords.map((r) => r.domain).filter((d) => d !== undefined && d !== ''),
312 issuersRecognized: issueRecords.filter((r) => isKnownCa(r.domain)).map((r) => r.domain),
313 issuewildOverrides: issuewildRecords.map((r) => r.domain),
314 wildcardPolicy: null,
315 blockAllIssuance: false,
316 };
317
318 if (issueRecords.some((r) => r.domain === '')) {
319 policy.blockAllIssuance = true;
320 }
321 if (issuewildRecords.some((r) => r.domain === '')) {
322 policy.wildcardPolicy = 'wildcards blocked (issuewild ";") — base names still governed by issue records';
323 } else if (issuewildRecords.length > 0) {
324 policy.wildcardPolicy = `wildcards restricted to: ${policy.issuewildOverrides.join(', ')}`;
325 }
326 return policy;
327}
328
329
330
331
332
333export function scoreCaaAudit(result) {
334 const issues = [];
335 let score = 100;
336
337 if (!result.caaFound) {
338 return {
339 score: 0,
340 grade: 'F',
341 issues: ['no CAA records found — any certificate authority can issue certificates for this domain'],
342 recommendations: [
343 'Publish a CAA record set (RFC 8659) to restrict which CAs can issue certificates for the domain',
344 'Start with an issue record for your current CA, then add an iodef contact for violation reports',
345 ],
346 };
347 }
348 if (!result.hasIssueTag) {
349 issues.push('no issue tag present — the CAA set does not restrict issuance (iodef-only sets authorize every CA)');
350 score -= 35;
351 }
352 if (result.hasIssuewildOnly) {
353 issues.push('only issuewild records present (no issue) — non-wildcard names are unrestricted');
354 score -= 25;
355 }
356 if (!result.hasIodef) {
357 issues.push('no iodef reporting contact — CA policy violations go unreported');
358 score -= 10;
359 } else if (!result.iodefValid) {
360 issues.push('iodef contact present but invalid — violation reports cannot reach it');
361 score -= 5;
362 }
363 if (result.unknownTagCount > 0) {
364 issues.push(`${result.unknownTagCount} record(s) use unknown CAA tags; CAs must ignore them`);
365 score -= 5;
366 }
367 if (result.blockAllIssuance && result.hasIssueTag) {
368 issues.push('all issuance blocked (";") — confirm this is intentional; certificates cannot be issued until records change');
369 }
370 if (result.unknownCaCount > 0) {
371 issues.push(`${result.unknownCaCount} issue record(s) reference unrecognized CA domains — verify they are real CAs`);
372 score -= 5;
373 }
374 if (result.recordIssues.length > 0) {
375 score -= Math.min(15, result.recordIssues.length * 5);
376 }
377
378 score = Math.max(0, Math.min(100, score));
379 const grade = score >= 90 ? 'A' : score >= 80 ? 'B' : score >= 70 ? 'C' : score >= 60 ? 'D' : 'F';
380 return { score, grade, issues, recommendations: buildRecommendations(result) };
381}
382
383function buildRecommendations(result) {
384 const recommendations = [];
385 if (!result.caaFound) {
386 recommendations.push('Publish CAA records (RFC 8659) to restrict which CAs may issue certificates for the domain');
387 return recommendations;
388 }
389 if (!result.hasIssueTag) {
390 recommendations.push('Add an issue record naming your CA (e.g. 0 issue "letsencrypt.org") so issuance is actually restricted');
391 }
392 if (result.hasIssuewildOnly) {
393 recommendations.push('Add a base issue record so non-wildcard names are restricted as well');
394 }
395 if (!result.hasIodef) {
396 recommendations.push('Add an iodef record (e.g. 0 iodef "mailto:security@example.com") to receive CA violation reports');
397 } else if (!result.iodefValid) {
398 recommendations.push('Fix the iodef contact so CA violation reports reach your team');
399 }
400 if (result.unknownTagCount > 0) {
401 recommendations.push('Remove or correct unknown CAA tags; CAs ignore records they do not understand');
402 }
403 if (result.unknownCaCount > 0) {
404 recommendations.push('Verify that unrecognized issue domains are real CAs, otherwise remove them');
405 }
406 if (result.blockAllIssuance) {
407 recommendations.push('All issuance is blocked by "issue ;" — update the record set before your next certificate renewal');
408 }
409 return recommendations;
410}
411
412
413
414
415
416export async function auditCaa(rawUrl, options = {}) {
417 const timeoutSeconds = clampInteger(options.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS, 3, 30);
418 const dohUrl = options.dohUrl || DEFAULT_DOH_URL;
419
420 const url = await normalizeAndValidateUrl(rawUrl);
421 const hostname = url.hostname.toLowerCase();
422
423 const body = await dohQuery(hostname, 'CAA', timeoutSeconds, dohUrl);
424 const answers = Array.isArray(body.Answer) ? body.Answer : [];
425
426 const records = parseCaaRecords(answers).map((r) => analyzeCaaRecord(r));
427
428 const tagCounts = {};
429 for (const r of records) tagCounts[r.tag] = (tagCounts[r.tag] || 0) + 1;
430
431 const policy = evaluatePolicy(records);
432 const iodefRecords = records.filter((r) => r.tag === 'iodef');
433 const iodef = iodefRecords.length > 0 ? iodefRecords[0].iodef : null;
434
435 const hasIssueTag = (tagCounts.issue ?? 0) > 0;
436 const hasIssuewildTag = (tagCounts.issuewild ?? 0) > 0;
437 const hasIodef = (tagCounts.iodef ?? 0) > 0;
438 const unknownTagCount = records.filter((r) => !KNOWN_TAGS.includes(r.tag)).length;
439 const unknownCaCount = records.filter(
440 (r) => (r.tag === 'issue' || r.tag === 'issuewild') && r.domain && DOMAIN_RE.test(r.domain) && !isKnownCa(r.domain),
441 ).length;
442
443 const summary = {
444 url: url.href,
445 domain: hostname,
446 caaFound: records.length > 0,
447 recordCount: records.length,
448 records,
449 tagCounts,
450 policy,
451 issuers: policy.issuers,
452 issuersRecognized: policy.issuersRecognized,
453 blockAllIssuance: policy.blockAllIssuance,
454 hasIssueTag,
455 hasIssuewildTag,
456 hasIodef,
457 iodefValid: hasIodef ? (iodef ? iodef.valid : false) : null,
458 iodef,
459 hasIssuewildOnly: hasIssuewildTag && !hasIssueTag,
460 unknownTagCount,
461 unknownCaCount,
462 hasDuplicateTags: Object.values(tagCounts).some((c) => c > 1),
463 recordIssues: records.flatMap((r) => r.issues),
464 checkedAt: new Date().toISOString(),
465 };
466 const { score, grade, issues, recommendations } = scoreCaaAudit(summary);
467 return { ...summary, score, grade, issues, recommendations };
468}
469
470const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
471
472if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
473 await Actor.init();
474 try {
475 const input = await Actor.getInput();
476 if (!input || !input.startUrl) {
477 throw new Error('startUrl is required');
478 }
479 const audit = await auditCaa(input.startUrl, {
480 timeoutSeconds: input.timeoutSeconds,
481 dohUrl: process.env.CAA_DOH_URL,
482 });
483 await Actor.pushData([audit]);
484 console.log(`CAA audit complete: ${audit.domain} — score ${audit.score} (${audit.grade})`);
485 } catch (error) {
486 console.error(`CAA audit failed: ${error.message}`);
487 throw error;
488 } finally {
489 await Actor.exit();
490 }
491}