@ultimat3/scraping 23.0.0 → 24.0.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.
@@ -9,7 +9,12 @@
9
9
  // `driver.open()`, and the proxy is a driver option the session only reports on the way back out.
10
10
 
11
11
  import { finiteCount, readWithinLimit } from '@ultimat3/core';
12
+ import type { HostRule } from './hosts';
12
13
  import type { ScrapeFetch } from './http';
14
+ import { redirectHop } from './http-redirect';
15
+ import { interceptVerdict } from './intercept';
16
+ import type { HostResolve } from './pinned-host';
17
+ import { dialTarget, resolveHost } from './pinned-host';
13
18
  import type { RobotsFetch } from './robots';
14
19
 
15
20
  /**
@@ -23,6 +28,9 @@ export const DEFAULT_ROBOTS_TIMEOUT_MS = 10_000;
23
28
  /** Google's own documented ceiling for the file, and generous for a list of path prefixes. */
24
29
  export const DEFAULT_ROBOTS_MAX_BYTES = 500 * 1024;
25
30
 
31
+ /** RFC 9309 §2.3.1.2: a crawler SHOULD follow at least five consecutive redirects for robots.txt. */
32
+ export const MAX_ROBOTS_REDIRECTS = 5;
33
+
26
34
  export interface RobotsFetchInit {
27
35
  /** Per-read wall clock. Defaults to `DEFAULT_ROBOTS_TIMEOUT_MS`. */
28
36
  readonly timeoutMs?: number | undefined;
@@ -41,6 +49,15 @@ export interface RobotsFetchInit {
41
49
  */
42
50
  readonly proxy?: (() => string | undefined) | undefined;
43
51
  readonly maxBytes?: number | undefined;
52
+ /**
53
+ * The run's `allowHosts`, asked about EVERY hop — the first URL included — through
54
+ * `interceptVerdict`, the one host rule. A hop off the list is never requested and the read
55
+ * answers "no robots". With none given, only a redirect that stays on the starting HOSTNAME is
56
+ * followed (a scheme upgrade, a moved path), still capped; another host is never dialled.
57
+ */
58
+ readonly allowHosts?: readonly HostRule[] | undefined;
59
+ /** The resolver a wildcard-admitted name is checked and pinned with. Defaults to `Bun.dns`. */
60
+ readonly resolve?: HostResolve | undefined;
44
61
  /**
45
62
  * The platform `fetch`, injectable so the default path itself is testable. `ScrapeFetch` and not
46
63
  * `typeof fetch`: the latter also carries `preconnect`, so nothing a caller can write satisfies
@@ -49,13 +66,38 @@ export interface RobotsFetchInit {
49
66
  readonly fetch?: ScrapeFetch | undefined;
50
67
  }
51
68
 
69
+ const onList = (url: string, allowHosts: readonly HostRule[]): boolean =>
70
+ interceptVerdict(url, 'fetch', { allowHosts }) === 'allow';
71
+
72
+ /** Same hostname over http(s) — the port may change with the scheme. Unparseable is not same. */
73
+ const sameHost = (from: string, to: string): boolean => {
74
+ try {
75
+ const next = new URL(to);
76
+ if (next.protocol !== 'http:' && next.protocol !== 'https:') return false;
77
+ return new URL(from).hostname === next.hostname;
78
+ } catch {
79
+ return false;
80
+ }
81
+ };
82
+
83
+ /** The final hop's body under the cap, or `undefined` for a non-2xx answer. */
84
+ const answerOf = async (response: Response, limit: number): Promise<string | undefined> => {
85
+ if (!response.ok) return undefined;
86
+ // Counted as it arrives rather than `.text()`, which materialises the whole body first: a
87
+ // multi-gigabyte robots.txt is a heap the worker never gets back.
88
+ const capped = await readWithinLimit(response.body, limit);
89
+ return 'over' in capped ? undefined : new TextDecoder().decode(capped.bytes);
90
+ };
91
+
52
92
  /**
53
93
  * Reads `robotsUrl`, or answers `undefined` — which the gate reads as "no restrictions", the
54
- * standard's own answer for a file it cannot obtain. A deadline that fires, a body past the cap
55
- * and a 404 are all the same answer on purpose: none of them is evidence of a rule.
94
+ * standard's own answer for a file it cannot obtain. A deadline that fires, a body past the cap,
95
+ * a 404 and a redirect off `allowHosts` are all the same answer on purpose: none of them is
96
+ * evidence of a rule.
56
97
  */
57
98
  export function robotsFetcher(init: RobotsFetchInit = {}): RobotsFetch {
58
99
  const call: ScrapeFetch = init.fetch ?? fetch;
100
+ const resolve = init.resolve ?? resolveHost;
59
101
  // Both bounds are screened HERE, at construction, and both floors are 1 — because every way this
60
102
  // read can fail is the same answer, `undefined`, which the gate reads as "no restrictions". A
61
103
  // `NaN` deadline throws a bare `TypeError` out of `AbortSignal.timeout` (measured: "Value NaN is
@@ -83,16 +125,42 @@ export function robotsFetcher(init: RobotsFetchInit = {}): RobotsFetch {
83
125
  // Resolved here, at the read, because the session that owns the exit did not exist when this
84
126
  // fetcher was built. An empty string is not an exit and is dropped with the absent one.
85
127
  const proxy = init.proxy?.();
128
+ const dialled = proxy === undefined || proxy === '' ? undefined : proxy;
86
129
  try {
87
- const response = await call(robotsUrl, {
88
- signal,
89
- ...(proxy === undefined || proxy === '' ? {} : { proxy }),
90
- });
91
- if (!response.ok) return undefined;
92
- // Counted as it arrives rather than `.text()`, which materialises the whole body first: a
93
- // multi-gigabyte robots.txt is a heap the worker never gets back.
94
- const capped = await readWithinLimit(response.body, limit);
95
- return 'over' in capped ? undefined : new TextDecoder().decode(capped.bytes);
130
+ let url = robotsUrl;
131
+ for (let hops = 0; ; hops += 1) {
132
+ if (init.allowHosts !== undefined && !onList(url, init.allowHosts)) return undefined;
133
+ // Pinned exactly as the HTTP leg pins (`pinned-host.ts`), and for its reason skipped
134
+ // through a proxy. A name resolving inward throws, which this read answers as "no robots".
135
+ const dial =
136
+ init.allowHosts === undefined || dialled !== undefined
137
+ ? { url }
138
+ : await dialTarget(url, init.allowHosts, resolve);
139
+ // `manual`, never the platform default: `follow` dials wherever the site points before
140
+ // anything here can ask whether that host is on the list.
141
+ const response = await call(dial.url, {
142
+ signal,
143
+ redirect: 'manual',
144
+ ...(dialled === undefined ? {} : { proxy: dialled }),
145
+ ...(dial.host === undefined ? {} : { headers: { host: dial.host } }),
146
+ ...(dial.serverName === undefined ? {} : { tls: { serverName: dial.serverName } }),
147
+ });
148
+ const next = redirectHop(
149
+ response.status,
150
+ response.headers.get('location'),
151
+ url,
152
+ 'GET',
153
+ undefined,
154
+ );
155
+ if (next === undefined) return await answerOf(response, limit);
156
+ await response.body?.cancel().catch(() => undefined);
157
+ if (hops >= MAX_ROBOTS_REDIRECTS) return undefined;
158
+ // With no list to ask, only a hop that stays on the host the read started on is
159
+ // followed — an `http:` → `https:` upgrade or a moved path. Another host is a host
160
+ // nobody screened, and is never dialled.
161
+ if (init.allowHosts === undefined && !sameHost(robotsUrl, next.url)) return undefined;
162
+ url = next.url;
163
+ }
96
164
  } catch {
97
165
  return undefined;
98
166
  }
package/src/scrape-run.ts CHANGED
@@ -19,8 +19,8 @@ import {
19
19
  createPrompt,
20
20
  ensureAuthenticated,
21
21
  markRefused,
22
+ observeSession,
22
23
  persistSession,
23
- restorableSession,
24
24
  } from './auth';
25
25
  import { scrapeClock } from './clock';
26
26
  import type { ScrapeSession } from './driver';
@@ -147,7 +147,8 @@ export async function runScrape<I, Row>(
147
147
 
148
148
  // Read BEFORE the browser opens: a refused credential must not reach a login form again, and
149
149
  // opening a session first would already have spent an identity on a run that cannot succeed.
150
- const restored = await restorableSession(plan);
150
+ const observed = await observeSession(plan);
151
+ const restored = observed.restorable;
151
152
  const pageTimeoutMs = toMillis(definition.pageTimeout, DEFAULT_PAGE_TIMEOUT_MS, 'pageTimeout');
152
153
  // The exit the session dials, readable only AFTER `driver.open()` — a driver with no run exit
153
154
  // falls back to its own option, and the gate below is an argument to `open()`, so the gate asks
@@ -174,6 +175,8 @@ export async function runScrape<I, Row>(
174
175
  // this gate reads as "no restrictions".
175
176
  robots: createRobotsGate({
176
177
  policy: definition.robots ?? 'obey',
178
+ // Every hop of the read screened by the run's own host rule, as the HTTP leg's are.
179
+ allowHosts: rules.allowHosts,
177
180
  timeoutMs: pageTimeoutMs,
178
181
  signal: args.ctx.signal,
179
182
  proxy: () => sessionProxy,
@@ -184,6 +187,11 @@ export async function runScrape<I, Row>(
184
187
  watchdog: definition.watchdog,
185
188
  });
186
189
  sessionProxy = session.proxy;
190
+ // The VERSION of the stored record this run is on — the one it found (restorable or not), or the
191
+ // one it persisted after its own login. A burn or a tombstone on the way out compares against
192
+ // it, so a record another run on the key saved meanwhile is not destroyed by this run's failure
193
+ // (`auth.ts`'s `stillSeen`).
194
+ let seen = observed.seen;
187
195
 
188
196
  try {
189
197
  if (definition.auth !== undefined) {
@@ -212,7 +220,15 @@ export async function runScrape<I, Row>(
212
220
  );
213
221
  // Persisted after a LOGIN only, never after a reuse: rewriting the record on every run
214
222
  // refreshes `savedAt` without refreshing the session, so `maxAge` would never expire it.
215
- if (loggedIn) await persistSession(plan, session.page);
223
+ //
224
+ // And a failure to SAVE never fails the attempt: the login happened, the body can run, and
225
+ // failing here made the retry log in again — one more arrival at the login form, and on a
226
+ // store that stays down, one per attempt. Logged, like the burn and the tombstone below.
227
+ if (loggedIn) {
228
+ await recordSessionOutcome('session.save', logger, async () => {
229
+ seen = await persistSession(plan, session.page);
230
+ });
231
+ }
216
232
  }
217
233
 
218
234
  const raw = await bodyWithRecovery(definition, args, session, logger, artifact, secrets);
@@ -252,9 +268,9 @@ export async function runScrape<I, Row>(
252
268
  // Kept for `onSettled`, and only when somebody declared one to read it.
253
269
  if (definition.onSettled !== undefined) rememberFailedUsage(args.runId, used);
254
270
  if (errorCode(thrown) === 'X_SCRAPE_AUTH_FAILED') {
255
- await recordSessionOutcome('session.refuse', logger, () => markRefused(plan));
271
+ await recordSessionOutcome('session.refuse', logger, () => markRefused(plan, seen));
256
272
  } else if (burnsSession(thrown)) {
257
- await recordSessionOutcome('session.burn', logger, () => burnSession(plan));
273
+ await recordSessionOutcome('session.burn', logger, () => burnSession(plan, seen));
258
274
  }
259
275
  if (definition.artifacts?.onFailure !== false) await saveFailureArtifact(session, artifact);
260
276
  throw thrown;
@@ -274,8 +290,8 @@ const usageFields = (used: ScrapeUsage): ScrapeEventFields => ({
274
290
  });
275
291
 
276
292
  /**
277
- * The tombstone or the burn, on the way out — best effort, and it may NEVER replace the failure
278
- * that caused it. `markRefused` reaches `store.save()` reaches `storage.put()`, so an S3 503 or an
293
+ * The save after a login, and the tombstone or the burn on the way out — best effort, and none
294
+ * may ever replace the run's own outcome. `markRefused` reaches `store.save()` reaches `storage.put()`, so an S3 503 or an
279
295
  * `X_STORAGE_PATH_UNSAFE` from a tenant whose key sanitises to nothing used to propagate out of
280
296
  * the catch and REPLACE a terminal `X_SCRAPE_AUTH_FAILED` with a retryable one. Attempt 2 then
281
297
  * found no tombstone — the save is what failed — and walked the same rejected password back to
@@ -283,15 +299,16 @@ const usageFields = (used: ScrapeUsage): ScrapeEventFields => ({
283
299
  * reason: the run's own error is the one the reader needs.
284
300
  */
285
301
  async function recordSessionOutcome(
286
- step: 'session.refuse' | 'session.burn',
302
+ step: 'session.save' | 'session.refuse' | 'session.burn',
287
303
  logger: ReturnType<typeof scrapeLogger>,
288
304
  write: () => Promise<void>,
289
305
  ): Promise<void> {
290
306
  try {
291
307
  await write();
292
308
  } catch (thrown) {
293
- // Logged rather than swallowed silently: the tombstone is missing, so the NEXT attempt will
294
- // re-probe rather than refuse cheaply, and that is a fact an operator has to be able to see.
309
+ // Logged rather than swallowed silently: a missing tombstone means the NEXT attempt re-probes
310
+ // rather than refusing cheaply, a missing save means it logs in again, and an operator has to
311
+ // be able to see either.
295
312
  logger.error('scrape.session.write_failed', { step, code: errorCode(thrown) });
296
313
  }
297
314
  }
package/src/scrape.ts CHANGED
@@ -30,6 +30,7 @@ import type { ScrapeClock } from './clock';
30
30
  import type { ScrapeDriver } from './driver';
31
31
  import { yieldHistoryMissing } from './error-throws';
32
32
  import type { YieldExpectation, YieldHistory } from './expect';
33
+ import { maxDropFraction } from './expect';
33
34
  import type { HostRule } from './hosts';
34
35
  import type { ScrapeHttp } from './http';
35
36
  import type { ScrapePage } from './page';
@@ -198,8 +199,9 @@ export function scrape<I, Row>(definition: ScrapeDefinition<I, Row>): JobHandle<
198
199
  // Two halves that must be set together. `maxDrop` is a fraction of a trailing median and only
199
200
  // `history:` can supply one, so declaring it alone is an alarm that cannot fire — refused here,
200
201
  // where it is written, rather than discovered as a scrape that never once went red.
201
- if (definition.expect?.maxDrop !== undefined && definition.history === undefined) {
202
- throw yieldHistoryMissing(definition.name);
202
+ if (definition.expect?.maxDrop !== undefined) {
203
+ if (definition.history === undefined) throw yieldHistoryMissing(definition.name);
204
+ maxDropFraction(definition.name, definition.expect.maxDrop);
203
205
  }
204
206
  const onSettled = definition.onSettled?.bind(definition);
205
207
  return job<I, ScrapeReport<Row>>({
package/src/secrets.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  // logger. The third is not, because pixels cannot be redacted after the fact.
8
8
 
9
9
  import type { Secret } from '@ultimat3/core';
10
- import { revealSecret, secret, UltimateError } from '@ultimat3/core';
10
+ import { escapeHtml, revealSecret, secret, UltimateError } from '@ultimat3/core';
11
11
  import type { ConsoleLine, NetworkEntry, PageError } from './rings';
12
12
 
13
13
  export interface ScrapeSecrets {
@@ -95,6 +95,56 @@ export const SECRET_PLACEHOLDER = '[redacted]';
95
95
  */
96
96
  export const MIN_REDACTABLE_LENGTH = 4;
97
97
 
98
+ /**
99
+ * The SUBSET a serialiser escaped, each character spelt by core's one table — a page escapes fewer
100
+ * characters than `escapeHtml` does (a browser's text serialiser leaves quotes alone), so the
101
+ * spelling is the table applied to that serialiser's set, never a second table.
102
+ */
103
+ const escapeOnly = (value: string, chars: RegExp): string => value.replace(chars, escapeHtml);
104
+
105
+ /**
106
+ * Every spelling a page or a URL writes one value in. The raw value alone missed the case
107
+ * `safeNetwork` exists for: a password in a query string arrives percent-encoded, or form-encoded
108
+ * (`+` for a space, `'` and `(` escaped), and a server-rendered page writes it HTML-escaped — as
109
+ * text (`&amp; &lt; &gt;`, what a browser's serialiser emits), as an attribute (`&amp; &quot;`),
110
+ * or through a template that escapes all five (`&#39;` or `&#x27;` for the apostrophe).
111
+ * Each spelling is at least as long as the value, so the length floor is decided on the value.
112
+ */
113
+ function spellingsOf(value: string): readonly string[] {
114
+ const text = escapeOnly(value, /[&<>]/g);
115
+ const attribute = escapeOnly(value, /[&"]/g);
116
+ const full = escapeOnly(value, /[&<>"]/g);
117
+ return [
118
+ value,
119
+ encodeURIComponent(value),
120
+ new URLSearchParams([['', value]]).toString().slice(1),
121
+ text,
122
+ attribute,
123
+ full.replaceAll("'", '&#39;'),
124
+ full.replaceAll("'", '&#x27;'),
125
+ ];
126
+ }
127
+
128
+ const REGEX_SPECIAL = /[.*+?^${}()|[\]\\/]/g;
129
+
130
+ /**
131
+ * One spelling as a pattern: literal throughout, except that the hex digits of a `%XX` escape
132
+ * match in either case — `%2F` and `%2f` are the same byte, and clients emit both. The value's own
133
+ * letters stay case-sensitive: `Hunter2` is not `hunter2`.
134
+ */
135
+ const spellingPattern = (spelling: string): RegExp =>
136
+ new RegExp(
137
+ spelling
138
+ .split(/(%[0-9A-Fa-f]{2})/)
139
+ .map((part, index) =>
140
+ index % 2 === 1
141
+ ? part.replace(/[A-Fa-f]/g, (digit) => `[${digit.toUpperCase()}${digit.toLowerCase()}]`)
142
+ : part.replace(REGEX_SPECIAL, '\\$&'),
143
+ )
144
+ .join(''),
145
+ 'g',
146
+ );
147
+
98
148
  /**
99
149
  * Redaction BY VALUE, over the text this package hands back or persists. Name-based redaction only
100
150
  * catches a secret travelling under a name somebody remembered to list, and a password pasted into
@@ -111,20 +161,22 @@ export const MIN_REDACTABLE_LENGTH = 4;
111
161
  * `MIN_REDACTABLE_LENGTH`, and pixels — which is why a secret TAINTS the page and captures are
112
162
  * refused outright.
113
163
  *
114
- * Longest first, so a secret that contains another one does not leave its tail behind.
164
+ * Every spelling of every value (`spellingsOf`), longest first, so a secret that contains another
165
+ * one does not leave its tail behind.
115
166
  */
116
167
  export function redactSecrets(text: string, secrets: ScrapeSecrets | undefined): string {
117
168
  if (secrets === undefined) return text;
118
169
  const learned = concealed.get(secrets);
119
170
  if (secrets.names.length === 0 && (learned === undefined || learned.size === 0)) return text;
120
171
  const values = [
121
- ...secrets.names.map((name) => revealSecret(secrets.get(name))),
122
- ...(learned ?? []),
123
- ]
124
- .filter((value) => value.length >= MIN_REDACTABLE_LENGTH)
125
- .sort((a, b) => b.length - a.length);
172
+ ...new Set(
173
+ [...secrets.names.map((name) => revealSecret(secrets.get(name))), ...(learned ?? [])]
174
+ .filter((value) => value.length >= MIN_REDACTABLE_LENGTH)
175
+ .flatMap(spellingsOf),
176
+ ),
177
+ ].sort((a, b) => b.length - a.length);
126
178
  let out = text;
127
- for (const value of values) out = out.split(value).join(SECRET_PLACEHOLDER);
179
+ for (const value of values) out = out.replace(spellingPattern(value), SECRET_PLACEHOLDER);
128
180
  return out;
129
181
  }
130
182
 
@@ -38,6 +38,13 @@ export interface SessionState extends SessionSnapshot {
38
38
  readonly key: string;
39
39
  /** ISO 8601. What a `maxAge` on reuse is measured against. */
40
40
  readonly savedAt: string;
41
+ /**
42
+ * Unique per save — what a burn or a tombstone compares to decide whether the record is still
43
+ * the one a run saw (`auth.ts`'s `stillSeen`). Never `savedAt`: two saves in one millisecond, or
44
+ * on one frozen clock, share it. Absent on a record written before it existed; `recordVersion`
45
+ * reads `savedAt` for that one.
46
+ */
47
+ readonly version?: string | undefined;
41
48
  /**
42
49
  * ISO 8601, set when this site REFUSED these credentials — and the reason the record survives
43
50
  * the failure instead of being deleted with it.
@@ -309,6 +316,7 @@ export function parseSessionState(raw: unknown, key: string): SessionState | und
309
316
  return {
310
317
  key,
311
318
  savedAt: value.savedAt,
319
+ ...(typeof value.version === 'string' ? { version: value.version } : {}),
312
320
  ...(typeof value.refusedAt === 'string' ? { refusedAt: value.refusedAt } : {}),
313
321
  cookies: value.cookies.flatMap((cookie: unknown) => toCookie(cookie) ?? []),
314
322
  headers: value.headers ?? {},
@@ -317,3 +325,6 @@ export function parseSessionState(raw: unknown, key: string): SessionState | und
317
325
  origin: value.origin ?? '',
318
326
  };
319
327
  }
328
+
329
+ /** The identity a conditional burn or tombstone compares: the record's version, else its `savedAt`. */
330
+ export const recordVersion = (state: SessionState): string => state.version ?? state.savedAt;
@@ -51,13 +51,61 @@ export function splitCredentials(url: string): {
51
51
  return { bare: parsed.toString().replace(/\/$/, ''), username, password };
52
52
  }
53
53
 
54
+ /**
55
+ * The words that name a credential, matched against a query key's COMPONENTS — split on `_`, `-`,
56
+ * `.` and camelCase — never as substrings: `keyboard` holds `key` and `monkey` holds it too, and
57
+ * concealing their values blanked ordinary words out of every artifact. Read off the key and not
58
+ * the value, because a provider's token can be short and the key is the URL's own statement.
59
+ */
60
+ const CREDENTIAL_WORDS = new Set([
61
+ 'token',
62
+ 'key',
63
+ 'apikey',
64
+ 'secret',
65
+ 'auth',
66
+ 'authorization',
67
+ 'sig',
68
+ 'signature',
69
+ 'pass',
70
+ 'password',
71
+ 'passwd',
72
+ 'pwd',
73
+ 'cred',
74
+ 'credential',
75
+ 'credentials',
76
+ 'session',
77
+ 'sessionid',
78
+ 'sid',
79
+ 'jwt',
80
+ 'bearer',
81
+ ]);
82
+
83
+ const credentialKey = (key: string): boolean =>
84
+ key
85
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
86
+ .toLowerCase()
87
+ .split(/[\s_.-]+/)
88
+ .some((part) => CREDENTIAL_WORDS.has(part));
89
+
90
+ /**
91
+ * A value that is a capability whatever it is called: long, one unbroken run, letters and digits
92
+ * mixed — a browser id, an access token. A word (`residential`, `true`, `eu`) is none of those.
93
+ */
94
+ const credentialShaped = (value: string): boolean =>
95
+ value.length >= 16 && !/\s/.test(value) && /[0-9]/.test(value) && /[a-z]/i.test(value);
96
+
54
97
  /**
55
98
  * Every value in the URL that is, or may be, a credential — for the run's redaction set.
56
99
  *
57
- * The whole URL first, then the parts that can be quoted on their own: a launcher's error says
58
- * `401 for /devtools/browser/<id>?token=…` as readily as it repeats the URL. The path counts
59
- * because a browser id in a CDP path IS the capability. Encoded and decoded forms both, since a
60
- * message may carry either. The host is left out on purpose: it is what an error is allowed to say.
100
+ * The whole URL first, then the parts a message quotes on their own: a launcher's error says
101
+ * `401 for /devtools/browser/<id>?token=…` as readily as it repeats the URL. Encoded and decoded
102
+ * forms both, since a message may carry either. The host is left out on purpose: it is what an
103
+ * error is allowed to say.
104
+ *
105
+ * A query VALUE or a path SEGMENT only when it is credential-shaped or sits under a
106
+ * credential-named key — never every value. Concealing `stealth=true&proxy=residential` blanked
107
+ * every `true` and `residential` in the page, the console and the network ring of the run: the
108
+ * unreadable artifact `MIN_REDACTABLE_LENGTH` exists to prevent.
61
109
  */
62
110
  export function urlSecretValues(url: string): readonly string[] {
63
111
  const values = new Set<string>([url]);
@@ -67,9 +115,22 @@ export function urlSecretValues(url: string): readonly string[] {
67
115
  // `admin` by value would blank the word out of every page artifact.
68
116
  values.add(parsed.password);
69
117
  values.add(decoded(parsed.password));
70
- if (parsed.pathname !== '/') values.add(`${parsed.pathname}${parsed.search}`);
71
- if (parsed.search !== '') values.add(parsed.search.slice(1));
72
- for (const value of parsed.searchParams.values()) values.add(value);
118
+ if (parsed.search !== '') {
119
+ values.add(`${parsed.pathname}${parsed.search}`);
120
+ values.add(parsed.search.slice(1));
121
+ }
122
+ const segments = parsed.pathname.split('/');
123
+ for (const [index, segment] of segments.entries()) {
124
+ // By POSITION as well as by shape: Chrome's `/devtools/<browser|page>/<id>` id IS the
125
+ // capability, and an id of letters alone is not credential-shaped.
126
+ const capability = index >= 2 && segments[index - 2] === 'devtools';
127
+ if (capability || credentialShaped(decoded(segment))) {
128
+ values.add(decoded(segment)).add(segment);
129
+ }
130
+ }
131
+ for (const [key, value] of parsed.searchParams) {
132
+ if (credentialKey(key) || credentialShaped(value)) values.add(value);
133
+ }
73
134
  } catch {
74
135
  // Not a URL: the whole string above is all there is to redact.
75
136
  }