agent-sanitizer 2.48.0 → 2.48.2

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.
@@ -24,8 +24,10 @@ import { randomBytes } from "node:crypto";
24
24
  import { basename, join } from "node:path";
25
25
  import { homedir, tmpdir, userInfo } from "node:os";
26
26
  import {
27
+ hookgateMarkerPath,
27
28
  lazyImport,
28
29
  markerIsTrusted,
30
+ probeSetupAlive,
29
31
  PROJECT_DIR,
30
32
  PROJECT_HASH,
31
33
  scrubUntrustedText,
@@ -442,8 +444,9 @@ function userGlobalLaunchHasContent(env = process.env) {
442
444
 
443
445
  /**
444
446
  * The one-time context line for a session where no InstructionsLoaded scan ran,
445
- * or null when the scan has been seen, the notice already ran this session, or
446
- * neither launch nor `touchedDir` could have fired the event.
447
+ * or null when the scan has been seen, the notice already ran this session, the
448
+ * host's cold-start marker says setup is still running, or neither launch nor
449
+ * `touchedDir` could have fired the event.
447
450
  *
448
451
  * PURE for the notice: nothing is recorded until the caller confirms it landed
449
452
  * in a returned response. Launch-emptiness is cached once found — fixed for the
@@ -472,6 +475,15 @@ export function instructionsLoadedGapNotice(
472
475
  ) {
473
476
  if (instructionsLoadedSeen(sessionId)) return null;
474
477
  if (markerIsTrusted(instructionsLoadedNoticeFile(sessionId))) return null;
478
+ // The host's setup script is still provisioning this session, so the scanner
479
+ // may not have been able to run yet: a launch event fires within a second of
480
+ // SessionStart, and a hook whose file or dependency setup has not written yet
481
+ // never reached the marker. Every cause below would be wrong, so say nothing
482
+ // while setup RUNS. Liveness, not the marker alone: a setup killed mid-install
483
+ // leaves its marker behind, and a marker test alone would then silence this
484
+ // notice for every later session on the host.
485
+ const marker = hookgateMarkerPath();
486
+ if (markerIsTrusted(marker) && probeSetupAlive(marker)) return null;
475
487
  const launchCached = markerIsTrusted(launchEmptyFile(sessionId));
476
488
  const launchHasBytes =
477
489
  !launchCached &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.48.0",
3
+ "version": "2.48.2",
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 `sv/sr/sig/se/sp/st/spr/skoid/sktid`), pagination cursors /
2380
+ // Azure SAS `sig` only), 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,16 +2392,67 @@ 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|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;
2396
-
2397
- // The SHORT-valued companions of a signed-CDN link: Azure SAS carries a version
2398
- // date (`sv`), a resource letter (`sr`), start/expiry timestamps (`st`/`se`), a
2399
- // permissions letter (`sp`), a protocol (`spr`) and a policy id (`si`) beside
2400
- // its one long `sig`. They mark a query as signed-CDN traffic, which is all
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;
2396
+
2397
+ // The SHORT-valued companions of a signed-CDN link. Azure SAS spells one long
2398
+ // `sig` beside a crowd of short fields, and the whole taxonomy is listed here:
2399
+ // service SAS (`sv` version, `sr` resource, `sp` permissions, `st`/`se`
2400
+ // start/expiry, `si` policy id, `sip` ip range, `spr` protocol, `sdd` directory
2401
+ // depth, `ses` encryption scope, `rscc`/`rscd`/`rsce`/`rscl`/`rsct` response
2402
+ // headers), account SAS (`ss` services, `srt` resource types), user-delegation
2403
+ // SAS (`skoid`/`sktid`/`saoid`/`suoid`/`scid` GUIDs, `skt`/`ske` key validity,
2404
+ // `sks` key service, `skv` key version) and the queue/table/blob operands
2405
+ // (`tn`, `startpk`/`endpk`/`startrk`/`endrk`, `snapshot`, `versionid`,
2406
+ // `restype`, `comp`). They mark a query as signed-CDN traffic, which is all
2401
2407
  // `allParamsBenign` needs — but a name whose benign value is short must never
2402
2408
  // excuse a BLOB, or renaming the payload to `?sr=<blob>` walks past every check
2403
- // above.
2404
- const BENIGN_SHORT_PARAM_RE = /^(?:se|sp|sr|sv|st|spr|si)$/i;
2409
+ // above. That is why every one of these sits here and not in the blob set: a
2410
+ // SAS timestamp, letter code, version date or GUID never reaches 40 characters.
2411
+ //
2412
+ // The set is not Azure's alone: every signed-URL scheme names its own short
2413
+ // fields, and each one listed here has a real value no longer opaque run can
2414
+ // hide in. `expires` is a unix timestamp; the access-key identifiers
2415
+ // (`AWSAccessKeyId`, `GoogleAccessId`, `OSSAccessKeyId`, `AccessKeyId`) are a
2416
+ // 20-character key id or a service-account address; CloudFront's `Key-Pair-Id`
2417
+ // is a key id; Tencent COS spells its algorithm, key id, time windows and
2418
+ // header lists in `q-*` beside one long `q-signature`; and the analytics ids
2419
+ // (`_ga`, `_gl`, `mc_eid`, `mc_cid`) are dot- and star-separated counters and
2420
+ // short hashes. Each sat in the blob set or nowhere, and both
2421
+ // placements were wrong: the blob set let `?key-pair-id=<blob>` ride, and being
2422
+ // absent made a real `?AWSAccessKeyId=…` link read as exfil. The long click-ids
2423
+ // (`gclid`, `fbclid`, `dclid`, `gbraid`, `wbraid`, `msclkid`) and `utm_*` stay
2424
+ // in the blob set, because their real values can be long opaque tokens.
2425
+ const BENIGN_SHORT_PARAM_RE =
2426
+ /^(?:se|sp|sr|sv|st|spr|si|sip|ss|srt|sdd|ses|sk(?:oid|tid|t|e|s|v)|saoid|suoid|scid|tn|start(?:pk|rk)|end(?:pk|rk)|snapshot|versionid|restype|comp|rsc[cdelt]|expires|awsaccesskeyid|googleaccessid|ossaccesskeyid|accesskeyid|key-pair-id|q-(?:ak|sign-algorithm|sign-time|key-time|header-list|url-param-list)|_ga|_gl|mc_eid|mc_cid)$/i;
2427
+
2428
+ // Longest benign value any BENIGN_SHORT_PARAM_RE name carries: an encoded SAS
2429
+ // timestamp is ~24 characters and a response-header override (`rscd`) tens
2430
+ // more, so 128 leaves every real one room. Past it the name has stopped being a
2431
+ // short field, and `allParamsBenign` must not read it as signed-CDN traffic —
2432
+ // otherwise a payload split into 100-character chunks across repeated short
2433
+ // names (`?si=<chunk>&si=<chunk>…`, which `rawParams` keeps as separate pairs)
2434
+ // suppresses the long-query backstop while each chunk stays under the blob bar.
2435
+ const BENIGN_SHORT_VALUE_MAX_LEN = 128;
2436
+
2437
+ // Budget for the short-named values of one query, summed. The per-value bound
2438
+ // alone leaves a payload spread across many distinct signed names, each value
2439
+ // sitting just inside it. The fullest real SAS measured here — a user-delegation
2440
+ // link carrying five GUIDs, both key-validity timestamps and the response-header
2441
+ // overrides — spends a little over 300 characters, so 512 clears every real one
2442
+ // while capping what the suppression can ever hide.
2443
+ const BENIGN_SHORT_TOTAL_MAX_LEN = 512;
2444
+
2445
+ // The access-key IDENTIFIER of a signed URL: AWS/GCS/OSS v2 presigning, Tencent
2446
+ // COS (`q-ak`) and CloudFront (`Key-Pair-Id`) all name the key in the URL beside
2447
+ // the signature. The id is public by design — the secret is what signs the
2448
+ // request, never what travels — but it reads as a credential to
2449
+ // `matchesSecretHint` (an `AKIA…` key id is an opaque run with a digit and an
2450
+ // access-key prefix), so every real v2 presigned link fired. Below the blob
2451
+ // floor these names skip the credential arm only; the blob arm still runs, so a
2452
+ // payload renamed `?AWSAccessKeyId=<blob>` is still reported.
2453
+ const PUBLIC_KEY_ID_PARAM_RE =
2454
+ /^(?:awsaccesskeyid|googleaccessid|ossaccesskeyid|accesskeyid|key-pair-id|q-ak)$/i;
2455
+ const BLOB_VALUE_MIN_LEN = 40;
2405
2456
 
2406
2457
  // matchesSecretHint is a deliberately broad PRE-gate whose bare-keyword arms
2407
2458
  // (`token`, `secret`, `authorization`, …) also match ordinary hyphen/word
@@ -2540,6 +2591,35 @@ function decodedBlobMatch(value) {
2540
2591
  return isBlobValue(decoded);
2541
2592
  }
2542
2593
 
2594
+ // A credential rarely travels alone in a parameter: a signed session cookie is
2595
+ // `s:<token>.<mac>`, an Authorization value is `Bearer <jwt>`, a versioned key is
2596
+ // `v1.<token>`. The blob tests are anchored to the WHOLE value, so any wrapper
2597
+ // defeats them. This splits a value on the characters no base64/hex alphabet
2598
+ // contains and asks whether one PART is a blob. Applied only where the parameter
2599
+ // NAME already says credential (`?session=`, `?auth=`, `?api_key=`), because on
2600
+ // an arbitrary parameter a 40-character run inside longer text is ordinary.
2601
+ const BLOB_RUN_SPLIT_RE = /[^A-Za-z0-9+/=_-]+/;
2602
+
2603
+ /**
2604
+ * True when some separator-delimited part of `value`, or of its
2605
+ * percent-decoded form, is blob-shaped.
2606
+ * @param {string} value
2607
+ * @returns {boolean}
2608
+ */
2609
+ function containsBlobRun(value) {
2610
+ let decoded = value;
2611
+ try {
2612
+ decoded = decodeURIComponent(value);
2613
+ } catch {
2614
+ // A malformed percent-sequence is not a decoding this layer can trust; the
2615
+ // raw form is still split below.
2616
+ }
2617
+ for (const form of decoded === value ? [value] : [value, decoded])
2618
+ for (const part of form.split(BLOB_RUN_SPLIT_RE))
2619
+ if (part !== form && isBlobValue(part)) return true;
2620
+ return false;
2621
+ }
2622
+
2543
2623
  /**
2544
2624
  * RAW (un-decoded) `name=value` pairs of a query/fragment string, split on `&`
2545
2625
  * and `;`. URLSearchParams is avoided on purpose: it percent-/`+`-decodes
@@ -2577,6 +2657,8 @@ function rawParams(qs) {
2577
2657
  */
2578
2658
  function paramExfilReason(name, value, rawName) {
2579
2659
  if (BENIGN_BLOB_PARAM_RE.test(name)) return null;
2660
+ const publicKeyId =
2661
+ PUBLIC_KEY_ID_PARAM_RE.test(name) && value.length < BLOB_VALUE_MIN_LEN;
2580
2662
  for (const candidate of [rawName, value]) {
2581
2663
  if (!candidate) continue;
2582
2664
  // A leaked credential is an OPAQUE, separator-free token. Gate the
@@ -2585,7 +2667,7 @@ function paramExfilReason(name, value, rawName) {
2585
2667
  // service/…abcdefghij1234567890`) otherwise matches "authorization" in one
2586
2668
  // place and a 20-char run in another and false-fires. Requiring both on the
2587
2669
  // SAME run keeps `ghp_…`-style contiguous tokens firing while dropping prose.
2588
- const opaqueRuns = candidate.match(OPAQUE_TOKEN_RE);
2670
+ const opaqueRuns = publicKeyId ? null : candidate.match(OPAQUE_TOKEN_RE);
2589
2671
  if (
2590
2672
  opaqueRuns?.some(
2591
2673
  (run) => VALUE_HAS_DIGIT_RE.test(run) && matchesSecretHint(run),
@@ -2595,6 +2677,14 @@ function paramExfilReason(name, value, rawName) {
2595
2677
  if (isBlobValue(candidate) || decodedBlobMatch(candidate))
2596
2678
  return "suspicious query parameter";
2597
2679
  }
2680
+ // A wrapped credential (`?session=s%3A<token>.<mac>`) in a parameter whose
2681
+ // name already says credential. The whole-value tests above miss it because
2682
+ // the wrapper is part of the value.
2683
+ if (
2684
+ (KEYWORD_PARAM_NAME_RE.test(name) || matchesSecretHint(name)) &&
2685
+ containsBlobRun(value)
2686
+ )
2687
+ return "credential-shaped token in URL parameter";
2598
2688
  return null;
2599
2689
  }
2600
2690
 
@@ -2630,14 +2720,30 @@ function rawUrlKeywordExfil(url) {
2630
2720
  * signed-CDN links, which are long by design. Only ever called once the query
2631
2721
  * is known to be long (and thus non-empty), so the vacuous-true empty case
2632
2722
  * cannot arise here.
2723
+ *
2724
+ * A short-valued name must clear three bars, not one. Each bar closes a way to
2725
+ * spend the suppression on bulk data whose individual values stay under the
2726
+ * blob bar: one over-long value, the same name repeated, and many distinct
2727
+ * names each just inside the per-value bound.
2633
2728
  * @param {URL} parsed
2634
2729
  * @returns {boolean}
2635
2730
  */
2636
2731
  function allParamsBenign(parsed) {
2637
- return rawParams(parsed.search.slice(1)).every(
2638
- ([name]) =>
2639
- BENIGN_BLOB_PARAM_RE.test(name) || BENIGN_SHORT_PARAM_RE.test(name),
2640
- );
2732
+ /** @type {Set<string>} */
2733
+ const shortNames = new Set();
2734
+ let shortBytes = 0;
2735
+ for (const [name, value] of rawParams(parsed.search.slice(1))) {
2736
+ if (BENIGN_BLOB_PARAM_RE.test(name)) continue;
2737
+ if (!BENIGN_SHORT_PARAM_RE.test(name)) return false;
2738
+ if (value.length > BENIGN_SHORT_VALUE_MAX_LEN) return false;
2739
+ // A signed URL spells each signed field once; the service rejects a
2740
+ // duplicate. A repeat is therefore a payload split across one name.
2741
+ if (shortNames.has(name)) return false;
2742
+ shortNames.add(name);
2743
+ shortBytes += value.length;
2744
+ if (shortBytes > BENIGN_SHORT_TOTAL_MAX_LEN) return false;
2745
+ }
2746
+ return true;
2641
2747
  }
2642
2748
 
2643
2749
  /**
@@ -99,8 +99,9 @@ export function launchEmptyFile(sessionId?: string): string;
99
99
  export function launchInstructionFiles(dir: string): string[];
100
100
  /**
101
101
  * The one-time context line for a session where no InstructionsLoaded scan ran,
102
- * or null when the scan has been seen, the notice already ran this session, or
103
- * neither launch nor `touchedDir` could have fired the event.
102
+ * or null when the scan has been seen, the notice already ran this session, the
103
+ * host's cold-start marker says setup is still running, or neither launch nor
104
+ * `touchedDir` could have fired the event.
104
105
  *
105
106
  * PURE for the notice: nothing is recorded until the caller confirms it landed
106
107
  * in a returned response. Launch-emptiness is cached once found — fixed for the