1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6
7
8
9
10const DEFAULT_RECORD_TYPES = ['A', 'AAAA', 'MX', 'TXT', 'DNSKEY'];
11const DEFAULT_TIMEOUT_SECONDS = 10;
12const DEFAULT_DOH_URL = 'https://cloudflare-dns.com/dns-query';
13
14
15const TYPE_A = 1;
16const TYPE_AAAA = 28;
17const TYPE_MX = 15;
18const TYPE_TXT = 16;
19const TYPE_DNSKEY = 48;
20const TYPE_DS = 43;
21const TYPE_RRSIG = 46;
22
23const TYPE_NAME_TO_NUMBER = { A: 1, AAAA: 28, MX: 15, TXT: 16, DNSKEY: 48, DS: 43, RRSIG: 46 };
24const TYPE_NUMBER_TO_NAME = { 1: 'A', 28: 'AAAA', 15: 'MX', 16: 'TXT', 48: 'DNSKEY', 43: 'DS', 46: 'RRSIG' };
25
26function isPrivateIPv4(ip) {
27 const parts = ip.split('.').map(Number);
28 if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return false;
29 const [a, b] = parts;
30 return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a === 127 || a === 0 || (a === 169 && b === 254);
31}
32
33function isPrivateIPv6(ip) {
34 const normalized = ip.toLowerCase();
35 return normalized === '::1' || normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe80:');
36}
37
38export async function normalizeAndValidateUrl(rawUrl) {
39 if (!rawUrl || typeof rawUrl !== 'string') throw new Error('startUrl is required');
40 if (/^[a-z][a-z0-9+.-]*:/i.test(rawUrl) && !/^https?:\/\//i.test(rawUrl)) throw new Error('Only HTTP and HTTPS URLs are supported');
41 const withScheme = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`;
42 const url = new URL(withScheme);
43 if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported');
44 if (!url.hostname || url.username || url.password) throw new Error('URL must be public and must not include credentials');
45
46 const literalType = net.isIP(url.hostname);
47 if (literalType === 4 && isPrivateIPv4(url.hostname)) throw new Error('Private IPv4 targets are blocked');
48 if (literalType === 6 && isPrivateIPv6(url.hostname)) throw new Error('Private IPv6 targets are blocked');
49
50 const records = literalType ? [{ address: url.hostname, family: literalType }] : await dns.lookup(url.hostname, { all: true });
51 for (const record of records) {
52 if (record.family === 4 && isPrivateIPv4(record.address)) throw new Error('DNS resolves to a private IPv4 address; blocked for SSRF safety');
53 if (record.family === 6 && isPrivateIPv6(record.address)) throw new Error('DNS resolves to a private IPv6 address; blocked for SSRF safety');
54 }
55 return url;
56}
57
58function clampInteger(value, fallback, min, max) {
59 const parsed = Number(value);
60 if (!Number.isFinite(parsed)) return fallback;
61 return Math.min(Math.max(Math.trunc(parsed), min), max);
62}
63
64function getParentDomain(domain) {
65
66
67 const parts = domain.split('.');
68 if (parts.length <= 2) return domain;
69 return parts.slice(1).join('.');
70}
71
72async function dohQuery(name, type, timeoutSeconds, dohUrl) {
73 const controller = new AbortController();
74 const timer = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
75 try {
76 const url = `${dohUrl}?name=${encodeURIComponent(name)}&type=${type}`;
77 const res = await fetch(url, {
78 headers: { accept: 'application/dns-json' },
79 signal: controller.signal,
80 });
81 if (!res.ok) return { Status: res.status, Answer: [], AD: false };
82 return await res.json();
83 } catch {
84 return { Status: 99, Answer: [], AD: false };
85 } finally {
86 clearTimeout(timer);
87 }
88}
89
90export function parseDsData(dohAnswers) {
91
92 return dohAnswers.map((a) => {
93 const parts = (a.data || '').split(/\s+/);
94 return {
95 keyTag: parts[0] ? parseInt(parts[0], 10) : null,
96 algorithm: parts[1] ? parseInt(parts[1], 10) : null,
97 digestType: parts[2] ? parseInt(parts[2], 10) : null,
98 digest: parts.slice(3).join(' ') || null,
99 };
100 }).filter((r) => r.keyTag !== null);
101}
102
103export function parseDnskeyData(dohAnswers) {
104
105 return dohAnswers.map((a) => {
106 const parts = (a.data || '').split(/\s+/);
107 const flags = parts[0] ? parseInt(parts[0], 10) : null;
108 const protocol = parts[1] ? parseInt(parts[1], 10) : null;
109 const algorithm = parts[2] ? parseInt(parts[2], 10) : null;
110 const publicKey = parts.slice(3).join(' ') || null;
111 return {
112 flags,
113 algorithm,
114 protocol,
115 publicKey: publicKey ? `${publicKey.slice(0, 16)}...` : null,
116 };
117 }).filter((r) => r.flags !== null);
118}
119
120
121export function classifyDnskeyFlags(flags) {
122 if (flags === undefined || flags === null) return { zoneKey: false, secureEntryPoint: false, revoked: false };
123 return {
124 zoneKey: Boolean(flags & 0x100),
125 secureEntryPoint: Boolean(flags & 0x001),
126 revoked: Boolean(flags & 0x080),
127 };
128}
129
130export async function auditDnssec(input) {
131 const timeoutSeconds = clampInteger(input.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS, 3, 30);
132 const recordTypes = input.recordTypes?.length ? input.recordTypes : DEFAULT_RECORD_TYPES;
133 const dohUrl = DEFAULT_DOH_URL;
134
135 let domain;
136 try {
137 const target = await normalizeAndValidateUrl(input.startUrl);
138 domain = target.hostname;
139 } catch (err) {
140 return {
141 inputUrl: input.startUrl, domain: null, checkedAt: new Date().toISOString(),
142 dsAtParent: { found: false, records: [], issues: [] },
143 dnskey: { found: false, records: [], keySigningKeys: [], zoneSigningKeys: [], issues: [] },
144 rrsigCoverage: { checked: [], signed: [], unsigned: [], issues: [] },
145 adBitCheck: { supported: null, issues: [] },
146 score: 0, grade: 'F', issues: [err.message], recommendations: [], error: err.message,
147 };
148 }
149
150 const parentDomain = getParentDomain(domain);
151
152
153 const dsResponse = await dohQuery(domain, 'DS', timeoutSeconds, dohUrl);
154 const dsAnswers = (dsResponse.Answer || []).filter((a) => a.type === TYPE_DS);
155 const dsParsed = parseDsData(dsAnswers);
156
157
158 const dnskeyResponse = await dohQuery(domain, 'DNSKEY', timeoutSeconds, dohUrl);
159 const dnskeyAnswers = (dnskeyResponse.Answer || []).filter((a) => a.type === TYPE_DNSKEY);
160 const dnskeyParsed = parseDnskeyData(dnskeyAnswers);
161 const kskKeys = dnskeyParsed.filter((k) => classifyDnskeyFlags(k.flags).secureEntryPoint);
162 const zskKeys = dnskeyParsed.filter((k) => classifyDnskeyFlags(k.flags).zoneKey && !classifyDnskeyFlags(k.flags).secureEntryPoint);
163
164
165 const rrsigChecks = await Promise.all(
166 recordTypes.map(async (type) => {
167 const response = await dohQuery(domain, type, timeoutSeconds, dohUrl);
168 const answers = response.Answer || [];
169 const typeNum = TYPE_NAME_TO_NUMBER[type.toUpperCase()];
170 const hasAnswer = answers.some((a) => a.type === typeNum);
171 const hasRrsig = answers.some((a) => a.type === TYPE_RRSIG);
172 return { type, hasAnswer, hasRrsig, signed: hasRrsig };
173 }),
174 );
175 const signedTypes = rrsigChecks.filter((c) => c.signed).map((c) => c.type);
176 const unsignedTypes = rrsigChecks.filter((c) => c.hasAnswer && !c.signed).map((c) => c.type);
177
178
179 const adSupported = Boolean(dnskeyResponse.AD);
180
181
182 let value = 100;
183 const issues = [];
184 const recommendations = [];
185
186 if (!dsParsed.length) {
187 value -= 35;
188 issues.push('No DS records found at parent zone — DNSSEC chain of trust is not anchored');
189 recommendations.push('Publish DS records at the parent zone (registrar) to establish a chain of trust from the root to your domain.');
190 } else {
191 issues.push(`${dsParsed.length} DS record(s) found at parent zone`);
192 }
193
194 if (!dnskeyParsed.length) {
195 value -= 35;
196 issues.push('No DNSKEY records found at the zone');
197 recommendations.push('Generate DNSSEC key pairs (KSK and ZSK) and publish DNSKEY records at the zone apex.');
198 } else {
199 if (kskKeys.length === 0) {
200 value -= 15;
201 issues.push('No KSK (key-signing key) found — DS at parent cannot be verified against zone DNSKEY');
202 recommendations.push('Create a KSK (flags 257) and use it to sign the DNSKEY RRset so DS records at the parent can be verified.');
203 }
204 if (zskKeys.length === 0) {
205 value -= 10;
206 issues.push('No ZSK (zone-signing key) found — record RRsets cannot be signed');
207 recommendations.push('Create a ZSK (flags 256) to sign non-DNSKEY record RRsets (A, AAAA, MX, TXT, etc.).');
208 }
209 }
210
211 if (unsignedTypes.length > 0) {
212 value -= 15 * Math.min(unsignedTypes.length, 3);
213 issues.push(`RRSIG coverage missing for: ${unsignedTypes.join(', ')}`);
214 recommendations.push(`Sign ${unsignedTypes.join(', ')} RRsets with RRSIG records using the zone's ZSK.`);
215 }
216
217 if (!adSupported) {
218 value -= 10;
219 issues.push('Recursive resolver did not return AD bit — DNSSEC validation may not be enforced upstream');
220 recommendations.push('Ensure upstream resolvers validate DNSSEC and set the AD bit on validated responses.');
221 }
222
223 if (dsParsed.length > 0 && dnskeyParsed.length > 0 && kskKeys.length > 0 && zskKeys.length > 0 && unsignedTypes.length === 0) {
224 issues.push('DNSSEC chain of trust appears complete: DS at parent, DNSKEY with KSK+ZSK, RRSIG on checked types');
225 }
226
227 const bounded = Math.max(0, Math.min(100, value));
228 const grade = bounded >= 90 ? 'A' : bounded >= 75 ? 'B' : bounded >= 60 ? 'C' : bounded >= 45 ? 'D' : 'F';
229
230 return {
231 inputUrl: input.startUrl, domain, checkedAt: new Date().toISOString(),
232 dsAtParent: {
233 found: dsParsed.length > 0,
234 parentDomain,
235 recordCount: dsParsed.length,
236 records: dsParsed,
237 issues: dsParsed.length === 0 ? ['No DS records found at parent zone'] : [],
238 },
239 dnskey: {
240 found: dnskeyParsed.length > 0,
241 keyCount: dnskeyParsed.length,
242 keySigningKeys: kskKeys,
243 zoneSigningKeys: zskKeys,
244 records: dnskeyParsed,
245 issues: [
246 ...(kskKeys.length === 0 ? ['No KSK found'] : []),
247 ...(zskKeys.length === 0 ? ['No ZSK found'] : []),
248 ],
249 },
250 rrsigCoverage: {
251 checked: recordTypes,
252 signed: signedTypes,
253 unsigned: unsignedTypes,
254 details: rrsigChecks.map((c) => ({ type: c.type, hasAnswer: c.hasAnswer, signed: c.signed })),
255 issues: unsignedTypes.length > 0 ? [`Missing RRSIG for: ${unsignedTypes.join(', ')}`] : [],
256 },
257 adBitCheck: {
258 supported: adSupported,
259 issues: adSupported ? [] : ['Recursive resolver did not return AD bit'],
260 },
261 score: bounded, grade, issues, recommendations, error: null,
262 };
263}
264
265const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
266
267if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
268 await Actor.init();
269 try {
270 const result = await auditDnssec(await Actor.getInput() || {});
271 await Actor.pushData(result);
272 await Actor.setValue('OUTPUT', result);
273 Actor.log.info('DNSSEC validation complete', { domain: result.domain, score: result.score, grade: result.grade });
274 } finally {
275 await Actor.exit();
276 }
277}