agent-sanitizer 2.43.10 → 2.43.11
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 +40 -37
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.43.
|
|
3
|
+
"version": "2.43.11",
|
|
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
|
@@ -2481,52 +2481,55 @@ function decodedBlobMatch(value) {
|
|
|
2481
2481
|
* values, turning a `+`-bearing base64 blob into a space-broken string that the
|
|
2482
2482
|
* anchored blob regexes would miss.
|
|
2483
2483
|
* @param {string} qs
|
|
2484
|
-
* @returns {Array<[string, string]>}
|
|
2484
|
+
* @returns {Array<[string, string, string]>} [lowercased name, value, RAW (case-preserved) name]
|
|
2485
2485
|
*/
|
|
2486
2486
|
function rawParams(qs) {
|
|
2487
|
-
/** @type {Array<[string, string]>} */
|
|
2487
|
+
/** @type {Array<[string, string, string]>} */
|
|
2488
2488
|
const pairs = [];
|
|
2489
2489
|
for (const pair of qs.split(/[&;]/)) {
|
|
2490
2490
|
if (!pair) continue;
|
|
2491
2491
|
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]);
|
|
2492
|
+
const rawName = eq === -1 ? pair : pair.slice(0, eq);
|
|
2493
|
+
const value = eq === -1 ? "" : pair.slice(eq + 1);
|
|
2494
|
+
pairs.push([rawName.toLowerCase(), value, rawName]);
|
|
2500
2495
|
}
|
|
2501
2496
|
return pairs;
|
|
2502
2497
|
}
|
|
2503
2498
|
|
|
2504
2499
|
/**
|
|
2505
|
-
* Exfil reason for one URL parameter, or null. A credential-shaped
|
|
2506
|
-
*
|
|
2507
|
-
*
|
|
2508
|
-
*
|
|
2509
|
-
*
|
|
2510
|
-
*
|
|
2500
|
+
* Exfil reason for one URL parameter, or null. A credential-shaped or
|
|
2501
|
+
* blob-shaped run in EITHER half of the pair — the value, or the name — in any
|
|
2502
|
+
* non-allowlisted parameter. Testing the name too (not just the value) is what
|
|
2503
|
+
* catches a payload that lands on the wrong side of the `=`: `?<blob>`,
|
|
2504
|
+
* `?<blob>=`, `?<blob>==` (trailing `=`s read as padding) and `?<blob>=x` (any
|
|
2505
|
+
* non-padding tail) are one channel, since the server reads the query string
|
|
2506
|
+
* either way. Allowlisted signing/pagination/analytics parameters are skipped
|
|
2507
|
+
* entirely (see BENIGN_BLOB_PARAM_RE).
|
|
2508
|
+
* @param {string} name lowercased parameter name, for the allowlist gate
|
|
2509
|
+
* @param {string} value RAW (un-decoded) value
|
|
2510
|
+
* @param {string} rawName RAW (case-preserved, un-decoded) name
|
|
2511
2511
|
* @returns {string | null}
|
|
2512
2512
|
*/
|
|
2513
|
-
function paramExfilReason(name, value) {
|
|
2513
|
+
function paramExfilReason(name, value, rawName) {
|
|
2514
2514
|
if (BENIGN_BLOB_PARAM_RE.test(name)) return null;
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
opaqueRuns
|
|
2524
|
-
|
|
2515
|
+
for (const candidate of [rawName, value]) {
|
|
2516
|
+
if (!candidate) continue;
|
|
2517
|
+
// A leaked credential is an OPAQUE, separator-free token. Gate the
|
|
2518
|
+
// secret-shape/digit test on the CONTIGUOUS opaque run(s), not on the whole
|
|
2519
|
+
// prose candidate: a benign path-like value (`?redirect=/authorization-
|
|
2520
|
+
// service/…abcdefghij1234567890`) otherwise matches "authorization" in one
|
|
2521
|
+
// place and a 20-char run in another and false-fires. Requiring both on the
|
|
2522
|
+
// SAME run keeps `ghp_…`-style contiguous tokens firing while dropping prose.
|
|
2523
|
+
const opaqueRuns = candidate.match(OPAQUE_TOKEN_RE);
|
|
2524
|
+
if (
|
|
2525
|
+
opaqueRuns?.some(
|
|
2526
|
+
(run) => VALUE_HAS_DIGIT_RE.test(run) && matchesSecretHint(run),
|
|
2527
|
+
)
|
|
2525
2528
|
)
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2529
|
+
return "credential-shaped token in URL parameter";
|
|
2530
|
+
if (isBlobValue(candidate) || decodedBlobMatch(candidate))
|
|
2531
|
+
return "suspicious query parameter";
|
|
2532
|
+
}
|
|
2530
2533
|
return null;
|
|
2531
2534
|
}
|
|
2532
2535
|
|
|
@@ -2547,9 +2550,9 @@ function rawUrlKeywordExfil(url) {
|
|
|
2547
2550
|
const qIdx = url.search(/[?#]/);
|
|
2548
2551
|
if (qIdx === -1) return null;
|
|
2549
2552
|
for (const segment of url.slice(qIdx + 1).split("#")) {
|
|
2550
|
-
for (const [name, value] of rawParams(segment)) {
|
|
2553
|
+
for (const [name, value, rawName] of rawParams(segment)) {
|
|
2551
2554
|
if (!KEYWORD_PARAM_NAME_RE.test(name)) continue;
|
|
2552
|
-
const reason = paramExfilReason(name, value);
|
|
2555
|
+
const reason = paramExfilReason(name, value, rawName);
|
|
2553
2556
|
if (reason) return reason;
|
|
2554
2557
|
}
|
|
2555
2558
|
}
|
|
@@ -2577,14 +2580,14 @@ function allParamsBenign(parsed) {
|
|
|
2577
2580
|
* @returns {string | null}
|
|
2578
2581
|
*/
|
|
2579
2582
|
function checkUrlParams(parsed) {
|
|
2580
|
-
for (const [name, value] of rawParams(parsed.search.slice(1))) {
|
|
2581
|
-
const reason = paramExfilReason(name, value);
|
|
2583
|
+
for (const [name, value, rawName] of rawParams(parsed.search.slice(1))) {
|
|
2584
|
+
const reason = paramExfilReason(name, value, rawName);
|
|
2582
2585
|
if (reason) return reason;
|
|
2583
2586
|
}
|
|
2584
2587
|
// The fragment carries the same `key=value` channel (`#token=…`); a bare
|
|
2585
2588
|
// 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);
|
|
2589
|
+
for (const [name, value, rawName] of rawParams(parsed.hash.slice(1))) {
|
|
2590
|
+
const reason = paramExfilReason(name, value, rawName);
|
|
2588
2591
|
if (reason) return reason;
|
|
2589
2592
|
}
|
|
2590
2593
|
return null;
|