agent-sanitizer 2.64.2 → 2.66.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.
- package/README.md +5 -0
- package/THREAT-MODEL.md +68 -32
- package/claude-hooks/lib/knobs.mjs +3 -3
- package/claude-hooks/lib/redactor-client.mjs +2 -2
- package/package.json +4 -3
- package/src/html-tree-adapter.mjs +11 -2
- package/src/html.mjs +104 -19
- package/types/claude-hooks/lib/knobs.d.mts +3 -3
package/README.md
CHANGED
|
@@ -227,6 +227,11 @@ guessing if the marketplace was never added. To pull a release by hand instead:
|
|
|
227
227
|
|
|
228
228
|
`plugin/README.md` has the managed-settings form for enabling it fleet-wide.
|
|
229
229
|
|
|
230
|
+
The plugin installs from this repository's `plugin-dist` branch, which CI
|
|
231
|
+
builds from each release tag; `main` carries the sources only, so a checkout
|
|
232
|
+
runs `pnpm install` (and `pnpm build:redactor` for the zipapp) to get a
|
|
233
|
+
runnable `plugin/`.
|
|
234
|
+
|
|
230
235
|
To wire them yourself instead, one entry dispatches every mode on `--hook=`:
|
|
231
236
|
|
|
232
237
|
```jsonc
|
package/THREAT-MODEL.md
CHANGED
|
@@ -122,8 +122,23 @@ range is preserved verbatim** (no re-serialization). Unclosed hidden markup
|
|
|
122
122
|
extends to the end of the fragment—fail-closed for truncated input.
|
|
123
123
|
|
|
124
124
|
Scripting/resource tags (`script`, `style`, `object`, `embed`, `iframe`, `svg`,
|
|
125
|
-
`math`) and `data:` URI resources are **reported, not removed**:
|
|
126
|
-
page source the model may legitimately need to inspect.
|
|
125
|
+
`math`, `template`) and `data:` URI resources are **reported, not removed**:
|
|
126
|
+
their bodies are page source the model may legitimately need to inspect.
|
|
127
|
+
|
|
128
|
+
A `<template>`'s content is a fragment the browser parses but never renders, so
|
|
129
|
+
instructions placed there reach a model reading the source and no human reading
|
|
130
|
+
the page. It is also where web components and client-side renderers keep their
|
|
131
|
+
UI markup, so the template is reported rather than spliced, and its content is
|
|
132
|
+
scanned like any other markup: a hidden element or comment inside it is
|
|
133
|
+
spliced, a `<script>` inside it reported, a URL inside it read by Layer 3. A
|
|
134
|
+
declarative shadow root (`shadowrootmode="open"`/`"closed"`) is reported too,
|
|
135
|
+
since one the parser fails to attach (a second root on one host, say) is an
|
|
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. Slot assignment is not
|
|
138
|
+
modelled: a hidden `<slot>` splices only itself, not the light-DOM children
|
|
139
|
+
the browser renders through it. A `template` inside SVG or MathML (or an HTML
|
|
140
|
+
`template.x`) is not reported: it has no template content, so its children are
|
|
141
|
+
ordinary children and are scanned as such.
|
|
127
142
|
|
|
128
143
|
HTML comments (`<!--…-->`, and the bogus `<!…>`/`<?…?>` forms) are spliced
|
|
129
144
|
like hidden elements — a human viewing the rendered page never sees them. But
|
|
@@ -146,9 +161,12 @@ Report—never rewrite—URLs in markdown links/images/definitions, every HTML/S
|
|
|
146
161
|
URL attribute in `src/url-attributes.mjs` (`src`, `href`, `xlink:href`,
|
|
147
162
|
`srcset`/`imagesrcset`, `ping`, `poster`, `object data`, form
|
|
148
163
|
`action`/`formaction`, `meta refresh`, …), CSS `url()` in `style` attributes and
|
|
149
|
-
`<style>` elements, and the same again inside HTML comments
|
|
150
|
-
carry data off-origin. A URL
|
|
151
|
-
|
|
164
|
+
`<style>` elements, and the same again inside HTML comments and `<template>`
|
|
165
|
+
content, that are shaped to carry data off-origin. A URL inside a comment or an
|
|
166
|
+
inert template is reported as not auto-fetched: nothing there loads until a
|
|
167
|
+
reader or a script acts on it. A URL's scheme is read by the WHATWG URL parser,
|
|
168
|
+
and `srcset` and `meta refresh` by ports of the HTML Standard's own parsing
|
|
169
|
+
steps:
|
|
152
170
|
|
|
153
171
|
- suspicious query/fragment parameters (long base64/hex blobs, credential-shaped
|
|
154
172
|
tokens), tuned to skip request-signing / pagination / analytics parameters
|
|
@@ -835,31 +853,49 @@ sanitizes (see `plugin/scripts/provision-hook-binary.sh`). That is the
|
|
|
835
853
|
product's only path that fetches an executable over the network and later runs
|
|
836
854
|
it, so what anchors its trust is worth stating exactly.
|
|
837
855
|
|
|
838
|
-
- **The anchor
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
the
|
|
856
|
+
- **The anchor travels with the plugin, not on the wire.**
|
|
857
|
+
`plugin/dist/hooks/hook-binaries.sha256` ships in the installed plugin, and the
|
|
858
|
+
SessionStart provisioner refuses to install any download that does not hash
|
|
859
|
+
to the digest it pins for that platform. A compromised or substituted release
|
|
860
|
+
asset alone therefore cannot be installed: it fails the comparison, is
|
|
861
|
+
deleted, and the launcher keeps degrading loudly through node.
|
|
862
|
+
- **The installed plugin is the `plugin-dist` branch, and no human reviews it.**
|
|
863
|
+
`.claude-plugin/marketplace.json` installs from `plugin-dist`, whose commits
|
|
864
|
+
`.github/workflows/plugin-dist.yaml` writes: it checks out a release tag (a
|
|
865
|
+
`main` commit that went through review and the required checks), builds the
|
|
866
|
+
bundle, the zipapp, the generated shell libs and the manifest, compiles the
|
|
867
|
+
binaries, uploads them only after `sha256sum --check` against that manifest
|
|
868
|
+
passes, and then pushes the build. The manifest and the binaries come from the
|
|
869
|
+
same run, so the digest proves the download is what that run built — not that
|
|
870
|
+
an independent rebuild agrees. What the user runs is therefore exactly as
|
|
871
|
+
trustworthy as whatever can write `plugin-dist`.
|
|
872
|
+
- **Who can write `plugin-dist`.** The workflow pushes with
|
|
873
|
+
`TEMPLATE_SYNC_TOKEN_ORG` (the stock `GITHUB_TOKEN` where that secret is
|
|
874
|
+
unset), and the push is fast-forward only. Pushes are restricted to that
|
|
875
|
+
identity by a branch ruleset on `refs/heads/plugin-dist` blocking creation,
|
|
876
|
+
update, deletion and non-fast-forward for everyone but the repository's admin
|
|
877
|
+
bypass actors. That ruleset is repository configuration, not code in this
|
|
878
|
+
tree, so nothing here creates it; `plugin-dist-ruleset.yaml` only detects
|
|
879
|
+
its absence, failing daily and on any marketplace change while no active
|
|
880
|
+
`update` rule covers the branch. Without it, any collaborator with write
|
|
881
|
+
access can push the branch and ship code to every installed user. With it, the residual writers are the
|
|
882
|
+
admins and anything that can run a workflow holding that secret — a workflow
|
|
883
|
+
edited onto a branch of this repository can read it, so write access to the
|
|
884
|
+
repository still reaches `plugin-dist` through Actions, as it reaches `main`'s
|
|
885
|
+
release path.
|
|
886
|
+
- **A manifest that disagrees with its bundle installs nothing.** The bake
|
|
887
|
+
refuses to stage a manifest whose `# bundle=` pin is not the bundle it built,
|
|
888
|
+
and the provisioner compares its own bundle against that pin and refuses on a
|
|
889
|
+
mismatch, so a hand-rebuilt bundle beside an older manifest degrades to the
|
|
890
|
+
node path rather than running a binary that is not the code the install ships.
|
|
891
|
+
A binary provisioned under an earlier release stays installed but loses its
|
|
892
|
+
install stamp, and the launcher runs a binary whose stamp does not name the
|
|
893
|
+
shipped manifest only on a host where no node is found — there an older
|
|
894
|
+
sanitizer still beats an unsanitized call.
|
|
895
|
+
- **Not covered: an attacker who can write `plugin-dist`, or the workflow that
|
|
896
|
+
writes it.** There is no signature; the manifest is trusted because it arrives
|
|
897
|
+
in the same commit as the rest of the plugin. Rewriting it is rewriting the
|
|
898
|
+
plugin, which is outside this boundary.
|
|
863
899
|
- **Not covered: re-verification at exec time.** The digest is checked once, at
|
|
864
900
|
install. The launcher then execs
|
|
865
901
|
`${CLAUDE_PLUGIN_DATA}/hook-binary/agent-sanitizer-hooks` on every hook
|
|
@@ -883,12 +919,12 @@ it, so what anchors its trust is worth stating exactly.
|
|
|
883
919
|
location.** The provisioned redactor venv (`${CLAUDE_PLUGIN_DATA}/venv` and its
|
|
884
920
|
`bin/`, whose daemon receives every secret-bearing payload) is handed to the
|
|
885
921
|
client only when `trusted_dir` vouches for both directories; otherwise the
|
|
886
|
-
client redacts through the
|
|
922
|
+
client redacts through the shipped zipapp. The degraded-warning marker root
|
|
887
923
|
under `TMPDIR` is read only when this user owns it with owner-only write, so a
|
|
888
924
|
squatted shared-temp entry cannot pre-suppress the warning that a call ran
|
|
889
925
|
unsanitized.
|
|
890
926
|
- **Opting out.** `AGENT_SANITIZER_HOOK_BINARY=0` never downloads and never runs
|
|
891
|
-
a binary. That leaves the node path running the
|
|
927
|
+
a binary. That leaves the node path running the shipped bundle and the
|
|
892
928
|
compile cache above; `AGENT_SANITIZER_COMPILE_CACHE=0` turns the cache off as
|
|
893
929
|
well, and an operator's own `NODE_COMPILE_CACHE` is used unchanged.
|
|
894
930
|
|
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
* cannot reach (it runs its no-node arm on shell builtins alone).
|
|
26
26
|
*
|
|
27
27
|
* THE single source of truth for the closed set. The shell shims cannot import
|
|
28
|
-
* it, so `plugin/scripts/lib/fail-open.sh` is GENERATED from it
|
|
29
|
-
* `scripts/gen-fail-open-lib.mjs
|
|
30
|
-
*
|
|
28
|
+
* it, so `plugin/scripts/lib/fail-open.sh` is GENERATED from it at build time
|
|
29
|
+
* by `scripts/gen-fail-open-lib.mjs`; plugin/test/fail-open-parity.test.mjs
|
|
30
|
+
* asserts the generated lib decides as failOpenEnabled(). Everything else that spells the set
|
|
31
31
|
* out by hand is an implementation that must appear in the parity table in
|
|
32
32
|
* tests/test_safe_launch.py.
|
|
33
33
|
*/
|
|
@@ -115,7 +115,7 @@ export const DEFAULT_SOCKET_PATH = resolveSocketPath(process.env, currentUid());
|
|
|
115
115
|
* sets it to the provisioned venv's console script when one exists, and
|
|
116
116
|
* tests point it at dead paths to drive the fail-closed arm; a fallback
|
|
117
117
|
* from an explicit setting would turn those arms into silent passes).
|
|
118
|
-
* 2. The
|
|
118
|
+
* 2. The shipped self-contained zipapp (`plugin/dist/redactor/daemon.pyz`) —
|
|
119
119
|
* the guaranteed floor: it needs only a python3 on PATH, so a cold start
|
|
120
120
|
* with no venv and no network still redacts instead of failing closed on
|
|
121
121
|
* every tool call. At `_AGENT_SANITIZER_REDACTOR_PYZ` when the launcher
|
|
@@ -129,7 +129,7 @@ function daemonCommand() {
|
|
|
129
129
|
const configured = readKnob("_AGENT_SANITIZER_REDACTOR_DAEMON");
|
|
130
130
|
if (configured) return [configured];
|
|
131
131
|
// Inside the built bundle import.meta.url is .../plugin/dist/hooks/<bundle>,
|
|
132
|
-
// so the
|
|
132
|
+
// so the shipped zipapp sits one level up; from the raw sources no such
|
|
133
133
|
// sibling exists and the check falls through.
|
|
134
134
|
const pyz =
|
|
135
135
|
readKnob("_AGENT_SANITIZER_REDACTOR_PYZ") ||
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.66.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": {
|
|
@@ -249,12 +249,13 @@
|
|
|
249
249
|
"check": "tsc --noEmit && tsc -p tsconfig.hooks.json --noEmit",
|
|
250
250
|
"typecheck": "tsc --noEmit && tsc -p tsconfig.hooks.json --noEmit",
|
|
251
251
|
"build:types": "tsc -p tsconfig.build.json && tsc -p tsconfig.build-hooks.json",
|
|
252
|
+
"build": "pnpm build:plugin && node plugin/scripts/build-redactor-pyz.mjs --if-missing",
|
|
253
|
+
"build:plugin": "node plugin/scripts/build-plugin.mjs",
|
|
254
|
+
"build:redactor": "node plugin/scripts/build-redactor-pyz.mjs",
|
|
252
255
|
"gen:joining-type": "node scripts/gen-joining-type.mjs",
|
|
253
256
|
"gen:unicode": "node scripts/gen-unicode.mjs",
|
|
254
257
|
"gen:gfm-autolink": "node scripts/gen-gfm-autolink.mjs",
|
|
255
258
|
"gen:unicode-properties": "node scripts/gen-unicode-properties.mjs",
|
|
256
|
-
"gen:fail-open-lib": "node scripts/gen-fail-open-lib.mjs",
|
|
257
|
-
"gen:node-floor-lib": "node scripts/gen-node-floor-lib.mjs",
|
|
258
259
|
"gen:hook-binaries": "node plugin/scripts/build-hook-binaries.mjs",
|
|
259
260
|
"lint": "eslint .",
|
|
260
261
|
"test:mutation": "node scripts/mutate.mjs",
|
|
@@ -174,8 +174,8 @@ export const MAX_HTML_DEPTH = 4096;
|
|
|
174
174
|
* the name up to its first `#` or `.`, lowercased.
|
|
175
175
|
*
|
|
176
176
|
* WHY IT MATTERS — the converter then recurses into the parse5 node's `content`,
|
|
177
|
-
* which only an HTML-namespace `<template>` has.
|
|
178
|
-
*
|
|
177
|
+
* which only an HTML-namespace `<template>` has. An `<svg><template>`, a
|
|
178
|
+
* `<math><template>` or an HTML `<template.x>` has none, the converter throws,
|
|
179
179
|
* and the whole document would be withheld over a benign element.
|
|
180
180
|
*/
|
|
181
181
|
const CONVERTS_AS_TEMPLATE = /^template(?:[#.]|$)/i;
|
|
@@ -189,6 +189,15 @@ const CONVERTS_AS_TEMPLATE = /^template(?:[#.]|$)/i;
|
|
|
189
189
|
* changes it (lowercasing, the SVG case map). The element then converts the way
|
|
190
190
|
* any element without template content does: its children as `children`, where
|
|
191
191
|
* every scan of the hast tree reads them, which is also where the DOM has them.
|
|
192
|
+
*
|
|
193
|
+
* WHY A MASK, and not an empty parse5 `content` handed to the element so the
|
|
194
|
+
* converter has a fragment to recurse into: hastscript's `h` and `s` (the
|
|
195
|
+
* converter builds every element with one of them) move the children of ANY
|
|
196
|
+
* element they name `template` into the hast node's `content` and leave its
|
|
197
|
+
* `children` empty, and the converter then overwrites that `content` with its
|
|
198
|
+
* conversion of the parse5 fragment. Handed an empty one, the element's
|
|
199
|
+
* children would vanish with no error, and every Layer 2/3 scan in
|
|
200
|
+
* `./html.mjs` would go blind to them.
|
|
192
201
|
*/
|
|
193
202
|
const TEMPLATE_MASK = " ";
|
|
194
203
|
|
package/src/html.mjs
CHANGED
|
@@ -1502,7 +1502,13 @@ export function isHiddenStyle(styleStr) {
|
|
|
1502
1502
|
// Scripting / resource-loading tags whose PRESENCE is reported to the model
|
|
1503
1503
|
// but whose content is preserved: their bodies are page source the model may
|
|
1504
1504
|
// legitimately need to inspect (how a page's scripts work, its styles, its
|
|
1505
|
-
// SVGs), so unlike hidden elements they are never removed.
|
|
1505
|
+
// SVGs), so unlike hidden elements they are never removed. An HTML `template`
|
|
1506
|
+
// is here because the browser parses its content into a fragment it never
|
|
1507
|
+
// renders — a reader of the source sees it, a reader of the page does not —
|
|
1508
|
+
// while that content is also where a component's UI markup legitimately lives.
|
|
1509
|
+
// A declarative shadow root is reported too: whether the parser attaches one
|
|
1510
|
+
// depends on its host's name and on the host having no earlier root, and a
|
|
1511
|
+
// root that fails to attach is an ordinary inert template.
|
|
1506
1512
|
export const REPORTED_TAGS = new Set([
|
|
1507
1513
|
"script",
|
|
1508
1514
|
"style",
|
|
@@ -1511,8 +1517,34 @@ export const REPORTED_TAGS = new Set([
|
|
|
1511
1517
|
"iframe",
|
|
1512
1518
|
"svg",
|
|
1513
1519
|
"math",
|
|
1520
|
+
"template",
|
|
1514
1521
|
]);
|
|
1515
1522
|
|
|
1523
|
+
/**
|
|
1524
|
+
* True for a template whose content the page never renders: every one but a
|
|
1525
|
+
* declarative shadow root (`shadowrootmode` of `open` or `closed`), whose
|
|
1526
|
+
* content the browser's parser attaches to the parent as a shadow tree.
|
|
1527
|
+
* @param {any} node a hast element with template content (a `content` root)
|
|
1528
|
+
* @returns {boolean}
|
|
1529
|
+
*/
|
|
1530
|
+
function isInertTemplate(node) {
|
|
1531
|
+
const mode = String(node.properties.shadowRootMode ?? "").toLowerCase();
|
|
1532
|
+
return mode !== "open" && mode !== "closed";
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
/**
|
|
1536
|
+
* True when Layer 2 reports `node`'s presence (see {@link REPORTED_TAGS}).
|
|
1537
|
+
* Only an HTML `template` has template content; one inside SVG or MathML, or a
|
|
1538
|
+
* `template.x`, is an ordinary element whose children are scanned as children.
|
|
1539
|
+
* @param {any} node a hast element
|
|
1540
|
+
* @returns {boolean}
|
|
1541
|
+
*/
|
|
1542
|
+
function isReportedElement(node) {
|
|
1543
|
+
return node.tagName === "template"
|
|
1544
|
+
? node.content !== undefined
|
|
1545
|
+
: REPORTED_TAGS.has(node.tagName);
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1516
1548
|
// HTML void elements: they never carry content and never emit a closing tag, so
|
|
1517
1549
|
// a hidden one (<img hidden>, <input hidden>, …) must be spliced as a single node
|
|
1518
1550
|
// — opening a balance region for it would run to the container's end (no close
|
|
@@ -1629,8 +1661,27 @@ function elementCascade(node) {
|
|
|
1629
1661
|
return cascade;
|
|
1630
1662
|
}
|
|
1631
1663
|
|
|
1632
|
-
//
|
|
1633
|
-
|
|
1664
|
+
// The template whose content each hast `content` root is, recorded by `walk`.
|
|
1665
|
+
const contentOwners = new WeakMap();
|
|
1666
|
+
|
|
1667
|
+
// The box `parent`'s children are laid out in: a `display:contents` parent
|
|
1668
|
+
// defers to its own parent, and a declarative shadow root's content to the
|
|
1669
|
+
// template's parent, the shadow host whose box its children render in.
|
|
1670
|
+
/** @param {any} parent @returns {any} */
|
|
1671
|
+
function layoutContainer(parent) {
|
|
1672
|
+
let container = parent;
|
|
1673
|
+
for (;;) {
|
|
1674
|
+
const template = contentOwners.get(container);
|
|
1675
|
+
if (template && !isInertTemplate(template))
|
|
1676
|
+
container = parents.get(template);
|
|
1677
|
+
else if (elementCascade(container).textOf("display") === "contents")
|
|
1678
|
+
container = parents.get(container);
|
|
1679
|
+
else return container;
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
// True when `node` lays out as a non-replaced INLINE box. A flex/grid layout
|
|
1684
|
+
// container (see {@link layoutContainer}) blockifies it.
|
|
1634
1685
|
/** @param {any} node @param {any} [parent] @returns {boolean} */
|
|
1635
1686
|
function isInlineBox(node, parent) {
|
|
1636
1687
|
if (!INLINE_ELEMENTS.has(node.tagName)) return false;
|
|
@@ -1642,10 +1693,7 @@ function isInlineBox(node, parent) {
|
|
|
1642
1693
|
BLOCKIFYING_VALUES.has(textOf("float"))
|
|
1643
1694
|
)
|
|
1644
1695
|
return false;
|
|
1645
|
-
|
|
1646
|
-
while (elementCascade(container).textOf("display") === "contents")
|
|
1647
|
-
container = parents.get(container);
|
|
1648
|
-
return !elementCascade(container)
|
|
1696
|
+
return !elementCascade(layoutContainer(parent))
|
|
1649
1697
|
.textOf("display")
|
|
1650
1698
|
.split(" ")
|
|
1651
1699
|
.some((token) => ITEM_BLOCKIFYING_DISPLAYS.has(token));
|
|
@@ -1893,6 +1941,13 @@ function hasDataSrc(el) {
|
|
|
1893
1941
|
* the `unist-util-visit` contract, which is what the call sites here are
|
|
1894
1942
|
* written against.
|
|
1895
1943
|
*
|
|
1944
|
+
* One addition to that contract: an HTML `template`'s content (hast keeps it
|
|
1945
|
+
* as a `content` root beside the element's empty `children`) is walked after
|
|
1946
|
+
* its children, with the template as its parent, and `SKIP` leaves it
|
|
1947
|
+
* unvisited too. Rendered or not (see {@link isInertTemplate}), that markup is
|
|
1948
|
+
* in the source a model reads, so a scan that stopped at `children` would pass
|
|
1949
|
+
* any hidden element, script or URL placed in it unseen.
|
|
1950
|
+
*
|
|
1896
1951
|
* Spelled out rather than imported because `unist-util-visit` allocates a fresh
|
|
1897
1952
|
* ancestors array and a closure per node, and on a document that is one long
|
|
1898
1953
|
* flat sibling list — 32k `<p>` elements per megabyte of ordinary HTML — that
|
|
@@ -1921,6 +1976,12 @@ function walk(tree, test, visitor, treeParent = undefined) {
|
|
|
1921
1976
|
: undefined;
|
|
1922
1977
|
if (result === EXIT) return;
|
|
1923
1978
|
if (result === SKIP) continue;
|
|
1979
|
+
if (node.type === "element" && node.content !== undefined) {
|
|
1980
|
+
contentOwners.set(node.content, node);
|
|
1981
|
+
nodes.push(node.content);
|
|
1982
|
+
indices.push(undefined);
|
|
1983
|
+
parents.push(node);
|
|
1984
|
+
}
|
|
1924
1985
|
const children = node.children;
|
|
1925
1986
|
if (children === undefined) continue;
|
|
1926
1987
|
for (let i = children.length - 1; i >= 0; i--) {
|
|
@@ -2163,7 +2224,7 @@ function scanFragmentTree(html, tree) {
|
|
|
2163
2224
|
const warned = newWarned();
|
|
2164
2225
|
/** @param {any} node */
|
|
2165
2226
|
const countElement = (node) => {
|
|
2166
|
-
if (
|
|
2227
|
+
if (isReportedElement(node)) countTag(warned, node.tagName);
|
|
2167
2228
|
if (hasDataSrc(node)) warned.dataSrc += 1;
|
|
2168
2229
|
};
|
|
2169
2230
|
// Subtrees still to scan, each with its parent: the tree itself, then the
|
|
@@ -2485,7 +2546,11 @@ function scanInlineChildren(node, text, ranges, warned) {
|
|
|
2485
2546
|
// A raw-text open tag starts an opaque region (a self-closing `/>`
|
|
2486
2547
|
// does not apply to these in HTML — they always open).
|
|
2487
2548
|
if (RAW_TEXT_ELEMENTS.has(el.tagName)) rawText = el.tagName;
|
|
2488
|
-
|
|
2549
|
+
// `el` is one tag parsed alone, so a `<template>` inside an inline
|
|
2550
|
+
// `<svg>`/`<math>` reads as HTML here and is reported, where the
|
|
2551
|
+
// source branch, which sees the namespace, does not: this errs toward
|
|
2552
|
+
// reporting.
|
|
2553
|
+
if (isReportedElement(el)) countTag(warned, el.tagName);
|
|
2489
2554
|
if (hasDataSrc(el)) warned.dataSrc += 1;
|
|
2490
2555
|
}
|
|
2491
2556
|
}
|
|
@@ -3628,20 +3693,40 @@ function documentUrls(text, parseMd, parseHtml) {
|
|
|
3628
3693
|
});
|
|
3629
3694
|
/** @type {string[]} */
|
|
3630
3695
|
const comments = [];
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
|
|
3696
|
+
// Nodes inside an inert template's content, where nothing loads, submits or
|
|
3697
|
+
// redirects until a script clones it into the page.
|
|
3698
|
+
const inert = new Set();
|
|
3699
|
+
walk(
|
|
3700
|
+
parseHtml(text),
|
|
3701
|
+
null,
|
|
3702
|
+
(/** @type {any} */ node, _index, /** @type {any} */ parent) => {
|
|
3703
|
+
if (
|
|
3704
|
+
inert.has(parent) ||
|
|
3705
|
+
(parent?.content === node && isInertTemplate(parent))
|
|
3706
|
+
)
|
|
3707
|
+
inert.add(node);
|
|
3708
|
+
if (node.type === "comment") comments.push(node.value);
|
|
3709
|
+
else if (node.type === "element")
|
|
3710
|
+
for (const url of elementUrls(node))
|
|
3711
|
+
urls.push(
|
|
3712
|
+
inert.has(node)
|
|
3713
|
+
? { ...url, autoFetched: false, context: "resource" }
|
|
3714
|
+
: url,
|
|
3715
|
+
);
|
|
3716
|
+
},
|
|
3717
|
+
);
|
|
3635
3718
|
return { urls, comments };
|
|
3636
3719
|
}
|
|
3637
3720
|
|
|
3638
3721
|
// Every URL a Layer-3 detector reads: markdown links/images/definitions and
|
|
3639
|
-
// the URL attributes of every element, also inside comments
|
|
3640
|
-
// below consume this, so a node type is in scope for
|
|
3641
|
-
// `parseMarkdown` memoizes its tree, so back-to-back
|
|
3642
|
-
// A comment is invisible on the page but not to a
|
|
3643
|
-
// its text is read one level deep with its markup
|
|
3644
|
-
// names is not fetched and has no off-origin
|
|
3722
|
+
// the URL attributes of every element, also inside comments and template
|
|
3723
|
+
// content. Both detectors below consume this, so a node type is in scope for
|
|
3724
|
+
// all of them or for none; `parseMarkdown` memoizes its tree, so back-to-back
|
|
3725
|
+
// detectors share the parse. A comment is invisible on the page but not to a
|
|
3726
|
+
// model reading the source: its text is read one level deep with its markup
|
|
3727
|
+
// openers defused, and what it names is not fetched and has no off-origin
|
|
3728
|
+
// rule. An inert template's content gets the same two answers: nothing in it
|
|
3729
|
+
// is fetched, submitted or followed until a script clones it into the page.
|
|
3645
3730
|
/** @param {string} text @returns {CollectedUrl[]} */
|
|
3646
3731
|
function collectUrls(text) {
|
|
3647
3732
|
const { urls, comments } = documentUrls(text, parseMarkdown, parseFragment);
|
|
@@ -45,9 +45,9 @@ export function readKnob<K extends keyof Knobs>(name: K, env?: NodeJS.ProcessEnv
|
|
|
45
45
|
* cannot reach (it runs its no-node arm on shell builtins alone).
|
|
46
46
|
*
|
|
47
47
|
* THE single source of truth for the closed set. The shell shims cannot import
|
|
48
|
-
* it, so `plugin/scripts/lib/fail-open.sh` is GENERATED from it
|
|
49
|
-
* `scripts/gen-fail-open-lib.mjs
|
|
50
|
-
*
|
|
48
|
+
* it, so `plugin/scripts/lib/fail-open.sh` is GENERATED from it at build time
|
|
49
|
+
* by `scripts/gen-fail-open-lib.mjs`; plugin/test/fail-open-parity.test.mjs
|
|
50
|
+
* asserts the generated lib decides as failOpenEnabled(). Everything else that spells the set
|
|
51
51
|
* out by hand is an implementation that must appear in the parity table in
|
|
52
52
|
* tests/test_safe_launch.py.
|
|
53
53
|
*/
|