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,82 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { normalizeVia } from "../agent.js";
|
|
6
|
+
import { log } from "../logger.js";
|
|
7
|
+
import { formatNames } from "../plugins/index.js";
|
|
8
|
+
import { Buckets, clientOf } from "./clients.js";
|
|
9
|
+
import { admit, Refusal, resolveRules } from "./policy.js";
|
|
10
|
+
import { charge, repeatKey } from "./queue.js";
|
|
11
|
+
import { traceCarrier } from "../telemetry.js";
|
|
12
|
+
// BullMQ states as the API names them; anything else is still waiting its turn.
|
|
13
|
+
const STATUS = { active: "running", completed: "done", failed: "failed" };
|
|
14
|
+
function iso(ms) {
|
|
15
|
+
return ms ? new Date(ms).toISOString() : undefined;
|
|
16
|
+
}
|
|
17
|
+
// A job as clients see it, with relative links so no Host header is trusted.
|
|
18
|
+
export async function view(job) {
|
|
19
|
+
const status = STATUS[await job.getState()] ?? "queued";
|
|
20
|
+
const self = `/v1/jobs/${job.id}`;
|
|
21
|
+
return {
|
|
22
|
+
id: job.id,
|
|
23
|
+
url: job.data.url,
|
|
24
|
+
policy: job.data.policy,
|
|
25
|
+
status,
|
|
26
|
+
...(typeof job.progress === "object" && { progress: job.progress }),
|
|
27
|
+
created: iso(job.timestamp),
|
|
28
|
+
started: iso(job.processedOn),
|
|
29
|
+
finished: iso(job.finishedOn),
|
|
30
|
+
...(status === "failed" && { error: job.failedReason }),
|
|
31
|
+
...(status === "done" && { summary: job.returnvalue.summary }),
|
|
32
|
+
links: { self, page: `/jobs/${job.id}`, events: `${self}/events`, ...(status === "done" && { reports: Object.fromEntries(formatNames().map((name) => [name, `${self}/report/${name}`])) }) },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
// The job the path names, or a 404.
|
|
36
|
+
export async function jobOf(queue, id) {
|
|
37
|
+
const job = (await queue.getJob(id));
|
|
38
|
+
if (!job)
|
|
39
|
+
throw new Refusal(404, "not-found", `job ${id}: not found or expired`);
|
|
40
|
+
return job;
|
|
41
|
+
}
|
|
42
|
+
// The job a repeat of this scan returns: queued, running or done inside the policy’s window.
|
|
43
|
+
async function repeated(jobs, key) {
|
|
44
|
+
const id = await jobs.redis.get(key);
|
|
45
|
+
const job = id ? (await jobs.queue.getJob(id)) : undefined;
|
|
46
|
+
const state = await job?.getState();
|
|
47
|
+
log.debug({ job: id, state }, "repeat looked up");
|
|
48
|
+
return state && state !== "failed" ? job : undefined;
|
|
49
|
+
}
|
|
50
|
+
// The address a request came from, through the proxies and CDN edges the settings trust.
|
|
51
|
+
export function clientAddress(c, jobs) {
|
|
52
|
+
const peer = c.env?.incoming?.socket.remoteAddress ?? "";
|
|
53
|
+
return clientOf(peer, c.req.header("x-forwarded-for"), [jobs.settings().clients.trusted, jobs.edges.list]);
|
|
54
|
+
}
|
|
55
|
+
// Admits `body` and queues its scan, or returns the job it repeats; every refusal comes before the host’s window is charged.
|
|
56
|
+
export async function submit(jobs, body, c) {
|
|
57
|
+
const current = jobs.settings();
|
|
58
|
+
const admitted = admit(body, current);
|
|
59
|
+
await resolveRules(admitted);
|
|
60
|
+
const key = repeatKey(admitted);
|
|
61
|
+
const repeat = admitted.policy.repeat ? await repeated(jobs, key) : undefined;
|
|
62
|
+
if (repeat) {
|
|
63
|
+
log.info({ job: repeat.id, host: admitted.host, policy: admitted.policy.name }, "scan repeated");
|
|
64
|
+
return { job: repeat, isRepeat: true };
|
|
65
|
+
}
|
|
66
|
+
const waiting = await jobs.queue.getWaitingCount();
|
|
67
|
+
log.debug({ waiting, maxQueued: current.maxQueued }, "queue depth checked");
|
|
68
|
+
if (waiting >= current.maxQueued)
|
|
69
|
+
throw new Refusal(503, "queue-full", `${waiting} scans are waiting; try again later`, 60);
|
|
70
|
+
const { rate } = current.clients;
|
|
71
|
+
const wait = rate ? jobs.buckets.take(clientAddress(c, jobs), rate) : 0;
|
|
72
|
+
if (rate && wait > 0)
|
|
73
|
+
throw new Refusal(429, "client-rate-limited", `a client may queue ${rate.jobs} scans per ${rate.seconds} s`, wait);
|
|
74
|
+
await charge(jobs.redis, admitted);
|
|
75
|
+
const via = normalizeVia(c.req.header("host"));
|
|
76
|
+
const data = { url: admitted.url, host: admitted.host, policy: admitted.policy.name, settings: admitted.settings, scanTimeout: admitted.policy.scanTimeout, deny: admitted.policy.rules.deny, via, ...(admitted.policy.repeat && { repeatKey: key }), trace: traceCarrier() };
|
|
77
|
+
const job = (await jobs.queue.add("scan", data, { jobId: randomUUID() }));
|
|
78
|
+
if (admitted.policy.repeat)
|
|
79
|
+
await jobs.redis.set(key, job.id, "EX", admitted.policy.repeat);
|
|
80
|
+
log.info({ job: job.id, host: admitted.host, policy: admitted.policy.name, repeat: admitted.policy.repeat }, "scan queued");
|
|
81
|
+
return { job, isRepeat: false };
|
|
82
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env -S node --experimental-strip-types
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
3
|
+
//
|
|
4
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { serve } from "@hono/node-server";
|
|
7
|
+
import { ConfigError } from "../config/index.js";
|
|
8
|
+
import { environmentSettings } from "../config/environment.js";
|
|
9
|
+
import { prepareBrowser } from "../crawl/browser.js";
|
|
10
|
+
import { log } from "../logger.js";
|
|
11
|
+
import { api } from "./api.js";
|
|
12
|
+
import { checkClock } from "./clock.js";
|
|
13
|
+
import { connect, scanQueue } from "./queue.js";
|
|
14
|
+
import { DEFAULT_PATH, watchSettings } from "./settings.js";
|
|
15
|
+
import { startWorker } from "./worker.js";
|
|
16
|
+
import { observeQueue, startTelemetry } from "../telemetry.js";
|
|
17
|
+
const MODES = ["api", "worker", "all"];
|
|
18
|
+
// Downloads Chromium into the cache once, before the worker takes a job; a failure leaves browser scans failing, never the worker.
|
|
19
|
+
async function prepareWorkerBrowser() {
|
|
20
|
+
const { browserInstall = true } = environmentSettings(process.env);
|
|
21
|
+
if (!browserInstall) {
|
|
22
|
+
log.info({ browserInstall }, "browser not prepared");
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
await prepareBrowser("chromium");
|
|
27
|
+
log.info({ browser: "chromium" }, "browser ready");
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
log.warn({ browser: "chromium", error: error instanceof Error ? error.message : String(error) }, "browser not ready, browser scans will fail");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// Starts the API, the worker or both, per SPIDERLINT_MODE, until SIGTERM or SIGINT.
|
|
34
|
+
async function main() {
|
|
35
|
+
const mode = process.env.SPIDERLINT_MODE ?? "all";
|
|
36
|
+
if (!MODES.includes(mode))
|
|
37
|
+
throw new ConfigError(`SPIDERLINT_MODE: invalid value ${mode} (expected: ${MODES.join("|")})`);
|
|
38
|
+
const path = process.env.SPIDERLINT_SERVER_CONFIG ?? DEFAULT_PATH;
|
|
39
|
+
const settings = watchSettings(path);
|
|
40
|
+
const { redis: url, redisPasswordFile, listen, retention, workers, clockReferences } = settings.current();
|
|
41
|
+
const password = redisPasswordFile ? readFileSync(redisPasswordFile, "utf8").trim() : undefined;
|
|
42
|
+
log.info({ mode, path, listen, workers, redisPasswordFile }, "server starting");
|
|
43
|
+
const telemetry = await startTelemetry("spiderlint");
|
|
44
|
+
void checkClock(clockReferences);
|
|
45
|
+
const closers = [];
|
|
46
|
+
if (mode !== "worker") {
|
|
47
|
+
const redis = connect(url, password);
|
|
48
|
+
const queue = scanQueue(redis, retention);
|
|
49
|
+
observeQueue(async () => queue.getJobCounts("waiting", "active", "delayed", "prioritized"));
|
|
50
|
+
const server = serve({ fetch: api(queue, redis, settings.current).fetch, hostname: listen.host, port: listen.port }, (info) => log.info({ address: info.address, port: info.port }, "api listening"));
|
|
51
|
+
closers.push(async () => new Promise((resolve) => server.close(resolve)), async () => queue.close(), async () => redis.quit());
|
|
52
|
+
}
|
|
53
|
+
if (mode !== "api") {
|
|
54
|
+
await prepareWorkerBrowser();
|
|
55
|
+
const redis = connect(url, password);
|
|
56
|
+
const worker = startWorker(redis, workers, retention);
|
|
57
|
+
closers.push(async () => worker.close(), async () => redis.quit());
|
|
58
|
+
}
|
|
59
|
+
if (telemetry)
|
|
60
|
+
closers.push(async () => telemetry.shutdown());
|
|
61
|
+
const stop = async (signal) => {
|
|
62
|
+
log.info({ signal, closers: closers.length }, "server stopping");
|
|
63
|
+
settings.close();
|
|
64
|
+
for (const close of closers) {
|
|
65
|
+
try {
|
|
66
|
+
await close();
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
log.warn({ error: String(error) }, "close failed");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
log.info({ signal }, "server stopped");
|
|
73
|
+
};
|
|
74
|
+
for (const signal of ["SIGTERM", "SIGINT"])
|
|
75
|
+
process.once(signal, (name) => void stop(name));
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
await main();
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
log.fatal({ error: error instanceof Error ? error.message : String(error) }, "server not started");
|
|
82
|
+
process.exitCode = error instanceof ConfigError ? 2 : 4;
|
|
83
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
6
|
+
import { readFile, realpath, stat } from "node:fs/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { load } from "cheerio";
|
|
9
|
+
import { ConfigError } from "../config/index.js";
|
|
10
|
+
import { LANGUAGES, translator } from "../i18n.js";
|
|
11
|
+
import { log } from "../logger.js";
|
|
12
|
+
const SLOTS = ["head", "header", "footer"];
|
|
13
|
+
const FILE = /^(head|header|footer)(?:\.([a-z]{2,3}))?\.html$/;
|
|
14
|
+
const RELOAD_MS = 5000;
|
|
15
|
+
const MEDIA = {
|
|
16
|
+
js: "text/javascript",
|
|
17
|
+
mjs: "text/javascript",
|
|
18
|
+
css: "text/css",
|
|
19
|
+
json: "application/json",
|
|
20
|
+
map: "application/json",
|
|
21
|
+
txt: "text/plain; charset=utf-8",
|
|
22
|
+
svg: "image/svg+xml",
|
|
23
|
+
png: "image/png",
|
|
24
|
+
jpg: "image/jpeg",
|
|
25
|
+
jpeg: "image/jpeg",
|
|
26
|
+
gif: "image/gif",
|
|
27
|
+
webp: "image/webp",
|
|
28
|
+
avif: "image/avif",
|
|
29
|
+
ico: "image/x-icon",
|
|
30
|
+
woff2: "font/woff2",
|
|
31
|
+
woff: "font/woff",
|
|
32
|
+
};
|
|
33
|
+
// The CSP hash source of `text`.
|
|
34
|
+
function hashOf(text) {
|
|
35
|
+
return `'sha256-${createHash("sha256").update(text).digest("base64")}'`;
|
|
36
|
+
}
|
|
37
|
+
// What a fragment’s scripts and styles ask of the policy: inline ones by hash, external ones by origin; an external one without `integrity` or off https is a ConfigError naming the file.
|
|
38
|
+
function sourcesOf(file, html) {
|
|
39
|
+
const found = { script: new Set(), style: new Set() };
|
|
40
|
+
const $ = load(html, undefined, false);
|
|
41
|
+
for (const element of $("script, style, link[rel~=stylesheet]")) {
|
|
42
|
+
const name = element.tagName;
|
|
43
|
+
const kind = name === "script" ? "script" : "style";
|
|
44
|
+
const attribute = name === "script" ? "src" : "href";
|
|
45
|
+
const reference = $(element).attr(attribute);
|
|
46
|
+
if (name === "style" || (name === "script" && reference === undefined)) {
|
|
47
|
+
found[kind].add(hashOf($(element).html() ?? ""));
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (reference === undefined)
|
|
51
|
+
continue;
|
|
52
|
+
if (!$(element).attr("integrity"))
|
|
53
|
+
throw new ConfigError(`page/${file}: <${name} ${attribute}="${reference}"> needs an integrity attribute`);
|
|
54
|
+
const isLocal = reference.startsWith("/") && !reference.startsWith("//");
|
|
55
|
+
if (!isLocal && !(URL.canParse(reference) && new URL(reference).protocol === "https:"))
|
|
56
|
+
throw new ConfigError(`page/${file}: ${reference} must be an https URL or a path on this server`);
|
|
57
|
+
found[kind].add(isLocal ? "'self'" : new URL(reference).origin);
|
|
58
|
+
}
|
|
59
|
+
return { script: [...found.script], style: [...found.style] };
|
|
60
|
+
}
|
|
61
|
+
// The page fragments of one directory — `head`, `header` and `footer`, each `<slot>.html` or `<slot>.<lang>.html` — rendered for every language when read, so a bad one fails at once.
|
|
62
|
+
export class Fragments {
|
|
63
|
+
rendered = new Map();
|
|
64
|
+
signature = "";
|
|
65
|
+
checked = 0;
|
|
66
|
+
directory;
|
|
67
|
+
constructor(directory) {
|
|
68
|
+
this.directory = directory;
|
|
69
|
+
this.read();
|
|
70
|
+
}
|
|
71
|
+
// The files of the directory with their modification times.
|
|
72
|
+
scan() {
|
|
73
|
+
return readdirSync(this.directory)
|
|
74
|
+
.filter((file) => FILE.test(file))
|
|
75
|
+
.toSorted((a, b) => a.localeCompare(b))
|
|
76
|
+
.map((file) => `${file}:${statSync(path.join(this.directory, file)).mtimeMs}`)
|
|
77
|
+
.join("\n");
|
|
78
|
+
}
|
|
79
|
+
// Reads every fragment and renders it for every language; throws ConfigError on a bad one.
|
|
80
|
+
read() {
|
|
81
|
+
let files;
|
|
82
|
+
try {
|
|
83
|
+
files = readdirSync(this.directory)
|
|
84
|
+
.filter((file) => FILE.test(file))
|
|
85
|
+
.toSorted((a, b) => a.localeCompare(b));
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
throw new ConfigError(`page/directory: ${this.directory} cannot be read (${error instanceof Error ? error.message : String(error)})`);
|
|
89
|
+
}
|
|
90
|
+
const texts = new Map(files.map((file) => [file, readFileSync(path.join(this.directory, file), "utf8")]));
|
|
91
|
+
const rendered = new Map();
|
|
92
|
+
for (const lang of LANGUAGES) {
|
|
93
|
+
for (const slot of SLOTS) {
|
|
94
|
+
const file = texts.has(`${slot}.${lang}.html`) ? `${slot}.${lang}.html` : `${slot}.html`;
|
|
95
|
+
const text = texts.get(file);
|
|
96
|
+
if (text === undefined)
|
|
97
|
+
continue;
|
|
98
|
+
const html = text.replaceAll("{lang}", () => lang).replaceAll("{dir}", () => translator(lang).dir);
|
|
99
|
+
const sources = sourcesOf(file, html);
|
|
100
|
+
rendered.set(`${slot}.${lang}`, { html, sources });
|
|
101
|
+
log.debug({ file, lang, scripts: sources.script, styles: sources.style }, "page fragment rendered");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
this.rendered = rendered;
|
|
105
|
+
this.signature = this.scan();
|
|
106
|
+
log.info({ directory: this.directory, files, hashes: rendered.values().reduce((sum, entry) => sum + entry.sources.script.length + entry.sources.style.length, 0) }, "page fragments loaded");
|
|
107
|
+
}
|
|
108
|
+
// Rereads the files when one changed, at most every few seconds; a bad edit is logged and the last good fragments stay.
|
|
109
|
+
refresh() {
|
|
110
|
+
const now = Date.now();
|
|
111
|
+
if (now - this.checked < RELOAD_MS)
|
|
112
|
+
return;
|
|
113
|
+
this.checked = now;
|
|
114
|
+
try {
|
|
115
|
+
const current = this.scan();
|
|
116
|
+
if (current === this.signature)
|
|
117
|
+
return;
|
|
118
|
+
try {
|
|
119
|
+
this.read();
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
log.error({ directory: this.directory, error: error instanceof Error ? error.message : String(error) }, "page fragments rejected; previous kept");
|
|
123
|
+
this.signature = current;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
log.error({ directory: this.directory, error: error instanceof Error ? error.message : String(error) }, "page fragments unreadable; previous kept");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// The fragment for `slot` in `lang`, `{lang}` and `{dir}` filled, and what it adds to the policy.
|
|
131
|
+
slot(slot, lang) {
|
|
132
|
+
this.refresh();
|
|
133
|
+
return this.rendered.get(`${slot}.${lang}`) ?? { html: "", sources: { script: [], style: [] } };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// The directory a settings file names, or a ConfigError.
|
|
137
|
+
export function directoryOf(key, value) {
|
|
138
|
+
try {
|
|
139
|
+
if (statSync(value).isDirectory())
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// falls through to the error below
|
|
144
|
+
}
|
|
145
|
+
throw new ConfigError(`${key}: ${value} is not a directory`);
|
|
146
|
+
}
|
|
147
|
+
// The file `relative` names under `root` and its media type; undefined for anything else, a directory, a dotfile or a path out of the root included.
|
|
148
|
+
export async function asset(root, relative) {
|
|
149
|
+
const segments = relative.split("/").filter(Boolean);
|
|
150
|
+
if (segments.length === 0 || segments.some((segment) => segment.startsWith(".") || segment.includes("\0") || segment.includes("\\")))
|
|
151
|
+
return undefined;
|
|
152
|
+
try {
|
|
153
|
+
const base = await realpath(root);
|
|
154
|
+
const file = await realpath(path.join(base, ...segments));
|
|
155
|
+
const found = await stat(file);
|
|
156
|
+
return !file.startsWith(`${base}${path.sep}`) || !found.isFile() ? undefined : { body: await readFile(file), type: MEDIA[path.extname(file).slice(1).toLowerCase()] ?? "application/octet-stream" };
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import picomatch from "picomatch";
|
|
5
|
+
import { ConfigError } from "../config/index.js";
|
|
6
|
+
import { fromSubtree } from "../config/policy.js";
|
|
7
|
+
import { validateSubtree } from "../config/schema.js";
|
|
8
|
+
import { validateRules } from "../index.js";
|
|
9
|
+
import { log } from "../logger.js";
|
|
10
|
+
import { CAPPED } from "./settings.js";
|
|
11
|
+
// `org.spiderlint` keys a request may set; the rest read files, load code, leave the address guard or skip robots.txt.
|
|
12
|
+
const REQUEST_KEYS = new Set(["canonical-origin", "rules", "exclude-rules", "override", "groups", "fetch", "browser", "scope", "concurrency", "rate", "timeout", "max-pages", "max-depth", "max-body-size", "keepalive", "include-urls", "exclude-urls", "resources", "links", "sitemap", "fold"]);
|
|
13
|
+
// Rules whose extractors connect to ports a stranger’s URL must never aim the server at.
|
|
14
|
+
export const NEVER_SERVED = ["sshfp", "sshfp/*"];
|
|
15
|
+
// What the form lets a visitor pick, default first, and the settings each lowers beside its rules; the policy still clamps after, so none raises a cap.
|
|
16
|
+
export const WEB_PRESETS = {
|
|
17
|
+
recommended: {},
|
|
18
|
+
"web-quick": { "max-pages": 25, resources: { fetch: false } },
|
|
19
|
+
"web-comprehensive": {},
|
|
20
|
+
};
|
|
21
|
+
// A request the server turns down, with a stable code a client can translate.
|
|
22
|
+
export class Refusal extends Error {
|
|
23
|
+
status;
|
|
24
|
+
code;
|
|
25
|
+
retryAfter;
|
|
26
|
+
constructor(status, code, message, retryAfter) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.status = status;
|
|
29
|
+
this.code = code;
|
|
30
|
+
this.retryAfter = retryAfter;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// The http or https seed, without credentials, and its lowercase host without a trailing dot.
|
|
34
|
+
export function seedOf(raw) {
|
|
35
|
+
const url = typeof raw === "string" && URL.canParse(raw) ? new URL(raw) : undefined;
|
|
36
|
+
if (!url || !["http:", "https:"].includes(url.protocol))
|
|
37
|
+
throw new Refusal(400, "invalid-url", "url: expected an absolute http or https URL");
|
|
38
|
+
if (url.username || url.password)
|
|
39
|
+
throw new Refusal(400, "invalid-url", "url: credentials are not accepted");
|
|
40
|
+
return { url: url.href, host: url.hostname.replace(/\.$/, "") };
|
|
41
|
+
}
|
|
42
|
+
// The first policy with a suffix `host` equals or ends in.
|
|
43
|
+
export function policyFor(host, policies) {
|
|
44
|
+
const bare = host.replaceAll(/^\[|\]$/g, "");
|
|
45
|
+
return policies.find((policy) => policy.hosts.some((suffix) => suffix === "*" || bare === suffix || bare.endsWith(`.${suffix}`)));
|
|
46
|
+
}
|
|
47
|
+
// `raw` under the cap, where 0 or unset is unlimited and so takes the cap.
|
|
48
|
+
function capped(raw, cap) {
|
|
49
|
+
if (cap === undefined)
|
|
50
|
+
return raw;
|
|
51
|
+
return raw === undefined || raw === 0 || raw > cap ? cap : raw;
|
|
52
|
+
}
|
|
53
|
+
// Every ruleset or rule ID the scan names, `recommended` when none is named.
|
|
54
|
+
function namedRules(settings) {
|
|
55
|
+
// The implicit `default` group is always there, as `groupsOf` adds it.
|
|
56
|
+
const groups = Object.values({ default: {}, ...settings.groups });
|
|
57
|
+
const named = [...(settings.rules ?? []), ...groups.flatMap((group) => group.rules ?? [])];
|
|
58
|
+
const isImplicit = settings.rules === undefined && groups.some((group) => group.rules === undefined);
|
|
59
|
+
return [...new Set([...named, ...(isImplicit ? ["recommended"] : [])])];
|
|
60
|
+
}
|
|
61
|
+
// Each named rule must match an `allow` entry and no `deny` entry.
|
|
62
|
+
function checkRules(settings, policy) {
|
|
63
|
+
const isDenied = picomatch([...policy.rules.deny, ...NEVER_SERVED]);
|
|
64
|
+
const isAllowed = policy.rules.allow ? picomatch(policy.rules.allow) : () => true;
|
|
65
|
+
for (const id of namedRules(settings)) {
|
|
66
|
+
log.debug({ id, policy: policy.name }, "requested rule checked");
|
|
67
|
+
if (isDenied(id) || !isAllowed(id))
|
|
68
|
+
throw new Refusal(403, "forbidden-rule", `rules: ${id} is not allowed by policy ${policy.name}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// The request settings of the web preset `name`, or a refusal when the form never offered it.
|
|
72
|
+
export function presetSettings(name) {
|
|
73
|
+
if (!Object.hasOwn(WEB_PRESETS, name))
|
|
74
|
+
throw new Refusal(400, "unknown-rule", `preset: ${name} is not a web preset`);
|
|
75
|
+
log.debug({ preset: name }, "web preset chosen");
|
|
76
|
+
return { rules: [name], ...WEB_PRESETS[name] };
|
|
77
|
+
}
|
|
78
|
+
// The web presets some admitting policy lets a request name, in the form’s order; the host typed picks the policy, so each stays checked on submit.
|
|
79
|
+
export function presetsOffered(server) {
|
|
80
|
+
const policies = server.policies.filter((policy) => policy.ban === undefined);
|
|
81
|
+
const offered = Object.keys(WEB_PRESETS).filter((name) => policies.some((policy) => {
|
|
82
|
+
try {
|
|
83
|
+
checkRules({ rules: [name] }, policy);
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (error instanceof Refusal)
|
|
88
|
+
return false;
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
}));
|
|
92
|
+
log.debug({ offered, policies: policies.length }, "web presets offered");
|
|
93
|
+
return offered;
|
|
94
|
+
}
|
|
95
|
+
// Each fetch mode the scan names must be allowed; unnamed, it takes the policy’s first.
|
|
96
|
+
function checkFetch(settings, policy) {
|
|
97
|
+
const groups = Object.values((settings.groups ?? {}));
|
|
98
|
+
const named = [settings.fetch, ...groups.map((group) => group.fetch)].filter((mode) => mode !== undefined);
|
|
99
|
+
const refused = named.find((mode) => !policy.fetch.includes(mode));
|
|
100
|
+
if (refused)
|
|
101
|
+
throw new Refusal(403, "forbidden-fetch", `fetch: ${refused} is not allowed by policy ${policy.name} (allowed: ${policy.fetch.join(", ")})`);
|
|
102
|
+
return settings.fetch === undefined ? { ...settings, fetch: policy.fetch.includes("auto") ? "auto" : policy.fetch[0] } : settings;
|
|
103
|
+
}
|
|
104
|
+
// The request as the scan will run it, or the Refusal naming why it may not run.
|
|
105
|
+
export function admit(body, server) {
|
|
106
|
+
const { url: rawUrl, settings: requested = {} } = (body ?? {});
|
|
107
|
+
const { url, host } = seedOf(rawUrl);
|
|
108
|
+
const policy = policyFor(host, server.policies);
|
|
109
|
+
log.info({ host, policy: policy?.name, isBanned: policy?.ban !== undefined }, "policy matched");
|
|
110
|
+
if (!policy)
|
|
111
|
+
throw new Refusal(403, "no-policy", `${host}: no policy admits this host`);
|
|
112
|
+
if (policy.ban)
|
|
113
|
+
throw new Refusal(403, "banned", policy.ban);
|
|
114
|
+
if (typeof requested !== "object" || requested === null || Array.isArray(requested))
|
|
115
|
+
throw new Refusal(400, "invalid-settings", "settings: expected an object");
|
|
116
|
+
const forbidden = Object.keys(requested).filter((key) => !REQUEST_KEYS.has(key));
|
|
117
|
+
if (forbidden.length > 0)
|
|
118
|
+
throw new Refusal(400, "forbidden-setting", `settings: ${forbidden.join(", ")} cannot be set through the server`);
|
|
119
|
+
let settings;
|
|
120
|
+
try {
|
|
121
|
+
settings = validateSubtree({ ...server.defaults, ...requested }, "settings");
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
if (error instanceof ConfigError)
|
|
125
|
+
throw new Refusal(400, "invalid-settings", error.message);
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
checkRules(settings, policy);
|
|
129
|
+
settings = checkFetch(settings, policy);
|
|
130
|
+
const caps = Object.fromEntries(CAPPED.map((key) => [key, capped(settings[key], policy.caps[key])]).filter(([, value]) => value !== undefined));
|
|
131
|
+
log.debug({ host, policy: policy.name, caps, denied: policy.rules.deny.length }, "request clamped");
|
|
132
|
+
return { url, host, policy, settings: { ...settings, ...caps, robots: true, "allow-private": server.allowPrivate, "browser-install": false } };
|
|
133
|
+
}
|
|
134
|
+
// Refuses a scan naming a ruleset or rule no configured plugin defines, before any window is charged.
|
|
135
|
+
export async function resolveRules(admitted) {
|
|
136
|
+
try {
|
|
137
|
+
await validateRules({ ...fromSubtree(admitted.settings), seeds: [admitted.url] });
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
if (error instanceof ConfigError)
|
|
141
|
+
throw new Refusal(400, "unknown-rule", error.message);
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
log.debug({ host: admitted.host }, "requested rules resolved");
|
|
145
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { BlockList, isIP } from "node:net";
|
|
5
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
6
|
+
import { agentHeaders } from "../agent.js";
|
|
7
|
+
import { log } from "../logger.js";
|
|
8
|
+
const TIMEOUT_MS = 15_000;
|
|
9
|
+
const ATTEMPTS = 3;
|
|
10
|
+
const CIDR = /[\d.:a-f]+\/\d{1,3}/gi;
|
|
11
|
+
// Documents naming each CDN's own edge space, as o9s/traefik reads them.
|
|
12
|
+
export const PROVIDERS = {
|
|
13
|
+
cloudflare: ["https://www.cloudflare.com/ips-v4", "https://www.cloudflare.com/ips-v6"],
|
|
14
|
+
akamai: ["https://techdocs.akamai.com/property-manager/pdfs/akamai_ipv4_CIDRs.txt", "https://techdocs.akamai.com/property-manager/pdfs/akamai_ipv6_CIDRs.txt"],
|
|
15
|
+
fastly: ["https://api.fastly.com/public-ip-list"],
|
|
16
|
+
};
|
|
17
|
+
// One document's text, retried with backoff and jitter; the last failure throws.
|
|
18
|
+
async function fetchText(url) {
|
|
19
|
+
for (let attempt = 0;; attempt += 1) {
|
|
20
|
+
try {
|
|
21
|
+
const response = await fetch(url, { headers: { ...agentHeaders() }, signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
22
|
+
if (!response.ok)
|
|
23
|
+
throw new Error(`${url} answers ${response.status}`);
|
|
24
|
+
return await response.text();
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
log.debug({ url, attempt, error: String(error) }, "edge range fetch failed");
|
|
28
|
+
if (attempt === ATTEMPTS - 1)
|
|
29
|
+
throw error;
|
|
30
|
+
await sleep(500 * 2 ** attempt + Math.random() * 250);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// The CIDR-shaped entries of `text` added to `list`; how many were valid.
|
|
35
|
+
export function addRanges(list, text) {
|
|
36
|
+
let added = 0;
|
|
37
|
+
for (const [range] of text.matchAll(CIDR)) {
|
|
38
|
+
const [address = "", prefix = ""] = range.split("/", 2);
|
|
39
|
+
const family = isIP(address);
|
|
40
|
+
if (family === 0 || Number(prefix) > (family === 6 ? 128 : 32))
|
|
41
|
+
continue;
|
|
42
|
+
list.addSubnet(address, Number(prefix), family === 6 ? "ipv6" : "ipv4");
|
|
43
|
+
added += 1;
|
|
44
|
+
}
|
|
45
|
+
return added;
|
|
46
|
+
}
|
|
47
|
+
// The live edge ranges of `providers`; a provider whose documents fail is skipped with a warning.
|
|
48
|
+
export async function edgeRanges(providers) {
|
|
49
|
+
const list = new BlockList();
|
|
50
|
+
for (const provider of providers) {
|
|
51
|
+
try {
|
|
52
|
+
const texts = await Promise.all((PROVIDERS[provider] ?? []).map(async (url) => fetchText(url)));
|
|
53
|
+
log.info({ provider, ranges: addRanges(list, texts.join("\n")) }, "edge ranges trusted");
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
log.warn({ provider, error: String(error) }, "edge ranges not fetched; keeping the other sources");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return list;
|
|
60
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { Queue } from "bullmq";
|
|
6
|
+
import { Redis } from "ioredis";
|
|
7
|
+
import { log } from "../logger.js";
|
|
8
|
+
import { Refusal } from "./policy.js";
|
|
9
|
+
export const QUEUE = "spiderlint-scans";
|
|
10
|
+
export const PREFIX = "spiderlint";
|
|
11
|
+
// The descriptor a scan runner writes progress to, one JSON line per change.
|
|
12
|
+
export const PROGRESS_FD = 3;
|
|
13
|
+
// One connection per role; BullMQ needs `maxRetriesPerRequest: null` for blocking commands, and retries with backoff itself.
|
|
14
|
+
export function connect(url, password) {
|
|
15
|
+
// eslint-disable-next-line unicorn/no-null -- BullMQ requires null, not undefined
|
|
16
|
+
const redis = new Redis(url, { ...(password && { password }), maxRetriesPerRequest: null, retryStrategy: (attempt) => Math.min(250 * 2 ** attempt, 10_000) + Math.random() * 250 });
|
|
17
|
+
redis.on("error", (error) => log.warn({ error: error.message }, "redis connection error"));
|
|
18
|
+
return redis;
|
|
19
|
+
}
|
|
20
|
+
// The scan queue, jobs kept `retention` seconds after they settle and never retried.
|
|
21
|
+
export function scanQueue(redis, retention) {
|
|
22
|
+
return new Queue(QUEUE, { connection: redis, prefix: PREFIX, defaultJobOptions: { attempts: 1, removeOnComplete: { age: retention }, removeOnFail: { age: retention } } });
|
|
23
|
+
}
|
|
24
|
+
// Counts one job against the policy’s window for the host; a full window refuses with the seconds until it opens.
|
|
25
|
+
export async function charge(redis, admitted) {
|
|
26
|
+
const { rate } = admitted.policy;
|
|
27
|
+
if (!rate)
|
|
28
|
+
return;
|
|
29
|
+
const key = `${PREFIX}:rate:${admitted.policy.name}:${admitted.host}`;
|
|
30
|
+
const reply = await redis
|
|
31
|
+
.multi()
|
|
32
|
+
.incr(key)
|
|
33
|
+
.pexpire(key, rate.seconds * 1000, "NX")
|
|
34
|
+
.pttl(key)
|
|
35
|
+
.exec();
|
|
36
|
+
const used = Number(reply?.[0]?.[1] ?? 0);
|
|
37
|
+
const left = Math.ceil(Number(reply?.[2]?.[1] ?? 0) / 1000);
|
|
38
|
+
log.info({ host: admitted.host, policy: admitted.policy.name, used, jobs: rate.jobs, left }, "rate window charged");
|
|
39
|
+
if (used > rate.jobs)
|
|
40
|
+
throw new Refusal(429, "rate-limited", `${admitted.host}: ${rate.jobs} scans per ${rate.seconds} s under policy ${admitted.policy.name}`, Math.max(1, left));
|
|
41
|
+
}
|
|
42
|
+
// The key a repeat of this scan finds its job under: the policy, the seed and the settings with keys sorted.
|
|
43
|
+
export function repeatKey(admitted) {
|
|
44
|
+
const sorted = JSON.stringify(admitted.settings, (_key, value) => (value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value).toSorted(([a], [b]) => a.localeCompare(b))) : value));
|
|
45
|
+
return `${PREFIX}:repeat:${admitted.policy.name}:${createHash("sha256").update(`${admitted.url}\n${sorted}`).digest("hex")}`;
|
|
46
|
+
}
|
|
47
|
+
// The key naming the last finished scan of `host`, which the badge reads.
|
|
48
|
+
export function latestKey(host) {
|
|
49
|
+
return `${PREFIX}:latest:${host}`;
|
|
50
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { writeSync } from "node:fs";
|
|
5
|
+
import { text } from "node:stream/consumers";
|
|
6
|
+
import { getCACertificates, setDefaultCACertificates } from "node:tls";
|
|
7
|
+
import { ConfigError } from "../config/index.js";
|
|
8
|
+
import { fromSubtree } from "../config/policy.js";
|
|
9
|
+
import { validateSubtree } from "../config/schema.js";
|
|
10
|
+
import { audit } from "../index.js";
|
|
11
|
+
import { log } from "../logger.js";
|
|
12
|
+
import { onProgress } from "../progress.js";
|
|
13
|
+
import { formatJson } from "../report/json.js";
|
|
14
|
+
import { inSpan, startTelemetry, withTraceCarrier } from "../telemetry.js";
|
|
15
|
+
import { NEVER_SERVED } from "./policy.js";
|
|
16
|
+
import { PROGRESS_FD } from "./queue.js";
|
|
17
|
+
// Exit codes: 0 the report is on stdout, 2 the settings are invalid, 3 no page was fetched, 4 the run failed.
|
|
18
|
+
async function main() {
|
|
19
|
+
const { url, settings, deny, via, crawlDeadline, trace } = JSON.parse(await text(process.stdin));
|
|
20
|
+
onProgress((progress) => writeSync(PROGRESS_FD, `${JSON.stringify(progress)}\n`));
|
|
21
|
+
const telemetry = await startTelemetry("spiderlint");
|
|
22
|
+
try {
|
|
23
|
+
const report = await withTraceCarrier(trace, async () => inSpan("scan", { "url.full": url }, async () => audit({ ...fromSubtree(validateSubtree(settings, "settings")), ...(via && { via }), seeds: [url], crawlDeadline, cacheMode: "off", denyRules: [...deny, ...NEVER_SERVED] })));
|
|
24
|
+
log.info({ url, pages: report.pages.length, findings: report.findings.length }, "scan finished");
|
|
25
|
+
if (report.pages.length === 0)
|
|
26
|
+
return 3;
|
|
27
|
+
process.stdout.write(formatJson(report));
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
log.error({ url, error: error instanceof Error ? error.message : String(error) }, "scan aborted");
|
|
32
|
+
return error instanceof ConfigError ? 2 : 4;
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
await telemetry?.shutdown();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
setDefaultCACertificates([...getCACertificates("default"), ...getCACertificates("system")]);
|
|
39
|
+
process.exitCode = await main();
|