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,200 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { X509Certificate } from "node:crypto";
|
|
5
|
+
import { log } from "../logger.js";
|
|
6
|
+
const DAY = 86_400_000;
|
|
7
|
+
// OpenSSL curve names to the NIST ones a certificate policy speaks.
|
|
8
|
+
const CURVES = { prime256v1: "P-256", secp384r1: "P-384", secp521r1: "P-521" };
|
|
9
|
+
const REDACTED = "[redacted]";
|
|
10
|
+
const SECRET = new Set(["authorization", "proxy-authorization", "cookie"]);
|
|
11
|
+
// got's phase names to the facts document's.
|
|
12
|
+
const PHASES = { wait: "wait", dns: "dns", tcp: "tcp", tls: "tls", request: "request", firstByte: "ttfb", download: "download", total: "total" };
|
|
13
|
+
// Measured phases in milliseconds; a phase the connection skipped stays absent.
|
|
14
|
+
export function timingFacts(transport) {
|
|
15
|
+
const timing = {};
|
|
16
|
+
const phases = Object.entries(transport.timings?.phases ?? {});
|
|
17
|
+
for (const [phase, value] of phases) {
|
|
18
|
+
const name = PHASES[phase];
|
|
19
|
+
if (name && value !== undefined)
|
|
20
|
+
timing[name] = value;
|
|
21
|
+
}
|
|
22
|
+
return timing;
|
|
23
|
+
}
|
|
24
|
+
// A got `beforeRequest` hook wrapping the request function got-scraping chose, so each 103 of the last hop lands in `hints`.
|
|
25
|
+
export function earlyHintsHook(url, hints) {
|
|
26
|
+
const listen = (request) => request.on("information", (info) => {
|
|
27
|
+
const link = info.headers?.link;
|
|
28
|
+
log.debug({ url, status: info.statusCode, link }, "informational response received");
|
|
29
|
+
if (info.statusCode === 103)
|
|
30
|
+
hints.push({ ...(link && { link: [link].flat().join(", ") }) });
|
|
31
|
+
});
|
|
32
|
+
return (options) => {
|
|
33
|
+
hints.length = 0;
|
|
34
|
+
const inner = options.getRequestFunction();
|
|
35
|
+
options.request = (target, native, callback) => {
|
|
36
|
+
const made = inner(target, native, callback);
|
|
37
|
+
if (made instanceof Promise)
|
|
38
|
+
void made.then(listen);
|
|
39
|
+
else
|
|
40
|
+
listen(made);
|
|
41
|
+
return made;
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
// Seconds a cookie lives: `Max-Age` first, else `Expires` against `now`, undefined for a session cookie (RFC 6265 §5.3).
|
|
46
|
+
function lifetime(flags, now) {
|
|
47
|
+
const maxAge = Number(flags.get("max-age") || NaN);
|
|
48
|
+
if (Number.isSafeInteger(maxAge))
|
|
49
|
+
return maxAge;
|
|
50
|
+
const expires = Date.parse(flags.get("expires") ?? "");
|
|
51
|
+
return Number.isNaN(expires) ? undefined : Math.round((expires - now) / 1000);
|
|
52
|
+
}
|
|
53
|
+
// Name and flags of each Set-Cookie; values never leave this function.
|
|
54
|
+
export function cookieFacts(setCookie, date) {
|
|
55
|
+
const now = Date.parse(String(date)) || Date.now();
|
|
56
|
+
const lines = setCookie === undefined ? [] : [setCookie].flat();
|
|
57
|
+
return lines.map((line) => {
|
|
58
|
+
const [pair = "", ...attributes] = line.split(";").map((part) => part.trim());
|
|
59
|
+
const flags = new Map(attributes.map((attribute) => {
|
|
60
|
+
const [key = "", value] = attribute.split("=", 2);
|
|
61
|
+
return [key.toLowerCase(), value?.trim()];
|
|
62
|
+
}));
|
|
63
|
+
const sameSite = flags.get("samesite");
|
|
64
|
+
const path = flags.get("path");
|
|
65
|
+
const domain = flags.get("domain");
|
|
66
|
+
const maxAge = lifetime(flags, now);
|
|
67
|
+
return { name: pair.split("=", 1)[0] ?? "", secure: flags.has("secure"), "http-only": flags.has("httponly"), ...(sameSite && { "same-site": sameSite }), ...(path !== undefined && { path }), ...(domain !== undefined && { domain }), ...(maxAge !== undefined && { "max-age": maxAge }) };
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
// Cache-status headers a CDN sets, each to a pattern its hit value matches.
|
|
71
|
+
const CACHE_HITS = [
|
|
72
|
+
["cf-cache-status", /^(?:hit|stale|revalidated|updating)$/i],
|
|
73
|
+
["x-cache", /\bhit\b/i],
|
|
74
|
+
["x-vercel-cache", /^(?:hit|stale|prerender)$/i],
|
|
75
|
+
["cdn-cache", /^hit$/i],
|
|
76
|
+
["cache-status", /;\s*hit\b/i],
|
|
77
|
+
];
|
|
78
|
+
// Seconds the response `Date`, taken mid-second, runs ahead of our clock at the midpoint of `sent` and `firstByte`, or undefined when absent, unparsable or served from a cache.
|
|
79
|
+
export function dateSkew(url, headers, sent, firstByte) {
|
|
80
|
+
const date = Date.parse(String([headers.date].flat()[0] ?? ""));
|
|
81
|
+
const age = Number([headers.age].flat()[0] ?? 0);
|
|
82
|
+
const hit = CACHE_HITS.find(([name, pattern]) => pattern.test(String([headers[name]].flat()[0] ?? "")))?.[0];
|
|
83
|
+
const isCached = age > 0 || hit !== undefined;
|
|
84
|
+
log.debug({ url, date: headers.date, age, hit, sent, firstByte }, "response date read");
|
|
85
|
+
const isMeasured = sent !== undefined && firstByte !== undefined && !isCached && !Number.isNaN(date);
|
|
86
|
+
return isMeasured ? Math.round((date + 500 - (sent + firstByte) / 2) / 1000) : undefined;
|
|
87
|
+
}
|
|
88
|
+
// One redirect hop to `url`, from the status and raw headers of the response that sent it there.
|
|
89
|
+
export function redirectHop(url, status, raw) {
|
|
90
|
+
const headers = redactHeaders(raw);
|
|
91
|
+
const by = [headers["x-redirect-by"] ?? headers["redirect-by"]].flat()[0];
|
|
92
|
+
log.debug({ url, status, by }, "redirect hop recorded");
|
|
93
|
+
return { url, status, headers, ...(by && { by }) };
|
|
94
|
+
}
|
|
95
|
+
// A got `beforeRedirect` hook appending each hop the request follows to `hops`.
|
|
96
|
+
export function redirectHook(hops) {
|
|
97
|
+
return (options, response) => {
|
|
98
|
+
hops.push(redirectHop(String(options.url), response.statusCode, response.headers));
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
// Response headers with credentials and cookie values replaced, and HTTP/2 pseudo-headers dropped, before anything is stored or reported.
|
|
102
|
+
export function redactHeaders(headers) {
|
|
103
|
+
const out = {};
|
|
104
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
105
|
+
if (value === undefined || name.startsWith(":"))
|
|
106
|
+
continue;
|
|
107
|
+
if (SECRET.has(name))
|
|
108
|
+
out[name] = REDACTED;
|
|
109
|
+
else if (name === "set-cookie")
|
|
110
|
+
out[name] = [value].flat().map((line) => line.replace(/^([^=;]*)=[^;]*/, (_pair, name) => `${name}=${REDACTED}`));
|
|
111
|
+
else
|
|
112
|
+
out[name] = value;
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
// `DNS:a, IP Address:b` to `[a, b]`.
|
|
117
|
+
function subjectAltNames(raw) {
|
|
118
|
+
return (raw ?? "")
|
|
119
|
+
.split(",")
|
|
120
|
+
.map((entry) => entry.trim().replace(/^[^:]+:/, ""))
|
|
121
|
+
.filter((entry) => entry.length > 0);
|
|
122
|
+
}
|
|
123
|
+
function isoDate(raw) {
|
|
124
|
+
const time = raw ? Date.parse(raw) : NaN;
|
|
125
|
+
return Number.isNaN(time) ? undefined : new Date(time).toISOString();
|
|
126
|
+
}
|
|
127
|
+
// The leaf's public key as type, size and curve; absent when the certificate does not parse.
|
|
128
|
+
function keyFacts(raw) {
|
|
129
|
+
if (!raw?.length)
|
|
130
|
+
return undefined;
|
|
131
|
+
try {
|
|
132
|
+
const key = new X509Certificate(raw).publicKey;
|
|
133
|
+
const { modulusLength, namedCurve } = key.asymmetricKeyDetails ?? {};
|
|
134
|
+
const type = key.asymmetricKeyType === "ec" ? "EC" : key.asymmetricKeyType?.startsWith("rsa") ? "RSA" : key.asymmetricKeyType === "ed25519" ? "Ed25519" : String(key.asymmetricKeyType);
|
|
135
|
+
return { type, ...(modulusLength && { bits: modulusLength }), ...(namedCurve && { curve: CURVES[namedCurve] ?? namedCurve }) };
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
log.debug({ error: String(error) }, "peer certificate key unreadable");
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// Signature algorithms from the leaf up to, not including, a self-signed root.
|
|
143
|
+
function signatureFacts(leaf) {
|
|
144
|
+
const algorithms = [];
|
|
145
|
+
try {
|
|
146
|
+
for (let cert = leaf; cert?.raw?.length && algorithms.length < 10; cert = cert.issuerCertificate) {
|
|
147
|
+
const x509 = new X509Certificate(cert.raw);
|
|
148
|
+
if (cert !== leaf && x509.checkIssued(x509))
|
|
149
|
+
break;
|
|
150
|
+
algorithms.push(x509.signatureAlgorithm ?? "unknown");
|
|
151
|
+
if (cert.issuerCertificate === cert)
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
log.debug({ error: String(error), read: algorithms.length }, "peer certificate chain unreadable");
|
|
157
|
+
}
|
|
158
|
+
return algorithms;
|
|
159
|
+
}
|
|
160
|
+
// This connection's TLS observation; absent on a plain-text socket.
|
|
161
|
+
export function tlsFacts(socket, now = Date.now()) {
|
|
162
|
+
if (typeof socket?.getPeerCertificate !== "function")
|
|
163
|
+
return undefined;
|
|
164
|
+
const cert = socket.getPeerCertificate(true);
|
|
165
|
+
const key = keyFacts(cert.raw);
|
|
166
|
+
const notAfter = isoDate(cert.valid_to);
|
|
167
|
+
const error = socket.authorizationError;
|
|
168
|
+
return {
|
|
169
|
+
...(socket.getProtocol() && { protocol: socket.getProtocol() }),
|
|
170
|
+
...(socket.getCipher()?.name && { cipher: socket.getCipher().name }),
|
|
171
|
+
...(socket.alpnProtocol && { alpn: socket.alpnProtocol }),
|
|
172
|
+
authorized: socket.authorized,
|
|
173
|
+
...(error && { error: String(error) }),
|
|
174
|
+
cert: {
|
|
175
|
+
...(cert.subject?.CN && { subject: String(cert.subject.CN) }),
|
|
176
|
+
...((cert.issuer?.O ?? cert.issuer?.CN) && { issuer: String(cert.issuer.O ?? cert.issuer.CN) }),
|
|
177
|
+
...(isoDate(cert.valid_from) && { "not-before": isoDate(cert.valid_from) }),
|
|
178
|
+
...(notAfter && { "not-after": notAfter, "days-left": Math.floor((Date.parse(notAfter) - now) / DAY) }),
|
|
179
|
+
san: subjectAltNames(cert.subjectaltname),
|
|
180
|
+
...(cert.fingerprint256 && { fingerprint256: cert.fingerprint256 }),
|
|
181
|
+
...(key && { key }),
|
|
182
|
+
...(cert.raw?.length && { signatures: signatureFacts(cert) }),
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
// Raises to HTTP/3 a page a cold TCP connection carried while it advertises h3 for its own authority on an origin QUIC already served.
|
|
187
|
+
export function servedVersions(pages) {
|
|
188
|
+
const quic = new Set(pages.filter((page) => page.http.version === "3.0").map((page) => page.url.origin));
|
|
189
|
+
for (const page of pages) {
|
|
190
|
+
if (page.http.version === undefined || page.http.version === "3.0" || !quic.has(page.url.origin))
|
|
191
|
+
continue;
|
|
192
|
+
const { hostname, port } = new URL(page.url.href);
|
|
193
|
+
const own = new Set([`:${port || "443"}`, `${hostname}:${port || "443"}`]);
|
|
194
|
+
const offers = page.http.parsed?.["alt-svc"]?.value;
|
|
195
|
+
const isAdvertised = Array.isArray(offers) && offers.some((offer) => offer.protocol === "h3" && own.has(offer.authority ?? ""));
|
|
196
|
+
log.debug({ url: page.url.href, observed: page.http.version, isAdvertised }, "http version of a page carried over tcp");
|
|
197
|
+
if (isAdvertised)
|
|
198
|
+
page.http.version = "3.0";
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import picomatch from "picomatch";
|
|
6
|
+
import { parse } from "yaml";
|
|
7
|
+
import { log } from "../logger.js";
|
|
8
|
+
const FILE = new URL("../../vendors/paths.yaml", import.meta.url);
|
|
9
|
+
const PROVIDERS = new URL("../../vendors/dns.yaml", import.meta.url);
|
|
10
|
+
const cache = {};
|
|
11
|
+
// The provider every one of `names` belongs to; none when they span providers, or one serves none of them.
|
|
12
|
+
export function dnsProvider(names) {
|
|
13
|
+
cache.providers ??= parse(readFileSync(PROVIDERS, "utf8")).map((entry) => ({ ...entry, isMatch: picomatch(entry.match) }));
|
|
14
|
+
const found = new Set(names.map((name) => cache.providers?.find((entry) => entry.isMatch(name.toLowerCase().replace(/\.$/, "")))));
|
|
15
|
+
const [entry] = found;
|
|
16
|
+
log.debug({ names, providers: [...found].map((candidate) => candidate?.provider) }, "dns provider matched");
|
|
17
|
+
return found.size === 1 ? entry : undefined;
|
|
18
|
+
}
|
|
19
|
+
// The shipped list, read and compiled once.
|
|
20
|
+
export function vendorPaths() {
|
|
21
|
+
if (cache.paths)
|
|
22
|
+
return cache.paths;
|
|
23
|
+
cache.paths = parse(readFileSync(FILE, "utf8")).map((entry) => ({ ...entry, isMatch: picomatch(entry.match, { dot: true }) }));
|
|
24
|
+
log.debug({ entries: cache.paths.length }, "vendor paths loaded");
|
|
25
|
+
return cache.paths;
|
|
26
|
+
}
|
|
27
|
+
// The first entry whose glob matches the URL’s path, when it is of `kind`.
|
|
28
|
+
export function vendorPath(href, kind) {
|
|
29
|
+
if (!URL.canParse(href))
|
|
30
|
+
return undefined;
|
|
31
|
+
const entry = vendorPaths().find((candidate) => candidate.isMatch(new URL(href).pathname));
|
|
32
|
+
return entry?.kind.includes(kind) ? entry : undefined;
|
|
33
|
+
}
|
|
34
|
+
// Each page’s anchors into a vendor path as `html.links.vendor`, and each origin’s detected vendor features as `site.origins.*.vendor`; `isOn` false clears both.
|
|
35
|
+
export function vendorFacts(pages, site, isOn) {
|
|
36
|
+
const byOrigin = new Map();
|
|
37
|
+
for (const page of pages) {
|
|
38
|
+
if (page.html)
|
|
39
|
+
delete page.html.links.vendor;
|
|
40
|
+
if (!isOn)
|
|
41
|
+
continue;
|
|
42
|
+
const anchors = page.html ? [...page.html.links.internal, ...page.html.links.external] : [];
|
|
43
|
+
const linked = anchors.flatMap((href) => {
|
|
44
|
+
const entry = vendorPath(href, "page");
|
|
45
|
+
return entry ? [{ href, entry }] : [];
|
|
46
|
+
});
|
|
47
|
+
const loads = [...(page.resources ?? []).map((resource) => resource.url), ...(page.html?.scripts ?? []).flatMap((script) => script.src ?? [])];
|
|
48
|
+
const loaded = loads.flatMap((href) => vendorPath(href, "resource") ?? []);
|
|
49
|
+
if (page.html && linked.length > 0)
|
|
50
|
+
page.html.links.vendor = Object.fromEntries(linked.map(({ entry, href }) => [href, entry.vendor]));
|
|
51
|
+
const features = [...linked.map(({ entry }) => entry), ...loaded].flatMap((entry) => entry.feature ?? []);
|
|
52
|
+
if (features.length > 0)
|
|
53
|
+
byOrigin.set(page.url.origin, new Set([...(byOrigin.get(page.url.origin) ?? []), ...features]));
|
|
54
|
+
log.debug({ url: page.url.href, links: linked.length, resources: loaded.length, features }, "vendor paths found");
|
|
55
|
+
}
|
|
56
|
+
const origins = Object.values(site.origins ?? {});
|
|
57
|
+
for (const facts of origins)
|
|
58
|
+
delete facts.vendor;
|
|
59
|
+
for (const [origin, features] of byOrigin)
|
|
60
|
+
site.origins = { ...site.origins, [origin]: { ...site.origins?.[origin], vendor: { features: [...features].toSorted((a, b) => a.localeCompare(b)) } } };
|
|
61
|
+
log.debug({ isOn, origins: Object.fromEntries([...byOrigin].map(([origin, features]) => [origin, [...features]])) }, "vendor features detected");
|
|
62
|
+
}
|
|
63
|
+
// Site findings about a vendor’s resource carry the vendor, so formatters list them apart from the site’s own.
|
|
64
|
+
export function attributeVendors(findings, isOn) {
|
|
65
|
+
if (!isOn)
|
|
66
|
+
return;
|
|
67
|
+
const attributed = findings
|
|
68
|
+
.filter((finding) => finding.scope === "site")
|
|
69
|
+
.flatMap((finding) => {
|
|
70
|
+
const entry = vendorPath(finding.url, "resource");
|
|
71
|
+
if (entry)
|
|
72
|
+
finding.vendor = entry.vendor;
|
|
73
|
+
return entry ? [finding.rule] : [];
|
|
74
|
+
});
|
|
75
|
+
log.debug({ findings: attributed.length, rules: [...new Set(attributed)] }, "vendor findings attributed");
|
|
76
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
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
|
+
import { said } from "../rules/message.js";
|
|
6
|
+
import { cell } from "../rules/run.js";
|
|
7
|
+
import { levelOf, round, scoreOf } from "../rules/score.js";
|
|
8
|
+
const HETEROGENEOUS = 0.2;
|
|
9
|
+
// The guide of the advisory folding raises, which no ruleset carries.
|
|
10
|
+
export const HETEROGENEOUS_GUIDE = { facts: ["group"], fix: "Split the group’s `match` so each template gets its own group, then check with `spiderlint list-groups`." };
|
|
11
|
+
// What a group’s result says: rule, sentence and locations, when every finding in it says the same; else none, and it never merges.
|
|
12
|
+
function signature(findings) {
|
|
13
|
+
const said = new Set(findings.map((finding) => JSON.stringify([finding.rule, finding.text ?? finding.message, finding.variables ?? {}, finding.locations ?? []])));
|
|
14
|
+
return said.size === 1 ? said.values().next().value : undefined;
|
|
15
|
+
}
|
|
16
|
+
// The fold of one group’s findings: its samples, their locations and data, the worst score.
|
|
17
|
+
function folded(findings, group, failed, applicable, isSampled) {
|
|
18
|
+
const samples = [...new Set(findings.map((finding) => finding.url))].slice(0, 3);
|
|
19
|
+
const first = findings[0];
|
|
20
|
+
const located = samples.map((url) => [url, findings.filter((finding) => finding.url === url).flatMap((finding) => finding.locations ?? [])]).filter(([, locations]) => locations.length > 0);
|
|
21
|
+
const { locations: _locations, data: _data, evidence: _evidence, ...shared } = first;
|
|
22
|
+
const score = round(Math.max(...findings.map((finding) => scoreOf(finding))));
|
|
23
|
+
const kept = findings.filter((finding) => samples.includes(finding.url));
|
|
24
|
+
const data = Object.assign({}, ...kept.map((finding) => finding.data ?? {}));
|
|
25
|
+
const evidence = new Map(kept.flatMap((finding) => finding.evidence ?? []).map((read) => [`${read.bucket}:${read.key}`, read])).values().toArray();
|
|
26
|
+
return {
|
|
27
|
+
...shared,
|
|
28
|
+
group,
|
|
29
|
+
severity: levelOf(score),
|
|
30
|
+
score,
|
|
31
|
+
scope: "group",
|
|
32
|
+
occurrences: failed,
|
|
33
|
+
coverage: Number((failed / applicable).toFixed(2)),
|
|
34
|
+
samples,
|
|
35
|
+
url: samples[0],
|
|
36
|
+
...(isSampled && { sampled: applicable }),
|
|
37
|
+
...(located.length > 0 && { sampleLocations: Object.fromEntries(located) }),
|
|
38
|
+
...(Object.keys(data).length > 0 && { data }),
|
|
39
|
+
...(evidence.length > 0 && { evidence }),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
// Groups failing a rule whole with the same result become one site finding naming them; a result one group alone shows stays its own.
|
|
43
|
+
function merged(results) {
|
|
44
|
+
const [only] = results;
|
|
45
|
+
if (only && results.length === 1)
|
|
46
|
+
return only.folded ? [only.folded] : only.findings;
|
|
47
|
+
const findings = results.flatMap((result) => result.findings).toSorted((a, b) => a.url.localeCompare(b.url));
|
|
48
|
+
const failed = results.reduce((sum, result) => sum + result.failed, 0);
|
|
49
|
+
const applicable = results.reduce((sum, result) => sum + result.applicable, 0);
|
|
50
|
+
const { group: _group, ...shared } = folded(findings, "", failed, applicable, results.some((result) => result.isSampled));
|
|
51
|
+
return [{ ...shared, scope: "site", groups: results.map((result) => result.group) }];
|
|
52
|
+
}
|
|
53
|
+
// Per (group, rule): one template-level finding once failures saturate the group, and one site finding where several groups fail the same way.
|
|
54
|
+
export function fold(run, options) {
|
|
55
|
+
const out = run.findings.filter((finding) => finding.scope !== "page");
|
|
56
|
+
const byCell = new Map();
|
|
57
|
+
for (const finding of run.findings) {
|
|
58
|
+
if (finding.scope !== "page")
|
|
59
|
+
continue;
|
|
60
|
+
const key = cell(finding.group, finding.rule);
|
|
61
|
+
byCell.set(key, [...(byCell.get(key) ?? []), finding]);
|
|
62
|
+
}
|
|
63
|
+
const saturated = new Map();
|
|
64
|
+
for (const [key, unordered] of byCell) {
|
|
65
|
+
const [group, rule] = key.split("\t");
|
|
66
|
+
const findings = unordered.toSorted((a, b) => a.url.localeCompare(b.url));
|
|
67
|
+
const failed = new Set(findings.map((finding) => finding.url)).size;
|
|
68
|
+
const applicable = run.applicable.get(key) ?? failed;
|
|
69
|
+
const ratio = failed / applicable;
|
|
70
|
+
const isFolded = options !== false && applicable >= options.min && ratio >= options.threshold;
|
|
71
|
+
const isSampled = run.sampled?.has(key) === true;
|
|
72
|
+
const result = options === false || (!isFolded && failed < applicable) ? undefined : signature(findings);
|
|
73
|
+
log.debug({ group, rule, failed, applicable, ratio: Number(ratio.toFixed(2)), folded: isFolded, isSaturated: result !== undefined }, "fold");
|
|
74
|
+
if (result !== undefined) {
|
|
75
|
+
saturated.set(result, [...(saturated.get(result) ?? []), { group, findings, failed, applicable, isSampled, ...(isFolded && { folded: folded(findings, group, failed, applicable, isSampled) }) }]);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (isFolded) {
|
|
79
|
+
out.push(folded(findings, group, failed, applicable, isSampled));
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
out.push(...findings);
|
|
83
|
+
if (options !== false && applicable >= options.min && ratio > HETEROGENEOUS) {
|
|
84
|
+
out.push({ rule: "groups/heterogeneous", severity: "info", score: 2, scope: "group", url: findings[0]?.url, group, ...said("{rule} fails on {count} of {total} pages; the group likely spans two templates", { rule, count: failed, total: applicable }) });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
for (const [key, results] of saturated) {
|
|
88
|
+
if (results.length > 1)
|
|
89
|
+
log.debug({ key, groups: results.map((result) => result.group), findings: results.reduce((sum, result) => sum + result.findings.length, 0) }, "groups merged into one site finding");
|
|
90
|
+
out.push(...merged(results));
|
|
91
|
+
}
|
|
92
|
+
log.debug({ before: run.findings.length, after: out.length }, "fold done");
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import picomatch from "picomatch";
|
|
5
|
+
import { ConfigError } from "../config/index.js";
|
|
6
|
+
import { log } from "../logger.js";
|
|
7
|
+
// `re:` is a regex, `content-type:` a response type prefix, anything else a picomatch glob.
|
|
8
|
+
function matcher(group, entry) {
|
|
9
|
+
if (entry.startsWith("re:")) {
|
|
10
|
+
try {
|
|
11
|
+
const regex = new RegExp(entry.slice(3));
|
|
12
|
+
return (facts) => regex.test(facts.url.pathname + facts.url.search);
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
throw new ConfigError(`group ${group}: ${entry}: ${error instanceof Error ? error.message : String(error)}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
if (entry.startsWith("content-type:")) {
|
|
19
|
+
const type = entry.slice("content-type:".length).toLowerCase();
|
|
20
|
+
return (facts) => String(facts.http.headers["content-type"] ?? "")
|
|
21
|
+
.toLowerCase()
|
|
22
|
+
.startsWith(type);
|
|
23
|
+
}
|
|
24
|
+
const glob = picomatch(entry);
|
|
25
|
+
return (facts) => glob(facts.url.pathname + facts.url.search);
|
|
26
|
+
}
|
|
27
|
+
// Ordered as declared; `default` is appended when the config omits it.
|
|
28
|
+
export function compileGroups(groups) {
|
|
29
|
+
const compiled = Object.entries(groups)
|
|
30
|
+
.filter(([name]) => name !== "default")
|
|
31
|
+
.map(([name, config]) => ({ name, matchers: (config.match ?? []).map((entry) => matcher(name, entry)) }));
|
|
32
|
+
return [...compiled, { name: "default", matchers: [] }];
|
|
33
|
+
}
|
|
34
|
+
// First group with a matching entry wins; `default` catches the rest.
|
|
35
|
+
export function assignGroup(facts, groups) {
|
|
36
|
+
for (const group of groups) {
|
|
37
|
+
const matched = group.matchers.findIndex((match) => match(facts));
|
|
38
|
+
if (matched === -1)
|
|
39
|
+
continue;
|
|
40
|
+
log.debug({ url: facts.url.href, group: group.name, matched }, "group assigned");
|
|
41
|
+
return group.name;
|
|
42
|
+
}
|
|
43
|
+
log.debug({ url: facts.url.href, group: "default" }, "group assigned");
|
|
44
|
+
return "default";
|
|
45
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
import { assignGroup, compileGroups } from "./assign.js";
|
|
6
|
+
export const SAMPLE_DEFAULT = 3;
|
|
7
|
+
// Slots per (group, expensive extractor), capped by the group’s `sample`; cheap extractors are never counted.
|
|
8
|
+
export class Sampler {
|
|
9
|
+
#groups;
|
|
10
|
+
#caps;
|
|
11
|
+
#taken = new Map();
|
|
12
|
+
// Extractor IDs each group’s rules read; absent, every extractor serves every group.
|
|
13
|
+
#wanted;
|
|
14
|
+
constructor(groups, wanted) {
|
|
15
|
+
this.#groups = compileGroups(groups);
|
|
16
|
+
this.#wanted = wanted;
|
|
17
|
+
this.#caps = new Map(Object.entries(groups).map(([name, group]) => [name, group.sample === "all" ? Infinity : (group.sample ?? SAMPLE_DEFAULT)]));
|
|
18
|
+
}
|
|
19
|
+
#key(group, id) {
|
|
20
|
+
return `${group}\t${id}`;
|
|
21
|
+
}
|
|
22
|
+
groupOf(page) {
|
|
23
|
+
return assignGroup(page, this.#groups);
|
|
24
|
+
}
|
|
25
|
+
// The cap of `group`; Infinity for `sample: all`.
|
|
26
|
+
cap(group) {
|
|
27
|
+
return this.#caps.get(group) ?? SAMPLE_DEFAULT;
|
|
28
|
+
}
|
|
29
|
+
// Counts a page that already carries `id`’s facts.
|
|
30
|
+
seed(page, id) {
|
|
31
|
+
const key = this.#key(this.groupOf(page), id);
|
|
32
|
+
this.#taken.set(key, (this.#taken.get(key) ?? 0) + 1);
|
|
33
|
+
}
|
|
34
|
+
// Every cheap extractor `page`’s group reads, and each expensive one while the group has a slot left, which it reserves.
|
|
35
|
+
take(page, active) {
|
|
36
|
+
const group = this.groupOf(page);
|
|
37
|
+
const cap = this.cap(group);
|
|
38
|
+
const wanted = this.#wanted?.get(group);
|
|
39
|
+
const read = wanted ? active.filter((extractor) => wanted.has(extractor.id)) : active;
|
|
40
|
+
log.debug({ url: page.url.href, group, active: active.length, read: read.length }, "group extractors");
|
|
41
|
+
return read.filter((extractor) => {
|
|
42
|
+
if (extractor.cost !== "expensive")
|
|
43
|
+
return true;
|
|
44
|
+
const key = this.#key(group, extractor.id);
|
|
45
|
+
const taken = this.#taken.get(key) ?? 0;
|
|
46
|
+
const isTaken = taken < cap;
|
|
47
|
+
log.debug({ url: page.url.href, group, extractor: extractor.id, taken, cap, isTaken }, "sampling");
|
|
48
|
+
if (isTaken)
|
|
49
|
+
this.#taken.set(key, taken + 1);
|
|
50
|
+
return isTaken;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
// Frees the slot of each expensive extractor in `chosen` that added no facts.
|
|
54
|
+
release(page, chosen, added) {
|
|
55
|
+
const group = this.groupOf(page);
|
|
56
|
+
const unused = chosen.filter((extractor) => extractor.cost === "expensive" && !added.includes(extractor.id));
|
|
57
|
+
for (const extractor of unused)
|
|
58
|
+
this.#taken.set(this.#key(group, extractor.id), (this.#taken.get(this.#key(group, extractor.id)) ?? 1) - 1);
|
|
59
|
+
}
|
|
60
|
+
}
|
package/dist/i18n.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { po } from "gettext-parser";
|
|
6
|
+
import { log } from "./logger.js";
|
|
7
|
+
// Languages with a catalog under `locales/`, English first as the source language.
|
|
8
|
+
export const LANGUAGES = ["en", "es", "uk"];
|
|
9
|
+
const LOCALES = new URL("../locales/", import.meta.url);
|
|
10
|
+
const catalogs = new Map();
|
|
11
|
+
// The catalog of `lang`, read once; a missing or broken file is an empty catalog, so strings stay English.
|
|
12
|
+
function catalog(lang) {
|
|
13
|
+
const cached = catalogs.get(lang);
|
|
14
|
+
if (cached)
|
|
15
|
+
return cached;
|
|
16
|
+
const entries = new Map();
|
|
17
|
+
try {
|
|
18
|
+
const parsed = po.parse(readFileSync(new URL(`${lang}/LC_MESSAGES/messages.po`, LOCALES)));
|
|
19
|
+
const translations = Object.entries(parsed.translations[""] ?? {});
|
|
20
|
+
for (const [msgid, entry] of translations)
|
|
21
|
+
if (msgid && entry.msgstr[0])
|
|
22
|
+
entries.set(msgid, entry.msgstr[0]);
|
|
23
|
+
log.debug({ lang, entries: entries.size }, "catalog loaded");
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
log.warn({ lang, error: String(error) }, `${lang} catalog not loaded, strings stay English:`);
|
|
27
|
+
}
|
|
28
|
+
catalogs.set(lang, entries);
|
|
29
|
+
return entries;
|
|
30
|
+
}
|
|
31
|
+
// The language tags of an `Accept-Language` header or POSIX locale (`uk_UA.UTF-8`) with a positive q-value, best first.
|
|
32
|
+
function ranked(accept) {
|
|
33
|
+
const parts = (accept ?? "").split(",").map((part) => {
|
|
34
|
+
const [tag = "", ...parameters] = part.trim().split(";");
|
|
35
|
+
const q = Number(parameters
|
|
36
|
+
.map((parameter) => parameter.trim())
|
|
37
|
+
.find((parameter) => parameter.startsWith("q="))
|
|
38
|
+
?.slice(2) ?? 1);
|
|
39
|
+
return { tag: tag.trim(), q: Number.isFinite(q) ? q : 0 };
|
|
40
|
+
});
|
|
41
|
+
return parts
|
|
42
|
+
.filter((entry) => entry.q > 0 && entry.tag !== "")
|
|
43
|
+
.toSorted((a, b) => b.q - a.q)
|
|
44
|
+
.map((entry) => entry.tag);
|
|
45
|
+
}
|
|
46
|
+
// The best language `accept` names that has a catalog, by q-value; English when none has.
|
|
47
|
+
export function negotiate(accept) {
|
|
48
|
+
const lang = ranked(accept)
|
|
49
|
+
.map((tag) => tag.toLowerCase().split(/[-_.@]/, 1)[0] ?? "")
|
|
50
|
+
.find((tag) => LANGUAGES.includes(tag)) ?? "en";
|
|
51
|
+
log.debug({ accept, lang }, "language negotiated");
|
|
52
|
+
return lang;
|
|
53
|
+
}
|
|
54
|
+
// The first tag `accept` names that `Intl` can format, as a BCP 47 locale (`de-DE`); undefined when none.
|
|
55
|
+
export function readerLocale(accept) {
|
|
56
|
+
for (const tag of ranked(accept)) {
|
|
57
|
+
const bare = tag.replace(/[.@].*$/, "").replaceAll("_", "-");
|
|
58
|
+
if (["*", "c", "posix"].includes(bare.toLowerCase()))
|
|
59
|
+
continue;
|
|
60
|
+
try {
|
|
61
|
+
const [locale] = Intl.getCanonicalLocales(bare);
|
|
62
|
+
if (locale && Intl.NumberFormat.supportedLocalesOf(locale).length > 0) {
|
|
63
|
+
log.debug({ accept, locale }, "reader locale chosen");
|
|
64
|
+
return locale;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
log.debug({ tag }, "language tag not a locale");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
// The language the process environment names, as gettext reads it.
|
|
74
|
+
export function environmentLanguage() {
|
|
75
|
+
return negotiate(process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG);
|
|
76
|
+
}
|
|
77
|
+
// The locale the process environment writes numbers in: `LC_NUMERIC`, then `LC_ALL`, then `LANG`.
|
|
78
|
+
export function environmentLocale() {
|
|
79
|
+
return readerLocale(process.env.LC_NUMERIC || process.env.LC_ALL || process.env.LANG);
|
|
80
|
+
}
|
|
81
|
+
// Strings and text direction in `lang`; numbers, dates and units in `locale`, `lang` unless named.
|
|
82
|
+
export function translator(lang, locale = lang) {
|
|
83
|
+
const entries = lang === "en" ? new Map() : catalog(lang);
|
|
84
|
+
const format = (options) => new Intl.NumberFormat(locale, { maximumFractionDigits: 1, ...options });
|
|
85
|
+
return {
|
|
86
|
+
lang,
|
|
87
|
+
locale,
|
|
88
|
+
dir: new Intl.Locale(lang).getTextInfo().direction,
|
|
89
|
+
_: (msgid, variables = {}) => (entries.get(msgid) ?? msgid).replaceAll(/\{(\w+)\}/g, (match, key) => (Object.hasOwn(variables, key) ? String(variables[key]) : match)),
|
|
90
|
+
number: (value, options = {}) => format(options).format(value),
|
|
91
|
+
};
|
|
92
|
+
}
|