mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-05-16 21:23:29 +02:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dec69a1993 | |||
| 15aab2584a | |||
| 399b697d75 | |||
| e0753fd03e | |||
| 9b1e493023 | |||
| 77d212098d | |||
| 39926007fe | |||
| 0e35506ae1 | |||
| 9ff8bfa44b | |||
| 1d9fcfd87e | |||
| 91cb650234 | |||
| 44e7d3b340 | |||
| 531b05299a | |||
| 0de69a6345 |
+1
-1
@@ -10,7 +10,7 @@
|
||||
# ============================================
|
||||
|
||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||
version: "v1.5.15"
|
||||
version: "v1.5.17"
|
||||
# 服务器配置
|
||||
server:
|
||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||
|
||||
@@ -269,6 +269,8 @@ func (db *DB) initTables() error {
|
||||
method TEXT NOT NULL DEFAULT 'post',
|
||||
cmd_param 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
|
||||
);`
|
||||
|
||||
@@ -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 {
|
||||
return fmt.Errorf("创建索引失败: %w", err)
|
||||
}
|
||||
@@ -732,6 +739,37 @@ func (db *DB) migrateVulnerabilitiesTable() error {
|
||||
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 创建知识库数据库连接(只包含知识库相关的表)
|
||||
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")
|
||||
|
||||
@@ -16,6 +16,8 @@ type WebShellConnection struct {
|
||||
Method string `json:"method"`
|
||||
CmdParam string `json:"cmdParam"`
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -58,7 +60,8 @@ func (db *DB) UpsertWebshellConnectionState(connectionID, stateJSON string) erro
|
||||
// ListWebshellConnections 列出所有 WebShell 连接,按创建时间倒序
|
||||
func (db *DB) ListWebshellConnections() ([]WebShellConnection, error) {
|
||||
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
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
@@ -72,7 +75,7 @@ func (db *DB) ListWebshellConnections() ([]WebShellConnection, error) {
|
||||
var list []WebShellConnection
|
||||
for rows.Next() {
|
||||
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 {
|
||||
db.logger.Warn("扫描 WebShell 连接行失败", zap.Error(err))
|
||||
continue
|
||||
@@ -85,11 +88,12 @@ func (db *DB) ListWebshellConnections() ([]WebShellConnection, error) {
|
||||
// GetWebshellConnection 根据 ID 获取一条连接
|
||||
func (db *DB) GetWebshellConnection(id string) (*WebShellConnection, error) {
|
||||
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 = ?
|
||||
`
|
||||
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 {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -103,10 +107,10 @@ func (db *DB) GetWebshellConnection(id string) (*WebShellConnection, error) {
|
||||
// CreateWebshellConnection 创建 WebShell 连接
|
||||
func (db *DB) CreateWebshellConnection(c *WebShellConnection) error {
|
||||
query := `
|
||||
INSERT INTO webshell_connections (id, url, password, type, method, cmd_param, remark, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO webshell_connections (id, url, password, type, method, cmd_param, remark, encoding, os, created_at)
|
||||
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 {
|
||||
db.logger.Error("创建 WebShell 连接失败", zap.Error(err), zap.String("id", c.ID))
|
||||
return err
|
||||
@@ -118,10 +122,10 @@ func (db *DB) CreateWebshellConnection(c *WebShellConnection) error {
|
||||
func (db *DB) UpdateWebshellConnection(c *WebShellConnection) error {
|
||||
query := `
|
||||
UPDATE webshell_connections
|
||||
SET url = ?, password = ?, type = ?, method = ?, cmd_param = ?, remark = ?
|
||||
SET url = ?, password = ?, type = ?, method = ?, cmd_param = ?, remark = ?, encoding = ?, os = ?
|
||||
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 {
|
||||
db.logger.Error("更新 WebShell 连接失败", zap.Error(err), zap.String("id", c.ID))
|
||||
return err
|
||||
|
||||
@@ -539,12 +539,7 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "未找到该 WebShell 连接"})
|
||||
return
|
||||
}
|
||||
remark := conn.Remark
|
||||
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)
|
||||
webshellContext := BuildWebshellAssistantContext(conn, WebshellSkillHintDefault, req.Message)
|
||||
// WebShell 模式下如果同时指定了角色,追加角色 user_prompt(工具集仍仅限 webshell 专用工具)
|
||||
if req.Role != "" && req.Role != "默认" && h.config.Roles != nil {
|
||||
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)
|
||||
return
|
||||
}
|
||||
remark := conn.Remark
|
||||
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)
|
||||
webshellContext := BuildWebshellAssistantContext(conn, WebshellSkillHintDefault, req.Message)
|
||||
// WebShell 模式下如果同时指定了角色,追加角色 user_prompt(工具集仍仅限 webshell 专用工具)
|
||||
if req.Role != "" && req.Role != "默认" && h.config.Roles != nil {
|
||||
if role, exists := h.config.Roles[req.Role]; exists && role.Enabled && role.UserPrompt != "" {
|
||||
|
||||
@@ -73,12 +73,7 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest) (*multiAgentPr
|
||||
h.logger.Warn("WebShell AI 助手:未找到连接", zap.String("id", req.WebShellConnectionID), zap.Error(errConn))
|
||||
return nil, fmt.Errorf("未找到该 WebShell 连接")
|
||||
}
|
||||
remark := conn.Remark
|
||||
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)
|
||||
webshellContext := BuildWebshellAssistantContext(conn, WebshellSkillHintMultiAgent, req.Message)
|
||||
// WebShell 模式下如果同时指定了角色,追加角色 user_prompt(工具集仍仅限 webshell 专用工具)
|
||||
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 != "" {
|
||||
|
||||
+369
-138
@@ -3,20 +3,302 @@ package handler
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"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 命令(类似冰蝎/蚁剑),避免前端跨域并统一构建请求
|
||||
type WebShellHandler struct {
|
||||
logger *zap.Logger
|
||||
@@ -44,6 +326,8 @@ type CreateConnectionRequest struct {
|
||||
Method string `json:"method"`
|
||||
CmdParam string `json:"cmd_param"`
|
||||
Remark string `json:"remark"`
|
||||
Encoding string `json:"encoding"`
|
||||
OS string `json:"os"`
|
||||
}
|
||||
|
||||
// UpdateConnectionRequest 更新连接请求
|
||||
@@ -54,6 +338,8 @@ type UpdateConnectionRequest struct {
|
||||
Method string `json:"method"`
|
||||
CmdParam string `json:"cmd_param"`
|
||||
Remark string `json:"remark"`
|
||||
Encoding string `json:"encoding"`
|
||||
OS string `json:"os"`
|
||||
}
|
||||
|
||||
// ListConnections 列出所有 WebShell 连接(GET /api/webshell/connections)
|
||||
@@ -109,6 +395,8 @@ func (h *WebShellHandler) CreateConnection(c *gin.Context) {
|
||||
Method: method,
|
||||
CmdParam: strings.TrimSpace(req.CmdParam),
|
||||
Remark: strings.TrimSpace(req.Remark),
|
||||
Encoding: normalizeWebshellEncoding(req.Encoding),
|
||||
OS: normalizeWebshellOS(req.OS),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := h.db.CreateWebshellConnection(conn); err != nil {
|
||||
@@ -159,6 +447,8 @@ func (h *WebShellHandler) UpdateConnection(c *gin.Context) {
|
||||
Method: method,
|
||||
CmdParam: strings.TrimSpace(req.CmdParam),
|
||||
Remark: strings.TrimSpace(req.Remark),
|
||||
Encoding: normalizeWebshellEncoding(req.Encoding),
|
||||
OS: normalizeWebshellOS(req.OS),
|
||||
}
|
||||
if err := h.db.UpdateWebshellConnection(conn); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -331,6 +621,8 @@ type ExecRequest struct {
|
||||
Type string `json:"type"` // php, asp, aspx, jsp, custom
|
||||
Method string `json:"method"` // GET 或 POST,空则默认 POST
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -344,23 +636,27 @@ type ExecResponse struct {
|
||||
|
||||
// FileOpRequest 文件操作请求
|
||||
type FileOpRequest struct {
|
||||
URL string `json:"url" binding:"required"`
|
||||
Password string `json:"password"`
|
||||
Type string `json:"type"`
|
||||
Method string `json:"method"` // GET 或 POST,空则默认 POST
|
||||
CmdParam string `json:"cmd_param"` // 命令参数名,如 cmd/xxx,空则默认 cmd
|
||||
Action string `json:"action" binding:"required"` // list, read, delete, write, mkdir, rename, upload, upload_chunk
|
||||
Path string `json:"path"`
|
||||
TargetPath string `json:"target_path"` // rename 时目标路径
|
||||
Content string `json:"content"` // write/upload 时使用
|
||||
ChunkIndex int `json:"chunk_index"` // upload_chunk 时,0 表示首块
|
||||
URL string `json:"url" binding:"required"`
|
||||
Password string `json:"password"`
|
||||
Type string `json:"type"`
|
||||
Method string `json:"method"` // GET 或 POST,空则默认 POST
|
||||
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,空则按 shellType 推断
|
||||
ConnectionID string `json:"connection_id,omitempty"` // 可选:连接 ID;服务端探活出 OS 后会回写到此连接
|
||||
Action string `json:"action" binding:"required"` // list, read, delete, write, mkdir, rename, upload, upload_chunk
|
||||
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 文件操作响应
|
||||
type FileOpResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Output string `json:"output"`
|
||||
Error string `json:"error,omitempty"`
|
||||
OK bool `json:"ok"`
|
||||
Output string `json:"output"`
|
||||
Error string `json:"error,omitempty"`
|
||||
DetectedOS string `json:"detected_os,omitempty"` // 仅在 auto 模式且探活成功时返回,前端应更新本地缓存
|
||||
}
|
||||
|
||||
func (h *WebShellHandler) Exec(c *gin.Context) {
|
||||
@@ -415,7 +711,7 @@ func (h *WebShellHandler) Exec(c *gin.Context) {
|
||||
if readErr != nil {
|
||||
h.logger.Warn("webshell exec read body", zap.Error(readErr))
|
||||
}
|
||||
output := string(out)
|
||||
output := decodeWebshellOutput(out, req.Encoding)
|
||||
httpCode := resp.StatusCode
|
||||
|
||||
c.JSON(http.StatusOK, ExecResponse{
|
||||
@@ -474,83 +770,32 @@ func (h *WebShellHandler) FileOp(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 通过执行系统命令实现文件操作(与通用一句话兼容)
|
||||
var command string
|
||||
shellType := strings.ToLower(strings.TrimSpace(req.Type))
|
||||
switch req.Action {
|
||||
case "list":
|
||||
path := strings.TrimSpace(req.Path)
|
||||
if path == "" {
|
||||
path = "."
|
||||
// 若 OS 未显式配置,先发一次探活命令,识别出真实 OS 再构造文件操作命令。
|
||||
// 这解决了 "Windows + PHP + OS=auto" 场景下旧 fallback 错发 `ls -la` 导致目录列不出来的问题。
|
||||
osTag := req.OS
|
||||
detectedOS := ""
|
||||
if normalizeWebshellOS(osTag) == "auto" {
|
||||
if probed := probeWebshellOSViaExec(h.newHTTPExecFn(req.URL, req.Password, req.Type, req.Method, req.CmdParam, req.Encoding)); probed != "" {
|
||||
osTag = probed
|
||||
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 = "ls -la " + h.escapePath(path)
|
||||
}
|
||||
case "read":
|
||||
if shellType == "asp" || shellType == "aspx" {
|
||||
command = "type " + h.escapePath(strings.TrimSpace(req.Path))
|
||||
} else {
|
||||
command = "cat " + h.escapePath(strings.TrimSpace(req.Path))
|
||||
}
|
||||
case "delete":
|
||||
if shellType == "asp" || shellType == "aspx" {
|
||||
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})
|
||||
}
|
||||
|
||||
command, cmdErr := h.buildFileCommand(fileCommandInput{
|
||||
Action: req.Action,
|
||||
Path: req.Path,
|
||||
TargetPath: req.TargetPath,
|
||||
Content: req.Content,
|
||||
ChunkIndex: req.ChunkIndex,
|
||||
OS: osTag,
|
||||
ShellType: req.Type,
|
||||
})
|
||||
if cmdErr != nil {
|
||||
c.JSON(http.StatusBadRequest, FileOpResponse{OK: false, Error: cmdErr.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -585,27 +830,15 @@ func (h *WebShellHandler) FileOp(c *gin.Context) {
|
||||
if readErr != nil {
|
||||
h.logger.Warn("webshell fileop read body", zap.Error(readErr))
|
||||
}
|
||||
output := string(out)
|
||||
output := decodeWebshellOutput(out, req.Encoding)
|
||||
|
||||
c.JSON(http.StatusOK, FileOpResponse{
|
||||
OK: resp.StatusCode == http.StatusOK,
|
||||
Output: output,
|
||||
OK: resp.StatusCode == http.StatusOK,
|
||||
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 调用)
|
||||
func (h *WebShellHandler) ExecWithConnection(conn *database.WebShellConnection, command string) (output string, ok bool, errMsg string) {
|
||||
if conn == nil {
|
||||
@@ -643,7 +876,7 @@ func (h *WebShellHandler) ExecWithConnection(conn *database.WebShellConnection,
|
||||
if readErr != nil {
|
||||
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
|
||||
@@ -652,40 +885,38 @@ func (h *WebShellHandler) FileOpWithConnection(conn *database.WebShellConnection
|
||||
return "", false, "connection is nil"
|
||||
}
|
||||
action = strings.ToLower(strings.TrimSpace(action))
|
||||
shellType := strings.ToLower(strings.TrimSpace(conn.Type))
|
||||
if shellType == "" {
|
||||
shellType = "php"
|
||||
}
|
||||
var command string
|
||||
// MCP 入口仅开放 list / read / write 三种动作,与工具文档的承诺保持一致
|
||||
switch action {
|
||||
case "list":
|
||||
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)
|
||||
case "list", "read", "write":
|
||||
// 支持的动作
|
||||
default:
|
||||
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"
|
||||
cmdParam := strings.TrimSpace(conn.CmdParam)
|
||||
if cmdParam == "" {
|
||||
@@ -714,5 +945,5 @@ func (h *WebShellHandler) FileOpWithConnection(conn *database.WebShellConnection
|
||||
if readErr != nil {
|
||||
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, ""
|
||||
}
|
||||
|
||||
@@ -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/PowerShell:dir /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/bash:ls -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 ""
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
"连接 ID:ws_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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 + 各种 shellType:asp/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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
// 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 {
|
||||
execHandlers = append(execHandlers, teleMw)
|
||||
}
|
||||
|
||||
@@ -130,6 +130,14 @@ func newEinoSummarizationMiddleware(
|
||||
}
|
||||
|
||||
// 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(
|
||||
ctx context.Context,
|
||||
originalMessages []adk.Message,
|
||||
@@ -157,80 +165,136 @@ func summarizeFinalizeWithRecentAssistantToolTrail(
|
||||
return out, nil
|
||||
}
|
||||
|
||||
selectedReverse := make([]adk.Message, 0, 8)
|
||||
seen := make(map[adk.Message]struct{})
|
||||
totalTokens := 0
|
||||
assistantToolKept := 0
|
||||
const minAssistantToolTrail = 4
|
||||
rounds := splitMessagesIntoRounds(nonSystem)
|
||||
if len(rounds) == 0 {
|
||||
out := make([]adk.Message, 0, len(systemMsgs)+1)
|
||||
out = append(out, systemMsgs...)
|
||||
out = append(out, summary)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
tryKeep := func(msg adk.Message) (bool, error) {
|
||||
if msg == nil {
|
||||
return false, nil
|
||||
// 目标:至少保留 minRounds 个 round 的执行轨迹;在预算允许时尽量多保留。
|
||||
// 优先确保最后一个 round(通常是最新的 tool 往返或 assistant 回复)存在。
|
||||
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 {
|
||||
return false, nil
|
||||
}
|
||||
n, err := tokenCounter(ctx, &summarization.TokenCounterInput{Messages: []adk.Message{msg}})
|
||||
n, err := tokenCounter(ctx, &summarization.TokenCounterInput{Messages: r.messages})
|
||||
if err != nil {
|
||||
return false, err
|
||||
return 0, err
|
||||
}
|
||||
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 {
|
||||
return false, nil
|
||||
if selectedCount >= minRounds {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
totalTokens += n
|
||||
selectedReverse = append(selectedReverse, msg)
|
||||
seen[msg] = struct{}{}
|
||||
return true, nil
|
||||
selectedRoundsReverse = append(selectedRoundsReverse, r)
|
||||
selectedCount++
|
||||
}
|
||||
|
||||
// 优先保留最近 assistant/tool,确保执行轨迹可续跑。
|
||||
for i := len(nonSystem) - 1; i >= 0; i-- {
|
||||
msg := nonSystem[i]
|
||||
if msg.Role != schema.Assistant && msg.Role != schema.Tool {
|
||||
continue
|
||||
}
|
||||
ok, err := tryKeep(msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok {
|
||||
assistantToolKept++
|
||||
}
|
||||
if assistantToolKept >= minAssistantToolTrail {
|
||||
break
|
||||
}
|
||||
// 还原时间顺序
|
||||
selectedMsgs := make([]adk.Message, 0, 8)
|
||||
for i := len(selectedRoundsReverse) - 1; i >= 0; i-- {
|
||||
selectedMsgs = append(selectedMsgs, selectedRoundsReverse[i].messages...)
|
||||
}
|
||||
|
||||
// 在预算内回填更多最近消息,保持短链路上下文。
|
||||
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 := make([]adk.Message, 0, len(systemMsgs)+1+len(selectedMsgs))
|
||||
out = append(out, systemMsgs...)
|
||||
out = append(out, summary)
|
||||
out = append(out, selected...)
|
||||
out = append(out, selectedMsgs...)
|
||||
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 {
|
||||
tc := agent.NewTikTokenCounter()
|
||||
return func(ctx context.Context, input *summarization.TokenCounterInput) (int, error) {
|
||||
|
||||
@@ -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) 落入 budget,assistant(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 round(4)能放下;
|
||||
// - 进一步的中间 round(assistant reply + user)也能放下;
|
||||
// - 更早的 c_big round(12)放不下会被跳过(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 消息;
|
||||
// 本项目通过自定义 Finalize(summarizeFinalizeWithRecentAssistantToolTrail)在 summary 后回填
|
||||
// 最近的 assistant/tool 轨迹。若 Finalize 的保留策略按"条数"截断而未按 round 对齐,可能保留
|
||||
// 了 tool 结果却把对应的 assistant(tool_calls) 落在了 summary 前面,形成孤儿 tool 消息。
|
||||
// - 同样,reduction / tool_search / 自定义断点恢复等任一改写历史的逻辑,都可能破坏
|
||||
// tool_call ↔ tool_result 配对。
|
||||
//
|
||||
// 一旦孤儿 tool 消息进入 ChatModel,OpenAI 兼容 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)
|
||||
}
|
||||
}
|
||||
@@ -257,6 +257,9 @@ func RunDeepAgent(
|
||||
subHandlers = append(subHandlers, einoSkillMW)
|
||||
}
|
||||
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 {
|
||||
subHandlers = append(subHandlers, teleMw)
|
||||
}
|
||||
@@ -393,6 +396,7 @@ func RunDeepAgent(
|
||||
deepHandlers = append(deepHandlers, einoSkillMW)
|
||||
}
|
||||
deepHandlers = append(deepHandlers, mainSumMw)
|
||||
deepHandlers = append(deepHandlers, newOrphanToolPrunerMiddleware(logger, "deep_orchestrator"))
|
||||
if teleMw := newEinoModelInputTelemetryMiddleware(logger, appCfg.OpenAI.Model, conversationID, "deep_orchestrator"); teleMw != nil {
|
||||
deepHandlers = append(deepHandlers, teleMw)
|
||||
}
|
||||
@@ -405,6 +409,7 @@ func RunDeepAgent(
|
||||
supHandlers = append(supHandlers, einoSkillMW)
|
||||
}
|
||||
supHandlers = append(supHandlers, mainSumMw)
|
||||
supHandlers = append(supHandlers, newOrphanToolPrunerMiddleware(logger, "supervisor_orchestrator"))
|
||||
if teleMw := newEinoModelInputTelemetryMiddleware(logger, appCfg.OpenAI.Model, conversationID, "supervisor_orchestrator"); teleMw != nil {
|
||||
supHandlers = append(supHandlers, teleMw)
|
||||
}
|
||||
@@ -455,6 +460,8 @@ func RunDeepAgent(
|
||||
FilesystemMiddleware: peFsMw,
|
||||
PlannerReplannerRewriteHandlers: []adk.ChatModelAgentMiddleware{
|
||||
mainSumMw,
|
||||
// 孤儿 tool 消息兜底:必须挂在 summarization 之后、telemetry 之前。
|
||||
newOrphanToolPrunerMiddleware(logger, "plan_execute_planner_replanner"),
|
||||
newEinoModelInputTelemetryMiddleware(logger, appCfg.OpenAI.Model, conversationID, "plan_execute_planner_replanner_rewrite"),
|
||||
},
|
||||
})
|
||||
|
||||
+624
-178
File diff suppressed because it is too large
Load Diff
@@ -95,6 +95,15 @@
|
||||
"severityLow": "Low",
|
||||
"severityInfo": "Info",
|
||||
"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",
|
||||
"batchQueues": "Batch task queues",
|
||||
"pending": "Pending",
|
||||
@@ -125,7 +134,7 @@
|
||||
"lastUpdated": "Last updated",
|
||||
"viewAll": "View all →",
|
||||
"recentVulns": "Recent vulnerabilities",
|
||||
"noVulnYet": "No vulnerabilities yet — start your first scan",
|
||||
"noVulnYet": "No recent vulnerabilities",
|
||||
"capabilities": "Capabilities",
|
||||
"mcpTools": "MCP tools",
|
||||
"rolesLabel": "Roles",
|
||||
@@ -184,7 +193,7 @@
|
||||
"recoCheckMonitorDesc": "View failed request details in MCP monitor",
|
||||
"recoSetupMcp": "Configure your first MCP tool",
|
||||
"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",
|
||||
"recentEvents": "Recent Events",
|
||||
"eventUntitled": "Event",
|
||||
@@ -193,7 +202,7 @@
|
||||
"mcpPartialDown_one": "{{count}} stopped",
|
||||
"mcpPartialDown_other": "{{count}} 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"
|
||||
},
|
||||
"chat": {
|
||||
@@ -546,6 +555,17 @@
|
||||
"typeCustom": "Custom",
|
||||
"cmdParam": "Command parameter name",
|
||||
"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",
|
||||
"remarkPlaceholder": "Friendly name for this connection",
|
||||
"deleteConfirm": "Delete this connection?",
|
||||
|
||||
@@ -95,6 +95,15 @@
|
||||
"severityLow": "低危",
|
||||
"severityInfo": "信息",
|
||||
"totalVulns": "总漏洞数",
|
||||
"riskLevel": "风险等级",
|
||||
"riskScore": "加权风险分",
|
||||
"riskSafe": "安全",
|
||||
"riskLow": "低",
|
||||
"riskMedium": "中",
|
||||
"riskHigh": "高",
|
||||
"riskSevere": "极高",
|
||||
"latestFound": "最近发现",
|
||||
"noneYet": "暂无",
|
||||
"runOverview": "运行概览",
|
||||
"batchQueues": "批量任务队列",
|
||||
"pending": "待执行",
|
||||
@@ -125,7 +134,7 @@
|
||||
"lastUpdated": "上次更新",
|
||||
"viewAll": "查看全部 →",
|
||||
"recentVulns": "最近漏洞",
|
||||
"noVulnYet": "暂无漏洞,开始你的第一次扫描吧",
|
||||
"noVulnYet": "暂无最近漏洞",
|
||||
"capabilities": "能力总览",
|
||||
"mcpTools": "MCP 工具",
|
||||
"rolesLabel": "角色",
|
||||
@@ -174,7 +183,7 @@
|
||||
"recoCheckMonitorDesc": "在 MCP 监控中查看失败的请求详情",
|
||||
"recoSetupMcp": "配置首个 MCP 工具",
|
||||
"recoSetupMcpDesc": "安装 MCP 服务后 Agent 才能调用具体能力",
|
||||
"recoStartScan": "开始第一次扫描",
|
||||
"recoStartScan": "在对话中发起扫描",
|
||||
"recoStartScanDesc": "在对话中描述目标,让 AI 协助执行",
|
||||
"recentEvents": "最近事件",
|
||||
"eventUntitled": "事件",
|
||||
@@ -182,7 +191,7 @@
|
||||
"mcpAllRunning": "全部运行",
|
||||
"mcpPartialDown": "{{count}} 个未运行",
|
||||
"mcpAllDown": "全部未运行",
|
||||
"noVulnDesc": "系统目前安全,开始一次扫描可以发现潜在问题",
|
||||
"noVulnDesc": "此处展示近期漏洞记录;在对话中完成检测后,新结果会出现在这里",
|
||||
"startScanBtn": "前往对话发起扫描"
|
||||
},
|
||||
"chat": {
|
||||
@@ -535,6 +544,17 @@
|
||||
"typeCustom": "自定义",
|
||||
"cmdParam": "命令参数名",
|
||||
"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": "备注",
|
||||
"remarkPlaceholder": "便于识别的备注名",
|
||||
"deleteConfirm": "确定要删除该连接吗?",
|
||||
|
||||
+1123
-651
File diff suppressed because it is too large
Load Diff
+153
-19
@@ -46,6 +46,7 @@ async function refreshDashboard() {
|
||||
if (severityTotalEl) severityTotalEl.textContent = '0';
|
||||
renderSeverityDonut({}, 0);
|
||||
renderVulnStatusPanel(null, 0);
|
||||
renderSeverityInsights(null, 0, null);
|
||||
setDashboardOverviewPlaceholder('…');
|
||||
setEl('dashboard-kpi-tools-calls', '…');
|
||||
setEl('dashboard-kpi-success-rate', '…');
|
||||
@@ -91,15 +92,16 @@ async function refreshDashboard() {
|
||||
|
||||
try {
|
||||
// /api/vulnerabilities/stats 只给出 by_severity 与 by_status 两个独立维度,
|
||||
// 无法得到「严重 × 待处理」的交叉计数。这里额外拉两次(limit=1,仅取 total),
|
||||
// 用真实的「待处理严重 / 待处理高危」数量驱动告警条与 KPI 副标,避免修复后仍报警。
|
||||
// 无法得到「严重 × 待处理」的交叉计数。这里按四档各拉一次(limit=1,仅取 total),
|
||||
// 用真实的「待处理 × 各严重度」数量驱动告警条 / KPI 副标 / 风险概览卡的加权分,
|
||||
// 避免「全部修复后风险等级仍显示极高」这类语义冲突。
|
||||
var openVulnQuery = function (sev) {
|
||||
return fetchJson('/api/vulnerabilities?severity=' + sev + '&status=open&limit=1');
|
||||
};
|
||||
const [
|
||||
tasksRes, vulnRes, batchRes, monitorRes, knowledgeRes, skillsRes,
|
||||
recentVulnsRes, rolesRes, agentsRes,
|
||||
openCriticalRes, openHighRes, toolsConfigRes,
|
||||
openCriticalRes, openHighRes, openMediumRes, openLowRes, toolsConfigRes,
|
||||
hitlPendingRes, notificationsRes, externalMcpStatsRes,
|
||||
webshellRes
|
||||
] = await Promise.all([
|
||||
@@ -114,6 +116,9 @@ async function refreshDashboard() {
|
||||
fetchJson('/api/multi-agent/markdown-agents'),
|
||||
openVulnQuery('critical'),
|
||||
openVulnQuery('high'),
|
||||
// 中/低危的「待处理」计数:用于风险概览卡的加权风险分,使其反映"当前未处理风险"
|
||||
openVulnQuery('medium'),
|
||||
openVulnQuery('low'),
|
||||
// 拉取 MCP 工具的「配置总数」用于「能力总览」(区别于 monitor/stats 的「有调用记录」)。
|
||||
// 仅取 total 字段,page_size=1 减少传输;total 已涵盖内部 + 外部 MCP + 直接注册的工具。
|
||||
fetchJson('/api/config/tools?page=1&page_size=1'),
|
||||
@@ -173,17 +178,25 @@ async function refreshDashboard() {
|
||||
|
||||
let criticalCount = 0;
|
||||
let highCount = 0;
|
||||
let mediumCount = 0;
|
||||
let lowCount = 0;
|
||||
let openCriticalCount = 0;
|
||||
let openHighCount = 0;
|
||||
let openMediumCount = 0;
|
||||
let openLowCount = 0;
|
||||
if (vulnRes && typeof vulnRes.total === 'number') {
|
||||
if (vulnTotalEl) vulnTotalEl.textContent = String(vulnRes.total);
|
||||
const bySeverity = vulnRes.by_severity || {};
|
||||
const total = vulnRes.total || 0;
|
||||
criticalCount = bySeverity.critical || 0;
|
||||
highCount = bySeverity.high || 0;
|
||||
mediumCount = bySeverity.medium || 0;
|
||||
lowCount = bySeverity.low || 0;
|
||||
// 优先用专门拉的「待处理」计数;若专项接口失败,则退回 by_severity(宁可误报,不可漏报)
|
||||
openCriticalCount = pickOpenCount(openCriticalRes, criticalCount);
|
||||
openHighCount = pickOpenCount(openHighRes, highCount);
|
||||
openMediumCount = pickOpenCount(openMediumRes, mediumCount);
|
||||
openLowCount = pickOpenCount(openLowRes, lowCount);
|
||||
if (severityTotalEl) severityTotalEl.textContent = String(total);
|
||||
severityIds.forEach(sev => {
|
||||
const count = bySeverity[sev] || 0;
|
||||
@@ -197,6 +210,11 @@ async function refreshDashboard() {
|
||||
});
|
||||
renderSeverityDonut(bySeverity, total);
|
||||
renderVulnStatusPanel(vulnRes.by_status || {}, total);
|
||||
renderSeverityInsights(
|
||||
{ critical: openCriticalCount, high: openHighCount, medium: openMediumCount, low: openLowCount },
|
||||
openCriticalCount + openHighCount + openMediumCount + openLowCount,
|
||||
recentVulnsRes
|
||||
);
|
||||
|
||||
// 漏洞 KPI 副标:徽章/文案均使用「待处理」口径
|
||||
const critBadge = document.getElementById('dashboard-kpi-vuln-critical-badge');
|
||||
@@ -225,6 +243,7 @@ async function refreshDashboard() {
|
||||
});
|
||||
renderSeverityDonut({}, 0);
|
||||
renderVulnStatusPanel(null, 0);
|
||||
renderSeverityInsights(null, 0, null);
|
||||
hideEl('dashboard-kpi-vuln-critical-badge');
|
||||
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 自动取最高级
|
||||
// - 用户点 × 后,把当前 reasons 指纹存入 sessionStorage,本会话内再出现完全相同的内容会自动跳过
|
||||
// - 当 reasons 集合发生变化(如又新增一类问题),指纹失效,banner 重新弹出,避免「忽略后永远不再提醒」
|
||||
// - 若曾展示过「更多类问题」的组合,之后仅部分问题消失,即使指纹与早年忽略相同,也会清除忽略并继续提醒(见 dashboard.alertLastReasons)
|
||||
function renderDashboardAlertBanner(stats) {
|
||||
const banner = document.getElementById('dashboard-alert-banner');
|
||||
const titleEl = document.getElementById('dashboard-alert-title');
|
||||
@@ -552,15 +595,28 @@ function renderDashboardAlertBanner(stats) {
|
||||
banner.hidden = true;
|
||||
banner.classList.remove('is-warning', 'is-danger', 'is-info');
|
||||
dashboardState.dismissedAlertKey = null;
|
||||
try { sessionStorage.removeItem(DASH_SESSION_ALERT_LAST_REASONS); } catch (_) {}
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// 检查是否被本会话忽略过同样的内容
|
||||
var dismissed = null;
|
||||
try { dismissed = sessionStorage.getItem('dashboard.dismissedAlert'); } catch (_) {}
|
||||
if (dismissed === fingerprint) {
|
||||
banner.hidden = true;
|
||||
return;
|
||||
@@ -609,6 +665,8 @@ function renderDashboardAlertBanner(stats) {
|
||||
btn.onclick = function () { try { switchPage('mcp-management'); } catch (e) {} };
|
||||
actsEl.appendChild(btn);
|
||||
}
|
||||
|
||||
try { sessionStorage.setItem(DASH_SESSION_ALERT_LAST_REASONS, reasonPartJoined); } catch (_) {}
|
||||
}
|
||||
|
||||
// External MCP 健康度:从 /api/external-mcp/stats 解析出 running / total / down,
|
||||
@@ -662,8 +720,8 @@ function getHitlPendingCount(res) {
|
||||
|
||||
// 「最近事件」内联展示:取通知摘要里最重要的前 N 条
|
||||
// 设计原则:
|
||||
// - 不重复 alert banner / KPI 已经表达过的信息(漏洞、HITL 等会被过滤掉避免冗余)
|
||||
// - 只显示 p0/p1 优先级,p2 作为兜底(当 p0/p1 不够时)
|
||||
// - 不重复 alert banner / KPI 已表达的「新漏洞」通知(vulnerability_created 仍过滤)
|
||||
// - HITL 待审批在推荐操作等处也会提示,但仍在此展示时间线,便于与任务完成等并列查看
|
||||
// - 整个 section 在没有可显示内容时整个隐藏,避免空模块占地方
|
||||
function renderRecentEvents(notifRes) {
|
||||
var section = document.getElementById('dashboard-section-events');
|
||||
@@ -671,8 +729,8 @@ function renderRecentEvents(notifRes) {
|
||||
if (!section || !listEl) return;
|
||||
|
||||
var items = (notifRes && Array.isArray(notifRes.items)) ? notifRes.items : [];
|
||||
// 过滤:只看有意义的事件,去掉 actionable 已处理的、以及类型已经在仪表盘其他位置覆盖的
|
||||
var coveredTypes = { 'vulnerability_created': true, 'hitl_pending': true };
|
||||
// 过滤:去掉新漏洞类型(与「最近漏洞」等板块避免重复);HITL 不再过滤
|
||||
var coveredTypes = { 'vulnerability_created': true };
|
||||
var filtered = items.filter(function (it) {
|
||||
if (!it || !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 lb = levelOrder[b.level] != null ? levelOrder[b.level] : 9;
|
||||
if (la !== lb) return la - lb;
|
||||
var ta = a.createdAt || a.created_at || 0;
|
||||
var tb = b.createdAt || b.created_at || 0;
|
||||
var ta = a.ts || a.createdAt || a.created_at || 0;
|
||||
var tb = b.ts || b.createdAt || b.created_at || 0;
|
||||
return new Date(tb).getTime() - new Date(ta).getTime();
|
||||
});
|
||||
|
||||
@@ -701,8 +759,9 @@ function renderRecentEvents(notifRes) {
|
||||
listEl.innerHTML = top.map(function (it) {
|
||||
var level = it.level || 'p2';
|
||||
var title = esc(it.title || it.message || dt('dashboard.eventUntitled', null, '事件'));
|
||||
var msg = esc(it.message || it.summary || '');
|
||||
var when = esc(timeAgoStr(it.createdAt || it.created_at));
|
||||
var msg = esc(it.message || it.summary || it.desc || '');
|
||||
var whenRaw = timeAgoStr(it.ts || it.createdAt || it.created_at);
|
||||
var when = esc(whenRaw || '—');
|
||||
return (
|
||||
'<div class="dashboard-event-item lvl-' + esc(level) + '">' +
|
||||
'<span class="dashboard-event-dot" aria-hidden="true"></span>' +
|
||||
@@ -730,7 +789,7 @@ function renderRecommendedActions(state) {
|
||||
if (state.openCriticalCount > 0) {
|
||||
actions.push({
|
||||
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 },
|
||||
'修复 ' + state.openCriticalCount + ' 个待处理严重漏洞'),
|
||||
desc: dt('dashboard.recoFixCriticalDesc', null, '严重等级的漏洞应优先处置'),
|
||||
@@ -784,7 +843,7 @@ function renderRecommendedActions(state) {
|
||||
actions.push({
|
||||
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>',
|
||||
title: dt('dashboard.recoStartScan', null, '开始第一次扫描'),
|
||||
title: dt('dashboard.recoStartScan', null, '在对话中发起扫描'),
|
||||
desc: dt('dashboard.recoStartScanDesc', null, '在对话中描述目标,让 AI 协助执行'),
|
||||
page: 'chat'
|
||||
});
|
||||
@@ -972,8 +1031,8 @@ function renderRecentVulns(res) {
|
||||
'<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>' +
|
||||
'</span>' +
|
||||
'<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-title">' + esc(dt('dashboard.noVulnYet', null, '暂无最近漏洞')) + '</div>' +
|
||||
'<div class="dashboard-empty-desc">' + esc(dt('dashboard.noVulnDesc', null, '此处展示近期漏洞记录;在对话中完成检测后,新结果会出现在这里')) + '</div>' +
|
||||
'<button type="button" class="dashboard-empty-action" data-action="scan">' +
|
||||
esc(dt('dashboard.startScanBtn', null, '前往对话发起扫描')) + ' →</button>'
|
||||
);
|
||||
@@ -1093,6 +1152,81 @@ function renderVulnStatusPanel(byStatus, total) {
|
||||
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) {
|
||||
const placeholder = document.getElementById('dashboard-tools-pie-placeholder');
|
||||
const barChartEl = document.getElementById('dashboard-tools-bar-chart');
|
||||
@@ -1377,7 +1511,7 @@ document.addEventListener('click', function (ev) {
|
||||
if (!btn) return;
|
||||
ev.preventDefault();
|
||||
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');
|
||||
if (banner) banner.hidden = true;
|
||||
});
|
||||
|
||||
@@ -287,10 +287,18 @@
|
||||
closeDropdown();
|
||||
return;
|
||||
}
|
||||
dropdown.style.display = 'block';
|
||||
bellBtn.classList.add('active');
|
||||
state.dropdownOpen = true;
|
||||
await refreshNotifications();
|
||||
// 从仪表盘「查看全部」等容器外入口打开时,同一 click 会冒泡到 document,
|
||||
// handleDocumentClick 会误判为「点在外面」并立刻关掉。推迟到宏任务再展开即可。
|
||||
const runOpen = async function () {
|
||||
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() {
|
||||
|
||||
+35
-5
@@ -1,6 +1,28 @@
|
||||
// 角色管理相关功能
|
||||
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 roles = [];
|
||||
@@ -56,6 +78,11 @@ function sortRoles(rolesArray) {
|
||||
|
||||
// 加载所有角色
|
||||
async function loadRoles() {
|
||||
if (window.i18nReady && typeof window.i18nReady.then === 'function') {
|
||||
try {
|
||||
await window.i18nReady;
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
try {
|
||||
const response = await apiFetch('/api/roles');
|
||||
if (!response.ok) {
|
||||
@@ -189,8 +216,9 @@ function renderRoleSelectionSidebar() {
|
||||
const icon = getRoleIcon(role);
|
||||
|
||||
// 处理默认角色的描述
|
||||
let description = role.description || _t('roles.noDescription');
|
||||
if (isDefaultRole && !role.description) {
|
||||
const plainDesc = rolePlainDescription(role);
|
||||
let description = plainDesc || _t('roles.noDescription');
|
||||
if (isDefaultRole && !plainDesc) {
|
||||
description = _t('roles.defaultRoleDescription');
|
||||
}
|
||||
|
||||
@@ -316,6 +344,7 @@ function renderRolesList() {
|
||||
const sortedRoles = sortRoles(filteredRoles);
|
||||
|
||||
rolesList.innerHTML = sortedRoles.map(role => {
|
||||
const plainDesc = rolePlainDescription(role);
|
||||
// 获取角色图标,如果是Unicode转义格式则转换为emoji
|
||||
let roleIcon = role.icon || '👤';
|
||||
if (roleIcon && typeof roleIcon === 'string') {
|
||||
@@ -369,7 +398,7 @@ function renderRolesList() {
|
||||
${role.enabled !== false ? _t('roles.enabled') : _t('roles.disabled')}
|
||||
</span>
|
||||
</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">
|
||||
<span class="role-card-tools-label">${_t('roleModal.toolsLabel')}</span>
|
||||
<span class="role-card-tools-value">${toolsDisplay}</span>
|
||||
@@ -1575,9 +1604,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
updateRoleSelectorDisplay();
|
||||
});
|
||||
|
||||
// 语言切换后刷新角色选择器显示(默认/自定义角色名)
|
||||
// 语言切换后刷新角色选择器与「选择角色」列表文案
|
||||
document.addEventListener('languagechange', () => {
|
||||
updateRoleSelectorDisplay();
|
||||
renderRoleSelectionSidebar();
|
||||
});
|
||||
|
||||
// 获取当前选中的角色(供chat.js使用)
|
||||
|
||||
@@ -405,10 +405,13 @@ async function loadToolsList(page = 1, searchKeyword = '') {
|
||||
}
|
||||
}
|
||||
|
||||
// 每行有两类复选框:行首「启用工具」与名称旁「常驻」;统计/全选只应针对行首启用复选框
|
||||
const TOOL_ENABLE_CHECKBOX_SELECTOR = '#tools-list .tool-item > input[type="checkbox"]';
|
||||
|
||||
// 保存当前页的工具状态到全局映射
|
||||
function saveCurrentPageToolStates() {
|
||||
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 toolName = item.dataset.toolName;
|
||||
const isExternal = item.dataset.isExternal === 'true';
|
||||
@@ -745,7 +748,7 @@ function handleToolAlwaysVisibleChange(toolName, alwaysVisible) {
|
||||
|
||||
// 全选工具
|
||||
function selectAllTools() {
|
||||
document.querySelectorAll('#tools-list input[type="checkbox"]').forEach(checkbox => {
|
||||
document.querySelectorAll(TOOL_ENABLE_CHECKBOX_SELECTOR).forEach(checkbox => {
|
||||
checkbox.checked = true;
|
||||
// 更新全局状态映射
|
||||
const toolItem = checkbox.closest('.tool-item');
|
||||
@@ -769,7 +772,7 @@ function selectAllTools() {
|
||||
|
||||
// 全不选工具
|
||||
function deselectAllTools() {
|
||||
document.querySelectorAll('#tools-list input[type="checkbox"]').forEach(checkbox => {
|
||||
document.querySelectorAll(TOOL_ENABLE_CHECKBOX_SELECTOR).forEach(checkbox => {
|
||||
checkbox.checked = false;
|
||||
// 更新全局状态映射
|
||||
const toolItem = checkbox.closest('.tool-item');
|
||||
@@ -826,9 +829,9 @@ async function updateToolsStats() {
|
||||
// 先保存当前页的状态到全局映射
|
||||
saveCurrentPageToolStates();
|
||||
|
||||
// 计算当前页的启用工具数
|
||||
const currentPageEnabled = Array.from(document.querySelectorAll('#tools-list input[type="checkbox"]:checked')).length;
|
||||
const currentPageTotal = document.querySelectorAll('#tools-list input[type="checkbox"]').length;
|
||||
// 计算当前页的启用工具数(仅行首「启用」复选框,不含「常驻」)
|
||||
const currentPageEnabled = Array.from(document.querySelectorAll(`${TOOL_ENABLE_CHECKBOX_SELECTOR}:checked`)).length;
|
||||
const currentPageTotal = document.querySelectorAll(TOOL_ENABLE_CHECKBOX_SELECTOR).length;
|
||||
|
||||
// 计算所有工具的启用数
|
||||
let totalEnabled = 0;
|
||||
|
||||
+149
-28
@@ -39,6 +39,100 @@ let webshellStreamingTypingId = 0;
|
||||
let webshellProbeStatusById = {};
|
||||
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/stream,body 带 orchestration */
|
||||
function resolveWebshellAiStreamRequest() {
|
||||
if (typeof apiFetch === 'undefined') {
|
||||
@@ -335,6 +429,17 @@ function wsT(key) {
|
||||
'webshell.addConnection': '添加连接',
|
||||
'webshell.cmdParam': '命令参数名',
|
||||
'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.noConnections': '暂无连接,请点击「添加连接」',
|
||||
'webshell.selectOrAdd': '请从左侧选择连接,或添加新的 WebShell 连接',
|
||||
@@ -661,9 +766,20 @@ function renderWebshellList() {
|
||||
} else if (probe && probe.state === 'fail') {
|
||||
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 (
|
||||
'<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-actions">' +
|
||||
'<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',
|
||||
method: ((conn.method || 'post').toLowerCase() === 'get') ? 'get' : 'post',
|
||||
cmd_param: conn.cmdParam || '',
|
||||
encoding: webshellConnEncoding(conn),
|
||||
os: webshellConnOS(conn),
|
||||
command: 'echo 1'
|
||||
})
|
||||
})
|
||||
@@ -3365,6 +3483,8 @@ function execWebshellCommand(conn, command) {
|
||||
type: conn.type || 'php',
|
||||
method: (conn.method || 'post').toLowerCase(),
|
||||
cmd_param: conn.cmdParam || '',
|
||||
encoding: webshellConnEncoding(conn),
|
||||
os: webshellConnOS(conn),
|
||||
command: command
|
||||
})
|
||||
}).then(function (r) { return r.json(); })
|
||||
@@ -3391,17 +3511,10 @@ function webshellFileListDir(conn, path) {
|
||||
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: 'list',
|
||||
path: path
|
||||
})
|
||||
body: webshellFileRequestBody(conn, { action: 'list', path: path })
|
||||
}).then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
applyWebshellDetectedOS(conn, data);
|
||||
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>';
|
||||
return;
|
||||
@@ -3497,16 +3610,9 @@ function fetchWebshellDirectoryItems(conn, path) {
|
||||
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: 'list',
|
||||
path: path
|
||||
})
|
||||
body: webshellFileRequestBody(conn, { action: 'list', path: path })
|
||||
}).then(function (r) { return r.json(); }).then(function (data) {
|
||||
applyWebshellDetectedOS(conn, data);
|
||||
if (!data || data.error || !data.ok) return [];
|
||||
return parseWebshellListItems(data.output || '');
|
||||
}).catch(function () {
|
||||
@@ -3801,7 +3907,7 @@ function webshellFileMkdir(conn, pathInput) {
|
||||
var name = prompt(wsT('webshell.newDir') || '新建目录', 'newdir');
|
||||
if (name == null || !name.trim()) return;
|
||||
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 () { webshellFileListDir(conn, base); })
|
||||
.catch(function () { webshellFileListDir(conn, base); });
|
||||
@@ -3848,7 +3954,7 @@ function webshellFileUpload(conn, pathInput) {
|
||||
webshellFileListDir(conn, base);
|
||||
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 () { idx++; sendNext(); })
|
||||
.catch(function () { idx++; sendNext(); });
|
||||
@@ -3867,7 +3973,7 @@ function webshellFileRename(conn, oldPath, oldName, listEl) {
|
||||
var parts = oldPath.split('/');
|
||||
var dir = parts.length > 1 ? parts.slice(0, -1).join('/') + '/' : '';
|
||||
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 () { 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', {
|
||||
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: 'read', path: path })
|
||||
body: webshellFileRequestBody(conn, { action: 'read', path: path })
|
||||
}).then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
var content = (data && data.output) != null ? data.output : (data.error || '');
|
||||
@@ -3927,7 +4033,7 @@ function webshellFileRead(conn, path, listEl, browsePath) {
|
||||
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: 'read', path: path })
|
||||
body: webshellFileRequestBody(conn, { action: 'read', path: path })
|
||||
}).then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
const out = (data && data.output) ? data.output : (data.error || '');
|
||||
@@ -3956,7 +4062,7 @@ function webshellFileEdit(conn, path, listEl) {
|
||||
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: 'read', path: path })
|
||||
body: webshellFileRequestBody(conn, { action: 'read', path: path })
|
||||
}).then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
const content = (data && data.output) ? data.output : (data.error || '');
|
||||
@@ -3992,7 +4098,7 @@ function webshellFileWrite(conn, path, content, onDone, listEl) {
|
||||
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: 'write', path: path, content: content })
|
||||
body: webshellFileRequestBody(conn, { action: 'write', path: path, content: content })
|
||||
}).then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (data && !data.ok && data.error && listEl) {
|
||||
@@ -4011,7 +4117,7 @@ function webshellFileDelete(conn, path, onDone) {
|
||||
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: 'delete', path: path })
|
||||
body: webshellFileRequestBody(conn, { action: 'delete', path: path })
|
||||
}).then(function (r) { return r.json(); })
|
||||
.then(function () { if (onDone) onDone(); })
|
||||
.catch(function () { if (onDone) onDone(); });
|
||||
@@ -4063,6 +4169,10 @@ function showAddWebshellModal() {
|
||||
document.getElementById('webshell-type').value = 'php';
|
||||
document.getElementById('webshell-method').value = 'post';
|
||||
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 = '';
|
||||
var titleEl = document.getElementById('webshell-modal-title');
|
||||
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-method').value = (conn.method || 'post').toLowerCase();
|
||||
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 || '';
|
||||
var titleEl = document.getElementById('webshell-modal-title');
|
||||
if (titleEl) titleEl.textContent = wsT('webshell.editConnectionTitle');
|
||||
@@ -4308,6 +4422,8 @@ function testWebshellConnection() {
|
||||
var method = ((document.getElementById('webshell-method') || {}).value || 'post').toLowerCase();
|
||||
var cmdParam = (document.getElementById('webshell-cmd-param') || {}).value;
|
||||
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');
|
||||
if (btn) { btn.disabled = true; btn.textContent = (typeof wsT === 'function' ? wsT('common.refresh') : '刷新') + '...'; }
|
||||
if (typeof apiFetch === 'undefined') {
|
||||
@@ -4315,6 +4431,7 @@ function testWebshellConnection() {
|
||||
alert(wsT('webshell.testFailed') || '连通性测试失败');
|
||||
return;
|
||||
}
|
||||
// 连通性使用 Windows/Linux 都识别的最小内建命令作为探测(echo 1 在 cmd 和 sh 下行为等价)
|
||||
apiFetch('/api/webshell/exec', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -4324,6 +4441,8 @@ function testWebshellConnection() {
|
||||
type: type,
|
||||
method: method === 'get' ? 'get' : 'post',
|
||||
cmd_param: cmdParam || '',
|
||||
encoding: encoding,
|
||||
os: osTag,
|
||||
command: 'echo 1'
|
||||
})
|
||||
})
|
||||
@@ -4369,12 +4488,14 @@ function saveWebshellConnection() {
|
||||
var method = ((document.getElementById('webshell-method') || {}).value || 'post').toLowerCase();
|
||||
var cmdParam = (document.getElementById('webshell-cmd-param') || {}).value;
|
||||
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;
|
||||
if (remark && typeof remark.trim === 'function') remark = remark.trim(); else remark = '';
|
||||
|
||||
var editIdEl = document.getElementById('webshell-edit-id');
|
||||
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;
|
||||
|
||||
var reqUrl = editId ? ('/api/webshell/connections/' + encodeURIComponent(editId)) : '/api/webshell/connections';
|
||||
|
||||
@@ -388,6 +388,40 @@
|
||||
<a class="dashboard-section-link" onclick="switchPage('vulnerabilities')" data-i18n="dashboard.viewAll">查看全部 →</a>
|
||||
</div>
|
||||
<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">
|
||||
<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>
|
||||
@@ -498,7 +532,7 @@
|
||||
<a class="dashboard-section-link" onclick="switchPage('vulnerabilities')" data-i18n="dashboard.viewAll">查看全部 →</a>
|
||||
</div>
|
||||
<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>
|
||||
</section>
|
||||
<section class="dashboard-section dashboard-section-overview">
|
||||
@@ -2925,6 +2959,25 @@
|
||||
<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=命令" />
|
||||
</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">
|
||||
<label for="webshell-remark" data-i18n="webshell.remark">备注</label>
|
||||
<input type="text" id="webshell-remark" data-i18n="webshell.remarkPlaceholder" data-i18n-attr="placeholder" placeholder="便于识别的备注名" />
|
||||
|
||||
Reference in New Issue
Block a user