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,
|
|
446
|
-
*
|
|
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.
|
|
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 `
|
|
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|
|
|
2396
|
-
|
|
2397
|
-
// The SHORT-valued companions of a signed-CDN link
|
|
2398
|
-
//
|
|
2399
|
-
//
|
|
2400
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
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,
|
|
103
|
-
*
|
|
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
|