agent-sanitizer 2.0.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/src/html.mjs ADDED
@@ -0,0 +1,2233 @@
1
+ /**
2
+ * Hidden-HTML splicing (Layer 2) and exfil-URL detection (Layer 3) for
3
+ * web/HTML ingress.
4
+ *
5
+ * Layer 2 strips exactly what a human viewing the rendered page cannot see —
6
+ * HTML comments and hidden elements (hiding inline styles, `hidden` attr) —
7
+ * by splicing those byte ranges out of the original text and leaving a
8
+ * placeholder; every byte outside a spliced range is preserved verbatim (no
9
+ * re-serialization). Scripting/resource tags (script, style, svg, iframe, …)
10
+ * and `data:` URI resources are REPORTED in the result's `warned` counts but
11
+ * never removed, so fetched page source stays inspectable.
12
+ *
13
+ * Layer 3 reports data-exfil-shaped URLs (suspicious query params, oversized
14
+ * payloads, embedded credentials) without modifying them; the caller surfaces
15
+ * the report as a warning.
16
+ *
17
+ * Split into its own module so it can be lazy-loaded: pulling in the
18
+ * remark/rehype/unified graph costs ~200ms of module-load time, so the main
19
+ * entry `await import()`s this module only when its cheap regex gates match.
20
+ */
21
+ // @ts-ignore -- css-tree ships no bundled types and @types/css-tree lags the 3.x
22
+ // API (e.g. `ident.decode`); the value AST is walked with local `any` types.
23
+ import * as csstree from "css-tree";
24
+ import { unified } from "unified";
25
+ import remarkParse from "remark-parse";
26
+ import remarkGfm from "remark-gfm";
27
+ import rehypeParse from "rehype-parse";
28
+ import { visit, SKIP, EXIT } from "unist-util-visit";
29
+ import {
30
+ HTML_TAG_PRESENT,
31
+ MD_LINK_HINT,
32
+ SECRET_HINT,
33
+ SECRET_HINT_EXT,
34
+ matchesSecretHint,
35
+ } from "./gates.mjs";
36
+
37
+ // The cheap pre-gates live in the dependency-free `./gates.mjs` so the package
38
+ // root can re-export them without eagerly loading this module's remark/rehype
39
+ // graph. Re-exported here too so the `./html` subpath keeps exposing them.
40
+ export {
41
+ HTML_TAG_PRESENT,
42
+ MD_LINK_HINT,
43
+ SECRET_HINT,
44
+ SECRET_HINT_EXT,
45
+ matchesSecretHint,
46
+ };
47
+
48
+ // ─── Layer 2: hidden-content detection ───────────────────────────────────────
49
+ //
50
+ // Values are tokenized by css-tree (its spec-compliant tokenizer/AST), not by
51
+ // hand-rolled regexes: `parseDeclarations` splits declarations, decodes CSS
52
+ // escapes, and strips `!important` through css-tree, and the structural
53
+ // detectors below inspect the resulting typed value nodes (Number, Dimension,
54
+ // Percentage, Function, …) directly. So an exponent (`scale(1e-3)`), an FF/CR
55
+ // escape terminator, an escaped `!important`, or a `;` inside a quoted value is
56
+ // read exactly as a browser reads it — the tokenizer-divergence bugs a
57
+ // hand-rolled parser kept re-introducing simply cannot arise. Ambiguity still
58
+ // fails OPEN (treated as visible): an unresolved unit, `calc()`, or `var()`
59
+ // never counts as hidden.
60
+
61
+ // A length/opacity/size is "near zero" when its magnitude is below this — a
62
+ // browser renders 0.0001px text or 0.001 opacity as effectively invisible, so
63
+ // requiring an exact 0 lets a trivially-perturbed value slip through.
64
+ const NEAR_ZERO_EPSILON = 0.01;
65
+
66
+ // A negative offset is "offscreen" only when it pushes the element ENTIRELY
67
+ // past the viewport edge — the magnitude that takes depends on the unit. An
68
+ // absolute unit (px and the font/char units) needs a large magnitude
69
+ // (< -900px). A viewport/percent unit clears the screen only at a full
70
+ // viewport-width: -100vw / -100% push a normal-width element fully out, but
71
+ // -50vw / -50% leave roughly half of it on screen, so the threshold is a full
72
+ // -100, not a partial shift. Flagging a partial shift would splice visible
73
+ // text, so this errs toward false-negative.
74
+ const OFFSCREEN_ABSOLUTE_THRESHOLD = -900;
75
+ const OFFSCREEN_VIEWPORT_THRESHOLD = -100;
76
+
77
+ // Absolute length units: a large negative magnitude is needed to clear the
78
+ // viewport. True viewport units clear it at a full -100. A `%` offset is
79
+ // viewport-relative for box offsets (left/top/…) but ELEMENT-relative inside a
80
+ // translate(), so it is handled by the callers, not these sets.
81
+ const ABSOLUTE_UNITS = new Set([
82
+ "px",
83
+ "em",
84
+ "rem",
85
+ "ex",
86
+ "ch",
87
+ "pt",
88
+ "pc",
89
+ "in",
90
+ "cm",
91
+ "mm",
92
+ ]);
93
+ const VIEWPORT_UNITS = new Set(["vw", "vh", "vmin", "vmax"]);
94
+ // Angle units for rotateX/rotateY, normalized to degrees by hueDegrees.
95
+ const ANGLE_UNITS = new Set(["deg", "grad", "rad", "turn"]);
96
+
97
+ /**
98
+ * The meaningful value tokens of a css-tree Value or Function node — its direct
99
+ * children minus the Operator/whitespace separators — in document order.
100
+ * @param {any} node
101
+ * @returns {any[]}
102
+ */
103
+ function valueTokens(node) {
104
+ /** @type {any[]} */
105
+ const tokens = [];
106
+ if (!node || !node.children) return tokens;
107
+ node.children.forEach((/** @type {any} */ child) => {
108
+ if (child.type !== "Operator" && child.type !== "WhiteSpace")
109
+ tokens.push(child);
110
+ });
111
+ return tokens;
112
+ }
113
+
114
+ /**
115
+ * The single meaningful token of a value node, or null when the value is empty
116
+ * or carries more than one token (`left:auto` → the Identifier; `left:1px 2px`
117
+ * → null). A one-token requirement mirrors the old anchored `^…$` regexes.
118
+ * @param {any} node
119
+ * @returns {any | null}
120
+ */
121
+ function soleToken(node) {
122
+ const tokens = valueTokens(node);
123
+ return tokens.length === 1 ? tokens[0] : null;
124
+ }
125
+
126
+ /**
127
+ * True when a value is a single length/number/percentage whose magnitude is
128
+ * (near) zero — `font-size:0`, `font-size:0.0001px`, `font-size:0%`. A keyword
129
+ * (`medium`), a multi-token value, or `calc()` fails open.
130
+ * @param {any} node
131
+ * @returns {boolean}
132
+ */
133
+ function isNearZeroLength(node) {
134
+ const token = soleToken(node);
135
+ if (
136
+ !token ||
137
+ (token.type !== "Number" &&
138
+ token.type !== "Dimension" &&
139
+ token.type !== "Percentage")
140
+ )
141
+ return false;
142
+ return Math.abs(parseFloat(token.value)) < NEAR_ZERO_EPSILON;
143
+ }
144
+
145
+ /**
146
+ * Group a Function node's children into its comma-separated argument lists.
147
+ * `translate(0, -9999px)` → `[[Number 0], [Dimension -9999px]]`.
148
+ * @param {any} fn
149
+ * @returns {any[][]}
150
+ */
151
+ function functionArgs(fn) {
152
+ /** @type {any[][]} */
153
+ const groups = [[]];
154
+ if (fn.children)
155
+ fn.children.forEach((/** @type {any} */ child) => {
156
+ if (child.type === "Operator" && child.value === ",") groups.push([]);
157
+ else if (child.type !== "WhiteSpace")
158
+ groups[groups.length - 1].push(child);
159
+ });
160
+ return groups;
161
+ }
162
+
163
+ /**
164
+ * True when a single length token is far enough offscreen to be fully clipped.
165
+ * `%` counts for box offsets (`allowPercent`, viewport-relative) but not inside
166
+ * a translate() (element-relative — unresolvable, fail open). A unitless Number
167
+ * (invalid CSS), an unknown unit, `calc()`, and `auto` all fail open.
168
+ * @param {any} token a css-tree value token
169
+ * @param {boolean} allowPercent
170
+ * @returns {boolean}
171
+ */
172
+ function isOffscreenLength(token, allowPercent) {
173
+ if (token.type === "Dimension") {
174
+ const n = parseFloat(token.value);
175
+ if (ABSOLUTE_UNITS.has(token.unit)) return n < OFFSCREEN_ABSOLUTE_THRESHOLD;
176
+ if (VIEWPORT_UNITS.has(token.unit))
177
+ return n <= OFFSCREEN_VIEWPORT_THRESHOLD;
178
+ return false;
179
+ }
180
+ if (token.type === "Percentage" && allowPercent)
181
+ return parseFloat(token.value) <= OFFSCREEN_VIEWPORT_THRESHOLD;
182
+ return false;
183
+ }
184
+
185
+ /**
186
+ * Like isOffscreenLength but for a box offset (`left`/`top`/…/`text-indent`):
187
+ * the value must be a single token, and `%` is viewport-relative here so it
188
+ * counts.
189
+ * @param {any} node value node for the offset property
190
+ * @returns {boolean}
191
+ */
192
+ function isOffscreenOffset(node) {
193
+ const token = soleToken(node);
194
+ return token ? isOffscreenLength(token, true) : false;
195
+ }
196
+
197
+ /**
198
+ * True when a `transform` renders text invisible: scaled to (near) nothing,
199
+ * rotated edge-on (an odd quarter-turn around X or Y projects to zero area), or
200
+ * translated far off any viewport. Walks the transform-function list so any
201
+ * hiding function anywhere in the list is caught.
202
+ * @param {any} node value node for `transform`
203
+ * @returns {boolean}
204
+ */
205
+ function isHidingTransform(node) {
206
+ if (!node) return false;
207
+ for (const fn of valueTokens(node)) {
208
+ if (fn.type !== "Function") continue;
209
+ const name = fn.name.toLowerCase();
210
+ const args = valueTokens(fn);
211
+ if (/^(?:scale|scale3d|scalex|scaley|matrix|matrix3d)$/.test(name)) {
212
+ // scale/matrix collapse to nothing when EITHER axis factor is (near-)zero —
213
+ // `scale(1,0)` / `scale3d(1,0,1)` collapse the Y axis, `matrix(1,0,0,0,…)`
214
+ // sets scaleY (d) to 0 — so testing only the first factor missed the
215
+ // multi-arg Y-collapse forms. css-tree reads the exponent form (`1e-3`) at
216
+ // full value; scale()/matrix() factors are <number>s (never lengths). For
217
+ // `matrix(a,b,c,d,…)` the two scale factors are a (index 0) and d (index 3).
218
+ // The two scale factors' positions differ per function: scale/scale3d/
219
+ // scaleX/scaleY carry them first (a single scaleX(0)/scaleY(0) sits at 0);
220
+ // `matrix(a,b,c,d,…)` puts scaleX=a (0) and scaleY=d (3); `matrix3d` puts
221
+ // scaleX=m11 (0) and scaleY=m22 (5). Do NOT use index 3 for matrix3d — that
222
+ // is m14, which is legitimately 0 on the identity matrix (false positive).
223
+ const numbers = args.filter(
224
+ (/** @type {any} */ a) => a.type === "Number",
225
+ );
226
+ const factorIdx =
227
+ name === "matrix" ? [0, 3] : name === "matrix3d" ? [0, 5] : [0, 1];
228
+ if (
229
+ factorIdx.some((/** @type {number} */ i) => {
230
+ const f = numbers[i];
231
+ return f && Math.abs(parseFloat(f.value)) < NEAR_ZERO_EPSILON;
232
+ })
233
+ )
234
+ return true;
235
+ } else if (name === "rotatex" || name === "rotatey") {
236
+ // An axis rotation collapses the box to a line at an odd quarter-turn.
237
+ // Only the axis-specific rotations collapse; a plain rotate()/rotateZ()
238
+ // spins in-plane and stays visible. The angle needs an explicit unit (a
239
+ // unitless nonzero angle is invalid CSS a browser drops); hueDegrees
240
+ // normalizes deg/grad/rad/turn to [0,360), and a near-90/270 band absorbs
241
+ // the float drift of rad→deg.
242
+ const a = args[0];
243
+ if (
244
+ a &&
245
+ a.type === "Dimension" &&
246
+ ANGLE_UNITS.has(a.unit.toLowerCase())
247
+ ) {
248
+ const degrees = hueDegrees(`${a.value}${a.unit}`.toLowerCase());
249
+ if (
250
+ degrees !== null &&
251
+ (Math.abs(degrees - 90) < NEAR_ZERO_EPSILON ||
252
+ Math.abs(degrees - 270) < NEAR_ZERO_EPSILON)
253
+ )
254
+ return true;
255
+ }
256
+ } else if (
257
+ name === "translate" ||
258
+ name === "translatex" ||
259
+ name === "translatey"
260
+ ) {
261
+ // A two-axis translate hides when EITHER axis clears the viewport. A `%`
262
+ // translate is element-relative (unresolvable), so it fails open.
263
+ for (const group of functionArgs(fn))
264
+ if (group.length === 1 && isOffscreenLength(group[0], false))
265
+ return true;
266
+ }
267
+ }
268
+ return false;
269
+ }
270
+
271
+ /**
272
+ * True when a `filter` renders content invisible: an `opacity()` function drops
273
+ * the element to fully transparent. The amount is a <number-percentage>; a
274
+ * percentage is divided to a fraction before the near-zero test. Other filter
275
+ * functions keep content visible; an unresolvable amount fails OPEN.
276
+ * @param {any} node value node for `filter`
277
+ * @returns {boolean}
278
+ */
279
+ function isHidingFilter(node) {
280
+ if (!node) return false;
281
+ for (const fn of valueTokens(node)) {
282
+ if (fn.type !== "Function" || fn.name.toLowerCase() !== "opacity") continue;
283
+ const amount = valueTokens(fn)[0];
284
+ if (!amount) continue;
285
+ if (
286
+ amount.type === "Number" &&
287
+ parseFloat(amount.value) < NEAR_ZERO_EPSILON
288
+ )
289
+ return true;
290
+ if (
291
+ amount.type === "Percentage" &&
292
+ parseFloat(amount.value) / 100 < NEAR_ZERO_EPSILON
293
+ )
294
+ return true;
295
+ }
296
+ return false;
297
+ }
298
+
299
+ // One `clip: rect(...)` edge as a number and unit, or null for `auto`/any
300
+ // non-length token (unresolvable → fail open). A bare Number carries unit "".
301
+ /** @param {any} token @returns {{ num: number, unit: string } | null} */
302
+ function clipEdge(token) {
303
+ if (token.type === "Dimension")
304
+ return { num: parseFloat(token.value), unit: token.unit };
305
+ if (token.type === "Number")
306
+ return { num: parseFloat(token.value), unit: "" };
307
+ if (token.type === "Percentage")
308
+ return { num: parseFloat(token.value), unit: "%" };
309
+ return null;
310
+ }
311
+
312
+ /**
313
+ * True when a legacy `clip: rect(top, right, bottom, left)` clips the element to
314
+ * ~ZERO AREA — the window's width (`right - left`) or height (`bottom - top`)
315
+ * collapses to near nothing. Parses ALL FOUR edges (checking only the first
316
+ * spliced a visible `rect(0px,100px,100px,0px)`); an `auto`/unresolvable edge,
317
+ * a wrong edge count, or a pair in mismatched units fails OPEN.
318
+ * @param {any} node value node for `clip`
319
+ * @returns {boolean}
320
+ */
321
+ function isClipRectHidden(node) {
322
+ if (!node) return false;
323
+ const rect = valueTokens(node).find(
324
+ (t) => t.type === "Function" && t.name.toLowerCase() === "rect",
325
+ );
326
+ if (!rect) return false;
327
+ const edges = valueTokens(rect).map(clipEdge);
328
+ if (edges.length !== 4 || edges.some((edge) => edge === null)) return false;
329
+ const [top, right, bottom, left] = /** @type {{num:number,unit:string}[]} */ (
330
+ edges
331
+ );
332
+ /**
333
+ * @param {{ num: number, unit: string }} a
334
+ * @param {{ num: number, unit: string }} b
335
+ */
336
+ const collapsed = (a, b) =>
337
+ a.unit === b.unit && Math.abs(a.num - b.num) < NEAR_ZERO_EPSILON;
338
+ return collapsed(left, right) || collapsed(top, bottom);
339
+ }
340
+
341
+ /**
342
+ * @param {(key: string) => any} nodeOf value node for a property, or null
343
+ * @param {(key: string) => string} textOf decoded/lowercased text for a property
344
+ * @returns {boolean}
345
+ */
346
+ function isPositionedOffscreen(nodeOf, textOf) {
347
+ const position = textOf("position");
348
+ // `relative`/`sticky` shift the rendered box off its normal spot just like
349
+ // `absolute`/`fixed` do, so a `left:-9999px` on any of them pushes the text
350
+ // off any viewport. `static` ignores offsets and is excluded.
351
+ if (!/\babsolute\b|\bfixed\b|\brelative\b|\bsticky\b/.test(position))
352
+ return false;
353
+ for (const side of ["left", "top", "right", "bottom"])
354
+ if (isOffscreenOffset(nodeOf(side))) return true;
355
+ // The legacy `clip` property only clips ABSOLUTELY-positioned boxes
356
+ // (absolute/fixed); a relative/sticky element ignores it, so reading its
357
+ // rect() as a hide there would splice visible text (fail open).
358
+ if (!/\babsolute\b|\bfixed\b/.test(position)) return false;
359
+ return isClipRectHidden(nodeOf("clip"));
360
+ }
361
+
362
+ // The full CSS named-color set canonicalized to `#rrggbb`, so any two identical
363
+ // resolvable named colors (`color:blue;background:blue`) — not just the handful
364
+ // that back white-on-white text — compare equal for the same-color hide test.
365
+ // `transparent` maps to itself (the sentinel isConcreteColor also accepts).
366
+ // var()/inherit/currentColor are deliberately absent: they resolve via the
367
+ // cascade and must fail OPEN, handled by isConcreteColor at the compare.
368
+ /** @type {Record<string, string>} */
369
+ // Stryker disable all — static CSS color data table (147 canonical name→hex
370
+ // entries). Mutating each hex/name literal yields hundreds of low-value,
371
+ // largely-equivalent mutants (no test can meaningfully pin every color) that
372
+ // balloon the html shard past its CI timeout. The canonicalization LOGIC that
373
+ // consumes this table stays under mutation. Same idiom as the Unicode data
374
+ // tables in standardized-variants.mjs/joining-type.mjs/cf-charset.mjs.
375
+ const NAMED_COLORS = {
376
+ aliceblue: "#f0f8ff",
377
+ antiquewhite: "#faebd7",
378
+ aqua: "#00ffff",
379
+ aquamarine: "#7fffd4",
380
+ azure: "#f0ffff",
381
+ beige: "#f5f5dc",
382
+ bisque: "#ffe4c4",
383
+ black: "#000000",
384
+ blanchedalmond: "#ffebcd",
385
+ blue: "#0000ff",
386
+ blueviolet: "#8a2be2",
387
+ brown: "#a52a2a",
388
+ burlywood: "#deb887",
389
+ cadetblue: "#5f9ea0",
390
+ chartreuse: "#7fff00",
391
+ chocolate: "#d2691e",
392
+ coral: "#ff7f50",
393
+ cornflowerblue: "#6495ed",
394
+ cornsilk: "#fff8dc",
395
+ crimson: "#dc143c",
396
+ cyan: "#00ffff",
397
+ darkblue: "#00008b",
398
+ darkcyan: "#008b8b",
399
+ darkgoldenrod: "#b8860b",
400
+ darkgray: "#a9a9a9",
401
+ darkgreen: "#006400",
402
+ darkgrey: "#a9a9a9",
403
+ darkkhaki: "#bdb76b",
404
+ darkmagenta: "#8b008b",
405
+ darkolivegreen: "#556b2f",
406
+ darkorange: "#ff8c00",
407
+ darkorchid: "#9932cc",
408
+ darkred: "#8b0000",
409
+ darksalmon: "#e9967a",
410
+ darkseagreen: "#8fbc8f",
411
+ darkslateblue: "#483d8b",
412
+ darkslategray: "#2f4f4f",
413
+ darkslategrey: "#2f4f4f",
414
+ darkturquoise: "#00ced1",
415
+ darkviolet: "#9400d3",
416
+ deeppink: "#ff1493",
417
+ deepskyblue: "#00bfff",
418
+ dimgray: "#696969",
419
+ dimgrey: "#696969",
420
+ dodgerblue: "#1e90ff",
421
+ firebrick: "#b22222",
422
+ floralwhite: "#fffaf0",
423
+ forestgreen: "#228b22",
424
+ fuchsia: "#ff00ff",
425
+ gainsboro: "#dcdcdc",
426
+ ghostwhite: "#f8f8ff",
427
+ gold: "#ffd700",
428
+ goldenrod: "#daa520",
429
+ gray: "#808080",
430
+ green: "#008000",
431
+ greenyellow: "#adff2f",
432
+ grey: "#808080",
433
+ honeydew: "#f0fff0",
434
+ hotpink: "#ff69b4",
435
+ indianred: "#cd5c5c",
436
+ indigo: "#4b0082",
437
+ ivory: "#fffff0",
438
+ khaki: "#f0e68c",
439
+ lavender: "#e6e6fa",
440
+ lavenderblush: "#fff0f5",
441
+ lawngreen: "#7cfc00",
442
+ lemonchiffon: "#fffacd",
443
+ lightblue: "#add8e6",
444
+ lightcoral: "#f08080",
445
+ lightcyan: "#e0ffff",
446
+ lightgoldenrodyellow: "#fafad2",
447
+ lightgray: "#d3d3d3",
448
+ lightgreen: "#90ee90",
449
+ lightgrey: "#d3d3d3",
450
+ lightpink: "#ffb6c1",
451
+ lightsalmon: "#ffa07a",
452
+ lightseagreen: "#20b2aa",
453
+ lightskyblue: "#87cefa",
454
+ lightslategray: "#778899",
455
+ lightslategrey: "#778899",
456
+ lightsteelblue: "#b0c4de",
457
+ lightyellow: "#ffffe0",
458
+ lime: "#00ff00",
459
+ limegreen: "#32cd32",
460
+ linen: "#faf0e6",
461
+ magenta: "#ff00ff",
462
+ maroon: "#800000",
463
+ mediumaquamarine: "#66cdaa",
464
+ mediumblue: "#0000cd",
465
+ mediumorchid: "#ba55d3",
466
+ mediumpurple: "#9370db",
467
+ mediumseagreen: "#3cb371",
468
+ mediumslateblue: "#7b68ee",
469
+ mediumspringgreen: "#00fa9a",
470
+ mediumturquoise: "#48d1cc",
471
+ mediumvioletred: "#c71585",
472
+ midnightblue: "#191970",
473
+ mintcream: "#f5fffa",
474
+ mistyrose: "#ffe4e1",
475
+ moccasin: "#ffe4b5",
476
+ navajowhite: "#ffdead",
477
+ navy: "#000080",
478
+ oldlace: "#fdf5e6",
479
+ olive: "#808000",
480
+ olivedrab: "#6b8e23",
481
+ orange: "#ffa500",
482
+ orangered: "#ff4500",
483
+ orchid: "#da70d6",
484
+ palegoldenrod: "#eee8aa",
485
+ palegreen: "#98fb98",
486
+ paleturquoise: "#afeeee",
487
+ palevioletred: "#db7093",
488
+ papayawhip: "#ffefd5",
489
+ peachpuff: "#ffdab9",
490
+ peru: "#cd853f",
491
+ pink: "#ffc0cb",
492
+ plum: "#dda0dd",
493
+ powderblue: "#b0e0e6",
494
+ purple: "#800080",
495
+ rebeccapurple: "#663399",
496
+ red: "#ff0000",
497
+ rosybrown: "#bc8f8f",
498
+ royalblue: "#4169e1",
499
+ saddlebrown: "#8b4513",
500
+ salmon: "#fa8072",
501
+ sandybrown: "#f4a460",
502
+ seagreen: "#2e8b57",
503
+ seashell: "#fff5ee",
504
+ sienna: "#a0522d",
505
+ silver: "#c0c0c0",
506
+ skyblue: "#87ceeb",
507
+ slateblue: "#6a5acd",
508
+ slategray: "#708090",
509
+ slategrey: "#708090",
510
+ snow: "#fffafa",
511
+ springgreen: "#00ff7f",
512
+ steelblue: "#4682b4",
513
+ tan: "#d2b48c",
514
+ teal: "#008080",
515
+ thistle: "#d8bfd8",
516
+ tomato: "#ff6347",
517
+ transparent: "transparent",
518
+ turquoise: "#40e0d0",
519
+ violet: "#ee82ee",
520
+ wheat: "#f5deb3",
521
+ white: "#ffffff",
522
+ whitesmoke: "#f5f5f5",
523
+ yellow: "#ffff00",
524
+ yellowgreen: "#9acd32",
525
+ };
526
+ // Stryker restore all
527
+
528
+ /**
529
+ * True when a canonicalized color is a concrete value we can compare for
530
+ * equality — a resolved `#rrggbb` hex or `transparent`. `var(--x)`/`inherit`/
531
+ * `currentColor` canonicalize to their raw token and are NOT concrete: their
532
+ * effective color depends on the cascade, so a same-color hide can't be proven.
533
+ * @param {string} canonical
534
+ * @returns {boolean}
535
+ */
536
+ function isConcreteColor(canonical) {
537
+ return canonical === "transparent" || /^#[0-9a-f]{6}$/.test(canonical);
538
+ }
539
+
540
+ /** @param {number} n @returns {string} clamped two-hex-digit byte */
541
+ function hexByte(n) {
542
+ return Math.max(0, Math.min(255, Math.round(n)))
543
+ .toString(16)
544
+ .padStart(2, "0");
545
+ }
546
+
547
+ /**
548
+ * Parse one rgb() channel: an integer/number `0..255` (clamped, as a browser
549
+ * clamps out-of-range) or a percentage `0%..100%` scaled to `0..255`. Returns
550
+ * null (fail open) on any other shape — a `none`/`calc()`/negative channel we
551
+ * cannot resolve to a concrete byte.
552
+ * @param {string} token
553
+ * @returns {number | null}
554
+ */
555
+ function rgbChannel(token) {
556
+ const pct = token.match(/^\+?(\d*\.?\d+)%$/);
557
+ if (pct) return (Math.min(100, parseFloat(pct[1])) / 100) * 255;
558
+ const num = token.match(/^\+?(\d*\.?\d+)$/);
559
+ if (num) return parseFloat(num[1]);
560
+ return null;
561
+ }
562
+
563
+ /**
564
+ * Parse an hsl() hue as degrees (a `<number>` or an `<angle>` in
565
+ * deg/grad/rad/turn), normalized to `[0,360)`. Returns null on anything else.
566
+ * @param {string} token
567
+ * @returns {number | null}
568
+ */
569
+ function hueDegrees(token) {
570
+ const match = token.match(/^([+-]?\d*\.?\d+)(deg|grad|rad|turn)?$/);
571
+ if (!match) return null;
572
+ const value = parseFloat(match[1]);
573
+ const unit = match[2] || "deg";
574
+ const deg =
575
+ unit === "grad"
576
+ ? (value * 360) / 400
577
+ : unit === "rad"
578
+ ? (value * 180) / Math.PI
579
+ : unit === "turn"
580
+ ? value * 360
581
+ : value;
582
+ return ((deg % 360) + 360) % 360;
583
+ }
584
+
585
+ /**
586
+ * Parse an hsl() saturation/lightness: a percentage or (CSS Color 4) a bare
587
+ * number, both read as `0..100` (clamped high). Returns null on any other shape.
588
+ * @param {string} token
589
+ * @returns {number | null}
590
+ */
591
+ function hslPercent(token) {
592
+ const match = token.match(/^\+?(\d*\.?\d+)%?$/);
593
+ return match ? Math.min(100, parseFloat(match[1])) : null;
594
+ }
595
+
596
+ /**
597
+ * Convert HSL (`h` in degrees, `s`/`l` in `0..100`) to lowercase `#rrggbb`.
598
+ * @param {number} h @param {number} s @param {number} l @returns {string}
599
+ */
600
+ function hslToHex(h, s, l) {
601
+ const sat = s / 100;
602
+ const light = l / 100;
603
+ const c = (1 - Math.abs(2 * light - 1)) * sat;
604
+ const hp = h / 60;
605
+ const x = c * (1 - Math.abs((hp % 2) - 1));
606
+ const [r, g, b] =
607
+ hp < 1
608
+ ? [c, x, 0]
609
+ : hp < 2
610
+ ? [x, c, 0]
611
+ : hp < 3
612
+ ? [0, c, x]
613
+ : hp < 4
614
+ ? [0, x, c]
615
+ : hp < 5
616
+ ? [x, 0, c]
617
+ : [c, 0, x];
618
+ const m = light - c / 2;
619
+ return `#${hexByte((r + m) * 255)}${hexByte((g + m) * 255)}${hexByte((b + m) * 255)}`;
620
+ }
621
+
622
+ /**
623
+ * Resolve an `rgb()/rgba()/hsl()/hsla()` function to `#rrggbb`, or
624
+ * `"transparent"` when its alpha channel is a literal zero (fully transparent
625
+ * text is invisible), or null when any component is unresolvable (fail open).
626
+ * Accepts the legacy comma form and the CSS Color 4 space/`/`-alpha form
627
+ * (`rgb(255 255 255 / 0.5)`, `hsl(0 0% 100%)`) and percentage channels.
628
+ * @param {string} value lowercased, trimmed
629
+ * @returns {string | null}
630
+ */
631
+ function canonicalizeColorFunction(value) {
632
+ const outer = value.match(/^(rgba?|hsla?)\(([^()]*)\)$/);
633
+ if (!outer) return null;
634
+ const isRgb = outer[1].startsWith("rgb");
635
+ let inner = outer[2].trim();
636
+ // Split the CSS Color 4 `<color> / <alpha>` form; a literal-zero alpha is
637
+ // fully transparent regardless of the color channels.
638
+ const slash = inner.split("/");
639
+ if (slash.length > 2) return null;
640
+ let alpha = slash.length === 2 ? slash[1].trim() : null;
641
+ if (slash.length === 2) inner = slash[0].trim();
642
+ const parts = inner.split(/[\s,]+/).filter(Boolean);
643
+ // The legacy comma form carries alpha as a 4th channel.
644
+ if (alpha === null && parts.length === 4) {
645
+ alpha = parts[3];
646
+ parts.length = 3;
647
+ }
648
+ // A literal-zero alpha is fully transparent — bare number (`0`, `0.0`) or the
649
+ // CSS Color 4 percentage form (`0%`), which a browser also renders invisible.
650
+ if (alpha !== null && /^\+?0*\.?0+%?$/.test(alpha)) return "transparent";
651
+ if (parts.length !== 3) return null;
652
+ if (isRgb) {
653
+ const channels = parts.map(rgbChannel);
654
+ if (channels.some((c) => c === null)) return null;
655
+ return `#${channels.map((c) => hexByte(/** @type {number} */ (c))).join("")}`;
656
+ }
657
+ const h = hueDegrees(parts[0]);
658
+ const s = hslPercent(parts[1]);
659
+ const l = hslPercent(parts[2]);
660
+ if (h === null || s === null || l === null) return null;
661
+ return hslToHex(h, s, l);
662
+ }
663
+
664
+ /**
665
+ * Canonicalize a CSS color to lowercase `#rrggbb` so `white`, `#FFF`,
666
+ * `#ffffff`, `rgb(255, 255, 255)`, `rgb(255 255 255)`, `rgb(100% 100% 100%)`,
667
+ * and `hsl(0 0% 100%)` all compare equal. Returns the trimmed lowercased input
668
+ * unchanged when it is not a form we recognize; callers gate the same-color
669
+ * compare on isConcreteColor so an unresolved token (`var()`, `inherit`) never
670
+ * falsely reads as a same-color hide.
671
+ * @param {string} raw
672
+ * @returns {string}
673
+ */
674
+ function canonicalizeColor(raw) {
675
+ const value = raw.trim().toLowerCase();
676
+ if (!value) return "";
677
+ // Own-key only: `in` would match inherited members, so a CSS value of
678
+ // `__proto__`/`constructor`/`toString` returns an object or function here
679
+ // (poisoning isHiddenStyle's return) instead of falling through as a plain
680
+ // string.
681
+ if (Object.hasOwn(NAMED_COLORS, value)) return NAMED_COLORS[value];
682
+ const shortHex = value.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);
683
+ if (shortHex)
684
+ return `#${shortHex[1]}${shortHex[1]}${shortHex[2]}${shortHex[2]}${shortHex[3]}${shortHex[3]}`;
685
+ if (/^#[0-9a-f]{6}$/.test(value)) return value;
686
+ return canonicalizeColorFunction(value) ?? value;
687
+ }
688
+
689
+ // The leading color token of a `background` shorthand (the first token that
690
+ // canonicalizes to a real color), so `background:#fff` still compares. Returns
691
+ // "" (fail open, no same-color hide) when the shorthand carries an IMAGE layer
692
+ // — `url(...)`, a gradient, or `image-set(...)`: the painted image can make
693
+ // same-colored text perfectly readable over it (and if it fails to load the
694
+ // element's own background shows through), so the flat color token is not
695
+ // provably the rendered backdrop.
696
+ /** @param {string} shorthand @returns {string} */
697
+ function backgroundColor(shorthand) {
698
+ if (/\burl\(|gradient\(|image-set\(/i.test(shorthand)) return "";
699
+ for (const token of shorthand.split(/\s+/)) {
700
+ const color = canonicalizeColor(token);
701
+ if (color && (color.startsWith("#") || color === "transparent"))
702
+ return color;
703
+ }
704
+ return "";
705
+ }
706
+
707
+ // One resolved `inset()` edge collapses the box only when it is a percentage of
708
+ // at least 50%: opposing edges (top/bottom, left/right) then sum to >=100% and
709
+ // leave zero area. A length (`inset(200px)`), `calc()`, or `0` cannot be proven
710
+ // to collapse without the box size, so it fails open (not collapsing).
711
+ /** @param {any} edge value token @returns {boolean} */
712
+ function isCollapsingInsetEdge(edge) {
713
+ return edge.type === "Percentage" && parseFloat(edge.value) >= 50;
714
+ }
715
+
716
+ // Expand an `inset()`'s 1–4 edge tokens to `[top, right, bottom, left]` using
717
+ // the CSS margin-style shorthand rules.
718
+ /** @param {any[]} parts @returns {any[]} */
719
+ function expandInsetEdges(parts) {
720
+ const [t, r = t, b = t, l = r] = parts;
721
+ return [t, r, b, l];
722
+ }
723
+
724
+ // The edge tokens of an `inset()`, stopping at a `round <border-radius>` suffix.
725
+ /** @param {any} fn `inset` Function node @returns {any[]} */
726
+ function insetEdges(fn) {
727
+ /** @type {any[]} */
728
+ const edges = [];
729
+ for (const token of valueTokens(fn)) {
730
+ if (token.type === "Identifier" && token.name.toLowerCase() === "round")
731
+ break;
732
+ edges.push(token);
733
+ }
734
+ return edges;
735
+ }
736
+
737
+ /**
738
+ * True when a `clip-path` clips the element to nothing: `circle(0)` (zero
739
+ * radius, in any unit), or an `inset()` whose FOUR resolved edges ALL collapse
740
+ * (each a percentage >=50%). A partial inset that leaves any edge open
741
+ * (`inset(50% 0 0 0)` — bottom half visible) is NOT hidden: inspecting only the
742
+ * first value would over-splice it. Decorative clips (`circle(50%)`, small
743
+ * insets, polygons) render content and are left alone.
744
+ * @param {any} node value node for `clip-path`
745
+ * @returns {boolean}
746
+ */
747
+ function isClipPathHidden(node) {
748
+ if (!node) return false;
749
+ for (const fn of valueTokens(node)) {
750
+ if (fn.type !== "Function") continue;
751
+ const name = fn.name.toLowerCase();
752
+ if (name === "circle") {
753
+ const radius = valueTokens(fn)[0];
754
+ if (
755
+ radius &&
756
+ (radius.type === "Number" ||
757
+ radius.type === "Dimension" ||
758
+ radius.type === "Percentage") &&
759
+ parseFloat(radius.value) === 0
760
+ )
761
+ return true;
762
+ } else if (name === "inset") {
763
+ const edges = insetEdges(fn);
764
+ if (
765
+ edges.length >= 1 &&
766
+ edges.length <= 4 &&
767
+ expandInsetEdges(edges).every(isCollapsingInsetEdge)
768
+ )
769
+ return true;
770
+ }
771
+ }
772
+ return false;
773
+ }
774
+
775
+ // The color painted by `-webkit-text-stroke` (the `<color>` token of the
776
+ // shorthand, or the `-webkit-text-stroke-color` longhand), canonicalized — or
777
+ // "" when it does not resolve to a concrete color. The longhand is a whole
778
+ // color value so canonicalizeColor handles it directly; the shorthand is
779
+ // `<line-width> || <color>`, so the width token (a length) is skipped and the
780
+ // remaining color token canonicalized.
781
+ /** @param {(key: string) => string} val @returns {string} */
782
+ function textStrokeColor(val) {
783
+ const longhand = canonicalizeColor(val("-webkit-text-stroke-color"));
784
+ if (isConcreteColor(longhand)) return longhand;
785
+ for (const token of val("-webkit-text-stroke").split(/\s+/).filter(Boolean)) {
786
+ const color = canonicalizeColor(token);
787
+ if (isConcreteColor(color)) return color;
788
+ }
789
+ return "";
790
+ }
791
+
792
+ // Gradient-clipped / outlined headings are VISIBLE despite an effectively
793
+ // transparent fill: `background-clip:text` (or its `-webkit-` alias) paints the
794
+ // background through the glyph shapes, and `-webkit-text-stroke` paints a visible
795
+ // outline around the (transparent-filled) glyphs. Either means the transparent
796
+ // paint is not the whole story, so the same-`transparent` hide must fail open.
797
+ // A concrete `-webkit-text-fill-color` is NOT checked here: the caller resolves
798
+ // the EFFECTIVE fill (fill override ?? color) before this runs, so a concrete
799
+ // fill already keeps `effectiveColor` non-transparent and never reaches here.
800
+ /** @param {(key: string) => string} val @returns {boolean} */
801
+ function isTextPaintedVisible(val) {
802
+ if (
803
+ val("background-clip") === "text" ||
804
+ val("-webkit-background-clip") === "text"
805
+ )
806
+ return true;
807
+ const stroke = textStrokeColor(val);
808
+ return isConcreteColor(stroke) && stroke !== "transparent";
809
+ }
810
+
811
+ // True when the element paints a background IMAGE layer — a `background-image`
812
+ // longhand set to anything but `none`, or a `background` shorthand carrying
813
+ // `url(...)`, a gradient, or `image-set(...)`. A same-color text/background hide
814
+ // CANNOT be proven when an image layer is present: the painted image can make
815
+ // same-colored text readable, and if it fails to load the element's own
816
+ // background shows through. Centralized so EVERY hide branch consults one
817
+ // image-layer check — the `background` shorthand path already failed open via
818
+ // {@link backgroundColor}, but the `background-color` longhand path inspected
819
+ // only the flat color and missed a co-declared `background-image`, splicing
820
+ // visible text. `background-clip:text` is NOT an image layer here — it paints
821
+ // the background THROUGH the glyphs and is handled by {@link isTextPaintedVisible}.
822
+ /** @param {(key: string) => string} textOf @returns {boolean} */
823
+ function hasImageLayer(textOf) {
824
+ const img = textOf("background-image");
825
+ if (img && img !== "none") return true;
826
+ return /\burl\(|gradient\(|image-set\(/i.test(textOf("background"));
827
+ }
828
+
829
+ /**
830
+ * @param {(key: string) => any} nodeOf value node for a property, or null
831
+ * @param {(key: string) => string} textOf decoded/lowercased text for a property
832
+ * @returns {boolean}
833
+ */
834
+ function isOverflowHidden(nodeOf, textOf) {
835
+ if (textOf("overflow") !== "hidden") return false;
836
+ for (const dim of ["height", "width", "max-height", "max-width"])
837
+ // Near-zero (epsilon band), not exact 0, so `height:0.0001px` still counts —
838
+ // matching the standalone size checks a browser renders as invisible.
839
+ if (isNearZeroLength(nodeOf(dim))) return true;
840
+ return false;
841
+ }
842
+
843
+ // The length units that denote a font-size in a `font` SHORTHAND. Only a length
844
+ // with one of these units is a size (a bare number there is a weight, a `%` a
845
+ // stretch), so those never misread as a zero size. `q` (quarter-mm) is a font
846
+ // length unit too, though it never gates an offset above.
847
+ const FONT_SIZE_UNITS = new Set([...ABSOLUTE_UNITS, "q"]);
848
+
849
+ /**
850
+ * True when a `font` SHORTHAND's font-size collapses the text to nothing. The
851
+ * font-size is the FIRST length token in the shorthand (it precedes an optional
852
+ * `/line-height` and the family); a near-zero one hides the text just like the
853
+ * `font-size` longhand.
854
+ * @param {any} node value node for `font`
855
+ * @returns {boolean}
856
+ */
857
+ function isFontShorthandHidden(node) {
858
+ if (!node) return false;
859
+ for (const token of valueTokens(node))
860
+ if (token.type === "Dimension" && FONT_SIZE_UNITS.has(token.unit))
861
+ return Math.abs(parseFloat(token.value)) < NEAR_ZERO_EPSILON;
862
+ return false;
863
+ }
864
+
865
+ // A CSS property-name ident AFTER escape decoding: up to two leading hyphens
866
+ // (vendor prefix / custom property), then a letter or underscore, then letters,
867
+ // digits, hyphens, or underscores. css-tree will accept an escaped ident as a
868
+ // declaration property (e.g. `\3a` decoding to `:`); this gate rejects anything
869
+ // a real browser's ident tokenizer would reject so a decoded non-ident property
870
+ // never drives a hidden verdict.
871
+ const CSS_PROPERTY_IDENT_RE = /^-{0,2}[A-Za-z_][A-Za-z0-9_-]*$/;
872
+
873
+ /**
874
+ * Reconstruct a declaration's decoded value as a string for keyword/color
875
+ * comparisons. Identifier tokens are escape-decoded through css-tree's ident
876
+ * decoder (so `no\6e e`/`hi\64 den` read as `none`/`hidden`, with FF/CR/CRLF
877
+ * terminators and invalid codepoints handled per the CSS spec); every other
878
+ * token is re-serialized. A whole-value `Raw` (an unparsed value) is returned
879
+ * verbatim — it never matches a hiding keyword, so it fails open.
880
+ * @param {any} valueNode
881
+ * @returns {string}
882
+ */
883
+ function declText(valueNode) {
884
+ if (!valueNode) return "";
885
+ if (valueNode.type === "Raw") return valueNode.value;
886
+ /** @type {string[]} */
887
+ const parts = [];
888
+ if (valueNode.children)
889
+ valueNode.children.forEach((/** @type {any} */ child) =>
890
+ parts.push(
891
+ child.type === "Identifier"
892
+ ? csstree.ident.decode(child.name)
893
+ : csstree.generate(child),
894
+ ),
895
+ );
896
+ return parts.join(" ");
897
+ }
898
+
899
+ /**
900
+ * Parse a style string into a map of decoded lowercase property name -> parsed
901
+ * value node, via css-tree's tolerant declaration-list parser. This replaces the
902
+ * hand-rolled declaration splitter, per-declaration salvage, escape decoder, and
903
+ * `!important` stripper in one pass: css-tree recovers per-declaration exactly
904
+ * as a browser does (a bogus declaration is dropped, the rest kept), keeps a `;`
905
+ * inside a string/`url()`/paren as part of the value, and exposes `!important`
906
+ * as `node.important` (so an escaped spelling `none!\69mportant` is stripped for
907
+ * free). Property names are escape-decoded and gated to real CSS idents;
908
+ * anything else is dropped (fail open). Later declarations win, per the cascade.
909
+ * @param {string} styleStr
910
+ * @returns {Map<string, any>}
911
+ */
912
+ function parseDeclarations(styleStr) {
913
+ /** @type {Map<string, any>} */
914
+ const decls = new Map();
915
+ let ast;
916
+ try {
917
+ ast = csstree.parse(styleStr, {
918
+ context: "declarationList",
919
+ parseValue: true,
920
+ parseCustomProperty: false,
921
+ onParseError() {},
922
+ });
923
+ /* c8 ignore start -- the only reachable throw path is a non-string / deeply
924
+ pathological input (css-tree's onParseError recovers ordinary bad CSS);
925
+ this fail-open upholds the module's never-throws contract for those. */
926
+ } catch {
927
+ return decls;
928
+ }
929
+ /* c8 ignore stop */
930
+ csstree.walk(ast, {
931
+ visit: "Declaration",
932
+ enter(/** @type {any} */ node) {
933
+ // ident.decode is pure string iteration and cannot throw on a real ident
934
+ // token; property is escape-decoded then gated to a clean CSS ident.
935
+ const property = csstree.ident.decode(node.property).trim().toLowerCase();
936
+ if (!CSS_PROPERTY_IDENT_RE.test(property)) return;
937
+ decls.set(property, node.value);
938
+ },
939
+ });
940
+ return decls;
941
+ }
942
+
943
+ /**
944
+ * @param {string} styleStr
945
+ * @returns {boolean}
946
+ */
947
+ export function isHiddenStyle(styleStr) {
948
+ const decls = parseDeclarations(styleStr);
949
+ if (decls.size === 0) return false;
950
+
951
+ /** @param {string} key */
952
+ const nodeOf = (key) => decls.get(key) ?? null;
953
+ // `!important` is already excluded by css-tree; escapes are decoded in
954
+ // declText. Trim/lowercase for the case-insensitive keyword compares.
955
+ /** @param {string} key */
956
+ const textOf = (key) => declText(decls.get(key)).trim().toLowerCase();
957
+
958
+ if (textOf("display") === "none") return true;
959
+ if (textOf("visibility") === "hidden" || textOf("visibility") === "collapse")
960
+ return true;
961
+ // `content-visibility:hidden` skips rendering the element's contents entirely
962
+ // (not even laid out), so the text is invisible to a human but present in the
963
+ // source. `auto`/`visible` keep it rendered and must not match.
964
+ if (textOf("content-visibility") === "hidden") return true;
965
+
966
+ // CSS clamps opacity to [0,1], so any NEGATIVE value renders fully
967
+ // transparent — `< EPSILON` (no `Math.abs`) treats `-1`/`-0.5` as hidden.
968
+ // `opacity` is a <number> or <percentage>; any other token (`0px`, a bare
969
+ // Dimension) is an INVALID declaration a browser ignores (element stays
970
+ // visible), so fail open on anything that isn't a single Number/Percentage.
971
+ const opacity = soleToken(nodeOf("opacity"));
972
+ if (opacity) {
973
+ let fraction = null;
974
+ if (opacity.type === "Number") fraction = parseFloat(opacity.value);
975
+ else if (opacity.type === "Percentage")
976
+ fraction = parseFloat(opacity.value) / 100;
977
+ if (fraction !== null && fraction < NEAR_ZERO_EPSILON) return true;
978
+ }
979
+
980
+ // `height`/`width` are deliberately NOT tested standalone here: with the
981
+ // default `overflow:visible`, a zero-sized box still paints its overflowing
982
+ // children, so a bare `width:0`/`height:0` leaves content on screen.
983
+ // `isOverflowHidden` below already covers the case where a zero dimension
984
+ // DOES hide content — gated on `overflow:hidden` also being present.
985
+ // `font-size:0`, in contrast, reliably collapses text to nothing on its own.
986
+ if (isNearZeroLength(nodeOf("font-size"))) return true;
987
+ // The `font` shorthand also carries the font-size, so a `font:0px/1 serif`
988
+ // collapses text just like the longhand — check its size token too.
989
+ if (isFontShorthandHidden(nodeOf("font"))) return true;
990
+
991
+ if (isPositionedOffscreen(nodeOf, textOf)) return true;
992
+
993
+ if (isOffscreenOffset(nodeOf("text-indent"))) return true;
994
+
995
+ // Clipped to nothing: the modern equivalent of the legacy `clip: rect(0…)`.
996
+ if (isClipPathHidden(nodeOf("clip-path"))) return true;
997
+ if (isHidingTransform(nodeOf("transform"))) return true;
998
+ if (isHidingFilter(nodeOf("filter"))) return true;
999
+
1000
+ // Same-color text on its background (white-on-white) and fully transparent
1001
+ // text are invisible to a human but plain text to the model. Colors are
1002
+ // canonicalized so `white`/`#fff`/`rgb(255,255,255)` mixes still compare.
1003
+ // The color actually PAINTED onto the glyphs: `-webkit-text-fill-color`
1004
+ // overrides `color` for the fill when it is concrete, so both hide branches
1005
+ // must reason about this effective fill, not the raw `color` property — else a
1006
+ // `color:#fff;-webkit-text-fill-color:#000` element (black text) is compared as
1007
+ // white and spliced white-on-white.
1008
+ const color = canonicalizeColor(textOf("color"));
1009
+ const fillOverride = canonicalizeColor(textOf("-webkit-text-fill-color"));
1010
+ const effectiveColor = isConcreteColor(fillOverride) ? fillOverride : color;
1011
+ // `color:transparent` (or a transparent fill override) hides text — UNLESS the
1012
+ // glyphs are painted by a background-clip:text gradient, a concrete
1013
+ // -webkit-text-fill-color, or a text stroke, in which case the text is visible.
1014
+ if (effectiveColor === "transparent" && !isTextPaintedVisible(textOf))
1015
+ return true;
1016
+ const background =
1017
+ canonicalizeColor(textOf("background-color")) ||
1018
+ backgroundColor(textOf("background"));
1019
+ // Only flag same-color when BOTH sides resolve to a concrete color (`#rrggbb`
1020
+ // or `transparent`), AND no background IMAGE layer is present (an image can
1021
+ // make same-colored text readable). `var(--x)`, `inherit`, and `currentColor`
1022
+ // canonicalize to their raw token, so two identical unresolved tokens (e.g. the
1023
+ // ubiquitous `color:var(--fg);background:var(--fg)`, which resolve to DIFFERENT
1024
+ // effective colors) would otherwise read as hidden and splice out visible text.
1025
+ // Fail open on anything we can't resolve.
1026
+ if (
1027
+ effectiveColor &&
1028
+ effectiveColor === background &&
1029
+ isConcreteColor(effectiveColor) &&
1030
+ !hasImageLayer(textOf)
1031
+ )
1032
+ return true;
1033
+
1034
+ return isOverflowHidden(nodeOf, textOf);
1035
+ }
1036
+
1037
+ // Scripting / resource-loading tags whose PRESENCE is reported to the model
1038
+ // but whose content is preserved: their bodies are page source the model may
1039
+ // legitimately need to inspect (how a page's scripts work, its styles, its
1040
+ // SVGs), so unlike hidden elements they are never removed.
1041
+ export const REPORTED_TAGS = new Set([
1042
+ "script",
1043
+ "style",
1044
+ "object",
1045
+ "embed",
1046
+ "iframe",
1047
+ "svg",
1048
+ "math",
1049
+ ]);
1050
+
1051
+ // HTML void elements: they never carry content and never emit a closing tag, so
1052
+ // a hidden one (<img hidden>, <input hidden>, …) must be spliced as a single node
1053
+ // — opening a balance region for it would run to the container's end (no close
1054
+ // ever arrives) and delete the visible text that follows.
1055
+ const VOID_ELEMENTS = new Set([
1056
+ "area",
1057
+ "base",
1058
+ "br",
1059
+ "col",
1060
+ "embed",
1061
+ "hr",
1062
+ "img",
1063
+ "input",
1064
+ "link",
1065
+ "meta",
1066
+ "param",
1067
+ "source",
1068
+ "track",
1069
+ "wbr",
1070
+ ]);
1071
+
1072
+ // Elements whose content is RAW TEXT / RCDATA / script data: parse5 recognizes
1073
+ // NO markup inside them (a `<!…` is not a comment, a `<b>` is not a tag) until
1074
+ // the matching end tag. The per-tag balance walk must model this or it would
1075
+ // splice a `<!…>` inside `<style>`/`<script>` as a bogus comment — mangling
1076
+ // source these tags are meant to preserve verbatim (and diverging from the
1077
+ // flow/source branch, which parse5 handles correctly). `noscript` is omitted:
1078
+ // under fragment parsing (scripting disabled) parse5 parses its content as
1079
+ // normal markup, so scanning it is correct. Once `plaintext` opens it runs to
1080
+ // EOF and never closes.
1081
+ const RAW_TEXT_ELEMENTS = new Set([
1082
+ "script",
1083
+ "style",
1084
+ "textarea",
1085
+ "title",
1086
+ "xmp",
1087
+ "iframe",
1088
+ "noembed",
1089
+ "noframes",
1090
+ "plaintext",
1091
+ ]);
1092
+
1093
+ /**
1094
+ * True for an element a rendered page would not show: `hidden` attribute or a
1095
+ * hiding inline style. Works on both hast nodes and parseHtmlTag results.
1096
+ * @param {any} node
1097
+ * @returns {boolean}
1098
+ */
1099
+ export function isHiddenElement(node) {
1100
+ if (node.type !== "element") return false;
1101
+ const { properties = {} } = node;
1102
+ if (properties.hidden !== undefined && properties.hidden !== null)
1103
+ return true;
1104
+ // `aria-hidden="true"` is deliberately NOT treated as a hiding signal: it
1105
+ // removes the element only from the ACCESSIBILITY TREE, not the rendered
1106
+ // page — a sighted human viewing the page still sees it (it's routinely
1107
+ // used on decorative icons and icon-font glyphs, and on visible text
1108
+ // duplicated for screen-reader dedup). Splicing on it would delete content
1109
+ // a human plainly sees, which is real harm under the precision-over-recall
1110
+ // doctrine for this layer.
1111
+ if (properties.style && isHiddenStyle(properties.style)) return true;
1112
+ return false;
1113
+ }
1114
+
1115
+ /** @param {any} el */
1116
+ function hasDataSrc(el) {
1117
+ return (
1118
+ typeof el.properties?.src === "string" &&
1119
+ el.properties.src.startsWith("data:")
1120
+ );
1121
+ }
1122
+
1123
+ /**
1124
+ * @param {string} htmlValue
1125
+ * @returns {any}
1126
+ */
1127
+ function parseHtmlTag(htmlValue) {
1128
+ const tree = unified().use(rehypeParse, { fragment: true }).parse(htmlValue);
1129
+ /** @type {any} */
1130
+ let firstElement = null;
1131
+ visit(tree, "element", (node) => {
1132
+ firstElement = node;
1133
+ return EXIT;
1134
+ });
1135
+ return firstElement;
1136
+ }
1137
+
1138
+ // Returns null on a closing tag: `</x>` alone can never be the *start* of a
1139
+ // hidden element, so only opens drive the surrounding loop's removal mode.
1140
+ /**
1141
+ * @param {string} htmlValue
1142
+ * @returns {string | null}
1143
+ */
1144
+ export function isHiddenOpen(htmlValue) {
1145
+ if (htmlValue.startsWith("</")) return null;
1146
+ const el = parseHtmlTag(htmlValue);
1147
+ if (!el) return null;
1148
+ if (isHiddenElement(el)) return el.tagName;
1149
+ return null;
1150
+ }
1151
+
1152
+ // The lowercased name of an HTML closing tag (`</div>` -> "div"), or null when
1153
+ // the value isn't a well-formed closing tag. The charset spans HTML custom-
1154
+ // element and namespaced names (hyphens, dots, colons) so a close like
1155
+ // `</foo-bar>` balances its matching open instead of throwing on a null match;
1156
+ // callers treat null as "not the tag we're closing" and strip it as part of the
1157
+ // surrounding removal region.
1158
+ /**
1159
+ * @param {string} htmlValue
1160
+ * @returns {string | null}
1161
+ */
1162
+ export function closingTagName(htmlValue) {
1163
+ // The charset is a superset of CommonMark's closing-tag grammar, so remark
1164
+ // never emits a `</…>` html node this fails to match; the null guard below is
1165
+ // defense-in-depth against a future parser/grammar change (hence unreachable).
1166
+ const match = htmlValue.match(/^<\/(?<tagName>[a-zA-Z][a-zA-Z0-9:._-]*)\s*>/);
1167
+ /* c8 ignore next */
1168
+ if (!match?.groups) return null;
1169
+ return match.groups.tagName.toLowerCase();
1170
+ }
1171
+
1172
+ // ─── Layer 2: splice engine ──────────────────────────────────────────────────
1173
+
1174
+ export const COMMENT_PLACEHOLDER = "[HTML comment removed]";
1175
+ export const HIDDEN_PLACEHOLDER = "[hidden HTML removed]";
1176
+ // Shown when the remark/rehype parse itself fails (e.g. pathologically nested
1177
+ // markup overflows the recursive tree walk with a RangeError). The top-level
1178
+ // `sanitize`/`sanitizeText` contract is "never throws, `cleaned` is always a
1179
+ // string", and this module is the only seam those callers own — so the HTML
1180
+ // layer must fail CLOSED here: withhold the whole unparseable input behind one
1181
+ // placeholder rather than let the exception escape and suppress all tool
1182
+ // output. Withholding (not passing through) is the safe choice — content we
1183
+ // could not inspect for hidden payloads is treated as if it were hidden.
1184
+ export const UNPARSEABLE_PLACEHOLDER = "[HTML unparseable — withheld]";
1185
+
1186
+ /**
1187
+ * Replace each range of `text` with its kind's placeholder, preserving every
1188
+ * byte outside the ranges verbatim. Overlapping/nested ranges are merged
1189
+ * (defense-in-depth — the scanners emit disjoint ranges).
1190
+ * @param {string} text
1191
+ * @param {Array<{start: number, end: number, kind: "comment" | "hidden"}>} ranges
1192
+ * @returns {string}
1193
+ */
1194
+ export function spliceRanges(text, ranges) {
1195
+ const sorted = [...ranges].sort(
1196
+ (left, right) => left.start - right.start || left.end - right.end,
1197
+ );
1198
+ /** @type {typeof ranges} */
1199
+ const merged = [];
1200
+ for (const range of sorted) {
1201
+ const last = merged[merged.length - 1];
1202
+ if (last && range.start < last.end) {
1203
+ if (range.end > last.end) last.end = range.end;
1204
+ // A hidden range absorbed into a comment range (the comment sorts first
1205
+ // on a tie) must keep the hidden label — hidden content placeholdered as
1206
+ // "[HTML comment removed]" would understate what was stripped. Hidden
1207
+ // dominates: if either side is hidden, the union is hidden.
1208
+ if (range.kind === "hidden") last.kind = "hidden";
1209
+ } else {
1210
+ merged.push({ ...range });
1211
+ }
1212
+ }
1213
+ let out = "";
1214
+ let cursor = 0;
1215
+ for (const range of merged) {
1216
+ out +=
1217
+ text.slice(cursor, range.start) +
1218
+ (range.kind === "comment" ? COMMENT_PLACEHOLDER : HIDDEN_PLACEHOLDER);
1219
+ cursor = range.end;
1220
+ }
1221
+ return out + text.slice(cursor);
1222
+ }
1223
+
1224
+ /** @returns {{ tags: Record<string, number>, dataSrc: number }} */
1225
+ function newWarned() {
1226
+ return { tags: {}, dataSrc: 0 };
1227
+ }
1228
+
1229
+ /**
1230
+ * @param {ReturnType<typeof newWarned>} warned
1231
+ * @param {string} tagName
1232
+ */
1233
+ function countTag(warned, tagName) {
1234
+ warned.tags[tagName] = (warned.tags[tagName] || 0) + 1;
1235
+ }
1236
+
1237
+ /**
1238
+ * @param {ReturnType<typeof newWarned>} into
1239
+ * @param {ReturnType<typeof newWarned>} from
1240
+ */
1241
+ function mergeWarned(into, from) {
1242
+ for (const [tag, count] of Object.entries(from.tags))
1243
+ into.tags[tag] = (into.tags[tag] || 0) + count;
1244
+ into.dataSrc += from.dataSrc;
1245
+ }
1246
+
1247
+ /** @param {ReturnType<typeof newWarned>} warned */
1248
+ function hasWarned(warned) {
1249
+ return warned.dataSrc > 0 || Object.keys(warned.tags).length > 0;
1250
+ }
1251
+
1252
+ /**
1253
+ * Scan raw HTML for hidden content to strip and preserved tags to report.
1254
+ * Returned ranges are offsets into `html`; comments and hidden elements span
1255
+ * the whole element including its content (rehype positions cover open tag
1256
+ * through matching close, and parse5 extends an unclosed element to the end
1257
+ * of the fragment — fail-closed for truncated markup).
1258
+ * @param {string} html
1259
+ * @returns {{ ranges: Array<{start: number, end: number, kind: "comment" | "hidden"}>, warned: ReturnType<typeof newWarned> }}
1260
+ */
1261
+ export function scanHtmlFragment(html) {
1262
+ const tree = unified().use(rehypeParse, { fragment: true }).parse(html);
1263
+ /** @type {Array<{start: number, end: number, kind: "comment" | "hidden"}>} */
1264
+ const ranges = [];
1265
+ const warned = newWarned();
1266
+ // @ts-ignore -- visit callback returns EXIT/SKIP only on matches; implicit undefined return is intentional
1267
+ // eslint-disable-next-line consistent-return
1268
+ visit(tree, (/** @type {any} */ node) => {
1269
+ const isComment = node.type === "comment";
1270
+ if (isComment || isHiddenElement(node)) {
1271
+ /* c8 ignore start -- parse5 omits positions only on recovery-synthesized
1272
+ elements (tbody and friends), which carry no attributes and so can
1273
+ never be hidden; fail closed on the whole fragment if that assumption
1274
+ ever breaks. */
1275
+ if (!node.position) {
1276
+ ranges.length = 0;
1277
+ ranges.push({ start: 0, end: html.length, kind: "hidden" });
1278
+ return EXIT;
1279
+ }
1280
+ /* c8 ignore stop */
1281
+ ranges.push({
1282
+ start: node.position.start.offset,
1283
+ end: node.position.end.offset,
1284
+ kind: isComment ? "comment" : "hidden",
1285
+ });
1286
+ return SKIP; // children are inside the spliced range
1287
+ }
1288
+ if (node.type !== "element") return; // eslint-disable-line consistent-return -- unist visit: undefined return means "continue", same as falling off the end
1289
+ if (REPORTED_TAGS.has(node.tagName)) countTag(warned, node.tagName);
1290
+ if (hasDataSrc(node)) warned.dataSrc += 1;
1291
+ });
1292
+ return { ranges, warned };
1293
+ }
1294
+
1295
+ const mdParser = unified().use(remarkParse).use(remarkGfm);
1296
+
1297
+ // A markup-declaration-open (`<!`) or processing-instruction-ish (`<?`) start.
1298
+ // Inside an inline html node these begin a *bogus comment* unless they open a
1299
+ // proper `<!--…-->` comment (handled on the fast path) — `<!bogus>`, `<?php?>`,
1300
+ // `<![CDATA[…]]>` all tokenize to comments the HTML-source branch already
1301
+ // strips. The prose branch matched only literal `<!--`, so the bogus forms
1302
+ // leaked through; this finds the candidates to validate.
1303
+ const BOGUS_COMMENT_OPEN_RE = /<[!?]/g;
1304
+
1305
+ // Raw source ending in UNTERMINATED markup — a `<` that opens a construct with
1306
+ // no closing `>` yet: an open tag (`<span`), an end tag (`</A`), or a bogus
1307
+ // comment / declaration (`<!`, `<?`). Per the HTML tokenizer such a construct
1308
+ // keeps consuming the input stream until the next `>`, so it absorbs the
1309
+ // following inline-html node (an open tag swallows it as bogus attributes; a
1310
+ // bogus end tag / `<!…` opens a bogus comment). parse5 (the flow/source branch,
1311
+ // via rehype) models this; the per-tag balance walk below does not, so without
1312
+ // this a fragment parses differently as a flow block than as a paragraph —
1313
+ // breaking idempotency once a first pass demotes a block to phrasing (see
1314
+ // html-property "second pass changes nothing"). An open/end tag requires a
1315
+ // name letter after the `<`/`</`, so literal prose like `a < b` or an `i <3 u`
1316
+ // emoticon is not mistaken for markup.
1317
+ const UNTERMINATED_MARKUP_TAIL_RE = /<(?:[!?]|\/?[a-zA-Z])[^>]*$/;
1318
+
1319
+ /**
1320
+ * Fold a raw source slice into the "inside an unterminated tag" state. A `>`
1321
+ * closes any open construct (so only the tail after the last `>` can leave one
1322
+ * open); with no `>` an already-open construct stays open. Operating on the
1323
+ * RAW source — not mdast node values — means markdown constructs that restructure
1324
+ * the character stream (code spans, emphasis, escapes) are seen exactly as
1325
+ * parse5 sees them, since only the literal `<`/`>` bytes matter.
1326
+ * @param {boolean} absorbing
1327
+ * @param {string} raw
1328
+ * @returns {boolean}
1329
+ */
1330
+ function foldAbsorb(absorbing, raw) {
1331
+ if (raw.includes(">")) return UNTERMINATED_MARKUP_TAIL_RE.test(raw);
1332
+ return absorbing || UNTERMINATED_MARKUP_TAIL_RE.test(raw);
1333
+ }
1334
+
1335
+ /**
1336
+ * Map of comment start-offset -> end-offset (exclusive) for EVERY comment the
1337
+ * HTML tokenizer finds in `value`, from a SINGLE rehype parse. Validated against
1338
+ * the real tokenizer (parse5) rather than a hand-rolled bogus-comment state
1339
+ * machine, so a bogus comment (`<!bogus>`, `<?php?>`, `<![CDATA[…]]>`) is spliced
1340
+ * to exactly the span a browser hides and a `<Foo>` element, a `<!doctype>`, or
1341
+ * visible prose never is. Replaces a per-candidate parse: the whole value is
1342
+ * tokenized once and every span read from that tree.
1343
+ * @param {string} value
1344
+ * @returns {Map<number, number>}
1345
+ */
1346
+ function commentSpans(value) {
1347
+ const tree = unified().use(rehypeParse, { fragment: true }).parse(value);
1348
+ /** @type {Map<number, number>} */
1349
+ const spans = new Map();
1350
+ visit(tree, "comment", (/** @type {any} */ node) => {
1351
+ if (node.position)
1352
+ spans.set(node.position.start.offset, node.position.end.offset);
1353
+ });
1354
+ return spans;
1355
+ }
1356
+
1357
+ /**
1358
+ * Append comment ranges found in `value` to `ranges`.
1359
+ *
1360
+ * Proper `<!--…-->` comments are located with linear indexOf scanning (a lazy
1361
+ * `<!--[\s\S]*?-->` regex backtracks polynomially on crafted input); the close
1362
+ * search starts 2 chars in so spec-abrupt closes (`<!-->`, `<!--->`) terminate
1363
+ * their own comment. Other `<!`/`<?` starts are bogus comments, spliced to the
1364
+ * exact span the HTML tokenizer assigns them so the prose branch reaches parity
1365
+ * with the HTML-source branch (which strips them via parse5).
1366
+ * @param {string} value
1367
+ * @param {number} base absolute offset of the start of `value`
1368
+ * @param {number} nodeEnd absolute offset of the end of the containing node
1369
+ * @param {Array<{start: number, end: number, kind: "comment" | "hidden"}>} ranges
1370
+ */
1371
+ function collectCommentRanges(value, base, nodeEnd, ranges) {
1372
+ BOGUS_COMMENT_OPEN_RE.lastIndex = 0;
1373
+ // Tokenized bogus-comment spans, parsed once on first need (many values carry
1374
+ // a proper `<!--` handled below without ever touching the tree).
1375
+ /** @type {Map<number, number> | null} */
1376
+ let spans = null;
1377
+ for (let match; (match = BOGUS_COMMENT_OPEN_RE.exec(value));) {
1378
+ const open = match.index;
1379
+ if (value.startsWith("<!--", open)) {
1380
+ const close = value.indexOf("-->", open + 2);
1381
+ /* c8 ignore start -- micromark only tokenizes inline comments WITH a
1382
+ terminator (an unterminated `<!--` in phrasing context stays literal
1383
+ text, visible to a human reader), so this is fail-closed
1384
+ defense-in-depth against a future tokenizer change. Unterminated
1385
+ comments in flow blocks are covered — parse5 handles them in
1386
+ scanHtmlFragment. */
1387
+ if (close === -1) {
1388
+ ranges.push({ start: base + open, end: nodeEnd, kind: "comment" });
1389
+ break;
1390
+ }
1391
+ /* c8 ignore stop */
1392
+ ranges.push({
1393
+ start: base + open,
1394
+ end: base + close + 3,
1395
+ kind: "comment",
1396
+ });
1397
+ BOGUS_COMMENT_OPEN_RE.lastIndex = close + 3;
1398
+ continue;
1399
+ }
1400
+ if (!spans) spans = commentSpans(value);
1401
+ const end = spans.get(open);
1402
+ // Not a comment (a `<Foo>` element, a `<!doctype>`, visible prose): leave
1403
+ // it untouched and resume scanning just past this `<`.
1404
+ if (end === undefined) continue;
1405
+ ranges.push({ start: base + open, end: base + end, kind: "comment" });
1406
+ BOGUS_COMMENT_OPEN_RE.lastIndex = end;
1407
+ }
1408
+ }
1409
+
1410
+ /**
1411
+ * Update hidden-region state for one html node while inside a tracked region.
1412
+ *
1413
+ * Mutates `state` in place. A closing tag for the tracked element decrements
1414
+ * depth; reaching zero closes the range. A nested open of the same tag
1415
+ * increments depth. Any other close is swallowed inside the region.
1416
+ * @param {{ tag: string | null, depth: number, regionStart: number }} state
1417
+ * @param {string} value
1418
+ * @param {number} nodeEnd absolute end offset of this node
1419
+ * @param {Array<{start: number, end: number, kind: "comment" | "hidden"}>} ranges
1420
+ */
1421
+ function updateHiddenState(state, value, nodeEnd, ranges) {
1422
+ if (value.startsWith("</")) {
1423
+ if (closingTagName(value) !== state.tag) return;
1424
+ state.depth--;
1425
+ if (state.depth === 0) {
1426
+ ranges.push({ start: state.regionStart, end: nodeEnd, kind: "hidden" });
1427
+ state.tag = null;
1428
+ }
1429
+ return;
1430
+ }
1431
+ const el = parseHtmlTag(value);
1432
+ if (el && el.tagName === state.tag) state.depth++;
1433
+ }
1434
+
1435
+ // The block-level phrasing containers whose inline html the balance walk owns.
1436
+ // (Nested phrasing — emphasis, links — is reached by recursing from these; html
1437
+ // directly under a flow parent like listItem/blockquote is owned by the flow
1438
+ // branch instead.)
1439
+ const PHRASING_ROOTS = new Set(["paragraph", "heading", "tableCell"]);
1440
+
1441
+ /**
1442
+ * Yield the `html` leaf nodes of a phrasing subtree in document order,
1443
+ * descending through nested inline containers (emphasis, links, …) but NOT into
1444
+ * flow parents (their html belongs to the flow/source branch).
1445
+ * @param {any} node
1446
+ * @returns {Generator<any>}
1447
+ */
1448
+ function* inlineHtmlLeaves(node) {
1449
+ for (const child of node.children) {
1450
+ if (child.type === "html") yield child;
1451
+ else if (
1452
+ Array.isArray(child.children) &&
1453
+ !FLOW_HTML_PARENTS.has(child.type)
1454
+ )
1455
+ yield* inlineHtmlLeaves(child);
1456
+ }
1457
+ }
1458
+
1459
+ /** @param {any} node @returns {boolean} */
1460
+ function hasHtmlLeaf(node) {
1461
+ for (const _ of inlineHtmlLeaves(node)) return true;
1462
+ return false;
1463
+ }
1464
+
1465
+ /**
1466
+ * Balance-walk a markdown phrasing root's html leaves in document order: a
1467
+ * hidden open tag starts a removal region that runs to its matching close (or
1468
+ * the container's end when unbalanced — fail-closed), comments become
1469
+ * single-node ranges, and preserved tags are counted. Inline html is tokenized
1470
+ * per TAG (an element's content sits in sibling text nodes), which is why this
1471
+ * walk exists instead of handing the value to rehype.
1472
+ *
1473
+ * The absorb state is folded from the RAW source between html nodes (not from
1474
+ * mdast node values), so markdown constructs that reshuffle the character
1475
+ * stream — code spans, emphasis, escapes — are seen exactly as parse5 sees
1476
+ * them. The root is walked in full document order (descending through nested
1477
+ * emphasis/links) so an unterminated tag in one node absorbs markup in a
1478
+ * sibling/nested node the way it does in the flat token stream.
1479
+ * @param {any} node
1480
+ * @param {string} text the full document source, for raw-slice absorb folding
1481
+ * @param {Array<{start: number, end: number, kind: "comment" | "hidden"}>} ranges
1482
+ * @param {ReturnType<typeof newWarned>} warned
1483
+ */
1484
+ function scanInlineChildren(node, text, ranges, warned) {
1485
+ const state =
1486
+ /** @type {{ tag: string | null, depth: number, regionStart: number }} */ ({
1487
+ tag: null,
1488
+ depth: 0,
1489
+ regionStart: 0,
1490
+ });
1491
+ // "Inside an unterminated tag / bogus comment" — parse5 absorbs following
1492
+ // markup into it until the next `>`.
1493
+ let absorbing = false;
1494
+ // Non-null while inside a raw-text element (its lowercased tag name); content
1495
+ // is opaque until the matching end tag.
1496
+ let rawText = /** @type {string | null} */ (null);
1497
+ // End offset of the last html node processed; the raw slice from here to the
1498
+ // next html node is what parse5 tokenizes between them.
1499
+ let prevEnd = node.position.start.offset;
1500
+ for (const child of inlineHtmlLeaves(node)) {
1501
+ const value = child.value;
1502
+ const base = child.position.start.offset;
1503
+ const end = child.position.end.offset;
1504
+ // Fold the inter-node source (markdown text, code spans, emphasis markers)
1505
+ // into the absorb state before deciding what to do with this html node.
1506
+ absorbing = foldAbsorb(absorbing, text.slice(prevEnd, base));
1507
+
1508
+ if (rawText) {
1509
+ // Raw-text content is opaque; only the matching end tag ends the region.
1510
+ if (new RegExp(`</${rawText}(?![a-z0-9-])`, "i").test(value))
1511
+ rawText = null;
1512
+ } else if (state.depth > 0) {
1513
+ updateHiddenState(state, value, end, ranges);
1514
+ } else if (!absorbing) {
1515
+ // Not absorbed into a preceding unterminated tag — scan normally.
1516
+ // Comments can share an inline html node with neighboring constructs
1517
+ // (e.g. in a list item, `<!-- c -->!` is ONE node), so comment spans are
1518
+ // located within the value and spliced individually rather than assuming
1519
+ // the node IS the comment.
1520
+ collectCommentRanges(value, base, end, ranges);
1521
+ const tagName = isHiddenOpen(value);
1522
+ if (tagName) {
1523
+ // A void element never emits a matching close, so a balance region
1524
+ // would extend to the container end and splice out following visible
1525
+ // text. Emit a single-node range instead (the source branch does too).
1526
+ if (VOID_ELEMENTS.has(tagName))
1527
+ ranges.push({ start: base, end, kind: "hidden" });
1528
+ else {
1529
+ state.tag = tagName;
1530
+ state.depth = 1;
1531
+ state.regionStart = base;
1532
+ }
1533
+ } else if (!value.startsWith("</")) {
1534
+ const el = parseHtmlTag(value);
1535
+ if (el) {
1536
+ // A raw-text open tag starts an opaque region (a self-closing `/>`
1537
+ // does not apply to these in HTML — they always open).
1538
+ if (RAW_TEXT_ELEMENTS.has(el.tagName)) rawText = el.tagName;
1539
+ if (REPORTED_TAGS.has(el.tagName)) countTag(warned, el.tagName);
1540
+ if (hasDataSrc(el)) warned.dataSrc += 1;
1541
+ }
1542
+ }
1543
+ }
1544
+ // else: absorbed into a preceding unterminated tag — parse5 treats it as
1545
+ // tag soup, not a comment/element, so leave it untouched (fail open).
1546
+
1547
+ absorbing = foldAbsorb(absorbing, value);
1548
+ prevEnd = end;
1549
+ }
1550
+ if (state.depth > 0) {
1551
+ ranges.push({
1552
+ start: state.regionStart,
1553
+ end: node.position.end.offset,
1554
+ kind: "hidden",
1555
+ });
1556
+ }
1557
+ }
1558
+
1559
+ // Containers whose direct html children are flow BLOCKS (complete markup —
1560
+ // tags and content in one node value), as opposed to the phrasing containers
1561
+ // (paragraph, heading, tableCell, emphasis, …) whose html children are
1562
+ // per-tag fragments needing the balance walk.
1563
+ const FLOW_HTML_PARENTS = new Set([
1564
+ "root",
1565
+ "blockquote",
1566
+ "listItem",
1567
+ "footnoteDefinition",
1568
+ ]);
1569
+
1570
+ /**
1571
+ * @param {string} text
1572
+ * @returns {{ ranges: Array<{start: number, end: number, kind: "comment" | "hidden"}>, warned: ReturnType<typeof newWarned> }}
1573
+ */
1574
+ function scanMarkdown(text) {
1575
+ const tree = mdParser.parse(text);
1576
+ /** @type {Array<{start: number, end: number, kind: "comment" | "hidden"}>} */
1577
+ const ranges = [];
1578
+ const warned = newWarned();
1579
+
1580
+ // Flow html blocks carry complete markup, so rehype locates comments/hidden
1581
+ // elements precisely within them; block-local offsets are shifted to
1582
+ // document coordinates.
1583
+ visit(tree, "html", (/** @type {any} */ node, _index, parent) => {
1584
+ if (!FLOW_HTML_PARENTS.has(parent?.type)) return;
1585
+ const base = node.position.start.offset;
1586
+ const sub = scanHtmlFragment(text.slice(base, node.position.end.offset));
1587
+ for (const range of sub.ranges) {
1588
+ ranges.push({
1589
+ start: base + range.start,
1590
+ end: base + range.end,
1591
+ kind: range.kind,
1592
+ });
1593
+ }
1594
+ mergeWarned(warned, sub.warned);
1595
+ });
1596
+
1597
+ // Every phrasing ROOT that holds inline html (paragraph, heading, tableCell,
1598
+ // …) gets the balance walk — not just paragraphs, so a hidden span inside a
1599
+ // heading cannot slip through. Nested inline containers (emphasis, links, …)
1600
+ // are walked as part of their root in document order, so the walk is skipped
1601
+ // for them here to avoid double-scanning and to keep the absorb state flowing
1602
+ // across those boundaries.
1603
+ visit(tree, (/** @type {any} */ node) => {
1604
+ if (!PHRASING_ROOTS.has(node.type)) return;
1605
+ if (!hasHtmlLeaf(node)) return;
1606
+ scanInlineChildren(node, text, ranges, warned);
1607
+ });
1608
+
1609
+ return { ranges, warned };
1610
+ }
1611
+
1612
+ // 30%-of-lines heuristic: HTML *source* gets scanned as one rehype fragment;
1613
+ // inline tags scattered in prose go through the markdown branch instead.
1614
+ /**
1615
+ * @param {string} text
1616
+ * @returns {boolean}
1617
+ */
1618
+ export function looksLikeHtmlSource(text) {
1619
+ const lines = text.split("\n");
1620
+ if (lines.length < 5) return false;
1621
+ let htmlLines = 0;
1622
+ for (const line of lines) {
1623
+ if (/<\/?[a-zA-Z][^<>]*>/.test(line)) htmlLines++;
1624
+ }
1625
+ return htmlLines / lines.length > 0.3;
1626
+ }
1627
+
1628
+ /**
1629
+ * Layer 2 over web-ingress text: splice out HTML comments and hidden elements
1630
+ * (placeholders mark the cuts; all other bytes are preserved verbatim) and
1631
+ * count preserved scripting/resource tags for the caller's warning. Returns
1632
+ * null when there is nothing to strip and nothing to report.
1633
+ * @param {string} text
1634
+ * @returns {{ text: string, removed: { comments: number, hidden: number }, warned: { tags: Record<string, number>, dataSrc: number } } | null}
1635
+ */
1636
+ export function sanitizeHtml(text) {
1637
+ if (!HTML_TAG_PRESENT.test(text)) return null;
1638
+ /** @type {{ ranges: Array<{start: number, end: number, kind: "comment" | "hidden"}>, warned: ReturnType<typeof newWarned> }} */
1639
+ let scan;
1640
+ try {
1641
+ scan = looksLikeHtmlSource(text)
1642
+ ? scanHtmlFragment(text)
1643
+ : scanMarkdown(text);
1644
+ } catch {
1645
+ // The parse/visit blew up (stack overflow on pathological nesting, or any
1646
+ // other parser error). Fail CLOSED at this boundary so `sanitize`/
1647
+ // `sanitizeText` keep their never-throw contract: withhold the whole input
1648
+ // behind a placeholder and report it as hidden content removed.
1649
+ return {
1650
+ text: UNPARSEABLE_PLACEHOLDER,
1651
+ removed: { comments: 0, hidden: 1 },
1652
+ warned: newWarned(),
1653
+ };
1654
+ }
1655
+ const { ranges, warned } = scan;
1656
+ if (ranges.length === 0 && !hasWarned(warned)) return null;
1657
+ const removed = { comments: 0, hidden: 0 };
1658
+ for (const range of ranges)
1659
+ removed[range.kind === "comment" ? "comments" : "hidden"]++;
1660
+ return {
1661
+ text: ranges.length > 0 ? spliceRanges(text, ranges) : text,
1662
+ removed,
1663
+ warned,
1664
+ };
1665
+ }
1666
+
1667
+ // ─── Layer 3: markdown/URL exfiltration detection ────────────────────────────
1668
+
1669
+ // Template-injection indicators, applied to the whole URL so they fire even
1670
+ // when it is too malformed for `new URL()` to parse (e.g. a non-ASCII host).
1671
+ // These are name-independent shapes — server-/client-side template syntax that
1672
+ // only appears in a URL when something is interpolating untrusted data — so
1673
+ // they carry signal on their own and need no value-shape gate.
1674
+ //
1675
+ // Keyword-PARAM detection (`?token=…`, `…#secret=…`) was REMOVED from this list
1676
+ // (finding #20): firing on the parameter NAME alone flagged every `?session=ok`
1677
+ // / `?key=pk_public_mapkey` / `?d=3`, drowning the real signal. A keyword
1678
+ // param is now flagged only when its VALUE is payload-shaped, via the
1679
+ // value-gated raw scan below (rawUrlKeywordExfil) which reuses the same
1680
+ // blob/credential shape test as the post-parse param walk — see
1681
+ // paramExfilReason. The raw scan keeps the pre-parse / fragment coverage the
1682
+ // old name arm had (an unparseable host means `new URL()` throws and the
1683
+ // post-parse walk never runs).
1684
+ const EXFIL_INDICATORS = [/\$\{[^{}]+\}/, /\{\{[^{}]+\}\}/];
1685
+
1686
+ // Parameter NAMES whose presence used to flag on sight; now they only gate
1687
+ // WHICH raw params the value-shape test is applied to before the URL is parsed.
1688
+ // Kept narrow (the historically over-eager set) so the raw pre-parse pass stays
1689
+ // cheap; any non-keyword param is still value-gated post-parse by the walk.
1690
+ const KEYWORD_PARAM_NAME_RE =
1691
+ /^(?:data|d|payload|exfil|leak|steal|secret|token|key|env|password|pwd|cookie|session|auth)$/i;
1692
+
1693
+ const LONG_QUERY_THRESHOLD = 200;
1694
+
1695
+ // A `data:` URI carries its payload inline instead of pointing at a host, so
1696
+ // the query/credential/fragment checks below never fire on it. Active-content
1697
+ // types (HTML, SVG, JS) are a script-injection vector; an oversized blob of any
1698
+ // type is an inline exfil/injection payload. A small inline image (icon) is
1699
+ // left alone so the common case isn't drowned in noise.
1700
+ const DATA_URI_ACTIVE_RE =
1701
+ /^\s*data:(?:text\/html|image\/svg\+xml|application\/(?:javascript|ecmascript|xhtml\+xml))[;,]/i;
1702
+ export const DATA_URI_LENGTH_THRESHOLD = 4096;
1703
+
1704
+ // javascript:/vbscript: URIs execute on navigation/load, never a legitimate
1705
+ // link target in fetched content — flagged regardless of payload.
1706
+ const SCRIPT_URI_RE = /^\s*(?:javascript|vbscript):/i;
1707
+
1708
+ const RELATIVE_URL_BASE = "http://relative.invalid";
1709
+
1710
+ // Parameter NAMES that legitimately carry a LONG opaque (base64/hex) value, so
1711
+ // a blob in one of them is NOT exfil: CDN request-signing (AWS SigV4 /
1712
+ // CloudFront `X-Amz-*`/`Signature`/`Policy`/`Key-Pair-Id`, GCS `X-Goog-*`,
1713
+ // Azure SAS `sv/sr/sig/se/sp/st/spr/skoid/sktid`), pagination cursors /
1714
+ // continuation tokens, and the long analytics click-IDs. Matched
1715
+ // case-insensitively against the exact (lowercased) parameter name. Scope is
1716
+ // deliberately limited to names whose benign value is genuinely a long token —
1717
+ // generic short params (`page`, `limit`, `v`, `t`, `cb`, …) are NOT listed,
1718
+ // since their values never reach the blob threshold anyway and listing them
1719
+ // would only widen the rename-dodge surface. A blob or credential-shaped value
1720
+ // in any OTHER parameter still fires — this allowlist trades a narrow dodge
1721
+ // (`?sig=<stolen>`) for not drowning the model in false positives on ordinary
1722
+ // fetched pages.
1723
+ const BENIGN_BLOB_PARAM_RE =
1724
+ /^(?:x-(?:amz|goog|ms|oss|obs)-[a-z0-9-]+|amz-[a-z0-9-]+|utm_[a-z]+|sig|signature|hmac|policy|credential|expires|key-pair-id|se|sp|sr|sv|st|spr|si|skoid|sktid|cursor|after|before|continuation|continuationtoken|continuation_token|pagetoken|page_token|nexttoken|next_token|gclid|fbclid|dclid|msclkid|gbraid|wbraid|_ga|_gl|mc_eid|mc_cid)$/i;
1725
+
1726
+ // matchesSecretHint is a deliberately broad PRE-gate whose bare-keyword arms
1727
+ // (`token`, `secret`, `authorization`, …) also match ordinary hyphen/word
1728
+ // delimited prose, and with no secret-redaction engine to refine the verdict
1729
+ // here a weak digit proxy isn't enough: `login-authenticate-2024` and
1730
+ // `the-secret-recipe-2024` clear "has a digit." A leaked credential is an
1731
+ // OPAQUE, separator-free token, so the value must additionally contain a
1732
+ // contiguous 20+ char `[A-Za-z0-9_]` run (no hyphen/space — that's what splits
1733
+ // the prose runs below the bar) AND a digit before it counts as one.
1734
+ const OPAQUE_TOKEN_RE = /[A-Za-z0-9_]{20,}/g;
1735
+ const VALUE_HAS_DIGIT_RE = /\d/;
1736
+
1737
+ // A value that is ENTIRELY a long base64 (40+ chars, optional `=` padding) or
1738
+ // hex (32+ chars) run. Anchored to the whole value (operating on the RAW,
1739
+ // un-decoded query so a `+` in base64 is not turned into a space), so a benign
1740
+ // short value with an incidental hex word never trips it. Both arms are linear.
1741
+ const BLOB_VALUE_B64_RE = /^[A-Za-z0-9+/]{40,}={0,2}$/;
1742
+ const BLOB_VALUE_HEX_RE = /^[A-Fa-f0-9]{32,}$/;
1743
+
1744
+ // RFC 4648 §5 url-safe base64 substitutes `-`/`_` for `+`/`/`, so a payload
1745
+ // encoded url-safe escapes the `[A-Za-z0-9+/]` arms above. Adding `-`/`_` to the
1746
+ // charset would re-admit a long hyphenated word-slug (`the-secret-history-of-…`)
1747
+ // as a "blob", so this arm distinguishes the two by CHARACTER MIX rather than a
1748
+ // contiguous run: bulk-encoded bytes drawn from base64url's 64-symbol alphabet
1749
+ // almost always carry BOTH an uppercase letter and a digit, whereas a human slug
1750
+ // is lowercase dictionary words joined by separators and shows neither. The
1751
+ // earlier contiguous-40-run gate was fragile — ordinary base64url scatters a
1752
+ // `-`/`_` roughly every ~30 chars, breaking any 40-char run, so a real beacon
1753
+ // (`?d=<200-char base64url of cookies>`) routinely dodged it. The mix test keeps
1754
+ // the slug benign (no uppercase) while catching the scattered-separator blob the
1755
+ // run gate missed. Anchored to the whole value for the same RAW-query reason.
1756
+ const BLOB_VALUE_B64URL_RE = /^[A-Za-z0-9_-]{40,}={0,2}$/;
1757
+ const B64URL_MIXED_RE = /(?=.*[A-Z])(?=.*[0-9])/;
1758
+
1759
+ // A path segment whose whole value is a base64/hex run longer than any standard
1760
+ // content hash (SHA-512 hex is 128, base64 88; SHA-256 hex 64) is bulk encoded
1761
+ // data — a beacon URL that smuggles its payload in the path to dodge the query
1762
+ // walk — rather than an asset fingerprint. The threshold sits just above the
1763
+ // SHA-512-hex ceiling so every real fingerprint clears it while a ~150-char
1764
+ // base64 of stolen cookies does not. Hyphens/underscores are excluded from the
1765
+ // standard arm so a long word-slug (`the-secret-history-of-…`) is not mistaken
1766
+ // for a payload; the url-safe arm re-admits `-`/`_` but, like the query arm
1767
+ // above, gates on a contiguous 40+ alphanumeric run to keep the slug benign.
1768
+ const PATH_BLOB_RE = /^(?:[A-Za-z0-9+/]+={0,2}|[A-Fa-f0-9]+)$/;
1769
+ const PATH_BLOB_MIN_LEN = 128;
1770
+
1771
+ /**
1772
+ * True for an entirely-url-safe-base64 value (≥40 chars) whose character mix —
1773
+ * at least one uppercase letter AND one digit — marks it as bulk-encoded bytes
1774
+ * rather than a lowercase hyphenated word-slug. Shared by the query and path
1775
+ * blob detectors. Precision-first: a value missing either class is treated as a
1776
+ * benign slug and passes (a false negative, per the detection-layer doctrine).
1777
+ * @param {string} value
1778
+ * @returns {boolean}
1779
+ */
1780
+ function isBase64UrlBlob(value) {
1781
+ return BLOB_VALUE_B64URL_RE.test(value) && B64URL_MIXED_RE.test(value);
1782
+ }
1783
+
1784
+ /** @param {string} value @returns {boolean} */
1785
+ function isBlobValue(value) {
1786
+ return (
1787
+ BLOB_VALUE_B64_RE.test(value) ||
1788
+ BLOB_VALUE_HEX_RE.test(value) ||
1789
+ isBase64UrlBlob(value)
1790
+ );
1791
+ }
1792
+
1793
+ /**
1794
+ * True when the percent-DECODED form of `value` is blob-shaped, even though
1795
+ * the raw value isn't (e.g. `A%41A%41…` decodes to a run of `A`s). This is a
1796
+ * REPORT-ONLY check — `paramExfilReason` never rewrites the URL, it only
1797
+ * names a reason for the caller's warning — so the false-positive cost of
1798
+ * decoding is much lower than it would be in the splicing layer. Applied IN
1799
+ * ADDITION to the raw-value test (never instead of it): the raw scan stays
1800
+ * the primary signal since `URLSearchParams`-style decoding elsewhere in this
1801
+ * file is deliberately avoided (it mangles `+` in base64). A malformed
1802
+ * percent-sequence throws in `decodeURIComponent`; that failure is not a
1803
+ * blob shape either way, so it fails open (skip the decoded check).
1804
+ * @param {string} value
1805
+ * @returns {boolean}
1806
+ */
1807
+ function decodedBlobMatch(value) {
1808
+ let decoded;
1809
+ try {
1810
+ decoded = decodeURIComponent(value);
1811
+ } catch {
1812
+ return false;
1813
+ }
1814
+ return isBlobValue(decoded);
1815
+ }
1816
+
1817
+ /**
1818
+ * RAW (un-decoded) `name=value` pairs of a query/fragment string, split on `&`
1819
+ * and `;`. URLSearchParams is avoided on purpose: it percent-/`+`-decodes
1820
+ * values, turning a `+`-bearing base64 blob into a space-broken string that the
1821
+ * anchored blob regexes would miss.
1822
+ * @param {string} qs
1823
+ * @returns {Array<[string, string]>}
1824
+ */
1825
+ function rawParams(qs) {
1826
+ /** @type {Array<[string, string]>} */
1827
+ const pairs = [];
1828
+ for (const pair of qs.split(/[&;]/)) {
1829
+ if (!pair) continue;
1830
+ const eq = pair.indexOf("=");
1831
+ const name = eq === -1 ? pair : pair.slice(0, eq);
1832
+ const value = eq === -1 ? "" : pair.slice(eq + 1);
1833
+ pairs.push([name.toLowerCase(), value]);
1834
+ }
1835
+ return pairs;
1836
+ }
1837
+
1838
+ /**
1839
+ * Exfil reason for one URL parameter, or null. A credential-shaped value in any
1840
+ * non-allowlisted parameter (reusing the secret-shape gate), or a long
1841
+ * base64/hex blob in one. Allowlisted signing/pagination/analytics parameters
1842
+ * are skipped entirely (see BENIGN_BLOB_PARAM_RE).
1843
+ * @param {string} name lowercased parameter name
1844
+ * @param {string} value RAW (un-decoded) value
1845
+ * @returns {string | null}
1846
+ */
1847
+ function paramExfilReason(name, value) {
1848
+ if (BENIGN_BLOB_PARAM_RE.test(name)) return null;
1849
+ // A leaked credential is an OPAQUE, separator-free token. Gate the
1850
+ // secret-shape/digit test on the CONTIGUOUS opaque run(s) of the value, not on
1851
+ // the whole prose value: a benign path-like value (`?redirect=/authorization-
1852
+ // service/…abcdefghij1234567890`) otherwise matches "authorization" in one
1853
+ // place and a 20-char run in another and false-fires. Requiring both on the
1854
+ // SAME run keeps `ghp_…`-style contiguous tokens firing while dropping prose.
1855
+ const opaqueRuns = value.match(OPAQUE_TOKEN_RE);
1856
+ if (
1857
+ opaqueRuns?.some(
1858
+ (run) => VALUE_HAS_DIGIT_RE.test(run) && matchesSecretHint(run),
1859
+ )
1860
+ )
1861
+ return "credential-shaped token in URL parameter";
1862
+ if (isBlobValue(value) || decodedBlobMatch(value))
1863
+ return "suspicious query parameter";
1864
+ return null;
1865
+ }
1866
+
1867
+ /**
1868
+ * Pre-parse, value-GATED keyword-parameter scan over the RAW URL string. Splits
1869
+ * off the query (`?…`) and fragment (`#…`) and applies the same blob/credential
1870
+ * value-shape test as the post-parse walk, but only to keyword-named params
1871
+ * (KEYWORD_PARAM_NAME_RE). This is the precision fix for finding #20: a keyword
1872
+ * param flags only when its value is actually payload-shaped, so `?session=ok`
1873
+ * and `?key=pk_public_mapkey` no longer fire. It runs BEFORE `new URL()` so a
1874
+ * blob in an unparseable-host URL (which the post-parse walk never reaches) is
1875
+ * still caught, preserving the coverage the old name-only arm had.
1876
+ * @param {string} url
1877
+ * @returns {string | null}
1878
+ */
1879
+ function rawUrlKeywordExfil(url) {
1880
+ // Strip the scheme+authority+path prefix: everything up to the first `?`/`#`.
1881
+ const qIdx = url.search(/[?#]/);
1882
+ if (qIdx === -1) return null;
1883
+ for (const segment of url.slice(qIdx + 1).split("#")) {
1884
+ for (const [name, value] of rawParams(segment)) {
1885
+ if (!KEYWORD_PARAM_NAME_RE.test(name)) continue;
1886
+ const reason = paramExfilReason(name, value);
1887
+ if (reason) return reason;
1888
+ }
1889
+ }
1890
+ return null;
1891
+ }
1892
+
1893
+ /**
1894
+ * True when every parameter of the parsed URL's query is in the benign
1895
+ * allowlist. Used to suppress the coarse long-query-string heuristic for
1896
+ * signed-CDN links, which are long by design. Only ever called once the query
1897
+ * is known to be long (and thus non-empty), so the vacuous-true empty case
1898
+ * cannot arise here.
1899
+ * @param {URL} parsed
1900
+ * @returns {boolean}
1901
+ */
1902
+ function allParamsBenign(parsed) {
1903
+ return rawParams(parsed.search.slice(1)).every(([name]) =>
1904
+ BENIGN_BLOB_PARAM_RE.test(name),
1905
+ );
1906
+ }
1907
+
1908
+ /**
1909
+ * Walk the query and fragment parameters of a parsed URL for an exfil reason.
1910
+ * @param {URL} parsed
1911
+ * @returns {string | null}
1912
+ */
1913
+ function checkUrlParams(parsed) {
1914
+ for (const [name, value] of rawParams(parsed.search.slice(1))) {
1915
+ const reason = paramExfilReason(name, value);
1916
+ if (reason) return reason;
1917
+ }
1918
+ // The fragment carries the same `key=value` channel (`#token=…`); a bare
1919
+ // anchor (`#section-2`) yields one empty-value param that trips nothing.
1920
+ for (const [name, value] of rawParams(parsed.hash.slice(1))) {
1921
+ const reason = paramExfilReason(name, value);
1922
+ if (reason) return reason;
1923
+ }
1924
+ return null;
1925
+ }
1926
+
1927
+ /**
1928
+ * A bulk encoded-data blob smuggled in a path segment (a beacon URL that avoids
1929
+ * query strings entirely), or null.
1930
+ * @param {URL} parsed
1931
+ * @returns {string | null}
1932
+ */
1933
+ function checkUrlPath(parsed) {
1934
+ for (const segment of parsed.pathname.split("/")) {
1935
+ if (
1936
+ segment.length > PATH_BLOB_MIN_LEN &&
1937
+ (PATH_BLOB_RE.test(segment) || isBase64UrlBlob(segment))
1938
+ )
1939
+ return "encoded data blob in path segment";
1940
+ }
1941
+ return null;
1942
+ }
1943
+
1944
+ /**
1945
+ * @param {string} url
1946
+ * @returns {string | null}
1947
+ */
1948
+ export function checkExfilUrl(url) {
1949
+ // A browser strips tab/newline/CR ANYWHERE in a URL before resolving its
1950
+ // scheme, so `java\tscript:alert(1)` navigates as `javascript:`. Strip them
1951
+ // for the scheme tests (the payload/length checks below keep the raw string).
1952
+ const schemeUrl = url.replace(/[\t\n\r]/g, "");
1953
+ if (/^\s*data:/i.test(schemeUrl)) {
1954
+ if (DATA_URI_ACTIVE_RE.test(schemeUrl)) return "active-content data: URI";
1955
+ if (url.length > DATA_URI_LENGTH_THRESHOLD)
1956
+ return "oversized inline data: payload";
1957
+ return null;
1958
+ }
1959
+ if (SCRIPT_URI_RE.test(schemeUrl)) return "script-executing URI";
1960
+ // Template-injection shapes (`${…}`, `{{…}}`) only in the query/fragment: a
1961
+ // brace in the PATH or host is a legitimate templated doc URL
1962
+ // (`/api/{{version}}/guide`), and flagging it both false-positives and
1963
+ // mislabels the location. Sliced from the raw string so an unparseable-host
1964
+ // URL is still covered before `new URL()` would throw.
1965
+ const qfIdx = url.search(/[?#]/);
1966
+ const queryAndFragment = qfIdx === -1 ? "" : url.slice(qfIdx);
1967
+ if (
1968
+ queryAndFragment &&
1969
+ EXFIL_INDICATORS.some((pattern) => pattern.test(queryAndFragment))
1970
+ )
1971
+ return "suspicious query parameter";
1972
+ // Value-gated keyword params, scanned on the RAW string so a blob in an
1973
+ // unparseable-host URL is caught before `new URL()` would throw.
1974
+ const keywordReason = rawUrlKeywordExfil(url);
1975
+ if (keywordReason) return keywordReason;
1976
+ // Userinfo and an oversized fragment are exfil channels the param walk misses:
1977
+ // credentials smuggled as `user:secret@host`, or a payload tucked in `#<blob>`.
1978
+ // Parse against a sentinel base so relative URLs don't throw.
1979
+ let parsed;
1980
+ try {
1981
+ parsed = new URL(url, RELATIVE_URL_BASE);
1982
+ } catch {
1983
+ return null;
1984
+ }
1985
+ if (parsed.username || parsed.password) return "embedded credentials";
1986
+ // A long query string is only suspicious when it carries a non-allowlisted
1987
+ // parameter — a signed-CDN URL is long by design (all `X-Amz-*`/SAS params).
1988
+ // Measure the query from `parsed.search` (the parser's query span), NOT a raw
1989
+ // indexOf("?") into the whole URL: a `?` inside the FRAGMENT (`/p#a?<blob>`)
1990
+ // would otherwise be read as the query start, leaving `parsed.search` empty so
1991
+ // `allParamsBenign` runs `[].every(...)` → vacuously true and suppresses the
1992
+ // flag. The fragment is length-checked separately just below.
1993
+ if (parsed.search.length > LONG_QUERY_THRESHOLD && !allParamsBenign(parsed))
1994
+ return "unusually long query string";
1995
+ if (parsed.hash.length > LONG_QUERY_THRESHOLD)
1996
+ return "unusually long fragment";
1997
+ return checkUrlParams(parsed) || checkUrlPath(parsed);
1998
+ }
1999
+
2000
+ /**
2001
+ * Host of a flagged URL — enough for the warning to name the destination
2002
+ * without echoing the payload-bearing query/fragment.
2003
+ * @param {string} url
2004
+ * @returns {string}
2005
+ */
2006
+ export function urlHost(url) {
2007
+ // A `data:` URI has no host; name the channel rather than echoing the payload.
2008
+ if (/^\s*data:/i.test(url)) return "(inline data: URI)";
2009
+ let parsed;
2010
+ try {
2011
+ parsed = new URL(url, RELATIVE_URL_BASE);
2012
+ } catch {
2013
+ // checkExfilUrl flags via regex before parsing, so it can hand us a URL
2014
+ // WHATWG rejects (e.g. a non-ASCII host).
2015
+ return "(unparsable URL)";
2016
+ }
2017
+ if (
2018
+ parsed.origin === RELATIVE_URL_BASE &&
2019
+ !url.startsWith(RELATIVE_URL_BASE)
2020
+ ) {
2021
+ return "(relative URL)";
2022
+ }
2023
+ return parsed.host;
2024
+ }
2025
+
2026
+ /**
2027
+ * True when `url` is an absolute, off-origin target (an authority that is not
2028
+ * the relative-resolution sentinel). Used for form `action`/`formaction` and
2029
+ * `meta refresh` URLs, where pointing off the page's own origin is the
2030
+ * exfil/redirect signal regardless of the query shape.
2031
+ * @param {string} url
2032
+ * @returns {boolean}
2033
+ */
2034
+ function isOffOrigin(url) {
2035
+ let parsed;
2036
+ try {
2037
+ parsed = new URL(url, RELATIVE_URL_BASE);
2038
+ } catch {
2039
+ return false;
2040
+ }
2041
+ return (
2042
+ parsed.origin !== RELATIVE_URL_BASE || url.startsWith(RELATIVE_URL_BASE)
2043
+ );
2044
+ }
2045
+
2046
+ /**
2047
+ * The redirect URL of a `<meta http-equiv="refresh">` content value
2048
+ * (`"5; url=https://…"`), or null when it carries no `url=` target.
2049
+ * @param {string} content
2050
+ * @returns {string | null}
2051
+ */
2052
+ function metaRefreshUrl(content) {
2053
+ // Do NOT exclude `;` from the URL run: the `;` separates the timeout from
2054
+ // `url=` BEFORE the target, while WITHIN the target it is a legal query
2055
+ // sub-delimiter — excluding it truncated a `?a=1;b=<blob>` exfil tail. The
2056
+ // optional leading quote is consumed and the run then stops at the closing
2057
+ // quote (quoted) or at whitespace (unquoted); a single group keeps both forms
2058
+ // without an unreachable no-match arm.
2059
+ const match = /** @type {{ groups: { url: string } } | null} */ (
2060
+ content.match(/url\s*=\s*['"]?(?<url>[^'"\s]+)/i)
2061
+ );
2062
+ return match ? match.groups.url : null;
2063
+ }
2064
+
2065
+ // HTML whitespace per the `srcset` grammar (ASCII whitespace).
2066
+ const SRCSET_WS_RE = /[ \t\n\f\r]/;
2067
+
2068
+ /**
2069
+ * URLs of a `srcset` value, parsed per the WHATWG "parse a srcset attribute"
2070
+ * grammar rather than a naive `split(",")`: a candidate's URL is a run of
2071
+ * non-whitespace characters, so a URL that itself contains commas (a `data:`
2072
+ * URI, or a query with `,`) is kept intact. A comma only separates candidates
2073
+ * when it trails the URL run or follows the (paren-aware) descriptor. Trailing
2074
+ * commas on the URL run mark a candidate with no descriptor.
2075
+ * @param {string} value
2076
+ * @returns {string[]}
2077
+ */
2078
+ function parseSrcset(value) {
2079
+ /** @type {string[]} */ const urls = [];
2080
+ let i = 0;
2081
+ const n = value.length;
2082
+ while (i < n) {
2083
+ while (i < n && (SRCSET_WS_RE.test(value[i]) || value[i] === ",")) i++;
2084
+ const start = i;
2085
+ while (i < n && !SRCSET_WS_RE.test(value[i])) i++;
2086
+ const run = value.slice(start, i);
2087
+ const url = run.replace(/,+$/, "");
2088
+ if (url) urls.push(url);
2089
+ // A URL run ending in a comma is a bare candidate (no descriptor); the
2090
+ // comma already delimits the next one, so skip descriptor parsing.
2091
+ if (run.endsWith(",")) continue;
2092
+ // Otherwise consume the descriptor up to the first unparenthesized comma.
2093
+ let depth = 0;
2094
+ while (i < n) {
2095
+ const c = value[i];
2096
+ if (c === "(") depth++;
2097
+ else if (c === ")" && depth > 0) depth--;
2098
+ else if (c === "," && depth === 0) {
2099
+ i++;
2100
+ break;
2101
+ }
2102
+ i++;
2103
+ }
2104
+ }
2105
+ return urls;
2106
+ }
2107
+
2108
+ /**
2109
+ * Candidate URLs of a `srcset` (a "url descriptor" string parsed per the HTML
2110
+ * grammar) or `ping` (a space-separated url list rehype delivers as an array)
2111
+ * attribute. An absent attribute (neither string nor array) yields none.
2112
+ * @param {unknown} value
2113
+ * @returns {string[]}
2114
+ */
2115
+ function multiUrlAttr(value) {
2116
+ if (Array.isArray(value))
2117
+ return value
2118
+ .map((candidate) => String(candidate).trim().split(/\s+/)[0])
2119
+ .filter(Boolean);
2120
+ if (typeof value === "string") return parseSrcset(value);
2121
+ return [];
2122
+ }
2123
+
2124
+ /**
2125
+ * URL-bearing attributes of every HTML element in `text`, parsed with rehype so
2126
+ * quoting/casing/entities are handled correctly (no hand-rolled tag regex).
2127
+ * `context` selects the per-URL check the caller applies: resource URLs get the
2128
+ * exfil-shape test; form-submission and meta-refresh targets additionally flag
2129
+ * any absolute off-origin destination.
2130
+ * @param {string} text
2131
+ * @returns {Array<{ url: string, isImage: boolean, context: "resource" | "form" | "refresh" }>}
2132
+ */
2133
+ function extractHtmlUrls(text) {
2134
+ const tree = unified().use(rehypeParse, { fragment: true }).parse(text);
2135
+ /** @type {Array<{ url: string, isImage: boolean, context: "resource" | "form" | "refresh" }>} */
2136
+ const urls = [];
2137
+ visit(tree, "element", (/** @type {any} */ node) => {
2138
+ // hast element nodes always carry a `properties` object (parse5 sets it).
2139
+ const props = node.properties;
2140
+ const isImage = node.tagName === "img";
2141
+ for (const key of ["src", "href", "background"])
2142
+ if (typeof props[key] === "string")
2143
+ urls.push({ url: props[key], isImage, context: "resource" });
2144
+ for (const key of ["srcSet", "ping"])
2145
+ for (const url of multiUrlAttr(props[key]))
2146
+ urls.push({ url, isImage, context: "resource" });
2147
+ for (const key of ["action", "formAction"])
2148
+ if (typeof props[key] === "string")
2149
+ urls.push({ url: props[key], isImage: false, context: "form" });
2150
+ // rehype delivers `http-equiv` as an array (comma-separated); join it back
2151
+ // so a `refresh` directive is matched regardless of how it was tokenized.
2152
+ const httpEquiv = Array.isArray(props.httpEquiv)
2153
+ ? props.httpEquiv.join(",").toLowerCase()
2154
+ : "";
2155
+ if (
2156
+ node.tagName === "meta" &&
2157
+ httpEquiv.includes("refresh") &&
2158
+ typeof props.content === "string"
2159
+ ) {
2160
+ const url = metaRefreshUrl(props.content);
2161
+ if (url) urls.push({ url, isImage: false, context: "refresh" });
2162
+ }
2163
+ });
2164
+ return urls;
2165
+ }
2166
+
2167
+ // Reason for an off-origin submission/redirect target by context; null leaves
2168
+ // the URL to the exfil-shape check alone.
2169
+ const OFF_ORIGIN_REASON = {
2170
+ form: "off-origin form action",
2171
+ refresh: "off-origin meta-refresh redirect",
2172
+ };
2173
+
2174
+ /**
2175
+ * Layer 3: report data-exfil-shaped URLs in markdown links/images/definitions
2176
+ * and HTML attributes (src/href/background/srcset/ping, form action/formaction,
2177
+ * meta-refresh). Detection only — the text is never modified; the caller
2178
+ * surfaces the threats as a warning.
2179
+ * @param {string} text
2180
+ * @returns {Array<{ isImage: boolean, reason: string, target: string }> | null}
2181
+ */
2182
+ export function detectExfil(text) {
2183
+ if (!MD_LINK_HINT.test(text) && !HTML_TAG_PRESENT.test(text)) return null;
2184
+
2185
+ /** @type {Array<{ isImage: boolean, reason: string, target: string }>} */
2186
+ const threats = [];
2187
+
2188
+ try {
2189
+ // Remark AST handles markdown links/images/definitions (balanced parens,
2190
+ // reference links) correctly, unlike a hand-rolled regex.
2191
+ const tree = mdParser.parse(text);
2192
+ visit(tree, (node) => {
2193
+ if (
2194
+ node.type !== "link" &&
2195
+ node.type !== "image" &&
2196
+ node.type !== "definition"
2197
+ )
2198
+ return;
2199
+ const reason = checkExfilUrl(node.url);
2200
+ if (!reason) return;
2201
+ threats.push({
2202
+ isImage: node.type === "image",
2203
+ reason,
2204
+ target: urlHost(node.url),
2205
+ });
2206
+ });
2207
+
2208
+ // HTML attributes (not AST nodes in remark).
2209
+ for (const { url, isImage, context } of extractHtmlUrls(text)) {
2210
+ const reason =
2211
+ checkExfilUrl(url) ||
2212
+ (context !== "resource" && isOffOrigin(url)
2213
+ ? OFF_ORIGIN_REASON[context]
2214
+ : null);
2215
+ if (!reason) continue;
2216
+ threats.push({ isImage, reason, target: urlHost(url) });
2217
+ }
2218
+ } catch {
2219
+ // The parse/visit blew up (stack overflow on pathological nesting). Fail
2220
+ // CLOSED so the never-throw contract holds: report one sentinel threat so
2221
+ // the caller still warns rather than crashing, since an input too nested to
2222
+ // scan could itself be hiding an exfil URL.
2223
+ return [
2224
+ {
2225
+ isImage: false,
2226
+ reason: "input too deeply nested to scan for exfil URLs",
2227
+ target: "(unparseable HTML)",
2228
+ },
2229
+ ];
2230
+ }
2231
+
2232
+ return threats.length > 0 ? threats : null;
2233
+ }