@juspay/neurolink 9.94.3 → 9.94.5

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.
@@ -2,6 +2,7 @@
2
2
  * Image processing utilities for multimodal support
3
3
  * Handles format conversion for different AI providers
4
4
  */
5
+ import { redactPathFromMessage } from "./logSanitize.js";
5
6
  import type { ProcessedImage, FileProcessingResult } from "../types/index.js";
6
7
  /**
7
8
  * Image processor class for handling provider-specific image formatting
@@ -59,9 +60,41 @@ export declare class ImageProcessor {
59
60
  */
60
61
  static detectImageType(input: string | Buffer): string;
61
62
  /**
62
- * Validate image size (default 10MB limit)
63
+ * Throwing size guard for a raw byte length. Prevents memory exhaustion by
64
+ * rejecting an oversized input BEFORE an unbounded read/allocation happens
65
+ * (#257) — callers that can get a size cheaply (e.g. `fs.stat`) should
66
+ * validate it before ever reading the bytes into memory. Default limit is
67
+ * the canonical `SIZE_LIMITS_BYTES.IMAGE_MAX` (10 MB); there is no public
68
+ * way to raise it — `maxSize` is an internal-only override for advanced
69
+ * callers (e.g. video image inputs use a higher limit).
70
+ *
71
+ * @param size - Byte length to validate
72
+ * @param context - Short label identifying the source (unused in the
73
+ * thrown message today, kept for call-site readability and future use)
74
+ * @param maxSize - Max allowed size in bytes
75
+ * @throws NeuroLinkError (`INVALID_IMAGE_SIZE`) if `size` is not a finite,
76
+ * non-negative number
77
+ * @throws NeuroLinkError (`IMAGE_TOO_LARGE`) if `size` exceeds `maxSize`
78
+ */
79
+ static validateSize(size: number, context: string, maxSize?: number): void;
80
+ /**
81
+ * Throwing size guard for image buffers. Prevents memory exhaustion by
82
+ * rejecting oversized buffers with a descriptive error BEFORE an unbounded
83
+ * `Buffer.toString("base64")` allocation (#257). Delegates to
84
+ * `validateSize` so buffer-based and pre-read (stat-based) callers share
85
+ * one implementation.
86
+ *
87
+ * @param buffer - Image buffer to validate
88
+ * @param context - Short label identifying the source (see `validateSize`)
89
+ * @param maxSize - Max allowed size in bytes
90
+ * @throws NeuroLinkError (`IMAGE_TOO_LARGE`) if the buffer exceeds `maxSize`
91
+ */
92
+ static validateBufferSize(buffer: Buffer, context: string, maxSize?: number): void;
93
+ /**
94
+ * Size-guarded Buffer → base64. Use in place of `buffer.toString("base64")`
95
+ * on any path that converts a caller-supplied image buffer (#257).
63
96
  */
64
- static validateImageSize(data: Buffer | string, maxSize?: number): boolean;
97
+ static safeBase64Convert(buffer: Buffer, context: string, maxSize?: number): string;
65
98
  /**
66
99
  * Validate image format
67
100
  */
