ssrwire 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.
Files changed (74) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/CONTRIBUTING.md +49 -0
  3. package/LICENSE +21 -0
  4. package/PUBLISHING.md +134 -0
  5. package/README.md +403 -0
  6. package/SECURITY.md +26 -0
  7. package/dist/agents.d.ts +14 -0
  8. package/dist/agents.d.ts.map +1 -0
  9. package/dist/agents.js +100 -0
  10. package/dist/agents.js.map +1 -0
  11. package/dist/analyze.d.ts +4 -0
  12. package/dist/analyze.d.ts.map +1 -0
  13. package/dist/analyze.js +494 -0
  14. package/dist/analyze.js.map +1 -0
  15. package/dist/audit.d.ts +3 -0
  16. package/dist/audit.d.ts.map +1 -0
  17. package/dist/audit.js +69 -0
  18. package/dist/audit.js.map +1 -0
  19. package/dist/bin.d.ts +3 -0
  20. package/dist/bin.d.ts.map +1 -0
  21. package/dist/bin.js +4 -0
  22. package/dist/bin.js.map +1 -0
  23. package/dist/cli.d.ts +2 -0
  24. package/dist/cli.d.ts.map +1 -0
  25. package/dist/cli.js +173 -0
  26. package/dist/cli.js.map +1 -0
  27. package/dist/config.d.ts +17 -0
  28. package/dist/config.d.ts.map +1 -0
  29. package/dist/config.js +262 -0
  30. package/dist/config.js.map +1 -0
  31. package/dist/http-probe.d.ts +7 -0
  32. package/dist/http-probe.d.ts.map +1 -0
  33. package/dist/http-probe.js +406 -0
  34. package/dist/http-probe.js.map +1 -0
  35. package/dist/index.d.ts +11 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +10 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/redact.d.ts +10 -0
  40. package/dist/redact.d.ts.map +1 -0
  41. package/dist/redact.js +154 -0
  42. package/dist/redact.js.map +1 -0
  43. package/dist/reporters.d.ts +9 -0
  44. package/dist/reporters.d.ts.map +1 -0
  45. package/dist/reporters.js +220 -0
  46. package/dist/reporters.js.map +1 -0
  47. package/dist/stream-parser.d.ts +9 -0
  48. package/dist/stream-parser.d.ts.map +1 -0
  49. package/dist/stream-parser.js +366 -0
  50. package/dist/stream-parser.js.map +1 -0
  51. package/dist/types.d.ts +134 -0
  52. package/dist/types.d.ts.map +1 -0
  53. package/dist/types.js +2 -0
  54. package/dist/types.js.map +1 -0
  55. package/dist/version.d.ts +2 -0
  56. package/dist/version.d.ts.map +1 -0
  57. package/dist/version.js +14 -0
  58. package/dist/version.js.map +1 -0
  59. package/examples/github-actions.yml +73 -0
  60. package/examples/ssrwire.config.yml +37 -0
  61. package/package.json +91 -0
  62. package/src/agents.ts +129 -0
  63. package/src/analyze.ts +628 -0
  64. package/src/audit.ts +89 -0
  65. package/src/bin.ts +5 -0
  66. package/src/cli.ts +207 -0
  67. package/src/config.ts +313 -0
  68. package/src/http-probe.ts +461 -0
  69. package/src/index.ts +34 -0
  70. package/src/redact.ts +173 -0
  71. package/src/reporters.ts +274 -0
  72. package/src/stream-parser.ts +424 -0
  73. package/src/types.ts +160 -0
  74. package/src/version.ts +19 -0
