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.
- mxready/__init__.py +1 -0
- mxready/api/__init__.py +4 -0
- mxready/api/middleware.py +149 -0
- mxready/api/rules.py +10 -0
- mxready/api/scans.py +164 -0
- mxready/api/workspace.py +156 -0
- mxready/app.py +179 -0
- mxready/cli/__init__.py +1 -0
- mxready/cli/build_verification_bundle.py +45 -0
- mxready/cli/generate_public_report.py +140 -0
- mxready/cli/scan_batch.py +368 -0
- mxready/cli/scan_repository.py +212 -0
- mxready/cli/serve.py +55 -0
- mxready/config.py +117 -0
- mxready/errors.py +11 -0
- mxready/logging_setup.py +88 -0
- mxready/mcp/__init__.py +5 -0
- mxready/mcp/server.py +433 -0
- mxready/models.py +382 -0
- mxready/persistence/__init__.py +1 -0
- mxready/persistence/sqlite.py +581 -0
- mxready/py.typed +0 -0
- mxready/reporting/__init__.py +4 -0
- mxready/reporting/badge.py +40 -0
- mxready/reporting/comparison.py +33 -0
- mxready/reporting/markdown.py +157 -0
- mxready/reporting/sarif.py +94 -0
- mxready/reporting/terminal.py +75 -0
- mxready/repository/__init__.py +14 -0
- mxready/repository/git_client.py +275 -0
- mxready/repository/github_archive.py +566 -0
- mxready/repository/identity.py +140 -0
- mxready/resources/__init__.py +19 -0
- mxready/resources/frontend/assets/index-BxJEwNUm.css +1 -0
- mxready/resources/frontend/assets/index-lzTCdWAt.js +9 -0
- mxready/resources/frontend/assets/mx-chip-C0HgFQyE.webp +0 -0
- mxready/resources/frontend/index.html +17 -0
- mxready/resources/rules/v1/core.yml +634 -0
- mxready/resources/rules/v1/manifest.yml +4 -0
- mxready/resources/schemas/verification-result-v1.json +259 -0
- mxready/scanning/__init__.py +17 -0
- mxready/scanning/analyzer.py +183 -0
- mxready/scanning/facts.py +596 -0
- mxready/scanning/indexer.py +299 -0
- mxready/scanning/path_context.py +174 -0
- mxready/scanning/python_source.py +67 -0
- mxready/scanning/rule_engine.py +595 -0
- mxready/scanning/rule_loader.py +282 -0
- mxready/scanning/toolchain.py +100 -0
- mxready/services/__init__.py +3 -0
- mxready/services/scans.py +239 -0
- mxready/services/worker.py +38 -0
- mxready/verification/__init__.py +11 -0
- mxready/verification/bundle.py +137 -0
- mxready/verification/validation.py +101 -0
- mxready-1.0.0.dist-info/METADATA +526 -0
- mxready-1.0.0.dist-info/RECORD +71 -0
- mxready-1.0.0.dist-info/WHEEL +5 -0
- mxready-1.0.0.dist-info/entry_points.txt +8 -0
- mxready-1.0.0.dist-info/licenses/LICENSE +201 -0
- mxready-1.0.0.dist-info/top_level.txt +2 -0
- mxready_runner/__init__.py +1 -0
- mxready_runner/__main__.py +3 -0
- mxready_runner/cli.py +88 -0
- mxready_runner/contracts.py +26 -0
- mxready_runner/execute.py +185 -0
- mxready_runner/inspect.py +79 -0
- mxready_runner/process.py +103 -0
- mxready_runner/py.typed +0 -0
- mxready_runner/redact.py +36 -0
- 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())
|
mxready_runner/py.typed
ADDED
|
File without changes
|
mxready_runner/redact.py
ADDED
|
@@ -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 ""))
|
mxready_runner/schema.py
ADDED
|
@@ -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
|