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,358 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import { load } from "cheerio";
5
+ import { log } from "../logger.js";
6
+ import { pageRule, resolve } from "../rules/builtin.js";
7
+ import { said } from "../rules/message.js";
8
+ import { SUPERSEDED } from "./schema-registry.js";
9
+ import { definePlugin } from "./types.js";
10
+ const ID = "structureddata";
11
+ const GALLERY = "https://developers.google.com/search/docs/appearance/structured-data/search-gallery";
12
+ // Properties a rich result requires per schema.org type, from Google Search Central’s per-feature pages as of 2026-09-25; refreshed by hand.
13
+ const REQUIRED = {
14
+ BreadcrumbList: ["itemListElement"],
15
+ Course: ["name", "description"],
16
+ Dataset: ["name", "description"],
17
+ Event: ["name", "startDate", "location"],
18
+ FAQPage: ["mainEntity"],
19
+ JobPosting: ["title", "description", "datePosted", "hiringOrganization"],
20
+ LocalBusiness: ["name", "address"],
21
+ Product: ["name"],
22
+ Recipe: ["name", "image"],
23
+ Review: ["author", "itemReviewed", "reviewRating"],
24
+ VideoObject: ["name", "thumbnailUrl", "uploadDate"],
25
+ };
26
+ // Types describing the page they sit on, whose `name` should echo its title and whose `url` its canonical.
27
+ const PAGES = new Set(["WebPage", "AboutPage", "CheckoutPage", "CollectionPage", "ContactPage", "FAQPage", "ItemPage", "MedicalWebPage", "ProfilePage", "QAPage", "SearchResultsPage"]);
28
+ // Article types, whose `headline` stands in for `name` and which carry a publication date.
29
+ const ARTICLES = new Set(["Article", "AdvertiserContentArticle", "AnalysisNewsArticle", "BackgroundNewsArticle", "BlogPosting", "LiveBlogPosting", "NewsArticle", "OpinionNewsArticle", "Report", "ReportageNewsArticle", "ReviewNewsArticle", "SatiricalArticle", "ScholarlyArticle", "SocialMediaPosting", "TechArticle"]);
30
+ // Per-page types never expected to share one identity across the site.
31
+ const PER_PAGE = new Set([...PAGES, ...ARTICLES, "BreadcrumbList", "ListItem"]);
32
+ // ISO 8601 date or date-time, as schema.org `Date` and `DateTime` accept it.
33
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/;
34
+ // Date-valued properties outside the `date*` and `*Date` names.
35
+ const DATES = new Set(["uploadDate", "validFrom", "validThrough", "expires", "priceValidUntil", "availabilityStarts", "availabilityEnds"]);
36
+ const isNode = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
37
+ // A vocabulary term’s bare name: `https://schema.org/Person` and `schema:Person` are `Person`.
38
+ const bare = (term) => term.replace(/^.*[/#:]/, "");
39
+ // An `@id` made absolute against the page, fragment kept; a blank node stays as written.
40
+ const absolute = (id, base) => (!id.startsWith("_:") && URL.canParse(id, base) ? new URL(id, base).href : id);
41
+ // The bare terms of a space-separated attribute.
42
+ const terms = (attribute) => (attribute ?? "")
43
+ .split(/\s+/)
44
+ .filter(Boolean)
45
+ .map((term) => bare(term));
46
+ const MICRODATA = { scope: "itemscope", prop: "itemprop", id: ["itemid"], type: "itemtype", items: "[itemscope]", props: "[itemprop]", tops: "[itemscope]:not([itemprop])" };
47
+ const RDFA = { scope: "typeof", prop: "property", id: ["resource", "about"], type: "typeof", items: "[typeof]", props: "[property]", tops: "[typeof]:not([property])" };
48
+ // The value a property element carries: its `content`, a URL attribute resolved, a machine value, or its text.
49
+ function valueOf($, element, base) {
50
+ const node = $(element);
51
+ const url = node.attr("href") ?? node.attr("src") ?? node.attr("data");
52
+ return node.attr("content") ?? (url === undefined ? undefined : resolve(url, base)) ?? node.attr("value") ?? node.attr("datetime") ?? node.text().replaceAll(/\s+/g, " ").trim();
53
+ }
54
+ // One item as a JSON-LD-shaped node: `@type`, `@id` and each property, nested items as nodes.
55
+ function itemOf($, element, syntax, base) {
56
+ const node = {};
57
+ const types = terms($(element).attr(syntax.type));
58
+ if (types.length > 0)
59
+ node["@type"] = types;
60
+ const id = syntax.id.map((key) => $(element).attr(key)).find((value) => value !== undefined);
61
+ if (id !== undefined)
62
+ node["@id"] = absolute(id, base);
63
+ const owned = $(element)
64
+ .find(syntax.props)
65
+ .filter((_, property) => $(property).parent().closest(syntax.items).get(0) === element);
66
+ for (const property of owned.get()) {
67
+ const value = $(property).attr(syntax.scope) === undefined ? valueOf($, property, base) : itemOf($, property, syntax, base);
68
+ const names = terms($(property).attr(syntax.prop));
69
+ for (const name of names)
70
+ node[name] = node[name] === undefined ? value : [node[name], value].flat();
71
+ }
72
+ return node;
73
+ }
74
+ // Top-level Microdata and RDFa items; a page without either marker skips the parse.
75
+ async function extract(page, body) {
76
+ if (!page.html || !/\bitemscope\b|\btypeof\s*=/i.test(body))
77
+ return;
78
+ const $ = load(body);
79
+ const top = (syntax) => $(syntax.tops)
80
+ .get()
81
+ .map((element) => itemOf($, element, syntax, page.url.href));
82
+ const facts = { microdata: top(MICRODATA), rdfa: top(RDFA) };
83
+ log.debug({ url: page.url.href, microdata: facts.microdata.length, rdfa: facts.rdfa.length }, "structured data read");
84
+ return facts;
85
+ }
86
+ // The top-level nodes of every syntax: each JSON-LD object, array entry and `@graph` member, then each Microdata and RDFa item.
87
+ function nodesOf(page) {
88
+ const blocks = (page.html?.jsonld ?? []).flatMap((block) => (Array.isArray(block) ? block : [block])).filter((block) => isNode(block) && !("@error" in block));
89
+ const markup = page[ID];
90
+ return [...blocks.flatMap((block) => (Array.isArray(block["@graph"]) ? block["@graph"].filter(isNode) : [block])), ...(markup?.microdata ?? []), ...(markup?.rdfa ?? [])];
91
+ }
92
+ // Every node at any depth, top-level ones first.
93
+ function* everyNode(value) {
94
+ if (Array.isArray(value))
95
+ for (const entry of value)
96
+ yield* everyNode(entry);
97
+ if (!isNode(value))
98
+ return;
99
+ yield value;
100
+ for (const [key, child] of Object.entries(value))
101
+ if (key !== "@context")
102
+ yield* everyNode(child);
103
+ }
104
+ // A node’s types, a bare name for a schema.org IRI.
105
+ function typesOf(node) {
106
+ const raw = node["@type"];
107
+ return (Array.isArray(raw) ? raw : [raw]).filter((type) => typeof type === "string").map((type) => type.replace(/^https?:\/\/schema\.org\//, ""));
108
+ }
109
+ // Present and not empty.
110
+ const isSet = (value) => value !== undefined && value !== null && value !== "" && !(Array.isArray(value) && value.length === 0);
111
+ // Every node of the page at any depth.
112
+ const allNodes = (page) => nodesOf(page).flatMap((top) => everyNode(top).toArray());
113
+ // A node that says something besides its identity.
114
+ const isDefinition = (node) => typeof node["@id"] === "string" && Object.keys(node).some((key) => key !== "@id" && key !== "@context");
115
+ const parse = pageRule("structured-data/parse", ["html.jsonld"], (page) => {
116
+ if (!page.html)
117
+ return;
118
+ const errors = page.html.jsonld.filter((block) => isNode(block) && typeof block["@error"] === "string").map((block) => block["@error"]);
119
+ return errors.length === 0 ? [] : [{ ...said("JSON-LD blocks do not parse"), data: { [page.url.href]: { blocks: errors.length } }, locations: errors, value: errors }];
120
+ }, { docs: "https://json-ld.org/spec/latest/json-ld/", fix: "Serialise the JSON-LD with a JSON encoder instead of a text template, so quotes and newlines in values are escaped." });
121
+ const required = pageRule("structured-data/required", ["html.jsonld", ID], (page) => {
122
+ if (!page.html)
123
+ return;
124
+ const locations = nodesOf(page).flatMap((node) => typesOf(node).flatMap((type) => {
125
+ const missing = (REQUIRED[type] ?? []).filter((property) => !isSet(node[property]));
126
+ return missing.length === 0 ? [] : [`${type} without ${missing.join(", ")}`];
127
+ }));
128
+ return locations.length === 0 ? [] : [{ ...said("structured data nodes lack properties their rich result requires"), data: { [page.url.href]: { nodes: locations.length } }, value: locations, locations }];
129
+ }, { docs: GALLERY, fix: "Add the missing properties to each named node, or drop a type the page does not really describe." });
130
+ // The URL a `ListItem.item` names: a string, or a node’s `@id` or `url`.
131
+ function itemUrl(item) {
132
+ if (typeof item === "string")
133
+ return item;
134
+ if (!isNode(item))
135
+ return undefined;
136
+ const url = item["@id"] ?? item.url;
137
+ return typeof url === "string" ? url : undefined;
138
+ }
139
+ // Lower-cased with whitespace collapsed, for a containment test between a title and a name.
140
+ const folded = (text) => text.toLowerCase().replaceAll(/\s+/g, " ").trim();
141
+ // Page and article nodes whose `url` or `mainEntityOfPage` is not the canonical, or whose name appears in neither `<title>` nor `og:title`.
142
+ const consistent = pageRule("structured-data/consistent", ["html.jsonld", ID, "html.canonical", "html.title", "html.property"], (page) => {
143
+ if (!page.html)
144
+ return;
145
+ const html = page.html;
146
+ const canonical = resolve(html.canonical ?? page.url.twin ?? page.url.href, page.url.href);
147
+ const titles = [html.title, html.property["og:title"]].filter((title) => title !== undefined).map((title) => folded(title));
148
+ const locations = nodesOf(page).flatMap((node) => {
149
+ const type = typesOf(node).find((name) => PAGES.has(name) || ARTICLES.has(name));
150
+ if (!type)
151
+ return [];
152
+ const urls = [node.url, node.mainEntityOfPage]
153
+ .map((value) => itemUrl(value))
154
+ .filter((url) => url !== undefined)
155
+ .map((url) => resolve(url, page.url.href));
156
+ const name = [node.headline, node.name].find((value) => typeof value === "string");
157
+ log.debug({ url: page.url.href, type, canonical, urls, name }, "page entity compared");
158
+ return [
159
+ ...urls.filter((url) => url !== canonical).map((url) => `${type} names ${url}, the canonical is ${canonical}`),
160
+ ...(name !== undefined && titles.length > 0 && titles.every((title) => !title.includes(folded(name)) && !folded(name).includes(title)) ? [`${type} “${name}” appears in neither the title nor og:title`] : []),
161
+ ];
162
+ });
163
+ return locations.length === 0 ? [] : [{ ...said("structured data claims disagree with the page’s own tags"), data: { [page.url.href]: { claims: locations.length } }, value: locations, locations }];
164
+ }, { docs: "https://developers.google.com/search/docs/appearance/structured-data/sd-policies", fix: "Point `url` and `mainEntityOfPage` at the canonical URL, and use the page title as the entity’s `name` or `headline`." });
165
+ // The document an `@id` points into: everything before its fragment.
166
+ const documentOf = (id) => id.split("#", 1)[0] ?? id;
167
+ // Each bare `{ "@id" }` reference to a blank node or fragment that the crawled page it points into never defines, once per `@id`, with the pages naming it.
168
+ const references = (severity) => ({
169
+ meta: { id: "structured-data/references", severity, scope: "site", facts: ["html.jsonld", ID], docs: "https://www.w3.org/TR/json-ld11/#node-identifiers", fix: "Define each referenced `@id` on the page it points into, or on the referring page itself; a reference into a page that never defines it names nothing." },
170
+ check(pages) {
171
+ const defined = new Map();
172
+ for (const page of pages) {
173
+ const ids = new Set(allNodes(page)
174
+ .filter((node) => isDefinition(node))
175
+ .map((node) => absolute(String(node["@id"]), page.url.href)));
176
+ for (const href of [page.url.href, page.url.twin])
177
+ if (href)
178
+ defined.set(resolve(href, href), ids);
179
+ }
180
+ const naming = new Map();
181
+ for (const page of pages) {
182
+ const own = defined.get(resolve(page.url.href, page.url.href)) ?? new Set();
183
+ const ids = new Set(allNodes(page)
184
+ .filter((node) => typeof node["@id"] === "string" && !isDefinition(node))
185
+ .map((node) => absolute(String(node["@id"]), page.url.href)));
186
+ for (const id of ids) {
187
+ const target = id.startsWith("_:") ? own : id.includes("#") ? defined.get(documentOf(id)) : undefined;
188
+ const isDangling = target !== undefined && !target.has(id) && !own.has(id);
189
+ log.debug({ rule: "structured-data/references", url: page.url.href, id, crawled: target !== undefined, isDangling }, "reference judged");
190
+ if (isDangling)
191
+ naming.set(id, [...(naming.get(id) ?? []), page.url.href]);
192
+ }
193
+ }
194
+ return naming
195
+ .entries()
196
+ .map(([id, urls]) => ({
197
+ rule: "structured-data/references",
198
+ severity,
199
+ scope: "site",
200
+ url: id,
201
+ ...(id.startsWith("_:") ? said("a reference names a node its own page never defines; named by these pages") : { ...said("a reference names a node its document never defines; named by these pages"), data: { [id]: { document: documentOf(id) } } }),
202
+ value: id,
203
+ urls,
204
+ }))
205
+ .toArray();
206
+ },
207
+ });
208
+ // Absolute URLs the page’s breadcrumb lists name.
209
+ function crumbsOf(page) {
210
+ const lists = nodesOf(page).filter((node) => typesOf(node).includes("BreadcrumbList"));
211
+ const items = lists.flatMap((list) => [list.itemListElement].flat().filter(isNode));
212
+ return [...new Set(items.flatMap((item) => itemUrl(item.item) ?? []).map((url) => resolve(url, page.url.href)))];
213
+ }
214
+ // Every breadcrumb target the crawl found answering outside 2xx or redirecting, once per target, with the pages naming it.
215
+ const breadcrumbs = (severity) => ({
216
+ meta: { id: "structured-data/breadcrumbs", severity, scope: "site", facts: ["html.jsonld", ID, "http.status", "site.redirects"], docs: "https://developers.google.com/search/docs/appearance/structured-data/breadcrumb", fix: "Point each breadcrumb `item` at the final URL of a page that answers 200." },
217
+ check(pages, _group, site) {
218
+ const status = new Map(pages.map((page) => [page.url.href, page.http.status]));
219
+ const redirects = site?.redirects ?? {};
220
+ const naming = new Map();
221
+ for (const page of pages)
222
+ for (const url of crumbsOf(page))
223
+ naming.set(url, [...(naming.get(url) ?? []), page.url.href]);
224
+ const findings = [];
225
+ for (const [url, urls] of naming) {
226
+ const [answer, landing] = [status.get(url), redirects[url]];
227
+ log.debug({ rule: "structured-data/breadcrumbs", url, status: answer, landing, pages: urls.length }, "breadcrumb judged");
228
+ const verdict = landing ? { ...said("the breadcrumb item redirects; named by these pages"), data: { [url]: { landing } } } : answer !== undefined && (answer < 200 || answer > 299) ? said("the breadcrumb item answers {status}; named by these pages", { status: String(answer) }) : undefined;
229
+ if (verdict)
230
+ findings.push({ rule: "structured-data/breadcrumbs", severity, scope: "site", url, ...verdict, value: landing ?? answer, urls });
231
+ }
232
+ return findings;
233
+ },
234
+ });
235
+ // Records that `page` shows `value` for `key`.
236
+ function sight(sightings, key, value, page) {
237
+ const values = sightings.get(key) ?? new Map();
238
+ values.set(value, (values.get(value) ?? new Set()).add(page));
239
+ sightings.set(key, values);
240
+ }
241
+ // One site finding for a key seen with more than one value, each value located with its page count.
242
+ function conflict(url, values, sentence, severity) {
243
+ const locations = values
244
+ .entries()
245
+ .map(([value, urls]) => `${value} on ${urls.size} page${urls.size === 1 ? "" : "s"}`)
246
+ .toArray();
247
+ return { rule: "structured-data/entities", severity, scope: "site", url, ...sentence, value: values.keys().toArray(), locations, urls: [...new Set(values.values().flatMap((urls) => urls.values()))] };
248
+ }
249
+ // One `@id` typed differently on different pages, and one named site-wide entity carried under several `@id`s.
250
+ const entities = (severity) => ({
251
+ meta: { id: "structured-data/entities", severity, scope: "site", facts: ["html.jsonld", ID], docs: "https://www.w3.org/TR/json-ld11/#node-identifiers", fix: "Give each real-world entity one absolute `@id` and the same `@type` on every page that describes it." },
252
+ check(pages) {
253
+ const typings = new Map();
254
+ const identities = new Map();
255
+ for (const page of pages) {
256
+ const definitions = allNodes(page).filter((node) => isDefinition(node) && typesOf(node).length > 0);
257
+ for (const node of definitions) {
258
+ const [id, types] = [absolute(String(node["@id"]), page.url.href), typesOf(node).toSorted((a, b) => a.localeCompare(b))];
259
+ sight(typings, id, types.join(", "), page.url.href);
260
+ const isShared = typeof node.name === "string" && types.every((type) => !PER_PAGE.has(type));
261
+ if (isShared)
262
+ sight(identities, `${types.join(", ")} “${String(node.name)}”`, id, page.url.href);
263
+ }
264
+ }
265
+ const findings = [];
266
+ for (const [id, values] of typings) {
267
+ log.debug({ rule: "structured-data/entities", id, typings: values.size }, "identity typing judged");
268
+ if (values.size > 1)
269
+ findings.push(conflict(id, values, said("one @id is typed {count} ways across the site", { count: values.size }), severity));
270
+ }
271
+ for (const [entity, values] of identities) {
272
+ log.debug({ rule: "structured-data/entities", entity, ids: values.size }, "entity identity judged");
273
+ if (values.size > 1)
274
+ findings.push(conflict(values
275
+ .keys()
276
+ .toArray()
277
+ .toSorted((a, b) => a.localeCompare(b))[0] ?? entity, values, said("{entity} is carried under {count} different @ids", { entity, count: values.size }), severity));
278
+ }
279
+ return findings;
280
+ },
281
+ });
282
+ // The term schema.org supersedes `term` with, if any.
283
+ const successor = (term) => (Object.hasOwn(SUPERSEDED, term) ? SUPERSEDED[term] : undefined);
284
+ // Types, properties and schema.org enumeration values that schema.org marks `supersededBy`.
285
+ const deprecated = pageRule("structured-data/deprecated", ["html.jsonld", ID], (page) => {
286
+ if (!page.html)
287
+ return;
288
+ const locations = new Set();
289
+ const nodes = allNodes(page);
290
+ for (const node of nodes) {
291
+ for (const type of typesOf(node))
292
+ if (successor(type))
293
+ locations.add(`type ${type} → ${successor(type)}`);
294
+ for (const [key, value] of Object.entries(node)) {
295
+ if (!key.startsWith("@") && successor(key))
296
+ locations.add(`property ${key} → ${successor(key)}`);
297
+ const iris = [value].flat().filter((term) => typeof term === "string" && /^https?:\/\/schema\.org\/\w+$/.test(term));
298
+ for (const iri of iris)
299
+ if (successor(bare(iri)))
300
+ locations.add(`value ${bare(iri)} → ${successor(bare(iri))}`);
301
+ }
302
+ }
303
+ log.debug({ url: page.url.href, superseded: locations.size }, "vocabulary judged");
304
+ return locations.size === 0 ? [] : [{ ...said("schema.org terms are superseded"), data: { [page.url.href]: { terms: locations.size } }, value: [...locations], locations: [...locations] }];
305
+ }, { docs: "https://schema.org/docs/attic.home.html", fix: "Rename each term to the one schema.org supersedes it with." });
306
+ // A date-valued property by name.
307
+ const isDateProperty = (key) => key.startsWith("date") || key.endsWith("Date") || DATES.has(key);
308
+ // Milliseconds since the epoch, NaN for anything but a parsable string.
309
+ const time = (value) => (typeof value === "string" ? Date.parse(value) : NaN);
310
+ // Dates that are not ISO 8601, a modification before publication, and a publication date disagreeing with `article:published_time`.
311
+ const dates = pageRule("structured-data/dates", ["html.jsonld", ID, "html.property"], (page) => {
312
+ if (!page.html)
313
+ return;
314
+ const meta = page.html.property;
315
+ const locations = allNodes(page).flatMap((node) => {
316
+ const type = typesOf(node)[0] ?? "node";
317
+ const invalid = Object.entries(node)
318
+ .filter(([key, value]) => isDateProperty(key) && typeof value === "string" && (!ISO_DATE.test(value.trim()) || Number.isNaN(Date.parse(value))))
319
+ .map(([key, value]) => `${type} ${key} “${String(value)}” is not ISO 8601`);
320
+ const [published, modified, shown] = [time(node.datePublished), time(node.dateModified), time(meta["article:published_time"])];
321
+ return [
322
+ ...invalid,
323
+ ...(published > modified ? [`${type} dateModified ${String(node.dateModified)} is before datePublished ${String(node.datePublished)}`] : []),
324
+ ...(published !== shown && !Number.isNaN(published) && !Number.isNaN(shown) ? [`${type} datePublished ${String(node.datePublished)} disagrees with article:published_time ${meta["article:published_time"]}`] : []),
325
+ ];
326
+ });
327
+ log.debug({ url: page.url.href, invalid: locations.length }, "dates judged");
328
+ return locations.length === 0 ? [] : [{ ...said("structured data dates are malformed or contradictory"), data: { [page.url.href]: { dates: locations.length } }, value: locations, locations }];
329
+ }, { docs: "https://developers.google.com/search/docs/appearance/structured-data/article", fix: "Write each date as ISO 8601 with a time zone, from the same source that fills `article:published_time`." });
330
+ export default definePlugin({
331
+ name: "structured-data",
332
+ extractors: [{ id: ID, extract }],
333
+ rules: {
334
+ "structured-data/parse": parse,
335
+ "structured-data/required": required,
336
+ "structured-data/breadcrumbs": breadcrumbs,
337
+ "structured-data/consistent": consistent,
338
+ "structured-data/references": references,
339
+ "structured-data/entities": entities,
340
+ "structured-data/deprecated": deprecated,
341
+ "structured-data/dates": dates,
342
+ },
343
+ presets: {
344
+ "structured-data": {
345
+ description: "JSON-LD, Microdata and RDFa that parse, carry what their rich result requires, agree with the page and each other, use current schema.org terms and valid dates, and whose breadcrumbs lead to live pages",
346
+ rules: {
347
+ "structured-data/parse": { severity: "error", score: 7.8 },
348
+ "structured-data/required": { severity: "warning", score: 5.8 },
349
+ "structured-data/breadcrumbs": { severity: "warning", score: 5.2 },
350
+ "structured-data/consistent": { severity: "warning", score: 5.4 },
351
+ "structured-data/references": { severity: "warning", score: 4.8 },
352
+ "structured-data/entities": { severity: "warning", score: 5 },
353
+ "structured-data/deprecated": { severity: "warning", score: 4 },
354
+ "structured-data/dates": { severity: "warning", score: 4.6 },
355
+ },
356
+ },
357
+ },
358
+ });
@@ -0,0 +1,298 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import { spawn } from "node:child_process";
5
+ import { X509Certificate } from "node:crypto";
6
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
7
+ import { tmpdir } from "node:os";
8
+ import path from "node:path";
9
+ import { getCACertificates } from "node:tls";
10
+ import { reason } from "../crawl/fetch.js";
11
+ import { log } from "../logger.js";
12
+ import { scan } from "./tls-scan.js";
13
+ import { definePlugin } from "./types.js";
14
+ const TIMEOUT_MS = 10_000;
15
+ // Milliseconds one origin’s scan may take: up to about 150 handshakes.
16
+ const SCAN_MS = 180_000;
17
+ // Whether `openssl` runs, per PATH, so a missing tool warns once.
18
+ const available = new Map();
19
+ // One `openssl` run with `input` on stdin, killed on `signal` or after the timeout.
20
+ function openssl(parameters, input, signal) {
21
+ return new Promise((resolve, reject) => {
22
+ const child = spawn("openssl", parameters, { signal: AbortSignal.any([signal, AbortSignal.timeout(TIMEOUT_MS)]) });
23
+ let output = "";
24
+ child.stdout.on("data", (chunk) => {
25
+ output += chunk.toString("utf8");
26
+ });
27
+ child.stderr.on("data", (chunk) => {
28
+ output += chunk.toString("utf8");
29
+ });
30
+ child.on("error", reject);
31
+ child.on("close", (code) => resolve({ code, output }));
32
+ child.stdin.end(input);
33
+ });
34
+ }
35
+ // Whether `openssl` runs; warns when it does not.
36
+ async function isRunnable() {
37
+ try {
38
+ const run = await openssl(["version"], "", AbortSignal.timeout(TIMEOUT_MS));
39
+ return run.code === 0;
40
+ }
41
+ catch (error) {
42
+ log.warn({ tool: "openssl", error: reason(error) }, "openssl not found; tls-probe early data skipped");
43
+ return false;
44
+ }
45
+ }
46
+ // Whether `openssl` is on PATH, asked once per PATH so a missing tool warns once.
47
+ function opensslFound() {
48
+ const key = process.env.PATH ?? "";
49
+ let known = available.get(key);
50
+ if (!known) {
51
+ known = isRunnable();
52
+ available.set(key, known);
53
+ }
54
+ return known;
55
+ }
56
+ // The `s_client` arguments reaching `host` at `address`.
57
+ function target(host, port, address) {
58
+ return ["s_client", "-connect", `${address.includes(":") ? `[${address}]` : address}:${port}`, "-servername", host];
59
+ }
60
+ function isIssuedBy(certificate, issuer) {
61
+ return certificate.checkIssued(issuer) && certificate.verify(issuer.publicKey);
62
+ }
63
+ // Whether each sent certificate is issued by the next, and the last is a root or issued by a trusted one.
64
+ function chainOf(certificates) {
65
+ const last = certificates.at(-1);
66
+ if (!last)
67
+ return;
68
+ const isLinked = certificates.slice(0, -1).every((certificate, index) => isIssuedBy(certificate, certificates[index + 1]));
69
+ const roots = [...getCACertificates("default"), ...getCACertificates("system")].map((pem) => new X509Certificate(pem));
70
+ // A cross-signed root is anchored by its own key in the store, whoever signed it.
71
+ const isAnchored = isIssuedBy(last, last) || roots.some((root) => isIssuedBy(last, root) || (root.subject === last.subject && root.publicKey.equals(last.publicKey)));
72
+ return { sent: certificates.length, complete: isLinked && isAnchored };
73
+ }
74
+ // Whether a DER OCSPResponse says `successful` (RFC 6960 §4.2.1): its first element, an ENUMERATED, is 0.
75
+ function isSuccessful(response) {
76
+ if (!response || response[0] !== 0x30)
77
+ return false;
78
+ const at = 2 + ((response[1] ?? 0) & 0x80 ? (response[1] ?? 0) & 0x7f : 0);
79
+ return response[at] === 0x0a && response[at + 1] === 1 && response[at + 2] === 0;
80
+ }
81
+ // Whether a TLS 1.3 session resumed with early data had it accepted; `false` when no ticket allows it.
82
+ async function isEarlyDataAccepted(host, port, address, signal) {
83
+ const directory = await mkdtemp(path.join(tmpdir(), "spiderlint-early-"));
84
+ const [session, data] = [path.join(directory, "session.pem"), path.join(directory, "early.txt")];
85
+ const request = `HEAD / HTTP/1.1\r\nHost: ${host}\r\nConnection: close\r\n\r\n`;
86
+ try {
87
+ await openssl([...target(host, port, address), "-tls1_3", "-ign_eof", "-sess_out", session], request, signal);
88
+ let ticket = "";
89
+ try {
90
+ ticket = await readFile(session, "utf8");
91
+ }
92
+ catch (error) {
93
+ log.debug({ host, port, error: reason(error) }, "session file unreadable");
94
+ }
95
+ if (!ticket) {
96
+ log.debug({ host, port }, "no session ticket; early data not tried");
97
+ return false;
98
+ }
99
+ await writeFile(data, request);
100
+ const resumed = await openssl([...target(host, port, address), "-tls1_3", "-ign_eof", "-sess_in", session, "-early_data", data], "", signal);
101
+ const verdict = /Early data was (accepted|rejected|not sent)/.exec(resumed.output)?.[1];
102
+ log.debug({ host, port, verdict }, "early data tried");
103
+ return verdict === "accepted";
104
+ }
105
+ finally {
106
+ await rm(directory, { recursive: true, force: true });
107
+ }
108
+ }
109
+ // Early data acceptance, or undefined when `openssl` gave no verdict, so the scan already read survives a slow s_client.
110
+ async function earlyData(host, port, address, signal) {
111
+ try {
112
+ return await isEarlyDataAccepted(host, port, address, signal);
113
+ }
114
+ catch (error) {
115
+ if (signal.aborted)
116
+ throw error;
117
+ log.debug({ host, port, error: reason(error) }, "early data unanswered");
118
+ }
119
+ }
120
+ // Protocols, suites, groups and negotiation-level weaknesses of one https origin from hand-built hellos, with its chain, OCSP stapling and, through `openssl s_client`, TLS 1.3 early data.
121
+ const probe = {
122
+ id: "tls-probe",
123
+ per: "origin",
124
+ resolves: true,
125
+ timeout: SCAN_MS,
126
+ async extract(origin, context) {
127
+ const url = new URL(origin);
128
+ if (url.protocol !== "https:")
129
+ return;
130
+ const host = url.hostname.replaceAll(/^\[|\]$/g, "");
131
+ const port = url.port || "443";
132
+ const address = await context.address(host);
133
+ const isFull = context.settings?.scan !== false;
134
+ const { certificates = [], ocsp, ...scanned } = await scan({ host, address, port: Number(port), signal: context.signal }, isFull);
135
+ if (scanned.protocols.length === 0)
136
+ throw new Error(`no handshake with ${host}:${port}`);
137
+ const sent = certificates.map((der) => new X509Certificate(der));
138
+ const chain = chainOf(sent);
139
+ const responder = sent[0]?.infoAccess?.includes("OCSP - URI:") ?? false;
140
+ const isEarly = scanned.protocols.includes("TLSv1.3") && (await opensslFound()) ? await earlyData(host, port, address, context.signal) : undefined;
141
+ log.debug({ origin, address, isFull, protocols: scanned.protocols, chain, responder, isStapled: isSuccessful(ocsp), isEarly }, "tls probed");
142
+ return { address, ...scanned, ...(chain && { chain }), ...(sent.length > 0 && { ocsp: { responder, stapled: isSuccessful(ocsp) } }), ...(isEarly !== undefined && { "early-data": isEarly }) };
143
+ },
144
+ };
145
+ // A `when` that skips an origin behind a Cloudflare edge.
146
+ const NOT_CLOUDFLARE = { not: { const: "cloudflare" } };
147
+ const RULES = {
148
+ "tls-probe/legacy-protocols": {
149
+ fact: "site.origins.*.tls-probe.legacy",
150
+ expect: { maxItems: 0 },
151
+ message: "the server still accepts {got}, which RFC 6176, RFC 7568 and RFC 8996 retire",
152
+ severity: "error",
153
+ score: 9.2,
154
+ docs: "https://www.rfc-editor.org/rfc/rfc9325#section-3.1.1",
155
+ fix: "Disable SSLv3, TLS 1.0 and TLS 1.1; keep only TLS 1.2 and 1.3.",
156
+ hints: { cloudflare: "Raise Minimum TLS Version to 1.2 under SSL/TLS, Edge Certificates in the Cloudflare dashboard: https://developers.cloudflare.com/ssl/edge-certificates/additional-options/minimum-tls/" },
157
+ },
158
+ "tls-probe/insecure-ciphers": {
159
+ fact: "site.origins.*.tls-probe.insecure-ciphers",
160
+ expect: { maxItems: 0 },
161
+ when: { "site.origins.*.tls-probe.insecure-ciphers": { type: "array" }, "site.origins.*.stack.edge.name": NOT_CLOUDFLARE },
162
+ message: "the server accepts suites broken outright: {got}",
163
+ severity: "error",
164
+ score: 9.4,
165
+ docs: "https://www.rfc-editor.org/rfc/rfc9325#section-4.1",
166
+ fix: "Remove NULL, RC4, 3DES and other broken cipher suites from the server config.",
167
+ },
168
+ "tls-probe/vulnerabilities": {
169
+ fact: "site.origins.*.tls-probe.vulnerabilities",
170
+ expect: { maxItems: 0 },
171
+ when: { "site.origins.*.tls-probe.vulnerabilities": { type: "array" }, "site.origins.*.stack.edge.name": NOT_CLOUDFLARE },
172
+ message: "what the server negotiates leaves it open to {got}",
173
+ severity: "error",
174
+ score: 9.6,
175
+ docs: "https://www.rfc-editor.org/rfc/rfc7457",
176
+ fix: "Update the TLS library, and disable the protocol, suite or extension each named attack needs.",
177
+ },
178
+ "tls-probe/weak-ciphers": {
179
+ fact: "site.origins.*.tls-probe.weak-ciphers",
180
+ expect: { maxItems: 0 },
181
+ when: { "site.origins.*.tls-probe.weak-ciphers": { type: "array" }, "site.origins.*.stack.edge.name": NOT_CLOUDFLARE },
182
+ message: "the server accepts suites without forward secrecy, with CBC or with a 64-bit block: {got}",
183
+ severity: "warning",
184
+ score: 6.2,
185
+ docs: "https://www.rfc-editor.org/rfc/rfc9325#section-4.2",
186
+ fix: "Keep only AEAD suites that offer forward secrecy (ECDHE).",
187
+ },
188
+ "tls-probe/forward-secrecy": {
189
+ fact: "site.origins.*.tls-probe.forward-secrecy",
190
+ expect: { const: "all" },
191
+ when: { "site.origins.*.tls-probe.forward-secrecy": { type: "string" }, "site.origins.*.stack.edge.name": NOT_CLOUDFLARE },
192
+ message: "forward secrecy covers {got} of the accepted suites, where “all” are expected",
193
+ severity: "warning",
194
+ score: 5.8,
195
+ docs: "https://www.rfc-editor.org/rfc/rfc9325#section-7.3",
196
+ fix: "Configure only ECDHE suites so every handshake offers forward secrecy.",
197
+ },
198
+ "tls-probe/weak-dh": {
199
+ fact: "site.origins.*.tls-probe.dh-bits",
200
+ expect: { minimum: 2048 },
201
+ when: { "site.origins.*.tls-probe.dh-bits": { type: "number" } },
202
+ message: "the server’s DHE prime has {got} bits, short of 2048",
203
+ severity: "warning",
204
+ score: [
205
+ [512, 6.4],
206
+ [1024, 5.4],
207
+ [2047, 3.4],
208
+ ],
209
+ docs: "https://www.rfc-editor.org/rfc/rfc9325#section-4.5",
210
+ fix: "Use a DH group of at least 2048 bits, or drop the DHE suites for ECDHE.",
211
+ },
212
+ "tls-probe/compression": {
213
+ fact: "site.origins.*.tls-probe.compression",
214
+ expect: { const: false },
215
+ when: { "site.origins.*.tls-probe.compression": { type: "boolean" } },
216
+ message: "the server compresses TLS records, through which CRIME reads secrets",
217
+ severity: "warning",
218
+ score: 6,
219
+ docs: "https://www.rfc-editor.org/rfc/rfc9325#section-3.3",
220
+ fix: "Disable TLS compression.",
221
+ },
222
+ "tls-probe/secure-renegotiation": {
223
+ fact: "site.origins.*.tls-probe.secure-renegotiation",
224
+ expect: { const: true },
225
+ when: { "site.origins.*.tls-probe.secure-renegotiation": { type: "boolean" } },
226
+ message: "the server does not answer renegotiation_info, so a renegotiation can be spliced into a session",
227
+ severity: "warning",
228
+ score: 5.2,
229
+ docs: "https://www.rfc-editor.org/rfc/rfc5746",
230
+ fix: "Enable secure renegotiation in the TLS library.",
231
+ },
232
+ "tls-probe/server-cipher-order": {
233
+ fact: "site.origins.*.tls-probe.server-order",
234
+ expect: { const: true },
235
+ when: { "site.origins.*.tls-probe.weak-ciphers": { minItems: 1 }, "site.origins.*.stack.edge.name": NOT_CLOUDFLARE },
236
+ message: "the server lets the client pick a weak suite over a strong one",
237
+ severity: "info",
238
+ score: 2.8,
239
+ docs: "https://www.rfc-editor.org/rfc/rfc9325#section-4.2.1",
240
+ fix: "Set the server to prefer its own strong suites over the client’s list.",
241
+ },
242
+ "tls-probe/fallback-scsv": {
243
+ fact: "site.origins.*.tls-probe.fallback-scsv",
244
+ expect: { const: true },
245
+ when: { "site.origins.*.tls-probe.fallback-scsv": { type: "boolean" } },
246
+ message: "the server accepts a needless fallback to an older version instead of refusing it",
247
+ severity: "info",
248
+ score: 2.4,
249
+ docs: "https://www.rfc-editor.org/rfc/rfc7507",
250
+ fix: "Enable TLS_FALLBACK_SCSV in the TLS library.",
251
+ },
252
+ "tls-probe/tls13-missing": {
253
+ fact: "site.origins.*.tls-probe.protocols",
254
+ expect: { contains: { const: "TLSv1.3" } },
255
+ message: "the server offers {got} and no TLS 1.3",
256
+ severity: "info",
257
+ score: 2.2,
258
+ docs: "https://www.rfc-editor.org/rfc/rfc9325#section-3.1.1",
259
+ fix: "Enable TLS 1.3 in the server configuration.",
260
+ },
261
+ "tls-probe/chain-complete": {
262
+ fact: "site.origins.*.tls-probe.chain.complete",
263
+ expect: { const: true },
264
+ when: { "site.origins.*.tls-probe.chain": { type: "object" } },
265
+ message: "the server does not send the intermediate certificates its chain needs, so clients that have not cached them fail",
266
+ severity: "error",
267
+ score: 8,
268
+ docs: "https://www.rfc-editor.org/rfc/rfc8446#section-4.4.2",
269
+ fix: "Configure the server to serve the full certificate chain, including intermediates.",
270
+ },
271
+ "tls-probe/ocsp-stapling": {
272
+ fact: "site.origins.*.tls-probe.ocsp.stapled",
273
+ expect: { const: true },
274
+ when: { "site.origins.*.tls-probe.ocsp.responder": true },
275
+ message: "the certificate names an OCSP responder but the server staples no response, so each client asks the CA",
276
+ severity: "info",
277
+ score: 1.8,
278
+ docs: "https://www.rfc-editor.org/rfc/rfc6066#section-8",
279
+ fix: "Enable OCSP stapling so the server attaches the CA’s response to handshakes.",
280
+ },
281
+ "tls-probe/early-data": {
282
+ fact: "site.origins.*.tls-probe.early-data",
283
+ expect: { const: false },
284
+ when: { "site.origins.*.tls-probe.early-data": { type: "boolean" } },
285
+ message: "the server accepts TLS 1.3 early data, which an attacker can replay; non-idempotent requests must answer 425",
286
+ severity: "info",
287
+ score: 2.6,
288
+ docs: "https://www.rfc-editor.org/rfc/rfc8470",
289
+ fix: "Disable early data, or answer 425 Too Early to non-idempotent requests.",
290
+ },
291
+ };
292
+ // Hand-built handshakes per https origin, for what one connection does not show.
293
+ export default definePlugin({
294
+ name: "tls-probe",
295
+ settings: { type: "object", properties: { scan: { type: "boolean", default: true } }, additionalProperties: false },
296
+ sites: [probe],
297
+ presets: { "tls-probe": { description: "Protocols, cipher suites, groups, negotiation weaknesses, chain completeness, OCSP stapling and early data of every https origin", rules: RULES } },
298
+ });