mxready 1.0.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 (71) hide show
  1. mxready/__init__.py +1 -0
  2. mxready/api/__init__.py +4 -0
  3. mxready/api/middleware.py +149 -0
  4. mxready/api/rules.py +10 -0
  5. mxready/api/scans.py +164 -0
  6. mxready/api/workspace.py +156 -0
  7. mxready/app.py +179 -0
  8. mxready/cli/__init__.py +1 -0
  9. mxready/cli/build_verification_bundle.py +45 -0
  10. mxready/cli/generate_public_report.py +140 -0
  11. mxready/cli/scan_batch.py +368 -0
  12. mxready/cli/scan_repository.py +212 -0
  13. mxready/cli/serve.py +55 -0
  14. mxready/config.py +117 -0
  15. mxready/errors.py +11 -0
  16. mxready/logging_setup.py +88 -0
  17. mxready/mcp/__init__.py +5 -0
  18. mxready/mcp/server.py +433 -0
  19. mxready/models.py +382 -0
  20. mxready/persistence/__init__.py +1 -0
  21. mxready/persistence/sqlite.py +581 -0
  22. mxready/py.typed +0 -0
  23. mxready/reporting/__init__.py +4 -0
  24. mxready/reporting/badge.py +40 -0
  25. mxready/reporting/comparison.py +33 -0
  26. mxready/reporting/markdown.py +157 -0
  27. mxready/reporting/sarif.py +94 -0
  28. mxready/reporting/terminal.py +75 -0
  29. mxready/repository/__init__.py +14 -0
  30. mxready/repository/git_client.py +275 -0
  31. mxready/repository/github_archive.py +566 -0
  32. mxready/repository/identity.py +140 -0
  33. mxready/resources/__init__.py +19 -0
  34. mxready/resources/frontend/assets/index-BxJEwNUm.css +1 -0
  35. mxready/resources/frontend/assets/index-lzTCdWAt.js +9 -0
  36. mxready/resources/frontend/assets/mx-chip-C0HgFQyE.webp +0 -0
  37. mxready/resources/frontend/index.html +17 -0
  38. mxready/resources/rules/v1/core.yml +634 -0
  39. mxready/resources/rules/v1/manifest.yml +4 -0
  40. mxready/resources/schemas/verification-result-v1.json +259 -0
  41. mxready/scanning/__init__.py +17 -0
  42. mxready/scanning/analyzer.py +183 -0
  43. mxready/scanning/facts.py +596 -0
  44. mxready/scanning/indexer.py +299 -0
  45. mxready/scanning/path_context.py +174 -0
  46. mxready/scanning/python_source.py +67 -0
  47. mxready/scanning/rule_engine.py +595 -0
  48. mxready/scanning/rule_loader.py +282 -0
  49. mxready/scanning/toolchain.py +100 -0
  50. mxready/services/__init__.py +3 -0
  51. mxready/services/scans.py +239 -0
  52. mxready/services/worker.py +38 -0
  53. mxready/verification/__init__.py +11 -0
  54. mxready/verification/bundle.py +137 -0
  55. mxready/verification/validation.py +101 -0
  56. mxready-1.0.0.dist-info/METADATA +526 -0
  57. mxready-1.0.0.dist-info/RECORD +71 -0
  58. mxready-1.0.0.dist-info/WHEEL +5 -0
  59. mxready-1.0.0.dist-info/entry_points.txt +8 -0
  60. mxready-1.0.0.dist-info/licenses/LICENSE +201 -0
  61. mxready-1.0.0.dist-info/top_level.txt +2 -0
  62. mxready_runner/__init__.py +1 -0
  63. mxready_runner/__main__.py +3 -0
  64. mxready_runner/cli.py +88 -0
  65. mxready_runner/contracts.py +26 -0
  66. mxready_runner/execute.py +185 -0
  67. mxready_runner/inspect.py +79 -0
  68. mxready_runner/process.py +103 -0
  69. mxready_runner/py.typed +0 -0
  70. mxready_runner/redact.py +36 -0
  71. mxready_runner/schema.py +326 -0
