@lincoln504/pi-research 1.3.8 → 1.3.10

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.
@@ -84,41 +84,73 @@ const MAX_LOG_MESSAGE_LENGTH = 10_000;
84
84
  // separately for the console). Matching the ESC control byte is the point here.
85
85
  // eslint-disable-next-line no-control-regex
86
86
  const ANSI_PATTERN = /\x1b\[[0-9;]*[A-Za-z]/g;
87
- // Credentials embedded in a URL userinfo component: scheme://user:pass@host
88
- const URL_CREDENTIALS_PATTERN = /([a-z][a-z0-9+.-]*:\/\/)[^/\s:@]+:[^/\s:@]+@/gi;
87
+ // Credentials embedded in a URL userinfo component: scheme://user:pass@host.
88
+ //
89
+ // The scheme is length-bounded, and that bound is load-bearing rather than
90
+ // cosmetic. With an unbounded `[a-z0-9+.-]*`, every position inside a long
91
+ // alphanumeric run greedily consumes the rest of the input and then backtracks
92
+ // one character at a time looking for `://` — quadratic in the input length. On
93
+ // the 40_000-character window redactSecrets scans that measured ~1.4 SECONDS of
94
+ // blocked event loop per call, on a function that runs for every single log
95
+ // message, over content that is largely untrusted (scraped pages, provider error
96
+ // bodies): a page carrying one long unbroken token was enough to trigger it. A
97
+ // bounded scheme makes each start position O(32) instead of O(n) — same matches
98
+ // (a longer run simply starts matching nearer the `://`), ~215x faster measured.
99
+ // RFC 3986 schemes are short; no registered scheme approaches 32 characters.
100
+ const URL_CREDENTIALS_PATTERN = /([a-z][a-z0-9+.-]{0,31}:\/\/)[^/\s:@]+:[^/\s:@]+@/gi;
101
+ // Left/right boundaries for secret key names and opaque token formats. `\b`
102
+ // is WRONG here: `_` is a word character, so `\b` finds no boundary between the
103
+ // prefix and the secret in the single most common credential shape there is —
104
+ // an env var like PI_RESEARCH_API_KEY=…, OPENAI_API_KEY=…, DB_PASSWORD=…. Every
105
+ // such line was written to the log in the clear while the unprefixed `api_key=`
106
+ // was masked. Treat `_` (and any other non-alphanumeric) as a boundary, so a
107
+ // prefixed name matches while a name merely CONTAINING the word does not
108
+ // ("monkey=1" must not read as "key=1").
109
+ const B_LEFT = '(?<![A-Za-z0-9])';
110
+ const B_RIGHT = '(?![A-Za-z0-9])';
89
111
  // Cookie / Set-Cookie headers. The KV value class below stops at whitespace, so
90
112
  // "Cookie: lang=en; SID=secret123" would mask only the first pair and leave every
91
113
  // later one exposed. A cookie string is a ;-and-space separated list of secrets,
92
114
  // not a single token — mask the ENTIRE header value to end-of-line.
93
- const COOKIE_HEADER_PATTERN = /\b(set[_-]?cookie|cookie)\b(["']?\s*[:=]\s*["']?)([^\r\n]+)/gi;
115
+ const COOKIE_HEADER_PATTERN = new RegExp(
116
+ `${B_LEFT}(set[_-]?cookie|cookie)${B_RIGHT}(["']?\\s*[:=]\\s*["']?)([^\\r\\n]+)`,
117
+ 'gi',
118
+ );
94
119
  // key=value / key: "value" pairs whose key names a secret. Includes bare
95
120
  // `key` (not just `api_key`/`private_key`): several provider query-param
96
121
  // APIs (e.g. StackExchange's `?key=<apikey>`) name the credential parameter
97
122
  // with no qualifying prefix. Over-redacting an unrelated "key" field (a
98
123
  // sort key, a map key) is the accepted trade-off — same one already made for
99
124
  // the equally generic bare `session`/`cookie` entries below.
100
- const SENSITIVE_KV_PATTERN =
101
- /\b(api[_-]?key|apikey|access[_-]?token|auth[_-]?token|refresh[_-]?token|secret|client[_-]?secret|password|passwd|pwd|authorization|bearer|set[_-]?cookie|cookie|session[_-]?id|session|csrf[_-]?token|xsrf[_-]?token|private[_-]?key|key)\b(["']?\s*[:=]\s*["']?)([^\s"',&)]+)/gi;
125
+ const SENSITIVE_KV_PATTERN = new RegExp(
126
+ `${B_LEFT}(api[_-]?key|apikey|access[_-]?token|auth[_-]?token|refresh[_-]?token|secret|client[_-]?secret|password|passwd|pwd|authorization|bearer|set[_-]?cookie|cookie|session[_-]?id|session|csrf[_-]?token|xsrf[_-]?token|private[_-]?key|key)${B_RIGHT}` +
127
+ `(["']?\\s*[:=]\\s*["']?)([^\\s"',&)]+)`,
128
+ 'gi',
129
+ );
102
130
  // Well-known opaque credential formats (OpenAI, GitHub, AWS, Slack, Anthropic).
103
- const KNOWN_TOKEN_PATTERN =
104
- /\b(sk-[A-Za-z0-9_-]{16,}|gh[posru]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{20,}|AKIA[0-9A-Z]{16}|xox[baprs]-[A-Za-z0-9-]{10,})\b/g;
131
+ const KNOWN_TOKEN_PATTERN = new RegExp(
132
+ `${B_LEFT}(sk-[A-Za-z0-9_-]{16,}|gh[posru]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{20,}|AKIA[0-9A-Z]{16}|xox[baprs]-[A-Za-z0-9-]{10,})${B_RIGHT}`,
133
+ 'g',
134
+ );
105
135
  // JSON Web Tokens — header.payload.signature, each a base64url segment.
106
- const JWT_PATTERN = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g;
136
+ const JWT_PATTERN = new RegExp(`${B_LEFT}eyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+`, 'g');
107
137
  // HTTP Basic credentials: "Basic <base64>".
108
- const BASIC_AUTH_PATTERN = /\bBasic\s+[A-Za-z0-9+/]{16,}={0,2}/gi;
138
+ const BASIC_AUTH_PATTERN = new RegExp(`${B_LEFT}Basic\\s+[A-Za-z0-9+/]{16,}={0,2}`, 'gi');
109
139
  // HTTP Bearer credentials: "Bearer <token>". The token is often opaque — many
110
140
  // providers (e.g. GLM/zhipuai) use dotted keys that are NOT JWTs and carry no
111
141
  // known prefix, so KNOWN_TOKEN_PATTERN/JWT_PATTERN miss them and the KV pass
112
142
  // only masks the word "Bearer" (its value class stops at the space). Match the
113
143
  // scheme and redact the whole RFC 6750 b64token that follows, whatever its shape.
114
- const BEARER_AUTH_PATTERN = /\bBearer\s+[A-Za-z0-9._~+/-]+=*/gi;
144
+ const BEARER_AUTH_PATTERN = new RegExp(`${B_LEFT}Bearer\\s+[A-Za-z0-9._~+/-]+=*`, 'gi');
115
145
  // Provider keys not covered by KNOWN_TOKEN_PATTERN: Google API/OAuth, Stripe.
116
- const PROVIDER_TOKEN_PATTERN =
117
- /\b(AIza[0-9A-Za-z_-]{35}|ya29\.[0-9A-Za-z_-]{20,}|[sp]k_(?:live|test)_[0-9A-Za-z]{16,})\b/g;
146
+ const PROVIDER_TOKEN_PATTERN = new RegExp(
147
+ `${B_LEFT}(AIza[0-9A-Za-z_-]{35}|ya29\\.[0-9A-Za-z_-]{20,}|[sp]k_(?:live|test)_[0-9A-Za-z]{16,})${B_RIGHT}`,
148
+ 'g',
149
+ );
118
150
  // Long opaque hex secrets (>=32 hex chars), e.g. the 64-hex browser auth secret
119
151
  // or MD5/SHA-style tokens. Diagnostic logs may over-redact content hashes of
120
152
  // this length; that trade-off favors not persisting credentials clear-text.
121
- const LONG_HEX_SECRET_PATTERN = /\b[0-9a-fA-F]{32,}\b/g;
153
+ const LONG_HEX_SECRET_PATTERN = new RegExp(`${B_LEFT}[0-9a-fA-F]{32,}${B_RIGHT}`, 'g');
122
154
  // All C0 control characters plus DEL — newlines/carriage returns are the log/
123
155
  // terminal-injection vector when written to a raw console line. Matching these
124
156
  // control bytes is exactly the intent.
@@ -1,28 +1,39 @@
1
1
  /**
2
2
  * Realistic User-Agent Strings for Browser Rotation
3
3
  *
4
- * Provides current, realistic User-Agent strings that mimic real browsers.
5
- * Updated regularly to match current browser versions.
4
+ * Sent by the FETCH scrape path (web-scraper.ts); the stealth browser path
5
+ * supplies its own fingerprint-consistent UA via camoufox.
6
+ *
7
+ * These have a shelf life. Both browsers ship a major version roughly every
8
+ * four weeks, and a UA more than a few majors behind stable is itself a
9
+ * bot signal — the exact thing rotating them is meant to avoid. Refresh the
10
+ * two Chrome and two Firefox majors below when they drift; keep the platform
11
+ * tokens as-is (Chrome freezes the macOS token at 10_15_7 and zeroes the
12
+ * minor version triplet, Firefox mirrors its major in both `rv:` and the
13
+ * trailing `Firefox/` token).
14
+ *
15
+ * Last refreshed 2026-08-14 against Chrome 151 stable (Chrome Releases,
16
+ * 2026-07-28) and Firefox 153 stable (firefox.com release notes, 2026-08-11).
6
17
  */
7
18
 
8
19
  export const USER_AGENTS = [
9
- // Chrome on Windows (updated 2025)
10
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
11
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36',
20
+ // Chrome on Windows
21
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36',
22
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36',
12
23
 
13
24
  // Chrome on macOS
14
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
15
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36',
25
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36',
26
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36',
16
27
 
17
28
  // Chrome on Linux
18
- 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
19
- 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36',
29
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36',
30
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36',
20
31
 
21
- // Firefox on Windows (updated 2025)
22
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:139.0) Gecko/20100101 Firefox/139.0',
23
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:138.0) Gecko/20100101 Firefox/138.0',
32
+ // Firefox on Windows
33
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:153.0) Gecko/20100101 Firefox/153.0',
34
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0',
24
35
 
