@juspay/neurolink 9.94.4 → 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.
@@ -4,7 +4,7 @@
4
4
  * Uses multi-strategy approach for reliable type identification
5
5
  */
6
6
  import { open, readFile, realpath } from "fs/promises";
7
- import { isAbsolute as isAbsolutePath, relative as relativePath, resolve as resolvePath, sep, } from "path";
7
+ import { basename, isAbsolute as isAbsolutePath, relative as relativePath, resolve as resolvePath, sep, } from "path";
8
8
  import { getGlobalDispatcher, interceptors, request } from "undici";
9
9
  // Lazy-loaded processor singletons — avoids loading heavy media deps
10
10
  // (mediabunny, fluent-ffmpeg, music-metadata, adm-zip) on every generate() call.
@@ -24,6 +24,7 @@ import { tracers, ATTR, withSpan } from "../telemetry/index.js";
24
24
  import { CSVProcessor } from "./csvProcessor.js";
25
25
  import { ImageProcessor } from "./imageProcessor.js";
26
26
  import { logger } from "./logger.js";
27
+ import { redactUrlForError, sanitizeErrorCause } from "./logSanitize.js";
27
28
  import { mimeHintToExtension, mimeHintToFileType, normalizeMimeHint, } from "./mimeTypeHints.js";
28
29
  import { PDFProcessor } from "./pdfProcessor.js";
