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.
- backend/__init__.py +1 -0
- backend/__main__.py +3 -0
- backend/_assets/config.yaml +195 -0
- backend/_assets/frontend/dist/assets/AgentsView-DFqRplI8.css +1 -0
- backend/_assets/frontend/dist/assets/AgentsView-DJTpfQ87.js +1 -0
- backend/_assets/frontend/dist/assets/AttackChainView-BVfI-SC6.js +1 -0
- backend/_assets/frontend/dist/assets/ChatView-Bg4nIlQ0.css +1 -0
- backend/_assets/frontend/dist/assets/ChatView-CRndtmEC.js +38 -0
- backend/_assets/frontend/dist/assets/DashboardView-BXs4AWv8.js +1 -0
- backend/_assets/frontend/dist/assets/DashboardView-WnHWiqBW.css +1 -0
- backend/_assets/frontend/dist/assets/EventDetailView-DQk6Qp7-.js +1 -0
- backend/_assets/frontend/dist/assets/EventDetailView-Dj-a-zWr.css +1 -0
- backend/_assets/frontend/dist/assets/EventsView-8xh8uKO0.js +1 -0
- backend/_assets/frontend/dist/assets/EventsView-BHfFn66K.css +1 -0
- backend/_assets/frontend/dist/assets/KnowledgeView-C2N3v9bF.css +1 -0
- backend/_assets/frontend/dist/assets/KnowledgeView-D54ODoC9.js +11 -0
- backend/_assets/frontend/dist/assets/LoginView-CjvFmo7C.js +1 -0
- backend/_assets/frontend/dist/assets/LoginView-D14FzcsP.css +1 -0
- backend/_assets/frontend/dist/assets/SettingsView-JOJuoABi.js +1 -0
- backend/_assets/frontend/dist/assets/UsersView-DhMXZhir.js +1 -0
- backend/_assets/frontend/dist/assets/_plugin-vue_export-helper-BDNMzG2s.js +1 -0
- backend/_assets/frontend/dist/assets/index-D9Su3fYY.css +1 -0
- backend/_assets/frontend/dist/assets/index-DBEjbyJR.js +4 -0
- backend/_assets/frontend/dist/assets/rolldown-runtime-DK3Fl9T5.js +1 -0
- backend/_assets/frontend/dist/assets/vendor-charts-Dh1nChzO.js +39 -0
- backend/_assets/frontend/dist/assets/vendor-icons-BTNgi7vU.js +1 -0
- backend/_assets/frontend/dist/assets/vendor-ui-Cdd65KTN.js +7367 -0
- backend/_assets/frontend/dist/assets/vendor-vue-CXKcZAmz.js +1 -0
- backend/_assets/frontend/dist/index.html +51 -0
- backend/_assets/frontend/index.html +46 -0
- backend/_assets/frontend/package-lock.json +2983 -0
- backend/_assets/frontend/package.json +37 -0
- backend/_assets/frontend/src/App.vue +231 -0
- backend/_assets/frontend/src/main.js +12 -0
- backend/_assets/frontend/src/router/index.js +117 -0
- backend/_assets/frontend/src/stores/chat.js +671 -0
- backend/_assets/frontend/src/styles/tokens.css +282 -0
- backend/_assets/frontend/src/utils/http.js +191 -0
- backend/_assets/frontend/src/views/AgentsView.vue +227 -0
- backend/_assets/frontend/src/views/AttackChainView.vue +210 -0
- backend/_assets/frontend/src/views/ChatView.vue +1107 -0
- backend/_assets/frontend/src/views/DashboardView.vue +514 -0
- backend/_assets/frontend/src/views/EventDetailView.vue +387 -0
- backend/_assets/frontend/src/views/EventsView.vue +253 -0
- backend/_assets/frontend/src/views/KnowledgeView.vue +883 -0
- backend/_assets/frontend/src/views/LoginView.vue +314 -0
- backend/_assets/frontend/src/views/SettingsView.vue +226 -0
- backend/_assets/frontend/src/views/UsersView.vue +235 -0
- backend/_assets/frontend/vite.config.js +55 -0
- backend/_assets/knowledge_data/compliance/regulations.json +383 -0
- backend/_assets/knowledge_data/cve/vulnerabilities.json +70720 -0
- backend/_assets/knowledge_data/mitre_attack/techniques.json +14097 -0
- backend/_assets/knowledge_data/remediation/remediation.json +574 -0
- backend/_assets/knowledge_data/threat_intel/actors.json +1602 -0
- backend/_assets/knowledge_data/threat_intel/malware.json +971 -0
- backend/agents/__init__.py +1 -0
- backend/agents/alert_filter/__init__.py +1 -0
- backend/agents/alert_filter/alert_filter_agent.py +88 -0
- backend/agents/analyst.py +331 -0
- backend/agents/base.py +553 -0
- backend/agents/classifier_agent.py +134 -0
- backend/agents/cot_engine.py +578 -0
- backend/agents/intel.py +46 -0
- backend/agents/knowledge/__init__.py +2 -0
- backend/agents/knowledge/agentic_rag.py +928 -0
- backend/agents/knowledge/knowledge_agent.py +47 -0
- backend/agents/registry.py +148 -0
- backend/agents/responder.py +51 -0
- backend/agents/summary_agent.py +219 -0
- backend/auto_ingestor.py +948 -0
- backend/auto_patrol.py +452 -0
- backend/cli_app.py +523 -0
- backend/config/__init__.py +20 -0
- backend/config/provider_profiles.py +416 -0
- backend/config/runtime_settings.py +84 -0
- backend/decision_fusion/__init__.py +32 -0
- backend/decision_fusion/base.py +127 -0
- backend/decision_fusion/dempster_shafer.py +419 -0
- backend/decision_fusion/weighted_average.py +141 -0
- backend/email_ingestor.py +588 -0
- backend/errors.py +243 -0
- backend/escalation.py +453 -0
- backend/evaluation/__init__.py +7 -0
- backend/evaluation/evaluator.py +197 -0
- backend/evaluation/metrics.py +210 -0
- backend/federation/__init__.py +22 -0
- backend/federation/core.py +743 -0
- backend/interface/__init__.py +1 -0
- backend/interface/api_server.py +1830 -0
- backend/interface/cli.py +671 -0
- backend/knowledge/__init__.py +11 -0
- backend/knowledge/compliance.py +101 -0
- backend/knowledge/cve_db.py +100 -0
- backend/knowledge/mitre_attack.py +457 -0
- backend/knowledge/remediation.py +84 -0
- backend/knowledge/threat_intel_kb.py +246 -0
- backend/llm/__init__.py +1 -0
- backend/llm/anthropic_compatible.py +191 -0
- backend/llm/base.py +81 -0
- backend/llm/deepseek.py +221 -0
- backend/llm/fallback.py +268 -0
- backend/llm/mock.py +204 -0
- backend/llm/openai_compatible.py +51 -0
- backend/llm/provider.py +217 -0
- backend/llm/qwen.py +218 -0
- backend/main.py +421 -0
- backend/memory/__init__.py +1 -0
- backend/memory/episodic_memory.py +132 -0
- backend/memory/manager.py +204 -0
- backend/memory/semantic_memory.py +156 -0
- backend/memory/session_memory.py +102 -0
- backend/ml_model/__init__.py +23 -0
- backend/ml_model/pipeline.py +191 -0
- backend/ml_model/trainer.py +627 -0
- backend/models/__init__.py +1 -0
- backend/models/event.py +62 -0
- backend/models/message.py +58 -0
- backend/models/output.py +1216 -0
- backend/monitoring/metrics.py +184 -0
- backend/orchestrator/__init__.py +17 -0
- backend/orchestrator/agent_router.py +145 -0
- backend/orchestrator/core.py +159 -0
- backend/orchestrator/dynamic_weights.py +287 -0
- backend/orchestrator/react_loop.py +1895 -0
- backend/orchestrator/risk_model.py +494 -0
- backend/orchestrator/risk_scorer.py +356 -0
- backend/reasoner/__init__.py +19 -0
- backend/reasoner/reasoner.py +316 -0
- backend/runtime_assets.py +45 -0
- backend/security/__init__.py +24 -0
- backend/security/adversarial/__init__.py +13 -0
- backend/security/adversarial/guard.py +121 -0
- backend/security/adversarial/redteam.py +121 -0
- backend/security/adversarial/scanner.py +208 -0
- backend/security/audit.py +118 -0
- backend/security/auth.py +283 -0
- backend/security/circuit_breaker.py +212 -0
- backend/security/dispatch.py +18 -0
- backend/security/ratelimit.py +237 -0
- backend/security/rbac.py +431 -0
- backend/security/refresh_sessions.py +193 -0
- backend/security/sanitizer.py +32 -0
- backend/storage/__init__.py +1 -0
- backend/storage/chroma_store.py +79 -0
- backend/storage/database.py +296 -0
- backend/storage/migrations/env.py +65 -0
- backend/storage/migrations/versions/0001_initial_schema.py +101 -0
- backend/storage/migrations/versions/0002_add_agent_logs_experiences.py +53 -0
- backend/storage/migrations/versions/0003_add_conversation_owner.py +30 -0
- backend/storage/models.py +194 -0
- backend/storage/repositories/__init__.py +1 -0
- backend/storage/repositories/asset_repo.py +86 -0
- backend/storage/repositories/conversation_repo.py +216 -0
- backend/storage/repositories/event_repo.py +76 -0
- backend/storage/repositories/trajectory_repo.py +324 -0
- backend/storage/retention.py +117 -0
- backend/tools/__init__.py +16 -0
- backend/tools/alert_filter.py +208 -0
- backend/tools/aliyun_firewall.py +237 -0
- backend/tools/aws_firewall.py +369 -0
- backend/tools/base.py +445 -0
- backend/tools/calling.py +207 -0
- backend/tools/cve_search.py +77 -0
- backend/tools/execution_engine.py +202 -0
- backend/tools/firewall.py +387 -0
- backend/tools/geoip.py +168 -0
- backend/tools/log_analyzer.py +185 -0
- backend/tools/ml_detector.py +226 -0
- backend/tools/registry.py +38 -0
- backend/tools/tencent_firewall.py +300 -0
- backend/tools/threat_intel.py +732 -0
- backend/tools/trust_checker.py +76 -0
- backend/utils/__init__.py +5 -0
- backend/utils/config_loader.py +164 -0
- backend/utils/env.py +75 -0
- backend/utils/logger.py +150 -0
- backend/utils/text.py +32 -0
- secagentx-3.0.0.dist-info/METADATA +325 -0
- secagentx-3.0.0.dist-info/RECORD +182 -0
- secagentx-3.0.0.dist-info/WHEEL +5 -0
- secagentx-3.0.0.dist-info/entry_points.txt +2 -0
- 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)
|