gitinstall 1.1.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 (59) hide show
  1. gitinstall/__init__.py +61 -0
  2. gitinstall/_sdk.py +541 -0
  3. gitinstall/academic.py +831 -0
  4. gitinstall/admin.html +327 -0
  5. gitinstall/auto_update.py +384 -0
  6. gitinstall/autopilot.py +349 -0
  7. gitinstall/badge.py +476 -0
  8. gitinstall/checkpoint.py +330 -0
  9. gitinstall/cicd.py +499 -0
  10. gitinstall/clawhub.html +718 -0
  11. gitinstall/config_schema.py +353 -0
  12. gitinstall/db.py +984 -0
  13. gitinstall/db_backend.py +445 -0
  14. gitinstall/dep_chain.py +337 -0
  15. gitinstall/dependency_audit.py +1153 -0
  16. gitinstall/detector.py +542 -0
  17. gitinstall/doctor.py +493 -0
  18. gitinstall/education.py +869 -0
  19. gitinstall/enterprise.py +802 -0
  20. gitinstall/error_fixer.py +953 -0
  21. gitinstall/event_bus.py +251 -0
  22. gitinstall/executor.py +577 -0
  23. gitinstall/feature_flags.py +138 -0
  24. gitinstall/fetcher.py +921 -0
  25. gitinstall/huggingface.py +922 -0
  26. gitinstall/hw_detect.py +988 -0
  27. gitinstall/i18n.py +664 -0
  28. gitinstall/installer_registry.py +362 -0
  29. gitinstall/knowledge_base.py +379 -0
  30. gitinstall/license_check.py +605 -0
  31. gitinstall/llm.py +569 -0
  32. gitinstall/log.py +236 -0
  33. gitinstall/main.py +1408 -0
  34. gitinstall/mcp_agent.py +841 -0
  35. gitinstall/mcp_server.py +386 -0
  36. gitinstall/monorepo.py +810 -0
  37. gitinstall/multi_source.py +425 -0
  38. gitinstall/onboard.py +276 -0
  39. gitinstall/planner.py +222 -0
  40. gitinstall/planner_helpers.py +323 -0
  41. gitinstall/planner_known_projects.py +1010 -0
  42. gitinstall/planner_templates.py +996 -0
  43. gitinstall/remote_gpu.py +633 -0
  44. gitinstall/resilience.py +608 -0
  45. gitinstall/run_tests.py +572 -0
  46. gitinstall/skills.py +476 -0
  47. gitinstall/tool_schemas.py +324 -0
  48. gitinstall/trending.py +279 -0
  49. gitinstall/uninstaller.py +415 -0
  50. gitinstall/validate_top100.py +607 -0
  51. gitinstall/watchdog.py +180 -0
  52. gitinstall/web.py +1277 -0
  53. gitinstall/web_ui.html +2277 -0
  54. gitinstall-1.1.0.dist-info/METADATA +275 -0
  55. gitinstall-1.1.0.dist-info/RECORD +59 -0
  56. gitinstall-1.1.0.dist-info/WHEEL +5 -0
  57. gitinstall-1.1.0.dist-info/entry_points.txt +3 -0
  58. gitinstall-1.1.0.dist-info/licenses/LICENSE +21 -0
  59. gitinstall-1.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1153 @@