@@ -0,0 +1,461 @@
1
+ import { createHash } from "node:crypto";
2
+ import { performance } from "node:perf_hooks";
3
+
4
+ import { createRedactionPlan, type RedactionPlan, redactText } from "./redact.js";
5
+ import { createStreamInspector } from "./stream-parser.js";
6
+ import type {
7
+ DocumentSignals,
8
+ HeaderSnapshot,
9
+ ProbeCompletion,
10
+ ProbeOptions,
11
+ ProbeResult,
12
+ ProbeTimings,
13
+ RedirectHop,
14
+ } from "./types.js";
15
+
16
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
17
+ const SNAPSHOT_HEADERS = Object.freeze([
18
+ "age",
19
+ "cache-control",
20
+ "cf-cache-status",
21
+ "content-encoding",
22
+ "content-language",
23
+ "content-length",
24
+ "content-type",
25
+ "date",
26
+ "etag",
27
+ "expires",
28
+ "last-modified",
29
+ "server",
30
+ "vary",
31
+ "x-cache",
32
+ "x-cache-hits",
33
+ "x-nextjs-cache",
34
+ "x-powered-by",
35
+ "x-vercel-cache",
36
+ ] as const);
37
+
38
+ interface FinalizeOptions {
39
+ readonly requestedUrl: string;
40
+ readonly finalUrl: string;
41
+ readonly probe: ProbeOptions;
42
+ readonly redirects: readonly RedirectHop[];
43
+ readonly headers: HeaderSnapshot;
44
+ readonly timings: ProbeTimings;
45
+ readonly bytesRead: number;
46
+ readonly signals: DocumentSignals;
47
+ readonly completion: ProbeCompletion;
48
+ readonly status?: number;
49
+ readonly bodySha256?: string;
50
+ readonly error?: string;
51
+ }
52
+
53
+ function emptySignals(): DocumentSignals {
54
+ return {
55
+ descriptions: [],
56
+ canonicals: [],
57
+ robots: [],
58
+ h1s: [],
59
+ jsonLd: [],
60
+ };
61
+ }
62
+
63
+ function emptyHeaders(): HeaderSnapshot {
64
+ return { values: {}, setCookiePresent: false };
65
+ }
66
+
67
+ function elapsedSince(startedAt: number): number {
68
+ return Math.max(0, performance.now() - startedAt);
69
+ }
70
+
71
+ function snapshotHeaders(headers: Headers, redaction: RedactionPlan): HeaderSnapshot {
72
+ const values: Record<string, string> = {};
73
+ for (const name of SNAPSHOT_HEADERS) {
74
+ const value = headers.get(name);
75
+ if (value !== null) values[name] = redactText(value, redaction);
76
+ }
77
+ return {
78
+ values,
79
+ setCookiePresent: headers.has("set-cookie"),
80
+ };
81
+ }
82
+
83
+ function redactSignals(signals: DocumentSignals, redaction: RedactionPlan): DocumentSignals {
84
+ const element = <Signal extends { readonly value: string }>(signal: Signal): Signal => ({
85
+ ...signal,
86
+ value: redactText(signal.value, redaction),
87
+ });
88
+ return {
89
+ ...(signals.title === undefined ? {} : { title: element(signals.title) }),
90
+ ...(signals.titles === undefined ? {} : { titles: signals.titles.map(element) }),
91
+ descriptions: signals.descriptions.map(element),
92
+ canonicals: signals.canonicals.map(element),
93
+ robots: signals.robots.map(element),
94
+ h1s: signals.h1s.map(element),
95
+ ...(signals.firstMainText === undefined
96
+ ? {}
97
+ : { firstMainText: element(signals.firstMainText) }),
98
+ jsonLd: signals.jsonLd.map((signal) => ({
99
+ ...signal,
100
+ types: signal.types.map((type) => redactText(type, redaction)),
101
+ ...(signal.error === undefined ? {} : { error: redactText(signal.error, redaction) }),
102
+ })),
103
+ ...(signals.headClosed === undefined ? {} : { headClosed: signals.headClosed }),
104
+ ...(signals.bodyStarted === undefined ? {} : { bodyStarted: signals.bodyStarted }),
105
+ ...(signals.documentClosed === undefined ? {} : { documentClosed: signals.documentClosed }),
106
+ };
107
+ }
108
+
109
+ function finalize(options: FinalizeOptions): ProbeResult {
110
+ return {
111
+ requestedUrl: options.requestedUrl,
112
+ finalUrl: options.finalUrl,
113
+ agent: options.probe.agent,
114
+ ...(options.status === undefined ? {} : { status: options.status }),
115
+ redirects: options.redirects,
116
+ headers: options.headers,
117
+ timings: options.timings,
118
+ bytesRead: options.bytesRead,
119
+ ...(options.bodySha256 === undefined ? {} : { bodySha256: options.bodySha256 }),
120
+ signals: options.signals,
121
+ completion: options.completion,
122
+ ...(options.error === undefined ? {} : { error: options.error }),
123
+ };
124
+ }
125
+
126
+ function parseHttpUrl(value: string): URL | undefined {
127
+ try {
128
+ const url = new URL(value);
129
+ if (
130
+ (url.protocol !== "http:" && url.protocol !== "https:") ||
131
+ url.username.length > 0 ||
132
+ url.password.length > 0
133
+ ) {
134
+ return undefined;
135
+ }
136
+ return url;
137
+ } catch {
138
+ return undefined;
139
+ }
140
+ }
141
+
142
+ function safeUrlForReport(value: string): string {
143
+ try {
144
+ const url = new URL(value);
145
+ url.username = "";
146
+ url.password = "";
147
+ return url.href;
148
+ } catch {
149
+ return "[invalid URL]";
150
+ }
151
+ }
152
+
153
+ function isHtmlContentType(value: string | null): boolean {
154
+ if (value === null || value.trim().length === 0) return false;
155
+ const mediaType = value.split(";", 1)[0]?.trim().toLowerCase();
156
+ return mediaType === "text/html" || mediaType === "application/xhtml+xml";
157
+ }
158
+
159
+ function createRequestHeaders(
160
+ agentUserAgent: string,
161
+ customHeaders: Headers,
162
+ includeCustom: boolean,
163
+ ): Headers {
164
+ const headers = includeCustom ? new Headers(customHeaders) : new Headers();
165
+ if (!headers.has("accept")) {
166
+ headers.set("accept", "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8");
167
+ }
168
+ headers.set("accept-encoding", "identity");
169
+ headers.set("user-agent", agentUserAgent);
170
+ return headers;
171
+ }
172
+
173
+ function validateLimits(options: ProbeOptions): string | undefined {
174
+ if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) {
175
+ return "timeoutMs must be greater than zero.";
176
+ }
177
+ if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes <= 0) {
178
+ return "maxBytes must be a positive safe integer.";
179
+ }
180
+ if (!Number.isSafeInteger(options.maxRedirects) || options.maxRedirects < 0) {
181
+ return "maxRedirects must be a non-negative safe integer.";
182
+ }
183
+ return undefined;
184
+ }
185
+
186
+ /**
187
+ * Read one URL exactly as an HTTP crawler would: no browser, no hydration, and redirects handled
188
+ * explicitly so caller-supplied headers cannot cross an origin boundary.
189
+ */
190
+ export async function probeUrl(options: ProbeOptions): Promise<ProbeResult> {
191
+ const startedAt = performance.now();
192
+ const requestedUrl = safeUrlForReport(options.url);
193
+ const initialUrl = parseHttpUrl(options.url);
194
+ const limitError = validateLimits(options);
195
+ if (initialUrl === undefined || limitError !== undefined) {
196
+ return finalize({
197
+ requestedUrl,
198
+ finalUrl: requestedUrl,
199
+ probe: options,
200
+ redirects: [],
201
+ headers: emptyHeaders(),
202
+ timings: { headersMs: elapsedSince(startedAt) },
203
+ bytesRead: 0,
204
+ signals: emptySignals(),
205
+ completion: "invalid-response",
206
+ error:
207
+ initialUrl === undefined
208
+ ? "URL must use http:// or https:// and cannot contain embedded credentials."
209
+ : (limitError ?? "Probe options are invalid."),
210
+ });
211
+ }
212
+
213
+ let customHeaders: Headers;
214
+ try {
215
+ customHeaders = new Headers(options.headers);
216
+ } catch {
217
+ return finalize({
218
+ requestedUrl,
219
+ finalUrl: initialUrl.href,
220
+ probe: options,
221
+ redirects: [],
222
+ headers: emptyHeaders(),
223
+ timings: { headersMs: elapsedSince(startedAt) },
224
+ bytesRead: 0,
225
+ signals: emptySignals(),
226
+ completion: "invalid-response",
227
+ error: "One or more custom request headers are invalid.",
228
+ });
229
+ }
230
+
231
+ const redaction = createRedactionPlan(
232
+ options.redactHeaderValues === false
233
+ ? []
234
+ : [...customHeaders.values()].filter((value) => value.length > 0),
235
+ );
236
+ const redirects: RedirectHop[] = [];
237
+ const controller = new AbortController();
238
+ let timedOut = false;
239
+ const timeout = setTimeout(() => {
240
+ timedOut = true;
241
+ controller.abort();
242
+ }, options.timeoutMs);
243
+
244
+ let currentUrl = initialUrl;
245
+ let includeCustomHeaders = true;
246
+ let lastHeaders = emptyHeaders();
247
+ try {
248
+ while (true) {
249
+ const hopStartedAt = performance.now();
250
+ let response: Response;
251
+ try {
252
+ response = await fetch(currentUrl, {
253
+ method: "GET",
254
+ headers: createRequestHeaders(
255
+ options.agent.userAgent,
256
+ customHeaders,
257
+ includeCustomHeaders,
258
+ ),
259
+ redirect: "manual",
260
+ signal: controller.signal,
261
+ });
262
+ } catch {
263
+ return finalize({
264
+ requestedUrl,
265
+ finalUrl: redactText(currentUrl.href, redaction),
266
+ probe: options,
267
+ redirects,
268
+ headers: lastHeaders,
269
+ timings: { headersMs: elapsedSince(startedAt) },
270
+ bytesRead: 0,
271
+ signals: emptySignals(),
272
+ completion: timedOut ? "timeout" : "network-error",
273
+ error: timedOut ? "Request timed out." : "Network request failed.",
274
+ });
275
+ }
276
+
277
+ lastHeaders = snapshotHeaders(response.headers, redaction);
278
+ const responseDurationMs = elapsedSince(hopStartedAt);
279
+
280
+ if (REDIRECT_STATUSES.has(response.status)) {
281
+ const location = response.headers.get("location");
282
+ if (location === null || location.trim().length === 0) {
283
+ await response.body?.cancel().catch(() => undefined);
284
+ return finalize({
285
+ requestedUrl,
286
+ finalUrl: redactText(currentUrl.href, redaction),
287
+ probe: options,
288
+ redirects,
289
+ headers: lastHeaders,
290
+ timings: { headersMs: elapsedSince(startedAt) },
291
+ bytesRead: 0,
292
+ signals: emptySignals(),
293
+ completion: "invalid-response",
294
+ status: response.status,
295
+ error: "Redirect response did not include a Location header.",
296
+ });
297
+ }
298
+
299
+ let nextUrl: URL;
300
+ try {
301
+ nextUrl = new URL(location, currentUrl);
302
+ } catch {
303
+ await response.body?.cancel().catch(() => undefined);
304
+ return finalize({
305
+ requestedUrl,
306
+ finalUrl: redactText(currentUrl.href, redaction),
307
+ probe: options,
308
+ redirects,
309
+ headers: lastHeaders,
310
+ timings: { headersMs: elapsedSince(startedAt) },
311
+ bytesRead: 0,
312
+ signals: emptySignals(),
313
+ completion: "invalid-response",
314
+ status: response.status,
315
+ error: "Redirect response included an invalid Location header.",
316
+ });
317
+ }
318
+
319
+ if (
320
+ (nextUrl.protocol !== "http:" && nextUrl.protocol !== "https:") ||
321
+ nextUrl.username.length > 0 ||
322
+ nextUrl.password.length > 0
323
+ ) {
324
+ await response.body?.cancel().catch(() => undefined);
325
+ return finalize({
326
+ requestedUrl,
327
+ finalUrl: redactText(currentUrl.href, redaction),
328
+ probe: options,
329
+ redirects,
330
+ headers: lastHeaders,
331
+ timings: { headersMs: elapsedSince(startedAt) },
332
+ bytesRead: 0,
333
+ signals: emptySignals(),
334
+ completion: "invalid-response",
335
+ status: response.status,
336
+ error:
337
+ nextUrl.username.length > 0 || nextUrl.password.length > 0
338
+ ? "Redirect target cannot contain embedded credentials."
339
+ : "Redirect target must use http:// or https://.",
340
+ });
341
+ }
342
+
343
+ redirects.push({
344
+ url: redactText(currentUrl.href, redaction),
345
+ status: response.status,
346
+ location: redactText(nextUrl.href, redaction),
347
+ durationMs: responseDurationMs,
348
+ });
349
+ await response.body?.cancel().catch(() => undefined);
350
+
351
+ if (redirects.length > options.maxRedirects) {
352
+ return finalize({
353
+ requestedUrl,
354
+ finalUrl: redactText(currentUrl.href, redaction),
355
+ probe: options,
356
+ redirects,
357
+ headers: lastHeaders,
358
+ timings: { headersMs: elapsedSince(startedAt) },
359
+ bytesRead: 0,
360
+ signals: emptySignals(),
361
+ completion: "invalid-response",
362
+ status: response.status,
363
+ error: `Redirect limit of ${options.maxRedirects} exceeded.`,
364
+ });
365
+ }
366
+
367
+ if (nextUrl.origin !== currentUrl.origin) includeCustomHeaders = false;
368
+ currentUrl = nextUrl;
369
+ continue;
370
+ }
371
+
372
+ const headersMs = elapsedSince(startedAt);
373
+ const contentType = response.headers.get("content-type");
374
+ if (!isHtmlContentType(contentType)) {
375
+ await response.body?.cancel().catch(() => undefined);
376
+ const mediaType = contentType?.split(";", 1)[0]?.trim();
377
+ return finalize({
378
+ requestedUrl,
379
+ finalUrl: redactText(currentUrl.href, redaction),
380
+ probe: options,
381
+ redirects,
382
+ headers: lastHeaders,
383
+ timings: { headersMs },
384
+ bytesRead: 0,
385
+ signals: emptySignals(),
386
+ completion: "invalid-response",
387
+ status: response.status,
388
+ error:
389
+ mediaType === undefined
390
+ ? "Expected an HTML response but the Content-Type header was missing."
391
+ : `Expected an HTML response but received Content-Type ${redactText(mediaType, redaction).slice(0, 160)}.`,
392
+ });
393
+ }
394
+ const inspector = createStreamInspector();
395
+ const hash = createHash("sha256");
396
+ let bytesRead = 0;
397
+ let firstByteMs: number | undefined;
398
+ let completion: ProbeCompletion = "complete";
399
+ let error: string | undefined;
400
+
401
+ if (response.body !== null) {
402
+ const reader = response.body.getReader();
403
+ try {
404
+ while (true) {
405
+ const read = await reader.read();
406
+ if (read.done) break;
407
+ if (read.value.byteLength === 0) continue;
408
+ if (firstByteMs === undefined) firstByteMs = elapsedSince(startedAt);
409
+
410
+ const available = options.maxBytes - bytesRead;
411
+ if (read.value.byteLength > available) {
412
+ if (available > 0) {
413
+ const prefix = read.value.subarray(0, available);
414
+ bytesRead += prefix.byteLength;
415
+ hash.update(prefix);
416
+ inspector.write(prefix, elapsedSince(startedAt));
417
+ }
418
+ completion = "max-bytes-exceeded";
419
+ error = `Response exceeded the ${options.maxBytes} byte limit.`;
420
+ await reader.cancel().catch(() => undefined);
421
+ break;
422
+ }
423
+
424
+ bytesRead += read.value.byteLength;
425
+ hash.update(read.value);
426
+ inspector.write(read.value, elapsedSince(startedAt));
427
+ }
428
+ } catch {
429
+ completion = timedOut ? "timeout" : "network-error";
430
+ error = timedOut ? "Request timed out." : "Response stream failed.";
431
+ } finally {
432
+ reader.releaseLock();
433
+ }
434
+ }
435
+
436
+ const completedAt = elapsedSince(startedAt);
437
+ const signals = redactSignals(inspector.end(completedAt), redaction);
438
+ const timings: ProbeTimings = {
439
+ headersMs,
440
+ ...(firstByteMs === undefined ? {} : { firstByteMs }),
441
+ ...(completion === "complete" ? { completeMs: completedAt } : {}),
442
+ };
443
+ return finalize({
444
+ requestedUrl,
445
+ finalUrl: redactText(currentUrl.href, redaction),
446
+ probe: options,
447
+ redirects,
448
+ headers: lastHeaders,
449
+ timings,
450
+ bytesRead,
451
+ bodySha256: hash.digest("hex"),
452
+ signals,
453
+ completion,
454
+ status: response.status,
455
+ ...(error === undefined ? {} : { error }),
456
+ });
457
+ }
458
+ } finally {
459
+ clearTimeout(timeout);
460
+ }
461
+ }
package/src/index.ts ADDED
@@ -0,0 +1,34 @@
1
+ export { BUILTIN_AGENTS, resolveAgent, resolveAgents } from "./agents.js";
2
+ export { analyzeTarget, summarizeAudit } from "./analyze.js";
3
+ export { runAudit } from "./audit.js";
4
+ export { loadConfig, parseHeaderOption } from "./config.js";
5
+ export { probeUrl } from "./http-probe.js";
6
+ export { redactProbe } from "./redact.js";
7
+ export { renderJson, renderReport, renderSarif, renderTerminal } from "./reporters.js";
8
+ export { createStreamInspector } from "./stream-parser.js";
9
+ export type {
10
+ AgentProfile,
11
+ AuditResult,
12
+ AuditSummary,
13
+ AuditTarget,
14
+ DocumentSignals,
15
+ ElementLocation,
16
+ ElementSignal,
17
+ Finding,
18
+ HeaderSnapshot,
19
+ JsonLdSignal,
20
+ ProbeCompletion,
21
+ ProbeOptions,
22
+ ProbeResult,
23
+ ProbeTimings,
24
+ RedirectHop,
25
+ ReportFormat,
26
+ RobotsAudience,
27
+ RobotsSignal,
28
+ Severity,
29
+ SsrWireConfig,
30
+ TargetAuditResult,
31
+ TargetExpectations,
32
+ TimingMark,
33
+ } from "./types.js";
34
+ export { VERSION } from "./version.js";
package/src/redact.ts ADDED
@@ -0,0 +1,173 @@
1
+ import type { AuditResult, DocumentSignals, ElementSignal, ProbeResult } from "./types.js";
2
+
3
+ const REDACTED = "[REDACTED]";
4
+
5
+ function base64Url(value: string): string {
6
+ return Buffer.from(value).toString("base64url");
7
+ }
8
+
9
+ function addForms(candidates: Set<string>, value: string): void {
10
+ if (!value) return;
11
+ candidates.add(value);
12
+ candidates.add(encodeURIComponent(value));
13
+ candidates.add(new URLSearchParams({ value }).toString().slice("value=".length));
14
+ candidates.add(Buffer.from(value).toString("base64"));
15
+ candidates.add(base64Url(value));
16
+ }
17
+
18
+ function decodedBasicCredential(value: string): string | undefined {
19
+ const encoded = value.match(/^basic\s+([a-z0-9+/]+={0,2})$/i)?.[1];
20
+ if (!encoded || encoded.length % 4 === 1) return undefined;
21
+ const decoded = Buffer.from(encoded, "base64").toString("utf8");
22
+ return decoded.includes("\uFFFD") || decoded.length === 0 ? undefined : decoded;
23
+ }
24
+
25
+ function variants(value: string): readonly string[] {
26
+ const trimmed = value.trim();
27
+ if (!trimmed) {
28
+ return [];
29
+ }
30
+
31
+ const candidates = new Set<string>();
32
+ addForms(candidates, trimmed);
33
+ const scheme = trimmed.match(/^(?:basic|bearer)\s+(.+)$/i)?.[1];
34
+ if (scheme) addForms(candidates, scheme);
35
+ const decodedBasic = decodedBasicCredential(trimmed);
36
+ if (decodedBasic) addForms(candidates, decodedBasic);
37
+
38
+ return [...candidates];
39
+ }
40
+
41
+ export interface RedactionPlan {
42
+ readonly exact: ReadonlySet<string>;
43
+ readonly substrings: readonly string[];
44
+ }
45
+
46
+ export function createRedactionPlan(secrets: readonly string[]): RedactionPlan {
47
+ const exact = new Set(secrets.flatMap(variants));
48
+ return {
49
+ exact,
50
+ substrings: [...exact]
51
+ .filter((candidate) => candidate.length >= 3)
52
+ .sort((a, b) => b.length - a.length),
53
+ };
54
+ }
55
+
56
+ export function redactText(value: string, plan: RedactionPlan): string {
57
+ if (plan.exact.has(value)) return REDACTED;
58
+ let redacted = value;
59
+ for (const pattern of plan.substrings) {
60
+ redacted = redacted.split(pattern).join(REDACTED);
61
+ }
62
+ return redacted;
63
+ }
64
+
65
+ function redactUnknown(value: unknown, plan: RedactionPlan): unknown {
66
+ if (typeof value === "string") {
67
+ return redactText(value, plan);
68
+ }
69
+ if (Array.isArray(value)) {
70
+ return value.map((item) => redactUnknown(item, plan));
71
+ }
72
+ if (value && typeof value === "object") {
73
+ return Object.fromEntries(
74
+ Object.entries(value).map(([key, item]) => [key, redactUnknown(item, plan)]),
75
+ );
76
+ }
77
+ return value;
78
+ }
79
+
80
+ function redactElement<Signal extends ElementSignal>(signal: Signal, plan: RedactionPlan): Signal {
81
+ return { ...signal, value: redactText(signal.value, plan) };
82
+ }
83
+
84
+ function redactSignals(signals: DocumentSignals, plan: RedactionPlan): DocumentSignals {
85
+ return {
86
+ ...(signals.title === undefined ? {} : { title: redactElement(signals.title, plan) }),
87
+ ...(signals.titles === undefined
88
+ ? {}
89
+ : { titles: signals.titles.map((signal) => redactElement(signal, plan)) }),
90
+ descriptions: signals.descriptions.map((signal) => redactElement(signal, plan)),
91
+ canonicals: signals.canonicals.map((signal) => redactElement(signal, plan)),
92
+ robots: signals.robots.map((signal) => redactElement(signal, plan)),
93
+ h1s: signals.h1s.map((signal) => redactElement(signal, plan)),
94
+ ...(signals.firstMainText === undefined
95
+ ? {}
96
+ : { firstMainText: redactElement(signals.firstMainText, plan) }),
97
+ jsonLd: signals.jsonLd.map((signal) => ({
98
+ ...signal,
99
+ types: signal.types.map((type) => redactText(type, plan)),
100
+ ...(signal.error === undefined ? {} : { error: redactText(signal.error, plan) }),
101
+ })),
102
+ ...(signals.headClosed === undefined ? {} : { headClosed: signals.headClosed }),
103
+ ...(signals.bodyStarted === undefined ? {} : { bodyStarted: signals.bodyStarted }),
104
+ ...(signals.documentClosed === undefined ? {} : { documentClosed: signals.documentClosed }),
105
+ };
106
+ }
107
+
108
+ function redactProbeWithPlan(probe: ProbeResult, plan: RedactionPlan): ProbeResult {
109
+ return {
110
+ ...probe,
111
+ requestedUrl: redactText(probe.requestedUrl, plan),
112
+ finalUrl: redactText(probe.finalUrl, plan),
113
+ agent: {
114
+ ...probe.agent,
115
+ label: redactText(probe.agent.label, plan),
116
+ userAgent: redactText(probe.agent.userAgent, plan),
117
+ },
118
+ redirects: probe.redirects.map((redirect) => ({
119
+ ...redirect,
120
+ url: redactText(redirect.url, plan),
121
+ location: redactText(redirect.location, plan),
122
+ })),
123
+ headers: {
124
+ ...probe.headers,
125
+ values: Object.fromEntries(
126
+ Object.entries(probe.headers.values).map(([name, value]) => [
127
+ name,
128
+ redactText(value, plan),
129
+ ]),
130
+ ),
131
+ },
132
+ signals: redactSignals(probe.signals, plan),
133
+ ...(probe.error === undefined ? {} : { error: redactText(probe.error, plan) }),
134
+ };
135
+ }
136
+
137
+ export function redactProbe(probe: ProbeResult, secrets: readonly string[]): ProbeResult {
138
+ const plan = createRedactionPlan(secrets);
139
+ if (plan.exact.size === 0) {
140
+ return probe;
141
+ }
142
+
143
+ return redactProbeWithPlan(probe, plan);
144
+ }
145
+
146
+ export function redactAudit(audit: AuditResult, secrets: readonly string[]): AuditResult {
147
+ const plan = createRedactionPlan(secrets);
148
+ if (plan.exact.size === 0) return audit;
149
+ return {
150
+ ...audit,
151
+ results: audit.results.map((result) => ({
152
+ target: {
153
+ ...result.target,
154
+ url: redactText(result.target.url, plan),
155
+ expectations: {
156
+ ...result.target.expectations,
157
+ ...(result.target.expectations.finalUrl === undefined
158
+ ? {}
159
+ : { finalUrl: redactText(result.target.expectations.finalUrl, plan) }),
160
+ },
161
+ },
162
+ probes: result.probes.map((probe) => redactProbeWithPlan(probe, plan)),
163
+ findings: result.findings.map((finding) => ({
164
+ ...finding,
165
+ message: redactText(finding.message, plan),
166
+ url: redactText(finding.url, plan),
167
+ ...(finding.evidence === undefined
168
+ ? {}
169
+ : { evidence: redactUnknown(finding.evidence, plan) as typeof finding.evidence }),
170
+ })),
171
+ })),
172
+ };
173
+ }