kestrix-scan 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.
- kestrix_scan/__init__.py +86 -0
- kestrix_scan/__main__.py +9 -0
- kestrix_scan/about.py +8 -0
- kestrix_scan/agent_config.py +288 -0
- kestrix_scan/analyze.py +424 -0
- kestrix_scan/c_tokens.py +486 -0
- kestrix_scan/ci_rules.py +310 -0
- kestrix_scan/claude_plugin/kestrix/.claude-plugin/plugin.json +17 -0
- kestrix_scan/claude_plugin/kestrix/hooks/hooks.json +83 -0
- kestrix_scan/claude_plugin/kestrix/hooks/kestrix_hook.py +981 -0
- kestrix_scan/claude_plugin/kestrix/skills/fix/SKILL.md +30 -0
- kestrix_scan/claude_plugin/kestrix/skills/fixing-security-findings/SKILL.md +3921 -0
- kestrix_scan/claude_plugin/kestrix/skills/kestrix-security/SKILL.md +244 -0
- kestrix_scan/claude_plugin/kestrix/skills/kestrix-security/reference/bitcoin.md +52 -0
- kestrix_scan/claude_plugin/kestrix/skills/kestrix-security/reference/detect.md +99 -0
- kestrix_scan/claude_plugin/kestrix/skills/kestrix-security/reference/findings-forensics.md +62 -0
- kestrix_scan/claude_plugin/kestrix/skills/kestrix-security/reference/findings-mail.md +118 -0
- kestrix_scan/claude_plugin/kestrix/skills/kestrix-security/reference/findings-webcheck.md +97 -0
- kestrix_scan/claude_plugin/kestrix/skills/kestrix-security/reference/json-fields.md +94 -0
- kestrix_scan/claude_plugin/kestrix/skills/review/SKILL.md +31 -0
- kestrix_scan/claude_plugin/kestrix/skills/scan/SKILL.md +32 -0
- kestrix_scan/claude_plugin/kestrix/skills/secure-code-review/SKILL.md +132 -0
- kestrix_scan/claude_plugin/kestrix/skills/status/SKILL.md +26 -0
- kestrix_scan/cli.py +491 -0
- kestrix_scan/config.py +267 -0
- kestrix_scan/deps.py +333 -0
- kestrix_scan/docker_rules.py +291 -0
- kestrix_scan/ecoversions.py +272 -0
- kestrix_scan/errors.py +24 -0
- kestrix_scan/hook.py +296 -0
- kestrix_scan/html_tokens.py +236 -0
- kestrix_scan/iac.py +55 -0
- kestrix_scan/infra_rules.py +490 -0
- kestrix_scan/init.py +237 -0
- kestrix_scan/install_guard.py +481 -0
- kestrix_scan/js_index.py +1498 -0
- kestrix_scan/js_rules.py +945 -0
- kestrix_scan/js_tokens.py +311 -0
- kestrix_scan/lockfiles.py +363 -0
- kestrix_scan/lockfiles_more.py +325 -0
- kestrix_scan/models.py +196 -0
- kestrix_scan/osvdb.py +584 -0
- kestrix_scan/php_index.py +787 -0
- kestrix_scan/php_rules.py +599 -0
- kestrix_scan/php_tokens.py +202 -0
- kestrix_scan/plugin.py +100 -0
- kestrix_scan/popular.py +283 -0
- kestrix_scan/popular_names.json +1 -0
- kestrix_scan/py.typed +0 -0
- kestrix_scan/pytest_plugin.py +66 -0
- kestrix_scan/python_rules.py +1595 -0
- kestrix_scan/registry.py +2195 -0
- kestrix_scan/ruby_tokens.py +310 -0
- kestrix_scan/sarif.py +143 -0
- kestrix_scan/secrets.py +217 -0
- kestrix_scan/server_index.py +1022 -0
- kestrix_scan/server_rules.py +885 -0
- kestrix_scan/shell_rules.py +299 -0
- kestrix_scan/skill.py +174 -0
- kestrix_scan/sql_rules.py +207 -0
- kestrix_scan/systems_rules.py +809 -0
- kestrix_scan/taint.py +1055 -0
- kestrix_scan/versions.py +218 -0
- kestrix_scan/walk.py +315 -0
- kestrix_scan/web_rules.py +352 -0
- kestrix_scan/yaml_lite.py +475 -0
- kestrix_scan-0.1.0.dist-info/METADATA +371 -0
- kestrix_scan-0.1.0.dist-info/RECORD +72 -0
- kestrix_scan-0.1.0.dist-info/WHEEL +4 -0
- kestrix_scan-0.1.0.dist-info/entry_points.txt +6 -0
- kestrix_scan-0.1.0.dist-info/licenses/LICENSE +202 -0
- kestrix_scan-0.1.0.dist-info/licenses/NOTICE +34 -0
kestrix_scan/__init__.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Ruthvik Bandari
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Kestrix code scanner: local, rule-based security checks for Python code and hard-coded secrets.
|
|
4
|
+
|
|
5
|
+
import kestrix_scan
|
|
6
|
+
|
|
7
|
+
config = kestrix_scan.load_config("src") # [tool.kestrix] or .kestrix.toml, if any
|
|
8
|
+
report = kestrix_scan.scan(["src"], config=config, fail_on="high")
|
|
9
|
+
for finding in report.findings:
|
|
10
|
+
print(finding.path, finding.line, finding.rule_id, finding.fix)
|
|
11
|
+
print(report.to_json())
|
|
12
|
+
raise SystemExit(kestrix_scan.exit_code(report))
|
|
13
|
+
|
|
14
|
+
Files are read as data: nothing is imported, executed or sent off the machine. There are no
|
|
15
|
+
runtime dependencies on Python 3.11 and newer (``tomli`` on 3.10).
|
|
16
|
+
|
|
17
|
+
The names below load their modules on first use, so importing ``kestrix_scan`` (as the pytest
|
|
18
|
+
plugin does on every pytest run) costs almost nothing.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import importlib
|
|
24
|
+
from typing import TYPE_CHECKING, Any
|
|
25
|
+
|
|
26
|
+
from .about import ATTRIBUTION, __author__, __version__
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from .analyze import FailOn as FailOn
|
|
30
|
+
from .analyze import exit_code as exit_code
|
|
31
|
+
from .analyze import scan as scan
|
|
32
|
+
from .config import Config as Config
|
|
33
|
+
from .config import load_config as load_config
|
|
34
|
+
from .errors import Reason as Reason
|
|
35
|
+
from .errors import Rejected as Rejected
|
|
36
|
+
from .models import Finding as Finding
|
|
37
|
+
from .models import ScanReport as ScanReport
|
|
38
|
+
from .models import SeverityCounts as SeverityCounts
|
|
39
|
+
from .registry import Rule as Rule
|
|
40
|
+
from .registry import rules as rules
|
|
41
|
+
|
|
42
|
+
_LAZY = {
|
|
43
|
+
"Config": "config",
|
|
44
|
+
"FailOn": "analyze",
|
|
45
|
+
"Finding": "models",
|
|
46
|
+
"Reason": "errors",
|
|
47
|
+
"Rejected": "errors",
|
|
48
|
+
"Rule": "registry",
|
|
49
|
+
"ScanReport": "models",
|
|
50
|
+
"SeverityCounts": "models",
|
|
51
|
+
"exit_code": "analyze",
|
|
52
|
+
"load_config": "config",
|
|
53
|
+
"rules": "registry",
|
|
54
|
+
"scan": "analyze",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
"ATTRIBUTION",
|
|
59
|
+
"Config",
|
|
60
|
+
"FailOn",
|
|
61
|
+
"Finding",
|
|
62
|
+
"Reason",
|
|
63
|
+
"Rejected",
|
|
64
|
+
"Rule",
|
|
65
|
+
"ScanReport",
|
|
66
|
+
"SeverityCounts",
|
|
67
|
+
"__author__",
|
|
68
|
+
"__version__",
|
|
69
|
+
"exit_code",
|
|
70
|
+
"load_config",
|
|
71
|
+
"rules",
|
|
72
|
+
"scan",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def __getattr__(name: str) -> Any:
|
|
77
|
+
module = _LAZY.get(name)
|
|
78
|
+
if module is None:
|
|
79
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
80
|
+
value = getattr(importlib.import_module(f"{__name__}.{module}"), name)
|
|
81
|
+
globals()[name] = value
|
|
82
|
+
return value
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def __dir__() -> list[str]:
|
|
86
|
+
return sorted({*globals(), *_LAZY})
|
kestrix_scan/__main__.py
ADDED
kestrix_scan/about.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Ruthvik Bandari
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Version and attribution, kept in a leaf module so every other module can import them."""
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
__author__ = "Ruthvik Bandari"
|
|
7
|
+
# Stamped on every report the scanner produces; the same text as the Kestrix workbench.
|
|
8
|
+
ATTRIBUTION = f"Kestrix by {__author__}"
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Ruthvik Bandari
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Rules for Claude Code and MCP configuration files (``config.*``).
|
|
4
|
+
|
|
5
|
+
Files, by path relative to the scan root:
|
|
6
|
+
|
|
7
|
+
- ``.claude/settings.json`` and ``.claude/settings.local.json``: ``hooks``, ``permissions``
|
|
8
|
+
(``defaultMode``, ``allow``), ``enableAllProjectMcpServers`` and ``env``;
|
|
9
|
+
- ``hooks/hooks.json`` (a plugin's hooks): ``hooks``;
|
|
10
|
+
- ``.mcp.json``: ``mcpServers`` with ``command``, ``args``, ``env``, ``url`` and ``headers``.
|
|
11
|
+
|
|
12
|
+
The field names follow https://code.claude.com/docs/en/settings, /hooks, /permissions and /mcp.
|
|
13
|
+
A repository's configuration is untrusted input: it is parsed with the standard-library ``json``
|
|
14
|
+
after a size check, only strings, lists and objects are read from it, and nothing in it is run or
|
|
15
|
+
expanded. Secret values never leave this module; a hit carries the kind and a redacted prefix.
|
|
16
|
+
JSON has no comments, so these findings cannot be suppressed inline; use ``rules.disable`` or a
|
|
17
|
+
baseline.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
from collections.abc import Iterator
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from .models import Severity, SkipReason, clip_snippet
|
|
29
|
+
from .registry import RULES, Hit
|
|
30
|
+
from .secrets import detect, redacted
|
|
31
|
+
|
|
32
|
+
MAX_CONFIG_BYTES = 256 * 1024
|
|
33
|
+
MAX_ITEMS = 500 # hooks, rules, servers or values read per list or object
|
|
34
|
+
|
|
35
|
+
_BROAD_SHELL_RULES = frozenset({"bash", "bash(*)", "bash(:*)", "bash(**)", "powershell", "powershell(*)"})
|
|
36
|
+
_FETCH = r"(?:curl|wget|iwr|irm|invoke-webrequest|invoke-restmethod)"
|
|
37
|
+
_INTERPRETER = r"(?:(?:ba|z|da|k|fi)?sh|python[0-9.]*|node|perl|ruby|iex|invoke-expression|pwsh|powershell)"
|
|
38
|
+
_PIPE_TO_SHELL = re.compile(
|
|
39
|
+
rf"(?i)\b{_FETCH}\b[^|;&\n]*\|\s*(?:sudo\s+(?:-\S+\s+)*)?(?:env\s+)?(?:/usr/bin/|/bin/|/usr/local/bin/)?{_INTERPRETER}\b"
|
|
40
|
+
)
|
|
41
|
+
_SUBSTITUTED = re.compile(
|
|
42
|
+
rf"(?i)(?:\b{_INTERPRETER}\s+(?:-c\s+)?|\beval\s+|\bsource\s+|\.\s+)[\"']?[<$]\(\s*{_FETCH}\b"
|
|
43
|
+
)
|
|
44
|
+
_DOWNLOAD_TO = re.compile(
|
|
45
|
+
rf"(?i)\b{_FETCH}\b[^;&|\n]*?\s(?:-o|-O|--output|--output-document|-OutFile)[ =]?\s*([^\s;&|]+)"
|
|
46
|
+
)
|
|
47
|
+
_RUN_FILE = r"(?:&&|;|\|\|)\s*(?:sudo\s+)?(?:(?:{interp})\s+|chmod\s+\+x\s+|source\s+|\.\s+)?\S*{name}\b"
|
|
48
|
+
_PINNED_NPM = re.compile(r"^(?:@[^/@\s]+/)?[^@\s]+@v?\d")
|
|
49
|
+
_PINNED_PY = re.compile(r"(?:==|@v?)\d")
|
|
50
|
+
_UVX_VALUE_FLAGS = frozenset(
|
|
51
|
+
{"--from", "--with", "--python", "-p", "--index-url", "--extra-index-url", "--index", "-w"}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def config_kind(rel: str) -> str | None:
|
|
56
|
+
"""'settings', 'plugin_hooks' or 'mcp' for a configuration file this module reads, else None."""
|
|
57
|
+
parts = rel.split("/")
|
|
58
|
+
name, parent = parts[-1], parts[-2] if len(parts) > 1 else ""
|
|
59
|
+
if parent == ".claude" and name in ("settings.json", "settings.local.json"):
|
|
60
|
+
return "settings"
|
|
61
|
+
if parent == "hooks" and name == "hooks.json":
|
|
62
|
+
return "plugin_hooks"
|
|
63
|
+
if name == ".mcp.json":
|
|
64
|
+
return "mcp"
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def check_config(text: str, rel: str) -> list[Hit] | SkipReason:
|
|
69
|
+
"""Rule hits for one configuration file, or why it could not be analyzed."""
|
|
70
|
+
kind = config_kind(rel)
|
|
71
|
+
if kind is None:
|
|
72
|
+
return []
|
|
73
|
+
if len(text.encode("utf-8", "replace")) > MAX_CONFIG_BYTES:
|
|
74
|
+
return "too_large"
|
|
75
|
+
try:
|
|
76
|
+
data = json.loads(text)
|
|
77
|
+
except ValueError:
|
|
78
|
+
return "syntax_error"
|
|
79
|
+
except RecursionError:
|
|
80
|
+
return "too_complex"
|
|
81
|
+
if not isinstance(data, dict):
|
|
82
|
+
return []
|
|
83
|
+
checker = _Config(text)
|
|
84
|
+
if kind in ("settings", "plugin_hooks"):
|
|
85
|
+
checker.hooks(data.get("hooks"))
|
|
86
|
+
if kind == "settings":
|
|
87
|
+
checker.permissions(data)
|
|
88
|
+
checker.env(data.get("env"))
|
|
89
|
+
if kind == "mcp":
|
|
90
|
+
checker.servers(data.get("mcpServers"))
|
|
91
|
+
return checker.hits
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _items(value: Any) -> Iterator[tuple[str, Any]]:
|
|
95
|
+
if isinstance(value, dict):
|
|
96
|
+
yield from list(value.items())[:MAX_ITEMS]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _list(value: Any) -> list[Any]:
|
|
100
|
+
return value[:MAX_ITEMS] if isinstance(value, list) else []
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def remote_exec(command: str) -> bool:
|
|
104
|
+
"""True when a shell command runs something it downloads: curl ... | sh, sh <(curl ...), or download then run."""
|
|
105
|
+
if _PIPE_TO_SHELL.search(command) or _SUBSTITUTED.search(command):
|
|
106
|
+
return True
|
|
107
|
+
for match in _DOWNLOAD_TO.finditer(command):
|
|
108
|
+
target = os.path.basename(match.group(1).strip("'\""))
|
|
109
|
+
if target and target != "-":
|
|
110
|
+
after = command[match.end() :]
|
|
111
|
+
if re.search(_RUN_FILE.format(interp=_INTERPRETER, name=re.escape(target)), after, re.I):
|
|
112
|
+
return True
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def unpinned_package(command: str, args: list[str]) -> str | None:
|
|
117
|
+
"""The package an ``npx -y`` or ``uvx`` launcher runs without a pinned version, or None."""
|
|
118
|
+
tokens = [*command.split(), *args]
|
|
119
|
+
if len(tokens) > 2 and os.path.basename(tokens[0]).lower() in ("cmd", "cmd.exe") and tokens[1].lower() == "/c":
|
|
120
|
+
tokens = tokens[2:]
|
|
121
|
+
if not tokens:
|
|
122
|
+
return None
|
|
123
|
+
launcher = os.path.basename(tokens[0]).lower().removesuffix(".cmd").removesuffix(".exe")
|
|
124
|
+
rest = tokens[1:]
|
|
125
|
+
if launcher == "npx":
|
|
126
|
+
if not {"-y", "--yes"} & set(rest):
|
|
127
|
+
return None
|
|
128
|
+
package = _npx_package(rest)
|
|
129
|
+
return package if package is not None and not _PINNED_NPM.match(package) else None
|
|
130
|
+
if launcher == "uvx" or (launcher == "uv" and rest[:2] == ["tool", "run"]):
|
|
131
|
+
package = _uvx_package(rest[2:] if launcher == "uv" else rest)
|
|
132
|
+
return package if package is not None and not _PINNED_PY.search(package) else None
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _npx_package(args: list[str]) -> str | None:
|
|
137
|
+
for i, arg in enumerate(args):
|
|
138
|
+
if arg in ("-p", "--package") and i + 1 < len(args):
|
|
139
|
+
return args[i + 1]
|
|
140
|
+
if arg.startswith("--package="):
|
|
141
|
+
return arg.split("=", 1)[1]
|
|
142
|
+
return next((arg for arg in args if not arg.startswith("-")), None)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _uvx_package(args: list[str]) -> str | None:
|
|
146
|
+
skip = False
|
|
147
|
+
for i, arg in enumerate(args):
|
|
148
|
+
if skip:
|
|
149
|
+
skip = False
|
|
150
|
+
continue
|
|
151
|
+
if arg == "--from" and i + 1 < len(args):
|
|
152
|
+
return args[i + 1]
|
|
153
|
+
if arg.startswith("--from="):
|
|
154
|
+
return arg.split("=", 1)[1]
|
|
155
|
+
if arg in _UVX_VALUE_FLAGS:
|
|
156
|
+
skip = True
|
|
157
|
+
elif not arg.startswith("-"):
|
|
158
|
+
return arg
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class _Config:
|
|
163
|
+
def __init__(self, text: str) -> None:
|
|
164
|
+
self.text = text
|
|
165
|
+
self.lines = text.splitlines()
|
|
166
|
+
self.hits: list[Hit] = []
|
|
167
|
+
|
|
168
|
+
def _locate(self, needle: str) -> tuple[int, int]:
|
|
169
|
+
"""1-based line and column of ``needle`` as written in the JSON text (first occurrence), else (1, 1)."""
|
|
170
|
+
for form in (json.dumps(needle, ensure_ascii=False)[1:-1], json.dumps(needle)[1:-1], needle):
|
|
171
|
+
index = self.text.find(form) if form else -1
|
|
172
|
+
if index >= 0:
|
|
173
|
+
line = self.text.count("\n", 0, index) + 1
|
|
174
|
+
return line, index - (self.text.rfind("\n", 0, index) + 1) + 1
|
|
175
|
+
return 1, 1
|
|
176
|
+
|
|
177
|
+
def hit(
|
|
178
|
+
self, rule_id: str, needle: str, message: str, *, snippet: str | None = None, severity: Severity | None = None
|
|
179
|
+
) -> None:
|
|
180
|
+
line, column = self._locate(needle)
|
|
181
|
+
text = self.lines[line - 1] if 0 < line <= len(self.lines) else ""
|
|
182
|
+
self.hits.append(
|
|
183
|
+
Hit(
|
|
184
|
+
rule_id=rule_id,
|
|
185
|
+
line=line,
|
|
186
|
+
column=column,
|
|
187
|
+
end_line=line,
|
|
188
|
+
message=message,
|
|
189
|
+
snippet=snippet if snippet is not None else clip_snippet(text),
|
|
190
|
+
severity=severity,
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# ----- settings and plugin hooks
|
|
195
|
+
|
|
196
|
+
def hooks(self, hooks: Any) -> None:
|
|
197
|
+
for event, groups in _items(hooks):
|
|
198
|
+
for group in _list(groups):
|
|
199
|
+
handlers = group.get("hooks") if isinstance(group, dict) else None
|
|
200
|
+
for handler in _list(handlers):
|
|
201
|
+
if not isinstance(handler, dict) or handler.get("type", "command") != "command":
|
|
202
|
+
continue
|
|
203
|
+
command = handler.get("command")
|
|
204
|
+
if not isinstance(command, str):
|
|
205
|
+
continue
|
|
206
|
+
args = [a for a in _list(handler.get("args")) if isinstance(a, str)]
|
|
207
|
+
if remote_exec(" ".join([command, *args])):
|
|
208
|
+
self.hit(
|
|
209
|
+
"config.claude.hook_remote_exec",
|
|
210
|
+
command,
|
|
211
|
+
f"The {event} hook runs a script it downloads when it runs, without a pinned version or checksum.",
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def permissions(self, settings: dict[str, Any]) -> None:
|
|
215
|
+
permissions = settings.get("permissions")
|
|
216
|
+
if isinstance(permissions, dict):
|
|
217
|
+
if permissions.get("defaultMode") == "bypassPermissions":
|
|
218
|
+
self.hit(
|
|
219
|
+
"config.claude.permissions_bypass",
|
|
220
|
+
"bypassPermissions",
|
|
221
|
+
"permissions.defaultMode is bypassPermissions. Claude Code 2.1.257 and newer ignore it in project "
|
|
222
|
+
"settings, but older versions start every session without permission prompts.",
|
|
223
|
+
)
|
|
224
|
+
for rule in _list(permissions.get("allow")):
|
|
225
|
+
if isinstance(rule, str) and rule.replace(" ", "").lower() in _BROAD_SHELL_RULES:
|
|
226
|
+
self.hit(
|
|
227
|
+
"config.claude.permissions_bypass",
|
|
228
|
+
rule,
|
|
229
|
+
f"The allow rule {rule!r} lets Claude run every shell command without asking.",
|
|
230
|
+
)
|
|
231
|
+
if settings.get("enableAllProjectMcpServers") is True:
|
|
232
|
+
self.hit(
|
|
233
|
+
"config.claude.permissions_bypass",
|
|
234
|
+
"enableAllProjectMcpServers",
|
|
235
|
+
"enableAllProjectMcpServers approves every server in the project's .mcp.json without a prompt.",
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
def env(self, env: Any) -> None:
|
|
239
|
+
for name, value in _items(env):
|
|
240
|
+
if isinstance(value, str):
|
|
241
|
+
self._secret("config.claude.env_leak", f"settings env {name}", name, value)
|
|
242
|
+
|
|
243
|
+
# ----- .mcp.json
|
|
244
|
+
|
|
245
|
+
def servers(self, servers: Any) -> None:
|
|
246
|
+
for server_name, server in _items(servers):
|
|
247
|
+
if not isinstance(server, dict):
|
|
248
|
+
continue
|
|
249
|
+
command = server.get("command")
|
|
250
|
+
args = [a for a in _list(server.get("args")) if isinstance(a, str)]
|
|
251
|
+
if isinstance(command, str):
|
|
252
|
+
package = unpinned_package(command, args)
|
|
253
|
+
if package is not None:
|
|
254
|
+
self.hit(
|
|
255
|
+
"config.mcp.unpinned_launcher",
|
|
256
|
+
package,
|
|
257
|
+
f"MCP server {server_name!r} runs the latest {package!r} at every start (no pinned version).",
|
|
258
|
+
)
|
|
259
|
+
for name, value in _items(server.get("env")):
|
|
260
|
+
if isinstance(value, str):
|
|
261
|
+
self._secret("config.mcp.secret_inline", f"{server_name} env {name}", name, value)
|
|
262
|
+
for name, value in _items(server.get("headers")):
|
|
263
|
+
if isinstance(value, str):
|
|
264
|
+
token = value.split(None, 1)[1] if value.lower().startswith(("bearer ", "token ")) else value
|
|
265
|
+
self._secret("config.mcp.secret_inline", f"{server_name} header {name}", f"{name} token", token)
|
|
266
|
+
previous = ""
|
|
267
|
+
for arg in args:
|
|
268
|
+
self._secret("config.mcp.secret_inline", f"{server_name} args", previous.lstrip("-"), arg)
|
|
269
|
+
previous = arg
|
|
270
|
+
url = server.get("url")
|
|
271
|
+
if isinstance(url, str):
|
|
272
|
+
self._secret("config.mcp.secret_inline", f"{server_name} url", "", url)
|
|
273
|
+
|
|
274
|
+
def _secret(self, rule_id: str, where: str, name: str, value: str) -> None:
|
|
275
|
+
if "${" in value:
|
|
276
|
+
return # an environment-variable reference, which is the fix
|
|
277
|
+
found = detect(value, name)
|
|
278
|
+
if found is None:
|
|
279
|
+
return
|
|
280
|
+
kind, secret = found
|
|
281
|
+
label = redacted(kind, secret)
|
|
282
|
+
self.hit(
|
|
283
|
+
rule_id,
|
|
284
|
+
secret,
|
|
285
|
+
f"{where} holds a literal {kind.replace('_', ' ')} ({label}).",
|
|
286
|
+
snippet=label,
|
|
287
|
+
severity=RULES[f"secret.{kind}"].severity,
|
|
288
|
+
)
|