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';
8const DEFAULT_LOGO_MAX_BYTES = 2 * 1024 * 1024;
9const DEFAULT_SELECTOR = 'default';
10const USER_AGENT = 'BimiRecordAuditor/0.1 (+https://console.apify.com/actors)';
11const MAX_REDIRECTS = 3;
12
13
14
15
16
17
18
19function isPrivateIPv4(ip) {
20 const parts = ip.split('.').map(Number);
21 if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return false;
22 const [a, b] = parts;
23 return a === 10
24 || (a === 172 && b >= 16 && b <= 31)
25 || (a === 192 && b === 168)
26 || a === 127
27 || a === 0
28 || (a === 169 && b === 254);
29}
30
31function isPrivateIPv6(ip) {
32 const normalized = ip.toLowerCase();
33 return normalized === '::1'
34 || normalized.startsWith('fc')
35 || normalized.startsWith('fd')
36 || normalized.startsWith('fe80:');
37}
38
39export async function normalizeAndValidateUrl(rawUrl, { requireHttps = false } = {}) {
40 if (!rawUrl || typeof rawUrl !== 'string') throw new Error('startUrl is required');
41 if (/^[a-z][a-z0-9+.-]*:/i.test(rawUrl) && !/^https?:\/\//i.test(rawUrl)) {
42 throw new Error('Only HTTP and HTTPS URLs are supported');
43 }
44 const withScheme = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`;
45 const url = new URL(withScheme);
46 if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported');
47 if (requireHttps && url.protocol !== 'https:') throw new Error('BIMI logo URLs must use HTTPS');
48 if (!url.hostname || url.username || url.password) throw new Error('URL must be public and must not include credentials');
49
50
51
52 const bareHostname = url.hostname.startsWith('[') && url.hostname.endsWith(']')
53 ? url.hostname.slice(1, -1)
54 : url.hostname;
55 const literalType = net.isIP(bareHostname);
56 if (literalType === 4 && isPrivateIPv4(bareHostname)) throw new Error('Private IPv4 targets are blocked');
57 if (literalType === 6 && isPrivateIPv6(bareHostname)) throw new Error('Private IPv6 targets are blocked');
58
59 const records = literalType
60 ? [{ address: url.hostname, family: literalType }]
61 : await dns.lookup(url.hostname, { all: true });
62 for (const record of records) {
63 if (record.family === 4 && isPrivateIPv4(record.address)) throw new Error('DNS resolves to a private IPv4 address; blocked for SSRF safety');
64 if (record.family === 6 && isPrivateIPv6(record.address)) throw new Error('DNS resolves to a private IPv6 address; blocked for SSRF safety');
65 }
66 return url;
67}
68
69function clampInteger(value, fallback, min, max) {
70 const parsed = Number(value);
71 if (!Number.isFinite(parsed)) return fallback;
72 return Math.min(Math.max(Math.trunc(parsed), min), max);
73}
74
75
76
77
78
79
80async function dohTxt(name, timeoutSeconds, dohUrl) {
81 const controller = new AbortController();
82 const timer = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
83 try {
84 const url = `${dohUrl}?name=${encodeURIComponent(name)}&type=TXT`;
85 const res = await fetch(url, {
86 headers: { accept: 'application/dns-json' },
87 signal: controller.signal,
88 });
89 if (!res.ok) throw new Error(`DoH resolver returned HTTP ${res.status}`);
90 const body = await res.json();
91 if (!body || body.Status === undefined) throw new Error('DoH resolver returned an invalid response');
92 const answers = Array.isArray(body.Answer) ? body.Answer : [];
93 return answers
94 .filter((a) => a && a.type === 16)
95 .map((a) => {
96 let data = typeof a.data === 'string' ? a.data : '';
97 if (data.length >= 2 && data.startsWith('"') && data.endsWith('"')) data = data.slice(1, -1);
98 return data;
99 });
100 } finally {
101 clearTimeout(timer);
102 }
103}
104
105
106
107
108
109
110async function fetchLogo(initialUrl, timeoutSeconds, maxBytes, redirectsRemaining = MAX_REDIRECTS) {
111 const url = await normalizeAndValidateUrl(initialUrl.href, { requireHttps: true });
112 const controller = new AbortController();
113 const timer = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
114 try {
115 const response = await fetch(url, {
116 redirect: 'manual',
117 signal: controller.signal,
118 headers: {
119 'user-agent': USER_AGENT,
120 accept: 'image/svg+xml,image/*;q=0.8,*/*;q=0.1',
121 },
122 });
123
124 if ([301, 302, 303, 307, 308].includes(response.status)) {
125 if (redirectsRemaining <= 0) throw new Error('Too many redirects');
126 const location = response.headers.get('location');
127 if (!location) throw new Error('Redirect without Location header');
128 const nextUrl = new URL(location, url.href);
129 try { await response.arrayBuffer(); } catch { }
130 return fetchLogo(nextUrl, timeoutSeconds, maxBytes, redirectsRemaining - 1);
131 }
132
133 const contentType = (response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase() || null;
134 const contentLengthHeader = response.headers.get('content-length');
135 const declaredLength = contentLengthHeader !== null ? Number(contentLengthHeader) : null;
136 if (declaredLength !== null && Number.isFinite(declaredLength) && declaredLength > maxBytes) {
137 try { await response.arrayBuffer(); } catch { }
138 throw new Error(`Logo response exceeds the ${maxBytes}-byte limit (Content-Length: ${declaredLength})`);
139 }
140
141 let buffer = Buffer.alloc(0);
142 try {
143 const arrayBuffer = await response.arrayBuffer();
144 buffer = Buffer.from(arrayBuffer);
145 } catch (error) {
146 if (error && error.name === 'AbortError') throw error;
147 throw new Error(`Logo fetch failed: ${error.message}`);
148 }
149 if (buffer.length > maxBytes) {
150 throw new Error(`Logo body exceeds the ${maxBytes}-byte limit (${buffer.length} bytes)`);
151 }
152 return {
153 status: response.status,
154 contentType,
155 contentLength: buffer.length,
156 buffer,
157 };
158 } finally {
159 clearTimeout(timer);
160 }
161}
162
163
164
165
166
167
168
169
170
171const SELECTOR_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
172const KNOWN_VERSIONS = ['bimi1', 'bimi2', 'bimi3'];
173
174export function parseBimiTagValue(data) {
175 if (typeof data !== 'string' || data.trim().length === 0) return null;
176 const m = data.trim().match(/^([a-z0-9]+)\s*=\s*(.*)$/i);
177 if (!m) return null;
178 return { tag: m[1].toLowerCase(), value: m[2] };
179}
180
181export function parseBimiRecord(recordText, expectedSelector) {
182 const result = {
183 selector: expectedSelector || null,
184 raw: typeof recordText === 'string' ? recordText : '',
185 tags: {},
186 tagIssues: [],
187 version: null,
188 logoUrl: null,
189 authorityUrl: null,
190 parseError: null,
191 };
192 if (typeof recordText !== 'string' || recordText.trim().length === 0) {
193 result.parseError = 'record is empty';
194 return result;
195 }
196
197
198
199 const parts = recordText.trim().split(/[;\s]+/).filter((p) => p.length > 0);
200 for (const part of parts) {
201 const parsed = parseBimiTagValue(part);
202 if (!parsed) {
203 result.tagIssues.push(`unparseable segment '${part}' (expected tag=value pairs)`);
204 continue;
205 }
206 if (result.tags[parsed.tag] !== undefined) {
207 result.tagIssues.push(`duplicate tag '${parsed.tag}'`);
208 continue;
209 }
210 result.tags[parsed.tag] = parsed.value;
211 }
212 result.version = result.tags.v !== undefined ? result.tags.v.toLowerCase() : null;
213 result.logoUrl = result.tags.l !== undefined ? result.tags.l : null;
214 result.authorityUrl = result.tags.a !== undefined ? result.tags.a : null;
215 return result;
216}
217
218export function analyzeBimiRecord(record, dmarcEnforced) {
219 const issues = [];
220 const rec = { ...record, issues };
221
222
223 if (rec.logoUrl === null) {
224 issues.push('missing required l= tag (logo location URL)');
225 } else {
226 try {
227 const url = new URL(rec.logoUrl);
228 if (url.protocol !== 'https:') {
229 issues.push(`logo URL '${rec.logoUrl}' must use HTTPS (BIMI requires https)`);
230 }
231 if (url.username || url.password) issues.push('logo URL contains credentials');
232 if (net.isIP(url.hostname) === 4 && isPrivateIPv4(url.hostname)) issues.push('logo URL targets a private IPv4 address');
233 if (net.isIP(url.hostname) === 6 && isPrivateIPv6(url.hostname)) issues.push('logo URL targets a private IPv6 address');
234 } catch {
235 issues.push(`logo URL '${rec.logoUrl}' is not an absolute URL`);
236 }
237 }
238
239
240 if (rec.version === null) {
241 issues.push('missing v= version tag; BIMI2+ receivers may reject the record');
242 } else if (!KNOWN_VERSIONS.includes(rec.version)) {
243 issues.push(`unknown version '${rec.version}' (known versions: ${KNOWN_VERSIONS.join(', ')})`);
244 }
245
246
247 if (rec.authorityUrl === null) {
248 if (rec.version === 'bimi1') {
249 issues.push('BIMI1 records require an a= authority evidence location (VMC/CMC certificate)');
250 } else {
251 issues.push('no a= authority evidence location; large receivers (Gmail, Apple Mail) require a Verified Mark Certificate for logo display');
252 }
253 } else {
254 try {
255 const url = new URL(rec.authorityUrl);
256 if (url.protocol !== 'https:') {
257 issues.push(`authority evidence URL '${rec.authorityUrl}' must use HTTPS`);
258 }
259 if (url.username || url.password) issues.push('authority evidence URL contains credentials');
260 if (net.isIP(url.hostname) === 4 && isPrivateIPv4(url.hostname)) issues.push('authority evidence URL targets a private IPv4 address');
261 if (net.isIP(url.hostname) === 6 && isPrivateIPv6(url.hostname)) issues.push('authority evidence URL targets a private IPv6 address');
262 } catch {
263 issues.push(`authority evidence URL '${rec.authorityUrl}' is not an absolute URL`);
264 }
265 }
266
267
268 if (rec.version === 'bimi1' && rec.authorityUrl === null) {
269 issues.push('legacy BIMI1 record without a= is not displayable — upgrade to v=BIMI2 with an a= VMC/CMC URL');
270 }
271
272
273 if (rec.tagIssues.length > 0) issues.push(...rec.tagIssues);
274
275
276 if (dmarcEnforced === false) {
277 issues.push('DMARC is not at enforcement (p=quarantine/reject) — receivers MUST NOT display the BIMI logo per spec');
278 } else if (dmarcEnforced === null) {
279 issues.push('DMARC policy could not be determined — verify the _dmarc TXT record');
280 }
281
282
283 for (const tag of Object.keys(rec.tags)) {
284 if (!['v', 'a', 'l'].includes(tag)) {
285 issues.push(`unknown BIMI tag '${tag}'`);
286 }
287 }
288 return rec;
289}
290
291
292
293
294
295
296export function parseDmarcPolicy(txtStrings) {
297 if (!Array.isArray(txtStrings) || txtStrings.length === 0) return null;
298
299 const dmarc = txtStrings.find((s) => /^v\s*=\s*DMARC1/i.test(s.trim()));
300 if (!dmarc) return null;
301 const policyMatch = dmarc.match(/\bp\s*=\s*([a-z]+)/i);
302 if (!policyMatch) return null;
303 return policyMatch[1].toLowerCase();
304}
305
306export function isDmarcEnforced(policy) {
307 return policy === 'quarantine' || policy === 'reject';
308}
309
310
311
312
313
314export async function validateLogo(logoUrl, options = {}) {
315 const timeoutSeconds = clampInteger(options.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS, 3, 30);
316 const maxBytes = clampInteger(options.maxLogoBytes, DEFAULT_LOGO_MAX_BYTES, 1024, 8 * 1024 * 1024);
317
318 const validation = {
319 checked: true,
320 url: logoUrl,
321 status: null,
322 contentType: null,
323 contentLength: null,
324 format: null,
325 formatIssues: [],
326 issues: [],
327 };
328
329 let fetchResult = null;
330 try {
331 fetchResult = await fetchLogo(logoUrl, timeoutSeconds, maxBytes);
332 validation.status = fetchResult.status;
333 validation.contentType = fetchResult.contentType;
334 validation.contentLength = fetchResult.contentLength;
335 } catch (error) {
336 validation.issues.push(`logo fetch failed: ${error.message}`);
337 return validation;
338 }
339
340 const buffer = fetchResult.buffer;
341 const svgText = buffer.subarray(0, 256).toString('utf8');
342 const isSvgText = /<svg[\s>]/i.test(svgText) || /^\s*(<\?xml[^>]*\?>\s*)?<svg[\s>]/i.test(svgText);
343
344 if (fetchResult.contentType === 'image/svg+xml') {
345 validation.format = 'svg';
346
347
348
349 const text = buffer.toString('utf8');
350 if (/\sonload\s*=|<script/i.test(text)) validation.formatIssues.push('SVG contains script code — BIMI SVG must not include scripts');
351 if (/<image[\s>]|xlink:href|href\s*=\s*["']https?:/i.test(text)) {
352 validation.formatIssues.push('SVG references external resources (image/href) — BIMI SVG must be self-contained');
353 }
354 const viewBoxMatch = text.match(/<svg[^>]*\sviewBox\s*=\s*["']([^"']+)["']/i);
355 if (!viewBoxMatch) {
356 validation.formatIssues.push('SVG missing viewBox attribute — BIMI requires a square viewBox');
357 } else {
358 const viewBox = viewBoxMatch[1].trim();
359 const components = viewBox.split(/[\s,]+/).map(Number);
360 if (components.length === 4 && components.every((n) => Number.isFinite(n))) {
361 if (components[2] !== components[3]) {
362 validation.formatIssues.push(`viewBox '${viewBox}' is not square (width ≠ height) — BIMI requires a square viewBox`);
363 }
364 } else {
365 validation.formatIssues.push(`viewBox '${viewBox}' is malformed — expected four numbers`);
366 }
367 }
368 } else if (isSvgText) {
369 validation.format = 'svg';
370 validation.formatIssues.push('SVG content detected but Content-Type is not image/svg+xml — receivers may reject it');
371 const text = buffer.toString('utf8');
372 if (/\sonload\s*=|<script/i.test(text)) validation.formatIssues.push('SVG contains script code — BIMI SVG must not include scripts');
373 } else if (fetchResult.contentType && fetchResult.contentType.startsWith('image/')) {
374 validation.format = 'raster';
375 validation.formatIssues.push(`raster image format '${fetchResult.contentType}' is not SVG — BIMI requires SVG Tiny Portable/Secure Static profile per spec`);
376 } else if (isSvgText === false && !fetchResult.contentType?.startsWith('image/')) {
377 validation.format = 'unknown';
378 validation.formatIssues.push(`unexpected logo content type '${fetchResult.contentType}' — BIMI requires an SVG image served over HTTPS`);
379 }
380
381 return validation;
382}
383
384
385
386
387
388export function scoreBimiAudit(result) {
389 const issues = [];
390 let score = 100;
391 let recommendations = [];
392
393 if (!result.bimiFound) {
394 return {
395 score: 0,
396 grade: 'F',
397 issues: ['no BIMI record found at selector._bimi.<domain>'],
398 recommendations: [
399 'Publish a BIMI record at selector._bimi.<domain> (e.g. default._bimi.example.com) with v=BIMI2, an a= VMC/CMC certificate URL, and an l= HTTPS SVG logo URL',
400 'Ensure DMARC is at enforcement (p=quarantine or p=reject) first — BIMI is only honored after DMARC enforcement',
401 ],
402 };
403 }
404
405 if (result.dmarcEnforced === false) {
406 issues.push('DMARC is not at enforcement — BIMI logos will not display');
407 score -= 40;
408 recommendations.push('Move DMARC to p=quarantine or p=reject before investing in BIMI; receivers ignore BIMI without DMARC enforcement');
409 } else if (result.dmarcEnforced === null) {
410 issues.push('DMARC policy could not be determined');
411 score -= 20;
412 recommendations.push('Publish a DMARC record at _dmarc.<domain> with p=quarantine or p=reject');
413 }
414
415 if (!result.hasRequiredLogoTag) {
416 issues.push('record missing required l= logo URL tag');
417 score -= 25;
418 recommendations.push('Add an l= tag pointing to an HTTPS SVG logo URL');
419 } else if (result.logo?.issues?.length > 0) {
420 issues.push(...result.logo.issues.map((i) => `logo: ${i}`));
421 score -= 10;
422 recommendations.push('Fix the logo URL so it serves a valid BIMI SVG over HTTPS');
423 }
424
425 if (result.version === null) {
426 issues.push('record missing v= version tag');
427 score -= 10;
428 recommendations.push('Add v=BIMI2 (or BIMI3) to future-proof the record');
429 } else if (!KNOWN_VERSIONS.includes(result.version)) {
430 issues.push(`unknown version '${result.version}'`);
431 score -= 5;
432 recommendations.push(`use v=BIMI2 or newer (${KNOWN_VERSIONS.join(', ')})`);
433 } else if (result.version === 'bimi1') {
434 issues.push('record uses legacy v=BIMI1 — upgrade');
435 score -= 5;
436 recommendations.push('Upgrade to v=BIMI2 and add an a= authority evidence URL');
437 }
438
439 if (result.authorityUrl === null) {
440 issues.push('no a= authority evidence (VMC/CMC) URL — Gmail and Apple Mail require a Verified Mark Certificate');
441 score -= 10;
442 recommendations.push('Obtain a VMC/CMC from a Mark Verifier (Digicert, Entrust) and add the a= tag with its HTTPS URL');
443 } else if (result.authority?.issues?.length > 0) {
444 issues.push(...result.authority.issues.map((i) => `authority: ${i}`));
445 score -= 5;
446 recommendations.push('Fix the authority evidence URL so it is a valid HTTPS URL');
447 }
448
449 if (result.logo?.formatIssues?.length > 0) issues.push(...result.logo.formatIssues.map((i) => `logo format: ${i}`));
450
451 if (result.recordIssues.length > 0) {
452 issues.push(...result.recordIssues);
453 score -= Math.min(15, result.recordIssues.length * 5);
454 }
455
456 score = Math.max(0, Math.min(100, score));
457 const grade = score >= 90 ? 'A' : score >= 80 ? 'B' : score >= 70 ? 'C' : score >= 60 ? 'D' : 'F';
458 recommendations = dedupe(recommendations);
459 return { score, grade, issues, recommendations };
460}
461
462function dedupe(list) {
463 return [...new Set(list)];
464}
465
466
467
468
469
470export async function auditBimi(rawUrl, options = {}) {
471 const timeoutSeconds = clampInteger(options.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS, 3, 30);
472 const dohUrl = options.dohUrl || DEFAULT_DOH_URL;
473 const selector = (options.selector && String(options.selector).trim()) || DEFAULT_SELECTOR;
474 if (!SELECTOR_RE.test(selector)) {
475 throw new Error(`invalid selector '${selector}' (must be letters, digits, hyphens)`);
476 }
477 const checkLogo = options.checkLogo === undefined ? true : Boolean(options.checkLogo);
478 const logoMaxBytes = clampInteger(options.logoMaxBytes, DEFAULT_LOGO_MAX_BYTES, 1024, 8 * 1024 * 1024);
479
480 const url = await normalizeAndValidateUrl(rawUrl);
481 const domain = url.hostname.toLowerCase();
482 const bimiName = `${selector}._bimi.${domain}`;
483
484
485 let bimiRecords = [];
486 let dohError = null;
487 try {
488 bimiRecords = await dohTxt(bimiName, timeoutSeconds, dohUrl);
489 } catch (error) {
490 dohError = error.message;
491 }
492
493
494 let dmarcTxt = [];
495 let dmarcPolicy = null;
496 try {
497 dmarcTxt = await dohTxt(`_dmarc.${domain}`, timeoutSeconds, dohUrl);
498 dmarcPolicy = parseDmarcPolicy(dmarcTxt);
499 } catch {
500 dmarcPolicy = null;
501 }
502 const dmarcEnforced = dmarcPolicy === null ? null : isDmarcEnforced(dmarcPolicy);
503
504 const bimiFound = bimiRecords.length > 0;
505 const primary = bimiFound ? parseBimiRecord(bimiRecords[0], selector) : null;
506 const parsedRecords = bimiRecords.map((r) => parseBimiRecord(r, selector));
507 const hasRequiredLogoTag = primary !== null && primary.logoUrl !== null;
508 const recordIssues = bimiFound ? parsedRecords.flatMap((r) => r.tagIssues) : [];
509
510 let logoAnalysis = null;
511 let authorityAnalysis = null;
512 if (bimiFound && hasRequiredLogoTag && primary.logoUrl && checkLogo) {
513 try {
514 const logoUrl = new URL(primary.logoUrl);
515 if (logoUrl.protocol !== 'https:') throw new Error('logo URL must use HTTPS');
516 logoAnalysis = await validateLogo(logoUrl, { timeoutSeconds, maxLogoBytes: logoMaxBytes });
517 } catch (error) {
518 logoAnalysis = { checked: true, url: primary.logoUrl, status: null, contentType: null, contentLength: null, format: null, formatIssues: [], issues: [`logo fetch failed: ${error.message}`] };
519 }
520 }
521 if (bimiFound && primary?.authorityUrl) {
522 try {
523 const authorityUrl = new URL(primary.authorityUrl);
524 if (authorityUrl.protocol !== 'https:') throw new Error('authority evidence URL must use HTTPS');
525 authorityAnalysis = { url: primary.authorityUrl, valid: true, issues: [] };
526 } catch (error) {
527 authorityAnalysis = { url: primary.authorityUrl, valid: false, issues: [error.message] };
528 }
529 }
530
531 const summary = {
532 url: url.href,
533 domain,
534 selector,
535 bimiName,
536 bimiFound,
537 recordCount: bimiRecords.length,
538 recordText: bimiFound ? bimiRecords[0] : null,
539 version: primary ? primary.version : null,
540 logoUrl: primary ? primary.logoUrl : null,
541 authorityUrl: primary ? primary.authorityUrl : null,
542 hasRequiredLogoTag,
543 dmarcPolicy,
544 dmarcEnforced,
545 logo: logoAnalysis,
546 authority: authorityAnalysis,
547 recordIssues,
548 dohError,
549 checkedAt: new Date().toISOString(),
550 };
551 const { score, grade, issues, recommendations } = scoreBimiAudit(summary);
552 return { ...summary, score, grade, issues, recommendations };
553}
554
555const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
556
557if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
558 await Actor.init();
559 try {
560 const input = await Actor.getInput();
561 if (!input || !input.startUrl) {
562 throw new Error('startUrl is required');
563 }
564 const audit = await auditBimi(input.startUrl, {
565 timeoutSeconds: input.timeoutSeconds,
566 selector: input.selector,
567 checkLogo: input.checkLogo,
568 logoMaxBytes: input.logoMaxBytes,
569 dohUrl: process.env.BIMI_DOH_URL,
570 });
571 await Actor.pushData([audit]);
572 console.log(`BIMI audit complete: ${audit.domain} selector=${audit.selector} — score ${audit.score} (${audit.grade})`);
573 } catch (error) {
574 console.error(`BIMI audit failed: ${error.message}`);
575 throw error;
576 } finally {
577 await Actor.exit();
578 }
579}