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.
Files changed (186) hide show
  1. package/LICENSE +235 -0
  2. package/README.md +244 -0
  3. package/bin/spiderlint.js +11 -0
  4. package/dist/agent.js +46 -0
  5. package/dist/cache/extractors.js +37 -0
  6. package/dist/cache/http.js +99 -0
  7. package/dist/cache/index.js +101 -0
  8. package/dist/cache/purge.js +63 -0
  9. package/dist/cache/status.js +52 -0
  10. package/dist/cli.js +602 -0
  11. package/dist/color.js +10 -0
  12. package/dist/config/environment.js +88 -0
  13. package/dist/config/index.js +104 -0
  14. package/dist/config/policy.js +170 -0
  15. package/dist/config/schema.js +127 -0
  16. package/dist/crawl/body.js +92 -0
  17. package/dist/crawl/browser.js +498 -0
  18. package/dist/crawl/crawl.js +58 -0
  19. package/dist/crawl/diversity.js +153 -0
  20. package/dist/crawl/dns.js +194 -0
  21. package/dist/crawl/fetch.js +48 -0
  22. package/dist/crawl/frontier.js +370 -0
  23. package/dist/crawl/guard.js +69 -0
  24. package/dist/crawl/http.js +171 -0
  25. package/dist/crawl/links.js +89 -0
  26. package/dist/crawl/log.js +27 -0
  27. package/dist/crawl/network.js +88 -0
  28. package/dist/crawl/probe.js +107 -0
  29. package/dist/crawl/profile.js +33 -0
  30. package/dist/crawl/rdap.js +86 -0
  31. package/dist/crawl/resolve.js +132 -0
  32. package/dist/crawl/resources.js +233 -0
  33. package/dist/crawl/robots.js +100 -0
  34. package/dist/crawl/route.js +92 -0
  35. package/dist/crawl/scope.js +32 -0
  36. package/dist/crawl/scratch.js +15 -0
  37. package/dist/crawl/sitemap.js +187 -0
  38. package/dist/crawl/svcb.js +59 -0
  39. package/dist/crawl/tls-hello.js +297 -0
  40. package/dist/crawl/tls-probe.js +71 -0
  41. package/dist/facts/browser.js +138 -0
  42. package/dist/facts/byline.js +17 -0
  43. package/dist/facts/co2.js +27 -0
  44. package/dist/facts/csp.js +71 -0
  45. package/dist/facts/export.js +79 -0
  46. package/dist/facts/flatten.js +33 -0
  47. package/dist/facts/graph.js +73 -0
  48. package/dist/facts/headers.js +330 -0
  49. package/dist/facts/html.js +230 -0
  50. package/dist/facts/labels.js +89 -0
  51. package/dist/facts/language.js +32 -0
  52. package/dist/facts/parity.js +38 -0
  53. package/dist/facts/read-note.js +11 -0
  54. package/dist/facts/resources.js +46 -0
  55. package/dist/facts/robots.js +13 -0
  56. package/dist/facts/sites.js +140 -0
  57. package/dist/facts/transport.js +200 -0
  58. package/dist/facts/types.js +4 -0
  59. package/dist/facts/vendors.js +76 -0
  60. package/dist/fold/index.js +94 -0
  61. package/dist/groups/assign.js +45 -0
  62. package/dist/groups/sample.js +60 -0
  63. package/dist/i18n.js +92 -0
  64. package/dist/index.js +607 -0
  65. package/dist/interrupt.js +37 -0
  66. package/dist/logger.js +95 -0
  67. package/dist/plugins/axe.js +104 -0
  68. package/dist/plugins/carbon-txt.js +96 -0
  69. package/dist/plugins/cookies-registry.js +25 -0
  70. package/dist/plugins/cookies.js +168 -0
  71. package/dist/plugins/css.js +328 -0
  72. package/dist/plugins/dns.js +864 -0
  73. package/dist/plugins/feed-content.js +132 -0
  74. package/dist/plugins/feed-model.js +282 -0
  75. package/dist/plugins/feeds.js +836 -0
  76. package/dist/plugins/html-validate.js +191 -0
  77. package/dist/plugins/htmlhint.js +95 -0
  78. package/dist/plugins/icons.js +302 -0
  79. package/dist/plugins/images.js +269 -0
  80. package/dist/plugins/index.js +286 -0
  81. package/dist/plugins/keyboard.js +194 -0
  82. package/dist/plugins/lighthouse.js +110 -0
  83. package/dist/plugins/link-text.js +73 -0
  84. package/dist/plugins/list.js +22 -0
  85. package/dist/plugins/live.js +254 -0
  86. package/dist/plugins/mail.js +985 -0
  87. package/dist/plugins/manifest.js +97 -0
  88. package/dist/plugins/markup.js +98 -0
  89. package/dist/plugins/messages.js +25 -0
  90. package/dist/plugins/network.js +192 -0
  91. package/dist/plugins/origin.js +488 -0
  92. package/dist/plugins/profile-links.js +102 -0
  93. package/dist/plugins/schema-registry.js +101 -0
  94. package/dist/plugins/sshfp.js +159 -0
  95. package/dist/plugins/stack.js +161 -0
  96. package/dist/plugins/structured-data.js +358 -0
  97. package/dist/plugins/tls-probe.js +298 -0
  98. package/dist/plugins/tls-registry.js +418 -0
  99. package/dist/plugins/tls-scan.js +280 -0
  100. package/dist/plugins/trackers-registry.js +27 -0
  101. package/dist/plugins/trackers.js +56 -0
  102. package/dist/plugins/types.js +7 -0
  103. package/dist/plugins/visit.js +50 -0
  104. package/dist/plugins/well-known-registry.js +111 -0
  105. package/dist/plugins/well-known.js +732 -0
  106. package/dist/progress.js +162 -0
  107. package/dist/report/agent.js +122 -0
  108. package/dist/report/checkstyle.js +22 -0
  109. package/dist/report/csv.js +18 -0
  110. package/dist/report/html.js +266 -0
  111. package/dist/report/human.js +220 -0
  112. package/dist/report/index.js +16 -0
  113. package/dist/report/json.js +8 -0
  114. package/dist/report/printable.js +29 -0
  115. package/dist/report/rating.js +26 -0
  116. package/dist/report/sarif.js +100 -0
  117. package/dist/report/stats.js +46 -0
  118. package/dist/rules/builtin.js +465 -0
  119. package/dist/rules/caching.js +103 -0
  120. package/dist/rules/catalog.js +139 -0
  121. package/dist/rules/clock.js +48 -0
  122. package/dist/rules/declarative.js +234 -0
  123. package/dist/rules/deprecated.js +45 -0
  124. package/dist/rules/disclosure.js +99 -0
  125. package/dist/rules/fix.js +21 -0
  126. package/dist/rules/i18n.js +131 -0
  127. package/dist/rules/insights.js +100 -0
  128. package/dist/rules/length.js +52 -0
  129. package/dist/rules/message.js +48 -0
  130. package/dist/rules/relations.js +88 -0
  131. package/dist/rules/robots.js +75 -0
  132. package/dist/rules/rulesets.js +134 -0
  133. package/dist/rules/run.js +75 -0
  134. package/dist/rules/score.js +61 -0
  135. package/dist/rules/types.js +7 -0
  136. package/dist/rules/url.js +90 -0
  137. package/dist/server/analytics.js +49 -0
  138. package/dist/server/api.js +134 -0
  139. package/dist/server/clients.js +54 -0
  140. package/dist/server/clock.js +33 -0
  141. package/dist/server/compress.js +58 -0
  142. package/dist/server/health.js +14 -0
  143. package/dist/server/jobs.js +82 -0
  144. package/dist/server/main.js +83 -0
  145. package/dist/server/page.js +161 -0
  146. package/dist/server/policy.js +145 -0
  147. package/dist/server/providers.js +60 -0
  148. package/dist/server/queue.js +50 -0
  149. package/dist/server/scan.js +39 -0
  150. package/dist/server/settings.js +197 -0
  151. package/dist/server/web.js +353 -0
  152. package/dist/server/worker.js +129 -0
  153. package/dist/store/disk.js +215 -0
  154. package/dist/store/memory.js +18 -0
  155. package/dist/telemetry-environment.js +9 -0
  156. package/dist/telemetry.js +104 -0
  157. package/locales/es/LC_MESSAGES/messages.po +1864 -0
  158. package/locales/uk/LC_MESSAGES/messages.po +1864 -0
  159. package/package.json +134 -2
  160. package/presets/browser.yaml +26 -0
  161. package/presets/graph.yaml +35 -0
  162. package/presets/i18n.yaml +18 -0
  163. package/presets/insights.yaml +12 -0
  164. package/presets/links.yaml +22 -0
  165. package/presets/parity.yaml +59 -0
  166. package/presets/performance.yaml +271 -0
  167. package/presets/recommended.yaml +50 -0
  168. package/presets/redirects.yaml +23 -0
  169. package/presets/resources.yaml +29 -0
  170. package/presets/robots.yaml +13 -0
  171. package/presets/security-headers.yaml +257 -0
  172. package/presets/seo.yaml +380 -0
  173. package/presets/sitemap.yaml +34 -0
  174. package/presets/suggestions.yaml +97 -0
  175. package/presets/sustainability.yaml +16 -0
  176. package/presets/tls.yaml +72 -0
  177. package/presets/vendor.yaml +14 -0
  178. package/presets/web-comprehensive.yaml +17 -0
  179. package/presets/web-quick.yaml +15 -0
  180. package/src/server/apple-touch-icon.png +0 -0
  181. package/src/server/logo-36.png +0 -0
  182. package/src/server/logo-72.png +0 -0
  183. package/src/server/logo.png +0 -0
  184. package/vendors/dns.yaml +21 -0
  185. package/vendors/paths.yaml +55 -0
  186. package/vendors/stack.yaml +57 -0