@@ -126,13 +159,28 @@ export declare const imageUtils: {
126
159
  */
127
160
  formatFileSize: (bytes: number) => string;
128
161
  /**
129
- * Convert Buffer to base64 string
162
+ * Convert Buffer to base64 string. Guarded by the same #257 size limit as
163
+ * every other conversion site (default `SIZE_LIMITS_BYTES.IMAGE_MAX`);
164
+ * pass `maxBytes` to override for a caller with a legitimate need for a
165
+ * different ceiling rather than silently accepting an unbounded buffer.
166
+ * `imageUtils`/`ImageProcessor` are internal-only — not re-exported from
167
+ * `src/lib/index.ts` or the package's public `exports` map — so this
168
+ * default does not change behavior for any external SDK consumer.
130
169
  */
131
- bufferToBase64: (buffer: Buffer) => string;
170
+ bufferToBase64: (buffer: Buffer, maxBytes?: number) => string;
132
171
  /**
133
172
  * Convert base64 string to Buffer
134
173
  */
135
174
  base64ToBuffer: (base64: string) => Buffer;
175
+ /**
176
+ * Redact `filePath` (both as given and its `path.resolve()`'d form) from
177
+ * an error message. Exposed directly (not just used internally by
178
+ * {@link fileToBase64DataUri}) so the resolved-path branch can be verified
179
+ * by a deterministic unit test — real `fs` errors only ever embed the
180
+ * literal path as passed, never a resolved variant, so that branch isn't
181
+ * otherwise observable through the async file-reading API.
182
+ */
183
+ redactPathFromMessage: typeof redactPathFromMessage;
136
184
  /**
137
185
  * Convert file path to base64 data URI
138
186
  */
@@ -2,11 +2,15 @@
2
2
  * Image processing utilities for multimodal support
3
3
  * Handles format conversion for different AI providers
4
4
  */
5
+ import { basename } from "path";
5
6
  import { logger } from "./logger.js";
7
+ import { redactPathFromMessage, redactUrlForError, redactUrlsInText, sanitizeErrorCause, } from "./logSanitize.js";
6
8
  import { urlDownloadRateLimiter } from "./rateLimiter.js";
7
9
  import { withRetry } from "./retryHandler.js";
8
10
  import { SYSTEM_LIMITS } from "../core/constants.js";
11
+ import { SIZE_LIMITS_BYTES } from "../processors/config/sizeLimits.js";
9
12
  import { getImageCache } from "./imageCache.js";
13
+ import { ErrorFactory, NeuroLinkError } from "./errorHandling.js";
10
14
  /**
11
15
  * Network error codes that should trigger a retry
12
16
  */
@@ -56,6 +60,25 @@ function isRetryableDownloadError(error) {
56
60
  }
57
61
  return false;
58
62
  }
63
+ /**
64
+ * Reject `detectImageType()`'s `application/octet-stream` sentinel — the
65
+ * honest "no known image signature (PNG/JPEG/GIF/WebP/BMP/TIFF/SVG/AVIF)
66
+ * matched" fallback (#261/#286). Packaging that sentinel into a data URI
67
+ * hands vision providers (OpenAI/Anthropic/Google) a MIME type they reject
68
+ * outright with an HTTP 400, so every public conversion path that turns
69
+ * unknown bytes into image output must fail loud here first instead of
70
+ * letting the sentinel slip through to a caller.
71
+ *
72
+ * @param mediaType - The MIME type returned by `detectImageType()`.
73
+ * @throws Error if `mediaType` is the octet-stream sentinel.
74
+ */
75
+ function assertKnownImageType(mediaType) {
76
+ if (mediaType === "application/octet-stream") {
77
+ logger.error("Unable to detect a supported image format from file content");
78
+ throw new Error("Unsupported or corrupted image: no known image signature " +
79
+ "(PNG/JPEG/GIF/WebP/BMP/TIFF/SVG/AVIF) was found in the file content.");
80
+ }
81
+ }
59
82
  /**
60
83
  * Image processor class for handling provider-specific image formatting
61
84
  */
@@ -75,7 +98,10 @@ export class ImageProcessor {
75
98
  throw new Error("Invalid image processing: buffer is empty");
76
99
  }
77
100
  const mediaType = this.detectImageType(content);
78
- const base64 = content.toString("base64");
101
+ // #261/#286 follow-up: fail loud instead of packaging the octet-stream
102
+ // sentinel as a "valid" image (see assertKnownImageType() above).
103
+ assertKnownImageType(mediaType);
104
+ const base64 = ImageProcessor.safeBase64Convert(content, "image processing");
79
105
  const dataUri = `data:${mediaType};base64,${base64}`;
80
106
  // Validate output before returning
81
107
  this.validateProcessOutput(dataUri, base64, mediaType);
@@ -138,10 +164,15 @@ export class ImageProcessor {
138
164
  return `data:image/jpeg;base64,${image}`;
139
165
  }
140
166
  // Handle Buffer - convert to data URI
141
- const base64 = image.toString("base64");
167
+ const base64 = ImageProcessor.safeBase64Convert(image, "OpenAI image input");
142
168
  return `data:image/jpeg;base64,${base64}`;
143
169
  }
144
170
  catch (error) {
171
+ // Preserve typed errors (e.g. IMAGE_TOO_LARGE) as-is — don't flatten
172
+ // them into a generic Error and lose the error `code` for callers.
173
+ if (error instanceof NeuroLinkError) {
174
+ throw error;
175
+ }
145
176
  logger.error("Failed to process image for OpenAI:", error);
146
177
  throw new Error(`Image processing failed for OpenAI: ${error instanceof Error ? error.message : "Unknown error"}`, { cause: error });
147
178
  }
