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
mxready/models.py
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from enum import StrEnum
|
|
3
|
+
from typing import Literal
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from pydantic import (
|
|
7
|
+
BaseModel,
|
|
8
|
+
ConfigDict,
|
|
9
|
+
Field,
|
|
10
|
+
field_validator,
|
|
11
|
+
model_validator,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class StrictModel(BaseModel):
|
|
16
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ScanStatus(StrEnum):
|
|
20
|
+
QUEUED = "queued"
|
|
21
|
+
CLONING = "cloning"
|
|
22
|
+
INDEXING = "indexing"
|
|
23
|
+
ANALYZING = "analyzing"
|
|
24
|
+
COMPLETED = "completed"
|
|
25
|
+
FAILED = "failed"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Severity(StrEnum):
|
|
29
|
+
BLOCKER = "blocker"
|
|
30
|
+
WARNING = "warning"
|
|
31
|
+
INFO = "info"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class StaticStatus(StrEnum):
|
|
35
|
+
PASSED = "passed"
|
|
36
|
+
WARNINGS = "warnings"
|
|
37
|
+
BLOCKED = "blocked"
|
|
38
|
+
FAILED = "failed"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class VerificationStatus(StrEnum):
|
|
42
|
+
NOT_RUN = "not-run"
|
|
43
|
+
VERIFIED = "verified"
|
|
44
|
+
FAILED = "failed"
|
|
45
|
+
STALE = "stale"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class BadgeStatus(StrEnum):
|
|
49
|
+
STATIC_PASSED = "static-passed"
|
|
50
|
+
WARNINGS = "warnings"
|
|
51
|
+
BLOCKED = "blocked"
|
|
52
|
+
VERIFIED = "verified"
|
|
53
|
+
VERIFICATION_STALE = "verification-stale"
|
|
54
|
+
SCAN_FAILED = "scan-failed"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ScanJob(StrictModel):
|
|
58
|
+
id: UUID
|
|
59
|
+
repo_url: str
|
|
60
|
+
requested_ref: str | None = None
|
|
61
|
+
resolved_commit: str | None = None
|
|
62
|
+
status: ScanStatus = ScanStatus.QUEUED
|
|
63
|
+
stage_message: str
|
|
64
|
+
created_at: datetime
|
|
65
|
+
updated_at: datetime
|
|
66
|
+
failure_code: str | None = None
|
|
67
|
+
failure_message: str | None = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ScanPage(StrictModel):
|
|
71
|
+
items: list[ScanJob]
|
|
72
|
+
total: int
|
|
73
|
+
limit: int
|
|
74
|
+
offset: int
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class SourceReference(StrictModel):
|
|
78
|
+
title: str
|
|
79
|
+
url: str
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class RepositorySnapshot(StrictModel):
|
|
83
|
+
provider: str
|
|
84
|
+
owner: str
|
|
85
|
+
name: str
|
|
86
|
+
url: str
|
|
87
|
+
commit: str = Field(pattern=r"^[0-9a-f]{40}$")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class FindingOccurrence(StrictModel):
|
|
91
|
+
line_start: int = Field(ge=1)
|
|
92
|
+
line_end: int = Field(ge=1)
|
|
93
|
+
evidence: str
|
|
94
|
+
|
|
95
|
+
@model_validator(mode="after")
|
|
96
|
+
def ordered(self) -> "FindingOccurrence":
|
|
97
|
+
if self.line_end < self.line_start:
|
|
98
|
+
raise ValueError("line_end must be greater than or equal to line_start")
|
|
99
|
+
return self
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ScanCoverage(StrictModel):
|
|
103
|
+
analyzed_files: int = Field(ge=0)
|
|
104
|
+
analyzed_bytes: int = Field(ge=0)
|
|
105
|
+
files_by_type: dict[str, int]
|
|
106
|
+
skipped_by_reason: dict[str, int]
|
|
107
|
+
excluded_directories: int = Field(ge=0)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Finding(StrictModel):
|
|
111
|
+
rule_id: str
|
|
112
|
+
rule_version: int = Field(ge=1)
|
|
113
|
+
severity: Severity
|
|
114
|
+
category: str
|
|
115
|
+
title: str
|
|
116
|
+
relative_path: str
|
|
117
|
+
line_start: int = Field(ge=1)
|
|
118
|
+
line_end: int = Field(ge=1)
|
|
119
|
+
evidence: str
|
|
120
|
+
message: str
|
|
121
|
+
recommendation: str
|
|
122
|
+
references: list[SourceReference]
|
|
123
|
+
count: int = Field(default=1, ge=1)
|
|
124
|
+
occurrences: list[FindingOccurrence] = Field(default_factory=list)
|
|
125
|
+
|
|
126
|
+
@model_validator(mode="after")
|
|
127
|
+
def line_range_is_ordered(self) -> "Finding":
|
|
128
|
+
if self.line_end < self.line_start:
|
|
129
|
+
raise ValueError("line_end must be greater than or equal to line_start")
|
|
130
|
+
return self
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class FindingReviewInput(StrictModel):
|
|
134
|
+
rule_id: str = Field(min_length=1, max_length=100)
|
|
135
|
+
relative_path: str = Field(min_length=1, max_length=4096)
|
|
136
|
+
status: Literal["confirmed", "false-positive", "deferred"]
|
|
137
|
+
reason: str = Field(min_length=1, max_length=1000)
|
|
138
|
+
|
|
139
|
+
@field_validator("reason")
|
|
140
|
+
@classmethod
|
|
141
|
+
def nonblank_reason(cls, value: str) -> str:
|
|
142
|
+
if not value.strip():
|
|
143
|
+
raise ValueError("A review reason is required")
|
|
144
|
+
return value.strip()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class FindingReview(FindingReviewInput):
|
|
148
|
+
id: int
|
|
149
|
+
created_at: datetime
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class FindingReviewPage(StrictModel):
|
|
153
|
+
items: list[FindingReview]
|
|
154
|
+
total: int
|
|
155
|
+
limit: int
|
|
156
|
+
offset: int
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class ScanSummary(StrictModel):
|
|
160
|
+
total_count: int = Field(ge=0)
|
|
161
|
+
blocker_count: int = Field(ge=0)
|
|
162
|
+
warning_count: int = Field(ge=0)
|
|
163
|
+
info_count: int = Field(ge=0)
|
|
164
|
+
top_blockers: list[str] = Field(default_factory=list)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class MigrationChecklistItem(StrictModel):
|
|
168
|
+
rule_id: str
|
|
169
|
+
title: str
|
|
170
|
+
action: str
|
|
171
|
+
affected_files: list[str]
|
|
172
|
+
completed: bool = False
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class AnalysisWarning(StrictModel):
|
|
176
|
+
code: str
|
|
177
|
+
relative_path: str | None = None
|
|
178
|
+
message: str
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class ScanReport(StrictModel):
|
|
182
|
+
schema_version: Literal["1.0"] = "1.0"
|
|
183
|
+
scan_id: UUID
|
|
184
|
+
repository: RepositorySnapshot
|
|
185
|
+
tool_version: str
|
|
186
|
+
ruleset_version: str
|
|
187
|
+
ruleset_digest: str | None = Field(default=None, pattern=r"^sha256:[0-9a-f]{64}$")
|
|
188
|
+
extra_ruleset_version: str | None = None
|
|
189
|
+
analysis_config: dict[str, int | float | str] = Field(default_factory=dict)
|
|
190
|
+
scanned_at: datetime
|
|
191
|
+
summary: ScanSummary
|
|
192
|
+
findings: list[Finding]
|
|
193
|
+
coverage: ScanCoverage | None = None
|
|
194
|
+
migration_checklist: list[MigrationChecklistItem]
|
|
195
|
+
analysis_warnings: list[AnalysisWarning]
|
|
196
|
+
static_status: StaticStatus
|
|
197
|
+
verification_status: VerificationStatus
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class VerificationCheck(StrictModel):
|
|
201
|
+
id: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{0,63}$")
|
|
202
|
+
command: list[str] = Field(min_length=1, max_length=32)
|
|
203
|
+
status: Literal["passed", "failed", "unavailable"]
|
|
204
|
+
return_code: int | None = None
|
|
205
|
+
stdout: str = Field(default="", max_length=16_384)
|
|
206
|
+
stderr: str = Field(default="", max_length=16_384)
|
|
207
|
+
duration_ms: int = Field(ge=0)
|
|
208
|
+
|
|
209
|
+
@field_validator("command")
|
|
210
|
+
@classmethod
|
|
211
|
+
def command_arguments_are_bounded(cls, value: list[str]) -> list[str]:
|
|
212
|
+
return _validate_result_command(value)
|
|
213
|
+
|
|
214
|
+
@model_validator(mode="after")
|
|
215
|
+
def status_matches_return_code(self) -> "VerificationCheck":
|
|
216
|
+
if self.status == "passed" and self.return_code != 0:
|
|
217
|
+
raise ValueError("passed checks must have return_code 0")
|
|
218
|
+
if self.status == "failed" and self.return_code == 0:
|
|
219
|
+
raise ValueError("failed checks cannot have return_code 0")
|
|
220
|
+
if self.status == "unavailable" and self.return_code is not None:
|
|
221
|
+
raise ValueError("unavailable checks cannot have a return code")
|
|
222
|
+
return self
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class VerificationCommand(StrictModel):
|
|
226
|
+
id: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{0,63}$")
|
|
227
|
+
command: list[str] = Field(min_length=1, max_length=32)
|
|
228
|
+
timeout_seconds: int = Field(ge=1, le=600)
|
|
229
|
+
status: Literal["passed", "failed", "timeout", "cancelled"]
|
|
230
|
+
return_code: int | None = None
|
|
231
|
+
stdout: str = Field(default="", max_length=16_384)
|
|
232
|
+
stderr: str = Field(default="", max_length=16_384)
|
|
233
|
+
duration_ms: int = Field(ge=0)
|
|
234
|
+
|
|
235
|
+
@field_validator("command")
|
|
236
|
+
@classmethod
|
|
237
|
+
def command_arguments_are_bounded(cls, value: list[str]) -> list[str]:
|
|
238
|
+
return _validate_result_command(value)
|
|
239
|
+
|
|
240
|
+
@model_validator(mode="after")
|
|
241
|
+
def status_matches_return_code(self) -> "VerificationCommand":
|
|
242
|
+
if self.status == "passed" and self.return_code != 0:
|
|
243
|
+
raise ValueError("passed commands must have return_code 0")
|
|
244
|
+
if self.status == "failed" and self.return_code == 0:
|
|
245
|
+
raise ValueError("failed commands cannot have return_code 0")
|
|
246
|
+
if self.status in {"timeout", "cancelled"} and self.return_code is not None:
|
|
247
|
+
raise ValueError("timed out or cancelled commands cannot have a return code")
|
|
248
|
+
return self
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class VerifiedRepositoryState(StrictModel):
|
|
252
|
+
working_directory: str = Field(min_length=1, max_length=4096)
|
|
253
|
+
commit: str = Field(pattern=r"^[0-9a-f]{40}$")
|
|
254
|
+
dirty: Literal[False]
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class VerificationRun(StrictModel):
|
|
258
|
+
repository_state: VerifiedRepositoryState | None = None
|
|
259
|
+
|
|
260
|
+
@model_validator(mode="after")
|
|
261
|
+
def repository_state_matches(self) -> "VerificationRun":
|
|
262
|
+
if self.repository_state and self.repository_state.commit != self.repository_commit:
|
|
263
|
+
raise ValueError("repository state commit must match repository_commit")
|
|
264
|
+
return self
|
|
265
|
+
|
|
266
|
+
schema_version: Literal["1.0"] = "1.0"
|
|
267
|
+
scan_id: UUID
|
|
268
|
+
repository_commit: str = Field(pattern=r"^[0-9a-f]{40}$")
|
|
269
|
+
runner_version: str = Field(pattern=r"^[0-9]+\.[0-9]+\.[0-9]+$")
|
|
270
|
+
environment_fingerprint: str = Field(pattern=r"^sha256:[0-9a-f]{64}$")
|
|
271
|
+
checks: list[VerificationCheck] = Field(max_length=64)
|
|
272
|
+
commands: list[VerificationCommand] = Field(max_length=64)
|
|
273
|
+
started_at: datetime
|
|
274
|
+
finished_at: datetime
|
|
275
|
+
overall_status: Literal["passed", "failed", "cancelled"]
|
|
276
|
+
|
|
277
|
+
@model_validator(mode="after")
|
|
278
|
+
def result_is_semantically_consistent(self) -> "VerificationRun":
|
|
279
|
+
identifiers = [item.id for item in self.checks] + [item.id for item in self.commands]
|
|
280
|
+
if len(identifiers) != len(set(identifiers)):
|
|
281
|
+
raise ValueError("verification result ids must be unique")
|
|
282
|
+
if self.overall_status == "passed" and any(item.status != "passed" for item in self.checks):
|
|
283
|
+
raise ValueError("passed results cannot contain failed environment checks")
|
|
284
|
+
if self.overall_status == "passed" and any(
|
|
285
|
+
item.status != "passed" for item in self.commands
|
|
286
|
+
):
|
|
287
|
+
raise ValueError("passed results cannot contain failed commands")
|
|
288
|
+
return self
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class VerificationSummary(StrictModel):
|
|
292
|
+
id: int
|
|
293
|
+
uploaded_at: datetime | None
|
|
294
|
+
started_at: datetime
|
|
295
|
+
finished_at: datetime
|
|
296
|
+
expires_at: datetime
|
|
297
|
+
status: VerificationStatus
|
|
298
|
+
overall_status: str
|
|
299
|
+
repository_commit: str
|
|
300
|
+
environment_fingerprint: str
|
|
301
|
+
runner_version: str
|
|
302
|
+
check_count: int
|
|
303
|
+
command_count: int
|
|
304
|
+
is_latest: bool
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class VerificationPage(StrictModel):
|
|
308
|
+
items: list[VerificationSummary]
|
|
309
|
+
total: int
|
|
310
|
+
limit: int
|
|
311
|
+
offset: int
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class VerificationOutput(StrictModel):
|
|
315
|
+
id: str
|
|
316
|
+
status: str
|
|
317
|
+
return_code: int | None
|
|
318
|
+
duration_ms: int
|
|
319
|
+
stdout: str
|
|
320
|
+
stderr: str
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class VerificationDetail(StrictModel):
|
|
324
|
+
summary: VerificationSummary
|
|
325
|
+
checks: list[VerificationOutput]
|
|
326
|
+
commands: list[VerificationOutput]
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _validate_result_command(value: list[str]) -> list[str]:
|
|
330
|
+
if any(
|
|
331
|
+
not argument or len(argument) > 4_096 or any(ord(character) < 32 for character in argument)
|
|
332
|
+
for argument in value
|
|
333
|
+
):
|
|
334
|
+
raise ValueError("command arguments must be bounded printable strings")
|
|
335
|
+
return value
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def summarize_findings(findings: list[Finding]) -> ScanSummary:
|
|
339
|
+
return ScanSummary(
|
|
340
|
+
total_count=len(findings),
|
|
341
|
+
blocker_count=sum(item.severity is Severity.BLOCKER for item in findings),
|
|
342
|
+
warning_count=sum(item.severity is Severity.WARNING for item in findings),
|
|
343
|
+
info_count=sum(item.severity is Severity.INFO for item in findings),
|
|
344
|
+
top_blockers=_top_priority_files(findings),
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _top_priority_files(findings: list[Finding], limit: int = 10) -> list[str]:
|
|
349
|
+
"""按受影响文件数给出优先复核清单:优先 blocker,其次 warning。"""
|
|
350
|
+
from collections import Counter
|
|
351
|
+
|
|
352
|
+
counter: Counter[str] = Counter()
|
|
353
|
+
for item in findings:
|
|
354
|
+
if item.severity is Severity.BLOCKER:
|
|
355
|
+
counter[item.relative_path] += 1
|
|
356
|
+
if not counter:
|
|
357
|
+
for item in findings:
|
|
358
|
+
if item.severity is Severity.WARNING:
|
|
359
|
+
counter[item.relative_path] += 1
|
|
360
|
+
return [path for path, _ in counter.most_common(limit)]
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def calculate_static_status(summary: ScanSummary) -> StaticStatus:
|
|
364
|
+
if summary.blocker_count:
|
|
365
|
+
return StaticStatus.BLOCKED
|
|
366
|
+
if summary.warning_count:
|
|
367
|
+
return StaticStatus.WARNINGS
|
|
368
|
+
return StaticStatus.PASSED
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def calculate_badge_status(report: ScanReport) -> BadgeStatus:
|
|
372
|
+
if report.static_status is StaticStatus.FAILED:
|
|
373
|
+
return BadgeStatus.SCAN_FAILED
|
|
374
|
+
if report.static_status is StaticStatus.BLOCKED:
|
|
375
|
+
return BadgeStatus.BLOCKED
|
|
376
|
+
if report.verification_status is VerificationStatus.VERIFIED:
|
|
377
|
+
return BadgeStatus.VERIFIED
|
|
378
|
+
if report.verification_status is VerificationStatus.STALE:
|
|
379
|
+
return BadgeStatus.VERIFICATION_STALE
|
|
380
|
+
if report.static_status is StaticStatus.WARNINGS:
|
|
381
|
+
return BadgeStatus.WARNINGS
|
|
382
|
+
return BadgeStatus.STATIC_PASSED
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Persistence adapters and schema migrations."""
|