29
30
  /**
@@ -1338,25 +1339,55 @@ export class FileDetector {
1338
1339
  const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES;
1339
1340
  const retryDelay = options?.retryDelay ?? DEFAULT_RETRY_DELAY;
1340
1341
  return withRetry(async () => {
1341
- const response = await request(url, {
1342
- dispatcher: getGlobalDispatcher().compose(interceptors.redirect({ maxRedirections: 5 })),
1343
- method: "GET",
1344
- headersTimeout: timeout,
1345
- bodyTimeout: timeout,
1346
- });
1347
- if (response.statusCode !== 200) {
1348
- throw new Error(`HTTP ${response.statusCode} fetching ${url}`);
1349
- }
1350
- const chunks = [];
1351
- let totalSize = 0;
1352
- for await (const chunk of response.body) {
1353
- totalSize += chunk.length;
1354
- if (totalSize > maxSize) {
1355
- throw new Error(`File too large: ${formatFileSize(totalSize)} (max: ${formatFileSize(maxSize)})`);
1342
+ try {
1343
+ const response = await request(url, {
1344
+ dispatcher: getGlobalDispatcher().compose(interceptors.redirect({ maxRedirections: 5 })),
1345
+ method: "GET",
1346
+ headersTimeout: timeout,
1347
+ bodyTimeout: timeout,
1348
+ });
1349
+ if (response.statusCode !== 200) {
1350
+ // Query string / fragment stripped — a presigned URL's token must
1351
+ // not be echoed into a thrown error.
1352
+ throw new Error(`HTTP ${response.statusCode} fetching ${redactUrlForError(url)}`);
1356
1353
  }
1357
- chunks.push(chunk);
1354
+ const chunks = [];
1355
+ let totalSize = 0;
1356
+ for await (const chunk of response.body) {
1357
+ totalSize += chunk.length;
1358
+ if (totalSize > maxSize) {
1359
+ throw new Error(`File too large: ${formatFileSize(totalSize)} (max: ${formatFileSize(maxSize)})`);
1360
+ }
1361
+ chunks.push(chunk);
1362
+ }
1363
+ return Buffer.concat(chunks);
1364
+ }
1365
+ catch (error) {
1366
+ // Node/undici DNS, TLS, and connect-timeout errors embed the full
1367
+ // request URL (including a presigned query token) in
1368
+ // `error.message`. Redact into a NEW error instead of mutating the
1369
+ // original in place, so anything that still holds a reference to
1370
+ // the original — debug logs, telemetry spans, upstream callers —
1371
+ // keeps seeing the real message. `.code` is copied onto the new
1372
+ // error so `isRetryableNetworkError`'s retry check in the outer
1373
+ // `withRetry` catch still classifies it correctly. The raw
1374
+ // original error is NEVER attached as `cause` — that would leave
1375
+ // the unredacted URL reachable via `error.cause.message` for
1376
+ // anything that walks the cause chain (cause-aware logging,
1377
+ // telemetry). `cause` instead gets its own sanitized copy.
1378
+ // `sanitizeErrorCause` handles non-`Error` thrown values too (a raw
1379
+ // string/object can just as easily carry the full URL), so there is
1380
+ // no unconditional `throw error` fallback that would bypass
1381
+ // redaction for that case.
1382
+ const cause = sanitizeErrorCause(error);
1383
+ const redacted = new Error(cause.message, { cause });
1384
+ redacted.name = cause.name;
1385
+ const code = cause.code;
1386
+ if (code !== undefined) {
1387
+ redacted.code = code;
1388
+ }
1389
+ throw redacted;
1358
1390
  }
1359
- return Buffer.concat(chunks);
1360
1391
  }, { maxRetries, retryDelay });
1361
1392
  }
1362
1393
  /**
@@ -1374,6 +1405,13 @@ export class FileDetector {
1374
1405
  // the base dir pointing outside cannot bypass containment. The
1375
1406
  // path.relative check (not a string prefix) correctly handles the root dir
1376
1407
  // ("/") and sibling-prefix ("/app" vs "/app-evil") edge cases.
1408
+ //
1409
+ // The actual open() below MUST target this validated `real` path, not the
1410
+ // original `filePath` — otherwise a symlink swapped between the realpath()
1411
+ // check and the open() call routes the read outside the sandbox even
1412
+ // though validation passed (TOCTOU). With no sandbox configured there's no
1413
+ // boundary to defend, so the original path is used as given.
1414
+ let pathToOpen = filePath;
1377
1415
  if (options?.allowedBaseDir) {
1378
1416
  let base;
1379
1417
  let real;
@@ -1381,24 +1419,66 @@ export class FileDetector {
1381
1419
  base = await realpath(resolvePath(options.allowedBaseDir));
1382
1420
  real = await realpath(filePath);
1383
1421
  }
1384
- catch {
1385
- throw new Error(`Access denied: "${filePath}" could not be resolved within the allowed base directory`);
1422
+ catch (error) {
1423
+ // Full path stays in the debug log; the thrown (potentially
1424
+ // client-facing) error only gets the basename to avoid leaking the
1425
+ // host's directory layout to an untrusted caller. The cause is
1426
+ // sanitized too — Node's realpath ENOENT/EACCES messages embed the
1427
+ // full path verbatim, which would otherwise survive on the cause
1428
+ // chain (cause-aware logging, telemetry) even though the outer
1429
+ // message is already redacted.
1430
+ logger.debug("loadFromPath: realpath resolution failed", {
1431
+ filePath,
1432
+ error,
1433
+ });
1434
+ // Assigned to a variable before the throw (rather than an inline
1435
+ // `{ cause: sanitizeErrorCause(...) }`) so the sanitized, path-redacted
1436
+ // copy is unambiguously the attached cause — the raw `error`, whose
1437
+ // message still embeds the full path, is never reachable from the
1438
+ // thrown result.
1439
+ const cause = sanitizeErrorCause(error, { filePath });
1440
+ const denied = new Error(`Access denied: "${basename(filePath)}" could not be resolved within the allowed base directory`, { cause });
1441
+ throw denied;
1386
1442
  }
1387
1443
  const rel = relativePath(base, real);
1388
1444
  if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolutePath(rel)) {
1389
- throw new Error(`Access denied: "${filePath}" resolves outside the allowed base directory`);
1445
+ logger.debug("loadFromPath: path resolves outside allowed base dir", {
1446
+ filePath,
1447
+ real,
1448
+ });
1449
+ throw new Error(`Access denied: "${basename(filePath)}" resolves outside the allowed base directory`);
1390
1450
  }
1451
+ pathToOpen = real;
1391
1452
  }
1392
1453
  // Open a handle and stat/read through the SAME descriptor so a symlink
1393
1454
  // swap between the size check and the read cannot occur (TOCTOU).
1394
- const handle = await open(filePath, "r");
1455
+ let handle;
1456
+ try {
1457
+ handle = await open(pathToOpen, "r");
1458
+ }
1459
+ catch (error) {
1460
+ // A failed open (ENOENT/EACCES/…) embeds the opened path verbatim in
1461
+ // its message. When a sandbox is configured `pathToOpen` is the
1462
+ // realpath-resolved target (`real`), which differs from both `filePath`
1463
+ // and its resolved form — so redact `pathToOpen` specifically, or the
1464
+ // full host path would survive on both the thrown message and the cause
1465
+ // chain despite this PR's path-redaction hardening.
1466
+ const cause = sanitizeErrorCause(error, { filePath: pathToOpen });
1467
+ const failed = new Error(cause.message, { cause });
1468
+ failed.name = cause.name;
1469
+ const code = cause.code;
1470
+ if (code !== undefined) {
1471
+ failed.code = code;
1472
+ }
1473
+ throw failed;
1474
+ }
1395
1475
  try {
1396
1476
  const statInfo = await handle.stat();
1397
1477
  if (!statInfo.isFile()) {
1398
- throw new Error(`Not a file: ${filePath}`);
1478
+ throw new Error(`Not a file: ${basename(filePath)}`);
1399
1479
  }
1400
1480
  if (statInfo.size > maxSize) {
1401
- throw new Error(`File too large: ${filePath} is ${formatFileSize(statInfo.size)} (max: ${formatFileSize(maxSize)})`);
1481
+ throw new Error(`File too large: ${basename(filePath)} is ${formatFileSize(statInfo.size)} (max: ${formatFileSize(maxSize)})`);
1402
1482
  }
1403
1483
  return await handle.readFile();
1404
1484
  }
@@ -1450,6 +1530,15 @@ class MagicBytesStrategy {
1450
1530
  input[6] === 0x79 &&
1451
1531
  input[7] === 0x70) {
1452
1532
  const brand = input.length >= 12 ? input.toString("latin1", 8, 12) : "";
1533
+ // AVIF images share the ISO-BMFF ftyp box with MP4/MOV; the major brand
1534
+ // ('avif' still, 'avis' sequence, 'avio' intra-only AV1 image/sequence
1535
+ // — spec-listed under compatible_brands but also emitted as
1536
+ // major_brand by real encoders) distinguishes them. Detect before the
1537
+ // audio/video branches so an AVIF buffer isn't misrouted to the video
1538
+ // pipeline (#286).
1539
+ if (/^(avif|avis|avio)/.test(brand)) {
1540
+ return this.result("image", "image/avif", 95);
1541
+ }
1453
1542
  if (/^(M4A|M4B|M4P|F4A|F4B)/.test(brand)) {
1454
1543
  return this.result("audio", "audio/mp4", 95);
1455
1544
  }
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { createHash } from "crypto";
16
16
  import { logger } from "./logger.js";
17
+ import { redactUrlForError } from "./logSanitize.js";
17
18
  /**
18
19
  * LRU Cache for downloaded images
19
20
  *
@@ -142,7 +143,9 @@ export class ImageCache {
142
143
  const entry = this.cache.get(normalizedUrl);
143
144
  if (!entry) {
144
145
  this.stats.misses++;
145
- logger.debug("Image cache miss", { url: normalizedUrl.substring(0, 50) });
146
+ logger.debug("Image cache miss", {
147
+ url: redactUrlForError(normalizedUrl),
148
+ });
146
149
  return null;
147
150
  }
148
151
  // Check TTL expiration
@@ -150,7 +153,7 @@ export class ImageCache {
150
153
  this.stats.expirations++;
151
154
  this.delete(normalizedUrl);
152
155
  logger.debug("Image cache entry expired", {
153
- url: normalizedUrl.substring(0, 50),
156
+ url: redactUrlForError(normalizedUrl),
154
157
  });
155
158
  return null;
156
159
  }
@@ -162,7 +165,7 @@ export class ImageCache {
162
165
  this.cache.set(normalizedUrl, entry);
163
166
  this.stats.hits++;
164
167
  logger.debug("Image cache hit", {
165
- url: normalizedUrl.substring(0, 50),
168
+ url: redactUrlForError(normalizedUrl),
166
169
  accessCount: entry.accessCount,
167
170
  });
168
171
  return entry;
@@ -192,7 +195,7 @@ export class ImageCache {
192
195
  // Skip caching if image exceeds max size
193
196
  if (size > this.maxImageSize) {
194
197
  logger.debug("Image too large to cache", {
195
- url: normalizedUrl.substring(0, 50),
198
+ url: redactUrlForError(normalizedUrl),
196
199
  size,
197
200
  maxSize: this.maxImageSize,
198
201
  });
@@ -211,8 +214,8 @@ export class ImageCache {
211
214
  // Update content hash index to point to the new URL as well
212
215
  this.contentHashIndex.set(contentHash, normalizedUrl);
213
216
  logger.debug("Image cache dedup hit", {
214
- newUrl: normalizedUrl.substring(0, 50),
215
- existingUrl: existingUrl.substring(0, 50),
217
+ newUrl: redactUrlForError(normalizedUrl),
218
+ existingUrl: redactUrlForError(existingUrl),
216
219
  });
217
220
  return;
218
221
  }
@@ -234,7 +237,7 @@ export class ImageCache {
234
237
  this.cache.set(normalizedUrl, entry);
235
238
  this.contentHashIndex.set(contentHash, normalizedUrl);
236
239
  logger.debug("Image cached", {
237
- url: normalizedUrl.substring(0, 50),
240
+ url: redactUrlForError(normalizedUrl),
238
241
  size,
239
242
  contentHash: contentHash.substring(0, 8),
240
243
  cacheSize: this.cache.size,
@@ -272,7 +275,7 @@ export class ImageCache {
272
275
  this.cache.delete(oldestKey);
273
276
  this.stats.evictions++;
274
277
  logger.debug("Image cache eviction", {
275
- url: String(oldestKey).substring(0, 50),
278
+ url: redactUrlForError(String(oldestKey)),
276
279
  });
277
280
  }
278
281
  }
@@ -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
@@ -171,6 +172,15 @@ export declare const imageUtils: {
171
172
  * Convert base64 string to Buffer
172
173
  */
