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
backend/tools/base.py
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class ToolResult:
|
|
9
|
+
success: bool = True
|
|
10
|
+
data: Any = None
|
|
11
|
+
error: str = ""
|
|
12
|
+
duration_ms: float = 0.0
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BaseTool(ABC):
|
|
16
|
+
name: str = ""
|
|
17
|
+
description: str = ""
|
|
18
|
+
parameters: dict = None # 子类在类级别重写此字段
|
|
19
|
+
agent_id: str = ""
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def params(self) -> dict:
|
|
23
|
+
"""安全访问 parameters,兼容子类类级别定义和实例级别定义"""
|
|
24
|
+
val = self.parameters
|
|
25
|
+
if val is None:
|
|
26
|
+
return {"type": "object", "properties": {}}
|
|
27
|
+
return val
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
async def execute(self, **kwargs) -> ToolResult:
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
def to_openai_function(self) -> dict:
|
|
34
|
+
return {
|
|
35
|
+
"type": "function",
|
|
36
|
+
"function": {
|
|
37
|
+
"name": self.name,
|
|
38
|
+
"description": self.description,
|
|
39
|
+
"parameters": self.params,
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
def to_tool_call(self) -> dict:
|
|
44
|
+
return {
|
|
45
|
+
"name": self.name,
|
|
46
|
+
"description": self.description,
|
|
47
|
+
"parameters": self.params,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ═══════════════════════════════════════════════════════════════
|
|
52
|
+
# 防火墙适配器接口 — 支持多种后端实现(企业级完善)
|
|
53
|
+
# ═══════════════════════════════════════════════════════════════
|
|
54
|
+
|
|
55
|
+
class FirewallAdapter(ABC):
|
|
56
|
+
"""防火墙后端适配器抽象接口"""
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
async def block_ip(self, ip: str, reason: str = "",
|
|
60
|
+
duration_minutes: int = 120) -> tuple[bool, str]:
|
|
61
|
+
...
|
|
62
|
+
|
|
63
|
+
@abstractmethod
|
|
64
|
+
async def unblock_ip(self, ip: str) -> tuple[bool, str]:
|
|
65
|
+
...
|
|
66
|
+
|
|
67
|
+
@abstractmethod
|
|
68
|
+
async def list_rules(self) -> list[dict]:
|
|
69
|
+
...
|
|
70
|
+
|
|
71
|
+
@abstractmethod
|
|
72
|
+
async def check_ip(self, ip: str) -> dict:
|
|
73
|
+
...
|
|
74
|
+
|
|
75
|
+
@abstractmethod
|
|
76
|
+
async def health_check(self) -> bool:
|
|
77
|
+
...
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class IptablesAdapter(FirewallAdapter):
|
|
81
|
+
"""iptables/nftables 真实防火墙后端(生产可用)
|
|
82
|
+
|
|
83
|
+
生产环境注意事项(已实测验证):
|
|
84
|
+
- 不使用 `-m comment` 模块:在 nf_tables 后端下,添加时带 comment
|
|
85
|
+
删除时匹配不一致,会导致规则残留(实测 Bug)
|
|
86
|
+
- 使用独立链 `SecAgentX-BLOCK`:封禁规则全部放到该链,
|
|
87
|
+
list_rules 只查该链,不会与 INPUT 链上的其他规则混淆
|
|
88
|
+
- 封禁/解封需要 root 权限:通过 sudo 执行
|
|
89
|
+
- 删除规则时带完整匹配条件(-s ip -j DROP),避免误删其他规则
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
CHAIN = "SecAgentX-BLOCK"
|
|
93
|
+
|
|
94
|
+
def __init__(self, chain: str = None, use_sudo: bool = True):
|
|
95
|
+
self.chain = chain or self.CHAIN
|
|
96
|
+
# 生产环境 iptables 通常需要 root;可配置为 False(已是 root)
|
|
97
|
+
self.use_sudo = use_sudo or os.geteuid() != 0
|
|
98
|
+
|
|
99
|
+
async def _run_iptables(self, args: list[str]) -> tuple[int, str, str]:
|
|
100
|
+
import asyncio
|
|
101
|
+
import subprocess
|
|
102
|
+
cmd = ["sudo", "iptables", *args] if self.use_sudo else ["iptables", *args]
|
|
103
|
+
proc = await asyncio.create_subprocess_exec(
|
|
104
|
+
*cmd,
|
|
105
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
106
|
+
)
|
|
107
|
+
stdout, stderr = await proc.communicate()
|
|
108
|
+
return proc.returncode or 0, stdout.decode(), stderr.decode()
|
|
109
|
+
|
|
110
|
+
async def _ensure_chain(self):
|
|
111
|
+
"""确保独立链存在(不存在则创建并接入 INPUT)。"""
|
|
112
|
+
rc, stdout, _ = await self._run_iptables(["-L", self.chain, "-n"])
|
|
113
|
+
if rc != 0:
|
|
114
|
+
# 创建独立链
|
|
115
|
+
await self._run_iptables(["-N", self.chain])
|
|
116
|
+
# 接入 INPUT 链(在 ACCEPT 之前插入)
|
|
117
|
+
await self._run_iptables(["-I", "INPUT", "-j", self.chain])
|
|
118
|
+
|
|
119
|
+
async def block_ip(self, ip: str, reason: str = "",
|
|
120
|
+
duration_minutes: int = 120) -> tuple[bool, str]:
|
|
121
|
+
await self._ensure_chain()
|
|
122
|
+
rc, _, stderr = await self._run_iptables([
|
|
123
|
+
"-A", self.chain, "-s", ip, "-j", "DROP",
|
|
124
|
+
])
|
|
125
|
+
if rc != 0:
|
|
126
|
+
return False, f"iptables block failed: {stderr}"
|
|
127
|
+
return True, ""
|
|
128
|
+
|
|
129
|
+
async def unblock_ip(self, ip: str) -> tuple[bool, str]:
|
|
130
|
+
# 在独立链中删除,带完整匹配
|
|
131
|
+
rc, _, stderr = await self._run_iptables([
|
|
132
|
+
"-D", self.chain, "-s", ip, "-j", "DROP",
|
|
133
|
+
])
|
|
134
|
+
if rc != 0:
|
|
135
|
+
# 规则可能不存在,不是致命错误
|
|
136
|
+
return True, f"rule not found (already removed): {stderr}"
|
|
137
|
+
return True, ""
|
|
138
|
+
|
|
139
|
+
async def list_rules(self) -> list[dict]:
|
|
140
|
+
rc, stdout, stderr = await self._run_iptables([
|
|
141
|
+
"-L", self.chain, "-n",
|
|
142
|
+
])
|
|
143
|
+
if rc != 0:
|
|
144
|
+
return []
|
|
145
|
+
rules = []
|
|
146
|
+
for line in stdout.splitlines():
|
|
147
|
+
parts = line.split()
|
|
148
|
+
# 格式: DROP all -- <src> <dst>
|
|
149
|
+
if len(parts) >= 4 and parts[0] == "DROP":
|
|
150
|
+
ip = parts[3]
|
|
151
|
+
if ip != "0.0.0.0/0":
|
|
152
|
+
rules.append({"ip": ip, "reason": ""})
|
|
153
|
+
return rules
|
|
154
|
+
|
|
155
|
+
async def check_ip(self, ip: str) -> dict:
|
|
156
|
+
rules = await self.list_rules()
|
|
157
|
+
for r in rules:
|
|
158
|
+
if r["ip"] == ip:
|
|
159
|
+
return {"is_blocked": True, "rule": r}
|
|
160
|
+
return {"is_blocked": False}
|
|
161
|
+
|
|
162
|
+
async def health_check(self) -> bool:
|
|
163
|
+
# 确保链存在(不存在则创建并接入 INPUT)——成功即健康
|
|
164
|
+
try:
|
|
165
|
+
await self._ensure_chain()
|
|
166
|
+
rc, _, stderr = await self._run_iptables(["-L", self.chain, "-n"])
|
|
167
|
+
return rc == 0 and "Permission" not in stderr
|
|
168
|
+
except Exception:
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class DisabledFirewallAdapter(FirewallAdapter):
|
|
173
|
+
"""安全默认值:不执行也不伪造任何防火墙变更。"""
|
|
174
|
+
|
|
175
|
+
_ERROR = "防火墙后端未配置;请显式设置 FIREWALL_BACKEND"
|
|
176
|
+
|
|
177
|
+
async def block_ip(self, ip: str, reason: str = "",
|
|
178
|
+
duration_minutes: int = 120) -> tuple[bool, str]:
|
|
179
|
+
return False, self._ERROR
|
|
180
|
+
|
|
181
|
+
async def unblock_ip(self, ip: str) -> tuple[bool, str]:
|
|
182
|
+
return False, self._ERROR
|
|
183
|
+
|
|
184
|
+
async def list_rules(self) -> list[dict]:
|
|
185
|
+
return []
|
|
186
|
+
|
|
187
|
+
async def check_ip(self, ip: str) -> dict:
|
|
188
|
+
return {"is_blocked": False, "status": "disabled"}
|
|
189
|
+
|
|
190
|
+
async def health_check(self) -> bool:
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class MockFirewallAdapter(FirewallAdapter):
|
|
195
|
+
"""本地 JSON 测试后端;生产不得启用。"""
|
|
196
|
+
|
|
197
|
+
# 文件写操作锁 — 防止并发写入导致数据竞态
|
|
198
|
+
_file_lock = None
|
|
199
|
+
|
|
200
|
+
def __init__(self):
|
|
201
|
+
self._rules: dict[str, dict] = {}
|
|
202
|
+
if MockFirewallAdapter._file_lock is None:
|
|
203
|
+
import asyncio
|
|
204
|
+
MockFirewallAdapter._file_lock = asyncio.Lock()
|
|
205
|
+
self._load()
|
|
206
|
+
|
|
207
|
+
def _load(self):
|
|
208
|
+
import json
|
|
209
|
+
path = os.getenv("BLACKLIST_FILE", "data/blacklist/blacklist.json")
|
|
210
|
+
if os.path.exists(path):
|
|
211
|
+
try:
|
|
212
|
+
with open(path) as f:
|
|
213
|
+
self._rules = json.load(f)
|
|
214
|
+
except Exception:
|
|
215
|
+
self._rules = {}
|
|
216
|
+
|
|
217
|
+
async def _save(self):
|
|
218
|
+
import json
|
|
219
|
+
path = os.getenv("BLACKLIST_FILE", "data/blacklist/blacklist.json")
|
|
220
|
+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
221
|
+
tmp = path + ".tmp"
|
|
222
|
+
async with MockFirewallAdapter._file_lock:
|
|
223
|
+
with open(tmp, "w") as f:
|
|
224
|
+
json.dump(self._rules, f, indent=2)
|
|
225
|
+
os.replace(tmp, path)
|
|
226
|
+
|
|
227
|
+
async def block_ip(self, ip: str, reason: str = "",
|
|
228
|
+
duration_minutes: int = 120) -> tuple[bool, str]:
|
|
229
|
+
from datetime import datetime, timezone, timedelta
|
|
230
|
+
self._rules[ip] = {
|
|
231
|
+
"reason": reason or "auto-block",
|
|
232
|
+
"blocked_at": datetime.now(timezone.utc).isoformat(),
|
|
233
|
+
"expire_at": (datetime.now(timezone.utc) + timedelta(minutes=duration_minutes)).isoformat(),
|
|
234
|
+
"duration_minutes": duration_minutes,
|
|
235
|
+
}
|
|
236
|
+
await self._save()
|
|
237
|
+
return True, ""
|
|
238
|
+
|
|
239
|
+
async def unblock_ip(self, ip: str) -> tuple[bool, str]:
|
|
240
|
+
self._rules.pop(ip, None)
|
|
241
|
+
await self._save()
|
|
242
|
+
return True, ""
|
|
243
|
+
|
|
244
|
+
async def list_rules(self) -> list[dict]:
|
|
245
|
+
from datetime import datetime, timezone
|
|
246
|
+
now = datetime.now(timezone.utc)
|
|
247
|
+
active = []
|
|
248
|
+
for ip, rule in self._rules.items():
|
|
249
|
+
expire = datetime.fromisoformat(rule["expire_at"])
|
|
250
|
+
if expire > now:
|
|
251
|
+
active.append({"ip": ip, **rule})
|
|
252
|
+
return active
|
|
253
|
+
|
|
254
|
+
async def check_ip(self, ip: str) -> dict:
|
|
255
|
+
rules = await self.list_rules()
|
|
256
|
+
for r in rules:
|
|
257
|
+
if r["ip"] == ip:
|
|
258
|
+
return {"is_blocked": True, "rule": r}
|
|
259
|
+
return {"is_blocked": False}
|
|
260
|
+
|
|
261
|
+
async def health_check(self) -> bool:
|
|
262
|
+
return True
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class NftablesAdapter(FirewallAdapter):
|
|
266
|
+
"""nftables 防火墙后端 — 现代 Linux 防火墙(iptables 的替代品)"""
|
|
267
|
+
|
|
268
|
+
def __init__(self, table: str = "secagentx", chain: str = "input"):
|
|
269
|
+
self.table = table
|
|
270
|
+
self.chain = chain
|
|
271
|
+
self._initialized = False
|
|
272
|
+
# 将初始化延迟到首次使用时(避免在 __init__ 中调用异步代码)
|
|
273
|
+
|
|
274
|
+
async def _ensure_table(self):
|
|
275
|
+
"""确保 nftables 表和链存在(延迟初始化)"""
|
|
276
|
+
if self._initialized:
|
|
277
|
+
return
|
|
278
|
+
try:
|
|
279
|
+
import asyncio
|
|
280
|
+
import subprocess as _subprocess
|
|
281
|
+
proc = await asyncio.create_subprocess_exec(
|
|
282
|
+
"nft", "list", "table", "inet", self.table,
|
|
283
|
+
stdout=_subprocess.PIPE, stderr=_subprocess.PIPE,
|
|
284
|
+
)
|
|
285
|
+
await proc.communicate()
|
|
286
|
+
if proc.returncode != 0:
|
|
287
|
+
# 表不存在,创建
|
|
288
|
+
await asyncio.create_subprocess_exec(
|
|
289
|
+
"nft", "add", "table", "inet", self.table,
|
|
290
|
+
)
|
|
291
|
+
await asyncio.create_subprocess_exec(
|
|
292
|
+
"nft", "add", "chain", "inet", self.table, self.chain,
|
|
293
|
+
"{ type filter hook input priority 0; policy accept; }",
|
|
294
|
+
)
|
|
295
|
+
self._initialized = True
|
|
296
|
+
except Exception:
|
|
297
|
+
pass # 非 root 用户下跳过,实际封禁时会报错
|
|
298
|
+
|
|
299
|
+
async def _run_nft(self, args: list[str]) -> tuple[int, str, str]:
|
|
300
|
+
import asyncio, subprocess
|
|
301
|
+
try:
|
|
302
|
+
proc = await asyncio.create_subprocess_exec(
|
|
303
|
+
"nft", *args,
|
|
304
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
305
|
+
)
|
|
306
|
+
stdout, stderr = await proc.communicate()
|
|
307
|
+
return proc.returncode or 0, stdout.decode(), stderr.decode()
|
|
308
|
+
except FileNotFoundError:
|
|
309
|
+
return 127, "", "nft executable is not installed"
|
|
310
|
+
except OSError as exc:
|
|
311
|
+
return 1, "", str(exc)
|
|
312
|
+
|
|
313
|
+
async def block_ip(self, ip: str, reason: str = "",
|
|
314
|
+
duration_minutes: int = 120) -> tuple[bool, str]:
|
|
315
|
+
await self._ensure_table()
|
|
316
|
+
comment = f"SecAgentX: {reason[:60]}" if reason else "SecAgentX: auto-block"
|
|
317
|
+
rc, _, stderr = await self._run_nft([
|
|
318
|
+
"add", "rule", "inet", self.table, self.chain,
|
|
319
|
+
"ip", "saddr", ip, "drop",
|
|
320
|
+
"comment", f'"{comment}"',
|
|
321
|
+
])
|
|
322
|
+
if rc != 0:
|
|
323
|
+
return False, f"nftables block failed: {stderr}"
|
|
324
|
+
return True, ""
|
|
325
|
+
|
|
326
|
+
async def unblock_ip(self, ip: str) -> tuple[bool, str]:
|
|
327
|
+
await self._ensure_table()
|
|
328
|
+
# nftables 没有 "delete rule by match" 的直接方式,需要 handle
|
|
329
|
+
# 先列出所有规则,找到对应 handle,再删除
|
|
330
|
+
rc, stdout, stderr = await self._run_nft([
|
|
331
|
+
"-a", "list", "chain", "inet", self.table, self.chain,
|
|
332
|
+
])
|
|
333
|
+
if rc != 0:
|
|
334
|
+
return True, f"cannot list rules (may not exist): {stderr}"
|
|
335
|
+
|
|
336
|
+
import re
|
|
337
|
+
for line in stdout.splitlines():
|
|
338
|
+
if f"saddr {ip}" in line and "drop" in line:
|
|
339
|
+
m = re.search(r'# handle (\d+)', line)
|
|
340
|
+
if m:
|
|
341
|
+
handle = m.group(1)
|
|
342
|
+
rc2, _, stderr2 = await self._run_nft([
|
|
343
|
+
"delete", "rule", "inet", self.table, self.chain,
|
|
344
|
+
"handle", handle,
|
|
345
|
+
])
|
|
346
|
+
if rc2 == 0:
|
|
347
|
+
return True, ""
|
|
348
|
+
return False, f"nftables unblock failed: {stderr2}"
|
|
349
|
+
return True, "rule not found (already removed)"
|
|
350
|
+
|
|
351
|
+
async def list_rules(self) -> list[dict]:
|
|
352
|
+
await self._ensure_table()
|
|
353
|
+
import re
|
|
354
|
+
rc, stdout, stderr = await self._run_nft([
|
|
355
|
+
"-a", "list", "chain", "inet", self.table, self.chain,
|
|
356
|
+
])
|
|
357
|
+
if rc != 0:
|
|
358
|
+
return []
|
|
359
|
+
rules = []
|
|
360
|
+
for line in stdout.splitlines():
|
|
361
|
+
if "saddr" in line and "drop" in line:
|
|
362
|
+
parts = line.split()
|
|
363
|
+
ip = ""
|
|
364
|
+
for i, p in enumerate(parts):
|
|
365
|
+
if p == "saddr" and i + 1 < len(parts):
|
|
366
|
+
ip = parts[i + 1]
|
|
367
|
+
comment_m = re.search(r'comment "([^"]*)"', line)
|
|
368
|
+
handle_m = re.search(r'# handle (\d+)', line)
|
|
369
|
+
rules.append({
|
|
370
|
+
"ip": ip,
|
|
371
|
+
"reason": comment_m.group(1).replace("SecAgentX: ", "") if comment_m else "",
|
|
372
|
+
"handle": handle_m.group(1) if handle_m else "",
|
|
373
|
+
})
|
|
374
|
+
return rules
|
|
375
|
+
|
|
376
|
+
async def check_ip(self, ip: str) -> dict:
|
|
377
|
+
await self._ensure_table()
|
|
378
|
+
rules = await self.list_rules()
|
|
379
|
+
for r in rules:
|
|
380
|
+
if r["ip"] == ip:
|
|
381
|
+
return {"is_blocked": True, "rule": r}
|
|
382
|
+
return {"is_blocked": False}
|
|
383
|
+
|
|
384
|
+
async def health_check(self) -> bool:
|
|
385
|
+
await self._ensure_table()
|
|
386
|
+
rc, _, _ = await self._run_nft(["list", "tables"])
|
|
387
|
+
return rc == 0
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
class FirewallAdapterFactory:
|
|
391
|
+
"""防火墙适配器工厂 — 通过环境变量切换后端"""
|
|
392
|
+
|
|
393
|
+
BACKENDS = {
|
|
394
|
+
"disabled": DisabledFirewallAdapter,
|
|
395
|
+
"mock": MockFirewallAdapter,
|
|
396
|
+
"iptables": IptablesAdapter,
|
|
397
|
+
"nftables": NftablesAdapter,
|
|
398
|
+
"aliyun": None, # 由 _lazy_load 动态加载
|
|
399
|
+
"tencent": None, # 由 _lazy_load 动态加载
|
|
400
|
+
"aws": None, # 由 _lazy_load 动态加载
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
_LAZY_LOADERS = {
|
|
404
|
+
"aliyun": {
|
|
405
|
+
"module": "backend.tools.aliyun_firewall",
|
|
406
|
+
"class": "AliyunFirewallAdapter",
|
|
407
|
+
"pip": "alibabacloud_ecs20140526",
|
|
408
|
+
},
|
|
409
|
+
"tencent": {
|
|
410
|
+
"module": "backend.tools.tencent_firewall",
|
|
411
|
+
"class": "TencentFirewallAdapter",
|
|
412
|
+
"pip": "tencentcloud-sdk-python-vpc",
|
|
413
|
+
},
|
|
414
|
+
"aws": {
|
|
415
|
+
"module": "backend.tools.aws_firewall",
|
|
416
|
+
"class": "AWSFirewallAdapter",
|
|
417
|
+
"pip": "boto3",
|
|
418
|
+
},
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
@classmethod
|
|
422
|
+
def create(cls, backend: str = None) -> FirewallAdapter:
|
|
423
|
+
backend = backend or os.getenv("FIREWALL_BACKEND", "disabled")
|
|
424
|
+
adapter_class = cls.BACKENDS.get(backend)
|
|
425
|
+
|
|
426
|
+
# 延迟加载云厂商适配器
|
|
427
|
+
if adapter_class is None and backend in cls._LAZY_LOADERS:
|
|
428
|
+
loader = cls._LAZY_LOADERS[backend]
|
|
429
|
+
try:
|
|
430
|
+
import importlib
|
|
431
|
+
mod = importlib.import_module(loader["module"])
|
|
432
|
+
adapter_class = getattr(mod, loader["class"])
|
|
433
|
+
cls.BACKENDS[backend] = adapter_class
|
|
434
|
+
except ImportError as e:
|
|
435
|
+
raise ValueError(
|
|
436
|
+
f"{loader['class']} 加载失败: {e}\n"
|
|
437
|
+
f"请安装: pip install {loader['pip']}"
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
if adapter_class is None:
|
|
441
|
+
raise ValueError(
|
|
442
|
+
f"不支持的防火墙后端: '{backend}'。"
|
|
443
|
+
f"可选: {', '.join(k for k, v in cls.BACKENDS.items() if v)}"
|
|
444
|
+
)
|
|
445
|
+
return adapter_class()
|
backend/tools/calling.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""
|
|
2
|
+
统一 Tool Calling 核心模块
|
|
3
|
+
|
|
4
|
+
标准化的 ToolCall 请求/响应数据模型,
|
|
5
|
+
以及 LLM Function Calling 格式 ↔ 内部工具系统的适配层。
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
import uuid
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
from .base import BaseTool, ToolResult
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# ═══════════════════════ 标准化数据模型 ═══════════════════════
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ToolCall:
|
|
19
|
+
"""
|
|
20
|
+
一次工具调用的完整请求记录
|
|
21
|
+
|
|
22
|
+
由 LLM 的 tool_calls 解析而来。
|
|
23
|
+
"""
|
|
24
|
+
call_id: str = field(default="") # 对应 LLM 返回的 tool_call_id
|
|
25
|
+
tool_name: str = field(default="") # 工具名(如 "threat_intel")
|
|
26
|
+
arguments: dict = field(default_factory=dict) # 解析后的参数字典
|
|
27
|
+
raw_arguments: str = "" # LLM 返回的原始 JSON 字符串
|
|
28
|
+
timestamp: float = 0.0 # 创建时间
|
|
29
|
+
source: str = "" # 来源标识("planner" / "agent:analyst" / "react")
|
|
30
|
+
round_number: int = 0 # ReAct 轮次
|
|
31
|
+
|
|
32
|
+
def __post_init__(self):
|
|
33
|
+
if not self.timestamp:
|
|
34
|
+
self.timestamp = time.time()
|
|
35
|
+
if not self.call_id:
|
|
36
|
+
self.call_id = f"tc_{uuid.uuid4().hex[:12]}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ToolCallResult:
|
|
41
|
+
"""一次工具调用的完整结果记录"""
|
|
42
|
+
call: ToolCall
|
|
43
|
+
success: bool = False
|
|
44
|
+
result: Any = None
|
|
45
|
+
error: str = ""
|
|
46
|
+
duration_ms: float = 0.0
|
|
47
|
+
formatted_for_llm: str = ""
|
|
48
|
+
|
|
49
|
+
def to_llm_message(self) -> dict:
|
|
50
|
+
"""转换为 OpenAI 兼容的 tool 角色消息"""
|
|
51
|
+
content = self.formatted_for_llm or json.dumps(
|
|
52
|
+
{"success": self.success, "result": self.result, "error": self.error},
|
|
53
|
+
ensure_ascii=False,
|
|
54
|
+
)
|
|
55
|
+
return {
|
|
56
|
+
"role": "tool",
|
|
57
|
+
"tool_call_id": self.call.call_id,
|
|
58
|
+
"content": content,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class ToolCallHistory:
|
|
64
|
+
"""完整工具调用历史(可用于审计和回放)"""
|
|
65
|
+
MAX_HISTORY = 5000 # 最多保留 5000 条,防止 OOM
|
|
66
|
+
calls: list[ToolCallResult] = field(default_factory=list)
|
|
67
|
+
|
|
68
|
+
def add(self, result: ToolCallResult):
|
|
69
|
+
self.calls.append(result)
|
|
70
|
+
if len(self.calls) > self.MAX_HISTORY:
|
|
71
|
+
# 保留最新的 80%
|
|
72
|
+
self.calls = self.calls[-self.MAX_HISTORY:]
|
|
73
|
+
|
|
74
|
+
def to_dict(self) -> list[dict]:
|
|
75
|
+
return [
|
|
76
|
+
{
|
|
77
|
+
"call_id": r.call.call_id,
|
|
78
|
+
"tool": r.call.tool_name,
|
|
79
|
+
"arguments": r.call.arguments,
|
|
80
|
+
"success": r.success,
|
|
81
|
+
"duration_ms": r.duration_ms,
|
|
82
|
+
"source": r.call.source,
|
|
83
|
+
"round": r.call.round_number,
|
|
84
|
+
}
|
|
85
|
+
for r in self.calls
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def total_calls(self) -> int:
|
|
90
|
+
return len(self.calls)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def failed_calls(self) -> list[ToolCallResult]:
|
|
94
|
+
return [r for r in self.calls if not r.success]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ═══════════════════════ LLM 适配函数 ═══════════════════════
|
|
98
|
+
|
|
99
|
+
def build_tools_for_llm(tools: list[BaseTool]) -> list[dict]:
|
|
100
|
+
"""
|
|
101
|
+
将内部工具列表转换为 OpenAI 兼容的 tools 参数格式
|
|
102
|
+
|
|
103
|
+
输出示例:
|
|
104
|
+
[{
|
|
105
|
+
"type": "function",
|
|
106
|
+
"function": {
|
|
107
|
+
"name": "threat_intel",
|
|
108
|
+
"description": "查询IP/域名/哈希的威胁情报",
|
|
109
|
+
"parameters": {"type": "object", "properties": {...}, "required": [...]}
|
|
110
|
+
}
|
|
111
|
+
}]
|
|
112
|
+
"""
|
|
113
|
+
return [t.to_openai_function() for t in tools]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def parse_tool_calls(
|
|
117
|
+
llm_response: dict,
|
|
118
|
+
source: str = "",
|
|
119
|
+
round_number: int = 0,
|
|
120
|
+
) -> list[ToolCall]:
|
|
121
|
+
"""
|
|
122
|
+
从 LLM 响应中解析 tool_calls
|
|
123
|
+
|
|
124
|
+
兼容:
|
|
125
|
+
- OpenAI 标准格式: choices[0].message.tool_calls[]
|
|
126
|
+
- DeepSeek 格式: 同上(兼容 OpenAI)
|
|
127
|
+
- Function_call 旧格式: choices[0].message.function_call(兼容遗留)
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
llm_response: LLM API 返回的完整 JSON 响应
|
|
131
|
+
source: 来源标识
|
|
132
|
+
round_number: ReAct 轮次
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
list[ToolCall]: 解析后的调用列表(可能为空)
|
|
136
|
+
"""
|
|
137
|
+
raw_calls = []
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
message = llm_response["choices"][0]["message"]
|
|
141
|
+
|
|
142
|
+
# 标准 OpenAI 格式
|
|
143
|
+
if "tool_calls" in message and message["tool_calls"]:
|
|
144
|
+
raw_calls = message["tool_calls"]
|
|
145
|
+
# 兼容旧版 function_call(部分 provider 仍返回这个)
|
|
146
|
+
elif "function_call" in message and message["function_call"]:
|
|
147
|
+
fc = message["function_call"]
|
|
148
|
+
raw_calls = [{
|
|
149
|
+
"id": f"call_{uuid.uuid4().hex[:12]}",
|
|
150
|
+
"type": "function",
|
|
151
|
+
"function": {
|
|
152
|
+
"name": fc.get("name", ""),
|
|
153
|
+
"arguments": fc.get("arguments", "{}"),
|
|
154
|
+
},
|
|
155
|
+
}]
|
|
156
|
+
except (KeyError, IndexError, TypeError):
|
|
157
|
+
return []
|
|
158
|
+
|
|
159
|
+
tool_calls = []
|
|
160
|
+
for tc in raw_calls:
|
|
161
|
+
try:
|
|
162
|
+
raw_args = tc.get("function", {}).get("arguments", "{}")
|
|
163
|
+
if isinstance(raw_args, str):
|
|
164
|
+
arguments = json.loads(raw_args)
|
|
165
|
+
else:
|
|
166
|
+
arguments = raw_args
|
|
167
|
+
|
|
168
|
+
tool_calls.append(ToolCall(
|
|
169
|
+
call_id=tc.get("id", f"tc_{uuid.uuid4().hex[:12]}"),
|
|
170
|
+
tool_name=tc.get("function", {}).get("name", ""),
|
|
171
|
+
arguments=arguments,
|
|
172
|
+
raw_arguments=raw_args if isinstance(raw_args, str)
|
|
173
|
+
else json.dumps(raw_args, ensure_ascii=False),
|
|
174
|
+
source=source,
|
|
175
|
+
round_number=round_number,
|
|
176
|
+
))
|
|
177
|
+
except (json.JSONDecodeError, KeyError, TypeError):
|
|
178
|
+
# 参数解析失败——保留记录但 arguments 为空
|
|
179
|
+
tool_calls.append(ToolCall(
|
|
180
|
+
call_id=tc.get("id", f"tc_{uuid.uuid4().hex[:12]}"),
|
|
181
|
+
tool_name=tc.get("function", {}).get("name", ""),
|
|
182
|
+
arguments={},
|
|
183
|
+
raw_arguments=str(tc.get("function", {}).get("arguments", "{}")),
|
|
184
|
+
source=source,
|
|
185
|
+
round_number=round_number,
|
|
186
|
+
))
|
|
187
|
+
|
|
188
|
+
return tool_calls
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def format_tool_result_for_llm(tool_name: str, result: ToolResult) -> str:
|
|
192
|
+
"""
|
|
193
|
+
将工具执行结果格式化为 LLM 可读的文本
|
|
194
|
+
|
|
195
|
+
规则:
|
|
196
|
+
- 成功: 结构化展示关键字段,超过 3000 字符截断
|
|
197
|
+
- 失败: 清晰说明错误原因
|
|
198
|
+
"""
|
|
199
|
+
if result.success:
|
|
200
|
+
data = result.data or {}
|
|
201
|
+
data_str = json.dumps(data, ensure_ascii=False, indent=2)
|
|
202
|
+
if len(data_str) > 3000:
|
|
203
|
+
data_str = data_str[:3000] + f"\n... (truncated, {len(data_str)} total)"
|
|
204
|
+
return f"工具 [{tool_name}] 执行成功 ({result.duration_ms:.0f}ms):\n{data_str}"
|
|
205
|
+
else:
|
|
206
|
+
return f"工具 [{tool_name}] 执行失败: {result.error}"
|
|
207
|
+
|