mxready 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. mxready/__init__.py +1 -0
  2. mxready/api/__init__.py +4 -0
  3. mxready/api/middleware.py +149 -0
  4. mxready/api/rules.py +10 -0
  5. mxready/api/scans.py +164 -0
  6. mxready/api/workspace.py +156 -0
  7. mxready/app.py +179 -0
  8. mxready/cli/__init__.py +1 -0
  9. mxready/cli/build_verification_bundle.py +45 -0
  10. mxready/cli/generate_public_report.py +140 -0
  11. mxready/cli/scan_batch.py +368 -0
  12. mxready/cli/scan_repository.py +212 -0
  13. mxready/cli/serve.py +55 -0
  14. mxready/config.py +117 -0
  15. mxready/errors.py +11 -0
  16. mxready/logging_setup.py +88 -0
  17. mxready/mcp/__init__.py +5 -0
  18. mxready/mcp/server.py +433 -0
  19. mxready/models.py +382 -0
  20. mxready/persistence/__init__.py +1 -0
  21. mxready/persistence/sqlite.py +581 -0
  22. mxready/py.typed +0 -0
  23. mxready/reporting/__init__.py +4 -0
  24. mxready/reporting/badge.py +40 -0
  25. mxready/reporting/comparison.py +33 -0
  26. mxready/reporting/markdown.py +157 -0
  27. mxready/reporting/sarif.py +94 -0
  28. mxready/reporting/terminal.py +75 -0
  29. mxready/repository/__init__.py +14 -0
  30. mxready/repository/git_client.py +275 -0
  31. mxready/repository/github_archive.py +566 -0
  32. mxready/repository/identity.py +140 -0
  33. mxready/resources/__init__.py +19 -0
  34. mxready/resources/frontend/assets/index-BxJEwNUm.css +1 -0
  35. mxready/resources/frontend/assets/index-lzTCdWAt.js +9 -0
  36. mxready/resources/frontend/assets/mx-chip-C0HgFQyE.webp +0 -0
  37. mxready/resources/frontend/index.html +17 -0
  38. mxready/resources/rules/v1/core.yml +634 -0
  39. mxready/resources/rules/v1/manifest.yml +4 -0
  40. mxready/resources/schemas/verification-result-v1.json +259 -0
  41. mxready/scanning/__init__.py +17 -0
  42. mxready/scanning/analyzer.py +183 -0
  43. mxready/scanning/facts.py +596 -0
  44. mxready/scanning/indexer.py +299 -0
  45. mxready/scanning/path_context.py +174 -0
  46. mxready/scanning/python_source.py +67 -0
  47. mxready/scanning/rule_engine.py +595 -0
  48. mxready/scanning/rule_loader.py +282 -0
  49. mxready/scanning/toolchain.py +100 -0
  50. mxready/services/__init__.py +3 -0
  51. mxready/services/scans.py +239 -0
  52. mxready/services/worker.py +38 -0
  53. mxready/verification/__init__.py +11 -0
  54. mxready/verification/bundle.py +137 -0
  55. mxready/verification/validation.py +101 -0
  56. mxready-1.0.0.dist-info/METADATA +526 -0
  57. mxready-1.0.0.dist-info/RECORD +71 -0
  58. mxready-1.0.0.dist-info/WHEEL +5 -0
  59. mxready-1.0.0.dist-info/entry_points.txt +8 -0
  60. mxready-1.0.0.dist-info/licenses/LICENSE +201 -0
  61. mxready-1.0.0.dist-info/top_level.txt +2 -0
  62. mxready_runner/__init__.py +1 -0
  63. mxready_runner/__main__.py +3 -0
  64. mxready_runner/cli.py +88 -0
  65. mxready_runner/contracts.py +26 -0
  66. mxready_runner/execute.py +185 -0
  67. mxready_runner/inspect.py +79 -0
  68. mxready_runner/process.py +103 -0
  69. mxready_runner/py.typed +0 -0
  70. mxready_runner/redact.py +36 -0
  71. mxready_runner/schema.py +326 -0
mxready/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
@@ -0,0 +1,4 @@
1
+ from mxready.api.rules import router as rules_router
2
+ from mxready.api.scans import router as scans_router
3
+
4
+ __all__ = ["rules_router", "scans_router"]
@@ -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
+ }
@@ -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
+ )
@@ -0,0 +1 @@
1
+ """Developer and release utilities for MXReady."""