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,445 @@
1
+ """MCP configuration rules GR101-GR106 (JSON/TOML agent config files)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import re
7
+ from collections.abc import Iterator
8
+ from urllib.parse import parse_qsl, urlsplit
9
+
10
+ from ghostrules.mcp_config import ServerDef, Span, settings_env_entries
11
+ from ghostrules.models import Finding, Kind, ScanTarget, Severity
12
+ from ghostrules.rules.helpers import build_finding
13
+ from ghostrules.rules.patterns import INJECTION_PATTERNS
14
+ from ghostrules.rules.registry import rule
15
+ from ghostrules.secrets import is_secret_key_name, known_secret, looks_like_secret_value, mask_value
16
+
17
+ CONFIG: tuple[Kind, ...] = ("config",)
18
+
19
+
20
+ def _finding(
21
+ target: ScanTarget,
22
+ rule_id: str,
23
+ severity: Severity,
24
+ title: str,
25
+ message: str,
26
+ span: Span,
27
+ fix_hint: str,
28
+ secrets: tuple[str, ...] = (),
29
+ ) -> Finding:
30
+ start, end = span
31
+ return build_finding(
32
+ target=target,
33
+ rule_id=rule_id,
34
+ severity=severity,
35
+ title=title,
36
+ message=message,
37
+ start=start,
38
+ end=max(end, start),
39
+ fix_hint=fix_hint,
40
+ limit=60,
41
+ secrets=secrets,
42
+ )
43
+
44
+
45
+ # --- GR101 ---------------------------------------------------------------------------------
46
+
47
+
48
+ def _candidate_values(server: ServerDef) -> Iterator[tuple[str, str, str | None, str]]:
49
+ """Yield ``(span key, where, key name or None, value)`` for every place a secret may sit."""
50
+ for key, value in server.env.items():
51
+ yield f"env.{key}", f"env var {key}", key, value
52
+ for key, value in server.headers.items():
53
+ yield f"headers.{key}", f"header {key}", key, value
54
+ for i, arg in enumerate(server.args):
55
+ name: str | None = None
56
+ value = arg
57
+ if arg.startswith("-") and "=" in arg:
58
+ name, value = arg.split("=", 1)
59
+ elif i > 0 and server.args[i - 1].startswith("-") and "=" not in server.args[i - 1]:
60
+ name = server.args[i - 1]
61
+ yield f"args.{i}", f"argument {i + 1}", name, value
62
+ if server.url:
63
+ parts = urlsplit(server.url)
64
+ if parts.password:
65
+ yield "url", "URL password", "password", parts.password
66
+ for name, value in parse_qsl(parts.query, keep_blank_values=True):
67
+ yield "url", f"URL parameter {name}", name, value
68
+ yield "url", "URL", None, server.url
69
+
70
+
71
+ def _detect_secret(key: str | None, value: str) -> tuple[str, str] | None:
72
+ """Return ``(label, secret)`` if the value is a hardcoded secret, else ``None``."""
73
+ found = known_secret(value)
74
+ if found:
75
+ return found
76
+ if key and is_secret_key_name(key) and looks_like_secret_value(value):
77
+ return "Secret value", value
78
+ return None
79
+
80
+
81
+ def _secret_findings(
82
+ target: ScanTarget,
83
+ where_prefix: str,
84
+ candidates: Iterator[tuple[str, str, str | None, str]],
85
+ spans: dict[str, Span] | None,
86
+ server: ServerDef | None,
87
+ ) -> Iterator[Finding]:
88
+ seen: set[tuple[str, str]] = set()
89
+ for span_key, where, key, value in candidates:
90
+ detected = _detect_secret(key, value)
91
+ if detected is None or (span_key, detected[1]) in seen:
92
+ continue
93
+ seen.add((span_key, detected[1]))
94
+ label, secret = detected
95
+ span = server.span(span_key) if server else (spans or {}).get(span_key, (0, 0))
96
+ yield _finding(
97
+ target,
98
+ "GR101",
99
+ Severity.CRITICAL,
100
+ "Hardcoded secret in agent config",
101
+ f"{label} hardcoded in {where}{where_prefix}: {mask_value(secret)}",
102
+ span,
103
+ "Reference an environment variable (for example ${GITHUB_TOKEN}) instead, and rotate "
104
+ "this secret: it is exposed to everyone with access to the repository.",
105
+ secrets=(secret,),
106
+ )
107
+
108
+
109
+ @rule(
110
+ "GR101",
111
+ Severity.CRITICAL,
112
+ "Hardcoded secret in agent config",
113
+ "API keys and tokens written into MCP server env/args/headers/URLs or agent settings env.",
114
+ CONFIG,
115
+ )
116
+ def gr101_hardcoded_secret(target: ScanTarget) -> Iterator[Finding]:
117
+ """Known token formats anywhere in a server definition, plus high-entropy values under
118
+ secret-looking names. Placeholders (``${VAR}``, ``<token>``) are fine. Always masked."""
119
+ for server in target.servers:
120
+ yield from _secret_findings(
121
+ target, f" of server '{server.name}'", _candidate_values(server), None, server
122
+ )
123
+ entries = settings_env_entries(target)
124
+ spans = {f"env.{e.key}": e.span for e in entries}
125
+ candidates = ((f"env.{e.key}", f"env var {e.key}", e.key, e.value) for e in entries)
126
+ yield from _secret_findings(target, " of the settings", candidates, spans, None)
127
+
128
+
129
+ # --- GR102 ---------------------------------------------------------------------------------
130
+
131
+ _RunnerSpec = tuple[str | None, frozenset[str], frozenset[str], bool]
132
+ # (required subcommand, flags taking a value to skip, flags whose value is the package, python?)
133
+ _RUNNERS: dict[str, _RunnerSpec] = {
134
+ "npx": (
135
+ None,
136
+ frozenset({"-c", "--call", "--registry", "--cache", "--userconfig", "--prefix", "-w"}),
137
+ frozenset({"-p", "--package"}),
138
+ False,
139
+ ),
140
+ "bunx": (None, frozenset({"--registry", "--cwd"}), frozenset({"-p", "--package"}), False),
141
+ "pnpm": ("dlx", frozenset({"--registry"}), frozenset({"-p", "--package"}), False),
142
+ "yarn": ("dlx", frozenset({"--registry"}), frozenset({"-p", "--package"}), False),
143
+ "uvx": (
144
+ None,
145
+ frozenset({"--python", "-p", "--with", "--index", "--index-url", "-i", "--env-file"}),
146
+ frozenset({"--from"}),
147
+ True,
148
+ ),
149
+ "pipx": (
150
+ "run",
151
+ frozenset({"--python", "--pip-args", "--index-url", "-i"}),
152
+ frozenset({"--spec"}),
153
+ True,
154
+ ),
155
+ }
156
+ _NPM_VERSION = re.compile(r"v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]+)?")
157
+ _PY_VERSION = re.compile(r"\d+(?:\.[0-9A-Za-z]+)*(?:[-+!][0-9A-Za-z.+-]+)?")
158
+ _GIT_SHA = re.compile(r"[@#][0-9a-fA-F]{7,40}$")
159
+ _VCS_PREFIXES = ("git+", "git:", "github:", "gitlab:", "bitbucket:", "ssh:")
160
+
161
+
162
+ def _package_arg(server: ServerDef) -> tuple[int, str, bool] | None:
163
+ """Locate the package a runner executes: ``(arg index, spec, is_python)``."""
164
+ runner = _RUNNERS.get(server.command_name)
165
+ if runner is None:
166
+ return None
167
+ subcommand, value_flags, spec_flags, python = runner
168
+ args = server.args
169
+ i = 0
170
+ if subcommand is not None:
171
+ if not args or args[0] != subcommand:
172
+ return None
173
+ i = 1
174
+ while i < len(args):
175
+ arg = args[i]
176
+ flag, _, inline = arg.partition("=")
177
+ if flag in spec_flags:
178
+ if inline:
179
+ return i, inline, python
180
+ return (i + 1, args[i + 1], python) if i + 1 < len(args) else None
181
+ if flag in value_flags and not inline:
182
+ i += 2
183
+ continue
184
+ if arg.startswith("-"):
185
+ i += 1
186
+ continue
187
+ return i, arg, python
188
+ return None
189
+
190
+
191
+ def _is_pinned(spec: str, python: bool) -> bool:
192
+ """Whether a package spec fixes an exact version (or commit, or is not fetched at all)."""
193
+ if spec.startswith((".", "/", "~", "file:")) or re.match(r"[A-Za-z]:[\\/]", spec):
194
+ return True # local path
195
+ if spec.startswith(_VCS_PREFIXES):
196
+ return _GIT_SHA.search(spec) is not None
197
+ if re.match(r"https?://", spec):
198
+ return True # direct archive URL: version cannot be judged from the spec
199
+ if python and re.search(r"===?\s*[^*\s,<>!~]+$", spec):
200
+ return True
201
+ at = spec.rfind("@")
202
+ if at <= 0:
203
+ return False
204
+ version = spec[at + 1 :]
205
+ return bool((_PY_VERSION if python else _NPM_VERSION).fullmatch(version))
206
+
207
+
208
+ def _pin_hint(spec: str, python: bool) -> str:
209
+ if spec.startswith(_VCS_PREFIXES):
210
+ return "Pin to a full commit SHA, for example ...repo.git@<40-hex-sha>."
211
+ name = spec[:1] + re.split(r"[=<>!~@]", spec[1:], maxsplit=1)[0]
212
+ return f"Pin an exact version, for example {name}{'==' if python else '@'}1.2.3."
213
+
214
+
215
+ @rule(
216
+ "GR102",
217
+ Severity.MEDIUM,
218
+ "Unpinned package execution",
219
+ "MCP servers launched with npx/uvx/etc. without an exact version run whatever is latest.",
220
+ CONFIG,
221
+ )
222
+ def gr102_unpinned_package(target: ScanTarget) -> Iterator[Finding]:
223
+ """npx, bunx, pnpm/yarn dlx, uvx and pipx run with a package that has no exact version."""
224
+ for server in target.servers:
225
+ located = _package_arg(server)
226
+ if located is None or _is_pinned(located[1], located[2]):
227
+ continue
228
+ index, spec, python = located
229
+ yield _finding(
230
+ target,
231
+ "GR102",
232
+ Severity.MEDIUM,
233
+ "Unpinned package execution",
234
+ f"Server '{server.name}' runs '{spec}' without an exact version: "
235
+ "whatever is published latest will execute with your agent's access.",
236
+ server.span(f"args.{index}"),
237
+ _pin_hint(spec, python),
238
+ )
239
+
240
+
241
+ # --- GR103 ---------------------------------------------------------------------------------
242
+
243
+
244
+ def _is_loopback(host: str) -> bool:
245
+ if host.lower() == "localhost":
246
+ return True
247
+ try:
248
+ return ipaddress.ip_address(host).is_loopback
249
+ except ValueError:
250
+ return False
251
+
252
+
253
+ @rule(
254
+ "GR103",
255
+ Severity.HIGH,
256
+ "Insecure remote MCP transport",
257
+ "Remote MCP servers reached over plain http:// expose traffic and credentials.",
258
+ CONFIG,
259
+ )
260
+ def gr103_insecure_transport(target: ScanTarget) -> Iterator[Finding]:
261
+ """``http://`` URLs to hosts other than localhost, 127.0.0.0/8 and ::1."""
262
+ for server in target.servers:
263
+ if not server.url:
264
+ continue
265
+ parts = urlsplit(server.url)
266
+ host = parts.hostname
267
+ if parts.scheme.lower() != "http" or not host or "$" in parts.netloc or "{" in parts.netloc:
268
+ continue
269
+ if _is_loopback(host):
270
+ continue
271
+ yield _finding(
272
+ target,
273
+ "GR103",
274
+ Severity.HIGH,
275
+ "Insecure remote MCP transport",
276
+ f"Server '{server.name}' connects to '{host}' over plain http://.",
277
+ server.span("url"),
278
+ "Use https:// so traffic and credentials are encrypted.",
279
+ )
280
+
281
+
282
+ # --- GR104 ---------------------------------------------------------------------------------
283
+
284
+ _FS_NAME_TOKENS = frozenset({"fs", "files"})
285
+ _HOME_VARS = frozenset({"$home", "${home}", "%userprofile%", "~"})
286
+ _BROAD_PATHS = frozenset({"/", "/users", "/home"}) | _HOME_VARS
287
+ _DRIVE_ROOT = re.compile(r"[A-Za-z]:[\\/]*")
288
+
289
+
290
+ def _is_filesystem_server(server: ServerDef, package: str | None) -> bool:
291
+ text = " ".join(filter(None, [server.name, package])).lower()
292
+ tokens = set(re.split(r"[^a-z0-9]+", text))
293
+ return "filesystem" in text or bool(tokens & _FS_NAME_TOKENS)
294
+
295
+
296
+ def _is_broad_path(arg: str) -> bool:
297
+ stripped = arg.strip()
298
+ if _DRIVE_ROOT.fullmatch(stripped):
299
+ return True
300
+ normal = stripped.rstrip("/\\") or "/"
301
+ return normal.lower() in _BROAD_PATHS
302
+
303
+
304
+ @rule(
305
+ "GR104",
306
+ Severity.MEDIUM,
307
+ "Overly broad filesystem scope",
308
+ "Filesystem MCP servers given the whole disk or the whole home directory.",
309
+ CONFIG,
310
+ )
311
+ def gr104_broad_filesystem(target: ScanTarget) -> Iterator[Finding]:
312
+ """Filesystem-like servers whose arguments include ``/``, ``~``, ``$HOME``, ``C:\\``..."""
313
+ for server in target.servers:
314
+ located = _package_arg(server)
315
+ package = located[1] if located else None
316
+ if not _is_filesystem_server(server, package):
317
+ continue
318
+ for i, arg in enumerate(server.args):
319
+ if (located and i == located[0]) or not _is_broad_path(arg):
320
+ continue
321
+ yield _finding(
322
+ target,
323
+ "GR104",
324
+ Severity.MEDIUM,
325
+ "Overly broad filesystem scope",
326
+ f"Filesystem server '{server.name}' can access '{arg}': the whole disk or home.",
327
+ server.span(f"args.{i}"),
328
+ "Restrict the server to the project directory, for example ${workspaceFolder}.",
329
+ )
330
+
331
+
332
+ # --- GR105 ---------------------------------------------------------------------------------
333
+
334
+ _POSIX_SHELLS = frozenset({"bash", "sh", "zsh", "dash", "ksh", "fish"})
335
+ _POWERSHELLS = frozenset({"powershell", "pwsh"})
336
+ _POSIX_C_FLAG = re.compile(r"-[a-zA-Z]*c[a-zA-Z]*")
337
+ _POWERSHELL_FLAGS = frozenset({"-c", "-command", "-e", "-enc", "-encodedcommand"})
338
+
339
+
340
+ def _runs_command_string(server: ServerDef) -> bool:
341
+ name = server.command_name
342
+ args = [a.lower() for a in server.args]
343
+ if name in _POSIX_SHELLS:
344
+ return any(_POSIX_C_FLAG.fullmatch(a) for a in server.args)
345
+ if name == "cmd":
346
+ return "/c" in args
347
+ if name in _POWERSHELLS:
348
+ return any(a in _POWERSHELL_FLAGS for a in args)
349
+ return False
350
+
351
+
352
+ @rule(
353
+ "GR105",
354
+ Severity.HIGH,
355
+ "Shell wrapper in server command",
356
+ "MCP servers started through a shell command string can run arbitrary commands.",
357
+ CONFIG,
358
+ )
359
+ def gr105_shell_wrapper(target: ScanTarget) -> Iterator[Finding]:
360
+ """``bash -c``, ``cmd /c``, ``powershell -Command``...; critical if it also downloads and
361
+ executes code (GR012 patterns)."""
362
+ for server in target.servers:
363
+ if not _runs_command_string(server):
364
+ continue
365
+ command_line = " ".join([server.command or "", *server.args])
366
+ remote = any(p.search(command_line) for p in INJECTION_PATTERNS["GR012"])
367
+ yield _finding(
368
+ target,
369
+ "GR105",
370
+ Severity.CRITICAL if remote else Severity.HIGH,
371
+ "Shell wrapper in server command",
372
+ f"Server '{server.name}' runs through a shell: {command_line[:120]}"
373
+ + (" (downloads and executes remote code)" if remote else ""),
374
+ server.span("command"),
375
+ "Launch the server binary directly with arguments instead of a shell command string.",
376
+ )
377
+
378
+
379
+ # --- GR106 ---------------------------------------------------------------------------------
380
+
381
+ _DOCKER_SOCK = "docker.sock"
382
+
383
+
384
+ _ROOT_MOUNT = re.compile(r"/(?::|$)")
385
+ _ROOT_VOLUME_FLAG = re.compile(r"(?:-v=?|--volume=)/(?::|$)")
386
+ _ROOT_BIND_MOUNT = re.compile(r"(?:^|,)(?:source|src)=/(?:,|$)")
387
+
388
+
389
+ def _mounts_host_root(args: tuple[str, ...], i: int) -> bool:
390
+ low = args[i].lower()
391
+ if low in ("-v", "--volume"):
392
+ return i + 1 < len(args) and _ROOT_MOUNT.match(args[i + 1]) is not None
393
+ return bool(_ROOT_VOLUME_FLAG.match(low) or _ROOT_BIND_MOUNT.search(low))
394
+
395
+
396
+ def _flag_value(args: tuple[str, ...], i: int, names: tuple[str, ...]) -> str | None:
397
+ """Value of an option given as ``--flag value`` or ``--flag=value``, if the flag matches."""
398
+ flag, eq, inline = args[i].lower().partition("=")
399
+ if flag not in names:
400
+ return None
401
+ if eq:
402
+ return inline
403
+ return args[i + 1].lower() if i + 1 < len(args) else ""
404
+
405
+
406
+ def _docker_issues(args: tuple[str, ...]) -> Iterator[tuple[int, str]]:
407
+ """Yield ``(arg index, description)`` for each dangerous docker option."""
408
+ for i, arg in enumerate(args):
409
+ low = arg.lower()
410
+ if low == "--privileged" or low.startswith("--privileged="):
411
+ yield i, "runs with --privileged"
412
+ elif _DOCKER_SOCK in low:
413
+ yield i, "mounts the Docker socket"
414
+ elif _mounts_host_root(args, i):
415
+ yield i, "mounts the host root filesystem"
416
+ elif _flag_value(args, i, ("--network", "--net")) == "host":
417
+ yield i, "uses the host network"
418
+ elif _flag_value(args, i, ("--pid",)) == "host":
419
+ yield i, "shares the host PID namespace"
420
+ elif "sys_admin" in (_flag_value(args, i, ("--cap-add",)) or ""):
421
+ yield i, "adds the SYS_ADMIN capability"
422
+
423
+
424
+ @rule(
425
+ "GR106",
426
+ Severity.HIGH,
427
+ "Dangerous Docker options",
428
+ "Docker-based MCP servers started with options that break container isolation.",
429
+ CONFIG,
430
+ )
431
+ def gr106_dangerous_docker(target: ScanTarget) -> Iterator[Finding]:
432
+ """``--privileged``, ``-v /:/x``, the Docker socket, host network/PID, ``SYS_ADMIN``."""
433
+ for server in target.servers:
434
+ if server.command_name != "docker":
435
+ continue
436
+ for index, description in _docker_issues(server.args):
437
+ yield _finding(
438
+ target,
439
+ "GR106",
440
+ Severity.HIGH,
441
+ "Dangerous Docker options",
442
+ f"Server '{server.name}' {description}: the container can take over the host.",
443
+ server.span(f"args.{index}"),
444
+ "Remove the option and mount only the specific directories the server needs.",
445
+ )
@@ -0,0 +1,187 @@
1
+ """Detection patterns for GR010-GR016, shared by the injection rules and by GR005.
2
+
3
+ These are plain data plus tiny pure helpers. The rules in ``injection.py`` add context
4
+ (code blocks, negations, severity) on top; ``match_injection`` answers only "does this text look
5
+ like one of those instructions?", which is what GR005 needs for hidden HTML comments.
6
+ All patterns are case-insensitive; Spanish variants are included where RULES.md lists them.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import binascii
13
+ import re
14
+ from typing import NamedTuple
15
+
16
+ from ghostrules.secrets import shannon_entropy
17
+ from ghostrules.unicode_utils import is_mostly_printable
18
+
19
+
20
+ def _compile(*patterns: str) -> tuple[re.Pattern[str], ...]:
21
+ return tuple(re.compile(p, re.IGNORECASE) for p in patterns)
22
+
23
+
24
+ _SECRET = (
25
+ r"(?:\.env\b|environment\s+variables?|\benv\b|\bsecrets?\b|\btokens?\b|api[\s_-]?keys?"
26
+ r"|\bcredentials\b|ssh\s+keys?|\bpasswords?\b)"
27
+ )
28
+ _EXFIL_VERB = r"\b(?:send|post|upload|append|include|exfiltrate|forward)\b"
29
+ _READ_VERB = (
30
+ r"\b(?:read|print|cat|send|upload|post|show|display|dump|output|copy|exfiltrate|attach"
31
+ r"|leak|paste|share)\b"
32
+ )
33
+ _SENSITIVE_PATH = (
34
+ r"(?:~/\.ssh|\bid_rsa\b|\bid_ed25519\b|\.aws/credentials|(?<!\w)\.env\b(?!\.(?:example|sample|template|dist)\b)|\.npmrc|\.pypirc|\.netrc"
35
+ r"|\bkubeconfig\b|\bkeychain\b|/etc/shadow|(?:chrome|firefox|safari|browser)\W{0,3}[\w ]{0,15}"
36
+ r"cookies?)"
37
+ )
38
+
39
+ EXFIL_PATTERNS = _compile(
40
+ r"!\[[^\]]*\]\(https?://[^)\s]*[?&][^=\s)]+=(?:\{|\$|%7B|<)",
41
+ rf"{_EXFIL_VERB}.{{0,80}}?{_SECRET}.{{0,200}}?https?://[^\s)\]>\"']+",
42
+ rf"{_EXFIL_VERB}.{{0,200}}?https?://.{{0,80}}?{_SECRET}",
43
+ )
44
+ COLLECTOR_PATTERNS = _compile(
45
+ r"\b(?:webhook\.site|requestbin(?:\.\w+)?|pipedream\.net|ngrok(?:-free)?\.(?:io|app)"
46
+ r"|interact\.sh|oast\.(?:fun|pro|live|site|online|me)|burpcollaborator\.net"
47
+ r"|beeceptor\.com|hookbin\.com|canarytokens(?:\.\w+)?)",
48
+ )
49
+
50
+ INJECTION_PATTERNS: dict[str, tuple[re.Pattern[str], ...]] = {
51
+ "GR010": _compile(
52
+ r"\bignore\s+(?:all\s+)?(?:previous|prior|above|earlier)\s+(?:instructions|rules|prompts)",
53
+ r"\bdisregard\s+(?:the\s+|all\s+)?(?:previous|above|system)",
54
+ r"\bforget\s+(?:everything|your\s+instructions)",
55
+ r"\bnew\s+(?:system\s+)?instructions\s*:",
56
+ r"\byou\s+are\s+now\s+(?:in\s+)?(?:developer|dan|jailbreak)",
57
+ r"\boverride\s+(?:the\s+)?(?:system|safety)",
58
+ r"\bignora\s+(?:todas\s+)?las\s+instrucciones\s+(?:anteriores|previas)",
59
+ r"\bolvida\s+(?:tus|las)\s+instrucciones",
60
+ ),
61
+ "GR011": _compile(
62
+ r"\b(?:do\s+not|don'?t)\s+(?:tell|inform|mention|show|reveal)(?:\s+this)?(?:\s+to)?"
63
+ r"\s+the\s+user",
64
+ r"\bwithout\s+(?:telling|asking|notifying)\s+the\s+user",
65
+ r"\b(?:silently|secretly|quietly)\s+(?:run|execute|add|send|install|modify)",
66
+ r"\bdon'?t\s+mention\s+(?:this|these)",
67
+ r"\bhide\s+(?:this|the)\s+(?:from|output)",
68
+ r"\bno\s+(?:se\s+lo\s+)?(?:digas|menciones|muestres)\s+al\s+usuario",
69
+ r"\bsin\s+avisar\s+al\s+usuario",
70
+ ),
71
+ "GR012": _compile(
72
+ r"\bcurl\b[^\n|]{0,200}\|\s*(?:sudo\s+)?(?:ba|z)?sh\b",
73
+ r"\bwget\b[^\n|]{0,200}\|\s*(?:sudo\s+)?(?:ba|z)?sh\b",
74
+ r"\bcurl\b[^\n|]{0,200}\|\s*(?:sudo\s+)?python[0-9.]*\b",
75
+ r"\b(?:iwr|invoke-webrequest)\b[^\n|]{0,200}\|\s*(?:iex|invoke-expression)\b",
76
+ r"\bpowershell(?:\.exe)?\b[^\n]{0,200}\s-e(?:nc|ncodedcommand)?\s+[A-Za-z0-9+/=]{16,}",
77
+ r"\bbase64\s+(?:-d|--decode)\b[^\n|]{0,200}\|\s*(?:ba)?sh\b",
78
+ r"\bbash\s+<\(\s*(?:curl|wget)\b",
79
+ r"\beval\s+[\"']?\$\(\s*(?:curl|wget)\b",
80
+ ),
81
+ "GR013": EXFIL_PATTERNS + COLLECTOR_PATTERNS,
82
+ "GR014": _compile(rf"{_READ_VERB}[^\n]{{0,60}}?{_SENSITIVE_PATH}"),
83
+ "GR016": _compile(
84
+ r"--dangerously-skip-permissions",
85
+ r"\bbypassPermissions\b",
86
+ r"--yolo\b",
87
+ r"\byolo\s+mode\b",
88
+ r"\bauto[- ]?approve\s+(?:all|every)",
89
+ r"\balways\s+(?:allow|approve|accept)\s+(?:all\s+)?(?:commands|tools|edits)",
90
+ r"\bdisable\s+(?:the\s+)?(?:sandbox|safety|guardrails)",
91
+ r"\bnever\s+ask\s+(?:for\s+)?(?:permission|confirmation)",
92
+ r"\bsin\s+pedir\s+(?:permiso|confirmaci[oó]n)",
93
+ ),
94
+ }
95
+
96
+ _IMPERATIVE_RE = re.compile(r"\b(?:you\s+must|always|never|assistant|agent|model)\b", re.IGNORECASE)
97
+ _AI_RE = re.compile(r"\bAI\b") # case-sensitive: "ai" is a common syllable/word elsewhere
98
+
99
+ _BASE64_RE = re.compile(r"[A-Za-z0-9+/]{60,}={0,2}")
100
+ _HEX_RE = re.compile(r"\b[0-9a-fA-F]{80,}\b")
101
+
102
+
103
+ SHELL_LANGS = frozenset({"sh", "bash", "shell", "zsh", "fish", "console", "shell-session"})
104
+ SHELL_LANGS |= frozenset({"powershell", "pwsh", "ps1", "cmd", "bat"})
105
+ _DIGEST_HEX_LENGTHS = frozenset({96, 128}) # sha384 / sha512 digests
106
+ _NOISE_MIN_ENTROPY = 4.5
107
+
108
+
109
+ class EncodedRun(NamedTuple):
110
+ """A base64/hex run. ``decoded`` is the text it decodes to, or ``None`` if it is not text."""
111
+
112
+ start: int
113
+ end: int
114
+ decoded: str | None
115
+
116
+
117
+ def _decode_text(data: bytes) -> str | None:
118
+ try:
119
+ text = data.decode("utf-8")
120
+ except UnicodeDecodeError:
121
+ return None
122
+ return text if is_mostly_printable(text) else None
123
+
124
+
125
+ def _looks_like_random_base64(run: str) -> bool:
126
+ """High-entropy, mixed-case-with-digits: random bytes rather than a path or identifier."""
127
+ return (
128
+ shannon_entropy(run) >= _NOISE_MIN_ENTROPY
129
+ and any(c.islower() for c in run)
130
+ and any(c.isupper() for c in run)
131
+ and any(c.isdigit() for c in run)
132
+ )
133
+
134
+
135
+ def find_encoded_runs(text: str) -> list[EncodedRun]:
136
+ """Find base64 (>= 60 chars) and hex (>= 80 chars) runs worth reporting.
137
+
138
+ Runs decoding to printable text are always returned. Runs that do not decode to text are
139
+ returned (``decoded=None``) only when they look like random encoded bytes: high-entropy base64
140
+ not embedded in a path/URL, or hex that is not a standard digest length. ``data:...;base64,``
141
+ URIs are skipped.
142
+ """
143
+ found: list[EncodedRun] = []
144
+ for match in _BASE64_RE.finditer(text):
145
+ before = text[: match.start()]
146
+ if before.endswith("base64,"):
147
+ continue
148
+ run = match.group()
149
+ padded = run + "=" * (-len(run) % 4) if "=" not in run else run
150
+ try:
151
+ decoded = _decode_text(base64.b64decode(padded, validate=True))
152
+ except (binascii.Error, ValueError):
153
+ continue
154
+ if decoded is not None:
155
+ found.append(EncodedRun(match.start(), match.end(), decoded))
156
+ elif not before.endswith(("/", "=")) and _looks_like_random_base64(run):
157
+ found.append(EncodedRun(match.start(), match.end(), None))
158
+ for match in _HEX_RE.finditer(text):
159
+ if len(match.group()) % 2:
160
+ continue
161
+ decoded = _decode_text(bytes.fromhex(match.group()))
162
+ if decoded is not None or len(match.group()) not in _DIGEST_HEX_LENGTHS:
163
+ found.append(EncodedRun(match.start(), match.end(), decoded))
164
+ return sorted(found)
165
+
166
+
167
+ def find_encoded_payloads(text: str) -> list[EncodedRun]:
168
+ """The subset of ``find_encoded_runs`` that decodes to printable text."""
169
+ return [run for run in find_encoded_runs(text) if run.decoded is not None]
170
+
171
+
172
+ def has_agent_directed_language(text: str) -> bool:
173
+ """True if the text addresses an AI agent imperatively (``you must``, ``assistant``...)."""
174
+ return bool(_IMPERATIVE_RE.search(text) or _AI_RE.search(text))
175
+
176
+
177
+ def match_injection(text: str) -> list[str]:
178
+ """Ids of the GR010-GR016 patterns matching ``text`` (raw or whitespace-normalised)."""
179
+ normalised = " ".join(text.split())
180
+ hits = [
181
+ rule_id
182
+ for rule_id, patterns in INJECTION_PATTERNS.items()
183
+ if any(p.search(text) or p.search(normalised) for p in patterns)
184
+ ]
185
+ if find_encoded_payloads(text):
186
+ hits.append("GR015")
187
+ return sorted(hits)