1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5import { parse as parseToml } from 'smol-toml';
6
7const USER_AGENT = 'CarbonTxtAuditor/0.1 (+https://apify.com)';
8const DEFAULT_TIMEOUT_SECONDS = 10;
9const MAX_BODY_BYTES = 256 * 1024;
10
11
12
13
14
15
16
17
18function isPrivateIPv4(ip) {
19 const parts = ip.split('.').map(Number);
20 if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return false;
21 const [a, b] = parts;
22 return a === 10
23 || (a === 172 && b >= 16 && b <= 31)
24 || (a === 192 && b === 168)
25 || a === 127
26 || a === 0
27 || (a === 169 && b === 254);
28}
29
30function isPrivateIPv6(ip) {
31 const normalized = ip.toLowerCase();
32 return normalized === '::1'
33 || normalized.startsWith('fc')
34 || normalized.startsWith('fd')
35 || 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)) {
41 throw new Error('Only HTTP and HTTPS URLs are supported');
42 }
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 (!url.hostname || url.username || url.password) throw new Error('URL must be public and must not include credentials');
48
49 const hostname = url.hostname.replace(/^\[|\]$/g, '');
50 const literalType = net.isIP(hostname);
51 if (literalType === 4 && isPrivateIPv4(hostname)) throw new Error('Private IPv4 targets are blocked');
52 if (literalType === 6 && isPrivateIPv6(hostname)) throw new Error('Private IPv6 targets are blocked');
53
54 const records = literalType ? [{ address: hostname, family: literalType }] : await dns.lookup(url.hostname, { all: true });
55 for (const record of records) {
56 if (record.family === 4 && isPrivateIPv4(record.address)) throw new Error('DNS resolves to a private IPv4 address; blocked for SSRF safety');
57 if (record.family === 6 && isPrivateIPv6(record.address)) throw new Error('DNS resolves to a private IPv6 address; blocked for SSRF safety');
58 }
59 return url;
60}
61
62
63
64
65
66
67async function fetchText(initialUrl, timeoutSeconds, { method = 'GET', redirectsRemaining = 3 } = {}) {
68 await normalizeAndValidateUrl(initialUrl.href);
69 const controller = new AbortController();
70 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
71 try {
72 const response = await fetch(initialUrl, {
73 method,
74 redirect: 'manual',
75 signal: controller.signal,
76 headers: {
77 'user-agent': USER_AGENT,
78 accept: 'text/plain, application/toml, */*;q=0.1',
79 },
80 });
81
82 if ([301, 302, 303, 307, 308].includes(response.status)) {
83 if (redirectsRemaining <= 0) throw new Error('Too many redirects');
84 const location = response.headers.get('location');
85 if (!location) throw new Error('Redirect without Location header');
86 const nextUrl = new URL(location, initialUrl.href);
87 await normalizeAndValidateUrl(nextUrl.href);
88 return fetchText(nextUrl, timeoutSeconds, { method, redirectsRemaining: redirectsRemaining - 1 });
89 }
90
91 let text = '';
92 if (method !== 'HEAD') {
93 try {
94 const buffer = await response.arrayBuffer();
95 text = buffer.byteLength > MAX_BODY_BYTES
96 ? new TextDecoder().decode(buffer.slice(0, MAX_BODY_BYTES))
97 : new TextDecoder().decode(buffer);
98 } catch {
99 text = '';
100 }
101 }
102
103 return {
104 ok: response.ok,
105 status: response.status,
106 finalUrl: response.url || initialUrl.href,
107 contentType: response.headers.get('content-type') || '',
108 carbonTxtLocationHeader: response.headers.get('carbontxt-location') || null,
109 https: (response.url || initialUrl.href).startsWith('https://'),
110 body: text,
111 error: null,
112 };
113 } catch (error) {
114 return {
115 ok: false,
116 status: null,
117 finalUrl: initialUrl.href,
118 contentType: '',
119 carbonTxtLocationHeader: null,
120 https: initialUrl.protocol === 'https:',
121 body: '',
122 error: error.message,
123 };
124 } finally {
125 clearTimeout(timeout);
126 }
127}
128
129
130
131
132
133
134
135
136
137const DNS_TXT_PATTERN = /^carbon-txt-location=(.+)$/i;
138
139export async function lookupDnsTxtLocation(hostname) {
140 try {
141 const records = await dns.resolveTxt(hostname);
142 for (const chunks of records) {
143 const joined = chunks.join('');
144 const match = DNS_TXT_PATTERN.exec(joined.trim());
145 if (match) return match[1].trim();
146 }
147 } catch {
148
149 }
150 return null;
151}
152
153export async function discoverCarbonTxt(baseUrl, timeoutSeconds) {
154 const attempts = [];
155
156 const dnsLocation = await lookupDnsTxtLocation(baseUrl.hostname);
157 if (dnsLocation) {
158 try {
159 const target = await normalizeAndValidateUrl(dnsLocation);
160 const result = await fetchText(target, timeoutSeconds);
161 attempts.push({ method: 'dns-txt', url: target.href, result });
162 if (result.ok && result.status === 200) return { method: 'dns-txt', url: target.href, result, attempts };
163 } catch (error) {
164 attempts.push({ method: 'dns-txt', url: dnsLocation, result: { ok: false, status: null, error: error.message, body: '', https: null, finalUrl: dnsLocation, contentType: '', carbonTxtLocationHeader: null } });
165 }
166 }
167
168 const rootUrl = new URL('/carbon.txt', baseUrl.origin);
169 const rootResult = await fetchText(rootUrl, timeoutSeconds);
170 attempts.push({ method: 'root', url: rootUrl.href, result: rootResult });
171 if (rootResult.ok && rootResult.status === 200) return { method: 'root', url: rootUrl.href, result: rootResult, attempts };
172
173 const wellKnownUrl = new URL('/.well-known/carbon.txt', baseUrl.origin);
174 const wellKnownResult = await fetchText(wellKnownUrl, timeoutSeconds);
175 attempts.push({ method: 'well-known', url: wellKnownUrl.href, result: wellKnownResult });
176 if (wellKnownResult.ok && wellKnownResult.status === 200) return { method: 'well-known', url: wellKnownUrl.href, result: wellKnownResult, attempts };
177
178 const headerProbeUrl = new URL('/', baseUrl.origin);
179 const headerProbeResult = await fetchText(headerProbeUrl, timeoutSeconds, { method: 'HEAD' });
180 attempts.push({ method: 'header-probe', url: headerProbeUrl.href, result: headerProbeResult });
181 if (headerProbeResult.carbonTxtLocationHeader) {
182 try {
183 const target = await normalizeAndValidateUrl(headerProbeResult.carbonTxtLocationHeader);
184 const result = await fetchText(target, timeoutSeconds);
185 attempts.push({ method: 'header', url: target.href, result });
186 if (result.ok && result.status === 200) return { method: 'header', url: target.href, result, attempts };
187 } catch (error) {
188 attempts.push({ method: 'header', url: headerProbeResult.carbonTxtLocationHeader, result: { ok: false, status: null, error: error.message, body: '', https: null, finalUrl: headerProbeResult.carbonTxtLocationHeader, contentType: '', carbonTxtLocationHeader: null } });
189 }
190 }
191
192 return { method: 'none', url: null, result: null, attempts };
193}
194
195
196
197
198
199const VALID_DOC_TYPES = ['web-page', 'annual-report', 'sustainability-page', 'certificate', 'csrd-report', 'ai-model-card', 'other'];
200
201function isHttpUrlString(value) {
202 if (typeof value !== 'string') return false;
203 try {
204 const u = new URL(value);
205 return u.protocol === 'http:' || u.protocol === 'https:';
206 } catch {
207 return false;
208 }
209}
210
211function validateDisclosure(entry, index) {
212 const issues = [];
213 if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
214 return { index, docType: null, url: null, validUntil: null, domain: null, title: null, issues: [`disclosures[${index}] must be a table`] };
215 }
216 const docType = typeof entry.doc_type === 'string' ? entry.doc_type : null;
217 if (!docType) issues.push(`disclosures[${index}] is missing required field 'doc_type'`);
218 else if (!VALID_DOC_TYPES.includes(docType)) issues.push(`disclosures[${index}].doc_type '${docType}' is not one of: ${VALID_DOC_TYPES.join(', ')}`);
219
220 const url = typeof entry.url === 'string' ? entry.url : null;
221 if (!url) issues.push(`disclosures[${index}] is missing required field 'url'`);
222 else if (!isHttpUrlString(url)) issues.push(`disclosures[${index}].url '${url}' must be an absolute http(s) URL`);
223
224 if ('domain' in entry && typeof entry.domain !== 'string') issues.push(`disclosures[${index}].domain must be a string`);
225 if ('title' in entry && typeof entry.title !== 'string') issues.push(`disclosures[${index}].title must be a string`);
226
227 return {
228 index,
229 docType,
230 url,
231 validUntil: entry.valid_until ? String(entry.valid_until) : null,
232 domain: entry.domain || null,
233 title: entry.title || null,
234 issues,
235 };
236}
237
238function validateUpstreamService(entry, index) {
239 const issues = [];
240 if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
241 return { index, domain: null, serviceType: null, issues: [`services[${index}] must be a table`] };
242 }
243 const domain = typeof entry.domain === 'string' ? entry.domain : null;
244 if (!domain) issues.push(`services[${index}] is missing field 'domain'`);
245 else if (/^https?:\/\//i.test(domain)) issues.push(`services[${index}].domain '${domain}' must not include a protocol`);
246
247 const serviceType = entry.service_type !== undefined ? entry.service_type : null;
248 if (serviceType !== null && typeof serviceType !== 'string' && !(Array.isArray(serviceType) && serviceType.every((s) => typeof s === 'string'))) {
249 issues.push(`services[${index}].service_type must be a string or an array of strings`);
250 }
251
252 return { index, domain, serviceType, issues };
253}
254
255export function analyzeCarbonTxt(doc) {
256 const issues = [];
257
258 if (typeof doc !== 'object' || doc === null || Array.isArray(doc)) {
259 issues.push('carbon.txt document is not a TOML table.');
260 return {
261 version: null, lastUpdated: null, hasOrgSection: false, disclosures: [], hasUpstreamSection: false, upstreamServices: [], issues,
262 };
263 }
264
265 const version = typeof doc.version === 'string' ? doc.version : null;
266 if (!version) issues.push("Missing recommended top-level key 'version' (required from carbon.txt syntax v0.3 onwards).");
267
268 const lastUpdated = doc.last_updated !== undefined ? String(doc.last_updated) : null;
269
270 const hasOrgSection = typeof doc.org === 'object' && doc.org !== null && !Array.isArray(doc.org);
271 let disclosures = [];
272 if (!hasOrgSection) {
273 issues.push("Missing required '[org]' table.");
274 } else if (!Array.isArray(doc.org.disclosures) || doc.org.disclosures.length === 0) {
275 issues.push("'[org]' table is missing required non-empty 'disclosures' array.");
276 } else {
277 disclosures = doc.org.disclosures.map((entry, index) => validateDisclosure(entry, index));
278 for (const d of disclosures) issues.push(...d.issues);
279 }
280
281 const hasUpstreamSection = typeof doc.upstream === 'object' && doc.upstream !== null && !Array.isArray(doc.upstream);
282 let upstreamServices = [];
283 if (hasUpstreamSection && Array.isArray(doc.upstream.services)) {
284 upstreamServices = doc.upstream.services.map((entry, index) => validateUpstreamService(entry, index));
285 for (const s of upstreamServices) issues.push(...s.issues);
286 }
287
288 return { version, lastUpdated, hasOrgSection, disclosures, hasUpstreamSection, upstreamServices, issues };
289}
290
291
292
293
294
295
296
297async function checkDisclosureUrl(urlString, timeoutSeconds) {
298 try {
299 const url = await normalizeAndValidateUrl(urlString);
300 let result = await fetchText(url, timeoutSeconds, { method: 'HEAD' });
301 if (result.status === 405 || result.status === null) {
302 result = await fetchText(url, timeoutSeconds, { method: 'GET' });
303 }
304 return { url: urlString, reachable: result.ok, status: result.status, https: result.https, error: result.error };
305 } catch (error) {
306 return { url: urlString, reachable: false, status: null, https: null, error: error.message };
307 }
308}
309
310
311
312
313
314function evalDiscovery({ discovery }) {
315 if (discovery.method === 'none') {
316 return { status: 'missing', note: 'No carbon.txt file was discoverable via DNS TXT record, /carbon.txt, /.well-known/carbon.txt, or a CarbonTxt-Location header.', recommendation: 'Publish a carbon.txt file at the root of your domain (https://example.com/carbon.txt) or advertise its location via DNS TXT record or CarbonTxt-Location header.' };
317 }
318 return { status: 'good', note: `carbon.txt discovered via ${discovery.method} at ${discovery.url}.` };
319}
320
321function evalHttps({ discovery }) {
322 if (discovery.method === 'none') return { status: 'info', note: 'No response to evaluate.' };
323 if (!discovery.result.https) {
324 return { status: 'missing', note: 'carbon.txt was not served over https.', recommendation: 'Serve carbon.txt over https.' };
325 }
326 return { status: 'good', note: 'carbon.txt served over https.' };
327}
328
329function evalToml({ tomlValid, parseError }) {
330 if (!tomlValid) {
331 return { status: 'missing', note: `carbon.txt body is not valid TOML${parseError ? `: ${parseError}` : '.'}`, recommendation: 'Fix the TOML syntax errors in the carbon.txt file (see carbontxt.org/syntax).' };
332 }
333 return { status: 'good', note: 'carbon.txt body parses as valid TOML.' };
334}
335
336function evalVersion({ analysis }) {
337 if (!analysis.version) {
338 return { status: 'warn', note: "No 'version' field declared.", recommendation: "Declare version = \"0.5\" at the top level (required from syntax v0.3 onwards)." };
339 }
340 return { status: 'good', note: `Declared syntax version '${analysis.version}'.` };
341}
342
343function evalOrgSection({ analysis }) {
344 if (!analysis.hasOrgSection) {
345 return { status: 'missing', note: "Missing required '[org]' table.", recommendation: "Add an '[org]' table with a non-empty 'disclosures' array." };
346 }
347 return { status: 'good', note: "'[org]' table is present." };
348}
349
350function evalDisclosures({ analysis }) {
351 if (!analysis.hasOrgSection) return { status: 'info', note: 'Skipped: no [org] table.' };
352 if (analysis.disclosures.length === 0) {
353 return { status: 'missing', note: "'[org]' table has no valid 'disclosures' entries.", recommendation: 'Add at least one org.disclosures entry with doc_type and url pointing to a publicly accessible sustainability disclosure document.' };
354 }
355 const bad = analysis.disclosures.filter((d) => d.issues.length > 0);
356 if (bad.length) {
357 return { status: 'warn', note: `${bad.length} of ${analysis.disclosures.length} disclosure entries have validation issues.`, recommendation: 'Fix invalid doc_type values and missing/malformed url fields in org.disclosures.' };
358 }
359 return { status: 'good', note: `${analysis.disclosures.length} valid disclosure entr${analysis.disclosures.length === 1 ? 'y' : 'ies'} found.` };
360}
361
362function evalUpstream({ analysis }) {
363 if (!analysis.hasUpstreamSection) {
364 return { status: 'info', note: "No '[upstream]' table (optional; only relevant for services that depend on upstream hosting/CDN providers)." };
365 }
366 const bad = analysis.upstreamServices.filter((s) => s.issues.length > 0);
367 if (bad.length) {
368 return { status: 'warn', note: `${bad.length} of ${analysis.upstreamServices.length} upstream.services entries have validation issues.`, recommendation: 'Fix upstream.services entries to use bare domains (no protocol) and valid service_type values.' };
369 }
370 if (analysis.upstreamServices.length === 0) {
371 return { status: 'warn', note: "'[upstream]' table is present but has no 'services' entries.", recommendation: "Add upstream.services entries for hosting, CDN, or other infrastructure providers, or remove the empty '[upstream]' table." };
372 }
373 return { status: 'good', note: `${analysis.upstreamServices.length} upstream service(s) documented.` };
374}
375
376function evalDisclosureReachability({ disclosureUrlChecks }) {
377 if (disclosureUrlChecks === null) return { status: 'info', note: 'Disclosure URL reachability checks were not requested.' };
378 if (disclosureUrlChecks.length === 0) return { status: 'info', note: 'No disclosure URLs to check.' };
379 const unreachable = disclosureUrlChecks.filter((c) => !c.reachable);
380 if (unreachable.length) {
381 return { status: 'warn', note: `${unreachable.length} of ${disclosureUrlChecks.length} disclosure URL(s) did not resolve successfully.`, recommendation: 'Fix or remove disclosure URLs that return errors or non-2xx/3xx status codes.' };
382 }
383 return { status: 'good', note: `All ${disclosureUrlChecks.length} disclosure URL(s) resolved successfully.` };
384}
385
386const CHECKS = [
387 { name: 'discovery', title: 'carbon.txt discovery', weight: 25, fn: evalDiscovery },
388 { name: 'https', title: 'Served over HTTPS', weight: 10, fn: evalHttps },
389 { name: 'toml', title: 'TOML validity', weight: 15, fn: evalToml },
390 { name: 'version', title: 'Syntax version declared', weight: 5, fn: evalVersion },
391 { name: 'org', title: "'[org]' table present", weight: 10, fn: evalOrgSection },
392 { name: 'disclosures', title: 'Disclosures valid', weight: 25, fn: evalDisclosures },
393 { name: 'upstream', title: 'Upstream services', weight: 5, fn: evalUpstream },
394 { name: 'reachability', title: 'Disclosure URL reachability', weight: 5, fn: evalDisclosureReachability },
395];
396
397export function buildCheckReports(context) {
398 const reports = [];
399 let earned = 0;
400 let possible = 0;
401 for (const check of CHECKS) {
402 const evaluation = check.fn(context);
403 if (evaluation.status !== 'info') possible += check.weight;
404 if (evaluation.status === 'good') earned += check.weight;
405 else if (evaluation.status === 'warn') earned += Math.round(check.weight * 0.5);
406 reports.push({
407 name: check.title,
408 check: check.name,
409 status: evaluation.status,
410 note: evaluation.note,
411 weight: check.weight,
412 recommendation: evaluation.recommendation || null,
413 });
414 }
415 return { reports, earned, possible };
416}
417
418export function scoreAudit(earned, possible) {
419 if (possible === 0) return 0;
420 return Math.min(100, Math.max(0, Math.round((earned / possible) * 100)));
421}
422
423export function gradeFromScore(score) {
424 if (score >= 95) return 'A+';
425 if (score >= 85) return 'A';
426 if (score >= 75) return 'B';
427 if (score >= 65) return 'C';
428 if (score >= 50) return 'D';
429 if (score >= 30) return 'E';
430 return 'F';
431}
432
433export function buildRecommendations(reports) {
434 const recs = [];
435 for (const r of reports) {
436 if (r.recommendation && !recs.includes(r.recommendation)) recs.push(r.recommendation);
437 }
438 if (recs.length === 0) {
439 recs.push('carbon.txt looks spec-conformant. Re-run this audit after updating sustainability disclosures to catch regressions.');
440 }
441 return recs;
442}
443
444
445
446
447
448export async function auditCarbonTxt(input) {
449 const domainRaw = input.startUrl || input.domain;
450 const timeoutSeconds = Math.min(Math.max(Number(input.timeoutSeconds || DEFAULT_TIMEOUT_SECONDS), 3), 30);
451 const checkDisclosureUrls = Boolean(input.checkDisclosureUrls);
452
453 const baseUrl = await normalizeAndValidateUrl(domainRaw);
454 const discovery = await discoverCarbonTxt(baseUrl, timeoutSeconds);
455
456 let doc = null;
457 let tomlValid = false;
458 let parseError = null;
459 if (discovery.result?.body) {
460 try {
461 doc = parseToml(discovery.result.body);
462 tomlValid = true;
463 } catch (err) {
464 parseError = err.message;
465 }
466 }
467
468 const analysis = doc
469 ? analyzeCarbonTxt(doc)
470 : { version: null, lastUpdated: null, hasOrgSection: false, disclosures: [], hasUpstreamSection: false, upstreamServices: [], issues: [] };
471
472 let disclosureUrlChecks = null;
473 if (checkDisclosureUrls && analysis.disclosures.length > 0) {
474 disclosureUrlChecks = [];
475 for (const d of analysis.disclosures) {
476 if (d.url) disclosureUrlChecks.push(await checkDisclosureUrl(d.url, timeoutSeconds));
477 }
478 }
479
480 const context = { discovery, tomlValid, parseError, analysis, disclosureUrlChecks };
481 const { reports, earned, possible } = buildCheckReports(context);
482 const score = scoreAudit(earned, possible);
483 const grade = gradeFromScore(score);
484 const recommendations = buildRecommendations(reports);
485
486 const issues = [
487 ...analysis.issues,
488 ...reports.filter((r) => r.status === 'warn' || r.status === 'missing').map((r) => `${r.name}: ${r.note}`),
489 ];
490
491 return {
492 inputDomain: domainRaw,
493 discoveryMethod: discovery.method,
494 carbonTxtUrl: discovery.url,
495 https: discovery.result?.https ?? null,
496 status: discovery.result?.status ?? null,
497 fileFound: discovery.method !== 'none',
498 tomlValid,
499 parseError,
500 version: analysis.version,
501 lastUpdated: analysis.lastUpdated,
502 hasOrgSection: analysis.hasOrgSection,
503 disclosureCount: analysis.disclosures.length,
504 disclosures: analysis.disclosures,
505 hasUpstreamSection: analysis.hasUpstreamSection,
506 upstreamServiceCount: analysis.upstreamServices.length,
507 upstreamServices: analysis.upstreamServices,
508 disclosureUrlChecks,
509 checks: reports,
510 issues: [...new Set(issues)],
511 score,
512 grade,
513 checkedAt: new Date().toISOString(),
514 recommendations,
515 error: discovery.result?.error ?? null,
516 };
517}
518
519
520
521
522
523const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
524
525if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
526 await Actor.init();
527 try {
528 const input = await Actor.getInput();
529 const result = await auditCarbonTxt(input || {});
530 await Actor.pushData(result);
531 await Actor.setValue('OUTPUT', result);
532 Actor.log.info('Carbon.txt audit complete', { carbonTxtUrl: result.carbonTxtUrl, score: result.score, grade: result.grade });
533 } finally {
534 await Actor.exit();
535 }
536}