1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6const USER_AGENT = 'CookieSecurityAuditor/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 const withScheme = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`;
42 const url = new URL(withScheme);
43 if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported');
44 if (!url.hostname || url.username || url.password) throw new Error('URL must be public and must not include credentials');
45
46 const literalType = net.isIP(url.hostname);
47 if (literalType === 4 && isPrivateIPv4(url.hostname)) throw new Error('Private IPv4 targets are blocked');
48 if (literalType === 6 && isPrivateIPv6(url.hostname)) throw new Error('Private IPv6 targets are blocked');
49
50 const records = literalType ? [{ address: url.hostname, family: literalType }] : await dns.lookup(url.hostname, { all: true });
51 for (const record of records) {
52 if (record.family === 4 && isPrivateIPv4(record.address)) throw new Error('DNS resolves to a private IPv4 address; blocked for SSRF safety');
53 if (record.family === 6 && isPrivateIPv6(record.address)) throw new Error('DNS resolves to a private IPv6 address; blocked for SSRF safety');
54 }
55 return url;
56}
57
58
59
60
61
62
63async function fetchCookies(initialUrl, timeoutSeconds, redirectsRemaining = 3, collected = []) {
64 await normalizeAndValidateUrl(initialUrl.href);
65 const controller = new AbortController();
66 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
67 try {
68 const response = await fetch(initialUrl, {
69 redirect: 'manual',
70 signal: controller.signal,
71 headers: {
72 'user-agent': USER_AGENT,
73 accept: 'text/html,application/xhtml+xml,*/*;q=0.1',
74 },
75 });
76
77
78
79
80 const setCookies = typeof response.headers.getSetCookie === 'function'
81 ? response.headers.getSetCookie()
82 : collectSetCookiesRaw(response.headers);
83
84 for (const raw of setCookies) {
85 collected.push({ raw, sourceUrl: initialUrl.href });
86 }
87
88 if ([301, 302, 303, 307, 308].includes(response.status)) {
89 if (redirectsRemaining <= 0) throw new Error('Too many redirects');
90 const location = response.headers.get('location');
91 if (!location) throw new Error('Redirect without Location header');
92 const nextUrl = new URL(location, initialUrl.href);
93
94 try { await response.arrayBuffer(); } catch { }
95 return fetchCookies(nextUrl, timeoutSeconds, redirectsRemaining - 1, collected);
96 }
97
98
99 try { await response.arrayBuffer(); } catch { }
100
101 return {
102 ok: response.ok,
103 status: response.status,
104 finalUrl: response.url || initialUrl.href,
105 https: (response.url || initialUrl.href).startsWith('https://'),
106 setCookies: collected,
107 error: null,
108 };
109 } catch (error) {
110 return {
111 ok: false,
112 status: null,
113 finalUrl: initialUrl.href,
114 https: initialUrl.protocol === 'https:',
115 setCookies: collected,
116 error: error.message,
117 };
118 } finally {
119 clearTimeout(timeout);
120 }
121}
122
123
124
125
126
127function collectSetCookiesRaw(headers) {
128 const raw = headers.get('set-cookie');
129 if (!raw) return [];
130
131
132 return [raw];
133}
134
135
136
137
138
139
140
141
142export function parseSetCookie(rawHeader) {
143 if (!rawHeader || typeof rawHeader !== 'string') return null;
144 const parts = rawHeader.split(';');
145 const firstPart = parts[0];
146 const eqIdx = firstPart.indexOf('=');
147 if (eqIdx === -1) return null;
148
149 const name = firstPart.slice(0, eqIdx).trim();
150 const value = firstPart.slice(eqIdx + 1).trim();
151 if (!name) return null;
152
153 const attrs = {};
154 for (let i = 1; i < parts.length; i++) {
155 const part = parts[i].trim();
156 if (!part) continue;
157 const attrEqIdx = part.indexOf('=');
158 const attrName = (attrEqIdx === -1 ? part : part.slice(0, attrEqIdx)).toLowerCase();
159 const attrValue = attrEqIdx === -1 ? '' : part.slice(attrEqIdx + 1).trim();
160 attrs[attrName] = attrValue;
161 }
162
163 return {
164 name,
165 value,
166 secure: 'secure' in attrs,
167 httpOnly: 'httponly' in attrs,
168 sameSite: parseSameSite(attrs.samesite),
169 sameSiteRaw: attrs.samesite ?? null,
170 domain: attrs.domain ?? null,
171 path: attrs.path ?? null,
172 maxAge: attrs['max-age'] ? parseInt(attrs['max-age'], 10) : null,
173 expires: attrs.expires ?? null,
174 partitioned: 'partitioned' in attrs,
175 prefix: detectPrefix(name),
176 isSession: isSessionCookie(attrs),
177 expirySeconds: calculateExpirySeconds(attrs),
178 };
179}
180
181function parseSameSite(raw) {
182 if (!raw) return 'unset';
183 const v = raw.toLowerCase().trim();
184 if (v === 'lax') return 'lax';
185 if (v === 'strict') return 'strict';
186 if (v === 'none') return 'none';
187 return 'unset';
188}
189
190function detectPrefix(name) {
191 if (name.startsWith('__Host-')) return '__Host-';
192 if (name.startsWith('__Secure-')) return '__Secure-';
193 return 'none';
194}
195
196function isSessionCookie(attrs) {
197
198 return !('max-age' in attrs) && !('expires' in attrs);
199}
200
201function calculateExpirySeconds(attrs) {
202 if ('max-age' in attrs) {
203 const n = parseInt(attrs['max-age'], 10);
204 return Number.isNaN(n) ? null : n;
205 }
206 if ('expires' in attrs && attrs.expires) {
207 const ts = Date.parse(attrs.expires);
208 if (Number.isNaN(ts)) return null;
209 return Math.round((ts - Date.now()) / 1000);
210 }
211 return null;
212}
213
214
215
216
217
218
219
220const TRACKING_COOKIES = new Set([
221
222 '_ga', '_gid', '_gat', '_gcl_au', '_gcl_gs', '_gcl_dc',
223 '__utma', '__utmb', '__utmc', '__utmz', '__utmt',
224
225 '_fbp', '_fbc', 'fr', 'dpr',
226
227 '_hjid', '_hjSessionUser_', '_hjSession_', '_hjAbsoluteSessionInProgress',
228
229 '__hstc', '__hssc', '__hssrc', 'hubspotutk',
230
231 'li_sug', 'bcookie', 'lidc', 'li.alerts', 'li_at',
232
233 'personalization_id', 'guest_id', 'ct0', 'twid',
234
235 'ajs_user_id', 'ajs_anonymous_id',
236
237 'mp_id', 'mp__segments',
238
239 'amp_device_id',
240
241 '_clck', '_clsk', 'MR', 'CLID',
242
243 'MUID', '_uetmsclkid',
244
245 '_pin_unauth', '_pinterest_sess',
246
247 'csv', 'reddit_session',
248
249 'ttp', 'ttcs',
250]);
251
252const TRACKING_PREFIXES = [
253 '_hj',
254 'ajs',
255 'mp_',
256 'amp_',
257 '_cl',
258 'opt',
259 'CookieConsent',
260 'consent',
261];
262
263function isTrackingCookie(name) {
264 if (TRACKING_COOKIES.has(name)) return true;
265 return TRACKING_PREFIXES.some((p) => name.startsWith(p));
266}
267
268
269
270
271
272
273
274function validatePrefix(parsed) {
275 if (parsed.prefix === '__Host-') {
276 const issues = [];
277 if (!parsed.secure) issues.push('__Host- prefix requires the Secure flag');
278 if (parsed.domain !== null) issues.push('__Host- prefix must not include a Domain attribute');
279 if (parsed.path !== '/') issues.push('__Host- prefix requires Path=/');
280 return { valid: issues.length === 0, issues };
281 }
282 if (parsed.prefix === '__Secure-') {
283 const issues = [];
284 if (!parsed.secure) issues.push('__Secure- prefix requires the Secure flag');
285 return { valid: issues.length === 0, issues };
286 }
287 return { valid: true, issues: [] };
288}
289
290
291
292
293
294const LONG_EXPIRY_THRESHOLD_SECONDS = 365 * 24 * 60 * 60;
295
296function isLikelySessionCookieName(name) {
297
298 const lower = name.toLowerCase();
299 return lower.includes('session') || lower.includes('sess')
300 || lower.includes('token') || lower.includes('auth')
301 || lower.includes('csrf') || lower === 'sid'
302 || lower.includes('jwt') || lower.includes('aspnet')
303 || lower.includes('phpsessid') || lower.includes('jsession');
304}
305
306export function analyzeCookie(parsed, requestHost) {
307 const prefixCheck = validatePrefix(parsed);
308 const tracking = isTrackingCookie(parsed.name);
309 const thirdParty = isThirdParty(parsed, requestHost);
310 const longExpiry = parsed.expirySeconds !== null && parsed.expirySeconds > LONG_EXPIRY_THRESHOLD_SECONDS;
311 const likelySession = isLikelySessionCookieName(parsed.name) || parsed.isSession;
312 const sameSiteNoneWithoutSecure = parsed.sameSite === 'none' && !parsed.secure;
313
314 const issues = [];
315
316 if (!parsed.secure) issues.push({ severity: 'error', message: `Cookie "${parsed.name}" is missing the Secure flag; it can be transmitted over plaintext HTTP.` });
317 if (likelySession && !parsed.httpOnly) issues.push({ severity: 'error', message: `Cookie "${parsed.name}" appears to be a session/auth cookie but is missing the HttpOnly flag; JavaScript can read it (XSS risk).` });
318 if (sameSiteNoneWithoutSecure) issues.push({ severity: 'error', message: `Cookie "${parsed.name}" has SameSite=None without Secure; browsers will reject this cookie.` });
319 if (parsed.sameSite === 'none' && parsed.secure) issues.push({ severity: 'warn', message: `Cookie "${parsed.name}" has SameSite=None; it is sent cross-site (CSRF surface). Consider Lax or Strict if cross-site sending is not required.` });
320 if (parsed.sameSite === 'unset') issues.push({ severity: 'warn', message: `Cookie "${parsed.name}" has no SameSite attribute; modern browsers default to Lax, but explicit declaration is recommended.` });
321 if (!prefixCheck.valid) for (const pi of prefixCheck.issues) issues.push({ severity: 'error', message: pi });
322 if (tracking) issues.push({ severity: 'info', message: `Cookie "${parsed.name}" is a known tracking/analytics cookie; review consent requirements under GDPR/CCPA.` });
323 if (longExpiry) issues.push({ severity: 'warn', message: `Cookie "${parsed.name}" has an expiry exceeding 1 year; long-lived cookies increase privacy exposure.` });
324 if (thirdParty && parsed.domain) issues.push({ severity: 'info', message: `Cookie "${parsed.name}" sets a Domain attribute (${parsed.domain}) that is not the request host; it can be sent cross-site.` });
325
326 return {
327 name: parsed.name,
328 value: parsed.value,
329 secure: parsed.secure,
330 httpOnly: parsed.httpOnly,
331 sameSite: parsed.sameSite,
332 sameSiteRaw: parsed.sameSiteRaw,
333 domain: parsed.domain,
334 path: parsed.path,
335 maxAge: parsed.maxAge,
336 expires: parsed.expires,
337 partitioned: parsed.partitioned,
338 prefix: parsed.prefix,
339 prefixValid: prefixCheck.valid,
340 prefixIssues: prefixCheck.issues,
341 isSession: parsed.isSession,
342 expirySeconds: parsed.expirySeconds,
343 longExpiry,
344 tracking,
345 thirdParty,
346 likelySession,
347 sameSiteNoneWithoutSecure,
348 issues,
349 };
350}
351
352
353
354function isThirdParty(parsed, requestHost) {
355 if (!parsed.domain) return false;
356 const cookieDomain = parsed.domain.toLowerCase().replace(/^\./, '');
357 const host = requestHost.toLowerCase();
358 if (host === cookieDomain) return false;
359 if (host.endsWith('.' + cookieDomain)) return false;
360 return true;
361}
362
363
364
365
366
367export function scoreCookies(analysis) {
368 if (analysis.cookieCount === 0) {
369 return {
370 score: 100,
371 grade: 'A+',
372 issues: [],
373 recommendations: ['No Set-Cookie headers were returned. If the page is expected to set cookies, verify that the request reaches the correct endpoint.'],
374 };
375 }
376
377 let score = 100;
378 const issues = [];
379
380
381 if (analysis.hasMissingSecure) {
382 score -= 20;
383 issues.push('One or more cookies are missing the Secure flag; they can be transmitted over plaintext HTTP.');
384 }
385
386
387 if (analysis.hasMissingHttpOnly) {
388 score -= 20;
389 issues.push('One or more session/auth cookies are missing the HttpOnly flag; JavaScript can read them (XSS theft risk).');
390 }
391
392
393 if (analysis.cookies.some((c) => c.sameSiteNoneWithoutSecure)) {
394 score -= 15;
395 issues.push('One or more cookies have SameSite=None without Secure; browsers will reject these cookies.');
396 }
397
398
399 if (analysis.sameSiteNoneCount > 0 && !analysis.cookies.some((c) => c.sameSiteNoneWithoutSecure)) {
400 score -= 10;
401 issues.push(`${analysis.sameSiteNoneCount} cookie(s) use SameSite=None; they are sent cross-site, expanding CSRF surface.`);
402 }
403
404
405 if (analysis.hasSameSiteUnset) {
406 score -= 5;
407 issues.push('One or more cookies have no SameSite attribute; explicit declaration is recommended.');
408 }
409
410
411 if (analysis.hasInvalidPrefix) {
412 score -= 10;
413 issues.push('One or more cookies with __Host- or __Secure- prefix do not meet the prefix requirements.');
414 }
415
416
417 if (analysis.trackingCookieCount > 0) {
418 const penalty = Math.min(15, analysis.trackingCookieCount * 3);
419 score -= penalty;
420 issues.push(`${analysis.trackingCookieCount} known tracking/analytics cookie(s) detected; review consent requirements under GDPR/CCPA.`);
421 }
422
423
424 if (analysis.hasLongExpiry) {
425 score -= 5;
426 issues.push('One or more cookies have an expiry exceeding 1 year; long-lived cookies increase privacy exposure.');
427 }
428
429
430 if (analysis.thirdPartyCount > 0) {
431 score -= 5;
432 issues.push(`${analysis.thirdPartyCount} cookie(s) set a Domain attribute that does not match the request host; they can be sent cross-site.`);
433 }
434
435
436 if (analysis.partitionedCount > 0 && !analysis.hasMissingSecure) {
437 score = Math.min(100, score + 3);
438 }
439
440 const bounded = Math.max(0, Math.min(100, score));
441 const grade = bounded >= 95 ? 'A+'
442 : bounded >= 85 ? 'A'
443 : bounded >= 75 ? 'B'
444 : bounded >= 65 ? 'C'
445 : bounded >= 50 ? 'D'
446 : bounded >= 30 ? 'E'
447 : 'F';
448
449 const recommendations = buildRecommendations(analysis, issues, bounded);
450 return { score: bounded, grade, issues, recommendations };
451}
452
453function buildRecommendations(analysis, issues, score) {
454 const recs = [...issues];
455
456 if (analysis.hasMissingSecure) {
457 recs.push('Add the Secure flag to all cookies so they are only transmitted over HTTPS.');
458 }
459 if (analysis.hasMissingHttpOnly) {
460 recs.push('Add the HttpOnly flag to all session, auth, and token cookies to prevent JavaScript access.');
461 }
462 if (analysis.hasSameSiteUnset) {
463 recs.push('Set an explicit SameSite attribute (Lax or Strict) on every cookie; do not rely on browser defaults.');
464 }
465 if (analysis.sameSiteNoneCount > 0) {
466 recs.push('Replace SameSite=None with SameSite=Lax or Strict where cross-site sending is not required.');
467 }
468 if (analysis.hasInvalidPrefix) {
469 recs.push('Fix __Host- cookies to use Secure, no Domain, Path=/. Fix __Secure- cookies to use Secure.');
470 }
471 if (analysis.trackingCookieCount > 0) {
472 recs.push('Ensure tracking cookies are gated by a consent management platform (CMP) and documented in the privacy policy.');
473 }
474 if (analysis.hasLongExpiry) {
475 recs.push('Reduce cookie expiry to the shortest practical lifetime; avoid persisting identifiers beyond 1 year.');
476 }
477 if (analysis.thirdPartyCount > 0) {
478 recs.push('Evaluate whether third-party Domain cookies are necessary; use Partitioned (CHIPS) for state isolation where possible.');
479 }
480 if (analysis.partitionedCount === 0 && analysis.cookieCount > 2) {
481 recs.push('Consider adopting Partitioned (CHIPS) cookies for third-party embeds to improve privacy isolation.');
482 }
483 if (score >= 95 && issues.length === 0) {
484 recs.push('Cookie security posture is strong; continue monitoring after deploys and CDN cutovers.');
485 }
486
487 return recs.length ? recs : ['No cookie issues detected.'];
488}
489
490
491
492
493
494export async function auditCookies(input) {
495 const timeoutSeconds = Math.min(Math.max(Number(input.timeoutSeconds || DEFAULT_TIMEOUT_SECONDS), 3), 30);
496
497 let startUrl;
498 let fetched;
499 try {
500 startUrl = await normalizeAndValidateUrl(input.startUrl);
501 fetched = await fetchCookies(startUrl, timeoutSeconds);
502 } catch (validationError) {
503 return errorResult(input.startUrl, validationError.message);
504 }
505
506 if (fetched.error) {
507 return errorResult(input.startUrl, fetched.error, fetched.finalUrl, fetched.https, fetched.status);
508 }
509
510 const requestHost = startUrl.hostname;
511 const analyzedCookies = [];
512 for (const entry of fetched.setCookies) {
513 const parsed = parseSetCookie(entry.raw);
514 if (!parsed) continue;
515 analyzedCookies.push(analyzeCookie(parsed, requestHost));
516 }
517
518
519 const byName = new Map();
520 for (const c of analyzedCookies) byName.set(c.name, c);
521 const cookies = [...byName.values()];
522
523 const analysis = aggregateAnalysis(cookies);
524 const scored = scoreCookies(analysis);
525
526 return {
527 inputUrl: input.startUrl,
528 normalizedInputUrl: startUrl.href,
529 finalUrl: fetched.finalUrl,
530 https: fetched.https,
531 ok: true,
532 checkedAt: new Date().toISOString(),
533 httpStatus: fetched.status,
534 cookieCount: analysis.cookieCount,
535 cookies: analysis.cookies,
536 secureCount: analysis.secureCount,
537 httpOnlyCount: analysis.httpOnlyCount,
538 sameSiteLaxCount: analysis.sameSiteLaxCount,
539 sameSiteStrictCount: analysis.sameSiteStrictCount,
540 sameSiteNoneCount: analysis.sameSiteNoneCount,
541 sameSiteUnsetCount: analysis.sameSiteUnsetCount,
542 partitionedCount: analysis.partitionedCount,
543 hostPrefixedCount: analysis.hostPrefixedCount,
544 securePrefixedCount: analysis.securePrefixedCount,
545 sessionCookieCount: analysis.sessionCookieCount,
546 persistentCookieCount: analysis.persistentCookieCount,
547 firstPartyCount: analysis.firstPartyCount,
548 thirdPartyCount: analysis.thirdPartyCount,
549 trackingCookieCount: analysis.trackingCookieCount,
550 trackingCookies: analysis.trackingCookies,
551 hasInsecureCookie: analysis.hasInsecureCookie,
552 hasMissingSecure: analysis.hasMissingSecure,
553 hasMissingHttpOnly: analysis.hasMissingHttpOnly,
554 hasSameSiteNone: analysis.hasSameSiteNone,
555 hasSameSiteUnset: analysis.hasSameSiteUnset,
556 hasInvalidPrefix: analysis.hasInvalidPrefix,
557 hasTrackingCookie: analysis.hasTrackingCookie,
558 hasLongExpiry: analysis.hasLongExpiry,
559 score: scored.score,
560 grade: scored.grade,
561 issues: scored.issues,
562 recommendations: scored.recommendations,
563 error: null,
564 };
565}
566
567function aggregateAnalysis(cookies) {
568 const secureCount = cookies.filter((c) => c.secure).length;
569 const httpOnlyCount = cookies.filter((c) => c.httpOnly).length;
570 const sameSiteLaxCount = cookies.filter((c) => c.sameSite === 'lax').length;
571 const sameSiteStrictCount = cookies.filter((c) => c.sameSite === 'strict').length;
572 const sameSiteNoneCount = cookies.filter((c) => c.sameSite === 'none').length;
573 const sameSiteUnsetCount = cookies.filter((c) => c.sameSite === 'unset').length;
574 const partitionedCount = cookies.filter((c) => c.partitioned).length;
575 const hostPrefixedCount = cookies.filter((c) => c.prefix === '__Host-').length;
576 const securePrefixedCount = cookies.filter((c) => c.prefix === '__Secure-').length;
577 const sessionCookieCount = cookies.filter((c) => c.isSession).length;
578 const persistentCookieCount = cookies.filter((c) => !c.isSession).length;
579 const firstPartyCount = cookies.filter((c) => !c.thirdParty).length;
580 const thirdPartyCount = cookies.filter((c) => c.thirdParty).length;
581 const trackingCookies = cookies.filter((c) => c.tracking).map((c) => c.name);
582 const trackingCookieCount = trackingCookies.length;
583
584 return {
585 cookies,
586 cookieCount: cookies.length,
587 secureCount,
588 httpOnlyCount,
589 sameSiteLaxCount,
590 sameSiteStrictCount,
591 sameSiteNoneCount,
592 sameSiteUnsetCount,
593 partitionedCount,
594 hostPrefixedCount,
595 securePrefixedCount,
596 sessionCookieCount,
597 persistentCookieCount,
598 firstPartyCount,
599 thirdPartyCount,
600 trackingCookies,
601 trackingCookieCount,
602 hasInsecureCookie: cookies.some((c) => !c.secure),
603 hasMissingSecure: cookies.some((c) => !c.secure),
604 hasMissingHttpOnly: cookies.some((c) => c.likelySession && !c.httpOnly),
605 hasSameSiteNone: sameSiteNoneCount > 0,
606 hasSameSiteUnset: sameSiteUnsetCount > 0,
607 hasInvalidPrefix: cookies.some((c) => !c.prefixValid),
608 hasTrackingCookie: trackingCookieCount > 0,
609 hasLongExpiry: cookies.some((c) => c.longExpiry),
610 };
611}
612
613function errorResult(inputUrl, errorMsg, finalUrl, https, httpStatus) {
614 return {
615 inputUrl,
616 normalizedInputUrl: null,
617 finalUrl: finalUrl || (typeof inputUrl === 'string' ? inputUrl : null),
618 https: https ?? false,
619 ok: false,
620 checkedAt: new Date().toISOString(),
621 httpStatus: httpStatus ?? null,
622 cookieCount: 0,
623 cookies: [],
624 secureCount: 0,
625 httpOnlyCount: 0,
626 sameSiteLaxCount: 0,
627 sameSiteStrictCount: 0,
628 sameSiteNoneCount: 0,
629 sameSiteUnsetCount: 0,
630 partitionedCount: 0,
631 hostPrefixedCount: 0,
632 securePrefixedCount: 0,
633 sessionCookieCount: 0,
634 persistentCookieCount: 0,
635 firstPartyCount: 0,
636 thirdPartyCount: 0,
637 trackingCookieCount: 0,
638 trackingCookies: [],
639 hasInsecureCookie: false,
640 hasMissingSecure: false,
641 hasMissingHttpOnly: false,
642 hasSameSiteNone: false,
643 hasSameSiteUnset: false,
644 hasInvalidPrefix: false,
645 hasTrackingCookie: false,
646 hasLongExpiry: false,
647 score: 0,
648 grade: 'F',
649 issues: ['The request failed before cookies could be inspected. Verify the URL is reachable and try again.'],
650 recommendations: ['Verify the URL is public, reachable, and returns a response.'],
651 error: errorMsg,
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 auditCookies(input || {});
666 await Actor.pushData(result);
667 await Actor.setValue('OUTPUT', result);
668 Actor.log.info('Cookie security audit complete', { finalUrl: result.finalUrl, score: result.score, grade: result.grade, cookieCount: result.cookieCount });
669 } finally {
670 await Actor.exit();
671 }
672}