25
36
  // Firefox on macOS
26
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:139.0) Gecko/20100101 Firefox/139.0',
27
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:138.0) Gecko/20100101 Firefox/138.0',
28
- ];
37
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:153.0) Gecko/20100101 Firefox/153.0',
38
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0',
39
+ ];
@@ -250,27 +250,73 @@ export function ssrfSafeLookup(
250
250
  });
251
251
  }
252
252
 
253
- // undici Agent used as the per-request dispatcher for scrape fetches. Cached
254
- // after first construction. `undefined` = not yet attempted, `null` = undici
255
- // unavailable (fall back to plain fetch with request-time validation only).
256
- let cachedSsrfDispatcher: unknown | null | undefined = undefined;
253
+ /**
254
+ * True when the error is one of our SSRF blocks — request-time validation
255
+ * (message contains "not allowed") or the connect-time DNS pin (`ESSRFBLOCKED`).
256
+ *
257
+ * The connect-time error never surfaces directly: undici wraps it in
258
+ * `TypeError: fetch failed` with the real error down the `cause` chain, so a
259
+ * message check on the outer error alone files a connect-time block as a generic
260
+ * fetch error. Walk the chain (bounded — causes can be cyclic) instead.
261
+ */
262
+ export function isSsrfBlockError(error: unknown): boolean {
263
+ let e: unknown = error;
264
+ for (let depth = 0; depth < 5 && e instanceof Error; depth++) {
265
+ if ((e as NodeJS.ErrnoException).code === 'ESSRFBLOCKED' || e.message.includes('not allowed')) {
266
+ return true;
267
+ }
268
+ e = (e as { cause?: unknown }).cause;
269
+ }
270
+ return false;
271
+ }
272
+
273
+ /** A fetch-shaped callable plus the dispatcher it must be invoked with. */
274
+ export interface SsrfSafeFetcher {
275
+ /** undici's own fetch — NOT the global one. See getSsrfSafeFetcher. */
276
+ fetch: (url: string, init: Record<string, unknown>) => Promise<Response>;
277
+ dispatcher: unknown;
278
+ }
279
+
280
+ // Cached after first construction. `undefined` = not yet attempted, `null` =
281
+ // undici unavailable (fall back to the global fetch with request-time
282
+ // validation only).
283
+ let cachedSsrfFetcher: SsrfSafeFetcher | null | undefined = undefined;
257
284
 
