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
ghostrules/rules/mcp.py
ADDED
|
@@ -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)
|