@@ -0,0 +1,75 @@
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 { scoreOf, weight } from "./score.js";
6
+ import { isPageRule } from "./types.js";
7
+ // Key of the (group, rule) cell the fold reads.
8
+ export function cell(group, rule) {
9
+ return `${group}\t${rule}`;
10
+ }
11
+ // One subject a rule judged, none when it had no pages or is a hint; it fails on any finding above `info`, at the cost of its worst score.
12
+ function judged(run, found, rule, pages = 1) {
13
+ if (pages === 0 || rule.meta.severity === "hint")
14
+ return;
15
+ run.checks.total += 1;
16
+ if (found.some((finding) => finding.severity === "error"))
17
+ run.checks.errored += 1;
18
+ const failing = found.filter((finding) => finding.severity !== "info" && finding.severity !== "hint");
19
+ const tally = run.perRule.get(rule.meta.id) ?? { checks: 0, failed: 0, pages: 0 };
20
+ run.perRule.set(rule.meta.id, { checks: tally.checks + 1, failed: tally.failed + (failing.length > 0 ? 1 : 0), pages: tally.pages + pages });
21
+ if (failing.length === 0)
22
+ return;
23
+ run.checks.failed += 1;
24
+ const worst = Math.max(...failing.map((finding) => scoreOf(finding)));
25
+ run.checks.cost = Number((run.checks.cost + weight(worst)).toFixed(4));
26
+ }
27
+ // How the crawl read a page: when, by which crawler, and whether the origin only confirmed a stored copy.
28
+ export function pageEvidence(page) {
29
+ return { bucket: "pages", key: page.url.href, ...(page.crawl.at && { at: page.crawl.at }), via: page.http.revalidated ? "revalidated" : "network", ...(page.crawl.mode && { mode: page.crawl.mode }) };
30
+ }
31
+ // One page rule over one group; `undefined` results are `when`-skips and do not count.
32
+ function runPageRule(rule, members, group, run, site) {
33
+ let applicable = 0;
34
+ for (const page of members) {
35
+ const found = rule.check(page, site);
36
+ if (found === undefined)
37
+ continue;
38
+ applicable += 1;
39
+ judged(run, found, rule);
40
+ const read = pageEvidence(page);
41
+ run.findings.push(...found.map((finding) => ({ ...finding, evidence: [read, ...(finding.evidence ?? [])] })));
42
+ }
43
+ run.applicable.set(cell(group, rule.meta.id), applicable);
44
+ }
45
+ // Page and group rules run within their group; site rules run once over the crawl.
46
+ export function runRules(pages, rulesByGroup, facts) {
47
+ const run = { findings: [], applicable: new Map(), perRule: new Map(), checks: { total: 0, failed: 0, errored: 0, cost: 0 } };
48
+ const site = new Map();
49
+ for (const [group, rules] of rulesByGroup) {
50
+ const members = pages.filter((page) => page.group === group);
51
+ for (const rule of rules) {
52
+ const before = run.findings.length;
53
+ if (isPageRule(rule))
54
+ runPageRule(rule, members, group, run, facts);
55
+ else if (rule.meta.scope === "group") {
56
+ const found = rule.check(members, group, facts) ?? [];
57
+ judged(run, found, rule, members.length);
58
+ run.findings.push(...found);
59
+ }
60
+ else if (!site.has(rule.meta.id))
61
+ site.set(rule.meta.id, rule);
62
+ log.debug({ rule: rule.meta.id, group, pages: members.length, findings: run.findings.length - before }, "rule ran");
63
+ }
64
+ }
65
+ for (const rule of site.values()) {
66
+ const found = rule.check(pages, undefined, facts);
67
+ log.debug({ rule: rule.meta.id, scope: "site", pages: pages.length, findings: found?.length, isSkipped: found === undefined }, "rule ran");
68
+ if (found === undefined)
69
+ continue;
70
+ judged(run, found, rule, pages.length);
71
+ run.findings.push(...found);
72
+ }
73
+ log.debug(run.checks, "checks judged");
74
+ return run;
75
+ }
@@ -0,0 +1,61 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ // Each level’s lowest and highest score in tenths, and the score a level alone stands for.
5
+ const BANDS = {
6
+ hint: { min: 0, max: 0.9, base: 0.5 },
7
+ info: { min: 1, max: 3.2, base: 2 },
8
+ warning: { min: 3.3, max: 6.5, base: 5 },
9
+ error: { min: 6.6, max: 9.9, base: 8 },
10
+ };
11
+ const LEVELS = ["error", "warning", "info", "hint"];
12
+ // A score on the one-decimal grid, 0.0 to 9.9.
13
+ export function round(score) {
14
+ return Math.min(9.9, Math.max(0, Math.round(score * 10) / 10));
15
+ }
16
+ // The level whose band holds the score.
17
+ export function levelOf(score) {
18
+ const tenths = round(score);
19
+ return LEVELS.find((level) => tenths >= BANDS[level].min) ?? "hint";
20
+ }
21
+ // The score a rule gets for a level alone.
22
+ export function baseScore(level) {
23
+ return BANDS[level].base;
24
+ }
25
+ // The score moved into a level’s band, so a pinned level never leaves it.
26
+ export function pin(level, score) {
27
+ return Math.min(BANDS[level].max, Math.max(BANDS[level].min, round(score)));
28
+ }
29
+ // The score between the two scale points around `value`, flat beyond the ends.
30
+ export function interpolate(scale, value) {
31
+ const points = scale.toSorted(([a], [b]) => a - b);
32
+ const at = Math.min(Math.max(value, points[0][0]), points.at(-1)[0]);
33
+ const index = Math.max(1, points.findIndex(([x]) => x >= at));
34
+ const [x0, y0] = points[index - 1];
35
+ const [x1, y1] = points[index];
36
+ return round(y0 + ((y1 - y0) * (at - x0)) / (x1 - x0));
37
+ }
38
+ // A finding’s score, the level’s base when no rule computed one.
39
+ export function scoreOf(finding) {
40
+ return finding.score ?? BANDS[finding.severity].base;
41
+ }
42
+ // What a failed check costs the rating: 1 at the warning base, rising with the square of the score.
43
+ export function weight(score) {
44
+ return (score / BANDS.warning.base) ** 2;
45
+ }
46
+ // Pages the finding touches: its folded count or listed URLs, else the whole site for a site-wide one, else one.
47
+ export function pagesOf(finding, total) {
48
+ return finding.occurrences ?? finding.urls?.length ?? (finding.scope === "site" ? total : 1);
49
+ }
50
+ // Score times the pages the finding touches.
51
+ export function impact(finding, total) {
52
+ return scoreOf(finding) * pagesOf(finding, total);
53
+ }
54
+ // Score times the share of the site’s pages the finding touches; a site-wide finding touches all of them.
55
+ export function importance(finding, total) {
56
+ return scoreOf(finding) * (total === 0 ? 1 : Math.min(1, pagesOf(finding, total) / total));
57
+ }
58
+ // Orders findings from the most to the least important, then by rule and URL.
59
+ export function byImportance(total) {
60
+ return (a, b) => importance(b, total) - importance(a, total) || a.rule.localeCompare(b.rule) || a.url.localeCompare(b.url);
61
+ }
@@ -0,0 +1,7 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ // TS does not narrow a union on a nested discriminant, so the guard is explicit.
5
+ export function isPageRule(rule) {
6
+ return rule.meta.scope === "page";
7
+ }
@@ -0,0 +1,90 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import anyAscii from "any-ascii";
5
+ import { log } from "../logger.js";
6
+ import { said } from "./message.js";
7
+ // A slug that is only an identifier: digits, a hex run with a digit, or a UUID.
8
+ const ID_ONLY = /^(?:\d+|(?=[a-f]*\d)[\da-f]{6,}|[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12})$/i;
9
+ // A last segment naming a file by its extension.
10
+ const FILE = /\.[a-z\d]{1,5}$/i;
11
+ // Lower-case ASCII word tokens of `text`, in any script, percent-decoded.
12
+ export function tokens(text) {
13
+ let decoded = text;
14
+ try {
15
+ decoded = decodeURIComponent(text);
16
+ }
17
+ catch {
18
+ log.debug({ text }, "slug not percent-decodable");
19
+ }
20
+ return new Set(anyAscii(decoded.normalize("NFKC"))
21
+ .toLowerCase()
22
+ .split(/[^a-z\d]+/)
23
+ .filter((token) => token.length > 0));
24
+ }
25
+ // The last non-empty path segment without its extension; empty for the root.
26
+ function slugOf(page) {
27
+ return (page.url.pathname.split("/").findLast((segment) => segment.length > 0) ?? "").replace(FILE, "");
28
+ }
29
+ // A slug that is an identifier or shares no word with the page’s title or first heading.
30
+ const readableSlug = (severity) => ({
31
+ meta: { id: "url/readable-slug", severity, scope: "page", facts: ["url.pathname", "html.title", "html.h1"], docs: "https://developers.google.com/search/docs/crawling-indexing/url-structure", fix: "Name the page in its URL with words from its title, as /spring-recipes for “Spring recipes”." },
32
+ check(page) {
33
+ const slug = slugOf(page);
34
+ const heading = [page.html?.title ?? "", ...(page.html?.h1 ?? [])].join(" ");
35
+ if (!slug || !heading.trim())
36
+ return;
37
+ const isId = ID_ONLY.test(slug);
38
+ const shared = tokens(slug).intersection(tokens(heading)).size;
39
+ log.debug({ rule: "url/readable-slug", url: page.url.href, slug, isId, shared }, "slug read against the title");
40
+ if (!isId && shared > 0)
41
+ return [];
42
+ const title = `“${page.html?.title ?? page.html?.h1[0]}”`;
43
+ const sentence = said(isId ? "the slug is an identifier, not words" : "the slug shares no word with the page title");
44
+ return [{ rule: "url/readable-slug", severity, scope: "page", url: page.url.href, group: page.group, ...sentence, ...(!isId && { data: { [page.url.href]: { title } } }), value: slug }];
45
+ },
46
+ });
47
+ // A site finding naming the majority form and every page in the minority, when both forms appear.
48
+ function minority(id, severity, text, variables, forms) {
49
+ const ranked = forms
50
+ .entries()
51
+ .toArray()
52
+ .toSorted(([, a], [, b]) => b.length - a.length);
53
+ const [[major, most] = ["", []], ...rest] = ranked;
54
+ const urls = rest.flatMap(([, pages]) => pages.map((page) => page.url.href));
55
+ log.debug({ rule: id, text, major, most: most.length, minor: urls.length }, "url forms counted");
56
+ const data = Object.fromEntries(rest.flatMap(([form, pages]) => pages.map((page) => [page.url.href, { form }])));
57
+ return urls.length === 0 ? undefined : { rule: id, severity, scope: "site", url: urls[0], ...said(text, { ...variables, count: most.length, total: most.length + urls.length, major }), data, value: Object.fromEntries(ranked.map(([form, pages]) => [form, pages.length])), urls };
58
+ }
59
+ // Pages whose words join with the separator fewer pages of the site use.
60
+ const separators = (severity) => ({
61
+ meta: { id: "url/separators", severity, scope: "site", facts: ["url.pathname"], docs: "https://developers.google.com/search/docs/crawling-indexing/url-structure", fix: "Join the words of every URL with hyphens, and redirect the others to them." },
62
+ check(pages) {
63
+ const forms = new Map();
64
+ for (const page of pages) {
65
+ const path = page.url.pathname.replace(FILE, "");
66
+ const form = /\w_\w/.test(path) ? (/\w-\w/.test(path) ? "mix both" : "use _") : /\w-\w/.test(path) ? "use -" : undefined;
67
+ if (form)
68
+ forms.set(form, [...(forms.get(form) ?? []), page]);
69
+ }
70
+ const finding = minority("url/separators", severity, "word separators differ: {count} of {total} pages {major}; the others differ", {}, forms);
71
+ return finding ? [finding] : [];
72
+ },
73
+ });
74
+ // Per group, pages whose trailing slash differs from the group’s usual form; the root and file names are left out.
75
+ const trailingSlash = (severity) => ({
76
+ meta: { id: "url/trailing-slash", severity, scope: "site", facts: ["url.pathname"], docs: "https://developers.google.com/search/blog/2010/04/to-slash-or-not-to-slash", fix: "Pick one form, with or without the trailing slash, and redirect the other to it." },
77
+ check(pages) {
78
+ const judged = pages.filter((page) => page.url.pathname !== "/" && !FILE.test(page.url.pathname));
79
+ return Map.groupBy(judged, (page) => page.group)
80
+ .entries()
81
+ .map(([group, members]) => minority("url/trailing-slash", severity, "trailing slashes differ in group {group}: {count} of {total} pages {major}; the others differ", { group }, Map.groupBy(members, (page) => (page.url.pathname.endsWith("/") ? "end with /" : "end without /"))))
82
+ .filter((finding) => finding !== undefined)
83
+ .toArray();
84
+ },
85
+ });
86
+ export const urlRules = {
87
+ "url/readable-slug": readableSlug,
88
+ "url/separators": separators,
89
+ "url/trailing-slash": trailingSlash,
90
+ };
@@ -0,0 +1,49 @@
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 TIMEOUT_MS = 3000;
6
+ const PAUSE_MS = 60_000;
7
+ const NAMES = { "/": "Scan form", "/jobs/:id": "Scan report" };
8
+ // Tracking stays off until this time after a failure, so a down Matomo costs a page one timeout, not every page.
9
+ const pause = { until: 0, failures: 0 };
10
+ // Whether the visitor asked not to be tracked.
11
+ function isRefused(c) {
12
+ return c.req.header("dnt") === "1" || c.req.header("sec-gpc") === "1";
13
+ }
14
+ // Reports the page `route` the visitor just got, without waiting for Matomo; the badge and the API never come here, and a visitor who sent DNT or Sec-GPC is not reported.
15
+ export function track(matomo, c, route, host) {
16
+ if (!matomo)
17
+ return;
18
+ const name = NAMES[route];
19
+ if (!name || c.req.method !== "GET" || Date.now() < pause.until || isRefused(c)) {
20
+ log.debug({ route, method: c.req.method, isRefused: isRefused(c), isPaused: Date.now() < pause.until }, "page view not tracked");
21
+ return;
22
+ }
23
+ const address = new URL("matomo.php", matomo.url);
24
+ const parameters = {
25
+ idsite: String(matomo.siteId),
26
+ rec: "1",
27
+ apiv: "1",
28
+ send_image: "0",
29
+ url: new URL(route, matomo.site).href,
30
+ action_name: host && matomo.isHostNamed ? `${name}: ${host}` : name,
31
+ ua: c.req.header("user-agent") ?? "",
32
+ lang: c.req.header("accept-language") ?? "",
33
+ rand: String(Math.random()).slice(2),
34
+ };
35
+ address.search = new URLSearchParams(parameters).toString();
36
+ void fetch(address, { signal: AbortSignal.timeout(TIMEOUT_MS), redirect: "manual" })
37
+ .then((response) => {
38
+ if (response.status >= 500)
39
+ throw new Error(`answered ${response.status}`);
40
+ pause.failures = 0;
41
+ log.debug({ route, status: response.status }, "page view tracked");
42
+ return response.status;
43
+ })
44
+ .catch((error) => {
45
+ pause.failures += 1;
46
+ pause.until = Date.now() + PAUSE_MS * Math.min(pause.failures, 10);
47
+ log.warn({ route, failures: pause.failures, error: error instanceof Error ? error.message : String(error) }, "page view not tracked; tracking paused");
48
+ });
49
+ }
@@ -0,0 +1,134 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import { Hono } from "hono";
5
+ import { bodyLimit } from "hono/body-limit";
6
+ import { routePath } from "hono/route";
7
+ import { secureHeaders } from "hono/secure-headers";
8
+ import { streamSSE } from "hono/streaming";
9
+ import { plain } from "../color.js";
10
+ import { negotiate } from "../i18n.js";
11
+ import { log } from "../logger.js";
12
+ import { formatNames, formatter } from "../plugins/index.js";
13
+ import { BlockList } from "node:net";
14
+ import { Buckets } from "./clients.js";
15
+ import { clientAddress, jobOf, submit, view } from "./jobs.js";
16
+ import { Refusal } from "./policy.js";
17
+ import { edgeRanges } from "./providers.js";
18
+ import { PAGE_CSP, web } from "./web.js";
19
+ import { countRefusal, countRequest, inSpan, withTraceCarrier } from "../telemetry.js";
20
+ export const BODY_MAX = 64 * 1024;
21
+ const EVENTS_MS = 1000;
22
+ const EDGES_MS = 24 * 60 * 60 * 1000;
23
+ // Media type and file extension per bundled format; a plugin’s format is plain text.
24
+ const MEDIA = { json: ["application/json", "json"], sarif: ["application/sarif+json", "sarif"], csv: ["text/csv", "csv"], checkstyle: ["application/xml", "xml"], human: ["text/plain", "txt"], html: ["text/html", "html"] };
25
+ // The refusal as `{ error: { code, message } }`, with Retry-After when it has one.
26
+ export function refused(c, refusal) {
27
+ countRefusal(refusal.code);
28
+ if (refusal.retryAfter !== undefined)
29
+ c.header("retry-after", String(refusal.retryAfter));
30
+ return c.json({ error: { code: refusal.code, message: refusal.message } }, refusal.status);
31
+ }
32
+ // The routes over one queue; `settings` is read per request, so a reloaded policy applies at once.
33
+ export function api(queue, redis, settings) {
34
+ const jobs = { queue, redis, settings, buckets: new Buckets(), edges: { list: new BlockList() } };
35
+ // CDN edge ranges, fetched now and daily, so a new edge is trusted without a restart.
36
+ const refresh = async () => {
37
+ const { providers } = settings().clients;
38
+ if (providers.length > 0)
39
+ jobs.edges.list = await edgeRanges(providers);
40
+ };
41
+ void refresh();
42
+ setInterval(() => void refresh(), EDGES_MS).unref();
43
+ const app = new Hono();
44
+ // One span and one count per request, continuing a trace the caller sends in `traceparent`.
45
+ app.use(async (c, next) => withTraceCarrier(c.req.header(), async () => inSpan(`${c.req.method} ${c.req.path}`, { "http.request.method": c.req.method, "url.path": c.req.path }, async (span) => {
46
+ await next();
47
+ const route = routePath(c, -1);
48
+ span.updateName(`${c.req.method} ${route}`);
49
+ span.setAttributes({ "http.route": route, "http.response.status_code": c.res.status });
50
+ countRequest(route, c.req.method, c.res.status);
51
+ })));
52
+ // Registered before the rest so it runs last: a badge may be embedded by any site.
53
+ app.use("/badge/*", async (c, next) => {
54
+ await next();
55
+ c.header("cross-origin-resource-policy", "cross-origin");
56
+ });
57
+ app.use(secureHeaders({ xXssProtection: false }));
58
+ app.use(async (c, next) => {
59
+ const started = performance.now();
60
+ await next();
61
+ log.info({ client: clientAddress(c, jobs), method: c.req.method, path: c.req.path, status: c.res.status, ms: Math.round(performance.now() - started) }, "request served");
62
+ });
63
+ app.onError((error, c) => {
64
+ if (error instanceof Refusal)
65
+ return refused(c, error);
66
+ log.error({ client: clientAddress(c, jobs), path: c.req.path, error: error.message }, "request failed");
67
+ return c.json({ error: { code: "internal", message: "internal error" } }, 500);
68
+ });
69
+ app.notFound((c) => {
70
+ if (c.req.path.startsWith("/v1/") || c.req.path === "/healthz")
71
+ return refused(c, new Refusal(404, "not-found", `${c.req.path}: no such route`));
72
+ c.header("content-security-policy", PAGE_CSP);
73
+ c.header("cache-control", "no-cache");
74
+ return c.html('<!DOCTYPE html>\n<html lang="en" dir="ltr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Not found</title></head><body><main><h1>Not found</h1><p><a href="/">Start a new scan</a></p></main></body></html>\n', 404);
75
+ });
76
+ app.post("/v1/jobs", bodyLimit({ maxSize: BODY_MAX, onError: (c) => refused(c, new Refusal(400, "invalid-body", `the body exceeds ${BODY_MAX} bytes`)) }), async (c) => {
77
+ let body;
78
+ try {
79
+ body = await c.req.json();
80
+ }
81
+ catch {
82
+ throw new Refusal(400, "invalid-body", "the body is not JSON");
83
+ }
84
+ const { job, isRepeat } = await submit(jobs, body, c);
85
+ c.header("location", `/v1/jobs/${job.id}`);
86
+ return c.json(await view(job), isRepeat ? 200 : 202);
87
+ });
88
+ app.get("/v1/jobs/:id", async (c) => {
89
+ const job = await jobOf(queue, c.req.param("id"));
90
+ return c.json(await view(job));
91
+ });
92
+ app.get("/v1/jobs/:id/events", async (c) => {
93
+ const id = c.req.param("id");
94
+ await jobOf(queue, id);
95
+ return streamSSE(c, async (stream) => {
96
+ let last = "";
97
+ while (!stream.aborted) {
98
+ const job = (await queue.getJob(id));
99
+ const current = job ? await view(job) : undefined;
100
+ const data = JSON.stringify(current ?? { id, status: "expired" });
101
+ const event = current ? (["done", "failed"].includes(current.status) ? current.status : "progress") : "expired";
102
+ if (data !== last)
103
+ await stream.writeSSE({ event, data });
104
+ log.debug({ job: id, event, isChanged: data !== last }, "job event polled");
105
+ last = data;
106
+ if (event !== "progress")
107
+ break;
108
+ await stream.sleep(EVENTS_MS);
109
+ }
110
+ });
111
+ });
112
+ app.get("/v1/jobs/:id/report/:format", async (c) => {
113
+ const job = await jobOf(queue, c.req.param("id"));
114
+ const name = c.req.param("format");
115
+ const format = formatter(name);
116
+ if (!format)
117
+ throw new Refusal(404, "unknown-format", `${name}: not one of ${formatNames().join(", ")}`);
118
+ if ((await job.getState()) !== "completed")
119
+ throw new Refusal(409, "not-ready", `job ${job.id}: no report until it is done`);
120
+ const [media, extension] = MEDIA[name] ?? ["text/plain", "txt"];
121
+ const report = { pages: [], site: {}, ...job.returnvalue };
122
+ c.header("content-type", `${media}; charset=utf-8`);
123
+ c.header("content-security-policy", PAGE_CSP);
124
+ c.header("content-disposition", `inline; filename="spiderlint-${job.data.host.replaceAll(/[^\w.-]/g, "-")}.${extension}"`);
125
+ const lang = negotiate(c.req.header("accept-language"));
126
+ return c.body(format(report, plain, false, lang));
127
+ });
128
+ app.get("/healthz", async (c) => {
129
+ const [pong, counts] = await Promise.all([redis.ping(), queue.getJobCounts("waiting", "active", "delayed", "completed", "failed")]);
130
+ return c.json({ status: pong === "PONG" ? "ok" : "degraded", queue: counts });
131
+ });
132
+ app.route("/", web(jobs));
133
+ return app;
134
+ }
@@ -0,0 +1,54 @@
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
+ const SWEEP_MS = 60_000;
7
+ // An IPv4-mapped IPv6 address as its IPv4 form.
8
+ function plain(address) {
9
+ return /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address)?.[1] ?? address;
10
+ }
11
+ function isTrusted(address, trusted) {
12
+ return isIP(address) !== 0 && trusted.some((list) => list.check(address, isIP(address) === 6 ? "ipv6" : "ipv4"));
13
+ }
14
+ // The peer, or while the hop in hand is a trusted proxy, the X-Forwarded-For entry it added, read right to left.
15
+ export function clientOf(peer, forwarded, trusted) {
16
+ let client = plain(peer);
17
+ const hops = (forwarded ?? "")
18
+ .split(",")
19
+ .map((entry) => plain(entry.trim()))
20
+ .toReversed();
21
+ for (const hop of hops) {
22
+ if (!isTrusted(client, trusted) || isIP(hop) === 0)
23
+ break;
24
+ client = hop;
25
+ }
26
+ return client;
27
+ }
28
+ // Token buckets per client address, in memory only, so an address never reaches Redis.
29
+ export class Buckets {
30
+ #buckets = new Map();
31
+ #swept = 0;
32
+ // Forgets buckets full again, at most once a minute.
33
+ #sweep(jobs, perMs, now) {
34
+ if (now - this.#swept < SWEEP_MS)
35
+ return;
36
+ this.#swept = now;
37
+ for (const [client, bucket] of this.#buckets)
38
+ if (bucket.tokens + (now - bucket.at) * perMs >= jobs)
39
+ this.#buckets.delete(client);
40
+ log.debug({ clients: this.#buckets.size }, "client buckets swept");
41
+ }
42
+ // Takes a token from the bucket of `rate.jobs` refilled evenly over `rate.seconds`; 0 when taken, else the seconds until one is back.
43
+ take(client, rate, now = Date.now()) {
44
+ const perMs = rate.jobs / (rate.seconds * 1000);
45
+ this.#sweep(rate.jobs, perMs, now);
46
+ const bucket = this.#buckets.get(client);
47
+ const tokens = bucket ? Math.min(rate.jobs, bucket.tokens + (now - bucket.at) * perMs) : rate.jobs;
48
+ const isTaken = tokens >= 1;
49
+ this.#buckets.set(client, { tokens: isTaken ? tokens - 1 : tokens, at: now });
50
+ const wait = isTaken ? 0 : Math.ceil((1 - tokens) / perMs / 1000);
51
+ log.debug({ tokens: Math.floor(tokens), jobs: rate.jobs, wait }, "client bucket taken");
52
+ return wait;
53
+ }
54
+ }
@@ -0,0 +1,33 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import { fetchRetrying, reason } from "../crawl/fetch.js";
5
+ import { dateSkew } from "../facts/transport.js";
6
+ import { log } from "../logger.js";
7
+ import { isOwnClock } from "../rules/clock.js";
8
+ // Seconds `url`’s `Date` runs ahead of our clock, or undefined when it does not answer or sends none.
9
+ async function skewOf(url) {
10
+ try {
11
+ const { value, ms } = await fetchRetrying(url, async (response) => {
12
+ const firstByte = Date.now();
13
+ await response.body?.cancel();
14
+ return { firstByte, headers: Object.fromEntries(response.headers) };
15
+ });
16
+ return dateSkew(url, value.headers, value.firstByte - ms, value.firstByte);
17
+ }
18
+ catch (error) {
19
+ log.debug({ url, error: reason(error) }, "clock reference unreachable");
20
+ return undefined;
21
+ }
22
+ }
23
+ // Compares our clock with each reference origin’s `Date` and warns when they all disagree with us alike.
24
+ export async function checkClock(references) {
25
+ if (references.length === 0)
26
+ return log.debug({ references }, "clock check disabled");
27
+ const answers = await Promise.all(references.map(async (url) => skewOf(url)));
28
+ const skews = answers.filter((skew) => skew !== undefined);
29
+ if (isOwnClock(skews))
30
+ log.warn({ offset: -skews[0], references: skews.length }, "the local clock is off; clock skew findings are unreliable");
31
+ else
32
+ log.info({ skews, references: references.length }, "local clock checked");
33
+ }
@@ -0,0 +1,58 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import { Duplex } from "node:stream";
5
+ import { createBrotliCompress, createDeflate, createGzip, createZstdCompress } from "node:zlib";
6
+ import { COMPRESSIBLE_CONTENT_TYPE_REGEX } from "hono/compress";
7
+ import { log } from "../logger.js";
8
+ const THRESHOLD = 1024;
9
+ const NO_TRANSFORM = /(?:^|,)\s*?no-transform\s*?(?:,|$)/i;
10
+ const VARY_ENCODING = /(?:^|,)\s*accept-encoding\s*(?:,|$)/i;
11
+ // Codings negotiated, best first; Hono’s own middleware stops at gzip and deflate.
12
+ const CODERS = { zstd: createZstdCompress, br: createBrotliCompress, gzip: createGzip, deflate: createDeflate };
13
+ const OFFERED = Object.keys(CODERS);
14
+ // The best of `offered` the client’s Accept-Encoding names, ties to the earlier one; undefined when it names none.
15
+ function preferred(header, offered) {
16
+ if (header === undefined)
17
+ return undefined;
18
+ const entries = header.split(",").map((part) => {
19
+ const [token = "", ...parameters] = part.trim().split(";");
20
+ const q = Number(parameters
21
+ .map((parameter) => parameter.trim())
22
+ .find((parameter) => parameter.startsWith("q="))
23
+ ?.slice(2) ?? 1);
24
+ return { token: token.trim().toLowerCase(), q: Number.isFinite(q) ? q : 0 };
25
+ });
26
+ const wildcard = entries.find((entry) => entry.token === "*")?.q ?? 0;
27
+ let [best, bestQ] = [undefined, 0];
28
+ for (const name of offered) {
29
+ const q = entries.find((entry) => entry.token === name)?.q ?? wildcard;
30
+ if (q > bestQ)
31
+ [best, bestQ] = [name, q];
32
+ }
33
+ return best;
34
+ }
35
+ // Answers compressible bodies in the client’s best coding, as Hono’s compress does plus zstd and br.
36
+ export function compress(threshold = THRESHOLD) {
37
+ return async function compress(context, next) {
38
+ await next();
39
+ const headers = context.res.headers;
40
+ const type = headers.get("content-type");
41
+ const declared = headers.get("content-length");
42
+ const length = declared === null ? undefined : Number(declared);
43
+ const isUntouched = context.req.method === "HEAD" || context.res.status === 206 || headers.has("content-encoding") || headers.has("transfer-encoding") || NO_TRANSFORM.test(headers.get("cache-control") ?? "");
44
+ if (isUntouched || !type || !COMPRESSIBLE_CONTENT_TYPE_REGEX.test(type) || (length !== undefined && (!Number.isFinite(length) || length < threshold)))
45
+ return;
46
+ const coding = preferred(context.req.header("accept-encoding"), OFFERED);
47
+ if (coding === undefined || !context.res.body)
48
+ return;
49
+ log.debug({ coding, path: context.req.path }, "response compressed");
50
+ const pipe = Duplex.toWeb(CODERS[coding]());
51
+ context.res = new Response(context.res.body.pipeThrough(pipe), context.res);
52
+ context.res.headers.delete("content-length");
53
+ context.res.headers.set("content-encoding", coding);
54
+ const current = context.res.headers.get("vary");
55
+ if (current !== "*" && !(current && VARY_ENCODING.test(current)))
56
+ context.res.headers.set("vary", current ? `${current}, Accept-Encoding` : "Accept-Encoding");
57
+ };
58
+ }
@@ -0,0 +1,14 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import { DEFAULT_PATH, readSettings } from "./settings.js";
5
+ // Exits 0 when the API answers /healthz on the address the settings name, 1 otherwise.
6
+ const { listen } = readSettings(process.env.SPIDERLINT_SERVER_CONFIG ?? DEFAULT_PATH);
7
+ const host = ["0.0.0.0", "::"].includes(listen.host) ? "127.0.0.1" : listen.host;
8
+ try {
9
+ const response = await fetch(`http://${host.includes(":") ? `[${host}]` : host}:${listen.port}/healthz`, { signal: AbortSignal.timeout(2000) });
10
+ process.exitCode = response.ok ? 0 : 1;
11
+ }
12
+ catch {
13
+ process.exitCode = 1;
14
+ }