agent-sanitizer 2.41.4 → 2.43.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.41.4",
3
+ "version": "2.43.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/gates.mjs CHANGED
@@ -44,6 +44,27 @@ export function needsMarkdownPipeline(text) {
44
44
  return HTML_TAG_PRESENT.test(text) || MD_LINK_HINT.test(text);
45
45
  }
46
46
 
47
+ /**
48
+ * Matches an absolute http(s) URL anywhere in the text. Gate for the Layer-3
49
+ * URL detectors, which read the URLs a GFM autolink literal yields and so find
50
+ * a look-alike host or an exfil-shaped query in bare prose — no tag and no
51
+ * link syntax required.
52
+ */
53
+ export const URL_PRESENT = /https?:\/\//i;
54
+
55
+ /**
56
+ * True when Layer 3 (exfil + confusable-host detection) can find something.
57
+ * A superset of {@link needsMarkdownPipeline}: a markup-bearing document can
58
+ * carry a relative exfil target (`[x](/collect?d=…)`), and a plain-prose one
59
+ * can carry an absolute URL with no markup at all. Layer 2 keeps the narrower
60
+ * gate — it can only splice what a tag delimits.
61
+ * @param {string} text
62
+ * @returns {boolean}
63
+ */
64
+ export function needsUrlScan(text) {
65
+ return URL_PRESENT.test(text) || needsMarkdownPipeline(text);
66
+ }
67
+
47
68
  // ─── Secret-shape pre-gate (Layer 3 URL-param reuse) ─────────────────────────
48
69
  // Cheap shape match that decides whether a URL parameter value carries a
49
70
  // credential (Layer 3). This hand-duplicates credential-shape knowledge that
package/src/html.mjs CHANGED
@@ -43,6 +43,7 @@ import {
43
43
  SECRET_HINT,
44
44
  SECRET_HINT_EXT,
45
45
  matchesSecretHint,
46
+ needsUrlScan,
46
47
  } from "./gates.mjs";
47
48
  import { confusableHost, describeConfusableHost } from "./confusable-host.mjs";
48
49
  import { SEVERITY } from "./severity.mjs";