258
285
  /**
259
286
  * Build (once) an undici Agent whose connector resolves via ssrfSafeLookup, so
260
287
  * every scrape fetch — including each manually-followed redirect hop — connects
261
- * only to a validated public address. Returns null if undici can't be loaded;
262
- * callers then fall back to plain fetch (request-time validation still applies).
288
+ * only to a validated public address, PAIRED with the fetch implementation that
289
+ * can actually accept it.
290
+ *
291
+ * The pairing is the whole point. A dispatcher is only honoured by the undici
292
+ * that created it: Node's global `fetch` is backed by the undici BUILT INTO the
293
+ * runtime (6.21.0 on Node 22, 7.24.4 on Node 25), while this package depends on
294
+ * undici 8.x. Handing a v8 `Agent` to the global fetch throws
295
+ * `UND_ERR_INVALID_ARG: invalid onRequestStart method` on every request, on
296
+ * every supported Node — which is exactly what it did: the entire fetch scrape
297
+ * path failed instantly, every URL was retried once and then fell back to the
298
+ * stealth browser (~4.5s per page against ~250ms), and the connect-time SSRF pin
299
+ * this dispatcher exists for never engaged even once. The browser fallback made
300
+ * it invisible: scrapes still succeeded, just 15-20x slower.
301
+ *
302
+ * Returning the two together makes the invalid combination unrepresentable —
303
+ * a caller cannot end up holding a dispatcher without the fetch that honours it.
304
+ * Returns null if undici can't be loaded; callers then use the global fetch with
305
+ * no dispatcher (request-time validation still applies).
263
306
  */