@@ -170,7 +201,7 @@ export class ImageProcessor {
170
201
  }
171
202
  }
172
203
  else {
173
- base64Data = image.toString("base64");
204
+ base64Data = ImageProcessor.safeBase64Convert(image, "provider image input");
174
205
  }
175
206
  return {
176
207
  mimeType,
@@ -178,6 +209,11 @@ export class ImageProcessor {
178
209
  };
179
210
  }
180
211
  catch (error) {
212
+ // Preserve typed errors (e.g. IMAGE_TOO_LARGE) as-is — don't flatten
213
+ // them into a generic Error and lose the error `code` for callers.
214
+ if (error instanceof NeuroLinkError) {
215
+ throw error;
216
+ }
181
217
  logger.error("Failed to process image for Google AI:", error);
182
218
  throw new Error(`Image processing failed for Google AI: ${error instanceof Error ? error.message : "Unknown error"}`, { cause: error });
183
219
  }
@@ -206,7 +242,7 @@ export class ImageProcessor {
206
242
  }
207
243
  }
208
244
  else {
209
- base64Data = image.toString("base64");
245
+ base64Data = ImageProcessor.safeBase64Convert(image, "provider image input");
210
246
  }
211
247
  return {
212
248
  mediaType,
@@ -214,6 +250,11 @@ export class ImageProcessor {
214
250
  };
215
251
  }
216
252
  catch (error) {
253
+ // Preserve typed errors (e.g. IMAGE_TOO_LARGE) as-is — don't flatten
254
+ // them into a generic Error and lose the error `code` for callers.
255
+ if (error instanceof NeuroLinkError) {
256
+ throw error;
257
+ }
217
258
  logger.error("Failed to process image for Anthropic:", error);
218
259
  throw new Error(`Image processing failed for Anthropic: ${error instanceof Error ? error.message : "Unknown error"}`, { cause: error });
219
260
  }
@@ -238,6 +279,11 @@ export class ImageProcessor {
238
279
  }
239
280
  }
240
281
  catch (error) {
282
+ // Preserve typed errors bubbling up from processImageForGoogle/
283
+ // processImageForAnthropic (e.g. IMAGE_TOO_LARGE) as-is.
284
+ if (error instanceof NeuroLinkError) {
285
+ throw error;
286
+ }
241
287
  logger.error("Failed to process image for Vertex AI:", error);
242
288
  throw new Error(`Image processing failed for Vertex AI: ${error instanceof Error ? error.message : "Unknown error"}`, { cause: error });
243
289
  }
@@ -305,37 +351,111 @@ export class ImageProcessor {
305
351
  return "image/svg+xml";
306
352
  }
307
353
  }
308
- // AVIF: check for "ftypavif" signature at bytes 4-11
354
+ // AVIF (ISOBMFF): "ftyp" box at offset 4, major brand at offset 8.
355
+ // Accept the AVIF-spec brand variants — 'avif' (still image), 'avis'
356
+ // (image sequence), and 'avio' (intra-only AV1 image/sequence — the
357
+ // spec lists it under compatible_brands rather than major_brand, but
358
+ // real-world encoders emit it as major_brand too, so it's accepted
359
+ // here) — and compare bytes directly rather than via Buffer.toString()
360
+ // (which would utf-8-decode non-ASCII bytes) so the check is exact
361
+ // (#286).
309
362
  if (input.length >= 12) {
310
- const ftyp = input.subarray(4, 8).toString();
311
- const brand = input.subarray(8, 12).toString();
312
- if (ftyp === "ftyp" && brand === "avif") {
363
+ const isFtyp = input[4] === 0x66 && // 'f'
364
+ input[5] === 0x74 && // 't'
365
+ input[6] === 0x79 && // 'y'
366
+ input[7] === 0x70; // 'p'
367
+ const brand = String.fromCharCode(input[8], input[9], input[10], input[11]);
368
+ if (isFtyp &&
369
+ (brand === "avif" || brand === "avis" || brand === "avio")) {
313
370
  return "image/avif";
314
371
  }
315
372
  }
373
+ // BMP: "BM" magic (0x42 0x4D)
374
+ if (input[0] === 0x42 && input[1] === 0x4d) {
375
+ return "image/bmp";
376
+ }
377
+ // TIFF: little-endian "II*\0" or big-endian "MM\0*"
378
+ if ((input[0] === 0x49 &&
379
+ input[1] === 0x49 &&
380
+ input[2] === 0x2a &&
381
+ input[3] === 0x00) ||
382
+ (input[0] === 0x4d &&
383
+ input[1] === 0x4d &&
384
+ input[2] === 0x00 &&
385
+ input[3] === 0x2a)) {
386
+ return "image/tiff";
387
+ }
316
388
  }
