spiderlint 0.0.0 → 0.143.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.
- package/LICENSE +235 -0
- package/README.md +244 -0
- package/bin/spiderlint.js +11 -0
- package/dist/agent.js +46 -0
- package/dist/cache/extractors.js +37 -0
- package/dist/cache/http.js +99 -0
- package/dist/cache/index.js +101 -0
- package/dist/cache/purge.js +63 -0
- package/dist/cache/status.js +52 -0
- package/dist/cli.js +602 -0
- package/dist/color.js +10 -0
- package/dist/config/environment.js +88 -0
- package/dist/config/index.js +104 -0
- package/dist/config/policy.js +170 -0
- package/dist/config/schema.js +127 -0
- package/dist/crawl/body.js +92 -0
- package/dist/crawl/browser.js +498 -0
- package/dist/crawl/crawl.js +58 -0
- package/dist/crawl/diversity.js +153 -0
- package/dist/crawl/dns.js +194 -0
- package/dist/crawl/fetch.js +48 -0
- package/dist/crawl/frontier.js +370 -0
- package/dist/crawl/guard.js +69 -0
- package/dist/crawl/http.js +171 -0
- package/dist/crawl/links.js +89 -0
- package/dist/crawl/log.js +27 -0
- package/dist/crawl/network.js +88 -0
- package/dist/crawl/probe.js +107 -0
- package/dist/crawl/profile.js +33 -0
- package/dist/crawl/rdap.js +86 -0
- package/dist/crawl/resolve.js +132 -0
- package/dist/crawl/resources.js +233 -0
- package/dist/crawl/robots.js +100 -0
- package/dist/crawl/route.js +92 -0
- package/dist/crawl/scope.js +32 -0
- package/dist/crawl/scratch.js +15 -0
- package/dist/crawl/sitemap.js +187 -0
- package/dist/crawl/svcb.js +59 -0
- package/dist/crawl/tls-hello.js +297 -0
- package/dist/crawl/tls-probe.js +71 -0
- package/dist/facts/browser.js +138 -0
- package/dist/facts/byline.js +17 -0
- package/dist/facts/co2.js +27 -0
- package/dist/facts/csp.js +71 -0
- package/dist/facts/export.js +79 -0
- package/dist/facts/flatten.js +33 -0
- package/dist/facts/graph.js +73 -0
- package/dist/facts/headers.js +330 -0
- package/dist/facts/html.js +230 -0
- package/dist/facts/labels.js +89 -0
- package/dist/facts/language.js +32 -0
- package/dist/facts/parity.js +38 -0
- package/dist/facts/read-note.js +11 -0
- package/dist/facts/resources.js +46 -0
- package/dist/facts/robots.js +13 -0
- package/dist/facts/sites.js +140 -0
- package/dist/facts/transport.js +200 -0
- package/dist/facts/types.js +4 -0
- package/dist/facts/vendors.js +76 -0
- package/dist/fold/index.js +94 -0
- package/dist/groups/assign.js +45 -0
- package/dist/groups/sample.js +60 -0
- package/dist/i18n.js +92 -0
- package/dist/index.js +607 -0
- package/dist/interrupt.js +37 -0
- package/dist/logger.js +95 -0
- package/dist/plugins/axe.js +104 -0
- package/dist/plugins/carbon-txt.js +96 -0
- package/dist/plugins/cookies-registry.js +25 -0
- package/dist/plugins/cookies.js +168 -0
- package/dist/plugins/css.js +328 -0
- package/dist/plugins/dns.js +864 -0
- package/dist/plugins/feed-content.js +132 -0
- package/dist/plugins/feed-model.js +282 -0
- package/dist/plugins/feeds.js +836 -0
- package/dist/plugins/html-validate.js +191 -0
- package/dist/plugins/htmlhint.js +95 -0
- package/dist/plugins/icons.js +302 -0
- package/dist/plugins/images.js +269 -0
- package/dist/plugins/index.js +286 -0
- package/dist/plugins/keyboard.js +194 -0
- package/dist/plugins/lighthouse.js +110 -0
- package/dist/plugins/link-text.js +73 -0
- package/dist/plugins/list.js +22 -0
- package/dist/plugins/live.js +254 -0
- package/dist/plugins/mail.js +985 -0
- package/dist/plugins/manifest.js +97 -0
- package/dist/plugins/markup.js +98 -0
- package/dist/plugins/messages.js +25 -0
- package/dist/plugins/network.js +192 -0
- package/dist/plugins/origin.js +488 -0
- package/dist/plugins/profile-links.js +102 -0
- package/dist/plugins/schema-registry.js +101 -0
- package/dist/plugins/sshfp.js +159 -0
- package/dist/plugins/stack.js +161 -0
- package/dist/plugins/structured-data.js +358 -0
- package/dist/plugins/tls-probe.js +298 -0
- package/dist/plugins/tls-registry.js +418 -0
- package/dist/plugins/tls-scan.js +280 -0
- package/dist/plugins/trackers-registry.js +27 -0
- package/dist/plugins/trackers.js +56 -0
- package/dist/plugins/types.js +7 -0
- package/dist/plugins/visit.js +50 -0
- package/dist/plugins/well-known-registry.js +111 -0
- package/dist/plugins/well-known.js +732 -0
- package/dist/progress.js +162 -0
- package/dist/report/agent.js +122 -0
- package/dist/report/checkstyle.js +22 -0
- package/dist/report/csv.js +18 -0
- package/dist/report/html.js +266 -0
- package/dist/report/human.js +220 -0
- package/dist/report/index.js +16 -0
- package/dist/report/json.js +8 -0
- package/dist/report/printable.js +29 -0
- package/dist/report/rating.js +26 -0
- package/dist/report/sarif.js +100 -0
- package/dist/report/stats.js +46 -0
- package/dist/rules/builtin.js +465 -0
- package/dist/rules/caching.js +103 -0
- package/dist/rules/catalog.js +139 -0
- package/dist/rules/clock.js +48 -0
- package/dist/rules/declarative.js +234 -0
- package/dist/rules/deprecated.js +45 -0
- package/dist/rules/disclosure.js +99 -0
- package/dist/rules/fix.js +21 -0
- package/dist/rules/i18n.js +131 -0
- package/dist/rules/insights.js +100 -0
- package/dist/rules/length.js +52 -0
- package/dist/rules/message.js +48 -0
- package/dist/rules/relations.js +88 -0
- package/dist/rules/robots.js +75 -0
- package/dist/rules/rulesets.js +134 -0
- package/dist/rules/run.js +75 -0
- package/dist/rules/score.js +61 -0
- package/dist/rules/types.js +7 -0
- package/dist/rules/url.js +90 -0
- package/dist/server/analytics.js +49 -0
- package/dist/server/api.js +134 -0
- package/dist/server/clients.js +54 -0
- package/dist/server/clock.js +33 -0
- package/dist/server/compress.js +58 -0
- package/dist/server/health.js +14 -0
- package/dist/server/jobs.js +82 -0
- package/dist/server/main.js +83 -0
- package/dist/server/page.js +161 -0
- package/dist/server/policy.js +145 -0
- package/dist/server/providers.js +60 -0
- package/dist/server/queue.js +50 -0
- package/dist/server/scan.js +39 -0
- package/dist/server/settings.js +197 -0
- package/dist/server/web.js +353 -0
- package/dist/server/worker.js +129 -0
- package/dist/store/disk.js +215 -0
- package/dist/store/memory.js +18 -0
- package/dist/telemetry-environment.js +9 -0
- package/dist/telemetry.js +104 -0
- package/locales/es/LC_MESSAGES/messages.po +1864 -0
- package/locales/uk/LC_MESSAGES/messages.po +1864 -0
- package/package.json +134 -2
- package/presets/browser.yaml +26 -0
- package/presets/graph.yaml +35 -0
- package/presets/i18n.yaml +18 -0
- package/presets/insights.yaml +12 -0
- package/presets/links.yaml +22 -0
- package/presets/parity.yaml +59 -0
- package/presets/performance.yaml +271 -0
- package/presets/recommended.yaml +50 -0
- package/presets/redirects.yaml +23 -0
- package/presets/resources.yaml +29 -0
- package/presets/robots.yaml +13 -0
- package/presets/security-headers.yaml +257 -0
- package/presets/seo.yaml +380 -0
- package/presets/sitemap.yaml +34 -0
- package/presets/suggestions.yaml +97 -0
- package/presets/sustainability.yaml +16 -0
- package/presets/tls.yaml +72 -0
- package/presets/vendor.yaml +14 -0
- package/presets/web-comprehensive.yaml +17 -0
- package/presets/web-quick.yaml +15 -0
- package/src/server/apple-touch-icon.png +0 -0
- package/src/server/logo-36.png +0 -0
- package/src/server/logo-72.png +0 -0
- package/src/server/logo.png +0 -0
- package/vendors/dns.yaml +21 -0
- package/vendors/paths.yaml +55 -0
- package/vendors/stack.yaml +57 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
const KEYS = ["mandatory", "alpn", "no-default-alpn", "port", "ipv4hint", "ech", "ipv6hint"];
|
|
5
|
+
// An uncompressed domain name at `offset`, as §2.2 requires, and the offset past it.
|
|
6
|
+
function name(data, offset) {
|
|
7
|
+
const labels = [];
|
|
8
|
+
for (let length = data.readUInt8(offset); length > 0; length = data.readUInt8(offset)) {
|
|
9
|
+
if (length > 63)
|
|
10
|
+
throw new Error(`SVCB target label length ${length} is a compression pointer`);
|
|
11
|
+
labels.push(data.toString("latin1", offset + 1, offset + 1 + length));
|
|
12
|
+
offset += 1 + length;
|
|
13
|
+
}
|
|
14
|
+
return [labels.length === 0 ? "." : labels.join("."), offset + 1];
|
|
15
|
+
}
|
|
16
|
+
// `width`-byte chunks of `value`, each rendered by `render`.
|
|
17
|
+
function chunks(value, width, render) {
|
|
18
|
+
if (value.length % width !== 0)
|
|
19
|
+
throw new Error(`SVCB address hint of ${value.length} bytes`);
|
|
20
|
+
return Array.from({ length: value.length / width }, (_, index) => render(value.subarray(index * width, (index + 1) * width)));
|
|
21
|
+
}
|
|
22
|
+
// Length-prefixed strings, the wire form of `alpn`.
|
|
23
|
+
function strings(value) {
|
|
24
|
+
const out = [];
|
|
25
|
+
for (let offset = 0; offset < value.length; offset += 1 + value.readUInt8(offset))
|
|
26
|
+
out.push(value.toString("latin1", offset + 1, offset + 1 + value.readUInt8(offset)));
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
// The RFC 5952 text form of a 16-byte IPv6 address, as the URL parser canonicalises it.
|
|
30
|
+
function ipv6(bytes) {
|
|
31
|
+
const groups = Array.from({ length: 8 }, (_, index) => bytes.readUInt16BE(index * 2).toString(16)).join(":");
|
|
32
|
+
return new URL(`http://[${groups}]/`).hostname.slice(1, -1);
|
|
33
|
+
}
|
|
34
|
+
// Each known SvcParamKey’s value, by key number (RFC 9460 §14.3.2).
|
|
35
|
+
const DECODERS = {
|
|
36
|
+
0: (value) => ({ mandatory: Array.from({ length: value.length / 2 }, (_, index) => KEYS[value.readUInt16BE(index * 2)] ?? `key${value.readUInt16BE(index * 2)}`) }),
|
|
37
|
+
1: (value) => ({ alpn: strings(value) }),
|
|
38
|
+
2: () => ({ "no-default-alpn": true }),
|
|
39
|
+
3: (value) => ({ port: value.readUInt16BE(0) }),
|
|
40
|
+
4: (value) => ({ ipv4hint: chunks(value, 4, (bytes) => [...bytes].join(".")) }),
|
|
41
|
+
5: () => ({ ech: true }),
|
|
42
|
+
6: (value) => ({ ipv6hint: chunks(value, 16, ipv6) }),
|
|
43
|
+
};
|
|
44
|
+
// The RDATA of a type 64 or 65 record: priority, target, then key-ordered parameters.
|
|
45
|
+
export function parseSvcb(data) {
|
|
46
|
+
const [target, start] = name(data, 2);
|
|
47
|
+
const record = { priority: data.readUInt16BE(0), target };
|
|
48
|
+
for (let offset = start; offset < data.length;) {
|
|
49
|
+
const key = data.readUInt16BE(offset);
|
|
50
|
+
const value = data.subarray(offset + 4, offset + 4 + data.readUInt16BE(offset + 2));
|
|
51
|
+
offset += 4 + value.length;
|
|
52
|
+
const decode = DECODERS[key];
|
|
53
|
+
if (decode)
|
|
54
|
+
Object.assign(record, decode(value));
|
|
55
|
+
else
|
|
56
|
+
(record.unknown ??= []).push(key);
|
|
57
|
+
}
|
|
58
|
+
return record;
|
|
59
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { createDecipheriv, createECDH, createHash, createHmac, createPublicKey, diffieHellman, generateKeyPairSync, randomBytes } from "node:crypto";
|
|
5
|
+
import { isIP } from "node:net";
|
|
6
|
+
// Wire versions by Node’s protocol name.
|
|
7
|
+
export const VERSIONS = { SSLv3: 0x03_00, TLSv1: 0x03_01, "TLSv1.1": 0x03_02, "TLSv1.2": 0x03_03, "TLSv1.3": 0x03_04 };
|
|
8
|
+
// The suite a client adds when it retries at a lower version (RFC 7507).
|
|
9
|
+
export const FALLBACK_SCSV = 0x56_00;
|
|
10
|
+
// The alert a server supporting RFC 7507 answers a needless fallback with.
|
|
11
|
+
export const INAPPROPRIATE_FALLBACK = 86;
|
|
12
|
+
// The one TLS 1.3 suite every server implements (RFC 8446 §9.1), which `openHandshake` decrypts.
|
|
13
|
+
export const TLS13_MANDATORY = 0x13_01;
|
|
14
|
+
// SSLv2 cipher kinds, by the name OpenSSL gives them.
|
|
15
|
+
export const SSL2_KINDS = {
|
|
16
|
+
0x01_00_80: "SSL_CK_RC4_128_WITH_MD5",
|
|
17
|
+
0x02_00_80: "SSL_CK_RC4_128_EXPORT40_WITH_MD5",
|
|
18
|
+
0x03_00_80: "SSL_CK_RC2_128_CBC_WITH_MD5",
|
|
19
|
+
0x04_00_80: "SSL_CK_RC2_128_CBC_EXPORT40_WITH_MD5",
|
|
20
|
+
0x05_00_80: "SSL_CK_IDEA_128_CBC_WITH_MD5",
|
|
21
|
+
0x06_00_40: "SSL_CK_DES_64_CBC_WITH_MD5",
|
|
22
|
+
0x07_00_c0: "SSL_CK_DES_192_EDE3_CBC_WITH_MD5",
|
|
23
|
+
};
|
|
24
|
+
const HANDSHAKE = 22;
|
|
25
|
+
const ALERT = 21;
|
|
26
|
+
const APPLICATION = 23;
|
|
27
|
+
const X25519 = 29;
|
|
28
|
+
const SECP256R1 = 23;
|
|
29
|
+
// The ServerHello random that marks a HelloRetryRequest (RFC 8446 §4.1.3).
|
|
30
|
+
const RETRY = Buffer.from("cf21ad74e59a6111be1d8c021e65b891c2a211167abb8c5e079e09e2c8a8339c", "hex");
|
|
31
|
+
// The DER prefix turning a raw x25519 key into a SubjectPublicKeyInfo.
|
|
32
|
+
const X25519_SPKI = Buffer.from("302a300506032b656e032100", "hex");
|
|
33
|
+
// Every signature scheme a server may sign with, so none refuses for want of one.
|
|
34
|
+
const SIGNATURES = [0x04_03, 0x05_03, 0x06_03, 0x08_07, 0x08_08, 0x08_04, 0x08_05, 0x08_06, 0x08_09, 0x08_0a, 0x08_0b, 0x08_1a, 0x08_1b, 0x08_1c, 0x04_01, 0x05_01, 0x06_01, 0x03_03, 0x03_01, 0x04_02, 0x05_02, 0x06_02, 0x02_03, 0x02_01, 0x02_02];
|
|
35
|
+
function u8(value) {
|
|
36
|
+
return Buffer.from([value]);
|
|
37
|
+
}
|
|
38
|
+
function u16(value) {
|
|
39
|
+
const bytes = Buffer.alloc(2);
|
|
40
|
+
bytes.writeUInt16BE(value);
|
|
41
|
+
return bytes;
|
|
42
|
+
}
|
|
43
|
+
function u24(value) {
|
|
44
|
+
const bytes = Buffer.alloc(3);
|
|
45
|
+
bytes.writeUIntBE(value, 0, 3);
|
|
46
|
+
return bytes;
|
|
47
|
+
}
|
|
48
|
+
// `body` behind its length in `width` bytes.
|
|
49
|
+
function vector(width, body) {
|
|
50
|
+
return Buffer.concat([[u8, u16, u24][width - 1]?.(body.length) ?? Buffer.alloc(0), body]);
|
|
51
|
+
}
|
|
52
|
+
function extension(type, body) {
|
|
53
|
+
return Buffer.concat([u16(type), vector(2, body)]);
|
|
54
|
+
}
|
|
55
|
+
// A vector of 16-bit values.
|
|
56
|
+
function list16(values) {
|
|
57
|
+
return vector(2, Buffer.concat(values.map((value) => u16(value))));
|
|
58
|
+
}
|
|
59
|
+
// The `server_name` body naming `host`.
|
|
60
|
+
function serverName(host) {
|
|
61
|
+
const name = vector(2, Buffer.from(host));
|
|
62
|
+
return vector(2, Buffer.concat([u8(0), name]));
|
|
63
|
+
}
|
|
64
|
+
// The `key_share` body carrying `shares`.
|
|
65
|
+
function keyShareList(shares) {
|
|
66
|
+
const entries = shares.map((share) => Buffer.concat([u16(share.group), vector(2, share.key)]));
|
|
67
|
+
return vector(2, Buffer.concat(entries));
|
|
68
|
+
}
|
|
69
|
+
// The extensions of a hello; SSLv3 sends none, TLS 1.3 swaps renegotiation and heartbeat for versions and key shares.
|
|
70
|
+
function extensionsOf(hello, version) {
|
|
71
|
+
if (version === VERSIONS.SSLv3)
|
|
72
|
+
return [];
|
|
73
|
+
const isModern = version === VERSIONS["TLSv1.3"];
|
|
74
|
+
return [
|
|
75
|
+
...(isIP(hello.host) === 0 ? [extension(0, serverName(hello.host))] : []),
|
|
76
|
+
extension(5, Buffer.from([1, 0, 0, 0, 0])),
|
|
77
|
+
extension(10, list16(hello.groups)),
|
|
78
|
+
extension(11, Buffer.from([1, 0])),
|
|
79
|
+
...(version >= VERSIONS["TLSv1.2"] ? [extension(13, list16(SIGNATURES))] : []),
|
|
80
|
+
...(isModern ? [extension(43, Buffer.from([2, 3, 4])), extension(51, keyShareList(hello.shares ?? []))] : [extension(15, u8(1)), extension(0xff_01, u8(0))]),
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
// One ClientHello record, padded out of the 256–511 byte range some load balancers drop (RFC 7685).
|
|
84
|
+
export function clientHello(hello) {
|
|
85
|
+
const version = VERSIONS[hello.protocol];
|
|
86
|
+
const isModern = version === VERSIONS["TLSv1.3"];
|
|
87
|
+
const session = vector(1, isModern ? randomBytes(32) : Buffer.alloc(0));
|
|
88
|
+
const compression = vector(1, Buffer.from(!isModern && hello.compression ? [1, 0] : [0]));
|
|
89
|
+
const head = Buffer.concat([u16(Math.min(version, VERSIONS["TLSv1.2"])), randomBytes(32), session, list16(hello.suites), compression]);
|
|
90
|
+
const extensions = extensionsOf(hello, version);
|
|
91
|
+
const length = 4 + head.length + 2 + extensions.reduce((sum, entry) => sum + entry.length, 0);
|
|
92
|
+
const padding = Buffer.alloc(Math.max(0, 512 - length - 4));
|
|
93
|
+
if (version !== VERSIONS.SSLv3 && length >= 256 && length < 512)
|
|
94
|
+
extensions.push(extension(21, padding));
|
|
95
|
+
const body = version === VERSIONS.SSLv3 ? head : Buffer.concat([head, vector(2, Buffer.concat(extensions))]);
|
|
96
|
+
const message = Buffer.concat([u8(1), vector(3, body)]);
|
|
97
|
+
return Buffer.concat([u8(HANDSHAKE), u16(Math.min(version, VERSIONS.TLSv1)), vector(2, message)]);
|
|
98
|
+
}
|
|
99
|
+
// An SSLv2 CLIENT-HELLO offering every cipher kind.
|
|
100
|
+
export function sslv2Hello() {
|
|
101
|
+
const kinds = Buffer.concat(Object.keys(SSL2_KINDS).map((kind) => u24(Number(kind))));
|
|
102
|
+
const body = Buffer.concat([u8(1), u16(0x00_02), u16(kinds.length), u16(0), u16(16), kinds, randomBytes(16)]);
|
|
103
|
+
return Buffer.concat([u16(0x80_00 | body.length), body]);
|
|
104
|
+
}
|
|
105
|
+
// Fresh x25519 and secp256r1 key shares, and the TLS 1.3 client that can decrypt the answer to the hello carrying them.
|
|
106
|
+
export function keyShares() {
|
|
107
|
+
const x25519 = generateKeyPairSync("x25519");
|
|
108
|
+
const p256 = createECDH("prime256v1");
|
|
109
|
+
p256.generateKeys();
|
|
110
|
+
const shares = [
|
|
111
|
+
{ group: X25519, key: x25519.publicKey.export({ format: "der", type: "spki" }).subarray(-32) },
|
|
112
|
+
{ group: SECP256R1, key: p256.getPublicKey() },
|
|
113
|
+
];
|
|
114
|
+
const agree = (group, share) => {
|
|
115
|
+
if (group === SECP256R1)
|
|
116
|
+
return p256.computeSecret(share);
|
|
117
|
+
if (group !== X25519)
|
|
118
|
+
return;
|
|
119
|
+
const publicKey = createPublicKey({ key: Buffer.concat([X25519_SPKI, share]), format: "der", type: "spki" });
|
|
120
|
+
return diffieHellman({ privateKey: x25519.privateKey, publicKey });
|
|
121
|
+
};
|
|
122
|
+
return { shares, client: (hello) => ({ hello: hello.subarray(5), agree }) };
|
|
123
|
+
}
|
|
124
|
+
function sha256(data) {
|
|
125
|
+
return createHash("sha256").update(data).digest();
|
|
126
|
+
}
|
|
127
|
+
function hmac(key, data) {
|
|
128
|
+
return createHmac("sha256", key).update(data).digest();
|
|
129
|
+
}
|
|
130
|
+
// HKDF-Expand-Label over SHA-256 (RFC 8446 §7.1).
|
|
131
|
+
function expandLabel(secret, label, context, length) {
|
|
132
|
+
const name = Buffer.from(`tls13 ${label}`);
|
|
133
|
+
const info = Buffer.concat([u16(length), vector(1, name), vector(1, context)]);
|
|
134
|
+
const blocks = [];
|
|
135
|
+
for (let block = Buffer.alloc(0), index = 1; blocks.length * 32 < length; index++) {
|
|
136
|
+
block = hmac(secret, Buffer.concat([block, info, u8(index)]));
|
|
137
|
+
blocks.push(block);
|
|
138
|
+
}
|
|
139
|
+
return Buffer.concat(blocks).subarray(0, length);
|
|
140
|
+
}
|
|
141
|
+
// The server handshake traffic decrypter of TLS_AES_128_GCM_SHA256, from the shared secret and the ClientHello…ServerHello transcript.
|
|
142
|
+
export function openHandshake(secret, transcript) {
|
|
143
|
+
const empty = Buffer.alloc(0);
|
|
144
|
+
const early = hmac(Buffer.alloc(32), Buffer.alloc(32));
|
|
145
|
+
const handshake = hmac(expandLabel(early, "derived", sha256(empty), 32), secret);
|
|
146
|
+
const traffic = expandLabel(handshake, "s hs traffic", sha256(transcript), 32);
|
|
147
|
+
const [key, iv] = [expandLabel(traffic, "key", empty, 16), expandLabel(traffic, "iv", empty, 12)];
|
|
148
|
+
let sequence = 0n;
|
|
149
|
+
return (header, sealed) => {
|
|
150
|
+
const nonce = Buffer.from(iv);
|
|
151
|
+
nonce.writeBigUInt64BE(nonce.readBigUInt64BE(4) ^ sequence++, 4);
|
|
152
|
+
const decipher = createDecipheriv("aes-128-gcm", key, nonce);
|
|
153
|
+
decipher.setAAD(header);
|
|
154
|
+
decipher.setAuthTag(sealed.subarray(-16));
|
|
155
|
+
return Buffer.concat([decipher.update(sealed.subarray(0, -16)), decipher.final()]);
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
// The ServerHello’s version, suite, compression and extensions into `flight`.
|
|
159
|
+
function readServerHello(body, flight) {
|
|
160
|
+
flight.version = body.readUInt16BE(0);
|
|
161
|
+
if (body.subarray(2, 34).equals(RETRY))
|
|
162
|
+
flight.retry = true;
|
|
163
|
+
let at = 35 + (body[34] ?? 0);
|
|
164
|
+
flight.suite = body.readUInt16BE(at);
|
|
165
|
+
flight.compression = body[at + 2];
|
|
166
|
+
at += 3;
|
|
167
|
+
flight.extensions = [];
|
|
168
|
+
const end = at + 2 <= body.length ? at + 2 + body.readUInt16BE(at) : at;
|
|
169
|
+
for (at += 2; at + 4 <= end; at += 4 + body.readUInt16BE(at + 2)) {
|
|
170
|
+
const type = body.readUInt16BE(at);
|
|
171
|
+
const data = body.subarray(at + 4, at + 4 + body.readUInt16BE(at + 2));
|
|
172
|
+
flight.extensions.push(type);
|
|
173
|
+
if (type === 43)
|
|
174
|
+
flight.version = data.readUInt16BE(0);
|
|
175
|
+
else if (type === 51)
|
|
176
|
+
flight.group = data.readUInt16BE(0);
|
|
177
|
+
if (type === 51 && !flight.retry)
|
|
178
|
+
flight.share = data.subarray(4, 4 + data.readUInt16BE(2));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// The OCSP response of a CertificateStatus body, when it carries one.
|
|
182
|
+
function readStatus(body) {
|
|
183
|
+
return body[0] === 1 ? body.subarray(4, 4 + body.readUIntBE(1, 3)) : undefined;
|
|
184
|
+
}
|
|
185
|
+
// The certificates of a Certificate message, and in TLS 1.3 the leaf’s stapled OCSP response.
|
|
186
|
+
function readCertificates(body, flight) {
|
|
187
|
+
const isModern = flight.version === VERSIONS["TLSv1.3"];
|
|
188
|
+
let at = isModern ? 1 + (body[0] ?? 0) : 0;
|
|
189
|
+
const end = at + 3 + body.readUIntBE(at, 3);
|
|
190
|
+
const certificates = [];
|
|
191
|
+
for (at += 3; at + 3 <= end;) {
|
|
192
|
+
const size = body.readUIntBE(at, 3);
|
|
193
|
+
certificates.push(body.subarray(at + 3, at + 3 + size));
|
|
194
|
+
at += 3 + size;
|
|
195
|
+
if (!isModern)
|
|
196
|
+
continue;
|
|
197
|
+
const extensionsEnd = at + 2 + body.readUInt16BE(at);
|
|
198
|
+
for (let entry = at + 2; entry + 4 <= extensionsEnd; entry += 4 + body.readUInt16BE(entry + 2)) {
|
|
199
|
+
const data = body.subarray(entry + 4, entry + 4 + body.readUInt16BE(entry + 2));
|
|
200
|
+
if (certificates.length === 1 && body.readUInt16BE(entry) === 5)
|
|
201
|
+
flight.ocsp = readStatus(data);
|
|
202
|
+
}
|
|
203
|
+
at = extensionsEnd;
|
|
204
|
+
}
|
|
205
|
+
flight.certificates = certificates;
|
|
206
|
+
}
|
|
207
|
+
// An SSLv2 SERVER-HELLO: its certificate and the cipher kinds it offers.
|
|
208
|
+
function readSsl2(data) {
|
|
209
|
+
const length = ((data[0] ?? 0) & 0x7f) * 256 + (data[1] ?? 0);
|
|
210
|
+
if (data.length < 2 + length)
|
|
211
|
+
return { complete: false };
|
|
212
|
+
const body = data.subarray(2, 2 + length);
|
|
213
|
+
const [certificate, kinds] = [body.readUInt16BE(5), body.readUInt16BE(7)];
|
|
214
|
+
const list = body.subarray(11 + certificate, 11 + certificate + kinds);
|
|
215
|
+
const offered = Array.from({ length: Math.floor(list.length / 3) }, (_, index) => list.readUIntBE(index * 3, 3));
|
|
216
|
+
return { complete: true, version: 0x00_02, certificates: [body.subarray(11, 11 + certificate)], kinds: offered };
|
|
217
|
+
}
|
|
218
|
+
// One handshake message into `flight`.
|
|
219
|
+
function readMessage(type, body, flight) {
|
|
220
|
+
switch (type) {
|
|
221
|
+
case 2: {
|
|
222
|
+
readServerHello(body, flight);
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
case 11: {
|
|
226
|
+
readCertificates(body, flight);
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
case 12: {
|
|
230
|
+
flight.keyExchange = body;
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
case 22: {
|
|
234
|
+
flight.ocsp = readStatus(body);
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// Whether a message of `type` ends what the server says before the client speaks.
|
|
240
|
+
function isLast(type, flight) {
|
|
241
|
+
return type === 14 || (type === 2 && flight.retry === true) || (type === 11 && flight.version === VERSIONS["TLSv1.3"]);
|
|
242
|
+
}
|
|
243
|
+
// The server’s answer so far, read afresh from every byte received; `client` decrypts a TLS 1.3 flight up to its Certificate.
|
|
244
|
+
export function readFlight(data, client) {
|
|
245
|
+
if (data.length >= 3 && ((data[0] ?? 0) & 0x80) !== 0 && data[2] === 4)
|
|
246
|
+
return readSsl2(data);
|
|
247
|
+
const flight = { complete: false };
|
|
248
|
+
let open;
|
|
249
|
+
let stream = Buffer.alloc(0);
|
|
250
|
+
let at = 0;
|
|
251
|
+
try {
|
|
252
|
+
for (let offset = 0; offset + 5 <= data.length;) {
|
|
253
|
+
const header = data.subarray(offset, offset + 5);
|
|
254
|
+
const body = data.subarray(offset + 5, offset + 5 + header.readUInt16BE(3));
|
|
255
|
+
if (body.length < header.readUInt16BE(3))
|
|
256
|
+
break;
|
|
257
|
+
offset += 5 + body.length;
|
|
258
|
+
let [type, content] = [header[0], body];
|
|
259
|
+
if (type === APPLICATION && open) {
|
|
260
|
+
const inner = open(header, body);
|
|
261
|
+
const end = inner.findLastIndex((byte) => byte !== 0);
|
|
262
|
+
[type, content] = [inner[end], inner.subarray(0, end)];
|
|
263
|
+
}
|
|
264
|
+
if (type === ALERT)
|
|
265
|
+
return { ...flight, complete: true, alert: content[1] };
|
|
266
|
+
if (type !== HANDSHAKE)
|
|
267
|
+
continue;
|
|
268
|
+
stream = Buffer.concat([stream, content]);
|
|
269
|
+
for (; at + 4 <= stream.length && at + 4 + stream.readUIntBE(at + 1, 3) <= stream.length; at += 4 + stream.readUIntBE(at + 1, 3)) {
|
|
270
|
+
const message = stream.subarray(at, at + 4 + stream.readUIntBE(at + 1, 3));
|
|
271
|
+
readMessage(message[0] ?? 0, message.subarray(4), flight);
|
|
272
|
+
if (isLast(message[0] ?? 0, flight))
|
|
273
|
+
return { ...flight, complete: true };
|
|
274
|
+
if (message[0] !== 2 || flight.version !== VERSIONS["TLSv1.3"])
|
|
275
|
+
continue;
|
|
276
|
+
const secret = client && flight.suite === TLS13_MANDATORY && flight.share && flight.group !== undefined ? client.agree(flight.group, flight.share) : undefined;
|
|
277
|
+
if (!secret || !client)
|
|
278
|
+
return { ...flight, complete: true };
|
|
279
|
+
open = openHandshake(secret, Buffer.concat([client.hello, message]));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
return { ...flight, complete: true, error: error instanceof Error ? error.message : String(error) };
|
|
285
|
+
}
|
|
286
|
+
return flight;
|
|
287
|
+
}
|
|
288
|
+
// The prime size of a DHE ServerKeyExchange, in bits.
|
|
289
|
+
export function dhBits(keyExchange) {
|
|
290
|
+
const prime = keyExchange.subarray(2, 2 + keyExchange.readUInt16BE(0));
|
|
291
|
+
const first = prime.findIndex((byte) => byte !== 0);
|
|
292
|
+
return first === -1 ? 0 : (prime.length - first) * 8 - Math.clz32(prime[first] ?? 0) + 24;
|
|
293
|
+
}
|
|
294
|
+
// The named curve of an ECDHE ServerKeyExchange.
|
|
295
|
+
export function curve(keyExchange) {
|
|
296
|
+
return keyExchange[0] === 3 ? keyExchange.readUInt16BE(1) : undefined;
|
|
297
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { isIP } from "node:net";
|
|
5
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
6
|
+
import { connect } from "node:tls";
|
|
7
|
+
import { tlsFacts } from "../facts/transport.js";
|
|
8
|
+
import { log } from "../logger.js";
|
|
9
|
+
import { delay, reason } from "./fetch.js";
|
|
10
|
+
import { guardedLookup, isPrivate, PrivateAddress } from "./guard.js";
|
|
11
|
+
const ATTEMPTS = 2;
|
|
12
|
+
const TIMEOUT_MS = 10_000;
|
|
13
|
+
const ALPN = ["h2", "http/1.1"];
|
|
14
|
+
// One handshake to `host` at `address`, read and closed; the certificate is judged, never enforced.
|
|
15
|
+
async function handshake(host, port, address, isPrivateAllowed) {
|
|
16
|
+
if (!isPrivateAllowed && address && isPrivate(address))
|
|
17
|
+
throw new PrivateAddress(`${address} is a private address`);
|
|
18
|
+
const servername = isIP(host) === 0 ? host : undefined;
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
const socket = connect({ host: address ?? host, port, ...(servername && { servername }), ALPNProtocols: ALPN, rejectUnauthorized: false, timeout: TIMEOUT_MS, ...(!isPrivateAllowed && { lookup: guardedLookup }) });
|
|
21
|
+
socket.once("secureConnect", () => {
|
|
22
|
+
resolve(tlsFacts(socket));
|
|
23
|
+
socket.end();
|
|
24
|
+
});
|
|
25
|
+
socket.once("timeout", () => socket.destroy(new Error(`TLS handshake with ${host}:${port} timed out`)));
|
|
26
|
+
socket.once("error", reject);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
// Handshakes once per (host, port, address) a browser crawl connected to, for the cipher, ALPN, SAN and fingerprint Chromium does not report.
|
|
30
|
+
export class TlsProber {
|
|
31
|
+
#answers = new Map();
|
|
32
|
+
#isPrivateAllowed;
|
|
33
|
+
constructor(isPrivateAllowed) {
|
|
34
|
+
this.#isPrivateAllowed = isPrivateAllowed;
|
|
35
|
+
}
|
|
36
|
+
// One probe with a retry after a failure other than a refused address.
|
|
37
|
+
async #retrying(host, port, address) {
|
|
38
|
+
for (let attempt = 0;; attempt += 1) {
|
|
39
|
+
try {
|
|
40
|
+
const facts = await handshake(host, port, address, this.#isPrivateAllowed);
|
|
41
|
+
log.debug({ host, port, address, attempt, protocol: facts?.protocol, alpn: facts?.alpn }, "TLS probed");
|
|
42
|
+
return facts;
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
const isLast = attempt === ATTEMPTS - 1 || error instanceof PrivateAddress;
|
|
46
|
+
log[isLast ? "warn" : "debug"]({ host, port, address, attempt, error: reason(error) }, "TLS probe failed");
|
|
47
|
+
if (isLast)
|
|
48
|
+
return undefined;
|
|
49
|
+
await sleep(delay(attempt));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// Handshakes sent, one per distinct key.
|
|
54
|
+
get probes() {
|
|
55
|
+
return this.#answers.size;
|
|
56
|
+
}
|
|
57
|
+
// The TLS facts of `url`’s host at `address`, undefined off `https:` or once both attempts fail.
|
|
58
|
+
facts(url, address) {
|
|
59
|
+
if (url.protocol !== "https:")
|
|
60
|
+
return Promise.resolve(undefined);
|
|
61
|
+
const port = Number(url.port || 443);
|
|
62
|
+
const host = url.hostname.replaceAll(/^\[|\]$/g, "");
|
|
63
|
+
const key = `${host}:${port}@${address ?? ""}`;
|
|
64
|
+
const known = this.#answers.get(key);
|
|
65
|
+
if (known)
|
|
66
|
+
return known;
|
|
67
|
+
const answer = this.#retrying(host, port, address);
|
|
68
|
+
this.#answers.set(key, answer);
|
|
69
|
+
return answer;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { isIP } from "node:net";
|
|
5
|
+
import { log } from "../logger.js";
|
|
6
|
+
import { cookieFacts, redirectHop } from "./transport.js";
|
|
7
|
+
const DAY = 86_400_000;
|
|
8
|
+
// Playwright resource types that map onto a resource kind; the main document is never one.
|
|
9
|
+
const KINDS = { script: "script", stylesheet: "style", image: "image", font: "font", document: "iframe", manifest: "manifest" };
|
|
10
|
+
// Where the init script keeps each `document.cookie` write for the crawler to read back.
|
|
11
|
+
const WRITES = "__spiderlintCookieWrites";
|
|
12
|
+
// Wraps the `document.cookie` setter before any page script runs, keeping each write with its value cut out.
|
|
13
|
+
export const COOKIE_WRITES = `(() => { const cookie = Object.getOwnPropertyDescriptor(Document.prototype, "cookie"); if (!cookie?.get || !cookie.set) return; const writes = []; Object.defineProperty(globalThis, "${WRITES}", { value: writes }); Object.defineProperty(Document.prototype, "cookie", { configurable: true, enumerable: cookie.enumerable, get: cookie.get, set(value) { writes.push(String(value).replace(/^([^=;]*)=[^;]*/, "$1=")); cookie.set.call(this, value); } }); })();`;
|
|
14
|
+
// Cookies the page’s scripts wrote through `document.cookie`, read as Set-Cookie lines, deletions aside; values never leave the page.
|
|
15
|
+
export async function scriptCookies(page) {
|
|
16
|
+
const writes = (await page.evaluate(`globalThis.${WRITES} ?? []`));
|
|
17
|
+
const cookies = cookieFacts([...new Set(writes)]).filter((cookie) => (cookie["max-age"] ?? 1) > 0);
|
|
18
|
+
log.debug({ url: page.url(), writes: writes.length, cookies: cookies.length }, "script cookie writes read");
|
|
19
|
+
return cookies;
|
|
20
|
+
}
|
|
21
|
+
// Header pairs as the facts document holds them: lower-cased names, repeated ones as arrays.
|
|
22
|
+
export function headerFacts(pairs) {
|
|
23
|
+
const out = {};
|
|
24
|
+
for (const { name, value } of pairs) {
|
|
25
|
+
const key = name.toLowerCase();
|
|
26
|
+
const had = out[key];
|
|
27
|
+
out[key] = had === undefined ? value : [had, value].flat();
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
// Milliseconds between two Resource Timing marks, absent when either was not reached.
|
|
32
|
+
function span(from, to) {
|
|
33
|
+
return from >= 0 && to >= from ? Math.round(to - from) : undefined;
|
|
34
|
+
}
|
|
35
|
+
// Chromium’s Resource Timing marks renamed to the phases got reports.
|
|
36
|
+
export function timingFacts(timing) {
|
|
37
|
+
const isSecure = timing.secureConnectionStart >= 0;
|
|
38
|
+
const phases = {
|
|
39
|
+
dns: span(timing.domainLookupStart, timing.domainLookupEnd),
|
|
40
|
+
tcp: span(timing.connectStart, isSecure ? timing.secureConnectionStart : timing.connectEnd),
|
|
41
|
+
tls: isSecure ? span(timing.secureConnectionStart, timing.connectEnd) : undefined,
|
|
42
|
+
ttfb: span(timing.requestStart, timing.responseStart),
|
|
43
|
+
download: span(timing.responseStart, timing.responseEnd),
|
|
44
|
+
total: span(0, timing.responseEnd),
|
|
45
|
+
};
|
|
46
|
+
return Object.fromEntries(Object.entries(phases).filter(([, value]) => value !== undefined));
|
|
47
|
+
}
|
|
48
|
+
// Every URL the navigation passed through after the first, with the status and headers of the response that sent it there.
|
|
49
|
+
export async function redirectFacts(request) {
|
|
50
|
+
const hops = [];
|
|
51
|
+
for (let hop = request, from = hop.redirectedFrom(); from; hop = from, from = from.redirectedFrom()) {
|
|
52
|
+
const response = await from.response();
|
|
53
|
+
const headers = response ? headerFacts(await response.headersArray()) : undefined;
|
|
54
|
+
hops.unshift(response && headers ? redirectHop(hop.url(), response.status(), headers) : { url: hop.url() });
|
|
55
|
+
}
|
|
56
|
+
return hops;
|
|
57
|
+
}
|
|
58
|
+
// The address Chromium connected to, with the family Node’s sockets report.
|
|
59
|
+
export function remoteFacts(address) {
|
|
60
|
+
if (!address)
|
|
61
|
+
return undefined;
|
|
62
|
+
const ip = address.ipAddress.replaceAll(/^\[|\]$/g, "");
|
|
63
|
+
const version = isIP(ip);
|
|
64
|
+
return { address: ip, ...(version > 0 && { family: `IPv${version}` }) };
|
|
65
|
+
}
|
|
66
|
+
// Chromium’s `TLS 1.3` spelled as Node’s `TLSv1.3`; QUIC always runs TLS 1.3 (RFC 9001 §4.2).
|
|
67
|
+
function protocolName(protocol) {
|
|
68
|
+
return protocol === "QUIC" ? "TLSv1.3" : protocol.replace(/^TLS (\d\.\d)$/, "TLSv$1");
|
|
69
|
+
}
|
|
70
|
+
// A navigation Chromium completed was authorized; cipher, ALPN and fingerprint stay absent, the protocol does not say them.
|
|
71
|
+
export function tlsFacts(details, now = Date.now()) {
|
|
72
|
+
if (!details?.protocol)
|
|
73
|
+
return undefined;
|
|
74
|
+
const notBefore = details.validFrom === undefined ? undefined : new Date(details.validFrom * 1000).toISOString();
|
|
75
|
+
const notAfter = details.validTo === undefined ? undefined : new Date(details.validTo * 1000).toISOString();
|
|
76
|
+
return {
|
|
77
|
+
protocol: protocolName(details.protocol),
|
|
78
|
+
authorized: true,
|
|
79
|
+
cert: {
|
|
80
|
+
...(details.subjectName && { subject: details.subjectName }),
|
|
81
|
+
...(details.issuer && { issuer: details.issuer }),
|
|
82
|
+
...(notBefore && { "not-before": notBefore }),
|
|
83
|
+
...(notAfter && { "not-after": notAfter, "days-left": Math.floor((Date.parse(notAfter) - now) / DAY) }),
|
|
84
|
+
san: [],
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
// What the network log adds to the static census: observed entries flagged, runtime-only ones appended, at most `max`.
|
|
89
|
+
export function observedResources(declared, requests, page, max) {
|
|
90
|
+
const found = new Map(declared.map((resource) => [`${resource.kind} ${resource.url}`, resource]));
|
|
91
|
+
for (const request of requests) {
|
|
92
|
+
const kind = KINDS[request.resourceType()];
|
|
93
|
+
const isMainFrame = request.frame().parentFrame() === null;
|
|
94
|
+
if (!kind || (kind === "iframe" && isMainFrame) || !URL.canParse(request.url()))
|
|
95
|
+
continue;
|
|
96
|
+
const url = new URL(request.url());
|
|
97
|
+
url.hash = "";
|
|
98
|
+
if (!/^https?:$/.test(url.protocol))
|
|
99
|
+
continue;
|
|
100
|
+
const key = `${kind} ${url.href}`;
|
|
101
|
+
const known = found.get(key);
|
|
102
|
+
found.set(key, { ...(known ?? { url: url.href, kind, origin: url.origin === page.origin ? "same" : "cross" }), observed: true });
|
|
103
|
+
}
|
|
104
|
+
return found.values().take(max).toArray();
|
|
105
|
+
}
|
|
106
|
+
// Body bytes on the wire; Chromium’s size goes negative on some responses, where Content-Length stands in, else `fallback`.
|
|
107
|
+
export async function wireSize(request, fallback = 0) {
|
|
108
|
+
const { responseBodySize } = await request.sizes();
|
|
109
|
+
if (responseBodySize >= 0)
|
|
110
|
+
return responseBodySize;
|
|
111
|
+
const response = await request.response();
|
|
112
|
+
const declared = Number((await response?.headerValue("content-length")) ?? NaN);
|
|
113
|
+
log.debug({ url: request.url(), responseBodySize, declared, fallback }, "wire size unknown, using Content-Length or fallback");
|
|
114
|
+
return Number.isSafeInteger(declared) ? declared : fallback;
|
|
115
|
+
}
|
|
116
|
+
// Bytes the network log received per resource kind, keyed as `browser.weight` names them; failed requests weigh nothing.
|
|
117
|
+
export async function weightFacts(requests) {
|
|
118
|
+
const weight = {};
|
|
119
|
+
for (const request of requests) {
|
|
120
|
+
const kind = KINDS[request.resourceType()];
|
|
121
|
+
if (request.failure() || (kind !== "script" && kind !== "style" && kind !== "image" && kind !== "font"))
|
|
122
|
+
continue;
|
|
123
|
+
weight[kind] = (weight[kind] ?? 0) + (await wireSize(request));
|
|
124
|
+
}
|
|
125
|
+
return weight;
|
|
126
|
+
}
|
|
127
|
+
// HTTP version per protocol Chromium’s network log names.
|
|
128
|
+
const HOP_VERSIONS = { h3: "3.0", h2: "2.0", "http/1.1": "1.1" };
|
|
129
|
+
// Chromium’s TLS completed by the probe when both saw one certificate, and the HTTP version: the measured hop, else QUIC is 3, the probe’s ALPN, plain text 1.1.
|
|
130
|
+
export function withProbe(url, seen, security, probed, hop) {
|
|
131
|
+
if (security === undefined)
|
|
132
|
+
return { ...(seen && { tls: seen }), version: HOP_VERSIONS[hop ?? ""] ?? "1.1" };
|
|
133
|
+
const isSame = probed !== undefined && (seen === undefined || (seen.cert.subject === probed.cert.subject && seen.cert["not-after"] === probed.cert["not-after"]));
|
|
134
|
+
log.debug({ url, security, hop, isProbed: probed !== undefined, isSame, subject: seen?.cert.subject, probedSubject: probed?.cert.subject }, "probed TLS compared");
|
|
135
|
+
const tls = isSame ? { ...probed, ...(seen?.protocol && { protocol: seen.protocol }) } : seen;
|
|
136
|
+
const version = HOP_VERSIONS[hop ?? ""] ?? (security === "QUIC" ? "3.0" : isSame ? (probed.alpn === "h2" ? "2.0" : "1.1") : undefined);
|
|
137
|
+
return { ...(tls && { tls }), ...(version && { version }) };
|
|
138
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { log } from "../logger.js";
|
|
5
|
+
const isNode = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6
|
+
// Every JSON-LD node, through arrays and `@graph`, in document order.
|
|
7
|
+
const nodesOf = (value) => (Array.isArray(value) ? value.flatMap((item) => nodesOf(item)) : isNode(value) ? [value, ...nodesOf(value["@graph"])] : []);
|
|
8
|
+
// A schema.org `author` as a name: a string, a node’s `name`, or the first of a list.
|
|
9
|
+
const nameOf = (value) => (typeof value === "string" ? value : Array.isArray(value) ? nameOf(value[0]) : isNode(value) && typeof value.name === "string" ? value.name : undefined);
|
|
10
|
+
// Author and publication date the way a link preview resolves them: JSON-LD first, then the head tags.
|
|
11
|
+
export function bylineFacts(html) {
|
|
12
|
+
const nodes = nodesOf(html.jsonld);
|
|
13
|
+
const author = nodes.map((node) => nameOf(node.author)).find(Boolean) ?? html.property["article:author"] ?? html.meta.author ?? html.property["og:author"] ?? html.meta["og:author"];
|
|
14
|
+
const published = nodes.map((node) => node.datePublished).find((value) => typeof value === "string") ?? html.property["article:published_time"];
|
|
15
|
+
log.debug({ nodes: nodes.length, author, published }, "byline resolved");
|
|
16
|
+
return { ...(author?.trim() && { author }), ...(published?.trim() && { published }) };
|
|
17
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { log } from "../logger.js";
|
|
5
|
+
// The model every estimate names, so two runs compare: Sustainable Web Design v4 through CO2.js 0.19.0.
|
|
6
|
+
const MODEL = { model: "swd", version: 4, library: "@tgwf/co2 0.19.0" };
|
|
7
|
+
// The estimator once loaded; absent until a run needs it.
|
|
8
|
+
const loaded = {};
|
|
9
|
+
// Loads CO2.js once, only for a run whose rules read `co2`.
|
|
10
|
+
export async function loadEstimator() {
|
|
11
|
+
const module = loaded.estimator ? undefined : await import("@tgwf/co2");
|
|
12
|
+
if (module)
|
|
13
|
+
loaded.estimator = new module.co2({ model: MODEL.model, version: MODEL.version, rating: true });
|
|
14
|
+
log.debug({ ...MODEL, isFresh: module !== undefined }, "co2 estimator loaded");
|
|
15
|
+
}
|
|
16
|
+
// Grams of CO2e per view of a page: its own transfer size plus each distinct resource it loaded, hosting counted as not green until the green check lands.
|
|
17
|
+
export function co2Facts(page) {
|
|
18
|
+
if (!loaded.estimator)
|
|
19
|
+
return undefined;
|
|
20
|
+
const sizes = new Map((page.resources ?? []).flatMap((resource) => (resource.http?.size.body === undefined ? [] : [[resource.url, resource.http.size.body]])));
|
|
21
|
+
const resources = sizes.values().reduce((sum, bytes) => sum + bytes, 0);
|
|
22
|
+
const bytes = page.http.size.body + resources;
|
|
23
|
+
const { total, rating } = loaded.estimator.perVisit(bytes, false);
|
|
24
|
+
const grams = Math.round(total * 10_000) / 10_000;
|
|
25
|
+
log.debug({ url: page.url.href, bytes, resources: sizes.size, grams, rating }, "co2 estimated");
|
|
26
|
+
return { ...MODEL, bytes, resources: sizes.size, grams, rating };
|
|
27
|
+
}
|