secagentx 3.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 (182) hide show
  1. backend/__init__.py +1 -0
  2. backend/__main__.py +3 -0
  3. backend/_assets/config.yaml +195 -0
  4. backend/_assets/frontend/dist/assets/AgentsView-DFqRplI8.css +1 -0
  5. backend/_assets/frontend/dist/assets/AgentsView-DJTpfQ87.js +1 -0
  6. backend/_assets/frontend/dist/assets/AttackChainView-BVfI-SC6.js +1 -0
  7. backend/_assets/frontend/dist/assets/ChatView-Bg4nIlQ0.css +1 -0
  8. backend/_assets/frontend/dist/assets/ChatView-CRndtmEC.js +38 -0
  9. backend/_assets/frontend/dist/assets/DashboardView-BXs4AWv8.js +1 -0
  10. backend/_assets/frontend/dist/assets/DashboardView-WnHWiqBW.css +1 -0
  11. backend/_assets/frontend/dist/assets/EventDetailView-DQk6Qp7-.js +1 -0
  12. backend/_assets/frontend/dist/assets/EventDetailView-Dj-a-zWr.css +1 -0
  13. backend/_assets/frontend/dist/assets/EventsView-8xh8uKO0.js +1 -0
  14. backend/_assets/frontend/dist/assets/EventsView-BHfFn66K.css +1 -0
  15. backend/_assets/frontend/dist/assets/KnowledgeView-C2N3v9bF.css +1 -0
  16. backend/_assets/frontend/dist/assets/KnowledgeView-D54ODoC9.js +11 -0
  17. backend/_assets/frontend/dist/assets/LoginView-CjvFmo7C.js +1 -0
  18. backend/_assets/frontend/dist/assets/LoginView-D14FzcsP.css +1 -0
  19. backend/_assets/frontend/dist/assets/SettingsView-JOJuoABi.js +1 -0
  20. backend/_assets/frontend/dist/assets/UsersView-DhMXZhir.js +1 -0
  21. backend/_assets/frontend/dist/assets/_plugin-vue_export-helper-BDNMzG2s.js +1 -0
  22. backend/_assets/frontend/dist/assets/index-D9Su3fYY.css +1 -0
  23. backend/_assets/frontend/dist/assets/index-DBEjbyJR.js +4 -0
  24. backend/_assets/frontend/dist/assets/rolldown-runtime-DK3Fl9T5.js +1 -0
  25. backend/_assets/frontend/dist/assets/vendor-charts-Dh1nChzO.js +39 -0
  26. backend/_assets/frontend/dist/assets/vendor-icons-BTNgi7vU.js +1 -0
  27. backend/_assets/frontend/dist/assets/vendor-ui-Cdd65KTN.js +7367 -0
  28. backend/_assets/frontend/dist/assets/vendor-vue-CXKcZAmz.js +1 -0
  29. backend/_assets/frontend/dist/index.html +51 -0
  30. backend/_assets/frontend/index.html +46 -0
  31. backend/_assets/frontend/package-lock.json +2983 -0
  32. backend/_assets/frontend/package.json +37 -0
  33. backend/_assets/frontend/src/App.vue +231 -0
  34. backend/_assets/frontend/src/main.js +12 -0
  35. backend/_assets/frontend/src/router/index.js +117 -0
  36. backend/_assets/frontend/src/stores/chat.js +671 -0
  37. backend/_assets/frontend/src/styles/tokens.css +282 -0
  38. backend/_assets/frontend/src/utils/http.js +191 -0
  39. backend/_assets/frontend/src/views/AgentsView.vue +227 -0
  40. backend/_assets/frontend/src/views/AttackChainView.vue +210 -0
  41. backend/_assets/frontend/src/views/ChatView.vue +1107 -0
  42. backend/_assets/frontend/src/views/DashboardView.vue +514 -0
  43. backend/_assets/frontend/src/views/EventDetailView.vue +387 -0
  44. backend/_assets/frontend/src/views/EventsView.vue +253 -0
  45. backend/_assets/frontend/src/views/KnowledgeView.vue +883 -0
  46. backend/_assets/frontend/src/views/LoginView.vue +314 -0
  47. backend/_assets/frontend/src/views/SettingsView.vue +226 -0
  48. backend/_assets/frontend/src/views/UsersView.vue +235 -0
  49. backend/_assets/frontend/vite.config.js +55 -0
  50. backend/_assets/knowledge_data/compliance/regulations.json +383 -0
  51. backend/_assets/knowledge_data/cve/vulnerabilities.json +70720 -0
  52. backend/_assets/knowledge_data/mitre_attack/techniques.json +14097 -0
  53. backend/_assets/knowledge_data/remediation/remediation.json +574 -0
  54. backend/_assets/knowledge_data/threat_intel/actors.json +1602 -0
  55. backend/_assets/knowledge_data/threat_intel/malware.json +971 -0
  56. backend/agents/__init__.py +1 -0
  57. backend/agents/alert_filter/__init__.py +1 -0
  58. backend/agents/alert_filter/alert_filter_agent.py +88 -0
  59. backend/agents/analyst.py +331 -0
  60. backend/agents/base.py +553 -0
  61. backend/agents/classifier_agent.py +134 -0
  62. backend/agents/cot_engine.py +578 -0
  63. backend/agents/intel.py +46 -0
  64. backend/agents/knowledge/__init__.py +2 -0
  65. backend/agents/knowledge/agentic_rag.py +928 -0
  66. backend/agents/knowledge/knowledge_agent.py +47 -0
  67. backend/agents/registry.py +148 -0
  68. backend/agents/responder.py +51 -0
  69. backend/agents/summary_agent.py +219 -0
  70. backend/auto_ingestor.py +948 -0
  71. backend/auto_patrol.py +452 -0
  72. backend/cli_app.py +523 -0
  73. backend/config/__init__.py +20 -0
  74. backend/config/provider_profiles.py +416 -0
  75. backend/config/runtime_settings.py +84 -0
  76. backend/decision_fusion/__init__.py +32 -0
  77. backend/decision_fusion/base.py +127 -0
  78. backend/decision_fusion/dempster_shafer.py +419 -0
  79. backend/decision_fusion/weighted_average.py +141 -0
  80. backend/email_ingestor.py +588 -0
  81. backend/errors.py +243 -0
  82. backend/escalation.py +453 -0
  83. backend/evaluation/__init__.py +7 -0
  84. backend/evaluation/evaluator.py +197 -0
  85. backend/evaluation/metrics.py +210 -0
  86. backend/federation/__init__.py +22 -0
  87. backend/federation/core.py +743 -0
  88. backend/interface/__init__.py +1 -0
  89. backend/interface/api_server.py +1830 -0
  90. backend/interface/cli.py +671 -0
  91. backend/knowledge/__init__.py +11 -0
  92. backend/knowledge/compliance.py +101 -0
  93. backend/knowledge/cve_db.py +100 -0
  94. backend/knowledge/mitre_attack.py +457 -0
  95. backend/knowledge/remediation.py +84 -0
  96. backend/knowledge/threat_intel_kb.py +246 -0
  97. backend/llm/__init__.py +1 -0
  98. backend/llm/anthropic_compatible.py +191 -0
  99. backend/llm/base.py +81 -0
  100. backend/llm/deepseek.py +221 -0
  101. backend/llm/fallback.py +268 -0
  102. backend/llm/mock.py +204 -0
  103. backend/llm/openai_compatible.py +51 -0
  104. backend/llm/provider.py +217 -0
  105. backend/llm/qwen.py +218 -0
  106. backend/main.py +421 -0
  107. backend/memory/__init__.py +1 -0
  108. backend/memory/episodic_memory.py +132 -0
  109. backend/memory/manager.py +204 -0
  110. backend/memory/semantic_memory.py +156 -0
  111. backend/memory/session_memory.py +102 -0
  112. backend/ml_model/__init__.py +23 -0
  113. backend/ml_model/pipeline.py +191 -0
  114. backend/ml_model/trainer.py +627 -0
  115. backend/models/__init__.py +1 -0
  116. backend/models/event.py +62 -0
  117. backend/models/message.py +58 -0
  118. backend/models/output.py +1216 -0
  119. backend/monitoring/metrics.py +184 -0
  120. backend/orchestrator/__init__.py +17 -0
  121. backend/orchestrator/agent_router.py +145 -0
  122. backend/orchestrator/core.py +159 -0
  123. backend/orchestrator/dynamic_weights.py +287 -0
  124. backend/orchestrator/react_loop.py +1895 -0
  125. backend/orchestrator/risk_model.py +494 -0
  126. backend/orchestrator/risk_scorer.py +356 -0
  127. backend/reasoner/__init__.py +19 -0
  128. backend/reasoner/reasoner.py +316 -0
  129. backend/runtime_assets.py +45 -0
  130. backend/security/__init__.py +24 -0
  131. backend/security/adversarial/__init__.py +13 -0
  132. backend/security/adversarial/guard.py +121 -0
  133. backend/security/adversarial/redteam.py +121 -0
  134. backend/security/adversarial/scanner.py +208 -0
  135. backend/security/audit.py +118 -0
  136. backend/security/auth.py +283 -0
  137. backend/security/circuit_breaker.py +212 -0
  138. backend/security/dispatch.py +18 -0
  139. backend/security/ratelimit.py +237 -0
  140. backend/security/rbac.py +431 -0
  141. backend/security/refresh_sessions.py +193 -0
  142. backend/security/sanitizer.py +32 -0
  143. backend/storage/__init__.py +1 -0
  144. backend/storage/chroma_store.py +79 -0
  145. backend/storage/database.py +296 -0
  146. backend/storage/migrations/env.py +65 -0
  147. backend/storage/migrations/versions/0001_initial_schema.py +101 -0
  148. backend/storage/migrations/versions/0002_add_agent_logs_experiences.py +53 -0
  149. backend/storage/migrations/versions/0003_add_conversation_owner.py +30 -0
  150. backend/storage/models.py +194 -0
  151. backend/storage/repositories/__init__.py +1 -0
  152. backend/storage/repositories/asset_repo.py +86 -0
  153. backend/storage/repositories/conversation_repo.py +216 -0
  154. backend/storage/repositories/event_repo.py +76 -0
  155. backend/storage/repositories/trajectory_repo.py +324 -0
  156. backend/storage/retention.py +117 -0
  157. backend/tools/__init__.py +16 -0
  158. backend/tools/alert_filter.py +208 -0
  159. backend/tools/aliyun_firewall.py +237 -0
  160. backend/tools/aws_firewall.py +369 -0
  161. backend/tools/base.py +445 -0
  162. backend/tools/calling.py +207 -0
  163. backend/tools/cve_search.py +77 -0
  164. backend/tools/execution_engine.py +202 -0
  165. backend/tools/firewall.py +387 -0
  166. backend/tools/geoip.py +168 -0
  167. backend/tools/log_analyzer.py +185 -0
  168. backend/tools/ml_detector.py +226 -0
  169. backend/tools/registry.py +38 -0
  170. backend/tools/tencent_firewall.py +300 -0
  171. backend/tools/threat_intel.py +732 -0
  172. backend/tools/trust_checker.py +76 -0
  173. backend/utils/__init__.py +5 -0
  174. backend/utils/config_loader.py +164 -0
  175. backend/utils/env.py +75 -0
  176. backend/utils/logger.py +150 -0
  177. backend/utils/text.py +32 -0
  178. secagentx-3.0.0.dist-info/METADATA +325 -0
  179. secagentx-3.0.0.dist-info/RECORD +182 -0
  180. secagentx-3.0.0.dist-info/WHEEL +5 -0
  181. secagentx-3.0.0.dist-info/entry_points.txt +2 -0
  182. secagentx-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1830 @@
