ghostrules 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,78 @@
1
+ """The single rule registry. Rules register themselves with the ``@rule`` decorator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Iterable
6
+ from dataclasses import dataclass
7
+
8
+ from ghostrules.models import Finding, Kind, ScanTarget, Severity
9
+
10
+ CheckFn = Callable[[ScanTarget], Iterable[Finding]]
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Rule:
15
+ """A detection rule: metadata plus a pure ``check`` function.
16
+
17
+ ``severity`` is the headline severity (as in RULES.md) used for listings and SARIF; a rule may
18
+ report other levels depending on context.
19
+ ``scans_decoded`` marks rules that must also run over hidden text decoded by other rules
20
+ (GR010-GR016); the scanner then bumps the severity of what they find there.
21
+ """
22
+
23
+ id: str
24
+ severity: Severity
25
+ title: str
26
+ description: str
27
+ applies_to: frozenset[Kind]
28
+ check: CheckFn
29
+ scans_decoded: bool = False
30
+
31
+
32
+ _REGISTRY: dict[str, Rule] = {}
33
+
34
+
35
+ def register(new_rule: Rule) -> Rule:
36
+ """Add a rule to the registry; duplicate ids are a programming error."""
37
+ if new_rule.id in _REGISTRY:
38
+ raise ValueError(f"duplicate rule id {new_rule.id}")
39
+ _REGISTRY[new_rule.id] = new_rule
40
+ return new_rule
41
+
42
+
43
+ def rule(
44
+ rule_id: str,
45
+ severity: Severity,
46
+ title: str,
47
+ description: str,
48
+ applies_to: Iterable[Kind],
49
+ *,
50
+ scans_decoded: bool = False,
51
+ ) -> Callable[[CheckFn], CheckFn]:
52
+ """Decorator registering a check function as a rule."""
53
+
54
+ def decorator(fn: CheckFn) -> CheckFn:
55
+ register(
56
+ Rule(
57
+ id=rule_id,
58
+ severity=severity,
59
+ title=title,
60
+ description=description,
61
+ applies_to=frozenset(applies_to),
62
+ check=fn,
63
+ scans_decoded=scans_decoded,
64
+ )
65
+ )
66
+ return fn
67
+
68
+ return decorator
69
+
70
+
71
+ def all_rules() -> list[Rule]:
72
+ """All registered rules, sorted by id."""
73
+ return sorted(_REGISTRY.values(), key=lambda r: r.id)
74
+
75
+
76
+ def get_rule(rule_id: str) -> Rule | None:
77
+ """Look up one rule by id."""
78
+ return _REGISTRY.get(rule_id)
@@ -0,0 +1,503 @@
1
+ """Hidden-content rules GR001-GR006: invisible Unicode, hidden HTML comments, homoglyphs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import unicodedata
7
+ from collections import Counter
8
+ from collections.abc import Iterator
9
+ from typing import NamedTuple
10
+
11
+ from ghostrules.models import Finding, Kind, ScanTarget, Severity
12
+ from ghostrules.rules.helpers import build_finding, clean_hint, group_by_line
13
+ from ghostrules.rules.patterns import has_agent_directed_language, match_injection
14
+ from ghostrules.rules.registry import rule
15
+ from ghostrules.unicode_utils import (
16
+ BIDI,
17
+ TAG,
18
+ VARIATION_SELECTOR,
19
+ ZERO_WIDTH,
20
+ char_name,
21
+ decode_tags,
22
+ decode_variation_selectors,
23
+ find_runs,
24
+ )
25
+
26
+ ALL_KINDS: tuple[Kind, ...] = ("instructions", "config", "other")
27
+
28
+ # --- GR001 ---------------------------------------------------------------------------------
29
+
30
+ _BLACK_FLAG = "\U0001f3f4"
31
+ _CANCEL_TAG = "\U000e007f"
32
+ _SUBDIVISION_CODE = re.compile(r"[a-z]{2}[a-z0-9]{1,3}") # gbeng, gbsct, usca...
33
+
34
+
35
+ def _is_flag_sequence(text: str, start: int, end: int) -> bool:
36
+ """A genuine emoji flag: 🏴 + tag letters spelling a subdivision code + cancel tag."""
37
+ if start == 0 or text[start - 1] != _BLACK_FLAG or text[end - 1] != _CANCEL_TAG:
38
+ return False
39
+ body = text[start : end - 1]
40
+ code = decode_tags(body)
41
+ return len(code) == len(body) and _SUBDIVISION_CODE.fullmatch(code) is not None
42
+
43
+
44
+ @rule(
45
+ "GR001",
46
+ Severity.CRITICAL,
47
+ "Unicode tag characters (ASCII smuggling)",
48
+ "Characters in U+E0000-U+E007F render as nothing but spell ASCII text that models read.",
49
+ ALL_KINDS,
50
+ )
51
+ def gr001_tag_characters(target: ScanTarget) -> Iterator[Finding]:
52
+ """One finding per contiguous run of tag characters, with the smuggled text decoded."""
53
+ for run in find_runs(target.text, TAG):
54
+ if _is_flag_sequence(target.text, run.start, run.end):
55
+ continue
56
+ decoded = decode_tags(target.text[run.start : run.end])
57
+ count = run.end - run.start
58
+ yield build_finding(
59
+ target=target,
60
+ rule_id="GR001",
61
+ severity=Severity.CRITICAL,
62
+ title="Unicode tag characters (ASCII smuggling)",
63
+ message=(
64
+ f"{count} invisible Unicode tag character(s). Humans see nothing; AI models "
65
+ "read them as ASCII text."
66
+ ),
67
+ start=run.start,
68
+ end=run.end,
69
+ decoded=decoded,
70
+ fix_hint=clean_hint(target),
71
+ limit=4,
72
+ )
73
+
74
+
75
+ # --- GR002 ---------------------------------------------------------------------------------
76
+
77
+ _RTL_RANGES = (
78
+ (0x0590, 0x05FF), # Hebrew
79
+ (0x0600, 0x06FF), # Arabic
80
+ (0x0700, 0x074F), # Syriac
81
+ (0x0750, 0x077F), # Arabic Supplement
82
+ (0x0780, 0x07BF), # Thaana
83
+ (0x08A0, 0x08FF), # Arabic Extended-A
84
+ (0xFB1D, 0xFDFF), # Hebrew/Arabic presentation forms
85
+ (0xFE70, 0xFEFF),
86
+ )
87
+ _RTL_MIN_LETTERS = 10
88
+ _RTL_MIN_RATIO = 0.3
89
+
90
+
91
+ def has_substantial_rtl(text: str) -> bool:
92
+ """True if at least 30% of the letters (and at least 10) are Hebrew/Arabic-family."""
93
+ letters = rtl = 0
94
+ for ch in text:
95
+ if ch.isalpha():
96
+ letters += 1
97
+ cp = ord(ch)
98
+ if any(lo <= cp <= hi for lo, hi in _RTL_RANGES):
99
+ rtl += 1
100
+ return rtl >= _RTL_MIN_LETTERS and rtl / letters >= _RTL_MIN_RATIO
101
+
102
+
103
+ def _char_offsets(text: str, kind: str) -> list[int]:
104
+ return [i for run in find_runs(text, kind) for i in range(run.start, run.end)]
105
+
106
+
107
+ def _describe_chars(chars: list[str]) -> str:
108
+ counts = Counter(f"U+{ord(c):04X} {char_name(c)}" for c in chars)
109
+ return ", ".join(f"{name} ×{n}" if n > 1 else name for name, n in counts.items())
110
+
111
+
112
+ @rule(
113
+ "GR002",
114
+ Severity.HIGH,
115
+ "Bidirectional control characters (Trojan Source)",
116
+ "Bidi controls reorder how text is displayed, so what you read differs from what is parsed.",
117
+ ALL_KINDS,
118
+ )
119
+ def gr002_bidi_controls(target: ScanTarget) -> Iterator[Finding]:
120
+ """One finding per line containing bidi controls; medium in genuinely RTL files."""
121
+ offsets = _char_offsets(target.text, BIDI)
122
+ if not offsets:
123
+ return
124
+ severity = Severity.MEDIUM if has_substantial_rtl(target.text) else Severity.HIGH
125
+ for positions in group_by_line(target, offsets).values():
126
+ chars = [target.text[i] for i in positions]
127
+ note = (
128
+ " The file contains substantial RTL text, so this may be legitimate."
129
+ if (severity is Severity.MEDIUM)
130
+ else ""
131
+ )
132
+ yield build_finding(
133
+ target=target,
134
+ rule_id="GR002",
135
+ severity=severity,
136
+ title="Bidirectional control characters (Trojan Source)",
137
+ message=(
138
+ f"{len(chars)} bidirectional control character(s): {_describe_chars(chars)}.{note}"
139
+ ),
140
+ start=positions[0],
141
+ end=positions[-1] + 1,
142
+ fix_hint=clean_hint(target),
143
+ limit=60,
144
+ with_name=True,
145
+ )
146
+
147
+
148
+ # --- GR003 ---------------------------------------------------------------------------------
149
+
150
+ _EMOJI_RANGES = (
151
+ (0x1F000, 0x1FAFF),
152
+ (0x2190, 0x21FF),
153
+ (0x2300, 0x23FF),
154
+ (0x2600, 0x27BF),
155
+ (0x2B00, 0x2BFF),
156
+ (0xFE0F, 0xFE0F),
157
+ )
158
+ _JOINER_SCRIPT_RANGES = ( # Persian/Arabic and the Indic scripts, where ZWNJ/ZWJ are real letters
159
+ (0x0600, 0x06FF),
160
+ (0x0750, 0x077F),
161
+ (0xFB50, 0xFDFF),
162
+ (0xFE70, 0xFEFF),
163
+ (0x0900, 0x0DFF),
164
+ )
165
+ _ZWJ, _ZWNJ = "\u200d", "\u200c"
166
+ _ZW_HIGH_PER_LINE = 5
167
+ _ZW_HIGH_PER_FILE = 20
168
+
169
+
170
+ def _in_ranges(ch: str, ranges: tuple[tuple[int, int], ...]) -> bool:
171
+ cp = ord(ch)
172
+ return any(lo <= cp <= hi for lo, hi in ranges)
173
+
174
+
175
+ def _is_legit_joiner(text: str, start: int, end: int) -> bool:
176
+ """A lone ZWJ/ZWNJ that has a real job: emoji sequences, Persian and Indic scripts.
177
+
178
+ Only isolated joiners qualify (a run of several is never typography), which keeps a payload
179
+ from hiding behind a neighbouring letter.
180
+ """
181
+ if end - start != 1:
182
+ return False
183
+ ch = text[start]
184
+ prev = text[start - 1] if start > 0 else ""
185
+ nxt = text[end] if end < len(text) else ""
186
+ if (
187
+ ch == _ZWJ
188
+ and prev
189
+ and nxt
190
+ and _in_ranges(prev, _EMOJI_RANGES)
191
+ and _in_ranges(nxt, _EMOJI_RANGES)
192
+ ):
193
+ return True
194
+ if ch in (_ZWJ, _ZWNJ):
195
+ return bool(
196
+ (prev and _in_ranges(prev, _JOINER_SCRIPT_RANGES))
197
+ or (nxt and _in_ranges(nxt, _JOINER_SCRIPT_RANGES))
198
+ )
199
+ return False
200
+
201
+
202
+ def zero_width_offsets(text: str) -> list[int]:
203
+ """Offsets of zero-width characters GR003 flags (legitimate joiners and the BOM excluded)."""
204
+ offsets: list[int] = []
205
+ for run in find_runs(text, ZERO_WIDTH):
206
+ for i in range(run.start, run.end):
207
+ if i == 0 and text[0] == "\ufeff":
208
+ continue # byte-order mark at the start of the file
209
+ if _is_legit_joiner(text, run.start, run.end):
210
+ continue
211
+ offsets.append(i)
212
+ return offsets
213
+
214
+
215
+ @rule(
216
+ "GR003",
217
+ Severity.MEDIUM,
218
+ "Zero-width / invisible formatting characters",
219
+ "Zero-width characters are invisible and can encode hidden data or split keywords.",
220
+ ALL_KINDS,
221
+ )
222
+ def gr003_zero_width(target: ScanTarget) -> Iterator[Finding]:
223
+ """One finding per line; high when a line has >= 5 or the file has >= 20 such characters."""
224
+ text = target.text
225
+ offsets = zero_width_offsets(text)
226
+ if not offsets:
227
+ return
228
+ by_line = group_by_line(target, offsets)
229
+ for positions in by_line.values():
230
+ high = len(positions) >= _ZW_HIGH_PER_LINE or len(offsets) >= _ZW_HIGH_PER_FILE
231
+ chars = [text[i] for i in positions]
232
+ yield build_finding(
233
+ target=target,
234
+ rule_id="GR003",
235
+ severity=Severity.HIGH if high else Severity.MEDIUM,
236
+ title="Zero-width / invisible formatting characters",
237
+ message=(
238
+ f"{len(chars)} invisible character(s) on this line ({len(offsets)} in the file): "
239
+ f"{_describe_chars(chars)}."
240
+ + (" Many of them: likely an encoded payload." if high else "")
241
+ ),
242
+ start=positions[0],
243
+ end=positions[-1] + 1,
244
+ fix_hint=clean_hint(target),
245
+ limit=60,
246
+ with_name=True,
247
+ )
248
+
249
+
250
+ # --- GR004 ---------------------------------------------------------------------------------
251
+
252
+ _VS_MIN_RUN = 3
253
+
254
+
255
+ @rule(
256
+ "GR004",
257
+ Severity.HIGH,
258
+ "Variation-selector steganography",
259
+ "Runs of variation selectors can hide arbitrary bytes behind a single visible character.",
260
+ ALL_KINDS,
261
+ )
262
+ def gr004_variation_selectors(target: ScanTarget) -> Iterator[Finding]:
263
+ """One finding per run of >= 3 variation selectors.
264
+
265
+ Legitimate use is a single selector after one base character (emoji presentation, CJK
266
+ variants), so no base-character exemption is needed above the threshold.
267
+ """
268
+ for run in find_runs(target.text, VARIATION_SELECTOR):
269
+ count = run.end - run.start
270
+ if count < _VS_MIN_RUN:
271
+ continue
272
+ decoded = decode_variation_selectors(target.text[run.start : run.end])
273
+ yield build_finding(
274
+ target=target,
275
+ rule_id="GR004",
276
+ severity=Severity.HIGH,
277
+ title="Variation-selector steganography",
278
+ message=(
279
+ f"Run of {count} variation selectors: likely bytes hidden after a visible "
280
+ "character."
281
+ ),
282
+ start=run.start,
283
+ end=run.end,
284
+ decoded=decoded,
285
+ fix_hint=clean_hint(target),
286
+ )
287
+
288
+
289
+ # --- GR005 ---------------------------------------------------------------------------------
290
+
291
+ _HTML_COMMENT_RE = re.compile(r"<!--(.*?)(?:-->|\Z)", re.DOTALL)
292
+ _COMMENT_MIN_CHARS = 40
293
+ _MARKDOWN_SUFFIXES = (".md", ".mdc", ".markdown", ".mdx")
294
+
295
+
296
+ @rule(
297
+ "GR005",
298
+ Severity.MEDIUM,
299
+ "Hidden HTML comment with instructions",
300
+ "HTML comments are invisible when Markdown is rendered but are read by AI agents.",
301
+ ("instructions", "other"),
302
+ )
303
+ def gr005_html_comments(target: ScanTarget) -> Iterator[Finding]:
304
+ """Long HTML comments that look like instructions (high if they match GR010-GR016).
305
+
306
+ Comments inside fenced code blocks or inline code are visible when rendered, so they are
307
+ ignored. An unterminated ``<!--`` hides the rest of the document and is treated as a comment
308
+ to the end.
309
+ """
310
+ if target.kind == "other" and not target.path.lower().endswith(_MARKDOWN_SUFFIXES):
311
+ return
312
+ for match in _HTML_COMMENT_RE.finditer(target.text):
313
+ body = match.group(1).strip()
314
+ if len(body) < _COMMENT_MIN_CHARS or target.code_block_at(match.start()) is not None:
315
+ continue
316
+ if target.in_inline_code(match.start()):
317
+ continue # shown literally when rendered, e.g. documentation about `<!-- -->`
318
+ matched = match_injection(body)
319
+ if matched:
320
+ severity = Severity.HIGH
321
+ reason = f"matches {', '.join(matched)} patterns"
322
+ elif has_agent_directed_language(body):
323
+ severity = Severity.MEDIUM
324
+ reason = "addresses the AI agent"
325
+ else:
326
+ continue
327
+ unterminated = not match.group().endswith("-->")
328
+ yield build_finding(
329
+ target=target,
330
+ rule_id="GR005",
331
+ severity=severity,
332
+ title="Hidden HTML comment with instructions",
333
+ message=(
334
+ f"HTML comment ({len(body)} chars) is invisible when rendered but {reason}."
335
+ + (
336
+ " The comment is never closed, so it hides the rest of the file."
337
+ if unterminated
338
+ else ""
339
+ )
340
+ ),
341
+ start=match.start(),
342
+ end=match.end(),
343
+ fix_hint="Make the text visible or delete the comment.",
344
+ limit=100,
345
+ )
346
+
347
+
348
+ # --- GR006 ---------------------------------------------------------------------------------
349
+
350
+ # Non-Latin letters that are visually near-identical to a Latin letter.
351
+ _CONFUSABLE_CODEPOINTS: dict[int, str] = {
352
+ # Cyrillic lowercase
353
+ 0x0430: "a", 0x0441: "c", 0x0435: "e", 0x043E: "o", 0x0440: "p", 0x0445: "x",
354
+ 0x0443: "y", 0x0456: "i", 0x0458: "j", 0x0455: "s", 0x0501: "d", 0x051B: "q", 0x051D: "w",
355
+ # Cyrillic uppercase
356
+ 0x0410: "A", 0x0412: "B", 0x0421: "C", 0x0415: "E", 0x041D: "H", 0x0406: "I", 0x0408: "J",
357
+ 0x041A: "K", 0x041C: "M", 0x041E: "O", 0x0420: "P", 0x0405: "S", 0x0422: "T", 0x0425: "X",
358
+ 0x0423: "Y",
359
+ # Greek lowercase
360
+ 0x03BF: "o", 0x03BD: "v", 0x03C1: "p", 0x03B9: "i", 0x03BA: "k",
361
+ # Greek uppercase
362
+ 0x0391: "A", 0x0392: "B", 0x0395: "E", 0x0396: "Z", 0x0397: "H", 0x0399: "I", 0x039A: "K",
363
+ 0x039C: "M", 0x039D: "N", 0x039F: "O", 0x03A1: "P", 0x03A4: "T", 0x03A5: "Y", 0x03A7: "X",
364
+ } # fmt: skip
365
+ _CONFUSABLES: dict[str, str] = {chr(cp): latin for cp, latin in _CONFUSABLE_CODEPOINTS.items()}
366
+ _WORD_RE = re.compile(r"\w+")
367
+ _URL_RE = re.compile(r"https?://[^\s<>\"'`)\]]+", re.IGNORECASE)
368
+ _HOST_RE = re.compile(r"https?://(?:[^/?#\s@]*@)?([^/?#\s:]+)", re.IGNORECASE)
369
+
370
+
371
+ def _is_latin(ch: str) -> bool:
372
+ return ch.isascii() and ch.isalpha() or "LATIN" in unicodedata.name(ch, "")
373
+
374
+
375
+ def _mixed_lookalikes(word: str) -> list[str]:
376
+ """Lookalike characters in a word that also contains Latin letters (else empty)."""
377
+ lookalikes = [ch for ch in word if ch in _CONFUSABLES]
378
+ if lookalikes and any(_is_latin(ch) for ch in word):
379
+ return lookalikes
380
+ return []
381
+
382
+
383
+ def _mixed_host_label(host: str) -> str | None:
384
+ """A host label mixing Latin with Cyrillic or Greek letters (any, not just lookalikes)."""
385
+ for label in host.split("."):
386
+ names = [unicodedata.name(ch, "") for ch in label if ch.isalpha()]
387
+ has_latin = any(n.startswith("LATIN") for n in names)
388
+ if has_latin and any(n.startswith(("CYRILLIC", "GREEK")) for n in names):
389
+ return label
390
+ return None
391
+
392
+
393
+ def _lookalike_message(word: str, chars: list[str]) -> str:
394
+ detail = ", ".join(
395
+ f"U+{ord(c):04X} {char_name(c)} (looks like {_CONFUSABLES[c]!r})"
396
+ for c in dict.fromkeys(chars)
397
+ )
398
+ return f"'{word}' mixes Latin with lookalike letters: {detail}."
399
+
400
+
401
+ @rule(
402
+ "GR006",
403
+ Severity.LOW,
404
+ "Mixed-script homoglyph word",
405
+ "Latin words with Cyrillic/Greek lookalikes disguise keywords and domains.",
406
+ ALL_KINDS,
407
+ )
408
+ def gr006_homoglyphs(target: ScanTarget) -> Iterator[Finding]:
409
+ """Mixed-script words in instruction files (low) and in any URL (high).
410
+
411
+ Only instruction files are checked for plain words; config and other files only for URLs.
412
+ """
413
+ text = target.text
414
+ url_spans: list[tuple[int, int]] = []
415
+ for url in _URL_RE.finditer(text):
416
+ url_spans.append(url.span())
417
+ bad: tuple[int, int, str] | None = None
418
+ host = _HOST_RE.match(url.group())
419
+ label = _mixed_host_label(host.group(1)) if host else None
420
+ if label is not None:
421
+ start = url.start() + url.group().index(label)
422
+ bad = (
423
+ start,
424
+ start + len(label),
425
+ f"Host label '{label}' mixes Latin and Cyrillic/Greek letters.",
426
+ )
427
+ else:
428
+ for word in _WORD_RE.finditer(url.group()):
429
+ chars = _mixed_lookalikes(word.group())
430
+ if chars:
431
+ bad = (
432
+ url.start() + word.start(),
433
+ url.start() + word.end(),
434
+ _lookalike_message(word.group(), chars),
435
+ )
436
+ break
437
+ if bad:
438
+ yield build_finding(
439
+ target=target,
440
+ rule_id="GR006",
441
+ severity=Severity.HIGH,
442
+ title="Mixed-script homoglyph in URL",
443
+ message=f"{bad[2]} The URL may impersonate another domain.",
444
+ start=bad[0],
445
+ end=bad[1],
446
+ fix_hint="Check the URL; replace lookalike characters with plain ASCII.",
447
+ limit=120,
448
+ )
449
+ if target.kind != "instructions":
450
+ return
451
+ for word in _WORD_RE.finditer(text):
452
+ if any(lo <= word.start() < hi for lo, hi in url_spans):
453
+ continue
454
+ chars = _mixed_lookalikes(word.group())
455
+ if chars:
456
+ yield build_finding(
457
+ target=target,
458
+ rule_id="GR006",
459
+ severity=Severity.LOW,
460
+ title="Mixed-script homoglyph word",
461
+ message=_lookalike_message(word.group(), chars),
462
+ start=word.start(),
463
+ end=word.end(),
464
+ fix_hint="Replace lookalike characters with plain ASCII.",
465
+ limit=120,
466
+ )
467
+
468
+
469
+ # --- Removal (used by ``ghostrules clean``) ------------------------------------------------
470
+
471
+
472
+ class Removal(NamedTuple):
473
+ """Characters ``clean`` will delete and what it deliberately keeps."""
474
+
475
+ offsets: list[int]
476
+ kept_bidi: int
477
+
478
+
479
+ def removable(text: str, *, keep_bom: bool = False) -> Removal:
480
+ """Offsets of the characters GR001-GR004 flag, i.e. what ``clean`` removes.
481
+
482
+ Exactly the flagged characters: emoji joiners, genuine flag sequences, variation selectors
483
+ in short runs and a byte-order mark kept with ``keep_bom`` all survive. Bidirectional
484
+ controls in files that are substantially right-to-left are kept (and counted), since they
485
+ are legitimate there.
486
+ """
487
+ found: set[int] = set()
488
+ for run in find_runs(text, TAG):
489
+ if not _is_flag_sequence(text, run.start, run.end):
490
+ found.update(range(run.start, run.end))
491
+ bidi = _char_offsets(text, BIDI)
492
+ kept_bidi = 0
493
+ if has_substantial_rtl(text):
494
+ kept_bidi = len(bidi)
495
+ else:
496
+ found.update(bidi)
497
+ found.update(zero_width_offsets(text))
498
+ for run in find_runs(text, VARIATION_SELECTOR):
499
+ if run.end - run.start >= _VS_MIN_RUN:
500
+ found.update(range(run.start, run.end))
501
+ if text.startswith("\ufeff") and not keep_bom:
502
+ found.add(0)
503
+ return Removal(sorted(found), kept_bidi)