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,118 @@
1
+ """Helpers shared by rule implementations: snippets and ``Finding`` construction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shlex
7
+ from collections import defaultdict
8
+ from collections.abc import Iterable
9
+
10
+ from ghostrules.models import Finding, ScanTarget, Severity
11
+ from ghostrules.secrets import apply_spans, map_offset, mask_secrets, mask_spans
12
+ from ghostrules.unicode_utils import render_invisible
13
+
14
+
15
+ def make_snippet(
16
+ line: str,
17
+ start: int,
18
+ end: int,
19
+ *,
20
+ context: int = 30,
21
+ limit: int = 8,
22
+ with_name: bool = False,
23
+ ) -> str:
24
+ """Render the part of ``line`` at ``[start, end)`` with some context, invisibles made visible.
25
+
26
+ The matched part is cut to ``limit`` characters (``…(+N more)``) so a long hidden payload
27
+ cannot flood the output; surrounding context is rendered without names.
28
+ """
29
+ middle = line[start:end]
30
+ shown = render_invisible(middle[:limit], with_name=with_name)
31
+ if len(middle) > limit:
32
+ shown += f"…(+{len(middle) - limit} more)"
33
+ before = render_invisible(line[max(0, start - context) : start], with_name=False)
34
+ after = render_invisible(line[end : end + context], with_name=False)
35
+ lead = "…" if start > context else ""
36
+ trail = "…" if end + context < len(line) else ""
37
+ return f"{lead}{before}{shown}{after}{trail}"
38
+
39
+
40
+ def build_finding(
41
+ *,
42
+ target: ScanTarget,
43
+ rule_id: str,
44
+ severity: Severity,
45
+ title: str,
46
+ message: str,
47
+ start: int,
48
+ end: int,
49
+ decoded: str | None = None,
50
+ fix_hint: str = "",
51
+ limit: int = 8,
52
+ with_name: bool = False,
53
+ secrets: Iterable[str] = (),
54
+ ) -> Finding:
55
+ """Build a ``Finding`` for the text at offsets ``[start, end)`` of the target.
56
+
57
+ Line/column point at ``start`` (1-based, code points). ``end_column`` is exclusive and is
58
+ clamped to the end of the first line when the match spans several lines.
59
+
60
+ The snippet is built from the line with every secret already masked (known formats plus the
61
+ exact values in ``secrets``), so a context window can never cut a secret in half and leave
62
+ a readable fragment. Positions in the finding still refer to the real file.
63
+ """
64
+ line, column = target.position(start)
65
+ raw_line = target.lines[line - 1]
66
+ last_line, last_column = target.position(max(end - 1, start))
67
+ end_column = last_column + 1 if last_line == line else len(raw_line) + 1
68
+ secret_values = tuple(secrets)
69
+ spans = mask_spans(raw_line, secret_values)
70
+ snippet = make_snippet(
71
+ apply_spans(raw_line, spans),
72
+ map_offset(spans, column - 1),
73
+ map_offset(spans, end_column - 1, end=True),
74
+ limit=limit,
75
+ with_name=with_name,
76
+ )
77
+ return Finding(
78
+ rule_id=rule_id,
79
+ severity=severity,
80
+ title=title,
81
+ message=mask_secrets(message, secret_values),
82
+ path=target.path,
83
+ line=line,
84
+ column=column,
85
+ end_column=end_column,
86
+ snippet=snippet,
87
+ decoded=mask_secrets(decoded, secret_values) if decoded else None,
88
+ fix_hint=fix_hint,
89
+ )
90
+
91
+
92
+ def group_by_line(target: ScanTarget, offsets: Iterable[int]) -> dict[int, list[int]]:
93
+ """Group character offsets by their 1-based line number (offsets stay in order)."""
94
+ grouped: dict[int, list[int]] = defaultdict(list)
95
+ for offset in offsets:
96
+ grouped[target.position(offset)[0]].append(offset)
97
+ return dict(grouped)
98
+
99
+
100
+ def clean_hint(target: ScanTarget) -> str:
101
+ """Standard remediation for invisible characters."""
102
+ return f"Remove the invisible characters: ghostrules clean {shlex.quote(target.path)} --write"
103
+
104
+
105
+ _URL_HOST_RE = re.compile(r"https?://(?:[^/?#\s@]*@)?([^/?#\s:)\]>]+)", re.IGNORECASE)
106
+
107
+
108
+ def host_allowed(host: str, allow_domains: Iterable[str]) -> bool:
109
+ """True if ``host`` is an allowed domain or a subdomain of one."""
110
+ host = host.lower().rstrip(".")
111
+ return any(host == d or host.endswith("." + d) for d in allow_domains)
112
+
113
+
114
+ def urls_allowed(text: str, allow_domains: Iterable[str]) -> bool:
115
+ """True if ``text`` contains URLs and every one of them points at an allowed domain."""
116
+ allow = tuple(allow_domains)
117
+ hosts = _URL_HOST_RE.findall(text)
118
+ return bool(allow and hosts and all(host_allowed(h, allow) for h in hosts))
@@ -0,0 +1,317 @@
1
+ """Malicious-instruction rules GR010-GR016 (natural language, English + Spanish).
2
+
3
+ Every rule matches the raw text and also a whitespace-normalised copy of the whole file, so a
4
+ phrase broken across lines still matches, then drops overlapping duplicates. All of them run on
5
+ instruction files and on hidden text decoded by other rules (``scans_decoded``).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from collections.abc import Iterable, Iterator
12
+
13
+ from ghostrules.models import Finding, Kind, ScanTarget, Severity
14
+ from ghostrules.rules.helpers import build_finding, host_allowed, urls_allowed
15
+ from ghostrules.rules.patterns import (
16
+ COLLECTOR_PATTERNS,
17
+ EXFIL_PATTERNS,
18
+ INJECTION_PATTERNS,
19
+ SHELL_LANGS,
20
+ find_encoded_runs,
21
+ )
22
+ from ghostrules.rules.registry import rule
23
+
24
+ INSTRUCTIONS: tuple[Kind, ...] = ("instructions",)
25
+ _EXCERPT_CHARS = 80
26
+ _LONG_SNIPPET = 200 # GR012 must show the whole command
27
+
28
+
29
+ def _spans(target: ScanTarget, patterns: Iterable[re.Pattern[str]]) -> list[tuple[int, int]]:
30
+ """Original-text spans matched by any pattern, on raw and normalised text, deduplicated."""
31
+ patterns = tuple(patterns)
32
+ spans = [m.span() for p in patterns for m in p.finditer(target.text)]
33
+ normalised, offsets = target.normalized
34
+ for p in patterns:
35
+ for m in p.finditer(normalised):
36
+ if m.end() > m.start():
37
+ spans.append((offsets[m.start()], offsets[m.end() - 1] + 1))
38
+ kept: list[tuple[int, int]] = []
39
+ for start, end in sorted(set(spans), key=lambda s: (s[0], -s[1])):
40
+ if not kept or start >= kept[-1][1]:
41
+ kept.append((start, end))
42
+ return kept
43
+
44
+
45
+ def _excerpt(target: ScanTarget, start: int, end: int) -> str:
46
+ text = " ".join(target.text[start:end].split())
47
+ return text if len(text) <= _EXCERPT_CHARS else text[:_EXCERPT_CHARS] + "…"
48
+
49
+
50
+ def _finding(
51
+ target: ScanTarget,
52
+ rule_id: str,
53
+ severity: Severity,
54
+ title: str,
55
+ message: str,
56
+ span: tuple[int, int],
57
+ *,
58
+ fix_hint: str,
59
+ decoded: str | None = None,
60
+ limit: int = 120,
61
+ ) -> Finding:
62
+ start, end = span
63
+ return build_finding(
64
+ target=target,
65
+ rule_id=rule_id,
66
+ severity=severity,
67
+ title=title,
68
+ message=f"{message}: “{_excerpt(target, start, end)}”",
69
+ start=start,
70
+ end=end,
71
+ decoded=decoded,
72
+ fix_hint=fix_hint,
73
+ limit=limit,
74
+ )
75
+
76
+
77
+ # --- GR010 ---------------------------------------------------------------------------------
78
+
79
+
80
+ @rule(
81
+ "GR010",
82
+ Severity.HIGH,
83
+ "Instruction override",
84
+ "Text telling the agent to ignore, forget or override its previous or system instructions.",
85
+ INSTRUCTIONS,
86
+ scans_decoded=True,
87
+ )
88
+ def gr010_instruction_override(target: ScanTarget) -> Iterator[Finding]:
89
+ """High; low when inside a fenced code block (likely documentation about prompt injection)."""
90
+ for span in _spans(target, INJECTION_PATTERNS["GR010"]):
91
+ in_block = target.code_block_at(span[0]) is not None
92
+ yield _finding(
93
+ target,
94
+ "GR010",
95
+ Severity.LOW if in_block else Severity.HIGH,
96
+ "Instruction override",
97
+ "Tries to override the agent's instructions"
98
+ + (" (appears inside a code block)" if in_block else ""),
99
+ span,
100
+ fix_hint="Remove the instruction. If this is documentation, keep it in a code block.",
101
+ )
102
+
103
+
104
+ # --- GR011 ---------------------------------------------------------------------------------
105
+
106
+
107
+ @rule(
108
+ "GR011",
109
+ Severity.HIGH,
110
+ "Concealment from the user",
111
+ "Text telling the agent to hide what it does from the user.",
112
+ INSTRUCTIONS,
113
+ scans_decoded=True,
114
+ )
115
+ def gr011_concealment(target: ScanTarget) -> Iterator[Finding]:
116
+ """Instructions to act without telling, showing or asking the user."""
117
+ for span in _spans(target, INJECTION_PATTERNS["GR011"]):
118
+ yield _finding(
119
+ target,
120
+ "GR011",
121
+ Severity.HIGH,
122
+ "Concealment from the user",
123
+ "Tells the agent to hide its actions from the user",
124
+ span,
125
+ fix_hint="Remove the instruction; agents should be transparent about their actions.",
126
+ )
127
+
128
+
129
+ # --- GR012 ---------------------------------------------------------------------------------
130
+
131
+
132
+ @rule(
133
+ "GR012",
134
+ Severity.CRITICAL,
135
+ "Remote code execution pattern",
136
+ "Commands that download and execute remote code (curl | sh, iex, encoded PowerShell...).",
137
+ INSTRUCTIONS,
138
+ scans_decoded=True,
139
+ )
140
+ def gr012_remote_code_execution(target: ScanTarget) -> Iterator[Finding]:
141
+ """Critical in instruction files; skipped when every URL in the command is allow-listed.
142
+
143
+ The snippet shows the whole command. (The "high elsewhere" case of RULES.md is GR105/GR107,
144
+ which reuse these patterns for MCP server commands and hooks.)
145
+ """
146
+ for span in _spans(target, INJECTION_PATTERNS["GR012"]):
147
+ if urls_allowed(target.text[span[0] : span[1]], target.allow_domains):
148
+ continue
149
+ yield _finding(
150
+ target,
151
+ "GR012",
152
+ Severity.CRITICAL,
153
+ "Remote code execution pattern",
154
+ "Downloads or decodes and executes code",
155
+ span,
156
+ fix_hint="Remove it, or download, review and pin the script instead of piping it.",
157
+ limit=_LONG_SNIPPET,
158
+ )
159
+
160
+
161
+ # --- GR013 ---------------------------------------------------------------------------------
162
+
163
+
164
+ def _lines_of(target: ScanTarget, span: tuple[int, int]) -> range:
165
+ first = target.position(span[0])[0]
166
+ last = target.position(max(span[1] - 1, span[0]))[0]
167
+ return range(first, last + 1)
168
+
169
+
170
+ @rule(
171
+ "GR013",
172
+ Severity.CRITICAL,
173
+ "Data exfiltration",
174
+ "Instructions to send secrets to a URL, markdown-image exfiltration, collector domains.",
175
+ INSTRUCTIONS,
176
+ scans_decoded=True,
177
+ )
178
+ def gr013_exfiltration(target: ScanTarget) -> Iterator[Finding]:
179
+ """Critical for exfiltration instructions; collector domains alone are high.
180
+
181
+ A collector domain on the same line as an exfiltration instruction is critical. One inside
182
+ the matched instruction is not reported again.
183
+ """
184
+ allow = target.allow_domains
185
+ exfil: list[tuple[int, int]] = []
186
+ for span in _spans(target, EXFIL_PATTERNS):
187
+ if urls_allowed(target.text[span[0] : span[1]], allow):
188
+ continue
189
+ exfil.append(span)
190
+ yield _finding(
191
+ target,
192
+ "GR013",
193
+ Severity.CRITICAL,
194
+ "Data exfiltration",
195
+ "Tells the agent to send data or secrets to a URL",
196
+ span,
197
+ fix_hint="Remove the instruction; never send environment data to external URLs.",
198
+ )
199
+ exfil_lines = {n for span in exfil for n in _lines_of(target, span)}
200
+ for span in _spans(target, COLLECTOR_PATTERNS):
201
+ domain = target.text[span[0] : span[1]]
202
+ if host_allowed(domain, allow) or any(s <= span[0] and span[1] <= e for s, e in exfil):
203
+ continue
204
+ combined = bool(exfil_lines & set(_lines_of(target, span)))
205
+ yield _finding(
206
+ target,
207
+ "GR013",
208
+ Severity.CRITICAL if combined else Severity.HIGH,
209
+ "Data exfiltration",
210
+ "References a request-collector domain"
211
+ + (" next to an exfiltration instruction" if combined else ""),
212
+ span,
213
+ fix_hint="Remove the reference, or add the domain to allow_domains if it is yours.",
214
+ )
215
+
216
+
217
+ # --- GR014 ---------------------------------------------------------------------------------
218
+
219
+ _NEGATION_BEFORE = re.compile(
220
+ r"(?:never|do\s+not|don'?t|must\s+not|should\s+not|shouldn'?t|mustn'?t|avoid|not"
221
+ r"|nunca|evita)\s+(?:[a-z']+\s+){0,2}$",
222
+ re.IGNORECASE,
223
+ )
224
+
225
+
226
+ # "Copy .env.example to .env.local": the sensitive path is the destination, not what is read.
227
+ _COPY_TO_DESTINATION = re.compile(
228
+ r"(?:copy|paste)\b.*\b(?:to|into|as)\b", re.IGNORECASE | re.DOTALL
229
+ )
230
+
231
+
232
+ @rule(
233
+ "GR014",
234
+ Severity.HIGH,
235
+ "Sensitive file access",
236
+ "Instructions to read, print or send credentials files (~/.ssh, .env, .npmrc...).",
237
+ INSTRUCTIONS,
238
+ scans_decoded=True,
239
+ )
240
+ def gr014_sensitive_files(target: ScanTarget) -> Iterator[Finding]:
241
+ """A sensitive path near a read/print/send verb, unless the verb is negated ("never print")."""
242
+ for span in _spans(target, INJECTION_PATTERNS["GR014"]):
243
+ if _NEGATION_BEFORE.search(target.text[max(0, span[0] - 40) : span[0]]):
244
+ continue
245
+ if _COPY_TO_DESTINATION.match(target.text[span[0] : span[1]]):
246
+ continue
247
+ yield _finding(
248
+ target,
249
+ "GR014",
250
+ Severity.HIGH,
251
+ "Sensitive file access",
252
+ "Tells the agent to access a credentials or secrets file",
253
+ span,
254
+ fix_hint="Remove the instruction; agents should not read or transmit credential files.",
255
+ )
256
+
257
+
258
+ # --- GR015 ---------------------------------------------------------------------------------
259
+
260
+
261
+ @rule(
262
+ "GR015",
263
+ Severity.MEDIUM,
264
+ "Encoded payload",
265
+ "Long base64 or hex strings in instruction files, which hide their content from reviewers.",
266
+ INSTRUCTIONS,
267
+ scans_decoded=True,
268
+ )
269
+ def gr015_encoded_payloads(target: ScanTarget) -> Iterator[Finding]:
270
+ """High when the run decodes to printable text (shown in ``decoded``), else medium.
271
+
272
+ Fenced code blocks are skipped unless labelled as a shell language; unlabelled blocks count
273
+ as non-shell. Hashes, ``data:`` URIs and path-like strings are filtered out by the finder.
274
+ """
275
+ for run in find_encoded_runs(target.text):
276
+ block = target.code_block_at(run.start)
277
+ if block is not None and block.lang not in SHELL_LANGS:
278
+ continue
279
+ readable = run.decoded is not None
280
+ yield _finding(
281
+ target,
282
+ "GR015",
283
+ Severity.HIGH if readable else Severity.MEDIUM,
284
+ "Encoded payload",
285
+ "Long encoded string that decodes to readable text"
286
+ if readable
287
+ else "Long encoded string of unknown content",
288
+ (run.start, run.end),
289
+ fix_hint="Replace the encoded text with its plain form, or remove it.",
290
+ decoded=run.decoded,
291
+ limit=40,
292
+ )
293
+
294
+
295
+ # --- GR016 ---------------------------------------------------------------------------------
296
+
297
+
298
+ @rule(
299
+ "GR016",
300
+ Severity.HIGH,
301
+ "Agent safety bypass",
302
+ "Instructions to skip permission prompts, disable sandboxes or auto-approve everything.",
303
+ INSTRUCTIONS,
304
+ scans_decoded=True,
305
+ )
306
+ def gr016_safety_bypass(target: ScanTarget) -> Iterator[Finding]:
307
+ """Flags that or instructions which switch off the agent's safety checks."""
308
+ for span in _spans(target, INJECTION_PATTERNS["GR016"]):
309
+ yield _finding(
310
+ target,
311
+ "GR016",
312
+ Severity.HIGH,
313
+ "Agent safety bypass",
314
+ "Tells the agent to bypass its permission or safety checks",
315
+ span,
316
+ fix_hint="Remove it; keep the agent's permission prompts and sandbox enabled.",
317
+ )