Compare commits

...

14 Commits

Author SHA1 Message Date
公明 dec69a1993 Update config.yaml 2026-05-01 01:33:17 +08:00
公明 15aab2584a Add files via upload 2026-05-01 01:32:54 +08:00
公明 399b697d75 Add files via upload 2026-05-01 01:31:19 +08:00
公明 e0753fd03e Add files via upload 2026-05-01 01:28:19 +08:00
公明 9b1e493023 Add files via upload 2026-05-01 01:05:48 +08:00
公明 77d212098d Add files via upload 2026-05-01 01:03:28 +08:00
公明 39926007fe Add files via upload 2026-05-01 01:01:30 +08:00
公明 0e35506ae1 Add files via upload 2026-05-01 01:00:23 +08:00
公明 9ff8bfa44b Add files via upload 2026-04-30 20:31:17 +08:00
公明 1d9fcfd87e Update version number to v1.5.16 2026-04-30 20:28:21 +08:00
公明 91cb650234 Add files via upload 2026-04-30 15:20:13 +08:00
公明 44e7d3b340 Add files via upload 2026-04-30 15:01:35 +08:00
公明 531b05299a Add files via upload 2026-04-30 10:49:19 +08:00
公明 0de69a6345 Add files via upload 2026-04-30 10:43:23 +08:00
28 changed files with 4281 additions and 1120 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
# ============================================ # ============================================
# 前端显示的版本号(可选,不填则显示默认版本) # 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.5.15" version: "v1.5.17"
# 服务器配置 # 服务器配置
server: server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口 host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
+38
View File
@@ -269,6 +269,8 @@ func (db *DB) initTables() error {
method TEXT NOT NULL DEFAULT 'post', method TEXT NOT NULL DEFAULT 'post',
cmd_param TEXT NOT NULL DEFAULT '', cmd_param TEXT NOT NULL DEFAULT '',
remark TEXT NOT NULL DEFAULT '', remark TEXT NOT NULL DEFAULT '',
encoding TEXT NOT NULL DEFAULT '',
os TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);` );`
@@ -402,6 +404,11 @@ func (db *DB) initTables() error {
// 不返回错误,允许继续运行 // 不返回错误,允许继续运行
} }
if err := db.migrateWebshellConnectionsTable(); err != nil {
db.logger.Warn("迁移webshell_connections表失败", zap.Error(err))
// 不返回错误,允许继续运行
}
if _, err := db.Exec(createIndexes); err != nil { if _, err := db.Exec(createIndexes); err != nil {
return fmt.Errorf("创建索引失败: %w", err) return fmt.Errorf("创建索引失败: %w", err)
} }
@@ -732,6 +739,37 @@ func (db *DB) migrateVulnerabilitiesTable() error {
return nil return nil
} }
// migrateWebshellConnectionsTable 迁移 webshell_connections 表,补充新字段
func (db *DB) migrateWebshellConnectionsTable() error {
columns := []struct {
name string
stmt string
}{
{name: "encoding", stmt: "ALTER TABLE webshell_connections ADD COLUMN encoding TEXT NOT NULL DEFAULT ''"},
{name: "os", stmt: "ALTER TABLE webshell_connections ADD COLUMN os TEXT NOT NULL DEFAULT ''"},
}
for _, col := range columns {
var count int
err := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('webshell_connections') WHERE name=?", col.name).Scan(&count)
if err != nil {
if _, addErr := db.Exec(col.stmt); addErr != nil {
errMsg := strings.ToLower(addErr.Error())
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
db.logger.Warn("添加webshell_connections字段失败", zap.String("field", col.name), zap.Error(addErr))
}
}
continue
}
if count == 0 {
if _, addErr := db.Exec(col.stmt); addErr != nil {
db.logger.Warn("添加webshell_connections字段失败", zap.String("field", col.name), zap.Error(addErr))
}
}
}
return nil
}
// NewKnowledgeDB 创建知识库数据库连接(只包含知识库相关的表) // NewKnowledgeDB 创建知识库数据库连接(只包含知识库相关的表)
func NewKnowledgeDB(dbPath string, logger *zap.Logger) (*DB, error) { func NewKnowledgeDB(dbPath string, logger *zap.Logger) (*DB, error) {
sqlDB, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=1&_busy_timeout=5000&_synchronous=NORMAL") sqlDB, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=1&_busy_timeout=5000&_synchronous=NORMAL")
+13 -9
View File
@@ -16,6 +16,8 @@ type WebShellConnection struct {
Method string `json:"method"` Method string `json:"method"`
CmdParam string `json:"cmdParam"` CmdParam string `json:"cmdParam"`
Remark string `json:"remark"` Remark string `json:"remark"`
Encoding string `json:"encoding"` // 目标响应编码:auto / utf-8 / gbk / gb18030,空值视为 auto
OS string `json:"os"` // 目标操作系统:auto / linux / windows,空值/未知视为 auto
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
} }
@@ -58,7 +60,8 @@ func (db *DB) UpsertWebshellConnectionState(connectionID, stateJSON string) erro
// ListWebshellConnections 列出所有 WebShell 连接,按创建时间倒序 // ListWebshellConnections 列出所有 WebShell 连接,按创建时间倒序
func (db *DB) ListWebshellConnections() ([]WebShellConnection, error) { func (db *DB) ListWebshellConnections() ([]WebShellConnection, error) {
query := ` query := `
SELECT id, url, password, type, method, cmd_param, remark, created_at SELECT id, url, password, type, method, cmd_param, remark,
COALESCE(encoding, '') AS encoding, COALESCE(os, '') AS os, created_at
FROM webshell_connections FROM webshell_connections
ORDER BY created_at DESC ORDER BY created_at DESC
` `
@@ -72,7 +75,7 @@ func (db *DB) ListWebshellConnections() ([]WebShellConnection, error) {
var list []WebShellConnection var list []WebShellConnection
for rows.Next() { for rows.Next() {
var c WebShellConnection var c WebShellConnection
err := rows.Scan(&c.ID, &c.URL, &c.Password, &c.Type, &c.Method, &c.CmdParam, &c.Remark, &c.CreatedAt) err := rows.Scan(&c.ID, &c.URL, &c.Password, &c.Type, &c.Method, &c.CmdParam, &c.Remark, &c.Encoding, &c.OS, &c.CreatedAt)
if err != nil { if err != nil {
db.logger.Warn("扫描 WebShell 连接行失败", zap.Error(err)) db.logger.Warn("扫描 WebShell 连接行失败", zap.Error(err))
continue continue
@@ -85,11 +88,12 @@ func (db *DB) ListWebshellConnections() ([]WebShellConnection, error) {
// GetWebshellConnection 根据 ID 获取一条连接 // GetWebshellConnection 根据 ID 获取一条连接
func (db *DB) GetWebshellConnection(id string) (*WebShellConnection, error) { func (db *DB) GetWebshellConnection(id string) (*WebShellConnection, error) {
query := ` query := `
SELECT id, url, password, type, method, cmd_param, remark, created_at SELECT id, url, password, type, method, cmd_param, remark,
COALESCE(encoding, '') AS encoding, COALESCE(os, '') AS os, created_at
FROM webshell_connections WHERE id = ? FROM webshell_connections WHERE id = ?
` `
var c WebShellConnection var c WebShellConnection
err := db.QueryRow(query, id).Scan(&c.ID, &c.URL, &c.Password, &c.Type, &c.Method, &c.CmdParam, &c.Remark, &c.CreatedAt) err := db.QueryRow(query, id).Scan(&c.ID, &c.URL, &c.Password, &c.Type, &c.Method, &c.CmdParam, &c.Remark, &c.Encoding, &c.OS, &c.CreatedAt)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
@@ -103,10 +107,10 @@ func (db *DB) GetWebshellConnection(id string) (*WebShellConnection, error) {
// CreateWebshellConnection 创建 WebShell 连接 // CreateWebshellConnection 创建 WebShell 连接
func (db *DB) CreateWebshellConnection(c *WebShellConnection) error { func (db *DB) CreateWebshellConnection(c *WebShellConnection) error {
query := ` query := `
INSERT INTO webshell_connections (id, url, password, type, method, cmd_param, remark, created_at) INSERT INTO webshell_connections (id, url, password, type, method, cmd_param, remark, encoding, os, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
` `
_, err := db.Exec(query, c.ID, c.URL, c.Password, c.Type, c.Method, c.CmdParam, c.Remark, c.CreatedAt) _, err := db.Exec(query, c.ID, c.URL, c.Password, c.Type, c.Method, c.CmdParam, c.Remark, c.Encoding, c.OS, c.CreatedAt)
if err != nil { if err != nil {
db.logger.Error("创建 WebShell 连接失败", zap.Error(err), zap.String("id", c.ID)) db.logger.Error("创建 WebShell 连接失败", zap.Error(err), zap.String("id", c.ID))
return err return err
@@ -118,10 +122,10 @@ func (db *DB) CreateWebshellConnection(c *WebShellConnection) error {
func (db *DB) UpdateWebshellConnection(c *WebShellConnection) error { func (db *DB) UpdateWebshellConnection(c *WebShellConnection) error {
query := ` query := `
UPDATE webshell_connections UPDATE webshell_connections
SET url = ?, password = ?, type = ?, method = ?, cmd_param = ?, remark = ? SET url = ?, password = ?, type = ?, method = ?, cmd_param = ?, remark = ?, encoding = ?, os = ?
WHERE id = ? WHERE id = ?
` `
result, err := db.Exec(query, c.URL, c.Password, c.Type, c.Method, c.CmdParam, c.Remark, c.ID) result, err := db.Exec(query, c.URL, c.Password, c.Type, c.Method, c.CmdParam, c.Remark, c.Encoding, c.OS, c.ID)
if err != nil { if err != nil {
db.logger.Error("更新 WebShell 连接失败", zap.Error(err), zap.String("id", c.ID)) db.logger.Error("更新 WebShell 连接失败", zap.Error(err), zap.String("id", c.ID))
return err return err
+2 -12
View File
@@ -539,12 +539,7 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "未找到该 WebShell 连接"}) c.JSON(http.StatusBadRequest, gin.H{"error": "未找到该 WebShell 连接"})
return return
} }
remark := conn.Remark webshellContext := BuildWebshellAssistantContext(conn, WebshellSkillHintDefault, req.Message)
if remark == "" {
remark = conn.URL
}
webshellContext := fmt.Sprintf("[WebShell 助手上下文] 当前连接 ID:%s,备注:%s。可用工具(仅在该连接上操作时使用,connection_id 填 \"%s\"):webshell_exec、webshell_file_list、webshell_file_read、webshell_file_write、record_vulnerability、list_knowledge_risk_types、search_knowledge_base。Skills 包请使用「多代理 / Eino DeepAgent」会话中的内置 `skill` 工具渐进加载。\n\n用户请求:%s",
conn.ID, remark, conn.ID, req.Message)
// WebShell 模式下如果同时指定了角色,追加角色 user_prompt(工具集仍仅限 webshell 专用工具) // WebShell 模式下如果同时指定了角色,追加角色 user_prompt(工具集仍仅限 webshell 专用工具)
if req.Role != "" && req.Role != "默认" && h.config.Roles != nil { if req.Role != "" && req.Role != "默认" && h.config.Roles != nil {
if role, exists := h.config.Roles[req.Role]; exists && role.Enabled && role.UserPrompt != "" { if role, exists := h.config.Roles[req.Role]; exists && role.Enabled && role.UserPrompt != "" {
@@ -1400,12 +1395,7 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
sendEvent("error", "未找到该 WebShell 连接", nil) sendEvent("error", "未找到该 WebShell 连接", nil)
return return
} }
remark := conn.Remark webshellContext := BuildWebshellAssistantContext(conn, WebshellSkillHintDefault, req.Message)
if remark == "" {
remark = conn.URL
}
webshellContext := fmt.Sprintf("[WebShell 助手上下文] 当前连接 ID:%s,备注:%s。可用工具(仅在该连接上操作时使用,connection_id 填 \"%s\"):webshell_exec、webshell_file_list、webshell_file_read、webshell_file_write、record_vulnerability、list_knowledge_risk_types、search_knowledge_base。Skills 包请使用「多代理 / Eino DeepAgent」会话中的内置 `skill` 工具渐进加载。\n\n用户请求:%s",
conn.ID, remark, conn.ID, req.Message)
// WebShell 模式下如果同时指定了角色,追加角色 user_prompt(工具集仍仅限 webshell 专用工具) // WebShell 模式下如果同时指定了角色,追加角色 user_prompt(工具集仍仅限 webshell 专用工具)
if req.Role != "" && req.Role != "默认" && h.config.Roles != nil { if req.Role != "" && req.Role != "默认" && h.config.Roles != nil {
if role, exists := h.config.Roles[req.Role]; exists && role.Enabled && role.UserPrompt != "" { if role, exists := h.config.Roles[req.Role]; exists && role.Enabled && role.UserPrompt != "" {
+1 -6
View File
@@ -73,12 +73,7 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest) (*multiAgentPr
h.logger.Warn("WebShell AI 助手:未找到连接", zap.String("id", req.WebShellConnectionID), zap.Error(errConn)) h.logger.Warn("WebShell AI 助手:未找到连接", zap.String("id", req.WebShellConnectionID), zap.Error(errConn))
return nil, fmt.Errorf("未找到该 WebShell 连接") return nil, fmt.Errorf("未找到该 WebShell 连接")
} }
remark := conn.Remark webshellContext := BuildWebshellAssistantContext(conn, WebshellSkillHintMultiAgent, req.Message)
if remark == "" {
remark = conn.URL
}
webshellContext := fmt.Sprintf("[WebShell 助手上下文] 当前连接 ID:%s,备注:%s。可用工具(仅在该连接上操作时使用,connection_id 填 \"%s\"):webshell_exec、webshell_file_list、webshell_file_read、webshell_file_write、record_vulnerability、list_knowledge_risk_types、search_knowledge_base。Skills 包请使用 Eino 多代理内置 `skill` 工具。\n\n用户请求:%s",
conn.ID, remark, conn.ID, req.Message)
// WebShell 模式下如果同时指定了角色,追加角色 user_prompt(工具集仍仅限 webshell 专用工具) // WebShell 模式下如果同时指定了角色,追加角色 user_prompt(工具集仍仅限 webshell 专用工具)
if req.Role != "" && req.Role != "默认" && h.config != nil && h.config.Roles != nil { if req.Role != "" && req.Role != "默认" && h.config != nil && h.config.Roles != nil {
if role, exists := h.config.Roles[req.Role]; exists && role.Enabled && role.UserPrompt != "" { if role, exists := h.config.Roles[req.Role]; exists && role.Enabled && role.UserPrompt != "" {
+369 -138
View File
@@ -3,20 +3,302 @@ package handler
import ( import (
"bytes" "bytes"
"database/sql" "database/sql"
"encoding/base64"
"encoding/json" "encoding/json"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"time" "time"
"unicode/utf8"
"cyberstrike-ai/internal/database" "cyberstrike-ai/internal/database"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"go.uber.org/zap" "go.uber.org/zap"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/transform"
) )
// webshellSupportedEncodings 允许的 WebShell 响应编码取值(小写,含空串代表 auto)
// 仅暴露目前最常见的几种,其他需求可后续扩展(如 Big5、Shift_JIS 等)。
var webshellSupportedEncodings = map[string]struct{}{
"": {}, // 未配置,按 auto 处理
"auto": {},
"utf-8": {},
"utf8": {},
"gbk": {},
"gb18030": {},
}
// normalizeWebshellEncoding 归一化编码标识:统一为小写,未知值回退为 auto,供持久化使用
func normalizeWebshellEncoding(enc string) string {
enc = strings.ToLower(strings.TrimSpace(enc))
if _, ok := webshellSupportedEncodings[enc]; !ok {
return "auto"
}
if enc == "" {
return "auto"
}
if enc == "utf8" {
return "utf-8"
}
return enc
}
// decodeWebshellOutput 把 WebShell 返回的字节按指定编码转换为合法 UTF-8 字符串。
// 约定:
// - "" / "auto":若已是合法 UTF-8 原样返回,否则依次尝试 GB18030(GBK 超集)解码。
// - "utf-8" / "utf8":原样返回,非法字节交由 JSON 层按 U+FFFD 处理(保持原有行为)。
// - "gbk" / "gb18030":强制按对应编码解码;失败则回退原始字节。
//
// 该函数对空输入直接返回空串,避免不必要的转换。
func decodeWebshellOutput(raw []byte, encoding string) string {
if len(raw) == 0 {
return ""
}
enc := normalizeWebshellEncoding(encoding)
switch enc {
case "utf-8":
return string(raw)
case "gbk":
if out, _, err := transform.Bytes(simplifiedchinese.GBK.NewDecoder(), raw); err == nil {
return string(out)
}
return string(raw)
case "gb18030":
if out, _, err := transform.Bytes(simplifiedchinese.GB18030.NewDecoder(), raw); err == nil {
return string(out)
}
return string(raw)
default: // auto
if utf8.Valid(raw) {
return string(raw)
}
// GB18030 是 GBK 的超集,覆盖范围最广,auto 模式统一用它兜底
if out, _, err := transform.Bytes(simplifiedchinese.GB18030.NewDecoder(), raw); err == nil {
return string(out)
}
return string(raw)
}
}
// webshellSupportedOS 允许的 WebShell 目标操作系统(小写,空串代表 auto)
var webshellSupportedOS = map[string]struct{}{
"": {},
"auto": {},
"linux": {},
"windows": {},
}
// normalizeWebshellOS 归一化 OS 标识,未知值回退为 auto,供持久化使用
func normalizeWebshellOS(osTag string) string {
osTag = strings.ToLower(strings.TrimSpace(osTag))
if _, ok := webshellSupportedOS[osTag]; !ok {
return "auto"
}
if osTag == "" {
return "auto"
}
return osTag
}
// resolveWebshellOS 根据连接的 os 与 shellType 推断最终目标 OS(仅返回 "linux" 或 "windows")。
// 规则:
// - 显式 linux / windows:按用户选择。
// - auto 或未知:asp/aspx → windows,其他 → linux。保持历史行为,平滑向后兼容。
func resolveWebshellOS(osTag, shellType string) string {
osTag = strings.ToLower(strings.TrimSpace(osTag))
switch osTag {
case "linux":
return "linux"
case "windows":
return "windows"
}
t := strings.ToLower(strings.TrimSpace(shellType))
if t == "asp" || t == "aspx" {
return "windows"
}
return "linux"
}
// quoteCmdPath 把路径按 Windows cmd.exe 规则转义。
// 使用双引号包裹,内部双引号转义为 ""(cmd 接受的写法)。
func quoteCmdPath(p string) string {
if p == "" {
return "\".\""
}
return "\"" + strings.ReplaceAll(p, "\"", "\"\"") + "\""
}
// quotePsSingle 把字符串按 PowerShell 单引号字符串规则转义(内部 ' → '')。
// 供 PowerShell 脚本参数使用,全脚本只用单引号,外层 cmd 再用双引号包裹即可安全传递。
func quotePsSingle(s string) string {
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}
// quoteShellSinglePosix 把路径按 POSIX sh 单引号规则转义(内部 ' → '\''
func quoteShellSinglePosix(p string) string {
if p == "" {
return "."
}
return "'" + strings.ReplaceAll(p, "'", "'\\''") + "'"
}
// quoteWebshellPath 按目标 OS 选择转义方案:Linux 用 POSIX 单引号,Windows 用 cmd 双引号
func quoteWebshellPath(path, osTag string) string {
if resolveWebshellOS(osTag, "") == "windows" {
return quoteCmdPath(path)
}
return quoteShellSinglePosix(path)
}
// buildWindowsPowerShellWrite 构造 Windows 端把 base64 内容一次性写入目标路径的 cmd 命令。
// 外层走 cmd.exe 的 powershell 调用,PowerShell 脚本里只用单引号字符串,避免嵌套引号陷阱。
func buildWindowsPowerShellWrite(path, b64 string) string {
script := "$b=[Convert]::FromBase64String(" + quotePsSingle(b64) + ");" +
"[IO.File]::WriteAllBytes(" + quotePsSingle(path) + ",$b)"
return "powershell -NoProfile -NonInteractive -Command \"" + script + "\""
}
// buildWindowsPowerShellAppend 构造 Windows 端把 base64 内容追加写入目标路径的 cmd 命令(用于分块上传)
func buildWindowsPowerShellAppend(path, b64 string) string {
script := "$b=[Convert]::FromBase64String(" + quotePsSingle(b64) + ");" +
"$f=[IO.File]::Open(" + quotePsSingle(path) + ",[IO.FileMode]::Append,[IO.FileAccess]::Write,[IO.FileShare]::None);" +
"try{$f.Write($b,0,$b.Length)}finally{$f.Close()}"
return "powershell -NoProfile -NonInteractive -Command \"" + script + "\""
}
// fileCommandInput 封装 buildFileCommand 的输入,避免长参数列表
type fileCommandInput struct {
Action string
Path string
TargetPath string
Content string
ChunkIndex int
OS string
ShellType string
}
// buildFileCommand 根据目标 OS 与文件操作类型生成具体的远端命令字符串。
// 同一份实现供 HTTP 入口(FileOp)与 MCP 入口(FileOpWithConnection)共用,避免双份维护。
// 返回值第二位是用户可见的业务错误(如 "path is required")。
func (h *WebShellHandler) buildFileCommand(in fileCommandInput) (string, error) {
targetOS := resolveWebshellOS(in.OS, in.ShellType)
action := strings.ToLower(strings.TrimSpace(in.Action))
path := strings.TrimSpace(in.Path)
switch action {
case "list":
p := path
if p == "" {
p = "."
}
if targetOS == "windows" {
return "dir /a " + quoteCmdPath(p), nil
}
return "ls -la " + quoteShellSinglePosix(p), nil
case "read":
if path == "" {
return "", errFileOpPathRequired
}
if targetOS == "windows" {
return "type " + quoteCmdPath(path), nil
}
return "cat " + quoteShellSinglePosix(path), nil
case "delete":
if path == "" {
return "", errFileOpPathRequired
}
if targetOS == "windows" {
return "del /q /f " + quoteCmdPath(path), nil
}
return "rm -f " + quoteShellSinglePosix(path), nil
case "mkdir":
if path == "" {
return "", errFileOpPathRequired
}
if targetOS == "windows" {
// cmd 的 md 默认会自动创建中间目录(等价于 Linux 的 mkdir -p
return "md " + quoteCmdPath(path), nil
}
return "mkdir -p " + quoteShellSinglePosix(path), nil
case "rename":
oldPath := path
newPath := strings.TrimSpace(in.TargetPath)
if oldPath == "" || newPath == "" {
return "", errFileOpRenameNeedsBothPaths
}
if targetOS == "windows" {
return "move /y " + quoteCmdPath(oldPath) + " " + quoteCmdPath(newPath), nil
}
return "mv -f " + quoteShellSinglePosix(oldPath) + " " + quoteShellSinglePosix(newPath), nil
case "write":
if path == "" {
return "", errFileOpPathRequired
}
// 统一策略:先把内容 base64 编码,再用目标平台对应方式解码写回,
// 这样既能写入任意二进制/含引号的文本,又避免各家 shell 的转义地狱。
b64 := base64.StdEncoding.EncodeToString([]byte(in.Content))
if targetOS == "windows" {
return buildWindowsPowerShellWrite(path, b64), nil
}
return "echo '" + b64 + "' | base64 -d > " + quoteShellSinglePosix(path), nil
case "upload":
if path == "" {
return "", errFileOpPathRequired
}
if len(in.Content) > 512*1024 {
return "", errFileOpUploadTooLarge
}
if targetOS == "windows" {
return buildWindowsPowerShellWrite(path, in.Content), nil
}
return "echo '" + in.Content + "' | base64 -d > " + quoteShellSinglePosix(path), nil
case "upload_chunk":
if path == "" {
return "", errFileOpPathRequired
}
if targetOS == "windows" {
if in.ChunkIndex == 0 {
return buildWindowsPowerShellWrite(path, in.Content), nil
}
return buildWindowsPowerShellAppend(path, in.Content), nil
}
redir := ">>"
if in.ChunkIndex == 0 {
redir = ">"
}
return "echo '" + in.Content + "' | base64 -d " + redir + " " + quoteShellSinglePosix(path), nil
}
return "", errFileOpUnsupportedAction(action)
}
// 业务错误常量,便于上层统一返回用户可见提示
var (
errFileOpPathRequired = simpleError("path is required")
errFileOpRenameNeedsBothPaths = simpleError("path and target_path are required for rename")
errFileOpUploadTooLarge = simpleError("upload content too large (max 512KB base64)")
)
func errFileOpUnsupportedAction(action string) error {
return simpleError("unsupported action: " + action)
}
// simpleError 是不带堆栈的轻量错误类型,供 buildFileCommand 报可预期的参数校验错误
type simpleError string
func (e simpleError) Error() string { return string(e) }
// WebShellHandler 代理执行 WebShell 命令(类似冰蝎/蚁剑),避免前端跨域并统一构建请求 // WebShellHandler 代理执行 WebShell 命令(类似冰蝎/蚁剑),避免前端跨域并统一构建请求
type WebShellHandler struct { type WebShellHandler struct {
logger *zap.Logger logger *zap.Logger
@@ -44,6 +326,8 @@ type CreateConnectionRequest struct {
Method string `json:"method"` Method string `json:"method"`
CmdParam string `json:"cmd_param"` CmdParam string `json:"cmd_param"`
Remark string `json:"remark"` Remark string `json:"remark"`
Encoding string `json:"encoding"`
OS string `json:"os"`
} }
// UpdateConnectionRequest 更新连接请求 // UpdateConnectionRequest 更新连接请求
@@ -54,6 +338,8 @@ type UpdateConnectionRequest struct {
Method string `json:"method"` Method string `json:"method"`
CmdParam string `json:"cmd_param"` CmdParam string `json:"cmd_param"`
Remark string `json:"remark"` Remark string `json:"remark"`
Encoding string `json:"encoding"`
OS string `json:"os"`
} }
// ListConnections 列出所有 WebShell 连接(GET /api/webshell/connections // ListConnections 列出所有 WebShell 连接(GET /api/webshell/connections
@@ -109,6 +395,8 @@ func (h *WebShellHandler) CreateConnection(c *gin.Context) {
Method: method, Method: method,
CmdParam: strings.TrimSpace(req.CmdParam), CmdParam: strings.TrimSpace(req.CmdParam),
Remark: strings.TrimSpace(req.Remark), Remark: strings.TrimSpace(req.Remark),
Encoding: normalizeWebshellEncoding(req.Encoding),
OS: normalizeWebshellOS(req.OS),
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
if err := h.db.CreateWebshellConnection(conn); err != nil { if err := h.db.CreateWebshellConnection(conn); err != nil {
@@ -159,6 +447,8 @@ func (h *WebShellHandler) UpdateConnection(c *gin.Context) {
Method: method, Method: method,
CmdParam: strings.TrimSpace(req.CmdParam), CmdParam: strings.TrimSpace(req.CmdParam),
Remark: strings.TrimSpace(req.Remark), Remark: strings.TrimSpace(req.Remark),
Encoding: normalizeWebshellEncoding(req.Encoding),
OS: normalizeWebshellOS(req.OS),
} }
if err := h.db.UpdateWebshellConnection(conn); err != nil { if err := h.db.UpdateWebshellConnection(conn); err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
@@ -331,6 +621,8 @@ type ExecRequest struct {
Type string `json:"type"` // php, asp, aspx, jsp, custom Type string `json:"type"` // php, asp, aspx, jsp, custom
Method string `json:"method"` // GET 或 POST,空则默认 POST Method string `json:"method"` // GET 或 POST,空则默认 POST
CmdParam string `json:"cmd_param"` // 命令参数名,如 cmd/xxx,空则默认 cmd CmdParam string `json:"cmd_param"` // 命令参数名,如 cmd/xxx,空则默认 cmd
Encoding string `json:"encoding"` // 响应编码:auto / utf-8 / gbk / gb18030,空则 auto
OS string `json:"os"` // 目标操作系统:auto / linux / windows,当前 exec 不用它,保留字段便于未来扩展
Command string `json:"command" binding:"required"` Command string `json:"command" binding:"required"`
} }
@@ -344,23 +636,27 @@ type ExecResponse struct {
// FileOpRequest 文件操作请求 // FileOpRequest 文件操作请求
type FileOpRequest struct { type FileOpRequest struct {
URL string `json:"url" binding:"required"` URL string `json:"url" binding:"required"`
Password string `json:"password"` Password string `json:"password"`
Type string `json:"type"` Type string `json:"type"`
Method string `json:"method"` // GET 或 POST,空则默认 POST Method string `json:"method"` // GET 或 POST,空则默认 POST
CmdParam string `json:"cmd_param"` // 命令参数名,如 cmd/xxx,空则默认 cmd CmdParam string `json:"cmd_param"` // 命令参数名,如 cmd/xxx,空则默认 cmd
Action string `json:"action" binding:"required"` // list, read, delete, write, mkdir, rename, upload, upload_chunk Encoding string `json:"encoding"` // 响应编码:auto / utf-8 / gbk / gb18030,空则 auto
Path string `json:"path"` OS string `json:"os"` // 目标操作系统:auto / linux / windows,空则按 shellType 推断
TargetPath string `json:"target_path"` // rename 时目标路径 ConnectionID string `json:"connection_id,omitempty"` // 可选:连接 ID;服务端探活出 OS 后会回写到此连接
Content string `json:"content"` // write/upload 时使用 Action string `json:"action" binding:"required"` // list, read, delete, write, mkdir, rename, upload, upload_chunk
ChunkIndex int `json:"chunk_index"` // upload_chunk 时,0 表示首块 Path string `json:"path"`
TargetPath string `json:"target_path"` // rename 时目标路径
Content string `json:"content"` // write/upload 时使用
ChunkIndex int `json:"chunk_index"` // upload_chunk 时,0 表示首块
} }
// FileOpResponse 文件操作响应 // FileOpResponse 文件操作响应
type FileOpResponse struct { type FileOpResponse struct {
OK bool `json:"ok"` OK bool `json:"ok"`
Output string `json:"output"` Output string `json:"output"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
DetectedOS string `json:"detected_os,omitempty"` // 仅在 auto 模式且探活成功时返回,前端应更新本地缓存
} }
func (h *WebShellHandler) Exec(c *gin.Context) { func (h *WebShellHandler) Exec(c *gin.Context) {
@@ -415,7 +711,7 @@ func (h *WebShellHandler) Exec(c *gin.Context) {
if readErr != nil { if readErr != nil {
h.logger.Warn("webshell exec read body", zap.Error(readErr)) h.logger.Warn("webshell exec read body", zap.Error(readErr))
} }
output := string(out) output := decodeWebshellOutput(out, req.Encoding)
httpCode := resp.StatusCode httpCode := resp.StatusCode
c.JSON(http.StatusOK, ExecResponse{ c.JSON(http.StatusOK, ExecResponse{
@@ -474,83 +770,32 @@ func (h *WebShellHandler) FileOp(c *gin.Context) {
return return
} }
// 通过执行系统命令实现文件操作(与通用一句话兼容) // 若 OS 未显式配置,先发一次探活命令,识别出真实 OS 再构造文件操作命令。
var command string // 这解决了 "Windows + PHP + OS=auto" 场景下旧 fallback 错发 `ls -la` 导致目录列不出来的问题。
shellType := strings.ToLower(strings.TrimSpace(req.Type)) osTag := req.OS
switch req.Action { detectedOS := ""
case "list": if normalizeWebshellOS(osTag) == "auto" {
path := strings.TrimSpace(req.Path) if probed := probeWebshellOSViaExec(h.newHTTPExecFn(req.URL, req.Password, req.Type, req.Method, req.CmdParam, req.Encoding)); probed != "" {
if path == "" { osTag = probed
path = "." detectedOS = probed
// 若前端带了 connection_id,顺带把探活结果持久化到该连接,后续刷新零成本
if cid := strings.TrimSpace(req.ConnectionID); cid != "" {
h.persistDetectedOS(cid, probed)
}
} }
if shellType == "asp" || shellType == "aspx" { }
command = "dir " + h.escapePath(path)
} else { command, cmdErr := h.buildFileCommand(fileCommandInput{
command = "ls -la " + h.escapePath(path) Action: req.Action,
} Path: req.Path,
case "read": TargetPath: req.TargetPath,
if shellType == "asp" || shellType == "aspx" { Content: req.Content,
command = "type " + h.escapePath(strings.TrimSpace(req.Path)) ChunkIndex: req.ChunkIndex,
} else { OS: osTag,
command = "cat " + h.escapePath(strings.TrimSpace(req.Path)) ShellType: req.Type,
} })
case "delete": if cmdErr != nil {
if shellType == "asp" || shellType == "aspx" { c.JSON(http.StatusBadRequest, FileOpResponse{OK: false, Error: cmdErr.Error()})
command = "del " + h.escapePath(strings.TrimSpace(req.Path))
} else {
command = "rm -f " + h.escapePath(strings.TrimSpace(req.Path))
}
case "write":
path := h.escapePath(strings.TrimSpace(req.Path))
command = "echo " + h.escapeForEcho(req.Content) + " > " + path
case "mkdir":
path := strings.TrimSpace(req.Path)
if path == "" {
c.JSON(http.StatusBadRequest, FileOpResponse{OK: false, Error: "path is required for mkdir"})
return
}
if shellType == "asp" || shellType == "aspx" {
command = "md " + h.escapePath(path)
} else {
command = "mkdir -p " + h.escapePath(path)
}
case "rename":
oldPath := strings.TrimSpace(req.Path)
newPath := strings.TrimSpace(req.TargetPath)
if oldPath == "" || newPath == "" {
c.JSON(http.StatusBadRequest, FileOpResponse{OK: false, Error: "path and target_path are required for rename"})
return
}
if shellType == "asp" || shellType == "aspx" {
command = "move /y " + h.escapePath(oldPath) + " " + h.escapePath(newPath)
} else {
command = "mv " + h.escapePath(oldPath) + " " + h.escapePath(newPath)
}
case "upload":
path := strings.TrimSpace(req.Path)
if path == "" {
c.JSON(http.StatusBadRequest, FileOpResponse{OK: false, Error: "path is required for upload"})
return
}
if len(req.Content) > 512*1024 {
c.JSON(http.StatusBadRequest, FileOpResponse{OK: false, Error: "upload content too large (max 512KB base64)"})
return
}
// base64 仅含 A-Za-z0-9+/=,用单引号包裹安全
command = "echo " + "'" + req.Content + "'" + " | base64 -d > " + h.escapePath(path)
case "upload_chunk":
path := strings.TrimSpace(req.Path)
if path == "" {
c.JSON(http.StatusBadRequest, FileOpResponse{OK: false, Error: "path is required for upload_chunk"})
return
}
redir := ">>"
if req.ChunkIndex == 0 {
redir = ">"
}
command = "echo " + "'" + req.Content + "'" + " | base64 -d " + redir + " " + h.escapePath(path)
default:
c.JSON(http.StatusBadRequest, FileOpResponse{OK: false, Error: "unsupported action: " + req.Action})
return return
} }
@@ -585,27 +830,15 @@ func (h *WebShellHandler) FileOp(c *gin.Context) {
if readErr != nil { if readErr != nil {
h.logger.Warn("webshell fileop read body", zap.Error(readErr)) h.logger.Warn("webshell fileop read body", zap.Error(readErr))
} }
output := string(out) output := decodeWebshellOutput(out, req.Encoding)
c.JSON(http.StatusOK, FileOpResponse{ c.JSON(http.StatusOK, FileOpResponse{
OK: resp.StatusCode == http.StatusOK, OK: resp.StatusCode == http.StatusOK,
Output: output, Output: output,
DetectedOS: detectedOS,
}) })
} }
func (h *WebShellHandler) escapePath(p string) string {
if p == "" {
return "."
}
// 简单转义空格与敏感字符,避免命令注入
return "'" + strings.ReplaceAll(p, "'", "'\\''") + "'"
}
func (h *WebShellHandler) escapeForEcho(s string) string {
// 仅用于 write:base64 写入更安全,这里简单用单引号包裹
return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'"
}
// ExecWithConnection 在指定 WebShell 连接上执行命令(供 MCP/Agent 等非 HTTP 调用) // ExecWithConnection 在指定 WebShell 连接上执行命令(供 MCP/Agent 等非 HTTP 调用)
func (h *WebShellHandler) ExecWithConnection(conn *database.WebShellConnection, command string) (output string, ok bool, errMsg string) { func (h *WebShellHandler) ExecWithConnection(conn *database.WebShellConnection, command string) (output string, ok bool, errMsg string) {
if conn == nil { if conn == nil {
@@ -643,7 +876,7 @@ func (h *WebShellHandler) ExecWithConnection(conn *database.WebShellConnection,
if readErr != nil { if readErr != nil {
h.logger.Warn("webshell ExecWithConnection read body", zap.Error(readErr)) h.logger.Warn("webshell ExecWithConnection read body", zap.Error(readErr))
} }
return string(out), resp.StatusCode == http.StatusOK, "" return decodeWebshellOutput(out, conn.Encoding), resp.StatusCode == http.StatusOK, ""
} }
// FileOpWithConnection 在指定 WebShell 连接上执行文件操作(供 MCP/Agent 调用),支持 list / read / write // FileOpWithConnection 在指定 WebShell 连接上执行文件操作(供 MCP/Agent 调用),支持 list / read / write
@@ -652,40 +885,38 @@ func (h *WebShellHandler) FileOpWithConnection(conn *database.WebShellConnection
return "", false, "connection is nil" return "", false, "connection is nil"
} }
action = strings.ToLower(strings.TrimSpace(action)) action = strings.ToLower(strings.TrimSpace(action))
shellType := strings.ToLower(strings.TrimSpace(conn.Type)) // MCP 入口仅开放 list / read / write 三种动作,与工具文档的承诺保持一致
if shellType == "" {
shellType = "php"
}
var command string
switch action { switch action {
case "list": case "list", "read", "write":
if path == "" { // 支持的动作
path = "."
}
if shellType == "asp" || shellType == "aspx" {
command = "dir " + h.escapePath(strings.TrimSpace(path))
} else {
command = "ls -la " + h.escapePath(strings.TrimSpace(path))
}
case "read":
path = strings.TrimSpace(path)
if path == "" {
return "", false, "path is required for read"
}
if shellType == "asp" || shellType == "aspx" {
command = "type " + h.escapePath(path)
} else {
command = "cat " + h.escapePath(path)
}
case "write":
path = strings.TrimSpace(path)
if path == "" {
return "", false, "path is required for write"
}
command = "echo " + h.escapeForEcho(content) + " > " + h.escapePath(path)
default: default:
return "", false, "unsupported action: " + action + " (supported: list, read, write)" return "", false, "unsupported action: " + action + " (supported: list, read, write)"
} }
// 若连接的 OS 为 auto,先探活并持久化,避免 AI/MCP 每次都对 Windows 发 `ls -la`
osTag := conn.OS
if normalizeWebshellOS(osTag) == "auto" {
if probed := probeWebshellOSViaExec(func(cmd string) (string, bool) {
out, exOk, _ := h.ExecWithConnection(conn, cmd)
return out, exOk
}); probed != "" {
osTag = probed
conn.OS = probed // 本次请求内使用探活结果
h.persistDetectedOS(conn.ID, probed)
}
}
command, cmdErr := h.buildFileCommand(fileCommandInput{
Action: action,
Path: path,
TargetPath: targetPath,
Content: content,
OS: osTag,
ShellType: conn.Type,
})
if cmdErr != nil {
return "", false, cmdErr.Error()
}
useGET := strings.ToUpper(strings.TrimSpace(conn.Method)) == "GET" useGET := strings.ToUpper(strings.TrimSpace(conn.Method)) == "GET"
cmdParam := strings.TrimSpace(conn.CmdParam) cmdParam := strings.TrimSpace(conn.CmdParam)
if cmdParam == "" { if cmdParam == "" {
@@ -714,5 +945,5 @@ func (h *WebShellHandler) FileOpWithConnection(conn *database.WebShellConnection
if readErr != nil { if readErr != nil {
h.logger.Warn("webshell FileOpWithConnection read body", zap.Error(readErr)) h.logger.Warn("webshell FileOpWithConnection read body", zap.Error(readErr))
} }
return string(out), resp.StatusCode == http.StatusOK, "" return decodeWebshellOutput(out, conn.Encoding), resp.StatusCode == http.StatusOK, ""
} }
+106
View File
@@ -0,0 +1,106 @@
package handler
import (
"strings"
"cyberstrike-ai/internal/database"
)
// WebshellSkillHintDefault 对话页 / Eino 单代理共用的 Skills 说明,放在 webshell 上下文末尾,
// 供 AI 选择 skill 加载入口时参考。
const WebshellSkillHintDefault = "Skills 包请使用「多代理 / Eino DeepAgent」会话中的内置 `skill` 工具渐进加载。"
// WebshellSkillHintMultiAgent 多代理 / Eino 多代理准备阶段使用的 Skills 说明
const WebshellSkillHintMultiAgent = "Skills 包请使用 Eino 多代理内置 `skill` 工具。"
// webshellAssistantToolList AI 助手在 WebShell 上下文下允许使用的工具清单(展示给模型用)。
// 注意:此处只是展示字符串,真正的权限限制是在调用方设置的 roleTools 切片里。
const webshellAssistantToolList = "webshell_exec、webshell_file_list、webshell_file_read、webshell_file_write、record_vulnerability、list_knowledge_risk_types、search_knowledge_base"
// BuildWebshellAssistantContext 根据连接信息与用户原始消息组装 AI 助手的上下文提示词。
// 上下文包含:连接 ID、备注、目标系统(及对应命令集建议)、响应编码、可用工具清单、Skills 加载入口、
// 以及最终的用户请求。调用方只需要决定 skillHint 的文案(默认使用 WebshellSkillHintDefault)。
//
// 之所以把这段逻辑抽到共享函数里,是为了避免 agent.go / multi_agent_prepare.go 等多处复制粘贴,
// 并确保当我们升级 OS / Encoding 文案时只需要改一处、测一处、同步生效。
func BuildWebshellAssistantContext(conn *database.WebShellConnection, skillHint, userMsg string) string {
if conn == nil {
// 兜底:调用方已保证 conn 非 nil,这里只是防御性返回原消息
return userMsg
}
remark := conn.Remark
if remark == "" {
remark = conn.URL
}
targetOS := resolveWebshellOS(conn.OS, conn.Type) // 归一为 "linux" / "windows"
encoding := normalizeWebshellEncoding(conn.Encoding)
if skillHint == "" {
skillHint = WebshellSkillHintDefault
}
var b strings.Builder
b.Grow(512 + len(userMsg))
b.WriteString("[WebShell 助手上下文] 连接 ID")
b.WriteString(conn.ID)
b.WriteString(",备注:")
b.WriteString(remark)
b.WriteByte('\n')
// 目标系统:明确告诉 AI 能用/不能用的命令集,避免它对着 Windows 发 ls/cat/rm
b.WriteString("- 目标系统:")
b.WriteString(describeTargetOSForPrompt(targetOS))
b.WriteByte('\n')
// 响应编码:仅在非 auto 时显式告知,auto 模式由后端自适应,不打扰模型
if encHint := describeEncodingForPrompt(encoding); encHint != "" {
b.WriteString("- 响应编码:")
b.WriteString(encHint)
b.WriteByte('\n')
}
// 工具清单 & connection_id 约束:保持旧有表达,AI 已熟悉
b.WriteString("可用工具(仅在该连接上操作时使用,connection_id 填 \"")
b.WriteString(conn.ID)
b.WriteString("\"):")
b.WriteString(webshellAssistantToolList)
b.WriteString("。")
b.WriteString(skillHint)
b.WriteString("\n\n用户请求:")
b.WriteString(userMsg)
return b.String()
}
// describeTargetOSForPrompt 返回某个 OS 对应的中文描述 + 推荐命令集 + 反例,
// 命令列表覆盖文件管理最常用的 6 类动作(查看/读/删/改名/建目录/查找),让 AI 能直接照抄。
func describeTargetOSForPrompt(targetOS string) string {
switch targetOS {
case "windows":
return "Windows(推荐 cmd/PowerShelldir /a、type、del /q /f、move /y、md、ren" +
"查找文件用 `dir /s /b 过滤词` 或 PowerShell `Get-ChildItem -Recurse`" +
"避免 ls / cat / rm / mv / find 等 Unix 命令,否则将返回 `不是内部或外部命令`)"
case "linux":
return "Linux/Unix(推荐 sh/bashls -la、cat、rm -f、mv、mkdir -p" +
"查找文件用 `find /path -name '*pattern*'`" +
"避免 dir、type、del、move 等 Windows 命令)"
default:
// 理论上不会走到这里,resolveWebshellOS 已经兜底
return "未知(请先执行 `uname || ver` 探测再决定命令集)"
}
}
// describeEncodingForPrompt 返回响应编码的人类可读描述;auto 返回空串以减少 token。
func describeEncodingForPrompt(encoding string) string {
switch encoding {
case "utf-8":
return "UTF-8(目标原生 UTF-8,无需额外解码)"
case "gbk":
return "GBK(中文 Windows;后端已自动转码为 UTF-8 返回,若仍出现大量 \\uFFFD 替换字符说明命令失败或编码识别错误)"
case "gb18030":
return "GB18030(后端已自动转码为 UTF-8 返回)"
default:
return ""
}
}
+170
View File
@@ -0,0 +1,170 @@
package handler
import (
"strings"
"testing"
"cyberstrike-ai/internal/database"
)
func TestBuildWebshellAssistantContext_WindowsExplicit(t *testing.T) {
conn := &database.WebShellConnection{
ID: "ws_win01",
Remark: "IIS Windows 靶机",
URL: "http://example.com/shell.php",
Type: "php",
OS: "windows",
Encoding: "gbk",
}
got := BuildWebshellAssistantContext(conn, WebshellSkillHintDefault, "列出当前目录并告诉我 flag 在哪")
mustContain(t, got,
"[WebShell 助手上下文]",
"ws_win01",
"IIS Windows 靶机",
"目标系统:Windows",
"dir /a",
"move /y",
"避免 ls / cat / rm",
"响应编码:GBK",
"后端已自动转码为 UTF-8",
"connection_id 填 \"ws_win01\"",
"webshell_exec、webshell_file_list",
WebshellSkillHintDefault,
"用户请求:列出当前目录并告诉我 flag 在哪",
)
// Windows 场景下不应出现 Linux 命令推荐
mustNotContain(t, got, "推荐 sh/bash")
}
func TestBuildWebshellAssistantContext_LinuxAutoFromPHP(t *testing.T) {
conn := &database.WebShellConnection{
ID: "ws_lnx01",
Remark: "", // 测试备注为空时 fallback URL
URL: "http://example.com/a.php",
Type: "php",
OS: "auto", // auto + php → linux
Encoding: "", // auto 编码不显式提示
}
got := BuildWebshellAssistantContext(conn, WebshellSkillHintDefault, "看看 /etc/passwd")
mustContain(t, got,
"连接 IDws_lnx01",
"备注:http://example.com/a.php", // 备注空时 fallback URL
"目标系统:Linux/Unix",
"ls -la",
"mkdir -p",
"避免 dir、type、del、move",
"用户请求:看看 /etc/passwd",
)
// encoding=auto 不应出现"响应编码:"这一行
mustNotContain(t, got, "响应编码:")
// Linux 场景不应出现 Windows 命令
mustNotContain(t, got, "推荐 cmd/PowerShell")
}
func TestBuildWebshellAssistantContext_AutoFromASPDefaultsToWindows(t *testing.T) {
// 保留向后兼容:旧连接没配 os,shellType=asp 时应视为 Windows
conn := &database.WebShellConnection{
ID: "ws_asp01",
Remark: "老 ASP 靶机",
Type: "asp",
OS: "", // 空串等同 auto
Encoding: "gb18030",
}
got := BuildWebshellAssistantContext(conn, WebshellSkillHintMultiAgent, "查当前用户")
mustContain(t, got,
"目标系统:Windows",
"响应编码:GB18030",
"后端已自动转码为 UTF-8 返回",
WebshellSkillHintMultiAgent,
)
// 多代理 skill 文案里没有 DeepAgent,不应混入 default 文案
mustNotContain(t, got, "DeepAgent")
}
func TestBuildWebshellAssistantContext_MultiAgentSkillHint(t *testing.T) {
conn := &database.WebShellConnection{ID: "ws_m1", Remark: "x", Type: "php", OS: "linux"}
got := BuildWebshellAssistantContext(conn, WebshellSkillHintMultiAgent, "hi")
mustContain(t, got, WebshellSkillHintMultiAgent)
mustNotContain(t, got, "DeepAgent")
}
func TestBuildWebshellAssistantContext_DefaultSkillHintFallback(t *testing.T) {
conn := &database.WebShellConnection{ID: "ws_d1", Remark: "x", Type: "php", OS: "linux"}
// skillHint 传空字符串时应回退到 default
got := BuildWebshellAssistantContext(conn, "", "hi")
mustContain(t, got, WebshellSkillHintDefault)
}
func TestBuildWebshellAssistantContext_UTF8EncodingIsAnnotated(t *testing.T) {
conn := &database.WebShellConnection{
ID: "ws_u1", Remark: "u", Type: "jsp", OS: "linux", Encoding: "utf-8",
}
got := BuildWebshellAssistantContext(conn, WebshellSkillHintDefault, "hi")
mustContain(t, got, "响应编码:UTF-8", "目标原生 UTF-8")
}
func TestBuildWebshellAssistantContext_NilConnReturnsUserMsg(t *testing.T) {
// 防御性:conn == nil 时不 panic,直接返回原消息
got := BuildWebshellAssistantContext(nil, WebshellSkillHintDefault, "just the message")
if got != "just the message" {
t.Errorf("nil conn should return userMsg as-is, got %q", got)
}
}
func TestDescribeTargetOSForPrompt(t *testing.T) {
cases := map[string][]string{
"windows": {"Windows", "dir /a", "move /y", "PowerShell"},
"linux": {"Linux/Unix", "ls -la", "mkdir -p"},
"": {"未知", "uname"}, // 防御性分支
}
for in, wants := range cases {
got := describeTargetOSForPrompt(in)
for _, w := range wants {
if !strings.Contains(got, w) {
t.Errorf("describeTargetOSForPrompt(%q) should contain %q, got: %s", in, w, got)
}
}
}
}
func TestDescribeEncodingForPrompt(t *testing.T) {
cases := map[string]string{
"utf-8": "UTF-8",
"gbk": "GBK",
"gb18030": "GB18030",
"auto": "",
"": "",
}
for in, want := range cases {
got := describeEncodingForPrompt(in)
if want == "" && got != "" {
t.Errorf("describeEncodingForPrompt(%q) should return empty string, got: %s", in, got)
}
if want != "" && !strings.Contains(got, want) {
t.Errorf("describeEncodingForPrompt(%q) should contain %q, got: %s", in, want, got)
}
}
}
// ---- 小工具 ----
func mustContain(t *testing.T, text string, substrings ...string) {
t.Helper()
for _, s := range substrings {
if !strings.Contains(text, s) {
t.Errorf("expected text to contain %q\n--- text ---\n%s", s, text)
}
}
}
func mustNotContain(t *testing.T, text string, substrings ...string) {
t.Helper()
for _, s := range substrings {
if strings.Contains(text, s) {
t.Errorf("text should not contain %q\n--- text ---\n%s", s, text)
}
}
}
+103
View File
@@ -0,0 +1,103 @@
package handler
import (
"testing"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/transform"
)
// mustEncode 使用指定编码对 UTF-8 字符串做编码,得到原始字节,用于构造测试输入
func mustEncode(t *testing.T, s string, enc string) []byte {
t.Helper()
var tr transform.Transformer
switch enc {
case "gbk":
tr = simplifiedchinese.GBK.NewEncoder()
case "gb18030":
tr = simplifiedchinese.GB18030.NewEncoder()
default:
t.Fatalf("unsupported test encoding: %s", enc)
}
out, _, err := transform.Bytes(tr, []byte(s))
if err != nil {
t.Fatalf("mustEncode(%s) failed: %v", enc, err)
}
return out
}
func TestNormalizeWebshellEncoding(t *testing.T) {
cases := map[string]string{
"": "auto",
" ": "auto",
"auto": "auto",
"AUTO": "auto",
"utf-8": "utf-8",
"UTF-8": "utf-8",
"utf8": "utf-8",
"gbk": "gbk",
"GBK": "gbk",
"gb18030": "gb18030",
"big5": "auto", // 未支持的回退到 auto
"anything": "auto",
}
for in, want := range cases {
if got := normalizeWebshellEncoding(in); got != want {
t.Errorf("normalizeWebshellEncoding(%q) = %q, want %q", in, got, want)
}
}
}
func TestDecodeWebshellOutput_AutoDetectsGBK(t *testing.T) {
// 模拟 Windows 中文 cmd 输出的 GBK 字节流
want := "用户名 SID 类型"
raw := mustEncode(t, want, "gbk")
// auto 模式:UTF-8 校验失败后应当回退 GB18030 解码,得到原始中文
got := decodeWebshellOutput(raw, "auto")
if got != want {
t.Errorf("decodeWebshellOutput(auto) = %q, want %q", got, want)
}
// 显式 GBK 模式:同样应当正确解码
got = decodeWebshellOutput(raw, "gbk")
if got != want {
t.Errorf("decodeWebshellOutput(gbk) = %q, want %q", got, want)
}
// 显式 GB18030 模式:GBK 是 GB18030 子集,也应正确解码
got = decodeWebshellOutput(raw, "gb18030")
if got != want {
t.Errorf("decodeWebshellOutput(gb18030) = %q, want %q", got, want)
}
}
func TestDecodeWebshellOutput_PassthroughUTF8(t *testing.T) {
// 已经是 UTF-8 的中文字符串,各模式都应返回原串(不破坏)
want := "hello 世界"
for _, enc := range []string{"", "auto", "utf-8"} {
if got := decodeWebshellOutput([]byte(want), enc); got != want {
t.Errorf("decodeWebshellOutput(%q) passthrough = %q, want %q", enc, got, want)
}
}
}
func TestDecodeWebshellOutput_ASCIIStable(t *testing.T) {
// 纯 ASCII 在任何模式下都必须保持原样
want := "whoami\nAdministrator\n"
for _, enc := range []string{"", "auto", "utf-8", "gbk", "gb18030"} {
if got := decodeWebshellOutput([]byte(want), enc); got != want {
t.Errorf("decodeWebshellOutput(%q) ASCII = %q, want %q", enc, got, want)
}
}
}
func TestDecodeWebshellOutput_EmptyInput(t *testing.T) {
// 空输入直接返回空串,不做额外分配
if got := decodeWebshellOutput(nil, "gbk"); got != "" {
t.Errorf("decodeWebshellOutput(nil) = %q, want empty", got)
}
if got := decodeWebshellOutput([]byte{}, "auto"); got != "" {
t.Errorf("decodeWebshellOutput([]) = %q, want empty", got)
}
}
+348
View File
@@ -0,0 +1,348 @@
package handler
import (
"encoding/base64"
"strings"
"testing"
"go.uber.org/zap"
)
func newTestWebShellHandler() *WebShellHandler {
return NewWebShellHandler(zap.NewNop(), nil)
}
func TestNormalizeWebshellOS(t *testing.T) {
cases := map[string]string{
"": "auto",
" ": "auto",
"auto": "auto",
"AUTO": "auto",
"linux": "linux",
"Linux": "linux",
"windows": "windows",
"WINDOWS": "windows",
"macos": "auto", // 未支持的回退 auto
"solaris": "auto",
}
for in, want := range cases {
if got := normalizeWebshellOS(in); got != want {
t.Errorf("normalizeWebshellOS(%q) = %q, want %q", in, got, want)
}
}
}
func TestResolveWebshellOS(t *testing.T) {
type testCase struct {
osTag string
shellType string
want string
}
cases := []testCase{
// 显式 OS:按用户选择,忽略 shellType
{"linux", "asp", "linux"},
{"windows", "php", "windows"},
{"LINUX", "jsp", "linux"},
// auto + 各种 shellTypeasp/aspx → windows,其他 → linux
{"auto", "asp", "windows"},
{"auto", "aspx", "windows"},
{"auto", "ASP", "windows"},
{"auto", "php", "linux"},
{"auto", "jsp", "linux"},
{"auto", "custom", "linux"},
{"auto", "", "linux"},
// 空/未知 OS 等价 auto
{"", "asp", "windows"},
{"", "php", "linux"},
{"unknown", "aspx", "windows"},
}
for _, c := range cases {
got := resolveWebshellOS(c.osTag, c.shellType)
if got != c.want {
t.Errorf("resolveWebshellOS(%q,%q) = %q, want %q", c.osTag, c.shellType, got, c.want)
}
}
}
func TestQuoteCmdPath(t *testing.T) {
cases := map[string]string{
"": `"."`,
`C:\Windows\Temp`: `"C:\Windows\Temp"`,
`C:\Program Files\a`: `"C:\Program Files\a"`,
`C:\weird"name\f.txt`: `"C:\weird""name\f.txt"`,
`.`: `"."`,
}
for in, want := range cases {
if got := quoteCmdPath(in); got != want {
t.Errorf("quoteCmdPath(%q) = %q, want %q", in, got, want)
}
}
}
func TestQuoteShellSinglePosix(t *testing.T) {
cases := map[string]string{
"": ".",
"/tmp/a b": "'/tmp/a b'",
"/tmp/it's.txt": `'/tmp/it'\''s.txt'`,
}
for in, want := range cases {
if got := quoteShellSinglePosix(in); got != want {
t.Errorf("quoteShellSinglePosix(%q) = %q, want %q", in, got, want)
}
}
}
// TestBuildFileCommand_LinuxBranch 覆盖 Linux 目标下每个 action 产出的命令
func TestBuildFileCommand_LinuxBranch(t *testing.T) {
h := newTestWebShellHandler()
base := fileCommandInput{OS: "linux", ShellType: "php"}
mustContain := func(t *testing.T, cmd string, substrings ...string) {
t.Helper()
for _, s := range substrings {
if !strings.Contains(cmd, s) {
t.Errorf("expected command to contain %q, got: %s", s, cmd)
}
}
}
mustNotContain := func(t *testing.T, cmd string, substrings ...string) {
t.Helper()
for _, s := range substrings {
if strings.Contains(cmd, s) {
t.Errorf("command should not contain %q, got: %s", s, cmd)
}
}
}
// list with empty path defaults to '.'
in := base
in.Action = "list"
cmd, err := h.buildFileCommand(in)
if err != nil {
t.Fatalf("list linux: unexpected err: %v", err)
}
mustContain(t, cmd, "ls -la", "'.'")
// list with path containing spaces
in.Path = "/tmp/my files"
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "ls -la ", "'/tmp/my files'")
// read with path
in = base
in.Action = "read"
in.Path = "/etc/passwd"
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "cat ", "'/etc/passwd'")
// read without path → error
in.Path = ""
if _, err := h.buildFileCommand(in); err != errFileOpPathRequired {
t.Errorf("read empty path: want errFileOpPathRequired, got %v", err)
}
// delete
in = base
in.Action = "delete"
in.Path = "/tmp/a.txt"
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "rm -f ", "'/tmp/a.txt'")
mustNotContain(t, cmd, "del")
// mkdir
in.Action = "mkdir"
in.Path = "/tmp/new/sub"
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "mkdir -p ", "'/tmp/new/sub'")
// rename
in = base
in.Action = "rename"
in.Path = "/tmp/a"
in.TargetPath = "/tmp/b"
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "mv -f ", "'/tmp/a'", "'/tmp/b'")
// rename missing target → error
in.TargetPath = ""
if _, err := h.buildFileCommand(in); err != errFileOpRenameNeedsBothPaths {
t.Errorf("rename empty target: want errFileOpRenameNeedsBothPaths, got %v", err)
}
// write
in = base
in.Action = "write"
in.Path = "/tmp/w.txt"
in.Content = "hello 世界"
cmd, _ = h.buildFileCommand(in)
b64 := base64.StdEncoding.EncodeToString([]byte("hello 世界"))
mustContain(t, cmd, "echo '"+b64+"'", "| base64 -d", "> '/tmp/w.txt'")
// upload
in = base
in.Action = "upload"
in.Path = "/tmp/bin"
in.Content = "YWJjZA==" // base64 of "abcd"
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "echo 'YWJjZA=='", "| base64 -d", "> '/tmp/bin'")
// upload oversized content → error
in.Content = strings.Repeat("A", 513*1024)
if _, err := h.buildFileCommand(in); err != errFileOpUploadTooLarge {
t.Errorf("upload too large: want errFileOpUploadTooLarge, got %v", err)
}
// upload_chunk with chunk_index=0 uses single redirect
in = base
in.Action = "upload_chunk"
in.Path = "/tmp/bin"
in.Content = "YWJj"
in.ChunkIndex = 0
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "base64 -d > '/tmp/bin'")
mustNotContain(t, cmd, ">>")
// upload_chunk with chunk_index>0 uses append redirect
in.ChunkIndex = 1
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "base64 -d >> '/tmp/bin'")
// unsupported action
in = base
in.Action = "nope"
if _, err := h.buildFileCommand(in); err == nil || !strings.Contains(err.Error(), "unsupported action") {
t.Errorf("unknown action: want unsupported action error, got %v", err)
}
}
// TestBuildFileCommand_WindowsBranch 覆盖 Windows 目标下每个 action 产出的命令
func TestBuildFileCommand_WindowsBranch(t *testing.T) {
h := newTestWebShellHandler()
base := fileCommandInput{OS: "windows", ShellType: "php"}
mustContain := func(t *testing.T, cmd string, substrings ...string) {
t.Helper()
for _, s := range substrings {
if !strings.Contains(cmd, s) {
t.Errorf("expected command to contain %q, got: %s", s, cmd)
}
}
}
mustNotContain := func(t *testing.T, cmd string, substrings ...string) {
t.Helper()
for _, s := range substrings {
if strings.Contains(cmd, s) {
t.Errorf("command should not contain %q, got: %s", s, cmd)
}
}
}
// list
in := base
in.Action = "list"
cmd, _ := h.buildFileCommand(in)
mustContain(t, cmd, "dir /a ", `"."`)
mustNotContain(t, cmd, "ls -la")
in.Path = `C:\Users\Public Docs`
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "dir /a ", `"C:\Users\Public Docs"`)
// read
in = base
in.Action = "read"
in.Path = `C:\flag.txt`
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "type ", `"C:\flag.txt"`)
// delete
in.Action = "delete"
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "del /q /f ", `"C:\flag.txt"`)
mustNotContain(t, cmd, "rm -f")
// mkdir
in.Action = "mkdir"
in.Path = `C:\a\b\c`
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "md ", `"C:\a\b\c"`)
// rename
in = base
in.Action = "rename"
in.Path = `C:\a.txt`
in.TargetPath = `C:\b.txt`
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "move /y ", `"C:\a.txt"`, `"C:\b.txt"`)
// write → PowerShell base64 one-liner
in = base
in.Action = "write"
in.Path = `C:\out.txt`
in.Content = "hello 世界"
cmd, _ = h.buildFileCommand(in)
wantB64 := base64.StdEncoding.EncodeToString([]byte("hello 世界"))
mustContain(t, cmd,
"powershell -NoProfile -NonInteractive -Command",
"[Convert]::FromBase64String('"+wantB64+"')",
"[IO.File]::WriteAllBytes('C:\\out.txt'",
)
mustNotContain(t, cmd, "echo ", "base64 -d")
// upload (chunk_index=0 equivalent) uses WriteAllBytes
in = base
in.Action = "upload"
in.Path = `C:\bin\f`
in.Content = "YWJjZA=="
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "WriteAllBytes('C:\\bin\\f'", "FromBase64String('YWJjZA==')")
// upload_chunk index=0 → WriteAllBytes
in.Action = "upload_chunk"
in.ChunkIndex = 0
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "WriteAllBytes(")
mustNotContain(t, cmd, "FileMode]::Append")
// upload_chunk index>0 → append (Open with Append mode)
in.ChunkIndex = 1
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "[IO.FileMode]::Append", "FromBase64String('YWJjZA==')")
}
// TestBuildFileCommand_AutoFallbackMatchesLegacyBehavior 确保 os=auto 时与旧版 shellType 判定行为完全一致
// asp/aspx 视为 Windows(旧行为),其他视为 Linux。
func TestBuildFileCommand_AutoFallbackMatchesLegacyBehavior(t *testing.T) {
h := newTestWebShellHandler()
// asp + auto → windows 命令
cmd, _ := h.buildFileCommand(fileCommandInput{Action: "list", OS: "auto", ShellType: "asp"})
if !strings.Contains(cmd, "dir /a") {
t.Errorf("auto + asp should use Windows cmd, got: %s", cmd)
}
cmd, _ = h.buildFileCommand(fileCommandInput{Action: "list", OS: "auto", ShellType: "aspx"})
if !strings.Contains(cmd, "dir /a") {
t.Errorf("auto + aspx should use Windows cmd, got: %s", cmd)
}
// php/jsp/custom + auto → linux 命令(与历史行为一致)
for _, st := range []string{"php", "jsp", "custom", ""} {
cmd, _ = h.buildFileCommand(fileCommandInput{Action: "list", OS: "auto", ShellType: st})
if !strings.Contains(cmd, "ls -la") {
t.Errorf("auto + %q should use Linux cmd, got: %s", st, cmd)
}
}
// 显式 OS 覆盖 shellType
cmd, _ = h.buildFileCommand(fileCommandInput{Action: "list", OS: "windows", ShellType: "php"})
if !strings.Contains(cmd, "dir /a") {
t.Errorf("explicit windows should override php shellType, got: %s", cmd)
}
cmd, _ = h.buildFileCommand(fileCommandInput{Action: "list", OS: "linux", ShellType: "asp"})
if !strings.Contains(cmd, "ls -la") {
t.Errorf("explicit linux should override asp shellType, got: %s", cmd)
}
}
+127
View File
@@ -0,0 +1,127 @@
package handler
import (
"bytes"
"io"
"net/http"
"strings"
"go.uber.org/zap"
)
// webshellOSProbeCommand 探活命令:利用 Windows cmd 与 POSIX shell 对 `%OS%` 展开差异进行判定。
// - Windows cmd`%OS%` 被展开为 `Windows_NT`,回显 `:OSPROBE_Windows_NT:END`
// - POSIX sh/bash`%OS%` 不是变量语法,作为字面量原样保留,回显 `:OSPROBE_%OS%:END`
//
// 一条命令即可得到明确的、互斥的信号,避免探活成本(相比发两次命令)。
// 冒号包裹是为了避免部分 shell 输出多余空白/BOM 时字符串匹配失效。
const webshellOSProbeCommand = "echo :OSPROBE_%OS%:END"
// probeWebshellOSViaExec 通过一次命令执行的回显推断目标操作系统。
//
// 返回值:
// - "windows" / "linux":识别成功
// - "":无法判定(调用方应保留既有 fallback 逻辑)
//
// 入参 execFn 是一个"发命令并拿到回显"的闭包;让 HTTP 入口和 MCP 入口可以共用同一套探活逻辑
// 而不必关心底层是如何发包的。
func probeWebshellOSViaExec(execFn func(cmd string) (output string, ok bool)) string {
if execFn == nil {
return ""
}
out, ok := execFn(webshellOSProbeCommand)
if !ok {
return ""
}
return classifyWebshellOSProbeOutput(out)
}
// classifyWebshellOSProbeOutput 纯函数:根据探活命令的回显判定 OS。
// 抽出来是为了单测可直接覆盖所有分支,无需真实 HTTP 调用。
func classifyWebshellOSProbeOutput(out string) string {
if out == "" {
return ""
}
lower := strings.ToLower(out)
// Windows 强信号:cmd.exe 成功展开了 %OS% 变量
if strings.Contains(out, "Windows_NT") {
return "windows"
}
// 容错:部分老版本 Windows 可能 `%OS%` 展开为其他字样(极少见),再看 PATH/OS 等次级线索
if strings.Contains(lower, "microsoft windows") {
return "windows"
}
// Linux/Unix 强信号:`%OS%` 字面量被原样回显,说明 shell 不是 cmd.exe
if strings.Contains(out, "%OS%") {
return "linux"
}
// 次级线索:部分 webshell 在 Linux 上可能走了其他外壳(如 zsh/ash),
// 但它们对 `%OS%` 同样不展开;若命中 OSPROBE 头部却没拿到 %OS% 字面量,
// 说明回显被中途截断或过滤,保守返回空让上层 fallback。
return ""
}
// newHTTPExecFn 为 HTTP FileOp 路径构造"发命令取回显"的闭包,供探活复用。
// 参数来自 HTTP 请求,复用 buildExecURL / buildExecBody 两个已有的命令编排器,
// 确保探活包与实际文件操作包走完全一致的 webshell 协议(GET/POST、参数名、编码)。
func (h *WebShellHandler) newHTTPExecFn(targetURL, password, shellType, method, cmdParam, encoding string) func(string) (string, bool) {
useGET := strings.ToUpper(strings.TrimSpace(method)) == "GET"
if strings.TrimSpace(cmdParam) == "" {
cmdParam = "cmd"
}
return func(cmd string) (string, bool) {
var (
httpReq *http.Request
err error
)
if useGET {
u := h.buildExecURL(targetURL, shellType, password, cmdParam, cmd)
httpReq, err = http.NewRequest(http.MethodGet, u, nil)
} else {
body := h.buildExecBody(shellType, password, cmdParam, cmd)
httpReq, err = http.NewRequest(http.MethodPost, targetURL, bytes.NewReader(body))
if err == nil {
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
}
if err != nil {
return "", false
}
httpReq.Header.Set("User-Agent", "Mozilla/5.0 (compatible; CyberStrikeAI-WebShell/1.0)")
resp, err := h.client.Do(httpReq)
if err != nil {
return "", false
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
return decodeWebshellOutput(raw, encoding), resp.StatusCode == http.StatusOK
}
}
// persistDetectedOS 把探活结果回写到连接表;失败只记日志不阻断主流程。
// 设计上故意只触发 UPDATE,不会新建记录,因此即便 connectionID 不存在也只是悄悄放弃。
func (h *WebShellHandler) persistDetectedOS(connectionID, detected string) {
connectionID = strings.TrimSpace(connectionID)
detected = normalizeWebshellOS(detected)
if connectionID == "" || detected == "" || detected == "auto" {
return
}
conn, err := h.db.GetWebshellConnection(connectionID)
if err != nil || conn == nil {
// 不是所有调用方都能提供有效 ID(比如临时测试),这里静默返回
return
}
if normalizeWebshellOS(conn.OS) != "auto" {
// 用户已经显式选过 OS,尊重用户选择,不自动覆盖
return
}
conn.OS = detected
if err := h.db.UpdateWebshellConnection(conn); err != nil {
h.logger.Warn("webshell 探活结果持久化失败", zap.String("id", connectionID), zap.String("os", detected), zap.Error(err))
return
}
h.logger.Info("webshell auto OS 探活成功并持久化", zap.String("id", connectionID), zap.String("os", detected))
}
+68
View File
@@ -0,0 +1,68 @@
package handler
import "testing"
func TestClassifyWebshellOSProbeOutput(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"Windows cmd 回显完整", ":OSPROBE_Windows_NT:END\r\n", "windows"},
{"Windows cmd 回显带额外空行", "\r\n:OSPROBE_Windows_NT:END\r\n", "windows"},
{"Windows 次级线索 - ver banner", "Microsoft Windows [版本 10.0.19045]\r\n", "windows"},
{"Linux sh 字面量回显", ":OSPROBE_%OS%:END\n", "linux"},
{"Linux 紧凑输出(无换行)", ":OSPROBE_%OS%:END", "linux"},
{"空输出 - 无法判定", "", ""},
{"被过滤的输出 - 无法判定", "something weird", ""},
{"仅有 OSPROBE 前缀但被截断 - 保守返回空", ":OSPROBE_:END", ""},
}
for _, c := range cases {
if got := classifyWebshellOSProbeOutput(c.in); got != c.want {
t.Errorf("case %q: got %q, want %q", c.name, got, c.want)
}
}
}
func TestProbeWebshellOSViaExec_SendsOneCommandOnly(t *testing.T) {
var calls []string
fn := func(cmd string) (string, bool) {
calls = append(calls, cmd)
return ":OSPROBE_Windows_NT:END", true
}
got := probeWebshellOSViaExec(fn)
if got != "windows" {
t.Fatalf("want windows, got %q", got)
}
if len(calls) != 1 {
t.Fatalf("probe should issue exactly one exec call, got %d: %v", len(calls), calls)
}
if calls[0] != webshellOSProbeCommand {
t.Errorf("probe command mismatch: got %q", calls[0])
}
}
func TestProbeWebshellOSViaExec_NotOkReturnsEmpty(t *testing.T) {
// HTTP 非 200 的场景:execFn 返回 ok=false,探活应放弃
fn := func(cmd string) (string, bool) { return "whatever", false }
if got := probeWebshellOSViaExec(fn); got != "" {
t.Errorf("want empty when exec not ok, got %q", got)
}
}
func TestProbeWebshellOSViaExec_NilSafeguard(t *testing.T) {
if got := probeWebshellOSViaExec(nil); got != "" {
t.Errorf("nil execFn should return empty, got %q", got)
}
}
func TestProbeWebshellOSViaExec_LinuxUname(t *testing.T) {
// 某些 webshell 对 `%OS%` 字面量也会过滤(例如安全规则),
// 但主要路径是"%OS% 字面量被原样回显"。这里覆盖标准 Linux 场景。
fn := func(cmd string) (string, bool) {
return ":OSPROBE_%OS%:END\n", true
}
if got := probeWebshellOSViaExec(fn); got != "linux" {
t.Errorf("Linux case: want linux, got %q", got)
}
}
@@ -95,6 +95,9 @@ func NewPlanExecuteRoot(ctx context.Context, a *PlanExecuteRootArgs) (adk.Resuma
} }
execHandlers = append(execHandlers, sumMw) execHandlers = append(execHandlers, sumMw)
} }
// 5. 孤儿 tool 消息兜底:必须挂在所有改写历史中间件(summarization/reduction/skill)之后、
// telemetry 之前,保证送入 ChatModel 的消息序列 tool_call ↔ tool_result 配对完整。
execHandlers = append(execHandlers, newOrphanToolPrunerMiddleware(a.Logger, "plan_execute_executor"))
if teleMw := newEinoModelInputTelemetryMiddleware(a.Logger, a.ModelName, a.ConversationID, "plan_execute_executor"); teleMw != nil { if teleMw := newEinoModelInputTelemetryMiddleware(a.Logger, a.ModelName, a.ConversationID, "plan_execute_executor"); teleMw != nil {
execHandlers = append(execHandlers, teleMw) execHandlers = append(execHandlers, teleMw)
} }
+120 -56
View File
@@ -130,6 +130,14 @@ func newEinoSummarizationMiddleware(
} }
// summarizeFinalizeWithRecentAssistantToolTrail 在摘要消息后保留最近 assistant/tool 轨迹,避免压缩后执行链断裂。 // summarizeFinalizeWithRecentAssistantToolTrail 在摘要消息后保留最近 assistant/tool 轨迹,避免压缩后执行链断裂。
//
// 关键不变量:tool_call ↔ tool_result 的 pair 必须整体保留或整体丢弃。
// 把消息切成 round(回合)为原子单位:
// - user(...) 单条为一个 round
// - assistant(tool_calls=[...]) 及其后连续的 role=tool 消息合成一个 round
// - 其它 assistant(reply, 无 tool_calls) 单条为一个 round。
//
// 倒序挑 round(预算不够即放弃该 round),保证 tool 消息不会跨 round 被孤立。
func summarizeFinalizeWithRecentAssistantToolTrail( func summarizeFinalizeWithRecentAssistantToolTrail(
ctx context.Context, ctx context.Context,
originalMessages []adk.Message, originalMessages []adk.Message,
@@ -157,80 +165,136 @@ func summarizeFinalizeWithRecentAssistantToolTrail(
return out, nil return out, nil
} }
selectedReverse := make([]adk.Message, 0, 8) rounds := splitMessagesIntoRounds(nonSystem)
seen := make(map[adk.Message]struct{}) if len(rounds) == 0 {
totalTokens := 0 out := make([]adk.Message, 0, len(systemMsgs)+1)
assistantToolKept := 0 out = append(out, systemMsgs...)
const minAssistantToolTrail = 4 out = append(out, summary)
return out, nil
}
tryKeep := func(msg adk.Message) (bool, error) { // 目标:至少保留 minRounds 个 round 的执行轨迹;在预算允许时尽量多保留。
if msg == nil { // 优先确保最后一个 round(通常是最新的 tool 往返或 assistant 回复)存在。
return false, nil const minRounds = 2
selectedRoundsReverse := make([]messageRound, 0, 8)
selectedCount := 0
totalTokens := 0
tokensOfRound := func(r messageRound) (int, error) {
if len(r.messages) == 0 {
return 0, nil
} }
if _, ok := seen[msg]; ok { n, err := tokenCounter(ctx, &summarization.TokenCounterInput{Messages: r.messages})
return false, nil
}
n, err := tokenCounter(ctx, &summarization.TokenCounterInput{Messages: []adk.Message{msg}})
if err != nil { if err != nil {
return false, err return 0, err
} }
if n <= 0 { if n <= 0 {
n = 1 n = len(r.messages)
} }
return n, nil
}
for i := len(rounds) - 1; i >= 0; i-- {
r := rounds[i]
n, err := tokensOfRound(r)
if err != nil {
return nil, err
}
// 预算不够:已经保留了足够 round 则停,否则跳过该 round 继续往前找
// (避免一个超大 round 挤占全部预算,至少保证有轨迹)。
if totalTokens+n > recentTrailTokenBudget { if totalTokens+n > recentTrailTokenBudget {
return false, nil if selectedCount >= minRounds {
break
}
continue
} }
totalTokens += n totalTokens += n
selectedReverse = append(selectedReverse, msg) selectedRoundsReverse = append(selectedRoundsReverse, r)
seen[msg] = struct{}{} selectedCount++
return true, nil
} }
// 优先保留最近 assistant/tool,确保执行轨迹可续跑。 // 还原时间顺序
for i := len(nonSystem) - 1; i >= 0; i-- { selectedMsgs := make([]adk.Message, 0, 8)
msg := nonSystem[i] for i := len(selectedRoundsReverse) - 1; i >= 0; i-- {
if msg.Role != schema.Assistant && msg.Role != schema.Tool { selectedMsgs = append(selectedMsgs, selectedRoundsReverse[i].messages...)
continue
}
ok, err := tryKeep(msg)
if err != nil {
return nil, err
}
if ok {
assistantToolKept++
}
if assistantToolKept >= minAssistantToolTrail {
break
}
} }
// 在预算内回填更多最近消息,保持短链路上下文。 out := make([]adk.Message, 0, len(systemMsgs)+1+len(selectedMsgs))
for i := len(nonSystem) - 1; i >= 0; i-- {
_, exists := seen[nonSystem[i]]
if exists {
continue
}
ok, err := tryKeep(nonSystem[i])
if err != nil {
return nil, err
}
if !ok {
break
}
}
selected := make([]adk.Message, 0, len(selectedReverse))
for i := len(selectedReverse) - 1; i >= 0; i-- {
selected = append(selected, selectedReverse[i])
}
out := make([]adk.Message, 0, len(systemMsgs)+1+len(selected))
out = append(out, systemMsgs...) out = append(out, systemMsgs...)
out = append(out, summary) out = append(out, summary)
out = append(out, selected...) out = append(out, selectedMsgs...)
return out, nil return out, nil
} }
// messageRound 表示一个"不可分割"的消息回合。
// - 对 assistant(tool_calls) + 随后若干 tool 消息的组合,round 内全部 call_id 成对完整;
// - 对独立的 user / assistant(reply) 消息,round 仅包含该条消息。
type messageRound struct {
messages []adk.Message
}
// splitMessagesIntoRounds 将非 system 消息切分为若干 round,保证:
// - 每个 assistant(tool_calls) 与其对应的 role=tool 响应消息在同一个 round
// - 孤立(无对应 assistant(tool_calls))的 role=tool 消息不会单独成为 round
// 而是被丢弃(这些消息在 pair 完整性层面已属孤儿,保留反而会触发 LLM 400)。
func splitMessagesIntoRounds(msgs []adk.Message) []messageRound {
if len(msgs) == 0 {
return nil
}
rounds := make([]messageRound, 0, len(msgs))
i := 0
for i < len(msgs) {
msg := msgs[i]
if msg == nil {
i++
continue
}
switch {
case msg.Role == schema.Assistant && len(msg.ToolCalls) > 0:
// 收集该 assistant 提供的 call_id 集合。
provided := make(map[string]struct{}, len(msg.ToolCalls))
for _, tc := range msg.ToolCalls {
if tc.ID != "" {
provided[tc.ID] = struct{}{}
}
}
round := messageRound{messages: []adk.Message{msg}}
j := i + 1
for j < len(msgs) {
next := msgs[j]
if next == nil {
j++
continue
}
if next.Role != schema.Tool {
break
}
if next.ToolCallID != "" {
if _, ok := provided[next.ToolCallID]; !ok {
// 下一条 tool 不属于当前 assistant,认为当前 round 结束。
break
}
}
round.messages = append(round.messages, next)
j++
}
rounds = append(rounds, round)
i = j
case msg.Role == schema.Tool:
// 孤儿 tool 消息:既不跟随在一个 assistant(tool_calls) 后,
// 说明它对应的 assistant 已被上游裁剪;直接丢弃,下一步到 orphan pruner
// 兜底也不会出错,但在 round 切分这里就剔除更干净。
i++
default:
// user / assistant(reply) / 其它:单条成 round。
rounds = append(rounds, messageRound{messages: []adk.Message{msg}})
i++
}
}
return rounds
}
func einoSummarizationTokenCounter(openAIModel string) summarization.TokenCounterFunc { func einoSummarizationTokenCounter(openAIModel string) summarization.TokenCounterFunc {
tc := agent.NewTikTokenCounter() tc := agent.NewTikTokenCounter()
return func(ctx context.Context, input *summarization.TokenCounterInput) (int, error) { return func(ctx context.Context, input *summarization.TokenCounterInput) (int, error) {
+345
View File
@@ -0,0 +1,345 @@
package multiagent
import (
"context"
"testing"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/adk/middlewares/summarization"
"github.com/cloudwego/eino/schema"
)
// fixedTokenCounter 让 tool 消息按 tokensPerToolMessage 计,其它消息按 1 计。
// 用于验证 tool-round 超预算时整体被跳过的分支。
func fixedTokenCounter(tokensPerToolMessage int) summarization.TokenCounterFunc {
return func(_ context.Context, in *summarization.TokenCounterInput) (int, error) {
total := 0
for _, msg := range in.Messages {
if msg == nil {
continue
}
switch msg.Role {
case schema.Tool:
total += tokensPerToolMessage
default:
total++
}
}
return total, nil
}
}
// variableTokenCounter 让 tool 消息按 len(Content) 计(可区分不同大小的 tool 结果),
// 其它消息按 1 计;assistant 附加 len(ToolCalls) token 近似 tool_calls schema 开销。
func variableTokenCounter() summarization.TokenCounterFunc {
return func(_ context.Context, in *summarization.TokenCounterInput) (int, error) {
total := 0
for _, msg := range in.Messages {
if msg == nil {
continue
}
if msg.Role == schema.Tool {
total += len(msg.Content)
continue
}
total++
total += len(msg.ToolCalls)
}
return total, nil
}
}
func TestSplitMessagesIntoRounds_Complex(t *testing.T) {
msgs := []adk.Message{
schema.UserMessage("q1"),
assistantToolCallsMsg("", "c1", "c2"),
schema.ToolMessage("r1", "c1"),
schema.ToolMessage("r2", "c2"),
schema.AssistantMessage("reply1", nil),
schema.UserMessage("q2"),
assistantToolCallsMsg("", "c3"),
schema.ToolMessage("r3", "c3"),
}
rounds := splitMessagesIntoRounds(msgs)
// 5 rounds: user(q1) | assistant(tc:c1,c2)+tool*2 | assistant(reply1) | user(q2) | assistant(tc:c3)+tool(c3)
if len(rounds) != 5 {
t.Fatalf("want 5 rounds, got %d", len(rounds))
}
// round 1 应为 tool-round,必须成对
r1 := rounds[1]
if len(r1.messages) != 3 {
t.Fatalf("rounds[1] size: want 3, got %d", len(r1.messages))
}
if r1.messages[0].Role != schema.Assistant || len(r1.messages[0].ToolCalls) != 2 {
t.Fatalf("rounds[1][0] must be assistant(tc=2)")
}
for i := 1; i < 3; i++ {
if r1.messages[i].Role != schema.Tool {
t.Fatalf("rounds[1][%d] must be tool, got %s", i, r1.messages[i].Role)
}
}
// 最后一个 round 成对
rLast := rounds[len(rounds)-1]
if len(rLast.messages) != 2 {
t.Fatalf("rounds[last] size: want 2, got %d", len(rLast.messages))
}
if rLast.messages[0].Role != schema.Assistant || rLast.messages[1].Role != schema.Tool {
t.Fatalf("last round must be assistant(tc)+tool(c3)")
}
}
func TestSplitMessagesIntoRounds_DropsOrphanTool(t *testing.T) {
// 起点直接是 tool 消息(孤儿)—— 应被丢弃,不独立成 round。
msgs := []adk.Message{
schema.ToolMessage("orphan", "c_old"),
schema.UserMessage("continue"),
assistantToolCallsMsg("", "c_new"),
schema.ToolMessage("r_new", "c_new"),
}
rounds := splitMessagesIntoRounds(msgs)
// user(continue) | assistant(tc:c_new)+tool(c_new) → 2 rounds
if len(rounds) != 2 {
t.Fatalf("want 2 rounds after dropping orphan, got %d", len(rounds))
}
for _, r := range rounds {
for _, m := range r.messages {
if m.Role == schema.Tool && m.ToolCallID == "c_old" {
t.Fatalf("orphan tool c_old must not appear in any round")
}
}
}
}
func TestSplitMessagesIntoRounds_ToolBelongsToCurrentAssistantOnly(t *testing.T) {
// 两个相邻 assistant(tc),第二个的 tool 不应被归到第一个 assistant。
msgs := []adk.Message{
assistantToolCallsMsg("", "c1"),
schema.ToolMessage("r1", "c1"),
assistantToolCallsMsg("", "c2"),
schema.ToolMessage("r2", "c2"),
}
rounds := splitMessagesIntoRounds(msgs)
if len(rounds) != 2 {
t.Fatalf("want 2 rounds, got %d", len(rounds))
}
if len(rounds[0].messages) != 2 || rounds[0].messages[0].ToolCalls[0].ID != "c1" {
t.Fatalf("round[0] wrong: %+v", rounds[0].messages)
}
if len(rounds[1].messages) != 2 || rounds[1].messages[0].ToolCalls[0].ID != "c2" {
t.Fatalf("round[1] wrong: %+v", rounds[1].messages)
}
}
func TestSplitMessagesIntoRounds_ToolBelongsToWrongAssistant(t *testing.T) {
// assistant(tc:c1) 后面跟一个 tool_call_id=c999 的 tool 消息(本不属它)。
// 切分规则:该 tool 不应拼入第一个 round(配对不完整),round 在此结束。
// 而 c999 又没有对应 assistant,应被当孤儿丢弃。
msgs := []adk.Message{
assistantToolCallsMsg("", "c1"),
schema.ToolMessage("wrong", "c999"),
schema.UserMessage("hi"),
}
rounds := splitMessagesIntoRounds(msgs)
// assistant(tc:c1) 没有对应 tool(c1),但不是孤儿(patchtoolcalls 会兜底补);
// 它独立成 round 允许上游后处理。user(hi) 独立成 round。共 2 rounds。
if len(rounds) != 2 {
t.Fatalf("want 2 rounds, got %d: %+v", len(rounds), rounds)
}
for _, r := range rounds {
for _, m := range r.messages {
if m.Role == schema.Tool && m.ToolCallID == "c999" {
t.Fatalf("wrong-owner tool must be dropped as orphan")
}
}
}
}
func TestSummarizeFinalize_KeepsToolRoundIntact(t *testing.T) {
// 关键回归测试:一个 tool-round 整体被保留,而不是只保留 tool 消息。
sys := schema.SystemMessage("sys")
summary := schema.AssistantMessage("summary_content", nil)
msgs := []adk.Message{
sys,
schema.UserMessage("q1"),
schema.AssistantMessage("reply_before_tc", nil), // 填料,占预算
assistantToolCallsMsg("", "c1"),
schema.ToolMessage("r1", "c1"),
}
// token 预算:2 条消息(1 assistant + 1 tool)恰好够用。
// 若按条数保留,可能先吃 tool(c1) 再吃 assistant(reply) 落入 budgetassistant(tc:c1) 被挤掉,导致孤儿。
// 按 round 保留时,整个 tool-round 为原子,要么保留 2 条都在,要么都不在。
out, err := summarizeFinalizeWithRecentAssistantToolTrail(
context.Background(),
msgs,
summary,
fixedTokenCounter(1),
2, // 预算:2 tokens
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// 必须包含 system + summary
if len(out) < 2 {
t.Fatalf("output too short: %d", len(out))
}
if out[0] != sys {
t.Fatalf("first message must be system")
}
if out[1] != summary {
t.Fatalf("second message must be summary")
}
// 关键不变量:每个被保留的 tool 消息,必须能在输出中找到提供其 ToolCallID 的 assistant(tc)。
assertNoOrphanTool(t, out)
}
func TestSummarizeFinalize_SkipsOversizedToolRoundButKeepsSmallerRound(t *testing.T) {
// 构造两个大小差异显著的 tool-round:
// c_big round 的 tool 结果 content="aaaaaaaaaa"10 bytes),round token ≈ 2 (assistant+tc) + 10 = 12
// c_ok round 的 tool 结果 content="ok"2 bytes),round token ≈ 2 + 2 = 4
// 配上 budget=8,使得:
// - 最新的 c_ok round4)能放下;
// - 进一步的中间 roundassistant reply + user)也能放下;
// - 更早的 c_big round12)放不下会被跳过(continue),而非 break。
sys := schema.SystemMessage("sys")
summary := schema.AssistantMessage("summary_content", nil)
msgs := []adk.Message{
sys,
schema.UserMessage("q1"),
assistantToolCallsMsg("", "c_big"),
schema.ToolMessage("aaaaaaaaaa", "c_big"),
schema.AssistantMessage("s", nil),
schema.UserMessage("q2"),
assistantToolCallsMsg("", "c_ok"),
schema.ToolMessage("ok", "c_ok"),
}
out, err := summarizeFinalizeWithRecentAssistantToolTrail(
context.Background(),
msgs,
summary,
variableTokenCounter(),
8,
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertNoOrphanTool(t, out)
// c_big 整个 round 必须被丢弃(tool 和 assistant 都不能出现)
for _, m := range out {
if m == nil {
continue
}
if m.Role == schema.Tool && m.ToolCallID == "c_big" {
t.Fatal("oversized tool round must be skipped: tool(c_big) leaked")
}
if m.Role == schema.Assistant {
for _, tc := range m.ToolCalls {
if tc.ID == "c_big" {
t.Fatal("oversized tool round must be skipped: assistant(tc:c_big) leaked")
}
}
}
}
// 最近 round (c_ok) 作为一个原子单位必须整体保留。
foundOKTool, foundOKAsst := false, false
for _, m := range out {
if m == nil {
continue
}
if m.Role == schema.Tool && m.ToolCallID == "c_ok" {
foundOKTool = true
}
if m.Role == schema.Assistant {
for _, tc := range m.ToolCalls {
if tc.ID == "c_ok" {
foundOKAsst = true
}
}
}
}
if !foundOKTool || !foundOKAsst {
t.Fatalf("recent tool-round (c_ok) must be retained as an atomic pair: assistantKept=%v toolKept=%v", foundOKAsst, foundOKTool)
}
}
func TestSummarizeFinalize_BudgetZeroFallsBackToSummaryOnly(t *testing.T) {
sys := schema.SystemMessage("sys")
summary := schema.AssistantMessage("summary", nil)
msgs := []adk.Message{
sys,
assistantToolCallsMsg("", "c1"),
schema.ToolMessage("r1", "c1"),
}
out, err := summarizeFinalizeWithRecentAssistantToolTrail(
context.Background(),
msgs,
summary,
fixedTokenCounter(1),
0,
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(out) != 2 || out[0] != sys || out[1] != summary {
t.Fatalf("budget=0 must yield [system, summary] only, got %+v", out)
}
}
func TestSummarizeFinalize_PreservesAllSystemMessages(t *testing.T) {
sys1 := schema.SystemMessage("sys1")
sys2 := schema.SystemMessage("sys2")
summary := schema.AssistantMessage("s", nil)
msgs := []adk.Message{
sys1,
schema.UserMessage("q"),
sys2, // 非典型位置,但应当被 system group 捕获
}
out, err := summarizeFinalizeWithRecentAssistantToolTrail(
context.Background(),
msgs,
summary,
fixedTokenCounter(1),
100,
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
systemCount := 0
for _, m := range out {
if m != nil && m.Role == schema.System {
systemCount++
}
}
if systemCount != 2 {
t.Fatalf("want 2 system messages retained, got %d", systemCount)
}
}
// assertNoOrphanTool 断言消息列表里的每个 role=tool 消息都能在更前面找到一个
// assistant(tool_calls) 提供相同 ID,否则说明产生了孤儿(触发 LLM 400 的根因)。
func assertNoOrphanTool(t *testing.T, msgs []adk.Message) {
t.Helper()
provided := make(map[string]struct{})
for _, m := range msgs {
if m == nil {
continue
}
if m.Role == schema.Assistant {
for _, tc := range m.ToolCalls {
if tc.ID != "" {
provided[tc.ID] = struct{}{}
}
}
}
if m.Role == schema.Tool && m.ToolCallID != "" {
if _, ok := provided[m.ToolCallID]; !ok {
t.Fatalf("orphan tool message found: ToolCallID=%q has no preceding assistant(tool_calls)", m.ToolCallID)
}
}
}
}
@@ -0,0 +1,124 @@
package multiagent
import (
"context"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
"go.uber.org/zap"
)
// orphanToolPrunerMiddleware 在每次 ChatModel 调用前剪掉没有对应 assistant(tool_calls) 的孤儿 tool 消息。
//
// 背景:
// - eino 的 summarization 中间件在触发摘要后,默认把所有非 system 消息替换为 1 条 summary 消息;
// 本项目通过自定义 FinalizesummarizeFinalizeWithRecentAssistantToolTrail)在 summary 后回填
// 最近的 assistant/tool 轨迹。若 Finalize 的保留策略按"条数"截断而未按 round 对齐,可能保留
// 了 tool 结果却把对应的 assistant(tool_calls) 落在了 summary 前面,形成孤儿 tool 消息。
// - 同样,reduction / tool_search / 自定义断点恢复等任一改写历史的逻辑,都可能破坏
// tool_call ↔ tool_result 配对。
//
// 一旦孤儿 tool 消息进入 ChatModelOpenAI 兼容 API(含 DashScope / 各类中转)会返回
// 400 "No tool call found for function call output with call_id ...",并被 Eino 包装成
// [NodeRunError] 抛出,终止整轮编排。
//
// 设计取舍:
// - 官方 patchtoolcalls 中间件只补反向(assistant(tc) 缺 tool_result),不处理孤儿 tool。
// 本中间件与之互补,专职兜底正向孤儿。
// - 仅剔除消息,不向历史里注入虚构 assistant(tc):虚构 tool_calls 反而会误导模型后续推理。
// 摘要已覆盖被裁剪段的语义,丢一条原始 tool 结果对对话连贯性影响最小。
// - 位置建议:挂在所有可能改写历史的中间件(summarization / reduction / skill / plantask /
// tool_search)之后,靠近 ChatModel 调用的那一端。
type orphanToolPrunerMiddleware struct {
adk.BaseChatModelAgentMiddleware
logger *zap.Logger
phase string
}
// newOrphanToolPrunerMiddleware 构造中间件。phase 仅用于日志区分 deep / supervisor /
// plan_execute_executor / sub_agent,不影响运行时行为。
func newOrphanToolPrunerMiddleware(logger *zap.Logger, phase string) adk.ChatModelAgentMiddleware {
return &orphanToolPrunerMiddleware{
logger: logger,
phase: phase,
}
}
// BeforeModelRewriteState 扫描消息列表,收集 assistant.tool_calls 提供的 call_id 集合,
// 再剔除掉 ToolCallID 不在该集合中的 role=tool 消息。
//
// 复杂度:O(N)。当未发现孤儿时不产生任何分配,state 原样返回以便上游快路径。
func (m *orphanToolPrunerMiddleware) BeforeModelRewriteState(
ctx context.Context,
state *adk.ChatModelAgentState,
mc *adk.ModelContext,
) (context.Context, *adk.ChatModelAgentState, error) {
_ = mc
if m == nil || state == nil || len(state.Messages) == 0 {
return ctx, state, nil
}
// 第一遍:收集所有已提供的 tool_call_id;同时快路径判定是否真的存在孤儿。
provided := make(map[string]struct{}, 8)
for _, msg := range state.Messages {
if msg == nil {
continue
}
if msg.Role == schema.Assistant {
for _, tc := range msg.ToolCalls {
if tc.ID != "" {
provided[tc.ID] = struct{}{}
}
}
}
}
hasOrphan := false
for _, msg := range state.Messages {
if msg == nil {
continue
}
if msg.Role == schema.Tool && msg.ToolCallID != "" {
if _, ok := provided[msg.ToolCallID]; !ok {
hasOrphan = true
break
}
}
}
if !hasOrphan {
return ctx, state, nil
}
// 第二遍:生成剪除孤儿后的新消息列表。
pruned := make([]adk.Message, 0, len(state.Messages))
droppedIDs := make([]string, 0, 2)
droppedNames := make([]string, 0, 2)
for _, msg := range state.Messages {
if msg == nil {
continue
}
if msg.Role == schema.Tool && msg.ToolCallID != "" {
if _, ok := provided[msg.ToolCallID]; !ok {
droppedIDs = append(droppedIDs, msg.ToolCallID)
droppedNames = append(droppedNames, msg.ToolName)
continue
}
}
pruned = append(pruned, msg)
}
if m.logger != nil {
m.logger.Warn("eino orphan tool messages pruned before model call",
zap.String("phase", m.phase),
zap.Int("dropped_count", len(droppedIDs)),
zap.Strings("dropped_tool_call_ids", droppedIDs),
zap.Strings("dropped_tool_names", droppedNames),
zap.Int("messages_before", len(state.Messages)),
zap.Int("messages_after", len(pruned)),
)
}
ns := *state
ns.Messages = pruned
return ctx, &ns, nil
}
@@ -0,0 +1,131 @@
package multiagent
import (
"context"
"testing"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
)
func assistantToolCallsMsg(content string, callIDs ...string) *schema.Message {
tcs := make([]schema.ToolCall, 0, len(callIDs))
for _, id := range callIDs {
tcs = append(tcs, schema.ToolCall{
ID: id,
Type: "function",
Function: schema.FunctionCall{
Name: "stub_tool",
Arguments: `{}`,
},
})
}
return schema.AssistantMessage(content, tcs)
}
func TestOrphanToolPruner_NoOpWhenPaired(t *testing.T) {
mw := newOrphanToolPrunerMiddleware(nil, "test").(*orphanToolPrunerMiddleware)
msgs := []adk.Message{
schema.SystemMessage("sys"),
schema.UserMessage("hi"),
assistantToolCallsMsg("", "c1", "c2"),
schema.ToolMessage("r1", "c1"),
schema.ToolMessage("r2", "c2"),
schema.AssistantMessage("done", nil),
}
in := &adk.ChatModelAgentState{Messages: msgs}
_, out, err := mw.BeforeModelRewriteState(context.Background(), in, &adk.ModelContext{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out == nil {
t.Fatal("expected non-nil state")
}
if len(out.Messages) != len(msgs) {
t.Fatalf("expected %d messages kept, got %d", len(msgs), len(out.Messages))
}
// 快路径:未发现孤儿时必须原地返回 state,不分配新切片。
if &out.Messages[0] != &msgs[0] {
t.Fatalf("expected state to be returned as-is (same backing slice) when no orphan present")
}
}
func TestOrphanToolPruner_DropsOrphanToolMessages(t *testing.T) {
mw := newOrphanToolPrunerMiddleware(nil, "test").(*orphanToolPrunerMiddleware)
msgs := []adk.Message{
schema.SystemMessage("sys"),
// 摘要前的 assistant(tc: c_old) 已被裁剪,但对应的 tool 结果漏保留了。
schema.ToolMessage("orphan result", "c_old"),
schema.UserMessage("continue"),
assistantToolCallsMsg("", "c_new"),
schema.ToolMessage("r_new", "c_new"),
}
in := &adk.ChatModelAgentState{Messages: msgs}
_, out, err := mw.BeforeModelRewriteState(context.Background(), in, &adk.ModelContext{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out == nil {
t.Fatal("expected non-nil state")
}
if len(out.Messages) != len(msgs)-1 {
t.Fatalf("expected %d messages after pruning, got %d", len(msgs)-1, len(out.Messages))
}
for _, m := range out.Messages {
if m != nil && m.Role == schema.Tool && m.ToolCallID == "c_old" {
t.Fatalf("orphan tool message with ToolCallID=c_old should have been dropped")
}
}
// 合法的 tool(c_new) 必须保留。
foundNew := false
for _, m := range out.Messages {
if m != nil && m.Role == schema.Tool && m.ToolCallID == "c_new" {
foundNew = true
break
}
}
if !foundNew {
t.Fatal("paired tool message (c_new) must be retained")
}
}
func TestOrphanToolPruner_EmptyToolCallIDIsIgnored(t *testing.T) {
// 空 ToolCallID 的 tool 消息在真实场景中极罕见,但不应当被误判为孤儿。
// 语义上把它当作"无法校验,保留",避免误删。
mw := newOrphanToolPrunerMiddleware(nil, "test").(*orphanToolPrunerMiddleware)
odd := schema.ToolMessage("no_id", "")
msgs := []adk.Message{
schema.UserMessage("hi"),
odd,
schema.AssistantMessage("ok", nil),
}
in := &adk.ChatModelAgentState{Messages: msgs}
_, out, err := mw.BeforeModelRewriteState(context.Background(), in, &adk.ModelContext{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(out.Messages) != len(msgs) {
t.Fatalf("empty ToolCallID tool message should be kept, got %d messages", len(out.Messages))
}
}
func TestOrphanToolPruner_NilAndEmpty(t *testing.T) {
mw := newOrphanToolPrunerMiddleware(nil, "test").(*orphanToolPrunerMiddleware)
ctx := context.Background()
// nil state
if _, out, err := mw.BeforeModelRewriteState(ctx, nil, &adk.ModelContext{}); err != nil || out != nil {
t.Fatalf("nil state: expected (nil,nil), got (%v,%v)", out, err)
}
// empty messages
empty := &adk.ChatModelAgentState{}
if _, out, err := mw.BeforeModelRewriteState(ctx, empty, &adk.ModelContext{}); err != nil || out != empty {
t.Fatalf("empty messages: expected same state, got (%v,%v)", out, err)
}
}
+7
View File
@@ -257,6 +257,9 @@ func RunDeepAgent(
subHandlers = append(subHandlers, einoSkillMW) subHandlers = append(subHandlers, einoSkillMW)
} }
subHandlers = append(subHandlers, subSumMw) subHandlers = append(subHandlers, subSumMw)
// 孤儿 tool 消息兜底:放在 summarization 之后,telemetry 之前,
// 以便 telemetry 记录的 token 数与 LLM 实际入参一致。
subHandlers = append(subHandlers, newOrphanToolPrunerMiddleware(logger, "sub_agent:"+id))
if teleMw := newEinoModelInputTelemetryMiddleware(logger, appCfg.OpenAI.Model, conversationID, "sub_agent"); teleMw != nil { if teleMw := newEinoModelInputTelemetryMiddleware(logger, appCfg.OpenAI.Model, conversationID, "sub_agent"); teleMw != nil {
subHandlers = append(subHandlers, teleMw) subHandlers = append(subHandlers, teleMw)
} }
@@ -393,6 +396,7 @@ func RunDeepAgent(
deepHandlers = append(deepHandlers, einoSkillMW) deepHandlers = append(deepHandlers, einoSkillMW)
} }
deepHandlers = append(deepHandlers, mainSumMw) deepHandlers = append(deepHandlers, mainSumMw)
deepHandlers = append(deepHandlers, newOrphanToolPrunerMiddleware(logger, "deep_orchestrator"))
if teleMw := newEinoModelInputTelemetryMiddleware(logger, appCfg.OpenAI.Model, conversationID, "deep_orchestrator"); teleMw != nil { if teleMw := newEinoModelInputTelemetryMiddleware(logger, appCfg.OpenAI.Model, conversationID, "deep_orchestrator"); teleMw != nil {
deepHandlers = append(deepHandlers, teleMw) deepHandlers = append(deepHandlers, teleMw)
} }
@@ -405,6 +409,7 @@ func RunDeepAgent(
supHandlers = append(supHandlers, einoSkillMW) supHandlers = append(supHandlers, einoSkillMW)
} }
supHandlers = append(supHandlers, mainSumMw) supHandlers = append(supHandlers, mainSumMw)
supHandlers = append(supHandlers, newOrphanToolPrunerMiddleware(logger, "supervisor_orchestrator"))
if teleMw := newEinoModelInputTelemetryMiddleware(logger, appCfg.OpenAI.Model, conversationID, "supervisor_orchestrator"); teleMw != nil { if teleMw := newEinoModelInputTelemetryMiddleware(logger, appCfg.OpenAI.Model, conversationID, "supervisor_orchestrator"); teleMw != nil {
supHandlers = append(supHandlers, teleMw) supHandlers = append(supHandlers, teleMw)
} }
@@ -455,6 +460,8 @@ func RunDeepAgent(
FilesystemMiddleware: peFsMw, FilesystemMiddleware: peFsMw,
PlannerReplannerRewriteHandlers: []adk.ChatModelAgentMiddleware{ PlannerReplannerRewriteHandlers: []adk.ChatModelAgentMiddleware{
mainSumMw, mainSumMw,
// 孤儿 tool 消息兜底:必须挂在 summarization 之后、telemetry 之前。
newOrphanToolPrunerMiddleware(logger, "plan_execute_planner_replanner"),
newEinoModelInputTelemetryMiddleware(logger, appCfg.OpenAI.Model, conversationID, "plan_execute_planner_replanner_rewrite"), newEinoModelInputTelemetryMiddleware(logger, appCfg.OpenAI.Model, conversationID, "plan_execute_planner_replanner_rewrite"),
}, },
}) })
+624 -178
View File
File diff suppressed because it is too large Load Diff
+23 -3
View File
@@ -95,6 +95,15 @@
"severityLow": "Low", "severityLow": "Low",
"severityInfo": "Info", "severityInfo": "Info",
"totalVulns": "Total vulnerabilities", "totalVulns": "Total vulnerabilities",
"riskLevel": "Risk level",
"riskScore": "Weighted risk score",
"riskSafe": "Safe",
"riskLow": "Low",
"riskMedium": "Medium",
"riskHigh": "High",
"riskSevere": "Severe",
"latestFound": "Latest found",
"noneYet": "None yet",
"runOverview": "Run overview", "runOverview": "Run overview",
"batchQueues": "Batch task queues", "batchQueues": "Batch task queues",
"pending": "Pending", "pending": "Pending",
@@ -125,7 +134,7 @@
"lastUpdated": "Last updated", "lastUpdated": "Last updated",
"viewAll": "View all →", "viewAll": "View all →",
"recentVulns": "Recent vulnerabilities", "recentVulns": "Recent vulnerabilities",
"noVulnYet": "No vulnerabilities yet — start your first scan", "noVulnYet": "No recent vulnerabilities",
"capabilities": "Capabilities", "capabilities": "Capabilities",
"mcpTools": "MCP tools", "mcpTools": "MCP tools",
"rolesLabel": "Roles", "rolesLabel": "Roles",
@@ -184,7 +193,7 @@
"recoCheckMonitorDesc": "View failed request details in MCP monitor", "recoCheckMonitorDesc": "View failed request details in MCP monitor",
"recoSetupMcp": "Configure your first MCP tool", "recoSetupMcp": "Configure your first MCP tool",
"recoSetupMcpDesc": "Install MCP server before Agent can invoke specific capabilities", "recoSetupMcpDesc": "Install MCP server before Agent can invoke specific capabilities",
"recoStartScan": "Start your first scan", "recoStartScan": "Start a scan from chat",
"recoStartScanDesc": "Describe your target in chat, AI will help execute", "recoStartScanDesc": "Describe your target in chat, AI will help execute",
"recentEvents": "Recent Events", "recentEvents": "Recent Events",
"eventUntitled": "Event", "eventUntitled": "Event",
@@ -193,7 +202,7 @@
"mcpPartialDown_one": "{{count}} stopped", "mcpPartialDown_one": "{{count}} stopped",
"mcpPartialDown_other": "{{count}} stopped", "mcpPartialDown_other": "{{count}} stopped",
"mcpAllDown": "All stopped", "mcpAllDown": "All stopped",
"noVulnDesc": "System looks safe — start a scan to discover potential issues", "noVulnDesc": "This list shows recent records; new results appear here when detection completes in chat",
"startScanBtn": "Go to chat to scan" "startScanBtn": "Go to chat to scan"
}, },
"chat": { "chat": {
@@ -546,6 +555,17 @@
"typeCustom": "Custom", "typeCustom": "Custom",
"cmdParam": "Command parameter name", "cmdParam": "Command parameter name",
"cmdParamPlaceholder": "Leave empty for cmd; e.g. xxx for xxx=command", "cmdParamPlaceholder": "Leave empty for cmd; e.g. xxx for xxx=command",
"encoding": "Response encoding",
"encodingAuto": "Auto detect",
"encodingUtf8": "UTF-8",
"encodingGbk": "GBK (Simplified Chinese Windows)",
"encodingGb18030": "GB18030",
"encodingHint": "Switch to GBK or GB18030 if the Simplified Chinese Windows target shows garbled output.",
"os": "Target OS",
"osAuto": "Auto (infer from Shell type)",
"osLinux": "Linux / Unix",
"osWindows": "Windows",
"osHint": "Determines whether file manager / uploads use Linux or Windows commands. Choose Windows for PHP/JSP hosted on Windows.",
"remark": "Remark", "remark": "Remark",
"remarkPlaceholder": "Friendly name for this connection", "remarkPlaceholder": "Friendly name for this connection",
"deleteConfirm": "Delete this connection?", "deleteConfirm": "Delete this connection?",
+23 -3
View File
@@ -95,6 +95,15 @@
"severityLow": "低危", "severityLow": "低危",
"severityInfo": "信息", "severityInfo": "信息",
"totalVulns": "总漏洞数", "totalVulns": "总漏洞数",
"riskLevel": "风险等级",
"riskScore": "加权风险分",
"riskSafe": "安全",
"riskLow": "低",
"riskMedium": "中",
"riskHigh": "高",
"riskSevere": "极高",
"latestFound": "最近发现",
"noneYet": "暂无",
"runOverview": "运行概览", "runOverview": "运行概览",
"batchQueues": "批量任务队列", "batchQueues": "批量任务队列",
"pending": "待执行", "pending": "待执行",
@@ -125,7 +134,7 @@
"lastUpdated": "上次更新", "lastUpdated": "上次更新",
"viewAll": "查看全部 →", "viewAll": "查看全部 →",
"recentVulns": "最近漏洞", "recentVulns": "最近漏洞",
"noVulnYet": "暂无漏洞,开始你的第一次扫描吧", "noVulnYet": "暂无最近漏洞",
"capabilities": "能力总览", "capabilities": "能力总览",
"mcpTools": "MCP 工具", "mcpTools": "MCP 工具",
"rolesLabel": "角色", "rolesLabel": "角色",
@@ -174,7 +183,7 @@
"recoCheckMonitorDesc": "在 MCP 监控中查看失败的请求详情", "recoCheckMonitorDesc": "在 MCP 监控中查看失败的请求详情",
"recoSetupMcp": "配置首个 MCP 工具", "recoSetupMcp": "配置首个 MCP 工具",
"recoSetupMcpDesc": "安装 MCP 服务后 Agent 才能调用具体能力", "recoSetupMcpDesc": "安装 MCP 服务后 Agent 才能调用具体能力",
"recoStartScan": "开始第一次扫描", "recoStartScan": "在对话中发起扫描",
"recoStartScanDesc": "在对话中描述目标,让 AI 协助执行", "recoStartScanDesc": "在对话中描述目标,让 AI 协助执行",
"recentEvents": "最近事件", "recentEvents": "最近事件",
"eventUntitled": "事件", "eventUntitled": "事件",
@@ -182,7 +191,7 @@
"mcpAllRunning": "全部运行", "mcpAllRunning": "全部运行",
"mcpPartialDown": "{{count}} 个未运行", "mcpPartialDown": "{{count}} 个未运行",
"mcpAllDown": "全部未运行", "mcpAllDown": "全部未运行",
"noVulnDesc": "系统目前安全,开始一次扫描可以发现潜在问题", "noVulnDesc": "此处展示近期漏洞记录;在对话中完成检测后,新结果会出现在这里",
"startScanBtn": "前往对话发起扫描" "startScanBtn": "前往对话发起扫描"
}, },
"chat": { "chat": {
@@ -535,6 +544,17 @@
"typeCustom": "自定义", "typeCustom": "自定义",
"cmdParam": "命令参数名", "cmdParam": "命令参数名",
"cmdParamPlaceholder": "不填默认为 cmd,如填 xxx 则请求为 xxx=命令", "cmdParamPlaceholder": "不填默认为 cmd,如填 xxx 则请求为 xxx=命令",
"encoding": "响应编码",
"encodingAuto": "自动检测",
"encodingUtf8": "UTF-8",
"encodingGbk": "GBK(中文 Windows",
"encodingGb18030": "GB18030",
"encodingHint": "中文 Windows 目标若出现乱码,请切换为 GBK 或 GB18030",
"os": "目标系统",
"osAuto": "自动(按 Shell 类型推断)",
"osLinux": "Linux / Unix",
"osWindows": "Windows",
"osHint": "决定文件管理/上传使用 Linux 还是 Windows 命令;PHP/JSP 跑在 Windows 上请选 Windows",
"remark": "备注", "remark": "备注",
"remarkPlaceholder": "便于识别的备注名", "remarkPlaceholder": "便于识别的备注名",
"deleteConfirm": "确定要删除该连接吗?", "deleteConfirm": "确定要删除该连接吗?",
+1123 -651
View File
File diff suppressed because it is too large Load Diff
+153 -19
View File
@@ -46,6 +46,7 @@ async function refreshDashboard() {
if (severityTotalEl) severityTotalEl.textContent = '0'; if (severityTotalEl) severityTotalEl.textContent = '0';
renderSeverityDonut({}, 0); renderSeverityDonut({}, 0);
renderVulnStatusPanel(null, 0); renderVulnStatusPanel(null, 0);
renderSeverityInsights(null, 0, null);
setDashboardOverviewPlaceholder('…'); setDashboardOverviewPlaceholder('…');
setEl('dashboard-kpi-tools-calls', '…'); setEl('dashboard-kpi-tools-calls', '…');
setEl('dashboard-kpi-success-rate', '…'); setEl('dashboard-kpi-success-rate', '…');
@@ -91,15 +92,16 @@ async function refreshDashboard() {
try { try {
// /api/vulnerabilities/stats 只给出 by_severity 与 by_status 两个独立维度, // /api/vulnerabilities/stats 只给出 by_severity 与 by_status 两个独立维度,
// 无法得到「严重 × 待处理」的交叉计数。这里额外拉两次(limit=1,仅取 total), // 无法得到「严重 × 待处理」的交叉计数。这里按四档各拉一次(limit=1,仅取 total),
// 用真实的「待处理严重 / 待处理高危」数量驱动告警条 KPI 副标,避免修复后仍报警。 // 用真实的「待处理 × 各严重度」数量驱动告警条 / KPI 副标 / 风险概览卡的加权分,
// 避免「全部修复后风险等级仍显示极高」这类语义冲突。
var openVulnQuery = function (sev) { var openVulnQuery = function (sev) {
return fetchJson('/api/vulnerabilities?severity=' + sev + '&status=open&limit=1'); return fetchJson('/api/vulnerabilities?severity=' + sev + '&status=open&limit=1');
}; };
const [ const [
tasksRes, vulnRes, batchRes, monitorRes, knowledgeRes, skillsRes, tasksRes, vulnRes, batchRes, monitorRes, knowledgeRes, skillsRes,
recentVulnsRes, rolesRes, agentsRes, recentVulnsRes, rolesRes, agentsRes,
openCriticalRes, openHighRes, toolsConfigRes, openCriticalRes, openHighRes, openMediumRes, openLowRes, toolsConfigRes,
hitlPendingRes, notificationsRes, externalMcpStatsRes, hitlPendingRes, notificationsRes, externalMcpStatsRes,
webshellRes webshellRes
] = await Promise.all([ ] = await Promise.all([
@@ -114,6 +116,9 @@ async function refreshDashboard() {
fetchJson('/api/multi-agent/markdown-agents'), fetchJson('/api/multi-agent/markdown-agents'),
openVulnQuery('critical'), openVulnQuery('critical'),
openVulnQuery('high'), openVulnQuery('high'),
// 中/低危的「待处理」计数:用于风险概览卡的加权风险分,使其反映"当前未处理风险"
openVulnQuery('medium'),
openVulnQuery('low'),
// 拉取 MCP 工具的「配置总数」用于「能力总览」(区别于 monitor/stats 的「有调用记录」)。 // 拉取 MCP 工具的「配置总数」用于「能力总览」(区别于 monitor/stats 的「有调用记录」)。
// 仅取 total 字段,page_size=1 减少传输;total 已涵盖内部 + 外部 MCP + 直接注册的工具。 // 仅取 total 字段,page_size=1 减少传输;total 已涵盖内部 + 外部 MCP + 直接注册的工具。
fetchJson('/api/config/tools?page=1&page_size=1'), fetchJson('/api/config/tools?page=1&page_size=1'),
@@ -173,17 +178,25 @@ async function refreshDashboard() {
let criticalCount = 0; let criticalCount = 0;
let highCount = 0; let highCount = 0;
let mediumCount = 0;
let lowCount = 0;
let openCriticalCount = 0; let openCriticalCount = 0;
let openHighCount = 0; let openHighCount = 0;
let openMediumCount = 0;
let openLowCount = 0;
if (vulnRes && typeof vulnRes.total === 'number') { if (vulnRes && typeof vulnRes.total === 'number') {
if (vulnTotalEl) vulnTotalEl.textContent = String(vulnRes.total); if (vulnTotalEl) vulnTotalEl.textContent = String(vulnRes.total);
const bySeverity = vulnRes.by_severity || {}; const bySeverity = vulnRes.by_severity || {};
const total = vulnRes.total || 0; const total = vulnRes.total || 0;
criticalCount = bySeverity.critical || 0; criticalCount = bySeverity.critical || 0;
highCount = bySeverity.high || 0; highCount = bySeverity.high || 0;
mediumCount = bySeverity.medium || 0;
lowCount = bySeverity.low || 0;
// 优先用专门拉的「待处理」计数;若专项接口失败,则退回 by_severity(宁可误报,不可漏报) // 优先用专门拉的「待处理」计数;若专项接口失败,则退回 by_severity(宁可误报,不可漏报)
openCriticalCount = pickOpenCount(openCriticalRes, criticalCount); openCriticalCount = pickOpenCount(openCriticalRes, criticalCount);
openHighCount = pickOpenCount(openHighRes, highCount); openHighCount = pickOpenCount(openHighRes, highCount);
openMediumCount = pickOpenCount(openMediumRes, mediumCount);
openLowCount = pickOpenCount(openLowRes, lowCount);
if (severityTotalEl) severityTotalEl.textContent = String(total); if (severityTotalEl) severityTotalEl.textContent = String(total);
severityIds.forEach(sev => { severityIds.forEach(sev => {
const count = bySeverity[sev] || 0; const count = bySeverity[sev] || 0;
@@ -197,6 +210,11 @@ async function refreshDashboard() {
}); });
renderSeverityDonut(bySeverity, total); renderSeverityDonut(bySeverity, total);
renderVulnStatusPanel(vulnRes.by_status || {}, total); renderVulnStatusPanel(vulnRes.by_status || {}, total);
renderSeverityInsights(
{ critical: openCriticalCount, high: openHighCount, medium: openMediumCount, low: openLowCount },
openCriticalCount + openHighCount + openMediumCount + openLowCount,
recentVulnsRes
);
// 漏洞 KPI 副标:徽章/文案均使用「待处理」口径 // 漏洞 KPI 副标:徽章/文案均使用「待处理」口径
const critBadge = document.getElementById('dashboard-kpi-vuln-critical-badge'); const critBadge = document.getElementById('dashboard-kpi-vuln-critical-badge');
@@ -225,6 +243,7 @@ async function refreshDashboard() {
}); });
renderSeverityDonut({}, 0); renderSeverityDonut({}, 0);
renderVulnStatusPanel(null, 0); renderVulnStatusPanel(null, 0);
renderSeverityInsights(null, 0, null);
hideEl('dashboard-kpi-vuln-critical-badge'); hideEl('dashboard-kpi-vuln-critical-badge');
setKpiSubText('dashboard-kpi-vuln-sub-text', '-'); setKpiSubText('dashboard-kpi-vuln-sub-text', '-');
} }
@@ -505,10 +524,34 @@ function setKpiRateBadge(id, rate, failedCount) {
} }
} }
// sessionStorage:告警条「×」忽略记录 + 最近一次**实际展示过**的 reason 片段(不含 level),
// 用于在「问题从多变少」(如审完 HITL 后只剩严重漏洞)时,避免误用更早对「仅子集」的忽略。
var DASH_SESSION_ALERT_DISMISSED = 'dashboard.dismissedAlert';
var DASH_SESSION_ALERT_LAST_REASONS = 'dashboard.alertLastReasons';
function dashboardAlertReasonKeySetFromJoined(s) {
if (!s || typeof s !== 'string') return new Set();
return new Set(s.split(',').map(function (x) { return x.trim(); }).filter(Boolean));
}
/** 当前 reason 片段相对上次展示的片段是否为真子集(用于清除过时的忽略) */
function dashboardAlertCurrentIsStrictSubsetOfLastShown(currentReasonJoined, lastReasonJoined) {
var cur = dashboardAlertReasonKeySetFromJoined(currentReasonJoined);
var last = dashboardAlertReasonKeySetFromJoined(lastReasonJoined);
if (cur.size === 0 || last.size === 0) return false;
if (cur.size >= last.size) return false;
var ok = true;
cur.forEach(function (k) {
if (!last.has(k)) ok = false;
});
return ok;
}
// 关键提醒条:根据严重情况渲染或隐藏。 // 关键提醒条:根据严重情况渲染或隐藏。
// - level: danger(红) > warning(橙) > info(蓝),按 reasons 自动取最高级 // - level: danger(红) > warning(橙) > info(蓝),按 reasons 自动取最高级
// - 用户点 × 后,把当前 reasons 指纹存入 sessionStorage,本会话内再出现完全相同的内容会自动跳过 // - 用户点 × 后,把当前 reasons 指纹存入 sessionStorage,本会话内再出现完全相同的内容会自动跳过
// - 当 reasons 集合发生变化(如又新增一类问题),指纹失效,banner 重新弹出,避免「忽略后永远不再提醒」 // - 当 reasons 集合发生变化(如又新增一类问题),指纹失效,banner 重新弹出,避免「忽略后永远不再提醒」
// - 若曾展示过「更多类问题」的组合,之后仅部分问题消失,即使指纹与早年忽略相同,也会清除忽略并继续提醒(见 dashboard.alertLastReasons
function renderDashboardAlertBanner(stats) { function renderDashboardAlertBanner(stats) {
const banner = document.getElementById('dashboard-alert-banner'); const banner = document.getElementById('dashboard-alert-banner');
const titleEl = document.getElementById('dashboard-alert-title'); const titleEl = document.getElementById('dashboard-alert-title');
@@ -552,15 +595,28 @@ function renderDashboardAlertBanner(stats) {
banner.hidden = true; banner.hidden = true;
banner.classList.remove('is-warning', 'is-danger', 'is-info'); banner.classList.remove('is-warning', 'is-danger', 'is-info');
dashboardState.dismissedAlertKey = null; dashboardState.dismissedAlertKey = null;
try { sessionStorage.removeItem(DASH_SESSION_ALERT_LAST_REASONS); } catch (_) {}
return; return;
} }
var fingerprint = level + '|' + reasonKeys.join(','); var fingerprint = level + '|' + reasonKeys.join(',');
var reasonPartJoined = reasonKeys.join(',');
// 检查是否被本会话忽略过同样的内容;若当前仅为「上次曾展示组合」的真子集,则清除忽略(最佳实践:部分处置后仍提醒剩余项)
var dismissed = null;
try { dismissed = sessionStorage.getItem(DASH_SESSION_ALERT_DISMISSED); } catch (_) {}
var lastShownReasons = '';
try { lastShownReasons = sessionStorage.getItem(DASH_SESSION_ALERT_LAST_REASONS) || ''; } catch (_) {}
if (dismissed === fingerprint && dashboardAlertCurrentIsStrictSubsetOfLastShown(reasonPartJoined, lastShownReasons)) {
try {
sessionStorage.removeItem(DASH_SESSION_ALERT_DISMISSED);
dismissed = null;
} catch (_) { /* ignore */ }
}
dashboardState.dismissedAlertKey = fingerprint; dashboardState.dismissedAlertKey = fingerprint;
// 检查是否被本会话忽略过同样的内容
var dismissed = null;
try { dismissed = sessionStorage.getItem('dashboard.dismissedAlert'); } catch (_) {}
if (dismissed === fingerprint) { if (dismissed === fingerprint) {
banner.hidden = true; banner.hidden = true;
return; return;
@@ -609,6 +665,8 @@ function renderDashboardAlertBanner(stats) {
btn.onclick = function () { try { switchPage('mcp-management'); } catch (e) {} }; btn.onclick = function () { try { switchPage('mcp-management'); } catch (e) {} };
actsEl.appendChild(btn); actsEl.appendChild(btn);
} }
try { sessionStorage.setItem(DASH_SESSION_ALERT_LAST_REASONS, reasonPartJoined); } catch (_) {}
} }
// External MCP 健康度:从 /api/external-mcp/stats 解析出 running / total / down // External MCP 健康度:从 /api/external-mcp/stats 解析出 running / total / down
@@ -662,8 +720,8 @@ function getHitlPendingCount(res) {
// 「最近事件」内联展示:取通知摘要里最重要的前 N 条 // 「最近事件」内联展示:取通知摘要里最重要的前 N 条
// 设计原则: // 设计原则:
// - 不重复 alert banner / KPI 已表达过的信息(漏洞、HITL 等会被过滤掉避免冗余 // - 不重复 alert banner / KPI 已表达的「新漏洞」通知(vulnerability_created 仍过滤
// - 只显示 p0/p1 优先级,p2 作为兜底(当 p0/p1 不够时) // - HITL 待审批在推荐操作等处也会提示,但仍在此展示时间线,便于与任务完成等并列查看
// - 整个 section 在没有可显示内容时整个隐藏,避免空模块占地方 // - 整个 section 在没有可显示内容时整个隐藏,避免空模块占地方
function renderRecentEvents(notifRes) { function renderRecentEvents(notifRes) {
var section = document.getElementById('dashboard-section-events'); var section = document.getElementById('dashboard-section-events');
@@ -671,8 +729,8 @@ function renderRecentEvents(notifRes) {
if (!section || !listEl) return; if (!section || !listEl) return;
var items = (notifRes && Array.isArray(notifRes.items)) ? notifRes.items : []; var items = (notifRes && Array.isArray(notifRes.items)) ? notifRes.items : [];
// 过滤:只看有意义的事件,去掉 actionable 已处理的、以及类型已经在仪表盘其他位置覆盖的 // 过滤:去掉新漏洞类型(与「最近漏洞」等板块避免重复);HITL 不再过滤
var coveredTypes = { 'vulnerability_created': true, 'hitl_pending': true }; var coveredTypes = { 'vulnerability_created': true };
var filtered = items.filter(function (it) { var filtered = items.filter(function (it) {
if (!it || !it.type) return false; if (!it || !it.type) return false;
if (coveredTypes[it.type]) return false; if (coveredTypes[it.type]) return false;
@@ -685,8 +743,8 @@ function renderRecentEvents(notifRes) {
var la = levelOrder[a.level] != null ? levelOrder[a.level] : 9; var la = levelOrder[a.level] != null ? levelOrder[a.level] : 9;
var lb = levelOrder[b.level] != null ? levelOrder[b.level] : 9; var lb = levelOrder[b.level] != null ? levelOrder[b.level] : 9;
if (la !== lb) return la - lb; if (la !== lb) return la - lb;
var ta = a.createdAt || a.created_at || 0; var ta = a.ts || a.createdAt || a.created_at || 0;
var tb = b.createdAt || b.created_at || 0; var tb = b.ts || b.createdAt || b.created_at || 0;
return new Date(tb).getTime() - new Date(ta).getTime(); return new Date(tb).getTime() - new Date(ta).getTime();
}); });
@@ -701,8 +759,9 @@ function renderRecentEvents(notifRes) {
listEl.innerHTML = top.map(function (it) { listEl.innerHTML = top.map(function (it) {
var level = it.level || 'p2'; var level = it.level || 'p2';
var title = esc(it.title || it.message || dt('dashboard.eventUntitled', null, '事件')); var title = esc(it.title || it.message || dt('dashboard.eventUntitled', null, '事件'));
var msg = esc(it.message || it.summary || ''); var msg = esc(it.message || it.summary || it.desc || '');
var when = esc(timeAgoStr(it.createdAt || it.created_at)); var whenRaw = timeAgoStr(it.ts || it.createdAt || it.created_at);
var when = esc(whenRaw || '—');
return ( return (
'<div class="dashboard-event-item lvl-' + esc(level) + '">' + '<div class="dashboard-event-item lvl-' + esc(level) + '">' +
'<span class="dashboard-event-dot" aria-hidden="true"></span>' + '<span class="dashboard-event-dot" aria-hidden="true"></span>' +
@@ -730,7 +789,7 @@ function renderRecommendedActions(state) {
if (state.openCriticalCount > 0) { if (state.openCriticalCount > 0) {
actions.push({ actions.push({
level: 'urgent', level: 'urgent',
icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><circle cx="12" cy="17" r="1" fill="currentColor" stroke="none"/></svg>',
title: dt('dashboard.recoFixCritical', { count: state.openCriticalCount }, title: dt('dashboard.recoFixCritical', { count: state.openCriticalCount },
'修复 ' + state.openCriticalCount + ' 个待处理严重漏洞'), '修复 ' + state.openCriticalCount + ' 个待处理严重漏洞'),
desc: dt('dashboard.recoFixCriticalDesc', null, '严重等级的漏洞应优先处置'), desc: dt('dashboard.recoFixCriticalDesc', null, '严重等级的漏洞应优先处置'),
@@ -784,7 +843,7 @@ function renderRecommendedActions(state) {
actions.push({ actions.push({
level: 'setup', level: 'setup',
icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
title: dt('dashboard.recoStartScan', null, '开始第一次扫描'), title: dt('dashboard.recoStartScan', null, '在对话中发起扫描'),
desc: dt('dashboard.recoStartScanDesc', null, '在对话中描述目标,让 AI 协助执行'), desc: dt('dashboard.recoStartScanDesc', null, '在对话中描述目标,让 AI 协助执行'),
page: 'chat' page: 'chat'
}); });
@@ -972,8 +1031,8 @@ function renderRecentVulns(res) {
'<span class="dashboard-empty-icon" aria-hidden="true">' + '<span class="dashboard-empty-icon" aria-hidden="true">' +
'<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/></svg>' + '<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/></svg>' +
'</span>' + '</span>' +
'<div class="dashboard-empty-title">' + esc(dt('dashboard.noVulnYet', null, '暂无漏洞')) + '</div>' + '<div class="dashboard-empty-title">' + esc(dt('dashboard.noVulnYet', null, '暂无最近漏洞')) + '</div>' +
'<div class="dashboard-empty-desc">' + esc(dt('dashboard.noVulnDesc', null, '系统目前安全,开始一次扫描可以发现潜在问题')) + '</div>' + '<div class="dashboard-empty-desc">' + esc(dt('dashboard.noVulnDesc', null, '此处展示近期漏洞记录;在对话中完成检测后,新结果会出现在这里')) + '</div>' +
'<button type="button" class="dashboard-empty-action" data-action="scan">' + '<button type="button" class="dashboard-empty-action" data-action="scan">' +
esc(dt('dashboard.startScanBtn', null, '前往对话发起扫描')) + ' →</button>' esc(dt('dashboard.startScanBtn', null, '前往对话发起扫描')) + ' →</button>'
); );
@@ -1093,6 +1152,81 @@ function renderVulnStatusPanel(byStatus, total) {
if (confirmedBar) confirmedBar.style.width = confirmedPct.toFixed(2) + '%'; if (confirmedBar) confirmedBar.style.width = confirmedPct.toFixed(2) + '%';
} }
// 风险概览卡:基于「待处理(open)」口径的严重度分布计算加权风险分 + 紧急徽章
//
// 为什么用 open 口径而不是全量:
// 如果用全量,全部漏洞修复后 by_severity 不变,风险分仍然居高,
// 但紧急徽章(待严重/待高危)已经归零——视觉上会出现「极高 + 0 待处理」的语义冲突。
// 改成 open 口径后,修复即卸掉风险,风险等级与紧急计数完全同步。
//
// bySeverityOpen: { critical, high, medium, low }(只统计 status=open 的漏洞;info 不计入)
// totalOpen: 待处理漏洞总数(= critical + high + medium + low),仅用于"全无待处理 → safe"判断
// recentVulnsRes: /api/vulnerabilities?limit=5 响应(用于"最近发现"时间,口径是全量,与处置状态无关)
function renderSeverityInsights(bySeverityOpen, totalOpen, recentVulnsRes) {
var riskBox = document.querySelector('.dashboard-severity-insight-risk');
var levelEl = document.getElementById('dashboard-severity-risk-level');
var fillEl = document.getElementById('dashboard-severity-risk-fill');
var scoreEl = document.getElementById('dashboard-severity-risk-score');
var urgentCriticalEl = document.getElementById('dashboard-severity-urgent-critical');
var urgentHighEl = document.getElementById('dashboard-severity-urgent-high');
var urgentCriticalCell = urgentCriticalEl ? urgentCriticalEl.closest('.dashboard-severity-insight-urgent-item') : null;
var urgentHighCell = urgentHighEl ? urgentHighEl.closest('.dashboard-severity-insight-urgent-item') : null;
var latestEl = document.getElementById('dashboard-severity-latest-time');
var sev = bySeverityOpen && typeof bySeverityOpen === 'object' ? bySeverityOpen : {};
var c = Number(sev.critical || 0) || 0;
var h = Number(sev.high || 0) || 0;
var m = Number(sev.medium || 0) || 0;
var l = Number(sev.low || 0) || 0;
// 加权分:严重 ×10、高危 ×5、中危 ×2、低危 ×0.5;信息忽略
// 阈值设计偏"保守"1 个待处理严重就进"中"2 个进"高",≥4 个进"极高"
var score = c * 10 + h * 5 + m * 2 + l * 0.5;
var level, levelKey, levelFallback;
var t = Number(totalOpen || 0) || 0;
if (t === 0 || score === 0) {
level = 'safe'; levelKey = 'dashboard.riskSafe'; levelFallback = '安全';
} else if (score <= 3) {
level = 'low'; levelKey = 'dashboard.riskLow'; levelFallback = '低';
} else if (score <= 10) {
level = 'medium'; levelKey = 'dashboard.riskMedium'; levelFallback = '中';
} else if (score <= 30) {
level = 'high'; levelKey = 'dashboard.riskHigh'; levelFallback = '高';
} else {
level = 'severe'; levelKey = 'dashboard.riskSevere'; levelFallback = '极高';
}
if (riskBox) riskBox.setAttribute('data-level', level);
if (levelEl) levelEl.textContent = dt(levelKey, null, levelFallback);
// 进度条用 0-100 线性映射:>=100 直接满格
var pct = Math.max(0, Math.min(100, score));
if (fillEl) fillEl.style.width = pct.toFixed(1) + '%';
if (scoreEl) {
// 分数保留一位小数(低危 0.5 权重可能出现非整数);整数直接显示
var displayScore = Math.round(score) === score ? String(score) : score.toFixed(1);
scoreEl.textContent = score >= 100 ? displayScore + '+' : displayScore;
}
// 紧急徽章直接复用 open 口径的 critical / high(与加权分完全同源,不会出现"风险极高 + 0 待处理"的矛盾)
if (urgentCriticalEl) urgentCriticalEl.textContent = formatNumber(c);
if (urgentHighEl) urgentHighEl.textContent = formatNumber(h);
if (urgentCriticalCell) urgentCriticalCell.classList.toggle('is-zero', c === 0);
if (urgentHighCell) urgentHighCell.classList.toggle('is-zero', h === 0);
if (latestEl) {
var list = recentVulnsRes && Array.isArray(recentVulnsRes.vulnerabilities) ? recentVulnsRes.vulnerabilities : [];
var latestIso = list.length > 0 ? list[0].created_at : null;
var timeStr = latestIso ? timeAgoStr(latestIso) : '';
if (timeStr) {
latestEl.textContent = timeStr;
latestEl.classList.remove('is-empty');
} else {
latestEl.textContent = dt('dashboard.noneYet', null, '暂无');
latestEl.classList.add('is-empty');
}
}
}
function renderDashboardToolsBar(monitorRes) { function renderDashboardToolsBar(monitorRes) {
const placeholder = document.getElementById('dashboard-tools-pie-placeholder'); const placeholder = document.getElementById('dashboard-tools-pie-placeholder');
const barChartEl = document.getElementById('dashboard-tools-bar-chart'); const barChartEl = document.getElementById('dashboard-tools-bar-chart');
@@ -1377,7 +1511,7 @@ document.addEventListener('click', function (ev) {
if (!btn) return; if (!btn) return;
ev.preventDefault(); ev.preventDefault();
var key = dashboardState.dismissedAlertKey || ''; var key = dashboardState.dismissedAlertKey || '';
try { sessionStorage.setItem('dashboard.dismissedAlert', key); } catch (_) {} try { sessionStorage.setItem(DASH_SESSION_ALERT_DISMISSED, key); } catch (_) {}
var banner = document.getElementById('dashboard-alert-banner'); var banner = document.getElementById('dashboard-alert-banner');
if (banner) banner.hidden = true; if (banner) banner.hidden = true;
}); });
+12 -4
View File
@@ -287,10 +287,18 @@
closeDropdown(); closeDropdown();
return; return;
} }
dropdown.style.display = 'block'; // 从仪表盘「查看全部」等容器外入口打开时,同一 click 会冒泡到 document
bellBtn.classList.add('active'); // handleDocumentClick 会误判为「点在外面」并立刻关掉。推迟到宏任务再展开即可。
state.dropdownOpen = true; const runOpen = async function () {
await refreshNotifications(); if (dropdown.style.display !== 'none') return;
dropdown.style.display = 'block';
bellBtn.classList.add('active');
state.dropdownOpen = true;
await refreshNotifications();
};
window.setTimeout(function () {
void runOpen();
}, 0);
} }
async function markAllSeen() { async function markAllSeen() {
+35 -5
View File
@@ -1,6 +1,28 @@
// 角色管理相关功能 // 角色管理相关功能
function _t(key, opts) { function _t(key, opts) {
return typeof window.t === 'function' ? window.t(key, opts) : key; if (typeof window.t === 'function') {
try {
var translated = window.t(key, opts);
if (typeof translated === 'string' && translated && translated !== key) {
return translated;
}
} catch (e) { /* ignore */ }
}
// i18n 未就绪或词条缺失时避免把 key 暴露给用户(与 zh-CN 默认一致)
if (key === 'roles.noDescription') return '暂无描述';
if (key === 'roles.noDescriptionShort') return '无描述';
if (key === 'roles.defaultRoleDescription') {
return '默认角色,不额外携带用户提示词,使用默认MCP';
}
return key;
}
/** 角色配置中的描述:trim,并把误存为 i18n key 的字面量视为空 */
function rolePlainDescription(role) {
const raw = typeof role.description === 'string' ? role.description.trim() : '';
if (!raw) return '';
if (raw === 'roles.noDescription' || raw === 'roles.noDescriptionShort') return '';
return raw;
} }
let currentRole = localStorage.getItem('currentRole') || ''; let currentRole = localStorage.getItem('currentRole') || '';
let roles = []; let roles = [];
@@ -56,6 +78,11 @@ function sortRoles(rolesArray) {
// 加载所有角色 // 加载所有角色
async function loadRoles() { async function loadRoles() {
if (window.i18nReady && typeof window.i18nReady.then === 'function') {
try {
await window.i18nReady;
} catch (e) { /* ignore */ }
}
try { try {
const response = await apiFetch('/api/roles'); const response = await apiFetch('/api/roles');
if (!response.ok) { if (!response.ok) {
@@ -189,8 +216,9 @@ function renderRoleSelectionSidebar() {
const icon = getRoleIcon(role); const icon = getRoleIcon(role);
// 处理默认角色的描述 // 处理默认角色的描述
let description = role.description || _t('roles.noDescription'); const plainDesc = rolePlainDescription(role);
if (isDefaultRole && !role.description) { let description = plainDesc || _t('roles.noDescription');
if (isDefaultRole && !plainDesc) {
description = _t('roles.defaultRoleDescription'); description = _t('roles.defaultRoleDescription');
} }
@@ -316,6 +344,7 @@ function renderRolesList() {
const sortedRoles = sortRoles(filteredRoles); const sortedRoles = sortRoles(filteredRoles);
rolesList.innerHTML = sortedRoles.map(role => { rolesList.innerHTML = sortedRoles.map(role => {
const plainDesc = rolePlainDescription(role);
// 获取角色图标,如果是Unicode转义格式则转换为emoji // 获取角色图标,如果是Unicode转义格式则转换为emoji
let roleIcon = role.icon || '👤'; let roleIcon = role.icon || '👤';
if (roleIcon && typeof roleIcon === 'string') { if (roleIcon && typeof roleIcon === 'string') {
@@ -369,7 +398,7 @@ function renderRolesList() {
${role.enabled !== false ? _t('roles.enabled') : _t('roles.disabled')} ${role.enabled !== false ? _t('roles.enabled') : _t('roles.disabled')}
</span> </span>
</div> </div>
<div class="role-card-description">${escapeHtml(role.description || _t('roles.noDescriptionShort'))}</div> <div class="role-card-description">${escapeHtml(plainDesc || _t('roles.noDescriptionShort'))}</div>
<div class="role-card-tools"> <div class="role-card-tools">
<span class="role-card-tools-label">${_t('roleModal.toolsLabel')}</span> <span class="role-card-tools-label">${_t('roleModal.toolsLabel')}</span>
<span class="role-card-tools-value">${toolsDisplay}</span> <span class="role-card-tools-value">${toolsDisplay}</span>
@@ -1575,9 +1604,10 @@ document.addEventListener('DOMContentLoaded', () => {
updateRoleSelectorDisplay(); updateRoleSelectorDisplay();
}); });
// 语言切换后刷新角色选择器显示(默认/自定义角色名) // 语言切换后刷新角色选择器与「选择角色」列表文案
document.addEventListener('languagechange', () => { document.addEventListener('languagechange', () => {
updateRoleSelectorDisplay(); updateRoleSelectorDisplay();
renderRoleSelectionSidebar();
}); });
// 获取当前选中的角色(供chat.js使用) // 获取当前选中的角色(供chat.js使用)
+9 -6
View File
@@ -405,10 +405,13 @@ async function loadToolsList(page = 1, searchKeyword = '') {
} }
} }
// 每行有两类复选框:行首「启用工具」与名称旁「常驻」;统计/全选只应针对行首启用复选框
const TOOL_ENABLE_CHECKBOX_SELECTOR = '#tools-list .tool-item > input[type="checkbox"]';
// 保存当前页的工具状态到全局映射 // 保存当前页的工具状态到全局映射
function saveCurrentPageToolStates() { function saveCurrentPageToolStates() {
document.querySelectorAll('#tools-list .tool-item').forEach(item => { document.querySelectorAll('#tools-list .tool-item').forEach(item => {
const checkbox = item.querySelector('input[type="checkbox"]'); const checkbox = item.querySelector(':scope > input[type="checkbox"]');
const toolKey = item.dataset.toolKey; // 使用唯一标识符 const toolKey = item.dataset.toolKey; // 使用唯一标识符
const toolName = item.dataset.toolName; const toolName = item.dataset.toolName;
const isExternal = item.dataset.isExternal === 'true'; const isExternal = item.dataset.isExternal === 'true';
@@ -745,7 +748,7 @@ function handleToolAlwaysVisibleChange(toolName, alwaysVisible) {
// 全选工具 // 全选工具
function selectAllTools() { function selectAllTools() {
document.querySelectorAll('#tools-list input[type="checkbox"]').forEach(checkbox => { document.querySelectorAll(TOOL_ENABLE_CHECKBOX_SELECTOR).forEach(checkbox => {
checkbox.checked = true; checkbox.checked = true;
// 更新全局状态映射 // 更新全局状态映射
const toolItem = checkbox.closest('.tool-item'); const toolItem = checkbox.closest('.tool-item');
@@ -769,7 +772,7 @@ function selectAllTools() {
// 全不选工具 // 全不选工具
function deselectAllTools() { function deselectAllTools() {
document.querySelectorAll('#tools-list input[type="checkbox"]').forEach(checkbox => { document.querySelectorAll(TOOL_ENABLE_CHECKBOX_SELECTOR).forEach(checkbox => {
checkbox.checked = false; checkbox.checked = false;
// 更新全局状态映射 // 更新全局状态映射
const toolItem = checkbox.closest('.tool-item'); const toolItem = checkbox.closest('.tool-item');
@@ -826,9 +829,9 @@ async function updateToolsStats() {
// 先保存当前页的状态到全局映射 // 先保存当前页的状态到全局映射
saveCurrentPageToolStates(); saveCurrentPageToolStates();
// 计算当前页的启用工具数 // 计算当前页的启用工具数(仅行首「启用」复选框,不含「常驻」)
const currentPageEnabled = Array.from(document.querySelectorAll('#tools-list input[type="checkbox"]:checked')).length; const currentPageEnabled = Array.from(document.querySelectorAll(`${TOOL_ENABLE_CHECKBOX_SELECTOR}:checked`)).length;
const currentPageTotal = document.querySelectorAll('#tools-list input[type="checkbox"]').length; const currentPageTotal = document.querySelectorAll(TOOL_ENABLE_CHECKBOX_SELECTOR).length;
// 计算所有工具的启用数 // 计算所有工具的启用数
let totalEnabled = 0; let totalEnabled = 0;
+149 -28
View File
@@ -39,6 +39,100 @@ let webshellStreamingTypingId = 0;
let webshellProbeStatusById = {}; let webshellProbeStatusById = {};
let webshellBatchProbeRunning = false; let webshellBatchProbeRunning = false;
/** 允许的响应编码,与后端 normalizeWebshellEncoding 对齐 */
const WEBSHELL_ALLOWED_ENCODINGS = ['auto', 'utf-8', 'gbk', 'gb18030'];
/** 归一化连接的 encoding 字段,返回 'auto' | 'utf-8' | 'gbk' | 'gb18030'(空/未知 → auto */
function normalizeWebshellEncoding(v) {
var s = (v == null ? '' : String(v)).trim().toLowerCase();
if (s === 'utf8') s = 'utf-8';
if (!s) return 'auto';
return WEBSHELL_ALLOWED_ENCODINGS.indexOf(s) >= 0 ? s : 'auto';
}
/** 从连接对象取编码,便于透传到 /api/webshell/exec 与 /api/webshell/file */
function webshellConnEncoding(conn) {
return normalizeWebshellEncoding(conn && conn.encoding);
}
/** 允许的目标 OS,与后端 normalizeWebshellOS 对齐 */
const WEBSHELL_ALLOWED_OS = ['auto', 'linux', 'windows'];
/** 归一化连接的 os 字段,返回 'auto' | 'linux' | 'windows'(空/未知 → auto */
function normalizeWebshellOS(v) {
var s = (v == null ? '' : String(v)).trim().toLowerCase();
if (!s) return 'auto';
return WEBSHELL_ALLOWED_OS.indexOf(s) >= 0 ? s : 'auto';
}
/** 从连接对象取目标 OS,便于透传到 /api/webshell/exec 与 /api/webshell/file */
function webshellConnOS(conn) {
return normalizeWebshellOS(conn && conn.os);
}
/**
* 组装 /api/webshell/file 的公共请求体
* 所有文件管理调用点都应走此函数避免遗漏字段 connection_id
* @param {Object} conn 连接对象
* @param {Object} extra 额外字段action / path / content / target_path / chunk_index ...
* @returns {string} JSON 字符串
*/
function webshellFileRequestBody(conn, extra) {
const base = {
url: conn.url,
password: conn.password || '',
type: conn.type || 'php',
method: (conn.method || 'post').toLowerCase(),
cmd_param: conn.cmdParam || '',
encoding: webshellConnEncoding(conn),
os: webshellConnOS(conn),
connection_id: conn.id || ''
};
const merged = Object.assign(base, extra || {});
return JSON.stringify(merged);
}
/**
* 当服务端探活命中目标系统 auto 连接首次列目录时出现
* 把结果同步到本地 webshellConnections 缓存 + 持久化到数据库
* 后续刷新不再探活AI 也能直接看到正确的 OS 上下文
*/
function applyWebshellDetectedOS(conn, data) {
if (!conn || !data || !data.detected_os) return;
const detected = normalizeWebshellOS(data.detected_os);
if (detected !== 'linux' && detected !== 'windows') return;
if (webshellConnOS(conn) !== 'auto') return; // 用户已显式配置,尊重之
conn.os = detected;
if (Array.isArray(webshellConnections)) {
for (var i = 0; i < webshellConnections.length; i++) {
if (webshellConnections[i] && webshellConnections[i].id === conn.id) {
webshellConnections[i].os = detected;
break;
}
}
}
if (typeof renderWebshellList === 'function') {
try { renderWebshellList(); } catch (e) {}
}
// 服务端已经回写了 DB;但极少数情况下调用方未带 connection_id,这里再兜底 PUT 一次
if (conn.id && typeof apiFetch === 'function') {
apiFetch('/api/webshell/connections/' + encodeURIComponent(conn.id), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: conn.url,
password: conn.password || '',
type: conn.type || 'php',
method: conn.method || 'post',
cmd_param: conn.cmdParam || '',
remark: conn.remark || '',
encoding: conn.encoding || 'auto',
os: detected
})
}).catch(function () {});
}
}
/** 与主对话页一致:Eino 模式走 /api/multi-agent/streambody 带 orchestration */ /** 与主对话页一致:Eino 模式走 /api/multi-agent/streambody 带 orchestration */
function resolveWebshellAiStreamRequest() { function resolveWebshellAiStreamRequest() {
if (typeof apiFetch === 'undefined') { if (typeof apiFetch === 'undefined') {
@@ -335,6 +429,17 @@ function wsT(key) {
'webshell.addConnection': '添加连接', 'webshell.addConnection': '添加连接',
'webshell.cmdParam': '命令参数名', 'webshell.cmdParam': '命令参数名',
'webshell.cmdParamPlaceholder': '不填默认为 cmd,如填 xxx 则请求为 xxx=命令', 'webshell.cmdParamPlaceholder': '不填默认为 cmd,如填 xxx 则请求为 xxx=命令',
'webshell.encoding': '响应编码',
'webshell.encodingAuto': '自动检测',
'webshell.encodingUtf8': 'UTF-8',
'webshell.encodingGbk': 'GBK(中文 Windows',
'webshell.encodingGb18030': 'GB18030',
'webshell.encodingHint': '中文 Windows 目标若出现乱码,请切换为 GBK 或 GB18030',
'webshell.os': '目标系统',
'webshell.osAuto': '自动(按 Shell 类型推断)',
'webshell.osLinux': 'Linux / Unix',
'webshell.osWindows': 'Windows',
'webshell.osHint': '决定文件管理/上传使用 Linux 还是 Windows 命令;PHP/JSP 跑在 Windows 上请选 Windows',
'webshell.connections': '连接列表', 'webshell.connections': '连接列表',
'webshell.noConnections': '暂无连接,请点击「添加连接」', 'webshell.noConnections': '暂无连接,请点击「添加连接」',
'webshell.selectOrAdd': '请从左侧选择连接,或添加新的 WebShell 连接', 'webshell.selectOrAdd': '请从左侧选择连接,或添加新的 WebShell 连接',
@@ -661,9 +766,20 @@ function renderWebshellList() {
} else if (probe && probe.state === 'fail') { } else if (probe && probe.state === 'fail') {
probeHtml = '<span class="webshell-probe-badge fail" title="' + escapeHtml(probe.message || '') + '">' + (wsT('webshell.probeOffline') || '离线') + '</span>'; probeHtml = '<span class="webshell-probe-badge fail" title="' + escapeHtml(probe.message || '') + '">' + (wsT('webshell.probeOffline') || '离线') + '</span>';
} }
var encNorm = normalizeWebshellEncoding(conn.encoding);
var encHtml = '';
if (encNorm && encNorm !== 'auto') {
encHtml = '<span class="webshell-probe-badge" title="' + escapeHtml(wsT('webshell.encoding') || '响应编码') + '">' + escapeHtml(encNorm.toUpperCase()) + '</span>';
}
var osNorm = normalizeWebshellOS(conn.os);
var osHtml = '';
if (osNorm && osNorm !== 'auto') {
var osLabel = osNorm === 'windows' ? 'WIN' : 'LINUX';
osHtml = '<span class="webshell-probe-badge" title="' + escapeHtml(wsT('webshell.os') || '目标系统') + '">' + osLabel + '</span>';
}
return ( return (
'<div class="webshell-item' + active + '" data-id="' + safeId + '">' + '<div class="webshell-item' + active + '" data-id="' + safeId + '">' +
'<div class="webshell-item-remark-row"><div class="webshell-item-remark" title="' + urlTitle + '">' + remark + '</div>' + probeHtml + '</div>' + '<div class="webshell-item-remark-row"><div class="webshell-item-remark" title="' + urlTitle + '">' + remark + '</div>' + probeHtml + osHtml + encHtml + '</div>' +
'<div class="webshell-item-url" title="' + urlTitle + '">' + url + '</div>' + '<div class="webshell-item-url" title="' + urlTitle + '">' + url + '</div>' +
'<div class="webshell-item-actions">' + '<div class="webshell-item-actions">' +
'<details class="webshell-conn-actions"><summary class="btn-ghost btn-sm webshell-conn-actions-btn" title="' + actionsLabel + '">' + actionsLabel + '</summary>' + '<details class="webshell-conn-actions"><summary class="btn-ghost btn-sm webshell-conn-actions-btn" title="' + actionsLabel + '">' + actionsLabel + '</summary>' +
@@ -709,6 +825,8 @@ function probeWebshellConnection(conn) {
type: conn.type || 'php', type: conn.type || 'php',
method: ((conn.method || 'post').toLowerCase() === 'get') ? 'get' : 'post', method: ((conn.method || 'post').toLowerCase() === 'get') ? 'get' : 'post',
cmd_param: conn.cmdParam || '', cmd_param: conn.cmdParam || '',
encoding: webshellConnEncoding(conn),
os: webshellConnOS(conn),
command: 'echo 1' command: 'echo 1'
}) })
}) })
@@ -3365,6 +3483,8 @@ function execWebshellCommand(conn, command) {
type: conn.type || 'php', type: conn.type || 'php',
method: (conn.method || 'post').toLowerCase(), method: (conn.method || 'post').toLowerCase(),
cmd_param: conn.cmdParam || '', cmd_param: conn.cmdParam || '',
encoding: webshellConnEncoding(conn),
os: webshellConnOS(conn),
command: command command: command
}) })
}).then(function (r) { return r.json(); }) }).then(function (r) { return r.json(); })
@@ -3391,17 +3511,10 @@ function webshellFileListDir(conn, path) {
apiFetch('/api/webshell/file', { apiFetch('/api/webshell/file', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: webshellFileRequestBody(conn, { action: 'list', path: path })
url: conn.url,
password: conn.password || '',
type: conn.type || 'php',
method: (conn.method || 'post').toLowerCase(),
cmd_param: conn.cmdParam || '',
action: 'list',
path: path
})
}).then(function (r) { return r.json(); }) }).then(function (r) { return r.json(); })
.then(function (data) { .then(function (data) {
applyWebshellDetectedOS(conn, data);
if (!data.ok && data.error) { if (!data.ok && data.error) {
listEl.innerHTML = '<div class="webshell-file-error">' + escapeHtml(data.error) + '</div><pre class="webshell-file-raw">' + escapeHtml(data.output || '') + '</pre>'; listEl.innerHTML = '<div class="webshell-file-error">' + escapeHtml(data.error) + '</div><pre class="webshell-file-raw">' + escapeHtml(data.output || '') + '</pre>';
return; return;
@@ -3497,16 +3610,9 @@ function fetchWebshellDirectoryItems(conn, path) {
return apiFetch('/api/webshell/file', { return apiFetch('/api/webshell/file', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: webshellFileRequestBody(conn, { action: 'list', path: path })
url: conn.url,
password: conn.password || '',
type: conn.type || 'php',
method: (conn.method || 'post').toLowerCase(),
cmd_param: conn.cmdParam || '',
action: 'list',
path: path
})
}).then(function (r) { return r.json(); }).then(function (data) { }).then(function (r) { return r.json(); }).then(function (data) {
applyWebshellDetectedOS(conn, data);
if (!data || data.error || !data.ok) return []; if (!data || data.error || !data.ok) return [];
return parseWebshellListItems(data.output || ''); return parseWebshellListItems(data.output || '');
}).catch(function () { }).catch(function () {
@@ -3801,7 +3907,7 @@ function webshellFileMkdir(conn, pathInput) {
var name = prompt(wsT('webshell.newDir') || '新建目录', 'newdir'); var name = prompt(wsT('webshell.newDir') || '新建目录', 'newdir');
if (name == null || !name.trim()) return; if (name == null || !name.trim()) return;
var path = base === '.' ? name.trim() : base + '/' + name.trim(); var path = base === '.' ? name.trim() : base + '/' + name.trim();
apiFetch('/api/webshell/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: conn.url, password: conn.password || '', type: conn.type || 'php', method: (conn.method || 'post').toLowerCase(), cmd_param: conn.cmdParam || '', action: 'mkdir', path: path }) }) apiFetch('/api/webshell/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: webshellFileRequestBody(conn, { action: 'mkdir', path: path }) })
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
.then(function () { webshellFileListDir(conn, base); }) .then(function () { webshellFileListDir(conn, base); })
.catch(function () { webshellFileListDir(conn, base); }); .catch(function () { webshellFileListDir(conn, base); });
@@ -3848,7 +3954,7 @@ function webshellFileUpload(conn, pathInput) {
webshellFileListDir(conn, base); webshellFileListDir(conn, base);
return; return;
} }
apiFetch('/api/webshell/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: conn.url, password: conn.password || '', type: conn.type || 'php', method: (conn.method || 'post').toLowerCase(), cmd_param: conn.cmdParam || '', action: 'upload_chunk', path: path, content: base64Chunks[idx], chunk_index: idx }) }) apiFetch('/api/webshell/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: webshellFileRequestBody(conn, { action: 'upload_chunk', path: path, content: base64Chunks[idx], chunk_index: idx }) })
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
.then(function () { idx++; sendNext(); }) .then(function () { idx++; sendNext(); })
.catch(function () { idx++; sendNext(); }); .catch(function () { idx++; sendNext(); });
@@ -3867,7 +3973,7 @@ function webshellFileRename(conn, oldPath, oldName, listEl) {
var parts = oldPath.split('/'); var parts = oldPath.split('/');
var dir = parts.length > 1 ? parts.slice(0, -1).join('/') + '/' : ''; var dir = parts.length > 1 ? parts.slice(0, -1).join('/') + '/' : '';
var newPath = dir + newName.trim(); var newPath = dir + newName.trim();
apiFetch('/api/webshell/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: conn.url, password: conn.password || '', type: conn.type || 'php', method: (conn.method || 'post').toLowerCase(), cmd_param: conn.cmdParam || '', action: 'rename', path: oldPath, target_path: newPath }) }) apiFetch('/api/webshell/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: webshellFileRequestBody(conn, { action: 'rename', path: oldPath, target_path: newPath }) })
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
.then(function () { webshellFileListDir(conn, document.getElementById('webshell-file-path').value.trim() || '.'); }) .then(function () { webshellFileListDir(conn, document.getElementById('webshell-file-path').value.trim() || '.'); })
.catch(function () { webshellFileListDir(conn, document.getElementById('webshell-file-path').value.trim() || '.'); }); .catch(function () { webshellFileListDir(conn, document.getElementById('webshell-file-path').value.trim() || '.'); });
@@ -3906,7 +4012,7 @@ function webshellFileDownload(conn, path) {
apiFetch('/api/webshell/file', { apiFetch('/api/webshell/file', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: conn.url, password: conn.password || '', type: conn.type || 'php', method: (conn.method || 'post').toLowerCase(), cmd_param: conn.cmdParam || '', action: 'read', path: path }) body: webshellFileRequestBody(conn, { action: 'read', path: path })
}).then(function (r) { return r.json(); }) }).then(function (r) { return r.json(); })
.then(function (data) { .then(function (data) {
var content = (data && data.output) != null ? data.output : (data.error || ''); var content = (data && data.output) != null ? data.output : (data.error || '');
@@ -3927,7 +4033,7 @@ function webshellFileRead(conn, path, listEl, browsePath) {
apiFetch('/api/webshell/file', { apiFetch('/api/webshell/file', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: conn.url, password: conn.password || '', type: conn.type || 'php', method: (conn.method || 'post').toLowerCase(), cmd_param: conn.cmdParam || '', action: 'read', path: path }) body: webshellFileRequestBody(conn, { action: 'read', path: path })
}).then(function (r) { return r.json(); }) }).then(function (r) { return r.json(); })
.then(function (data) { .then(function (data) {
const out = (data && data.output) ? data.output : (data.error || ''); const out = (data && data.output) ? data.output : (data.error || '');
@@ -3956,7 +4062,7 @@ function webshellFileEdit(conn, path, listEl) {
apiFetch('/api/webshell/file', { apiFetch('/api/webshell/file', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: conn.url, password: conn.password || '', type: conn.type || 'php', method: (conn.method || 'post').toLowerCase(), cmd_param: conn.cmdParam || '', action: 'read', path: path }) body: webshellFileRequestBody(conn, { action: 'read', path: path })
}).then(function (r) { return r.json(); }) }).then(function (r) { return r.json(); })
.then(function (data) { .then(function (data) {
const content = (data && data.output) ? data.output : (data.error || ''); const content = (data && data.output) ? data.output : (data.error || '');
@@ -3992,7 +4098,7 @@ function webshellFileWrite(conn, path, content, onDone, listEl) {
apiFetch('/api/webshell/file', { apiFetch('/api/webshell/file', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: conn.url, password: conn.password || '', type: conn.type || 'php', method: (conn.method || 'post').toLowerCase(), cmd_param: conn.cmdParam || '', action: 'write', path: path, content: content }) body: webshellFileRequestBody(conn, { action: 'write', path: path, content: content })
}).then(function (r) { return r.json(); }) }).then(function (r) { return r.json(); })
.then(function (data) { .then(function (data) {
if (data && !data.ok && data.error && listEl) { if (data && !data.ok && data.error && listEl) {
@@ -4011,7 +4117,7 @@ function webshellFileDelete(conn, path, onDone) {
apiFetch('/api/webshell/file', { apiFetch('/api/webshell/file', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: conn.url, password: conn.password || '', type: conn.type || 'php', method: (conn.method || 'post').toLowerCase(), cmd_param: conn.cmdParam || '', action: 'delete', path: path }) body: webshellFileRequestBody(conn, { action: 'delete', path: path })
}).then(function (r) { return r.json(); }) }).then(function (r) { return r.json(); })
.then(function () { if (onDone) onDone(); }) .then(function () { if (onDone) onDone(); })
.catch(function () { if (onDone) onDone(); }); .catch(function () { if (onDone) onDone(); });
@@ -4063,6 +4169,10 @@ function showAddWebshellModal() {
document.getElementById('webshell-type').value = 'php'; document.getElementById('webshell-type').value = 'php';
document.getElementById('webshell-method').value = 'post'; document.getElementById('webshell-method').value = 'post';
document.getElementById('webshell-cmd-param').value = ''; document.getElementById('webshell-cmd-param').value = '';
var osSelEl = document.getElementById('webshell-os');
if (osSelEl) osSelEl.value = 'auto';
var encSelEl = document.getElementById('webshell-encoding');
if (encSelEl) encSelEl.value = 'auto';
document.getElementById('webshell-remark').value = ''; document.getElementById('webshell-remark').value = '';
var titleEl = document.getElementById('webshell-modal-title'); var titleEl = document.getElementById('webshell-modal-title');
if (titleEl) titleEl.textContent = wsT('webshell.addConnection'); if (titleEl) titleEl.textContent = wsT('webshell.addConnection');
@@ -4081,6 +4191,10 @@ function showEditWebshellModal(connId) {
document.getElementById('webshell-type').value = conn.type || 'php'; document.getElementById('webshell-type').value = conn.type || 'php';
document.getElementById('webshell-method').value = (conn.method || 'post').toLowerCase(); document.getElementById('webshell-method').value = (conn.method || 'post').toLowerCase();
document.getElementById('webshell-cmd-param').value = conn.cmdParam || ''; document.getElementById('webshell-cmd-param').value = conn.cmdParam || '';
var osEditEl = document.getElementById('webshell-os');
if (osEditEl) osEditEl.value = normalizeWebshellOS(conn.os);
var encEditEl = document.getElementById('webshell-encoding');
if (encEditEl) encEditEl.value = normalizeWebshellEncoding(conn.encoding);
document.getElementById('webshell-remark').value = conn.remark || ''; document.getElementById('webshell-remark').value = conn.remark || '';
var titleEl = document.getElementById('webshell-modal-title'); var titleEl = document.getElementById('webshell-modal-title');
if (titleEl) titleEl.textContent = wsT('webshell.editConnectionTitle'); if (titleEl) titleEl.textContent = wsT('webshell.editConnectionTitle');
@@ -4308,6 +4422,8 @@ function testWebshellConnection() {
var method = ((document.getElementById('webshell-method') || {}).value || 'post').toLowerCase(); var method = ((document.getElementById('webshell-method') || {}).value || 'post').toLowerCase();
var cmdParam = (document.getElementById('webshell-cmd-param') || {}).value; var cmdParam = (document.getElementById('webshell-cmd-param') || {}).value;
if (cmdParam && typeof cmdParam.trim === 'function') cmdParam = cmdParam.trim(); else cmdParam = ''; if (cmdParam && typeof cmdParam.trim === 'function') cmdParam = cmdParam.trim(); else cmdParam = '';
var osTag = normalizeWebshellOS((document.getElementById('webshell-os') || {}).value);
var encoding = normalizeWebshellEncoding((document.getElementById('webshell-encoding') || {}).value);
var btn = document.getElementById('webshell-test-btn'); var btn = document.getElementById('webshell-test-btn');
if (btn) { btn.disabled = true; btn.textContent = (typeof wsT === 'function' ? wsT('common.refresh') : '刷新') + '...'; } if (btn) { btn.disabled = true; btn.textContent = (typeof wsT === 'function' ? wsT('common.refresh') : '刷新') + '...'; }
if (typeof apiFetch === 'undefined') { if (typeof apiFetch === 'undefined') {
@@ -4315,6 +4431,7 @@ function testWebshellConnection() {
alert(wsT('webshell.testFailed') || '连通性测试失败'); alert(wsT('webshell.testFailed') || '连通性测试失败');
return; return;
} }
// 连通性使用 Windows/Linux 都识别的最小内建命令作为探测(echo 1 在 cmd 和 sh 下行为等价)
apiFetch('/api/webshell/exec', { apiFetch('/api/webshell/exec', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -4324,6 +4441,8 @@ function testWebshellConnection() {
type: type, type: type,
method: method === 'get' ? 'get' : 'post', method: method === 'get' ? 'get' : 'post',
cmd_param: cmdParam || '', cmd_param: cmdParam || '',
encoding: encoding,
os: osTag,
command: 'echo 1' command: 'echo 1'
}) })
}) })
@@ -4369,12 +4488,14 @@ function saveWebshellConnection() {
var method = ((document.getElementById('webshell-method') || {}).value || 'post').toLowerCase(); var method = ((document.getElementById('webshell-method') || {}).value || 'post').toLowerCase();
var cmdParam = (document.getElementById('webshell-cmd-param') || {}).value; var cmdParam = (document.getElementById('webshell-cmd-param') || {}).value;
if (cmdParam && typeof cmdParam.trim === 'function') cmdParam = cmdParam.trim(); else cmdParam = ''; if (cmdParam && typeof cmdParam.trim === 'function') cmdParam = cmdParam.trim(); else cmdParam = '';
var osTag = normalizeWebshellOS((document.getElementById('webshell-os') || {}).value);
var encoding = normalizeWebshellEncoding((document.getElementById('webshell-encoding') || {}).value);
var remark = (document.getElementById('webshell-remark') || {}).value; var remark = (document.getElementById('webshell-remark') || {}).value;
if (remark && typeof remark.trim === 'function') remark = remark.trim(); else remark = ''; if (remark && typeof remark.trim === 'function') remark = remark.trim(); else remark = '';
var editIdEl = document.getElementById('webshell-edit-id'); var editIdEl = document.getElementById('webshell-edit-id');
var editId = editIdEl ? editIdEl.value.trim() : ''; var editId = editIdEl ? editIdEl.value.trim() : '';
var body = { url: url, password: password, type: type, method: method === 'get' ? 'get' : 'post', cmd_param: cmdParam, remark: remark || url }; var body = { url: url, password: password, type: type, method: method === 'get' ? 'get' : 'post', cmd_param: cmdParam, encoding: encoding, os: osTag, remark: remark || url };
if (typeof apiFetch === 'undefined') return; if (typeof apiFetch === 'undefined') return;
var reqUrl = editId ? ('/api/webshell/connections/' + encodeURIComponent(editId)) : '/api/webshell/connections'; var reqUrl = editId ? ('/api/webshell/connections/' + encodeURIComponent(editId)) : '/api/webshell/connections';
+54 -1
View File
@@ -388,6 +388,40 @@
<a class="dashboard-section-link" onclick="switchPage('vulnerabilities')" data-i18n="dashboard.viewAll">查看全部 →</a> <a class="dashboard-section-link" onclick="switchPage('vulnerabilities')" data-i18n="dashboard.viewAll">查看全部 →</a>
</div> </div>
<div class="dashboard-severity-wrap"> <div class="dashboard-severity-wrap">
<!-- 风险概览卡:填充 donut 左侧留白;提供「结论性」洞察(风险等级/加权分/待处理计数/最新时间),
与右侧 legend 的「明细」形成互补,避免和下方「最近漏洞」列表重复 -->
<aside class="dashboard-severity-insights" aria-label="风险概览">
<div class="dashboard-severity-insight-risk" data-level="safe">
<div class="dashboard-severity-insight-head">
<span class="dashboard-severity-insight-label" data-i18n="dashboard.riskLevel">风险等级</span>
<span class="dashboard-severity-insight-risk-badge" id="dashboard-severity-risk-level" data-i18n="dashboard.riskSafe">安全</span>
</div>
<div class="dashboard-severity-insight-score-track" aria-hidden="true">
<div class="dashboard-severity-insight-score-fill" id="dashboard-severity-risk-fill" style="width: 0%"></div>
</div>
<div class="dashboard-severity-insight-score-meta">
<span class="dashboard-severity-insight-score-label" data-i18n="dashboard.riskScore">加权风险分</span>
<span class="dashboard-severity-insight-score-value" id="dashboard-severity-risk-score">0</span>
</div>
</div>
<div class="dashboard-severity-insight-urgent-group">
<span class="dashboard-severity-insight-label" data-i18n="dashboard.statusOpen">待处理</span>
<div class="dashboard-severity-insight-urgent">
<div class="dashboard-severity-insight-urgent-item u-critical" role="button" tabindex="0" onclick="switchPage('vulnerabilities')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('vulnerabilities'); }" title="查看待处理严重漏洞">
<span class="dashboard-severity-insight-urgent-value" id="dashboard-severity-urgent-critical">0</span>
<span class="dashboard-severity-insight-urgent-label" data-i18n="dashboard.severityCritical">严重</span>
</div>
<div class="dashboard-severity-insight-urgent-item u-high" role="button" tabindex="0" onclick="switchPage('vulnerabilities')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('vulnerabilities'); }" title="查看待处理高危漏洞">
<span class="dashboard-severity-insight-urgent-value" id="dashboard-severity-urgent-high">0</span>
<span class="dashboard-severity-insight-urgent-label" data-i18n="dashboard.severityHigh">高危</span>
</div>
</div>
</div>
<div class="dashboard-severity-insight-latest">
<span class="dashboard-severity-insight-label" data-i18n="dashboard.latestFound">最近发现</span>
<span class="dashboard-severity-insight-time" id="dashboard-severity-latest-time" data-i18n="dashboard.noneYet">暂无</span>
</div>
</aside>
<div class="dashboard-severity-chart"> <div class="dashboard-severity-chart">
<svg class="dashboard-severity-donut" id="dashboard-severity-donut" viewBox="0 0 480 260" preserveAspectRatio="xMidYMid meet" aria-hidden="true"> <svg class="dashboard-severity-donut" id="dashboard-severity-donut" viewBox="0 0 480 260" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
<g id="dashboard-severity-donut-track"></g> <g id="dashboard-severity-donut-track"></g>
@@ -498,7 +532,7 @@
<a class="dashboard-section-link" onclick="switchPage('vulnerabilities')" data-i18n="dashboard.viewAll">查看全部 →</a> <a class="dashboard-section-link" onclick="switchPage('vulnerabilities')" data-i18n="dashboard.viewAll">查看全部 →</a>
</div> </div>
<div class="dashboard-recent-vulns" id="dashboard-recent-vulns"> <div class="dashboard-recent-vulns" id="dashboard-recent-vulns">
<div class="dashboard-recent-vulns-empty" id="dashboard-recent-vulns-empty" data-i18n="dashboard.noVulnYet">暂无漏洞,开始你的第一次扫描吧</div> <div class="dashboard-recent-vulns-empty" id="dashboard-recent-vulns-empty" data-i18n="dashboard.noVulnYet">暂无最近漏洞</div>
</div> </div>
</section> </section>
<section class="dashboard-section dashboard-section-overview"> <section class="dashboard-section dashboard-section-overview">
@@ -2925,6 +2959,25 @@
<label for="webshell-cmd-param" data-i18n="webshell.cmdParam">命令参数名</label> <label for="webshell-cmd-param" data-i18n="webshell.cmdParam">命令参数名</label>
<input type="text" id="webshell-cmd-param" data-i18n="webshell.cmdParamPlaceholder" data-i18n-attr="placeholder" placeholder="不填默认为 cmd,如 xxx 则请求为 xxx=命令" /> <input type="text" id="webshell-cmd-param" data-i18n="webshell.cmdParamPlaceholder" data-i18n-attr="placeholder" placeholder="不填默认为 cmd,如 xxx 则请求为 xxx=命令" />
</div> </div>
<div class="form-group">
<label for="webshell-os" data-i18n="webshell.os">目标系统</label>
<select id="webshell-os">
<option value="auto" data-i18n="webshell.osAuto">自动(按 Shell 类型推断)</option>
<option value="linux" data-i18n="webshell.osLinux">Linux / Unix</option>
<option value="windows" data-i18n="webshell.osWindows">Windows</option>
</select>
<small class="form-hint" data-i18n="webshell.osHint">决定文件管理/上传使用 Linux 还是 Windows 命令;PHP/JSP 跑在 Windows 上请选 Windows</small>
</div>
<div class="form-group">
<label for="webshell-encoding" data-i18n="webshell.encoding">响应编码</label>
<select id="webshell-encoding">
<option value="auto" data-i18n="webshell.encodingAuto">自动检测</option>
<option value="utf-8" data-i18n="webshell.encodingUtf8">UTF-8</option>
<option value="gbk" data-i18n="webshell.encodingGbk">GBK(中文 Windows</option>
<option value="gb18030" data-i18n="webshell.encodingGb18030">GB18030</option>
</select>
<small class="form-hint" data-i18n="webshell.encodingHint">中文 Windows 目标若出现乱码,请切换为 GBK 或 GB18030</small>
</div>
<div class="form-group"> <div class="form-group">
<label for="webshell-remark" data-i18n="webshell.remark">备注</label> <label for="webshell-remark" data-i18n="webshell.remark">备注</label>
<input type="text" id="webshell-remark" data-i18n="webshell.remarkPlaceholder" data-i18n-attr="placeholder" placeholder="便于识别的备注名" /> <input type="text" id="webshell-remark" data-i18n="webshell.remarkPlaceholder" data-i18n-attr="placeholder" placeholder="便于识别的备注名" />