@@ -0,0 +1,103 @@
1
+ """Execute reviewed argument arrays while bounding captured output in memory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import signal
7
+ import subprocess
8
+ import sys
9
+ from collections.abc import Sequence
10
+ from contextlib import suppress
11
+ from subprocess import CompletedProcess, TimeoutExpired
12
+ from threading import Thread
13
+ from typing import BinaryIO
14
+
15
+ from mxready_runner.redact import MAX_OUTPUT_BYTES
16
+
17
+
18
+ class _Capture:
19
+ def __init__(self) -> None:
20
+ self.data = bytearray()
21
+ self.truncated = False
22
+
23
+ def read(self, stream: BinaryIO) -> None:
24
+ try:
25
+ while chunk := stream.read(4096):
26
+ remaining = MAX_OUTPUT_BYTES - len(self.data)
27
+ self.data.extend(chunk[:remaining])
28
+ self.truncated |= len(chunk) > remaining
29
+ finally:
30
+ stream.close()
31
+
32
+ def text(self) -> str:
33
+ suffix = b"[TRUNCATED]" if self.truncated else b""
34
+ return (
35
+ bytes(self.data[: MAX_OUTPUT_BYTES - len(suffix)]).decode(
36
+ "utf-8",
37
+ errors="replace",
38
+ )
39
+ + suffix.decode()
40
+ )
41
+
42
+
43
+ def _terminate_tree(process: subprocess.Popen[bytes]) -> None:
44
+ if sys.platform != "win32":
45
+ with suppress(ProcessLookupError):
46
+ os.killpg(process.pid, signal.SIGKILL)
47
+ else:
48
+ # Fixed system command, never interpolated project input or a shell command.
49
+ with suppress(OSError, TimeoutExpired):
50
+ subprocess.run(
51
+ ["taskkill", "/PID", str(process.pid), "/T", "/F"],
52
+ shell=False,
53
+ stdout=subprocess.DEVNULL,
54
+ stderr=subprocess.DEVNULL,
55
+ timeout=5,
56
+ check=False,
57
+ )
58
+ if process.poll() is None:
59
+ process.kill()
60
+
61
+
62
+ def run_command(
63
+ command: Sequence[str],
64
+ *,
65
+ timeout: int,
66
+ cwd: str | os.PathLike[str] | None = None,
67
+ env: dict[str, str] | None = None,
68
+ ) -> CompletedProcess[str]:
69
+ with subprocess.Popen(
70
+ list(command),
71
+ shell=False,
72
+ cwd=cwd,
73
+ env=env,
74
+ stdout=subprocess.PIPE,
75
+ stderr=subprocess.PIPE,
76
+ start_new_session=os.name == "posix",
77
+ creationflags=getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) if os.name == "nt" else 0,
78
+ ) as process:
79
+ assert process.stdout is not None and process.stderr is not None
80
+ stdout, stderr = _Capture(), _Capture()
81
+ readers = [
82
+ Thread(target=stdout.read, args=(process.stdout,), daemon=True),
83
+ Thread(target=stderr.read, args=(process.stderr,), daemon=True),
84
+ ]
85
+ for reader in readers:
86
+ reader.start()
87
+ timed_out = False
88
+ try:
89
+ process.wait(timeout=timeout)
90
+ except TimeoutExpired:
91
+ timed_out = True
92
+ _terminate_tree(process)
93
+ process.wait()
94
+ finally:
95
+ for reader in readers:
96
+ reader.join(timeout=0.1)
97
+ if any(reader.is_alive() for reader in readers):
98
+ _terminate_tree(process)
99
+ for reader in readers:
100
+ reader.join(timeout=5)
101
+ if timed_out:
102
+ raise TimeoutExpired(command, timeout, output=stdout.text(), stderr=stderr.text())
103
+ return CompletedProcess(list(command), process.returncode, stdout.text(), stderr.text())
File without changes
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ MAX_OUTPUT_BYTES = 16_384
6
+
7
+ _SENSITIVE_NAME = r"[A-Z0-9_]*(?:TOKEN|PASSWORD|SECRET|KEY|AUTHORIZATION)"
8
+ _BEARER = re.compile(r"(?i)(\bAuthorization\s*:\s*Bearer\s+)\S+")
9
+ _QUOTED_ASSIGNMENT = re.compile(rf"(?i)(\b{_SENSITIVE_NAME}\b\s*[:=]\s*)([\"'])(.*?)(\2)")
10
+ _ASSIGNMENT = re.compile(rf"(?i)(\b{_SENSITIVE_NAME}\b\s*[:=]\s*)([^\s,;]+)")
11
+ _UNIX_HOME = re.compile(r"(?i)(/(?:home|Users)/)[^/\s]+")
12
+ _WINDOWS_HOME = re.compile(r"(?i)([A-Z]:\\Users\\)[^\\\s]+")
13
+
14
+
15
+ def redact_text(value: str) -> str:
16
+ redacted = _BEARER.sub(r"\1[REDACTED]", value)
17
+ redacted = _QUOTED_ASSIGNMENT.sub(
18
+ lambda match: f"{match.group(1)}{match.group(2)}[REDACTED]{match.group(2)}",
19
+ redacted,
20
+ )
21
+ redacted = _ASSIGNMENT.sub(r"\1[REDACTED]", redacted)
22
+ redacted = _UNIX_HOME.sub(r"\1[USER]", redacted)
23
+ return _WINDOWS_HOME.sub(r"\1[USER]", redacted)
24
+
25
+
26
+ def truncate_text(value: str, max_bytes: int = MAX_OUTPUT_BYTES) -> str:
27
+ encoded = value.encode("utf-8", errors="replace")
28
+ if len(encoded) <= max_bytes:
29
+ return value
30
+ suffix = b"[TRUNCATED]"
31
+ prefix = encoded[: max(0, max_bytes - len(suffix))]
32
+ return prefix.decode("utf-8", errors="ignore") + suffix.decode("ascii")
33
+
34
+
35
+ def sanitize_output(value: str | None) -> str:
36
+ return truncate_text(redact_text(value or ""))
@@ -0,0 +1,326 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ from dataclasses import asdict, dataclass
7
+ from pathlib import Path
8
+ from typing import Any, Literal
9
+ from urllib.parse import urlsplit
10
+ from uuid import UUID
11
+
12
+ _COMMIT = re.compile(r"[0-9a-f]{40}")
13
+ _VERSION = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+")
14
+ _COMMAND_ID = re.compile(r"[a-z0-9][a-z0-9-]{0,63}")
15
+ _REPOSITORY_SEGMENT = re.compile(r"[A-Za-z0-9_.-]+")
16
+ _FORBIDDEN_EXECUTABLES = {
17
+ "apk",
18
+ "apt",
19
+ "apt-get",
20
+ "bash",
21
+ "brew",
22
+ "busybox",
23
+ "choco",
24
+ "cmd",
25
+ "command",
26
+ "conda",
27
+ "dnf",
28
+ "env",
29
+ "mamba",
30
+ "nice",
31
+ "nohup",
32
+ "npm",
33
+ "pacman",
34
+ "pip",
35
+ "pip3",
36
+ "pnpm",
37
+ "powershell",
38
+ "pwsh",
39
+ "sh",
40
+ "sudo",
41
+ "uv",
42
+ "xargs",
43
+ "yum",
44
+ "yarn",
45
+ "zsh",
46
+ }
47
+ _FORBIDDEN_PYTHON_MODULES = {
48
+ "conda",
49
+ "ensurepip",
50
+ "pip",
51
+ "pip3",
52
+ "poetry",
53
+ "uv",
54
+ }
55
+ _SHELL_TOKENS = {"&", "&&", ";", "<", "<<", ">", ">>", "|", "||"}
56
+ _REDIRECTION = re.compile(r"(?:[0-9]*)(?:>>?|<<?).+")
57
+ _MAX_MANIFEST_BYTES = 1_048_576
58
+
59
+
60
+ class ManifestError(ValueError):
61
+ pass
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class CommandSpec:
66
+ id: str
67
+ command: list[str]
68
+ timeout_seconds: int
69
+
70
+
71
+ @dataclass(frozen=True, slots=True)
72
+ class Manifest:
73
+ schema_version: str
74
+ scan_id: str
75
+ repository_url: str
76
+ repository_commit: str
77
+ runner_version: str
78
+ checks: list[CommandSpec]
79
+ project_commands: list[CommandSpec]
80
+
81
+
82
+ @dataclass(frozen=True, slots=True)
83
+ class CheckResult:
84
+ id: str
85
+ command: list[str]
86
+ status: Literal["passed", "failed", "unavailable"]
87
+ return_code: int | None
88
+ stdout: str
89
+ stderr: str
90
+ duration_ms: int
91
+
92
+ def to_dict(self) -> dict[str, Any]:
93
+ return asdict(self)
94
+
95
+
96
+ @dataclass(frozen=True, slots=True)
97
+ class CommandResult:
98
+ id: str
99
+ command: list[str]
100
+ timeout_seconds: int
101
+ status: Literal["passed", "failed", "timeout", "cancelled"]
102
+ return_code: int | None
103
+ stdout: str
104
+ stderr: str
105
+ duration_ms: int
106
+
107
+ def to_dict(self) -> dict[str, Any]:
108
+ return asdict(self)
109
+
110
+
111
+ @dataclass(frozen=True, slots=True)
112
+ class RunResult:
113
+ schema_version: str
114
+ scan_id: str
115
+ repository_commit: str
116
+ runner_version: str
117
+ environment_fingerprint: str
118
+ checks: list[CheckResult]
119
+ commands: list[CommandResult]
120
+ started_at: str
121
+ finished_at: str
122
+ overall_status: Literal["passed", "failed", "cancelled"]
123
+ repository_state: dict[str, str | bool] | None = None
124
+
125
+ def to_dict(self) -> dict[str, Any]:
126
+ return {
127
+ "schema_version": self.schema_version,
128
+ "scan_id": self.scan_id,
129
+ "repository_commit": self.repository_commit,
130
+ "runner_version": self.runner_version,
131
+ "environment_fingerprint": self.environment_fingerprint,
132
+ "checks": [item.to_dict() for item in self.checks],
133
+ "commands": [item.to_dict() for item in self.commands],
134
+ "started_at": self.started_at,
135
+ "finished_at": self.finished_at,
136
+ "overall_status": self.overall_status,
137
+ "repository_state": self.repository_state,
138
+ }
139
+
140
+ def to_json(self, *, indent: int = 2) -> str:
141
+ return json.dumps(
142
+ self.to_dict(),
143
+ ensure_ascii=False,
144
+ indent=indent,
145
+ sort_keys=True,
146
+ )
147
+
148
+
149
+ def load_manifest(path: str | Path) -> Manifest:
150
+ manifest_path = Path(path)
151
+ try:
152
+ if manifest_path.stat().st_size > _MAX_MANIFEST_BYTES:
153
+ raise ManifestError("manifest is too large")
154
+ document = json.loads(
155
+ manifest_path.read_text(encoding="utf-8-sig"),
156
+ object_pairs_hook=_unique_object,
157
+ )
158
+ return _validate_manifest(document)
159
+ except ManifestError:
160
+ raise
161
+ except (OSError, UnicodeError, json.JSONDecodeError, TypeError, ValueError) as error:
162
+ raise ManifestError("manifest is not valid JSON-compatible YAML") from error
163
+
164
+
165
+ def _validate_manifest(document: Any) -> Manifest:
166
+ if not isinstance(document, dict):
167
+ raise ManifestError("manifest root must be an object")
168
+ expected_fields = {
169
+ "schema_version",
170
+ "scan_id",
171
+ "repository_url",
172
+ "repository_commit",
173
+ "runner_version",
174
+ "checks",
175
+ "project_commands",
176
+ }
177
+ if set(document) != expected_fields:
178
+ raise ManifestError("manifest fields do not match the schema")
179
+ if document["schema_version"] != "1.0":
180
+ raise ManifestError("unsupported manifest schema version")
181
+
182
+ scan_id = _validate_uuid(document["scan_id"])
183
+ repository_url = _validate_repository_url(document["repository_url"])
184
+ repository_commit = document["repository_commit"]
185
+ if not isinstance(repository_commit, str) or _COMMIT.fullmatch(repository_commit) is None:
186
+ raise ManifestError("repository commit must be 40 lowercase hexadecimal characters")
187
+ runner_version = document["runner_version"]
188
+ if not isinstance(runner_version, str) or _VERSION.fullmatch(runner_version) is None:
189
+ raise ManifestError("runner version is invalid")
190
+
191
+ checks = _validate_command_list(document["checks"], "checks")
192
+ _validate_environment_checks(checks)
193
+ project_commands = _validate_command_list(
194
+ document["project_commands"],
195
+ "project_commands",
196
+ )
197
+ identifiers = [item.id for item in [*checks, *project_commands]]
198
+ if len(identifiers) != len(set(identifiers)):
199
+ raise ManifestError("command identifiers must be unique")
200
+
201
+ return Manifest(
202
+ schema_version="1.0",
203
+ scan_id=scan_id,
204
+ repository_url=repository_url,
205
+ repository_commit=repository_commit,
206
+ runner_version=runner_version,
207
+ checks=checks,
208
+ project_commands=project_commands,
209
+ )
210
+
211
+
212
+ def _validate_command_list(value: Any, field_name: str) -> list[CommandSpec]:
213
+ if not isinstance(value, list) or len(value) > 64:
214
+ raise ManifestError(f"{field_name} must be a bounded list")
215
+ return [_validate_command(item) for item in value]
216
+
217
+
218
+ def _validate_environment_checks(checks: list[CommandSpec]) -> None:
219
+ # contracts imports CommandSpec from this module; resolve it after module initialization.
220
+ from mxready_runner.contracts import DEFAULT_CHECKS
221
+
222
+ allowed = {check.id: check for check in DEFAULT_CHECKS}
223
+ for check in checks:
224
+ expected = allowed.get(check.id)
225
+ if (
226
+ expected is None
227
+ or check.command != expected.command
228
+ or check.timeout_seconds > expected.timeout_seconds
229
+ ):
230
+ raise ManifestError("environment checks must match the fixed runner contract")
231
+
232
+
233
+ def _validate_command(value: Any) -> CommandSpec:
234
+ if not isinstance(value, dict) or set(value) != {
235
+ "id",
236
+ "command",
237
+ "timeout_seconds",
238
+ }:
239
+ raise ManifestError("command fields do not match the schema")
240
+ identifier = value["id"]
241
+ if not isinstance(identifier, str) or _COMMAND_ID.fullmatch(identifier) is None:
242
+ raise ManifestError("command id is invalid")
243
+ command = value["command"]
244
+ if (
245
+ not isinstance(command, list)
246
+ or not 1 <= len(command) <= 32
247
+ or any(
248
+ not isinstance(argument, str)
249
+ or not argument
250
+ or len(argument) > 4_096
251
+ or any(ord(character) < 32 for character in argument)
252
+ for argument in command
253
+ )
254
+ ):
255
+ raise ManifestError("command must be a bounded array of strings")
256
+ _validate_command_safety(command)
257
+
258
+ timeout = value["timeout_seconds"]
259
+ if type(timeout) is not int or not 1 <= timeout <= 600:
260
+ raise ManifestError("command timeout must be between 1 and 600 seconds")
261
+ return CommandSpec(
262
+ id=identifier,
263
+ command=list(command),
264
+ timeout_seconds=timeout,
265
+ )
266
+
267
+
268
+ def _validate_command_safety(command: list[str]) -> None:
269
+ executable = command[0].replace("\\", "/").rsplit("/", 1)[-1].casefold()
270
+ executable = os.path.splitext(executable)[0]
271
+ if executable in _FORBIDDEN_EXECUTABLES or command[0].startswith("-"):
272
+ raise ManifestError("privileged, shell, and package-manager commands are forbidden")
273
+ if (
274
+ executable.startswith("python")
275
+ and len(command) >= 3
276
+ and command[1] == "-m"
277
+ and command[2].split(".", 1)[0].casefold() in _FORBIDDEN_PYTHON_MODULES
278
+ ):
279
+ raise ManifestError("Python package-manager modules are forbidden")
280
+ if any(argument in _SHELL_TOKENS or _REDIRECTION.fullmatch(argument) for argument in command):
281
+ raise ManifestError("shell operators and redirection are forbidden")
282
+
283
+
284
+ def _validate_uuid(value: Any) -> str:
285
+ if not isinstance(value, str):
286
+ raise ManifestError("scan id must be a UUID")
287
+ try:
288
+ parsed = UUID(value)
289
+ except ValueError as error:
290
+ raise ManifestError("scan id must be a UUID") from error
291
+ if str(parsed) != value.casefold():
292
+ raise ManifestError("scan id must use canonical UUID form")
293
+ return str(parsed)
294
+
295
+
296
+ def _validate_repository_url(value: Any) -> str:
297
+ if not isinstance(value, str) or not value.startswith("https://"):
298
+ raise ManifestError("repository URL must use HTTPS")
299
+ try:
300
+ parsed = urlsplit(value)
301
+ port = parsed.port
302
+ except ValueError as error:
303
+ raise ManifestError("repository URL is invalid") from error
304
+ if (
305
+ parsed.scheme != "https"
306
+ or parsed.hostname not in {"github.com", "gitee.com"}
307
+ or parsed.username is not None
308
+ or parsed.password is not None
309
+ or port is not None
310
+ or parsed.query
311
+ or parsed.fragment
312
+ ):
313
+ raise ManifestError("repository URL must identify a public GitHub or Gitee repo")
314
+ parts = parsed.path.removesuffix(".git").strip("/").split("/")
315
+ if len(parts) != 2 or any(_REPOSITORY_SEGMENT.fullmatch(part) is None for part in parts):
316
+ raise ManifestError("repository URL path is invalid")
317
+ return value.removesuffix(".git")
318
+
319
+
320
+ def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
321
+ result: dict[str, Any] = {}
322
+ for key, value in pairs:
323
+ if key in result:
324
+ raise ManifestError("duplicate manifest field")
325
+ result[key] = value
326
+ return result