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,139 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { groupsOf } from "../index.js";
|
|
5
|
+
import { log } from "../logger.js";
|
|
6
|
+
import { ConfigError } from "../config/index.js";
|
|
7
|
+
import { ruleMaker } from "../plugins/index.js";
|
|
8
|
+
import { compileRule } from "./declarative.js";
|
|
9
|
+
import { fixFor } from "./fix.js";
|
|
10
|
+
import { compileRulesets, lookup, presetNames, resolveRuleset } from "./rulesets.js";
|
|
11
|
+
const PREFIX = "spiderlint:";
|
|
12
|
+
const TONE = { error: "red", warning: "yellow", info: "blue", hint: "dim", off: "dim" };
|
|
13
|
+
function bare(name) {
|
|
14
|
+
return name.startsWith(PREFIX) ? name.slice(PREFIX.length) : name;
|
|
15
|
+
}
|
|
16
|
+
// The user's rulesets, then every shipped preset.
|
|
17
|
+
function sources(config) {
|
|
18
|
+
return [...Object.keys(config.rulesets), ...presetNames().map((name) => `${PREFIX}${name}`)];
|
|
19
|
+
}
|
|
20
|
+
// Severities each rule runs at across the configured groups, flags applied.
|
|
21
|
+
function running(config) {
|
|
22
|
+
const severities = new Map();
|
|
23
|
+
const groups = Object.entries(groupsOf(config));
|
|
24
|
+
for (const [group, { rules }] of groups) {
|
|
25
|
+
const compiled = compileRulesets(rules, config.rulesets, new Set(config.excludeRules), config.overrides);
|
|
26
|
+
log.debug({ group, rulesets: rules, rules: compiled.length }, "catalog group compiled");
|
|
27
|
+
for (const { meta } of compiled)
|
|
28
|
+
severities.set(meta.id, (severities.get(meta.id) ?? new Set()).add(meta.severity));
|
|
29
|
+
}
|
|
30
|
+
return severities;
|
|
31
|
+
}
|
|
32
|
+
// A ruleset and every ruleset it extends, by bare name.
|
|
33
|
+
function closure(name, rulesets) {
|
|
34
|
+
return [bare(name), ...(lookup(name, rulesets)?.extends ?? []).flatMap((parent) => closure(parent, rulesets))];
|
|
35
|
+
}
|
|
36
|
+
// Every rule the named rulesets resolve to, or every rule any ruleset defines.
|
|
37
|
+
export function listRules(config, names) {
|
|
38
|
+
const severities = running(config);
|
|
39
|
+
const all = sources(config);
|
|
40
|
+
const homes = new Map();
|
|
41
|
+
for (const name of all) {
|
|
42
|
+
const own = Object.keys(lookup(name, config.rulesets)?.rules ?? {});
|
|
43
|
+
for (const id of own)
|
|
44
|
+
homes.set(id, [...(homes.get(id) ?? []), bare(name)]);
|
|
45
|
+
}
|
|
46
|
+
const specs = Object.assign({}, ...(names.length > 0 ? names : all).map((name) => resolveRuleset(name, config.rulesets)));
|
|
47
|
+
log.debug({ rulesets: names, rules: Object.keys(specs).length }, "catalog rules resolved");
|
|
48
|
+
return Object.entries(specs)
|
|
49
|
+
.toSorted(([a], [b]) => a.localeCompare(b))
|
|
50
|
+
.map(([id, spec]) => describeRule(id, spec, severities, homes.get(id) ?? []));
|
|
51
|
+
}
|
|
52
|
+
// A rule’s score as `5.0`, or the scale a dynamic one follows as `0 days → 6.8, 2 → 5.2`.
|
|
53
|
+
function scoreText({ score, scale }) {
|
|
54
|
+
return scale ? `dynamic: ${scale.map(([value, points]) => `${value} → ${points.toFixed(1)}`).join(", ")}` : (score ?? 0).toFixed(1);
|
|
55
|
+
}
|
|
56
|
+
// One rule’s catalog row, compiled at its preset severity so an `off` rule still has a meta.
|
|
57
|
+
function describeRule(id, spec, severities, rulesets) {
|
|
58
|
+
const preset = spec.severity ?? "warning";
|
|
59
|
+
const { meta } = compileRule(id, { ...spec, severity: preset === "off" ? "warning" : preset });
|
|
60
|
+
const kind = spec.unique ? "unique" : spec.fact ? "declarative" : "built-in";
|
|
61
|
+
return { id, severity: [...(severities.get(id) ?? ["off"])].join("/"), preset, score: scoreText(meta), scope: meta.scope, rulesets, facts: meta.facts, docs: meta.docs, kind, expect: spec.expect, when: spec.when, message: spec.message, fix: meta.fix };
|
|
62
|
+
}
|
|
63
|
+
// Everything known about one rule; a rule no ruleset carries and no plugin makes is a config error.
|
|
64
|
+
export function explainRule(config, id) {
|
|
65
|
+
const found = listRules(config, []).find((rule) => rule.id === id);
|
|
66
|
+
log.debug({ rule: id, found: found !== undefined, maker: ruleMaker(id) !== undefined }, "rule explained");
|
|
67
|
+
if (found)
|
|
68
|
+
return found;
|
|
69
|
+
if (ruleMaker(id))
|
|
70
|
+
return describeRule(id, { severity: "off" }, running(config), []);
|
|
71
|
+
throw new ConfigError(`rule ${id}: not defined (see spiderlint list-rules)`);
|
|
72
|
+
}
|
|
73
|
+
// Each shipped rule’s docs and the presets that list it, presets extending none first.
|
|
74
|
+
export function shippedRules() {
|
|
75
|
+
const rules = new Map();
|
|
76
|
+
const presets = presetNames()
|
|
77
|
+
.map((name) => ({ name, config: lookup(`${PREFIX}${name}`, {}) }))
|
|
78
|
+
.toSorted((a, b) => Number(Boolean(a.config?.extends?.length)) - Number(Boolean(b.config?.extends?.length)));
|
|
79
|
+
for (const { name, config } of presets) {
|
|
80
|
+
const own = Object.keys(config?.rules ?? {});
|
|
81
|
+
for (const id of own)
|
|
82
|
+
rules.set(id, { presets: [...(rules.get(id)?.presets ?? []), { name, description: config?.description ?? "" }] });
|
|
83
|
+
}
|
|
84
|
+
const all = Object.entries(resolveRuleset(`${PREFIX}all`, {}));
|
|
85
|
+
for (const [id, spec] of all) {
|
|
86
|
+
const { meta } = compileRule(id, { ...spec, severity: !spec.severity || spec.severity === "off" ? "warning" : spec.severity });
|
|
87
|
+
rules.set(id, { presets: rules.get(id)?.presets ?? [], ...(meta.docs && { docs: meta.docs }) });
|
|
88
|
+
}
|
|
89
|
+
log.debug({ presets: presets.length, rules: rules.size }, "shipped rules mapped");
|
|
90
|
+
return rules;
|
|
91
|
+
}
|
|
92
|
+
// Every shipped preset, with whether a configured group runs it.
|
|
93
|
+
export function listPresets(config) {
|
|
94
|
+
const used = new Set(Object.values(groupsOf(config)).flatMap(({ rules }) => rules.flatMap((name) => closure(name, config.rulesets))));
|
|
95
|
+
return presetNames().map((name) => {
|
|
96
|
+
const preset = lookup(`${PREFIX}${name}`, config.rulesets);
|
|
97
|
+
return { name, description: preset?.description ?? "", rules: Object.keys(resolveRuleset(`${PREFIX}${name}`, config.rulesets)).length, extends: (preset?.extends ?? []).map((parent) => bare(parent)), used: used.has(name) };
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
// Left-aligned columns sized to their widest cell; the header row bold.
|
|
101
|
+
function table(rows, paint, style) {
|
|
102
|
+
const widths = rows[0]?.map((_cell, column) => Math.max(...rows.map((row) => (row[column] ?? "").length))) ?? [];
|
|
103
|
+
return rows
|
|
104
|
+
.map((row, index) => row
|
|
105
|
+
.map((cell, column) => {
|
|
106
|
+
const padded = column === row.length - 1 ? cell : cell.padEnd(widths[column] ?? 0);
|
|
107
|
+
const tone = index === 0 ? "bold" : style(index, column, cell);
|
|
108
|
+
return tone ? paint(tone, padded) : padded;
|
|
109
|
+
})
|
|
110
|
+
.join(" ")
|
|
111
|
+
.trimEnd())
|
|
112
|
+
.join("\n");
|
|
113
|
+
}
|
|
114
|
+
export function formatRules(rules, paint) {
|
|
115
|
+
const rows = [["RULE", "SEVERITY", "SCORE", "SCOPE", "RULESET", "DOCS"], ...rules.map((rule) => [rule.id, rule.severity, rule.score.split(":", 1)[0], rule.scope, rule.rulesets.join(", "), rule.docs ?? ""])];
|
|
116
|
+
return table(rows, paint, (_row, column, cell) => (column === 1 ? TONE[cell.split("/", 1)[0] ?? ""] : column === 5 ? "dim" : undefined));
|
|
117
|
+
}
|
|
118
|
+
// One labelled line per field the rule carries; schemas as compact JSON.
|
|
119
|
+
export function formatExplanation(rule, paint) {
|
|
120
|
+
const rows = [
|
|
121
|
+
["severity", `${rule.severity} (preset ${rule.preset})`],
|
|
122
|
+
["score", rule.score],
|
|
123
|
+
["scope", rule.scope],
|
|
124
|
+
["kind", rule.kind],
|
|
125
|
+
["rulesets", rule.rulesets.join(", ") || undefined],
|
|
126
|
+
["reads", rule.facts.join(", ")],
|
|
127
|
+
["expect", rule.expect && JSON.stringify(rule.expect)],
|
|
128
|
+
["when", rule.when && JSON.stringify(rule.when)],
|
|
129
|
+
["message", rule.message],
|
|
130
|
+
["fix", rule.fix && fixFor(rule.fix)],
|
|
131
|
+
["docs", rule.docs],
|
|
132
|
+
];
|
|
133
|
+
const lines = rows.filter((row) => row[1] !== undefined).map(([label, value]) => `${paint("dim", label.padEnd(9))}${value}`);
|
|
134
|
+
return [paint("bold", rule.id), ...lines].join("\n");
|
|
135
|
+
}
|
|
136
|
+
export function formatPresets(presets, paint) {
|
|
137
|
+
const rows = [["PRESET", "RULES", "USED", "DESCRIPTION"], ...presets.map((preset) => [preset.name, String(preset.rules), preset.used ? "yes" : "no", preset.description])];
|
|
138
|
+
return table(rows, paint, (_row, column, cell) => (column === 2 ? (cell === "yes" ? "green" : "dim") : undefined));
|
|
139
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { log } from "../logger.js";
|
|
5
|
+
import { said } from "./message.js";
|
|
6
|
+
// Seconds a host’s clock may drift before it is a warning, and before it is an error.
|
|
7
|
+
const TOLERATED = 60;
|
|
8
|
+
const BROKEN = 3600;
|
|
9
|
+
// Seconds by which hosts that all skew alike may still disagree when the fault is our own clock.
|
|
10
|
+
const SPREAD = 5;
|
|
11
|
+
// The middle value of a non-empty list.
|
|
12
|
+
function median(values) {
|
|
13
|
+
const sorted = values.toSorted((a, b) => a - b);
|
|
14
|
+
return sorted[Math.floor(sorted.length / 2)];
|
|
15
|
+
}
|
|
16
|
+
// Two or more hosts off by nearly the same amount in the same direction.
|
|
17
|
+
export function isOwnClock(skews) {
|
|
18
|
+
const isOff = skews.every((skew) => Math.abs(skew) > TOLERATED && Math.sign(skew) === Math.sign(skews[0]));
|
|
19
|
+
return skews.length > 1 && isOff && Math.max(...skews) - Math.min(...skews) <= SPREAD;
|
|
20
|
+
}
|
|
21
|
+
// One finding per host whose `Date` runs off our clock, an error past an hour; none, and one warning, when every host is off alike.
|
|
22
|
+
const clockSkew = (severity) => ({
|
|
23
|
+
meta: { id: "http/clock-skew", severity, scope: "site", facts: ["http.date-skew"], docs: "https://www.rfc-editor.org/rfc/rfc9110#section-6.6.1", fix: "Synchronise the server clock with NTP (chrony or systemd-timesyncd)." },
|
|
24
|
+
check(pages) {
|
|
25
|
+
const measured = pages.filter((page) => page.http["date-skew"] !== undefined);
|
|
26
|
+
const hosts = Map.groupBy(measured, (page) => page.url.host)
|
|
27
|
+
.entries()
|
|
28
|
+
.map(([host, members]) => ({ host, skew: median(members.map((page) => page.http["date-skew"])), urls: members.map((page) => page.url.href) }))
|
|
29
|
+
.toArray();
|
|
30
|
+
const skews = hosts.map((entry) => entry.skew);
|
|
31
|
+
log.debug({ rule: "http/clock-skew", hosts: hosts.length, skews }, "host clocks compared");
|
|
32
|
+
if (isOwnClock(skews)) {
|
|
33
|
+
log.warn({ offset: -skews[0], hosts: hosts.length }, `the local clock is off by ${-skews[0]} s; clock skew findings suppressed`);
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
const findings = [];
|
|
37
|
+
for (const { host, skew, urls } of hosts) {
|
|
38
|
+
if (Math.abs(skew) <= TOLERATED)
|
|
39
|
+
continue;
|
|
40
|
+
const level = Math.abs(skew) > BROKEN ? "error" : severity;
|
|
41
|
+
findings.push({ rule: "http/clock-skew", severity: level, scope: "site", url: host, ...said(skew > 0 ? "the host’s clock runs ahead of ours" : "the host’s clock runs behind ours"), data: { [host]: { skew: { fact: "http.date-skew", value: Math.abs(skew) } } }, value: skew, urls });
|
|
42
|
+
}
|
|
43
|
+
return findings;
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
export const clockRules = {
|
|
47
|
+
"http/clock-skew": clockSkew,
|
|
48
|
+
};
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
5
|
+
import { ConfigError } from "../config/index.js";
|
|
6
|
+
import { label } from "../facts/labels.js";
|
|
7
|
+
import { subjectPath } from "../facts/sites.js";
|
|
8
|
+
import { log } from "../logger.js";
|
|
9
|
+
import { ruleMaker } from "../plugins/index.js";
|
|
10
|
+
import { said } from "./message.js";
|
|
11
|
+
import { baseScore, interpolate, levelOf, pin, scoreOf } from "./score.js";
|
|
12
|
+
import { isPageRule } from "./types.js";
|
|
13
|
+
// strictTypes off so `{ minItems: 1 }` needs no `type: array` beside it; `format: uri` is an absolute URL a browser parses.
|
|
14
|
+
const ajv = new Ajv2020({ strictTypes: false, formats: { uri: (text) => URL.canParse(text) } });
|
|
15
|
+
// Dotted path into a facts document; absent stays undefined.
|
|
16
|
+
export function get(facts, path) {
|
|
17
|
+
let value = facts;
|
|
18
|
+
for (const key of path.split(".")) {
|
|
19
|
+
if (value === null || typeof value !== "object")
|
|
20
|
+
return undefined;
|
|
21
|
+
value = value[key];
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
// JSON pointer walk for the value an AJV error points at.
|
|
26
|
+
function at(value, pointer) {
|
|
27
|
+
return pointer === "" ? value : get(value, pointer.slice(1).replaceAll("/", "."));
|
|
28
|
+
}
|
|
29
|
+
// A string past `max` keeps its head and tail around one ellipsis, since either end may hold the fix.
|
|
30
|
+
function elide(text, max) {
|
|
31
|
+
return text.length > max ? `${text.slice(0, Math.ceil(max / 2))}…${text.slice(text.length - Math.floor(max / 2) + 1)}` : text;
|
|
32
|
+
}
|
|
33
|
+
// Rendering of an offending value for the finding message; a title or description shows whole.
|
|
34
|
+
export function describe(value) {
|
|
35
|
+
if (typeof value === "string")
|
|
36
|
+
return `${value.length} characters: “${elide(value, 200)}”`;
|
|
37
|
+
if (Array.isArray(value) && value.length === 0)
|
|
38
|
+
return "none";
|
|
39
|
+
if (Array.isArray(value))
|
|
40
|
+
return value.every((item) => typeof item === "string" || typeof item === "number") ? `${value.length} items: ${elide(value.map((item) => (typeof item === "string" ? `“${item}”` : String(item))).join(", "), 200)}` : `${value.length} items`;
|
|
41
|
+
const json = JSON.stringify(value);
|
|
42
|
+
return json.length > 80 ? `${json.slice(0, 77)}…` : json;
|
|
43
|
+
}
|
|
44
|
+
// The rule’s own sentence as a template over `{got}` and `{field}`, else AJV’s wording against the fact path.
|
|
45
|
+
function message(fact, value, error, text) {
|
|
46
|
+
const actual = value === undefined ? undefined : at(value, error.instancePath);
|
|
47
|
+
// A length says nothing against an exact value, so a `const` or `enum` miss shows the value alone.
|
|
48
|
+
const got = value === undefined ? "none" : typeof actual === "string" && ["const", "enum"].includes(error.keyword) ? `“${actual}”` : describe(actual);
|
|
49
|
+
const field = error.instancePath.slice(1).replaceAll("/", ".") || fact.split(".").at(-1);
|
|
50
|
+
if (text)
|
|
51
|
+
return said(text, { got, field });
|
|
52
|
+
const name = label(fact) ?? fact;
|
|
53
|
+
return { message: value === undefined ? `${name} is absent` : `${name}${error.instancePath} ${error.message} (got ${got})` };
|
|
54
|
+
}
|
|
55
|
+
function severityOf(id, spec, fallback) {
|
|
56
|
+
const severity = typeof spec.score === "number" && !spec.pinned ? levelOf(spec.score) : (spec.severity ?? fallback);
|
|
57
|
+
if (severity === "off")
|
|
58
|
+
throw new ConfigError(`rule ${id}: compiled while off`);
|
|
59
|
+
return severity;
|
|
60
|
+
}
|
|
61
|
+
// A `when` path read from the site document, as `site.role`, rather than one subject’s facts.
|
|
62
|
+
function isSitePath(path) {
|
|
63
|
+
return path.startsWith("site.") && !/^site\.(origins|hosts)\./.test(path);
|
|
64
|
+
}
|
|
65
|
+
// The `when` entries a site-wide guard reads.
|
|
66
|
+
function siteWhen(when) {
|
|
67
|
+
return Object.fromEntries(Object.entries(when ?? {}).filter(([path]) => isSitePath(path)));
|
|
68
|
+
}
|
|
69
|
+
// A `when` entry is a constant to equal, or a schema object the fact must satisfy.
|
|
70
|
+
function guard(id, when) {
|
|
71
|
+
const tests = Object.entries(when ?? {}).map(([path, expected]) => {
|
|
72
|
+
const test = typeof expected === "object" && expected !== null ? ajv.compile(expected) : (actual) => actual === expected;
|
|
73
|
+
return { path, expected, test };
|
|
74
|
+
});
|
|
75
|
+
return (facts, subject, site) => {
|
|
76
|
+
for (const { path, expected, test } of tests) {
|
|
77
|
+
const actual = get(isSitePath(path) ? { site } : facts, path);
|
|
78
|
+
if (test(actual))
|
|
79
|
+
continue;
|
|
80
|
+
log.debug({ rule: id, url: subject, when: path, expected, actual }, "rule skipped");
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
// A page whose facts lack the extractor's top-level key is skipped, not failed.
|
|
87
|
+
function compilePage(id, spec, fact, validate) {
|
|
88
|
+
const severity = severityOf(id, spec, "warning");
|
|
89
|
+
const root = fact.split(".", 1)[0];
|
|
90
|
+
const isSkipped = guard(id, spec.when);
|
|
91
|
+
return {
|
|
92
|
+
meta: { id, severity, scope: "page", facts: [fact], docs: spec.docs, fix: spec.fix, expect: spec.expect },
|
|
93
|
+
check(page, site) {
|
|
94
|
+
if (isSkipped(page, page.url.href, site))
|
|
95
|
+
return;
|
|
96
|
+
if (get(page, root) === undefined) {
|
|
97
|
+
log.debug({ rule: id, url: page.url.href, extractor: root }, "rule skipped");
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const value = get(page, fact);
|
|
101
|
+
if (validate(value))
|
|
102
|
+
return [];
|
|
103
|
+
const error = validate.errors?.[0];
|
|
104
|
+
return [{ rule: id, severity, scope: "page", url: page.url.href, group: page.group, ...message(fact, value, error, spec.message), value }];
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
// One finding per value held by two or more distinct URLs.
|
|
109
|
+
function compileUnique(id, spec, fact) {
|
|
110
|
+
const severity = severityOf(id, spec, "warning");
|
|
111
|
+
const scope = spec.scope === "group" ? "group" : "site";
|
|
112
|
+
const isSkipped = guard(id, spec.when);
|
|
113
|
+
return {
|
|
114
|
+
meta: { id, severity, scope, facts: [fact], docs: spec.docs, fix: spec.fix },
|
|
115
|
+
check(pages, group, site) {
|
|
116
|
+
const byValue = new Map();
|
|
117
|
+
for (const page of pages) {
|
|
118
|
+
if (isSkipped(page, page.url.href, site))
|
|
119
|
+
continue;
|
|
120
|
+
const value = get(page, fact);
|
|
121
|
+
if (typeof value !== "string")
|
|
122
|
+
continue;
|
|
123
|
+
byValue.set(value, [...(byValue.get(value) ?? []), page.url.href]);
|
|
124
|
+
}
|
|
125
|
+
const findings = [];
|
|
126
|
+
for (const [value, urls] of byValue) {
|
|
127
|
+
if (urls.length < 2)
|
|
128
|
+
continue;
|
|
129
|
+
log.debug({ rule: id, group, value, pages: urls.length }, "duplicate value");
|
|
130
|
+
findings.push({ rule: id, severity, scope, url: urls[0], group, ...(spec.message ? said(spec.message, { got: describe(value) }) : { message: `${label(fact) ?? fact} is shared by ${urls.length} pages (${describe(value)})` }), value, urls });
|
|
131
|
+
}
|
|
132
|
+
return findings;
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
// One check per origin or host whose facts carry the extractor’s key, keyed by that subject; only `when` paths under the same subjects apply.
|
|
137
|
+
// A subject whose path runs through a probe robots.txt withheld is skipped, not failed.
|
|
138
|
+
function isWithheld(facts, path, rule, subject) {
|
|
139
|
+
const parts = path.split(".");
|
|
140
|
+
const withheld = parts.find((_part, index) => get(facts, parts.slice(0, index + 1).join("."))?.disallowed === true);
|
|
141
|
+
if (withheld)
|
|
142
|
+
log.debug({ rule, subject, path, withheld }, "rule skipped, robots.txt withheld its probe");
|
|
143
|
+
return withheld !== undefined;
|
|
144
|
+
}
|
|
145
|
+
function compileSubject(id, spec, fact, subject, validate) {
|
|
146
|
+
const severity = severityOf(id, spec, "warning");
|
|
147
|
+
const prefix = `site.${subject.kind}.*.`;
|
|
148
|
+
const when = Object.entries(spec.when ?? {}).filter(([path]) => path.startsWith(prefix));
|
|
149
|
+
log.debug({ rule: id, kind: subject.kind, when: when.length, site: Object.keys(siteWhen(spec.when)), ignored: Object.keys(spec.when ?? {}).length - when.length - Object.keys(siteWhen(spec.when)).length }, "subject rule compiled");
|
|
150
|
+
const isSkipped = guard(id, Object.fromEntries(when.map(([path, expected]) => [path.slice(prefix.length), expected])));
|
|
151
|
+
const isSiteSkipped = guard(id, siteWhen(spec.when));
|
|
152
|
+
const needs = [...when.map(([path]) => path), ...(spec.hints ? [`${prefix}stack`] : [])].filter((path) => subjectPath(path)?.id !== subject.id);
|
|
153
|
+
return {
|
|
154
|
+
meta: { id, severity, scope: "site", facts: [fact, ...new Set(needs)], docs: spec.docs, fix: spec.fix, expect: spec.expect, ...(spec.linked === true && subject.kind === "hosts" && { linked: true }) },
|
|
155
|
+
check(_pages, _group, site) {
|
|
156
|
+
if (isSiteSkipped({}, "site", site))
|
|
157
|
+
return;
|
|
158
|
+
const unjudged = new Set(spec.linked === true ? [] : (site?.linked ?? []));
|
|
159
|
+
const judged = Object.entries(site?.[subject.kind] ?? {}).filter(([name, facts]) => facts[subject.id] !== undefined && !unjudged.has(name) && !isSkipped(facts, name) && !isWithheld(facts, subject.path, id, name));
|
|
160
|
+
log.debug({ rule: id, subjects: judged.length, linked: spec.linked === true }, "subjects judged");
|
|
161
|
+
return judged.length === 0
|
|
162
|
+
? undefined
|
|
163
|
+
: judged.flatMap(([name, facts]) => {
|
|
164
|
+
const value = get(facts, subject.path);
|
|
165
|
+
if (validate(value))
|
|
166
|
+
return [];
|
|
167
|
+
const error = validate.errors?.[0];
|
|
168
|
+
const hint = hintFor(spec.hints, facts.stack);
|
|
169
|
+
if (hint)
|
|
170
|
+
log.debug({ rule: id, subject: name, service: hint.service }, "service hint chosen");
|
|
171
|
+
return [{ rule: id, severity, scope: "site", url: name, ...message(fact, value, error, spec.message), value, ...(hint && { hint: hint.text }) }];
|
|
172
|
+
});
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
// The hint for the first service the subject’s `stack` names, if the rule has one for it.
|
|
177
|
+
function hintFor(hints, stack) {
|
|
178
|
+
const names = Object.values((stack ?? {})).map((entry) => entry.name);
|
|
179
|
+
const service = names.find((name) => hints?.[name] !== undefined);
|
|
180
|
+
return service && hints ? { service, text: hints[service] } : undefined;
|
|
181
|
+
}
|
|
182
|
+
// A built-in page rule honours `when` as a declarative one does; an aggregate one its `site.` paths.
|
|
183
|
+
function guarded(rule, when) {
|
|
184
|
+
const id = rule.meta.id;
|
|
185
|
+
if (isPageRule(rule)) {
|
|
186
|
+
const isSkipped = guard(id, when);
|
|
187
|
+
return { ...rule, check: (page, site) => (isSkipped(page, page.url.href, site) ? undefined : rule.check(page, site)) };
|
|
188
|
+
}
|
|
189
|
+
const isSkipped = guard(id, siteWhen(when));
|
|
190
|
+
return { ...rule, check: (pages, group, site) => (isSkipped({}, "site", site) ? undefined : rule.check(pages, group, site)) };
|
|
191
|
+
}
|
|
192
|
+
// Gives every finding its score: the spec’s number, else the rule’s own or the scale’s over the finding’s value, else the level’s base; a hand-written level pins it inside its band.
|
|
193
|
+
function scored(rule, spec) {
|
|
194
|
+
const fixed = typeof spec.score === "number" ? (spec.pinned ? pin(spec.severity, spec.score) : spec.score) : undefined;
|
|
195
|
+
const scale = typeof spec.score === "object" ? spec.score : undefined;
|
|
196
|
+
const level = (spec.pinned ? spec.severity : rule.meta.severity);
|
|
197
|
+
const meta = { ...rule.meta, score: fixed ?? (scale ? undefined : baseScore(level)), scale };
|
|
198
|
+
const grade = (finding) => {
|
|
199
|
+
const measured = scale && typeof finding.value === "number" ? interpolate(scale, finding.value) : finding.score;
|
|
200
|
+
const raw = fixed ?? measured ?? scoreOf(finding);
|
|
201
|
+
const score = spec.pinned ? pin(level, raw) : raw;
|
|
202
|
+
log.debug({ rule: rule.meta.id, url: finding.url, score, pinned: spec.pinned === true }, "finding scored");
|
|
203
|
+
return { ...finding, score, severity: levelOf(score) };
|
|
204
|
+
};
|
|
205
|
+
const all = (found) => found?.map((finding) => grade(finding));
|
|
206
|
+
return isPageRule(rule) ? { meta: { ...meta, scope: "page" }, check: (page, site) => all(rule.check(page, site)) } : { meta: { ...meta, scope: rule.meta.scope }, check: (pages, group, site) => all(rule.check(pages, group, site)) };
|
|
207
|
+
}
|
|
208
|
+
// A rule from its spec, every finding scored.
|
|
209
|
+
export function compileRule(id, spec) {
|
|
210
|
+
return scored(compileUnscored(id, spec), spec);
|
|
211
|
+
}
|
|
212
|
+
// `fact` + `expect` is a page rule, `unique` an aggregate, an ID without `fact` a built-in handed its `expect`; else a config error.
|
|
213
|
+
function compileUnscored(id, spec) {
|
|
214
|
+
if (spec.unique)
|
|
215
|
+
return compileUnique(id, spec, spec.unique);
|
|
216
|
+
const make = spec.fact ? undefined : ruleMaker(id);
|
|
217
|
+
if (!spec.fact && (make || !spec.expect)) {
|
|
218
|
+
if (!make)
|
|
219
|
+
throw new ConfigError(`rule ${id}: needs fact and expect, or unique, or a built-in ID`);
|
|
220
|
+
const rule = make(severityOf(id, spec, "warning"), spec.expect);
|
|
221
|
+
// A ruleset entry’s `docs` and `fix` win over the built-in’s own.
|
|
222
|
+
Object.assign(rule.meta, spec.docs && { docs: spec.docs }, spec.fix && { fix: spec.fix });
|
|
223
|
+
return guarded(rule, spec.when);
|
|
224
|
+
}
|
|
225
|
+
if (!spec.fact || !spec.expect)
|
|
226
|
+
throw new ConfigError(`rule ${id}: needs both fact and expect`);
|
|
227
|
+
try {
|
|
228
|
+
const subject = subjectPath(spec.fact);
|
|
229
|
+
return subject ? compileSubject(id, spec, spec.fact, subject, ajv.compile(spec.expect)) : compilePage(id, spec, spec.fact, ajv.compile(spec.expect));
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
throw new ConfigError(`rule ${id}: expect ${error instanceof Error ? error.message : String(error)}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { log } from "../logger.js";
|
|
5
|
+
import { said } from "./message.js";
|
|
6
|
+
// Response headers no current browser acts on, by lower-cased name.
|
|
7
|
+
export const DEPRECATED = {
|
|
8
|
+
"x-xss-protection": { name: "X-XSS-Protection", instead: "browsers removed the filter; Content-Security-Policy replaces it", docs: "https://developer.mozilla.org/docs/Web/HTTP/Headers/X-XSS-Protection" },
|
|
9
|
+
"report-to": { name: "Report-To", instead: "Reporting-Endpoints replaces it", docs: "https://developer.mozilla.org/docs/Web/HTTP/Headers/Report-To", unless: "nel" },
|
|
10
|
+
"expect-ct": { name: "Expect-CT", instead: "browsers enforce Certificate Transparency by default", docs: "https://developer.mozilla.org/docs/Web/HTTP/Headers/Expect-CT" },
|
|
11
|
+
"public-key-pins": { name: "Public-Key-Pins", instead: "browsers removed key pinning", docs: "https://chromestatus.com/feature/5903385005916160" },
|
|
12
|
+
"public-key-pins-report-only": { name: "Public-Key-Pins-Report-Only", instead: "browsers removed key pinning", docs: "https://chromestatus.com/feature/5903385005916160" },
|
|
13
|
+
"feature-policy": { name: "Feature-Policy", instead: "Permissions-Policy replaces it", docs: "https://developer.mozilla.org/docs/Web/HTTP/Headers/Permissions-Policy" },
|
|
14
|
+
p3p: { name: "P3P", instead: "W3C retired the standard and no browser reads it", docs: "https://www.w3.org/TR/P3P/" },
|
|
15
|
+
};
|
|
16
|
+
// One finding per page naming every deprecated header it sends, with what replaces each.
|
|
17
|
+
const deprecatedHeader = (severity) => ({
|
|
18
|
+
meta: { id: "http/deprecated-header", severity, scope: "page", facts: ["http.headers"], docs: "https://developer.mozilla.org/docs/Web/HTTP/Headers", fix: "Remove each deprecated header, and send Reporting-Endpoints in place of Report-To unless NEL still reads it." },
|
|
19
|
+
check(page) {
|
|
20
|
+
const sent = Object.keys(page.http.headers).filter((name) => {
|
|
21
|
+
const entry = DEPRECATED[name];
|
|
22
|
+
return entry !== undefined && (entry.unless === undefined || page.http.headers[entry.unless] === undefined);
|
|
23
|
+
});
|
|
24
|
+
log.debug({ rule: "http/deprecated-header", url: page.url.href, sent }, "deprecated headers checked");
|
|
25
|
+
if (sent.length === 0)
|
|
26
|
+
return [];
|
|
27
|
+
const listed = sent.map((name) => DEPRECATED[name]);
|
|
28
|
+
return [
|
|
29
|
+
{
|
|
30
|
+
rule: "http/deprecated-header",
|
|
31
|
+
severity,
|
|
32
|
+
scope: "page",
|
|
33
|
+
url: page.url.href,
|
|
34
|
+
group: page.group,
|
|
35
|
+
...said("the page sends deprecated headers"),
|
|
36
|
+
data: { [page.url.href]: { headers: listed.map((entry) => entry.name).join(", ") } },
|
|
37
|
+
locations: listed.map((entry) => `${entry.name} → ${entry.instead}`),
|
|
38
|
+
value: Object.fromEntries(listed.map((entry) => [entry.name, entry.docs])),
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
export const deprecatedRules = {
|
|
44
|
+
"http/deprecated-header": deprecatedHeader,
|
|
45
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { getDomain } from "tldts";
|
|
5
|
+
import { log } from "../logger.js";
|
|
6
|
+
import { said } from "./message.js";
|
|
7
|
+
// Response headers, and the generator meta, that name the software behind a response.
|
|
8
|
+
export const DISCLOSING = {
|
|
9
|
+
server: "version",
|
|
10
|
+
via: "version",
|
|
11
|
+
"x-generator": "version",
|
|
12
|
+
"meta generator": "version",
|
|
13
|
+
"x-powered-by": "always",
|
|
14
|
+
"x-aspnet-version": "always",
|
|
15
|
+
"x-aspnetmvc-version": "always",
|
|
16
|
+
"x-debug-token": "always",
|
|
17
|
+
"x-debug-token-link": "always",
|
|
18
|
+
"x-drupal-cache": "presence",
|
|
19
|
+
"x-varnish": "presence",
|
|
20
|
+
"x-runtime": "presence",
|
|
21
|
+
};
|
|
22
|
+
// Products a CDN or hosting platform names in `Server` and the owner cannot remove.
|
|
23
|
+
export const UNREMOVABLE = new Set(["cloudflare", "amazons3", "cloudfront", "fastly", "akamaighost", "akamainetstorage", "netlify", "vercel", "github.com", "gws", "esf", "sffe", "google frontend", "bunnycdn", "keycdn"]);
|
|
24
|
+
// A version number: a digit after `/`, a space or `(`, optionally `v`-prefixed.
|
|
25
|
+
const VERSION = /(?:^|[/\s(])v?\d/i;
|
|
26
|
+
// A value with `Via`’s received-protocol dropped, since `1.1` there is HTTP’s version, not the proxy’s.
|
|
27
|
+
function stripped(field, value) {
|
|
28
|
+
return field === "via"
|
|
29
|
+
? value
|
|
30
|
+
.split(",")
|
|
31
|
+
.map((hop) => hop.trim().replace(/^\S+\s+/, ""))
|
|
32
|
+
.join(", ")
|
|
33
|
+
: value.trim();
|
|
34
|
+
}
|
|
35
|
+
// The severity one field earns, or undefined when it discloses nothing the owner can change.
|
|
36
|
+
function judge(field, value, severity) {
|
|
37
|
+
const product = stripped(field, value);
|
|
38
|
+
const kind = DISCLOSING[field] ?? "presence";
|
|
39
|
+
const isVersioned = VERSION.test(product);
|
|
40
|
+
const isUnremovable = field === "server" && UNREMOVABLE.has(product.toLowerCase().split("/", 1)[0] ?? "");
|
|
41
|
+
log.debug({ rule: "http/server-disclosure", field, value, kind, isVersioned, isUnremovable }, "disclosure judged");
|
|
42
|
+
if (isUnremovable || product.length === 0)
|
|
43
|
+
return undefined;
|
|
44
|
+
return kind === "always" || (isVersioned && kind === "version") ? severity : "hint";
|
|
45
|
+
}
|
|
46
|
+
// Each disclosing field of one response, joined when repeated.
|
|
47
|
+
function fields(headers, generator) {
|
|
48
|
+
const found = Object.keys(DISCLOSING).flatMap((name) => {
|
|
49
|
+
const value = [headers?.[name] ?? []].flat().join(", ");
|
|
50
|
+
return value ? [[name, value]] : [];
|
|
51
|
+
});
|
|
52
|
+
return generator ? [...found, ["meta generator", generator]] : found;
|
|
53
|
+
}
|
|
54
|
+
// The registrable domain of a host, the host itself when it has none.
|
|
55
|
+
function domainOf(host) {
|
|
56
|
+
return getDomain(host, { allowPrivateDomains: true }) ?? host;
|
|
57
|
+
}
|
|
58
|
+
// One finding per host, field and value across every page and every resource under a crawled registrable domain.
|
|
59
|
+
const serverDisclosure = (severity) => ({
|
|
60
|
+
meta: {
|
|
61
|
+
id: "http/server-disclosure",
|
|
62
|
+
severity,
|
|
63
|
+
scope: "site",
|
|
64
|
+
facts: ["http.headers", "html.meta.generator", "resources"],
|
|
65
|
+
docs: "https://developer.mozilla.org/docs/Web/HTTP/Reference/Headers/Server",
|
|
66
|
+
fix: "Remove the version from the Server header and drop X-Powered-By and debug headers at the server or proxy.",
|
|
67
|
+
},
|
|
68
|
+
check(pages) {
|
|
69
|
+
const domains = new Set(pages.map((page) => domainOf(page.url.host)));
|
|
70
|
+
const seen = new Map();
|
|
71
|
+
const note = (url, found) => {
|
|
72
|
+
const host = new URL(url).host;
|
|
73
|
+
for (const [field, value] of found) {
|
|
74
|
+
const key = `${host}\n${field}\n${value}`;
|
|
75
|
+
const entry = seen.get(key) ?? { host, field, value, urls: new Set() };
|
|
76
|
+
entry.urls.add(url);
|
|
77
|
+
seen.set(key, entry);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
for (const page of pages) {
|
|
81
|
+
note(page.url.href, fields(page.http.headers, page.html?.meta.generator));
|
|
82
|
+
const owned = (page.resources ?? []).filter((resource) => domains.has(domainOf(new URL(resource.url).host)));
|
|
83
|
+
for (const resource of owned)
|
|
84
|
+
note(resource.url, fields(resource.http?.headers));
|
|
85
|
+
}
|
|
86
|
+
const findings = [];
|
|
87
|
+
for (const { host, field, value, urls } of seen.values()) {
|
|
88
|
+
const level = judge(field, value, severity);
|
|
89
|
+
if (!level)
|
|
90
|
+
continue;
|
|
91
|
+
const listed = [...urls].toSorted((a, b) => a.localeCompare(b));
|
|
92
|
+
findings.push({ rule: "http/server-disclosure", severity: level, scope: "site", url: host, ...said("the host names its software in {field}", { field }), data: { [host]: { value } }, value: { [field]: value }, urls: listed, score: level === "hint" ? 3.5 : 5.4 });
|
|
93
|
+
}
|
|
94
|
+
return findings;
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
export const disclosureRules = {
|
|
98
|
+
"http/server-disclosure": serverDisclosure,
|
|
99
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { getDomain } from "tldts";
|
|
5
|
+
import { log } from "../logger.js";
|
|
6
|
+
const PLACEHOLDER = /\{(host|domain|origin|url)\}/g;
|
|
7
|
+
// The subject a finding names: a page URL, else the bare host or origin a site rule keys it by.
|
|
8
|
+
function subject(finding) {
|
|
9
|
+
const href = URL.canParse(finding.url) ? finding.url : `https://${finding.url}`;
|
|
10
|
+
if (!URL.canParse(href))
|
|
11
|
+
return undefined;
|
|
12
|
+
const url = new URL(href);
|
|
13
|
+
return { host: url.hostname, domain: getDomain(url.hostname, { allowPrivateDomains: true }) ?? url.hostname, origin: url.origin, url: url.href };
|
|
14
|
+
}
|
|
15
|
+
// A rule’s fix, or the hint for the service the finding’s subject runs on, with `{host}`, `{domain}`, `{origin}` and `{url}` taken from the finding, else shown as `<host>`.
|
|
16
|
+
export function fixFor(fix, finding) {
|
|
17
|
+
const values = finding && subject(finding);
|
|
18
|
+
if (finding && !values)
|
|
19
|
+
log.debug({ rule: finding.rule, url: finding.url }, "fix left generic, no subject");
|
|
20
|
+
return (finding?.hint ?? fix).replaceAll(PLACEHOLDER, (_match, name) => values?.[name] ?? `<${name}>`);
|
|
21
|
+
}
|