agent-sanitizer 2.34.7 → 2.34.9
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/claude-hooks/lib/authored-content.mjs +5 -28
- package/claude-hooks/sanitize-output.mjs +9 -39
- package/claude-hooks/scan-invisible-chars.mjs +5 -6
- package/package.json +3 -2
- package/src/html.mjs +108 -14
- package/src/invisible.mjs +548 -238
- package/src/output.mjs +14 -3
- package/types/claude-hooks/sanitize-output.d.mts +1 -1
- package/types/output.d.mts +10 -0
|
@@ -53,36 +53,11 @@ import { lazyImport } from "./hook-io.mjs";
|
|
|
53
53
|
const { stripAnsiFully } = /** @type {typeof import("agent-sanitizer")} */ (
|
|
54
54
|
await lazyImport("agent-sanitizer")
|
|
55
55
|
);
|
|
56
|
-
const { STRIP,
|
|
56
|
+
const { STRIP, SCATTERED_THRESHOLD, hasLongRun, stripInvisible } =
|
|
57
57
|
/** @type {typeof import("agent-sanitizer/invisible")} */ (
|
|
58
58
|
await lazyImport("agent-sanitizer/invisible")
|
|
59
59
|
);
|
|
60
60
|
|
|
61
|
-
/**
|
|
62
|
-
* "A run of {@link LONG_RUN_THRESHOLD} or more invisibles", bounded per match.
|
|
63
|
-
*
|
|
64
|
-
* Built from the engine's own class and threshold rather than imported as a
|
|
65
|
-
* ready-made pattern or scan function, because the bundle resolves
|
|
66
|
-
* `agent-sanitizer` to the PINNED published engine, which trails this repo:
|
|
67
|
-
* anything this hook imports has to exist in that pin, or the import binds
|
|
68
|
-
* undefined and the hook fails closed on every payload. STRIP and
|
|
69
|
-
* LONG_RUN_THRESHOLD are the primitives that define a long run, so deriving the
|
|
70
|
-
* pattern here keeps the answer identical to the engine's across pins, with no
|
|
71
|
-
* version-specific scan API to adopt when the pin moves.
|
|
72
|
-
*
|
|
73
|
-
* The upper bound is what makes it safe on a large payload: V8 pushes one
|
|
74
|
-
* backtrack entry per iteration of a quantifier onto a stack capped at 64 MB,
|
|
75
|
-
* so an UNBOUNDED run pattern throws `RangeError: Maximum call stack size
|
|
76
|
-
* exceeded` once a single run passes ~8.4 M code points — an 8 MB paste of
|
|
77
|
-
* zero-widths into a Write body is exactly that. A bound of 2^20 iterations
|
|
78
|
-
* sits ~8x under the ceiling, and a longer run still answers yes: any run of at
|
|
79
|
-
* least the threshold contains a prefix this matches.
|
|
80
|
-
*/
|
|
81
|
-
const LONG_RUN_CHUNK_RE = new RegExp(
|
|
82
|
-
`(?:${STRIP.source}){${LONG_RUN_THRESHOLD},${1 << 20}}`,
|
|
83
|
-
"gu",
|
|
84
|
-
);
|
|
85
|
-
|
|
86
61
|
// Content fields the model authors, per tool. Paths and confusables are the
|
|
87
62
|
// confusable layer's domain; here we target the free-text fields that carry
|
|
88
63
|
// model-authored prose / code / data out into persisted or displayed artifacts.
|
|
@@ -165,8 +140,10 @@ export function authoredScopeDecision(tool) {
|
|
|
165
140
|
// user→model surfaces share one definition of "stego payload".
|
|
166
141
|
/** @param {string} text */
|
|
167
142
|
function isPayloadCapable(text) {
|
|
168
|
-
|
|
169
|
-
|
|
143
|
+
// hasLongRun, not a pattern built here: the engine's scan is bounded per
|
|
144
|
+
// `exec`, which is what keeps an 8 MB run of zero-widths in a Write body from
|
|
145
|
+
// throwing `RangeError: Maximum call stack size exceeded` out of this hook.
|
|
146
|
+
if (hasLongRun(text)) return true;
|
|
170
147
|
return (text.match(STRIP)?.length ?? 0) >= SCATTERED_THRESHOLD;
|
|
171
148
|
}
|
|
172
149
|
|
|
@@ -82,8 +82,11 @@ export const { applyLayer1, matchesSecretHint, SECRET_HINT, SECRET_HINT_EXT } =
|
|
|
82
82
|
const _output = /** @type {typeof import("agent-sanitizer/output")} */ (
|
|
83
83
|
await lazyImport("agent-sanitizer/output")
|
|
84
84
|
);
|
|
85
|
-
const {
|
|
86
|
-
|
|
85
|
+
const {
|
|
86
|
+
sanitizeText: sanitizeTextSeam,
|
|
87
|
+
composeContext: composeContextSeam,
|
|
88
|
+
withheldWarning,
|
|
89
|
+
} = _output;
|
|
87
90
|
export const { describeRemoved, describeWarned, suppressToolOutput } = _output;
|
|
88
91
|
|
|
89
92
|
const HOOK_NAME = "sanitize-output";
|
|
@@ -94,12 +97,7 @@ const HOOK_NAME = "sanitize-output";
|
|
|
94
97
|
// splice/withhold warnings make is NOT kept for this output. Fixed prose, no
|
|
95
98
|
// error text — the redactor runs on attacker-influenced content and this line
|
|
96
99
|
// reaches the model-facing context. Exported so tests assert it by reference.
|
|
97
|
-
|
|
98
|
-
// output.mjs's "Withheld the ${label}" template: the plugin bundle resolves
|
|
99
|
-
// the engine to the pinned registry release, so hook code cannot use a new
|
|
100
|
-
// engine export until the pin advances past it.
|
|
101
|
-
export const REVEAL_WITHHELD_WARNING =
|
|
102
|
-
"Withheld the reveal sidecar: it could not be vetted for secrets";
|
|
100
|
+
export const REVEAL_WITHHELD_WARNING = withheldWarning("reveal sidecar");
|
|
103
101
|
|
|
104
102
|
// Total wall-clock budget for one hook invocation's blocking daemon calls — the
|
|
105
103
|
// Layer-4 redactor — SHARED across every string leaf of the tool output. Each
|
|
@@ -116,18 +114,6 @@ const SANITIZE_BUDGET_MS = positiveMsOr(
|
|
|
116
114
|
120000,
|
|
117
115
|
);
|
|
118
116
|
|
|
119
|
-
// Non-WARNING note for a strip whose only change was INERT ANSI on a local tool:
|
|
120
|
-
// the display-only colour git/pytest/npm/etc. emit by default, and/or a stray
|
|
121
|
-
// escape byte that formed no sequence at all. The engine now returns this text
|
|
122
|
-
// itself, as a NOTE-severity finding alongside the warnings, so this copy is the
|
|
123
|
-
// FALLBACK for exactly one case: a bundle built against a pinned engine older
|
|
124
|
-
// than that severity split, whose result carries `sgrNote` but no `notes`. Same
|
|
125
|
-
// sentence, so a plugin on the old pin keeps today's wording instead of falling
|
|
126
|
-
// back to a bare "output sanitized".
|
|
127
|
-
const SGR_OUTPUT_NOTE =
|
|
128
|
-
"Inert ANSI stripped (display-only colour and/or a stray escape byte that " +
|
|
129
|
-
"formed no control sequence); pipe through cat -v to inspect raw escapes.";
|
|
130
|
-
|
|
131
117
|
// Web-ingress tools always get the Layer 2 HTML rewrite; local tools — Read,
|
|
132
118
|
// Bash, Grep, gh — never do. A local HTML/markdown pass either rewrites bytes the
|
|
133
119
|
// model is about to edit or deletes content (diffs, PR bodies, page
|
|
@@ -309,10 +295,8 @@ export async function sanitizeText(
|
|
|
309
295
|
/** @type {{ cleaned: string, warnings: string[], notes?: string[], modified: boolean, sgrNote: boolean, reveal?: string, splices?: Array<{ placeholder: string, original: string }> }} */ (
|
|
310
296
|
await sanitizeTextSeam(text, seamOptions)
|
|
311
297
|
);
|
|
312
|
-
// The one place the seam's shape is normalized
|
|
313
|
-
//
|
|
314
|
-
// case (see SGR_OUTPUT_NOTE). Defaulting here means nothing downstream has to
|
|
315
|
-
// know that, and the banner composer sees one shape either way.
|
|
298
|
+
// The one place the seam's shape is normalized, so nothing downstream has to
|
|
299
|
+
// branch on an absent `notes` and the banner composer sees one shape.
|
|
316
300
|
const result = { ...seamResult, notes: seamResult.notes ?? [] };
|
|
317
301
|
return ext.postText
|
|
318
302
|
? applyPostText(
|
|
@@ -1006,7 +990,7 @@ export async function evaluateToolOutput(input, ext = {}) {
|
|
|
1006
990
|
// hidden-HTML splice to read about does not also need the colour codes).
|
|
1007
991
|
const baseContext =
|
|
1008
992
|
sgrNote && warnings.length === 0
|
|
1009
|
-
?
|
|
993
|
+
? [...new Set(notes)].join(" ")
|
|
1010
994
|
: composeContext(modified, warnings, input.tool_name);
|
|
1011
995
|
const additionalContext = revealRead
|
|
1012
996
|
? `${REVEAL_READ_ENVELOPE} ${baseContext}`
|
|
@@ -1017,20 +1001,6 @@ export async function evaluateToolOutput(input, ext = {}) {
|
|
|
1017
1001
|
return emit(modified ? "modified" : "flagged", fields);
|
|
1018
1002
|
}
|
|
1019
1003
|
|
|
1020
|
-
/**
|
|
1021
|
-
* The model-facing line for a note-only result: the seam's own note text,
|
|
1022
|
-
* deduped and joined, with no WARNING prefix.
|
|
1023
|
-
*
|
|
1024
|
-
* Empty only against a pinned engine that predates the severity split (see
|
|
1025
|
-
* SGR_OUTPUT_NOTE): there `sgrNote` still arrives true with no `notes` to go
|
|
1026
|
-
* with it, and printing nothing would drop the one thing that run had to say.
|
|
1027
|
-
* @param {string[]} notes
|
|
1028
|
-
* @returns {string}
|
|
1029
|
-
*/
|
|
1030
|
-
function noteContext(notes) {
|
|
1031
|
-
return notes.length === 0 ? SGR_OUTPUT_NOTE : [...new Set(notes)].join(" ");
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
1004
|
/**
|
|
1035
1005
|
* Judge a normalized PostToolUse event: run the sanitization pipeline and
|
|
1036
1006
|
* express its outcome as a control-plane Verdict. sanitize-output only ever
|
|
@@ -41,12 +41,11 @@ import {
|
|
|
41
41
|
import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
|
|
42
42
|
import { reportSlowHook, startHookTimer } from "./lib/hook-timing.mjs";
|
|
43
43
|
// Relative, not the `agent-sanitizer` specifier every other engine import uses:
|
|
44
|
-
// this is the scan's SCOPE, which is hook policy and
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
// statically carries none of the fail-open hazard lazyImport exists to cover.
|
|
44
|
+
// this is the scan's SCOPE, which is hook policy, and package.json's exports map
|
|
45
|
+
// deliberately does not publish it — routing it through the specifier would fail
|
|
46
|
+
// to resolve. The module is dependency-free data (see src/claude-context.mjs),
|
|
47
|
+
// so importing it statically carries none of the fail-open hazard lazyImport
|
|
48
|
+
// exists to cover.
|
|
50
49
|
import {
|
|
51
50
|
CLAUDE_CONTEXT_SUBDIRS,
|
|
52
51
|
CLAUDE_INSTRUCTION_GLOBS,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.34.
|
|
3
|
+
"version": "2.34.9",
|
|
4
4
|
"description": "Defend an agent against hidden-content injection: strip payload-capable invisible Unicode and ANSI, splice out human-invisible HTML, and flag data-exfil URLs in untrusted text before any model sees it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
"@stryker-mutator/tap-runner": "^9.6.1",
|
|
54
54
|
"@types/node": "25.9.1",
|
|
55
55
|
"acorn": "^8.18.0",
|
|
56
|
+
"agent-sanitizer": "link:.",
|
|
56
57
|
"c8": "11.0.0",
|
|
57
58
|
"esbuild": "0.28.1",
|
|
58
59
|
"eslint": "10.4.0",
|
|
@@ -60,7 +61,7 @@
|
|
|
60
61
|
"globals": "17.6.0",
|
|
61
62
|
"lint-staged": "^17.0.5",
|
|
62
63
|
"prettier": "^3.0.0",
|
|
63
|
-
"
|
|
64
|
+
"smol-toml": "^1.7.1",
|
|
64
65
|
"typescript": "6.0.3",
|
|
65
66
|
"typescript-eslint": "8.61.0",
|
|
66
67
|
"yaml": "^2.9.0"
|
package/src/html.mjs
CHANGED
|
@@ -36,7 +36,7 @@ import { unified } from "unified";
|
|
|
36
36
|
import remarkParse from "remark-parse";
|
|
37
37
|
import remarkGfm from "remark-gfm";
|
|
38
38
|
import rehypeParse from "rehype-parse";
|
|
39
|
-
import {
|
|
39
|
+
import { SKIP, EXIT } from "unist-util-visit";
|
|
40
40
|
import {
|
|
41
41
|
HTML_TAG_PRESENT,
|
|
42
42
|
MD_LINK_HINT,
|
|
@@ -1374,14 +1374,93 @@ function hasDataSrc(el) {
|
|
|
1374
1374
|
// there is exactly one parser configuration to reason about.
|
|
1375
1375
|
const htmlParser = unified().use(rehypeParse, { fragment: true });
|
|
1376
1376
|
|
|
1377
|
+
/**
|
|
1378
|
+
* Preorder depth-first walk of a unist tree, calling `visitor(node, index,
|
|
1379
|
+
* parent)` on every node whose `type` is `test` (or on every node when `test` is
|
|
1380
|
+
* null). `EXIT` ends the walk, `SKIP` leaves the node's children unvisited —
|
|
1381
|
+
* the `unist-util-visit` contract, which is what the call sites here are
|
|
1382
|
+
* written against.
|
|
1383
|
+
*
|
|
1384
|
+
* Spelled out rather than imported because `unist-util-visit` allocates a fresh
|
|
1385
|
+
* ancestors array and a closure per node, and on a document that is one long
|
|
1386
|
+
* flat sibling list — 32k `<p>` elements per megabyte of ordinary HTML — that
|
|
1387
|
+
* turns a linear walk super-linear: 3480 ms against this walk's 62 ms over the
|
|
1388
|
+
* same 4 MB tree. The three parallel arrays are the stack, so the walk itself
|
|
1389
|
+
* allocates nothing per node.
|
|
1390
|
+
* @param {any} tree
|
|
1391
|
+
* @param {string | null} test
|
|
1392
|
+
* @param {(node: any, index: number | undefined, parent: any) => unknown} visitor
|
|
1393
|
+
*/
|
|
1394
|
+
function walk(tree, test, visitor) {
|
|
1395
|
+
/** @type {any[]} */
|
|
1396
|
+
const nodes = [tree];
|
|
1397
|
+
/** @type {Array<number | undefined>} */
|
|
1398
|
+
const indices = [undefined];
|
|
1399
|
+
/** @type {any[]} */
|
|
1400
|
+
const parents = [undefined];
|
|
1401
|
+
while (nodes.length > 0) {
|
|
1402
|
+
const node = nodes.pop();
|
|
1403
|
+
const index = indices.pop();
|
|
1404
|
+
const parent = parents.pop();
|
|
1405
|
+
const result =
|
|
1406
|
+
test === null || node.type === test
|
|
1407
|
+
? visitor(node, index, parent)
|
|
1408
|
+
: undefined;
|
|
1409
|
+
if (result === EXIT) return;
|
|
1410
|
+
if (result === SKIP) continue;
|
|
1411
|
+
const children = node.children;
|
|
1412
|
+
if (children === undefined) continue;
|
|
1413
|
+
for (let i = children.length - 1; i >= 0; i--) {
|
|
1414
|
+
nodes.push(children[i]);
|
|
1415
|
+
indices.push(i);
|
|
1416
|
+
parents.push(node);
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/**
|
|
1422
|
+
* `parse` with its most recent (input, tree) pair remembered.
|
|
1423
|
+
*
|
|
1424
|
+
* Layers 2 and 3 each parse the SAME tool output: `sanitizeHtml` tokenizes it to
|
|
1425
|
+
* decide the source-vs-markdown branch, and `detectExfil` tokenizes it again to
|
|
1426
|
+
* read `src`/`href` off the elements — two full parse5 runs and two full
|
|
1427
|
+
* micromark runs over one document, which is most of what the HTML layer costs
|
|
1428
|
+
* on ordinary prose. Every consumer only READS the tree (the sole AST mutation
|
|
1429
|
+
* in this module is over a css-tree value parsed per style string), so one tree
|
|
1430
|
+
* is safe to hand to both.
|
|
1431
|
+
*
|
|
1432
|
+
* One entry, replaced on every miss, so the retained footprint is one tree for
|
|
1433
|
+
* the document most recently sanitized — the tree that call had allocated
|
|
1434
|
+
* anyway. A miss re-parses and answers identically, so the cache can never
|
|
1435
|
+
* change a verdict, only what one costs.
|
|
1436
|
+
* @param {(text: string) => any} parse
|
|
1437
|
+
* @returns {(text: string) => any}
|
|
1438
|
+
*/
|
|
1439
|
+
function lastParseCached(parse) {
|
|
1440
|
+
/** @type {string | null} */
|
|
1441
|
+
let cachedText = null;
|
|
1442
|
+
/** @type {any} */
|
|
1443
|
+
let cachedTree = null;
|
|
1444
|
+
return (text) => {
|
|
1445
|
+
if (cachedText === text) return cachedTree;
|
|
1446
|
+
// The key is recorded only AFTER the parse returns. A parse that throws —
|
|
1447
|
+
// which is how a pathologically nested fragment reaches the fail-closed
|
|
1448
|
+
// withhold — must leave the entry untouched, or the next call for that same
|
|
1449
|
+
// text hits a key whose tree came from a DIFFERENT document and gets a
|
|
1450
|
+
// verdict about the wrong input instead of the withhold.
|
|
1451
|
+
const tree = parse(text);
|
|
1452
|
+
cachedText = text;
|
|
1453
|
+
cachedTree = tree;
|
|
1454
|
+
return tree;
|
|
1455
|
+
};
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1377
1458
|
/**
|
|
1378
1459
|
* Parse `html` as an HTML fragment with the real tokenizer (parse5, via rehype).
|
|
1379
1460
|
* @param {string} html
|
|
1380
1461
|
* @returns {any}
|
|
1381
1462
|
*/
|
|
1382
|
-
|
|
1383
|
-
return htmlParser.parse(html);
|
|
1384
|
-
}
|
|
1463
|
+
const parseFragment = lastParseCached((html) => htmlParser.parse(html));
|
|
1385
1464
|
|
|
1386
1465
|
/**
|
|
1387
1466
|
* @param {string} htmlValue
|
|
@@ -1391,7 +1470,7 @@ function parseHtmlTag(htmlValue) {
|
|
|
1391
1470
|
const tree = parseFragment(htmlValue);
|
|
1392
1471
|
/** @type {any} */
|
|
1393
1472
|
let firstElement = null;
|
|
1394
|
-
|
|
1473
|
+
walk(tree, "element", (node) => {
|
|
1395
1474
|
firstElement = node;
|
|
1396
1475
|
return EXIT;
|
|
1397
1476
|
});
|
|
@@ -1606,7 +1685,7 @@ function scanFragmentTree(html, tree) {
|
|
|
1606
1685
|
const warned = newWarned();
|
|
1607
1686
|
// @ts-ignore -- visit callback returns EXIT/SKIP only on matches; implicit undefined return is intentional
|
|
1608
1687
|
// eslint-disable-next-line consistent-return
|
|
1609
|
-
|
|
1688
|
+
walk(tree, null, (/** @type {any} */ node) => {
|
|
1610
1689
|
const isComment = node.type === "comment";
|
|
1611
1690
|
if (isComment || isHiddenElement(node)) {
|
|
1612
1691
|
/* c8 ignore start -- parse5 omits positions only on recovery-synthesized
|
|
@@ -1635,6 +1714,20 @@ function scanFragmentTree(html, tree) {
|
|
|
1635
1714
|
|
|
1636
1715
|
const mdParser = unified().use(remarkParse).use(remarkGfm);
|
|
1637
1716
|
|
|
1717
|
+
/** The markdown tree for `text`, cached the same way {@link parseFragment} is.
|
|
1718
|
+
* @type {(text: string) => any} */
|
|
1719
|
+
const parseMarkdown = lastParseCached((text) => mdParser.parse(text));
|
|
1720
|
+
|
|
1721
|
+
// A `code` node is a FENCED or INDENTED block and nothing else, so either a
|
|
1722
|
+
// three-run of backticks/tildes or a line opening on a four-column indent must
|
|
1723
|
+
// appear somewhere in the text for one to exist. CommonMark expands a tab to
|
|
1724
|
+
// the next four-column tab stop, so fewer than four spaces followed by a tab is
|
|
1725
|
+
// an indent too (" \tfoo" is a code block) — hence ` *\t` rather than `\t`.
|
|
1726
|
+
// Read over the whole document and deliberately loose (a stray ``` anywhere is
|
|
1727
|
+
// enough to parse), because the only sound direction to be wrong in here is
|
|
1728
|
+
// towards parsing.
|
|
1729
|
+
const MARKDOWN_CODE_HINT = /```|~~~|^(?: {4}| *\t)/m;
|
|
1730
|
+
|
|
1638
1731
|
// A markup-declaration-open (`<!`) or processing-instruction-ish (`<?`) start.
|
|
1639
1732
|
// Inside an inline html node these begin a *bogus comment* unless they open a
|
|
1640
1733
|
// proper `<!--…-->` comment (handled on the fast path) — `<!bogus>`, `<?php?>`,
|
|
@@ -1688,7 +1781,7 @@ function commentSpans(value) {
|
|
|
1688
1781
|
const tree = parseFragment(value);
|
|
1689
1782
|
/** @type {Map<number, number>} */
|
|
1690
1783
|
const spans = new Map();
|
|
1691
|
-
|
|
1784
|
+
walk(tree, "comment", (/** @type {any} */ node) => {
|
|
1692
1785
|
if (node.position)
|
|
1693
1786
|
spans.set(node.position.start.offset, node.position.end.offset);
|
|
1694
1787
|
});
|
|
@@ -1916,7 +2009,7 @@ const FLOW_HTML_PARENTS = new Set([
|
|
|
1916
2009
|
* @returns {{ ranges: SpliceRange[], warned: ReturnType<typeof newWarned> }}
|
|
1917
2010
|
*/
|
|
1918
2011
|
function scanMarkdown(text) {
|
|
1919
|
-
const tree =
|
|
2012
|
+
const tree = parseMarkdown(text);
|
|
1920
2013
|
/** @type {SpliceRange[]} */
|
|
1921
2014
|
const ranges = [];
|
|
1922
2015
|
const warned = newWarned();
|
|
@@ -1924,7 +2017,7 @@ function scanMarkdown(text) {
|
|
|
1924
2017
|
// Flow html blocks carry complete markup, so rehype locates comments/hidden
|
|
1925
2018
|
// elements precisely within them; block-local offsets are shifted to
|
|
1926
2019
|
// document coordinates.
|
|
1927
|
-
|
|
2020
|
+
walk(tree, "html", (/** @type {any} */ node, _index, parent) => {
|
|
1928
2021
|
if (!FLOW_HTML_PARENTS.has(parent?.type)) return;
|
|
1929
2022
|
const base = node.position.start.offset;
|
|
1930
2023
|
const sub = scanHtmlFragment(text.slice(base, node.position.end.offset));
|
|
@@ -1944,7 +2037,7 @@ function scanMarkdown(text) {
|
|
|
1944
2037
|
// are walked as part of their root in document order, so the walk is skipped
|
|
1945
2038
|
// for them here to avoid double-scanning and to keep the absorb state flowing
|
|
1946
2039
|
// across those boundaries.
|
|
1947
|
-
|
|
2040
|
+
walk(tree, null, (/** @type {any} */ node) => {
|
|
1948
2041
|
if (!PHRASING_ROOTS.has(node.type)) return;
|
|
1949
2042
|
if (!hasHtmlLeaf(node)) return;
|
|
1950
2043
|
scanInlineChildren(node, text, ranges, warned);
|
|
@@ -1969,8 +2062,9 @@ function scanMarkdown(text) {
|
|
|
1969
2062
|
* @returns {boolean}
|
|
1970
2063
|
*/
|
|
1971
2064
|
function hasMarkdownCode(text) {
|
|
2065
|
+
if (!MARKDOWN_CODE_HINT.test(text)) return false;
|
|
1972
2066
|
let found = false;
|
|
1973
|
-
|
|
2067
|
+
walk(parseMarkdown(text), "code", () => {
|
|
1974
2068
|
found = true;
|
|
1975
2069
|
return EXIT;
|
|
1976
2070
|
});
|
|
@@ -2564,7 +2658,7 @@ function extractHtmlUrls(text) {
|
|
|
2564
2658
|
const tree = parseFragment(text);
|
|
2565
2659
|
/** @type {Array<{ url: string, isImage: boolean, autoFetched: boolean, context: "resource" | "form" | "refresh" }>} */
|
|
2566
2660
|
const urls = [];
|
|
2567
|
-
|
|
2661
|
+
walk(tree, "element", (/** @type {any} */ node) => {
|
|
2568
2662
|
// hast element nodes always carry a `properties` object (parse5 sets it).
|
|
2569
2663
|
const props = node.properties;
|
|
2570
2664
|
const isImage = node.tagName === "img";
|
|
@@ -2640,8 +2734,8 @@ export function detectExfil(text) {
|
|
|
2640
2734
|
try {
|
|
2641
2735
|
// Remark AST handles markdown links/images/definitions (balanced parens,
|
|
2642
2736
|
// reference links) correctly, unlike a hand-rolled regex.
|
|
2643
|
-
const tree =
|
|
2644
|
-
|
|
2737
|
+
const tree = parseMarkdown(text);
|
|
2738
|
+
walk(tree, null, (node) => {
|
|
2645
2739
|
if (
|
|
2646
2740
|
node.type !== "link" &&
|
|
2647
2741
|
node.type !== "image" &&
|