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/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
mxready/api/__init__.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""进程内 API 防护中间件:滑动窗口限流 + 请求体上限(默认关闭,按需开启)。"""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from collections import defaultdict, deque
|
|
5
|
+
|
|
6
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
7
|
+
from starlette.responses import JSONResponse
|
|
8
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _SlidingWindowLimiter:
|
|
12
|
+
"""滑动窗口日志限流。桶按 IP 建立且条目过期后并不自动消失,
|
|
13
|
+
因此周期性清扫空桶与过期桶,避免公网环境下大量一次性 IP 造成内存无界增长。"""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
limit: int,
|
|
18
|
+
window_seconds: float = 60.0,
|
|
19
|
+
*,
|
|
20
|
+
sweep_interval: int = 64,
|
|
21
|
+
) -> None:
|
|
22
|
+
self._limit = limit
|
|
23
|
+
self._window = window_seconds
|
|
24
|
+
self._hits: dict[str, deque[float]] = defaultdict(deque)
|
|
25
|
+
self._sweep_interval = max(1, sweep_interval)
|
|
26
|
+
self._calls_since_sweep = 0
|
|
27
|
+
|
|
28
|
+
def allow(self, key: str) -> bool:
|
|
29
|
+
now = time.monotonic()
|
|
30
|
+
self._calls_since_sweep += 1
|
|
31
|
+
if self._calls_since_sweep >= self._sweep_interval:
|
|
32
|
+
self._calls_since_sweep = 0
|
|
33
|
+
self._sweep(now)
|
|
34
|
+
window = self._hits[key]
|
|
35
|
+
while window and now - window[0] >= self._window:
|
|
36
|
+
window.popleft()
|
|
37
|
+
if len(window) >= self._limit:
|
|
38
|
+
return False
|
|
39
|
+
window.append(now)
|
|
40
|
+
return True
|
|
41
|
+
|
|
42
|
+
def _sweep(self, now: float) -> None:
|
|
43
|
+
expired = [
|
|
44
|
+
key
|
|
45
|
+
for key, window in self._hits.items()
|
|
46
|
+
if not window or now - window[-1] >= self._window
|
|
47
|
+
]
|
|
48
|
+
for key in expired:
|
|
49
|
+
del self._hits[key]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RateLimitMiddleware(BaseHTTPMiddleware):
|
|
53
|
+
"""按客户端 IP 限制写操作频率(创建扫描 / 上传验证 / 保存清单)。"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, app, *, limit: int, window_seconds: float = 60.0) -> None:
|
|
56
|
+
super().__init__(app)
|
|
57
|
+
self._limiter = _SlidingWindowLimiter(limit, window_seconds)
|
|
58
|
+
|
|
59
|
+
async def dispatch(self, request, call_next):
|
|
60
|
+
path = request.url.path.rstrip("/")
|
|
61
|
+
is_write = (
|
|
62
|
+
request.method == "POST"
|
|
63
|
+
and (
|
|
64
|
+
path == "/api/scans"
|
|
65
|
+
or path.endswith("/verification-runs")
|
|
66
|
+
or (path.startswith("/api/scans/") and path.endswith("/reviews"))
|
|
67
|
+
)
|
|
68
|
+
) or (
|
|
69
|
+
request.method == "PATCH" and path.startswith("/api/scans/") and "/checklist/" in path
|
|
70
|
+
)
|
|
71
|
+
if is_write:
|
|
72
|
+
client_ip = request.client.host if request.client else "unknown"
|
|
73
|
+
if not self._limiter.allow(client_ip):
|
|
74
|
+
return JSONResponse(
|
|
75
|
+
status_code=429,
|
|
76
|
+
content={
|
|
77
|
+
"error": {
|
|
78
|
+
"code": "RATE_LIMITED",
|
|
79
|
+
"message": "请求过于频繁,请稍后再试。",
|
|
80
|
+
"details": {},
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
)
|
|
84
|
+
return await call_next(request)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class RequestBodyLimitMiddleware:
|
|
88
|
+
"""同时校验 Content-Length 和实际接收字节数。"""
|
|
89
|
+
|
|
90
|
+
def __init__(self, app: ASGIApp, *, max_bytes: int) -> None:
|
|
91
|
+
self.app = app
|
|
92
|
+
self._max_bytes = max_bytes
|
|
93
|
+
|
|
94
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
95
|
+
if scope["type"] != "http":
|
|
96
|
+
await self.app(scope, receive, send)
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
path = scope.get("path", "").rstrip("/")
|
|
100
|
+
# 验证结果上传走端点自身的流式分块校验(UPLOAD_TOO_LARGE 语义),
|
|
101
|
+
# 全局请求体上限在这里放行,避免错误码契约被静默替换。
|
|
102
|
+
if scope.get("method") == "POST" and path.endswith("/verification-runs"):
|
|
103
|
+
await self.app(scope, receive, send)
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
headers = {key.lower(): value for key, value in scope.get("headers", [])}
|
|
107
|
+
declared = headers.get(b"content-length")
|
|
108
|
+
if declared is not None:
|
|
109
|
+
try:
|
|
110
|
+
if int(declared.decode("ascii")) > self._max_bytes:
|
|
111
|
+
await self._reject(scope, receive, send)
|
|
112
|
+
return
|
|
113
|
+
except (UnicodeDecodeError, ValueError):
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
received = 0
|
|
117
|
+
buffered: deque[Message] = deque()
|
|
118
|
+
while True:
|
|
119
|
+
message = await receive()
|
|
120
|
+
if message["type"] != "http.request":
|
|
121
|
+
buffered.append(message)
|
|
122
|
+
break
|
|
123
|
+
received += len(message.get("body", b""))
|
|
124
|
+
if received > self._max_bytes:
|
|
125
|
+
await self._reject(scope, receive, send)
|
|
126
|
+
return
|
|
127
|
+
buffered.append(message)
|
|
128
|
+
if not message.get("more_body", False):
|
|
129
|
+
break
|
|
130
|
+
|
|
131
|
+
async def replay_receive() -> Message:
|
|
132
|
+
if buffered:
|
|
133
|
+
return buffered.popleft()
|
|
134
|
+
return await receive()
|
|
135
|
+
|
|
136
|
+
await self.app(scope, replay_receive, send)
|
|
137
|
+
|
|
138
|
+
async def _reject(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
139
|
+
response = JSONResponse(
|
|
140
|
+
status_code=413,
|
|
141
|
+
content={
|
|
142
|
+
"error": {
|
|
143
|
+
"code": "REQUEST_TOO_LARGE",
|
|
144
|
+
"message": f"请求体不能超过 {self._max_bytes} 字节。",
|
|
145
|
+
"details": {},
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
)
|
|
149
|
+
await response(scope, receive, send)
|
mxready/api/rules.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from fastapi import APIRouter, Request
|
|
2
|
+
|
|
3
|
+
from mxready.scanning.rule_loader import RuleCatalog
|
|
4
|
+
|
|
5
|
+
router = APIRouter(prefix="/rules", tags=["rules"])
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@router.get("", response_model=RuleCatalog)
|
|
9
|
+
def get_rules(request: Request) -> RuleCatalog:
|
|
10
|
+
return request.app.state.rule_catalog
|
mxready/api/scans.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Request, status
|
|
7
|
+
from fastapi.responses import Response
|
|
8
|
+
from pydantic import Field, HttpUrl
|
|
9
|
+
from starlette.concurrency import run_in_threadpool
|
|
10
|
+
|
|
11
|
+
from mxready.errors import MxReadyError
|
|
12
|
+
from mxready.models import ScanJob, ScanReport, StrictModel
|
|
13
|
+
from mxready.reporting.badge import render_badge
|
|
14
|
+
from mxready.reporting.markdown import render_markdown
|
|
15
|
+
from mxready.reporting.sarif import render_sarif
|
|
16
|
+
from mxready.verification.bundle import build_verification_bundle
|
|
17
|
+
from mxready.verification.validation import MAX_VERIFICATION_UPLOAD_BYTES
|
|
18
|
+
|
|
19
|
+
router = APIRouter(prefix="/scans", tags=["scans"])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CreateScanRequest(StrictModel):
|
|
23
|
+
repo_url: HttpUrl
|
|
24
|
+
ref: str | None = Field(default=None, max_length=200)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@router.post("", response_model=ScanJob, status_code=status.HTTP_202_ACCEPTED)
|
|
28
|
+
def create_scan(
|
|
29
|
+
payload: CreateScanRequest,
|
|
30
|
+
request: Request,
|
|
31
|
+
) -> ScanJob:
|
|
32
|
+
service = request.app.state.scan_service
|
|
33
|
+
job = service.create_scan(str(payload.repo_url), payload.ref)
|
|
34
|
+
return job
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@router.get("/{scan_id}", response_model=ScanJob)
|
|
38
|
+
def get_scan(scan_id: UUID, request: Request) -> ScanJob:
|
|
39
|
+
return request.app.state.scan_service.get_scan(scan_id)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@router.get("/{scan_id}/report", response_model=ScanReport)
|
|
43
|
+
def get_report(scan_id: UUID, request: Request) -> ScanReport:
|
|
44
|
+
return request.app.state.scan_service.get_report(scan_id)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@router.get("/{scan_id}/report.md")
|
|
48
|
+
def download_markdown_report(scan_id: UUID, request: Request) -> Response:
|
|
49
|
+
report = request.app.state.scan_service.get_report(scan_id)
|
|
50
|
+
return Response(
|
|
51
|
+
content=render_markdown(report),
|
|
52
|
+
media_type="text/markdown",
|
|
53
|
+
headers=_attachment_headers(report, "md"),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@router.get("/{scan_id}/report.sarif")
|
|
58
|
+
def download_sarif_report(scan_id: UUID, request: Request) -> Response:
|
|
59
|
+
report = request.app.state.scan_service.get_report(scan_id)
|
|
60
|
+
return Response(
|
|
61
|
+
content=render_sarif(report),
|
|
62
|
+
media_type="application/sarif+json",
|
|
63
|
+
headers=_attachment_headers(report, "sarif"),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@router.get("/{scan_id}/report.json")
|
|
68
|
+
def download_json_report(scan_id: UUID, request: Request) -> Response:
|
|
69
|
+
report = request.app.state.scan_service.get_report(scan_id)
|
|
70
|
+
return Response(
|
|
71
|
+
content=report.model_dump_json(indent=2),
|
|
72
|
+
media_type="application/json",
|
|
73
|
+
headers=_attachment_headers(report, "json"),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@router.get("/{scan_id}/badge.svg")
|
|
78
|
+
def get_badge(scan_id: UUID, request: Request) -> Response:
|
|
79
|
+
report = request.app.state.scan_service.get_report(scan_id)
|
|
80
|
+
return Response(
|
|
81
|
+
content=render_badge(report),
|
|
82
|
+
media_type="image/svg+xml",
|
|
83
|
+
headers={
|
|
84
|
+
"Cache-Control": "no-store",
|
|
85
|
+
"X-Content-Type-Options": "nosniff",
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@router.get("/{scan_id}/verification-bundle")
|
|
91
|
+
def download_verification_bundle(scan_id: UUID, request: Request) -> Response:
|
|
92
|
+
report = request.app.state.scan_service.get_report(scan_id)
|
|
93
|
+
return Response(
|
|
94
|
+
content=build_verification_bundle(report),
|
|
95
|
+
media_type="application/zip",
|
|
96
|
+
headers=_attachment_headers(
|
|
97
|
+
report,
|
|
98
|
+
"zip",
|
|
99
|
+
label="mxready-verification",
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@router.post("/{scan_id}/verification-runs", response_model=ScanReport)
|
|
105
|
+
async def upload_verification_run(
|
|
106
|
+
scan_id: UUID,
|
|
107
|
+
request: Request,
|
|
108
|
+
) -> ScanReport:
|
|
109
|
+
content_type = request.headers.get("content-type", "").partition(";")[0].strip().casefold()
|
|
110
|
+
if content_type != "application/json":
|
|
111
|
+
raise MxReadyError(
|
|
112
|
+
"VERIFICATION_SCHEMA_INVALID",
|
|
113
|
+
"Verification results must use application/json.",
|
|
114
|
+
)
|
|
115
|
+
payload = await _read_limited_body(request)
|
|
116
|
+
return await run_in_threadpool(
|
|
117
|
+
request.app.state.scan_service.attach_verification,
|
|
118
|
+
scan_id,
|
|
119
|
+
payload,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def _read_limited_body(request: Request) -> bytes:
|
|
124
|
+
declared_size = request.headers.get("content-length")
|
|
125
|
+
if declared_size is not None:
|
|
126
|
+
try:
|
|
127
|
+
if int(declared_size) > MAX_VERIFICATION_UPLOAD_BYTES:
|
|
128
|
+
raise MxReadyError(
|
|
129
|
+
"UPLOAD_TOO_LARGE",
|
|
130
|
+
"Verification results must not exceed 1 MiB.",
|
|
131
|
+
)
|
|
132
|
+
except ValueError:
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
body = bytearray()
|
|
136
|
+
async for chunk in request.stream():
|
|
137
|
+
remaining = MAX_VERIFICATION_UPLOAD_BYTES + 1 - len(body)
|
|
138
|
+
body.extend(chunk[: max(0, remaining)])
|
|
139
|
+
if len(body) > MAX_VERIFICATION_UPLOAD_BYTES or len(chunk) > remaining:
|
|
140
|
+
raise MxReadyError(
|
|
141
|
+
"UPLOAD_TOO_LARGE",
|
|
142
|
+
"Verification results must not exceed 1 MiB.",
|
|
143
|
+
)
|
|
144
|
+
return bytes(body)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _attachment_headers(
|
|
148
|
+
report: ScanReport,
|
|
149
|
+
extension: str,
|
|
150
|
+
*,
|
|
151
|
+
label: str = "mxready",
|
|
152
|
+
) -> dict[str, str]:
|
|
153
|
+
safe_repository_name = re.sub(
|
|
154
|
+
r"[^A-Za-z0-9_-]+",
|
|
155
|
+
"-",
|
|
156
|
+
report.repository.name,
|
|
157
|
+
).strip("-_")[:64]
|
|
158
|
+
safe_repository_name = safe_repository_name or "repository"
|
|
159
|
+
filename = f"{safe_repository_name}-{report.repository.commit[:12]}-{label}.{extension}"
|
|
160
|
+
return {
|
|
161
|
+
"Content-Disposition": f'attachment; filename="{filename}"',
|
|
162
|
+
"Cache-Control": "no-store",
|
|
163
|
+
"X-Content-Type-Options": "nosniff",
|
|
164
|
+
}
|
mxready/api/workspace.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""History and user annotations for a single trusted MXReady deployment."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Query, Request
|
|
8
|
+
from pydantic import StrictBool
|
|
9
|
+
|
|
10
|
+
from mxready.errors import MxReadyError
|
|
11
|
+
from mxready.models import (
|
|
12
|
+
FindingReview,
|
|
13
|
+
FindingReviewInput,
|
|
14
|
+
FindingReviewPage,
|
|
15
|
+
ScanPage,
|
|
16
|
+
ScanReport,
|
|
17
|
+
ScanStatus,
|
|
18
|
+
StrictModel,
|
|
19
|
+
VerificationCheck,
|
|
20
|
+
VerificationCommand,
|
|
21
|
+
VerificationDetail,
|
|
22
|
+
VerificationOutput,
|
|
23
|
+
VerificationPage,
|
|
24
|
+
VerificationRun,
|
|
25
|
+
VerificationSummary,
|
|
26
|
+
)
|
|
27
|
+
from mxready.verification.validation import VERIFICATION_MAX_AGE, verification_status_at
|
|
28
|
+
from mxready_runner.redact import sanitize_output
|
|
29
|
+
|
|
30
|
+
router = APIRouter(prefix="/scans", tags=["workspace"])
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ChecklistUpdate(StrictModel):
|
|
34
|
+
completed: StrictBool
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@router.get("", response_model=ScanPage)
|
|
38
|
+
def list_scans(
|
|
39
|
+
request: Request,
|
|
40
|
+
q: str = Query(default="", max_length=200),
|
|
41
|
+
status: ScanStatus | None = None,
|
|
42
|
+
limit: int = Query(default=20, ge=1, le=50),
|
|
43
|
+
offset: int = Query(default=0, ge=0, le=1_000_000),
|
|
44
|
+
) -> ScanPage:
|
|
45
|
+
return request.app.state.store.list_jobs(
|
|
46
|
+
query=q.strip(), status=status, limit=limit, offset=offset
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _summary(
|
|
51
|
+
run_id: int, uploaded: datetime | None, run: VerificationRun, latest: bool
|
|
52
|
+
) -> VerificationSummary:
|
|
53
|
+
return VerificationSummary(
|
|
54
|
+
id=run_id,
|
|
55
|
+
uploaded_at=uploaded,
|
|
56
|
+
started_at=run.started_at,
|
|
57
|
+
finished_at=run.finished_at,
|
|
58
|
+
expires_at=run.finished_at + VERIFICATION_MAX_AGE,
|
|
59
|
+
status=verification_status_at(run, now=datetime.now(UTC)),
|
|
60
|
+
overall_status=run.overall_status,
|
|
61
|
+
repository_commit=run.repository_commit,
|
|
62
|
+
environment_fingerprint=run.environment_fingerprint,
|
|
63
|
+
runner_version=run.runner_version,
|
|
64
|
+
check_count=len(run.checks),
|
|
65
|
+
command_count=len(run.commands),
|
|
66
|
+
is_latest=latest,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@router.get("/{scan_id}/verification-runs", response_model=VerificationPage)
|
|
71
|
+
def list_verifications(
|
|
72
|
+
scan_id: UUID,
|
|
73
|
+
request: Request,
|
|
74
|
+
limit: int = Query(default=10, ge=1, le=50),
|
|
75
|
+
offset: int = Query(default=0, ge=0, le=1_000_000),
|
|
76
|
+
) -> VerificationPage:
|
|
77
|
+
request.app.state.scan_service.get_scan(scan_id)
|
|
78
|
+
rows, total, latest = request.app.state.store.verification_history(
|
|
79
|
+
scan_id, limit=limit, offset=offset
|
|
80
|
+
)
|
|
81
|
+
return VerificationPage(
|
|
82
|
+
items=[_summary(i, uploaded, run, i == latest) for i, uploaded, run in rows],
|
|
83
|
+
total=total,
|
|
84
|
+
limit=limit,
|
|
85
|
+
offset=offset,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@router.get("/{scan_id}/verification-runs/{run_id}", response_model=VerificationDetail)
|
|
90
|
+
def verification_detail(scan_id: UUID, run_id: int, request: Request) -> VerificationDetail:
|
|
91
|
+
request.app.state.scan_service.get_scan(scan_id)
|
|
92
|
+
record = request.app.state.store.verification_detail(scan_id, run_id)
|
|
93
|
+
if record is None:
|
|
94
|
+
raise MxReadyError("VERIFICATION_NOT_FOUND", "The verification record does not exist.")
|
|
95
|
+
uploaded, run, latest = record
|
|
96
|
+
|
|
97
|
+
# Do not expose command arguments (which may contain credentials). Logs remain bounded
|
|
98
|
+
# and are sanitized again at this new read boundary, including legacy records.
|
|
99
|
+
def outputs(
|
|
100
|
+
items: Sequence[VerificationCheck | VerificationCommand],
|
|
101
|
+
) -> list[VerificationOutput]:
|
|
102
|
+
return [
|
|
103
|
+
VerificationOutput(
|
|
104
|
+
id=item.id,
|
|
105
|
+
status=item.status,
|
|
106
|
+
return_code=item.return_code,
|
|
107
|
+
duration_ms=item.duration_ms,
|
|
108
|
+
stdout=sanitize_output(item.stdout),
|
|
109
|
+
stderr=sanitize_output(item.stderr),
|
|
110
|
+
)
|
|
111
|
+
for item in items
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
return VerificationDetail(
|
|
115
|
+
summary=_summary(run_id, uploaded, run, latest),
|
|
116
|
+
checks=outputs(run.checks),
|
|
117
|
+
commands=outputs(run.commands),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@router.patch("/{scan_id}/checklist/{rule_id}", response_model=ScanReport)
|
|
122
|
+
def update_checklist(
|
|
123
|
+
scan_id: UUID, rule_id: str, payload: ChecklistUpdate, request: Request
|
|
124
|
+
) -> ScanReport:
|
|
125
|
+
service = request.app.state.scan_service
|
|
126
|
+
report = service.get_report(scan_id)
|
|
127
|
+
if not any(item.rule_id == rule_id for item in report.migration_checklist):
|
|
128
|
+
raise MxReadyError("CHECKLIST_ITEM_NOT_FOUND", "The checklist item does not exist.")
|
|
129
|
+
request.app.state.store.set_checklist_item(scan_id, rule_id, payload.completed)
|
|
130
|
+
return service.get_report(scan_id)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@router.post("/{scan_id}/reviews", response_model=FindingReview)
|
|
134
|
+
def append_review(scan_id: UUID, payload: FindingReviewInput, request: Request) -> FindingReview:
|
|
135
|
+
report = request.app.state.scan_service.get_report(scan_id)
|
|
136
|
+
if not any(
|
|
137
|
+
f.rule_id == payload.rule_id and f.relative_path == payload.relative_path
|
|
138
|
+
for f in report.findings
|
|
139
|
+
):
|
|
140
|
+
raise MxReadyError("FINDING_NOT_FOUND", "The finding does not exist in this report.")
|
|
141
|
+
return request.app.state.store.append_review(scan_id, payload)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@router.get("/{scan_id}/reviews", response_model=FindingReviewPage)
|
|
145
|
+
def list_reviews(
|
|
146
|
+
scan_id: UUID,
|
|
147
|
+
request: Request,
|
|
148
|
+
rule_id: str = Query(min_length=1, max_length=100),
|
|
149
|
+
relative_path: str = Query(min_length=1, max_length=4096),
|
|
150
|
+
limit: int = Query(default=20, ge=1, le=50),
|
|
151
|
+
offset: int = Query(default=0, ge=0, le=1_000_000),
|
|
152
|
+
) -> FindingReviewPage:
|
|
153
|
+
request.app.state.scan_service.get_report(scan_id)
|
|
154
|
+
return request.app.state.store.review_history(
|
|
155
|
+
scan_id, rule_id, relative_path, limit=limit, offset=offset
|
|
156
|
+
)
|
mxready/app.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from contextlib import asynccontextmanager
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
6
|
+
from fastapi.exceptions import RequestValidationError
|
|
7
|
+
from fastapi.responses import FileResponse, JSONResponse, Response
|
|
8
|
+
from fastapi.staticfiles import StaticFiles
|
|
9
|
+
from starlette.concurrency import run_in_threadpool
|
|
10
|
+
|
|
11
|
+
from mxready import __version__
|
|
12
|
+
from mxready.api import rules_router, scans_router
|
|
13
|
+
from mxready.api.middleware import (
|
|
14
|
+
RateLimitMiddleware,
|
|
15
|
+
RequestBodyLimitMiddleware,
|
|
16
|
+
)
|
|
17
|
+
from mxready.api.workspace import router as workspace_router
|
|
18
|
+
from mxready.config import Settings
|
|
19
|
+
from mxready.errors import MxReadyError
|
|
20
|
+
from mxready.logging_setup import setup_logging
|
|
21
|
+
from mxready.persistence.sqlite import SQLiteStore
|
|
22
|
+
from mxready.repository.git_client import GitClient
|
|
23
|
+
from mxready.repository.identity import build_providers
|
|
24
|
+
from mxready.scanning.analyzer import ScanAnalyzer
|
|
25
|
+
from mxready.scanning.rule_loader import load_rule_catalog
|
|
26
|
+
from mxready.services.scans import ScanService
|
|
27
|
+
from mxready.services.worker import ScanWorker
|
|
28
|
+
|
|
29
|
+
_ERROR_STATUS_CODES = {
|
|
30
|
+
"INVALID_REPOSITORY_URL": 400,
|
|
31
|
+
"UNSUPPORTED_REPOSITORY_HOST": 400,
|
|
32
|
+
"INVALID_GIT_REF": 400,
|
|
33
|
+
"SCAN_NOT_FOUND": 404,
|
|
34
|
+
"VERIFICATION_NOT_FOUND": 404,
|
|
35
|
+
"CHECKLIST_ITEM_NOT_FOUND": 404,
|
|
36
|
+
"FINDING_NOT_FOUND": 404,
|
|
37
|
+
"SCAN_NOT_COMPLETED": 409,
|
|
38
|
+
"VERIFICATION_COMMIT_MISMATCH": 409,
|
|
39
|
+
"UPLOAD_TOO_LARGE": 413,
|
|
40
|
+
"VERIFICATION_SCHEMA_INVALID": 422,
|
|
41
|
+
"SCAN_LIMIT_REACHED": 429,
|
|
42
|
+
"RATE_LIMITED": 429,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def create_app(settings: Settings | None = None, *, start_worker: bool = True) -> FastAPI:
|
|
49
|
+
resolved = settings or Settings.from_env()
|
|
50
|
+
setup_logging(resolved.log_level)
|
|
51
|
+
|
|
52
|
+
@asynccontextmanager
|
|
53
|
+
async def lifespan(app: FastAPI):
|
|
54
|
+
resolved.data_dir.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
resolved.temp_dir.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
app.state.settings = resolved
|
|
57
|
+
store = SQLiteStore(resolved.data_dir / "mxready.db")
|
|
58
|
+
store.initialize()
|
|
59
|
+
if resolved.scan_retention_days > 0:
|
|
60
|
+
pruned = store.prune_old_scans(resolved.scan_retention_days)
|
|
61
|
+
if pruned:
|
|
62
|
+
logger.info(
|
|
63
|
+
"pruned old scan records",
|
|
64
|
+
extra={"count": pruned, "retention_days": resolved.scan_retention_days},
|
|
65
|
+
)
|
|
66
|
+
app.state.store = store
|
|
67
|
+
providers = build_providers(resolved.allowed_hosts)
|
|
68
|
+
catalog = load_rule_catalog(
|
|
69
|
+
resolved.rules_dir,
|
|
70
|
+
extra_directory=resolved.extra_rules_dir,
|
|
71
|
+
)
|
|
72
|
+
app.state.rule_catalog = catalog
|
|
73
|
+
app.state.scan_service = ScanService(
|
|
74
|
+
store,
|
|
75
|
+
GitClient(),
|
|
76
|
+
ScanAnalyzer(
|
|
77
|
+
catalog,
|
|
78
|
+
providers=providers,
|
|
79
|
+
enable_prefilter=resolved.prefilter_enabled,
|
|
80
|
+
),
|
|
81
|
+
resolved,
|
|
82
|
+
providers=providers,
|
|
83
|
+
)
|
|
84
|
+
worker = ScanWorker(app.state.scan_service)
|
|
85
|
+
if start_worker:
|
|
86
|
+
worker.start()
|
|
87
|
+
try:
|
|
88
|
+
yield
|
|
89
|
+
finally:
|
|
90
|
+
if start_worker:
|
|
91
|
+
await run_in_threadpool(worker.stop)
|
|
92
|
+
|
|
93
|
+
app = FastAPI(title="MXReady", version=__version__, lifespan=lifespan)
|
|
94
|
+
if resolved.rate_limit_enabled:
|
|
95
|
+
app.add_middleware(RateLimitMiddleware, limit=resolved.rate_limit_per_minute)
|
|
96
|
+
app.add_middleware(
|
|
97
|
+
RequestBodyLimitMiddleware,
|
|
98
|
+
max_bytes=resolved.max_request_bytes,
|
|
99
|
+
)
|
|
100
|
+
app.include_router(scans_router, prefix="/api")
|
|
101
|
+
app.include_router(workspace_router, prefix="/api")
|
|
102
|
+
app.include_router(rules_router, prefix="/api")
|
|
103
|
+
|
|
104
|
+
@app.get("/api/health")
|
|
105
|
+
def health() -> dict[str, str]:
|
|
106
|
+
return {"status": "ok", "version": __version__}
|
|
107
|
+
|
|
108
|
+
@app.exception_handler(MxReadyError)
|
|
109
|
+
async def handle_mxready_error(
|
|
110
|
+
request: Request,
|
|
111
|
+
error: MxReadyError,
|
|
112
|
+
) -> JSONResponse:
|
|
113
|
+
del request
|
|
114
|
+
return JSONResponse(
|
|
115
|
+
status_code=_ERROR_STATUS_CODES.get(error.code, 500),
|
|
116
|
+
content={
|
|
117
|
+
"error": {
|
|
118
|
+
"code": error.code,
|
|
119
|
+
"message": error.message,
|
|
120
|
+
"details": error.details,
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
@app.exception_handler(RequestValidationError)
|
|
126
|
+
async def handle_request_validation(
|
|
127
|
+
request: Request,
|
|
128
|
+
error: RequestValidationError,
|
|
129
|
+
) -> JSONResponse:
|
|
130
|
+
del request, error
|
|
131
|
+
return JSONResponse(
|
|
132
|
+
status_code=400,
|
|
133
|
+
content={
|
|
134
|
+
"error": {
|
|
135
|
+
"code": "INVALID_REQUEST",
|
|
136
|
+
"message": "The request body or path parameters are invalid.",
|
|
137
|
+
"details": {},
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
_mount_built_frontend(app, resolved.frontend_dist)
|
|
143
|
+
return app
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class _ImmutableStaticFiles(StaticFiles):
|
|
147
|
+
async def get_response(self, path: str, scope) -> Response:
|
|
148
|
+
response = await super().get_response(path, scope)
|
|
149
|
+
if response.status_code < 400:
|
|
150
|
+
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
|
151
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
152
|
+
return response
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _mount_built_frontend(app: FastAPI, frontend_dist: Path) -> None:
|
|
156
|
+
index_path = frontend_dist / "index.html"
|
|
157
|
+
if not index_path.is_file():
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
assets_path = frontend_dist / "assets"
|
|
161
|
+
if assets_path.is_dir():
|
|
162
|
+
app.mount(
|
|
163
|
+
"/assets",
|
|
164
|
+
_ImmutableStaticFiles(directory=assets_path),
|
|
165
|
+
name="frontend-assets",
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
@app.get("/{full_path:path}", include_in_schema=False)
|
|
169
|
+
async def serve_spa(full_path: str) -> FileResponse:
|
|
170
|
+
if full_path == "api" or full_path.startswith("api/"):
|
|
171
|
+
raise HTTPException(status_code=404, detail="Not found")
|
|
172
|
+
return FileResponse(
|
|
173
|
+
index_path,
|
|
174
|
+
media_type="text/html",
|
|
175
|
+
headers={
|
|
176
|
+
"Cache-Control": "no-store",
|
|
177
|
+
"X-Content-Type-Options": "nosniff",
|
|
178
|
+
},
|
|
179
|
+
)
|
mxready/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Developer and release utilities for MXReady."""
|