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,465 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { isJudged } from "../crawl/links.js";
|
|
6
|
+
import { mediaOf } from "../crawl/sitemap.js";
|
|
7
|
+
import { parseCacheControl, parseLink } from "../facts/headers.js";
|
|
8
|
+
import { evidence } from "../facts/read-note.js";
|
|
9
|
+
import { log } from "../logger.js";
|
|
10
|
+
import { clockRules } from "./clock.js";
|
|
11
|
+
import { deprecatedRules } from "./deprecated.js";
|
|
12
|
+
import { disclosureRules } from "./disclosure.js";
|
|
13
|
+
import { i18nRules } from "./i18n.js";
|
|
14
|
+
import { cachingRules } from "./caching.js";
|
|
15
|
+
import { insightRules, partition } from "./insights.js";
|
|
16
|
+
import { lengthRules } from "./length.js";
|
|
17
|
+
import { said } from "./message.js";
|
|
18
|
+
import { relationRules } from "./relations.js";
|
|
19
|
+
import { robotsRules } from "./robots.js";
|
|
20
|
+
import { urlRules } from "./url.js";
|
|
21
|
+
// Every in-scope page answering 4xx, 5xx or nothing, with the pages that link to it.
|
|
22
|
+
const brokenInternal = (severity) => ({
|
|
23
|
+
meta: { id: "links/broken-internal", severity, scope: "site", facts: ["http.status", "crawl.referrers"], docs: "https://developers.google.com/search/docs/crawling-indexing/http-network-errors", fix: "Fix the page at the link target so it answers 2xx, or point the links to a working URL." },
|
|
24
|
+
check(pages) {
|
|
25
|
+
const findings = [];
|
|
26
|
+
for (const page of pages) {
|
|
27
|
+
if (page.http.status > 0 && page.http.status < 400)
|
|
28
|
+
continue;
|
|
29
|
+
log.debug({ url: page.url.href, status: page.http.status, referrers: page.crawl.referrers.length }, "broken link");
|
|
30
|
+
findings.push({
|
|
31
|
+
rule: "links/broken-internal",
|
|
32
|
+
severity,
|
|
33
|
+
scope: "site",
|
|
34
|
+
url: page.url.href,
|
|
35
|
+
...(page.http.error ? said("the page could not be fetched: {error}; linked from these pages", { error: page.http.error }) : said("the page answers {status}; linked from these pages", { status: String(page.http.status) })),
|
|
36
|
+
value: page.http.status,
|
|
37
|
+
urls: page.crawl.referrers,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return findings;
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
// Every probed external link answering 4xx or 5xx, or nothing, once per target, with the pages linking to it; a 429, a bot wall, an excluded host or a refused address is not judged.
|
|
44
|
+
const brokenExternal = (severity) => ({
|
|
45
|
+
meta: { id: "links/broken-external", severity, scope: "site", facts: ["site.links", "html.links.external"], docs: "https://developer.mozilla.org/docs/Web/HTTP/Reference/Status/404", fix: "Remove or repoint each broken external link." },
|
|
46
|
+
check(pages, _group, site) {
|
|
47
|
+
const answers = site?.links ?? {};
|
|
48
|
+
const linking = new Map();
|
|
49
|
+
for (const page of pages) {
|
|
50
|
+
const hrefs = page.html?.links.external ?? [];
|
|
51
|
+
for (const href of hrefs)
|
|
52
|
+
linking.set(href, [...(linking.get(href) ?? []), page.url.href]);
|
|
53
|
+
}
|
|
54
|
+
const findings = [];
|
|
55
|
+
for (const [href, urls] of linking) {
|
|
56
|
+
const answer = answers[href];
|
|
57
|
+
const isBroken = answer !== undefined && isJudged(answer) && (answer.status === 0 || answer.status >= 400);
|
|
58
|
+
log.debug({ rule: "links/broken-external", url: href, status: answer?.status, excluded: answer?.excluded, refused: answer?.refused, walled: answer?.walled, isBroken }, "external link judged");
|
|
59
|
+
if (!isBroken)
|
|
60
|
+
continue;
|
|
61
|
+
const verdict = answer.status === 0 ? said("the link could not be reached: {error}; linked from these pages", { error: answer.error ?? "" }) : said("the link answers {status}; linked from these pages", { status: String(answer.status) });
|
|
62
|
+
findings.push({ rule: "links/broken-external", severity, scope: "site", url: href, ...verdict, evidence: [evidence("probes", href, { ...(answer.checked && { at: answer.checked }) })], value: answer.status, urls });
|
|
63
|
+
}
|
|
64
|
+
return findings;
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
// Every sitemap image or video answering 4xx or 5xx, or nothing, once per file, with the pages listing it.
|
|
68
|
+
const sitemapMedia = (severity) => ({
|
|
69
|
+
meta: { id: "sitemap/media", severity, scope: "site", facts: ["sitemap.images", "sitemap.videos", "site.links"], docs: "https://developers.google.com/search/docs/crawling-indexing/sitemaps/image-sitemaps", fix: "Remove or repoint each sitemap image and video entry that no longer answers 200." },
|
|
70
|
+
check(pages, _group, site) {
|
|
71
|
+
const listing = new Map();
|
|
72
|
+
for (const page of pages)
|
|
73
|
+
for (const href of mediaOf(page))
|
|
74
|
+
listing.set(href, [...(listing.get(href) ?? []), page.url.href]);
|
|
75
|
+
const findings = [];
|
|
76
|
+
for (const [href, urls] of listing) {
|
|
77
|
+
const answer = site?.links?.[href];
|
|
78
|
+
const isBroken = answer !== undefined && isJudged(answer) && (answer.status === 0 || answer.status >= 400);
|
|
79
|
+
log.debug({ rule: "sitemap/media", url: href, status: answer?.status, isBroken }, "sitemap media judged");
|
|
80
|
+
if (!isBroken)
|
|
81
|
+
continue;
|
|
82
|
+
const verdict = answer.status === 0 ? said("the sitemap media could not be reached: {error}; listed by these pages", { error: answer.error ?? "" }) : said("the sitemap media answers {status}; listed by these pages", { status: String(answer.status) });
|
|
83
|
+
findings.push({ rule: "sitemap/media", severity, scope: "site", url: href, ...verdict, value: answer.status, urls });
|
|
84
|
+
}
|
|
85
|
+
return findings;
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
// Every internal link answering with a redirect, once per target, with the pages linking to it.
|
|
89
|
+
const redirectedInternal = (severity) => ({
|
|
90
|
+
meta: { id: "links/redirected-internal", severity, scope: "site", facts: ["site.redirects", "html.links.internal"], docs: "https://developers.google.com/search/docs/crawling-indexing/301-redirects", fix: "Make the link target point at the final URL, or serve the content directly." },
|
|
91
|
+
check(pages, _group, site) {
|
|
92
|
+
const landing = new Map(Object.entries(site?.redirects ?? {}));
|
|
93
|
+
const linking = new Map();
|
|
94
|
+
for (const page of pages) {
|
|
95
|
+
const hrefs = new Set(page.html?.links.internal);
|
|
96
|
+
for (const href of hrefs)
|
|
97
|
+
if (landing.has(href))
|
|
98
|
+
linking.set(href, [...(linking.get(href) ?? []), page.url.href]);
|
|
99
|
+
}
|
|
100
|
+
log.debug({ rule: "links/redirected-internal", redirecting: landing.size, linked: linking.size }, "internal redirects judged");
|
|
101
|
+
return linking
|
|
102
|
+
.entries()
|
|
103
|
+
.map(([href, urls]) => ({ rule: "links/redirected-internal", severity, scope: "site", url: href, ...said("the link redirects elsewhere; linked from these pages"), data: { [href]: { landing: landing.get(href) } }, value: landing.get(href), urls }))
|
|
104
|
+
.toArray();
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
// Every sitemap file that failed to fetch, failed to parse, or named no URL.
|
|
108
|
+
const sitemapUnreadable = (severity) => ({
|
|
109
|
+
meta: { id: "sitemap/unreadable", severity, scope: "site", facts: ["site.sitemaps"], docs: "https://www.sitemaps.org/protocol.html", fix: "Serve the sitemap at its listed URL and make sure it parses as XML or plain text with one URL per line." },
|
|
110
|
+
check(_pages, _group, site) {
|
|
111
|
+
const files = site?.sitemaps ?? [];
|
|
112
|
+
log.debug({ rule: "sitemap/unreadable", files: files.length }, "sitemap files judged");
|
|
113
|
+
return files.filter((file) => file.error).map((file) => ({ rule: "sitemap/unreadable", severity, scope: "site", url: file.url, ...said("the sitemap could not be read"), data: { [file.url]: { error: file.error } }, value: file.status }));
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
// A header's value, repeated fields joined; absent is empty.
|
|
117
|
+
export function header(page, name) {
|
|
118
|
+
const value = page.http.headers[name] ?? "";
|
|
119
|
+
return Array.isArray(value) ? value.join(", ") : value;
|
|
120
|
+
}
|
|
121
|
+
// Framing refused by CSP `frame-ancestors` or by `X-Frame-Options` DENY or SAMEORIGIN.
|
|
122
|
+
const frameOptions = (severity) => ({
|
|
123
|
+
meta: {
|
|
124
|
+
id: "http/frame-options",
|
|
125
|
+
severity,
|
|
126
|
+
scope: "page",
|
|
127
|
+
facts: ["http.csp.directives", "http.headers.x-frame-options"],
|
|
128
|
+
docs: "https://developer.mozilla.org/docs/Web/HTTP/Headers/Content-Security-Policy/frame-ancestors",
|
|
129
|
+
fix: "Send a Content-Security-Policy frame-ancestors directive, or X-Frame-Options: DENY.",
|
|
130
|
+
},
|
|
131
|
+
check(page) {
|
|
132
|
+
const hasAncestors = page.http.csp?.directives?.["frame-ancestors"] !== undefined;
|
|
133
|
+
const options = header(page, "x-frame-options").trim();
|
|
134
|
+
const isDenied = hasAncestors || /^(?:deny|sameorigin)$/i.test(options);
|
|
135
|
+
log.debug({ rule: "http/frame-options", url: page.url.href, hasAncestors, options, isDenied }, "framing checked");
|
|
136
|
+
return isDenied
|
|
137
|
+
? []
|
|
138
|
+
: [
|
|
139
|
+
{
|
|
140
|
+
rule: "http/frame-options",
|
|
141
|
+
severity,
|
|
142
|
+
scope: "page",
|
|
143
|
+
url: page.url.href,
|
|
144
|
+
group: page.group,
|
|
145
|
+
...said("neither content-security-policy frame-ancestors nor x-frame-options refuses framing"),
|
|
146
|
+
...(options && { data: { [page.url.href]: { "x-frame-options": options } } }),
|
|
147
|
+
value: options || undefined,
|
|
148
|
+
},
|
|
149
|
+
];
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
// Parsed `Link` entries, empty when the value breaks RFC 8288.
|
|
153
|
+
const linksOf = (parsed) => parsed?.value ?? [];
|
|
154
|
+
// Absolute targets of the entries carrying relation token `relation`.
|
|
155
|
+
function targets(links, relation, base) {
|
|
156
|
+
return links.filter((link) => (link.rel ?? "").split(/\s+/).includes(relation)).map((link) => resolve(link.href ?? "", base));
|
|
157
|
+
}
|
|
158
|
+
// Absolute targets of the `Link` entries carrying relation token `relation`.
|
|
159
|
+
export function linkTargets(raw, relation, base) {
|
|
160
|
+
return targets(linksOf(parseLink(raw)), relation, base);
|
|
161
|
+
}
|
|
162
|
+
// A preload a 103 hinted that the final response’s `Link` no longer carries.
|
|
163
|
+
const earlyHintsPreload = (severity) => ({
|
|
164
|
+
meta: { id: "http/early-hints-preload", severity, scope: "page", facts: ["http.early-hints", "http.parsed.link"], docs: "https://developer.mozilla.org/docs/Web/HTTP/Status/103", fix: "Remove the preload from the 103 Early Hints, or add it to the final Link header." },
|
|
165
|
+
check(page) {
|
|
166
|
+
const hints = page.http["early-hints"];
|
|
167
|
+
const parsed = page.http.parsed?.link;
|
|
168
|
+
if (!hints || (parsed?.errors.length ?? 0) > 0)
|
|
169
|
+
return;
|
|
170
|
+
const final = new Set(targets(linksOf(parsed), "preload", page.url.href));
|
|
171
|
+
const dropped = [...new Set(hints.flatMap((hint) => linkTargets(hint.link ?? "", "preload", page.url.href))).difference(final)];
|
|
172
|
+
log.debug({ rule: "http/early-hints-preload", url: page.url.href, hints: hints.length, final: final.size, dropped: dropped.length }, "early hints compared");
|
|
173
|
+
return dropped.length === 0 ? [] : [{ rule: "http/early-hints-preload", severity, scope: "page", url: page.url.href, group: page.group, ...said("a 103 Early Hints preload is missing from the final Link header"), data: { [page.url.href]: { preloads: dropped.join(", ") } }, value: dropped }];
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
// The origin of an http(s) URL, else undefined.
|
|
177
|
+
function originOf(href) {
|
|
178
|
+
if (!href || !URL.canParse(href))
|
|
179
|
+
return undefined;
|
|
180
|
+
const url = new URL(href);
|
|
181
|
+
return /^https?:$/.test(url.protocol) ? url.origin : undefined;
|
|
182
|
+
}
|
|
183
|
+
// Head links and `Link` header entries whose `rel` carries one of `relations`.
|
|
184
|
+
function withRelation(page, relations) {
|
|
185
|
+
const headers = linksOf(page.http.parsed?.link).map((link) => ({ ...link, href: resolve(link.href ?? "", page.url.href) }));
|
|
186
|
+
return [...(page.html?.head.links ?? []), ...headers].filter((link) => (link.rel ?? "")
|
|
187
|
+
.toLowerCase()
|
|
188
|
+
.split(/\s+/)
|
|
189
|
+
.some((token) => relations.includes(token)));
|
|
190
|
+
}
|
|
191
|
+
// Origins warmed by `preconnect` or `dns-prefetch`, with the relation that names each.
|
|
192
|
+
function hintedOrigins(page) {
|
|
193
|
+
return new Map(withRelation(page, ["preconnect", "dns-prefetch"]).flatMap((link) => (originOf(link.href) ? [[originOf(link.href), (link.rel ?? "").toLowerCase()]] : [])));
|
|
194
|
+
}
|
|
195
|
+
// A hinted origin no resource of the rendered page loads; a static parse misses what CSS and scripts load, so only a browser census is judged.
|
|
196
|
+
const preconnectUnused = pageRule("html/preconnect-unused", ["html.head.links", "http.parsed.link", "resources"], (page) => {
|
|
197
|
+
if (!page.browser || !page.html)
|
|
198
|
+
return;
|
|
199
|
+
const used = new Set((page.resources ?? []).map((resource) => originOf(resource.url)));
|
|
200
|
+
const unused = hintedOrigins(page)
|
|
201
|
+
.entries()
|
|
202
|
+
.filter(([origin]) => !used.has(origin))
|
|
203
|
+
.toArray();
|
|
204
|
+
log.debug({ rule: "html/preconnect-unused", url: page.url.href, used: used.size, unused: unused.length }, "resource hints matched");
|
|
205
|
+
return unused.map(([origin, relation]) => ({ ...said("rel={relation} warms an origin that no resource of the page loads", { relation }), data: { [page.url.href]: { origin } }, value: origin }));
|
|
206
|
+
}, { docs: "https://developer.mozilla.org/docs/Web/HTML/Reference/Attributes/rel/preconnect", fix: "Remove the preconnect or dns-prefetch link to an origin the page no longer loads from." });
|
|
207
|
+
// Viewport meta keys with the values browsers read; any other key or value is dropped quietly.
|
|
208
|
+
const VIEWPORT = {
|
|
209
|
+
width: /^(?:device-width|\d+(?:\.\d+)?)$/i,
|
|
210
|
+
height: /^(?:device-height|\d+(?:\.\d+)?)$/i,
|
|
211
|
+
"initial-scale": /^\d*\.?\d+$/,
|
|
212
|
+
"minimum-scale": /^\d*\.?\d+$/,
|
|
213
|
+
"maximum-scale": /^\d*\.?\d+$/,
|
|
214
|
+
"user-scalable": /^(?:yes|no|\d*\.?\d+)$/i,
|
|
215
|
+
"viewport-fit": /^(?:auto|contain|cover)$/i,
|
|
216
|
+
"interactive-widget": /^(?:resizes-visual|resizes-content|overlays-content)$/i,
|
|
217
|
+
};
|
|
218
|
+
// Each viewport entry a browser drops: an unknown key, a value it cannot read, or a key without `=`.
|
|
219
|
+
const viewportSyntax = pageRule("html/viewport-syntax", ["html.meta.viewport"], (page) => {
|
|
220
|
+
const content = page.html?.meta.viewport;
|
|
221
|
+
if (content === undefined)
|
|
222
|
+
return;
|
|
223
|
+
const entries = content
|
|
224
|
+
.replaceAll(/\s*=\s*/g, "=")
|
|
225
|
+
.split(/[\s,;]+/)
|
|
226
|
+
.filter((entry) => entry.length > 0);
|
|
227
|
+
const dropped = entries.filter((entry) => {
|
|
228
|
+
const [key = "", value] = entry.split("=", 2);
|
|
229
|
+
return value === undefined || !VIEWPORT[key.toLowerCase()]?.test(value);
|
|
230
|
+
});
|
|
231
|
+
log.debug({ rule: "html/viewport-syntax", url: page.url.href, entries: entries.length, dropped: dropped.length }, "viewport entries read");
|
|
232
|
+
return dropped.map((entry) => ({ ...said("a meta viewport entry is not a key and value browsers read, so they ignore it"), data: { [page.url.href]: { entry } }, value: entry }));
|
|
233
|
+
}, { docs: "https://drafts.csswg.org/css-viewport/#viewport-meta", fix: "Keep meta viewport to known keys, as in width=device-width, initial-scale=1." });
|
|
234
|
+
// Each meta theme-color whose content is no CSS <color>, which browsers then ignore; css-tree loads on first use.
|
|
235
|
+
const themeColorSyntax = pageRule("html/theme-color-syntax", ["html.metas"], (page) => {
|
|
236
|
+
const colors = page.html?.metas.filter((meta) => meta.name.toLowerCase() === "theme-color");
|
|
237
|
+
if (!colors?.length)
|
|
238
|
+
return;
|
|
239
|
+
const { lexer } = createRequire(import.meta.url)("css-tree");
|
|
240
|
+
const invalid = colors.filter((meta) => !lexer.match("<color>", meta.content.trim()).matched);
|
|
241
|
+
log.debug({ rule: "html/theme-color-syntax", url: page.url.href, colors: colors.length, invalid: invalid.length }, "theme colors read");
|
|
242
|
+
return invalid.map((meta) => ({ ...(meta.content.trim() === "" ? said("meta theme-color is empty, so browsers ignore it") : { ...said("meta theme-color is not a CSS color, so browsers ignore it"), data: { [page.url.href]: { content: meta.content } } }), value: meta.content }));
|
|
243
|
+
}, { docs: "https://html.spec.whatwg.org/multipage/semantics.html#meta-theme-color", fix: "Set <meta name=theme-color> content to a CSS color, as in #1a73e8." });
|
|
244
|
+
// Cross origins serving a head script without `async`, `defer` or `type=module`, or a style sheet for every medium.
|
|
245
|
+
function blockingOrigins(page) {
|
|
246
|
+
const scripts = (page.html?.scripts ?? []).filter((script) => script.head && !script.async && !script.defer && script.type !== "module").map((script) => [originOf(script.src), "script"]);
|
|
247
|
+
const styles = withRelation(page, ["stylesheet"])
|
|
248
|
+
.filter((link) => !link.media || /\b(?:all|screen)\b/i.test(link.media))
|
|
249
|
+
.map((link) => [originOf(link.href), "style sheet"]);
|
|
250
|
+
return new Map([...scripts, ...styles].filter((entry) => entry[0] !== undefined && entry[0] !== page.url.origin));
|
|
251
|
+
}
|
|
252
|
+
// A cross origin serving a render-blocking resource with no `preconnect`, `dns-prefetch` or `preload` towards it.
|
|
253
|
+
const preconnectMissing = pageRule("html/preconnect-missing", ["html.head.links", "http.parsed.link", "html.scripts"], (page) => {
|
|
254
|
+
if (!page.html)
|
|
255
|
+
return;
|
|
256
|
+
const warmed = new Set([...hintedOrigins(page).keys(), ...withRelation(page, ["preload"]).map((link) => originOf(link.href))]);
|
|
257
|
+
const cold = blockingOrigins(page)
|
|
258
|
+
.entries()
|
|
259
|
+
.filter(([origin]) => !warmed.has(origin))
|
|
260
|
+
.toArray();
|
|
261
|
+
log.debug({ rule: "html/preconnect-missing", url: page.url.href, warmed: warmed.size, cold: cold.length }, "render-blocking origins matched");
|
|
262
|
+
return cold.map(([origin, kind]) => ({ ...said("an origin serves a render-blocking {kind} and nothing preconnects to it", { kind }), data: { [page.url.href]: { origin } }, value: origin }));
|
|
263
|
+
}, { docs: "https://web.dev/articles/preconnect-and-dns-prefetch", fix: 'Add <link rel="preconnect" href="https://origin.example"> for each cross origin a render-blocking script or style sheet comes from.' });
|
|
264
|
+
// A `preconnect` to an origin serving fonts that lacks `crossorigin`, so the font fetch opens a second connection.
|
|
265
|
+
const preconnectCrossorigin = pageRule("html/preconnect-crossorigin", ["html.head.links", "http.parsed.link", "resources"], (page) => {
|
|
266
|
+
if (!page.html)
|
|
267
|
+
return;
|
|
268
|
+
const fonts = new Set([
|
|
269
|
+
...(page.resources ?? []).filter((resource) => resource.kind === "font").map((resource) => originOf(resource.url)),
|
|
270
|
+
...withRelation(page, ["preload"])
|
|
271
|
+
.filter((link) => link.as === "font")
|
|
272
|
+
.map((link) => originOf(link.href)),
|
|
273
|
+
]);
|
|
274
|
+
const preconnects = withRelation(page, ["preconnect"]).filter((link) => originOf(link.href) !== undefined && fonts.has(originOf(link.href)));
|
|
275
|
+
const anonymous = new Set(preconnects.filter((link) => link.crossorigin !== undefined).map((link) => originOf(link.href)));
|
|
276
|
+
const bare = [...new Set(preconnects.map((link) => originOf(link.href))).difference(anonymous)];
|
|
277
|
+
log.debug({ rule: "html/preconnect-crossorigin", url: page.url.href, fonts: fonts.size, preconnects: preconnects.length, bare: bare.length }, "font preconnects matched");
|
|
278
|
+
return bare.map((origin) => ({ ...said("the preconnect to a font origin lacks crossorigin, so fonts open a second connection"), data: { [page.url.href]: { origin: String(origin) } }, value: origin }));
|
|
279
|
+
}, { docs: "https://developer.mozilla.org/docs/Web/HTML/Reference/Attributes/rel/preconnect", fix: "Add the crossorigin attribute to the preconnect link towards the origin fonts load from." });
|
|
280
|
+
// Fragment-free absolute form of a URL relative to the page; an unparsable value stays as written.
|
|
281
|
+
export function resolve(raw, base) {
|
|
282
|
+
if (!URL.canParse(raw, base))
|
|
283
|
+
return raw;
|
|
284
|
+
const url = new URL(raw, base);
|
|
285
|
+
url.hash = "";
|
|
286
|
+
return url.href;
|
|
287
|
+
}
|
|
288
|
+
// A page whose `fact` names a URL other than its own or its twin on the canonical origin; a page without the fact is skipped.
|
|
289
|
+
function pointsHere(id, fact, label, read, guide = {}) {
|
|
290
|
+
return (severity) => ({
|
|
291
|
+
meta: { id, severity, scope: "page", facts: [fact], ...guide },
|
|
292
|
+
check(page) {
|
|
293
|
+
const raw = page.html && read(page.html);
|
|
294
|
+
if (raw === undefined)
|
|
295
|
+
return;
|
|
296
|
+
const target = resolve(raw, page.url.href);
|
|
297
|
+
const here = [page.url.href, page.url.twin].flatMap((href) => (href ? [resolve(href, href)] : []));
|
|
298
|
+
const matches = here.includes(target);
|
|
299
|
+
log.debug({ rule: id, url: page.url.href, twin: page.url.twin, target, matches }, "self reference checked");
|
|
300
|
+
return matches ? [] : [{ rule: id, severity, scope: "page", url: page.url.href, group: page.group, ...said("{label} names another URL, not this page", { label }), data: { [page.url.href]: { target } }, value: raw }];
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
// Per-connection facts that should not differ between pages of one host.
|
|
305
|
+
const ORIGIN = [
|
|
306
|
+
["tls.cert.fingerprint256", "certificate fingerprint", (page) => page.tls?.cert.fingerprint256],
|
|
307
|
+
["tls.protocol", "TLS version", (page) => page.tls?.protocol],
|
|
308
|
+
["http.remote.address", "server address", (page) => page.http.remote?.address],
|
|
309
|
+
["http.headers.server", "Server header", (page) => header(page, "server") || undefined],
|
|
310
|
+
];
|
|
311
|
+
// The finding for one host and fact when its value varies across the host's pages.
|
|
312
|
+
function varies(severity, host, members, fact, label, read) {
|
|
313
|
+
const byValue = partition(members, read);
|
|
314
|
+
log.debug({ rule: "http/consistent-origin", host, fact, values: byValue.size }, "origin fact compared");
|
|
315
|
+
if (byValue.size < 2)
|
|
316
|
+
return undefined;
|
|
317
|
+
const entries = byValue.entries().toArray();
|
|
318
|
+
const urls = entries.flatMap(([, group]) => group.map((page) => page.url.href));
|
|
319
|
+
const data = Object.fromEntries(entries.flatMap(([value, group]) => group.map((page) => [page.url.href, { value }])));
|
|
320
|
+
return { rule: "http/consistent-origin", severity, scope: "site", url: urls[0], ...said("{label} varies across {host}", { label, host }), data, value: Object.fromEntries(entries.map(([value, group]) => [value, group.length])), urls };
|
|
321
|
+
}
|
|
322
|
+
// One finding per host and fact whose value varies across its pages, with the URL count per value.
|
|
323
|
+
const consistentOrigin = (severity) => ({
|
|
324
|
+
meta: { id: "http/consistent-origin", severity, scope: "site", facts: ORIGIN.map(([fact]) => fact), fix: "Make every page of the host serve the same certificate, TLS protocol and Server header." },
|
|
325
|
+
check(pages) {
|
|
326
|
+
const hosts = Map.groupBy(pages, (page) => page.url.host)
|
|
327
|
+
.entries()
|
|
328
|
+
.toArray();
|
|
329
|
+
return hosts.flatMap(([host, members]) => ORIGIN.map(([fact, label, read]) => varies(severity, host, members, fact, label, read)).filter((finding) => finding !== undefined));
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
// A page rule: one finding per offence `judge` returns, none when empty, skipped when undefined.
|
|
333
|
+
export function pageRule(id, facts, judge, guide = {}) {
|
|
334
|
+
return (severity) => ({
|
|
335
|
+
meta: { id, severity, scope: "page", facts, ...guide },
|
|
336
|
+
check(page) {
|
|
337
|
+
const offences = judge(page);
|
|
338
|
+
log.debug({ rule: id, url: page.url.href, offences: offences?.length }, "page judged");
|
|
339
|
+
return offences?.map((offence) => ({ rule: id, severity, scope: "page", url: page.url.href, group: page.group, ...offence }));
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
// A site rule keyed by resource URL: one finding per offending resource, its pages as `urls`.
|
|
344
|
+
export function resourceRule(id, isUsed, verdict, facts = ["resources"], valueOf = (resource) => resource.http?.status, guide = {}) {
|
|
345
|
+
return (severity) => ({
|
|
346
|
+
meta: { id, severity, scope: "site", facts, ...guide },
|
|
347
|
+
check(pages) {
|
|
348
|
+
const usedBy = new Map();
|
|
349
|
+
const uses = pages.flatMap((page) => (page.resources ?? []).filter((resource) => isUsed(page, resource)).map((resource) => ({ page, resource })));
|
|
350
|
+
for (const { page, resource } of uses) {
|
|
351
|
+
const entry = usedBy.get(resource.url) ?? { resource, urls: [] };
|
|
352
|
+
entry.urls.push(page.url.href);
|
|
353
|
+
usedBy.set(resource.url, entry);
|
|
354
|
+
}
|
|
355
|
+
const findings = [];
|
|
356
|
+
for (const [url, { resource, urls }] of usedBy) {
|
|
357
|
+
const judged = verdict(resource);
|
|
358
|
+
log.debug({ rule: id, resource: url, pages: urls.length, isFinding: judged !== undefined }, "resource judged");
|
|
359
|
+
if (judged)
|
|
360
|
+
findings.push({ rule: id, severity, scope: "site", url, ...judged, value: valueOf(resource), urls });
|
|
361
|
+
}
|
|
362
|
+
return findings;
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
const isAnyUse = () => true;
|
|
367
|
+
// A fetched resource answering outside 2xx, or not at all.
|
|
368
|
+
const resourceStatus = (resource) => {
|
|
369
|
+
const http = resource.http;
|
|
370
|
+
if (!http || (http.status >= 200 && http.status < 300))
|
|
371
|
+
return;
|
|
372
|
+
return http.status === 0 ? said("{kind} could not be fetched: {error}; used by these pages", { kind: resource.kind, error: http.error ?? "" }) : said("{kind} answers {status}; used by these pages", { kind: resource.kind, status: String(http.status) });
|
|
373
|
+
};
|
|
374
|
+
// A resource header's value, repeated fields joined; absent is empty.
|
|
375
|
+
function resourceHeader(resource, name) {
|
|
376
|
+
return [resource.http?.headers[name] ?? []].flat().join(", ");
|
|
377
|
+
}
|
|
378
|
+
// A resource that answered 2xx.
|
|
379
|
+
function isServed(resource) {
|
|
380
|
+
const status = resource.http?.status ?? 0;
|
|
381
|
+
return status >= 200 && status < 300;
|
|
382
|
+
}
|
|
383
|
+
// One year, the `max-age` a fingerprinted asset can carry.
|
|
384
|
+
const YEAR = 31_536_000;
|
|
385
|
+
// A file name carrying a content hash: a `.` or `-` segment of 8+ word characters with a digit, before the extension.
|
|
386
|
+
const HASHED = /[.-](?=[\w-]*\d)[\w-]{8,}\.\w+$/;
|
|
387
|
+
// A fingerprinted or `immutable` asset whose `Cache-Control` keeps it for less than a year.
|
|
388
|
+
const resourceCacheControl = (resource) => {
|
|
389
|
+
const policy = resourceHeader(resource, "cache-control");
|
|
390
|
+
const isImmutable = /\bimmutable\b/i.test(policy);
|
|
391
|
+
const isHashed = HASHED.test(new URL(resource.url).pathname);
|
|
392
|
+
if (!isServed(resource) || (!isImmutable && !isHashed))
|
|
393
|
+
return;
|
|
394
|
+
const { value, errors } = parseCacheControl(policy);
|
|
395
|
+
const directives = (value ?? {});
|
|
396
|
+
const maxAge = Number(directives["max-age"] ?? 0);
|
|
397
|
+
const isLong = maxAge >= YEAR && !directives["no-cache"] && !directives["no-store"];
|
|
398
|
+
log.debug({ rule: "resources/cache-control", resource: resource.url, isImmutable, isHashed, maxAge, isLong, errors }, "resource cache policy judged");
|
|
399
|
+
const variables = { kind: resource.kind };
|
|
400
|
+
if (errors.length > 0)
|
|
401
|
+
return { ...said(isHashed ? "fingerprinted {kind} sends a Cache-Control that breaks RFC 9111; used by these pages" : "immutable {kind} sends a Cache-Control that breaks RFC 9111; used by these pages", variables), data: { [resource.url]: { errors: errors.join("; ") } } };
|
|
402
|
+
return isLong ? undefined : { ...said(isHashed ? "fingerprinted {kind} is cached for less than a year; used by these pages" : "immutable {kind} is cached for less than a year; used by these pages", variables), data: { [resource.url]: { "max-age": maxAge, ...(policy && { "cache-control": policy }) } } };
|
|
403
|
+
};
|
|
404
|
+
// Text formats worth compressing, fonts other than WOFF and WOFF2 included.
|
|
405
|
+
const TEXT = /^(?:text\/|image\/svg\+xml|application\/(?:(?:[\w.-]+\+)?(?:json|xml)|javascript|ecmascript|wasm|vnd\.ms-fontobject|x-font-(?:ttf|otf)|font-sfnt)|font\/(?:ttf|otf|sfnt|collection))/;
|
|
406
|
+
// A text asset over 1 KB answered without br, gzip or zstd.
|
|
407
|
+
const resourceCompression = (resource) => {
|
|
408
|
+
const type = resource.http?.["content-type"] ?? "";
|
|
409
|
+
const coding = resourceHeader(resource, "content-encoding");
|
|
410
|
+
const isText = TEXT.test(type) && (resource.http?.size.body ?? 0) >= 1024;
|
|
411
|
+
const isCompressed = /\b(?:br|gzip|zstd)\b/i.test(coding);
|
|
412
|
+
log.debug({ rule: "resources/compression", resource: resource.url, type, coding, isText, isCompressed }, "resource compression judged");
|
|
413
|
+
return isText && !isCompressed && isServed(resource) ? { ...said("a text {kind} is not served with br, gzip or zstd; used by these pages", { kind: resource.kind }), data: { [resource.url]: { type, ...(coding && { "content-encoding": coding }) } } } : undefined;
|
|
414
|
+
};
|
|
415
|
+
// Values in quotes, comma separated; none when empty.
|
|
416
|
+
const quoted = (values) => (values.length === 0 ? "none" : values.map((value) => `“${value}”`).join(", "));
|
|
417
|
+
// Served declarations a script changed once the page rendered, each as its tag, served value and rendered value; an unrendered page is skipped.
|
|
418
|
+
const declarationRewritten = pageRule("html/declaration-rewritten", ["html.rewritten"], (page) => {
|
|
419
|
+
if (!page.parity)
|
|
420
|
+
return;
|
|
421
|
+
const rewritten = page.html?.rewritten ?? [];
|
|
422
|
+
return rewritten.length === 0
|
|
423
|
+
? []
|
|
424
|
+
: [{ ...said("a script changes declarations the served HTML makes; rules judge the served ones"), data: { [page.url.href]: { declarations: rewritten.length } }, value: rewritten, locations: rewritten.map((entry) => `${entry.tag} served ${quoted(entry.served)}, rendered ${quoted(entry.rendered)}`) }];
|
|
425
|
+
}, { docs: "https://html.spec.whatwg.org/multipage/semantics.html#the-meta-element", fix: "Serve each declaration with its final value, so readers that run no script see what the page means." });
|
|
426
|
+
// TypeScript rules a preset enables by ID alone.
|
|
427
|
+
export const builtin = {
|
|
428
|
+
"links/broken-internal": brokenInternal,
|
|
429
|
+
"links/redirected-internal": redirectedInternal,
|
|
430
|
+
"links/broken-external": brokenExternal,
|
|
431
|
+
"http/frame-options": frameOptions,
|
|
432
|
+
"html/declaration-rewritten": declarationRewritten,
|
|
433
|
+
"http/early-hints-preload": earlyHintsPreload,
|
|
434
|
+
"html/preconnect-unused": preconnectUnused,
|
|
435
|
+
"html/preconnect-missing": preconnectMissing,
|
|
436
|
+
"html/preconnect-crossorigin": preconnectCrossorigin,
|
|
437
|
+
"html/viewport-syntax": viewportSyntax,
|
|
438
|
+
"html/theme-color-syntax": themeColorSyntax,
|
|
439
|
+
"http/consistent-origin": consistentOrigin,
|
|
440
|
+
"sitemap/unreadable": sitemapUnreadable,
|
|
441
|
+
"sitemap/media": sitemapMedia,
|
|
442
|
+
...robotsRules,
|
|
443
|
+
...i18nRules,
|
|
444
|
+
...disclosureRules,
|
|
445
|
+
...deprecatedRules,
|
|
446
|
+
...clockRules,
|
|
447
|
+
...urlRules,
|
|
448
|
+
...insightRules,
|
|
449
|
+
...cachingRules,
|
|
450
|
+
...lengthRules,
|
|
451
|
+
...relationRules,
|
|
452
|
+
"resources/status": resourceRule("resources/status", (_page, resource) => resource.kind !== "enclosure", resourceStatus, undefined, (resource) => resource.http?.status ?? 0, { docs: "https://developer.mozilla.org/docs/Web/HTTP/Reference/Status", fix: "Fix the resource server so it answers 2xx, or remove the resource from the page." }),
|
|
453
|
+
"resources/mixed-content": resourceRule("resources/mixed-content", (page, resource) => page.url.protocol === "https:" && resource.url.startsWith("http:"), (resource) => said("{kind} loads over http: on the https: pages that use it", { kind: resource.kind }), undefined, undefined, { docs: "https://developer.mozilla.org/docs/Web/Security/Mixed_content", fix: "Load the resource over https instead of http." }),
|
|
454
|
+
"resources/sri": resourceRule("resources/sri", (_page, resource) => resource.origin === "cross" && (resource.kind === "script" || resource.kind === "style"), (resource) => (resource.integrity ? undefined : said("cross-origin {kind} without integrity; used by these pages", { kind: resource.kind })), undefined, undefined, { docs: "https://developer.mozilla.org/docs/Web/Security/Subresource_Integrity", fix: "Add an integrity attribute (sha384 or sha256) and crossorigin=anonymous to each cross-origin script and style sheet." }),
|
|
455
|
+
"resources/cache-control": resourceRule("resources/cache-control", isAnyUse, resourceCacheControl, undefined, (resource) => resource.http?.headers["cache-control"], {
|
|
456
|
+
docs: "https://developer.mozilla.org/docs/Web/HTTP/Guides/Caching#cache_busting",
|
|
457
|
+
fix: "Send Cache-Control: public, max-age=31536000, immutable with every fingerprinted asset.",
|
|
458
|
+
}),
|
|
459
|
+
"resources/compression": resourceRule("resources/compression", isAnyUse, resourceCompression, undefined, (resource) => resource.http?.headers["content-encoding"], {
|
|
460
|
+
docs: "https://developer.mozilla.org/docs/Web/HTTP/Guides/Compression",
|
|
461
|
+
fix: "Serve text assets br, zstd or gzip to every client whose Accept-Encoding offers one.",
|
|
462
|
+
}),
|
|
463
|
+
"html/canonical-self": pointsHere("html/canonical-self", "html.canonical", "canonical link", (html) => html.canonical, { docs: "https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls", fix: "Point <link rel=canonical> at this page’s own URL." }),
|
|
464
|
+
"html/og-url-self": pointsHere("html/og-url-self", "html.property.og:url", "og:url", (html) => html.property["og:url"], { docs: "https://ogp.me/#metadata", fix: "Point <meta property=og:url> at this page’s own URL." }),
|
|
465
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
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 { parseCacheControl } from "../facts/headers.js";
|
|
7
|
+
import { log } from "../logger.js";
|
|
8
|
+
import { said } from "./message.js";
|
|
9
|
+
const ID = "caching/long-without-hash";
|
|
10
|
+
const DEFAULTS = { threshold: 2_592_000, entropy: [], allow: [] };
|
|
11
|
+
// Build directories whose every file name a framework fingerprints.
|
|
12
|
+
const BUILD_DIRECTORIES = ["/_astro/**", "/_next/static/**", "/_nuxt/**", "/_app/immutable/**"];
|
|
13
|
+
// A path token holding a content hash or version: 6+ hex with a digit and a letter, 8+ word characters with a digit, or a dotted version.
|
|
14
|
+
const HASH = /(?:^|[/._~-])(?:(?=[a-f\d]*\d)(?=[a-f\d]*[a-f])[a-f\d]{6,}|(?=[a-z\d]*\d)[a-z\d]{8,}|v?\d+\.\d+(?:\.\d+)*)(?=$|[/._~-])/i;
|
|
15
|
+
// `re:` is a regex, anything else a picomatch glob, both over path and query.
|
|
16
|
+
function matcher(entry) {
|
|
17
|
+
if (!entry.startsWith("re:"))
|
|
18
|
+
return picomatch(entry);
|
|
19
|
+
try {
|
|
20
|
+
const regex = new RegExp(entry.slice(3));
|
|
21
|
+
return (target) => regex.test(target);
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
throw new ConfigError(`rule ${ID}: ${entry}: ${error instanceof Error ? error.message : String(error)}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// A response’s Cache-Control, repeated fields joined; absent is empty.
|
|
28
|
+
function policyOf(headers) {
|
|
29
|
+
return [headers?.["cache-control"] ?? []].flat().join(", ");
|
|
30
|
+
}
|
|
31
|
+
// Every 2xx page and resource once, by URL; a page counts its referrers, a resource the pages loading it.
|
|
32
|
+
function served(pages) {
|
|
33
|
+
const byUrl = new Map();
|
|
34
|
+
const resources = pages.flatMap((page) => page.resources ?? []);
|
|
35
|
+
for (const page of pages) {
|
|
36
|
+
if (page.http.status >= 200 && page.http.status < 300)
|
|
37
|
+
byUrl.set(page.url.href, { url: page.url.href, type: page.http["content-type"], policy: policyOf(page.http.headers), users: page.crawl.referrers.length });
|
|
38
|
+
}
|
|
39
|
+
for (const resource of resources) {
|
|
40
|
+
const status = resource.http?.status ?? 0;
|
|
41
|
+
if (status < 200 || status >= 300)
|
|
42
|
+
continue;
|
|
43
|
+
const known = byUrl.get(resource.url);
|
|
44
|
+
byUrl.set(resource.url, known ? { ...known, users: known.users + 1 } : { url: resource.url, type: resource.http?.["content-type"] ?? "", policy: policyOf(resource.http?.headers), users: 1 });
|
|
45
|
+
}
|
|
46
|
+
return byUrl.values().toArray();
|
|
47
|
+
}
|
|
48
|
+
// `immutable`, or the longer of `max-age` and `s-maxage` past the threshold; `no-cache` and `no-store` revalidate, and a broken header is another rule’s.
|
|
49
|
+
function lifetime(policy, threshold) {
|
|
50
|
+
if (!policy)
|
|
51
|
+
return undefined;
|
|
52
|
+
const { value, errors } = parseCacheControl(policy);
|
|
53
|
+
const directives = (value ?? {});
|
|
54
|
+
if (errors.length > 0 || directives["no-cache"] !== undefined || directives["no-store"] !== undefined)
|
|
55
|
+
return undefined;
|
|
56
|
+
if (directives.immutable !== undefined)
|
|
57
|
+
return "immutable";
|
|
58
|
+
return Math.max(Number(directives["max-age"] ?? 0), Number(directives["s-maxage"] ?? 0)) > threshold ? "long" : undefined;
|
|
59
|
+
}
|
|
60
|
+
const SENTENCES = {
|
|
61
|
+
immutable: "{type} files are sent immutable at URLs that carry no hash or version, so caches serve a stale copy after the next publish",
|
|
62
|
+
long: "{type} files are cached long at URLs that carry no hash or version, so caches serve a stale copy after the next publish",
|
|
63
|
+
};
|
|
64
|
+
// One finding per media type and kind of promise, listing every long-cached URL that cannot change with its content.
|
|
65
|
+
const longWithoutHash = (severity, expect) => {
|
|
66
|
+
const settings = { ...DEFAULTS, ...expect };
|
|
67
|
+
const versioned = [...BUILD_DIRECTORIES, ...settings.entropy].map((entry) => matcher(entry));
|
|
68
|
+
const allowed = settings.allow.map((entry) => matcher(entry));
|
|
69
|
+
return {
|
|
70
|
+
meta: {
|
|
71
|
+
id: ID,
|
|
72
|
+
severity,
|
|
73
|
+
scope: "site",
|
|
74
|
+
facts: ["http.headers.cache-control", "resources"],
|
|
75
|
+
docs: "https://www.rfc-editor.org/rfc/rfc8246",
|
|
76
|
+
fix: "Put a content hash or version in the file name, as cv.3f9a1c.pdf, or send Cache-Control: no-cache.",
|
|
77
|
+
},
|
|
78
|
+
check(pages) {
|
|
79
|
+
const flagged = new Map();
|
|
80
|
+
for (const entry of served(pages)) {
|
|
81
|
+
const promise = lifetime(entry.policy, settings.threshold);
|
|
82
|
+
if (!promise)
|
|
83
|
+
continue;
|
|
84
|
+
const url = new URL(entry.url);
|
|
85
|
+
const target = url.pathname + url.search;
|
|
86
|
+
const isVersioned = url.search.length > 1 || HASH.test(url.pathname) || versioned.some((match) => match(target));
|
|
87
|
+
const isAllowed = allowed.some((match) => match(target));
|
|
88
|
+
log.debug({ rule: ID, url: entry.url, policy: entry.policy, promise, isVersioned, isAllowed }, "cache lifetime judged");
|
|
89
|
+
if (isVersioned || isAllowed)
|
|
90
|
+
continue;
|
|
91
|
+
const key = `${promise}\t${entry.type || "none"}`;
|
|
92
|
+
flagged.set(key, [...(flagged.get(key) ?? []), entry]);
|
|
93
|
+
}
|
|
94
|
+
log.debug({ rule: ID, threshold: settings.threshold, groups: flagged.keys().toArray() }, "long-cached URLs grouped by type");
|
|
95
|
+
return [...flagged].map(([key, entries]) => {
|
|
96
|
+
const [promise, type] = key.split("\t");
|
|
97
|
+
const data = Object.fromEntries(entries.map((entry) => [entry.url, { "cache-control": entry.policy, pages: entry.users }]));
|
|
98
|
+
return { rule: ID, severity: promise === "immutable" && severity === "warning" ? "error" : severity, scope: "site", url: entries[0]?.url, ...said(SENTENCES[promise], { type }), data, value: type, urls: entries.map((entry) => entry.url) };
|
|
99
|
+
});
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
export const cachingRules = { [ID]: longWithoutHash };
|