@@ -231,26 +232,34 @@ function isHidingTransform(node) {
231
232
  const name = fn.name;
232
233
  const args = valueTokens(fn);
233
234
  if (/^(?:scale|scale3d|scalex|scaley|matrix|matrix3d)$/.test(name)) {
234
- // scale/matrix collapse to nothing when EITHER axis factor is (near-)zero
235
- // `scale(1,0)` / `scale3d(1,0,1)` collapse the Y axis, `matrix(1,0,0,0,…)`
236
- // sets scaleY (d) to 0 so testing only the first factor missed the
237
- // multi-arg Y-collapse forms. css-tree reads the exponent form (`1e-3`) at
238
- // full value; scale()/matrix() factors are <number>s (never lengths). For
239
- // `matrix(a,b,c,d,…)` the two scale factors are a (index 0) and d (index 3).
240
- // The two scale factors' positions differ per function: scale/scale3d/
241
- // scaleX/scaleY carry them first (a single scaleX(0)/scaleY(0) sits at 0);
242
- // `matrix(a,b,c,d,…)` puts scaleX=a (0) and scaleY=d (3); `matrix3d` puts
243
- // scaleX=m11 (0) and scaleY=m22 (5). Do NOT use index 3 for matrix3d — that
244
- // is m14, which is legitimately 0 on the identity matrix (false positive).
245
- const numbers = args.filter(
246
- (/** @type {any} */ a) => a.type === "Number",
247
- );
235
+ // scale/matrix collapse to nothing when EITHER axis factor is (near-)zero:
236
+ // `scale(1,0)` collapses the Y axis, as does `matrix(1,0,0,0,…)` (d=0).
237
+ // A factor sits at a fixed ARGUMENT position, so the comma-separated
238
+ // argument list is what carries it indexing a Number-filtered token list
239
+ // instead drops an unresolvable `var()`/`calc()` argument and slides a
240
+ // later benign 0 into a factor slot, splicing VISIBLE text.
241
+ // Factor positions per function:
242
+ // scale/scale3d/scaleX/scaleY: scaleX=0, scaleY=1 (a lone scaleX(0) or
243
+ // scaleY(0) argument sits at 0)
244
+ // matrix(a,b,c,d,…): scaleX=a (0), scaleY=d (3)
245
+ // matrix3d(m11,…): scaleX=m11 (0), scaleY=m22 (5) index 3 is m14,
246
+ // which the identity matrix leaves at 0 (a false positive)
247
+ const groups = functionArgs(fn);
248
248
  const factorIdx =
249
249
  name === "matrix" ? [0, 3] : name === "matrix3d" ? [0, 5] : [0, 1];
250
+ // css-tree reads the exponent form (`1e-3`) at full value; scale()/matrix()
251
+ // factors are <number>s (never lengths). An argument that is not one Number
252
+ // token — `var(--x)`, `calc(…)`, a missing slot — is unresolvable and fails
253
+ // OPEN: it yields no hidden verdict.
250
254
  if (
251
255
  factorIdx.some((/** @type {number} */ i) => {
252
- const f = numbers[i];
253
- return f && Math.abs(parseFloat(f.value)) < NEAR_ZERO_EPSILON;
256
+ const group = groups[i];
257
+ const factor = group && group.length === 1 ? group[0] : null;
258
+ return (
259
+ factor !== null &&
260
+ factor.type === "Number" &&
261
+ Math.abs(parseFloat(factor.value)) < NEAR_ZERO_EPSILON
262
+ );
254
263
  })
255
264
  )
256
265
  return true;
@@ -680,12 +689,37 @@ function canonicalizeColorFunction(value) {
680
689
  }
681
690
 
682
691
  /**
683
- * Canonicalize a CSS color to lowercase `#rrggbb` so `white`, `#FFF`,
684
- * `#ffffff`, `rgb(255, 255, 255)`, `rgb(255 255 255)`, `rgb(100% 100% 100%)`,
685
- * and `hsl(0 0% 100%)` all compare equal. Returns the trimmed lowercased input
686
- * unchanged when it is not a form we recognize; callers gate the same-color
687
- * compare on isConcreteColor so an unresolved token (`var()`, `inherit`) never
688
- * falsely reads as a same-color hide.
692
+ * Fold one hex color body 3, 4, 6 or 8 digits, `#` already stripped — to
693
+ * `#rrggbb`, or to `transparent` when its alpha byte is zero. `#RGB`/`#RGBA`
694
+ * expand by doubling each digit, exactly as a browser reads them.
695
+ *
696
+ * A PARTIAL alpha (neither `00` nor `ff`) drops out and leaves the color, which
697
+ * is what {@link canonicalizeColorFunction} already does with `rgba(…, .5)`:
698
+ * the caller compares this color against the backdrop it is painted on, and a
699
+ * color blended over its own value renders that value at every alpha. Keeping
700
+ * the two spellings apart here would leave `#ffffff80` a bypass of a hide that
701
+ * `rgba(255,255,255,.5)` is caught for.
702
+ * @param {string} digits
703
+ * @returns {string}
704
+ */
705
+ function canonicalizeHex(digits) {
706
+ const expanded =
707
+ digits.length <= 4
708
+ ? [...digits].map((digit) => digit + digit).join("")
709
+ : digits;
710
+ if (expanded.slice(6) === "00") return "transparent";
711
+ return `#${expanded.slice(0, 6)}`;
712
+ }
713
+
714
+ /**
715
+ * Canonicalize a CSS color to lowercase `#rrggbb` (or the `transparent`
716
+ * sentinel) so `white`, `#FFF`, `#ffffff`, `#ffffffff`, `rgb(255, 255, 255)`,
717
+ * `rgb(255 255 255)`, `rgb(100% 100% 100%)`, and `hsl(0 0% 100%)` all compare
718
+ * equal — as do `transparent`, `#0000`, `#00000000` and `rgba(0,0,0,0)`.
719
+ * Returns the trimmed lowercased input unchanged when it is not a form we
720
+ * recognize; callers gate the same-color compare on isConcreteColor so an
721
+ * unresolved token (`var()`, `inherit`) never falsely reads as a same-color
722
+ * hide.
689
723
  * @param {string} raw
690
724
  * @returns {string}
691
725
  */
@@ -697,10 +731,11 @@ function canonicalizeColor(raw) {
697
731
  // (poisoning isHiddenStyle's return) instead of falling through as a plain
698
732
  // string.
699
733
  if (Object.hasOwn(NAMED_COLORS, value)) return NAMED_COLORS[value];
700
- const shortHex = value.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);
701
- if (shortHex)
702
- return `#${shortHex[1]}${shortHex[1]}${shortHex[2]}${shortHex[2]}${shortHex[3]}${shortHex[3]}`;
703
- if (/^#[0-9a-f]{6}$/.test(value)) return value;
734
+ // All four hex notations in one match: reading only #RGB and #RRGGBB left
735
+ // `#0000` and `#00000000` — exact synonyms of `transparent`, which IS a hide —
736
+ // unresolved, so invisible text reached the model.
737
+ const hex = value.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/);
738
+ if (hex) return canonicalizeHex(hex[1]);
704
739
  return canonicalizeColorFunction(value) ?? value;
705
740
  }
706
741
 
@@ -2341,9 +2376,13 @@ const RELATIVE_URL_BASE = "http://relative.invalid";
2341
2376
  // would only widen the rename-dodge surface. A blob or credential-shaped value
2342
2377
  // in any OTHER parameter still fires — this allowlist trades a narrow dodge
2343
2378
  // (`?sig=<stolen>`) for not drowning the model in false positives on ordinary
2344
- // fetched pages.
2379
+ // fetched pages. The OAuth callback pair (`code`, `state`) is listed for that
2380
+ // same trade: a redirect URL is one of the most common things a fetched page or
2381
+ // a browser tool prints, and both values are opaque by design (an authorization
2382
+ // code, a signed CSRF nonce), so the value shape cannot separate them from a
2383
+ // payload.
2345
2384
  const BENIGN_BLOB_PARAM_RE =
2346
- /^(?: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|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|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;
2347
2386
 
2348
2387
  // matchesSecretHint is a deliberately broad PRE-gate whose bare-keyword arms
2349
2388
  // (`token`, `secret`, `authorization`, …) also match ordinary hyphen/word
@@ -2451,7 +2490,12 @@ function rawParams(qs) {
2451
2490
  if (!pair) continue;
2452
2491
  const eq = pair.indexOf("=");
2453
2492
  const name = eq === -1 ? pair : pair.slice(0, eq);
2454
- const value = eq === -1 ? "" : pair.slice(eq + 1);
2493
+ // A VALUELESS param carries its payload in the only token it has, so that
2494
+ // token is its candidate value too — `?<blob>`, `?d=<blob>`, and `?<blob>=`
2495
+ // (whose sole `=` is base64 padding) are one channel. The name alone still
2496
+ // goes through BENIGN_BLOB_PARAM_RE.
2497
+ const afterEq = eq === -1 ? "" : pair.slice(eq + 1);
2498
+ const value = afterEq === "" ? pair : afterEq;
2455
2499
  pairs.push([name.toLowerCase(), value]);
2456
2500
  }
2457
2501
  return pairs;
@@ -2877,7 +2921,7 @@ function collectUrls(text) {
2877
2921
  * @returns {Array<{ isImage: boolean, autoFetched: boolean, reason: string, target: string }> | null}
2878
2922
  */
2879
2923
  export function detectExfil(text) {
2880
- if (!MD_LINK_HINT.test(text) && !HTML_TAG_PRESENT.test(text)) return null;
2924
+ if (!needsUrlScan(text)) return null;
2881
2925
 
2882
2926
  /** @type {Array<{ isImage: boolean, autoFetched: boolean, reason: string, target: string }>} */
2883
2927
  const threats = [];
@@ -2924,7 +2968,7 @@ export function detectExfil(text) {
2924
2968
  * @returns {Array<{ severity: string, description: string }> | null}
2925
2969
  */
2926
2970
  export function detectConfusableHosts(text) {
2927
- if (!MD_LINK_HINT.test(text) && !HTML_TAG_PRESENT.test(text)) return null;
2971
+ if (!needsUrlScan(text)) return null;
2928
2972
 
2929
2973
  /** @type {Array<{ severity: string, description: string }>} */
2930
2974
  const threats = [];
package/src/output.mjs CHANGED
@@ -29,7 +29,7 @@ import {
29
29
  describeStripped,
30
30
  isIncidentalInvisible,
31
31
  } from "./invisible.mjs";
32
- import { needsMarkdownPipeline } from "./gates.mjs";
32
+ import { needsMarkdownPipeline, needsUrlScan } from "./gates.mjs";
33
33
  import {
34
34
  applyLayer1,
35
35
  INERT_ANSI_NOTE,
@@ -418,8 +418,13 @@ async function applyMarkdownPipeline(state, { html, exfilScan, deadline }) {
418
418
  let reveal;
419
419
  /** @type {Array<{ placeholder: string, original: string }>} */
420
420
  const splices = [];
421
- if ((!html && !exfilScan) || !needsMarkdownPipeline(inputText))
422
- return { reveal: undefined, splices };
421
+ // Each layer carries its OWN pre-gate: Layer 2 can only splice what a tag
422
+ // delimits, while Layer 3's detectors read a bare `https://…` in prose that
423
+ // holds no markup at all. Gating both on the markup test hid every plain-text
424
+ // look-alike host and exfil URL from the scan.
425
+ const runLayer2 = Boolean(html) && needsMarkdownPipeline(inputText);
426
+ const runLayer3 = Boolean(exfilScan) && needsUrlScan(inputText);
427
+ if (!runLayer2 && !runLayer3) return { reveal: undefined, splices };
423
428
  // INVARIANT: this refusal stops a layer below from STARTING with no budget
424
429
  // left. Each parses the whole document in ONE synchronous call, so nothing
425
430
  // interrupts it, and a host that kills the overrun hook shows the RAW text.
@@ -455,7 +460,7 @@ async function applyMarkdownPipeline(state, { html, exfilScan, deadline }) {
455
460
  // Layer 2 — strips what a rendered page would not show (comments, hidden
456
461
  // elements); scripting/resource tags preserved+reported. Each cut leaves a
457
462
  // keyed placeholder whose original bytes ride out in `splices`.
458
- if (html) {
463
+ if (runLayer2) {
459
464
  const layer2 = sanitizeHtml(state.text);
460
465
  if (layer2) {
461
466
  if (layer2.text !== state.text) {
@@ -494,7 +499,7 @@ async function applyMarkdownPipeline(state, { html, exfilScan, deadline }) {
494
499
  // use them. Scan the ORIGINAL text, not the Layer-2 splice output: a beacon
495
500
  // URL hidden inside a display:none element or an HTML comment is MORE
496
501
  // suspicious, not less, yet Layer 2 has already removed it from `cleaned`.
497
- if (exfilScan) {
502
+ if (runLayer3) {
498
503
  refuseIfSpent();
499
504
  const threats = detectExfil(inputText);
500
505
  // Severity tracks who does the fetching. An auto-fetched target — an image,
package/types/gates.d.mts CHANGED
@@ -9,6 +9,16 @@
9
9
  * @returns {boolean}
10
10
  */
11
11
  export function needsMarkdownPipeline(text: string): boolean;
12
+ /**
13
+ * True when Layer 3 (exfil + confusable-host detection) can find something.
14
+ * A superset of {@link needsMarkdownPipeline}: a markup-bearing document can
15
+ * carry a relative exfil target (`[x](/collect?d=…)`), and a plain-prose one
16
+ * can carry an absolute URL with no markup at all. Layer 2 keeps the narrower
17
+ * gate — it can only splice what a tag delimits.
18
+ * @param {string} text
19
+ * @returns {boolean}
20
+ */
21
+ export function needsUrlScan(text: string): boolean;
12
22
  /**
13
23
  * True when either pre-gate alternation shape-matches `text`. Split into two
14
24
  * literals (see SECRET_HINT) and OR'd so neither grows into a
@@ -44,6 +54,13 @@ export const HTML_TAG_PRESENT: RegExp;
44
54
  * exfiltration detection).
45
55
  */
46
56
  export const MD_LINK_HINT: RegExp;
57
+ /**
58
+ * Matches an absolute http(s) URL anywhere in the text. Gate for the Layer-3
59
+ * URL detectors, which read the URLs a GFM autolink literal yields and so find
60
+ * a look-alike host or an exfil-shaped query in bare prose — no tag and no
61
+ * link syntax required.
62
+ */
63
+ export const URL_PRESENT: RegExp;
47
64
  /** @type {RegExp} */
48
65
  export const SECRET_HINT: RegExp;
49
66
  /** @type {RegExp} */