173
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;
174
184
  /**
175
185
  * Convert file path to base64 data URI
176
186
  */
@@ -2,7 +2,9 @@
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";
@@ -58,6 +60,25 @@ function isRetryableDownloadError(error) {
58
60
  }
59
61
  return false;
60
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
+ }
61
82
  /**
62
83
  * Image processor class for handling provider-specific image formatting
63
84
  */
@@ -77,6 +98,9 @@ export class ImageProcessor {
77
98
  throw new Error("Invalid image processing: buffer is empty");
78
99
  }
79
100
  const mediaType = this.detectImageType(content);
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);
80
104
  const base64 = ImageProcessor.safeBase64Convert(content, "image processing");
81
105
  const dataUri = `data:${mediaType};base64,${base64}`;
82
106
  // Validate output before returning
@@ -327,20 +351,50 @@ export class ImageProcessor {
327
351
  return "image/svg+xml";
328
352
  }
329
353
  }
330
- // 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).
331
362
  if (input.length >= 12) {
332
- const ftyp = input.subarray(4, 8).toString();
333
- const brand = input.subarray(8, 12).toString();
334
- 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")) {
335
370
  return "image/avif";
336
371
  }
337
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
+ }
338
388
  }
339
- 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";
340
394
  }
341
395
  catch (error) {
342
396
  logger.warn("Failed to detect image type, using default:", error);
343
- return "image/jpeg";
397
+ return "application/octet-stream";
344
398
  }
