agent-sanitizer 2.66.0 → 2.66.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.
- package/THREAT-MODEL.md +43 -33
- package/package.json +3 -1
- package/src/html.mjs +146 -36
- package/types/html.d.mts +1 -0
package/THREAT-MODEL.md
CHANGED
|
@@ -134,9 +134,13 @@ spliced, a `<script>` inside it reported, a URL inside it read by Layer 3. A
|
|
|
134
134
|
declarative shadow root (`shadowrootmode="open"`/`"closed"`) is reported too,
|
|
135
135
|
since one the parser fails to attach (a second root on one host, say) is an
|
|
136
136
|
ordinary inert template; its content is laid out in its host's box, so a flex
|
|
137
|
-
host blockifies it as it would a light-DOM child.
|
|
138
|
-
|
|
139
|
-
|
|
137
|
+
host blockifies it as it would a light-DOM child. An inherited hide on the host
|
|
138
|
+
(`visibility:hidden`, say) is judged through the root the parser attaches (the
|
|
139
|
+
first one, on a host `attachShadow` accepts), whose content inherits from the
|
|
140
|
+
host and never from the template's own attributes; a slot given light-DOM
|
|
141
|
+
children there renders them, so its fallback content is spliced. Slot
|
|
142
|
+
assignment is otherwise not modelled: a hidden `<slot>` splices only itself,
|
|
143
|
+
not the light-DOM children the browser renders through it. A `template` inside SVG or MathML (or an HTML
|
|
140
144
|
`template.x`) is not reported: it has no template content, so its children are
|
|
141
145
|
ordinary children and are scanned as such.
|
|
142
146
|
|
|
@@ -1020,38 +1024,44 @@ up to the constant factors a parse pays, and one sort where findings or splice
|
|
|
1020
1024
|
ranges arrive in an order the caller does not promise (`foldConfusables`,
|
|
1021
1025
|
`mergeRanges`). The bound that matters is that nothing is _quadratic_ — a sort
|
|
1022
1026
|
of the findings in a 1 MB paste is milliseconds, a quadratic rescan of it is
|
|
1023
|
-
minutes — and quadratic is what the two
|
|
1027
|
+
minutes — and quadratic is what the two tiers below are built to catch,
|
|
1024
1028
|
because neither can see what the other does.
|
|
1025
1029
|
|
|
1026
|
-
**
|
|
1027
|
-
regexploit over an inventory that `scripts/extract-js-regexes.mjs`
|
|
1028
|
-
walking the TypeScript AST of every shipped `.mjs` (not by matching
|
|
1029
|
-
with a regex), and fails on any pattern with super-linear
|
|
1030
|
-
`RegExp(…)` built at runtime must be statically resolvable or
|
|
1031
|
-
exemption, so a new dynamic pattern is a red build rather than a
|
|
1032
|
-
`tests/secrets/test_redos_static_guard.py` is the twin over the Python
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1030
|
+
**Deterministic — every pattern, no clock.** `tests/test_redos_js_static_guard.py`
|
|
1031
|
+
drives regexploit over an inventory that `scripts/extract-js-regexes.mjs`
|
|
1032
|
+
extracts by walking the TypeScript AST of every shipped `.mjs` (not by matching
|
|
1033
|
+
regex source with a regex), and fails on any pattern with super-linear
|
|
1034
|
+
backtracking. A `RegExp(…)` built at runtime must be statically resolvable or
|
|
1035
|
+
carry a written exemption, so a new dynamic pattern is a red build rather than a
|
|
1036
|
+
hole. `tests/secrets/test_redos_static_guard.py` is the twin over the Python
|
|
1037
|
+
engine. In `pnpm lint`, `eslint-plugin-redos` (recheck) and
|
|
1038
|
+
`eslint-plugin-regexp`'s `no-super-linear-backtracking` and
|
|
1039
|
+
`no-super-linear-move` read every shipped regex literal — the last of them
|
|
1040
|
+
reports the shape that carries no backtracking at all, an **unanchored**
|
|
1041
|
+
pattern retried at every start offset (`(?=.*[A-Z])(?=.*[0-9])` against a long
|
|
1042
|
+
value carrying neither class). `test/regex-recheck.test.mjs` hands every
|
|
1043
|
+
pattern a shipped module actually compiles at runtime to recheck. Every recheck
|
|
1044
|
+
verdict runs with its wall-clock incubation and attack timeouts off, so it
|
|
1045
|
+
rests on step counts alone and no machine's speed can change it.
|
|
1046
|
+
|
|
1047
|
+
**Budgets — everything that is not a pattern.** Work that is not a regex at
|
|
1048
|
+
all — a string rebuilt once per finding, a prefix re-sliced inside a loop, a
|
|
1049
|
+
re-walk of an already-walked node, a parser's own look-back — no pattern
|
|
1050
|
+
analyzer will ever report. `test/algorithmic-complexity.test.mjs` runs each
|
|
1051
|
+
entry point ONCE, after a warm-up, over an adversarial input sized so the
|
|
1052
|
+
linear implementation costs tens to hundreds of milliseconds, against an
|
|
1053
|
+
absolute CPU ceiling at least ten times that reading (`assertUnderBudget` in
|
|
1054
|
+
`test/helpers/budget.mjs`, twin `tests/_budget.py`). A quadratic at that size
|
|
1055
|
+
overshoots the ceiling by another order of magnitude, so no runner is slow or
|
|
1056
|
+
busy enough to blur the two; a constant-factor slowdown passes, by design. Each
|
|
1057
|
+
case also asserts, at the size it measures, that the input still reaches the
|
|
1058
|
+
code the case is about — a length gate answering first is how such a case
|
|
1059
|
+
quietly degrades into timing nothing. `tests/secrets/test_algorithmic_complexity.py`
|
|
1060
|
+
is the twin over the Python engine. `test/hook-latency.test.mjs` holds the
|
|
1061
|
+
three blocking hook paths to absolute ceilings per 256 KB call, read
|
|
1062
|
+
uninstrumented in CI. Every test verdict in the repo that reads a clock goes
|
|
1063
|
+
through that one helper (`test/real-clock-thresholds.test.mjs` and its Python
|
|
1064
|
+
twin enforce it).
|
|
1055
1065
|
|
|
1056
1066
|
**Idioms new code is held to.** Each is the fix for a defect one of the gates
|
|
1057
1067
|
caught:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.66.
|
|
3
|
+
"version": "2.66.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": {
|
|
@@ -62,11 +62,13 @@
|
|
|
62
62
|
"esbuild": "0.28.1",
|
|
63
63
|
"eslint": "10.4.0",
|
|
64
64
|
"eslint-plugin-redos": "4.5.0",
|
|
65
|
+
"eslint-plugin-regexp": "3.3.1",
|
|
65
66
|
"fast-check": "4.8.0",
|
|
66
67
|
"globals": "17.6.0",
|
|
67
68
|
"lint-staged": "^17.0.5",
|
|
68
69
|
"micromark-extension-gfm-autolink-literal": "2.1.0",
|
|
69
70
|
"prettier": "^3.0.0",
|
|
71
|
+
"recheck": "4.5.0",
|
|
70
72
|
"rehype-parse": "9.0.1",
|
|
71
73
|
"remark-gfm": "4.0.1",
|
|
72
74
|
"smol-toml": "^1.7.1",
|
package/src/html.mjs
CHANGED
|
@@ -1680,6 +1680,100 @@ function layoutContainer(parent) {
|
|
|
1680
1680
|
}
|
|
1681
1681
|
}
|
|
1682
1682
|
|
|
1683
|
+
// The elements `attachShadow` accepts as a host, besides a custom element.
|
|
1684
|
+
const SHADOW_HOST_TAGS = new Set([
|
|
1685
|
+
"article",
|
|
1686
|
+
"aside",
|
|
1687
|
+
"blockquote",
|
|
1688
|
+
"body",
|
|
1689
|
+
"div",
|
|
1690
|
+
"footer",
|
|
1691
|
+
"h1",
|
|
1692
|
+
"h2",
|
|
1693
|
+
"h3",
|
|
1694
|
+
"h4",
|
|
1695
|
+
"h5",
|
|
1696
|
+
"h6",
|
|
1697
|
+
"header",
|
|
1698
|
+
"main",
|
|
1699
|
+
"nav",
|
|
1700
|
+
"p",
|
|
1701
|
+
"section",
|
|
1702
|
+
"span",
|
|
1703
|
+
]);
|
|
1704
|
+
// Hyphenated names HTML reserves, so no custom element takes them.
|
|
1705
|
+
const RESERVED_CUSTOM_ELEMENT_NAMES = new Set([
|
|
1706
|
+
"annotation-xml",
|
|
1707
|
+
"color-profile",
|
|
1708
|
+
"font-face",
|
|
1709
|
+
"font-face-src",
|
|
1710
|
+
"font-face-uri",
|
|
1711
|
+
"font-face-format",
|
|
1712
|
+
"font-face-name",
|
|
1713
|
+
"missing-glyph",
|
|
1714
|
+
]);
|
|
1715
|
+
|
|
1716
|
+
// True for an ASCII valid custom element name (the tokenizer only opens a tag
|
|
1717
|
+
// on a letter, lowercased). The grammar also admits non-ASCII names; reading
|
|
1718
|
+
// one as no host leaves its shadow content inert, which only ever splices more
|
|
1719
|
+
// of a hidden host.
|
|
1720
|
+
/** @param {string} name @returns {boolean} */
|
|
1721
|
+
function isCustomElementName(name) {
|
|
1722
|
+
return (
|
|
1723
|
+
name.includes("-") &&
|
|
1724
|
+
!RESERVED_CUSTOM_ELEMENT_NAMES.has(name) &&
|
|
1725
|
+
[...name].every(
|
|
1726
|
+
(char) =>
|
|
1727
|
+
(char >= "a" && char <= "z") ||
|
|
1728
|
+
(char >= "0" && char <= "9") ||
|
|
1729
|
+
"-._".includes(char),
|
|
1730
|
+
)
|
|
1731
|
+
);
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
// The declarative shadow root the parser attaches to `host`: its first template
|
|
1735
|
+
// child with a valid `shadowrootmode`, when `attachShadow` accepts the host.
|
|
1736
|
+
// Every other template stays in the tree as an ordinary, inert one.
|
|
1737
|
+
/** @param {any} host a hast element @returns {any} the attached template */
|
|
1738
|
+
function attachedShadowTemplate(host) {
|
|
1739
|
+
if (!SHADOW_HOST_TAGS.has(host.tagName) && !isCustomElementName(host.tagName))
|
|
1740
|
+
return undefined;
|
|
1741
|
+
return host.children.find(
|
|
1742
|
+
(/** @type {any} */ child) =>
|
|
1743
|
+
child.content !== undefined && !isInertTemplate(child),
|
|
1744
|
+
);
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
// The slot names `host`'s light children are assigned to: an element's `slot`
|
|
1748
|
+
// attribute, or the default slot for a text node or an element without one.
|
|
1749
|
+
/** @param {any} host @param {any} template @returns {Set<string>} */
|
|
1750
|
+
function assignedSlotNames(host, template) {
|
|
1751
|
+
const names = new Set();
|
|
1752
|
+
for (const child of host.children) {
|
|
1753
|
+
if (child === template) continue;
|
|
1754
|
+
if (child.type === "text") names.add("");
|
|
1755
|
+
else if (child.type === "element")
|
|
1756
|
+
names.add(String(child.properties.slot ?? ""));
|
|
1757
|
+
}
|
|
1758
|
+
return names;
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
// `node`'s children in render order, as runs no splice may span. A declarative
|
|
1762
|
+
// shadow root attached to `node` replaces its template with the content's
|
|
1763
|
+
// children, laid out in `node` and inheriting from it: the parser removes the
|
|
1764
|
+
// template, so its own attributes style nothing. That run carries the template.
|
|
1765
|
+
/** @param {any} node @returns {Array<[any[], any]>} */
|
|
1766
|
+
function renderedRuns(node) {
|
|
1767
|
+
const template = attachedShadowTemplate(node);
|
|
1768
|
+
if (!template) return [[node.children, undefined]];
|
|
1769
|
+
const at = node.children.indexOf(template);
|
|
1770
|
+
return [
|
|
1771
|
+
[node.children.slice(0, at), undefined],
|
|
1772
|
+
[template.content.children, template],
|
|
1773
|
+
[node.children.slice(at + 1), undefined],
|
|
1774
|
+
];
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1683
1777
|
// True when `node` lays out as a non-replaced INLINE box. A flex/grid layout
|
|
1684
1778
|
// container (see {@link layoutContainer}) blockifies it.
|
|
1685
1779
|
/** @param {any} node @param {any} [parent] @returns {boolean} */
|
|
@@ -1780,7 +1874,8 @@ function keepsHide(kind, cascade, inlineBox, color) {
|
|
|
1780
1874
|
return keeps;
|
|
1781
1875
|
}
|
|
1782
1876
|
|
|
1783
|
-
|
|
1877
|
+
// `slots`: the assigned slot names of the shadow tree a node sits in, if any.
|
|
1878
|
+
/** @typedef {{ kinds: Set<InheritedHide>, color: ColorState, slots: Set<string> | undefined }} RegionState */
|
|
1784
1879
|
// `neutral`: nothing to see either way; `mixed`: hidden and visible content.
|
|
1785
1880
|
/** @typedef {"hidden" | "neutral" | "visible" | "mixed"} RegionVerdict */
|
|
1786
1881
|
|
|
@@ -1795,11 +1890,17 @@ function regionVerdicts(root, kinds) {
|
|
|
1795
1890
|
const stack = [];
|
|
1796
1891
|
/** @param {any} node @param {RegionState} state */
|
|
1797
1892
|
const pushChildren = (node, state) => {
|
|
1798
|
-
for (const
|
|
1893
|
+
for (const [children, template] of renderedRuns(node)) {
|
|
1894
|
+
const runState = template
|
|
1895
|
+
? { ...state, slots: assignedSlotNames(node, template) }
|
|
1896
|
+
: state;
|
|
1897
|
+
for (const child of children) stack.push([child, node, runState]);
|
|
1898
|
+
}
|
|
1799
1899
|
};
|
|
1800
1900
|
pushChildren(root, {
|
|
1801
1901
|
kinds,
|
|
1802
1902
|
color: colorState(elementCascade(root), null),
|
|
1903
|
+
slots: undefined,
|
|
1803
1904
|
});
|
|
1804
1905
|
const order = [root];
|
|
1805
1906
|
while (stack.length > 0) {
|
|
@@ -1821,7 +1922,11 @@ function regionVerdicts(root, kinds) {
|
|
|
1821
1922
|
// Only an element's own context-free hides are ever added on the way
|
|
1822
1923
|
// down, so a region never hides more than its elements would alone.
|
|
1823
1924
|
const own = elementHides(node, parent);
|
|
1824
|
-
|
|
1925
|
+
// A slot given light children renders them, never its fallback content.
|
|
1926
|
+
const unrenderedFallback =
|
|
1927
|
+
node.tagName === "slot" &&
|
|
1928
|
+
state.slots?.has(String(node.properties.name ?? ""));
|
|
1929
|
+
if (own.has("box") || unrenderedFallback) {
|
|
1825
1930
|
verdicts.set(node, "hidden");
|
|
1826
1931
|
continue;
|
|
1827
1932
|
}
|
|
@@ -1834,7 +1939,7 @@ function regionVerdicts(root, kinds) {
|
|
|
1834
1939
|
if (REPLACED_ELEMENTS.has(node.tagName))
|
|
1835
1940
|
verdicts.set(node, inForce.has("visibility") ? "hidden" : "visible");
|
|
1836
1941
|
else if (inForce.size === 0) verdicts.set(node, "visible");
|
|
1837
|
-
else pushChildren(node, { kinds: inForce, color });
|
|
1942
|
+
else pushChildren(node, { kinds: inForce, color, slots: state.slots });
|
|
1838
1943
|
}
|
|
1839
1944
|
}
|
|
1840
1945
|
for (let i = order.length - 1; i >= 0; i--) {
|
|
@@ -1842,7 +1947,7 @@ function regionVerdicts(root, kinds) {
|
|
|
1842
1947
|
if (verdicts.has(node)) continue;
|
|
1843
1948
|
let hidden = false;
|
|
1844
1949
|
let shown = false;
|
|
1845
|
-
for (const child of node.children) {
|
|
1950
|
+
for (const child of renderedRuns(node).flatMap(([children]) => children)) {
|
|
1846
1951
|
const verdict = verdicts.get(child);
|
|
1847
1952
|
if (verdict === "hidden") hidden = true;
|
|
1848
1953
|
else if (verdict === "visible" || verdict === "mixed") shown = true;
|
|
@@ -1888,38 +1993,43 @@ function inheritedRegionSplice(root, kinds) {
|
|
|
1888
1993
|
trailingComments = [];
|
|
1889
1994
|
trailingElements = [];
|
|
1890
1995
|
};
|
|
1891
|
-
for (const
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
position
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1996
|
+
for (const [children, template] of renderedRuns(node)) {
|
|
1997
|
+
closeRun();
|
|
1998
|
+
// The template the parser removed is left standing, not spliced.
|
|
1999
|
+
if (template) mixed.push(template);
|
|
2000
|
+
for (const child of children) {
|
|
2001
|
+
const position = child.position;
|
|
2002
|
+
if (
|
|
2003
|
+
!position ||
|
|
2004
|
+
position.start.offset < lastEnd ||
|
|
2005
|
+
position.end.offset > node.position.end.offset
|
|
2006
|
+
)
|
|
2007
|
+
return null;
|
|
2008
|
+
lastEnd = position.end.offset;
|
|
2009
|
+
const verdict = verdicts.get(child);
|
|
2010
|
+
if (verdict === "hidden") {
|
|
2011
|
+
if (runStart === -1) {
|
|
2012
|
+
closeRun(); // what precedes the run stays outside it
|
|
2013
|
+
runStart = position.start.offset;
|
|
2014
|
+
}
|
|
2015
|
+
runEnd = position.end.offset;
|
|
2016
|
+
trailingComments = [];
|
|
2017
|
+
trailingElements = [];
|
|
2018
|
+
} else if (verdict === "mixed") {
|
|
2019
|
+
closeRun();
|
|
2020
|
+
stack.push(child);
|
|
2021
|
+
} else if (verdict === "visible") {
|
|
2022
|
+
closeRun();
|
|
2023
|
+
rescan.push([child, node]);
|
|
2024
|
+
} else if (child.type === "comment") {
|
|
2025
|
+
trailingComments.push({
|
|
2026
|
+
start: position.start.offset,
|
|
2027
|
+
end: position.end.offset,
|
|
2028
|
+
kind: "comment",
|
|
2029
|
+
});
|
|
2030
|
+
} else if (child.type === "element") {
|
|
2031
|
+
trailingElements.push([child, node]);
|
|
1905
2032
|
}
|
|
1906
|
-
runEnd = position.end.offset;
|
|
1907
|
-
trailingComments = [];
|
|
1908
|
-
trailingElements = [];
|
|
1909
|
-
} else if (verdict === "mixed") {
|
|
1910
|
-
closeRun();
|
|
1911
|
-
stack.push(child);
|
|
1912
|
-
} else if (verdict === "visible") {
|
|
1913
|
-
closeRun();
|
|
1914
|
-
rescan.push([child, node]);
|
|
1915
|
-
} else if (child.type === "comment") {
|
|
1916
|
-
trailingComments.push({
|
|
1917
|
-
start: position.start.offset,
|
|
1918
|
-
end: position.end.offset,
|
|
1919
|
-
kind: "comment",
|
|
1920
|
-
});
|
|
1921
|
-
} else if (child.type === "element") {
|
|
1922
|
-
trailingElements.push([child, node]);
|
|
1923
2033
|
}
|
|
1924
2034
|
}
|
|
1925
2035
|
closeRun();
|
package/types/html.d.mts
CHANGED
|
@@ -195,6 +195,7 @@ export type ColorState = {
|
|
|
195
195
|
export type RegionState = {
|
|
196
196
|
kinds: Set<InheritedHide>;
|
|
197
197
|
color: ColorState;
|
|
198
|
+
slots: Set<string> | undefined;
|
|
198
199
|
};
|
|
199
200
|
export type RegionVerdict = "hidden" | "neutral" | "visible" | "mixed";
|
|
200
201
|
export type CollectedUrl = {
|