agent-sanitizer 2.43.11 → 2.43.12

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 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.43.11",
3
+ "version": "2.43.12",
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
@@ -2382,7 +2382,16 @@ const RELATIVE_URL_BASE = "http://relative.invalid";
2382
2382
  // code, a signed CSRF nonce), so the value shape cannot separate them from a
2383
2383
  // payload.
2384
2384
  const BENIGN_BLOB_PARAM_RE =
2385
- /^(?:x-(?:amz|goog|ms|oss|obs)-[a-z0-9-]+|amz-[a-z0-9-]+|utm_[a-z]+|sig|signature|hmac|policy|credential|expires|key-pair-id|se|sp|sr|sv|st|spr|si|skoid|sktid|code|state|cursor|after|before|continuation|continuationtoken|continuation_token|pagetoken|page_token|nexttoken|next_token|gclid|fbclid|dclid|msclkid|gbraid|wbraid|_ga|_gl|mc_eid|mc_cid)$/i;
2385
+ /^(?:x-(?:amz|goog|ms|oss|obs)-[a-z0-9-]+|amz-[a-z0-9-]+|utm_[a-z]+|sig|signature|hmac|policy|credential|expires|key-pair-id|skoid|sktid|code|state|cursor|after|before|continuation|continuationtoken|continuation_token|pagetoken|page_token|nexttoken|next_token|gclid|fbclid|dclid|msclkid|gbraid|wbraid|_ga|_gl|mc_eid|mc_cid)$/i;
2386
+
2387
+ // The SHORT-valued companions of a signed-CDN link: Azure SAS carries a version
2388
+ // date (`sv`), a resource letter (`sr`), start/expiry timestamps (`st`/`se`), a
2389
+ // permissions letter (`sp`), a protocol (`spr`) and a policy id (`si`) beside
2390
+ // its one long `sig`. They mark a query as signed-CDN traffic, which is all
2391
+ // `allParamsBenign` needs — but a name whose benign value is short must never
2392
+ // excuse a BLOB, or renaming the payload to `?sr=<blob>` walks past every check
2393
+ // above.
2394
+ const BENIGN_SHORT_PARAM_RE = /^(?:se|sp|sr|sv|st|spr|si)$/i;
2386
2395
 
2387
2396
  // matchesSecretHint is a deliberately broad PRE-gate whose bare-keyword arms
2388
2397
  // (`token`, `secret`, `authorization`, …) also match ordinary hyphen/word
@@ -2442,6 +2451,52 @@ function isBase64UrlBlob(value) {
2442
2451
  return BLOB_VALUE_B64URL_RE.test(value) && B64URL_MIXED_RE.test(value);
2443
2452
  }
2444
2453
 
2454
+ // `.` and `,` are legal in a URL but sit outside every blob alphabet above, so
2455
+ // chunking a payload on them (`aGVsbG8.d29ybGQ.…`) fails each anchored test
2456
+ // whole. Rejoining asks how many encoded bytes a component carries rather than
2457
+ // whether it is one unbroken token, which is what a beacon answers.
2458
+ const BLOB_SEPARATOR_RE = /[.,]/g;
2459
+
2460
+ /**
2461
+ * The separator-free residue of `value`, or null when it carried no separator
2462
+ * (the un-chunked case every caller has already tested).
2463
+ * @param {string} value
2464
+ * @returns {string | null}
2465
+ */
2466
+ function chunkedBlobResidue(value) {
2467
+ const joined = value.replace(BLOB_SEPARATOR_RE, "");
2468
+ return joined.length === value.length ? null : joined;
2469
+ }
2470
+
2471
+ /**
2472
+ * True when a PATH SEGMENT is a payload chunked across `.`/`,`.
2473
+ *
2474
+ * The rejoined bytes must carry base64url's character mix — an uppercase letter
2475
+ * AND a digit — which the unbroken test does not demand. Rejoining is what makes
2476
+ * the stricter bar necessary: a batch REST path is a list of separator-joined
2477
+ * identifiers, so `/v1/quotes/AAPL,MSFT,GOOG,…` rejoins into a pure-uppercase
2478
+ * run and `/v1/products/101,102,…` into a pure-digit one, both long enough to
2479
+ * clear the floor and match the un-mixed arm. The mix is what tells bulk-encoded
2480
+ * bytes from a list of names. It costs a chunked single-alphabet payload, which
2481
+ * is the false negative this layer's doctrine says to take.
2482
+ *
2483
+ * Applied to the PATH only. A JWT is natively three dot-separated base64url
2484
+ * segments running 130 to 600+ chars, so on a query value neither shape nor
2485
+ * length separates a chunked payload from an ordinary `?token=<jwt>`; that
2486
+ * residual is pinned as a test rather than left implicit.
2487
+ * @param {string} segment
2488
+ * @returns {boolean}
2489
+ */
2490
+ function isChunkedPathBlob(segment) {
2491
+ const joined = chunkedBlobResidue(segment);
2492
+ return (
2493
+ joined !== null &&
2494
+ joined.length > PATH_BLOB_MIN_LEN &&
2495
+ ((PATH_BLOB_RE.test(joined) && B64URL_MIXED_RE.test(joined)) ||
2496
+ isBase64UrlBlob(joined))
2497
+ );
2498
+ }
2499
+
2445
2500
  /** @param {string} value @returns {boolean} */
2446
2501
  function isBlobValue(value) {
2447
2502
  return (
@@ -2569,8 +2624,9 @@ function rawUrlKeywordExfil(url) {
2569
2624
  * @returns {boolean}
2570
2625
  */
2571
2626
  function allParamsBenign(parsed) {
2572
- return rawParams(parsed.search.slice(1)).every(([name]) =>
2573
- BENIGN_BLOB_PARAM_RE.test(name),
2627
+ return rawParams(parsed.search.slice(1)).every(
2628
+ ([name]) =>
2629
+ BENIGN_BLOB_PARAM_RE.test(name) || BENIGN_SHORT_PARAM_RE.test(name),
2574
2630
  );
2575
2631
  }
2576
2632
 
@@ -2602,8 +2658,9 @@ function checkUrlParams(parsed) {
2602
2658
  function checkUrlPath(parsed) {
2603
2659
  for (const segment of parsed.pathname.split("/")) {
2604
2660
  if (
2605
- segment.length > PATH_BLOB_MIN_LEN &&
2606
- (PATH_BLOB_RE.test(segment) || isBase64UrlBlob(segment))
2661
+ (segment.length > PATH_BLOB_MIN_LEN &&
2662
+ (PATH_BLOB_RE.test(segment) || isBase64UrlBlob(segment))) ||
2663
+ isChunkedPathBlob(segment)
2607
2664
  )
2608
2665
  return "encoded data blob in path segment";
2609
2666
  }