agent-sanitizer 2.19.4 → 2.19.6
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 +100 -16
- package/types/html.d.mts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.19.
|
|
3
|
+
"version": "2.19.6",
|
|
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
|
@@ -1219,12 +1219,26 @@ function hasDataSrc(el) {
|
|
|
1219
1219
|
);
|
|
1220
1220
|
}
|
|
1221
1221
|
|
|
1222
|
+
// One shared fragment parser for every HTML parse in this module (mirroring
|
|
1223
|
+
// `mdParser` below): all of them must agree on the tokenizer's verdict, so
|
|
1224
|
+
// there is exactly one parser configuration to reason about.
|
|
1225
|
+
const htmlParser = unified().use(rehypeParse, { fragment: true });
|
|
1226
|
+
|
|
1227
|
+
/**
|
|
1228
|
+
* Parse `html` as an HTML fragment with the real tokenizer (parse5, via rehype).
|
|
1229
|
+
* @param {string} html
|
|
1230
|
+
* @returns {any}
|
|
1231
|
+
*/
|
|
1232
|
+
function parseFragment(html) {
|
|
1233
|
+
return htmlParser.parse(html);
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1222
1236
|
/**
|
|
1223
1237
|
* @param {string} htmlValue
|
|
1224
1238
|
* @returns {any}
|
|
1225
1239
|
*/
|
|
1226
1240
|
function parseHtmlTag(htmlValue) {
|
|
1227
|
-
const tree =
|
|
1241
|
+
const tree = parseFragment(htmlValue);
|
|
1228
1242
|
/** @type {any} */
|
|
1229
1243
|
let firstElement = null;
|
|
1230
1244
|
visit(tree, "element", (node) => {
|
|
@@ -1358,7 +1372,19 @@ function hasWarned(warned) {
|
|
|
1358
1372
|
* @returns {{ ranges: Array<{start: number, end: number, kind: "comment" | "hidden"}>, warned: ReturnType<typeof newWarned> }}
|
|
1359
1373
|
*/
|
|
1360
1374
|
export function scanHtmlFragment(html) {
|
|
1361
|
-
|
|
1375
|
+
return scanFragmentTree(html, parseFragment(html));
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
/**
|
|
1379
|
+
* `scanHtmlFragment` for a caller that already has the fragment tree — the
|
|
1380
|
+
* dispatch in `sanitizeHtml` parses to decide the branch, so re-parsing there
|
|
1381
|
+
* would tokenize the same input twice. `tree` MUST be the parse of `html`;
|
|
1382
|
+
* the ranges are offsets into `html`, read from that tree's positions.
|
|
1383
|
+
* @param {string} html
|
|
1384
|
+
* @param {any} tree
|
|
1385
|
+
* @returns {{ ranges: Array<{start: number, end: number, kind: "comment" | "hidden"}>, warned: ReturnType<typeof newWarned> }}
|
|
1386
|
+
*/
|
|
1387
|
+
function scanFragmentTree(html, tree) {
|
|
1362
1388
|
/** @type {Array<{start: number, end: number, kind: "comment" | "hidden"}>} */
|
|
1363
1389
|
const ranges = [];
|
|
1364
1390
|
const warned = newWarned();
|
|
@@ -1443,7 +1469,7 @@ function foldAbsorb(absorbing, raw) {
|
|
|
1443
1469
|
* @returns {Map<number, number>}
|
|
1444
1470
|
*/
|
|
1445
1471
|
function commentSpans(value) {
|
|
1446
|
-
const tree =
|
|
1472
|
+
const tree = parseFragment(value);
|
|
1447
1473
|
/** @type {Map<number, number>} */
|
|
1448
1474
|
const spans = new Map();
|
|
1449
1475
|
visit(tree, "comment", (/** @type {any} */ node) => {
|
|
@@ -1711,20 +1737,77 @@ function scanMarkdown(text) {
|
|
|
1711
1737
|
return { ranges, warned };
|
|
1712
1738
|
}
|
|
1713
1739
|
|
|
1714
|
-
// 30%-of-lines heuristic: HTML *source* gets scanned as one rehype fragment;
|
|
1715
|
-
// inline tags scattered in prose go through the markdown branch instead.
|
|
1716
1740
|
/**
|
|
1741
|
+
* True when remark finds a code block — fenced or indented — in `text`.
|
|
1742
|
+
*
|
|
1743
|
+
* Asked before the HTML tokenizer because "no character data outside the
|
|
1744
|
+
* markup" cannot see an INDENTED code block: its four leading spaces are
|
|
1745
|
+
* whitespace, so a document that is nothing but one indented block
|
|
1746
|
+
* (`" <div hidden>x</div>\n"`) satisfies the rule and takes the source
|
|
1747
|
+
* branch, and the hidden element gets spliced out of a block the renderer
|
|
1748
|
+
* displays as literal text. A fence escapes only incidentally, because the
|
|
1749
|
+
* backticks are non-whitespace character data. Code blocks are markdown-ONLY
|
|
1750
|
+
* syntax, so their presence settles the question the same way the tokenizer
|
|
1751
|
+
* does — by parsing, not by counting.
|
|
1717
1752
|
* @param {string} text
|
|
1718
1753
|
* @returns {boolean}
|
|
1719
1754
|
*/
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1755
|
+
function hasMarkdownCode(text) {
|
|
1756
|
+
let found = false;
|
|
1757
|
+
visit(mdParser.parse(text), "code", () => {
|
|
1758
|
+
found = true;
|
|
1759
|
+
return EXIT;
|
|
1760
|
+
});
|
|
1761
|
+
return found;
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
/**
|
|
1765
|
+
* The parsed fragment tree for `text` when `text` is HTML *source*, else null.
|
|
1766
|
+
*
|
|
1767
|
+
* "HTML source" means the markup accounts for the WHOLE document: the real
|
|
1768
|
+
* tokenizer (parse5, via rehype) places every element there is, and the only
|
|
1769
|
+
* character data it leaves OUTSIDE all of them is whitespace. That is exactly
|
|
1770
|
+
* the property the source branch needs — it hands the whole input to
|
|
1771
|
+
* `scanHtmlFragment` as one fragment, which is faithful only when there is no
|
|
1772
|
+
* non-HTML syntax around the markup for that parse to misread.
|
|
1773
|
+
*
|
|
1774
|
+
* Everything else fails OPEN to the markdown branch, which parses with remark
|
|
1775
|
+
* and scans only the spans remark itself calls HTML. That is the conservative
|
|
1776
|
+
* direction: markdown-only constructs (fenced/indented code, tables, lists)
|
|
1777
|
+
* keep their meaning, so an HTML sample inside a code fence is displayed
|
|
1778
|
+
* rather than spliced. The dispatch this replaces counted tag-shaped LINES and
|
|
1779
|
+
* took the source branch above 30% of them, which got that case wrong — it
|
|
1780
|
+
* spliced hidden-element examples out of documentation code blocks.
|
|
1781
|
+
*
|
|
1782
|
+
* Character data is judged by its DECODED value, as a renderer sees it: the
|
|
1783
|
+
* ignored `<html>`/`<head>`/`<body>` tags of a full page leave their
|
|
1784
|
+
* surrounding newlines merged into one text node, and ` ` between two
|
|
1785
|
+
* elements is whitespace on the page.
|
|
1786
|
+
* @param {string} text
|
|
1787
|
+
* @returns {any}
|
|
1788
|
+
*/
|
|
1789
|
+
function htmlSourceTree(text) {
|
|
1790
|
+
if (hasMarkdownCode(text)) return null;
|
|
1791
|
+
const tree = parseFragment(text);
|
|
1792
|
+
let sawElement = false;
|
|
1793
|
+
// Only ROOT children can hold character data outside an element; everything
|
|
1794
|
+
// deeper is by construction inside one.
|
|
1795
|
+
for (const node of tree.children) {
|
|
1796
|
+
if (node.type === "element") sawElement = true;
|
|
1797
|
+
// Comments and the doctype are markup, not character data.
|
|
1798
|
+
else if (node.type === "text" && node.value.trim() !== "") return null;
|
|
1726
1799
|
}
|
|
1727
|
-
return
|
|
1800
|
+
return sawElement ? tree : null;
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
/**
|
|
1804
|
+
* True when `text` is HTML source rather than markdown that merely contains
|
|
1805
|
+
* tags — see `htmlSourceTree` for the definition and the fail-open rationale.
|
|
1806
|
+
* @param {string} text
|
|
1807
|
+
* @returns {boolean}
|
|
1808
|
+
*/
|
|
1809
|
+
export function looksLikeHtmlSource(text) {
|
|
1810
|
+
return htmlSourceTree(text) !== null;
|
|
1728
1811
|
}
|
|
1729
1812
|
|
|
1730
1813
|
/**
|
|
@@ -1740,9 +1823,10 @@ export function sanitizeHtml(text) {
|
|
|
1740
1823
|
/** @type {{ ranges: Array<{start: number, end: number, kind: "comment" | "hidden"}>, warned: ReturnType<typeof newWarned> }} */
|
|
1741
1824
|
let scan;
|
|
1742
1825
|
try {
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1826
|
+
// One parse decides the branch AND feeds it, so the source branch does not
|
|
1827
|
+
// tokenize the input twice.
|
|
1828
|
+
const sourceTree = htmlSourceTree(text);
|
|
1829
|
+
scan = sourceTree ? scanFragmentTree(text, sourceTree) : scanMarkdown(text);
|
|
1746
1830
|
} catch {
|
|
1747
1831
|
// The parse/visit blew up (stack overflow on pathological nesting, or any
|
|
1748
1832
|
// other parser error). Fail CLOSED at this boundary so `sanitize`/
|
|
@@ -2233,7 +2317,7 @@ function multiUrlAttr(value) {
|
|
|
2233
2317
|
* @returns {Array<{ url: string, isImage: boolean, context: "resource" | "form" | "refresh" }>}
|
|
2234
2318
|
*/
|
|
2235
2319
|
function extractHtmlUrls(text) {
|
|
2236
|
-
const tree =
|
|
2320
|
+
const tree = parseFragment(text);
|
|
2237
2321
|
/** @type {Array<{ url: string, isImage: boolean, context: "resource" | "form" | "refresh" }>} */
|
|
2238
2322
|
const urls = [];
|
|
2239
2323
|
visit(tree, "element", (/** @type {any} */ node) => {
|
package/types/html.d.mts
CHANGED
|
@@ -51,6 +51,8 @@ export function scanHtmlFragment(html: string): {
|
|
|
51
51
|
warned: ReturnType<typeof newWarned>;
|
|
52
52
|
};
|
|
53
53
|
/**
|
|
54
|
+
* True when `text` is HTML source rather than markdown that merely contains
|
|
55
|
+
* tags — see `htmlSourceTree` for the definition and the fail-open rationale.
|
|
54
56
|
* @param {string} text
|
|
55
57
|
* @returns {boolean}
|
|
56
58
|
*/
|