345
399
  }
346
400
  /**
@@ -415,6 +469,12 @@ export class ImageProcessor {
415
469
  "image/tiff",
416
470
  "image/svg+xml",
417
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).
418
478
  ];
419
479
  return supportedFormats.includes(mediaType.toLowerCase());
420
480
  }
@@ -495,7 +555,19 @@ export class ImageProcessor {
495
555
  */
496
556
  static processImage(image, provider, model) {
497
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
+ }
498
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);
499
571
  const size = typeof image === "string"
500
572
  ? Buffer.byteLength(image, "base64")
501
573
  : image.length;
@@ -687,6 +759,15 @@ export const imageUtils = {
687
759
  const cleanBase64 = base64.includes(",") ? base64.split(",")[1] : base64;
688
760
  return Buffer.from(cleanBase64, "base64");
689
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,
690
771
  /**
691
772
  * Convert file path to base64 data URI
692
773
  */
@@ -706,11 +787,38 @@ export const imageUtils = {
706
787
  // Enhanced MIME detection: try buffer content first, fallback to filename
707
788
  const mimeType = ImageProcessor.detectImageType(buffer) ||
708
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);
709
796
  const base64 = buffer.toString("base64");
710
797
  return `data:${mimeType};base64,${base64}`;
711
798
  }
712
799
  catch (error) {
713
- 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;
714
822
  }
715
823
  },
716
824
  /**
@@ -796,14 +904,31 @@ export const imageUtils = {
796
904
  maxDelay: SYSTEM_LIMITS.DEFAULT_MAX_DELAY,
797
905
  retryCondition: isRetryableDownloadError,
798
906
  onRetry: (attempt, error) => {
799
- 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));
800
914
  const attemptsLeft = maxAttempts - attempt;
801
- 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...`);
802
916
  },
803
917
  });
804
918
  }
805
919
  catch (error) {
806
- 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;
807
932
  }
808
933
  },
809
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