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
@@ -0,0 +1,581 @@
1
+ import sqlite3
2
+ from collections.abc import Iterator
3
+ from contextlib import contextmanager
4
+ from datetime import UTC, datetime, timedelta
5
+ from pathlib import Path
6
+ from uuid import UUID, uuid4
7
+
8
+ from mxready.errors import MxReadyError
9
+ from mxready.models import (
10
+ FindingReview,
11
+ FindingReviewInput,
12
+ FindingReviewPage,
13
+ ScanJob,
14
+ ScanPage,
15
+ ScanReport,
16
+ ScanStatus,
17
+ VerificationRun,
18
+ )
19
+
20
+ # 顺序迁移列表:索引 i 对应 user_version = i + 1。新增表结构变更时在末尾追加,
21
+ # initialize() 会从当前版本顺序执行到最新版本。
22
+ _MIGRATIONS: list[str] = [
23
+ # v1:初始表
24
+ """
25
+ CREATE TABLE IF NOT EXISTS scan_jobs (
26
+ id TEXT PRIMARY KEY,
27
+ repo_url TEXT NOT NULL,
28
+ requested_ref TEXT,
29
+ resolved_commit TEXT,
30
+ status TEXT NOT NULL,
31
+ stage_message TEXT NOT NULL,
32
+ created_at TEXT NOT NULL,
33
+ updated_at TEXT NOT NULL,
34
+ failure_code TEXT,
35
+ failure_message TEXT,
36
+ report_json TEXT,
37
+ verification_json TEXT
38
+ )
39
+ """,
40
+ "ALTER TABLE scan_jobs ADD COLUMN worker_token TEXT",
41
+ "ALTER TABLE scan_jobs ADD COLUMN lease_expires_at TEXT",
42
+ "CREATE INDEX IF NOT EXISTS scan_jobs_status_created ON scan_jobs(status, created_at)",
43
+ "CREATE INDEX IF NOT EXISTS scan_jobs_updated ON scan_jobs(updated_at)",
44
+ """CREATE TABLE verification_runs (
45
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
46
+ scan_id TEXT NOT NULL REFERENCES scan_jobs(id) ON DELETE CASCADE,
47
+ uploaded_at TEXT,
48
+ verification_json TEXT NOT NULL
49
+ )""",
50
+ "CREATE INDEX verification_runs_scan ON verification_runs(scan_id, id DESC)",
51
+ """INSERT INTO verification_runs(scan_id, uploaded_at, verification_json)
52
+ SELECT id, NULL, verification_json FROM scan_jobs
53
+ WHERE verification_json IS NOT NULL""",
54
+ """CREATE TABLE checklist_progress (
55
+ scan_id TEXT NOT NULL REFERENCES scan_jobs(id) ON DELETE CASCADE,
56
+ rule_id TEXT NOT NULL,
57
+ completed INTEGER NOT NULL CHECK(completed IN (0, 1)),
58
+ PRIMARY KEY (scan_id, rule_id)
59
+ )""",
60
+ """CREATE TABLE finding_reviews (
61
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
62
+ scan_id TEXT NOT NULL REFERENCES scan_jobs(id) ON DELETE CASCADE,
63
+ rule_id TEXT NOT NULL,
64
+ relative_path TEXT NOT NULL,
65
+ status TEXT NOT NULL CHECK(status IN ('confirmed', 'false-positive', 'deferred')),
66
+ reason TEXT NOT NULL,
67
+ created_at TEXT NOT NULL
68
+ )""",
69
+ "CREATE INDEX finding_reviews_lookup "
70
+ "ON finding_reviews(scan_id, rule_id, relative_path, id DESC)",
71
+ ]
72
+
73
+
74
+ class SQLiteStore:
75
+ def __init__(self, path: Path) -> None:
76
+ self.path = path
77
+
78
+ @contextmanager
79
+ def _connect(self) -> Iterator[sqlite3.Connection]:
80
+ connection = sqlite3.connect(self.path, timeout=5)
81
+ connection.row_factory = sqlite3.Row
82
+ connection.execute("PRAGMA foreign_keys = ON")
83
+ try:
84
+ with connection:
85
+ yield connection
86
+ finally:
87
+ connection.close()
88
+
89
+ def initialize(self) -> None:
90
+ self.path.parent.mkdir(parents=True, exist_ok=True)
91
+ with self._connect() as connection:
92
+ connection.execute("PRAGMA journal_mode = WAL")
93
+ connection.execute("BEGIN IMMEDIATE")
94
+ current = int(connection.execute("PRAGMA user_version").fetchone()[0])
95
+ for version in range(current, len(_MIGRATIONS)):
96
+ connection.execute(_MIGRATIONS[version])
97
+ connection.execute(f"PRAGMA user_version = {version + 1}")
98
+
99
+ def create_job(
100
+ self,
101
+ repo_url: str,
102
+ requested_ref: str | None,
103
+ *,
104
+ max_active: int = 0,
105
+ ) -> ScanJob:
106
+ now = datetime.now(UTC)
107
+ job = ScanJob(
108
+ id=uuid4(),
109
+ repo_url=repo_url,
110
+ requested_ref=requested_ref,
111
+ status=ScanStatus.QUEUED,
112
+ stage_message="Waiting to start",
113
+ created_at=now,
114
+ updated_at=now,
115
+ )
116
+ with self._connect() as connection:
117
+ if max_active > 0:
118
+ connection.execute("BEGIN IMMEDIATE")
119
+ active = self._count_active_jobs(connection)
120
+ if active >= max_active:
121
+ raise MxReadyError(
122
+ "SCAN_LIMIT_REACHED",
123
+ "并发扫描数量已达上限,请稍后再试。",
124
+ {"active": str(active), "limit": str(max_active)},
125
+ )
126
+ connection.execute(
127
+ """
128
+ INSERT INTO scan_jobs (
129
+ id, repo_url, requested_ref, resolved_commit, status,
130
+ stage_message, created_at, updated_at, failure_code,
131
+ failure_message
132
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
133
+ """,
134
+ (
135
+ str(job.id),
136
+ job.repo_url,
137
+ job.requested_ref,
138
+ job.resolved_commit,
139
+ job.status.value,
140
+ job.stage_message,
141
+ job.created_at.isoformat(),
142
+ job.updated_at.isoformat(),
143
+ job.failure_code,
144
+ job.failure_message,
145
+ ),
146
+ )
147
+ return job
148
+
149
+ def get_job(self, scan_id: UUID) -> ScanJob | None:
150
+ with self._connect() as connection:
151
+ row = connection.execute(
152
+ """
153
+ SELECT id, repo_url, requested_ref, resolved_commit, status,
154
+ stage_message, created_at, updated_at, failure_code,
155
+ failure_message
156
+ FROM scan_jobs
157
+ WHERE id = ?
158
+ """,
159
+ (str(scan_id),),
160
+ ).fetchone()
161
+ if row is None:
162
+ return None
163
+ return self._job_from_row(row)
164
+
165
+ def list_jobs(
166
+ self,
167
+ *,
168
+ query: str = "",
169
+ status: ScanStatus | None = None,
170
+ limit: int = 20,
171
+ offset: int = 0,
172
+ ) -> ScanPage:
173
+ # instr treats %, _ and quote characters literally; all values are bound.
174
+ where = "instr(lower(repo_url), lower(?)) > 0 AND (? IS NULL OR status = ?)"
175
+ parameters = (query, status, status)
176
+ with self._connect() as connection:
177
+ connection.execute("BEGIN")
178
+ total = connection.execute(
179
+ f"SELECT COUNT(*) FROM scan_jobs WHERE {where}", parameters
180
+ ).fetchone()[0]
181
+ rows = connection.execute(
182
+ "SELECT id, repo_url, requested_ref, resolved_commit, status, stage_message, "
183
+ "created_at, updated_at, failure_code, failure_message FROM scan_jobs "
184
+ f"WHERE {where} ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?",
185
+ (*parameters, limit, offset),
186
+ ).fetchall()
187
+ return ScanPage(
188
+ items=[self._job_from_row(row) for row in rows], total=total, limit=limit, offset=offset
189
+ )
190
+
191
+ def update_job(
192
+ self,
193
+ scan_id: UUID,
194
+ *,
195
+ status: ScanStatus,
196
+ stage_message: str,
197
+ resolved_commit: str | None = None,
198
+ failure_code: str | None = None,
199
+ failure_message: str | None = None,
200
+ worker_token: str | None = None,
201
+ ) -> ScanJob:
202
+ current = self.get_job(scan_id)
203
+ if current is None:
204
+ raise MxReadyError(
205
+ "SCAN_NOT_FOUND",
206
+ "The requested scan does not exist.",
207
+ {"scan_id": str(scan_id)},
208
+ )
209
+ updated = current.model_copy(
210
+ update={
211
+ "status": status,
212
+ "stage_message": stage_message,
213
+ "resolved_commit": resolved_commit or current.resolved_commit,
214
+ "failure_code": failure_code,
215
+ "failure_message": failure_message,
216
+ "updated_at": datetime.now(UTC),
217
+ }
218
+ )
219
+ with self._connect() as connection:
220
+ connection.execute(
221
+ """
222
+ UPDATE scan_jobs
223
+ SET resolved_commit = ?, status = ?, stage_message = ?,
224
+ updated_at = ?, failure_code = ?, failure_message = ?
225
+ WHERE id = ? AND (? IS NULL OR
226
+ (worker_token = ? AND lease_expires_at > ?))
227
+ """,
228
+ (
229
+ updated.resolved_commit,
230
+ updated.status.value,
231
+ updated.stage_message,
232
+ updated.updated_at.isoformat(),
233
+ updated.failure_code,
234
+ updated.failure_message,
235
+ str(scan_id),
236
+ worker_token,
237
+ worker_token,
238
+ datetime.now(UTC).isoformat(),
239
+ ),
240
+ )
241
+ if connection.execute("SELECT changes()").fetchone()[0] != 1:
242
+ raise MxReadyError("SCAN_LEASE_LOST", "The scan execution lease has expired.")
243
+ if status in {ScanStatus.COMPLETED, ScanStatus.FAILED}:
244
+ connection.execute(
245
+ "UPDATE scan_jobs SET worker_token = NULL, lease_expires_at = NULL "
246
+ "WHERE id = ?",
247
+ (str(scan_id),),
248
+ )
249
+ return updated
250
+
251
+ def recover_expired_jobs(self, *, now: datetime | None = None) -> None:
252
+ """Fail abandoned executions only; queued and live leased jobs survive startup."""
253
+ timestamp = (now or datetime.now(UTC)).isoformat()
254
+ with self._connect() as connection:
255
+ connection.execute(
256
+ """
257
+ UPDATE scan_jobs
258
+ SET status = ?, stage_message = ?, updated_at = ?,
259
+ failure_code = ?, failure_message = ?,
260
+ worker_token = NULL, lease_expires_at = NULL
261
+ WHERE status IN (?, ?, ?) AND
262
+ (lease_expires_at IS NULL OR lease_expires_at <= ?)
263
+ """,
264
+ (
265
+ ScanStatus.FAILED.value,
266
+ "Scan interrupted",
267
+ timestamp,
268
+ "SCAN_INTERRUPTED",
269
+ ("The MXReady service restarted before this scan completed. Start a new scan."),
270
+ ScanStatus.CLONING.value,
271
+ ScanStatus.INDEXING.value,
272
+ ScanStatus.ANALYZING.value,
273
+ timestamp,
274
+ ),
275
+ )
276
+
277
+ def queued_job_ids(self, limit: int = 1) -> list[UUID]:
278
+ with self._connect() as connection:
279
+ rows = connection.execute(
280
+ "SELECT id FROM scan_jobs WHERE status = ? ORDER BY created_at, id LIMIT ?",
281
+ (ScanStatus.QUEUED.value, limit),
282
+ ).fetchall()
283
+ return [UUID(row["id"]) for row in rows]
284
+
285
+ def claim_job(self, scan_id: UUID, token: str, *, lease_seconds: int) -> bool:
286
+ now = datetime.now(UTC)
287
+ with self._connect() as connection:
288
+ cursor = connection.execute(
289
+ """UPDATE scan_jobs SET worker_token = ?, lease_expires_at = ?,
290
+ status = ?, stage_message = ?, updated_at = ?
291
+ WHERE id = ? AND status = ?""",
292
+ (
293
+ token,
294
+ (now + timedelta(seconds=lease_seconds)).isoformat(),
295
+ ScanStatus.CLONING.value,
296
+ "正在安全获取仓库",
297
+ now.isoformat(),
298
+ str(scan_id),
299
+ ScanStatus.QUEUED.value,
300
+ ),
301
+ )
302
+ return cursor.rowcount == 1
303
+
304
+ def renew_lease(self, scan_id: UUID, token: str, *, lease_seconds: int) -> bool:
305
+ now = datetime.now(UTC)
306
+ with self._connect() as connection:
307
+ cursor = connection.execute(
308
+ """UPDATE scan_jobs SET lease_expires_at = ?
309
+ WHERE id = ? AND worker_token = ? AND lease_expires_at > ?
310
+ AND status IN (?, ?, ?)""",
311
+ (
312
+ (now + timedelta(seconds=lease_seconds)).isoformat(),
313
+ str(scan_id),
314
+ token,
315
+ now.isoformat(),
316
+ ScanStatus.CLONING.value,
317
+ ScanStatus.INDEXING.value,
318
+ ScanStatus.ANALYZING.value,
319
+ ),
320
+ )
321
+ return cursor.rowcount == 1
322
+
323
+ def count_active_jobs(self) -> int:
324
+ """统计排队与执行中的扫描任务数。"""
325
+ with self._connect() as connection:
326
+ return self._count_active_jobs(connection)
327
+
328
+ @staticmethod
329
+ def _count_active_jobs(connection: sqlite3.Connection) -> int:
330
+ row = connection.execute(
331
+ """
332
+ SELECT COUNT(*) FROM scan_jobs
333
+ WHERE status IN (?, ?, ?, ?)
334
+ """,
335
+ (
336
+ ScanStatus.QUEUED.value,
337
+ ScanStatus.CLONING.value,
338
+ ScanStatus.INDEXING.value,
339
+ ScanStatus.ANALYZING.value,
340
+ ),
341
+ ).fetchone()
342
+ return int(row[0])
343
+
344
+ def prune_old_scans(self, days: int) -> int:
345
+ """删除超过 `days` 天未更新的扫描记录,返回删除条数。"""
346
+ if days <= 0:
347
+ return 0
348
+ cutoff = (datetime.now(UTC) - timedelta(days=days)).isoformat()
349
+ with self._connect() as connection:
350
+ cursor = connection.execute(
351
+ "DELETE FROM scan_jobs WHERE updated_at < ? AND status IN (?, ?)",
352
+ (cutoff, ScanStatus.COMPLETED.value, ScanStatus.FAILED.value),
353
+ )
354
+ return cursor.rowcount
355
+
356
+ def backup(self, destination: Path) -> None:
357
+ """使用 SQLite 在线备份 API 将数据库备份到 `destination`。"""
358
+ destination.parent.mkdir(parents=True, exist_ok=True)
359
+ source = sqlite3.connect(self.path, timeout=5)
360
+ target = sqlite3.connect(destination)
361
+ try:
362
+ with target:
363
+ source.backup(target)
364
+ finally:
365
+ target.close()
366
+ source.close()
367
+
368
+ def save_report(self, report: ScanReport, *, worker_token: str | None = None) -> None:
369
+ with self._connect() as connection:
370
+ cursor = connection.execute(
371
+ """UPDATE scan_jobs SET report_json = ?, updated_at = ? WHERE id = ?
372
+ AND (? IS NULL OR (worker_token = ? AND lease_expires_at > ?))""",
373
+ (
374
+ report.model_dump_json(),
375
+ datetime.now(UTC).isoformat(),
376
+ str(report.scan_id),
377
+ worker_token,
378
+ worker_token,
379
+ datetime.now(UTC).isoformat(),
380
+ ),
381
+ )
382
+ if cursor.rowcount == 0 and worker_token is not None:
383
+ raise MxReadyError("SCAN_LEASE_LOST", "The scan execution lease has expired.")
384
+ self._ensure_updated(cursor.rowcount, report.scan_id)
385
+
386
+ def get_report(self, scan_id: UUID) -> ScanReport | None:
387
+ with self._connect() as connection:
388
+ row = connection.execute(
389
+ "SELECT report_json FROM scan_jobs WHERE id = ?",
390
+ (str(scan_id),),
391
+ ).fetchone()
392
+ progress = connection.execute(
393
+ "SELECT rule_id, completed FROM checklist_progress WHERE scan_id = ?",
394
+ (str(scan_id),),
395
+ ).fetchall()
396
+ if row is None or row["report_json"] is None:
397
+ return None
398
+ report = ScanReport.model_validate_json(row["report_json"])
399
+ saved = {item["rule_id"]: bool(item["completed"]) for item in progress}
400
+ return report.model_copy(
401
+ update={
402
+ "migration_checklist": [
403
+ item.model_copy(update={"completed": saved.get(item.rule_id, item.completed)})
404
+ for item in report.migration_checklist
405
+ ]
406
+ }
407
+ )
408
+
409
+ def append_review(self, scan_id: UUID, payload: FindingReviewInput) -> FindingReview:
410
+ now = datetime.now(UTC)
411
+ with self._connect() as connection:
412
+ cursor = connection.execute(
413
+ "INSERT INTO finding_reviews(scan_id, rule_id, relative_path, "
414
+ "status, reason, created_at) "
415
+ "VALUES (?, ?, ?, ?, ?, ?)",
416
+ (
417
+ str(scan_id),
418
+ payload.rule_id,
419
+ payload.relative_path,
420
+ payload.status,
421
+ payload.reason,
422
+ now.isoformat(),
423
+ ),
424
+ )
425
+ assert cursor.lastrowid is not None
426
+ return FindingReview(**payload.model_dump(), id=cursor.lastrowid, created_at=now)
427
+
428
+ def review_history(
429
+ self, scan_id: UUID, rule_id: str, relative_path: str, *, limit: int, offset: int
430
+ ) -> FindingReviewPage:
431
+ args = (str(scan_id), rule_id, relative_path)
432
+ where = " WHERE scan_id = ? AND rule_id = ? AND relative_path = ?"
433
+ with self._connect() as connection:
434
+ total = connection.execute(
435
+ "SELECT COUNT(*) FROM finding_reviews" + where, args
436
+ ).fetchone()[0]
437
+ rows = connection.execute(
438
+ "SELECT id, rule_id, relative_path, status, reason, created_at FROM finding_reviews"
439
+ + where
440
+ + " ORDER BY id DESC LIMIT ? OFFSET ?",
441
+ (*args, limit, offset),
442
+ ).fetchall()
443
+ return FindingReviewPage(
444
+ items=[FindingReview.model_validate(dict(row)) for row in rows],
445
+ total=total,
446
+ limit=limit,
447
+ offset=offset,
448
+ )
449
+
450
+ def set_checklist_item(self, scan_id: UUID, rule_id: str, completed: bool) -> None:
451
+ with self._connect() as connection:
452
+ connection.execute(
453
+ "INSERT INTO checklist_progress(scan_id, rule_id, completed) VALUES (?, ?, ?) "
454
+ "ON CONFLICT(scan_id, rule_id) DO UPDATE SET completed = excluded.completed",
455
+ (str(scan_id), rule_id, int(completed)),
456
+ )
457
+
458
+ def save_verification(self, scan_id: UUID, run: VerificationRun) -> None:
459
+ with self._connect() as connection:
460
+ cursor = connection.execute(
461
+ "UPDATE scan_jobs SET verification_json = ?, updated_at = ? WHERE id = ?",
462
+ (
463
+ run.model_dump_json(),
464
+ datetime.now(UTC).isoformat(),
465
+ str(scan_id),
466
+ ),
467
+ )
468
+ self._ensure_updated(cursor.rowcount, scan_id)
469
+
470
+ def save_verification_and_report(
471
+ self,
472
+ scan_id: UUID,
473
+ run: VerificationRun,
474
+ report: ScanReport,
475
+ ) -> None:
476
+ if report.scan_id != scan_id or run.scan_id != scan_id:
477
+ raise ValueError("verification, report, and scan ids must match")
478
+ with self._connect() as connection:
479
+ cursor = connection.execute(
480
+ """
481
+ UPDATE scan_jobs
482
+ SET verification_json = ?, report_json = ?, updated_at = ?
483
+ WHERE id = ?
484
+ """,
485
+ (
486
+ run.model_dump_json(),
487
+ report.model_dump_json(),
488
+ datetime.now(UTC).isoformat(),
489
+ str(scan_id),
490
+ ),
491
+ )
492
+ self._ensure_updated(cursor.rowcount, scan_id)
493
+ connection.execute(
494
+ "INSERT INTO verification_runs(scan_id, uploaded_at, verification_json) "
495
+ "VALUES (?, ?, ?)",
496
+ (str(scan_id), datetime.now(UTC).isoformat(), run.model_dump_json()),
497
+ )
498
+
499
+ def verification_history(
500
+ self,
501
+ scan_id: UUID,
502
+ *,
503
+ limit: int = 10,
504
+ offset: int = 0,
505
+ ) -> tuple[list[tuple[int, datetime | None, VerificationRun]], int, int | None]:
506
+ with self._connect() as connection:
507
+ connection.execute("BEGIN")
508
+ count, latest = connection.execute(
509
+ "SELECT COUNT(*), MAX(id) FROM verification_runs WHERE scan_id = ?",
510
+ (str(scan_id),),
511
+ ).fetchone()
512
+ rows = connection.execute(
513
+ "SELECT * FROM verification_runs WHERE scan_id = ? ORDER BY id DESC "
514
+ "LIMIT ? OFFSET ?",
515
+ (str(scan_id), limit, offset),
516
+ ).fetchall()
517
+ return (
518
+ [
519
+ (
520
+ row["id"],
521
+ datetime.fromisoformat(row["uploaded_at"]) if row["uploaded_at"] else None,
522
+ VerificationRun.model_validate_json(row["verification_json"]),
523
+ )
524
+ for row in rows
525
+ ],
526
+ count,
527
+ latest,
528
+ )
529
+
530
+ def verification_detail(
531
+ self,
532
+ scan_id: UUID,
533
+ run_id: int,
534
+ ) -> tuple[datetime | None, VerificationRun, bool] | None:
535
+ with self._connect() as connection:
536
+ row = connection.execute(
537
+ "SELECT *, id = (SELECT MAX(id) FROM verification_runs WHERE scan_id = ?) "
538
+ "AS is_latest FROM verification_runs WHERE scan_id = ? AND id = ?",
539
+ (str(scan_id), str(scan_id), run_id),
540
+ ).fetchone()
541
+ if row is None:
542
+ return None
543
+ return (
544
+ datetime.fromisoformat(row["uploaded_at"]) if row["uploaded_at"] else None,
545
+ VerificationRun.model_validate_json(row["verification_json"]),
546
+ bool(row["is_latest"]),
547
+ )
548
+
549
+ def get_verification(self, scan_id: UUID) -> VerificationRun | None:
550
+ with self._connect() as connection:
551
+ row = connection.execute(
552
+ "SELECT verification_json FROM scan_jobs WHERE id = ?",
553
+ (str(scan_id),),
554
+ ).fetchone()
555
+ if row is None or row["verification_json"] is None:
556
+ return None
557
+ return VerificationRun.model_validate_json(row["verification_json"])
558
+
559
+ @staticmethod
560
+ def _job_from_row(row: sqlite3.Row) -> ScanJob:
561
+ return ScanJob(
562
+ id=UUID(row["id"]),
563
+ repo_url=row["repo_url"],
564
+ requested_ref=row["requested_ref"],
565
+ resolved_commit=row["resolved_commit"],
566
+ status=ScanStatus(row["status"]),
567
+ stage_message=row["stage_message"],
568
+ created_at=datetime.fromisoformat(row["created_at"]),
569
+ updated_at=datetime.fromisoformat(row["updated_at"]),
570
+ failure_code=row["failure_code"],
571
+ failure_message=row["failure_message"],
572
+ )
573
+
574
+ @staticmethod
575
+ def _ensure_updated(row_count: int, scan_id: UUID) -> None:
576
+ if row_count == 0:
577
+ raise MxReadyError(
578
+ "SCAN_NOT_FOUND",
579
+ "The requested scan does not exist.",
580
+ {"scan_id": str(scan_id)},
581
+ )
mxready/py.typed ADDED
File without changes
@@ -0,0 +1,4 @@
1
+ from mxready.reporting.badge import render_badge
2
+ from mxready.reporting.markdown import render_markdown
3
+
4
+ __all__ = ["render_badge", "render_markdown"]
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import html
4
+
5
+ from mxready.models import ScanReport, calculate_badge_status
6
+
7
+ COLORS = {
8
+ "static-passed": "#2e7d32",
9
+ "warnings": "#b26a00",
10
+ "blocked": "#b3261e",
11
+ "verified": "#1565c0",
12
+ "verification-stale": "#6b5e00",
13
+ "scan-failed": "#5f6368",
14
+ }
15
+
16
+
17
+ def render_badge(report: ScanReport) -> str:
18
+ status = calculate_badge_status(report).value
19
+ label = "MXReady"
20
+ escaped_label = html.escape(label, quote=True)
21
+ escaped_status = html.escape(status, quote=True)
22
+ escaped_accessible_name = html.escape(f"{label}: {status}", quote=True)
23
+ color = COLORS[status]
24
+
25
+ return (
26
+ '<svg xmlns="http://www.w3.org/2000/svg" width="220" height="20" '
27
+ f'role="img" aria-label="{escaped_accessible_name}">'
28
+ f"<title>{escaped_accessible_name}</title>"
29
+ '<clipPath id="mxready-badge"><rect width="220" height="20" rx="3"/></clipPath>'
30
+ '<g clip-path="url(#mxready-badge)">'
31
+ '<rect width="78" height="20" fill="#424242"/>'
32
+ f'<rect x="78" width="142" height="20" fill="{color}"/>'
33
+ "</g>"
34
+ '<g fill="#fff" text-anchor="middle" '
35
+ 'font-family="Verdana,DejaVu Sans,sans-serif" font-size="11">'
36
+ f'<text x="39" y="14">{escaped_label}</text>'
37
+ f'<text x="149" y="14">{escaped_status}</text>'
38
+ "</g>"
39
+ "</svg>"
40
+ )
@@ -0,0 +1,33 @@
1
+ """Conservative CI baseline policy: never silently compare different analyzers."""
2
+
3
+ from mxready.models import Finding, ScanReport, Severity
4
+
5
+
6
+ def new_blockers(baseline: ScanReport, current: ScanReport) -> list[Finding]:
7
+ def normalize(url: str) -> str:
8
+ return url.rstrip("/").removesuffix(".git").lower()
9
+
10
+ if normalize(baseline.repository.url) != normalize(current.repository.url):
11
+ raise ValueError("Baseline must describe the same repository")
12
+ fields = (
13
+ "ruleset_digest",
14
+ "ruleset_version",
15
+ "extra_ruleset_version",
16
+ "tool_version",
17
+ "analysis_config",
18
+ )
19
+ if not baseline.ruleset_digest or any(
20
+ getattr(baseline, k) != getattr(current, k) for k in fields
21
+ ):
22
+ raise ValueError(
23
+ "Baseline analysis conditions differ or ruleset digest is missing; regenerate it"
24
+ )
25
+ before = {(f.rule_id, f.relative_path): f for f in baseline.findings}
26
+ result = []
27
+ for finding in current.findings:
28
+ old = before.get((finding.rule_id, finding.relative_path))
29
+ if finding.severity == Severity.BLOCKER and (
30
+ old is None or old.severity != Severity.BLOCKER or finding.count > old.count
31
+ ):
32
+ result.append(finding)
33
+ return result