agent-sanitizer 2.43.10 → 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.
- package/package.json +1 -1
- package/src/html.mjs +102 -42
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.43.
|
|
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|
|
|
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 (
|
|
@@ -2481,52 +2536,55 @@ function decodedBlobMatch(value) {
|
|
|
2481
2536
|
* values, turning a `+`-bearing base64 blob into a space-broken string that the
|
|
2482
2537
|
* anchored blob regexes would miss.
|
|
2483
2538
|
* @param {string} qs
|
|
2484
|
-
* @returns {Array<[string, string]>}
|
|
2539
|
+
* @returns {Array<[string, string, string]>} [lowercased name, value, RAW (case-preserved) name]
|
|
2485
2540
|
*/
|
|
2486
2541
|
function rawParams(qs) {
|
|
2487
|
-
/** @type {Array<[string, string]>} */
|
|
2542
|
+
/** @type {Array<[string, string, string]>} */
|
|
2488
2543
|
const pairs = [];
|
|
2489
2544
|
for (const pair of qs.split(/[&;]/)) {
|
|
2490
2545
|
if (!pair) continue;
|
|
2491
2546
|
const eq = pair.indexOf("=");
|
|
2492
|
-
const
|
|
2493
|
-
|
|
2494
|
-
|
|
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;
|
|
2499
|
-
pairs.push([name.toLowerCase(), value]);
|
|
2547
|
+
const rawName = eq === -1 ? pair : pair.slice(0, eq);
|
|
2548
|
+
const value = eq === -1 ? "" : pair.slice(eq + 1);
|
|
2549
|
+
pairs.push([rawName.toLowerCase(), value, rawName]);
|
|
2500
2550
|
}
|
|
2501
2551
|
return pairs;
|
|
2502
2552
|
}
|
|
2503
2553
|
|
|
2504
2554
|
/**
|
|
2505
|
-
* Exfil reason for one URL parameter, or null. A credential-shaped
|
|
2506
|
-
*
|
|
2507
|
-
*
|
|
2508
|
-
*
|
|
2509
|
-
*
|
|
2510
|
-
*
|
|
2555
|
+
* Exfil reason for one URL parameter, or null. A credential-shaped or
|
|
2556
|
+
* blob-shaped run in EITHER half of the pair — the value, or the name — in any
|
|
2557
|
+
* non-allowlisted parameter. Testing the name too (not just the value) is what
|
|
2558
|
+
* catches a payload that lands on the wrong side of the `=`: `?<blob>`,
|
|
2559
|
+
* `?<blob>=`, `?<blob>==` (trailing `=`s read as padding) and `?<blob>=x` (any
|
|
2560
|
+
* non-padding tail) are one channel, since the server reads the query string
|
|
2561
|
+
* either way. Allowlisted signing/pagination/analytics parameters are skipped
|
|
2562
|
+
* entirely (see BENIGN_BLOB_PARAM_RE).
|
|
2563
|
+
* @param {string} name lowercased parameter name, for the allowlist gate
|
|
2564
|
+
* @param {string} value RAW (un-decoded) value
|
|
2565
|
+
* @param {string} rawName RAW (case-preserved, un-decoded) name
|
|
2511
2566
|
* @returns {string | null}
|
|
2512
2567
|
*/
|
|
2513
|
-
function paramExfilReason(name, value) {
|
|
2568
|
+
function paramExfilReason(name, value, rawName) {
|
|
2514
2569
|
if (BENIGN_BLOB_PARAM_RE.test(name)) return null;
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
opaqueRuns
|
|
2524
|
-
|
|
2570
|
+
for (const candidate of [rawName, value]) {
|
|
2571
|
+
if (!candidate) continue;
|
|
2572
|
+
// A leaked credential is an OPAQUE, separator-free token. Gate the
|
|
2573
|
+
// secret-shape/digit test on the CONTIGUOUS opaque run(s), not on the whole
|
|
2574
|
+
// prose candidate: a benign path-like value (`?redirect=/authorization-
|
|
2575
|
+
// service/…abcdefghij1234567890`) otherwise matches "authorization" in one
|
|
2576
|
+
// place and a 20-char run in another and false-fires. Requiring both on the
|
|
2577
|
+
// SAME run keeps `ghp_…`-style contiguous tokens firing while dropping prose.
|
|
2578
|
+
const opaqueRuns = candidate.match(OPAQUE_TOKEN_RE);
|
|
2579
|
+
if (
|
|
2580
|
+
opaqueRuns?.some(
|
|
2581
|
+
(run) => VALUE_HAS_DIGIT_RE.test(run) && matchesSecretHint(run),
|
|
2582
|
+
)
|
|
2525
2583
|
)
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2584
|
+
return "credential-shaped token in URL parameter";
|
|
2585
|
+
if (isBlobValue(candidate) || decodedBlobMatch(candidate))
|
|
2586
|
+
return "suspicious query parameter";
|
|
2587
|
+
}
|
|
2530
2588
|
return null;
|
|
2531
2589
|
}
|
|
2532
2590
|
|
|
@@ -2547,9 +2605,9 @@ function rawUrlKeywordExfil(url) {
|
|
|
2547
2605
|
const qIdx = url.search(/[?#]/);
|
|
2548
2606
|
if (qIdx === -1) return null;
|
|
2549
2607
|
for (const segment of url.slice(qIdx + 1).split("#")) {
|
|
2550
|
-
for (const [name, value] of rawParams(segment)) {
|
|
2608
|
+
for (const [name, value, rawName] of rawParams(segment)) {
|
|
2551
2609
|
if (!KEYWORD_PARAM_NAME_RE.test(name)) continue;
|
|
2552
|
-
const reason = paramExfilReason(name, value);
|
|
2610
|
+
const reason = paramExfilReason(name, value, rawName);
|
|
2553
2611
|
if (reason) return reason;
|
|
2554
2612
|
}
|
|
2555
2613
|
}
|
|
@@ -2566,8 +2624,9 @@ function rawUrlKeywordExfil(url) {
|
|
|
2566
2624
|
* @returns {boolean}
|
|
2567
2625
|
*/
|
|
2568
2626
|
function allParamsBenign(parsed) {
|
|
2569
|
-
return rawParams(parsed.search.slice(1)).every(
|
|
2570
|
-
|
|
2627
|
+
return rawParams(parsed.search.slice(1)).every(
|
|
2628
|
+
([name]) =>
|
|
2629
|
+
BENIGN_BLOB_PARAM_RE.test(name) || BENIGN_SHORT_PARAM_RE.test(name),
|
|
2571
2630
|
);
|
|
2572
2631
|
}
|
|
2573
2632
|
|
|
@@ -2577,14 +2636,14 @@ function allParamsBenign(parsed) {
|
|
|
2577
2636
|
* @returns {string | null}
|
|
2578
2637
|
*/
|
|
2579
2638
|
function checkUrlParams(parsed) {
|
|
2580
|
-
for (const [name, value] of rawParams(parsed.search.slice(1))) {
|
|
2581
|
-
const reason = paramExfilReason(name, value);
|
|
2639
|
+
for (const [name, value, rawName] of rawParams(parsed.search.slice(1))) {
|
|
2640
|
+
const reason = paramExfilReason(name, value, rawName);
|
|
2582
2641
|
if (reason) return reason;
|
|
2583
2642
|
}
|
|
2584
2643
|
// The fragment carries the same `key=value` channel (`#token=…`); a bare
|
|
2585
2644
|
// anchor (`#section-2`) yields one empty-value param that trips nothing.
|
|
2586
|
-
for (const [name, value] of rawParams(parsed.hash.slice(1))) {
|
|
2587
|
-
const reason = paramExfilReason(name, value);
|
|
2645
|
+
for (const [name, value, rawName] of rawParams(parsed.hash.slice(1))) {
|
|
2646
|
+
const reason = paramExfilReason(name, value, rawName);
|
|
2588
2647
|
if (reason) return reason;
|
|
2589
2648
|
}
|
|
2590
2649
|
return null;
|
|
@@ -2599,8 +2658,9 @@ function checkUrlParams(parsed) {
|
|
|
2599
2658
|
function checkUrlPath(parsed) {
|
|
2600
2659
|
for (const segment of parsed.pathname.split("/")) {
|
|
2601
2660
|
if (
|
|
2602
|
-
segment.length > PATH_BLOB_MIN_LEN &&
|
|
2603
|
-
|
|
2661
|
+
(segment.length > PATH_BLOB_MIN_LEN &&
|
|
2662
|
+
(PATH_BLOB_RE.test(segment) || isBase64UrlBlob(segment))) ||
|
|
2663
|
+
isChunkedPathBlob(segment)
|
|
2604
2664
|
)
|
|
2605
2665
|
return "encoded data blob in path segment";
|
|
2606
2666
|
}
|