1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6const USER_AGENT = 'NELReportingAuditor/0.1 (+https://apify.com)';
7const DEFAULT_TIMEOUT_SECONDS = 10;
8
9
10
11
12
13
14
15
16function isPrivateIPv4(ip) {
17 const parts = ip.split('.').map(Number);
18 if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return false;
19 const [a, b] = parts;
20 return a === 10
21 || (a === 172 && b >= 16 && b <= 31)
22 || (a === 192 && b === 168)
23 || a === 127
24 || a === 0
25 || (a === 169 && b === 254);
26}
27
28function isPrivateIPv6(ip) {
29 const normalized = ip.toLowerCase();
30 return normalized === '::1'
31 || normalized.startsWith('fc')
32 || normalized.startsWith('fd')
33 || normalized.startsWith('fe80:');
34}
35
36export async function normalizeAndValidateUrl(rawUrl) {
37 if (!rawUrl || typeof rawUrl !== 'string') throw new Error('startUrl is required');
38 if (/^[a-z][a-z0-9+.-]*:/i.test(rawUrl) && !/^https?:\/\//i.test(rawUrl)) {
39 throw new Error('Only HTTP and HTTPS URLs are supported');
40 }
41
42 const withScheme = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`;
43 const url = new URL(withScheme);
44 if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported');
45 if (!url.hostname || url.username || url.password) throw new Error('URL must be public and must not include credentials');
46
47 const hostname = url.hostname.replace(/^\[|\]$/g, '');
48 const literalType = net.isIP(hostname);
49 if (literalType === 4 && isPrivateIPv4(hostname)) throw new Error('Private IPv4 targets are blocked');
50 if (literalType === 6 && isPrivateIPv6(hostname)) throw new Error('Private IPv6 targets are blocked');
51
52 const records = literalType ? [{ address: hostname, family: literalType }] : await dns.lookup(url.hostname, { all: true });
53 for (const record of records) {
54 if (record.family === 4 && isPrivateIPv4(record.address)) throw new Error('DNS resolves to a private IPv4 address; blocked for SSRF safety');
55 if (record.family === 6 && isPrivateIPv6(record.address)) throw new Error('DNS resolves to a private IPv6 address; blocked for SSRF safety');
56 }
57 return url;
58}
59
60
61
62
63
64
65
66async function fetchHeaders(initialUrl, timeoutSeconds, redirectsRemaining = 3) {
67 await normalizeAndValidateUrl(initialUrl.href);
68 const controller = new AbortController();
69 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
70 try {
71 const response = await fetch(initialUrl, {
72 method: 'GET',
73 redirect: 'manual',
74 signal: controller.signal,
75 headers: {
76 'user-agent': USER_AGENT,
77 accept: 'text/html,application/xhtml+xml,application/json,*/*;q=0.1',
78 },
79 });
80
81 if ([301, 302, 303, 307, 308].includes(response.status)) {
82 if (redirectsRemaining <= 0) throw new Error('Too many redirects');
83 const location = response.headers.get('location');
84 if (!location) throw new Error('Redirect without Location header');
85 const nextUrl = new URL(location, initialUrl.href);
86 await normalizeAndValidateUrl(nextUrl.href);
87 return fetchHeaders(nextUrl, timeoutSeconds, redirectsRemaining - 1);
88 }
89
90
91 try { await response.arrayBuffer(); } catch { }
92
93 const headerMap = {};
94 response.headers.forEach((value, key) => { headerMap[key.toLowerCase()] = value; });
95
96 return {
97 ok: response.ok,
98 status: response.status,
99 finalUrl: response.url || initialUrl.href,
100 https: (response.url || initialUrl.href).startsWith('https://'),
101 headers: headerMap,
102 error: null,
103 };
104 } catch (error) {
105 return {
106 ok: false,
107 status: null,
108 finalUrl: initialUrl.href,
109 https: initialUrl.protocol === 'https:',
110 headers: {},
111 truncated: false,
112 error: error.message,
113 };
114 } finally {
115 clearTimeout(timeout);
116 }
117}
118
119
120
121
122
123
124
125
126const NEL_KNOWN_KEYS = new Set(['report_to', 'max_age', 'include_subdomains', 'failure_fraction', 'success_fraction']);
127
128export function parseNelHeader(value) {
129 if (!value) return { present: false, valid: false, policy: null, issues: [] };
130 const issues = [];
131 let policy = null;
132 try {
133 policy = JSON.parse(value);
134 } catch {
135 return { present: true, valid: false, policy: null, issues: ['NEL header is not valid JSON.'] };
136 }
137 if (policy === null || typeof policy !== 'object' || Array.isArray(policy)) {
138 return { present: true, valid: false, policy: null, issues: ['NEL header must be a JSON object.'] };
139 }
140 if (!('report_to' in policy)) issues.push("NEL policy is missing the required 'report_to' key.");
141 else if (typeof policy.report_to !== 'string' || !policy.report_to.trim()) issues.push("NEL 'report_to' must be a non-empty string naming a Report-To group.");
142
143 if ('max_age' in policy) {
144 if (typeof policy.max_age !== 'number' || !Number.isFinite(policy.max_age) || policy.max_age < 0) {
145 issues.push("NEL 'max_age' must be a non-negative number.");
146 } else if (policy.max_age === 0) {
147 issues.push("NEL 'max_age' is 0; the logging policy expires immediately (removes NEL).");
148 } else if (policy.max_age < 86400) {
149 issues.push(`NEL 'max_age' of ${policy.max_age}s is very short; browsers expire the policy quickly. Consider at least 86400 (1 day).`);
150 }
151 } else {
152 issues.push("NEL policy is missing 'max_age'; browsers treat the policy as immediately expired.");
153 }
154
155 if ('include_subdomains' in policy && typeof policy.include_subdomains !== 'boolean') {
156 issues.push("NEL 'include_subdomains' must be a boolean.");
157 }
158 for (const key of ['failure_fraction', 'success_fraction']) {
159 if (key in policy) {
160 const v = policy[key];
161 if (typeof v !== 'number' || !Number.isFinite(v) || v < 0 || v > 1) {
162 issues.push(`NEL '${key}' must be a number between 0 and 1.`);
163 }
164 }
165 }
166 const unknownKeys = Object.keys(policy).filter((k) => !NEL_KNOWN_KEYS.has(k));
167 if (unknownKeys.length) issues.push(`NEL policy contains unknown keys: ${unknownKeys.join(', ')}.`);
168
169 return { present: true, valid: issues.length === 0, policy, issues };
170}
171
172
173
174
175
176
177
178
179
180
181function splitTopLevelJsonObjects(raw) {
182
183 const objects = [];
184 let depth = 0;
185 let inString = false;
186 let escaped = false;
187 let start = -1;
188 for (let i = 0; i < raw.length; i += 1) {
189 const ch = raw[i];
190 if (escaped) { escaped = false; continue; }
191 if (ch === '\\') { escaped = true; continue; }
192 if (ch === '"') { inString = !inString; continue; }
193 if (inString) continue;
194 if (ch === '{') {
195 if (depth === 0) start = i;
196 depth += 1;
197 } else if (ch === '}') {
198 depth -= 1;
199 if (depth === 0 && start >= 0) {
200 objects.push(raw.slice(start, i + 1));
201 start = -1;
202 }
203 }
204 }
205 return objects;
206}
207
208export function parseReportToHeader(rawValues) {
209
210 const result = { present: rawValues.length > 0, valid: false, groups: [], issues: [] };
211 if (!rawValues.length) return result;
212
213 const groups = [];
214 const issues = [];
215 for (const raw of rawValues) {
216 const candidates = splitTopLevelJsonObjects(raw);
217 let found = 0;
218 for (const candidate of candidates) {
219 let obj;
220 try {
221 obj = JSON.parse(candidate);
222 } catch {
223 issues.push('Report-To contains a JSON object that failed to parse.');
224 continue;
225 }
226 if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
227 issues.push('Report-To group must be a JSON object.');
228 continue;
229 }
230 found += 1;
231 const group = {
232 group: typeof obj.group === 'string' && obj.group.trim() ? obj.group : null,
233 maxAge: null,
234 includeSubdomains: Boolean(obj.include_subdomains),
235 endpoints: [],
236 issues: [],
237 };
238 if (!group.group) group.issues.push("Report-To group is missing a 'group' name.");
239 if ('max_age' in obj) {
240 if (typeof obj.max_age !== 'number' || !Number.isFinite(obj.max_age) || obj.max_age < 0) {
241 group.issues.push("Report-To 'max_age' must be a non-negative number.");
242 } else if (obj.max_age === 0) {
243 group.issues.push("Report-To 'max_age' is 0; the group expires immediately.");
244 } else {
245 group.maxAge = obj.max_age;
246 }
247 } else {
248 group.issues.push("Report-To group is missing 'max_age'.");
249 }
250 if (!Array.isArray(obj.endpoints) || obj.endpoints.length === 0) {
251 group.issues.push("Report-To group has no 'endpoints' array.");
252 } else {
253 for (const ep of obj.endpoints) {
254 if (!ep || typeof ep !== 'object' || typeof ep.url !== 'string' || !/^https:\/\//i.test(ep.url)) {
255 group.issues.push('Report-To endpoint is missing an https url.');
256 continue;
257 }
258 group.endpoints.push({ url: ep.url, priority: typeof ep.priority === 'number' ? ep.priority : null, weight: typeof ep.weight === 'number' ? ep.weight : null });
259 }
260 }
261 groups.push(group);
262 }
263 if (found === 0 && candidates.length === 0) issues.push('Report-To header contains no JSON object groups.');
264 }
265
266
267 const seen = new Map();
268 for (const g of groups) {
269 if (g.group) {
270 if (seen.has(g.group)) issues.push(`Report-To defines group '${g.group}' more than once; browsers keep the last definition.`);
271 seen.set(g.group, g);
272 }
273 }
274
275 result.groups = groups;
276 result.issues = issues;
277 result.valid = groups.length > 0 && groups.every((g) => g.issues.length === 0) && issues.length === 0;
278 return result;
279}
280
281
282
283
284
285
286export function parseReportingEndpointsHeader(rawValues) {
287 const result = { present: rawValues.length > 0, valid: false, endpoints: [], issues: [] };
288 if (!rawValues.length) return result;
289
290 const endpoints = [];
291 const issues = [];
292 const names = new Map();
293 for (const raw of rawValues) {
294
295 const pieces = [];
296 let inString = false;
297 let escaped = false;
298 let buf = '';
299 for (const ch of raw) {
300 if (escaped) { buf += ch; escaped = false; continue; }
301 if (ch === '\\') { buf += ch; escaped = true; continue; }
302 if (ch === '"') { inString = !inString; buf += ch; continue; }
303 if (ch === ',' && !inString) { pieces.push(buf); buf = ''; continue; }
304 buf += ch;
305 }
306 if (buf.trim()) pieces.push(buf);
307
308 for (const piece of pieces) {
309 const match = /^\s*([A-Za-z0-9_-]+)\s*=\s*"?([^"]*)"?\s*$/.exec(piece);
310 if (!match) {
311 issues.push(`Reporting-Endpoints entry could not be parsed: ${piece.trim().slice(0, 60)}`);
312 continue;
313 }
314 const [, name, url] = match;
315 if (!/^https:\/\//i.test(url)) {
316 issues.push(`Reporting-Endpoints entry '${name}' does not use an https url.`);
317 }
318 if (names.has(name)) issues.push(`Reporting-Endpoints defines name '${name}' more than once.`);
319 names.set(name, url);
320 endpoints.push({ name, url });
321 }
322 }
323
324 result.endpoints = endpoints;
325 result.issues = issues;
326 result.valid = endpoints.length > 0 && issues.length === 0;
327 return result;
328}
329
330
331
332
333
334
335
336export function analyzeEndpoints(reportToGroups, reportingEndpoints, finalUrl) {
337 const auditedOrigin = (() => {
338 try { return new URL(finalUrl).hostname.toLowerCase(); } catch { return null; }
339 })();
340
341 const all = [];
342 for (const group of reportToGroups) {
343 for (const ep of group.endpoints) {
344 all.push({ source: `Report-To group '${group.group}'`, url: ep.url });
345 }
346 }
347 for (const ep of reportingEndpoints) {
348 all.push({ source: `Reporting-Endpoints '${ep.name}'`, url: ep.url });
349 }
350
351 const analyzed = [];
352 for (const item of all) {
353 let host = null;
354 let loop = false;
355 try {
356 const u = new URL(item.url);
357 host = u.hostname.toLowerCase();
358 loop = auditedOrigin !== null && host === auditedOrigin;
359 } catch {
360 host = null;
361 }
362 analyzed.push({
363 source: item.source,
364 url: item.url,
365 host,
366 https: /^https:\/\//i.test(item.url),
367 reportLoop: loop,
368 parseError: host === null,
369 });
370 }
371
372 const endpointsByHost = new Set(analyzed.filter((e) => e.host).map((e) => e.host));
373 return { endpoints: analyzed, endpointCount: analyzed.length, httpsEndpointCount: analyzed.filter((e) => e.https).length, endpointsByHost };
374}
375
376
377
378
379
380
381
382
383function evalNel({ nel }) {
384 if (!nel.present) {
385 return {
386 status: 'info',
387 note: 'No NEL header on this response. Network Error Logging is not configured.',
388 recommendation: "To capture client-side network failures (DNS, TLS, connection, HTTP protocol errors), set a NEL header like {\"report_to\":\"default\",\"max_age\":2592000} together with a Report-To group.",
389 };
390 }
391 if (!nel.valid) {
392 return { status: 'missing', note: `NEL header present but invalid: ${nel.issues.join(' ')}`, recommendation: 'Fix the NEL JSON so browsers parse the logging policy.' };
393 }
394 const parts = [`report_to=${nel.policy.report_to}`, `max_age=${nel.policy.max_age}`];
395 if (nel.policy.include_subdomains) parts.push('include_subdomains');
396 if (typeof nel.policy.failure_fraction === 'number') parts.push(`failure_fraction=${nel.policy.failure_fraction}`);
397 if (typeof nel.policy.success_fraction === 'number') parts.push(`success_fraction=${nel.policy.success_fraction}`);
398 return { status: 'good', note: `NEL policy: ${parts.join(', ')}.` };
399}
400
401function evalReportTo({ reportTo, nel }) {
402 if (!reportTo.present) {
403 if (nel.present) {
404 return { status: 'missing', note: 'NEL is set but no Report-To header exists, so reports have nowhere to be delivered.', recommendation: 'Add a Report-To header defining the group named by the NEL report_to field with at least one https endpoint.' };
405 }
406 return { status: 'info', note: 'No Report-To header. The legacy Reporting API pipeline (required by NEL) is not configured.' };
407 }
408 if (reportTo.issues.length || reportTo.groups.some((g) => g.issues.length)) {
409 const problems = [...reportTo.issues, ...reportTo.groups.flatMap((g) => g.issues.map((i) => `group '${g.group}': ${i}`))];
410 return { status: 'missing', note: `Report-To present but invalid: ${problems.join(' ')}`, recommendation: 'Fix the Report-To JSON: each group needs a name, a positive max_age, and at least one https endpoint.' };
411 }
412 const count = reportTo.groups.reduce((acc, g) => acc + g.endpoints.length, 0);
413 return { status: 'good', note: `Report-To defines ${reportTo.groups.length} group(s) with ${count} endpoint(s).` };
414}
415
416function evalReportingEndpoints({ reportingEndpoints }) {
417 if (!reportingEndpoints.present) {
418 return { status: 'info', note: 'No Reporting-Endpoints header (new Reporting API).' };
419 }
420 if (reportingEndpoints.issues.length || reportingEndpoints.endpoints.length === 0) {
421 return { status: 'missing', note: `Reporting-Endpoints present but invalid: ${reportingEndpoints.issues.join(' ')}`, recommendation: 'Fix Reporting-Endpoints entries; each must be name="https://endpoint".' };
422 }
423 return { status: 'good', note: `Reporting-Endpoints defines ${reportingEndpoints.endpoints.length} named endpoint(s).` };
424}
425
426function evalWiring({ nel, reportTo }) {
427 if (!nel.present) return { status: 'info', note: 'No NEL policy to wire.' };
428 const problems = [];
429 let dangling = false;
430 if (nel.valid && typeof nel.policy.report_to === 'string') {
431 const referenced = reportTo.groups.some((g) => g.group === nel.policy.report_to);
432 if (!referenced) {
433 dangling = true;
434 problems.push(`NEL 'report_to' references group '${nel.policy.report_to}' which is not defined in Report-To (reports would be dropped).`);
435 }
436 }
437 if (problems.length) {
438 return { status: 'missing', note: problems.join(' '), recommendation: `Define a Report-To group named '${nel.policy?.report_to}' with an https endpoint, or update the NEL report_to value.`, danglingReportTo: dangling };
439 }
440 if (nel.valid) return { status: 'good', note: `NEL correctly references Report-To group '${nel.policy.report_to}'.`, danglingReportTo: false };
441 return { status: 'info', note: 'NEL wiring could not be verified because the NEL policy is invalid.', danglingReportTo: false };
442}
443
444function evalLegacyVsNew({ reportTo, reportingEndpoints }) {
445 if (!reportTo.present && !reportingEndpoints.present) {
446 return { status: 'info', note: 'Neither the legacy (Report-To) nor the new (Reporting-Endpoints) Reporting API pipeline is configured.' };
447 }
448 if (reportingEndpoints.present && !reportTo.present) {
449 return {
450 status: 'warn',
451 note: 'Only Reporting-Endpoints is set. The new Reporting API does not support NEL — Network Error Logging only works with the legacy Report-To pipeline (and CSP reports also still use Report-To in most browsers).',
452 recommendation: 'Add a Report-To group if you want NEL to work; Reporting-Endpoints alone will not receive NEL reports.',
453 };
454 }
455 if (reportTo.present && reportingEndpoints.present) {
456 const reportToNames = new Set(reportTo.groups.map((g) => g.group).filter(Boolean));
457 const newNames = new Set(reportingEndpoints.endpoints.map((e) => e.name));
458 const shared = [...reportToNames].filter((n) => newNames.has(n));
459 if (!shared.length) {
460 return { status: 'info', note: 'Both pipelines are set with disjoint group/endpoint names.' };
461 }
462 return { status: 'good', note: `Both legacy (Report-To) and new (Reporting-Endpoints) pipelines are configured; ${shared.length} name(s) exist in both (${shared.join(', ')}).` };
463 }
464 return { status: 'good', note: 'Legacy Report-To pipeline is configured (the pipeline NEL requires).' };
465}
466
467function evalEndpoints({ endpointAnalysis }) {
468 if (!endpointAnalysis.endpointCount) {
469 return { status: 'info', note: 'No report endpoints found in any header.' };
470 }
471 const problems = [];
472 const loops = endpointAnalysis.endpoints.filter((e) => e.reportLoop);
473 const nonHttps = endpointAnalysis.endpoints.filter((e) => !e.https);
474 const unparseable = endpointAnalysis.endpoints.filter((e) => e.parseError);
475 if (loops.length) {
476 problems.push(`${loops.length} report endpoint(s) point back at the audited origin — the NEL spec forbids report loops (${loops.map((e) => e.url).join(', ')}).`);
477 }
478 if (nonHttps.length) problems.push(`${nonHttps.length} report endpoint(s) do not use https.`);
479 if (unparseable.length) problems.push(`${unparseable.length} report endpoint URL(s) could not be parsed.`);
480 if (problems.length) {
481 return { status: 'missing', note: problems.join(' '), recommendation: 'Point report endpoints at a dedicated https collector you control (e.g., report-uri.com or a self-hosted collector), not the audited origin.', reportLoop: loops.length > 0 };
482 }
483 return { status: 'good', note: `${endpointAnalysis.endpointCount} report endpoint(s) found; all https and none create report loops.`, reportLoop: false };
484}
485
486const CHECKS = [
487 { name: 'nel', title: 'NEL header', weight: 30, fn: evalNel },
488 { name: 'report-to', title: 'Report-To header', weight: 30, fn: evalReportTo },
489 { name: 'reporting-endpoints', title: 'Reporting-Endpoints header', weight: 10, fn: evalReportingEndpoints },
490 { name: 'wiring', title: 'NEL-to-Report-To wiring', weight: 15, fn: evalWiring },
491 { name: 'pipelines', title: 'Legacy vs new pipeline', weight: 5, fn: evalLegacyVsNew },
492 { name: 'endpoints', title: 'Report endpoints', weight: 10, fn: evalEndpoints },
493];
494
495export function buildCheckReports(nel, reportTo, reportingEndpoints, endpointAnalysis) {
496 const reports = [];
497 let earned = 0;
498 let possible = 0;
499 let danglingReportTo = false;
500 let reportLoop = false;
501 for (const check of CHECKS) {
502 const evaluation = check.fn({ nel, reportTo, reportingEndpoints, endpointAnalysis });
503 if (evaluation.danglingReportTo) danglingReportTo = true;
504 if (evaluation.reportLoop) reportLoop = true;
505
506 if (evaluation.status !== 'info') possible += check.weight;
507 if (evaluation.status === 'good') earned += check.weight;
508 else if (evaluation.status === 'warn') earned += Math.round(check.weight * 0.5);
509 reports.push({
510 name: check.title,
511 check: check.name,
512 status: evaluation.status,
513 note: evaluation.note,
514 weight: check.weight,
515 recommendation: evaluation.recommendation || null,
516 });
517 }
518 return { reports, earned, possible, danglingReportTo, reportLoop };
519}
520
521export function scoreAudit(earned, possible) {
522 if (possible === 0) return 0;
523 return Math.min(100, Math.max(0, Math.round((earned / possible) * 100)));
524}
525
526export function gradeFromScore(score) {
527 if (score >= 95) return 'A+';
528 if (score >= 85) return 'A';
529 if (score >= 75) return 'B';
530 if (score >= 65) return 'C';
531 if (score >= 50) return 'D';
532 if (score >= 30) return 'E';
533 return 'F';
534}
535
536export function buildRecommendations(reports, nel, reportTo) {
537 const recs = new Set();
538 for (const r of reports) {
539 if (r.recommendation) recs.add(r.recommendation);
540 }
541 if (!nel.present && !reportTo.present) {
542 recs.add('To start with Network Error Logging: set Report-To with a default group pointing at an https collector, then a NEL header like {"report_to":"default","max_age":2592000,"include_subdomains":true}.');
543 }
544 if (recs.size === 0) {
545 recs.add('NEL and Reporting API configuration looks well-formed. Re-run this audit after deploy or CDN changes to catch wiring regressions.');
546 }
547 return [...recs];
548}
549
550
551
552
553
554export async function auditNelReporting(input) {
555 const startUrl = await normalizeAndValidateUrl(input.startUrl);
556 const timeoutSeconds = Math.min(Math.max(Number(input.timeoutSeconds || DEFAULT_TIMEOUT_SECONDS), 3), 30);
557
558 const fetchResult = await fetchHeaders(startUrl, timeoutSeconds);
559
560 if (fetchResult.error) {
561 return {
562 inputUrl: input.startUrl,
563 finalUrl: fetchResult.finalUrl,
564 https: fetchResult.https,
565 status: null,
566 hasNel: false,
567 hasReportTo: false,
568 hasReportingEndpoints: false,
569 nel: null,
570 reportTo: null,
571 reportingEndpoints: [],
572 danglingReportTo: false,
573 reportLoop: false,
574 maxAge: null,
575 includeSubdomains: false,
576 endpointCount: 0,
577 httpsEndpointCount: 0,
578 checks: [],
579 issues: [],
580 score: 0,
581 grade: 'F',
582 checkedAt: new Date().toISOString(),
583 recommendations: ['The request failed before headers could be inspected. Verify the URL is reachable and try again.'],
584 error: fetchResult.error,
585 };
586 }
587
588
589
590 const headerEntries = fetchResult.headers;
591 const reportToRaw = typeof headerEntries['report-to'] === 'string' ? [headerEntries['report-to']] : [];
592 const reportingEndpointsRaw = typeof headerEntries['reporting-endpoints'] === 'string' ? [headerEntries['reporting-endpoints']] : [];
593
594 const nel = parseNelHeader(headerEntries.nel);
595 const reportTo = parseReportToHeader(reportToRaw);
596 const reportingEndpoints = parseReportingEndpointsHeader(reportingEndpointsRaw);
597 const endpointAnalysis = analyzeEndpoints(reportTo.groups, reportingEndpoints.endpoints, fetchResult.finalUrl);
598
599 const { reports, earned, possible, danglingReportTo, reportLoop } = buildCheckReports(nel, reportTo, reportingEndpoints, endpointAnalysis);
600 const score = scoreAudit(earned, possible);
601 const grade = gradeFromScore(score);
602 const recommendations = buildRecommendations(reports, nel, reportTo);
603
604 const issues = reports
605 .filter((r) => r.status === 'warn' || r.status === 'missing')
606 .map((r) => `${r.name}: ${r.note}`);
607
608 return {
609 inputUrl: input.startUrl,
610 finalUrl: fetchResult.finalUrl,
611 https: fetchResult.https,
612 status: fetchResult.status,
613 hasNel: nel.present,
614 hasReportTo: reportTo.present,
615 hasReportingEndpoints: reportingEndpoints.present,
616 nel: {
617 raw: headerEntries.nel || null,
618 valid: nel.valid,
619 reportTo: nel.policy?.report_to ?? null,
620 maxAge: nel.policy?.max_age ?? null,
621 includeSubdomains: nel.policy?.include_subdomains === true,
622 failureFraction: nel.policy?.failure_fraction ?? null,
623 successFraction: nel.policy?.success_fraction ?? null,
624 issues: nel.issues,
625 },
626 reportTo: {
627 present: reportTo.present,
628 valid: reportTo.valid,
629 groups: reportTo.groups.map((g) => ({
630 group: g.group,
631 maxAge: g.maxAge,
632 includeSubdomains: g.includeSubdomains,
633 endpoints: g.endpoints,
634 issues: g.issues,
635 })),
636 issues: reportTo.issues,
637 },
638 reportingEndpoints: reportingEndpoints.endpoints.map((e) => ({ name: e.name, url: e.url, https: /^https:\/\//i.test(e.url) })),
639 danglingReportTo,
640 reportLoop,
641 maxAge: nel.policy?.max_age ?? null,
642 includeSubdomains: nel.policy?.include_subdomains === true,
643 endpointCount: endpointAnalysis.endpointCount,
644 httpsEndpointCount: endpointAnalysis.httpsEndpointCount,
645 endpointAnalysis: endpointAnalysis.endpoints,
646 checks: reports,
647 issues,
648 score,
649 grade,
650 checkedAt: new Date().toISOString(),
651 recommendations,
652 };
653}
654
655
656
657
658
659const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
660
661if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
662 await Actor.init();
663 try {
664 const input = await Actor.getInput();
665 const result = await auditNelReporting(input || {});
666 await Actor.pushData(result);
667 await Actor.setValue('OUTPUT', result);
668 Actor.log.info('NEL/Reporting audit complete', { finalUrl: result.finalUrl, score: result.score, grade: result.grade });
669 } finally {
670 await Actor.exit();
671 }
672}