@fulldotdev/scan 0.1.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/README.md +79 -0
- package/bin/fullscan.js +2 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +239 -0
- package/dist/engine/analysis.d.ts +2 -0
- package/dist/engine/analysis.js +747 -0
- package/dist/engine/browser-inspection.d.ts +143 -0
- package/dist/engine/browser-inspection.js +567 -0
- package/dist/engine/browser.d.ts +22 -0
- package/dist/engine/browser.js +629 -0
- package/dist/engine/collect.d.ts +6 -0
- package/dist/engine/collect.js +359 -0
- package/dist/engine/crawl-scope.d.ts +22 -0
- package/dist/engine/crawl-scope.js +145 -0
- package/dist/engine/env.d.ts +1 -0
- package/dist/engine/env.js +3 -0
- package/dist/engine/html.d.ts +307 -0
- package/dist/engine/html.js +645 -0
- package/dist/engine/language.d.ts +13 -0
- package/dist/engine/language.js +75 -0
- package/dist/engine/lighthouse-evidence.d.ts +36 -0
- package/dist/engine/lighthouse-evidence.js +69 -0
- package/dist/engine/lighthouse.d.ts +4 -0
- package/dist/engine/lighthouse.js +284 -0
- package/dist/engine/log.d.ts +1 -0
- package/dist/engine/log.js +4 -0
- package/dist/engine/network.d.ts +53 -0
- package/dist/engine/network.js +296 -0
- package/dist/engine/proxy.d.ts +8 -0
- package/dist/engine/proxy.js +95 -0
- package/dist/engine/run.d.ts +38 -0
- package/dist/engine/run.js +202 -0
- package/dist/engine/select.d.ts +6 -0
- package/dist/engine/select.js +38 -0
- package/dist/engine/site.d.ts +186 -0
- package/dist/engine/site.js +758 -0
- package/dist/engine/srcset.d.ts +1 -0
- package/dist/engine/srcset.js +31 -0
- package/dist/engine/state.d.ts +30 -0
- package/dist/engine/state.js +198 -0
- package/dist/engine/structured-data.d.ts +83 -0
- package/dist/engine/structured-data.js +331 -0
- package/dist/engine/types.d.ts +128 -0
- package/dist/engine/types.js +63 -0
- package/dist/engine.d.ts +1 -0
- package/dist/engine.js +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +7 -0
- package/dist/report/build.d.ts +377 -0
- package/dist/report/build.js +2263 -0
- package/dist/report/evidence.d.ts +58 -0
- package/dist/report/evidence.js +192 -0
- package/dist/report/rules.d.ts +41 -0
- package/dist/report/rules.js +301 -0
- package/package.json +52 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import dns from "node:dns/promises";
|
|
2
|
+
import { isIP } from "node:net";
|
|
3
|
+
import ipaddr from "ipaddr.js";
|
|
4
|
+
import { Agent, fetch } from "undici";
|
|
5
|
+
import robotsParserModule from "robots-parser";
|
|
6
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
7
|
+
import { errorMessage, version, } from "./types.js";
|
|
8
|
+
export const userAgent = `FulldevScan/${version} (+https://scan.full.dev)`;
|
|
9
|
+
const robotsParser = robotsParserModule;
|
|
10
|
+
export function sameSite(a, b) {
|
|
11
|
+
return (new URL(a).hostname.replace(/^www\./, "") ===
|
|
12
|
+
new URL(b).hostname.replace(/^www\./, ""));
|
|
13
|
+
}
|
|
14
|
+
export function normalizeUrl(input, base) {
|
|
15
|
+
const url = new URL(input, base);
|
|
16
|
+
if (!["https:", "http:"].includes(url.protocol) ||
|
|
17
|
+
url.username ||
|
|
18
|
+
url.password)
|
|
19
|
+
throw new Error("Only public HTTP(S) URLs without credentials are supported");
|
|
20
|
+
if (url.port && !["80", "443"].includes(url.port) && !localTargetsAllowed)
|
|
21
|
+
throw new Error("Only ports 80 and 443 are supported");
|
|
22
|
+
url.hash = "";
|
|
23
|
+
return url.href;
|
|
24
|
+
}
|
|
25
|
+
// The same path with or without a trailing slash; null for the root or
|
|
26
|
+
// URLs with a query string.
|
|
27
|
+
export function slashTwin(url) {
|
|
28
|
+
const parsed = new URL(url);
|
|
29
|
+
if (parsed.pathname.length <= 1 || parsed.search)
|
|
30
|
+
return null;
|
|
31
|
+
parsed.pathname = parsed.pathname.endsWith("/")
|
|
32
|
+
? parsed.pathname.slice(0, -1)
|
|
33
|
+
: `${parsed.pathname}/`;
|
|
34
|
+
return parsed.href;
|
|
35
|
+
}
|
|
36
|
+
// A scan of a local development server needs loopback and private
|
|
37
|
+
// addresses and any port; every other scan refuses them.
|
|
38
|
+
let localTargetsAllowed = false;
|
|
39
|
+
export function setLocalTargetsAllowed(allowed) {
|
|
40
|
+
localTargetsAllowed = allowed;
|
|
41
|
+
}
|
|
42
|
+
export function localTargets() {
|
|
43
|
+
return localTargetsAllowed;
|
|
44
|
+
}
|
|
45
|
+
export function publicAddress(address) {
|
|
46
|
+
try {
|
|
47
|
+
const parsed = ipaddr.process(address);
|
|
48
|
+
if (localTargetsAllowed)
|
|
49
|
+
return ["unicast", "loopback", "private"].includes(parsed.range());
|
|
50
|
+
return parsed.range() === "unicast";
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export async function resolvePublic(hostname) {
|
|
57
|
+
const host = hostname.replace(/^\[|\]$/g, "");
|
|
58
|
+
if (!localTargetsAllowed &&
|
|
59
|
+
(host === "localhost" ||
|
|
60
|
+
host.endsWith(".localhost") ||
|
|
61
|
+
host.endsWith(".local")))
|
|
62
|
+
throw new Error("Private host blocked");
|
|
63
|
+
const addresses = isIP(host)
|
|
64
|
+
? [{ address: host, family: isIP(host) }]
|
|
65
|
+
: await Promise.race([
|
|
66
|
+
dns.lookup(host, { all: true }),
|
|
67
|
+
new Promise((_, reject) => {
|
|
68
|
+
const timer = setTimeout(() => reject(new Error("DNS lookup timeout")), 5000);
|
|
69
|
+
timer.unref();
|
|
70
|
+
}),
|
|
71
|
+
]);
|
|
72
|
+
if (!addresses.length || addresses.some((a) => !publicAddress(a.address)))
|
|
73
|
+
throw new Error("Non-public address blocked");
|
|
74
|
+
return addresses;
|
|
75
|
+
}
|
|
76
|
+
// Shared across every job of one scan inside a worker: robots.txt is fetched
|
|
77
|
+
// once per origin, request pacing spans jobs and connections are kept alive.
|
|
78
|
+
export class HttpSession {
|
|
79
|
+
robots = new Map();
|
|
80
|
+
lastRequest = new Map();
|
|
81
|
+
// Origins that answered 429 during this scan get a longer gap between
|
|
82
|
+
// requests for the rest of the session (Shopify limits per minute).
|
|
83
|
+
slowdown = new Map();
|
|
84
|
+
agents = new Map();
|
|
85
|
+
agent(origin, pinned, timeout) {
|
|
86
|
+
const key = `${origin}|${pinned.address}`;
|
|
87
|
+
let agent = this.agents.get(key);
|
|
88
|
+
if (!agent) {
|
|
89
|
+
agent = new Agent({
|
|
90
|
+
connect: {
|
|
91
|
+
timeout,
|
|
92
|
+
lookup: (_host, options, callback) => {
|
|
93
|
+
if (options.all)
|
|
94
|
+
callback(null, [pinned]);
|
|
95
|
+
else
|
|
96
|
+
callback(null, pinned.address, pinned.family);
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
this.agents.set(key, agent);
|
|
101
|
+
}
|
|
102
|
+
return agent;
|
|
103
|
+
}
|
|
104
|
+
async close() {
|
|
105
|
+
const agents = [...this.agents.values()];
|
|
106
|
+
this.agents.clear();
|
|
107
|
+
await Promise.all(agents.map((agent) => agent.close()));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
export class HttpClient {
|
|
111
|
+
options;
|
|
112
|
+
resolver;
|
|
113
|
+
deadline;
|
|
114
|
+
session;
|
|
115
|
+
constructor(options, resolver = resolvePublic, deadline = Date.now() + 150000, session = new HttpSession()) {
|
|
116
|
+
this.options = options;
|
|
117
|
+
this.resolver = resolver;
|
|
118
|
+
this.deadline = deadline;
|
|
119
|
+
this.session = session;
|
|
120
|
+
}
|
|
121
|
+
get remainingMs() {
|
|
122
|
+
return Math.max(0, this.deadline - Date.now());
|
|
123
|
+
}
|
|
124
|
+
async getRobots(url) {
|
|
125
|
+
const origin = new URL(url).origin;
|
|
126
|
+
const robots = this.session.robots;
|
|
127
|
+
if (!robots.has(origin))
|
|
128
|
+
robots.set(origin, (async () => {
|
|
129
|
+
const result = await this.get(`${origin}/robots.txt`, {
|
|
130
|
+
respectRobots: false,
|
|
131
|
+
retry: false,
|
|
132
|
+
});
|
|
133
|
+
const usable = result.status === 200 &&
|
|
134
|
+
!result.truncated &&
|
|
135
|
+
!/^\s*</.test(result.body);
|
|
136
|
+
return {
|
|
137
|
+
result,
|
|
138
|
+
parser: usable ? robotsParser(result.finalUrl, result.body) : null,
|
|
139
|
+
allowed: usable || result.status === 404 || result.status === 410,
|
|
140
|
+
};
|
|
141
|
+
})());
|
|
142
|
+
return robots.get(origin);
|
|
143
|
+
}
|
|
144
|
+
async get(input, settings = {}) {
|
|
145
|
+
let result = await this.request(input, settings);
|
|
146
|
+
if (settings.retry === false)
|
|
147
|
+
return result;
|
|
148
|
+
if (result.status === 429) {
|
|
149
|
+
// Rate limited (Shopify does this on product pages): slow this origin
|
|
150
|
+
// down for the rest of the scan, wait what the server asks (capped)
|
|
151
|
+
// and try once more.
|
|
152
|
+
this.slowDown(input);
|
|
153
|
+
const asked = Number(result.headers["retry-after"]) || 0;
|
|
154
|
+
await delay(Math.min(Math.max(asked * 1000, 2000), 8000));
|
|
155
|
+
result = { ...(await this.request(input, settings)), attempts: 2 };
|
|
156
|
+
if (result.status === 429)
|
|
157
|
+
this.slowDown(input);
|
|
158
|
+
}
|
|
159
|
+
else if ((result.status !== null && result.status >= 500) ||
|
|
160
|
+
result.outcome === "error") {
|
|
161
|
+
await delay(400);
|
|
162
|
+
result = { ...(await this.request(input, settings)), attempts: 2 };
|
|
163
|
+
}
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
// Gap between requests to one origin: the configured delay, or more once
|
|
167
|
+
// the origin has rate limited us. Doubles per 429, capped at 5 seconds.
|
|
168
|
+
slowDown(input) {
|
|
169
|
+
const origin = new URL(input).origin;
|
|
170
|
+
const current = this.session.slowdown.get(origin) ?? 0;
|
|
171
|
+
this.session.slowdown.set(origin, Math.min(current ? current * 2 : 1500, 5000));
|
|
172
|
+
}
|
|
173
|
+
delayFor(origin) {
|
|
174
|
+
return Math.max(this.options.requestDelayMs, this.session.slowdown.get(origin) ?? 0);
|
|
175
|
+
}
|
|
176
|
+
// Waits until this origin may be requested again and records the request.
|
|
177
|
+
// Also used before browser page loads, which count against the same limit.
|
|
178
|
+
async pace(input) {
|
|
179
|
+
const origin = new URL(input).origin;
|
|
180
|
+
const wait = Math.max(0, this.delayFor(origin) -
|
|
181
|
+
(Date.now() - (this.session.lastRequest.get(origin) ?? 0)));
|
|
182
|
+
if (wait)
|
|
183
|
+
await delay(wait);
|
|
184
|
+
this.session.lastRequest.set(origin, Date.now());
|
|
185
|
+
}
|
|
186
|
+
async request(input, settings) {
|
|
187
|
+
const start = performance.now();
|
|
188
|
+
const result = {
|
|
189
|
+
url: input,
|
|
190
|
+
finalUrl: input,
|
|
191
|
+
status: null,
|
|
192
|
+
headers: {},
|
|
193
|
+
redirects: [],
|
|
194
|
+
body: "",
|
|
195
|
+
bytes: 0,
|
|
196
|
+
durationMs: 0,
|
|
197
|
+
fetchedAt: new Date().toISOString(),
|
|
198
|
+
outcome: "error",
|
|
199
|
+
attempts: 1,
|
|
200
|
+
};
|
|
201
|
+
try {
|
|
202
|
+
if (this.remainingMs < 1000) {
|
|
203
|
+
result.outcome = "inconclusive";
|
|
204
|
+
result.error = "collector-budget-exhausted";
|
|
205
|
+
return result;
|
|
206
|
+
}
|
|
207
|
+
let current = normalizeUrl(input);
|
|
208
|
+
const visited = new Set();
|
|
209
|
+
for (let redirects = 0; redirects <= 5; redirects++) {
|
|
210
|
+
if (this.remainingMs < 1000) {
|
|
211
|
+
result.outcome = "inconclusive";
|
|
212
|
+
result.error = "collector-budget-exhausted";
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
if (visited.has(current))
|
|
216
|
+
throw new Error("Redirect loop");
|
|
217
|
+
visited.add(current);
|
|
218
|
+
const url = new URL(current);
|
|
219
|
+
if (settings.respectRobots !== false &&
|
|
220
|
+
url.pathname !== "/robots.txt") {
|
|
221
|
+
const policy = await this.getRobots(current);
|
|
222
|
+
if (!policy.allowed ||
|
|
223
|
+
policy.parser?.isAllowed(current, "FulldevScan") === false) {
|
|
224
|
+
result.outcome = "blocked";
|
|
225
|
+
result.error =
|
|
226
|
+
"Robots policy disallows this request or is unavailable";
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const addresses = await this.resolver(url.hostname);
|
|
231
|
+
const pinned = addresses[0];
|
|
232
|
+
await this.pace(current);
|
|
233
|
+
const dispatcher = this.session.agent(url.origin, pinned, this.options.requestTimeoutMs);
|
|
234
|
+
const response = await fetch(current, {
|
|
235
|
+
dispatcher,
|
|
236
|
+
redirect: "manual",
|
|
237
|
+
signal: AbortSignal.timeout(Math.max(1, Math.min(this.options.requestTimeoutMs, this.remainingMs))),
|
|
238
|
+
headers: {
|
|
239
|
+
"user-agent": userAgent,
|
|
240
|
+
accept: settings.accept ?? "*/*",
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
result.finalUrl = current;
|
|
244
|
+
result.status = response.status;
|
|
245
|
+
result.headers = Object.fromEntries([...response.headers].filter(([k]) => !["set-cookie", "authorization", "proxy-authorization"].includes(k)));
|
|
246
|
+
if ([301, 302, 303, 307, 308].includes(response.status) &&
|
|
247
|
+
response.headers.has("location")) {
|
|
248
|
+
const location = normalizeUrl(response.headers.get("location"), current);
|
|
249
|
+
result.redirects.push({
|
|
250
|
+
url: current,
|
|
251
|
+
status: response.status,
|
|
252
|
+
location,
|
|
253
|
+
});
|
|
254
|
+
await response.body?.cancel();
|
|
255
|
+
if (redirects === 5)
|
|
256
|
+
throw new Error("Redirect limit reached");
|
|
257
|
+
current = location;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
const chunks = [];
|
|
261
|
+
const reader = response.body?.getReader();
|
|
262
|
+
const limit = settings.maxBytes ?? this.options.maxResponseBytes;
|
|
263
|
+
if (reader)
|
|
264
|
+
while (true) {
|
|
265
|
+
const { done, value } = await reader.read();
|
|
266
|
+
if (done)
|
|
267
|
+
break;
|
|
268
|
+
const remaining = limit - result.bytes;
|
|
269
|
+
chunks.push(Buffer.from(value.slice(0, remaining)));
|
|
270
|
+
result.bytes += Math.min(value.length, remaining);
|
|
271
|
+
if (value.length > remaining || result.bytes >= limit) {
|
|
272
|
+
result.truncated = true;
|
|
273
|
+
await reader.cancel();
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
result.raw = Buffer.concat(chunks);
|
|
278
|
+
result.body = result.raw.toString("utf8");
|
|
279
|
+
result.outcome = [401, 403, 429].includes(response.status)
|
|
280
|
+
? "inconclusive"
|
|
281
|
+
: response.ok
|
|
282
|
+
? "ok"
|
|
283
|
+
: "http-error";
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
result.error = errorMessage(error);
|
|
289
|
+
result.outcome = /blocked|Only public|Only ports/.test(result.error)
|
|
290
|
+
? "blocked"
|
|
291
|
+
: "error";
|
|
292
|
+
}
|
|
293
|
+
result.durationMs = Math.round(performance.now() - start);
|
|
294
|
+
return result;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import { localTargets, normalizeUrl, resolvePublic } from "./network.js";
|
|
4
|
+
// Every browser connection, including redirects/subresources, goes through DNS-pinned public egress.
|
|
5
|
+
export async function createEgressProxy() {
|
|
6
|
+
const blocked = [];
|
|
7
|
+
const sockets = new Set();
|
|
8
|
+
const remember = (socket) => {
|
|
9
|
+
sockets.add(socket);
|
|
10
|
+
socket.on("close", () => sockets.delete(socket));
|
|
11
|
+
socket.setTimeout(30000, () => socket.destroy());
|
|
12
|
+
};
|
|
13
|
+
const report = (target, error) => {
|
|
14
|
+
if (blocked.length < 500)
|
|
15
|
+
blocked.push({ target, error: String(error) });
|
|
16
|
+
};
|
|
17
|
+
const server = http.createServer(async (req, res) => {
|
|
18
|
+
try {
|
|
19
|
+
const url = new URL(normalizeUrl(req.url ?? ""));
|
|
20
|
+
if (url.protocol !== "http:")
|
|
21
|
+
throw new Error("Use CONNECT for HTTPS");
|
|
22
|
+
const [address] = await resolvePublic(url.hostname);
|
|
23
|
+
const headers = {
|
|
24
|
+
...req.headers,
|
|
25
|
+
host: url.host,
|
|
26
|
+
};
|
|
27
|
+
delete headers["proxy-authorization"];
|
|
28
|
+
delete headers["proxy-connection"];
|
|
29
|
+
const upstream = http.request({
|
|
30
|
+
host: address.address,
|
|
31
|
+
port: 80,
|
|
32
|
+
path: url.pathname + url.search,
|
|
33
|
+
method: req.method,
|
|
34
|
+
headers,
|
|
35
|
+
timeout: 15000,
|
|
36
|
+
}, (response) => {
|
|
37
|
+
res.writeHead(response.statusCode ?? 502, response.headers);
|
|
38
|
+
response.pipe(res);
|
|
39
|
+
});
|
|
40
|
+
upstream.on("socket", remember);
|
|
41
|
+
upstream.on("error", (error) => {
|
|
42
|
+
report(url.href, error);
|
|
43
|
+
res.destroy();
|
|
44
|
+
});
|
|
45
|
+
upstream.on("timeout", () => upstream.destroy());
|
|
46
|
+
req.on("aborted", () => upstream.destroy());
|
|
47
|
+
req.pipe(upstream);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
report(req.url ?? "", error);
|
|
51
|
+
res.writeHead(403);
|
|
52
|
+
res.end("Destination blocked");
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
server.on("connection", remember);
|
|
56
|
+
server.on("connect", async (req, client, head) => {
|
|
57
|
+
try {
|
|
58
|
+
const url = new URL(`https://${req.url}`);
|
|
59
|
+
normalizeUrl(url.href);
|
|
60
|
+
if (url.port && url.port !== "443" && !localTargets())
|
|
61
|
+
throw new Error("CONNECT only supports port 443");
|
|
62
|
+
const [address] = await resolvePublic(url.hostname);
|
|
63
|
+
const upstream = net.connect({
|
|
64
|
+
host: address.address,
|
|
65
|
+
port: Number(url.port || 443),
|
|
66
|
+
});
|
|
67
|
+
remember(upstream);
|
|
68
|
+
upstream.on("connect", () => {
|
|
69
|
+
client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
70
|
+
if (head.length)
|
|
71
|
+
upstream.write(head);
|
|
72
|
+
client.pipe(upstream);
|
|
73
|
+
upstream.pipe(client);
|
|
74
|
+
});
|
|
75
|
+
upstream.on("error", () => client.destroy());
|
|
76
|
+
client.on("error", () => upstream.destroy());
|
|
77
|
+
client.on("close", () => upstream.destroy());
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
report(req.url ?? "", error);
|
|
81
|
+
client.end("HTTP/1.1 403 Forbidden\r\n\r\n");
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
85
|
+
const port = server.address().port;
|
|
86
|
+
return {
|
|
87
|
+
port,
|
|
88
|
+
blocked,
|
|
89
|
+
close: async () => {
|
|
90
|
+
for (const socket of sockets)
|
|
91
|
+
socket.destroy();
|
|
92
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ScanState } from "./state.js";
|
|
2
|
+
import { type JobKind, type Options, type Scan } from "./types.js";
|
|
3
|
+
import { type PreviousScan, type Report, type StoredFinding } from "../report/build.js";
|
|
4
|
+
export type ScanEvent = {
|
|
5
|
+
type: "start";
|
|
6
|
+
scan: Scan;
|
|
7
|
+
} | {
|
|
8
|
+
type: "phase";
|
|
9
|
+
phase: "crawl" | "lighthouse" | "analysis" | "report";
|
|
10
|
+
} | {
|
|
11
|
+
type: "job";
|
|
12
|
+
kind: JobKind;
|
|
13
|
+
key: string;
|
|
14
|
+
outcome: "completed" | "failed" | "retry";
|
|
15
|
+
attempt: number;
|
|
16
|
+
durationMs: number;
|
|
17
|
+
error?: string;
|
|
18
|
+
pending: number;
|
|
19
|
+
} | {
|
|
20
|
+
type: "done";
|
|
21
|
+
scan: Scan;
|
|
22
|
+
};
|
|
23
|
+
export interface RunInput {
|
|
24
|
+
url: string;
|
|
25
|
+
options?: Partial<Options>;
|
|
26
|
+
rotation?: number;
|
|
27
|
+
previous?: PreviousScan;
|
|
28
|
+
budgetMs?: number;
|
|
29
|
+
signal?: AbortSignal;
|
|
30
|
+
onEvent?: (event: ScanEvent) => void;
|
|
31
|
+
}
|
|
32
|
+
export interface RunOutput {
|
|
33
|
+
scan: Scan;
|
|
34
|
+
report: Report;
|
|
35
|
+
findings: StoredFinding[];
|
|
36
|
+
state: ScanState;
|
|
37
|
+
}
|
|
38
|
+
export declare function runScan(input: RunInput): Promise<RunOutput>;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { analyze } from "./analysis.js";
|
|
3
|
+
import { collectPage, collectResource, sampledForMarkdown } from "./collect.js";
|
|
4
|
+
import { HttpClient, HttpSession, normalizeUrl, resolvePublic, setLocalTargetsAllowed, } from "./network.js";
|
|
5
|
+
import { selectLighthousePages } from "./select.js";
|
|
6
|
+
import { collectSitemap, discover } from "./site.js";
|
|
7
|
+
import { ScanState } from "./state.js";
|
|
8
|
+
import { errorMessage, optionsSchema, version, } from "./types.js";
|
|
9
|
+
import { buildReport, } from "../report/build.js";
|
|
10
|
+
const crawlKinds = [
|
|
11
|
+
"discover",
|
|
12
|
+
"sitemap",
|
|
13
|
+
"page",
|
|
14
|
+
"resource",
|
|
15
|
+
"browser",
|
|
16
|
+
];
|
|
17
|
+
const maxJobMs = 300000;
|
|
18
|
+
export async function runScan(input) {
|
|
19
|
+
const options = optionsSchema.parse(input.options ?? {});
|
|
20
|
+
setLocalTargetsAllowed(options.allowLocal);
|
|
21
|
+
const url = normalizeUrl(input.url);
|
|
22
|
+
await resolvePublic(new URL(url).hostname);
|
|
23
|
+
const scan = {
|
|
24
|
+
id: randomUUID(),
|
|
25
|
+
url,
|
|
26
|
+
status: "running",
|
|
27
|
+
options,
|
|
28
|
+
createdAt: new Date().toISOString(),
|
|
29
|
+
rotation: input.rotation ?? 0,
|
|
30
|
+
coverage: { omittedCandidates: 0 },
|
|
31
|
+
};
|
|
32
|
+
const state = new ScanState(scan);
|
|
33
|
+
const emit = (event) => input.onEvent?.(event);
|
|
34
|
+
emit({ type: "start", scan });
|
|
35
|
+
const session = new HttpSession();
|
|
36
|
+
const http = new HttpClient(options, undefined, undefined, session);
|
|
37
|
+
const deadline = input.budgetMs ? Date.now() + input.budgetMs : Infinity;
|
|
38
|
+
let browser;
|
|
39
|
+
const browserSession = async () => {
|
|
40
|
+
const { openBrowserSession } = await import("./browser.js");
|
|
41
|
+
if (browser && !browser.connected) {
|
|
42
|
+
await browser.close();
|
|
43
|
+
browser = undefined;
|
|
44
|
+
}
|
|
45
|
+
return (browser ??= await openBrowserSession());
|
|
46
|
+
};
|
|
47
|
+
const run = async (job) => {
|
|
48
|
+
switch (job.kind) {
|
|
49
|
+
case "discover":
|
|
50
|
+
return discover(scan, http);
|
|
51
|
+
case "sitemap":
|
|
52
|
+
return collectSitemap(scan, job.key, http);
|
|
53
|
+
case "page":
|
|
54
|
+
return collectPage(scan, job.key, http, job.key === scan.url ||
|
|
55
|
+
sampledForMarkdown(job.key) ||
|
|
56
|
+
state.hasUsableMarkdown());
|
|
57
|
+
case "resource":
|
|
58
|
+
return collectResource(job.key, http, scan);
|
|
59
|
+
case "browser":
|
|
60
|
+
return (await import("./browser.js")).collectBrowser(scan, job.key, http, await browserSession());
|
|
61
|
+
case "lighthouse":
|
|
62
|
+
return (await import("./lighthouse.js")).collectLighthouse(scan, job.key, http, await browserSession());
|
|
63
|
+
case "analysis":
|
|
64
|
+
return analyze(scan, state.records(["page", "resource", "markdown", "rendered"]), state.urlRows());
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
const execute = async (job, kinds) => {
|
|
68
|
+
job.status = "running";
|
|
69
|
+
job.attempts++;
|
|
70
|
+
const started = performance.now();
|
|
71
|
+
try {
|
|
72
|
+
const result = await Promise.race([
|
|
73
|
+
run(job),
|
|
74
|
+
new Promise((_, reject) => {
|
|
75
|
+
const timer = setTimeout(() => reject(new Error(`Job exceeded ${maxJobMs / 1000} seconds`)), maxJobMs);
|
|
76
|
+
timer.unref();
|
|
77
|
+
}),
|
|
78
|
+
]);
|
|
79
|
+
result.observations.push({
|
|
80
|
+
kind: "collector",
|
|
81
|
+
key: `${job.kind}:${job.key}`,
|
|
82
|
+
data: {
|
|
83
|
+
collector: job.kind,
|
|
84
|
+
version,
|
|
85
|
+
attempt: job.attempts,
|
|
86
|
+
durationMs: Math.round(performance.now() - started),
|
|
87
|
+
observations: result.observations.length,
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
state.record(result);
|
|
91
|
+
job.status = "completed";
|
|
92
|
+
job.durationMs = Math.round(performance.now() - started);
|
|
93
|
+
emit({
|
|
94
|
+
type: "job",
|
|
95
|
+
kind: job.kind,
|
|
96
|
+
key: job.key,
|
|
97
|
+
outcome: "completed",
|
|
98
|
+
attempt: job.attempts,
|
|
99
|
+
durationMs: job.durationMs,
|
|
100
|
+
pending: state.pending(kinds),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
const message = errorMessage(error);
|
|
105
|
+
// Browser and Lighthouse jobs get one retry; the rest three attempts.
|
|
106
|
+
const limit = ["browser", "lighthouse"].includes(job.kind) ? 2 : 3;
|
|
107
|
+
job.error = message;
|
|
108
|
+
job.status = job.attempts < limit ? "queued" : "failed";
|
|
109
|
+
job.durationMs = Math.round(performance.now() - started);
|
|
110
|
+
if (job.status === "failed" && ["browser", "lighthouse"].includes(job.kind))
|
|
111
|
+
state.record({
|
|
112
|
+
observations: [
|
|
113
|
+
{
|
|
114
|
+
kind: job.kind === "lighthouse" ? "lighthouse-run" : "browser",
|
|
115
|
+
key: job.key,
|
|
116
|
+
data: {
|
|
117
|
+
status: "skipped",
|
|
118
|
+
reason: "collector-failed",
|
|
119
|
+
error: message,
|
|
120
|
+
attempts: job.attempts,
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
});
|
|
125
|
+
emit({
|
|
126
|
+
type: "job",
|
|
127
|
+
kind: job.kind,
|
|
128
|
+
key: job.key,
|
|
129
|
+
outcome: job.status === "failed" ? "failed" : "retry",
|
|
130
|
+
attempt: job.attempts,
|
|
131
|
+
durationMs: job.durationMs,
|
|
132
|
+
error: message,
|
|
133
|
+
pending: state.pending(kinds),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
// Chromium and Lighthouse leave memory behind; a fresh browser for the
|
|
137
|
+
// next page is cheaper than the process growing without bound.
|
|
138
|
+
if (["browser", "lighthouse"].includes(job.kind) &&
|
|
139
|
+
browser &&
|
|
140
|
+
process.memoryUsage().rss > 1500 * 1024 * 1024) {
|
|
141
|
+
await browser.close();
|
|
142
|
+
browser = undefined;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
const drain = async (kinds) => {
|
|
146
|
+
let job;
|
|
147
|
+
while ((job = state.next(kinds))) {
|
|
148
|
+
if (Date.now() > deadline || input.signal?.aborted) {
|
|
149
|
+
state.cancelQueued();
|
|
150
|
+
scan.coverage.expired = true;
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
await execute(job, kinds);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
try {
|
|
157
|
+
emit({ type: "phase", phase: "crawl" });
|
|
158
|
+
state.enqueue("discover", "site");
|
|
159
|
+
await drain(crawlKinds);
|
|
160
|
+
emit({ type: "phase", phase: "lighthouse" });
|
|
161
|
+
const pages = state
|
|
162
|
+
.records(["page"])
|
|
163
|
+
.filter((p) => p.data?.http?.outcome === "ok" && p.data?.html !== false)
|
|
164
|
+
.sort((a, b) => a.key.localeCompare(b.key));
|
|
165
|
+
const { selected, templates } = selectLighthousePages(pages, options.browser && !scan.coverage.expired
|
|
166
|
+
? options
|
|
167
|
+
: { lighthouse: "off", lighthousePerTemplate: 1 }, scan.rotation, scan.url);
|
|
168
|
+
scan.coverage.templates = templates;
|
|
169
|
+
scan.coverage.lighthouseEligible = pages.length;
|
|
170
|
+
scan.coverage.lighthouseSelected = selected;
|
|
171
|
+
scan.coverage.lighthouseSelection =
|
|
172
|
+
options.lighthouse === "all"
|
|
173
|
+
? "Every eligible HTML page"
|
|
174
|
+
: options.lighthouse === "templates"
|
|
175
|
+
? `Homepage plus ${options.lighthousePerTemplate} rotating page per template (${templates} templates)`
|
|
176
|
+
: "Lighthouse disabled";
|
|
177
|
+
for (const url of selected)
|
|
178
|
+
state.enqueue("lighthouse", url);
|
|
179
|
+
await drain(["lighthouse"]);
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
await browser?.close();
|
|
183
|
+
browser = undefined;
|
|
184
|
+
}
|
|
185
|
+
emit({ type: "phase", phase: "analysis" });
|
|
186
|
+
state.enqueue("analysis", "site");
|
|
187
|
+
await drain(["analysis"]);
|
|
188
|
+
await session.close();
|
|
189
|
+
emit({ type: "phase", phase: "report" });
|
|
190
|
+
scan.coverage.jobs = state.jobCounts();
|
|
191
|
+
const unfinished = state.jobs.some((j) => ["failed", "cancelled", "queued"].includes(j.status));
|
|
192
|
+
scan.status = unfinished || scan.coverage.expired ? "partial" : "completed";
|
|
193
|
+
scan.finishedAt = new Date().toISOString();
|
|
194
|
+
const { report, findings } = await buildReport(scan, {
|
|
195
|
+
records: state.records(),
|
|
196
|
+
urls: state.urlRows(),
|
|
197
|
+
artifacts: [...state.artifacts.values()],
|
|
198
|
+
previous: input.previous,
|
|
199
|
+
});
|
|
200
|
+
emit({ type: "done", scan });
|
|
201
|
+
return { scan, report, findings, state };
|
|
202
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Observation, Options } from "./types.js";
|
|
2
|
+
export declare function templateKey(page: Observation): string;
|
|
3
|
+
export declare function selectLighthousePages(pages: Observation[], options: Pick<Options, "lighthouse" | "lighthousePerTemplate">, rotation: number, home: string): {
|
|
4
|
+
selected: string[];
|
|
5
|
+
templates: number;
|
|
6
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { pageType, templateGroup } from "./crawl-scope.js";
|
|
2
|
+
import { structuredTypes } from "./html.js";
|
|
3
|
+
// Pages that share a page type and a URL pattern almost always share a
|
|
4
|
+
// template, and Lighthouse on one of them says what it would say for all.
|
|
5
|
+
export function templateKey(page) {
|
|
6
|
+
const types = (page.data.structuredData ?? []).flatMap((s) => s.parseValid ? structuredTypes(s.data) : []);
|
|
7
|
+
const type = pageType(page.key, {
|
|
8
|
+
structuredTypes: types,
|
|
9
|
+
pagination: page.data.pagination ?? null,
|
|
10
|
+
});
|
|
11
|
+
return `${type}:${templateGroup(page.key)}`;
|
|
12
|
+
}
|
|
13
|
+
// The homepage always, then `perTemplate` pages of every template. Each
|
|
14
|
+
// template walks through its pages from run to run so every page gets its
|
|
15
|
+
// turn over time.
|
|
16
|
+
export function selectLighthousePages(pages, options, rotation, home) {
|
|
17
|
+
const groups = new Map();
|
|
18
|
+
for (const page of pages) {
|
|
19
|
+
const id = templateKey(page);
|
|
20
|
+
groups.set(id, [...(groups.get(id) ?? []), page.key]);
|
|
21
|
+
}
|
|
22
|
+
if (options.lighthouse === "off")
|
|
23
|
+
return { selected: [], templates: groups.size };
|
|
24
|
+
const urls = pages.map((p) => p.key);
|
|
25
|
+
if (options.lighthouse === "all")
|
|
26
|
+
return { selected: [...new Set(urls)], templates: groups.size };
|
|
27
|
+
const homepage = urls.find((u) => u === home) ??
|
|
28
|
+
urls.find((u) => new URL(u).pathname === "/");
|
|
29
|
+
const picks = homepage ? [homepage] : [];
|
|
30
|
+
const take = options.lighthousePerTemplate;
|
|
31
|
+
for (const members of groups.values()) {
|
|
32
|
+
const sorted = [...members].sort();
|
|
33
|
+
const count = Math.min(take, sorted.length);
|
|
34
|
+
for (let j = 0; j < count; j++)
|
|
35
|
+
picks.push(sorted[(rotation * count + j) % sorted.length]);
|
|
36
|
+
}
|
|
37
|
+
return { selected: [...new Set(picks)], templates: groups.size };
|
|
38
|
+
}
|