agent-sanitizer 2.52.0 → 2.54.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.
@@ -0,0 +1,28 @@
1
+ Third-party code redistributed in this package.
2
+
3
+ src/vendor/gfm-autolink-literal.mjs
4
+ Copied from micromark-extension-gfm-autolink-literal@2.1.0, with one
5
+ function changed (see that file's header). Original licence follows.
6
+
7
+ (The MIT License)
8
+
9
+ Copyright (c) 2020 Titus Wormer <tituswormer@gmail.com>
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining
12
+ a copy of this software and associated documentation files (the
13
+ 'Software'), to deal in the Software without restriction, including
14
+ without limitation the rights to use, copy, modify, merge, publish,
15
+ distribute, sublicense, and/or sell copies of the Software, and to
16
+ permit persons to whom the Software is furnished to do so, subject to
17
+ the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be
20
+ included in all copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
23
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
24
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
25
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
26
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
27
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
28
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -40,6 +40,15 @@ const result = await sanitize(pageSource, { html: true });
40
40
  // Layer 3 alone: flag exfil-shaped URLs without splicing anything (for text
41
41
  // that must stay byte-faithful, e.g. a PR diff). Implied by `html: true`.
42
42
  const scanned = await sanitize(diffText, { exfilScan: true });
43
+
44
+ // Layer 3 reads an exact-digest-length hex value under a generic parameter
45
+ // name (`?v=<md5>`, an ETag, a commit id) as a fingerprint. `flagDigestValues`
46
+ // reports it as payload instead — more false positives, no 16-to-64-byte
47
+ // channel under a name the caller picks. For monitors, not for splicing.
48
+ const strict = await sanitize(logText, {
49
+ exfilScan: true,
50
+ flagDigestValues: true,
51
+ });
43
52
  ```
44
53
 
45
54
  `sanitize` never throws and never silently drops content—any change comes with
package/THREAT-MODEL.md CHANGED
@@ -140,6 +140,18 @@ attributes (`src`/`href`/`background`/`srcset`/`ping`, form `action`/`formaction
140
140
  - off-origin form actions and `meta refresh` redirects
141
141
  - `javascript:` / `vbscript:` targets
142
142
 
143
+ **The digest exemption, and the switch that lifts it.** A value that is
144
+ exactly one digest width of hex (32/40/56/64/96/128) under a generic parameter
145
+ name reads as a fingerprint, not a payload: a cache-buster `?v=<md5>`, an ETag,
146
+ a request id, a git commit, imgix's `?s=`. Under a name that already says
147
+ credential the same characters read as payload and still flag. That leaves a
148
+ residual, because the caller writing the URL picks the name: a payload padded to
149
+ exactly one digest length rides under a generic one, buying 16 to 64 bytes per
150
+ parameter. `flagDigestValues` moves the trade-off — it drops the exemption
151
+ entirely, at the cost of flagging every real fingerprint — for a caller whose
152
+ job is monitoring rather than presenting text to a model. Like every Layer 3
153
+ option it can only ADD detection; there is no switch that turns a report off.
154
+
143
155
  Each threat carries a `reason` and the destination `target` (never the
144
156
  payload-bearing query/fragment) — the finding is shown to the operator with the
145
157
  target named and the payload withheld, since re-presenting the exfil payload in
@@ -15,7 +15,7 @@
15
15
  * bare `{ text, html }` keeps working). Per op:
16
16
  *
17
17
  * sanitize { text, html? } -> { cleaned, found, warnings, notes, splices? }
18
- * sanitizeText { text, html?, exfilScan? } -> { cleaned, warnings, notes, modified, sgrNote }
18
+ * sanitizeText { text, html?, exfilScan?, flagDigestValues? } -> { cleaned, warnings, notes, modified, sgrNote }
19
19
  * classifyPrompt { text } -> { action, reason? }
20
20
  * scanInstructionFiles { globs, cwd? } -> { findings: [{ file, findings }] }
21
21
  * cleanFile { path } -> { changed }
@@ -135,6 +135,7 @@ export const OPS = {
135
135
  {
136
136
  html: Boolean(req.html),
137
137
  exfilScan: Boolean(req.exfilScan),
138
+ flagDigestValues: Boolean(req.flagDigestValues),
138
139
  },
139
140
  );
