agent-sanitizer 2.48.5 → 2.49.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/html.mjs +62 -20
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.48.5",
3
+ "version": "2.49.0",
4
4
  "description": "Defend an agent against hidden-content injection: strip payload-capable invisible Unicode and ANSI, splice out human-invisible HTML, and flag data-exfil URLs in untrusted text before any model sees it.",
5
5
  "type": "module",
6
6
  "repository": {
package/src/html.mjs CHANGED
@@ -2377,7 +2377,7 @@ const RELATIVE_URL_BASE = "http://relative.invalid";
2377
2377
  // Parameter NAMES that legitimately carry a LONG opaque (base64/hex) value, so
2378
2378
  // a blob in one of them is NOT exfil: CDN request-signing (AWS SigV4 /
2379
2379
  // CloudFront `X-Amz-*`/`Signature`/`Policy`/`Key-Pair-Id`, GCS `X-Goog-*`,
2380
- // Azure SAS `sig` only), pagination cursors /
2380
+ // Azure SAS `sig` only, Cloudflare `verify`), pagination cursors /
2381
2381
  // continuation tokens, and the long analytics click-IDs. Matched
2382
2382
  // case-insensitively against the exact (lowercased) parameter name. Scope is
2383
2383
  // deliberately limited to names whose benign value is genuinely a long token —
@@ -2392,7 +2392,7 @@ const RELATIVE_URL_BASE = "http://relative.invalid";
2392
2392
  // code, a signed CSRF nonce), so the value shape cannot separate them from a
2393
2393
  // payload.
2394
2394
  const BENIGN_BLOB_PARAM_RE =
2395
- /^(?:x-(?:amz|goog|ms|oss|obs)-[a-z0-9-]+|amz-[a-z0-9-]+|utm_[a-z]+|sig|signature|hmac|q-signature|policy|credential|code|state|cursor|after|before|continuation|continuationtoken|continuation_token|pagetoken|page_token|nexttoken|next_token|gclid|fbclid|dclid|gbraid|wbraid|msclkid)$/i;
2395
+ /^(?:x-(?:amz|goog|ms|oss|obs)-[a-z0-9-]+|amz-[a-z0-9-]+|utm_[a-z]+|sig|signature|hmac|verify|q-signature|policy|credential|code|state|cursor|after|before|continuation|continuationtoken|continuation_token|pagetoken|page_token|nexttoken|next_token|gclid|fbclid|dclid|gbraid|wbraid|msclkid)$/i;
2396
2396
 
2397
2397
  // The SHORT-valued companions of a signed-CDN link. Azure SAS spells one long
2398
2398
  // `sig` beside a crowd of short fields, and the whole taxonomy is listed here:
@@ -2466,11 +2466,25 @@ const OPAQUE_TOKEN_RE = /[A-Za-z0-9_]{20,}/g;
2466
2466
  const VALUE_HAS_DIGIT_RE = /\d/;
2467
2467
 
2468
2468
  // A value that is ENTIRELY a long base64 (40+ chars, optional `=` padding) or
2469
- // hex (32+ chars) run. Anchored to the whole value (operating on the RAW,
2470
- // un-decoded query so a `+` in base64 is not turned into a space), so a benign
2471
- // short value with an incidental hex word never trips it. Both arms are linear.
2469
+ // hex run. Anchored to the whole value (operating on the RAW, un-decoded query
2470
+ // so a `+` in base64 is not turned into a space), so a benign short value with
2471
+ // an incidental hex word never trips it. Both arms are linear.
2472
+ //
2473
+ // A value that is EXACTLY one digest — all hex, at an MD5/SHA length — is a
2474
+ // FINGERPRINT rather than a payload, and `isBlobValue` below exempts it. A
2475
+ // cache-buster (`?v=<md5>`), an ETag, a request id, a git commit and a
2476
+ // signed-CDN token (imgix `?s=`, KeyCDN `?secure=`) are all a bare digest under
2477
+ // a generic name, and every one of them was reported before: hex is a subset of
2478
+ // base64's alphabet, so the 40-char base64 arm caught them on charset alone.
2472
2479
  const BLOB_VALUE_B64_RE = /^[A-Za-z0-9+/]{40,}={0,2}$/;
2473
- const BLOB_VALUE_HEX_RE = /^[A-Fa-f0-9]{32,}$/;
2480
+ const HEX_ONLY_RE = /^[A-Fa-f0-9]+$/;
2481
+ // 16 bytes, the floor the hex arm has always carried.
2482
+ const HEX_BLOB_MIN_LEN = 32;
2483
+ // MD5, SHA-1, SHA-224, SHA-256, SHA-384, SHA-512 in hex. Exact lengths, not a
2484
+ // range: bulk data is any length, so only the digest sizes themselves are
2485
+ // ambiguous, and an attacker who pads a payload to exactly 64 hex characters
2486
+ // buys 32 bytes per parameter. A value one character either side is a payload.
2487
+ const DIGEST_HEX_LENGTHS = new Set([32, 40, 56, 64, 96, 128]);
2474
2488
 
2475
2489
  // RFC 4648 §5 url-safe base64 substitutes `-`/`_` for `+`/`/`, so a payload
2476
2490
  // encoded url-safe escapes the `[A-Za-z0-9+/]` arms above. Adding `-`/`_` to the
@@ -2497,7 +2511,10 @@ const B64URL_MIXED_RE = /(?=.*[A-Z])(?=.*[0-9])/;
2497
2511
  // for a payload; the url-safe arm re-admits `-`/`_` but, like the query arm
2498
2512
  // above, gates on a contiguous 40+ alphanumeric run to keep the slug benign.
2499
2513
  const PATH_BLOB_RE = /^(?:[A-Za-z0-9+/]+={0,2}|[A-Fa-f0-9]+)$/;
2500
- const PATH_BLOB_MIN_LEN = 128;
2514
+ // SHA-512 is 128 hex characters and no standard digest is longer, so a run past
2515
+ // this is bulk encoded data rather than any fingerprint. Shared by the two path
2516
+ // walks so the segment gate and the chunked-residue gate cannot drift.
2517
+ const DIGEST_MAX_LEN = 128;
2501
2518
 
2502
2519
  /**
2503
2520
  * True for an entirely-url-safe-base64 value (≥40 chars) whose character mix —
@@ -2552,19 +2569,33 @@ function isChunkedPathBlob(segment) {
2552
2569
  const joined = chunkedBlobResidue(segment);
2553
2570
  return (
2554
2571
  joined !== null &&
2555
- joined.length > PATH_BLOB_MIN_LEN &&
2572
+ joined.length > DIGEST_MAX_LEN &&
2556
2573
  ((PATH_BLOB_RE.test(joined) && B64URL_MIXED_RE.test(joined)) ||
2557
2574
  isBase64UrlBlob(joined))
2558
2575
  );
2559
2576
  }
2560
2577
 
2561
- /** @param {string} value @returns {boolean} */
2562
- function isBlobValue(value) {
2563
- return (
2564
- BLOB_VALUE_B64_RE.test(value) ||
2565
- BLOB_VALUE_HEX_RE.test(value) ||
2566
- isBase64UrlBlob(value)
2567
- );
2578
+ /**
2579
+ * Bulk-encoded bytes, with the digest exemption applied. `digestIsBenign` says
2580
+ * whether an exact-digest-length hex value counts as a fingerprint here: true
2581
+ * under a generic parameter name, where a cache-buster or an ETag is the
2582
+ * overwhelmingly likelier reading, and false under a name that already says
2583
+ * credential, where the same 64 characters are as likely 32 bytes of hex-encoded
2584
+ * cookie. The name is what separates them — the value shape cannot.
2585
+ * @param {string} value
2586
+ * @param {boolean} digestIsBenign
2587
+ * @returns {boolean}
2588
+ */
2589
+ function isBlobValue(value, digestIsBenign) {
2590
+ // An all-hex value keeps its own floor, which sits below base64's: hex costs
2591
+ // two characters per byte, so 32 of them is already 16 bytes of payload. Only
2592
+ // the digest lengths are exempt, never a range around them.
2593
+ if (HEX_ONLY_RE.test(value))
2594
+ return (
2595
+ value.length >= HEX_BLOB_MIN_LEN &&
2596
+ !(digestIsBenign && DIGEST_HEX_LENGTHS.has(value.length))
2597
+ );
2598
+ return BLOB_VALUE_B64_RE.test(value) || isBase64UrlBlob(value);
2568
2599
  }
2569
2600
 
2570
2601
  /**
@@ -2579,16 +2610,17 @@ function isBlobValue(value) {
2579
2610
  * percent-sequence throws in `decodeURIComponent`; that failure is not a
2580
2611
  * blob shape either way, so it fails open (skip the decoded check).
2581
2612
  * @param {string} value
2613
+ * @param {boolean} digestIsBenign
2582
2614
  * @returns {boolean}
2583
2615
  */
2584
- function decodedBlobMatch(value) {
2616
+ function decodedBlobMatch(value, digestIsBenign) {
2585
2617
  let decoded;
2586
2618
  try {
2587
2619
  decoded = decodeURIComponent(value);
2588
2620
  } catch {
2589
2621
  return false;
2590
2622
  }
2591
- return isBlobValue(decoded);
2623
+ return isBlobValue(decoded, digestIsBenign);
2592
2624
  }
2593
2625
 
2594
2626
  // A credential rarely travels alone in a parameter: a signed session cookie is
@@ -2616,7 +2648,7 @@ function containsBlobRun(value) {
2616
2648
  }
2617
2649
  for (const form of decoded === value ? [value] : [value, decoded])
2618
2650
  for (const part of form.split(BLOB_RUN_SPLIT_RE))
2619
- if (part !== form && isBlobValue(part)) return true;
2651
+ if (part !== form && isBlobValue(part, false)) return true;
2620
2652
  return false;
2621
2653
  }
2622
2654
 
@@ -2659,6 +2691,13 @@ function paramExfilReason(name, value, rawName) {
2659
2691
  if (BENIGN_BLOB_PARAM_RE.test(name)) return null;
2660
2692
  const publicKeyId =
2661
2693
  PUBLIC_KEY_ID_PARAM_RE.test(name) && value.length < BLOB_VALUE_MIN_LEN;
2694
+ // Every fingerprint the digest exemption exists for sits under a GENERIC name
2695
+ // — a cache-buster `?v=`, an ETag, imgix's `?s=`, a commit id. Under a name
2696
+ // that already says credential the same 64 hex characters read as 32 bytes of
2697
+ // payload, so the exemption stops there.
2698
+ const digestIsBenign = !(
2699
+ KEYWORD_PARAM_NAME_RE.test(name) || matchesSecretHint(name)
2700
+ );
2662
2701
  for (const candidate of [rawName, value]) {
2663
2702
  if (!candidate) continue;
2664
2703
  // A leaked credential is an OPAQUE, separator-free token. Gate the
@@ -2674,7 +2713,10 @@ function paramExfilReason(name, value, rawName) {
2674
2713
  )
2675
2714
  )
2676
2715
  return "credential-shaped token in URL parameter";
2677
- if (isBlobValue(candidate) || decodedBlobMatch(candidate))
2716
+ if (
2717
+ isBlobValue(candidate, digestIsBenign) ||
2718
+ decodedBlobMatch(candidate, digestIsBenign)
2719
+ )
2678
2720
  return "suspicious query parameter";
2679
2721
  }
2680
2722
  // A wrapped credential (`?session=s%3A<token>.<mac>`) in a parameter whose
@@ -2774,7 +2816,7 @@ function checkUrlParams(parsed) {
2774
2816
  function checkUrlPath(parsed) {
2775
2817
  for (const segment of parsed.pathname.split("/")) {
2776
2818
  if (
2777
- (segment.length > PATH_BLOB_MIN_LEN &&
2819
+ (segment.length > DIGEST_MAX_LEN &&
2778
2820
  (PATH_BLOB_RE.test(segment) || isBase64UrlBlob(segment))) ||
2779
2821
  isChunkedPathBlob(segment)
2780
2822
  )