@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.
- package/README.md +4 -3
- package/package.json +5 -5
- package/src/auth.ts +90 -14
- package/src/cdp-arm.ts +65 -1
- package/src/cdp-fake-target.ts +67 -0
- package/src/cdp-fake.ts +9 -1
- package/src/cdp-port.ts +21 -0
- package/src/cdp-target.ts +70 -48
- package/src/driver-cdp.ts +12 -3
- package/src/driver.ts +1 -1
- package/src/error-throws-session.ts +37 -0
- package/src/error-throws.ts +5 -15
- package/src/errors.ts +8 -2
- package/src/expect.ts +21 -4
- package/src/html-target.ts +10 -15
- package/src/http-recorded.ts +3 -1
- package/src/http.ts +47 -10
- package/src/index.ts +3 -1
- package/src/launch-args.ts +51 -0
- package/src/pinned-host.ts +65 -0
- package/src/recover.ts +3 -2
- package/src/robots-fetch.ts +79 -11
- package/src/scrape-run.ts +27 -10
- package/src/scrape.ts +4 -2
- package/src/secrets.ts +60 -8
- package/src/session-state.ts +11 -0
- package/src/url-secrets.ts +68 -7
package/src/robots-fetch.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
278
|
-
*
|
|
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:
|
|
294
|
-
//
|
|
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
|
|
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 (`& < >`, what a browser's serialiser emits), as an attribute (`& "`),
|
|
110
|
+
* or through a template that escapes all five (`'` or `'` 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("'", '''),
|
|
124
|
+
full.replaceAll("'", '''),
|
|
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
|
-
*
|
|
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
|
-
...
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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.
|
|
179
|
+
for (const value of values) out = out.replace(spellingPattern(value), SECRET_PLACEHOLDER);
|
|
128
180
|
return out;
|
|
129
181
|
}
|
|
130
182
|
|
package/src/session-state.ts
CHANGED
|
@@ -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;
|
package/src/url-secrets.ts
CHANGED
|
@@ -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
|
|
58
|
-
* `401 for /devtools/browser/<id>?token=…` as readily as it repeats the URL.
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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.
|
|
71
|
-
|
|
72
|
-
|
|
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
|
}
|