1
+ """
2
+ dependency_audit.py - 依赖安全审计系统
3
+ ========================================
4
+
5
+ 安装前扫描项目依赖,识别:
6
+ 1. 已知 CVE 漏洞的包版本
7
+ 2. 恶意/误植攻击包名(typosquatting)
8
+ 3. 过时且不再维护的依赖
9
+ 4. 可疑的依赖模式(如 postinstall 脚本)
10
+
11
+ 支持:Python (pip), Node.js (npm), Rust (cargo), Go (go mod)
12
+
13
+ 零外部依赖,纯 Python 标准库。
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import re
21
+ import time
22
+ import urllib.error
23
+ import urllib.request
24
+ from dataclasses import dataclass, field
25
+ from typing import Optional
26
+
27
+
28
+ # ── 风险等级 ──
29
+ RISK_CRITICAL = "critical"
30
+ RISK_HIGH = "high"
31
+ RISK_MEDIUM = "medium"
32
+ RISK_LOW = "low"
33
+ RISK_INFO = "info"
34
+
35
+
36
+ @dataclass
37
+ class VulnReport:
38
+ """单条漏洞/风险 报告"""
39
+ package: str
40
+ version: str = ""
41
+ risk: str = RISK_INFO
42
+ category: str = "" # cve, typosquat, unmaintained, suspicious
43
+ description: str = ""
44
+ cve_id: str = ""
45
+ advisory_url: str = ""
46
+ fix_version: str = ""
47
+ ecosystem: str = "" # python, npm, cargo, go
48
+
49
+
50
+ @dataclass
51
+ class AuditResult:
52
+ """完整审计结果"""
53
+ ecosystem: str
54
+ total_packages: int = 0
55
+ vulnerabilities: list[VulnReport] = field(default_factory=list)
56
+ warnings: list[VulnReport] = field(default_factory=list)
57
+ scan_time: float = 0.0
58
+ error: str = ""
59
+
60
+ @property
61
+ def critical_count(self) -> int:
62
+ return sum(1 for v in self.vulnerabilities if v.risk == RISK_CRITICAL)
63
+
64
+ @property
65
+ def high_count(self) -> int:
66
+ return sum(1 for v in self.vulnerabilities if v.risk == RISK_HIGH)
67
+
68
+ @property
69
+ def is_safe(self) -> bool:
70
+ return self.critical_count == 0 and self.high_count == 0
71
+
72
+
73
+ # ─────────────────────────────────────────────
74
+ # 已知恶意/危险包(typosquatting 检测)
75
+ # ─────────────────────────────────────────────
76
+
77
+ # 常见被误植攻击的包名映射 {恶意包名: 真实包名}
78
+ KNOWN_TYPOSQUATS_PYTHON = {
79
+ "python-dateutil": None, # 这个是正规的
80
+ "python3-dateutil": "python-dateutil",
81
+ "reqeusts": "requests",
82
+ "requsets": "requests",
83
+ "reque5ts": "requests",
84
+ "request": "requests",
85
+ "python-nmap": None, # 正规
86
+ "nmap-python": "python-nmap",
87
+ "djang0": "django",
88
+ "djnago": "django",
89
+ "colourama": "colorama",
90
+ "openvc": "opencv-python",
91
+ "python-opencv": "opencv-python",
92
+ "numppy": "numpy",
93
+ "sciipy": "scipy",
94
+ "pand4s": "pandas",
95
+ "matplotilib": "matplotlib",
96
+ "tesorflow": "tensorflow",
97
+ "pytoroch": "pytorch",
98
+ "flassk": "flask",
99
+ "crytography": "cryptography",
100
+ "cyptography": "cryptography",
101
+ "beautiflsoup4": "beautifulsoup4",
102
+ "beautifulsoup": "beautifulsoup4",
103
+ "sqlalcheni": "sqlalchemy",
104
+ "cereals": None, # 不是 serial,但可疑
105
+ "setup-tools": "setuptools",
106
+ "set-up-tools": "setuptools",
107
+ }
108
+
109
+ KNOWN_TYPOSQUATS_NPM = {
110
+ "crossenv": "cross-env",
111
+ "cross-env.js": "cross-env",
112
+ "d3.js": "d3",
113
+ "fabric-js": "fabric",
114
+ "ffmpegs": "ffmpeg",
115
+ "gruntcli": "grunt-cli",
116
+ "http-proxy.js": "http-proxy",
117
+ "mariadb": None, # 正规
118
+ "mongose": "mongoose",
119
+ "mssql.js": "mssql",
120
+ "mssql-node": "mssql",
121
+ "nodecaffe": "node-caffe",
122
+ "nodefabric": "node-fabric",
123
+ "nodeffmpeg": "node-ffmpeg",
124
+ "nodemailer-js": "nodemailer",
125
+ "noderequest": "request",
126
+ "nodesass": "node-sass",
127
+ "opencv.js": "opencv",
128
+ "openssl.js": "openssl",
129
+ "proxy.js": "proxy",
130
+ "shadowsock": "shadowsocks",
131
+ "smb": None, # 正规
132
+ "sqlite.js": "sqlite3",
133
+ "sqliter": "sqlite3",
134
+ "sulern": None,
135
+ "tkinter": None,
136
+ }
137
+
138
+ # ── 已知废弃/危险的 Python 包 ──
139
+ DEPRECATED_PYTHON = {
140
+ "pycrypto": "已被 pycryptodome 取代,存在已知漏洞",
141
+ "pyopenssl": "考虑使用 ssl 标准库模块",
142
+ "nose": "已停止维护,建议迁移到 pytest",
143
+ "imp": "Python 3.12 已移除,使用 importlib",
144
+ "distutils": "Python 3.12 已移除,使用 setuptools",
145
+ "optparse": "已被 argparse 取代",
146
+ "cgi": "Python 3.13 已移除",
147
+ "cgitb": "Python 3.13 已移除",
148
+ "imghdr": "Python 3.13 已移除",
149
+ "mailcap": "Python 3.13 已移除",
150
+ "msilib": "Python 3.13 已移除",
151
+ "nis": "Python 3.13 已移除",
152
+ "nntplib": "Python 3.13 已移除",
153
+ "ossaudiodev": "Python 3.13 已移除",
154
+ "pipes": "Python 3.13 已移除",
155
+ "sndhdr": "Python 3.13 已移除",
156
+ "spwd": "Python 3.13 已移除",
157
+ "sunau": "Python 3.13 已移除",
158
+ "telnetlib": "Python 3.13 已移除",
159
+ "uu": "Python 3.13 已移除",
160
+ "xdrlib": "Python 3.13 已移除",
161
+ }
162
+
163
+
164
+ # ─────────────────────────────────────────────
165
+ # 解析依赖文件
166
+ # ─────────────────────────────────────────────
167
+
168
+ def parse_requirements_txt(content: str) -> list[tuple[str, str]]:
169
+ """解析 requirements.txt → [(包名, 版本约束)]"""
170
+ deps = []
171
+ for line in content.splitlines():
172
+ line = line.strip()
173
+ if not line or line.startswith("#") or line.startswith("-"):
174
+ continue
175
+ # 处理 name==version, name>=version, name~=version 等
176
+ m = re.match(r'^([a-zA-Z0-9_.-]+)\s*([><=!~]+\s*[\d.a-zA-Z*]+(?:\s*,\s*[><=!~]+\s*[\d.a-zA-Z*]+)*)?', line)
177
+ if m:
178
+ name = m.group(1).lower().replace("-", "_").replace(".", "_")
179
+ version = m.group(2).strip() if m.group(2) else ""
180
+ deps.append((name, version))
181
+ return deps
182
+
183
+
184
+ def parse_package_json(content: str) -> list[tuple[str, str]]:
185
+ """解析 package.json → [(包名, 版本)]"""
186
+ deps = []
187
+ try:
188
+ data = json.loads(content)
189
+ for section in ("dependencies", "devDependencies", "peerDependencies"):
190
+ for name, version in data.get(section, {}).items():
191
+ deps.append((name.lower(), str(version)))
192
+ except (json.JSONDecodeError, TypeError):
193
+ pass
194
+ return deps
195
+
196
+
197
+ def parse_cargo_toml(content: str) -> list[tuple[str, str]]:
198
+ """解析 Cargo.toml 的 [dependencies] → [(包名, 版本)]"""
199
+ deps = []
200
+ in_deps = False
201
+ for line in content.splitlines():
202
+ line = line.strip()
203
+ if line.startswith("[dependencies]"):
204
+ in_deps = True
205
+ continue
206
+ if line.startswith("[") and in_deps:
207
+ in_deps = False
208
+ continue
209
+ if in_deps and "=" in line:
210
+ parts = line.split("=", 1)
211
+ name = parts[0].strip().lower()
212
+ version = parts[1].strip().strip('"').strip("'")
213
+ if not version.startswith("{"):
214
+ deps.append((name, version))
215
+ return deps
216
+
217
+
218
+ def parse_go_mod(content: str) -> list[tuple[str, str]]:
219
+ """解析 go.mod 的 require 块 → [(模块路径, 版本)]"""
220
+ deps = []
221
+ in_require = False
222
+ for line in content.splitlines():
223
+ line = line.strip()
224
+ if line.startswith("require ("):
225
+ in_require = True
226
+ continue
227
+ if line == ")" and in_require:
228
+ in_require = False
229
+ continue
230
+ if in_require:
231
+ parts = line.split()
232
+ if len(parts) >= 2:
233
+ deps.append((parts[0], parts[1]))
234
+ elif line.startswith("require "):
235
+ parts = line[8:].split()
236
+ if len(parts) >= 2:
237
+ deps.append((parts[0], parts[1]))
238
+ return deps
239
+
240
+
241
+ # ─────────────────────────────────────────────
242
+ # 审计引擎
243
+ # ─────────────────────────────────────────────
244
+
245
+ def _check_typosquats_python(deps: list[tuple[str, str]]) -> list[VulnReport]:
246
+ """检查 Python 包名是否为已知误植攻击包"""
247
+ results = []
248
+ for name, version in deps:
249
+ normalized = name.lower().replace("-", "_").replace(".", "_")
250
+ # 检查我们的已知列表
251
+ for bad_name, real_name in KNOWN_TYPOSQUATS_PYTHON.items():
252
+ bad_normalized = bad_name.lower().replace("-", "_").replace(".", "_")
253
+ if normalized == bad_normalized and real_name is not None:
254
+ results.append(VulnReport(
255
+ package=name,
256
+ version=version,
257
+ risk=RISK_CRITICAL,
258
+ category="typosquat",
259
+ description=f"疑似误植攻击!'{name}' 可能是 '{real_name}' 的恶意仿冒",
260
+ ecosystem="python",
261
+ ))
262
+ return results
263
+
264
+
265
+ def _check_typosquats_npm(deps: list[tuple[str, str]]) -> list[VulnReport]:
266
+ """检查 npm 包名是否为已知误植攻击包"""
267
+ results = []
268
+ for name, version in deps:
269
+ lower_name = name.lower()
270
+ for bad_name, real_name in KNOWN_TYPOSQUATS_NPM.items():
271
+ if lower_name == bad_name.lower() and real_name is not None:
272
+ results.append(VulnReport(
273
+ package=name,
274
+ version=version,
275
+ risk=RISK_CRITICAL,
276
+ category="typosquat",
277
+ description=f"疑似误植攻击!'{name}' 可能是 '{real_name}' 的恶意仿冒",
278
+ ecosystem="npm",
279
+ ))
280
+ return results
281
+
282
+
283
+ def _check_deprecated_python(deps: list[tuple[str, str]]) -> list[VulnReport]:
284
+ """检查 Python 废弃/危险包"""
285
+ results = []
286
+ for name, version in deps:
287
+ normalized = name.lower().replace("-", "_").replace(".", "_")
288
+ if normalized in DEPRECATED_PYTHON:
289
+ results.append(VulnReport(
290
+ package=name,
291
+ version=version,
292
+ risk=RISK_MEDIUM,
293
+ category="deprecated",
294
+ description=DEPRECATED_PYTHON[normalized],
295
+ ecosystem="python",
296
+ ))
297
+ return results
298
+
299
+
300
+ def _check_version_patterns(deps: list[tuple[str, str]], ecosystem: str) -> list[VulnReport]:
301
+ """检查可疑的版本模式"""
302
+ results = []
303
+ for name, version in deps:
304
+ # 无版本约束 → 警告
305
+ if not version or version == "*" or version == "latest":
306
+ results.append(VulnReport(
307
+ package=name,
308
+ version=version or "(无版本约束)",
309
+ risk=RISK_LOW,
310
+ category="unpinned",
311
+ description="未锁定版本,可能导致不可复现的安装",
312
+ ecosystem=ecosystem,
313
+ ))
314
+ return results
315
+
316
+
317
+ def _check_pypi_advisory(name: str, version: str) -> list[VulnReport]:
318
+ """查询 PyPI JSON API 检查包信息(不依赖外部数据库)"""
319
+ results = []
320
+ try:
321
+ url = f"https://pypi.org/pypi/{name}/json"
322
+ req = urllib.request.Request(url, headers={"User-Agent": "gitinstall/1.0"})
323
+ with urllib.request.urlopen(req, timeout=5) as resp:
324
+ data = json.loads(resp.read())
325
+ info = data.get("info", {})
326
+
327
+ # 检查是否被标记为废弃
328
+ classifiers = info.get("classifiers", [])
329
+ for c in classifiers:
330
+ if "Inactive" in c or "Obsolete" in c:
331
+ results.append(VulnReport(
332
+ package=name,
333
+ version=version,
334
+ risk=RISK_MEDIUM,
335
+ category="unmaintained",
336
+ description=f"包已被标记为不活跃: {c}",
337
+ ecosystem="python",
338
+ ))
339
+
340
+ # 检查 vulnerabilities 字段 (PEP 691)
341
+ vulns = data.get("vulnerabilities", [])
342
+ for v in vulns:
343
+ cve_id = ""
344
+ for alias in v.get("aliases", []):
345
+ if alias.startswith("CVE-"):
346
+ cve_id = alias
347
+ break
348
+ results.append(VulnReport(
349
+ package=name,
350
+ version=version,
351
+ risk=RISK_HIGH if cve_id else RISK_MEDIUM,
352
+ category="cve",
353
+ description=v.get("summary", v.get("details", "已知漏洞")[:200]),
354
+ cve_id=cve_id,
355
+ advisory_url=v.get("link", ""),
356
+ fix_version=", ".join(v.get("fixed_in", [])),
357
+ ecosystem="python",
358
+ ))
359
+ except (urllib.error.URLError, OSError, json.JSONDecodeError, KeyError):
360
+ pass # 网络不可用时静默跳过
361
+ return results
362
+
363
+
364
+ def _check_npm_advisory(name: str, version: str) -> list[VulnReport]:
365
+ """查询 npm registry 检查包信息"""
366
+ results = []
367
+ try:
368
+ url = f"https://registry.npmjs.org/{name}"
369
+ req = urllib.request.Request(url, headers={"User-Agent": "gitinstall/1.0"})
370
+ with urllib.request.urlopen(req, timeout=5) as resp:
371
+ data = json.loads(resp.read())
372
+
373
+ # 检查是否被废弃
374
+ if data.get("deprecated"):
375
+ results.append(VulnReport(
376
+ package=name,
377
+ version=version,
378
+ risk=RISK_MEDIUM,
379
+ category="deprecated",
380
+ description=f"包已被废弃: {str(data['deprecated'])[:200]}",
381
+ ecosystem="npm",
382
+ ))
383
+ except (urllib.error.URLError, OSError, json.JSONDecodeError, KeyError):
384
+ pass
385
+ return results
386
+
387
+
388
+ # ─────────────────────────────────────────────
389
+ # 主入口
390
+ # ─────────────────────────────────────────────
391
+
392
+ def audit_python_deps(content: str, online: bool = False) -> AuditResult:
393
+ """
394
+ 审计 Python 依赖(requirements.txt 内容)
395
+
396
+ Args:
397
+ content: requirements.txt 文件内容
398
+ online: 是否查询在线漏洞数据库(较慢)
399
+ """
400
+ start = time.time()
401
+ deps = parse_requirements_txt(content)
402
+ result = AuditResult(ecosystem="python", total_packages=len(deps))
403
+
404
+ # 离线检查(快速)
405
+ result.vulnerabilities.extend(_check_typosquats_python(deps))
406
+ result.vulnerabilities.extend(_check_deprecated_python(deps))
407
+ result.warnings.extend(_check_version_patterns(deps, "python"))
408
+
409
+ # 在线检查(如请求)
410
+ if online:
411
+ for name, version in deps[:20]: # 限制请求数
412
+ result.vulnerabilities.extend(_check_pypi_advisory(name, version))
413
+
414
+ result.scan_time = time.time() - start
415
+ return result
416
+
417
+
418
+ def audit_npm_deps(content: str, online: bool = False) -> AuditResult:
419
+ """审计 npm 依赖(package.json 内容)"""
420
+ start = time.time()
421
+ deps = parse_package_json(content)
422
+ result = AuditResult(ecosystem="npm", total_packages=len(deps))
423
+
424
+ result.vulnerabilities.extend(_check_typosquats_npm(deps))
425
+ result.warnings.extend(_check_version_patterns(deps, "npm"))
426
+
427
+ if online:
428
+ for name, version in deps[:20]:
429
+ result.vulnerabilities.extend(_check_npm_advisory(name, version))
430
+
431
+ result.scan_time = time.time() - start
432
+ return result
433
+
434
+
435
+ def audit_cargo_deps(content: str) -> AuditResult:
436
+ """审计 Cargo 依赖"""
437
+ start = time.time()
438
+ deps = parse_cargo_toml(content)
439
+ result = AuditResult(ecosystem="cargo", total_packages=len(deps))
440
+ result.warnings.extend(_check_version_patterns(deps, "cargo"))
441
+ result.scan_time = time.time() - start
442
+ return result
443
+
444
+
445
+ def audit_go_deps(content: str) -> AuditResult:
446
+ """审计 Go 依赖"""
447
+ start = time.time()
448
+ deps = parse_go_mod(content)
449
+ result = AuditResult(ecosystem="go", total_packages=len(deps))
450
+ result.warnings.extend(_check_version_patterns(deps, "go"))
451
+ result.scan_time = time.time() - start
452
+ return result
453
+
454
+
455
+ def audit_project(dependency_files: dict, online: bool = False) -> list[AuditResult]:
456
+ """
457
+ 审计项目所有依赖文件。
458
+
459
+ Args:
460
+ dependency_files: {文件名: 内容} 如 fetcher.py 返回的 dependency_files
461
+ online: 是否在线查询漏洞
462
+ Returns:
463
+ 各生态系统的审计结果列表
464
+ """
465
+ results = []
466
+
467
+ for filename, content in dependency_files.items():
468
+ lower = filename.lower()
469
+ if lower in ("requirements.txt", "requirements-dev.txt",
470
+ "requirements_dev.txt", "requirements-test.txt"):
471
+ results.append(audit_python_deps(content, online))
472
+ elif "setup.py" in lower or "pyproject.toml" in lower:
473
+ # setup.py/pyproject.toml 中的依赖格式不同,
474
+ # 但可以尝试提取 install_requires 行
475
+ extracted = _extract_setup_py_deps(content)
476
+ if extracted:
477
+ results.append(audit_python_deps(extracted, online))
478
+ elif lower == "package.json":
479
+ results.append(audit_npm_deps(content, online))
480
+ elif lower == "cargo.toml":
481
+ results.append(audit_cargo_deps(content))
482
+ elif lower == "go.mod":
483
+ results.append(audit_go_deps(content))
484
+
485
+ return results
486
+
487
+
488
+ def _extract_setup_py_deps(content: str) -> str:
489
+ """从 setup.py 或 pyproject.toml 提取依赖列表为 requirements.txt 格式"""
490
+ deps = []
491
+ # 匹配 install_requires=[...] 或 dependencies=[...]
492
+ pattern = r'(?:install_requires|dependencies)\s*=\s*\[(.*?)\]'
493
+ m = re.search(pattern, content, re.DOTALL)
494
+ if m:
495
+ block = m.group(1)
496
+ for item in re.findall(r'["\']([^"\']+)["\']', block):
497
+ deps.append(item)
498
+ return "\n".join(deps)
499
+
500
+
501
+ # ─────────────────────────────────────────────
502
+ # 格式化输出
503
+ # ─────────────────────────────────────────────
504
+
505
+ _RISK_ICONS = {
506
+ RISK_CRITICAL: "🚨",
507
+ RISK_HIGH: "❌",
508
+ RISK_MEDIUM: "⚠️ ",
509
+ RISK_LOW: "ℹ️ ",
510
+ RISK_INFO: "💡",
511
+ }
512
+
513
+ _RISK_COLORS = {
514
+ RISK_CRITICAL: "\033[91m",
515
+ RISK_HIGH: "\033[91m",
516
+ RISK_MEDIUM: "\033[93m",
517
+ RISK_LOW: "\033[94m",
518
+ RISK_INFO: "\033[90m",
519
+ }
520
+ _RESET = "\033[0m"
521
+
522
+
523
+ def format_audit_results(results: list[AuditResult]) -> str:
524
+ """格式化审计结果为终端输出"""
525
+ lines = ["", "🔍 依赖安全审计报告", "=" * 50]
526
+
527
+ all_safe = True
528
+ total_vulns = 0
529
+ total_warns = 0
530
+
531
+ for r in results:
532
+ lines.append(f"\n📦 {r.ecosystem.upper()} ({r.total_packages} 个包, {r.scan_time:.1f}s)")
533
+ lines.append("-" * 40)
534
+
535
+ if r.error:
536
+ lines.append(f" ❌ 审计出错: {r.error}")
537
+ continue
538
+
539
+ if r.vulnerabilities:
540
+ all_safe = False
541
+ total_vulns += len(r.vulnerabilities)
542
+ for v in r.vulnerabilities:
543
+ icon = _RISK_ICONS.get(v.risk, "?")
544
+ color = _RISK_COLORS.get(v.risk, "")
545
+ lines.append(f" {icon} {color}[{v.risk.upper()}]{_RESET} {v.package} {v.version}")
546
+ lines.append(f" {v.description}")
547
+ if v.cve_id:
548
+ lines.append(f" CVE: {v.cve_id}")
549
+ if v.fix_version:
550
+ lines.append(f" 修复版本: {v.fix_version}")
551
+
552
+ if r.warnings:
553
+ total_warns += len(r.warnings)
554
+ for w in r.warnings:
555
+ icon = _RISK_ICONS.get(w.risk, "?")
556
+ lines.append(f" {icon} {w.package}: {w.description}")
557
+
558
+ if not r.vulnerabilities and not r.warnings:
559
+ lines.append(" ✅ 未发现安全问题")
560
+
561
+ lines.append("\n" + "=" * 50)
562
+ if all_safe and total_warns == 0:
563
+ lines.append("✅ 审计通过:未发现安全风险")
564
+ else:
565
+ lines.append(f"📊 总计: {total_vulns} 个漏洞, {total_warns} 个警告")
566
+ if total_vulns > 0:
567
+ lines.append("⚠️ 建议修复所有漏洞后再安装")
568
+
569
+ return "\n".join(lines)
570
+
571
+
572
+ def audit_to_dict(results: list[AuditResult]) -> dict:
573
+ """序列化审计结果为 JSON"""
574
+ return {
575
+ "results": [
576
+ {
577
+ "ecosystem": r.ecosystem,
578
+ "total_packages": r.total_packages,
579
+ "scan_time": round(r.scan_time, 3),
580
+ "is_safe": r.is_safe,
581
+ "vulnerabilities": [
582
+ {
583
+ "package": v.package,
584
+ "version": v.version,
585
+ "risk": v.risk,
586
+ "category": v.category,
587
+ "description": v.description,
588
+ "cve_id": v.cve_id,
589
+ "fix_version": v.fix_version,
590
+ }
591
+ for v in r.vulnerabilities
592
+ ],
593
+ "warnings": [
594
+ {
595
+ "package": w.package,
596
+ "description": w.description,
597
+ "risk": w.risk,
598
+ }
599
+ for w in r.warnings
600
+ ],
601
+ }
602
+ for r in results
603
+ ],
604
+ "overall_safe": all(r.is_safe for r in results),
605
+ }
606
+
607
+
608
+ # ─────────────────────────────────────────────
609
+ # SBOM (Software Bill of Materials) 生成
610
+ # 支持 CycloneDX 1.5 JSON 和 SPDX 2.3 JSON
611
+ # ─────────────────────────────────────────────
612
+
613
+ import uuid
614
+ from datetime import datetime, timezone
615
+
616
+
617
+ def _collect_all_deps(project_dir: str) -> list[dict]:
618
+ """从项目目录中收集所有依赖信息"""
619
+ deps = []
620
+ req_path = os.path.join(project_dir, "requirements.txt")
621
+ if os.path.isfile(req_path):
622
+ with open(req_path, "r", encoding="utf-8", errors="ignore") as f:
623
+ content = f.read()
624
+ for name, version in parse_requirements_txt(content):
625
+ deps.append({"name": name, "version": version, "ecosystem": "python"})
626
+ pkg_path = os.path.join(project_dir, "package.json")
627
+ if os.path.isfile(pkg_path):
628
+ with open(pkg_path, "r", encoding="utf-8", errors="ignore") as f:
629
+ content = f.read()
630
+ for name, version in parse_package_json(content):
631
+ deps.append({"name": name, "version": version, "ecosystem": "npm"})
632
+ cargo_path = os.path.join(project_dir, "Cargo.toml")
633
+ if os.path.isfile(cargo_path):
634
+ with open(cargo_path, "r", encoding="utf-8", errors="ignore") as f:
635
+ content = f.read()
636
+ for name, version in parse_cargo_toml(content):
637
+ deps.append({"name": name, "version": version, "ecosystem": "cargo"})
638
+ gomod_path = os.path.join(project_dir, "go.mod")
639
+ if os.path.isfile(gomod_path):
640
+ with open(gomod_path, "r", encoding="utf-8", errors="ignore") as f:
641
+ content = f.read()
642
+ for name, version in parse_go_mod(content):
643
+ deps.append({"name": name, "version": version, "ecosystem": "go"})
644
+ return deps
645
+
646
+
647
+ def generate_sbom_cyclonedx(
648
+ project_dir: str,
649
+ project_name: str = "",
650
+ project_version: str = "0.0.0",
651
+ ) -> dict:
652
+ """
653
+ 生成 CycloneDX 1.5 JSON 格式的 SBOM。
654
+ CycloneDX 是 OWASP 标准,被 GitHub、GitLab、NIST 广泛认可。
655
+ """
656
+ deps = _collect_all_deps(project_dir)
657
+ if not project_name:
658
+ project_name = os.path.basename(os.path.abspath(project_dir))
659
+
660
+ serial = f"urn:uuid:{uuid.uuid4()}"
661
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
662
+
663
+ purl_prefix = {
664
+ "python": "pkg:pypi", "npm": "pkg:npm",
665
+ "cargo": "pkg:cargo", "go": "pkg:golang",
666
+ }
667
+
668
+ components = []
669
+ for dep in deps:
670
+ name = dep.get("name", "")
671
+ version = dep.get("version", "")
672
+ eco = dep.get("ecosystem", "")
673
+ purl = f"{purl_prefix.get(eco, 'pkg:generic')}/{name}"
674
+ if version:
675
+ purl += f"@{version}"
676
+ components.append({
677
+ "type": "library",
678
+ "name": name,
679
+ "version": version or "unknown",
680
+ "purl": purl,
681
+ "bom-ref": f"{eco}/{name}@{version or 'unknown'}",
682
+ })
683
+
684
+ return {
685
+ "bomFormat": "CycloneDX",
686
+ "specVersion": "1.5",
687
+ "serialNumber": serial,
688
+ "version": 1,
689
+ "metadata": {
690
+ "timestamp": now,
691
+ "tools": {"components": [{"type": "application", "name": "gitinstall", "version": "1.1.0"}]},
692
+ "component": {
693
+ "type": "application", "name": project_name,
694
+ "version": project_version, "bom-ref": f"root/{project_name}",
695
+ },
696
+ },
697
+ "components": components,
698
+ "dependencies": [{"ref": f"root/{project_name}", "dependsOn": [c["bom-ref"] for c in components]}],
699
+ }
700
+
701
+
702
+ def generate_sbom_spdx(
703
+ project_dir: str,
704
+ project_name: str = "",
705
+ project_version: str = "0.0.0",
706
+ ) -> dict:
707
+ """
708
+ 生成 SPDX 2.3 JSON 格式的 SBOM。
709
+ SPDX 是 Linux Foundation 和 ISO/IEC 5962:2021 标准。
710
+ """
711
+ deps = _collect_all_deps(project_dir)
712
+ if not project_name:
713
+ project_name = os.path.basename(os.path.abspath(project_dir))
714
+
715
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
716
+ doc_ns = f"https://spdx.org/spdxdocs/{project_name}-{uuid.uuid4()}"
717
+
718
+ purl_prefix = {
719
+ "python": "pkg:pypi", "npm": "pkg:npm",
720
+ "cargo": "pkg:cargo", "go": "pkg:golang",
721
+ }
722
+
723
+ packages = [{
724
+ "SPDXID": "SPDXRef-RootPackage", "name": project_name,
725
+ "versionInfo": project_version, "downloadLocation": "NOASSERTION",
726
+ "filesAnalyzed": False, "supplier": "NOASSERTION",
727
+ }]
728
+ relationships = []
729
+
730
+ for i, dep in enumerate(deps):
731
+ name = dep.get("name", "")
732
+ version = dep.get("version", "")
733
+ eco = dep.get("ecosystem", "")
734
+ spdx_id = f"SPDXRef-Package-{i + 1}"
735
+ purl = f"{purl_prefix.get(eco, 'pkg:generic')}/{name}"
736
+ if version:
737
+ purl += f"@{version}"
738
+ packages.append({
739
+ "SPDXID": spdx_id, "name": name,
740
+ "versionInfo": version or "NOASSERTION",
741
+ "downloadLocation": "NOASSERTION", "filesAnalyzed": False,
742
+ "externalRefs": [{"referenceCategory": "PACKAGE-MANAGER", "referenceType": "purl", "referenceLocator": purl}],
743
+ })
744
+ relationships.append({
745
+ "spdxElementId": "SPDXRef-RootPackage",
746
+ "relatedSpdxElement": spdx_id,
747
+ "relationshipType": "DEPENDS_ON",
748
+ })
749
+
750
+ relationships.append({
751
+ "spdxElementId": "SPDXRef-DOCUMENT",
752
+ "relatedSpdxElement": "SPDXRef-RootPackage",
753
+ "relationshipType": "DESCRIBES",
754
+ })
755
+
756
+ return {
757
+ "spdxVersion": "SPDX-2.3",
758
+ "dataLicense": "CC0-1.0",
759
+ "SPDXID": "SPDXRef-DOCUMENT",
760
+ "name": project_name,
761
+ "documentNamespace": doc_ns,
762
+ "creationInfo": {
763
+ "created": now,
764
+ "creators": ["Tool: gitinstall-1.1.0"],
765
+ "licenseListVersion": "3.22",
766
+ },
767
+ "packages": packages,
768
+ "relationships": relationships,
769
+ }
770
+
771
+
772
+ def export_sbom(
773
+ project_dir: str,
774
+ output_path: Optional[str] = None,
775
+ fmt: str = "cyclonedx",
776
+ project_name: str = "",
777
+ project_version: str = "0.0.0",
778
+ ) -> str:
779
+ """导出 SBOM 到文件。返回输出文件路径。"""
780
+ if fmt == "spdx":
781
+ sbom = generate_sbom_spdx(project_dir, project_name, project_version)
782
+ suffix = "spdx.json"
783
+ else:
784
+ sbom = generate_sbom_cyclonedx(project_dir, project_name, project_version)
785
+ suffix = "cdx.json"
786
+
787
+ if not output_path:
788
+ name = project_name or os.path.basename(os.path.abspath(project_dir))
789
+ output_path = os.path.join(project_dir, f"{name}.{suffix}")
790
+
791
+ with open(output_path, "w", encoding="utf-8") as f:
792
+ json.dump(sbom, f, indent=2, ensure_ascii=False)
793
+ return output_path
794
+
795
+
796
+ # ─────────────────────────────────────────────
797
+ # DevSecOps 安全左移 (Market Opportunity #5)
798
+ # ─────────────────────────────────────────────
799
+
800
+ @dataclass
801
+ class SecurityPolicy:
802
+ """安全策略定义"""
803
+ name: str = ""
804
+ description: str = ""
805
+ rules: list[dict] = field(default_factory=list)
806
+ severity: str = "high" # critical | high | medium | low
807
+ action: str = "block" # block | warn | audit
808
+ enabled: bool = True
809
+
810
+
811
+ @dataclass
812
+ class PolicyResult:
813
+ """策略检查结果"""
814
+ policy_name: str = ""
815
+ passed: bool = True
816
+ violations: list[dict] = field(default_factory=list)
817
+ action: str = "block"
818
+ message: str = ""
819
+
820
+
821
+ @dataclass
822
+ class ComplianceReport:
823
+ """合规性报告"""
824
+ framework: str = "" # soc2 | iso27001 | nist | pci_dss
825
+ project_name: str = ""
826
+ timestamp: str = ""
827
+ total_checks: int = 0
828
+ passed_checks: int = 0
829
+ failed_checks: int = 0
830
+ findings: list[dict] = field(default_factory=list)
831
+ score: float = 0.0
832
+
833
+
834
+ # ── 预置安全策略 ──
835
+
836
+ _DEFAULT_POLICIES: list[dict] = [
837
+ {
838
+ "name": "no-critical-vulns",
839
+ "description": "禁止存在 CRITICAL 级漏洞",
840
+ "check": "max_risk",
841
+ "threshold": RISK_CRITICAL,
842
+ "action": "block",
843
+ "severity": "critical",
844
+ },
845
+ {
846
+ "name": "no-typosquats",
847
+ "description": "禁止使用已知 typosquatting 包",
848
+ "check": "category",
849
+ "category": "typosquatting",
850
+ "action": "block",
851
+ "severity": "critical",
852
+ },
853
+ {
854
+ "name": "no-deprecated",
855
+ "description": "禁止使用已废弃包",
856
+ "check": "category",
857
+ "category": "deprecated",
858
+ "action": "warn",
859
+ "severity": "medium",
860
+ },
861
+ {
862
+ "name": "version-pinning",
863
+ "description": "所有依赖必须固定版本",
864
+ "check": "category",
865
+ "category": "unpinned_version",
866
+ "action": "warn",
867
+ "severity": "low",
868
+ },
869
+ {
870
+ "name": "max-deps-limit",
871
+ "description": "依赖数量不超过阈值",
872
+ "check": "dep_count",
873
+ "threshold": 200,
874
+ "action": "warn",
875
+ "severity": "medium",
876
+ },
877
+ ]
878
+
879
+
880
+ def create_security_policy(
881
+ name: str,
882
+ rules: list[dict],
883
+ action: str = "block",
884
+ severity: str = "high",
885
+ description: str = "",
886
+ ) -> SecurityPolicy:
887
+ """创建自定义安全策略"""
888
+ return SecurityPolicy(
889
+ name=name,
890
+ description=description or name,
891
+ rules=rules,
892
+ severity=severity,
893
+ action=action,
894
+ )
895
+
896
+
897
+ def evaluate_policies(
898
+ audit_results: list[AuditResult],
899
+ policies: list[dict] | None = None,
900
+ ) -> list[PolicyResult]:
901
+ """
902
+ 策略即代码(Policy-as-Code):按策略检查审计结果。
903
+
904
+ 返回每条策略的通过/违反结果。
905
+ """
906
+ if policies is None:
907
+ policies = _DEFAULT_POLICIES
908
+
909
+ results = []
910
+
911
+ for policy in policies:
912
+ if not policy.get("enabled", True):
913
+ continue
914
+
915
+ check_type = policy.get("check", "")
916
+ violations: list[dict] = []
917
+
918
+ if check_type == "max_risk":
919
+ threshold = policy.get("threshold", RISK_HIGH)
920
+ for ar in audit_results:
921
+ for v in ar.vulnerabilities:
922
+ if v.risk >= threshold:
923
+ violations.append({
924
+ "package": v.package,
925
+ "risk": v.risk,
926
+ "description": v.description,
927
+ "cve": v.cve_id,
928
+ })
929
+
930
+ elif check_type == "category":
931
+ target_cat = policy.get("category", "")
932
+ for ar in audit_results:
933
+ for v in ar.vulnerabilities:
934
+ if target_cat.lower() in v.category.lower():
935
+ violations.append({
936
+ "package": v.package,
937
+ "category": v.category,
938
+ "description": v.description,
939
+ })
940
+
941
+ elif check_type == "dep_count":
942
+ threshold = policy.get("threshold", 200)
943
+ for ar in audit_results:
944
+ if ar.total_packages > threshold:
945
+ violations.append({
946
+ "ecosystem": ar.ecosystem,
947
+ "count": ar.total_packages,
948
+ "threshold": threshold,
949
+ })
950
+
951
+ elif check_type == "license":
952
+ # 禁止使用特定许可证
953
+ blocked = policy.get("blocked_licenses", [])
954
+ # 此检查需要外部许可证数据
955
+ pass
956
+
957
+ results.append(PolicyResult(
958
+ policy_name=policy.get("name", ""),
959
+ passed=len(violations) == 0,
960
+ violations=violations,
961
+ action=policy.get("action", "block"),
962
+ message=policy.get("description", ""),
963
+ ))
964
+
965
+ return results
966
+
967
+
968
+ def security_gate(
969
+ audit_results: list[AuditResult],
970
+ policies: list[dict] | None = None,
971
+ ) -> dict:
972
+ """
973
+ 安全门禁:在安装前执行安全检查,决定是否放行。
974
+
975
+ 返回:
976
+ {
977
+ "allowed": True/False,
978
+ "blockers": [...], # 阻止安装的违规
979
+ "warnings": [...], # 告警但不阻止
980
+ "summary": "..."
981
+ }
982
+ """
983
+ policy_results = evaluate_policies(audit_results, policies)
984
+
985
+ blockers = [pr for pr in policy_results if not pr.passed and pr.action == "block"]
986
+ warnings = [pr for pr in policy_results if not pr.passed and pr.action == "warn"]
987
+
988
+ return {
989
+ "allowed": len(blockers) == 0,
990
+ "blockers": [
991
+ {"policy": b.policy_name, "violations": b.violations, "message": b.message}
992
+ for b in blockers
993
+ ],
994
+ "warnings": [
995
+ {"policy": w.policy_name, "violations": w.violations, "message": w.message}
996
+ for w in warnings
997
+ ],
998
+ "summary": _gate_summary(blockers, warnings),
999
+ }
1000
+
1001
+
1002
+ def _gate_summary(blockers: list, warnings: list) -> str:
1003
+ if blockers:
1004
+ names = ", ".join(b.policy_name for b in blockers)
1005
+ return f"🚫 安装被阻止: 违反策略 [{names}]"
1006
+ if warnings:
1007
+ names = ", ".join(w.policy_name for w in warnings)
1008
+ return f"⚠️ 安装允许但有警告: [{names}]"
1009
+ return "✅ 所有安全策略检查通过"
1010
+
1011
+
1012
+ # ── 合规性报告 ──
1013
+
1014
+ _COMPLIANCE_FRAMEWORKS: dict[str, list[dict]] = {
1015
+ "soc2": [
1016
+ {"id": "CC6.1", "name": "逻辑/物理访问控制", "check": "no_critical_vulns"},
1017
+ {"id": "CC6.6", "name": "系统边界安全", "check": "no_typosquats"},
1018
+ {"id": "CC6.8", "name": "恶意软件防护", "check": "no_typosquats"},
1019
+ {"id": "CC7.1", "name": "漏洞管理", "check": "vuln_scan_performed"},
1020
+ {"id": "CC7.2", "name": "安全事件监控", "check": "audit_logging"},
1021
+ {"id": "CC8.1", "name": "变更管理", "check": "version_pinning"},
1022
+ ],
1023
+ "iso27001": [
1024
+ {"id": "A.12.6.1", "name": "技术漏洞管理", "check": "no_critical_vulns"},
1025
+ {"id": "A.14.1.2", "name": "安全应用服务", "check": "no_typosquats"},
1026
+ {"id": "A.14.1.3", "name": "应用事务保护", "check": "version_pinning"},
1027
+ {"id": "A.14.2.5", "name": "系统安全工程", "check": "sbom_generated"},
1028
+ {"id": "A.15.1.3", "name": "ICT 供应链", "check": "supply_chain_audit"},
1029
+ ],
1030
+ "nist": [
1031
+ {"id": "RA-5", "name": "漏洞监控和扫描", "check": "vuln_scan_performed"},
1032
+ {"id": "SA-11", "name": "开发安全测试", "check": "no_critical_vulns"},
1033
+ {"id": "SA-12", "name": "供应链保护", "check": "no_typosquats"},
1034
+ {"id": "CM-7", "name": "最小功能原则", "check": "dep_count_limit"},
1035
+ {"id": "SI-2", "name": "缺陷修补", "check": "no_deprecated"},
1036
+ ],
1037
+ "pci_dss": [
1038
+ {"id": "6.3.2", "name": "漏洞识别和管理", "check": "no_critical_vulns"},
1039
+ {"id": "6.5", "name": "安全编码实践", "check": "no_typosquats"},
1040
+ {"id": "11.3", "name": "渗透测试", "check": "vuln_scan_performed"},
1041
+ ],
1042
+ }
1043
+
1044
+
1045
+ def generate_compliance_report(
1046
+ audit_results: list[AuditResult],
1047
+ framework: str = "soc2",
1048
+ project_name: str = "",
1049
+ sbom_generated: bool = False,
1050
+ ) -> ComplianceReport:
1051
+ """
1052
+ 根据审计结果生成合规性报告。
1053
+
1054
+ 支持: soc2, iso27001, nist, pci_dss
1055
+ """
1056
+ checks = _COMPLIANCE_FRAMEWORKS.get(framework.lower(), _COMPLIANCE_FRAMEWORKS["soc2"])
1057
+
1058
+ # 预计算审计状态
1059
+ has_critical = any(
1060
+ v.risk >= RISK_CRITICAL for ar in audit_results for v in ar.vulnerabilities
1061
+ )
1062
+ has_typosquats = any(
1063
+ "typosquat" in v.category.lower() for ar in audit_results for v in ar.vulnerabilities
1064
+ )
1065
+ has_pinning_issues = any(
1066
+ "unpin" in v.category.lower() for ar in audit_results for v in ar.vulnerabilities
1067
+ )
1068
+ has_deprecated = any(
1069
+ "deprecat" in v.category.lower() for ar in audit_results for v in ar.vulnerabilities
1070
+ )
1071
+ total_deps = sum(ar.total_packages for ar in audit_results)
1072
+
1073
+ check_map = {
1074
+ "no_critical_vulns": not has_critical,
1075
+ "no_typosquats": not has_typosquats,
1076
+ "version_pinning": not has_pinning_issues,
1077
+ "vuln_scan_performed": len(audit_results) > 0,
1078
+ "audit_logging": True, # 审计日志被执行即为通过
1079
+ "sbom_generated": sbom_generated,
1080
+ "supply_chain_audit": not has_typosquats and not has_critical,
1081
+ "dep_count_limit": total_deps <= 200,
1082
+ "no_deprecated": not has_deprecated,
1083
+ }
1084
+
1085
+ findings = []
1086
+ passed = 0
1087
+ for check in checks:
1088
+ check_id = check["check"]
1089
+ result = check_map.get(check_id, False)
1090
+ if result:
1091
+ passed += 1
1092
+ findings.append({
1093
+ "control_id": check["id"],
1094
+ "name": check["name"],
1095
+ "passed": result,
1096
+ "details": f"{'✅ 通过' if result else '❌ 未通过'}: {check['name']}",
1097
+ })
1098
+
1099
+ total = len(checks)
1100
+ score = (passed / total * 100) if total > 0 else 0
1101
+
1102
+ now = __import__("datetime").datetime.now().isoformat()
1103
+
1104
+ return ComplianceReport(
1105
+ framework=framework.upper(),
1106
+ project_name=project_name,
1107
+ timestamp=now,
1108
+ total_checks=total,
1109
+ passed_checks=passed,
1110
+ failed_checks=total - passed,
1111
+ findings=findings,
1112
+ score=round(score, 1),
1113
+ )
1114
+
1115
+
1116
+ def format_compliance_report(report: ComplianceReport) -> str:
1117
+ """格式化合规性报告"""
1118
+ grade = "A+" if report.score >= 95 else "A" if report.score >= 90 else \
1119
+ "B" if report.score >= 80 else "C" if report.score >= 70 else \
1120
+ "D" if report.score >= 60 else "F"
1121
+
1122
+ lines = [
1123
+ f"📋 {report.framework} 合规性报告",
1124
+ f" 项目: {report.project_name or 'N/A'}",
1125
+ f" 时间: {report.timestamp}",
1126
+ f" 得分: {report.score}% ({grade})",
1127
+ f" 检查: {report.passed_checks}/{report.total_checks} 通过",
1128
+ "",
1129
+ ]
1130
+
1131
+ for f in report.findings:
1132
+ icon = "✅" if f["passed"] else "❌"
1133
+ lines.append(f" {icon} [{f['control_id']}] {f['name']}")
1134
+
1135
+ return "\n".join(lines)
1136
+
1137
+
1138
+ def format_security_gate(gate_result: dict) -> str:
1139
+ """格式化安全门禁结果"""
1140
+ lines = [gate_result.get("summary", "")]
1141
+
1142
+ blockers = gate_result.get("blockers", [])
1143
+ for b in blockers:
1144
+ lines.append(f" 🚫 {b['policy']}: {b['message']}")
1145
+ for v in b.get("violations", [])[:3]:
1146
+ pkg = v.get("package", v.get("ecosystem", ""))
1147
+ lines.append(f" - {pkg}: {v.get('description', v.get('count', ''))}")
1148
+
1149
+ warnings = gate_result.get("warnings", [])
1150
+ for w in warnings:
1151
+ lines.append(f" ⚠️ {w['policy']}: {w['message']}")
1152
+
1153
+ return "\n".join(lines)