agent-sanitizer 2.47.9 → 2.47.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 +6 -3
- package/src/html-tree-adapter.mjs +143 -0
- package/src/html.mjs +18 -21
- package/types/html-tree-adapter.d.mts +23 -0
- package/types/html.d.mts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.47.
|
|
3
|
+
"version": "2.47.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": {
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
"globals": "17.6.0",
|
|
64
64
|
"lint-staged": "^17.0.5",
|
|
65
65
|
"prettier": "^3.0.0",
|
|
66
|
+
"rehype-parse": "9.0.1",
|
|
66
67
|
"smol-toml": "^1.7.1",
|
|
67
68
|
"typescript": "6.0.3",
|
|
68
69
|
"typescript-eslint": "8.61.0",
|
|
@@ -214,12 +215,14 @@
|
|
|
214
215
|
"dependencies": {
|
|
215
216
|
"agent-control-plane-core": "0.3.0",
|
|
216
217
|
"css-tree": "^3.2.1",
|
|
218
|
+
"hast-util-from-parse5": "8.0.3",
|
|
217
219
|
"namespace-guard": "0.20.0",
|
|
218
|
-
"
|
|
220
|
+
"parse5": "7.3.0",
|
|
219
221
|
"remark-gfm": "4.0.1",
|
|
220
222
|
"remark-parse": "11.0.0",
|
|
221
223
|
"unified": "11.0.5",
|
|
222
|
-
"unist-util-visit": "5.1.0"
|
|
224
|
+
"unist-util-visit": "5.1.0",
|
|
225
|
+
"vfile": "6.0.3"
|
|
223
226
|
},
|
|
224
227
|
"scripts": {
|
|
225
228
|
"test": "node scripts/coverage.mjs",
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parse5 tree adapter this package parses HTML fragments with, and the
|
|
3
|
+
* fragment parse itself.
|
|
4
|
+
*
|
|
5
|
+
* WHY IT EXISTS — parse5 ends a fragment parse by moving every child of the
|
|
6
|
+
* parsed `html` element into the fragment it returns, one at a time, always
|
|
7
|
+
* taking the first (`_adoptNodes`). The default tree adapter removes each one
|
|
8
|
+
* with `childNodes.splice(0, 1)`, which shifts every remaining sibling: for a
|
|
9
|
+
* document with N top-level nodes that is N^2/2 element moves. 256 KB of
|
|
10
|
+
* ordinary `<p>` prose is ~8600 of them, and the removal alone was a quarter of
|
|
11
|
+
* the whole HTML layer's cost. The cursor below turns each removal into an
|
|
12
|
+
* increment, so the drain is linear in N.
|
|
13
|
+
*
|
|
14
|
+
* It is a separate module, and not in the package's `exports` map, because it
|
|
15
|
+
* is an implementation detail of `./html` that its own test parses against.
|
|
16
|
+
*/
|
|
17
|
+
import {
|
|
18
|
+
defaultTreeAdapter,
|
|
19
|
+
parseFragment as parse5ParseFragment,
|
|
20
|
+
} from "parse5";
|
|
21
|
+
import { fromParse5 } from "hast-util-from-parse5";
|
|
22
|
+
import { VFile } from "vfile";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A tree adapter for ONE parse, plus the settle step that ends it.
|
|
26
|
+
*
|
|
27
|
+
* The pending state is per-parse rather than module-level so that a parse which
|
|
28
|
+
* throws cannot leave a deferred removal for the next one to inherit.
|
|
29
|
+
* @returns {{ adapter: any, settle: () => void }}
|
|
30
|
+
*/
|
|
31
|
+
export function createTreeAdapter() {
|
|
32
|
+
/**
|
|
33
|
+
* Where a parent's un-detached children start, for every parent with a
|
|
34
|
+
* removal still deferred.
|
|
35
|
+
* @type {Map<any, number>}
|
|
36
|
+
*/
|
|
37
|
+
const drainedUpTo = new Map();
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Put `parent.childNodes` in the state the default adapter's splices would
|
|
41
|
+
* have left it in.
|
|
42
|
+
*
|
|
43
|
+
* INVARIANT — every adapter method that reads or writes a parent's children
|
|
44
|
+
* BY POSITION calls this first, so a deferred removal is never observable to
|
|
45
|
+
* parse5, which reaches every child through this adapter. `setDocumentType`
|
|
46
|
+
* is the one method that scans a node's children without going through those:
|
|
47
|
+
* it scans the DOCUMENT's, and a fragment parse detaches nothing from the
|
|
48
|
+
* document.
|
|
49
|
+
* @param {any} parent
|
|
50
|
+
*/
|
|
51
|
+
function flush(parent) {
|
|
52
|
+
const at = drainedUpTo.get(parent);
|
|
53
|
+
if (at === undefined) return;
|
|
54
|
+
drainedUpTo.delete(parent);
|
|
55
|
+
parent.childNodes.splice(0, at);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Flush every parent still holding a deferred removal.
|
|
60
|
+
*
|
|
61
|
+
* This is what makes the deferral invisible to the tree's READERS, which get
|
|
62
|
+
* the nodes directly and not through the adapter: `hast-util-from-parse5`
|
|
63
|
+
* here, and any consumer of the parse5 AST. Call it once the parse has
|
|
64
|
+
* returned, before anything reads the tree.
|
|
65
|
+
*/
|
|
66
|
+
function settle() {
|
|
67
|
+
for (const parent of [...drainedUpTo.keys()]) flush(parent);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const adapter = {
|
|
71
|
+
...defaultTreeAdapter,
|
|
72
|
+
/** @param {any} node */
|
|
73
|
+
detachNode(node) {
|
|
74
|
+
const parent = node.parentNode;
|
|
75
|
+
if (!parent) return;
|
|
76
|
+
const at = drainedUpTo.get(parent) ?? 0;
|
|
77
|
+
// The front of the un-drained region: record the removal and move on. Any
|
|
78
|
+
// other position is a mid-tree detach (the adoption agency algorithm,
|
|
79
|
+
// foster parenting), which is rare and takes the default adapter's splice.
|
|
80
|
+
if (parent.childNodes[at] === node) {
|
|
81
|
+
drainedUpTo.set(parent, at + 1);
|
|
82
|
+
node.parentNode = null;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
flush(parent);
|
|
86
|
+
defaultTreeAdapter.detachNode(node);
|
|
87
|
+
},
|
|
88
|
+
/** @param {any} node */
|
|
89
|
+
getFirstChild(node) {
|
|
90
|
+
return node.childNodes[drainedUpTo.get(node) ?? 0];
|
|
91
|
+
},
|
|
92
|
+
/** @param {any} node */
|
|
93
|
+
getChildNodes(node) {
|
|
94
|
+
flush(node);
|
|
95
|
+
return defaultTreeAdapter.getChildNodes(node);
|
|
96
|
+
},
|
|
97
|
+
/** @param {any} parent @param {any} child */
|
|
98
|
+
appendChild(parent, child) {
|
|
99
|
+
flush(parent);
|
|
100
|
+
defaultTreeAdapter.appendChild(parent, child);
|
|
101
|
+
},
|
|
102
|
+
/** @param {any} parent @param {any} child @param {any} reference */
|
|
103
|
+
insertBefore(parent, child, reference) {
|
|
104
|
+
flush(parent);
|
|
105
|
+
defaultTreeAdapter.insertBefore(parent, child, reference);
|
|
106
|
+
},
|
|
107
|
+
/** @param {any} parent @param {string} text */
|
|
108
|
+
insertText(parent, text) {
|
|
109
|
+
flush(parent);
|
|
110
|
+
defaultTreeAdapter.insertText(parent, text);
|
|
111
|
+
},
|
|
112
|
+
/** @param {any} parent @param {string} text @param {any} reference */
|
|
113
|
+
insertTextBefore(parent, text, reference) {
|
|
114
|
+
flush(parent);
|
|
115
|
+
defaultTreeAdapter.insertTextBefore(parent, text, reference);
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
return { adapter, settle };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Parse `html` as an HTML fragment and return the hast tree.
|
|
124
|
+
*
|
|
125
|
+
* This is `hast-util-from-html`'s fragment path with {@link createTreeAdapter}'s
|
|
126
|
+
* adapter in place of the default one — the option `rehype-parse` does not
|
|
127
|
+
* forward. The settings are that path's own: positions on, parse errors ignored
|
|
128
|
+
* (this package reports on the tree, never on the tokenizer's complaints), and
|
|
129
|
+
* scripting off, so `noscript` content parses as markup.
|
|
130
|
+
* @param {string} html
|
|
131
|
+
* @returns {any}
|
|
132
|
+
*/
|
|
133
|
+
export function parseHtmlFragment(html) {
|
|
134
|
+
const { adapter, settle } = createTreeAdapter();
|
|
135
|
+
const fragment = parse5ParseFragment(html, {
|
|
136
|
+
sourceCodeLocationInfo: true,
|
|
137
|
+
onParseError: null,
|
|
138
|
+
scriptingEnabled: false,
|
|
139
|
+
treeAdapter: adapter,
|
|
140
|
+
});
|
|
141
|
+
settle();
|
|
142
|
+
return fromParse5(fragment, { file: new VFile(html) });
|
|
143
|
+
}
|
package/src/html.mjs
CHANGED
|
@@ -49,7 +49,7 @@ import { ident as cssIdent } from "css-tree/utils";
|
|
|
49
49
|
import { unified } from "unified";
|
|
50
50
|
import remarkParse from "remark-parse";
|
|
51
51
|
import remarkGfm from "remark-gfm";
|
|
52
|
-
import
|
|
52
|
+
import { parseHtmlFragment } from "./html-tree-adapter.mjs";
|
|
53
53
|
import { SKIP, EXIT } from "unist-util-visit";
|
|
54
54
|
import {
|
|
55
55
|
HTML_TAG_PRESENT,
|
|
@@ -1420,11 +1420,6 @@ function hasDataSrc(el) {
|
|
|
1420
1420
|
);
|
|
1421
1421
|
}
|
|
1422
1422
|
|
|
1423
|
-
// One shared fragment parser for every HTML parse in this module (mirroring
|
|
1424
|
-
// `mdParser` below): all of them must agree on the tokenizer's verdict, so
|
|
1425
|
-
// there is exactly one parser configuration to reason about.
|
|
1426
|
-
const htmlParser = unified().use(rehypeParse, { fragment: true });
|
|
1427
|
-
|
|
1428
1423
|
/**
|
|
1429
1424
|
* Preorder depth-first walk of a unist tree, calling `visitor(node, index,
|
|
1430
1425
|
* parent)` on every node whose `type` is `test` (or on every node when `test` is
|
|
@@ -1507,11 +1502,12 @@ function lastParseCached(parse) {
|
|
|
1507
1502
|
}
|
|
1508
1503
|
|
|
1509
1504
|
/**
|
|
1510
|
-
*
|
|
1511
|
-
*
|
|
1512
|
-
*
|
|
1505
|
+
* The one HTML parse in this module: the real tokenizer (parse5, through
|
|
1506
|
+
* `./html-tree-adapter.mjs`), with the last (input, tree) pair remembered. Every
|
|
1507
|
+
* parse here must agree on the tokenizer's verdict, so there is exactly one
|
|
1508
|
+
* parser configuration to reason about.
|
|
1513
1509
|
*/
|
|
1514
|
-
const parseFragment = lastParseCached(
|
|
1510
|
+
const parseFragment = lastParseCached(parseHtmlFragment);
|
|
1515
1511
|
|
|
1516
1512
|
/**
|
|
1517
1513
|
* @param {string} htmlValue
|
|
@@ -1722,7 +1718,7 @@ function hasWarned(warned) {
|
|
|
1722
1718
|
/**
|
|
1723
1719
|
* Scan raw HTML for hidden content to strip and preserved tags to report.
|
|
1724
1720
|
* Returned ranges are offsets into `html`; comments and hidden elements span
|
|
1725
|
-
* the whole element including its content (
|
|
1721
|
+
* the whole element including its content (hast positions cover open tag
|
|
1726
1722
|
* through matching close, and parse5 extends an unclosed element to the end
|
|
1727
1723
|
* of the fragment — fail-closed for truncated markup).
|
|
1728
1724
|
* @param {string} html
|
|
@@ -1803,8 +1799,8 @@ const BOGUS_COMMENT_OPEN_RE = /<[!?]/g;
|
|
|
1803
1799
|
// comment / declaration (`<!`, `<?`). Per the HTML tokenizer such a construct
|
|
1804
1800
|
// keeps consuming the input stream until the next `>`, so it absorbs the
|
|
1805
1801
|
// following inline-html node (an open tag swallows it as bogus attributes; a
|
|
1806
|
-
// bogus end tag / `<!…` opens a bogus comment). parse5 (the flow/source branch
|
|
1807
|
-
//
|
|
1802
|
+
// bogus end tag / `<!…` opens a bogus comment). parse5 (the flow/source branch)
|
|
1803
|
+
// models this; the per-tag balance walk below does not, so without
|
|
1808
1804
|
// this a fragment parses differently as a flow block than as a paragraph —
|
|
1809
1805
|
// breaking idempotency once a first pass demotes a block to phrasing (see
|
|
1810
1806
|
// html-property "second pass changes nothing"). An open/end tag requires a
|
|
@@ -1830,7 +1826,7 @@ function foldAbsorb(absorbing, raw) {
|
|
|
1830
1826
|
|
|
1831
1827
|
/**
|
|
1832
1828
|
* Map of comment start-offset -> end-offset (exclusive) for EVERY comment the
|
|
1833
|
-
* HTML tokenizer finds in `value`, from a SINGLE
|
|
1829
|
+
* HTML tokenizer finds in `value`, from a SINGLE parse5 parse. Validated against
|
|
1834
1830
|
* the real tokenizer (parse5) rather than a hand-rolled bogus-comment state
|
|
1835
1831
|
* machine, so a bogus comment (`<!bogus>`, `<?php?>`, `<![CDATA[…]]>`) is spliced
|
|
1836
1832
|
* to exactly the span a browser hides and a `<Foo>` element, a `<!doctype>`, or
|
|
@@ -1964,7 +1960,7 @@ function hasHtmlLeaf(node) {
|
|
|
1964
1960
|
* the container's end when unbalanced — fail-closed), comments become
|
|
1965
1961
|
* single-node ranges, and preserved tags are counted. Inline html is tokenized
|
|
1966
1962
|
* per TAG (an element's content sits in sibling text nodes), which is why this
|
|
1967
|
-
* walk exists instead of handing the value to
|
|
1963
|
+
* walk exists instead of handing the value to parse5.
|
|
1968
1964
|
*
|
|
1969
1965
|
* The absorb state is folded from the RAW source between html nodes (not from
|
|
1970
1966
|
* mdast node values), so markdown constructs that reshuffle the character
|
|
@@ -2076,7 +2072,7 @@ function scanMarkdown(text) {
|
|
|
2076
2072
|
const ranges = [];
|
|
2077
2073
|
const warned = newWarned();
|
|
2078
2074
|
|
|
2079
|
-
// Flow html blocks carry complete markup, so
|
|
2075
|
+
// Flow html blocks carry complete markup, so parse5 locates comments/hidden
|
|
2080
2076
|
// elements precisely within them; block-local offsets are shifted to
|
|
2081
2077
|
// document coordinates.
|
|
2082
2078
|
walk(tree, "html", (/** @type {any} */ node, _index, parent) => {
|
|
@@ -2137,7 +2133,7 @@ function hasMarkdownCode(text) {
|
|
|
2137
2133
|
* The parsed fragment tree for `text` when `text` is HTML *source*, else null.
|
|
2138
2134
|
*
|
|
2139
2135
|
* "HTML source" means the markup accounts for the WHOLE document: the real
|
|
2140
|
-
* tokenizer (parse5
|
|
2136
|
+
* tokenizer (parse5) places every element there is, and the only
|
|
2141
2137
|
* character data it leaves OUTSIDE all of them is whitespace. That is exactly
|
|
2142
2138
|
* the property the source branch needs — it hands the whole input to
|
|
2143
2139
|
* `scanHtmlFragment` as one fragment, which is faithful only when there is no
|
|
@@ -2847,7 +2843,7 @@ function parseSrcset(value) {
|
|
|
2847
2843
|
|
|
2848
2844
|
/**
|
|
2849
2845
|
* Candidate URLs of a `srcset` (a "url descriptor" string parsed per the HTML
|
|
2850
|
-
* grammar) or `ping` (a space-separated url list
|
|
2846
|
+
* grammar) or `ping` (a space-separated url list hast delivers as an array)
|
|
2851
2847
|
* attribute. An absent attribute (neither string nor array) yields none.
|
|
2852
2848
|
* @param {unknown} value
|
|
2853
2849
|
* @returns {string[]}
|
|
@@ -2862,8 +2858,9 @@ function multiUrlAttr(value) {
|
|
|
2862
2858
|
}
|
|
2863
2859
|
|
|
2864
2860
|
/**
|
|
2865
|
-
* URL-bearing attributes of every HTML element in `text`, parsed with
|
|
2866
|
-
* quoting/casing/entities are handled correctly (no
|
|
2861
|
+
* URL-bearing attributes of every HTML element in `text`, parsed with the real
|
|
2862
|
+
* HTML tokenizer so quoting/casing/entities are handled correctly (no
|
|
2863
|
+
* hand-rolled tag regex).
|
|
2867
2864
|
* `context` selects the per-URL check the caller applies: resource URLs get the
|
|
2868
2865
|
* exfil-shape test; form-submission and meta-refresh targets additionally flag
|
|
2869
2866
|
* any absolute off-origin destination.
|
|
@@ -2906,7 +2903,7 @@ function extractHtmlUrls(text) {
|
|
|
2906
2903
|
autoFetched: true,
|
|
2907
2904
|
context: "form",
|
|
2908
2905
|
});
|
|
2909
|
-
//
|
|
2906
|
+
// hast delivers `http-equiv` as an array (comma-separated); join it back
|
|
2910
2907
|
// so a `refresh` directive is matched regardless of how it was tokenized.
|
|
2911
2908
|
const httpEquiv = Array.isArray(props.httpEquiv)
|
|
2912
2909
|
? props.httpEquiv.join(",").toLowerCase()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tree adapter for ONE parse, plus the settle step that ends it.
|
|
3
|
+
*
|
|
4
|
+
* The pending state is per-parse rather than module-level so that a parse which
|
|
5
|
+
* throws cannot leave a deferred removal for the next one to inherit.
|
|
6
|
+
* @returns {{ adapter: any, settle: () => void }}
|
|
7
|
+
*/
|
|
8
|
+
export function createTreeAdapter(): {
|
|
9
|
+
adapter: any;
|
|
10
|
+
settle: () => void;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Parse `html` as an HTML fragment and return the hast tree.
|
|
14
|
+
*
|
|
15
|
+
* This is `hast-util-from-html`'s fragment path with {@link createTreeAdapter}'s
|
|
16
|
+
* adapter in place of the default one — the option `rehype-parse` does not
|
|
17
|
+
* forward. The settings are that path's own: positions on, parse errors ignored
|
|
18
|
+
* (this package reports on the tree, never on the tokenizer's complaints), and
|
|
19
|
+
* scripting off, so `noscript` content parses as markup.
|
|
20
|
+
* @param {string} html
|
|
21
|
+
* @returns {any}
|
|
22
|
+
*/
|
|
23
|
+
export function parseHtmlFragment(html: string): any;
|
package/types/html.d.mts
CHANGED
|
@@ -53,7 +53,7 @@ export function spliceRanges(text: string, ranges: SpliceRange[]): {
|
|
|
53
53
|
/**
|
|
54
54
|
* Scan raw HTML for hidden content to strip and preserved tags to report.
|
|
55
55
|
* Returned ranges are offsets into `html`; comments and hidden elements span
|
|
56
|
-
* the whole element including its content (
|
|
56
|
+
* the whole element including its content (hast positions cover open tag
|
|
57
57
|
* through matching close, and parse5 extends an unclosed element to the end
|
|
58
58
|
* of the fragment — fail-closed for truncated markup).
|
|
59
59
|
* @param {string} html
|