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,416 @@
|
|
|
1
|
+
"""模型 Provider 档案与系统凭据存储。
|
|
2
|
+
|
|
3
|
+
配置文件只保存路由元数据和凭据引用;API Key 由系统 Keyring 或 Windows
|
|
4
|
+
DPAPI 保存。也可通过环境变量提供凭据,适合 Docker、CI 和无桌面的服务器。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import tempfile
|
|
13
|
+
import base64
|
|
14
|
+
import ctypes
|
|
15
|
+
from dataclasses import asdict, dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Optional
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("secagentx.provider_profiles")
|
|
20
|
+
|
|
21
|
+
KEYRING_SERVICE = "SecAgentX"
|
|
22
|
+
CONFIG_VERSION = 1
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
PROVIDER_PRESETS: dict[str, dict[str, Any]] = {
|
|
26
|
+
"deepseek": {
|
|
27
|
+
"label": "DeepSeek",
|
|
28
|
+
"protocol": "openai_compatible",
|
|
29
|
+
"api_base": "https://api.deepseek.com/v1",
|
|
30
|
+
"model": "deepseek-chat",
|
|
31
|
+
"env_key": "DEEPSEEK_API_KEY",
|
|
32
|
+
},
|
|
33
|
+
"qwen": {
|
|
34
|
+
"label": "通义千问 (DashScope)",
|
|
35
|
+
"protocol": "openai_compatible",
|
|
36
|
+
"api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
37
|
+
"model": "qwen-max",
|
|
38
|
+
"env_key": "QWEN_API_KEY",
|
|
39
|
+
},
|
|
40
|
+
"openai": {
|
|
41
|
+
"label": "OpenAI",
|
|
42
|
+
"protocol": "openai_compatible",
|
|
43
|
+
"api_base": "https://api.openai.com/v1",
|
|
44
|
+
"model": "gpt-4o-mini",
|
|
45
|
+
"env_key": "OPENAI_API_KEY",
|
|
46
|
+
},
|
|
47
|
+
"anthropic": {
|
|
48
|
+
"label": "Anthropic",
|
|
49
|
+
"protocol": "anthropic_compatible",
|
|
50
|
+
"api_base": "https://api.anthropic.com/v1",
|
|
51
|
+
"model": "claude-sonnet-4-5",
|
|
52
|
+
"env_key": "ANTHROPIC_API_KEY",
|
|
53
|
+
},
|
|
54
|
+
"azure_openai": {
|
|
55
|
+
"label": "Azure OpenAI",
|
|
56
|
+
"protocol": "openai_compatible",
|
|
57
|
+
"api_base": "",
|
|
58
|
+
"model": "",
|
|
59
|
+
"env_key": "AZURE_OPENAI_API_KEY",
|
|
60
|
+
"auth_style": "api-key",
|
|
61
|
+
"api_version": "2024-10-21",
|
|
62
|
+
},
|
|
63
|
+
"gemini": {
|
|
64
|
+
"label": "Google Gemini (OpenAI 兼容接口)",
|
|
65
|
+
"protocol": "openai_compatible",
|
|
66
|
+
"api_base": "https://generativelanguage.googleapis.com/v1beta/openai",
|
|
67
|
+
"model": "gemini-2.5-flash",
|
|
68
|
+
"env_key": "GEMINI_API_KEY",
|
|
69
|
+
},
|
|
70
|
+
"openrouter": {
|
|
71
|
+
"label": "OpenRouter",
|
|
72
|
+
"protocol": "openai_compatible",
|
|
73
|
+
"api_base": "https://openrouter.ai/api/v1",
|
|
74
|
+
"model": "openai/gpt-4o-mini",
|
|
75
|
+
"env_key": "OPENROUTER_API_KEY",
|
|
76
|
+
},
|
|
77
|
+
"xai": {
|
|
78
|
+
"label": "xAI (Grok)",
|
|
79
|
+
"protocol": "openai_compatible",
|
|
80
|
+
"api_base": "https://api.x.ai/v1",
|
|
81
|
+
"model": "grok-3-mini",
|
|
82
|
+
"env_key": "XAI_API_KEY",
|
|
83
|
+
},
|
|
84
|
+
"kimi": {
|
|
85
|
+
"label": "Moonshot / Kimi",
|
|
86
|
+
"protocol": "openai_compatible",
|
|
87
|
+
"api_base": "https://api.moonshot.cn/v1",
|
|
88
|
+
"model": "moonshot-v1-8k",
|
|
89
|
+
"env_key": "MOONSHOT_API_KEY",
|
|
90
|
+
},
|
|
91
|
+
"ollama": {
|
|
92
|
+
"label": "Ollama(本地)",
|
|
93
|
+
"protocol": "openai_compatible",
|
|
94
|
+
"api_base": "http://127.0.0.1:11434/v1",
|
|
95
|
+
"model": "llama3.2",
|
|
96
|
+
"env_key": "",
|
|
97
|
+
"requires_api_key": False,
|
|
98
|
+
},
|
|
99
|
+
"lmstudio": {
|
|
100
|
+
"label": "LM Studio(本地)",
|
|
101
|
+
"protocol": "openai_compatible",
|
|
102
|
+
"api_base": "http://127.0.0.1:1234/v1",
|
|
103
|
+
"model": "local-model",
|
|
104
|
+
"env_key": "",
|
|
105
|
+
"requires_api_key": False,
|
|
106
|
+
},
|
|
107
|
+
"custom_openai": {
|
|
108
|
+
"label": "自定义 OpenAI 兼容接口",
|
|
109
|
+
"protocol": "openai_compatible",
|
|
110
|
+
"api_base": "",
|
|
111
|
+
"model": "",
|
|
112
|
+
"env_key": "SECAGENTX_LLM_API_KEY",
|
|
113
|
+
},
|
|
114
|
+
"custom_anthropic": {
|
|
115
|
+
"label": "自定义 Anthropic 兼容接口",
|
|
116
|
+
"protocol": "anthropic_compatible",
|
|
117
|
+
"api_base": "",
|
|
118
|
+
"model": "",
|
|
119
|
+
"env_key": "SECAGENTX_LLM_API_KEY",
|
|
120
|
+
},
|
|
121
|
+
"mock": {
|
|
122
|
+
"label": "离线 Mock(仅验证流程)",
|
|
123
|
+
"protocol": "mock",
|
|
124
|
+
"api_base": "mock://local",
|
|
125
|
+
"model": "mock-llm",
|
|
126
|
+
"env_key": "",
|
|
127
|
+
"requires_api_key": False,
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def get_secagentx_home() -> Path:
|
|
133
|
+
override = os.getenv("SECAGENTX_HOME", "").strip()
|
|
134
|
+
if override:
|
|
135
|
+
return Path(override).expanduser().resolve()
|
|
136
|
+
if os.name == "nt" and os.getenv("APPDATA"):
|
|
137
|
+
return Path(os.environ["APPDATA"]) / "SecAgentX"
|
|
138
|
+
config_home = os.getenv("XDG_CONFIG_HOME", "").strip()
|
|
139
|
+
return (Path(config_home) if config_home else Path.home() / ".config") / "secagentx"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass
|
|
143
|
+
class ProviderProfile:
|
|
144
|
+
profile_id: str
|
|
145
|
+
provider_id: str
|
|
146
|
+
label: str
|
|
147
|
+
protocol: str
|
|
148
|
+
api_base: str
|
|
149
|
+
model: str
|
|
150
|
+
credential_type: str = "keyring"
|
|
151
|
+
credential_ref: str = ""
|
|
152
|
+
env_key: str = ""
|
|
153
|
+
auth_style: str = "bearer"
|
|
154
|
+
api_version: str = ""
|
|
155
|
+
requires_api_key: bool = True
|
|
156
|
+
timeout_seconds: float = 60.0
|
|
157
|
+
max_tokens: int = 4096
|
|
158
|
+
|
|
159
|
+
@classmethod
|
|
160
|
+
def from_dict(cls, value: dict[str, Any]) -> "ProviderProfile":
|
|
161
|
+
allowed = cls.__dataclass_fields__.keys()
|
|
162
|
+
return cls(**{k: value[k] for k in allowed if k in value})
|
|
163
|
+
|
|
164
|
+
def runtime_config(self, api_key: str = "") -> dict[str, Any]:
|
|
165
|
+
return {
|
|
166
|
+
"api_base": self.api_base,
|
|
167
|
+
"api_key": api_key,
|
|
168
|
+
"model": self.model,
|
|
169
|
+
"timeout_seconds": self.timeout_seconds,
|
|
170
|
+
"max_tokens": self.max_tokens,
|
|
171
|
+
"auth_style": self.auth_style,
|
|
172
|
+
"api_version": self.api_version,
|
|
173
|
+
"allow_no_key": not self.requires_api_key,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class CredentialStoreError(RuntimeError):
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class CredentialStore:
|
|
182
|
+
"""系统凭据包装;Windows 可在无第三方包时使用用户级 DPAPI。"""
|
|
183
|
+
|
|
184
|
+
def __init__(self, service: str = KEYRING_SERVICE, dpapi_path: Optional[Path] = None):
|
|
185
|
+
self.service = service
|
|
186
|
+
self.dpapi_path = dpapi_path or (get_secagentx_home() / "credentials.dpapi")
|
|
187
|
+
|
|
188
|
+
@staticmethod
|
|
189
|
+
def _keyring_available() -> bool:
|
|
190
|
+
try:
|
|
191
|
+
import keyring
|
|
192
|
+
backend = keyring.get_keyring()
|
|
193
|
+
return backend is not None and backend.priority > 0
|
|
194
|
+
except Exception:
|
|
195
|
+
return False
|
|
196
|
+
|
|
197
|
+
@staticmethod
|
|
198
|
+
def available() -> bool:
|
|
199
|
+
return CredentialStore._keyring_available() or os.name == "nt"
|
|
200
|
+
|
|
201
|
+
@staticmethod
|
|
202
|
+
def _dpapi_transform(payload: bytes, protect: bool) -> bytes:
|
|
203
|
+
if os.name != "nt":
|
|
204
|
+
raise CredentialStoreError("当前系统无可用的本地安全凭据存储")
|
|
205
|
+
|
|
206
|
+
class DataBlob(ctypes.Structure):
|
|
207
|
+
_fields_ = [("cbData", ctypes.c_uint32), ("pbData", ctypes.POINTER(ctypes.c_ubyte))]
|
|
208
|
+
|
|
209
|
+
buffer = ctypes.create_string_buffer(payload)
|
|
210
|
+
input_blob = DataBlob(len(payload), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte)))
|
|
211
|
+
output_blob = DataBlob()
|
|
212
|
+
crypt32 = ctypes.windll.crypt32
|
|
213
|
+
function = crypt32.CryptProtectData if protect else crypt32.CryptUnprotectData
|
|
214
|
+
if protect:
|
|
215
|
+
success = function(
|
|
216
|
+
ctypes.byref(input_blob), None, None, None, None, 0,
|
|
217
|
+
ctypes.byref(output_blob),
|
|
218
|
+
)
|
|
219
|
+
else:
|
|
220
|
+
success = function(
|
|
221
|
+
ctypes.byref(input_blob), None, None, None, None, 0,
|
|
222
|
+
ctypes.byref(output_blob),
|
|
223
|
+
)
|
|
224
|
+
if not success:
|
|
225
|
+
raise CredentialStoreError("Windows DPAPI 操作失败")
|
|
226
|
+
try:
|
|
227
|
+
return ctypes.string_at(output_blob.pbData, output_blob.cbData)
|
|
228
|
+
finally:
|
|
229
|
+
ctypes.windll.kernel32.LocalFree(output_blob.pbData)
|
|
230
|
+
|
|
231
|
+
def _load_dpapi(self) -> dict[str, str]:
|
|
232
|
+
if not self.dpapi_path.exists():
|
|
233
|
+
return {}
|
|
234
|
+
try:
|
|
235
|
+
encrypted = base64.b64decode(self.dpapi_path.read_bytes(), validate=True)
|
|
236
|
+
raw = self._dpapi_transform(encrypted, protect=False)
|
|
237
|
+
value = json.loads(raw.decode("utf-8"))
|
|
238
|
+
return value if isinstance(value, dict) else {}
|
|
239
|
+
except Exception as exc:
|
|
240
|
+
raise CredentialStoreError("无法读取 Windows DPAPI 凭据库") from exc
|
|
241
|
+
|
|
242
|
+
def _save_dpapi(self, values: dict[str, str]) -> None:
|
|
243
|
+
self.dpapi_path.parent.mkdir(parents=True, exist_ok=True)
|
|
244
|
+
raw = json.dumps(values, ensure_ascii=False).encode("utf-8")
|
|
245
|
+
encoded = base64.b64encode(self._dpapi_transform(raw, protect=True))
|
|
246
|
+
fd, tmp_name = tempfile.mkstemp(prefix="credentials-", suffix=".tmp", dir=self.dpapi_path.parent)
|
|
247
|
+
try:
|
|
248
|
+
with os.fdopen(fd, "wb") as handle:
|
|
249
|
+
handle.write(encoded)
|
|
250
|
+
handle.flush()
|
|
251
|
+
os.fsync(handle.fileno())
|
|
252
|
+
os.chmod(tmp_name, 0o600)
|
|
253
|
+
os.replace(tmp_name, self.dpapi_path)
|
|
254
|
+
finally:
|
|
255
|
+
if os.path.exists(tmp_name):
|
|
256
|
+
os.unlink(tmp_name)
|
|
257
|
+
|
|
258
|
+
def set(self, reference: str, secret: str) -> None:
|
|
259
|
+
if not secret:
|
|
260
|
+
raise CredentialStoreError("拒绝保存空 API Key")
|
|
261
|
+
if self._keyring_available():
|
|
262
|
+
import keyring
|
|
263
|
+
try:
|
|
264
|
+
keyring.set_password(self.service, reference, secret)
|
|
265
|
+
# 安装 Keyring 后迁移并清除可能遗留的 DPAPI 副本。
|
|
266
|
+
if os.name == "nt" and self.dpapi_path.exists():
|
|
267
|
+
values = self._load_dpapi()
|
|
268
|
+
if values.pop(f"{self.service}:{reference}", None) is not None:
|
|
269
|
+
self._save_dpapi(values)
|
|
270
|
+
return
|
|
271
|
+
except Exception:
|
|
272
|
+
if os.name != "nt":
|
|
273
|
+
raise CredentialStoreError("系统 Keyring 写入失败,请改用环境变量")
|
|
274
|
+
values = self._load_dpapi()
|
|
275
|
+
values[f"{self.service}:{reference}"] = secret
|
|
276
|
+
self._save_dpapi(values)
|
|
277
|
+
|
|
278
|
+
def get(self, reference: str) -> str:
|
|
279
|
+
if not reference:
|
|
280
|
+
return ""
|
|
281
|
+
if self._keyring_available():
|
|
282
|
+
import keyring
|
|
283
|
+
try:
|
|
284
|
+
secret = keyring.get_password(self.service, reference) or ""
|
|
285
|
+
if secret:
|
|
286
|
+
return secret
|
|
287
|
+
except Exception:
|
|
288
|
+
pass
|
|
289
|
+
if os.name == "nt":
|
|
290
|
+
try:
|
|
291
|
+
return self._load_dpapi().get(f"{self.service}:{reference}", "")
|
|
292
|
+
except CredentialStoreError:
|
|
293
|
+
return ""
|
|
294
|
+
return ""
|
|
295
|
+
|
|
296
|
+
def delete(self, reference: str) -> None:
|
|
297
|
+
if not reference:
|
|
298
|
+
return
|
|
299
|
+
if self._keyring_available():
|
|
300
|
+
import keyring
|
|
301
|
+
try:
|
|
302
|
+
keyring.delete_password(self.service, reference)
|
|
303
|
+
except Exception:
|
|
304
|
+
pass
|
|
305
|
+
if os.name == "nt":
|
|
306
|
+
values = self._load_dpapi()
|
|
307
|
+
values.pop(f"{self.service}:{reference}", None)
|
|
308
|
+
self._save_dpapi(values)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
class ProviderProfileStore:
|
|
312
|
+
def __init__(self, path: Optional[Path] = None, credentials: Optional[CredentialStore] = None):
|
|
313
|
+
self.path = path or (get_secagentx_home() / "providers.json")
|
|
314
|
+
self.credentials = credentials or CredentialStore()
|
|
315
|
+
|
|
316
|
+
def load(self) -> dict[str, Any]:
|
|
317
|
+
if not self.path.exists():
|
|
318
|
+
return {"version": CONFIG_VERSION, "active_profile": "", "profiles": {}}
|
|
319
|
+
try:
|
|
320
|
+
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
321
|
+
if not isinstance(data.get("profiles"), dict):
|
|
322
|
+
raise ValueError("profiles 必须为对象")
|
|
323
|
+
return data
|
|
324
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
325
|
+
raise ValueError(f"Provider 配置损坏: {self.path}: {exc}") from exc
|
|
326
|
+
|
|
327
|
+
def save(self, data: dict[str, Any]) -> None:
|
|
328
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
329
|
+
payload = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
|
|
330
|
+
fd, tmp_name = tempfile.mkstemp(prefix="providers-", suffix=".tmp", dir=self.path.parent)
|
|
331
|
+
try:
|
|
332
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
333
|
+
handle.write(payload)
|
|
334
|
+
handle.flush()
|
|
335
|
+
os.fsync(handle.fileno())
|
|
336
|
+
os.chmod(tmp_name, 0o600)
|
|
337
|
+
os.replace(tmp_name, self.path)
|
|
338
|
+
finally:
|
|
339
|
+
if os.path.exists(tmp_name):
|
|
340
|
+
os.unlink(tmp_name)
|
|
341
|
+
|
|
342
|
+
def active(self) -> Optional[ProviderProfile]:
|
|
343
|
+
data = self.load()
|
|
344
|
+
profile_id = data.get("active_profile", "")
|
|
345
|
+
raw = data.get("profiles", {}).get(profile_id)
|
|
346
|
+
return ProviderProfile.from_dict(raw) if raw else None
|
|
347
|
+
|
|
348
|
+
def list_profiles(self) -> list[ProviderProfile]:
|
|
349
|
+
return [ProviderProfile.from_dict(v) for v in self.load().get("profiles", {}).values()]
|
|
350
|
+
|
|
351
|
+
def get_secret(self, profile: ProviderProfile) -> str:
|
|
352
|
+
if not profile.requires_api_key:
|
|
353
|
+
return "local-no-key"
|
|
354
|
+
if profile.env_key and os.getenv(profile.env_key):
|
|
355
|
+
return os.environ[profile.env_key]
|
|
356
|
+
if profile.credential_type == "env":
|
|
357
|
+
return os.getenv(profile.credential_ref or profile.env_key, "")
|
|
358
|
+
return self.credentials.get(profile.credential_ref)
|
|
359
|
+
|
|
360
|
+
def put(self, profile: ProviderProfile, api_key: str = "", make_active: bool = True) -> None:
|
|
361
|
+
if profile.requires_api_key and api_key:
|
|
362
|
+
profile.credential_type = "keyring"
|
|
363
|
+
profile.credential_ref = profile.credential_ref or f"provider:{profile.profile_id}"
|
|
364
|
+
self.credentials.set(profile.credential_ref, api_key)
|
|
365
|
+
elif profile.requires_api_key and not (
|
|
366
|
+
(profile.env_key and os.getenv(profile.env_key)) or self.get_secret(profile)
|
|
367
|
+
):
|
|
368
|
+
raise CredentialStoreError("未提供可用 API Key")
|
|
369
|
+
|
|
370
|
+
data = self.load()
|
|
371
|
+
data["version"] = CONFIG_VERSION
|
|
372
|
+
data.setdefault("profiles", {})[profile.profile_id] = asdict(profile)
|
|
373
|
+
if make_active:
|
|
374
|
+
data["active_profile"] = profile.profile_id
|
|
375
|
+
self.save(data)
|
|
376
|
+
|
|
377
|
+
def set_active(self, profile_id: str) -> ProviderProfile:
|
|
378
|
+
data = self.load()
|
|
379
|
+
raw = data.get("profiles", {}).get(profile_id)
|
|
380
|
+
if not raw:
|
|
381
|
+
raise KeyError(f"Provider 档案不存在: {profile_id}")
|
|
382
|
+
data["active_profile"] = profile_id
|
|
383
|
+
self.save(data)
|
|
384
|
+
return ProviderProfile.from_dict(raw)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def activate_profile(profile: ProviderProfile, api_key: str) -> None:
|
|
388
|
+
os.environ["SECAGENTX_ACTIVE_PROVIDER"] = profile.protocol
|
|
389
|
+
os.environ["LLM_PROVIDER"] = profile.protocol
|
|
390
|
+
os.environ["SECAGENTX_LLM_PROFILE"] = profile.profile_id
|
|
391
|
+
os.environ["SECAGENTX_LLM_PROVIDER_ID"] = profile.provider_id
|
|
392
|
+
os.environ["SECAGENTX_LLM_API_BASE"] = profile.api_base
|
|
393
|
+
os.environ["SECAGENTX_LLM_MODEL"] = profile.model
|
|
394
|
+
os.environ["SECAGENTX_LLM_AUTH_STYLE"] = profile.auth_style
|
|
395
|
+
os.environ["SECAGENTX_LLM_API_VERSION"] = profile.api_version
|
|
396
|
+
os.environ["SECAGENTX_LLM_ALLOW_NO_KEY"] = "true" if not profile.requires_api_key else "false"
|
|
397
|
+
if api_key:
|
|
398
|
+
os.environ["SECAGENTX_LLM_API_KEY"] = api_key
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def activate_stored_profile(store: Optional[ProviderProfileStore] = None) -> Optional[ProviderProfile]:
|
|
402
|
+
"""加载活动档案到当前进程;无配置时保持现有环境变量兼容行为。"""
|
|
403
|
+
store = store or ProviderProfileStore()
|
|
404
|
+
try:
|
|
405
|
+
profile = store.active()
|
|
406
|
+
if not profile:
|
|
407
|
+
return None
|
|
408
|
+
secret = store.get_secret(profile)
|
|
409
|
+
if profile.requires_api_key and not secret:
|
|
410
|
+
logger.warning("活动 Provider %s 的凭据不可用", profile.profile_id)
|
|
411
|
+
return profile
|
|
412
|
+
activate_profile(profile, secret)
|
|
413
|
+
return profile
|
|
414
|
+
except ValueError as exc:
|
|
415
|
+
logger.error("%s", exc)
|
|
416
|
+
return None
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Web 运行设置。
|
|
2
|
+
|
|
3
|
+
管理员密码仅保存 bcrypt 哈希;JWT secret 保存于系统 Keyring。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import secrets
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
from .provider_profiles import CredentialStore, CredentialStoreError, get_secagentx_home
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RuntimeSettingsStore:
|
|
19
|
+
def __init__(self, path: Optional[Path] = None, credentials: Optional[CredentialStore] = None):
|
|
20
|
+
self.path = path or (get_secagentx_home() / "settings.json")
|
|
21
|
+
self.credentials = credentials or CredentialStore()
|
|
22
|
+
|
|
23
|
+
def load(self) -> dict[str, Any]:
|
|
24
|
+
if not self.path.exists():
|
|
25
|
+
return {"version": 1, "web": {"host": "127.0.0.1", "port": 8000}}
|
|
26
|
+
try:
|
|
27
|
+
return json.loads(self.path.read_text(encoding="utf-8"))
|
|
28
|
+
except Exception as exc:
|
|
29
|
+
raise ValueError(f"运行配置损坏: {self.path}: {exc}") from exc
|
|
30
|
+
|
|
31
|
+
def save(self, data: dict[str, Any]) -> None:
|
|
32
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
fd, tmp_name = tempfile.mkstemp(prefix="settings-", suffix=".tmp", dir=self.path.parent)
|
|
34
|
+
try:
|
|
35
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
36
|
+
json.dump(data, handle, ensure_ascii=False, indent=2)
|
|
37
|
+
handle.write("\n")
|
|
38
|
+
handle.flush()
|
|
39
|
+
os.fsync(handle.fileno())
|
|
40
|
+
os.chmod(tmp_name, 0o600)
|
|
41
|
+
os.replace(tmp_name, self.path)
|
|
42
|
+
finally:
|
|
43
|
+
if os.path.exists(tmp_name):
|
|
44
|
+
os.unlink(tmp_name)
|
|
45
|
+
|
|
46
|
+
def configure_web_credentials(self, password: str) -> None:
|
|
47
|
+
if len(password) < 12:
|
|
48
|
+
raise ValueError("管理员密码至少需要 12 个字符")
|
|
49
|
+
import bcrypt
|
|
50
|
+
|
|
51
|
+
data = self.load()
|
|
52
|
+
data["admin_password_hash"] = bcrypt.hashpw(
|
|
53
|
+
password.encode("utf-8"), bcrypt.gensalt(rounds=12)
|
|
54
|
+
).decode("utf-8")
|
|
55
|
+
jwt_ref = data.get("jwt_secret_ref") or "runtime:jwt-secret"
|
|
56
|
+
self.credentials.set(jwt_ref, secrets.token_urlsafe(48))
|
|
57
|
+
data["jwt_secret_ref"] = jwt_ref
|
|
58
|
+
self.save(data)
|
|
59
|
+
|
|
60
|
+
def activate(self) -> dict[str, Any]:
|
|
61
|
+
data = self.load()
|
|
62
|
+
password_hash = data.get("admin_password_hash", "")
|
|
63
|
+
if password_hash and not os.getenv("SECAGENTX_PASSWORD_HASH") and not os.getenv("SECAGENTX_PASSWORD"):
|
|
64
|
+
os.environ["SECAGENTX_PASSWORD_HASH"] = password_hash
|
|
65
|
+
jwt_ref = data.get("jwt_secret_ref", "")
|
|
66
|
+
if jwt_ref and not os.getenv("SECAGENTX_JWT_SECRET"):
|
|
67
|
+
jwt_secret = self.credentials.get(jwt_ref)
|
|
68
|
+
if jwt_secret:
|
|
69
|
+
os.environ["SECAGENTX_JWT_SECRET"] = jwt_secret
|
|
70
|
+
return data
|
|
71
|
+
|
|
72
|
+
def web_ready(self) -> bool:
|
|
73
|
+
self.activate()
|
|
74
|
+
return bool(
|
|
75
|
+
(os.getenv("SECAGENTX_PASSWORD_HASH") or os.getenv("SECAGENTX_PASSWORD"))
|
|
76
|
+
and os.getenv("SECAGENTX_JWT_SECRET")
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def activate_runtime_settings() -> None:
|
|
81
|
+
try:
|
|
82
|
+
RuntimeSettingsStore().activate()
|
|
83
|
+
except (ValueError, CredentialStoreError):
|
|
84
|
+
return
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Decision Fusion 决策融合模块 — 统一决策框架(Sense-Decide 分离)
|
|
3
|
+
|
|
4
|
+
职责:
|
|
5
|
+
- 各专业 Agent(感知层)只输出证据包 EvidencePackage
|
|
6
|
+
- Decision Fusion(决策层)统一裁决 → 唯一 final verdict
|
|
7
|
+
- 融合引擎为可替换模块(接口见 base.py)
|
|
8
|
+
|
|
9
|
+
用法:
|
|
10
|
+
from backend.decision_fusion import FusionEngineFactory
|
|
11
|
+
engine = FusionEngineFactory.get_engine("dempster_shafer", config={...})
|
|
12
|
+
result = engine.fuse(evidence_packages) # -> FusionResult
|
|
13
|
+
|
|
14
|
+
引擎注册表:
|
|
15
|
+
- dempster_shafer (默认,DS 证据理论,支持未知/冲突)
|
|
16
|
+
- weighted_average (参考实现 / 兜底)
|
|
17
|
+
"""
|
|
18
|
+
from .base import DecisionFusionEngine, FusionEngineFactory, DEFAULT_AGENT_WEIGHTS
|
|
19
|
+
from .dempster_shafer import DempsterShaferEngine
|
|
20
|
+
from .weighted_average import WeightedAverageEngine
|
|
21
|
+
from ..models.output import (
|
|
22
|
+
EvidencePackage, EvidenceMass, FusionConflict, FusionResult,
|
|
23
|
+
FusionVerdict, Finding, build_evidence_package, build_fusion_result,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"DecisionFusionEngine", "FusionEngineFactory", "DEFAULT_AGENT_WEIGHTS",
|
|
28
|
+
"DempsterShaferEngine", "WeightedAverageEngine",
|
|
29
|
+
"EvidencePackage", "EvidenceMass", "FusionConflict", "FusionResult",
|
|
30
|
+
"FusionVerdict", "Finding", "build_evidence_package", "build_fusion_result",
|
|
31
|
+
]
|
|
32
|
+
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DecisionFusionEngine — 决策融合引擎抽象接口(可替换模块)
|
|
3
|
+
|
|
4
|
+
设计目标(Sense-Decide 分离):
|
|
5
|
+
1. 各专业 Agent 只输出证据包(EvidencePackage),不直接裁决风险
|
|
6
|
+
2. Decision Fusion 统一裁决 → 唯一 final verdict
|
|
7
|
+
3. 引擎是可替换模块:通过工厂按名称注册/切换,默认 Dempster-Shafer
|
|
8
|
+
|
|
9
|
+
接入新引擎步骤:
|
|
10
|
+
1. 继承 DecisionFusionEngine,实现 fuse()
|
|
11
|
+
2. 在 FusionEngineFactory._ENGINES 注册(或在运行时 register)
|
|
12
|
+
3. 配置 config.yaml: decision_fusion.engine = "你的引擎名"
|
|
13
|
+
|
|
14
|
+
所有引擎输出统一 FusionResult(见 backend/models/output.py),
|
|
15
|
+
上层(react_loop / RiskScorer / 前端)不感知具体算法。
|
|
16
|
+
"""
|
|
17
|
+
from abc import ABC, abstractmethod
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
from ..models.output import (
|
|
21
|
+
EvidencePackage, FusionResult, build_fusion_result,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# ── 各 Agent 的固定基础权重(与 react_loop.CONFIDENCE_WEIGHTS 对齐) ──
|
|
25
|
+
DEFAULT_AGENT_WEIGHTS = {
|
|
26
|
+
"analyst-001": 0.35,
|
|
27
|
+
"intel-001": 0.25,
|
|
28
|
+
"responder-001": 0.20,
|
|
29
|
+
"alert-filter-001": 0.10,
|
|
30
|
+
"knowledge-001": 0.10,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DecisionFusionEngine(ABC):
|
|
35
|
+
"""决策融合引擎抽象基类 — 可替换模块接口。
|
|
36
|
+
|
|
37
|
+
子类只需实现 fuse();其余辅助方法(权重归一化等)可直接复用。
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
#: 引擎唯一标识(config 中 decision_fusion.engine 引用此名称)
|
|
41
|
+
name: str = "base"
|
|
42
|
+
|
|
43
|
+
def __init__(self, config: Optional[dict] = None):
|
|
44
|
+
self.config = config or {}
|
|
45
|
+
self.agent_weights = dict(DEFAULT_AGENT_WEIGHTS)
|
|
46
|
+
self.agent_weights.update(self.config.get("agent_weights", {}) or {})
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def fuse(self, evidence_packages: list[EvidencePackage]) -> FusionResult:
|
|
50
|
+
"""融合所有证据包,产出唯一权威裁决。
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
evidence_packages: 各 Agent 证据包(已标准化)
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
FusionResult(verdict/confidence/conflicts/decision_path ...)
|
|
57
|
+
"""
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
# ── 共享辅助 ──
|
|
61
|
+
|
|
62
|
+
def _weight_for(self, agent_id: str, degraded: bool = False) -> float:
|
|
63
|
+
"""获取 Agent 基础权重(降级折半,与既有纪律一致)。"""
|
|
64
|
+
w = self.agent_weights.get(agent_id, 0.0)
|
|
65
|
+
if degraded:
|
|
66
|
+
w *= 0.5
|
|
67
|
+
return w
|
|
68
|
+
|
|
69
|
+
def _normalize_weights(self, packages: list[EvidencePackage]) -> list[tuple[EvidencePackage, float]]:
|
|
70
|
+
"""为每个证据包计算归一化权重(基础权重 × 证据可靠度)。
|
|
71
|
+
|
|
72
|
+
规则:
|
|
73
|
+
- failed 包权重 = 0(不参与融合,但记录)
|
|
74
|
+
- 无结构化裁决(evidence_confidence 接近默认且无 findings)→ 权重打折
|
|
75
|
+
"""
|
|
76
|
+
scored = []
|
|
77
|
+
for p in packages:
|
|
78
|
+
if p.failed:
|
|
79
|
+
scored.append((p, 0.0))
|
|
80
|
+
continue
|
|
81
|
+
base = self._weight_for(p.agent_id, p.degraded)
|
|
82
|
+
# 证据可靠度:findings 均值 / evidence_confidence
|
|
83
|
+
ev_conf = p.evidence_confidence
|
|
84
|
+
if ev_conf <= 0.0:
|
|
85
|
+
ev_conf = 0.5
|
|
86
|
+
scored.append((p, base * ev_conf))
|
|
87
|
+
total = sum(w for _, w in scored)
|
|
88
|
+
if total <= 0:
|
|
89
|
+
return [(p, 0.0) for p in packages]
|
|
90
|
+
return [(p, w / total) for p, w in scored]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ═══════════════════════ 引擎工厂(可替换模块注册表) ═══════════════════════
|
|
94
|
+
|
|
95
|
+
class FusionEngineFactory:
|
|
96
|
+
"""决策融合引擎工厂 — 按名称获取引擎实例。
|
|
97
|
+
|
|
98
|
+
默认注册 dempster_shafer;可调用 register() 注册自定义引擎。
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
_ENGINES: dict[str, type] = {}
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def register(cls, name: str, engine_cls: type) -> None:
|
|
105
|
+
cls._ENGINES[name] = engine_cls
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def get_engine(cls, name: str = "dempster_shafer",
|
|
109
|
+
config: Optional[dict] = None) -> "DecisionFusionEngine":
|
|
110
|
+
"""获取引擎实例;未知名称抛 ValueError(配置错误要尽早暴露)。"""
|
|
111
|
+
engine_cls = cls._ENGINES.get(name)
|
|
112
|
+
if engine_cls is None:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"未知决策融合引擎: {name!r},可用: {sorted(cls._ENGINES.keys())}"
|
|
115
|
+
)
|
|
116
|
+
return engine_cls(config=config)
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def available(cls) -> list[str]:
|
|
120
|
+
return sorted(cls._ENGINES.keys())
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
__all__ = [
|
|
124
|
+
"DecisionFusionEngine", "FusionEngineFactory",
|
|
125
|
+
"DEFAULT_AGENT_WEIGHTS",
|
|
126
|
+
]
|
|
127
|
+
|