140
141
  return { cleaned, warnings, notes, modified, sgrNote };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.52.0",
3
+ "version": "2.54.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": {
@@ -65,6 +65,7 @@
65
65
  "lint-staged": "^17.0.5",
66
66
  "prettier": "^3.0.0",
67
67
  "rehype-parse": "9.0.1",
68
+ "remark-gfm": "4.0.1",
68
69
  "smol-toml": "^1.7.1",
69
70
  "typescript": "6.0.3",
70
71
  "typescript-eslint": "8.61.0",
@@ -204,6 +205,7 @@
204
205
  },
205
206
  "files": [
206
207
  "src/*.mjs",
208
+ "src/vendor/*.mjs",
207
209
  "python/agent_sanitizer/data/invisible-charset.json",
208
210
  "python/agent_sanitizer/secrets/data/credential-names.json",
209
211
  "python/agent_sanitizer/secrets/data/redaction-floor.json",
@@ -213,6 +215,7 @@
213
215
  "bin/sanitize-cli.mjs",
214
216
  "types",
215
217
  "LICENSE",
218
+ "LICENSE-THIRD-PARTY",
216
219
  "README.md",
217
220
  "THREAT-MODEL.md",
218
221
  "SECURITY.md"
@@ -221,9 +224,16 @@
221
224
  "agent-control-plane-core": "0.3.0",
222
225
  "css-tree": "^3.2.1",
223
226
  "hast-util-from-parse5": "8.0.3",
227
+ "mdast-util-gfm": "3.1.0",
228
+ "micromark-extension-gfm-footnote": "2.1.0",
229
+ "micromark-extension-gfm-strikethrough": "2.1.0",
230
+ "micromark-extension-gfm-table": "2.1.1",
231
+ "micromark-extension-gfm-task-list-item": "2.1.0",
232
+ "micromark-util-character": "2.1.1",
233
+ "micromark-util-combine-extensions": "2.0.1",
234
+ "micromark-util-types": "2.0.2",
224
235
  "namespace-guard": "0.20.0",
225
236
  "parse5": "7.3.0",
226
- "remark-gfm": "4.0.1",
227
237
  "remark-parse": "11.0.0",
228
238
  "unified": "11.0.5",
229
239
  "unist-util-visit": "5.1.0",
package/src/gfm.mjs ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * `remark-gfm` with one extension swapped for a corrected copy.
3
+ *
4
+ * This is `remark-gfm@4.0.1`'s own plugin body and
5
+ * `micromark-extension-gfm@3.0.0`'s own `gfm()` composition, inlined so that
6
+ * `gfmAutolinkLiteral` can come from `./vendor/gfm-autolink-literal.mjs`
7
+ * instead of the published package — see that file for what differs and when
8
+ * to delete both. Every other extension, and the mdast layer, are the upstream
9
+ * ones at the versions `remark-gfm` pins.
10
+ *
11
+ * Assembling the list here rather than adding an extension beside `remark-gfm`
12
+ * is deliberate: micromark tries the constructs registered for a character in
13
+ * order, so a second autolink extension would run AFTER the upstream one and
14
+ * change nothing.
15
+ *
16
+ * `test/gfm-autolink-parity.test.mjs` pins this against upstream's answers.
17
+ */
18
+ import { gfmFromMarkdown, gfmToMarkdown } from "mdast-util-gfm";
19
+ import { gfmFootnote } from "micromark-extension-gfm-footnote";
20
+ import { gfmStrikethrough } from "micromark-extension-gfm-strikethrough";
21
+ import { gfmTable } from "micromark-extension-gfm-table";
22
+ import { gfmTaskListItem } from "micromark-extension-gfm-task-list-item";
23
+ import { combineExtensions } from "micromark-util-combine-extensions";
24
+
25
+ import { gfmAutolinkLiteral } from "./vendor/gfm-autolink-literal.mjs";
26
+
27
+ /**
28
+ * A unified plugin adding GFM support: autolink literals, footnotes,
29
+ * strikethrough, tables and task lists — the same set, in the same order, as
30
+ * `remark-gfm`.
31
+ * @this {any} unified processor
32
+ * @returns {undefined}
33
+ */
34
+ export default function remarkGfmFixed() {
35
+ const data = this.data();
36
+ const micromarkExtensions =
37
+ data.micromarkExtensions || (data.micromarkExtensions = []);
38
+ const fromMarkdownExtensions =
39
+ data.fromMarkdownExtensions || (data.fromMarkdownExtensions = []);
40
+ const toMarkdownExtensions =
41
+ data.toMarkdownExtensions || (data.toMarkdownExtensions = []);
42
+
43
+ micromarkExtensions.push(
44
+ combineExtensions([
45
+ gfmAutolinkLiteral(),
46
+ gfmFootnote(),
47
+ gfmStrikethrough(),
48
+ gfmTable(),
49
+ gfmTaskListItem(),
50
+ ]),
51
+ );
52
+ fromMarkdownExtensions.push(gfmFromMarkdown());
53
+ toMarkdownExtensions.push(gfmToMarkdown());
54
+ }
package/src/html.mjs CHANGED
@@ -48,7 +48,7 @@ import cssGenerate from "css-tree/generator";
48
48
  import { ident as cssIdent } from "css-tree/utils";
49
49
  import { unified } from "unified";
50
50
  import remarkParse from "remark-parse";
51
- import remarkGfm from "remark-gfm";
51
+ import remarkGfm from "./gfm.mjs";
52
52
  import { parseHtmlFragment } from "./html-tree-adapter.mjs";
53
53
  import { SKIP, EXIT } from "unist-util-visit";
54
54
  import {
@@ -2720,9 +2720,13 @@ function rawParams(qs) {
2720
2720
  * @param {string} name lowercased parameter name, for the allowlist gate
2721
2721
  * @param {string} value RAW (un-decoded) value
2722
2722
  * @param {string} rawName RAW (case-preserved, un-decoded) name
2723
+ * @param {boolean} flagDigestValues drop the digest exemption entirely. The
2724
+ * name is the weak half of the test above, because the caller writing the
2725
+ * URL picks it: a payload padded to exactly one digest length otherwise
2726
+ * rides under any generic name.
2723
2727
  * @returns {string | null}
2724
2728
  */
2725
- function paramExfilReason(name, value, rawName) {
2729
+ function paramExfilReason(name, value, rawName, flagDigestValues) {
2726
2730
  if (BENIGN_BLOB_PARAM_RE.test(name)) return null;
2727
2731
  const publicKeyId =
2728
2732
  PUBLIC_KEY_ID_PARAM_RE.test(name) && value.length < BLOB_VALUE_MIN_LEN;
@@ -2730,9 +2734,9 @@ function paramExfilReason(name, value, rawName) {
2730
2734
  // — a cache-buster `?v=`, an ETag, imgix's `?s=`, a commit id. Under a name
2731
2735
  // that already says credential the same 64 hex characters read as 32 bytes of
2732
2736
  // payload, so the exemption stops there.
2733
- const digestIsBenign = !(
2734
- KEYWORD_PARAM_NAME_RE.test(name) || matchesSecretHint(name)
2735
- );
2737
+ const digestIsBenign =
2738
+ !flagDigestValues &&
2739
+ !(KEYWORD_PARAM_NAME_RE.test(name) || matchesSecretHint(name));
2736
2740
  for (const candidate of [rawName, value]) {
2737
2741
  if (!candidate) continue;
2738
2742
  // A leaked credential is an OPAQUE, separator-free token. Gate the
@@ -2784,7 +2788,10 @@ function rawUrlKeywordExfil(url) {
2784
2788
  for (const segment of url.slice(qIdx + 1).split("#")) {
2785
2789
  for (const [name, value, rawName] of rawParams(segment)) {
2786
2790
  if (!KEYWORD_PARAM_NAME_RE.test(name)) continue;
2787
- const reason = paramExfilReason(name, value, rawName);
2791
+ // Only credential-named params reach here, and the digest exemption never
2792
+ // applies to those, so there is no exemption for `flagDigestValues` to
2793
+ // lift on this path.
2794
+ const reason = paramExfilReason(name, value, rawName, false);
2788
2795
  if (reason) return reason;
2789
2796
  }
2790
2797
  }
@@ -2826,19 +2833,18 @@ function allParamsBenign(parsed) {
2826
2833
  /**
2827
2834
  * Walk the query and fragment parameters of a parsed URL for an exfil reason.
2828
2835
  * @param {URL} parsed
2836
+ * @param {boolean} flagDigestValues
2829
2837
  * @returns {string | null}
2830
2838
  */
2831
- function checkUrlParams(parsed) {
2832
- for (const [name, value, rawName] of rawParams(parsed.search.slice(1))) {
2833
- const reason = paramExfilReason(name, value, rawName);
2834
- if (reason) return reason;
2835
- }
2836
- // The fragment carries the same `key=value` channel (`#token=…`); a bare
2837
- // anchor (`#section-2`) yields one empty-value param that trips nothing.
2838
- for (const [name, value, rawName] of rawParams(parsed.hash.slice(1))) {
2839
- const reason = paramExfilReason(name, value, rawName);
2840
- if (reason) return reason;
2841
- }
2839
+ function checkUrlParams(parsed, flagDigestValues) {
2840
+ // Query and fragment are one channel: `#token=…` carries what `?token=…` does,
2841
+ // and a bare anchor (`#section-2`) yields one empty-value param that trips
2842
+ // nothing.
2843
+ for (const segment of [parsed.search.slice(1), parsed.hash.slice(1)])
2844
+ for (const [name, value, rawName] of rawParams(segment)) {
2845
+ const reason = paramExfilReason(name, value, rawName, flagDigestValues);
2846
+ if (reason) return reason;
2847
+ }
2842
2848
  return null;
2843
2849
  }
2844
2850
 
@@ -2861,10 +2867,18 @@ function checkUrlPath(parsed) {
2861
2867
  }
2862
2868
 
2863
2869
  /**
2870
+ * `flagDigestValues` drops the digest exemption: an exact-digest-length hex
2871
+ * value under a generic parameter name is reported as payload rather than read
2872
+ * as a fingerprint. Off by default because the exemption is what keeps a
2873
+ * cache-buster, an ETag and a commit id quiet; on for a caller whose cost of a
2874
+ * missed 16-to-64-byte channel beats its cost of those false positives. Like
2875
+ * every option this module takes, it can only ADD detection.
2864
2876
  * @param {string} url
2877
+ * @param {{ flagDigestValues?: boolean }} [options]
2865
2878
  * @returns {string | null}
2866
2879
  */
2867
- export function checkExfilUrl(url) {
2880
+ export function checkExfilUrl(url, options = {}) {
2881
+ const { flagDigestValues = false } = options;
2868
2882
  // A browser strips tab/newline/CR ANYWHERE in a URL before resolving its
2869
2883
  // scheme, so `java\tscript:alert(1)` navigates as `javascript:`. Strip them
2870
2884
  // for the scheme tests (the payload/length checks below keep the raw string).
@@ -2913,7 +2927,7 @@ export function checkExfilUrl(url) {
2913
2927
  return "unusually long query string";
2914
2928
  if (parsed.hash.length > LONG_QUERY_THRESHOLD)
2915
2929
  return "unusually long fragment";
2916
- return checkUrlParams(parsed) || checkUrlPath(parsed);
2930
+ return checkUrlParams(parsed, flagDigestValues) || checkUrlPath(parsed);
2917
2931
  }
2918
2932
 
2919
2933
  /**
@@ -3177,9 +3191,10 @@ function collectUrls(text) {
3177
3191
  * link somebody has to follow. Both are reported; the caller uses it to decide
3178
3192
  * how loudly (see the exfil tier in ./output.mjs).
3179
3193
  * @param {string} text
3194
+ * @param {{ flagDigestValues?: boolean }} [options] see {@link checkExfilUrl}
3180
3195
  * @returns {Array<{ isImage: boolean, autoFetched: boolean, reason: string, target: string }> | null}
3181
3196
  */
3182
- export function detectExfil(text) {
3197
+ export function detectExfil(text, options = {}) {
3183
3198
  if (!needsUrlScan(text)) return null;
3184
3199
 
3185
3200
  /** @type {Array<{ isImage: boolean, autoFetched: boolean, reason: string, target: string }>} */
@@ -3188,7 +3203,7 @@ export function detectExfil(text) {
3188
3203
  try {
3189
3204
  for (const { url, isImage, autoFetched, context } of collectUrls(text)) {
3190
3205
  const reason =
3191
- checkExfilUrl(url) ||
3206
+ checkExfilUrl(url, options) ||
3192
3207
  (context !== "resource" && isOffOrigin(url)
3193
3208
  ? OFF_ORIGIN_REASON[context]
3194
3209
  : null);
package/src/index.mjs CHANGED
@@ -102,14 +102,23 @@ export {
102
102
  * legitimate markup — matching the separate flags `sanitizeText` takes for the
103
103
  * tool-output pipeline, which needs Layer 3's detection without Layer 2's
104
104
  * splice.
105
+ *
106
+ * `flagDigestValues` widens Layer 3 only: it drops the digest exemption, so an
107
+ * exact-digest-length hex value under a generic parameter name is reported as
108
+ * payload rather than read as a cache-buster or an ETag. Off by default, and
109
+ * like `exfilScan` it can only ADD detection.
105
110
  * @param {string} text
106
- * @param {{ html?: boolean, exfilScan?: boolean } | null} [options]
111
+ * @param {{ html?: boolean, exfilScan?: boolean, flagDigestValues?: boolean } | null} [options]
107
112
  * @returns {Promise<{ cleaned: string, found: string[], warnings: string[], notes: string[], splices?: Array<{ placeholder: string, original: string }> }>}
108
113
  */
109
114
  export async function sanitize(text, options) {
110
115
  if (typeof text !== "string")
111
116
  throw new TypeError("sanitize(text, options): text must be a string");
112
- const { html = false, exfilScan = false } = options ?? {};
117
+ const {
118
+ html = false,
119
+ exfilScan = false,
120
+ flagDigestValues = false,
121
+ } = options ?? {};
113
122
  const { cleaned, found, warnings, notes, splices } = await sanitizeText(
114
123
  text,
115
124
  {
@@ -118,6 +127,7 @@ export async function sanitize(text, options) {
118
127
  // an opt-OUT would make `{ html: true, exfilScan: false }` splice Layer 2
119
128
  // while silently dropping Layer 3's report — a fail-open the docs deny.
120
129
  exfilScan: exfilScan || html,
130
+ flagDigestValues,
121
131
  },
122
132
  );
123
133
  return {
package/src/output.mjs CHANGED
@@ -393,10 +393,13 @@ function processLayer1(text, sgrCarveOut) {
393
393
  * vet them before they leave. The transform itself stays pure — the caller owns
394
394
  * any persistence.
395
395
  * @param {PipelineState} state
396
- * @param {{ html?: boolean, exfilScan?: boolean, deadline?: Deadline }} options
396
+ * @param {{ html?: boolean, exfilScan?: boolean, flagDigestValues?: boolean, deadline?: Deadline }} options
397
397
  * @returns {Promise<{ reveal: string | undefined, splices: Array<{ placeholder: string, original: string }> }>}
398
398
  */
399
- async function applyMarkdownPipeline(state, { html, exfilScan, deadline }) {
399
+ async function applyMarkdownPipeline(
400
+ state,
401
+ { html, exfilScan, flagDigestValues, deadline },
402
+ ) {
400
403
  const inputText = state.text;
401
404
  /** @type {string | undefined} */
402
405
  let reveal;
@@ -485,7 +488,7 @@ async function applyMarkdownPipeline(state, { html, exfilScan, deadline }) {
485
488
  // suspicious, not less, yet Layer 2 has already removed it from `cleaned`.
486
489
  if (runLayer3) {
487
490
  refuseIfSpent();
488
- const threats = detectExfil(inputText);
491
+ const threats = detectExfil(inputText, { flagDigestValues });
489
492
  // Severity tracks who does the fetching. An auto-fetched target — an image,
490
493
  // a stylesheet, a form action, a meta refresh — exfiltrates the moment the
491
494
  // content renders, with nobody deciding anything: a WARNING. A plain LINK
@@ -571,6 +574,7 @@ async function vetStageValue(text, redact, findings, label) {
571
574
  * @typedef {{
572
575
  * html?: boolean,
573
576
  * exfilScan?: boolean,
577
+ * flagDigestValues?: boolean,
574
578
  * redact?: (text: string) => Promise<RedactResult|null> | (RedactResult|null),
575
579
  * filterInjection?: (text: string) => Promise<Layer5Result|null> | (Layer5Result|null),
576
580
  * sgrCarveOut?: boolean,