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.
- ghostrules/__init__.py +6 -0
- ghostrules/_compat.py +12 -0
- ghostrules/clean.py +41 -0
- ghostrules/cli.py +319 -0
- ghostrules/config.py +93 -0
- ghostrules/jsonloc.py +194 -0
- ghostrules/markdown_utils.py +59 -0
- ghostrules/mcp_config.py +243 -0
- ghostrules/models.py +188 -0
- ghostrules/py.typed +0 -0
- ghostrules/reporters/__init__.py +1 -0
- ghostrules/reporters/common.py +86 -0
- ghostrules/reporters/json_.py +45 -0
- ghostrules/reporters/markdown.py +55 -0
- ghostrules/reporters/sarif.py +137 -0
- ghostrules/reporters/table.py +235 -0
- ghostrules/reveal.py +90 -0
- ghostrules/rules/__init__.py +16 -0
- ghostrules/rules/agent_settings.py +159 -0
- ghostrules/rules/helpers.py +118 -0
- ghostrules/rules/injection.py +317 -0
- ghostrules/rules/mcp.py +445 -0
- ghostrules/rules/patterns.py +187 -0
- ghostrules/rules/registry.py +78 -0
- ghostrules/rules/unicode.py +503 -0
- ghostrules/scanner.py +244 -0
- ghostrules/secrets.py +219 -0
- ghostrules/targets.py +220 -0
- ghostrules/unicode_utils.py +138 -0
- ghostrules-0.1.0.dist-info/METADATA +333 -0
- ghostrules-0.1.0.dist-info/RECORD +34 -0
- ghostrules-0.1.0.dist-info/WHEEL +4 -0
- ghostrules-0.1.0.dist-info/entry_points.txt +2 -0
- ghostrules-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|