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.
Files changed (72) hide show
  1. kestrix_scan/__init__.py +86 -0
  2. kestrix_scan/__main__.py +9 -0
  3. kestrix_scan/about.py +8 -0
  4. kestrix_scan/agent_config.py +288 -0
  5. kestrix_scan/analyze.py +424 -0
  6. kestrix_scan/c_tokens.py +486 -0
  7. kestrix_scan/ci_rules.py +310 -0
  8. kestrix_scan/claude_plugin/kestrix/.claude-plugin/plugin.json +17 -0
  9. kestrix_scan/claude_plugin/kestrix/hooks/hooks.json +83 -0
  10. kestrix_scan/claude_plugin/kestrix/hooks/kestrix_hook.py +981 -0
  11. kestrix_scan/claude_plugin/kestrix/skills/fix/SKILL.md +30 -0
  12. kestrix_scan/claude_plugin/kestrix/skills/fixing-security-findings/SKILL.md +3921 -0
  13. kestrix_scan/claude_plugin/kestrix/skills/kestrix-security/SKILL.md +244 -0
  14. kestrix_scan/claude_plugin/kestrix/skills/kestrix-security/reference/bitcoin.md +52 -0
  15. kestrix_scan/claude_plugin/kestrix/skills/kestrix-security/reference/detect.md +99 -0
  16. kestrix_scan/claude_plugin/kestrix/skills/kestrix-security/reference/findings-forensics.md +62 -0
  17. kestrix_scan/claude_plugin/kestrix/skills/kestrix-security/reference/findings-mail.md +118 -0
  18. kestrix_scan/claude_plugin/kestrix/skills/kestrix-security/reference/findings-webcheck.md +97 -0
  19. kestrix_scan/claude_plugin/kestrix/skills/kestrix-security/reference/json-fields.md +94 -0
  20. kestrix_scan/claude_plugin/kestrix/skills/review/SKILL.md +31 -0
  21. kestrix_scan/claude_plugin/kestrix/skills/scan/SKILL.md +32 -0
  22. kestrix_scan/claude_plugin/kestrix/skills/secure-code-review/SKILL.md +132 -0
  23. kestrix_scan/claude_plugin/kestrix/skills/status/SKILL.md +26 -0
  24. kestrix_scan/cli.py +491 -0
  25. kestrix_scan/config.py +267 -0
  26. kestrix_scan/deps.py +333 -0
  27. kestrix_scan/docker_rules.py +291 -0
  28. kestrix_scan/ecoversions.py +272 -0
  29. kestrix_scan/errors.py +24 -0
  30. kestrix_scan/hook.py +296 -0
  31. kestrix_scan/html_tokens.py +236 -0
  32. kestrix_scan/iac.py +55 -0
  33. kestrix_scan/infra_rules.py +490 -0
  34. kestrix_scan/init.py +237 -0
  35. kestrix_scan/install_guard.py +481 -0
  36. kestrix_scan/js_index.py +1498 -0
  37. kestrix_scan/js_rules.py +945 -0
  38. kestrix_scan/js_tokens.py +311 -0
  39. kestrix_scan/lockfiles.py +363 -0
  40. kestrix_scan/lockfiles_more.py +325 -0
  41. kestrix_scan/models.py +196 -0
  42. kestrix_scan/osvdb.py +584 -0
  43. kestrix_scan/php_index.py +787 -0
  44. kestrix_scan/php_rules.py +599 -0
  45. kestrix_scan/php_tokens.py +202 -0
  46. kestrix_scan/plugin.py +100 -0
  47. kestrix_scan/popular.py +283 -0
  48. kestrix_scan/popular_names.json +1 -0
  49. kestrix_scan/py.typed +0 -0
  50. kestrix_scan/pytest_plugin.py +66 -0
  51. kestrix_scan/python_rules.py +1595 -0
  52. kestrix_scan/registry.py +2195 -0
  53. kestrix_scan/ruby_tokens.py +310 -0
  54. kestrix_scan/sarif.py +143 -0
  55. kestrix_scan/secrets.py +217 -0
  56. kestrix_scan/server_index.py +1022 -0
  57. kestrix_scan/server_rules.py +885 -0
  58. kestrix_scan/shell_rules.py +299 -0
  59. kestrix_scan/skill.py +174 -0
  60. kestrix_scan/sql_rules.py +207 -0
  61. kestrix_scan/systems_rules.py +809 -0
  62. kestrix_scan/taint.py +1055 -0
  63. kestrix_scan/versions.py +218 -0
  64. kestrix_scan/walk.py +315 -0
  65. kestrix_scan/web_rules.py +352 -0
  66. kestrix_scan/yaml_lite.py +475 -0
  67. kestrix_scan-0.1.0.dist-info/METADATA +371 -0
  68. kestrix_scan-0.1.0.dist-info/RECORD +72 -0
  69. kestrix_scan-0.1.0.dist-info/WHEEL +4 -0
  70. kestrix_scan-0.1.0.dist-info/entry_points.txt +6 -0
  71. kestrix_scan-0.1.0.dist-info/licenses/LICENSE +202 -0
  72. kestrix_scan-0.1.0.dist-info/licenses/NOTICE +34 -0
@@ -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})
@@ -0,0 +1,9 @@
1
+ # SPDX-FileCopyrightText: 2026 Ruthvik Bandari
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """``python -m kestrix_scan``: the same as the ``kestrix-scan`` command."""
4
+
5
+ import sys
6
+
7
+ from .cli import main
8
+
9
+ sys.exit(main(prog="python -m kestrix_scan"))
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
+ )