1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6const USER_AGENT = 'ThirdPartyScriptsAuditor/0.1 (+https://apify.com)';
7const DEFAULT_TIMEOUT_SECONDS = 10;
8const DEFAULT_MAX_HTML_BYTES = 1024 * 1024;
9const MAX_HTML_BYTES = 2 * 1024 * 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 literalType = net.isIP(url.hostname);
50 if (literalType === 4 && isPrivateIPv4(url.hostname)) throw new Error('Private IPv4 targets are blocked');
51 if (literalType === 6 && isPrivateIPv6(url.hostname)) throw new Error('Private IPv6 targets are blocked');
52
53 const records = literalType ? [{ address: url.hostname, family: literalType }] : await dns.lookup(url.hostname, { all: true });
54 for (const record of records) {
55 if (record.family === 4 && isPrivateIPv4(record.address)) throw new Error('DNS resolves to a private IPv4 address; blocked for SSRF safety');
56 if (record.family === 6 && isPrivateIPv6(record.address)) throw new Error('DNS resolves to a private IPv6 address; blocked for SSRF safety');
57 }
58 return url;
59}
60
61
62
63
64
65async function fetchHtml(initialUrl, timeoutSeconds, maxBytes, redirectsRemaining = 3) {
66 await normalizeAndValidateUrl(initialUrl.href);
67 const controller = new AbortController();
68 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
69 try {
70 const response = await fetch(initialUrl, {
71 redirect: 'manual',
72 signal: controller.signal,
73 headers: {
74 'user-agent': USER_AGENT,
75 accept: 'text/html,application/xhtml+xml,*/*;q=0.1',
76 },
77 });
78
79 if ([301, 302, 303, 307, 308].includes(response.status)) {
80 if (redirectsRemaining <= 0) throw new Error('Too many redirects');
81 const location = response.headers.get('location');
82 if (!location) throw new Error('Redirect without Location header');
83 const nextUrl = new URL(location, initialUrl.href);
84 await normalizeAndValidateUrl(nextUrl.href);
85 return fetchHtml(nextUrl, timeoutSeconds, maxBytes, redirectsRemaining - 1);
86 }
87
88
89 const reader = response.body.getReader();
90 const chunks = [];
91 let total = 0;
92 let truncated = false;
93 while (true) {
94 const { done, value } = await reader.read();
95 if (done) break;
96 if (total + value.length > maxBytes) {
97 chunks.push(value.slice(0, Math.max(0, maxBytes - total)));
98 truncated = true;
99 break;
100 }
101 chunks.push(value);
102 total += value.length;
103 }
104 try { await reader.cancel(); } catch { }
105
106 const html = new TextDecoder('utf-8', { fatal: false }).decode(Buffer.concat(chunks));
107 const headerMap = {};
108 response.headers.forEach((value, key) => { headerMap[key.toLowerCase()] = value; });
109
110 return {
111 ok: response.ok,
112 status: response.status,
113 finalUrl: response.url || initialUrl.href,
114 https: (response.url || initialUrl.href).startsWith('https://'),
115 html,
116 headers: headerMap,
117 truncated,
118 error: null,
119 };
120 } catch (error) {
121 return {
122 ok: false,
123 status: null,
124 finalUrl: initialUrl.href,
125 https: initialUrl.protocol === 'https:',
126 html: '',
127 headers: {},
128 truncated: false,
129 error: error.message,
130 };
131 } finally {
132 clearTimeout(timeout);
133 }
134}
135
136
137
138
139
140function decodeHtmlEntities(value) {
141 if (!value) return '';
142 return value
143 .replace(/&/gi, '&')
144 .replace(/"/gi, '"')
145 .replace(/'|'/gi, "'")
146 .replace(/</gi, '<')
147 .replace(/>/gi, '>')
148 .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
149 .replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(parseInt(code, 16)))
150 .trim();
151}
152
153function parseAttributes(tag) {
154 const attrs = {};
155
156 const attrPattern = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*(?:=\s*("[^"]*"|'[^']*'|[^\s"'>/]+))?/g;
157 for (const match of tag.matchAll(attrPattern)) {
158 if (match[2] === undefined) {
159
160 attrs[match[1].toLowerCase()] = '';
161 } else {
162 const raw = match[2];
163 const unquoted = raw.startsWith('"') || raw.startsWith("'") ? raw.slice(1, -1) : raw;
164 attrs[match[1].toLowerCase()] = decodeHtmlEntities(unquoted);
165 }
166 }
167 return attrs;
168}
169
170function matchTag(html, tagName) {
171
172 const pattern = new RegExp(`<${tagName}\\b[^>]*>`, 'gi');
173 return [...html.matchAll(pattern)].map((m) => ({ raw: m[0], attrs: parseAttributes(m[0]) }));
174}
175
176
177
178
179
180
181
182const PROVIDER_MATCHERS = [
183 { provider: 'Google Tag Manager', test: (h) => h.includes('googletagmanager.com') },
184 { provider: 'Google Analytics', test: (h) => h.includes('google-analytics.com') || h.includes('ssl-google-analytics.com') },
185 { provider: 'Google Fonts', test: (h) => h.includes('fonts.googleapis.com') || h.includes('fonts.gstatic.com') },
186 { provider: 'Google Ads', test: (h) => h.includes('googleadservices.com') || h.includes('doubleclick.net') || h.includes('googlesyndication.com') },
187 { provider: 'YouTube', test: (h) => h.includes('youtube.com') || h.includes('youtu.be') },
188 { provider: 'Facebook', test: (h) => h.includes('facebook.com') || h.includes('fbcdn.net') || h.includes('connect.facebook.net') },
189 { provider: 'Meta Pixel', test: (h) => h.includes('connect.facebook.net') },
190 { provider: 'X (Twitter)', test: (h) => h.includes('twitter.com') || h.includes('twimg.com') || h.includes('x.com') },
191 { provider: 'Hotjar', test: (h) => h.includes('hotjar.com') },
192 { provider: 'Microsoft Clarity', test: (h) => h.includes('clarity.ms') },
193 { provider: 'HubSpot', test: (h) => h.includes('hs-scripts.com') || h.includes('hsforms.net') || h.includes('hubspot.com') },
194 { provider: 'Stripe', test: (h) => h.includes('js.stripe.com') || h.includes('m.stripe.com') },
195 { provider: 'Cloudflare', test: (h) => h.includes('cloudflareinsights.com') || h.includes('cloudflare.com') },
196 { provider: 'jsDelivr', test: (h) => h.includes('cdn.jsdelivr.net') },
197 { provider: 'Bootstrap CDN', test: (h) => h.includes('stackpath.bootstrapcdn.com') },
198 { provider: 'unpkg', test: (h) => h.includes('unpkg.com') },
199 { provider: 'LinkedIn', test: (h) => h.includes('linkedin.com') },
200 { provider: 'TikTok', test: (h) => h.includes('tiktok.com') || h.includes('snssdk.com') },
201 { provider: 'Pinterest', test: (h) => h.includes('pinimg.com') || h.includes('pinterest.com') },
202 { provider: 'Amazon', test: (h) => h.includes('amazon.com') || h.includes('amazonaws.com') },
203 { provider: 'Salesforce', test: (h) => h.includes('salesforce.com') || h.includes('force.com') || h.includes('sfdc.net') },
204 { provider: 'Marketo', test: (h) => h.includes('marketo.com') || h.includes('mktoresp.com') || h.includes('mktoweb.com') },
205 { provider: 'Zendesk', test: (h) => h.includes('zdassets.com') || h.includes('zendesk.com') },
206 { provider: 'Intercom', test: (h) => h.includes('widget.intercom.io') || h.includes('intercomcdn.com') },
207 { provider: 'Segment', test: (h) => h.includes('cdn.segment.com') || h.includes('analytics.segment.com') },
208 { provider: 'Sentry', test: (h) => h.includes('sentry.io') || h.includes('sentry-cdn.com') },
209 { provider: 'Datadog', test: (h) => h.includes('datadog') || h.includes('browser-sdk.datadoghq.com') },
210 { provider: 'Mixpanel', test: (h) => h.includes('mixpanel.com') || h.includes('cdn.mxpnl.com') },
211 { provider: 'Amplitude', test: (h) => h.includes('amplitude.com') },
212 { provider: 'FullStory', test: (h) => h.includes('fullstory.com') },
213 { provider: 'Plausible', test: (h) => h.includes('plausible.io') },
214 { provider: 'Matomo', test: (h) => h.includes('matomo') || h.includes('cdn.matomo.cloud') },
215 { provider: 'TikTok Pixel', test: (h) => h.includes('analytics.tiktok.com') },
216];
217
218
219const TRACKER_MATCHERS = [
220 { provider: 'Google Ads', test: (h) => h.includes('doubleclick.net') || h.includes('googleadservices.com') || h.includes('googlesyndication.com') },
221 { provider: 'Facebook', test: (h) => h.includes('connect.facebook.net') || h.includes('facebook.com/tr') },
222 { provider: 'TikTok Pixel', test: (h) => h.includes('analytics.tiktok.com') || h.includes('bat.bing.com') === false && h.includes('tiktok.com') },
223 { provider: 'LinkedIn Insight', test: (h) => h.includes('px.ads.linkedin.com') || h.includes('snap.licdn.com') },
224 { provider: 'X (Twitter) Ads', test: (h) => h.includes('ads-twitter.com') || h.includes('analytics.twitter.com') },
225 { provider: 'Microsoft Ads', test: (h) => h.includes('bat.bing.com') },
226 { provider: 'Criteo', test: (h) => h.includes('criteo.com') || h.includes('criteo.net') },
227 { provider: 'Taboola', test: (h) => h.includes('taboola.com') },
228 { provider: 'Outbrain', test: (h) => h.includes('outbrain.com') },
229 { provider: 'Yandex Metrica', test: (h) => h.includes('mc.yandex.ru') || h.includes('yandex.ru') || h.includes('yandex.com') },
230 { provider: 'Baidu Ads', test: (h) => h.includes('hm.baidu.com') || h.includes('pos.baidu.com') },
231];
232
233function classifyProvider(hostname) {
234 const h = hostname.toLowerCase();
235 for (const m of PROVIDER_MATCHERS) {
236 if (m.test(h)) return m.provider;
237 }
238 return 'Unknown';
239}
240
241function isTracker(hostname) {
242 const h = hostname.toLowerCase();
243 for (const m of TRACKER_MATCHERS) {
244 if (m.test(h)) return m.provider;
245 }
246 return null;
247}
248
249
250
251
252
253function originOf(href, baseUrl) {
254 try {
255 const u = new URL(href, baseUrl);
256 return `${u.protocol}//${u.host}`;
257 } catch {
258 return null;
259 }
260}
261
262function hostOf(href, baseUrl) {
263 try {
264 const u = new URL(href, baseUrl);
265 return u.hostname.toLowerCase();
266 } catch {
267 return null;
268 }
269}
270
271function isExternalTo(href, baseUrl) {
272 const host = hostOf(href, baseUrl);
273 if (!host) return false;
274 let pageHost;
275 try { pageHost = new URL(baseUrl).hostname.toLowerCase(); } catch { return false; }
276 if (host === pageHost) return false;
277
278 const pageSuffix = pageHost.split('.').slice(-2).join('.');
279 const hostSuffix = host.split('.').slice(-2).join('.');
280 return hostSuffix !== pageSuffix;
281}
282
283function analyzeScriptTag(tag, baseUrl) {
284 const src = tag.attrs.src || '';
285 const isInline = !src;
286 const attrs = tag.attrs;
287 const asyncPresent = attrs.async !== undefined;
288 const deferPresent = attrs.defer !== undefined;
289 const type = (attrs.type || '').toLowerCase() || null;
290 const crossorigin = attrs.crossorigin !== undefined ? (attrs.crossorigin || 'anonymous') : null;
291 const integrity = attrs.integrity || null;
292 const isModule = type === 'module';
293
294 if (isInline) {
295 return {
296 tag: 'script',
297 src: '',
298 origin: null,
299 host: null,
300 provider: 'Inline',
301 thirdParty: false,
302 renderBlocking: false,
303 hasSri: false,
304 https: null,
305 tracker: null,
306 module: isModule,
307 issues: [],
308 recommendation: null,
309 };
310 }
311
312 const origin = originOf(src, baseUrl);
313 const host = hostOf(src, baseUrl);
314 const external = isExternalTo(src, baseUrl);
315 const provider = classifyProvider(host || '');
316 const tracker = external ? isTracker(host || '') : null;
317 const issues = [];
318 let recommendation = null;
319
320
321 const renderBlocking = !isModule && !asyncPresent && !deferPresent;
322 if (renderBlocking) {
323 issues.push('render-blocking script: no async or defer');
324 recommendation = 'Add async or defer to non-critical scripts to avoid blocking HTML parsing.';
325 }
326
327
328 const isThirdPartyScript = external;
329 if (isThirdPartyScript && !integrity) {
330 issues.push('missing Subresource Integrity (integrity attribute)');
331 if (!recommendation) recommendation = 'Add an integrity attribute (SRI hash) to third-party scripts to prevent CDN tampering.';
332 }
333
334
335 const srcScheme = /^https:\/\//i.test(src) ? 'https' : /^http:\/\//i.test(src) ? 'http' : 'relative';
336 const https = srcScheme === 'https' ? true : srcScheme === 'http' ? false : null;
337 if (srcScheme === 'http') {
338 issues.push('insecure http:// script source');
339 if (!recommendation) recommendation = 'Load scripts over HTTPS to avoid mixed-content warnings and tampering.';
340 }
341
342
343 if (integrity && !crossorigin) {
344 issues.push('integrity requires crossorigin to take effect');
345 if (!recommendation) recommendation = 'Add crossorigin to <script integrity> so the browser enforces the SRI hash.';
346 }
347
348 return {
349 tag: 'script',
350 src,
351 origin,
352 host,
353 provider: external ? provider : (host ? 'First-party' : 'Inline'),
354 thirdParty: external,
355 renderBlocking,
356 hasSri: !!integrity,
357 https,
358 tracker,
359 module: isModule,
360 issues,
361 recommendation,
362 };
363}
364
365function analyzeLinkTag(tag, baseUrl) {
366 const rel = (tag.attrs.rel || '').toLowerCase();
367 const href = tag.attrs.href || '';
368
369
370 const isStylesheet = rel.includes('stylesheet');
371 const isPreload = rel.includes('preload');
372 const isModulepreload = rel.includes('modulepreload');
373 if (!isStylesheet && !isPreload && !isModulepreload) return null;
374
375 if (!href) {
376 return {
377 tag: 'link',
378 rel,
379 src: '',
380 origin: null,
381 host: null,
382 provider: 'Inline',
383 thirdParty: false,
384 renderBlocking: isStylesheet,
385 hasSri: false,
386 https: null,
387 tracker: null,
388 issues: ['missing href'],
389 recommendation: 'Add an href attribute to this link tag.',
390 };
391 }
392
393 const origin = originOf(href, baseUrl);
394 const host = hostOf(href, baseUrl);
395 const external = isExternalTo(href, baseUrl);
396 const provider = classifyProvider(host || '');
397 const tracker = external ? isTracker(host || '') : null;
398 const integrity = tag.attrs.integrity || null;
399 const crossorigin = tag.attrs.crossorigin !== undefined ? (tag.attrs.crossorigin || 'anonymous') : null;
400 const issues = [];
401 let recommendation = null;
402
403
404 const media = tag.attrs.media || '';
405 const renderBlocking = isStylesheet && (!media || media === 'all' || media === 'screen');
406 if (renderBlocking) {
407 issues.push('render-blocking stylesheet' + (media ? ` (media="${media}")` : ''));
408 if (!recommendation) recommendation = 'Consider loading non-critical CSS asynchronously (media swap, preload + onload) or inlining critical CSS.';
409 }
410
411 if (external && !integrity) {
412 issues.push('missing Subresource Integrity (integrity attribute)');
413 if (!recommendation) recommendation = 'Add an integrity attribute (SRI hash) to third-party stylesheets to prevent CDN tampering.';
414 }
415
416 const srcScheme = /^https:\/\//i.test(href) ? 'https' : /^http:\/\//i.test(href) ? 'http' : 'relative';
417 const https = srcScheme === 'https' ? true : srcScheme === 'http' ? false : null;
418 if (srcScheme === 'http') {
419 issues.push('insecure http:// stylesheet source');
420 if (!recommendation) recommendation = 'Load stylesheets over HTTPS to avoid mixed-content warnings and tampering.';
421 }
422
423 if (integrity && !crossorigin) {
424 issues.push('integrity requires crossorigin to take effect');
425 if (!recommendation) recommendation = 'Add crossorigin to <link integrity> so the browser enforces the SRI hash.';
426 }
427
428 return {
429 tag: 'link',
430 rel,
431 src: href,
432 origin,
433 host,
434 provider: external ? provider : (host ? 'First-party' : 'Inline'),
435 thirdParty: external,
436 renderBlocking,
437 hasSri: !!integrity,
438 https,
439 tracker,
440 issues,
441 recommendation,
442 };
443}
444
445function analyzeIframeTag(tag, baseUrl) {
446 const src = tag.attrs.src || '';
447 if (!src) {
448 return {
449 tag: 'iframe',
450 src: '',
451 origin: null,
452 host: null,
453 provider: 'Inline',
454 thirdParty: false,
455 renderBlocking: false,
456 hasSri: false,
457 https: null,
458 tracker: null,
459 issues: ['iframe without src'],
460 recommendation: 'Add a src to this iframe or remove it if unused.',
461 };
462 }
463
464 const origin = originOf(src, baseUrl);
465 const host = hostOf(src, baseUrl);
466 const external = isExternalTo(src, baseUrl);
467 const provider = classifyProvider(host || '');
468 const tracker = external ? isTracker(host || '') : null;
469 const loading = (tag.attrs.loading || '').toLowerCase();
470 const issues = [];
471 let recommendation = null;
472
473 if (loading !== 'lazy') {
474 issues.push('iframe missing loading="lazy"');
475 if (!recommendation) recommendation = 'Add loading="lazy" to non-critical iframes to defer layout and network work.';
476 }
477
478 const srcScheme = /^https:\/\//i.test(src) ? 'https' : /^http:\/\//i.test(src) ? 'http' : 'relative';
479 const https = srcScheme === 'https' ? true : srcScheme === 'http' ? false : null;
480 if (srcScheme === 'http') {
481 issues.push('insecure http:// iframe source');
482 if (!recommendation) recommendation = 'Load iframes over HTTPS to avoid mixed-content warnings and tampering.';
483 }
484
485 return {
486 tag: 'iframe',
487 src,
488 origin,
489 host,
490 provider: external ? provider : (host ? 'First-party' : 'Inline'),
491 thirdParty: external,
492 renderBlocking: false,
493 hasSri: false,
494 https,
495 tracker,
496 issues,
497 recommendation,
498 };
499}
500
501export function analyzeThirdPartyResources(html, baseUrl) {
502 const resources = [];
503
504 for (const tag of matchTag(html, 'script')) {
505 const parsed = analyzeScriptTag(tag, baseUrl);
506 if (parsed) resources.push(parsed);
507 }
508 for (const tag of matchTag(html, 'link')) {
509 const parsed = analyzeLinkTag(tag, baseUrl);
510 if (parsed) resources.push(parsed);
511 }
512 for (const tag of matchTag(html, 'iframe')) {
513 const parsed = analyzeIframeTag(tag, baseUrl);
514 if (parsed) resources.push(parsed);
515 }
516
517
518 const firstPartyCount = resources.filter((r) => !r.thirdParty && r.src).length;
519 const thirdPartyCount = resources.filter((r) => r.thirdParty).length;
520 const byType = {};
521 for (const r of resources) {
522 const key = r.tag === 'link' ? `${r.tag}:${(r.rel || 'stylesheet').split(' ')[0]}` : r.tag;
523 byType[key] = (byType[key] || 0) + 1;
524 }
525 const byProvider = {};
526 for (const r of resources) {
527 if (!r.provider) continue;
528 if (r.provider === 'Inline' || r.provider === 'First-party') continue;
529 byProvider[r.provider] = (byProvider[r.provider] || 0) + 1;
530 }
531
532
533 const issues = [];
534 for (const r of resources) {
535 for (const issue of r.issues) {
536 const line = `${r.tag} ${r.src || '(inline)'}: ${issue}`;
537 if (!issues.includes(line)) issues.push(line);
538 }
539 }
540
541 return { resources, firstPartyCount, thirdPartyCount, byType, byProvider, issues };
542}
543
544export function scoreThirdPartyResources(resources, issues) {
545
546 if (resources.length === 0) return 100;
547
548 let earned = 60;
549 const trackerCount = resources.filter((r) => r.tracker).length;
550 const blockingCount = resources.filter((r) => r.renderBlocking).length;
551 const missingSriThirdParty = resources.filter((r) => r.thirdParty && !r.hasSri && (r.tag === 'script' || r.tag === 'link')).length;
552 const insecureCount = resources.filter((r) => r.https === false).length;
553
554
555 if (trackerCount === 0) earned += 15;
556 if (blockingCount === 0) earned += 15;
557 if (missingSriThirdParty === 0) earned += 5;
558 if (insecureCount === 0) earned += 5;
559
560
561 earned -= Math.min(trackerCount * 8, 30);
562 earned -= Math.min(blockingCount * 5, 25);
563 earned -= Math.min(missingSriThirdParty * 3, 15);
564 earned -= Math.min(insecureCount * 5, 20);
565 earned -= Math.min(issues.length * 2, 20);
566
567 return Math.max(0, Math.min(100, Math.round(earned)));
568}
569
570export function gradeFromScore(score) {
571 if (score >= 95) return 'A+';
572 if (score >= 85) return 'A';
573 if (score >= 75) return 'B';
574 if (score >= 65) return 'C';
575 if (score >= 50) return 'D';
576 if (score >= 30) return 'E';
577 return 'F';
578}
579
580export function buildRecommendations(resources, issues, byProvider) {
581 const recs = new Set();
582
583 for (const r of resources) {
584 if (r.recommendation) recs.add(r.recommendation);
585 }
586
587 const trackers = resources.filter((r) => r.tracker);
588 if (trackers.length > 0) {
589 const names = [...new Set(trackers.map((r) => r.tracker))].slice(0, 5).join(', ');
590 recs.add(`Detected advertising/tracking scripts (${names}). Review consent banners and privacy policy for GDPR/CCPA compliance.`);
591 }
592
593 const blocking = resources.filter((r) => r.renderBlocking);
594 if (blocking.length > 0) {
595 recs.add(`${blocking.length} render-blocking resource(s) found. Add async/defer to non-critical scripts and consider preload for late-discovered CSS.`);
596 }
597
598 const missingSri = resources.filter((r) => r.thirdParty && !r.hasSri && (r.tag === 'script' || r.tag === 'link'));
599 if (missingSri.length > 0) {
600 recs.add(`${missingSri.length} third-party script/stylesheet(s) missing Subresource Integrity. Add integrity hashes to protect against CDN tampering.`);
601 }
602
603 const insecure = resources.filter((r) => r.https === false);
604 if (insecure.length > 0) {
605 recs.add(`${insecure.length} resource(s) loaded over insecure http://. Move to HTTPS to prevent mixed-content and MITM risks.`);
606 }
607
608 if (issues.length === 0 && resources.length > 0) {
609 recs.add('Third-party script posture looks well-structured. Schedule this audit periodically to catch regressions.');
610 }
611
612 return [...recs];
613}
614
615
616
617
618
619export async function auditThirdPartyScripts(input) {
620 const startUrl = await normalizeAndValidateUrl(input.startUrl);
621 const timeoutSeconds = Math.min(Math.max(Number(input.timeoutSeconds || DEFAULT_TIMEOUT_SECONDS), 3), 30);
622 const maxHtmlBytes = Math.min(Math.max(Number(input.maxHtmlBytes || DEFAULT_MAX_HTML_BYTES), 16384), MAX_HTML_BYTES);
623
624 const fetchResult = await fetchHtml(startUrl, timeoutSeconds, maxHtmlBytes);
625
626 if (fetchResult.error) {
627 return {
628 inputUrl: input.startUrl,
629 finalUrl: fetchResult.finalUrl,
630 https: fetchResult.https,
631 resourceCount: 0,
632 firstPartyCount: 0,
633 thirdPartyCount: 0,
634 byType: {},
635 byProvider: {},
636 resources: [],
637 issues: [],
638 score: 0,
639 grade: 'F',
640 checkedAt: new Date().toISOString(),
641 recommendations: ['The request failed before HTML could be inspected. Verify the URL is reachable and try again.'],
642 error: fetchResult.error,
643 };
644 }
645
646 const contentType = fetchResult.headers['content-type'] || '';
647 const isHtml = /text\/html|application\/xhtml+xml/i.test(contentType);
648
649 const { resources, firstPartyCount, thirdPartyCount, byType, byProvider, issues } = isHtml
650 ? analyzeThirdPartyResources(fetchResult.html, fetchResult.finalUrl)
651 : { resources: [], firstPartyCount: 0, thirdPartyCount: 0, byType: {}, byProvider: {}, issues: ['Response is not HTML; cannot parse third-party resources.'] };
652
653 const score = scoreThirdPartyResources(resources, issues);
654 const grade = gradeFromScore(score);
655 const recommendations = buildRecommendations(resources, issues, byProvider);
656
657 return {
658 inputUrl: input.startUrl,
659 finalUrl: fetchResult.finalUrl,
660 https: fetchResult.https,
661 resourceCount: resources.length,
662 firstPartyCount,
663 thirdPartyCount,
664 byType,
665 byProvider,
666 resources,
667 issues,
668 score,
669 grade,
670 checkedAt: new Date().toISOString(),
671 recommendations,
672 };
673}
674
675
676
677
678
679const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
680
681if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
682 await Actor.init();
683 try {
684 const input = await Actor.getInput();
685 const result = await auditThirdPartyScripts(input || {});
686 await Actor.pushData(result);
687 await Actor.setValue('OUTPUT', result);
688 Actor.log.info('Third-party scripts audit complete', { finalUrl: result.finalUrl, resourceCount: result.resourceCount, thirdPartyCount: result.thirdPartyCount, score: result.score, grade: result.grade });
689 } finally {
690 await Actor.exit();
691 }
692}