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,282 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
from pydantic import Field, ValidationError, field_validator, model_validator
|
|
12
|
+
|
|
13
|
+
from mxready.errors import MxReadyError
|
|
14
|
+
from mxready.models import Severity, SourceReference, StrictModel
|
|
15
|
+
|
|
16
|
+
_RULE_ID = re.compile(r"MXR-[A-Z]+-[0-9]{3}")
|
|
17
|
+
_CATEGORY = re.compile(r"[a-z][a-z0-9-]*")
|
|
18
|
+
_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*")
|
|
19
|
+
_FACT_NAME = re.compile(r"[A-Za-z][A-Za-z0-9_]*")
|
|
20
|
+
_RULE_FILE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]*\.ya?ml")
|
|
21
|
+
_REGEX_FLAGS = {
|
|
22
|
+
"ASCII": re.ASCII,
|
|
23
|
+
"DOTALL": re.DOTALL,
|
|
24
|
+
"IGNORECASE": re.IGNORECASE,
|
|
25
|
+
"MULTILINE": re.MULTILINE,
|
|
26
|
+
"VERBOSE": re.VERBOSE,
|
|
27
|
+
}
|
|
28
|
+
_MAX_RULE_FILE_BYTES = 1_048_576
|
|
29
|
+
_RULE_UPDATED_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RulePattern(StrictModel):
|
|
33
|
+
type: Literal["regex", "dependency", "fact"]
|
|
34
|
+
expression: str | None = None
|
|
35
|
+
flags: list[str] = Field(default_factory=list)
|
|
36
|
+
name: str | None = None
|
|
37
|
+
equals: bool | str | None = None
|
|
38
|
+
|
|
39
|
+
@field_validator("flags")
|
|
40
|
+
@classmethod
|
|
41
|
+
def flags_are_supported_and_unique(cls, value: list[str]) -> list[str]:
|
|
42
|
+
if len(value) != len(set(value)) or any(flag not in _REGEX_FLAGS for flag in value):
|
|
43
|
+
raise ValueError("regex flags must be supported and unique")
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
@model_validator(mode="after")
|
|
47
|
+
def pattern_contract_matches_type(self) -> RulePattern:
|
|
48
|
+
if self.type == "regex":
|
|
49
|
+
if (
|
|
50
|
+
not isinstance(self.expression, str)
|
|
51
|
+
or not self.expression.strip()
|
|
52
|
+
or len(self.expression) > 1_000
|
|
53
|
+
or self.name is not None
|
|
54
|
+
or self.equals is not None
|
|
55
|
+
):
|
|
56
|
+
raise ValueError("invalid regex pattern contract")
|
|
57
|
+
try:
|
|
58
|
+
re.compile(self.expression, regex_flags(self.flags))
|
|
59
|
+
except re.error as error:
|
|
60
|
+
raise ValueError("invalid regex expression") from error
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
if self.type == "dependency":
|
|
64
|
+
if (
|
|
65
|
+
not isinstance(self.name, str)
|
|
66
|
+
or _NAME.fullmatch(self.name) is None
|
|
67
|
+
or self.expression is not None
|
|
68
|
+
or self.flags
|
|
69
|
+
or self.equals is not None
|
|
70
|
+
):
|
|
71
|
+
raise ValueError("invalid dependency pattern contract")
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
if (
|
|
75
|
+
not isinstance(self.name, str)
|
|
76
|
+
or _FACT_NAME.fullmatch(self.name) is None
|
|
77
|
+
or self.equals is None
|
|
78
|
+
or self.expression is not None
|
|
79
|
+
or self.flags
|
|
80
|
+
):
|
|
81
|
+
raise ValueError("invalid fact pattern contract")
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class RuleDefinition(StrictModel):
|
|
86
|
+
id: str
|
|
87
|
+
version: int = Field(ge=1)
|
|
88
|
+
title: str
|
|
89
|
+
category: str
|
|
90
|
+
severity: Severity
|
|
91
|
+
file_globs: list[str] = Field(min_length=1)
|
|
92
|
+
patterns: list[RulePattern] = Field(min_length=1)
|
|
93
|
+
message: str
|
|
94
|
+
recommendation: str
|
|
95
|
+
references: list[SourceReference] = Field(min_length=1)
|
|
96
|
+
updated: str | None = None
|
|
97
|
+
confidence: Literal["documented", "needs-review"] = "documented"
|
|
98
|
+
|
|
99
|
+
@field_validator("updated")
|
|
100
|
+
@classmethod
|
|
101
|
+
def updated_has_iso_date(cls, value: str | None) -> str | None:
|
|
102
|
+
if value is not None and _RULE_UPDATED_DATE.fullmatch(value) is None:
|
|
103
|
+
raise ValueError("updated must use YYYY-MM-DD")
|
|
104
|
+
return value
|
|
105
|
+
|
|
106
|
+
@field_validator("id")
|
|
107
|
+
@classmethod
|
|
108
|
+
def id_has_public_format(cls, value: str) -> str:
|
|
109
|
+
if _RULE_ID.fullmatch(value) is None:
|
|
110
|
+
raise ValueError("invalid rule id")
|
|
111
|
+
return value
|
|
112
|
+
|
|
113
|
+
@field_validator("category")
|
|
114
|
+
@classmethod
|
|
115
|
+
def category_has_stable_format(cls, value: str) -> str:
|
|
116
|
+
if _CATEGORY.fullmatch(value) is None:
|
|
117
|
+
raise ValueError("invalid rule category")
|
|
118
|
+
return value
|
|
119
|
+
|
|
120
|
+
@field_validator("title", "message", "recommendation")
|
|
121
|
+
@classmethod
|
|
122
|
+
def prose_is_non_empty(cls, value: str) -> str:
|
|
123
|
+
stripped = value.strip()
|
|
124
|
+
if not stripped:
|
|
125
|
+
raise ValueError("rule prose must not be empty")
|
|
126
|
+
return stripped
|
|
127
|
+
|
|
128
|
+
@field_validator("file_globs")
|
|
129
|
+
@classmethod
|
|
130
|
+
def file_globs_are_relative_and_unique(cls, value: list[str]) -> list[str]:
|
|
131
|
+
if len(value) != len(set(value)):
|
|
132
|
+
raise ValueError("file globs must be unique")
|
|
133
|
+
for pattern in value:
|
|
134
|
+
if (
|
|
135
|
+
not pattern
|
|
136
|
+
or len(pattern) > 200
|
|
137
|
+
or pattern.startswith(("/", "\\"))
|
|
138
|
+
or "\\" in pattern
|
|
139
|
+
or ".." in pattern.split("/")
|
|
140
|
+
):
|
|
141
|
+
raise ValueError("file globs must be safe relative patterns")
|
|
142
|
+
return value
|
|
143
|
+
|
|
144
|
+
@field_validator("references")
|
|
145
|
+
@classmethod
|
|
146
|
+
def references_are_https(
|
|
147
|
+
cls,
|
|
148
|
+
value: list[SourceReference],
|
|
149
|
+
) -> list[SourceReference]:
|
|
150
|
+
for reference in value:
|
|
151
|
+
try:
|
|
152
|
+
parsed = urlsplit(reference.url)
|
|
153
|
+
port = parsed.port
|
|
154
|
+
except ValueError as error:
|
|
155
|
+
raise ValueError("invalid reference URL") from error
|
|
156
|
+
if (
|
|
157
|
+
not reference.title.strip()
|
|
158
|
+
or not reference.url.startswith("https://")
|
|
159
|
+
or parsed.scheme != "https"
|
|
160
|
+
or not parsed.hostname
|
|
161
|
+
or parsed.username is not None
|
|
162
|
+
or parsed.password is not None
|
|
163
|
+
or port is not None
|
|
164
|
+
):
|
|
165
|
+
raise ValueError("rule references must use public HTTPS URLs")
|
|
166
|
+
return value
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class RuleCatalog(StrictModel):
|
|
170
|
+
version: str
|
|
171
|
+
rules: list[RuleDefinition]
|
|
172
|
+
extra_version: str | None = None
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def digest(self) -> str:
|
|
176
|
+
document = self.model_dump(mode="json")
|
|
177
|
+
document["rules"] = sorted(document["rules"], key=lambda rule: rule["id"])
|
|
178
|
+
payload = json.dumps(document, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
179
|
+
return "sha256:" + hashlib.sha256(payload.encode()).hexdigest()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class _RuleManifest(StrictModel):
|
|
183
|
+
schema_version: Literal["1.0"]
|
|
184
|
+
ruleset_version: str
|
|
185
|
+
rule_files: list[str] = Field(min_length=1)
|
|
186
|
+
|
|
187
|
+
@field_validator("ruleset_version")
|
|
188
|
+
@classmethod
|
|
189
|
+
def version_is_non_empty(cls, value: str) -> str:
|
|
190
|
+
stripped = value.strip()
|
|
191
|
+
if not stripped or len(stripped) > 100:
|
|
192
|
+
raise ValueError("invalid ruleset version")
|
|
193
|
+
return stripped
|
|
194
|
+
|
|
195
|
+
@field_validator("rule_files")
|
|
196
|
+
@classmethod
|
|
197
|
+
def files_are_safe_and_unique(cls, value: list[str]) -> list[str]:
|
|
198
|
+
if len(value) != len(set(value)) or any(
|
|
199
|
+
_RULE_FILE.fullmatch(item) is None for item in value
|
|
200
|
+
):
|
|
201
|
+
raise ValueError("invalid rule file list")
|
|
202
|
+
return value
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def load_rule_catalog(
|
|
206
|
+
directory: Path,
|
|
207
|
+
extra_directory: Path | None = None,
|
|
208
|
+
) -> RuleCatalog:
|
|
209
|
+
"""Load a complete, strictly validated, versioned YAML rule catalog.
|
|
210
|
+
|
|
211
|
+
`extra_directory` 可选:合并另一目录中的规则集(自定义规则扩展)。
|
|
212
|
+
"""
|
|
213
|
+
try:
|
|
214
|
+
catalog = _load_catalog(Path(directory))
|
|
215
|
+
if extra_directory is None:
|
|
216
|
+
return catalog
|
|
217
|
+
return _merge_catalogs(catalog, _load_catalog(Path(extra_directory)))
|
|
218
|
+
except MxReadyError:
|
|
219
|
+
raise
|
|
220
|
+
except (
|
|
221
|
+
OSError,
|
|
222
|
+
TypeError,
|
|
223
|
+
UnicodeError,
|
|
224
|
+
ValidationError,
|
|
225
|
+
ValueError,
|
|
226
|
+
yaml.YAMLError,
|
|
227
|
+
) as error:
|
|
228
|
+
raise _ruleset_invalid() from error
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _load_catalog(directory: Path) -> RuleCatalog:
|
|
232
|
+
manifest_document = _load_yaml(directory / "manifest.yml")
|
|
233
|
+
manifest = _RuleManifest.model_validate(manifest_document)
|
|
234
|
+
|
|
235
|
+
rules: list[RuleDefinition] = []
|
|
236
|
+
seen_ids: set[str] = set()
|
|
237
|
+
for filename in manifest.rule_files:
|
|
238
|
+
document = _load_yaml(directory / filename)
|
|
239
|
+
if not isinstance(document, list) or not document:
|
|
240
|
+
raise _ruleset_invalid()
|
|
241
|
+
for rule_document in document:
|
|
242
|
+
rule = RuleDefinition.model_validate(rule_document)
|
|
243
|
+
if rule.id in seen_ids:
|
|
244
|
+
raise _ruleset_invalid()
|
|
245
|
+
seen_ids.add(rule.id)
|
|
246
|
+
rules.append(rule)
|
|
247
|
+
|
|
248
|
+
if not rules:
|
|
249
|
+
raise _ruleset_invalid()
|
|
250
|
+
return RuleCatalog(version=manifest.ruleset_version, rules=rules)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _merge_catalogs(base: RuleCatalog, extra: RuleCatalog) -> RuleCatalog:
|
|
254
|
+
merged = {rule.id: rule for rule in base.rules}
|
|
255
|
+
for rule in extra.rules:
|
|
256
|
+
existing = merged.get(rule.id)
|
|
257
|
+
if existing is not None and existing != rule:
|
|
258
|
+
raise _ruleset_invalid()
|
|
259
|
+
merged[rule.id] = rule
|
|
260
|
+
return RuleCatalog(
|
|
261
|
+
version=base.version, rules=list(merged.values()), extra_version=extra.version
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def regex_flags(flags: list[str]) -> re.RegexFlag:
|
|
266
|
+
resolved = re.NOFLAG
|
|
267
|
+
for flag in flags:
|
|
268
|
+
resolved |= _REGEX_FLAGS[flag]
|
|
269
|
+
return resolved
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _load_yaml(path: Path) -> Any:
|
|
273
|
+
if path.stat().st_size > _MAX_RULE_FILE_BYTES:
|
|
274
|
+
raise ValueError("rule file is too large")
|
|
275
|
+
return yaml.safe_load(path.read_text(encoding="utf-8-sig"))
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _ruleset_invalid() -> MxReadyError:
|
|
279
|
+
return MxReadyError(
|
|
280
|
+
"RULESET_INVALID",
|
|
281
|
+
"规则集无法加载或未通过校验,请检查规则文件。",
|
|
282
|
+
)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""工具链画像:接入 cu-bridge 之后,部分"未迁移"前提下成立的命中不再是阻塞项。
|
|
2
|
+
|
|
3
|
+
静态命中本身没有错——`NVCC = nvcc` 确实还在文件里。错的是严重级别所依赖的前提:
|
|
4
|
+
"项目尚未接入 MXMACA 兼容层"。cu-bridge 的 `make_maca` / `cucc` 会在构建期接管
|
|
5
|
+
这些调用,此时该写法是**预期状态**,继续报 blocker 等于把已迁移项目判成未迁移。
|
|
6
|
+
|
|
7
|
+
降级必须留痕,否则就变成用规则把结论藏起来。因此每次降级都会:
|
|
8
|
+
1. 把严重级别下调一级,并在 message 末尾注明原因;
|
|
9
|
+
2. 往 `analysis_warnings` 里写一条可追溯记录(含规则 ID 与文件数)。
|
|
10
|
+
|
|
11
|
+
只降一级、不降到 info:cu-bridge 接管的是构建入口,无法证明每个 nvcc 调用都走了
|
|
12
|
+
包装器——脚本里 `subprocess.run(["nvcc", ...])` 仍然绕过它,那属于真问题,降成
|
|
13
|
+
info 会放跑。
|
|
14
|
+
|
|
15
|
+
同理,降级只作用于**声明式构建文件**(Makefile / `*.mk` / CMakeLists.txt / `*.cmake`)。
|
|
16
|
+
这些文件里的 `NVCC = nvcc` 是构建设置,由 cu-bridge 的 conf.json 在编译期改写;
|
|
17
|
+
而 `.py` / `.sh` 里的 nvcc 是运行时命令,是否走了包装器只能人工判断,保持 blocker。
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from mxready.models import AnalysisWarning, Finding, Severity
|
|
23
|
+
from mxready.scanning.facts import ProjectFacts
|
|
24
|
+
|
|
25
|
+
CU_BRIDGE_FLAG = "uses_cu_bridge_toolchain"
|
|
26
|
+
CU_BRIDGE_WARNING_CODE = "TOOLCHAIN_CU_BRIDGE_DOWNGRADE"
|
|
27
|
+
|
|
28
|
+
# 规则 ID -> 降级后的级别。只有"前提是未迁移"的规则才该进这张表;
|
|
29
|
+
# 描述源码语义(PTX、线程束宽度)的规则与构建方式无关,不适用。
|
|
30
|
+
_CU_BRIDGE_DOWNGRADES: dict[str, Severity] = {
|
|
31
|
+
"MXR-TOOLCHAIN-001": Severity.WARNING,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# 只在这些文件里认定"构建入口已被接管"。命令式脚本不在内。
|
|
35
|
+
_BUILD_DEFINITION_NAMES = {"cmakelists.txt", "gnumakefile", "makefile"}
|
|
36
|
+
_BUILD_DEFINITION_SUFFIXES = (".cmake", ".mk")
|
|
37
|
+
|
|
38
|
+
_DOWNGRADE_NOTE = (
|
|
39
|
+
"(已检测到 cu-bridge 工具链接入:该构建入口通常由 make_maca/cucc 接管,"
|
|
40
|
+
"此项从 blocker 降为 warning,请确认是否有脚本绕过包装器。)"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def apply_toolchain_profile(
|
|
45
|
+
findings: list[Finding],
|
|
46
|
+
facts: ProjectFacts,
|
|
47
|
+
) -> tuple[list[Finding], list[AnalysisWarning]]:
|
|
48
|
+
"""按工具链画像调整严重级别,返回新列表与需要记录到报告的告警。"""
|
|
49
|
+
if not facts.flags.get(CU_BRIDGE_FLAG):
|
|
50
|
+
return findings, []
|
|
51
|
+
|
|
52
|
+
adjusted: list[Finding] = []
|
|
53
|
+
downgraded_rules: dict[str, set[str]] = {}
|
|
54
|
+
for finding in findings:
|
|
55
|
+
target = _CU_BRIDGE_DOWNGRADES.get(finding.rule_id)
|
|
56
|
+
if (
|
|
57
|
+
target is None
|
|
58
|
+
or finding.severity is not Severity.BLOCKER
|
|
59
|
+
or not _is_build_definition(finding.relative_path)
|
|
60
|
+
):
|
|
61
|
+
adjusted.append(finding)
|
|
62
|
+
continue
|
|
63
|
+
downgraded_rules.setdefault(finding.rule_id, set()).add(finding.relative_path)
|
|
64
|
+
adjusted.append(
|
|
65
|
+
finding.model_copy(
|
|
66
|
+
update={
|
|
67
|
+
"severity": target,
|
|
68
|
+
"message": _append_note(finding.message),
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if not downgraded_rules:
|
|
74
|
+
return adjusted, []
|
|
75
|
+
|
|
76
|
+
details = ";".join(
|
|
77
|
+
f"{rule_id}({len(paths)} 个文件)" for rule_id, paths in sorted(downgraded_rules.items())
|
|
78
|
+
)
|
|
79
|
+
return adjusted, [
|
|
80
|
+
AnalysisWarning(
|
|
81
|
+
code=CU_BRIDGE_WARNING_CODE,
|
|
82
|
+
relative_path=None,
|
|
83
|
+
message=(
|
|
84
|
+
f"检测到 cu-bridge 工具链接入,已将 {details} 从 blocker 降为 warning。"
|
|
85
|
+
"降级只覆盖声明式构建文件;命令式脚本(.py / .sh)中的 nvcc 调用仍按 "
|
|
86
|
+
"blocker 报告,因为它可能绕过 make_maca 的 PATH 注入。"
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _is_build_definition(relative_path: str) -> bool:
|
|
93
|
+
name = relative_path.rsplit("/", 1)[-1].casefold()
|
|
94
|
+
return name in _BUILD_DEFINITION_NAMES or name.endswith(_BUILD_DEFINITION_SUFFIXES)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _append_note(message: str) -> str:
|
|
98
|
+
if _DOWNGRADE_NOTE in message:
|
|
99
|
+
return message
|
|
100
|
+
return f"{message}{_DOWNGRADE_NOTE}"
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from tempfile import TemporaryDirectory
|
|
9
|
+
from threading import Event, Thread
|
|
10
|
+
from uuid import UUID, uuid4
|
|
11
|
+
|
|
12
|
+
from mxready.config import Settings
|
|
13
|
+
from mxready.errors import MxReadyError
|
|
14
|
+
from mxready.models import ScanJob, ScanReport, ScanStatus
|
|
15
|
+
from mxready.persistence.sqlite import SQLiteStore
|
|
16
|
+
from mxready.repository.git_client import GitClient
|
|
17
|
+
from mxready.repository.identity import parse_repository_url, validate_git_ref
|
|
18
|
+
from mxready.scanning.analyzer import ScanAnalyzer
|
|
19
|
+
from mxready.verification.validation import (
|
|
20
|
+
validate_verification_upload,
|
|
21
|
+
verification_status_at,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
_STAGE_MESSAGES = {
|
|
27
|
+
ScanStatus.CLONING: "正在安全获取仓库",
|
|
28
|
+
ScanStatus.INDEXING: "正在建立源码索引",
|
|
29
|
+
ScanStatus.ANALYZING: "正在执行兼容性规则",
|
|
30
|
+
ScanStatus.COMPLETED: "扫描完成",
|
|
31
|
+
ScanStatus.FAILED: "扫描失败",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ScanService:
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
store: SQLiteStore,
|
|
39
|
+
git_client: GitClient,
|
|
40
|
+
analyzer: ScanAnalyzer,
|
|
41
|
+
settings: Settings,
|
|
42
|
+
providers: Mapping[str, str] | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self.store = store
|
|
45
|
+
self.git_client = git_client
|
|
46
|
+
self.analyzer = analyzer
|
|
47
|
+
self.settings = settings
|
|
48
|
+
self.providers = providers
|
|
49
|
+
|
|
50
|
+
def create_scan(
|
|
51
|
+
self,
|
|
52
|
+
repo_url: str,
|
|
53
|
+
requested_ref: str | None,
|
|
54
|
+
) -> ScanJob:
|
|
55
|
+
identity = parse_repository_url(repo_url, self.providers)
|
|
56
|
+
reference = validate_git_ref(requested_ref)
|
|
57
|
+
canonical_url = identity.clone_url.removesuffix(".git")
|
|
58
|
+
return self.store.create_job(
|
|
59
|
+
canonical_url,
|
|
60
|
+
reference,
|
|
61
|
+
max_active=self.settings.max_concurrent_scans,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def get_scan(self, scan_id: UUID) -> ScanJob:
|
|
65
|
+
job = self.store.get_job(scan_id)
|
|
66
|
+
if job is None:
|
|
67
|
+
raise MxReadyError(
|
|
68
|
+
"SCAN_NOT_FOUND",
|
|
69
|
+
"The requested scan does not exist.",
|
|
70
|
+
)
|
|
71
|
+
return job
|
|
72
|
+
|
|
73
|
+
def get_report(self, scan_id: UUID) -> ScanReport:
|
|
74
|
+
job = self.get_scan(scan_id)
|
|
75
|
+
if job.status is not ScanStatus.COMPLETED:
|
|
76
|
+
raise MxReadyError(
|
|
77
|
+
"SCAN_NOT_COMPLETED",
|
|
78
|
+
"The requested scan has not completed successfully.",
|
|
79
|
+
)
|
|
80
|
+
report = self.store.get_report(scan_id)
|
|
81
|
+
if report is None:
|
|
82
|
+
raise MxReadyError(
|
|
83
|
+
"SCAN_INTERNAL_ERROR",
|
|
84
|
+
"The completed scan report could not be loaded.",
|
|
85
|
+
)
|
|
86
|
+
run = self.store.get_verification(scan_id)
|
|
87
|
+
if run is not None:
|
|
88
|
+
report = report.model_copy(
|
|
89
|
+
update={
|
|
90
|
+
"verification_status": verification_status_at(run, now=datetime.now(UTC)),
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
return report
|
|
94
|
+
|
|
95
|
+
def run_scan(self, scan_id: UUID) -> None:
|
|
96
|
+
job = self.get_scan(scan_id)
|
|
97
|
+
token = str(uuid4())
|
|
98
|
+
if not self.store.claim_job(scan_id, token, lease_seconds=self.settings.task_lease_seconds):
|
|
99
|
+
return
|
|
100
|
+
with self._heartbeat(scan_id, token):
|
|
101
|
+
self._run_claimed_scan(job, token)
|
|
102
|
+
|
|
103
|
+
@contextmanager
|
|
104
|
+
def _heartbeat(self, scan_id: UUID, token: str):
|
|
105
|
+
stopped = Event()
|
|
106
|
+
|
|
107
|
+
def renew() -> None:
|
|
108
|
+
while not stopped.wait(self.settings.task_lease_seconds / 3):
|
|
109
|
+
try:
|
|
110
|
+
if not self.store.renew_lease(
|
|
111
|
+
scan_id,
|
|
112
|
+
token,
|
|
113
|
+
lease_seconds=self.settings.task_lease_seconds,
|
|
114
|
+
):
|
|
115
|
+
return
|
|
116
|
+
except Exception:
|
|
117
|
+
logger.exception("scan lease renewal failed", extra={"scan_id": str(scan_id)})
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
thread = Thread(target=renew, name=f"scan-heartbeat-{scan_id}", daemon=True)
|
|
121
|
+
thread.start()
|
|
122
|
+
try:
|
|
123
|
+
yield
|
|
124
|
+
finally:
|
|
125
|
+
stopped.set()
|
|
126
|
+
thread.join()
|
|
127
|
+
|
|
128
|
+
def _run_claimed_scan(self, job: ScanJob, token: str) -> None:
|
|
129
|
+
scan_id = job.id
|
|
130
|
+
|
|
131
|
+
logger.info("scan started", extra={"scan_id": str(scan_id)})
|
|
132
|
+
try:
|
|
133
|
+
self.store.update_job(
|
|
134
|
+
scan_id,
|
|
135
|
+
status=ScanStatus.CLONING,
|
|
136
|
+
stage_message=_STAGE_MESSAGES[ScanStatus.CLONING],
|
|
137
|
+
worker_token=token,
|
|
138
|
+
)
|
|
139
|
+
identity = parse_repository_url(job.repo_url, self.providers)
|
|
140
|
+
|
|
141
|
+
with TemporaryDirectory(
|
|
142
|
+
prefix=f"mxready-{scan_id}-",
|
|
143
|
+
dir=self.settings.temp_dir,
|
|
144
|
+
) as temporary_directory:
|
|
145
|
+
repository_root = Path(temporary_directory) / "repository"
|
|
146
|
+
commit = self.git_client.clone(
|
|
147
|
+
identity,
|
|
148
|
+
job.requested_ref,
|
|
149
|
+
repository_root,
|
|
150
|
+
)
|
|
151
|
+
logger.info(
|
|
152
|
+
"repository cloned",
|
|
153
|
+
extra={"scan_id": str(scan_id), "commit": commit},
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def persist_stage(status: ScanStatus) -> None:
|
|
157
|
+
self.store.update_job(
|
|
158
|
+
scan_id,
|
|
159
|
+
status=status,
|
|
160
|
+
stage_message=_STAGE_MESSAGES[status],
|
|
161
|
+
resolved_commit=commit,
|
|
162
|
+
worker_token=token,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
report = self.analyzer.analyze(
|
|
166
|
+
repository_root,
|
|
167
|
+
repository_url=job.repo_url,
|
|
168
|
+
commit=commit,
|
|
169
|
+
scan_id=scan_id,
|
|
170
|
+
stage_callback=persist_stage,
|
|
171
|
+
)
|
|
172
|
+
self.store.save_report(report, worker_token=token)
|
|
173
|
+
logger.info(
|
|
174
|
+
"scan analysis completed",
|
|
175
|
+
extra={"scan_id": str(scan_id)},
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
self.store.update_job(
|
|
179
|
+
scan_id,
|
|
180
|
+
status=ScanStatus.COMPLETED,
|
|
181
|
+
stage_message=_STAGE_MESSAGES[ScanStatus.COMPLETED],
|
|
182
|
+
resolved_commit=commit,
|
|
183
|
+
worker_token=token,
|
|
184
|
+
)
|
|
185
|
+
except MxReadyError as error:
|
|
186
|
+
if error.code != "SCAN_LEASE_LOST":
|
|
187
|
+
self._mark_failed(scan_id, error.code, error.message, token)
|
|
188
|
+
except Exception:
|
|
189
|
+
logger.exception(
|
|
190
|
+
"Unexpected scan failure for scan_id=%s",
|
|
191
|
+
scan_id,
|
|
192
|
+
)
|
|
193
|
+
self._mark_failed(
|
|
194
|
+
scan_id,
|
|
195
|
+
"SCAN_INTERNAL_ERROR",
|
|
196
|
+
"The scan failed unexpectedly. Start a new scan and try again.",
|
|
197
|
+
token,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def attach_verification(
|
|
201
|
+
self,
|
|
202
|
+
scan_id: UUID,
|
|
203
|
+
payload: bytes,
|
|
204
|
+
*,
|
|
205
|
+
now: datetime | None = None,
|
|
206
|
+
) -> ScanReport:
|
|
207
|
+
report = self.get_report(scan_id)
|
|
208
|
+
validated = validate_verification_upload(
|
|
209
|
+
report,
|
|
210
|
+
payload,
|
|
211
|
+
now=now or datetime.now(UTC),
|
|
212
|
+
)
|
|
213
|
+
updated_report = report.model_copy(update={"verification_status": validated.status})
|
|
214
|
+
self.store.save_verification_and_report(
|
|
215
|
+
scan_id,
|
|
216
|
+
validated.run,
|
|
217
|
+
updated_report,
|
|
218
|
+
)
|
|
219
|
+
return updated_report
|
|
220
|
+
|
|
221
|
+
def _mark_failed(
|
|
222
|
+
self,
|
|
223
|
+
scan_id: UUID,
|
|
224
|
+
code: str,
|
|
225
|
+
message: str,
|
|
226
|
+
token: str,
|
|
227
|
+
) -> None:
|
|
228
|
+
try:
|
|
229
|
+
self.store.update_job(
|
|
230
|
+
scan_id,
|
|
231
|
+
status=ScanStatus.FAILED,
|
|
232
|
+
stage_message=_STAGE_MESSAGES[ScanStatus.FAILED],
|
|
233
|
+
failure_code=code,
|
|
234
|
+
failure_message=message,
|
|
235
|
+
worker_token=token,
|
|
236
|
+
)
|
|
237
|
+
except MxReadyError as error:
|
|
238
|
+
if error.code != "SCAN_LEASE_LOST":
|
|
239
|
+
raise
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Bounded consumers of the durable SQLite scan queue."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from threading import Event, Thread
|
|
5
|
+
|
|
6
|
+
from mxready.services.scans import ScanService
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ScanWorker:
|
|
12
|
+
def __init__(self, service: ScanService) -> None:
|
|
13
|
+
self.service = service
|
|
14
|
+
self.stopped = Event()
|
|
15
|
+
self.threads: list[Thread] = []
|
|
16
|
+
|
|
17
|
+
def start(self) -> None:
|
|
18
|
+
for index in range(self.service.settings.scan_worker_threads):
|
|
19
|
+
thread = Thread(target=self._consume, name=f"scan-worker-{index}", daemon=True)
|
|
20
|
+
self.threads.append(thread)
|
|
21
|
+
thread.start()
|
|
22
|
+
|
|
23
|
+
def stop(self) -> None:
|
|
24
|
+
self.stopped.set()
|
|
25
|
+
for thread in self.threads:
|
|
26
|
+
thread.join()
|
|
27
|
+
|
|
28
|
+
def _consume(self) -> None:
|
|
29
|
+
while not self.stopped.is_set():
|
|
30
|
+
try:
|
|
31
|
+
self.service.store.recover_expired_jobs()
|
|
32
|
+
for scan_id in self.service.store.queued_job_ids():
|
|
33
|
+
if self.stopped.is_set():
|
|
34
|
+
break
|
|
35
|
+
self.service.run_scan(scan_id)
|
|
36
|
+
except Exception:
|
|
37
|
+
logger.exception("scan queue consumer failed")
|
|
38
|
+
self.stopped.wait(0.5)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from mxready.verification.bundle import build_verification_bundle
|
|
2
|
+
from mxready.verification.validation import (
|
|
3
|
+
ValidatedVerification,
|
|
4
|
+
validate_verification_upload,
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ValidatedVerification",
|
|
9
|
+
"build_verification_bundle",
|
|
10
|
+
"validate_verification_upload",
|
|
11
|
+
]
|