264
- export async function getSsrfSafeDispatcher(): Promise<unknown | null> {
265
- if (cachedSsrfDispatcher !== undefined) return cachedSsrfDispatcher as unknown | null;
307
+ export async function getSsrfSafeFetcher(): Promise<SsrfSafeFetcher | null> {
308
+ if (cachedSsrfFetcher !== undefined) return cachedSsrfFetcher;
266
309
  try {
267
- const { Agent } = await import('undici');
268
- cachedSsrfDispatcher = new Agent({ connect: { lookup: ssrfSafeLookup } });
310
+ const { Agent, fetch: undiciFetch } = await import('undici');
311
+ cachedSsrfFetcher = {
312
+ fetch: undiciFetch as unknown as SsrfSafeFetcher['fetch'],
313
+ dispatcher: new Agent({ connect: { lookup: ssrfSafeLookup } }),
314
+ };
269
315
  } catch (e) {
270
316
  logger.warn(`[Scrapers] undici unavailable; connect-time SSRF pinning disabled (request-time validation still active): ${e instanceof Error ? e.message : String(e)}`);
271
- cachedSsrfDispatcher = null;
317
+ cachedSsrfFetcher = null;
272
318
  }
273
- return cachedSsrfDispatcher as unknown | null;
319
+ return cachedSsrfFetcher;
274
320
  }
275
321
 
276
322
  /**
@@ -379,6 +425,18 @@ function isPrivateIpv6(ip: string): boolean {
379
425
  // metadata service) — block the whole prefix regardless of what it wraps,
380
426
  // same rationale as the 6to4/Teredo ranges just above.
381
427
  if (normalized.startsWith('64:ff9b:')) return true;
428
+ // IPv4-COMPATIBLE ::/96 (RFC 4291 §2.5.5.1, deprecated) — the sibling of the
429
+ // ::ffff:0:0/96 mapped range decoded above, and the last IPv4-in-IPv6 form
430
+ // left open. `http://[::127.0.0.1]/` normalizes to `[::7f00:1]`, which matches
431
+ // none of the checks above: not `::1`, no `fc`/`fd`/`fe8x` prefix, no `::ffff:`
432
+ // prefix for extractMappedIpv4 to decode, and not one of the tunnel prefixes.
433
+ // Whether it reaches the embedded IPv4 depends on the OS, an intervening
434
+ // proxy, and NAT64 translation — the same "depends on the path" property that
435
+ // made 6to4/Teredo/NAT64 whole-range blocks the right call, so block the whole
436
+ // /96 rather than decode it. Deprecated with no legitimate scrape target.
437
+ // Matched structurally (top 96 bits zero => at most two groups after `::`),
438
+ // which is why it cannot swallow `::ffff:7f00:1` (three groups, ffff set).
439
+ if (/^::(?:\d{1,3}(?:\.\d{1,3}){3}|[0-9a-f]{1,4}(?::[0-9a-f]{1,4})?)$/.test(normalized)) return true;
382
440
  // All-zeros (unspecified address)
383
441
  if (normalized === '::' || normalized === '0:0:0:0:0:0:0:0') return true;
384
442
  return false;
@@ -23,7 +23,7 @@ import {
23
23
  import {
24
24
  FETCH_LAYER_TIMEOUT,
25
25
  } from './types.ts';
26
- import { getRandomUserAgent, extractDomain, validateUrlForSSRF, validateContent, createNativeMarkdownConverter, createJsMarkdownConverter, getSsrfSafeDispatcher, formatErrorWithCause, isBenignScrapeFailure, } from './scraper-utils.ts';
26
+ import { getRandomUserAgent, extractDomain, validateUrlForSSRF, validateContent, createNativeMarkdownConverter, createJsMarkdownConverter, getSsrfSafeFetcher, formatErrorWithCause, isBenignScrapeFailure, isSsrfBlockError, } from './scraper-utils.ts';
27
27
  import { isTransientError, abortableDelay } from './retry-utils.ts';
28
28
  import { safeUnref } from '../utils/safe-unref.ts';
29
29
  import { readBodyCapped, BodyTooLargeError } from '../utils/http-body.ts';
@@ -208,7 +208,7 @@ function decodeHtmlBody(bytes: Uint8Array, contentTypeHeader: string): string {
208
208
  * counts are precisely what production incidents are diagnosed from.
209
209
  */
210
210
  function trackFetchLayerFailure(url: string, error: unknown): void {
211
- const isSsrf = error instanceof Error && error.message.includes('not allowed');
211
+ const isSsrf = isSsrfBlockError(error);
212
212
  errorTracker.trackError(error instanceof Error ? error : String(error), {
213
213
  component: 'scrapers',
214
214
  operation: 'fetch',
@@ -226,7 +226,12 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
226
226
  // closing the DNS-rebinding TOCTOU gap between validateUrlForSSRF (request
227
227
  // time) and fetch's own re-resolution (connect time). Null when undici is
228
228
  // unavailable — request-time validation above still applies.
229
- const dispatcher = await getSsrfSafeDispatcher();
229
+ //
230
+ // The fetch implementation comes WITH the dispatcher and must be used with it:
231
+ // a dispatcher is only honoured by the undici that built it, and the global
232
+ // fetch is backed by Node's bundled undici, not ours. See getSsrfSafeFetcher.
233
+ const fetcher = await getSsrfSafeFetcher();
234
+ const doFetch = fetcher?.fetch ?? ((u: string, init: Record<string, unknown>) => fetch(u, init as RequestInit));
230
235
 
231
236
  const controller = new AbortController();
232
237
  const timeoutId = setTimeout(() => controller.abort(), FETCH_LAYER_TIMEOUT);
@@ -250,18 +255,23 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
250
255
  const MAX_REDIRECTS = 10;
251
256
  let currentUrl = url;
252
257
  let response!: Response;
258
+ // One UA for the whole redirect chain. Picked per hop, a request could
259
+ // present as Chrome on Windows and then as Firefox on macOS while following
260
+ // a single Location chain — a browser never does that, so the rotation meant
261
+ // to blend in was instead handing out a distinctive signal.
262
+ const userAgent = getRandomUserAgent();
253
263
 
254
264
  for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
255
- response = await fetch(currentUrl, {
265
+ response = await doFetch(currentUrl, {
256
266
  signal: controller.signal,
257
267
  redirect: 'manual',
258
268
  headers: {
259
- 'User-Agent': getRandomUserAgent(),
269
+ 'User-Agent': userAgent,
260
270
  'Accept': 'text/html,application/xhtml+xml,application/pdf,*/*;q=0.8',
261
271
  },
262
- // RequestInit's standard type omits undici's `dispatcher`; cast to attach it.
263
- ...(dispatcher ? { dispatcher } : {}),
264
- } as RequestInit);
272
+ // Only ever attached alongside the fetch that honours it (see above).
273
+ ...(fetcher ? { dispatcher: fetcher.dispatcher } : {}),
274
+ });
265
275
 
266
276
  if (response.status >= 300 && response.status < 400) {
267
277
  const location = response.headers.get('location');
@@ -375,7 +385,7 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
375
385
  // call in a retry, and tracking per attempt would record one failure twice —
376
386
  // inflating exactly the error counts these numbers are read to diagnose.
377
387
  // scrapeSingle calls trackFetchLayerFailure once with the final error.
378
- if (error instanceof Error && error.message.includes('not allowed')) {
388
+ if (isSsrfBlockError(error)) {
379
389
  metrics.increment('scrape_operations_total', 1, { layer: 'fetch', status: 'ssrf_blocked' });
380
390
  metrics.observe('scrape_latency_ms', Date.now() - fetchStart, { layer: 'fetch', status: 'ssrf_blocked' });
381
391
  throw error;