317
- return "image/jpeg"; // Default fallback
389
+ // No known image signature matched. Return a safe, honest sentinel
390
+ // rather than mislabeling arbitrary bytes as JPEG (#261): a wrong
391
+ // image/jpeg label silently corrupts the downstream provider request.
392
+ logger.warn("Could not detect image type from magic bytes; using application/octet-stream");
393
+ return "application/octet-stream";
318
394
  }
319
395
  catch (error) {
320
396
  logger.warn("Failed to detect image type, using default:", error);
321
- return "image/jpeg";
397
+ return "application/octet-stream";
322
398
  }
323
399
  }
324
400
  /**
325
- * Validate image size (default 10MB limit)
401
+ * Throwing size guard for a raw byte length. Prevents memory exhaustion by
402
+ * rejecting an oversized input BEFORE an unbounded read/allocation happens
403
+ * (#257) — callers that can get a size cheaply (e.g. `fs.stat`) should
404
+ * validate it before ever reading the bytes into memory. Default limit is
405
+ * the canonical `SIZE_LIMITS_BYTES.IMAGE_MAX` (10 MB); there is no public
406
+ * way to raise it — `maxSize` is an internal-only override for advanced
407
+ * callers (e.g. video image inputs use a higher limit).
408
+ *
409
+ * @param size - Byte length to validate
410
+ * @param context - Short label identifying the source (unused in the
411
+ * thrown message today, kept for call-site readability and future use)
412
+ * @param maxSize - Max allowed size in bytes
413
+ * @throws NeuroLinkError (`INVALID_IMAGE_SIZE`) if `size` is not a finite,
414
+ * non-negative number
415
+ * @throws NeuroLinkError (`IMAGE_TOO_LARGE`) if `size` exceeds `maxSize`
326
416
  */
