@fulldotdev/scan 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +79 -0
  2. package/bin/fullscan.js +2 -0
  3. package/dist/cli.d.ts +1 -0
  4. package/dist/cli.js +239 -0
  5. package/dist/engine/analysis.d.ts +2 -0
  6. package/dist/engine/analysis.js +747 -0
  7. package/dist/engine/browser-inspection.d.ts +143 -0
  8. package/dist/engine/browser-inspection.js +567 -0
  9. package/dist/engine/browser.d.ts +22 -0
  10. package/dist/engine/browser.js +629 -0
  11. package/dist/engine/collect.d.ts +6 -0
  12. package/dist/engine/collect.js +359 -0
  13. package/dist/engine/crawl-scope.d.ts +22 -0
  14. package/dist/engine/crawl-scope.js +145 -0
  15. package/dist/engine/env.d.ts +1 -0
  16. package/dist/engine/env.js +3 -0
  17. package/dist/engine/html.d.ts +307 -0
  18. package/dist/engine/html.js +645 -0
  19. package/dist/engine/language.d.ts +13 -0
  20. package/dist/engine/language.js +75 -0
  21. package/dist/engine/lighthouse-evidence.d.ts +36 -0
  22. package/dist/engine/lighthouse-evidence.js +69 -0
  23. package/dist/engine/lighthouse.d.ts +4 -0
  24. package/dist/engine/lighthouse.js +284 -0
  25. package/dist/engine/log.d.ts +1 -0
  26. package/dist/engine/log.js +4 -0
  27. package/dist/engine/network.d.ts +53 -0
  28. package/dist/engine/network.js +296 -0
  29. package/dist/engine/proxy.d.ts +8 -0
  30. package/dist/engine/proxy.js +95 -0
  31. package/dist/engine/run.d.ts +38 -0
  32. package/dist/engine/run.js +202 -0
  33. package/dist/engine/select.d.ts +6 -0
  34. package/dist/engine/select.js +38 -0
  35. package/dist/engine/site.d.ts +186 -0
  36. package/dist/engine/site.js +758 -0
  37. package/dist/engine/srcset.d.ts +1 -0
  38. package/dist/engine/srcset.js +31 -0
  39. package/dist/engine/state.d.ts +30 -0
  40. package/dist/engine/state.js +198 -0
  41. package/dist/engine/structured-data.d.ts +83 -0
  42. package/dist/engine/structured-data.js +331 -0
  43. package/dist/engine/types.d.ts +128 -0
  44. package/dist/engine/types.js +63 -0
  45. package/dist/engine.d.ts +1 -0
  46. package/dist/engine.js +1 -0
  47. package/dist/index.d.ts +8 -0
  48. package/dist/index.js +7 -0
  49. package/dist/report/build.d.ts +377 -0
  50. package/dist/report/build.js +2263 -0
  51. package/dist/report/evidence.d.ts +58 -0
  52. package/dist/report/evidence.js +192 -0
  53. package/dist/report/rules.d.ts +41 -0
  54. package/dist/report/rules.js +301 -0
  55. package/package.json +52 -0
