agent-input-sanitizer 1.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/LICENSE +202 -0
- package/README.md +152 -0
- package/THREAT-MODEL.md +77 -0
- package/package.json +106 -0
- package/src/gates.mjs +54 -0
- package/src/html.mjs +937 -0
- package/src/index.mjs +221 -0
- package/src/invisible.mjs +248 -0
package/src/index.mjs
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Top-level convenience entry for agent-input-sanitizer.
|
|
3
|
+
*
|
|
4
|
+
* `sanitize` always runs the zero-dependency Layer 1 (invisible-char + ANSI
|
|
5
|
+
* stripping, lone-surrogate normalization) and, when `html` is requested,
|
|
6
|
+
* lazy-loads the heavier HTML layer (Layers 2 & 3) so the remark/rehype graph
|
|
7
|
+
* is only paid for by callers that ask for it.
|
|
8
|
+
*
|
|
9
|
+
* The low-level building blocks stay public via the `./invisible` and `./html`
|
|
10
|
+
* subpath entries; import those directly when you want a single layer without
|
|
11
|
+
* the convenience wrapper.
|
|
12
|
+
*/
|
|
13
|
+
import { stripInvisibleWithReport, LONG_RUN_RE } from "./invisible.mjs";
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
stripInvisible,
|
|
17
|
+
stripInvisibleWithReport,
|
|
18
|
+
isSgrOnly,
|
|
19
|
+
STRIP,
|
|
20
|
+
SGR_RE,
|
|
21
|
+
CHECKS,
|
|
22
|
+
VS,
|
|
23
|
+
BLANK_NON_CF,
|
|
24
|
+
LONG_RUN_RE,
|
|
25
|
+
LONG_RUN_THRESHOLD,
|
|
26
|
+
SCATTERED_THRESHOLD,
|
|
27
|
+
} from "./invisible.mjs";
|
|
28
|
+
|
|
29
|
+
// Layer 2/3 cheap pre-gates. Re-exported from the dependency-free `./gates.mjs`
|
|
30
|
+
// (not `./html.mjs`) so consumers can share the exact HTML-tag/markdown-link
|
|
31
|
+
// hints and secret-shape pre-gate without duplicating the regexes — and without
|
|
32
|
+
// pulling in the heavy remark/rehype graph that a re-export from `./html.mjs`
|
|
33
|
+
// would eagerly load on every root import.
|
|
34
|
+
export {
|
|
35
|
+
HTML_TAG_PRESENT,
|
|
36
|
+
MD_LINK_HINT,
|
|
37
|
+
SECRET_HINT,
|
|
38
|
+
SECRET_HINT_EXT,
|
|
39
|
+
matchesSecretHint,
|
|
40
|
+
} from "./gates.mjs";
|
|
41
|
+
|
|
42
|
+
// The two raw control introducers an ANSI sequence can start with: 7-bit
|
|
43
|
+
// ESC (U+001B) and the 8-bit C1 CSI (U+009B). Both are category Cc, so the
|
|
44
|
+
// invisible-char pass (which targets Cf / variation / blank fillers) never
|
|
45
|
+
// removes them; the residual sweep below is what guarantees neither survives.
|
|
46
|
+
// eslint-disable-next-line no-control-regex -- matching the raw introducers is the point
|
|
47
|
+
const CONTROL_INTRODUCER_RE = /[\u001b\u009b]/g;
|
|
48
|
+
|
|
49
|
+
// Full ANSI escape grammar (CSI/SGR/OSC-with-BEL), not just SGR: the Layer-1
|
|
50
|
+
// guarantee is that no control introducer survives, and a cursor-move or
|
|
51
|
+
// erase sequence is as much a display-spoofing hazard as a color one. The
|
|
52
|
+
// pattern is linear (every quantified run is bounded or non-overlapping), so it
|
|
53
|
+
// carries no catastrophic-backtracking risk on adversarial input.
|
|
54
|
+
// prettier-ignore
|
|
55
|
+
// eslint-disable-next-line no-control-regex -- matching ESC-led sequences is the point
|
|
56
|
+
const ANSI_RE = /[\u001b\u009b][[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-ntqry=><~]))/g;
|
|
57
|
+
|
|
58
|
+
// Unpaired UTF-16 surrogates (high not followed by low, or low not preceded by
|
|
59
|
+
// high). Normalized before the HTML parser, which throws on a stray byte —
|
|
60
|
+
// which would otherwise let a single malformed code unit suppress all output.
|
|
61
|
+
const LONE_SURROGATE_RE =
|
|
62
|
+
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Strip ANSI escape sequences to a fixed point. Removing one sequence can
|
|
66
|
+
* reconstitute another around it (a lone ESC left of `ESC[32m[0m` gains the
|
|
67
|
+
* trailing `[0m` once the inner sequence is removed, forming a brand-new valid
|
|
68
|
+
* sequence the single pass would miss), so iterate until stable: every changed
|
|
69
|
+
* pass consumes at least one ESC introducer, so the pass count is bounded by
|
|
70
|
+
* the input's ESC count, and ANSI-free text exits after one pass.
|
|
71
|
+
* @param {string} input
|
|
72
|
+
* @returns {string}
|
|
73
|
+
*/
|
|
74
|
+
function stripAnsiFully(input) {
|
|
75
|
+
let prev = input;
|
|
76
|
+
let out = prev.replace(ANSI_RE, "");
|
|
77
|
+
while (out !== prev) {
|
|
78
|
+
prev = out;
|
|
79
|
+
out = prev.replace(ANSI_RE, "");
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Layer 1: ANSI + invisible-char strip with a result guaranteed free of every
|
|
86
|
+
* raw ANSI control introducer (7-bit ESC U+001B and 8-bit C1 CSI U+009B).
|
|
87
|
+
*
|
|
88
|
+
* Removing an invisible character can reconstitute an escape its split hid from
|
|
89
|
+
* the ANSI pass (`ESC`<ZWSP>`[32m` → `ESC[32m`), so strip ANSI again after the
|
|
90
|
+
* invisible pass — but only when stripInvisible changed something, since
|
|
91
|
+
* reconstitution is impossible otherwise and the re-strip is a wasted pass on
|
|
92
|
+
* the hot clean path. The ANSI strip still cannot match an *incomplete*
|
|
93
|
+
* reconstituted sequence (a lone `ESC[` left when an inner complete sequence is
|
|
94
|
+
* removed from a nested split), so a final sweep removes every residual raw
|
|
95
|
+
* introducer outright — that sweep, not the regex matching, is the guarantee
|
|
96
|
+
* that no control introducer survives. `deAnsi` is the ANSI strip of the
|
|
97
|
+
* original (invisible runs intact), the scope a LONG_RUN payload check needs.
|
|
98
|
+
* @param {string} text
|
|
99
|
+
* @returns {{ cleaned: string, deAnsi: string, found: string[] }}
|
|
100
|
+
*/
|
|
101
|
+
function applyLayer1(text) {
|
|
102
|
+
const deAnsi = stripAnsiFully(text);
|
|
103
|
+
// stripInvisibleWithReport returns `found` for exactly the categories it
|
|
104
|
+
// removed — so a ZWNJ/ZWJ the carve-out PRESERVES never registers as a strip,
|
|
105
|
+
// and the leading-BOM exception is already handled inside it.
|
|
106
|
+
const { cleaned: afterInvis, found } = stripInvisibleWithReport(deAnsi);
|
|
107
|
+
let ansiFound = deAnsi.length !== text.length;
|
|
108
|
+
|
|
109
|
+
let cleaned = afterInvis;
|
|
110
|
+
if (afterInvis !== deAnsi) {
|
|
111
|
+
const reStripped = stripAnsiFully(afterInvis);
|
|
112
|
+
if (reStripped.length !== afterInvis.length) ansiFound = true;
|
|
113
|
+
cleaned = reStripped;
|
|
114
|
+
}
|
|
115
|
+
const swept = cleaned.replace(CONTROL_INTRODUCER_RE, "");
|
|
116
|
+
if (swept !== cleaned) {
|
|
117
|
+
cleaned = swept;
|
|
118
|
+
ansiFound = true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (ansiFound) found.push("ANSI escapes");
|
|
122
|
+
return { cleaned, deAnsi, found };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** @param {{ comments: number, hidden: number }} removed */
|
|
126
|
+
function describeRemoved(removed) {
|
|
127
|
+
const parts = [];
|
|
128
|
+
if (removed.comments > 0) parts.push(`${removed.comments} HTML comment(s)`);
|
|
129
|
+
if (removed.hidden > 0) parts.push(`${removed.hidden} hidden element(s)`);
|
|
130
|
+
return parts.join(", ");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** @param {{ tags: Record<string, number>, dataSrc: number }} warned */
|
|
134
|
+
function describeWarned(warned) {
|
|
135
|
+
const parts = Object.entries(warned.tags).map(
|
|
136
|
+
([tag, count]) => `${tag}×${count}`,
|
|
137
|
+
);
|
|
138
|
+
if (warned.dataSrc > 0) parts.push(`data: URI×${warned.dataSrc}`);
|
|
139
|
+
return parts.length > 0
|
|
140
|
+
? `Preserved but reported (page source kept inspectable): ${parts.join(", ")}`
|
|
141
|
+
: "";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Sanitize untrusted text before any LLM sees it.
|
|
146
|
+
*
|
|
147
|
+
* Always runs Layer 1 (invisible-char + ANSI stripping, lone-surrogate
|
|
148
|
+
* normalization). When `html` is true, also lazy-loads the HTML layer to splice
|
|
149
|
+
* out human-invisible HTML (comments, hidden elements — Layer 2) and detect
|
|
150
|
+
* data-exfil-shaped URLs (Layer 3); the heavy remark/rehype dependency is only
|
|
151
|
+
* imported on that path. The exfil scan runs on the pre-splice text so a beacon
|
|
152
|
+
* URL hidden inside a `display:none` element is still reported, not buried by
|
|
153
|
+
* its own removal.
|
|
154
|
+
*
|
|
155
|
+
* `found` names the categories neutralized; `warnings` carries the
|
|
156
|
+
* operator-facing notices. `cleaned` is always a string, never throws, and
|
|
157
|
+
* changes only carry a warning (no silent suppression).
|
|
158
|
+
* @param {string} text
|
|
159
|
+
* @param {{ html?: boolean }} [options]
|
|
160
|
+
* @returns {Promise<{ cleaned: string, found: string[], warnings: string[] }>}
|
|
161
|
+
*/
|
|
162
|
+
export async function sanitize(text, { html = false } = {}) {
|
|
163
|
+
/** @type {string[]} */ const found = [];
|
|
164
|
+
/** @type {string[]} */ const warnings = [];
|
|
165
|
+
|
|
166
|
+
const { cleaned: layer1, deAnsi, found: invisFound } = applyLayer1(text);
|
|
167
|
+
let cleaned = layer1;
|
|
168
|
+
if (invisFound.length > 0) {
|
|
169
|
+
found.push(...invisFound);
|
|
170
|
+
let msg = `Stripped: ${invisFound.join(", ")}`;
|
|
171
|
+
LONG_RUN_RE.lastIndex = 0;
|
|
172
|
+
if (LONG_RUN_RE.test(deAnsi))
|
|
173
|
+
msg += " [LONG RUN — possible injection payload]";
|
|
174
|
+
warnings.push(msg);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const wellFormed = cleaned.replace(LONE_SURROGATE_RE, "\uFFFD");
|
|
178
|
+
if (wellFormed !== cleaned) {
|
|
179
|
+
cleaned = wellFormed;
|
|
180
|
+
found.push("Lone UTF-16 surrogates");
|
|
181
|
+
warnings.push("Normalized lone UTF-16 surrogates");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!html) return { cleaned, found, warnings };
|
|
185
|
+
|
|
186
|
+
const { sanitizeHtml, detectExfil } = await import("./html.mjs");
|
|
187
|
+
// Scan for exfil URLs on the text BEFORE Layer 2 splices anything out — a
|
|
188
|
+
// beacon URL hidden in a comment or hidden element is more suspicious, not
|
|
189
|
+
// less, yet Layer 2 would otherwise remove it from view before the scan.
|
|
190
|
+
const preSplice = cleaned;
|
|
191
|
+
|
|
192
|
+
const layer2 = sanitizeHtml(cleaned);
|
|
193
|
+
if (layer2) {
|
|
194
|
+
if (layer2.text !== cleaned) {
|
|
195
|
+
cleaned = layer2.text;
|
|
196
|
+
if (layer2.removed.comments > 0) found.push("HTML comments");
|
|
197
|
+
if (layer2.removed.hidden > 0) found.push("hidden HTML");
|
|
198
|
+
warnings.push(
|
|
199
|
+
`HTML sanitized: ${describeRemoved(layer2.removed)} replaced with placeholders`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
const preserved = describeWarned(layer2.warned);
|
|
203
|
+
if (preserved) warnings.push(preserved);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const threats = detectExfil(preSplice);
|
|
207
|
+
if (threats) {
|
|
208
|
+
found.push("exfil URLs");
|
|
209
|
+
const reasons = [
|
|
210
|
+
...new Set(
|
|
211
|
+
threats.map(
|
|
212
|
+
(threat) =>
|
|
213
|
+
`${threat.isImage ? "image" : "link"} to ${threat.target}: ${threat.reason}`,
|
|
214
|
+
),
|
|
215
|
+
),
|
|
216
|
+
];
|
|
217
|
+
warnings.push(`Exfil-shaped URLs detected: ${reasons.join("; ")}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return { cleaned, found, warnings };
|
|
221
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, zero-dependency invisible-character + ANSI/SGR primitives.
|
|
3
|
+
*
|
|
4
|
+
* Removes payload-capable Unicode (general-category Cf format chars, variation
|
|
5
|
+
* selectors, blank-rendering fillers, soft hyphens, interior BOMs) while
|
|
6
|
+
* preserving ZWNJ/ZWJ in genuine linguistic and emoji contexts, and reports
|
|
7
|
+
* which categories were removed.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const VS = [
|
|
11
|
+
...Array.from({ length: 16 }, (_, i) => 0xfe00 + i),
|
|
12
|
+
...Array.from({ length: 240 }, (_, i) => 0xe0100 + i),
|
|
13
|
+
]
|
|
14
|
+
.map((codePoint) => String.fromCodePoint(codePoint))
|
|
15
|
+
.join("");
|
|
16
|
+
|
|
17
|
+
// Code points that render blank / zero-width but are NOT general category Cf,
|
|
18
|
+
// so the \p{Cf} check below misses them: the Hangul fillers (category Lo,
|
|
19
|
+
// U+115F/U+1160/U+3164/U+FFA0) and the Braille blank pattern (category So,
|
|
20
|
+
// U+2800). A run of these carries a hidden payload exactly as zero-widths do.
|
|
21
|
+
export const BLANK_NON_CF = "\u115F\u1160\u3164\uFFA0\u2800";
|
|
22
|
+
|
|
23
|
+
const REGEX_FLAGS = "gu";
|
|
24
|
+
|
|
25
|
+
/** @type {Array<[string, RegExp]>} */
|
|
26
|
+
export const CHECKS = [
|
|
27
|
+
["Format chars (Cf)", new RegExp(`\\p{Cf}`, REGEX_FLAGS)],
|
|
28
|
+
["Variation selectors", new RegExp(`[${VS}]`, REGEX_FLAGS)],
|
|
29
|
+
["Blank-rendering fillers", new RegExp(`[${BLANK_NON_CF}]`, REGEX_FLAGS)],
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export const STRIP = new RegExp(
|
|
33
|
+
CHECKS.map(([, regex]) => regex.source).join("|"),
|
|
34
|
+
REGEX_FLAGS,
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
// SGR (Select Graphic Rendition): ESC [ <digits/semicolons> m — colors, bold,
|
|
38
|
+
// reset. The grammar is closed: params are [0-9;]* and the final byte is `m`,
|
|
39
|
+
// so a match can only restyle text, never reposition the cursor, erase, or
|
|
40
|
+
// smuggle an OSC string. Text is "SGR-only" when removing these leaves no ESC
|
|
41
|
+
// byte at all — a lone or partial escape therefore is not SGR-only.
|
|
42
|
+
// eslint-disable-next-line no-control-regex -- matching ESC-led sequences is the point
|
|
43
|
+
export const SGR_RE = /\x1b\[[0-9;]*m/g;
|
|
44
|
+
|
|
45
|
+
// eslint-disable-next-line no-control-regex -- ESC (U+001B) is exactly what we test for
|
|
46
|
+
const ESC_RE = /\x1b/;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* True when every ESC byte in `text` belongs to a display-only SGR color
|
|
50
|
+
* sequence (so stripping the ANSI removed only cosmetic styling, nothing that
|
|
51
|
+
* could move the cursor, erase, or carry a payload).
|
|
52
|
+
* @param {string} text
|
|
53
|
+
* @returns {boolean}
|
|
54
|
+
*/
|
|
55
|
+
export function isSgrOnly(text) {
|
|
56
|
+
return !ESC_RE.test(text.replace(SGR_RE, ""));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const LONG_RUN_THRESHOLD = 10;
|
|
60
|
+
|
|
61
|
+
/** Total invisible-char count above which a file/prompt is treated as
|
|
62
|
+
* payload-capable even without a long run (threshold-evasion catch). */
|
|
63
|
+
export const SCATTERED_THRESHOLD = 30;
|
|
64
|
+
|
|
65
|
+
export const LONG_RUN_RE = new RegExp(
|
|
66
|
+
`(?:${STRIP.source}){${LONG_RUN_THRESHOLD},}`,
|
|
67
|
+
REGEX_FLAGS,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// Leading-BOM marker, preserved by stripInvisibleWithReport (see its doc).
|
|
71
|
+
const BOM = "\uFEFF";
|
|
72
|
+
// ─── ZWNJ/ZWJ linguistic carve-out ───────────────────────────────────────────
|
|
73
|
+
// ZWNJ (U+200C) and ZWJ (U+200D) are general category Cf, so the STRIP pass
|
|
74
|
+
// would treat them as hidden-payload bytes. But they are MANDATORY for correct
|
|
75
|
+
// rendering between letters of several scripts (Arabic/Persian and many Indic
|
|
76
|
+
// scripts) and inside emoji ZWJ sequences — blanket stripping corrupts
|
|
77
|
+
// legitimate non-English output. Preserve them ONLY in an unambiguous
|
|
78
|
+
// linguistic context (immediately between two letters of such a script, or
|
|
79
|
+
// between two members of an emoji ZWJ sequence) and strip them as a payload
|
|
80
|
+
// everywhere else: a long run, scattered past SCATTERED_THRESHOLD, a
|
|
81
|
+
// leading/trailing position, or between Latin/ASCII/secret-shaped characters.
|
|
82
|
+
// Over-strip beats under-strip — the carve-out fires only when BOTH neighbors
|
|
83
|
+
// clearly belong to the context.
|
|
84
|
+
const ZWNJ = 0x200c;
|
|
85
|
+
const ZWJ = 0x200d;
|
|
86
|
+
|
|
87
|
+
// Scripts whose orthography uses ZWNJ/ZWJ between letters as a rendering
|
|
88
|
+
// control. Single source of truth: LINGUISTIC_LETTER is built from this list,
|
|
89
|
+
// and the test suite drives one preserve-case per entry, so adding a script
|
|
90
|
+
// here without a matching test fails.
|
|
91
|
+
export const LINGUISTIC_SCRIPTS = [
|
|
92
|
+
"Arabic",
|
|
93
|
+
"Devanagari",
|
|
94
|
+
"Bengali",
|
|
95
|
+
"Gurmukhi",
|
|
96
|
+
"Gujarati",
|
|
97
|
+
"Oriya",
|
|
98
|
+
"Tamil",
|
|
99
|
+
"Telugu",
|
|
100
|
+
"Kannada",
|
|
101
|
+
"Malayalam",
|
|
102
|
+
"Sinhala",
|
|
103
|
+
];
|
|
104
|
+
const LINGUISTIC_LETTER = new RegExp(
|
|
105
|
+
`[${LINGUISTIC_SCRIPTS.map((script) => `\\p{Script=${script}}`).join("")}]`,
|
|
106
|
+
"u",
|
|
107
|
+
);
|
|
108
|
+
// Left side of an emoji joiner: a pictograph or a skin-tone modifier (a base
|
|
109
|
+
// emoji may carry a modifier before the joiner, e.g. a health-worker sequence).
|
|
110
|
+
const EMOJI_LEFT = /[\p{Extended_Pictographic}\p{Emoji_Modifier}]/u;
|
|
111
|
+
// Right side of an emoji joiner is always the next component's base pictograph.
|
|
112
|
+
const EMOJI_BASE = /\p{Extended_Pictographic}/u;
|
|
113
|
+
|
|
114
|
+
// Non-global single-char classifiers (CHECKS carry `g`, whose lastIndex is
|
|
115
|
+
// stateful across `.test`). carveStrip uses these to attribute each removed
|
|
116
|
+
// char to its CHECKS category so `found` names exactly what was stripped.
|
|
117
|
+
const CHECK_ONE = CHECKS.map(
|
|
118
|
+
([label, re]) =>
|
|
119
|
+
/** @type {[string, RegExp]} */ ([label, new RegExp(re.source, "u")]),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The CHECKS category label a single code point belongs to, or null when it is
|
|
124
|
+
* not payload-capable (an ordinary visible character).
|
|
125
|
+
* @param {string} ch one code point
|
|
126
|
+
* @returns {string | null}
|
|
127
|
+
*/
|
|
128
|
+
function classify(ch) {
|
|
129
|
+
for (const [label, re] of CHECK_ONE) if (re.test(ch)) return label;
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* True when `ch` (a ZWNJ/ZWJ) sits in an unambiguous linguistic context and so
|
|
135
|
+
* must be preserved rather than stripped. `prev`/`next` are the adjacent code
|
|
136
|
+
* points (single-code-point strings), or "" at a string boundary.
|
|
137
|
+
* @param {string} ch
|
|
138
|
+
* @param {string} prev
|
|
139
|
+
* @param {string} next
|
|
140
|
+
* @returns {boolean}
|
|
141
|
+
*/
|
|
142
|
+
function isPreservedJoiner(ch, prev, next) {
|
|
143
|
+
const cp = ch.codePointAt(0);
|
|
144
|
+
if (cp !== ZWNJ && cp !== ZWJ) return false;
|
|
145
|
+
// prev/next are "" at a string boundary (see carveStrip), so a leading or
|
|
146
|
+
// trailing joiner matches neither script nor emoji class and falls through.
|
|
147
|
+
if (LINGUISTIC_LETTER.test(prev) && LINGUISTIC_LETTER.test(next)) return true;
|
|
148
|
+
// Emoji ZWJ sequences use ZWJ only, never ZWNJ.
|
|
149
|
+
if (cp === ZWJ && EMOJI_LEFT.test(prev) && EMOJI_BASE.test(next)) return true;
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Bulk strip (the common path: no ZWNJ/ZWJ present, so no carve-out can apply).
|
|
155
|
+
* A single regex pass removes every payload-capable char; `found` names the
|
|
156
|
+
* categories present via `.search` (which ignores the `g` lastIndex).
|
|
157
|
+
* @param {string} body
|
|
158
|
+
* @returns {{ cleaned: string, found: string[] }}
|
|
159
|
+
*/
|
|
160
|
+
function bulkStrip(body) {
|
|
161
|
+
const found = CHECKS.filter(([, re]) => body.search(re) !== -1).map(
|
|
162
|
+
([label]) => label,
|
|
163
|
+
);
|
|
164
|
+
return { cleaned: body.replace(STRIP, ""), found };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Carve-out strip (a ZWNJ/ZWJ is present): walk code points, preserving a join
|
|
169
|
+
* control only where isPreservedJoiner holds AND the text stays under the
|
|
170
|
+
* scatter floor — otherwise it is stripped like any other payload byte. `found`
|
|
171
|
+
* reports only categories actually removed, so a preserved joiner never makes
|
|
172
|
+
* the caller claim a strip that did not happen.
|
|
173
|
+
* @param {string} body
|
|
174
|
+
* @returns {{ cleaned: string, found: string[] }}
|
|
175
|
+
*/
|
|
176
|
+
function carveStrip(body) {
|
|
177
|
+
// SCATTERED_THRESHOLD is the floor: past it, treat every invisible as payload
|
|
178
|
+
// regardless of context (threshold-evasion catch — over-strip beats under).
|
|
179
|
+
// Materialise codepoints once; count invisibles in a first pass so we know
|
|
180
|
+
// whether the carve-out applies before building the output string.
|
|
181
|
+
const cps = Array.from(body);
|
|
182
|
+
let invisCount = 0;
|
|
183
|
+
for (const ch of cps) if (classify(ch) !== null) invisCount++;
|
|
184
|
+
const allowCarveOut = invisCount < SCATTERED_THRESHOLD;
|
|
185
|
+
const foundLabels = new Set();
|
|
186
|
+
let out = "";
|
|
187
|
+
for (let i = 0; i < cps.length; i++) {
|
|
188
|
+
const ch = cps[i];
|
|
189
|
+
const label = classify(ch);
|
|
190
|
+
if (label === null) {
|
|
191
|
+
out += ch; // ordinary visible character
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (
|
|
195
|
+
allowCarveOut &&
|
|
196
|
+
isPreservedJoiner(ch, cps[i - 1] ?? "", cps[i + 1] ?? "")
|
|
197
|
+
) {
|
|
198
|
+
out += ch;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
foundLabels.add(label);
|
|
202
|
+
}
|
|
203
|
+
const found = CHECKS.filter(([label]) => foundLabels.has(label)).map(
|
|
204
|
+
([label]) => label,
|
|
205
|
+
);
|
|
206
|
+
return { cleaned: out, found };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* True when `body` holds at least one ZWNJ/ZWJ (so the carve-out may apply).
|
|
211
|
+
* @param {string} body
|
|
212
|
+
* @returns {boolean}
|
|
213
|
+
*/
|
|
214
|
+
function hasJoinControl(body) {
|
|
215
|
+
return (
|
|
216
|
+
body.includes(String.fromCodePoint(ZWNJ)) ||
|
|
217
|
+
body.includes(String.fromCodePoint(ZWJ))
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Strip payload-capable invisible chars and report which categories were
|
|
223
|
+
* removed. A single leading U+FEFF (BOM) is preserved as a legitimate marker;
|
|
224
|
+
* interior BOMs and all soft hyphens (U+00AD) are stripped, since either can
|
|
225
|
+
* encode hidden instructions. ZWNJ/ZWJ survive only in a linguistic context
|
|
226
|
+
* (see the carve-out above). `found` names exactly the categories stripped, so
|
|
227
|
+
* a caller never warns about a strip the carve-out skipped.
|
|
228
|
+
* @param {string} text
|
|
229
|
+
* @returns {{ cleaned: string, found: string[] }}
|
|
230
|
+
*/
|
|
231
|
+
export function stripInvisibleWithReport(text) {
|
|
232
|
+
const hasLeadingBom = text.charCodeAt(0) === 0xfeff;
|
|
233
|
+
const body = hasLeadingBom ? text.slice(1) : text;
|
|
234
|
+
const { cleaned, found } = hasJoinControl(body)
|
|
235
|
+
? carveStrip(body)
|
|
236
|
+
: bulkStrip(body);
|
|
237
|
+
return { cleaned: hasLeadingBom ? BOM + cleaned : cleaned, found };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Strip payload-capable invisible chars (cleaned text only). See
|
|
242
|
+
* stripInvisibleWithReport for the BOM and ZWNJ/ZWJ carve-out semantics.
|
|
243
|
+
* @param {string} text
|
|
244
|
+
* @returns {string}
|
|
245
|
+
*/
|
|
246
|
+
export function stripInvisible(text) {
|
|
247
|
+
return stripInvisibleWithReport(text).cleaned;
|
|
248
|
+
}
|