327
- static validateImageSize(data, maxSize = 10 * 1024 * 1024) {
328
- try {
329
- const size = typeof data === "string"
330
- ? Buffer.byteLength(data, "base64")
331
- : data.length;
332
- return size <= maxSize;
333
- }
334
- catch (error) {
335
- logger.warn("Failed to validate image size:", error);
336
- return false;
417
+ static validateSize(size, context, maxSize = SIZE_LIMITS_BYTES.IMAGE_MAX) {
418
+ // Reject a malformed maxSize before it's ever compared: `size > NaN` and
419
+ // `size > Infinity` (for a negative maxSize) can both evaluate `false`,
420
+ // which would let a corrupted/negative limit bypass the guard entirely.
421
+ if (!Number.isFinite(maxSize) || maxSize < 0) {
422
+ throw ErrorFactory.invalidConfiguration("maxSize", "must be a finite, non-negative byte count", { maxSize, context });
423
+ }
424
+ // Fail closed on malformed sizes first: `NaN > maxSize` and
425
+ // `-1 > maxSize` both evaluate to `false`, which would otherwise let a
426
+ // corrupted stat/header value silently skip the guard below.
427
+ if (!Number.isFinite(size) || size < 0) {
428
+ throw ErrorFactory.invalidImageSize(size);
429
+ }
430
+ if (size > maxSize) {
431
+ const mb = (size / (1024 * 1024)).toFixed(1);
432
+ const maxMb = (maxSize / (1024 * 1024)).toFixed(0);
433
+ throw ErrorFactory.imageTooLarge(mb, maxMb);
337
434
  }
338
435
  }
436
+ /**
437
+ * Throwing size guard for image buffers. Prevents memory exhaustion by
438
+ * rejecting oversized buffers with a descriptive error BEFORE an unbounded
439
+ * `Buffer.toString("base64")` allocation (#257). Delegates to
440
+ * `validateSize` so buffer-based and pre-read (stat-based) callers share
441
+ * one implementation.
442
+ *
443
+ * @param buffer - Image buffer to validate
444
+ * @param context - Short label identifying the source (see `validateSize`)
445
+ * @param maxSize - Max allowed size in bytes
446
+ * @throws NeuroLinkError (`IMAGE_TOO_LARGE`) if the buffer exceeds `maxSize`
447
+ */
448
+ static validateBufferSize(buffer, context, maxSize = SIZE_LIMITS_BYTES.IMAGE_MAX) {
449
+ ImageProcessor.validateSize(buffer.length, context, maxSize);
450
+ }
451
+ /**
452
+ * Size-guarded Buffer → base64. Use in place of `buffer.toString("base64")`
453
+ * on any path that converts a caller-supplied image buffer (#257).
454
+ */
455
+ static safeBase64Convert(buffer, context, maxSize = SIZE_LIMITS_BYTES.IMAGE_MAX) {
456
+ ImageProcessor.validateBufferSize(buffer, context, maxSize);
457
+ return buffer.toString("base64");
458
+ }
339
459
  /**
340
460
  * Validate image format
341
461
  */
@@ -349,6 +469,12 @@ export class ImageProcessor {
349
469
  "image/tiff",
350
470
  "image/svg+xml",
351
471
  "image/avif",
472
+ // Deliberately excludes "application/octet-stream": that's
473
+ // detectImageType()'s honest sentinel for bytes matching no known
474
+ // image signature (#261), not a real image format. Vision providers
475
+ // (OpenAI/Anthropic/Google) reject it outright with an HTTP 400, so
476
+ // process() must fail loud instead of packaging it as a valid image
477
+ // (see the octet-stream guard in process() below).
352
478
  ];
353
479
  return supportedFormats.includes(mediaType.toLowerCase());
354
480
  }
@@ -429,7 +555,19 @@ export class ImageProcessor {
429
555
  */
430
556
  static processImage(image, provider, model) {
431
557
  try {
558
+ // #257: reject an oversized buffer before any format-detection or
559
+ // conversion work runs on it — mirrors the guard every
560
+ // `processImageForX()` branch below already applies via
561
+ // `safeBase64Convert()`, so the typed IMAGE_TOO_LARGE error surfaces
562
+ // here too instead of being pre-empted by the octet-stream check next.
563
+ if (Buffer.isBuffer(image)) {
564
+ ImageProcessor.validateBufferSize(image, "image processing");
565
+ }
432
566
  const mediaType = ImageProcessor.detectImageType(image);
567
+ // #261/#286 follow-up: reject the octet-stream sentinel here too — the
568
+ // returned ProcessedImage.mediaType is what callers use to build a
569
+ // data URI, so this is a separate public path that must not emit it.
570
+ assertKnownImageType(mediaType);
433
571
  const size = typeof image === "string"
434
572
  ? Buffer.byteLength(image, "base64")
435
573
  : image.length;
@@ -467,7 +605,7 @@ export class ImageProcessor {
467
605
  : image;
468
606
  }
469
607
  else {
470
- data = image.toString("base64");
608
+ data = ImageProcessor.safeBase64Convert(image, "image input");
471
609
  }
472
610
  format = "base64";
473
611
  }
@@ -479,6 +617,11 @@ export class ImageProcessor {
479
617
  };
480
618
  }
481
619
  catch (error) {
620
+ // Preserve typed errors (e.g. IMAGE_TOO_LARGE) bubbling up from the
621
+ // provider-specific branches above as-is.
622
+ if (error instanceof NeuroLinkError) {
623
+ throw error;
624
+ }
482
625
  logger.error(`Failed to process image for ${provider}:`, error);
483
626
  throw new Error(`Image processing failed: ${error instanceof Error ? error.message : "Unknown error"}`, { cause: error });
484
627
  }
@@ -597,10 +740,16 @@ export const imageUtils = {
597
740
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
598
741
  },
599
742
  /**
600
- * Convert Buffer to base64 string
743
+ * Convert Buffer to base64 string. Guarded by the same #257 size limit as
744
+ * every other conversion site (default `SIZE_LIMITS_BYTES.IMAGE_MAX`);
745
+ * pass `maxBytes` to override for a caller with a legitimate need for a
746
+ * different ceiling rather than silently accepting an unbounded buffer.
747
+ * `imageUtils`/`ImageProcessor` are internal-only — not re-exported from
748
+ * `src/lib/index.ts` or the package's public `exports` map — so this
749
+ * default does not change behavior for any external SDK consumer.
601
750
  */
602
- bufferToBase64: (buffer) => {
603
- return buffer.toString("base64");
751
+ bufferToBase64: (buffer, maxBytes = SIZE_LIMITS_BYTES.IMAGE_MAX) => {
752
+ return ImageProcessor.safeBase64Convert(buffer, "buffer-to-base64", maxBytes);
604
753
  },
605
754
  /**
606
755
  * Convert base64 string to Buffer
@@ -610,6 +759,15 @@ export const imageUtils = {
610
759
  const cleanBase64 = base64.includes(",") ? base64.split(",")[1] : base64;
611
760
  return Buffer.from(cleanBase64, "base64");
612
761
  },
762
+ /**
763
+ * Redact `filePath` (both as given and its `path.resolve()`'d form) from
764
+ * an error message. Exposed directly (not just used internally by
765
+ * {@link fileToBase64DataUri}) so the resolved-path branch can be verified
766
+ * by a deterministic unit test — real `fs` errors only ever embed the
767
+ * literal path as passed, never a resolved variant, so that branch isn't
768
+ * otherwise observable through the async file-reading API.
769
+ */
770
+ redactPathFromMessage,
613
771
  /**
614
772
  * Convert file path to base64 data URI
615
773
  */
@@ -629,11 +787,38 @@ export const imageUtils = {
629
787
  // Enhanced MIME detection: try buffer content first, fallback to filename
630
788
  const mimeType = ImageProcessor.detectImageType(buffer) ||
631
789
  ImageProcessor.detectImageType(filePath);
790
+ // #261/#286 follow-up: reject the octet-stream sentinel here too —
791
+ // this is a third public path (alongside process() and
792
+ // processImage()) that can otherwise package unknown bytes into a
793
+ // `data:application/octet-stream;base64,...` URI a vision provider
794
+ // rejects outright.
795
+ assertKnownImageType(mimeType);
632
796
  const base64 = buffer.toString("base64");
633
797
  return `data:${mimeType};base64,${base64}`;
634
798
  }
635
799
  catch (error) {
636
- throw new Error(`Failed to convert file to base64: ${error instanceof Error ? error.message : "Unknown error"}`, { cause: error });
800
+ // Full path stays in the debug log; the thrown (potentially
801
+ // client-facing) error only gets the basename. The underlying fs
802
+ // error's own message is scrubbed too — Node's ENOENT/EACCES text
803
+ // embeds the full path verbatim (e.g. "no such file or directory,
804
+ // stat '/full/path'"), which would otherwise leak the host's
805
+ // directory layout right back through the "reason" suffix. The raw
806
+ // `error` is never attached as `cause` either — that would leave the
807
+ // unredacted path reachable via `error.cause.message` for anything
808
+ // that walks the cause chain (cause-aware logging, telemetry); a
809
+ // sanitized copy (via the shared `sanitizeErrorCause`) is attached
810
+ // instead, mirroring `urlToBase64DataUri` below.
811
+ logger.debug("fileToBase64DataUri failed", { filePath, error });
812
+ const safeName = basename(filePath);
813
+ const cause = sanitizeErrorCause(error, { filePath });
814
+ const reason = error instanceof Error ? cause.message : "Unknown error";
815
+ // Assigned to a variable before throwing (rather than `throw new
816
+ // Error(...)` inline) so the sanitized `cause` — a deliberately NEW,
817
+ // redacted Error — isn't mistaken by tooling for a dropped/altered
818
+ // reference to the originally caught `error`; the raw error is never
819
+ // reachable from the thrown result at all, by design.
820
+ const wrapped = new Error(`Failed to convert file to base64 (${safeName}): ${reason}`, { cause });
821
+ throw wrapped;
637
822
  }
638
823
  },
639
824
  /**
@@ -719,14 +904,31 @@ export const imageUtils = {
719
904
  maxDelay: SYSTEM_LIMITS.DEFAULT_MAX_DELAY,
720
905
  retryCondition: isRetryableDownloadError,
721
906
  onRetry: (attempt, error) => {
722
- const message = error instanceof Error ? error.message : String(error);
907
+ // Some fetch/DNS/TLS errors embed the full request URL (including
908
+ // a presigned query token) in `error.message` — redact that text
909
+ // too, not just the explicit `url` interpolation below. The
910
+ // non-Error branch is sanitized too: String(error) on a thrown
911
+ // non-Error value (e.g. a raw string or object) can just as
912
+ // easily carry the same signed URL.
913
+ const message = redactUrlsInText(error instanceof Error ? error.message : String(error));
723
914
  const attemptsLeft = maxAttempts - attempt;
724
- logger.warn(`⚠️ Image download attempt ${attempt} failed for ${url}: ${message}. ${attemptsLeft} ${attemptsLeft === 1 ? "attempt" : "attempts"} remaining...`);
915
+ logger.warn(`⚠️ Image download attempt ${attempt} failed for ${redactUrlForError(url)}: ${message}. ${attemptsLeft} ${attemptsLeft === 1 ? "attempt" : "attempts"} remaining...`);
725
916
  },
726
917
  });
727
918
  }
728
919
  catch (error) {
729
- throw new Error(`Failed to download and convert URL to base64: ${error instanceof Error ? error.message : "Unknown error"}`, { cause: error });
920
+ // Query string / fragment stripped a presigned S3/GCS URL or SAS
921
+ // token embedded there must not be echoed into a thrown error. The
922
+ // underlying error's own message is scrubbed too, since fetch/DNS/TLS
923
+ // errors often embed the full URL (query string and all) themselves.
924
+ // The raw `error` is never attached as `cause` — that would leave the
925
+ // unredacted URL reachable via `error.cause.message` for anything
926
+ // that walks the cause chain (cause-aware logging, telemetry); a
927
+ // sanitized copy is attached instead.
928
+ const sourceUrl = redactUrlForError(url);
929
+ const cause = sanitizeErrorCause(error);
930
+ const wrapped = new Error(`Failed to download and convert URL to base64 (${sourceUrl}): ${cause.message}`, { cause });
931
+ throw wrapped;
730
932
  }
731
933
  },
732
934
  /**
@@ -61,11 +61,110 @@ export declare function safeDebugSerialize(value: unknown, maxLen?: number): str
61
61
  * for diagnostics. Use this for any `baseURL`/endpoint a caller may have
62
62
  * supplied with inline credentials. The match is global, so every `//…@`
63
63
  * authority in the string is redacted (e.g. a proxy chain or a URL embedded
64
- * in a query parameter), not just the first.
64
+ * in a query parameter), not just the first. IPv6 literal hosts (`//u:p@[::1]/x`)
65
+ * and percent-encoded userinfo (`//u:p%40x@host/x`) are handled naturally —
66
+ * neither `[`/`]` nor `%` stop either pass below, so the bracketed host or
67
+ * encoded byte just rides along as part of the (non-redacted) remainder.
68
+ *
69
+ * This is best-effort defense-in-depth for free-form logging, deliberately
70
+ * regex-based so it can scrub *multiple* embedded authorities out of
71
+ * arbitrary text (a real URL parser only ever parses one URL at a time).
72
+ * It is NOT the primary protection for user-facing errors — that's
73
+ * {@link redactUrlForError}, which parses the single input via `new URL()`
74
+ * and reconstructs from protocol+host+pathname, never touching userinfo at
75
+ * all. Prefer that function whenever the input is known to be exactly one
76
+ * URL.
77
+ *
78
+ * Two passes, deliberately:
79
+ * 1. Well-formed authorities — credentials with no raw `/` — bounded at the
80
+ * authority's first `/` (or string end). Greedy backtracking naturally
81
+ * absorbs multiple `@` segments within one authority (e.g. `//a@b@host`
82
+ * → `//***@host`) while leaving a second, separate `//...@` authority
83
+ * elsewhere in the string (e.g. one embedded in a query parameter)
84
+ * untouched, because pass 1's own exclusion of `/` stops it at the first
85
+ * `/`, before it can ever reach a nested authority.
86
+ * 2. Malformed authorities whose credentials contain an unescaped `/`
87
+ * (e.g. `//user:sec/ret@host/path`) — pass 1 can't find an `@` before
88
+ * hitting that `/`, so this pass allows `/` and redacts up to the last
89
+ * remaining `@`. It stops before any *nested* `//` (via a negative
90
+ * lookahead) rather than excluding a specific character like `*` —
91
+ * excluding a literal character is what let a password containing both
92
+ * `/` and `*` (e.g. `//user:pa*ss/word@host/path`, legal under RFC 3986)
93
+ * slip through both passes entirely in an earlier version of this
94
+ * function.
65
95
  *
66
96
  * @param url - The URL (or URL-shaped string) to redact.
67
97
  */
68
98
  export declare function redactUrlCredentials(url: string): string;
99
+ /**
100
+ * Reduce a URL to its origin + pathname for safe inclusion in error messages
101
+ * and logs, dropping the query string and fragment — where presigned-URL
102
+ * signatures, SAS tokens, and other secrets commonly live — while keeping
103
+ * enough of the URL (scheme, host, path) for the error to stay diagnostic.
104
+ *
105
+ * Falls back to a best-effort redacted string (query/fragment and
106
+ * `user:pass@` stripped) when the input isn't a parseable absolute URL.
107
+ * Either way the result is capped at 200 chars so a very long host/path
108
+ * can't blow up the error message.
109
+ *
110
+ * @param url - The URL (or URL-shaped string) to redact.
111
+ */
112
+ export declare function redactUrlForError(url: string): string;
113
+ /**
114
+ * Scrub any embedded absolute URLs out of free-form text — typically the
115
+ * `.message` of an underlying network/DNS/TLS error thrown by `fetch`,
116
+ * undici's `request()`, or Node's resolver, all of which frequently embed
117
+ * the full request URL (query string, credentials, and all) verbatim.
118
+ * Interpolating that message raw into a thrown or logged error would leak a
119
+ * presigned URL's token even after the explicit URL argument was already
120
+ * redacted via {@link redactUrlForError}. Finds each `scheme://…` run in the
121
+ * text and replaces it with its redacted form.
122
+ *
123
+ * @param text - Free-form text (typically `Error.message`) to scrub.
124
+ */
125
+ export declare function redactUrlsInText(text: string): string;
126
+ /**
127
+ * Redact every occurrence of `filePath` from an error message, checking both
128
+ * the path exactly as given and its `path.resolve()`'d form.
129
+ *
130
+ * Node's own fs errors (ENOENT/EACCES) embed the exact string passed to the
131
+ * fs call, so a plain substring match usually suffices — but wrapped or
132
+ * lower-level errors sometimes normalize the path first (relative→absolute,
133
+ * trailing-slash variations), which an exact-match-only redaction would miss.
134
+ * This is defense-in-depth, not a guarantee: a `realpath`-resolved (symlink-
135
+ * following) variant can still differ from both forms and would slip through.
136
+ *
137
+ * @param message - The error message to scrub.
138
+ * @param filePath - The path to redact (both as given and resolved).
139
+ */
140
+ export declare function redactPathFromMessage(message: string, filePath: string): string;
141
+ /**
142
+ * Build a sanitized copy of a network/URL/file error, safe to attach as
143
+ * `Error.cause` on a rethrow.
144
+ *
145
+ * Redacting only the OUTER thrown error's message isn't enough: if the RAW
146
+ * underlying error is attached as `cause`, anything that walks the cause
147
+ * chain (cause-aware logging, telemetry spans) can still recover an
148
+ * unredacted URL/path/secret via `error.cause.message`. This returns a NEW
149
+ * `Error` whose message has been through {@link redactUrlsInText} — and,
150
+ * when `options.filePath` is supplied, {@link redactPathFromMessage} too, so
151
+ * a filesystem error's ENOENT/EACCES text doesn't leak the host's directory
152
+ * layout via the cause chain the way the outer message already avoids.
153
+ * `.name` and `.code` (`NodeJS.ErrnoException`) are copied over so retry
154
+ * classification (e.g. `isRetryableNetworkError`) still works when it
155
+ * inspects the cause. The raw original is never referenced by the result.
156
+ *
157
+ * Handles non-`Error` thrown values too (a raw string/object can just as
158
+ * easily carry an unredacted URL or path) by running their `String()` form
159
+ * through the same redaction before wrapping.
160
+ *
161
+ * @param error - The raw underlying error (or thrown value) to sanitize.
162
+ * @param options - `filePath`: a known filesystem path to redact to its
163
+ * basename, in addition to URL redaction.
164
+ */
165
+ export declare function sanitizeErrorCause(error: unknown, options?: {
166
+ filePath?: string;
167
+ }): Error;
69
168
  /**
70
169
  * Recursively sanitize a record/array, returning a structurally identical
71
170
  * value with sensitive keys redacted and string values run through