@@ -0,0 +1,758 @@
1
+ import { candidateKind, crawlUrl } from "./crawl-scope.js";
2
+ import dns from "node:dns/promises";
3
+ import tls from "node:tls";
4
+ import { getDomain } from "tldts";
5
+ import { XMLParser, XMLValidator } from "fast-xml-parser";
6
+ import { gunzipSync } from "node:zlib";
7
+ import { resolvePublic, sameSite, userAgent } from "./network.js";
8
+ import { env } from "./env.js";
9
+ import { resolveLink, markdownEvidence, markdownLinks } from "./html.js";
10
+ import { errorMessage, httpEvidence, } from "./types.js";
11
+ export const xmlParser = new XMLParser({
12
+ ignoreAttributes: false,
13
+ processEntities: true,
14
+ parseTagValue: false,
15
+ removeNSPrefix: true,
16
+ });
17
+ const array = (value) => value === undefined ? [] : Array.isArray(value) ? value : [value];
18
+ export function parseSitemap(body) {
19
+ if (/<!DOCTYPE|<!ENTITY/i.test(body))
20
+ return {
21
+ valid: false,
22
+ error: "DTD/entity declarations are not supported",
23
+ urls: [],
24
+ sitemaps: [],
25
+ };
26
+ const valid = XMLValidator.validate(body);
27
+ if (valid !== true)
28
+ return { valid: false, error: valid.err.msg, urls: [], sitemaps: [] };
29
+ const data = xmlParser.parse(body);
30
+ const urls = array(data.urlset?.url).map((node) => ({
31
+ url: String(node.loc ?? ""),
32
+ lastmod: node.lastmod ?? null,
33
+ priority: node.priority ?? null,
34
+ changefreq: node.changefreq ?? null,
35
+ }));
36
+ const sitemaps = array(data.sitemapindex?.sitemap).map((node) => String(node.loc ?? ""));
37
+ return {
38
+ valid: !!(data.urlset !== undefined || data.sitemapindex !== undefined),
39
+ urls,
40
+ sitemaps,
41
+ };
42
+ }
43
+ // Crawlers grouped by what they do with a page. Products change; the bot
44
+ // name stays the evidence.
45
+ export const botPurposes = {
46
+ search: [
47
+ "Googlebot",
48
+ "bingbot",
49
+ "DuckDuckBot",
50
+ "Applebot",
51
+ "OAI-SearchBot",
52
+ "Claude-SearchBot",
53
+ "PerplexityBot",
54
+ ],
55
+ training: [
56
+ "GPTBot",
57
+ "ClaudeBot",
58
+ "Google-Extended",
59
+ "CCBot",
60
+ "Applebot-Extended",
61
+ "Bytespider",
62
+ "meta-externalagent",
63
+ ],
64
+ "user-request": ["ChatGPT-User", "Claude-User", "Perplexity-User"],
65
+ };
66
+ export function crawlerPolicy(robots, url) {
67
+ return {
68
+ robotsUrl: robots.result.finalUrl,
69
+ robotsOutcome: robots.result.outcome,
70
+ bots: Object.entries(botPurposes).flatMap(([purpose, bots]) => bots.map((bot) => ({
71
+ bot,
72
+ purpose,
73
+ allowed: robots.allowed
74
+ ? (robots.parser?.isAllowed(url, bot) ?? true)
75
+ : null,
76
+ }))),
77
+ interpretation: "declared robots policy; does not establish real bot access, usage, indexing or citations",
78
+ };
79
+ }
80
+ // One sitemap document per job, so a large index resumes where the previous
81
+ // worker stopped. Child sitemaps and page URLs become candidates.
82
+ export async function collectSitemap(scan, url, http) {
83
+ const response = await http.get(url, {
84
+ respectRobots: false,
85
+ retry: false,
86
+ maxBytes: scan.options.maxSitemapBytes,
87
+ });
88
+ let parsed = {
89
+ valid: false,
90
+ error: "Not a successful response",
91
+ urls: [],
92
+ sitemaps: [],
93
+ };
94
+ const artifacts = [];
95
+ if (response.status === 200 && !response.truncated) {
96
+ try {
97
+ const body = response.raw?.[0] === 0x1f && response.raw?.[1] === 0x8b
98
+ ? gunzipSync(response.raw, {
99
+ maxOutputLength: scan.options.maxSitemapBytes,
100
+ }).toString("utf8")
101
+ : response.body;
102
+ parsed = parseSitemap(body);
103
+ artifacts.push({
104
+ kind: "sitemap",
105
+ key: url,
106
+ body,
107
+ contentType: "application/xml",
108
+ });
109
+ }
110
+ catch (error) {
111
+ parsed = {
112
+ valid: false,
113
+ error: errorMessage(error),
114
+ urls: [],
115
+ sitemaps: [],
116
+ };
117
+ }
118
+ }
119
+ const candidates = [];
120
+ let external = 0;
121
+ const entries = parsed.urls.map((entry) => {
122
+ const resolved = resolveLink(entry.url, url);
123
+ const scanTarget = resolved ? crawlUrl(resolved, scan) : null;
124
+ if (scanTarget && sameSite(scanTarget, scan.url))
125
+ candidates.push({
126
+ url: scanTarget,
127
+ source: `sitemap:${url}`,
128
+ kind: candidateKind(scanTarget, scan),
129
+ });
130
+ else
131
+ external++;
132
+ return {
133
+ url: entry.url,
134
+ scanUrl: scanTarget,
135
+ lastmod: entry.lastmod,
136
+ ...(entry.priority ? { priority: entry.priority } : {}),
137
+ ...(entry.changefreq ? { changefreq: entry.changefreq } : {}),
138
+ };
139
+ });
140
+ const children = parsed.sitemaps.flatMap((child) => {
141
+ const resolved = resolveLink(child, response.finalUrl || url);
142
+ return resolved ? [crawlUrl(resolved, scan)] : [];
143
+ });
144
+ for (const child of children)
145
+ if (sameSite(child, scan.url))
146
+ candidates.push({
147
+ url: child,
148
+ source: `sitemap:${url}`,
149
+ kind: "sitemap",
150
+ });
151
+ return {
152
+ observations: [
153
+ {
154
+ kind: "sitemap",
155
+ key: url,
156
+ data: {
157
+ http: httpEvidence(response),
158
+ valid: parsed.valid,
159
+ error: "error" in parsed ? parsed.error : undefined,
160
+ gzip: response.raw?.[0] === 0x1f && response.raw?.[1] === 0x8b,
161
+ sitemaps: children,
162
+ urls: entries,
163
+ entryCount: entries.length,
164
+ externalEntries: external,
165
+ truncated: !!response.truncated,
166
+ },
167
+ },
168
+ ],
169
+ artifacts,
170
+ candidates,
171
+ };
172
+ }
173
+ export async function dnsSnapshot(host) {
174
+ const domain = getDomain(host) ?? host;
175
+ const tasks = [
176
+ ["a", () => dns.resolve4(host)],
177
+ ["aaaa", () => dns.resolve6(host)],
178
+ ["cname", () => dns.resolveCname(host)],
179
+ ["ns", () => dns.resolveNs(domain)],
180
+ ["mx", () => dns.resolveMx(domain)],
181
+ ["txt", () => dns.resolveTxt(domain)],
182
+ ["dmarc", () => dns.resolveTxt(`_dmarc.${domain}`)],
183
+ ["mtasts", () => dns.resolveTxt(`_mta-sts.${domain}`)],
184
+ ["bimi", () => dns.resolveTxt(`default._bimi.${domain}`)],
185
+ ["caa", () => dns.resolveCaa(domain)],
186
+ ];
187
+ const records = Object.fromEntries(await Promise.all(tasks.map(async ([key, fn]) => {
188
+ try {
189
+ return [
190
+ key,
191
+ {
192
+ status: "observed",
193
+ values: await Promise.race([
194
+ fn(),
195
+ new Promise((_, reject) => {
196
+ const t = setTimeout(() => reject(new Error("DNS timeout")), 5000);
197
+ t.unref();
198
+ }),
199
+ ]),
200
+ },
201
+ ];
202
+ }
203
+ catch (error) {
204
+ return [key, { status: "unavailable", error: errorMessage(error) }];
205
+ }
206
+ })));
207
+ const txt = records.txt.values?.map((parts) => parts.join("")) ?? [];
208
+ const dmarc = records.dmarc.values?.map((parts) => parts.join("")) ?? [];
209
+ return {
210
+ host,
211
+ domain,
212
+ records,
213
+ email: {
214
+ spf: txt.filter((t) => t.startsWith("v=spf1")),
215
+ dmarc: dmarc.filter((t) => t.startsWith("v=DMARC1")),
216
+ mtaSts: records.mtasts.values?.map((p) => p.join("")) ?? [],
217
+ bimi: records.bimi.values?.map((p) => p.join("")) ?? [],
218
+ scope: "public-record inspection only; no mailbox delivery or DKIM-selector discovery",
219
+ },
220
+ };
221
+ }
222
+ export async function tlsSnapshot(host) {
223
+ try {
224
+ const [address] = await resolvePublic(host);
225
+ return await new Promise((resolve) => {
226
+ const socket = tls.connect({
227
+ host: address.address,
228
+ port: 443,
229
+ servername: host,
230
+ rejectUnauthorized: false,
231
+ }, () => {
232
+ const cert = socket.getPeerCertificate();
233
+ const hostnameError = tls.checkServerIdentity(host, cert);
234
+ resolve({
235
+ status: "observed",
236
+ authorized: socket.authorized,
237
+ authorizationError: socket.authorizationError ?? null,
238
+ hostnameValid: !hostnameError,
239
+ hostnameError: hostnameError?.message ?? null,
240
+ protocol: socket.getProtocol(),
241
+ cipher: socket.getCipher(),
242
+ certificate: {
243
+ subject: cert.subject,
244
+ issuer: cert.issuer,
245
+ subjectaltname: cert.subjectaltname,
246
+ validFrom: cert.valid_from,
247
+ validTo: cert.valid_to,
248
+ fingerprint256: cert.fingerprint256,
249
+ serialNumber: cert.serialNumber,
250
+ },
251
+ daysRemaining: cert.valid_to
252
+ ? Math.floor((Date.parse(cert.valid_to) - Date.now()) / 86400000)
253
+ : null,
254
+ });
255
+ socket.destroy();
256
+ });
257
+ socket.setTimeout(10000, () => socket.destroy(new Error("TLS timeout")));
258
+ socket.on("error", (error) => {
259
+ resolve({ status: "unavailable", error: errorMessage(error) });
260
+ socket.destroy();
261
+ });
262
+ });
263
+ }
264
+ catch (error) {
265
+ return { status: "unavailable", error: errorMessage(error) };
266
+ }
267
+ }
268
+ // Passive discovery of the files agents read: llms.txt, its full variant,
269
+ // auth.md and the OAuth protected-resource metadata it should point at.
270
+ // Everything is fetched with the normal public-network guard; nothing
271
+ // registers, authenticates or follows an authorization flow.
272
+ async function aiDiscovery(root, http, result) {
273
+ const text = async (path) => {
274
+ const url = `${root.origin}${path}`;
275
+ const response = await http.get(url, { retry: false });
276
+ return { url, response };
277
+ };
278
+ const llms = await text("/llms.txt");
279
+ const llmsFull = await text("/llms-full.txt");
280
+ const markdownFile = (file) => {
281
+ const content = file.response.body;
282
+ const links = file.response.outcome === "ok" ? markdownLinks(content, file.url) : [];
283
+ if (file.response.outcome === "ok")
284
+ result.artifacts.push({
285
+ kind: "ai-file",
286
+ key: file.url,
287
+ body: content,
288
+ contentType: "text/plain",
289
+ });
290
+ return {
291
+ url: file.url,
292
+ http: httpEvidence(file.response),
293
+ present: file.response.status === 200,
294
+ ...markdownEvidence(content, file.response.headers["content-type"]),
295
+ title: content.match(/^#\s+(.+)$/m)?.[1] ?? null,
296
+ headings: [...content.matchAll(/^#{1,3}\s+(.+)$/gm)]
297
+ .map((m) => m[1].trim())
298
+ .slice(0, 50),
299
+ links: links.slice(0, 500),
300
+ characters: content.length,
301
+ };
302
+ };
303
+ result.observations.push({
304
+ kind: "ai-discovery",
305
+ key: root.origin,
306
+ data: {
307
+ llms: markdownFile(llms),
308
+ llmsFull: markdownFile(llmsFull),
309
+ scope: "Passive GET of optional discovery files; absence is informational and nothing was registered or authenticated",
310
+ },
311
+ });
312
+ for (const file of [llms, llmsFull])
313
+ if (file.response.outcome === "ok")
314
+ for (const link of markdownLinks(file.response.body, file.url))
315
+ if (link.url)
316
+ result.candidates.push({
317
+ url: link.url,
318
+ source: `llms:${file.url}`,
319
+ kind: "resource",
320
+ });
321
+ }
322
+ export async function discover(scan, http) {
323
+ const result = {
324
+ observations: [],
325
+ artifacts: [],
326
+ candidates: [{ url: scan.url, source: "submitted", kind: "page" }],
327
+ };
328
+ const root = new URL(scan.url);
329
+ await resolvePublic(root.hostname);
330
+ const [dnsData, tlsData, robots] = await Promise.all([
331
+ dnsSnapshot(root.hostname),
332
+ tlsSnapshot(root.hostname),
333
+ http.getRobots(scan.url),
334
+ ]);
335
+ result.observations.push({ kind: "dns", key: root.hostname, data: dnsData }, { kind: "tls", key: root.hostname, data: tlsData }, {
336
+ kind: "robots",
337
+ key: root.origin,
338
+ data: {
339
+ http: httpEvidence(robots.result),
340
+ content: robots.result.body,
341
+ crawlerPolicyEvaluatedPerPage: true,
342
+ policy: crawlerPolicy(robots, scan.url),
343
+ },
344
+ });
345
+ result.artifacts.push({
346
+ kind: "robots",
347
+ key: root.origin,
348
+ body: robots.result.body,
349
+ contentType: "text/plain",
350
+ });
351
+ const declared = [...robots.result.body.matchAll(/^sitemap:\s*(\S+)/gim)]
352
+ .map((m) => resolveLink(m[1], root.origin))
353
+ .filter((u) => !!u)
354
+ .map((u) => crawlUrl(u, scan));
355
+ for (const url of declared)
356
+ if (sameSite(url, scan.url))
357
+ result.candidates.push({ url, source: "robots", kind: "sitemap" });
358
+ for (const path of [
359
+ "/sitemap.xml",
360
+ "/sitemap_index.xml",
361
+ "/sitemap-index.xml",
362
+ ])
363
+ result.candidates.push({
364
+ url: `${root.origin}${path}`,
365
+ source: "default-location",
366
+ kind: "sitemap",
367
+ });
368
+ await aiDiscovery(root, http, result);
369
+ for (const path of ["/.well-known/security.txt", "/security.txt"]) {
370
+ const url = `${root.origin}${path}`;
371
+ const response = await http.get(url, { retry: false });
372
+ const content = response.body;
373
+ const fields = content
374
+ .split(/\r?\n/)
375
+ .filter((line) => !line.startsWith("#"))
376
+ .flatMap((line) => {
377
+ const m = /^([A-Za-z-]+):\s*(.+)$/.exec(line);
378
+ return m ? [{ name: m[1].toLowerCase(), value: m[2] }] : [];
379
+ });
380
+ result.observations.push({
381
+ kind: "security-txt",
382
+ key: url,
383
+ data: { http: httpEvidence(response), fields, content },
384
+ });
385
+ if (response.outcome === "ok")
386
+ result.artifacts.push({
387
+ kind: "security-txt",
388
+ key: url,
389
+ body: content,
390
+ contentType: "text/plain",
391
+ });
392
+ }
393
+ const variantHosts = new Set([root.hostname]);
394
+ const domain = getDomain(root.hostname);
395
+ if (domain &&
396
+ (root.hostname === domain || root.hostname === `www.${domain}`)) {
397
+ variantHosts.add(domain);
398
+ variantHosts.add(`www.${domain}`);
399
+ }
400
+ for (const host of variantHosts)
401
+ for (const protocol of ["http:", "https:"]) {
402
+ const url = `${protocol}//${host}/`;
403
+ const response = await http.get(url, { retry: false, maxBytes: 1000 });
404
+ result.observations.push({
405
+ kind: "url-variant",
406
+ key: url,
407
+ data: httpEvidence(response),
408
+ });
409
+ }
410
+ // Free external sources, all independent; none may fail the job. A local
411
+ // target has nothing registered anywhere, so they are skipped.
412
+ const registered = domain ?? root.hostname;
413
+ const siteAddress = dnsData.records.a?.values?.[0] ?? null;
414
+ if (!scan.options.external || scan.options.allowLocal)
415
+ return result;
416
+ const [domainData, field, history, safe, labs, preload, certificates, lists] = await Promise.all([
417
+ domainSnapshot(registered),
418
+ fieldData({ origin: root.origin }),
419
+ fieldHistory(root.origin),
420
+ safeBrowsing(scan.url),
421
+ sslLabs(root.hostname),
422
+ hstsPreload(registered),
423
+ certificateLogs(registered),
424
+ blocklists(registered, siteAddress),
425
+ ]);
426
+ result.observations.push({
427
+ kind: "domain",
428
+ key: registered,
429
+ data: domainData,
430
+ });
431
+ if (field)
432
+ result.observations.push({
433
+ kind: "field-data",
434
+ key: root.origin,
435
+ data: field,
436
+ });
437
+ if (history)
438
+ result.observations.push({
439
+ kind: "field-history",
440
+ key: root.origin,
441
+ data: history,
442
+ });
443
+ if (safe)
444
+ result.observations.push({
445
+ kind: "safe-browsing",
446
+ key: scan.url,
447
+ data: safe,
448
+ });
449
+ result.observations.push({ kind: "ssl-labs", key: root.hostname, data: labs }, { kind: "hsts-preload", key: registered, data: preload }, { kind: "certificates", key: registered, data: certificates }, { kind: "blocklists", key: registered, data: lists });
450
+ return result;
451
+ }
452
+ // Registration data through the RDAP bootstrap service: expiry date and
453
+ // registrar. Public registry data only.
454
+ export async function domainSnapshot(domain) {
455
+ try {
456
+ const response = await fetch(`https://rdap.org/domain/${encodeURIComponent(domain)}`, {
457
+ headers: {
458
+ accept: "application/rdap+json, application/json",
459
+ "user-agent": userAgent,
460
+ },
461
+ signal: AbortSignal.timeout(10000),
462
+ });
463
+ if (!response.ok)
464
+ return {
465
+ status: "unavailable",
466
+ domain,
467
+ error: `RDAP ${response.status}`,
468
+ };
469
+ const data = await response.json();
470
+ const expiration = (data.events ?? []).find((e) => e.eventAction === "expiration")?.eventDate;
471
+ const registrar = (data.entities ?? [])
472
+ .find((e) => (e.roles ?? []).includes("registrar"))
473
+ ?.vcardArray?.[1]?.find((v) => v[0] === "fn")?.[3];
474
+ return {
475
+ status: "observed",
476
+ domain,
477
+ expiresAt: expiration ?? null,
478
+ daysRemaining: expiration
479
+ ? Math.floor((Date.parse(expiration) - Date.now()) / 86400000)
480
+ : null,
481
+ registrar: registrar ?? null,
482
+ statuses: data.status ?? [],
483
+ source: "rdap.org",
484
+ };
485
+ }
486
+ catch (error) {
487
+ return { status: "unavailable", domain, error: errorMessage(error) };
488
+ }
489
+ }
490
+ // Real-user Core Web Vitals from the Chrome UX Report when a product-level
491
+ // API key is configured. 28-day window, 75th percentile, for one origin or
492
+ // one page URL; the level is part of the evidence.
493
+ // The Chrome UX Report API allows about 150 queries per minute; page jobs
494
+ // can run faster than that on small pages, so requests are paced.
495
+ let lastFieldRequest = 0;
496
+ export async function fieldData(target) {
497
+ const key = googleKey();
498
+ if (!key)
499
+ return null;
500
+ const level = "url" in target ? "url" : "origin";
501
+ const wait = lastFieldRequest + 450 - Date.now();
502
+ if (wait > 0)
503
+ await new Promise((r) => setTimeout(r, wait));
504
+ lastFieldRequest = Date.now();
505
+ try {
506
+ const response = await fetch(`https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=${encodeURIComponent(key)}`, {
507
+ method: "POST",
508
+ headers: { "content-type": "application/json" },
509
+ body: JSON.stringify(target),
510
+ signal: AbortSignal.timeout(10000),
511
+ });
512
+ if (response.status === 404)
513
+ return {
514
+ available: false,
515
+ level,
516
+ note: level === "url"
517
+ ? "Not enough Chrome traffic for this page"
518
+ : "Not enough Chrome traffic for field data",
519
+ };
520
+ if (!response.ok)
521
+ return {
522
+ available: false,
523
+ level,
524
+ note: `Chrome UX Report ${response.status}`,
525
+ };
526
+ const { record } = await response.json();
527
+ const p75 = (metric) => {
528
+ const value = record?.metrics?.[metric]?.percentiles?.p75;
529
+ return value === undefined || value === null ? null : Number(value);
530
+ };
531
+ const date = (d) => d
532
+ ? `${d.year}-${String(d.month).padStart(2, "0")}-${String(d.day).padStart(2, "0")}`
533
+ : null;
534
+ return {
535
+ available: true,
536
+ level,
537
+ lcp: p75("largest_contentful_paint"),
538
+ inp: p75("interaction_to_next_paint"),
539
+ cls: p75("cumulative_layout_shift"),
540
+ period: {
541
+ from: date(record?.collectionPeriod?.firstDate),
542
+ to: date(record?.collectionPeriod?.lastDate),
543
+ },
544
+ source: "Chrome UX Report API",
545
+ };
546
+ }
547
+ catch (error) {
548
+ return { available: false, level, note: errorMessage(error) };
549
+ }
550
+ }
551
+ const googleKey = () => env("GOOGLE_API_KEY") || env("CRUX_API_KEY");
552
+ async function getJson(url, init = {}, timeoutMs = 15000) {
553
+ const response = await fetch(url, {
554
+ ...init,
555
+ headers: {
556
+ "user-agent": userAgent,
557
+ accept: "application/json",
558
+ ...(init.headers ?? {}),
559
+ },
560
+ signal: AbortSignal.timeout(timeoutMs),
561
+ });
562
+ return {
563
+ response,
564
+ body: response.ok ? await response.json().catch(() => null) : null,
565
+ };
566
+ }
567
+ // Google Safe Browsing: is the site flagged as malware or phishing?
568
+ export async function safeBrowsing(url) {
569
+ const key = googleKey();
570
+ if (!key)
571
+ return null;
572
+ try {
573
+ const { response, body } = await getJson(`https://safebrowsing.googleapis.com/v4/threatMatches:find?key=${encodeURIComponent(key)}`, {
574
+ method: "POST",
575
+ headers: { "content-type": "application/json" },
576
+ body: JSON.stringify({
577
+ client: { clientId: "fulldev-monitor", clientVersion: "1" },
578
+ threatInfo: {
579
+ threatTypes: [
580
+ "MALWARE",
581
+ "SOCIAL_ENGINEERING",
582
+ "UNWANTED_SOFTWARE",
583
+ "POTENTIALLY_HARMFUL_APPLICATION",
584
+ ],
585
+ platformTypes: ["ANY_PLATFORM"],
586
+ threatEntryTypes: ["URL"],
587
+ threatEntries: [{ url }],
588
+ },
589
+ }),
590
+ });
591
+ if (!response.ok)
592
+ return {
593
+ status: "unavailable",
594
+ error: `Safe Browsing ${response.status}`,
595
+ };
596
+ const threats = (body?.matches ?? []).map((m) => String(m.threatType));
597
+ return {
598
+ status: "observed",
599
+ flagged: threats.length > 0,
600
+ threats: [...new Set(threats)],
601
+ };
602
+ }
603
+ catch (error) {
604
+ return { status: "unavailable", error: errorMessage(error) };
605
+ }
606
+ }
607
+ // Chrome UX Report history: weekly 75th percentiles for the last 25 weeks.
608
+ export async function fieldHistory(origin) {
609
+ const key = googleKey();
610
+ if (!key)
611
+ return null;
612
+ try {
613
+ const { response, body } = await getJson(`https://chromeuxreport.googleapis.com/v1/records:queryHistoryRecord?key=${encodeURIComponent(key)}`, {
614
+ method: "POST",
615
+ headers: { "content-type": "application/json" },
616
+ body: JSON.stringify({ origin }),
617
+ });
618
+ if (response.status === 404)
619
+ return { available: false };
620
+ if (!response.ok)
621
+ return { available: false, note: `Chrome UX Report ${response.status}` };
622
+ const record = body?.record;
623
+ const series = (metric) => (record?.metrics?.[metric]?.percentilesTimeseries?.p75s ?? []).map((v) => (v === null || v === undefined ? null : Number(v)));
624
+ const periods = (record?.collectionPeriods ?? []).map((p) => {
625
+ const d = p.lastDate;
626
+ return d
627
+ ? `${d.year}-${String(d.month).padStart(2, "0")}-${String(d.day).padStart(2, "0")}`
628
+ : null;
629
+ });
630
+ const lcp = series("largest_contentful_paint"), inp = series("interaction_to_next_paint"), cls = series("cumulative_layout_shift");
631
+ return {
632
+ available: true,
633
+ points: periods.map((to, i) => ({
634
+ to,
635
+ lcp: lcp[i] ?? null,
636
+ inp: inp[i] ?? null,
637
+ cls: cls[i] ?? null,
638
+ })),
639
+ };
640
+ }
641
+ catch (error) {
642
+ return { available: false, note: errorMessage(error) };
643
+ }
644
+ }
645
+ // SSL Labs grade. Cached results up to a week old are accepted; a fresh
646
+ // assessment started here is picked up by the next scan.
647
+ export async function sslLabs(host) {
648
+ try {
649
+ const { response, body } = await getJson(`https://api.ssllabs.com/api/v3/analyze?host=${encodeURIComponent(host)}&publish=off&fromCache=on&maxAge=168&all=done`);
650
+ if (!response.ok)
651
+ return { status: "unavailable", error: `SSL Labs ${response.status}` };
652
+ if (body?.status !== "READY")
653
+ return {
654
+ status: "pending",
655
+ note: body?.status === "ERROR"
656
+ ? body?.statusMessage
657
+ : "assessment running, result on the next scan",
658
+ };
659
+ const endpoints = (body.endpoints ?? []);
660
+ const grades = endpoints
661
+ .map((e) => e.grade)
662
+ .filter(Boolean)
663
+ .sort();
664
+ return {
665
+ status: "observed",
666
+ grade: grades[0] ?? null,
667
+ hasWarnings: endpoints.some((e) => e.hasWarnings),
668
+ endpoints: endpoints.length,
669
+ testedAt: body.testTime ? new Date(body.testTime).toISOString() : null,
670
+ source: "SSL Labs",
671
+ };
672
+ }
673
+ catch (error) {
674
+ return { status: "unavailable", error: errorMessage(error) };
675
+ }
676
+ }
677
+ export async function hstsPreload(domain) {
678
+ try {
679
+ const { response, body } = await getJson(`https://hstspreload.org/api/v2/status?domain=${encodeURIComponent(domain)}`, {}, 8000);
680
+ if (!response.ok)
681
+ return {
682
+ status: "unavailable",
683
+ error: `hstspreload.org ${response.status}`,
684
+ };
685
+ return { status: "observed", preload: body?.status ?? "unknown" };
686
+ }
687
+ catch (error) {
688
+ return { status: "unavailable", error: errorMessage(error) };
689
+ }
690
+ }
691
+ // Certificate transparency logs: every hostname that ever got a certificate.
692
+ export async function certificateLogs(domain) {
693
+ try {
694
+ const { response, body } = await getJson(`https://crt.sh/?q=${encodeURIComponent(`%.${domain}`)}&exclude=expired&output=json`, {}, 30000);
695
+ if (!response.ok || !Array.isArray(body))
696
+ return { status: "unavailable", error: `crt.sh ${response.status}` };
697
+ const monthAgo = Date.now() - 30 * 86400000;
698
+ const hostnames = new Set();
699
+ const issuers = new Set();
700
+ let recent = 0;
701
+ for (const entry of body) {
702
+ for (const name of String(entry.name_value ?? "").split("\n"))
703
+ if (name && !name.startsWith("*"))
704
+ hostnames.add(name.toLowerCase());
705
+ if (entry.issuer_name)
706
+ issuers.add(String(entry.issuer_name).replace(/^.*O=([^,]+).*$/, "$1"));
707
+ if (Date.parse(entry.not_before) > monthAgo)
708
+ recent++;
709
+ }
710
+ return {
711
+ status: "observed",
712
+ hostnames: [...hostnames].sort().slice(0, 200),
713
+ issuers: [...issuers].slice(0, 20),
714
+ issuedLast30Days: recent,
715
+ source: "crt.sh",
716
+ };
717
+ }
718
+ catch (error) {
719
+ return { status: "unavailable", error: errorMessage(error) };
720
+ }
721
+ }
722
+ // SURBL answers plain DNS queries for free; Spamhaus refuses shared
723
+ // resolvers such as Netlify's, so it is left out.
724
+ export async function blocklists(domain, _address) {
725
+ const lookups = [
726
+ {
727
+ list: "SURBL",
728
+ query: `${domain}.multi.surbl.org`,
729
+ refused: /^127\.0\.0\.1$/,
730
+ listed: /^127\.0\.0\./,
731
+ },
732
+ ];
733
+ const results = await Promise.all(lookups.map(async ({ list, query, refused, listed }) => {
734
+ try {
735
+ const answers = await Promise.race([
736
+ dns.resolve4(query),
737
+ new Promise((_, reject) => {
738
+ const t = setTimeout(() => reject(new Error("DNS timeout")), 5000);
739
+ t.unref();
740
+ }),
741
+ ]);
742
+ if (answers.some((a) => refused.test(a)))
743
+ return { list, status: "unavailable", note: "query refused" };
744
+ return {
745
+ list,
746
+ status: answers.some((a) => listed.test(a)) ? "listed" : "clean",
747
+ answers,
748
+ };
749
+ }
750
+ catch (error) {
751
+ const code = error?.code;
752
+ return code === "ENOTFOUND" || code === "ENODATA"
753
+ ? { list, status: "clean" }
754
+ : { list, status: "unavailable", note: errorMessage(error) };
755
+ }
756
+ }));
757
+ return { status: "observed", results };
758
+ }