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 +1 -1
- package/src/gates.mjs +21 -0
- package/src/html.mjs +75 -31
- package/src/output.mjs +10 -5
- package/types/gates.d.mts +17 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.
|
|
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)`
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
// scaleX=
|
|
244
|
-
//
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
253
|
-
|
|
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
|
-
*
|
|
684
|
-
* `#
|
|
685
|
-
*
|
|
686
|
-
*
|
|
687
|
-
*
|
|
688
|
-
*
|
|
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
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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
|
-
|
|
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 (!
|
|
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 (!
|
|
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
|
-
|
|
422
|
-
|
|
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 (
|
|
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 (
|
|
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} */
|