1
+ import os
2
+ import sys
3
+ import json
4
+ import asyncio
5
+ import difflib
6
+ import uuid
7
+ import logging
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ # 统一环境初始化 (加载 .env + 设置项目根目录)
13
+ from backend.utils.env import init_environment
14
+ init_environment()
15
+
16
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
17
+
18
+ from backend.main import load_config, init_application, init_db
19
+ from backend.orchestrator.core import Orchestrator
20
+ from backend.knowledge.mitre_attack import MitreAttackKnowledge, TECHNIQUES_INDEX
21
+ from backend.knowledge.cve_db import CVEDatabase
22
+ from backend.knowledge.compliance import ComplianceKnowledge
23
+ from backend.knowledge.remediation import RemediationKnowledge
24
+ from backend.storage.repositories.conversation_repo import ConversationRepository
25
+ from backend.storage.database import Database, get_repository
26
+ from backend.utils.logger import logger
27
+ from backend.errors import error_response, AppError, ERROR_CODES
28
+ from backend.monitoring.metrics import (
29
+ record_alert, set_queue_size, set_circuit_breaker as set_cb_metric,
30
+ )
31
+ from pydantic import BaseModel
32
+
33
+ try:
34
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query, Request, Depends, HTTPException
35
+ from fastapi.middleware.cors import CORSMiddleware
36
+ from fastapi.responses import JSONResponse
37
+ from fastapi.exception_handlers import http_exception_handler
38
+ HAS_FASTAPI = True
39
+ except ImportError:
40
+ HAS_FASTAPI = False
41
+
42
+
43
+ class WebSocketManager:
44
+ def __init__(self):
45
+ self.connections: dict[str, WebSocket] = {}
46
+
47
+ async def connect(self, ws_id: str, ws: WebSocket):
48
+ await ws.accept()
49
+ self.connections[ws_id] = ws
50
+
51
+ def disconnect(self, ws_id: str):
52
+ self.connections.pop(ws_id, None)
53
+
54
+ async def broadcast(self, message: dict):
55
+ dead = []
56
+ for ws_id, ws in self.connections.items():
57
+ try:
58
+ await ws.send_json(message)
59
+ except Exception:
60
+ dead.append(ws_id)
61
+ for ws_id in dead:
62
+ self.disconnect(ws_id)
63
+
64
+
65
+ orchestrator: Optional[Orchestrator] = None
66
+ ws_manager = WebSocketManager()
67
+
68
+
69
+ # ═══════════ 话题切换检测(v2.5:切断跨话题历史串味) ═══════════
70
+ # 同一 WS 会话内连续提问时,若当前问题与最近一轮历史提问相似度低,
71
+ # 视为"新话题",不注入旧历史 —— 防止 LLM 把新问题当作旧分析的延续。
72
+ TOPIC_SIM_THRESHOLD = 0.20 # 低于此相似度 → 判定为新话题,不注入历史
73
+
74
+ # 追问信号:新文本含这些承接词 → 视为对上一话题的追问,保留历史
75
+ TOPIC_FOLLOWUP_MARKERS = (
76
+ "这个", "该IP", "这个IP", "它", "其", "该", "继续", "另外", "还有",
77
+ "那", "哪些", "具体", "进一步", "如何", "补充", "还有没有",
78
+ )
79
+
80
+
81
+ def _is_new_topic(new_text: str, last_user_text: str) -> bool:
82
+ """判断当前提问是否为全新话题(与最近一轮用户提问比较)。"""
83
+ if not last_user_text:
84
+ return False
85
+ # 追问信号:新问题包含承接词 → 视为追问同一话题
86
+ for marker in TOPIC_FOLLOWUP_MARKERS:
87
+ if marker in new_text:
88
+ return False
89
+ # 相似度:字符 bigram Jaccard
90
+ def bigrams(s: str):
91
+ s = (s or "").strip()
92
+ return {s[i:i+2] for i in range(max(0, len(s)-1))}
93
+ a, b = bigrams(new_text), bigrams(last_user_text)
94
+ if not a or not b:
95
+ return True
96
+ inter = len(a & b)
97
+ sim = inter / (len(a) + len(b) - inter) if (len(a) + len(b) - inter) else 0
98
+ return sim < TOPIC_SIM_THRESHOLD
99
+
100
+
101
+ def create_app() -> Optional[FastAPI]:
102
+ if not HAS_FASTAPI:
103
+ logger.warning("fastapi not installed. run: pip install fastapi uvicorn websockets")
104
+ return None
105
+
106
+ global orchestrator
107
+ config = load_config()
108
+ orchestrator = init_application(config)
109
+
110
+ from contextlib import asynccontextmanager
111
+
112
+ @asynccontextmanager
113
+ async def lifespan(app: FastAPI):
114
+ """管理后台任务的启动和关闭"""
115
+ _bg_tasks = []
116
+ auto_modules = getattr(orchestrator, '_auto_modules', {})
117
+
118
+ # 启动后台任务
119
+ ingestor = auto_modules.get('ingestor')
120
+ if ingestor and hasattr(ingestor, 'start'):
121
+ task = asyncio.ensure_future(ingestor.start())
122
+ _bg_tasks.append(task)
123
+ logger.info("[auto] 告警接入器: 已启动")
124
+
125
+ email_ingestor = auto_modules.get('email_ingestor')
126
+ if email_ingestor and hasattr(email_ingestor, 'start'):
127
+ task = asyncio.ensure_future(email_ingestor.start())
128
+ _bg_tasks.append(task)
129
+ logger.info("[auto] 邮件接入器: 已启动")
130
+
131
+ patrol = auto_modules.get('patrol')
132
+ if patrol and hasattr(patrol, 'start'):
133
+ task = asyncio.ensure_future(patrol.start())
134
+ _bg_tasks.append(task)
135
+ logger.info(f"[auto] 安全巡检器: 已启动")
136
+
137
+ yield
138
+
139
+ # 关闭后台任务
140
+ for task in _bg_tasks:
141
+ task.cancel()
142
+ if _bg_tasks:
143
+ await asyncio.gather(*_bg_tasks, return_exceptions=True)
144
+ logger.info(f"[auto] 后台任务已关闭 ({len(_bg_tasks)} 个)")
145
+
146
+ app = FastAPI(title="SecAgentX API", version="3.0.0", lifespan=lifespan)
147
+
148
+ # ═══════════════════════ 安全中间件 ═══════════════════════
149
+
150
+ # CORS 加固 — 限制来源域名
151
+ _server_port = os.getenv("SECAGENTX_PORT", "8000").strip() or "8000"
152
+ _default_origins = ",".join((
153
+ "http://localhost:3000", "http://127.0.0.1:3000",
154
+ "http://localhost:5173", "http://127.0.0.1:5173",
155
+ f"http://localhost:{_server_port}", f"http://127.0.0.1:{_server_port}",
156
+ ))
157
+ ALLOWED_ORIGINS = os.getenv(
158
+ "SECAGENTX_CORS_ORIGINS",
159
+ _default_origins,
160
+ ).split(",")
161
+ app.add_middleware(
162
+ CORSMiddleware,
163
+ allow_origins=[o.strip() for o in ALLOWED_ORIGINS if o.strip()],
164
+ allow_credentials=True,
165
+ allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
166
+ allow_headers=["Authorization", "Content-Type", "X-Request-ID"],
167
+ )
168
+
169
+ # 请求追踪中间件(生产可观测性)— 为每个请求分配唯一 trace_id
170
+ @app.middleware("http")
171
+ async def trace_middleware(request: Request, call_next):
172
+ import uuid
173
+ import time
174
+ trace_id = request.headers.get("X-Request-ID", "") or uuid.uuid4().hex[:16]
175
+ request.state.trace_id = trace_id
176
+ start = time.monotonic()
177
+ response = await call_next(request)
178
+ duration_ms = (time.monotonic() - start) * 1000
179
+ response.headers["X-Request-ID"] = trace_id
180
+ # 记录慢请求(>2s)便于排查
181
+ if duration_ms > 2000:
182
+ logger.warning(
183
+ f"[trace:{trace_id}] 慢请求 {request.method} {request.url.path} "
184
+ f"耗时 {duration_ms:.0f}ms"
185
+ )
186
+ return response
187
+
188
+ # 速率限制中间件(防止 API 滥用)
189
+ @app.middleware("http")
190
+ async def ratelimit_middleware(request: Request, call_next):
191
+ path = request.url.path
192
+ client_ip = request.client.host if request.client else "unknown"
193
+
194
+ # /api/metrics 和 /api/health 不限流
195
+ if path in ("/api/metrics", "/api/health"):
196
+ return await call_next(request)
197
+
198
+ from backend.security.ratelimit import ratelimiter
199
+ allowed, retry_after = await ratelimiter.check(path, client_ip)
200
+ if not allowed:
201
+ return error_response(
202
+ "RATE_LIMIT_EXCEEDED",
203
+ extra={"retry_after": retry_after},
204
+ headers={"Retry-After": str(retry_after)},
205
+ )
206
+ return await call_next(request)
207
+
208
+ # 认证中间件(除开放路径外的所有 API 都需要 JWT)
209
+ @app.middleware("http")
210
+ async def auth_middleware(request: Request, call_next):
211
+ path = request.url.path
212
+ method = request.method
213
+
214
+ # 开放路径跳过认证
215
+ from backend.security.auth import is_open_path
216
+ if is_open_path(path):
217
+ return await call_next(request)
218
+
219
+ # WebSocket 认证在 ws handler 内完成(返回 HTTP 错误对 WS 无效)
220
+ if path.startswith("/ws/"):
221
+ return await call_next(request)
222
+
223
+ # API 路径需要 Bearer Token
224
+ if path.startswith("/api") or path.startswith("/webhook"):
225
+ from backend.security.auth import verify_token, get_bearer_token_from_request
226
+ try:
227
+ credentials = await get_bearer_token_from_request(request)
228
+ if credentials is None:
229
+ # 也支持 Query Param 方式(用于 SSE 等场景)
230
+ token_param = request.query_params.get("token", "")
231
+ if token_param:
232
+ from fastapi.security import HTTPAuthorizationCredentials
233
+ credentials = HTTPAuthorizationCredentials(
234
+ scheme="bearer", credentials=token_param
235
+ )
236
+ else:
237
+ return error_response("AUTH_TOKEN_MISSING")
238
+ await verify_token(credentials)
239
+ except Exception as e:
240
+ err_str = str(e)
241
+ if "expired" in err_str.lower():
242
+ return error_response("AUTH_TOKEN_EXPIRED")
243
+ return error_response("AUTH_TOKEN_INVALID", message=err_str)
244
+ return await call_next(request)
245
+
246
+ return await call_next(request)
247
+
248
+ # 全局异常处理器 — 敏感信息脱敏
249
+ @app.exception_handler(Exception)
250
+ async def global_exception_handler(request, exc):
251
+ from backend.security.sanitizer import sanitize_error
252
+ sanitized = sanitize_error(str(exc))
253
+ logger.error(
254
+ f"未捕获异常: {sanitized}",
255
+ extra={"path": request.url.path},
256
+ )
257
+ return error_response("GEN_INTERNAL_ERROR", message=sanitized)
258
+
259
+ # ═══════════════════ 托管前端静态文件 ═══════════════════
260
+ from fastapi.staticfiles import StaticFiles
261
+ from backend.runtime_assets import frontend_dist as resolve_frontend_dist
262
+ frontend_dist = str(resolve_frontend_dist())
263
+ if os.path.exists(frontend_dist):
264
+ app.mount("/assets", StaticFiles(directory=os.path.join(frontend_dist, "assets")), name="assets")
265
+
266
+ @app.get("/")
267
+ async def serve_frontend():
268
+ from fastapi.responses import FileResponse
269
+ return FileResponse(os.path.join(frontend_dist, "index.html"))
270
+
271
+ @app.exception_handler(404)
272
+ async def spa_fallback(request, exc):
273
+ """Vue SPA fallback: 所有非 API 路由返回 index.html"""
274
+ from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
275
+ path = request.url.path
276
+ if path.startswith("/api") or path.startswith("/ws") or path.startswith("/webhook"):
277
+ return JSONResponse({"error": "Not Found"}, status_code=404)
278
+
279
+ # ─── 修复: 子路由下被 SPA fallback 吞掉的静态资源请求 ───
280
+ # 前端 vite 使用 base:'./' 构建(为兼容 OpenIM /secagentx/ 子路径部署),
281
+ # index.html 中资源为相对路径 ./assets/xxx.js。
282
+ # 独立部署时若在 /chat、/dashboard 等子路由刷新页面,浏览器会把
283
+ # ./assets/xxx.js 解析为 /chat/assets/xxx.js,被本 fallback 捕获并返回
284
+ # index.html(HTML),浏览器将其当作 JS 执行 → 语法错误 → 白屏。
285
+ #
286
+ # 修复: 识别这类被吞掉的资源请求并重定向到真实 /assets/ 路径。
287
+ if "/assets/" in path:
288
+ asset_rel = path.split("/assets/", 1)[1]
289
+ real_asset = os.path.join(frontend_dist, "assets", asset_rel)
290
+ if os.path.isfile(real_asset):
291
+ return RedirectResponse(f"/assets/{asset_rel}", status_code=301)
292
+ return JSONResponse({"error": "Not Found"}, status_code=404)
293
+
294
+ # 其他带扩展名的静态资源请求(非 assets 目录):直接 404,
295
+ # 避免把 HTML 当 JS/CSS 返回导致白屏
296
+ ext = os.path.splitext(path)[1].lower()
297
+ if ext in {".js", ".css", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico",
298
+ ".woff", ".woff2", ".ttf", ".eot", ".map", ".webp", ".mp4", ".webm"}:
299
+ return JSONResponse({"error": "Not Found"}, status_code=404)
300
+
301
+ # 正常 SPA 路由(不带扩展名) → 返回 index.html
302
+ index_path = os.path.join(frontend_dist, "index.html")
303
+ if os.path.exists(index_path):
304
+ return FileResponse(index_path)
305
+ return JSONResponse({"error": "Not Found"}, status_code=404)
306
+ logger.info(f"serving frontend from: {frontend_dist}")
307
+ else:
308
+ logger.info(f"frontend dist not found at {frontend_dist}, API-only mode")
309
+
310
+ # ═══════════════════════ 认证 API ═══════════════════════
311
+
312
+ from backend.security.auth import (
313
+ ACCESS_TOKEN_EXPIRE_MINUTES,
314
+ REFRESH_TOKEN_EXPIRE_MINUTES,
315
+ RefreshTokenInvalid,
316
+ RefreshTokenReuseDetected,
317
+ create_access_token,
318
+ create_refresh_token,
319
+ rotate_refresh_token,
320
+ LoginBody,
321
+ )
322
+ from backend.security.rbac import get_user_manager
323
+ user_manager = get_user_manager() # 启动时即校验管理员初始凭据
324
+
325
+ @app.post("/api/auth/login")
326
+ async def login(body: LoginBody):
327
+ """登录获取 JWT 双令牌(access_token + refresh_token)
328
+
329
+ 使用 RBAC UserManager 进行用户认证,支持多用户(admin/operator/analyst/viewer)。
330
+ """
331
+ user = user_manager.authenticate(body.username, body.password)
332
+ if not user:
333
+ return error_response("AUTH_WRONG_CREDENTIALS")
334
+
335
+ access_token = create_access_token(
336
+ sub=user.username, role=user.role, token_version=user.token_version
337
+ )
338
+ refresh_token = create_refresh_token(
339
+ sub=user.username, role=user.role, token_version=user.token_version
340
+ )
341
+
342
+ # ─── OpenIM 账号同步钩子(异步,不影响登录响应) ───
343
+ # 登录成功后,自动将用户同步到 OpenIM(通过桥接器)
344
+ try:
345
+ bridge_url = os.getenv("OPENIM_BRIDGE_URL", "").rstrip("/")
346
+ if bridge_url:
347
+ asyncio.ensure_future(_sync_user_to_openim(
348
+ bridge_url, user.username,
349
+ user.display_name or user.username, user.role,
350
+ ))
351
+ except Exception as _sync_e:
352
+ logger.warning(f"OpenIM 账号同步钩子异常: {_sync_e}")
353
+
354
+ return {
355
+ "access_token": access_token,
356
+ "refresh_token": refresh_token,
357
+ "token_type": "bearer",
358
+ "expires_in": ACCESS_TOKEN_EXPIRE_MINUTES * 60,
359
+ "refresh_expires_in": REFRESH_TOKEN_EXPIRE_MINUTES * 60,
360
+ "user": user.to_dict(),
361
+ }
362
+
363
+
364
+ async def _sync_user_to_openim(bridge_url: str, username: str,
365
+ display_name: str, role: str) -> None:
366
+ """异步调用桥接器同步用户到 OpenIM(失败仅记录日志)"""
367
+ try:
368
+ import httpx
369
+ async with httpx.AsyncClient(timeout=10) as client:
370
+ resp = await client.post(
371
+ f"{bridge_url}/account/sync",
372
+ json={
373
+ "username": username,
374
+ "display_name": display_name,
375
+ "role": role,
376
+ },
377
+ )
378
+ if resp.status_code == 200:
379
+ data = resp.json()
380
+ if data.get("success"):
381
+ logger.info(f"OpenIM 账号自动同步成功: {username} → {data.get('openim_user_id')}")
382
+ else:
383
+ logger.warning(f"OpenIM 账号同步返回失败: {username}: {data}")
384
+ else:
385
+ logger.warning(f"OpenIM 账号同步 HTTP {resp.status_code}")
386
+ except Exception as e:
387
+ logger.warning(f"OpenIM 账号同步异常: {e}")
388
+
389
+ class RefreshBody(BaseModel):
390
+ refresh_token: str
391
+
392
+ @app.post("/api/auth/refresh")
393
+ async def refresh_token(body: RefreshBody):
394
+ """使用 refresh_token 换取新的 access_token + refresh_token
395
+
396
+ 请求体: {"refresh_token": "..."}
397
+ 返回: 新的 access_token + refresh_token(轮换机制:旧 refresh_token 不再有效)
398
+ """
399
+ try:
400
+ new_access, new_refresh, expires_in = rotate_refresh_token(body.refresh_token)
401
+ return {
402
+ "access_token": new_access,
403
+ "refresh_token": new_refresh,
404
+ "token_type": "bearer",
405
+ "expires_in": expires_in,
406
+ "refresh_expires_in": REFRESH_TOKEN_EXPIRE_MINUTES * 60,
407
+ }
408
+ except RefreshTokenReuseDetected as e:
409
+ return error_response("AUTH_TOKEN_INVALID", message=str(e))
410
+ except RefreshTokenInvalid as e:
411
+ return error_response("AUTH_TOKEN_INVALID", message=str(e))
412
+ except Exception as e:
413
+ err_msg = str(e)
414
+ if "expired" in err_msg.lower():
415
+ return error_response("AUTH_TOKEN_EXPIRED", message="refresh_token 已过期,请重新登录")
416
+ return error_response("AUTH_TOKEN_INVALID", message=f"无效的 refresh_token: {err_msg}")
417
+
418
+
419
+ # ═══════════════════════ 用户管理 API ═══════════════════════
420
+ from backend.security.auth import verify_token
421
+ from backend.security.rbac import require_permission
422
+
423
+ @app.get("/api/users")
424
+ async def list_users(_=Depends(require_permission("admin:users"))):
425
+ """列出所有用户(需要 admin:users 权限)"""
426
+ mgr = get_user_manager()
427
+ return {"users": mgr.list_users()}
428
+
429
+ class CreateUserBody(BaseModel):
430
+ username: str
431
+ password: str
432
+ role: str = "viewer"
433
+ display_name: str = ""
434
+
435
+ @app.post("/api/users")
436
+ async def create_user(body: CreateUserBody,
437
+ _=Depends(require_permission("admin:users"))):
438
+ """创建用户(需要 admin:users 权限)"""
439
+ mgr = get_user_manager()
440
+ try:
441
+ user = mgr.create_user(
442
+ username=body.username,
443
+ password=body.password,
444
+ role=body.role,
445
+ display_name=body.display_name,
446
+ )
447
+ return {"status": "ok", "user": user.to_dict()}
448
+ except ValueError as e:
449
+ return error_response("USER_CREATE_FAILED", message=str(e))
450
+
451
+ class UpdateUserBody(BaseModel):
452
+ role: str = None
453
+ enabled: bool = None
454
+ display_name: str = None
455
+ password: str = None
456
+
457
+ @app.put("/api/users/{username}")
458
+ async def update_user(username: str, body: UpdateUserBody,
459
+ _=Depends(require_permission("admin:users"))):
460
+ """更新用户信息(需要 admin:users 权限)"""
461
+ mgr = get_user_manager()
462
+ try:
463
+ user = mgr.update_user(
464
+ username=username,
465
+ role=body.role,
466
+ enabled=body.enabled,
467
+ display_name=body.display_name,
468
+ password=body.password,
469
+ )
470
+ if not user:
471
+ return error_response("USER_NOT_FOUND", message=f"用户不存在: {username}")
472
+ return {"status": "ok", "user": user.to_dict()}
473
+ except ValueError as e:
474
+ return error_response("USER_UPDATE_FAILED", message=str(e))
475
+
476
+ @app.delete("/api/users/{username}")
477
+ async def delete_user(username: str,
478
+ _=Depends(require_permission("admin:users"))):
479
+ """删除用户(需要 admin:users 权限)"""
480
+ mgr = get_user_manager()
481
+ try:
482
+ if mgr.delete_user(username):
483
+ return {"status": "ok", "message": f"用户 {username} 已删除"}
484
+ return error_response("USER_NOT_FOUND", message=f"用户不存在: {username}")
485
+ except ValueError as e:
486
+ return error_response("USER_DELETE_FAILED", message=str(e))
487
+
488
+ @app.get("/api/users/me")
489
+ async def current_user(token=Depends(verify_token)):
490
+ """获取当前用户信息"""
491
+ mgr = get_user_manager()
492
+ user = mgr.get_user(token.sub)
493
+ if not user:
494
+ return error_response("USER_NOT_FOUND")
495
+ result = user.to_dict()
496
+ result["permissions"] = sorted(list(
497
+ __import__("backend.security.rbac", fromlist=["ROLE_PERMISSIONS"]).ROLE_PERMISSIONS.get(user.role, set())
498
+ ))
499
+ return result
500
+
501
+ # ═══════════════════════ 健康检查 API ═══════════════════════
502
+
503
+ @app.get("/api/health")
504
+ async def health():
505
+ """详细的健康检查(含各组件状态)"""
506
+ checks = {
507
+ "status": "ok",
508
+ "service": "secagentx",
509
+ "version": "3.0.0",
510
+ "timestamp": __import__("datetime").datetime.now(
511
+ __import__("datetime").timezone.utc
512
+ ).isoformat(),
513
+ }
514
+
515
+ # 1. Agent 状态摘要(精简版,不暴露内部运行细节)
516
+ agents = orchestrator.get_agent_statuses()
517
+ checks["agents"] = [
518
+ {"id": a["id"], "name": a["name"], "status": a["status"]}
519
+ for a in agents
520
+ ]
521
+
522
+ # 2. 数据库检查
523
+ try:
524
+ async with get_repository() as repo:
525
+ row = await repo.fetch_one("SELECT COUNT(*) as cnt FROM events")
526
+ checks["database"] = "ok"
527
+ checks["database_events"] = row["cnt"] if row else 0
528
+ except Exception as e:
529
+ checks["database"] = "error"
530
+ checks["status"] = "degraded"
531
+
532
+ # 3. LLM 只报告是否可用,不暴露厂商、模型或哪个 Key 已配
533
+ deepseek_ok = bool(os.getenv("DEEPSEEK_API_KEY"))
534
+ qwen_ok = bool(os.getenv("QWEN_API_KEY"))
535
+ runtime_provider = bool(os.getenv("SECAGENTX_ACTIVE_PROVIDER"))
536
+ runtime_key = bool(os.getenv("SECAGENTX_LLM_API_KEY"))
537
+ no_key_provider = os.getenv("SECAGENTX_LLM_ALLOW_NO_KEY", "false").lower() == "true"
538
+ llm_available = deepseek_ok or qwen_ok or (
539
+ runtime_provider and (runtime_key or no_key_provider)
540
+ )
541
+ checks["llm"] = {
542
+ "available": llm_available,
543
+ }
544
+ if not llm_available:
545
+ checks["status"] = "degraded"
546
+
547
+ # 4. 工具状态必须反映真实可用性,不能把缺失的本地库报告为 ready。
548
+ threat_keys = ["VT_API_KEY", "ABUSEIPDB_API_KEY", "OTX_API_KEY"]
549
+ local_threat_path = Path(os.getenv(
550
+ "THREAT_IPS_PATH", "data/blacklist/threat_ips.json"
551
+ ))
552
+ threat_status = (
553
+ "external_api" if any(os.getenv(k) for k in threat_keys)
554
+ else "local_blacklist" if local_threat_path.is_file()
555
+ else "unavailable"
556
+ )
557
+ firewall_backend = os.getenv("FIREWALL_BACKEND", "disabled")
558
+ checks["tools"] = {
559
+ "threat_intel": threat_status,
560
+ "firewall": (
561
+ "disabled" if firewall_backend == "disabled"
562
+ else "test_only" if firewall_backend == "mock"
563
+ else "ready"
564
+ ),
565
+ }
566
+
567
+ # 5. 熔断器
568
+ try:
569
+ from backend.security.circuit_breaker import circuit_breaker
570
+ cb = circuit_breaker.get_status()
571
+ checks["circuit_breaker"] = {
572
+ "state": cb["state"],
573
+ "is_blocked": cb["is_blocked"],
574
+ "blocks_today": cb["blocks_today"],
575
+ }
576
+ except Exception:
577
+ pass
578
+
579
+ return checks
580
+
581
+ # ═══════════════════════ Prometheus 指标 API ═══════════════════════
582
+
583
+ @app.get("/api/metrics")
584
+ async def metrics():
585
+ """Prometheus 格式的监控指标"""
586
+ from backend.monitoring.metrics import get_metrics, set_active_blocks, set_agent_status
587
+ from starlette.responses import Response
588
+
589
+ # 更新实时指标
590
+ try:
591
+ from backend.security.circuit_breaker import circuit_breaker
592
+ cb_status = circuit_breaker.get_status()
593
+ set_cb_metric(cb_status.get("state", "closed"))
594
+
595
+ # Agent 状态
596
+ agents = orchestrator.get_agent_statuses()
597
+ for a in agents:
598
+ set_agent_status(a.get("id", ""), a.get("name", ""), a.get("status", "unknown"))
599
+
600
+ # 活跃封禁数(通过防火墙工具查询)
601
+ try:
602
+ fw_result = await orchestrator.tools.execute("firewall_manage", action="list")
603
+ if fw_result.success:
604
+ rules = fw_result.data.get("rules", [])
605
+ set_active_blocks(len(rules))
606
+ except Exception:
607
+ pass
608
+
609
+ # 队列大小
610
+ auto_mods = orchestrator.get_auto_modules()
611
+ ingestor = auto_mods.get("ingestor")
612
+ if ingestor:
613
+ stats = ingestor.get_stats()
614
+ set_queue_size(stats.get("queue_size", 0))
615
+ except Exception:
616
+ pass
617
+
618
+ metrics_data = get_metrics()
619
+ return Response(
620
+ content=metrics_data,
621
+ media_type="text/plain; charset=utf-8",
622
+ headers={"Cache-Control": "no-cache"},
623
+ )
624
+
625
+ @app.get("/api/stats", dependencies=[Depends(require_permission("dashboard:view"))])
626
+ async def stats():
627
+ """系统统计(含 Agent 状态和数据库真实数据指标)"""
628
+ base = orchestrator.get_stats()
629
+ # 补充数据库统计
630
+ try:
631
+ from backend.storage.database import get_repository
632
+ async with get_repository() as repo:
633
+ ev_total = (await repo.fetch_one("SELECT COUNT(*) as c FROM events"))["c"] or 0
634
+ ev_open = (await repo.fetch_one("SELECT COUNT(*) as c FROM events WHERE status='open'"))["c"] or 0
635
+ ev_high = (await repo.fetch_one("SELECT COUNT(*) as c FROM events WHERE severity='紧急' OR severity='高危'"))["c"] or 0
636
+ ioc_total = (await repo.fetch_one("SELECT COUNT(*) as c FROM ioc_database"))["c"] or 0
637
+ src_ips = (await repo.fetch_one("SELECT COUNT(DISTINCT source_ip) as c FROM events"))["c"] or 0
638
+ base.update({
639
+ "events_total": ev_total,
640
+ "events_open": ev_open,
641
+ "events_high_severity": ev_high,
642
+ "ioc_total": ioc_total,
643
+ "unique_source_ips": src_ips,
644
+ })
645
+ except Exception:
646
+ pass
647
+ return base
648
+
649
+ # ==================== 零人工干预 API ====================
650
+
651
+ class AlertWebhookBody(BaseModel):
652
+ """Webhook 告警数据模型"""
653
+ id: str = ""
654
+ title: str = ""
655
+ description: str = ""
656
+ src_ip: str = ""
657
+ source_ip: str = "" # 兼容不同字段名
658
+ dst_ip: str = ""
659
+ severity: str = "中危"
660
+ alert_type: str = ""
661
+ timestamp: str = ""
662
+
663
+ @app.post("/webhook/alert", dependencies=[Depends(require_permission("events:write"))])
664
+ async def webhook_alert(alert: AlertWebhookBody):
665
+ """
666
+ Webhook 告警接入端点
667
+
668
+ 企业平台只需 POST JSON 到 /webhook/alert,SecAgentX 将自动:
669
+ 1. 分析告警
670
+ 2. 评估置信度
671
+ 3. 按阈值自动处置(封禁/闭环/升级通知)
672
+
673
+ 请求示例:
674
+ {
675
+ "id": "alert-001",
676
+ "title": "SSH暴力破解",
677
+ "description": "源IP 45.33.32.156 在5分钟内进行了100次SSH登录尝试",
678
+ "src_ip": "45.33.32.156",
679
+ "severity": "高危"
680
+ }
681
+ """
682
+ auto_mods = orchestrator.get_auto_modules()
683
+ ingestor = auto_mods.get("ingestor")
684
+
685
+ if not ingestor:
686
+ return error_response("AUTO_NOT_ENABLED")
687
+
688
+ # 合并兼容字段
689
+ alert_dict = alert.model_dump()
690
+ if not alert_dict.get("src_ip") and alert_dict.get("source_ip"):
691
+ alert_dict["src_ip"] = alert_dict["source_ip"]
692
+
693
+ result = await ingestor.handle_webhook(alert_dict)
694
+
695
+ # Prometheus 指标记录
696
+ action = result.get("action", "unknown")
697
+ record_alert(action)
698
+
699
+ return result
700
+
701
+ @app.get("/api/auto/status", dependencies=[Depends(require_permission("dashboard:view"))])
702
+ async def auto_operation_status():
703
+ """获取零人工干预模块的运行状态"""
704
+ auto_mods = orchestrator.get_auto_modules()
705
+ # 数据保留策略状态
706
+ retention_status = None
707
+ try:
708
+ from backend.storage.retention import DataRetention
709
+ config = orchestrator.get_config() if hasattr(orchestrator, 'get_config') else {}
710
+ retention = DataRetention(config.get("auto_operation", {}))
711
+ retention_status = retention.get_summary()
712
+ except Exception:
713
+ pass
714
+
715
+ return {
716
+ "enabled": bool(auto_mods),
717
+ "ingestor": auto_mods.get("ingestor", {}).get_stats() if auto_mods.get("ingestor") else None,
718
+ "patrol": auto_mods.get("patrol", {}).get_stats() if auto_mods.get("patrol") else None,
719
+ "escalation": auto_mods.get("escalator", {}).get_status() if auto_mods.get("escalator") else None,
720
+ "data_retention": retention_status,
721
+ }
722
+
723
+ @app.post("/api/auto/patrol", dependencies=[Depends(require_permission("events:write"))])
724
+ async def trigger_patrol():
725
+ """手动触发一次巡检"""
726
+ auto_mods = orchestrator.get_auto_modules()
727
+ patrol = auto_mods.get("patrol")
728
+ if not patrol:
729
+ return error_response("AUTO_PATROL_NOT_ENABLED")
730
+ result = await patrol.patrol_once()
731
+ return {"status": "ok", "result": result}
732
+
733
+ # ═══════════════════ 跨区域联邦 API ═══════════════════
734
+ # 联邦 API 依赖: 验证对端 Token
735
+ async def verify_federation_request(request: Request):
736
+ """FastAPI 依赖: 验证跨区域同步请求的身份"""
737
+ from backend.federation.core import verify_peer_request
738
+ valid, detail = await verify_peer_request(request)
739
+ if not valid:
740
+ from fastapi.responses import JSONResponse
741
+ raise HTTPException(status_code=403, detail=f"联邦认证失败: {detail}")
742
+ return detail # 返回 region_id
743
+
744
+ @app.get("/api/federation/status", dependencies=[Depends(require_permission("dashboard:view"))])
745
+ async def federation_status():
746
+ """获取跨区域联邦状态(所有对端区域健康状态)"""
747
+ fed = getattr(orchestrator, "_federation", None)
748
+ if not fed:
749
+ return {"enabled": False, "error": "联邦模块未初始化"}
750
+ return fed.get_status()
751
+
752
+ class FederationEventBody(BaseModel):
753
+ events: list[dict] = []
754
+ source_region: str = ""
755
+
756
+ @app.post("/api/federation/events",
757
+ dependencies=[Depends(verify_federation_request)])
758
+ async def federation_receive_events(body: FederationEventBody):
759
+ """接收对端区域推送的事件(由对端 Federation 调用,需验证 Token)"""
760
+ fed = getattr(orchestrator, "_federation", None)
761
+ if not fed or not fed.enabled:
762
+ return error_response("FED_NOT_ENABLED")
763
+ await fed._save_remote_events(body.source_region, body.events)
764
+ return {"status": "ok", "received": len(body.events)}
765
+
766
+ @app.get("/api/federation/events",
767
+ dependencies=[Depends(verify_federation_request)])
768
+ async def federation_get_events(limit: int = 100, since: str = "", exclude_region: str = ""):
769
+ """
770
+ 提供给对端区域拉取事件(增量同步)
771
+
772
+ 防环路(修复版):
773
+ 远程同步的事件 ID 前缀为 `fed-{region}-`,
774
+ 用 `WHERE id NOT LIKE 'fed-?-%%'` 在 SQL 层面过滤,
775
+ 不再依赖标题字符串匹配。
776
+ """
777
+ async with get_repository() as db:
778
+ if since:
779
+ if exclude_region:
780
+ exclude_pattern = f"fed-{exclude_region}-%"
781
+ rows = await db.fetch_all(
782
+ "SELECT * FROM events WHERE created_at > ? "
783
+ "AND id NOT LIKE ? "
784
+ "ORDER BY created_at ASC LIMIT ?",
785
+ (since, exclude_pattern, limit),
786
+ )
787
+ else:
788
+ rows = await db.fetch_all(
789
+ "SELECT * FROM events WHERE created_at > ? ORDER BY created_at ASC LIMIT ?",
790
+ (since, limit),
791
+ )
792
+ else:
793
+ if exclude_region:
794
+ exclude_pattern = f"fed-{exclude_region}-%"
795
+ rows = await db.fetch_all(
796
+ "SELECT * FROM events WHERE id NOT LIKE ? "
797
+ "ORDER BY created_at DESC LIMIT ?",
798
+ (exclude_pattern, limit),
799
+ )
800
+ else:
801
+ rows = await db.fetch_all(
802
+ "SELECT * FROM events ORDER BY created_at DESC LIMIT ?",
803
+ (limit,),
804
+ )
805
+ events = []
806
+ for r in rows:
807
+ events.append({
808
+ "id": r["id"], "title": r["title"],
809
+ "severity": r["severity"], "status": r["status"],
810
+ "source_ip": r.get("source_ip", ""),
811
+ "description": r.get("description", ""),
812
+ "created_at": r["created_at"],
813
+ })
814
+ return {"events": events}
815
+
816
+ class FederationBlacklistBody(BaseModel):
817
+ entries: list[dict] = []
818
+ source_region: str = ""
819
+
820
+ @app.post("/api/federation/blacklist",
821
+ dependencies=[Depends(verify_federation_request)])
822
+ async def federation_receive_blacklist(body: FederationBlacklistBody):
823
+ """接收对端区域推送的黑名单(由对端 Federation 调用,需验证 Token)"""
824
+ fed = getattr(orchestrator, "_federation", None)
825
+ if not fed or not fed.enabled:
826
+ return error_response("FED_NOT_ENABLED")
827
+ await fed._apply_remote_blacklist(body.source_region, body.entries)
828
+ return {"status": "ok", "received": len(body.entries)}
829
+
830
+ @app.get("/api/federation/blacklist",
831
+ dependencies=[Depends(verify_federation_request)])
832
+ async def federation_get_blacklist():
833
+ """提供给对端区域拉取黑名单(需验证 Token)"""
834
+ try:
835
+ # 从 orchestrator 注册的防火墙工具获取实际后端,而非硬编码 mock
836
+ fw = orchestrator.tools.get("firewall_manage")
837
+ if not fw:
838
+ return {"entries": [], "error": "防火墙工具未注册"}
839
+ result = await fw.execute(action="list")
840
+ if result.success:
841
+ rules = result.data.get("rules", [])
842
+ entries = [
843
+ {"ip": r["ip"], "reason": r.get("reason", ""),
844
+ "duration_minutes": r.get("duration_minutes", 120),
845
+ "blocked_at": r.get("blocked_at", "")}
846
+ for r in rules
847
+ ]
848
+ return {"entries": entries}
849
+ return {"entries": []}
850
+ except Exception as e:
851
+ return {"entries": [], "error": str(e)}
852
+
853
+ @app.get("/api/federation/dashboard", dependencies=[Depends(require_permission("dashboard:view"))])
854
+ async def federation_dashboard():
855
+ """跨区域统一安全仪表盘 — 聚合所有区域的事件和封禁状态"""
856
+ fed = getattr(orchestrator, "_federation", None)
857
+ if not fed or not fed.enabled:
858
+ return {"enabled": False, "regions": [{"id": "local", "name": "本区域"}]}
859
+
860
+ regions_data = []
861
+ # 本区域
862
+ async with get_repository() as db:
863
+ row = await db.fetch_one("SELECT COUNT(*) as cnt FROM events")
864
+ local_events = row["cnt"] if row else 0
865
+ row2 = await db.fetch_one(
866
+ "SELECT COUNT(*) as cnt FROM events WHERE status='open'"
867
+ )
868
+ open_events = row2["cnt"] if row2 else 0
869
+
870
+ regions_data.append({
871
+ "id": fed.region_id,
872
+ "name": fed.region_name,
873
+ "is_local": True,
874
+ "is_healthy": True,
875
+ "events": local_events,
876
+ "open_events": open_events,
877
+ })
878
+
879
+ # 对端区域
880
+ for peer in fed.get_peers():
881
+ import httpx
882
+ try:
883
+ async with httpx.AsyncClient(timeout=5) as client:
884
+ resp = await client.get(
885
+ f"{peer.api_url}/api/health",
886
+ headers={"Authorization": f"Bearer {peer.api_token}"},
887
+ )
888
+ if resp.status_code == 200:
889
+ data = resp.json()
890
+ regions_data.append({
891
+ "id": peer.region_id,
892
+ "name": peer.region_name,
893
+ "is_local": False,
894
+ "is_healthy": True,
895
+ "events": data.get("database_events", 0),
896
+ })
897
+ else:
898
+ regions_data.append({
899
+ "id": peer.region_id,
900
+ "name": peer.region_name,
901
+ "is_local": False,
902
+ "is_healthy": False,
903
+ })
904
+ except Exception:
905
+ regions_data.append({
906
+ "id": peer.region_id,
907
+ "name": peer.region_name,
908
+ "is_local": False,
909
+ "is_healthy": False,
910
+ })
911
+
912
+ return {"enabled": True, "regions": regions_data}
913
+
914
+ # ==================== 审计日志 API ====================
915
+ @app.get("/api/audit/logs", dependencies=[Depends(require_permission("audit:read"))])
916
+ async def list_audit_logs(
917
+ target: str = "",
918
+ action_type: str = "",
919
+ limit: int = 50,
920
+ ):
921
+ """查询操作审计日志"""
922
+ from backend.security.audit import audit_repo
923
+ return {
924
+ "logs": audit_repo.query(
925
+ target=target, action_type=action_type, limit=min(limit, 200)
926
+ )
927
+ }
928
+
929
+ @app.get("/api/audit/recent", dependencies=[Depends(require_permission("audit:read"))])
930
+ async def recent_audit_logs(limit: int = 20):
931
+ """获取最近的操作审计日志"""
932
+ from backend.security.audit import audit_repo
933
+ return {"logs": audit_repo.get_recent(limit=min(limit, 200))}
934
+
935
+ @app.get("/api/auto/config", dependencies=[Depends(require_permission("admin:config"))])
936
+ async def auto_operation_config():
937
+ """返回当前零人工干预配置"""
938
+ config = orchestrator.get_config()
939
+ return config.get("auto_operation", {})
940
+
941
+ @app.put("/api/admin/config", dependencies=[Depends(require_permission("admin:config"))])
942
+ async def update_config(body: dict):
943
+ """更新运行时配置(自动处置阈值、巡检间隔、数据保留策略等)"""
944
+ if not orchestrator._auto_modules:
945
+ raise HTTPException(status_code=503, detail="自动模块未初始化")
946
+ try:
947
+ old_config = orchestrator.get_config() or {}
948
+ new_config = old_config.copy()
949
+ auto_op = new_config.setdefault("auto_operation", {})
950
+
951
+ if "auto_operation" in body:
952
+ for key, val in body["auto_operation"].items():
953
+ if isinstance(val, dict) and isinstance(auto_op.get(key), dict):
954
+ auto_op[key].update(val)
955
+ else:
956
+ auto_op[key] = val
957
+
958
+ if "data_retention" in body:
959
+ dr = new_config.setdefault("auto_operation", {}).setdefault("data_retention", {})
960
+ dr.update(body["data_retention"])
961
+
962
+ import yaml
963
+ config_path = os.getenv("SECAGENTX_CONFIG", "config.yaml")
964
+ with open(config_path, "w", encoding="utf-8") as f:
965
+ yaml.safe_dump(new_config, f, allow_unicode=True, default_flow_style=False)
966
+
967
+ logger.info(f"[config] 运行时配置已更新: {list(body.keys())}")
968
+ return {"status": "ok", "updated_keys": list(body.keys())}
969
+ except Exception as e:
970
+ logger.error(f"[config] 配置更新失败: {e}")
971
+ raise HTTPException(status_code=500, detail=f"配置保存失败: {e}")
972
+
973
+ @app.get("/api/agents", dependencies=[Depends(require_permission("agents:read"))])
974
+ async def list_agents():
975
+ return {"agents": orchestrator.get_agent_statuses()}
976
+
977
+ @app.get("/api/agents/runtime", dependencies=[Depends(require_permission("agents:read"))])
978
+ async def agents_runtime():
979
+ """Agent 运行时仪表盘数据(状态、延迟、Token 消耗)"""
980
+ return {"agents": orchestrator.get_agent_runtime()}
981
+
982
+ @app.get("/api/evaluation/agents", dependencies=[Depends(require_permission("agents:read"))])
983
+ async def evaluation_agents():
984
+ """Agent 评估评分卡(M5 确定性评分)"""
985
+ try:
986
+ from backend.evaluation.metrics import AgentMetrics
987
+ from backend.evaluation.evaluator import AgentEvaluator
988
+ metrics = AgentMetrics()
989
+ result = await AgentEvaluator.evaluate_all(metrics)
990
+ return result
991
+ except Exception as e:
992
+ logger.error(f"[evaluation] 评估计算失败: {e}")
993
+ raise HTTPException(status_code=500, detail=f"评估计算失败: {e}")
994
+
995
+ # ==================== 事件格式化辅助函数 ====================
996
+ def _extract_raw(raw_str: str) -> dict:
997
+ """安全解析 raw_data JSON 字段"""
998
+ try:
999
+ return json.loads(raw_str) if isinstance(raw_str, str) and raw_str.strip() else {}
1000
+ except (json.JSONDecodeError, TypeError):
1001
+ return {}
1002
+
1003
+ def _format_event_list_item(r: dict) -> dict:
1004
+ """格式化事件列表项,从 raw_data 提取结构化字段"""
1005
+ raw = _extract_raw(r.get("raw_data", ""))
1006
+ desc = r.get("description", "") or ""
1007
+
1008
+ # 优先使用 raw_data 中的结构化置信度
1009
+ confidence = raw.get("confidence", 0)
1010
+ if not confidence:
1011
+ import re
1012
+ conf_match = re.search(r'置信度\s*(\d+\.?\d*)%?', desc)
1013
+ confidence = min(float(conf_match.group(1)) / 100.0, 1.0) if conf_match else 0.75
1014
+
1015
+ return {
1016
+ "id": r["id"],
1017
+ "title": r["title"],
1018
+ "severity": r["severity"],
1019
+ "status": r["status"],
1020
+ "source_ip": r["source_ip"],
1021
+ "alert_type": r["alert_type"],
1022
+ "technique_id": r.get("technique_id", ""),
1023
+ "tactic_id": r.get("tactic_id", ""),
1024
+ "description": (desc[:200] + "...") if len(desc) > 200 else desc,
1025
+ "confidence": round(confidence, 2),
1026
+ "cve_id": raw.get("cve_id", ""),
1027
+ "actor_name": raw.get("actor_name", ""),
1028
+ "malware_name": raw.get("malware_name", ""),
1029
+ "threat_sources": raw.get("threat_sources", []),
1030
+ "threat_level": raw.get("threat_level", ""),
1031
+ "dest_ip": raw.get("dest_ip", ""),
1032
+ "dest_port": raw.get("dest_port", 0),
1033
+ "protocol": raw.get("protocol", ""),
1034
+ "created_at": r["created_at"],
1035
+ "resolved_at": r.get("resolved_at"),
1036
+ }
1037
+
1038
+ def _format_event_detail(r: dict) -> dict:
1039
+ """格式化事件详情,完整解析 raw_data"""
1040
+ raw = _extract_raw(r.get("raw_data", ""))
1041
+ desc = r.get("description", "") or ""
1042
+
1043
+ # 置信度
1044
+ confidence = raw.get("confidence", 0)
1045
+ if not confidence:
1046
+ import re
1047
+ conf_match = re.search(r'置信度\s*(\d+\.?\d*)%?', desc)
1048
+ confidence = min(float(conf_match.group(1)) / 100.0, 1.0) if conf_match else 0.85
1049
+
1050
+ # 技术映射
1051
+ techniques = raw.get("techniques", [])
1052
+ if not techniques and r.get("mitre_technique_id"):
1053
+ techniques = [{
1054
+ "id": r["mitre_technique_id"],
1055
+ "name": raw.get("mitre_technique_name", ""),
1056
+ "confidence": confidence,
1057
+ "tactic": r.get("mitre_tactic_id", ""),
1058
+ "tactic_name": raw.get("mitre_tactic_name", ""),
1059
+ }]
1060
+
1061
+ return {
1062
+ "id": r["id"],
1063
+ "title": r["title"],
1064
+ "severity": r["severity"],
1065
+ "status": r["status"],
1066
+ "source_ip": r["source_ip"],
1067
+ "alert_type": r["alert_type"],
1068
+ "description": desc,
1069
+ "confidence": round(confidence, 2),
1070
+ "source": raw.get("source", "实时威胁情报"),
1071
+ "destination": raw.get("dest_ip", raw.get("destination", "Server-01")),
1072
+ "destination_port": raw.get("dest_port", None),
1073
+ "cve_id": raw.get("cve_id", ""),
1074
+ "actor_name": raw.get("actor_name", ""),
1075
+ "actor_country": raw.get("actor_country", ""),
1076
+ "malware_name": raw.get("malware_name", ""),
1077
+ "threat_sources": raw.get("threat_sources", []),
1078
+ "threat_level": raw.get("threat_level", ""),
1079
+ "protocol": raw.get("protocol", ""),
1080
+ "dest_ip": raw.get("dest_ip", ""),
1081
+ "dest_port": raw.get("dest_port", 0),
1082
+ "created_at": r["created_at"],
1083
+ "resolved_at": r.get("resolved_at"),
1084
+ "techniques": techniques,
1085
+ "iocs": raw.get("iocs", []),
1086
+ "recommendation": raw.get("recommendation", []),
1087
+ "ai_analysis": desc,
1088
+ "raw_data": r.get("raw_data", "{}"),
1089
+ }
1090
+
1091
+ # ==================== 事件 API ====================
1092
+ @app.get("/api/events", dependencies=[Depends(require_permission("events:read"))])
1093
+ async def list_events(
1094
+ limit: int = 50,
1095
+ offset: int = 0,
1096
+ severity: str = "",
1097
+ status: str = "",
1098
+ alert_type: str = "",
1099
+ ):
1100
+ """获取安全事件列表(支持分页和筛选)"""
1101
+ limit = min(max(limit, 1), 200)
1102
+ offset = max(offset, 0)
1103
+ async with get_repository() as repo:
1104
+ where_clauses = []
1105
+ params = []
1106
+ if severity:
1107
+ where_clauses.append("severity = ?")
1108
+ params.append(severity)
1109
+ if status:
1110
+ where_clauses.append("status = ?")
1111
+ params.append(status)
1112
+ if alert_type:
1113
+ where_clauses.append("alert_type = ?")
1114
+ params.append(alert_type)
1115
+ where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
1116
+
1117
+ import re
1118
+ rows = await repo.fetch_all(
1119
+ f"SELECT id, title, severity, status, source_ip, alert_type, "
1120
+ f"COALESCE(mitre_technique_id, '') as technique_id, "
1121
+ f"COALESCE(mitre_tactic_id, '') as tactic_id, "
1122
+ f"description, raw_data, created_at, resolved_at "
1123
+ f"FROM events {where_sql} ORDER BY created_at DESC LIMIT ? OFFSET ?",
1124
+ tuple(params) + (limit, offset),
1125
+ )
1126
+ # 同时查总数
1127
+ count_row = await repo.fetch_one(
1128
+ f"SELECT COUNT(*) as cnt FROM events {where_sql}",
1129
+ tuple(params),
1130
+ )
1131
+ total = count_row["cnt"] if count_row else 0
1132
+ events = []
1133
+ for r in rows:
1134
+ event = _format_event_list_item(dict(r))
1135
+ events.append(event)
1136
+ return {"events": events, "total": total, "limit": limit, "offset": offset}
1137
+
1138
+ @app.get("/api/events/{event_id}", dependencies=[Depends(require_permission("events:read"))])
1139
+ async def get_event(event_id: str):
1140
+ """获取单个事件详情(含 IOC、技术映射、响应建议等完整数据)"""
1141
+ async with get_repository() as repo:
1142
+ row = await repo.fetch_one(
1143
+ "SELECT id, title, severity, status, source_ip, alert_type, "
1144
+ "description, mitre_tactic_id, mitre_technique_id, "
1145
+ "resolution, resolved_by, raw_data, created_at, resolved_at "
1146
+ "FROM events WHERE id = ?", (event_id,)
1147
+ )
1148
+ if not row:
1149
+ raise HTTPException(status_code=404, detail="事件未找到")
1150
+
1151
+ event = _format_event_detail(dict(row))
1152
+ return event
1153
+
1154
+ # ==================== 处置 API(供 IM 桥接器调用) ====================
1155
+ class DispatchBody(BaseModel):
1156
+ action: str # block / unblock / confirm / ignore / escalate / status
1157
+ ip: str = ""
1158
+ event_id: str = ""
1159
+ duration_minutes: int = 120
1160
+ reason: str = ""
1161
+
1162
+ @app.post("/api/dispatch")
1163
+ async def dispatch(body: DispatchBody, token=Depends(verify_token)):
1164
+ """
1165
+ 安全处置统一入口(IM 桥接器调用)
1166
+
1167
+ 动作:
1168
+ block <ip> — 封禁 IP(默认 120 分钟)
1169
+ unblock <ip> — 解封 IP
1170
+ confirm <event_id> — 确认事件(状态→confirmed)
1171
+ ignore <event_id> — 忽略事件(状态→ignored)
1172
+ escalate <event_id> — 升级事件(状态→escalated)
1173
+ status [ip] — 查询封禁状态
1174
+
1175
+ 认证: JWT(需 events:write / firewall:block 权限)
1176
+ """
1177
+ action = (body.action or "").lower()
1178
+ ip = (body.ip or "").strip()
1179
+ event_id = (body.event_id or "").strip()
1180
+ from backend.security.dispatch import permission_for_dispatch
1181
+ from backend.security.rbac import enforce_permission
1182
+ try:
1183
+ required_permission = permission_for_dispatch(action)
1184
+ except ValueError as exc:
1185
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
1186
+ current_user = enforce_permission(token, required_permission)
1187
+ operator = current_user.username
1188
+
1189
+ # ─── 封禁 IP ───
1190
+ if action == "block":
1191
+ if not ip:
1192
+ return {"success": False, "error": "block 需要指定 IP"}
1193
+ try:
1194
+ fw_tool = orchestrator.tools.get("firewall_manage")
1195
+ if not fw_tool:
1196
+ return {"success": False, "error": "防火墙工具未初始化"}
1197
+ from backend.tools.firewall import FirewallExecutionContext
1198
+ authorization_context = FirewallExecutionContext.authenticated_api(
1199
+ actor=operator,
1200
+ permission=required_permission,
1201
+ reason=body.reason or "IM 人工处置",
1202
+ )
1203
+ result = await fw_tool.execute(
1204
+ action="block", ip=ip, reason=body.reason or "IM 人工处置",
1205
+ duration_minutes=body.duration_minutes, confidence=1.0,
1206
+ authorization_context=authorization_context,
1207
+ )
1208
+ if result.success:
1209
+ # 更新该 IP 相关事件状态为 blocked
1210
+ async with get_repository() as repo:
1211
+ await repo.execute(
1212
+ "UPDATE events SET status='blocked' WHERE source_ip=? AND status IN ('open','escalated')",
1213
+ (ip,),
1214
+ )
1215
+ return {"success": True, "action": "blocked", "ip": ip,
1216
+ "message": result.data.get("message", f"IP {ip} 已封禁")}
1217
+ return {"success": False, "action": "blocked", "ip": ip,
1218
+ "error": result.error or "封禁失败"}
1219
+ except Exception as e:
1220
+ logger.error(f"封禁异常: {e}")
1221
+ return {"success": False, "error": f"封禁异常: {e}"}
1222
+
1223
+ # ─── 解封 IP ───
1224
+ if action == "unblock":
1225
+ if not ip:
1226
+ return {"success": False, "error": "unblock 需要指定 IP"}
1227
+ try:
1228
+ fw_tool = orchestrator.tools.get("firewall_manage")
1229
+ if not fw_tool:
1230
+ return {"success": False, "error": "防火墙工具未初始化"}
1231
+ from backend.tools.firewall import FirewallExecutionContext
1232
+ authorization_context = FirewallExecutionContext.authenticated_api(
1233
+ actor=operator,
1234
+ permission=required_permission,
1235
+ reason=body.reason or "IM 人工解封",
1236
+ )
1237
+ result = await fw_tool.execute(
1238
+ action="unblock", ip=ip, reason=body.reason or "IM 人工解封",
1239
+ confidence=1.0,
1240
+ authorization_context=authorization_context,
1241
+ )
1242
+ if result.success:
1243
+ return {"success": True, "action": "unblocked", "ip": ip,
1244
+ "message": f"IP {ip} 已解封"}
1245
+ return {"success": False, "action": "unblocked", "ip": ip,
1246
+ "error": result.error or "解封失败"}
1247
+ except Exception as e:
1248
+ logger.error(f"解封异常: {e}")
1249
+ return {"success": False, "error": f"解封异常: {e}"}
1250
+
1251
+ # ─── 确认/忽略/升级事件 ───
1252
+ if action in ("confirm", "ignore", "escalate"):
1253
+ if not event_id:
1254
+ return {"success": False, "error": f"{action} 需要指定事件ID"}
1255
+ status_map = {"confirm": "confirmed", "ignore": "ignored", "escalate": "escalated"}
1256
+ new_status = status_map[action]
1257
+ try:
1258
+ async with get_repository() as repo:
1259
+ row = await repo.fetch_one(
1260
+ "SELECT source_ip FROM events WHERE id=?", (event_id,)
1261
+ )
1262
+ if not row:
1263
+ return {"success": False, "error": f"事件 {event_id} 不存在"}
1264
+ await repo.execute(
1265
+ "UPDATE events SET status=?, resolved_at=? WHERE id=?",
1266
+ (new_status, datetime.now(timezone.utc).isoformat(), event_id),
1267
+ )
1268
+ return {"success": True, "action": action, "event_id": event_id,
1269
+ "status": new_status, "source_ip": row["source_ip"]}
1270
+ except Exception as e:
1271
+ logger.error(f"事件处置异常: {e}")
1272
+ return {"success": False, "error": f"事件处置异常: {e}"}
1273
+
1274
+ # ─── 查询封禁状态 ───
1275
+ if action == "status":
1276
+ try:
1277
+ fw_tool = orchestrator.tools.get("firewall_manage")
1278
+ if not fw_tool:
1279
+ return {"success": False, "error": "防火墙工具未初始化"}
1280
+ if ip:
1281
+ result = await fw_tool.execute(action="check", ip=ip)
1282
+ return {"success": True, "ip": ip, "is_blocked": bool(
1283
+ result.data.get("is_blocked") if result.success else False
1284
+ )}
1285
+ # 无 IP 时返回系统概览
1286
+ return {"success": True, "note": "请指定 IP 查询封禁状态"}
1287
+ except Exception as e:
1288
+ logger.error(f"查询状态异常: {e}")
1289
+ return {"success": False, "error": f"查询状态异常: {e}"}
1290
+
1291
+ return {"success": False, "error": f"未知动作: {action}"}
1292
+
1293
+ # ==================== 轨迹数据(Trajectory) ====================
1294
+ @app.get("/api/conversations")
1295
+ async def list_conversations_api(
1296
+ limit: int = 50, token_data=Depends(verify_token)
1297
+ ):
1298
+ """获取会话列表(供轨迹页下拉选择)"""
1299
+ from backend.storage.repositories.conversation_repo import ConversationRepository
1300
+ async with get_repository() as repo_db:
1301
+ repo = ConversationRepository(repo_db, owner_id=token_data.sub)
1302
+ rows = await repo.list_conversations(limit=limit)
1303
+ conversations = []
1304
+ for r in rows:
1305
+ conversations.append({
1306
+ "conversation_id": r.get("id") or r.get("conversation_id"),
1307
+ "title": r.get("title", ""),
1308
+ "created_at": r.get("created_at", ""),
1309
+ "updated_at": r.get("updated_at", ""),
1310
+ })
1311
+ return {"conversations": conversations}
1312
+
1313
+ @app.get("/api/trajectory/stats")
1314
+ async def trajectory_stats(token_data=Depends(verify_token)):
1315
+ """轨迹聚合统计(供轨迹界面顶部卡片)"""
1316
+ from backend.storage.repositories.trajectory_repo import TrajectoryRepository
1317
+ async with get_repository() as repo_db:
1318
+ repo = TrajectoryRepository(repo_db, owner_id=token_data.sub)
1319
+ return await repo.get_stats()
1320
+
1321
+ @app.get("/api/trajectory")
1322
+ async def list_trajectories(
1323
+ conversation_id: str = "",
1324
+ limit: int = 50,
1325
+ token_data=Depends(verify_token),
1326
+ ):
1327
+ """查询轨迹记录(可选按会话过滤)"""
1328
+ from backend.storage.repositories.trajectory_repo import TrajectoryRepository
1329
+ async with get_repository() as repo_db:
1330
+ repo = TrajectoryRepository(repo_db, owner_id=token_data.sub)
1331
+ cid = conversation_id.strip() or None
1332
+ try:
1333
+ rows = await repo.get_trajectories(conversation_id=cid, limit=limit)
1334
+ except PermissionError:
1335
+ raise HTTPException(status_code=404, detail="会话不存在")
1336
+ return {"trajectories": rows}
1337
+
1338
+ @app.get("/api/trajectory/{conversation_id}")
1339
+ async def get_conversation_trajectory_api(
1340
+ conversation_id: str, token_data=Depends(verify_token)
1341
+ ):
1342
+ """获取指定会话的完整轨迹(合并同会话多条记录为一条时间线)"""
1343
+ from backend.storage.repositories.trajectory_repo import TrajectoryRepository
1344
+ async with get_repository() as repo_db:
1345
+ repo = TrajectoryRepository(repo_db, owner_id=token_data.sub)
1346
+ try:
1347
+ return await repo.get_conversation_trajectory(conversation_id)
1348
+ except PermissionError:
1349
+ raise HTTPException(status_code=404, detail="会话不存在")
1350
+
1351
+ # ==================== 对抗测试(Adversarial) ====================
1352
+ @app.get("/api/adversarial/report", dependencies=[Depends(require_permission("agents:read"))])
1353
+ async def adversarial_report():
1354
+ """批量执行对抗样本,返回检出率/误报率报告"""
1355
+ from backend.security.adversarial.scanner import PromptInjectionScanner
1356
+ from backend.security.adversarial.redteam import RedTeamProbe
1357
+ scanner = PromptInjectionScanner()
1358
+ return RedTeamProbe.run_all(scanner)
1359
+
1360
+ @app.get("/api/adversarial/probes", dependencies=[Depends(require_permission("agents:read"))])
1361
+ async def adversarial_probes():
1362
+ """返回对抗样本库 + 正常样本(供界面展示与手动测试)"""
1363
+ from backend.security.adversarial.redteam import RedTeamProbe
1364
+ return {
1365
+ "probes": RedTeamProbe.all_probes(),
1366
+ "benign": RedTeamProbe.benign_samples(),
1367
+ }
1368
+
1369
+ @app.post("/api/adversarial/probe", dependencies=[Depends(require_permission("agents:read"))])
1370
+ async def adversarial_probe(payload: dict):
1371
+ """手动探测一条输入是否含注入"""
1372
+ from backend.security.adversarial.scanner import PromptInjectionScanner
1373
+ text = (payload or {}).get("text", "")
1374
+ scanner = PromptInjectionScanner()
1375
+ return scanner.scan(text or "")
1376
+
1377
+ @app.get("/api/attack-chain/from-events", dependencies=[Depends(require_permission("events:read"))])
1378
+ async def attack_chain_from_events(count: int = 30):
1379
+ """只从数据库中的最近事件聚类生成攻击链。"""
1380
+ async with get_repository() as repo:
1381
+ rows = await repo.fetch_all(
1382
+ "SELECT id, title, severity, source_ip, alert_type, "
1383
+ "mitre_technique_id, mitre_tactic_id, description, created_at "
1384
+ "FROM events ORDER BY created_at DESC LIMIT ?", (min(count, 200),)
1385
+ )
1386
+
1387
+ if not rows:
1388
+ # 无事件时返回空结构
1389
+ return {
1390
+ "coverage": 0, "detected_steps": 0, "total_steps": 7,
1391
+ "overall_confidence": 0, "total_alerts": 0,
1392
+ "source_ip": "", "kill_chain_visual": [],
1393
+ "steps": [], "missing_stages": [],
1394
+ "cluster": None,
1395
+ }
1396
+
1397
+ # MITRE ATT&CK 杀伤链 7 阶段
1398
+ KILL_CHAIN = [
1399
+ {"pos": 1, "tactic_id": "TA0043", "tactic_name": "侦察"},
1400
+ {"pos": 2, "tactic_id": "TA0001", "tactic_name": "初始访问"},
1401
+ {"pos": 3, "tactic_id": "TA0002", "tactic_name": "执行"},
1402
+ {"pos": 4, "tactic_id": "TA0003", "tactic_name": "持久化"},
1403
+ {"pos": 5, "tactic_id": "TA0004", "tactic_name": "权限提升"},
1404
+ {"pos": 6, "tactic_id": "TA0005", "tactic_name": "防御规避"},
1405
+ {"pos": 7, "tactic_id": "TA0040", "tactic_name": "影响"},
1406
+ ]
1407
+
1408
+ # 按源 IP 聚类
1409
+ from collections import Counter
1410
+ source_ips = Counter(r["source_ip"] for r in rows if r.get("source_ip"))
1411
+ top_ip = source_ips.most_common(1)[0][0] if source_ips else ""
1412
+
1413
+ # 提取检测到的战术阶段
1414
+ detected_tactics = set()
1415
+ for r in rows:
1416
+ tid = (r.get("mitre_tactic_id") or "").strip()
1417
+ if tid:
1418
+ detected_tactics.add(tid)
1419
+
1420
+ steps = []
1421
+ kill_chain_visual = []
1422
+ matched_count = 0
1423
+ for kc in KILL_CHAIN:
1424
+ detected = kc["tactic_id"] in detected_tactics
1425
+ if detected:
1426
+ matched_count += 1
1427
+ conf = round(0.65 + (len([r for r in rows if r.get("mitre_tactic_id") == kc["tactic_id"]]) * 0.05), 2)
1428
+ technique_id = ""
1429
+ technique_name = ""
1430
+ for r in rows:
1431
+ if r.get("mitre_tactic_id") == kc["tactic_id"] and r.get("mitre_technique_id"):
1432
+ technique_id = r["mitre_technique_id"]
1433
+ break
1434
+ evidence = [r["title"] for r in rows[:3] if r.get("title")]
1435
+ steps.append({
1436
+ "position": kc["pos"],
1437
+ "tactic_name": kc["tactic_name"],
1438
+ "confidence": min(conf, 0.98),
1439
+ "technique_id": technique_id,
1440
+ "technique_name": technique_name,
1441
+ "alerts_count": sum(1 for r in rows if r.get("mitre_tactic_id") == kc["tactic_id"]),
1442
+ "evidence": evidence,
1443
+ })
1444
+ kill_chain_visual.append({
1445
+ "position": f"{kc['pos']}/{len(KILL_CHAIN)}",
1446
+ "tactic_name": kc["tactic_name"],
1447
+ "status": "detected",
1448
+ "confidence": min(conf, 0.98),
1449
+ "technique_name": technique_name,
1450
+ "technique_id": technique_id,
1451
+ "alerts_count": len(evidence),
1452
+ })
1453
+ else:
1454
+ kill_chain_visual.append({
1455
+ "position": f"{kc['pos']}/{len(KILL_CHAIN)}",
1456
+ "tactic_name": kc["tactic_name"],
1457
+ "status": "missed",
1458
+ "confidence": 0,
1459
+ "technique_name": "",
1460
+ "technique_id": "",
1461
+ "alerts_count": 0,
1462
+ })
1463
+
1464
+ missing_stages = [
1465
+ {"tactic_id": kc["tactic_id"], "tactic_name": kc["tactic_name"], "pos": kc["pos"]}
1466
+ for kc in KILL_CHAIN if kc["tactic_id"] not in detected_tactics
1467
+ ]
1468
+
1469
+ overall_confidence = round(matched_count / len(KILL_CHAIN), 2) if matched_count > 0 else 0
1470
+ total_alerts = len(rows)
1471
+ alert_types = list(set(r["alert_type"] for r in rows if r.get("alert_type")))
1472
+
1473
+ return {
1474
+ "coverage": round(matched_count / len(KILL_CHAIN) * 100),
1475
+ "detected_steps": matched_count,
1476
+ "total_steps": len(KILL_CHAIN),
1477
+ "overall_confidence": overall_confidence,
1478
+ "total_alerts": total_alerts,
1479
+ "source_ip": top_ip,
1480
+ "kill_chain_visual": kill_chain_visual,
1481
+ "steps": steps,
1482
+ "missing_stages": missing_stages,
1483
+ "cluster": {
1484
+ "cluster_id": f"CLU-{uuid.uuid4().hex[:6].upper()}",
1485
+ "source_ip": top_ip,
1486
+ "alert_count": total_alerts,
1487
+ "duration_minutes": 30,
1488
+ "target_ips": list(source_ips.keys())[:5],
1489
+ "alert_types": alert_types[:8],
1490
+ },
1491
+ }
1492
+
1493
+ # ==================== MITRE ATT&CK 结构化API ====================
1494
+ @app.get("/api/mitre/technique/{technique_id}", dependencies=[Depends(require_permission("knowledge:read"))])
1495
+ async def mitre_technique(technique_id: str):
1496
+ """查询MITRE ATT&CK技术详情(含评分、检测、缓解、关联CVE)"""
1497
+ mk = MitreAttackKnowledge()
1498
+ result = mk.get_technique(technique_id.upper())
1499
+ if not result:
1500
+ return error_response("RES_TECHNIQUE_NOT_FOUND", message=f"Technique {technique_id} not found")
1501
+ return result
1502
+
1503
+ @app.get("/api/mitre/search", dependencies=[Depends(require_permission("knowledge:read"))])
1504
+ async def mitre_search(q: str = "", risk: str = "", tactic: str = ""):
1505
+ """搜索MITRE ATT&CK(支持按风险等级和战术筛选)"""
1506
+ mk = MitreAttackKnowledge()
1507
+
1508
+ def _mitre_all_techniques() -> dict:
1509
+ """返回全部技术的内部索引(id → 技术详情)"""
1510
+ return TECHNIQUES_INDEX
1511
+ filters = {}
1512
+ if risk: filters["risk_levels"] = risk.split(",")
1513
+ if tactic: filters["tactics"] = tactic.split(",")
1514
+ # q="*" 或空字符串表示返回全部
1515
+ if not q or q.strip() in ("*", ""):
1516
+ # 直接遍历所有技术,返回完整列表(不截断,供前端热力图全量展示)
1517
+ all_results = []
1518
+ for tid, tech in _mitre_all_techniques().items():
1519
+ if filters.get("tactics") and tech.get("tactic", "") not in filters["tactics"]:
1520
+ continue
1521
+ all_results.append({
1522
+ "id": tid, "name": tech.get("name", ""),
1523
+ "tactic_id": tech.get("tactic", ""),
1524
+ "tactic_name": tech.get("tactic_name", "未知"),
1525
+ "type": "technique",
1526
+ "description": tech.get("description", "")[:200],
1527
+ "risk_level": tech.get("scores", {}).get("risk_level", "中危"),
1528
+ "risk_score": tech.get("scores", {}).get("risk_score", 5.0),
1529
+ "has_cve": bool(tech.get("related_cves")),
1530
+ "sub_techniques": list(tech.get("sub_techniques", {}).keys()),
1531
+ "sub_count": len(tech.get("sub_techniques", {})),
1532
+ })
1533
+ # 按风险分排序(高风险的在前)
1534
+ all_results.sort(key=lambda x: x.get("risk_score", 0), reverse=True)
1535
+ return all_results
1536
+ results = mk.search(q, filters) if q else []
1537
+ return results
1538
+
1539
+ @app.get("/api/mitre/kill-chain", dependencies=[Depends(require_permission("knowledge:read"))])
1540
+ async def mitre_kill_chain():
1541
+ """获取完整杀伤链视图(战术阶段排序+技术分布)"""
1542
+ mk = MitreAttackKnowledge()
1543
+ return mk.get_kill_chain()
1544
+
1545
+ @app.get("/api/mitre/dashboard", dependencies=[Depends(require_permission("knowledge:read"))])
1546
+ async def mitre_dashboard():
1547
+ """获取MITRE仪表盘数据"""
1548
+ mk = MitreAttackKnowledge()
1549
+ return mk.get_dashboard()
1550
+
1551
+ @app.get("/api/mitre/attack-flow", dependencies=[Depends(require_permission("knowledge:read"))])
1552
+ async def mitre_attack_flow(ids: str = ""):
1553
+ """获取攻击路径流(逗号分隔的技术ID)"""
1554
+ mk = MitreAttackKnowledge()
1555
+ technique_ids = [i.strip() for i in ids.split(",") if i.strip()]
1556
+ if not technique_ids:
1557
+ return []
1558
+ return mk.get_attack_flow(technique_ids)
1559
+
1560
+ # ==================== CVE 漏洞库 API ====================
1561
+ @app.get("/api/cve/search", dependencies=[Depends(require_permission("knowledge:read"))])
1562
+ async def cve_search(q: str = ""):
1563
+ """搜索 CVE 漏洞(支持 ID、描述模糊搜索)"""
1564
+ cve_db = CVEDatabase()
1565
+ if not q or q.strip() in ("*", ""):
1566
+ # 返回全部(按严重程度排序)
1567
+ all_cves = cve_db.get_critical_recent(days=3650)
1568
+ # 补充 non-critical
1569
+ for c in cve_db.vulnerabilities:
1570
+ if c not in all_cves and len(all_cves) < 76:
1571
+ all_cves.append(c)
1572
+ return all_cves[:76]
1573
+ results = cve_db.search(q)
1574
+ return results
1575
+
1576
+ @app.get("/api/cve/{cve_id}", dependencies=[Depends(require_permission("knowledge:read"))])
1577
+ async def cve_detail(cve_id: str):
1578
+ """查询 CVE 详情"""
1579
+ cve_db = CVEDatabase()
1580
+ result = cve_db.get_by_id(cve_id.upper())
1581
+ if not result:
1582
+ return error_response("RES_CVE_NOT_FOUND", message=f"CVE {cve_id} not found")
1583
+ return result
1584
+
1585
+ # ==================== 合规知识库 API ====================
1586
+ @app.get("/api/compliance/search", dependencies=[Depends(require_permission("knowledge:read"))])
1587
+ async def compliance_search(q: str = ""):
1588
+ """搜索合规法规(支持按名称、关键词搜索)"""
1589
+ ck = ComplianceKnowledge()
1590
+ if not q or q.strip() in ("*", ""):
1591
+ # 返回全部
1592
+ return [{
1593
+ "name": r.get("name", ""),
1594
+ "abbr": r.get("abbr", ""),
1595
+ "description": r.get("description", ""),
1596
+ "key_requirements": r.get("key_requirements", [])[:5],
1597
+ "penalties": r.get("penalties", ""),
1598
+ } for r in ck.regulations]
1599
+ results = ck.search(q)
1600
+ return results
1601
+
1602
+ @app.get("/api/compliance/{regulation_name}", dependencies=[Depends(require_permission("knowledge:read"))])
1603
+ async def compliance_detail(regulation_name: str):
1604
+ """查询某个法规的完整信息"""
1605
+ ck = ComplianceKnowledge()
1606
+ result = ck.get_regulation(regulation_name)
1607
+ if not result:
1608
+ return error_response("RES_REGULATION_NOT_FOUND", message=f"Regulation {regulation_name} not found")
1609
+ return result
1610
+
1611
+ # ==================== 应急响应剧本 API ====================
1612
+ @app.get("/api/remediation/search", dependencies=[Depends(require_permission("knowledge:read"))])
1613
+ async def remediation_search(q: str = ""):
1614
+ """搜索应急响应剧本(按场景名称、指标、处置动作搜索)"""
1615
+ rk = RemediationKnowledge()
1616
+ if not q or q.strip() in ("*", ""):
1617
+ return [{
1618
+ "scenario": pb.get("scenario", ""),
1619
+ "indicators": pb.get("indicators", ""),
1620
+ "immediate_actions": pb.get("immediate_actions", []),
1621
+ "medium_term": pb.get("medium_term", []),
1622
+ "long_term": pb.get("long_term", []),
1623
+ } for pb in rk.playbooks]
1624
+ return rk.search(q)
1625
+
1626
+ @app.get("/api/remediation/{scenario_name}", dependencies=[Depends(require_permission("knowledge:read"))])
1627
+ async def remediation_detail(scenario_name: str):
1628
+ """查询某个场景的完整响应剧本"""
1629
+ rk = RemediationKnowledge()
1630
+ result = rk.get_by_scenario(scenario_name)
1631
+ if not result:
1632
+ return error_response("RES_SCENARIO_NOT_FOUND", message=f"Scenario {scenario_name} not found")
1633
+ return result
1634
+
1635
+ # ==================== CVE 按 MITRE 技术关联查询 ====================
1636
+ @app.get("/api/cve/by-mitre/{technique_id}", dependencies=[Depends(require_permission("knowledge:read"))])
1637
+ async def cve_by_mitre(technique_id: str):
1638
+ """按 MITRE ATT&CK 技术 ID 查询相关 CVE"""
1639
+ cve_db = CVEDatabase()
1640
+ results = cve_db.get_by_mitre_technique(technique_id.upper())
1641
+ return results
1642
+
1643
+ # ==================== 统一 WebSocket(带对话上下文持久化)====================
1644
+ @app.websocket("/ws/chat")
1645
+ async def chat_websocket(websocket: WebSocket):
1646
+ """
1647
+ 统一 WebSocket 入口(带对话上下文持久化)
1648
+
1649
+ 使用 TrueReAct 循环(LLM Function Calling 驱动的 Think→Tool→Observe)。
1650
+ 每次用户消息会从数据库加载历史上下文,并将本轮对话保存到数据库,
1651
+ 实现多轮对话的连贯性。
1652
+
1653
+ 认证方式: 通过 query param 传递 JWT Token
1654
+ ws://host/ws/chat?token=xxx
1655
+ """
1656
+ ws_id = uuid.uuid4().hex[:8]
1657
+
1658
+ # 浏览器 WebSocket 必须来自显式 CORS 白名单;无 Origin 的 CLI/服务端客户端保留。
1659
+ origin = (websocket.headers.get("origin") or "").rstrip("/")
1660
+ allowed_ws_origins = {o.strip().rstrip("/") for o in ALLOWED_ORIGINS if o.strip()}
1661
+ if origin and origin not in allowed_ws_origins:
1662
+ await websocket.close(code=4003, reason="WebSocket Origin 不在允许列表")
1663
+ return
1664
+
1665
+ # ═══════════ WebSocket 认证(在 accept() 之前完成) ═══════════
1666
+ try:
1667
+ token = websocket.query_params.get("token", "")
1668
+ if not token:
1669
+ await websocket.close(code=4001, reason="缺少认证令牌")
1670
+ return
1671
+ from backend.security.auth import verify_token, validate_live_token_data
1672
+ from fastapi.security import HTTPAuthorizationCredentials
1673
+ credentials = HTTPAuthorizationCredentials(scheme="bearer", credentials=token)
1674
+ token_data = await verify_token(credentials)
1675
+ except Exception as e:
1676
+ err_str = str(e)
1677
+ reason = "令牌已过期" if "expired" in err_str.lower() else f"无效令牌: {err_str}"
1678
+ try:
1679
+ await websocket.close(code=4001, reason=reason)
1680
+ except Exception:
1681
+ pass
1682
+ return
1683
+
1684
+ await ws_manager.connect(ws_id, websocket)
1685
+
1686
+ # 支持恢复历史对话:客户端可通过 query param 传入 conversation_id
1687
+ resume_id = websocket.query_params.get("conversation_id", "").strip()
1688
+
1689
+ # 初始化数据库和会话仓库(统一使用 Repository,自动适配 SQLite/PostgreSQL)
1690
+ from backend.storage.database import Repository
1691
+ repo_db = Repository()
1692
+ repo = ConversationRepository(repo_db, owner_id=token_data.sub)
1693
+ # PostgreSQL 模式下不执行内联 DDL(由 Alembic 迁移管理)
1694
+ from backend.storage.database import _is_postgres
1695
+ if not _is_postgres():
1696
+ from backend.storage.database import get_sqlite_path
1697
+ init_db(get_sqlite_path(repo_db.url))
1698
+
1699
+ if resume_id:
1700
+ # 恢复已有对话
1701
+ existing = await repo.get_conversation(resume_id)
1702
+ if existing:
1703
+ conversation_id = resume_id
1704
+ logger.info(f"恢复历史对话", extra={"ws_id": ws_id, "conversation_id": conversation_id})
1705
+ else:
1706
+ # 指定的 conversation_id 不存在 → 自动创建新对话(兼容旧客户端)
1707
+ conversation_id = f"ws-{ws_id}-{uuid.uuid4().hex[:8]}"
1708
+ await repo.create_conversation(
1709
+ title=f"WebSocket对话 {conversation_id}",
1710
+ conversation_id=conversation_id,
1711
+ )
1712
+ logger.info(f"指定的对话 {resume_id} 不存在,创建新对话", extra={"ws_id": ws_id, "conversation_id": conversation_id})
1713
+ else:
1714
+ # 新对话
1715
+ conversation_id = f"ws-{ws_id}-{uuid.uuid4().hex[:8]}"
1716
+ await repo.create_conversation(
1717
+ title=f"WebSocket对话 {conversation_id}",
1718
+ conversation_id=conversation_id,
1719
+ )
1720
+ logger.info(f"新 WebSocket 连接", extra={"ws_id": ws_id, "conversation_id": conversation_id})
1721
+
1722
+ try:
1723
+ while True:
1724
+ data = await websocket.receive_json()
1725
+
1726
+ # 长连接不能绕过 access token 过期、禁用或角色/密码变更。
1727
+ try:
1728
+ validate_live_token_data(token_data)
1729
+ except HTTPException as exc:
1730
+ await websocket.close(code=4001, reason=str(exc.detail))
1731
+ return
1732
+
1733
+ # ═══════════════════ 心跳检测 ═══════════════════
1734
+ if data.get("type") == "ping":
1735
+ await websocket.send_json({
1736
+ "type": "pong",
1737
+ "timestamp": __import__("datetime").datetime.now(
1738
+ __import__("datetime").timezone.utc
1739
+ ).isoformat(),
1740
+ })
1741
+ continue
1742
+
1743
+ text = data.get("message", "").strip()
1744
+ if not text:
1745
+ continue
1746
+
1747
+ # 1. 从数据库加载历史消息,构造 LLM 对话历史
1748
+ history_db = await repo.get_messages(conversation_id, limit=20)
1749
+ history_messages = []
1750
+ # 话题切换检测:若当前提问与最近一轮用户提问相似度低,
1751
+ # 视为全新话题,不注入旧历史(切断跨话题串味)
1752
+ _last_user = ""
1753
+ for _m in history_db:
1754
+ if _m.get("role") == "user" and _m.get("content"):
1755
+ _last_user = _m.get("content")
1756
+ _is_topic_switch = _is_new_topic(text, _last_user) if _last_user else False
1757
+ if _is_topic_switch:
1758
+ logger.info(
1759
+ "[topic] 检测到话题切换,不注入历史上下文 (新问题 vs 上轮问题 相似度过低)"
1760
+ )
1761
+ for msg in history_db:
1762
+ role = msg.get("role", "")
1763
+ content = msg.get("content", "")
1764
+ if role in ("user", "assistant") and content:
1765
+ # 新话题:跳过所有旧历史(仅保留当前问题,由下方单独追加)
1766
+ if _is_topic_switch:
1767
+ continue
1768
+ history_messages.append({"role": role, "content": content})
1769
+
1770
+ # 2. 保存用户消息到数据库
1771
+ await repo.save_message(
1772
+ conversation_id=conversation_id,
1773
+ role="user",
1774
+ content=text,
1775
+ agent_id="orchestrator",
1776
+ )
1777
+
1778
+ # 3. 调用编排器(传入历史上下文)
1779
+ assistant_content = ""
1780
+ async for chunk in orchestrator.process(text, history_messages=history_messages):
1781
+ # 收集最终 assistant 回复(用于保存到 DB)
1782
+ ctype = chunk.get("type", "")
1783
+ if ctype in ("true_react_complete", "orchestrator_complete", "true_react_max_rounds"):
1784
+ content = chunk.get("content", "") or chunk.get("summary", "")
1785
+ if content and not assistant_content:
1786
+ assistant_content = content
1787
+ await websocket.send_json(chunk)
1788
+
1789
+ # 4. 保存 LLM 回复到数据库
1790
+ if assistant_content:
1791
+ await repo.save_message(
1792
+ conversation_id=conversation_id,
1793
+ role="assistant",
1794
+ content=assistant_content,
1795
+ agent_id="orchestrator",
1796
+ )
1797
+
1798
+ except WebSocketDisconnect:
1799
+ logger.info(f"WebSocket 断开", extra={"ws_id": ws_id})
1800
+ ws_manager.disconnect(ws_id)
1801
+ except Exception as e:
1802
+ try:
1803
+ await websocket.send_json({"type": "error", "error": str(e)})
1804
+ except Exception:
1805
+ pass
1806
+ ws_manager.disconnect(ws_id)
1807
+ logger.error(f"WebSocket 异常", extra={
1808
+ "ws_id": ws_id, "error": str(e),
1809
+ })
1810
+ finally:
1811
+ # 关闭数据库连接,防止泄漏
1812
+ try:
1813
+ await repo_db.close()
1814
+ except Exception:
1815
+ pass
1816
+
1817
+ return app
1818
+
1819
+
1820
+ if __name__ == "__main__":
1821
+ if not HAS_FASTAPI:
1822
+ logger.error("请安装依赖: pip install fastapi uvicorn websockets")
1823
+ sys.exit(1)
1824
+
1825
+ import uvicorn
1826
+ app = create_app()
1827
+ if app:
1828
+ host = os.getenv("SECAGENTX_HOST", "0.0.0.0")
1829
+ port = int(os.getenv("SECAGENTX_PORT", "8000"))
1830
+ uvicorn.run(app, host=host, port=port)