1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6const USER_AGENT = 'GpcSupportAuditor/0.1 (+https://apify.com)';
7const DEFAULT_TIMEOUT_SECONDS = 10;
8const MAX_BODY_BYTES = 64 * 1024;
9
10
11
12
13
14
15
16
17function isPrivateIPv4(ip) {
18 const parts = ip.split('.').map(Number);
19 if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return false;
20 const [a, b] = parts;
21 return a === 10
22 || (a === 172 && b >= 16 && b <= 31)
23 || (a === 192 && b === 168)
24 || a === 127
25 || a === 0
26 || (a === 169 && b === 254);
27}
28
29function isPrivateIPv6(ip) {
30 const normalized = ip.toLowerCase();
31 return normalized === '::1'
32 || normalized.startsWith('fc')
33 || normalized.startsWith('fd')
34 || normalized.startsWith('fe80:');
35}
36
37export async function normalizeAndValidateUrl(rawUrl) {
38 if (!rawUrl || typeof rawUrl !== 'string') throw new Error('startUrl is required');
39 if (/^[a-z][a-z0-9+.-]*:/i.test(rawUrl) && !/^https?:\/\//i.test(rawUrl)) {
40 throw new Error('Only HTTP and HTTPS URLs are supported');
41 }
42
43 const withScheme = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`;
44 const url = new URL(withScheme);
45 if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported');
46 if (!url.hostname || url.username || url.password) throw new Error('URL must be public and must not include credentials');
47
48 const hostname = url.hostname.replace(/^\[|\]$/g, '');
49 const literalType = net.isIP(hostname);
50 if (literalType === 4 && isPrivateIPv4(hostname)) throw new Error('Private IPv4 targets are blocked');
51 if (literalType === 6 && isPrivateIPv6(hostname)) throw new Error('Private IPv6 targets are blocked');
52
53 const records = literalType ? [{ address: 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
65
66
67async function fetchGpcJson(initialUrl, timeoutSeconds, redirectsRemaining = 5) {
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: 'GET',
74 redirect: 'manual',
75 signal: controller.signal,
76 headers: {
77 'user-agent': USER_AGENT,
78 accept: 'application/json',
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 fetchGpcJson(nextUrl, timeoutSeconds, redirectsRemaining - 1);
89 }
90
91 let text = '';
92 try {
93 const buffer = await response.arrayBuffer();
94 text = buffer.byteLength > MAX_BODY_BYTES
95 ? new TextDecoder().decode(buffer.slice(0, MAX_BODY_BYTES))
96 : new TextDecoder().decode(buffer);
97 } catch {
98 text = '';
99 }
100
101 return {
102 ok: response.ok,
103 status: response.status,
104 finalUrl: response.url || initialUrl.href,
105 contentType: response.headers.get('content-type') || '',
106 https: (response.url || initialUrl.href).startsWith('https://'),
107 body: text,
108 error: null,
109 };
110 } catch (error) {
111 return {
112 ok: false,
113 status: null,
114 finalUrl: initialUrl.href,
115 contentType: '',
116 https: initialUrl.protocol === 'https:',
117 body: '',
118 error: error.message,
119 };
120 } finally {
121 clearTimeout(timeout);
122 }
123}
124
125
126
127
128
129
130
131const RFC3339_FULL_DATE = /^\d{4}-\d{2}-\d{2}$/;
132const RFC3339_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
133
134export function isValidRfc3339(value) {
135 if (typeof value !== 'string') return false;
136 return RFC3339_FULL_DATE.test(value) || RFC3339_DATE_TIME.test(value);
137}
138
139export function parseGpcJson(bodyText) {
140 const result = {
141 jsonValid: false,
142 parseError: null,
143 isObject: false,
144 gpcValue: null,
145 gpcValid: false,
146 lastUpdate: null,
147 lastUpdateValid: false,
148 unknownMembers: [],
149 };
150 if (!bodyText || !bodyText.trim()) {
151 result.parseError = 'Empty response body';
152 return result;
153 }
154 let doc;
155 try {
156 doc = JSON.parse(bodyText);
157 result.jsonValid = true;
158 } catch (err) {
159 result.parseError = err.message;
160 return result;
161 }
162 if (typeof doc !== 'object' || doc === null || Array.isArray(doc)) {
163 result.parseError = 'Top-level value is not a JSON object';
164 return result;
165 }
166 result.isObject = true;
167
168 if ('gpc' in doc) {
169 result.gpcValue = doc.gpc;
170 result.gpcValid = typeof doc.gpc === 'boolean';
171 }
172
173 if ('lastUpdate' in doc) {
174 result.lastUpdate = typeof doc.lastUpdate === 'string' ? doc.lastUpdate : String(doc.lastUpdate);
175 result.lastUpdateValid = isValidRfc3339(doc.lastUpdate);
176 }
177
178 const knownKeys = new Set(['gpc', 'lastUpdate']);
179 result.unknownMembers = Object.keys(doc).filter((k) => !knownKeys.has(k));
180
181 return result;
182}
183
184
185
186
187
188function evalFetch({ fetchResult }) {
189 if (!fetchResult.ok || fetchResult.status !== 200) {
190 return { status: 'missing', note: `GPC support resource was not found or did not return HTTP 200 (status: ${fetchResult.status ?? 'unreachable'}).`, recommendation: 'Publish a GPC support resource at /.well-known/gpc.json returning HTTP 200.' };
191 }
192 return { status: 'good', note: `GPC support resource responded with HTTP 200 at ${fetchResult.finalUrl}.` };
193}
194
195function evalHttps({ fetchResult }) {
196 if (!fetchResult.https) {
197 return { status: 'missing', note: 'GPC support resource was not served over HTTPS.', recommendation: 'Serve /.well-known/gpc.json over HTTPS.' };
198 }
199 return { status: 'good', note: 'GPC support resource served over HTTPS.' };
200}
201
202function evalContentType({ fetchResult }) {
203 const ct = fetchResult.contentType.toLowerCase();
204 if (!ct.includes('application/json')) {
205 return { status: 'warn', note: `Content-Type was '${fetchResult.contentType || '(none)'}' instead of application/json; per spec, support is 'unknown' without this media type.`, recommendation: 'Serve /.well-known/gpc.json with Content-Type: application/json.' };
206 }
207 return { status: 'good', note: 'Content-Type is application/json.' };
208}
209
210function evalJson({ parsed }) {
211 if (!parsed.jsonValid) {
212 return { status: 'missing', note: `Response body is not valid JSON${parsed.parseError ? `: ${parsed.parseError}` : '.'}`, recommendation: 'Return a valid JSON object from /.well-known/gpc.json.' };
213 }
214 if (!parsed.isObject) {
215 return { status: 'missing', note: 'Response body is valid JSON but not a JSON object.', recommendation: 'Return a JSON object (not an array or scalar) with a boolean gpc member.' };
216 }
217 return { status: 'good', note: 'Response body is a valid JSON object.' };
218}
219
220function evalGpcMember({ parsed }) {
221 if (!parsed.isObject) return { status: 'info', note: 'Skipped: response was not a JSON object.' };
222 if (parsed.gpcValue === null) {
223 return { status: 'missing', note: "Required 'gpc' member is missing. Support is unknown.", recommendation: "Add a top-level boolean 'gpc' member (true or false)." };
224 }
225 if (!parsed.gpcValid) {
226 return { status: 'missing', note: `'gpc' member is present but not a boolean (value: ${JSON.stringify(parsed.gpcValue)}). Per spec, any other value makes support unknown.`, recommendation: "Set 'gpc' to the literal boolean true or false, not a string or number." };
227 }
228 return { status: 'good', note: `'gpc' member declares support: ${parsed.gpcValue}.` };
229}
230
231function evalLastUpdate({ parsed }) {
232 if (!parsed.isObject) return { status: 'info', note: 'Skipped: response was not a JSON object.' };
233 if (parsed.lastUpdate === null) {
234 return { status: 'warn', note: "Optional 'lastUpdate' member is absent.", recommendation: "Add a 'lastUpdate' member (RFC3339 full-date or date-time) so legal interpretation of this statement is anchored in time." };
235 }
236 if (!parsed.lastUpdateValid) {
237 return { status: 'warn', note: `'lastUpdate' value '${parsed.lastUpdate}' is not valid RFC3339. The last-update time is unknown.`, recommendation: "Format 'lastUpdate' as YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss.sssZ (RFC3339)." };
238 }
239 return { status: 'good', note: `'lastUpdate' is a valid RFC3339 value: ${parsed.lastUpdate}.` };
240}
241
242const CHECKS = [
243 { name: 'fetch', title: 'GPC support resource reachable', weight: 25, fn: evalFetch },
244 { name: 'https', title: 'Served over HTTPS', weight: 10, fn: evalHttps },
245 { name: 'contentType', title: 'Content-Type is application/json', weight: 10, fn: evalContentType },
246 { name: 'json', title: 'Valid JSON object', weight: 20, fn: evalJson },
247 { name: 'gpc', title: "'gpc' boolean member valid", weight: 25, fn: evalGpcMember },
248 { name: 'lastUpdate', title: "'lastUpdate' member valid", weight: 10, fn: evalLastUpdate },
249];
250
251export function buildCheckReports(context) {
252 const reports = [];
253 let earned = 0;
254 let possible = 0;
255 for (const check of CHECKS) {
256 const evaluation = check.fn(context);
257 if (evaluation.status !== 'info') possible += check.weight;
258 if (evaluation.status === 'good') earned += check.weight;
259 else if (evaluation.status === 'warn') earned += Math.round(check.weight * 0.5);
260 reports.push({
261 name: check.title,
262 check: check.name,
263 status: evaluation.status,
264 note: evaluation.note,
265 weight: check.weight,
266 recommendation: evaluation.recommendation || null,
267 });
268 }
269 return { reports, earned, possible };
270}
271
272export function scoreAudit(earned, possible) {
273 if (possible === 0) return 0;
274 return Math.min(100, Math.max(0, Math.round((earned / possible) * 100)));
275}
276
277export function gradeFromScore(score) {
278 if (score >= 95) return 'A+';
279 if (score >= 85) return 'A';
280 if (score >= 75) return 'B';
281 if (score >= 65) return 'C';
282 if (score >= 50) return 'D';
283 if (score >= 30) return 'E';
284 return 'F';
285}
286
287export function buildRecommendations(reports) {
288 const recs = [];
289 for (const r of reports) {
290 if (r.recommendation && !recs.includes(r.recommendation)) recs.push(r.recommendation);
291 }
292 if (recs.length === 0) {
293 recs.push('GPC support resource looks spec-conformant. Re-run this audit after changing privacy practices to catch regressions.');
294 }
295 return recs;
296}
297
298export function determineSupportStatus(parsed, fetchResult) {
299 if (!fetchResult.ok || fetchResult.status !== 200) return 'unknown';
300 const ct = fetchResult.contentType.toLowerCase();
301 if (!ct.includes('application/json')) return 'unknown';
302 if (!parsed.jsonValid || !parsed.isObject) return 'unknown';
303 if (!parsed.gpcValid) return 'unknown';
304 return parsed.gpcValue ? 'supports' : 'does-not-support';
305}
306
307
308
309
310
311export async function auditGpcSupport(input) {
312 const domainRaw = input.startUrl || input.domain;
313 const timeoutSeconds = Math.min(Math.max(Number(input.timeoutSeconds || DEFAULT_TIMEOUT_SECONDS), 3), 30);
314
315 const baseUrl = await normalizeAndValidateUrl(domainRaw);
316 const gpcJsonUrl = new URL('/.well-known/gpc.json', baseUrl.origin);
317 const fetchResult = await fetchGpcJson(gpcJsonUrl, timeoutSeconds);
318 const parsed = parseGpcJson(fetchResult.body);
319
320 const context = { fetchResult, parsed };
321 const { reports, earned, possible } = buildCheckReports(context);
322 const score = scoreAudit(earned, possible);
323 const grade = gradeFromScore(score);
324 const recommendations = buildRecommendations(reports);
325 const supportStatus = determineSupportStatus(parsed, fetchResult);
326
327 const requestedHost = baseUrl.hostname;
328 let finalHost = null;
329 try {
330 finalHost = new URL(fetchResult.finalUrl).hostname;
331 } catch {
332 finalHost = null;
333 }
334
335 const issues = reports.filter((r) => r.status === 'warn' || r.status === 'missing').map((r) => `${r.name}: ${r.note}`);
336
337 return {
338 inputDomain: domainRaw,
339 gpcJsonUrl: gpcJsonUrl.href,
340 finalUrl: fetchResult.finalUrl,
341 crossOriginRedirect: Boolean(finalHost && finalHost !== requestedHost),
342 https: fetchResult.https,
343 status: fetchResult.status,
344 contentType: fetchResult.contentType || null,
345 jsonValid: parsed.jsonValid,
346 parseError: parsed.parseError,
347 gpcValue: parsed.gpcValue,
348 gpcValid: parsed.gpcValid,
349 lastUpdate: parsed.lastUpdate,
350 lastUpdateValid: parsed.lastUpdateValid,
351 unknownMembers: parsed.unknownMembers,
352 supportStatus,
353 checks: reports,
354 issues: [...new Set(issues)],
355 score,
356 grade,
357 checkedAt: new Date().toISOString(),
358 recommendations,
359 error: fetchResult.error,
360 };
361}
362
363
364
365
366
367const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
368
369if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
370 await Actor.init();
371 try {
372 const input = await Actor.getInput();
373 const result = await auditGpcSupport(input || {});
374 await Actor.pushData(result);
375 await Actor.setValue('OUTPUT', result);
376 Actor.log.info('GPC support audit complete', { gpcJsonUrl: result.gpcJsonUrl, supportStatus: result.supportStatus, score: result.score, grade: result.grade });
377 } finally {
378 await Actor.exit();
379 }
380}