Compare commits

...

18 Commits

Author SHA1 Message Date
公明 971a2d35cb Update config.yaml 2026-03-27 23:19:59 +08:00
公明 ff25d6e9ec Add files via upload 2026-03-27 23:16:18 +08:00
公明 c247e8405d Add files via upload 2026-03-27 23:05:16 +08:00
公明 6c71c090b5 Add files via upload 2026-03-27 22:41:23 +08:00
公明 0d262cb30b Add files via upload 2026-03-27 22:27:03 +08:00
公明 5b82924035 Update terminal.go 2026-03-27 20:25:59 +08:00
公明 7f32360096 Update mcp_reverse_shell.py 2026-03-27 19:39:49 +08:00
公明 6ffd084135 Add files via upload 2026-03-27 00:45:19 +08:00
公明 0e763cfd98 Add files via upload 2026-03-27 00:43:33 +08:00
公明 711eda935e Add files via upload 2026-03-27 00:22:58 +08:00
公明 42d5489993 Add files via upload 2026-03-25 23:26:40 +08:00
公明 5bc7a54118 Add files via upload 2026-03-25 21:54:31 +08:00
公明 e41d19fffe Add files via upload 2026-03-25 21:32:43 +08:00
公明 1e222efe29 Add files via upload 2026-03-25 21:12:16 +08:00
公明 1c394acd4a Add files via upload 2026-03-25 20:49:40 +08:00
公明 5e29a6e9b7 Add files via upload 2026-03-25 20:06:06 +08:00
公明 cce64e213f Add files via upload 2026-03-25 19:49:14 +08:00
公明 80de8cf748 Add files via upload 2026-03-25 19:25:23 +08:00
55 changed files with 3068 additions and 265 deletions
+30 -16
View File
@@ -31,49 +31,55 @@ CyberStrikeAI is an **AI-native security testing platform** built in Go. It inte
<img src="./images/web-console.png" alt="Web Console" width="100%">
</td>
<td width="33.33%" align="center">
<strong>Attack Chain Visualization</strong><br/>
<img src="./images/attack-chain.png" alt="Attack Chain" width="100%">
</td>
<td width="33.33%" align="center">
<strong>Task Management</strong><br/>
<img src="./images/task-management.png" alt="Task Management" width="100%">
</td>
<td width="33.33%" align="center">
<strong>Vulnerability Management</strong><br/>
<img src="./images/vulnerability-management.png" alt="Vulnerability Management" width="100%">
</td>
</tr>
<tr>
<td width="33.33%" align="center">
<strong>Vulnerability Management</strong><br/>
<img src="./images/vulnerability-management.png" alt="Vulnerability Management" width="100%">
<strong>WebShell Management</strong><br/>
<img src="./images/webshell-management.png" alt="WebShell Management" width="100%">
</td>
<td width="33.33%" align="center">
<strong>MCP Management</strong><br/>
<img src="./images/mcp-management.png" alt="MCP management" width="100%">
</td>
<td width="33.33%" align="center">
<strong>MCP stdio Mode</strong><br/>
<img src="./images/mcp-stdio2.png" alt="MCP stdio mode" width="100%">
<strong>Knowledge Base</strong><br/>
<img src="./images/knowledge-base.png" alt="Knowledge Base" width="100%">
</td>
</tr>
<tr>
<td width="33.33%" align="center">
<strong>Knowledge Base</strong><br/>
<img src="./images/knowledge-base.png" alt="Knowledge Base" width="100%">
</td>
<td width="33.33%" align="center">
<strong>Skills Management</strong><br/>
<img src="./images/skills.png" alt="Skills Management" width="100%">
</td>
<td width="33.33%" align="center">
<strong>Agent Management</strong><br/>
<img src="./images/agent-management.png" alt="Agent Management" width="100%">
</td>
<td width="33.33%" align="center">
<strong>Role Management</strong><br/>
<img src="./images/role-management.png" alt="Role Management" width="100%">
</td>
</tr>
<tr>
<td width="33.33%" align="center">
<strong>WebShell Management</strong><br/>
<img src="./images/webshell-management.png" alt="WebShell Management" width="100%">
<strong>System Settings</strong><br/>
<img src="./images/settings.png" alt="System settings" width="100%">
</td>
<td width="33.33%" align="center">
<strong>MCP stdio Mode</strong><br/>
<img src="./images/mcp-stdio2.png" alt="MCP stdio mode" width="100%">
</td>
<td width="33.33%" align="center">
<strong>Burp Suite Plugin</strong><br/>
<img src="./images/plugins.png" alt="Burp Suite plugin" width="100%">
</td>
<td width="33.33%" align="center"></td>
<td width="33.33%" align="center"></td>
</tr>
</table>
@@ -97,6 +103,14 @@ CyberStrikeAI is an **AI-native security testing platform** built in Go. It inte
- 📱 **Chatbot**: DingTalk and Lark (Feishu) long-lived connections so you can talk to CyberStrikeAI from mobile (see [Robot / Chatbot guide](docs/robot_en.md) for setup and commands)
- 🐚 **WebShell management**: Add and manage WebShell connections (e.g. IceSword/AntSword compatible), use a virtual terminal for command execution, a built-in file manager for file operations, and an AI assistant tab that orchestrates tests and keeps per-connection conversation history; supports PHP, ASP, ASPX, JSP and custom shell types with configurable request method and command parameter.
## Plugins
CyberStrikeAI includes optional integrations under `plugins/`.
- **Burp Suite extension**: `plugins/burp-suite/cyberstrikeai-burp-extension/`
Build output: `plugins/burp-suite/cyberstrikeai-burp-extension/dist/cyberstrikeai-burp-extension.jar`
Docs: `plugins/burp-suite/cyberstrikeai-burp-extension/README.md`
## Tool Overview
CyberStrikeAI ships with 100+ curated tools covering the whole kill chain:
+30 -16
View File
@@ -30,49 +30,55 @@ CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集
<img src="./images/web-console.png" alt="Web 控制台" width="100%">
</td>
<td width="33.33%" align="center">
<strong>攻击链可视化</strong><br/>
<img src="./images/attack-chain.png" alt="攻击链" width="100%">
</td>
<td width="33.33%" align="center">
<strong>任务管理</strong><br/>
<img src="./images/task-management.png" alt="任务管理" width="100%">
</td>
<td width="33.33%" align="center">
<strong>漏洞管理</strong><br/>
<img src="./images/vulnerability-management.png" alt="漏洞管理" width="100%">
</td>
</tr>
<tr>
<td width="33.33%" align="center">
<strong>漏洞管理</strong><br/>
<img src="./images/vulnerability-management.png" alt="漏洞管理" width="100%">
<strong>WebShell 管理</strong><br/>
<img src="./images/webshell-management.png" alt="WebShell 管理" width="100%">
</td>
<td width="33.33%" align="center">
<strong>MCP 管理</strong><br/>
<img src="./images/mcp-management.png" alt="MCP 管理" width="100%">
</td>
<td width="33.33%" align="center">
<strong>MCP stdio 模式</strong><br/>
<img src="./images/mcp-stdio2.png" alt="MCP stdio 模式" width="100%">
<strong>知识库</strong><br/>
<img src="./images/knowledge-base.png" alt="知识库" width="100%">
</td>
</tr>
<tr>
<td width="33.33%" align="center">
<strong>知识库</strong><br/>
<img src="./images/knowledge-base.png" alt="知识库" width="100%">
</td>
<td width="33.33%" align="center">
<strong>Skills 管理</strong><br/>
<img src="./images/skills.png" alt="Skills 管理" width="100%">
</td>
<td width="33.33%" align="center">
<strong>Agent 管理</strong><br/>
<img src="./images/agent-management.png" alt="Agent 管理" width="100%">
</td>
<td width="33.33%" align="center">
<strong>角色管理</strong><br/>
<img src="./images/role-management.png" alt="角色管理" width="100%">
</td>
</tr>
<tr>
<td width="33.33%" align="center">
<strong>WebShell 管理</strong><br/>
<img src="./images/webshell-management.png" alt="WebShell 管理" width="100%">
<strong>系统设置</strong><br/>
<img src="./images/settings.png" alt="系统设置" width="100%">
</td>
<td width="33.33%" align="center">
<strong>MCP stdio 模式</strong><br/>
<img src="./images/mcp-stdio2.png" alt="MCP stdio 模式" width="100%">
</td>
<td width="33.33%" align="center">
<strong>Burp Suite 插件</strong><br/>
<img src="./images/plugins.png" alt="Burp Suite 插件" width="100%">
</td>
<td width="33.33%" align="center"></td>
<td width="33.33%" align="center"></td>
</tr>
</table>
@@ -96,6 +102,14 @@ CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集
- 📱 **机器人**:支持钉钉、飞书长连接,在手机端与 CyberStrikeAI 对话(配置与命令详见 [机器人使用说明](docs/robot.md)
- 🐚 **WebShell 管理**:添加与管理 WebShell 连接(兼容冰蝎/蚁剑等),通过虚拟终端执行命令、内置文件管理进行文件操作,并提供按连接维度保存历史的 AI 助手标签页;支持 PHP/ASP/ASPX/JSP 及自定义类型,可配置请求方法与命令参数。
## 插件(Plugins
可选集成在 `plugins/` 目录下。
- **Burp Suite 插件**`plugins/burp-suite/cyberstrikeai-burp-extension/`
构建产物:`plugins/burp-suite/cyberstrikeai-burp-extension/dist/cyberstrikeai-burp-extension.jar`
说明文档:`plugins/burp-suite/cyberstrikeai-burp-extension/README.zh-CN.md`
## 工具概览
系统预置 100+ 渗透/攻防工具,覆盖完整攻击链:
+1 -1
View File
@@ -10,7 +10,7 @@
# ============================================
# 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.4.2"
version: "v1.4.3"
# 服务器配置
server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
Binary file not shown.

After

Width:  |  Height:  |  Size: 627 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

+365
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
@@ -25,6 +26,7 @@ import (
"cyberstrike-ai/internal/storage"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
@@ -336,6 +338,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
webshellHandler := handler.NewWebShellHandler(log.Logger, db)
chatUploadsHandler := handler.NewChatUploadsHandler(log.Logger)
registerWebshellTools(mcpServer, db, webshellHandler, log.Logger)
registerWebshellManagementTools(mcpServer, db, webshellHandler, log.Logger)
configHandler := handler.NewConfigHandler(configPath, cfg, mcpServer, executor, agent, attackChainHandler, externalMCPMgr, log.Logger)
externalMCPHandler := handler.NewExternalMCPHandler(externalMCPMgr, cfg, configPath, log.Logger)
roleHandler := handler.NewRoleHandler(cfg, configPath, log.Logger)
@@ -384,6 +387,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
// 设置 WebShell 工具注册器(ApplyConfig 时重新注册)
webshellRegistrar := func() error {
registerWebshellTools(mcpServer, db, webshellHandler, log.Logger)
registerWebshellManagementTools(mcpServer, db, webshellHandler, log.Logger)
return nil
}
configHandler.SetWebshellToolRegistrar(webshellRegistrar)
@@ -1270,6 +1274,367 @@ func registerWebshellTools(mcpServer *mcp.Server, db *database.DB, webshellHandl
logger.Info("WebShell 工具注册成功")
}
// registerWebshellManagementTools 注册 WebShell 连接管理 MCP 工具
func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, webshellHandler *handler.WebShellHandler, logger *zap.Logger) {
if db == nil {
logger.Warn("跳过 WebShell 管理工具注册:db 为空")
return
}
// manage_webshell_list - 列出所有 webshell 连接
listTool := mcp.Tool{
Name: builtin.ToolManageWebshellList,
Description: "列出所有已保存的 WebShell 连接,返回连接ID、URL、类型、备注等信息。",
ShortDescription: "列出所有 WebShell 连接",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
}
listHandler := func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
connections, err := db.ListWebshellConnections()
if err != nil {
return &mcp.ToolResult{
Content: []mcp.Content{{Type: "text", Text: "获取连接列表失败: " + err.Error()}},
IsError: true,
}, nil
}
if len(connections) == 0 {
return &mcp.ToolResult{
Content: []mcp.Content{{Type: "text", Text: "暂无 WebShell 连接"}},
IsError: false,
}, nil
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("找到 %d 个 WebShell 连接:\n\n", len(connections)))
for _, conn := range connections {
sb.WriteString(fmt.Sprintf("ID: %s\n", conn.ID))
sb.WriteString(fmt.Sprintf(" URL: %s\n", conn.URL))
sb.WriteString(fmt.Sprintf(" 类型: %s\n", conn.Type))
sb.WriteString(fmt.Sprintf(" 请求方式: %s\n", conn.Method))
sb.WriteString(fmt.Sprintf(" 命令参数: %s\n", conn.CmdParam))
if conn.Remark != "" {
sb.WriteString(fmt.Sprintf(" 备注: %s\n", conn.Remark))
}
sb.WriteString(fmt.Sprintf(" 创建时间: %s\n", conn.CreatedAt.Format("2006-01-02 15:04:05")))
sb.WriteString("\n")
}
return &mcp.ToolResult{
Content: []mcp.Content{{Type: "text", Text: sb.String()}},
IsError: false,
}, nil
}
mcpServer.RegisterTool(listTool, listHandler)
// manage_webshell_add - 添加新的 webshell 连接
addTool := mcp.Tool{
Name: builtin.ToolManageWebshellAdd,
Description: "添加新的 WebShell 连接到管理系统。支持 PHP、ASP、ASPX、JSP 等类型的一句话木马。",
ShortDescription: "添加 WebShell 连接",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"url": map[string]interface{}{
"type": "string",
"description": "Shell 地址,如 http://target.com/shell.php(必填)",
},
"password": map[string]interface{}{
"type": "string",
"description": "连接密码/密钥,如冰蝎/蚁剑的连接密码",
},
"type": map[string]interface{}{
"type": "string",
"description": "Shell 类型:php、asp、aspx、jsp,默认为 php",
"enum": []string{"php", "asp", "aspx", "jsp"},
},
"method": map[string]interface{}{
"type": "string",
"description": "请求方式:GET 或 POST,默认为 POST",
"enum": []string{"GET", "POST"},
},
"cmd_param": map[string]interface{}{
"type": "string",
"description": "命令参数名,不填默认为 cmd",
},
"remark": map[string]interface{}{
"type": "string",
"description": "备注,便于识别的备注名",
},
},
"required": []string{"url"},
},
}
addHandler := func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
urlStr, _ := args["url"].(string)
if urlStr == "" {
return &mcp.ToolResult{
Content: []mcp.Content{{Type: "text", Text: "错误: url 参数必填"}},
IsError: true,
}, nil
}
password, _ := args["password"].(string)
shellType, _ := args["type"].(string)
if shellType == "" {
shellType = "php"
}
method, _ := args["method"].(string)
if method == "" {
method = "post"
}
cmdParam, _ := args["cmd_param"].(string)
if cmdParam == "" {
cmdParam = "cmd"
}
remark, _ := args["remark"].(string)
// 生成连接ID
connID := "ws_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:12]
conn := &database.WebShellConnection{
ID: connID,
URL: urlStr,
Password: password,
Type: strings.ToLower(shellType),
Method: strings.ToLower(method),
CmdParam: cmdParam,
Remark: remark,
CreatedAt: time.Now(),
}
if err := db.CreateWebshellConnection(conn); err != nil {
return &mcp.ToolResult{
Content: []mcp.Content{{Type: "text", Text: "添加 WebShell 连接失败: " + err.Error()}},
IsError: true,
}, nil
}
return &mcp.ToolResult{
Content: []mcp.Content{{
Type: "text",
Text: fmt.Sprintf("WebShell 连接添加成功!\n\n连接ID: %s\nURL: %s\n类型: %s\n请求方式: %s\n命令参数: %s", conn.ID, conn.URL, conn.Type, conn.Method, conn.CmdParam),
}},
IsError: false,
}, nil
}
mcpServer.RegisterTool(addTool, addHandler)
// manage_webshell_update - 更新 webshell 连接
updateTool := mcp.Tool{
Name: builtin.ToolManageWebshellUpdate,
Description: "更新已存在的 WebShell 连接信息。",
ShortDescription: "更新 WebShell 连接",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"connection_id": map[string]interface{}{
"type": "string",
"description": "要更新的 WebShell 连接 ID(必填)",
},
"url": map[string]interface{}{
"type": "string",
"description": "新的 Shell 地址",
},
"password": map[string]interface{}{
"type": "string",
"description": "新的连接密码/密钥",
},
"type": map[string]interface{}{
"type": "string",
"description": "新的 Shell 类型:php、asp、aspx、jsp",
"enum": []string{"php", "asp", "aspx", "jsp"},
},
"method": map[string]interface{}{
"type": "string",
"description": "新的请求方式:GET 或 POST",
"enum": []string{"GET", "POST"},
},
"cmd_param": map[string]interface{}{
"type": "string",
"description": "新的命令参数名",
},
"remark": map[string]interface{}{
"type": "string",
"description": "新的备注",
},
},
"required": []string{"connection_id"},
},
}
updateHandler := func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
connID, _ := args["connection_id"].(string)
if connID == "" {
return &mcp.ToolResult{
Content: []mcp.Content{{Type: "text", Text: "错误: connection_id 参数必填"}},
IsError: true,
}, nil
}
// 获取现有连接
existing, err := db.GetWebshellConnection(connID)
if err != nil || existing == nil {
return &mcp.ToolResult{
Content: []mcp.Content{{Type: "text", Text: "未找到指定的 WebShell 连接: " + connID}},
IsError: true,
}, nil
}
// 更新字段(如果提供了新值)
if urlStr, ok := args["url"].(string); ok && urlStr != "" {
existing.URL = urlStr
}
if password, ok := args["password"].(string); ok {
existing.Password = password
}
if shellType, ok := args["type"].(string); ok && shellType != "" {
existing.Type = strings.ToLower(shellType)
}
if method, ok := args["method"].(string); ok && method != "" {
existing.Method = strings.ToLower(method)
}
if cmdParam, ok := args["cmd_param"].(string); ok && cmdParam != "" {
existing.CmdParam = cmdParam
}
if remark, ok := args["remark"].(string); ok {
existing.Remark = remark
}
if err := db.UpdateWebshellConnection(existing); err != nil {
return &mcp.ToolResult{
Content: []mcp.Content{{Type: "text", Text: "更新 WebShell 连接失败: " + err.Error()}},
IsError: true,
}, nil
}
return &mcp.ToolResult{
Content: []mcp.Content{{
Type: "text",
Text: fmt.Sprintf("WebShell 连接更新成功!\n\n连接ID: %s\nURL: %s\n类型: %s\n请求方式: %s\n命令参数: %s\n备注: %s", existing.ID, existing.URL, existing.Type, existing.Method, existing.CmdParam, existing.Remark),
}},
IsError: false,
}, nil
}
mcpServer.RegisterTool(updateTool, updateHandler)
// manage_webshell_delete - 删除 webshell 连接
deleteTool := mcp.Tool{
Name: builtin.ToolManageWebshellDelete,
Description: "删除指定的 WebShell 连接。",
ShortDescription: "删除 WebShell 连接",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"connection_id": map[string]interface{}{
"type": "string",
"description": "要删除的 WebShell 连接 ID(必填)",
},
},
"required": []string{"connection_id"},
},
}
deleteHandler := func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
connID, _ := args["connection_id"].(string)
if connID == "" {
return &mcp.ToolResult{
Content: []mcp.Content{{Type: "text", Text: "错误: connection_id 参数必填"}},
IsError: true,
}, nil
}
if err := db.DeleteWebshellConnection(connID); err != nil {
return &mcp.ToolResult{
Content: []mcp.Content{{Type: "text", Text: "删除 WebShell 连接失败: " + err.Error()}},
IsError: true,
}, nil
}
return &mcp.ToolResult{
Content: []mcp.Content{{
Type: "text",
Text: fmt.Sprintf("WebShell 连接 %s 已成功删除", connID),
}},
IsError: false,
}, nil
}
mcpServer.RegisterTool(deleteTool, deleteHandler)
// manage_webshell_test - 测试 webshell 连接
testTool := mcp.Tool{
Name: builtin.ToolManageWebshellTest,
Description: "测试指定的 WebShell 连接是否可用,会尝试执行一个简单的命令(如 whoami 或 dir)。",
ShortDescription: "测试 WebShell 连接",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"connection_id": map[string]interface{}{
"type": "string",
"description": "要测试的 WebShell 连接 ID(必填)",
},
"command": map[string]interface{}{
"type": "string",
"description": "测试命令,默认为 whoamiLinux)或 dirWindows",
},
},
"required": []string{"connection_id"},
},
}
testHandler := func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
connID, _ := args["connection_id"].(string)
if connID == "" {
return &mcp.ToolResult{
Content: []mcp.Content{{Type: "text", Text: "错误: connection_id 参数必填"}},
IsError: true,
}, nil
}
// 获取连接
conn, err := db.GetWebshellConnection(connID)
if err != nil || conn == nil {
return &mcp.ToolResult{
Content: []mcp.Content{{Type: "text", Text: "未找到指定的 WebShell 连接: " + connID}},
IsError: true,
}, nil
}
// 确定测试命令
testCmd, _ := args["command"].(string)
if testCmd == "" {
// 根据 shell 类型选择默认命令
if conn.Type == "asp" || conn.Type == "aspx" {
testCmd = "dir"
} else {
testCmd = "whoami"
}
}
// 执行测试命令
output, ok, errMsg := webshellHandler.ExecWithConnection(conn, testCmd)
if errMsg != "" {
return &mcp.ToolResult{
Content: []mcp.Content{{Type: "text", Text: fmt.Sprintf("连接测试失败!\n\n连接ID: %s\nURL: %s\n错误: %s", connID, conn.URL, errMsg)}},
IsError: true,
}, nil
}
if !ok {
return &mcp.ToolResult{
Content: []mcp.Content{{Type: "text", Text: fmt.Sprintf("连接测试失败!HTTP 非 200\n\n连接ID: %s\nURL: %s\n输出: %s", connID, conn.URL, output)}},
IsError: true,
}, nil
}
return &mcp.ToolResult{
Content: []mcp.Content{{
Type: "text",
Text: fmt.Sprintf("连接测试成功!\n\n连接ID: %s\nURL: %s\n类型: %s\n\n测试命令: %s\n输出结果:\n%s", connID, conn.URL, conn.Type, testCmd, output),
}},
IsError: false,
}, nil
}
mcpServer.RegisterTool(testTool, testHandler)
logger.Info("WebShell 管理工具注册成功")
}
// initializeKnowledge 初始化知识库组件(用于动态初始化)
func initializeKnowledge(
cfg *config.Config,
+9 -6
View File
@@ -224,9 +224,9 @@ func (h *SkillsHandler) GetSkillBoundRoles(c *gin.Context) {
boundRoles := h.getRolesBoundToSkill(skillName)
c.JSON(http.StatusOK, gin.H{
"skill": skillName,
"bound_roles": boundRoles,
"bound_count": len(boundRoles),
"skill": skillName,
"bound_roles": boundRoles,
"bound_count": len(boundRoles),
})
}
@@ -323,6 +323,7 @@ func (h *SkillsHandler) CreateSkill(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建skill文件失败: " + err.Error()})
return
}
h.manager.InvalidateSkill(req.Name)
h.logger.Info("创建skill成功", zap.String("skill", req.Name))
c.JSON(http.StatusOK, gin.H{
@@ -443,6 +444,7 @@ func (h *SkillsHandler) UpdateSkill(c *gin.Context) {
if skillFile != targetFile {
os.Remove(skillFile)
}
h.manager.InvalidateSkill(skillName)
h.logger.Info("更新skill成功", zap.String("skill", skillName))
c.JSON(http.StatusOK, gin.H{
@@ -461,8 +463,8 @@ func (h *SkillsHandler) DeleteSkill(c *gin.Context) {
// 检查是否有角色绑定了该skill,如果有则自动移除绑定
affectedRoles := h.removeSkillFromRoles(skillName)
if len(affectedRoles) > 0 {
h.logger.Info("从角色中移除skill绑定",
zap.String("skill", skillName),
h.logger.Info("从角色中移除skill绑定",
zap.String("skill", skillName),
zap.Strings("roles", affectedRoles))
}
@@ -483,10 +485,11 @@ func (h *SkillsHandler) DeleteSkill(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除skill失败: " + err.Error()})
return
}
h.manager.InvalidateSkill(skillName)
responseMsg := "skill已删除"
if len(affectedRoles) > 0 {
responseMsg = fmt.Sprintf("skill已删除,已自动从 %d 个角色中移除绑定: %s",
responseMsg = fmt.Sprintf("skill已删除,已自动从 %d 个角色中移除绑定: %s",
len(affectedRoles), strings.Join(affectedRoles, ", "))
}
+1 -1
View File
@@ -19,7 +19,7 @@ import (
const (
terminalMaxCommandLen = 4096
terminalMaxOutputLen = 256 * 1024 // 256KB
terminalTimeout = 120 * time.Second
terminalTimeout = 30 * time.Minute
)
// TerminalHandler 处理系统设置中的终端命令执行
+18 -1
View File
@@ -19,6 +19,13 @@ const (
ToolWebshellFileList = "webshell_file_list"
ToolWebshellFileRead = "webshell_file_read"
ToolWebshellFileWrite = "webshell_file_write"
// WebShell 连接管理工具(用于通过 MCP 管理 webshell 连接)
ToolManageWebshellList = "manage_webshell_list"
ToolManageWebshellAdd = "manage_webshell_add"
ToolManageWebshellUpdate = "manage_webshell_update"
ToolManageWebshellDelete = "manage_webshell_delete"
ToolManageWebshellTest = "manage_webshell_test"
)
// IsBuiltinTool 检查工具名称是否是内置工具
@@ -32,7 +39,12 @@ func IsBuiltinTool(toolName string) bool {
ToolWebshellExec,
ToolWebshellFileList,
ToolWebshellFileRead,
ToolWebshellFileWrite:
ToolWebshellFileWrite,
ToolManageWebshellList,
ToolManageWebshellAdd,
ToolManageWebshellUpdate,
ToolManageWebshellDelete,
ToolManageWebshellTest:
return true
default:
return false
@@ -51,5 +63,10 @@ func GetAllBuiltinTools() []string {
ToolWebshellFileList,
ToolWebshellFileRead,
ToolWebshellFileWrite,
ToolManageWebshellList,
ToolManageWebshellAdd,
ToolManageWebshellUpdate,
ToolManageWebshellDelete,
ToolManageWebshellTest,
}
}
+9 -2
View File
@@ -297,6 +297,7 @@ func RunDeepAgent(
return agent == "" || agent == orchestratorName
}
// 仅保留主代理最后一次 assistant 输出,避免把多轮中间回复拼接到最终答案。
var lastAssistant string
var reasoningStreamSeq int64
var einoSubReplyStreamSeq int64
@@ -335,6 +336,7 @@ func RunDeepAgent(
var toolStreamFragments []schema.ToolCall
var subAssistantBuf strings.Builder
var subReplyStreamID string
var mainAssistantBuf strings.Builder
for {
chunk, rerr := mv.MessageStream.Recv()
if rerr != nil {
@@ -376,7 +378,7 @@ func RunDeepAgent(
"conversationId": conversationID,
"mcpExecutionIds": snapshotMCPIDs(),
})
lastAssistant += chunk.Content
mainAssistantBuf.WriteString(chunk.Content)
} else if !streamsMainAssistant(ev.AgentName) {
if progress != nil {
if subReplyStreamID == "" {
@@ -401,6 +403,11 @@ func RunDeepAgent(
toolStreamFragments = append(toolStreamFragments, chunk.ToolCalls...)
}
}
if streamsMainAssistant(ev.AgentName) {
if s := strings.TrimSpace(mainAssistantBuf.String()); s != "" {
lastAssistant = s
}
}
if subAssistantBuf.Len() > 0 && progress != nil {
if s := strings.TrimSpace(subAssistantBuf.String()); s != "" {
if subReplyStreamID != "" {
@@ -455,7 +462,7 @@ func RunDeepAgent(
"mcpExecutionIds": snapshotMCPIDs(),
})
}
lastAssistant += body
lastAssistant = body
} else if progress != nil {
progress("eino_agent_reply", body, map[string]interface{}{
"conversationId": conversationID,
+74 -39
View File
@@ -14,8 +14,14 @@ import (
type Manager struct {
skillsDir string
logger *zap.Logger
skills map[string]*Skill // 缓存已加载的skills
mu sync.RWMutex // 保护skills map的并发访问
skills map[string]*cachedSkill // 缓存已加载的skills(含文件状态)
mu sync.RWMutex // 保护skills map的并发访问
}
type cachedSkill struct {
skill *Skill
filePath string
modTime int64
}
// Skill Skill定义
@@ -31,49 +37,43 @@ func NewManager(skillsDir string, logger *zap.Logger) *Manager {
return &Manager{
skillsDir: skillsDir,
logger: logger,
skills: make(map[string]*Skill),
skills: make(map[string]*cachedSkill),
}
}
// LoadSkill 加载单个skill
func (m *Manager) LoadSkill(skillName string) (*Skill, error) {
// 先尝试读锁检查缓存
m.mu.RLock()
if skill, exists := m.skills[skillName]; exists {
m.mu.RUnlock()
return skill, nil
}
m.mu.RUnlock()
// 构建skill路径
skillPath := filepath.Join(m.skillsDir, skillName)
// 检查目录是否存在
if _, err := os.Stat(skillPath); os.IsNotExist(err) {
m.InvalidateSkill(skillName)
return nil, fmt.Errorf("skill %s not found", skillName)
}
// 查找SKILL.md文件
skillFile := filepath.Join(skillPath, "SKILL.md")
if _, err := os.Stat(skillFile); os.IsNotExist(err) {
// 尝试其他可能的文件名
alternatives := []string{
filepath.Join(skillPath, "skill.md"),
filepath.Join(skillPath, "README.md"),
filepath.Join(skillPath, "readme.md"),
}
found := false
for _, alt := range alternatives {
if _, err := os.Stat(alt); err == nil {
skillFile = alt
found = true
break
}
}
if !found {
return nil, fmt.Errorf("skill file not found for %s", skillName)
}
// 查找skill文件并读取文件状态
skillFile, err := m.resolveSkillFile(skillPath)
if err != nil {
m.InvalidateSkill(skillName)
return nil, err
}
fileInfo, err := os.Stat(skillFile)
if err != nil {
m.InvalidateSkill(skillName)
return nil, fmt.Errorf("failed to stat skill file: %w", err)
}
modTime := fileInfo.ModTime().UnixNano()
// 先尝试读锁命中缓存(文件路径和修改时间都未变化)
m.mu.RLock()
if cached, exists := m.skills[skillName]; exists &&
cached.filePath == skillFile &&
cached.modTime == modTime {
m.mu.RUnlock()
return cached.skill, nil
}
m.mu.RUnlock()
// 读取skill文件
content, err := os.ReadFile(skillFile)
@@ -83,15 +83,14 @@ func (m *Manager) LoadSkill(skillName string) (*Skill, error) {
// 解析skill内容
skill := m.parseSkillContent(string(content), skillName, skillPath)
// 使用写锁缓存skill(双重检查,避免重复加载)
// 使用写锁更新缓存
m.mu.Lock()
// 再次检查,可能其他goroutine已经加载了
if existing, exists := m.skills[skillName]; exists {
m.mu.Unlock()
return existing, nil
m.skills[skillName] = &cachedSkill{
skill: skill,
filePath: skillFile,
modTime: modTime,
}
m.skills[skillName] = skill
m.mu.Unlock()
return skill, nil
@@ -161,6 +160,42 @@ func (m *Manager) ListSkills() ([]string, error) {
return skills, nil
}
func (m *Manager) resolveSkillFile(skillPath string) (string, error) {
// 优先标准文件名
skillFile := filepath.Join(skillPath, "SKILL.md")
if _, err := os.Stat(skillFile); err == nil {
return skillFile, nil
}
// 兼容历史文件名
alternatives := []string{
filepath.Join(skillPath, "skill.md"),
filepath.Join(skillPath, "README.md"),
filepath.Join(skillPath, "readme.md"),
}
for _, alt := range alternatives {
if _, err := os.Stat(alt); err == nil {
return alt, nil
}
}
return "", fmt.Errorf("skill file not found for %s", filepath.Base(skillPath))
}
// InvalidateSkill 使指定skill缓存失效
func (m *Manager) InvalidateSkill(skillName string) {
m.mu.Lock()
delete(m.skills, skillName)
m.mu.Unlock()
}
// InvalidateAll 清空全部skill缓存
func (m *Manager) InvalidateAll() {
m.mu.Lock()
m.skills = make(map[string]*cachedSkill)
m.mu.Unlock()
}
// parseSkillContent 解析skill内容
// 支持YAML front matter格式,类似goskills
func (m *Manager) parseSkillContent(content, skillName, skillPath string) *Skill {
+95 -39
View File
@@ -29,6 +29,11 @@ _LISTENER_PORT: int | None = None
_CLIENT_SOCK: socket.socket | None = None
_CLIENT_ADDR: tuple[str, int] | None = None
_LOCK = threading.Lock()
_STOP_EVENT = threading.Event()
_READY_EVENT = threading.Event()
_LAST_LISTEN_ERROR: str | None = None
_LISTENER_THREAD_JOIN_TIMEOUT = 1.0
_START_READY_TIMEOUT = 1.5
# 用于 send_command 的输出结束标记(避免无限等待)
_END_MARKER = "__RS_DONE__"
@@ -62,37 +67,55 @@ def _get_local_ips() -> list[str]:
def _accept_loop(port: int) -> None:
"""在后台线程中:bind、listen、accept,只接受一个客户端。"""
global _LISTENER, _CLIENT_SOCK, _CLIENT_ADDR, _LISTENER_PORT
global _LISTENER, _CLIENT_SOCK, _CLIENT_ADDR, _LISTENER_PORT, _LAST_LISTEN_ERROR
sock: socket.socket | None = None
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("0.0.0.0", port))
sock.listen(1)
# 避免 stop_listener 关闭后 accept() 长时间不返回:用超时轮询检查停止事件
sock.settimeout(0.5)
with _LOCK:
_LISTENER = sock
# 阻塞 accept,只接受一个连接
client, addr = sock.accept()
_LISTENER_PORT = port
_LAST_LISTEN_ERROR = None
_READY_EVENT.set()
# 循环 accept:只接受一个连接,或等待 stop 事件
while not _STOP_EVENT.is_set():
try:
client, addr = sock.accept()
except socket.timeout:
continue
except OSError:
break
with _LOCK:
_CLIENT_SOCK = client
_CLIENT_ADDR = (addr[0], addr[1])
break
except OSError as e:
with _LOCK:
_CLIENT_SOCK = client
_CLIENT_ADDR = (addr[0], addr[1])
except OSError:
pass
_LAST_LISTEN_ERROR = str(e)
_READY_EVENT.set()
finally:
with _LOCK:
if _LISTENER:
try:
_LISTENER.close()
except OSError:
pass
_LISTENER = None
_LISTENER = None
_LISTENER_PORT = None
if sock is not None:
try:
sock.close()
except OSError:
pass
def _start_listener(port: int) -> str:
global _LISTENER_THREAD, _LISTENER_PORT, _CLIENT_SOCK, _CLIENT_ADDR
global _LISTENER_THREAD, _LISTENER_PORT, _CLIENT_SOCK, _CLIENT_ADDR, _LAST_LISTEN_ERROR
old_thread: threading.Thread | None = None
with _LOCK:
if _LISTENER is not None or (_LISTENER_THREAD is not None and _LISTENER_THREAD.is_alive()):
return f"已在监听中(端口: {_LISTENER_PORT}),请先 stop_listener 再重新 start。"
if _LISTENER is not None:
# _LISTENER_PORT 可能短暂为 None(例如刚 stop/start),因此做个兜底显示
show_port = _LISTENER_PORT if _LISTENER_PORT is not None else port
return f"已在监听中(端口: {show_port}),请先 stop_listener 再重新 start。"
if _CLIENT_SOCK is not None:
try:
_CLIENT_SOCK.close()
@@ -100,39 +123,72 @@ def _start_listener(port: int) -> str:
pass
_CLIENT_SOCK = None
_CLIENT_ADDR = None
old_thread = _LISTENER_THREAD
# 若旧线程还没完全退出,短暂等待一下以减少端口绑定失败概率
if old_thread is not None and old_thread.is_alive():
old_thread.join(timeout=0.5)
_STOP_EVENT.clear()
_READY_EVENT.clear()
_LAST_LISTEN_ERROR = None
th = threading.Thread(target=_accept_loop, args=(port,), daemon=True)
th.start()
_LISTENER_THREAD = th
time.sleep(0.2)
# 等待后台线程完成 bind/listen(或失败)
_READY_EVENT.wait(timeout=_START_READY_TIMEOUT)
with _LOCK:
if _LISTENER is not None:
_LISTENER_PORT = port
ips = _get_local_ips()
addrs = ", ".join(f"{ip}:{port}" for ip in ips)
return (
f"已在 0.0.0.0:{port} 开始监听。"
f"目标机请反弹到: {addrs}(任选其一)。连接后使用 reverse_shell_send_command 执行命令。"
)
return f"监听 0.0.0.0:{port} 已启动(若端口被占用会失败,请检查)"
err = _LAST_LISTEN_ERROR
listening = _LISTENER is not None
if listening:
ips = _get_local_ips()
addrs = ", ".join(f"{ip}:{port}" for ip in ips)
return (
f"已在 0.0.0.0:{port} 开始监听。"
f"目标机请反弹到: {addrs}(任选其一)。连接后使用 reverse_shell_send_command 执行命令"
)
if err:
return f"启动监听失败(0.0.0.0:{port}):{err}"
# 仍未准备好:可能线程调度较慢或环境异常;给出可操作的提示
return f"启动监听未确认成功(0.0.0.0:{port})。请调用 reverse_shell_status 确认,或稍后重试。"
def _stop_listener() -> str:
global _LISTENER, _LISTENER_THREAD, _CLIENT_SOCK, _CLIENT_ADDR, _LISTENER_PORT
listener_sock: socket.socket | None = None
client_sock: socket.socket | None = None
old_thread: threading.Thread | None = None
with _LOCK:
if _LISTENER is not None:
try:
_LISTENER.close()
except OSError:
pass
_LISTENER = None
_STOP_EVENT.set()
_READY_EVENT.set()
listener_sock = _LISTENER
old_thread = _LISTENER_THREAD
_LISTENER = None
_LISTENER_PORT = None
if _CLIENT_SOCK is not None:
try:
_CLIENT_SOCK.close()
except OSError:
pass
_CLIENT_SOCK = None
_CLIENT_ADDR = None
client_sock = _CLIENT_SOCK
_CLIENT_SOCK = None
_CLIENT_ADDR = None
if listener_sock is not None:
try:
listener_sock.close()
except OSError:
pass
if client_sock is not None:
try:
client_sock.close()
except OSError:
pass
# 等待监听线程退出,避免 stop/start 竞态导致“端口 None 仍提示已在监听中”
if old_thread is not None and old_thread.is_alive():
old_thread.join(timeout=_LISTENER_THREAD_JOIN_TIMEOUT)
with _LOCK:
_LISTENER_THREAD = None
return "监听已停止,已断开当前客户端(如有)。"
+12
View File
@@ -0,0 +1,12 @@
## Plugins
This directory contains optional plugins/extensions that integrate CyberStrikeAI with other tools.
- `burp-suite/`: Burp Suite extensions
### Burp Suite Extension
- **Path**: `plugins/burp-suite/cyberstrikeai-burp-extension/`
- **Build output**: `plugins/burp-suite/cyberstrikeai-burp-extension/dist/cyberstrikeai-burp-extension.jar`
- **Docs**: see the plugin folder `README.md` / `README.zh-CN.md`
@@ -0,0 +1,68 @@
## CyberStrikeAI Burp Suite Extension
中文说明见:`README.zh-CN.md`
### What it does
- Configure **Host / Port / Password** and choose **Single-Agent** or **Multi-Agent**
- Click **Validate** to login (`POST /api/auth/login`) and verify token (`GET /api/auth/validate`)
- Right-click any HTTP message in Burp and send it to CyberStrikeAI for **streaming web pentest**
- Keep a **test history sidebar** (searchable) so you can revisit previous runs
- Output is split into **collapsible Progress** + **Final Response** (Markdown rendering supported)
- View captured **Request / Response** for each run
- **Stop** a running task (calls `/api/agent-loop/cancel` once `conversationId` is available)
### Build
Requirements:
- JDK 11+
- Maven (recommended) OR Burp Extender API jar (offline mode)
#### Option A (recommended): Maven build (no need to locate Burp)
```bash
cd plugins/burp-suite/cyberstrikeai-burp-extension
./build-mvn.sh
```
Output:
- `dist/cyberstrikeai-burp-extension.jar`
#### Option B: Offline build with `build.sh` (needs Burp API jar)
1) Create `lib/` and copy Burp's API jar into it:
```bash
mkdir -p lib
# copy from your Burp installation, for example:
# cp "/path/to/burp-extender-api.jar" lib/
```
2) Build:
```bash
cd plugins/burp-suite/cyberstrikeai-burp-extension
./build.sh
```
Output:
- `dist/cyberstrikeai-burp-extension.jar`
#### Option C: Gradle (optional)
If you already have Gradle available, you can still use `build.gradle` to build.
### Load in Burp Suite
- Burp Suite → **Extensions****Installed****Add**
- Extension type: **Java**
- Select the jar above
### Notes
- This extension connects to your CyberStrikeAI server (default is `http://127.0.0.1:8080`).
- It uses **Bearer Token** authentication obtained from the configured password.
@@ -0,0 +1,108 @@
## CyberStrikeAI Burp Suite 插件(中文说明)
### 功能概述
- 在 Burp 的 `CyberStrikeAI` 标签页中配置 **Host、端口、密码、单/多 Agent**
- 点击 **Validate(验证)**
- 调用 `POST /api/auth/login` 用密码换取 Token
- 调用 `GET /api/auth/validate` 校验 Token
- 验证通过后 Token 会保存在插件内存中(本次 Burp 会话有效)
- 右键任意 HTTP 请求包 → **Send to CyberStrikeAI (stream test)**
- 将该 HTTP 请求(含 headers/body;若存在响应则附带截断片段)发送到 CyberStrikeAI
- 以 **SSE 流式**接收返回内容,并在标签页中实时展示
- 单 Agent`POST /api/agent-loop/stream`
- 多 Agent`POST /api/multi-agent/stream`(需要服务端启用 `multi_agent.enabled: true`
- **测试历史侧边栏(可搜索)**:每次发送都会新增一条记录,方便回看与对比
- **Output 分区**`Progress`(可折叠)+ `Final Response`(主区域)
- **Markdown 渲染**:最终输出可在 Output 主区域渲染为富文本(可开关)
- **Request / Response 回看**:右侧 Tab 可直接查看该次捕获到的原始请求/响应
- **Stop 取消**:任务创建会话后可调用 `/api/agent-loop/cancel` 停止当前会话任务
### 编译(不依赖 Gradle/Maven,推荐)
> 给普通用户:你们应当直接发 **编译好的 jar**,用户在 Burp 里加载即可,**不需要编译**。
#### 方式 A(推荐,通用):用 Maven 编译(不需要知道 Burp 在哪)
适合:开发者/CI 打包一次,发布给所有用户使用。
环境要求:
- JDK 11+
- Maven(会从 Maven Central 下载 `burp-extender-api` 依赖)
编译打包:
```bash
cd plugins/burp-suite/cyberstrikeai-burp-extension
./build-mvn.sh
```
产物:
- `dist/cyberstrikeai-burp-extension.jar`
#### 方式 B(离线):纯 JDK 编译(需要 Burp 的 API jar
- JDK 11+
- Burp Extender API 的 jar(来自你的 Burp 安装目录)
#### 步骤
1) 在插件目录创建 `lib/`,并把 `burp-extender-api.jar` 复制进去:
```bash
cd plugins/burp-suite/cyberstrikeai-burp-extension
mkdir -p lib
# 复制 Burp 自带的 API jar 到这里,例如:
# cp "/path/to/burp-extender-api.jar" lib/
```
2) 一键编译打包:
```bash
cd plugins/burp-suite/cyberstrikeai-burp-extension
./build.sh
```
产物:
- `dist/cyberstrikeai-burp-extension.jar`
### 在 Burp Suite 中加载
- Burp Suite → **Extensions****Installed****Add**
- Extension type**Java**
- 选择 `dist/cyberstrikeai-burp-extension.jar`
### 使用方法
1) 打开 Burp 顶部标签页 `CyberStrikeAI`
2) 填写:
- **Host**:例如 `127.0.0.1`
- **Port**:例如 `8080`
- **Password**:你的 CyberStrikeAI 登录密码(对应服务端 `config.yaml``auth.password`
- **Agent mode**:选择 `Single Agent``Multi Agent`
3) 点击 **Validate**
- 成功:状态显示 `OK (token saved)`
- 失败:状态会显示错误原因(例如密码错误、服务不可达、401/403 等)
4) 在 Burp 的 Proxy/HTTP history/Repeater 等列表中选中一条 HTTP 包
5) 右键 → **Send to CyberStrikeAI (stream test)**
6) 每次发送后会在 `CyberStrikeAI` 标签页左侧显示一个“测试记录”(请求标题 + 单/多 Agent + 状态);点击对应记录即可在右侧查看该次的流式输出结果
### 常见问题(排错)
- **Validate 失败 / 401**
- 确认密码是否正确(服务端 `auth.password`
- 确认 IP/端口是否能访问(例如浏览器能打开 `http://IP:PORT/`
- 若服务器启用了反向代理/HTTPS,需要把插件里 baseUrl 改成对应协议与端口(当前插件默认使用 `http://`
- **选择 Multi Agent 后提示“多代理未启用”**
- 服务端需要开启:`config.yaml``multi_agent.enabled: true`
- 并重启服务(或按你们项目的动态 apply 配置流程启用)
- **右键发送后无流式输出**
- 先确认已 Validate(拿到 Token
- 确认 Burp 能访问到 CyberStrikeAI(网络/代理/防火墙)
- 服务端的流式端点为 SSE,插件会解析 `data: {json}` 行;如果中间件缓冲可能影响实时性
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DIST_DIR="$ROOT_DIR/dist"
MVN_BIN=""
if command -v mvn >/dev/null 2>&1; then
MVN_BIN="mvn"
else
# Auto-provision Maven for developer convenience.
# This is only used to build the jar once in CI/dev; Burp users don't need to run this.
MAVEN_VERSION="3.9.6"
BASE_DIR="${HOME}/.cache/cyberstrikeai-burp-extension"
MAVEN_DIR="$BASE_DIR/apache-maven-$MAVEN_VERSION"
MAVEN_TGZ="$BASE_DIR/apache-maven-$MAVEN_VERSION-bin.tar.gz"
MAVEN_URL="https://archive.apache.org/dist/maven/maven-3/$MAVEN_VERSION/binaries/apache-maven-$MAVEN_VERSION-bin.tar.gz"
if [[ -x "$MAVEN_DIR/bin/mvn" ]]; then
MVN_BIN="$MAVEN_DIR/bin/mvn"
else
echo "[*] Maven not found. Downloading Maven $MAVEN_VERSION ..."
mkdir -p "$BASE_DIR"
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$MAVEN_URL" -o "$MAVEN_TGZ"
elif command -v wget >/dev/null 2>&1; then
wget -q "$MAVEN_URL" -O "$MAVEN_TGZ"
else
echo "Missing: curl/wget (needed to download Maven)."
exit 1
fi
tar -xzf "$MAVEN_TGZ" -C "$BASE_DIR"
MVN_BIN="$MAVEN_DIR/bin/mvn"
fi
fi
rm -rf "$DIST_DIR"
mkdir -p "$DIST_DIR"
echo "[*] Building with Maven (downloads Burp API from Maven Central)..."
(cd "$ROOT_DIR" && "$MVN_BIN" -q -DskipTests package)
cp "$ROOT_DIR/target/cyberstrikeai-burp-extension-1.0.0.jar" "$DIST_DIR/cyberstrikeai-burp-extension.jar"
echo "[+] Done: $DIST_DIR/cyberstrikeai-burp-extension.jar"
@@ -0,0 +1,45 @@
plugins {
id 'java'
id 'com.github.johnrengelman.shadow' version '8.1.1'
}
group = 'ai.cyberstrike'
version = '1.0.0'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(11)
}
}
repositories {
mavenCentral()
}
dependencies {
// Burp Extender API (legacy). Burp will provide the interfaces at runtime, but we compile against it.
implementation 'net.portswigger.burp.extender:burp-extender-api:2.3'
// JSON parsing for SSE payloads.
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.2'
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
options.release = 11
}
jar {
manifest {
attributes(
'Main-Class': 'burp.BurpExtender'
)
}
}
shadowJar {
archiveBaseName.set('cyberstrikeai-burp-extension')
archiveClassifier.set('all')
archiveVersion.set('')
}
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIB_DIR="$ROOT_DIR/lib"
DIST_DIR="$ROOT_DIR/dist"
BUILD_DIR="$ROOT_DIR/.build"
API_JAR="$LIB_DIR/burp-extender-api.jar"
if [[ ! -f "$API_JAR" ]]; then
echo "Missing: $API_JAR"
echo "Please copy Burp's burp-extender-api.jar into plugins/burp-suite/cyberstrikeai-burp-extension/lib/"
exit 1
fi
rm -rf "$BUILD_DIR" "$DIST_DIR"
mkdir -p "$BUILD_DIR" "$DIST_DIR"
SRC_FILES=$(find "$ROOT_DIR/src/main/java" -name "*.java")
echo "[*] Compiling..."
javac \
-encoding UTF-8 \
--release 11 \
-cp "$API_JAR" \
-d "$BUILD_DIR" \
$SRC_FILES
echo "[*] Packaging..."
JAR_OUT="$DIST_DIR/cyberstrikeai-burp-extension.jar"
jar --create --file "$JAR_OUT" --main-class burp.BurpExtender -C "$BUILD_DIR" .
echo "[+] Done: $JAR_OUT"
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>ai.cyberstrike</groupId>
<artifactId>cyberstrikeai-burp-extension</artifactId>
<version>1.0.0</version>
<name>CyberStrikeAI Burp Suite Extension</name>
<properties>
<maven.compiler.release>11</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- Compile-only: Burp provides these classes at runtime -->
<dependency>
<groupId>net.portswigger.burp.extender</groupId>
<artifactId>burp-extender-api</artifactId>
<version>2.3</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.2</version>
<configuration>
<archive>
<manifest>
<mainClass>burp.BurpExtender</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,2 @@
rootProject.name = "cyberstrikeai-burp-extension"
@@ -0,0 +1,146 @@
package burp;
import javax.swing.*;
import java.util.ArrayList;
import java.util.List;
public class BurpExtender implements IBurpExtender, IContextMenuFactory {
private IBurpExtenderCallbacks callbacks;
private IExtensionHelpers helpers;
private CyberStrikeAITab tab;
private final CyberStrikeAIClient client = new CyberStrikeAIClient();
@Override
public void registerExtenderCallbacks(IBurpExtenderCallbacks callbacks) {
this.callbacks = callbacks;
this.helpers = callbacks.getHelpers();
callbacks.setExtensionName("CyberStrikeAI Extension");
this.tab = new CyberStrikeAITab();
callbacks.addSuiteTab(tab);
callbacks.registerContextMenuFactory(this);
callbacks.printOutput("CyberStrikeAI extension loaded.");
}
@Override
public List<JMenuItem> createMenuItems(IContextMenuInvocation invocation) {
List<JMenuItem> items = new ArrayList<>();
JMenuItem sendItem = new JMenuItem("Send to CyberStrikeAI (stream test)");
sendItem.addActionListener(e -> {
IHttpRequestResponse[] selected = invocation.getSelectedMessages();
if (selected == null || selected.length == 0) {
return;
}
CyberStrikeAIClient.Config cfg = tab.currentConfig();
String token = tab.getToken();
if (token == null || token.trim().isEmpty()) {
JOptionPane.showMessageDialog(tab.getUiComponent(),
"Please click Validate first to obtain a token.",
"CyberStrikeAI", JOptionPane.WARNING_MESSAGE);
return;
}
String prompt = HttpMessageFormatter.toPrompt(helpers, selected[0]);
String title = HttpMessageFormatter.getRequestTitle(helpers, selected[0]);
String agentModeStr = (cfg.agentMode == CyberStrikeAIClient.AgentMode.MULTI) ? "Multi Agent" : "Single Agent";
String runId = tab.startNewRun(title, agentModeStr, selected[0]);
tab.appendProgressToRun(runId, "\n[server] " + cfg.baseUrl + "\n\n");
client.streamTest(cfg, token, prompt, new CyberStrikeAIClient.StreamListener() {
@Override
public void onEvent(String type, String message, String rawJson) {
if (type == null) type = "";
switch (type) {
case "response_delta":
case "eino_agent_reply_stream_delta":
// delta chunk (content only)
tab.appendFinalToRun(runId, message);
break;
case "response":
// final response (full)
tab.appendFinalToRun(runId, "\n\n--- Final Response ---\n");
tab.appendFinalToRun(runId, message);
tab.setFinalResponse(runId, message);
break;
case "progress":
tab.appendProgressToRun(runId, "\n[progress] " + message + "\n");
tab.setRunStatus(runId, "running");
break;
case "cancelled":
tab.appendProgressToRun(runId, "\n[cancelled] " + message + "\n");
tab.setRunStatus(runId, "cancelled");
break;
case "error":
tab.appendProgressToRun(runId, "\n[error] " + message + "\n");
tab.setRunStatus(runId, "error");
break;
case "thinking_stream_start":
if (tab.isShowDebugEvents()) {
tab.resetThinkingStream(runId);
}
break;
case "thinking_stream_delta":
case "tool_call":
case "tool_result":
case "tool_result_delta":
// debug; hide by default
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()) {
if ("thinking_stream_delta".equals(type)) {
tab.appendThinkingDelta(runId, message);
} else {
tab.appendProgressToRun(runId, "\n[" + type + "] " + message + "\n");
}
}
break;
case "conversation":
// Capture conversationId for stop/cancel.
if (rawJson != null) {
String convId = SimpleJson.extractStringField(rawJson, "conversationId");
if (convId != null && !convId.trim().isEmpty()) {
tab.setRunConversationId(runId, convId);
}
}
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()) {
tab.appendProgressToRun(runId, "\n[" + type + "] " + message + "\n");
}
break;
case "done":
// handled in onDone too
break;
default:
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()) {
tab.appendProgressToRun(runId, "\n[" + type + "] " + message + "\n");
}
break;
}
}
@Override
public void onError(String message, Exception e) {
tab.appendProgressToRun(runId, "\n[error] " + message + "\n");
tab.setRunStatus(runId, "error");
callbacks.printError("CyberStrikeAI stream error: " + message);
if (e != null) {
callbacks.printError(e.toString());
}
}
@Override
public void onDone() {
tab.appendProgressToRun(runId, "\n\n[done]\n");
tab.setRunStatus(runId, "done");
}
});
});
items.add(sendItem);
return items;
}
}
@@ -0,0 +1,234 @@
package burp;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
final class CyberStrikeAIClient {
static final class Config {
final String baseUrl; // e.g. http://127.0.0.1:8080
final String password;
final AgentMode agentMode;
Config(String baseUrl, String password, AgentMode agentMode) {
this.baseUrl = baseUrl;
this.password = password;
this.agentMode = agentMode;
}
}
enum AgentMode {
SINGLE,
MULTI
}
interface StreamListener {
void onEvent(String type, String message, String rawJson);
void onError(String message, Exception e);
void onDone();
}
String loginAndValidate(Config cfg) throws IOException {
String token = login(cfg.baseUrl, cfg.password);
validate(cfg.baseUrl, token);
return token;
}
private String login(String baseUrl, String password) throws IOException {
URL url = new URL(baseUrl + "/api/auth/login");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("Accept", "application/json");
String body = "{\"password\":\"" + escapeJson(password) + "\"}";
try (OutputStream os = conn.getOutputStream()) {
os.write(body.getBytes(StandardCharsets.UTF_8));
}
int code = conn.getResponseCode();
String contentType = conn.getHeaderField("Content-Type");
String resp = readAll(code >= 200 && code < 300 ? conn.getInputStream() : conn.getErrorStream());
// Friendly diagnosis: HTML usually means wrong host/port (e.g., hit Burp UI/proxy page).
if (looksLikeHtml(resp) || (contentType != null && contentType.toLowerCase().contains("text/html"))) {
throw new IOException("Login failed: server returned HTML, not API JSON. Check IP/Port and ensure you point to CyberStrikeAI backend.");
}
String serverError = SimpleJson.extractStringField(resp, "error");
if (code < 200 || code >= 300) {
if (!serverError.isEmpty()) {
throw new IOException("Login failed (" + code + "): " + serverError);
}
throw new IOException("Login failed (" + code + ").");
}
if (!serverError.isEmpty()) {
throw new IOException("Login failed: " + serverError);
}
String token = SimpleJson.extractStringField(resp, "token");
if (token.isEmpty()) {
throw new IOException("Login response missing token. Check backend address and credentials.");
}
return token;
}
private void validate(String baseUrl, String token) throws IOException {
URL url = new URL(baseUrl + "/api/auth/validate");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("Authorization", "Bearer " + token);
int code = conn.getResponseCode();
String resp = readAll(code >= 200 && code < 300 ? conn.getInputStream() : conn.getErrorStream());
if (code < 200 || code >= 300) {
throw new IOException("Validate failed (" + code + "): " + resp);
}
}
void streamTest(Config cfg, String token, String message, StreamListener listener) {
String path = (cfg.agentMode == AgentMode.MULTI) ? "/api/multi-agent/stream" : "/api/agent-loop/stream";
String urlStr = cfg.baseUrl + path;
Map<String, Object> payload = new HashMap<>();
payload.put("message", message);
payload.put("conversationId", "");
payload.put("role", "");
new Thread(() -> {
HttpURLConnection conn = null;
try {
URL url = new URL(urlStr);
conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("Accept", "text/event-stream");
conn.setRequestProperty("Authorization", "Bearer " + token);
String body = toJson(payload);
try (OutputStream os = conn.getOutputStream()) {
os.write(body.getBytes(StandardCharsets.UTF_8));
}
int code = conn.getResponseCode();
InputStream is = (code >= 200 && code < 300) ? conn.getInputStream() : conn.getErrorStream();
if (is == null) {
throw new IOException("No response body (HTTP " + code + ")");
}
try (BufferedReader br = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
String line;
while ((line = br.readLine()) != null) {
// SSE format: "data: {json}"
if (line.startsWith("data:")) {
String json = line.substring("data:".length()).trim();
if (!json.isEmpty()) {
String type = SimpleJson.extractStringField(json, "type");
String msg = SimpleJson.extractStringField(json, "message");
listener.onEvent(type, msg, json);
if ("done".equals(type)) {
break;
}
}
}
}
}
listener.onDone();
} catch (Exception e) {
listener.onError(e.getMessage(), e);
} finally {
if (conn != null) {
conn.disconnect();
}
}
}, "CyberStrikeAI-Stream").start();
}
void cancelByConversationId(String baseUrl, String token, String conversationId) throws IOException {
if (conversationId == null || conversationId.trim().isEmpty()) {
throw new IOException("Missing conversationId.");
}
URL url = new URL(baseUrl + "/api/agent-loop/cancel");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("Accept", "application/json");
conn.setRequestProperty("Authorization", "Bearer " + token);
String body = "{\"conversationId\":\"" + escapeJson(conversationId.trim()) + "\"}";
try (OutputStream os = conn.getOutputStream()) {
os.write(body.getBytes(StandardCharsets.UTF_8));
}
int code = conn.getResponseCode();
String resp = readAll(code >= 200 && code < 300 ? conn.getInputStream() : conn.getErrorStream());
if (code < 200 || code >= 300) {
String serverError = SimpleJson.extractStringField(resp, "error");
if (!serverError.isEmpty()) {
throw new IOException("Cancel failed (" + code + "): " + serverError);
}
throw new IOException("Cancel failed (" + code + ").");
}
}
private static String toJson(Map<String, Object> payload) {
String message = payload.get("message") != null ? String.valueOf(payload.get("message")) : "";
String conversationId = payload.get("conversationId") != null ? String.valueOf(payload.get("conversationId")) : "";
String role = payload.get("role") != null ? String.valueOf(payload.get("role")) : "";
return "{"
+ "\"message\":\"" + escapeJson(message) + "\","
+ "\"conversationId\":\"" + escapeJson(conversationId) + "\","
+ "\"role\":\"" + escapeJson(role) + "\""
+ "}";
}
private static String escapeJson(String s) {
if (s == null) return "";
StringBuilder sb = new StringBuilder(s.length() + 16);
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
switch (c) {
case '\\': sb.append("\\\\"); break;
case '"': sb.append("\\\""); break;
case '\n': sb.append("\\n"); break;
case '\r': sb.append("\\r"); break;
case '\t': sb.append("\\t"); break;
default:
if (c < 0x20) {
sb.append(String.format("\\u%04x", (int) c));
} else {
sb.append(c);
}
}
}
return sb.toString();
}
private static String readAll(InputStream is) throws IOException {
if (is == null) return "";
try (BufferedReader br = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = br.readLine()) != null) {
sb.append(line).append('\n');
}
return sb.toString().trim();
}
}
private static boolean looksLikeHtml(String s) {
if (s == null) return false;
String t = s.trim().toLowerCase();
return t.startsWith("<!doctype html") || t.startsWith("<html") || t.contains("<head>") || t.contains("<body");
}
}
@@ -0,0 +1,762 @@
package burp;
import javax.swing.*;
import java.awt.*;
import java.awt.datatransfer.StringSelection;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
final class CyberStrikeAITab implements ITab {
private final JPanel root = new JPanel(new BorderLayout());
private final JTextField hostField = new JTextField("127.0.0.1");
private final JTextField portField = new JTextField("8080");
private final JPasswordField passwordField = new JPasswordField();
private final JComboBox<String> agentModeBox = new JComboBox<>(new String[]{"Single Agent", "Multi Agent"});
private final JButton validateButton = new JButton("Validate");
private final JButton clearButton = new JButton("Clear Output");
private final JButton stopButton = new JButton("Stop");
private final JButton copyButton = new JButton("Copy");
private final JButton clearAllButton = new JButton("Clear All");
private final JLabel statusLabel = new JLabel("Not validated");
private final JCheckBox showDebugEventsBox = new JCheckBox("Show debug events", false);
private final JCheckBox renderMarkdownBox = new JCheckBox("Render Markdown", true);
private final JTextArea progressArea = new JTextArea();
private final JTextArea finalRawArea = new JTextArea(); // raw final stream / final response
private final JEditorPane markdownPane = new JEditorPane("text/html", "");
private final CardLayout outputCardsLayout = new CardLayout();
private final JPanel outputCards = new JPanel(outputCardsLayout);
private final JPanel outputRoot = new JPanel(new BorderLayout());
private final JPanel progressContainer = new JPanel(new CardLayout());
private final JToggleButton progressToggle = new JToggleButton("Progress ▾", true);
private final JTextArea requestArea = new JTextArea();
private final JTextArea responseArea = new JTextArea();
private final JTabbedPane rightTabs = new JTabbedPane();
private final CyberStrikeAIClient client = new CyberStrikeAIClient();
private final AtomicReference<String> tokenRef = new AtomicReference<>("");
private final DefaultListModel<TestRun> testListModel = new DefaultListModel<>();
private final JList<TestRun> testList = new JList<>(testListModel);
private final DefaultListModel<TestRun> filteredListModel = new DefaultListModel<>();
private final JList<TestRun> filteredList = new JList<>(filteredListModel);
private final JTextField searchField = new JTextField();
private final Map<String, TestRun> runs = new HashMap<>();
private final Map<String, Integer> runIdToIndex = new HashMap<>();
private final AtomicInteger runSeq = new AtomicInteger(1);
private String selectedRunId = null;
private static final class TestRun {
final String id;
final String title;
final String agentMode;
final StringBuilder buffer = new StringBuilder();
final StringBuilder progressBuffer = new StringBuilder();
final StringBuilder finalBuffer = new StringBuilder();
final StringBuilder thinkingPending = new StringBuilder();
String status;
String conversationId;
String requestRaw;
String responseRaw;
String finalResponse;
TestRun(String id, String title, String agentMode) {
this.id = id;
this.title = title;
this.agentMode = agentMode;
this.status = "running";
this.conversationId = "";
this.requestRaw = "";
this.responseRaw = "";
this.finalResponse = "";
}
@Override
public String toString() {
return id;
}
}
CyberStrikeAITab() {
root.add(buildConfigPanel(), BorderLayout.NORTH);
root.add(buildMainPane(), BorderLayout.CENTER);
wireActions();
}
private JComponent buildConfigPanel() {
// Best-practice toolbar layout:
// Row 1 = connection settings
// Row 2 = run controls + view options
JPanel rootPanel = new JPanel();
rootPanel.setLayout(new BoxLayout(rootPanel, BoxLayout.Y_AXIS));
rootPanel.setBorder(BorderFactory.createEmptyBorder(4, 6, 4, 6));
hostField.setColumns(14);
portField.setColumns(6);
passwordField.setColumns(12);
agentModeBox.setPreferredSize(new Dimension(160, agentModeBox.getPreferredSize().height));
JPanel row1 = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 2));
row1.add(new JLabel("Host"));
row1.add(hostField);
row1.add(new JLabel("Port"));
row1.add(portField);
row1.add(new JLabel("Password"));
row1.add(passwordField);
row1.add(validateButton);
row1.add(statusLabel);
JPanel row2 = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 2));
row2.add(new JLabel("Agent"));
row2.add(agentModeBox);
row2.add(stopButton);
row2.add(copyButton);
row2.add(clearButton);
row2.add(showDebugEventsBox);
row2.add(renderMarkdownBox);
rootPanel.add(row1);
rootPanel.add(row2);
return rootPanel;
}
private JComponent buildMainPane() {
JPanel sidebarPanel = buildSidebarPanel();
JComponent right = buildRightPanel();
JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, sidebarPanel, right);
split.setResizeWeight(0.25);
split.setBorder(null);
return split;
}
private JPanel buildSidebarPanel() {
JPanel p = new JPanel(new BorderLayout());
filteredList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
filteredList.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 12));
filteredList.setCellRenderer(new TestRunCellRenderer());
filteredList.addListSelectionListener(e -> {
if (!e.getValueIsAdjusting()) {
String id = getSelectedRunIdFromList();
if (id != null) {
setLogAreaToRun(id);
}
}
});
JLabel title = new JLabel("Test History");
title.setBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8));
JPanel top = new JPanel(new BorderLayout(8, 6));
top.setBorder(BorderFactory.createEmptyBorder(0, 8, 0, 8));
top.add(title, BorderLayout.NORTH);
searchField.setToolTipText("Search runs (title)");
top.add(searchField, BorderLayout.SOUTH);
JScrollPane sp = new JScrollPane(filteredList);
sp.setBorder(BorderFactory.createTitledBorder("Runs"));
clearAllButton.addActionListener(e -> clearAllRuns());
JPanel bottom = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 6));
bottom.add(clearAllButton);
p.add(top, BorderLayout.NORTH);
p.add(sp, BorderLayout.CENTER);
p.add(bottom, BorderLayout.SOUTH);
p.setPreferredSize(new Dimension(320, 200));
return p;
}
private JComponent buildRightPanel() {
configureTextArea(progressArea, true);
configureTextArea(finalRawArea, true);
markdownPane.setEditable(false);
markdownPane.putClientProperty(JEditorPane.HONOR_DISPLAY_PROPERTIES, Boolean.TRUE);
markdownPane.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 12));
markdownPane.setOpaque(true);
markdownPane.setBackground(Color.WHITE);
configureTextArea(requestArea, false);
configureTextArea(responseArea, false);
outputCards.add(new JScrollPane(finalRawArea), "raw");
outputCards.add(new JScrollPane(markdownPane), "md");
outputRoot.add(buildOutputHeader(), BorderLayout.NORTH);
outputRoot.add(buildOutputBody(), BorderLayout.CENTER);
rightTabs.addTab("Output", outputRoot);
rightTabs.addTab("Request", new JScrollPane(requestArea));
rightTabs.addTab("Response", new JScrollPane(responseArea));
return rightTabs;
}
private JComponent buildOutputHeader() {
JPanel header = new JPanel(new BorderLayout(8, 0));
header.setBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8));
JPanel left = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 0));
left.add(progressToggle);
header.add(left, BorderLayout.WEST);
return header;
}
private JComponent buildOutputBody() {
JScrollPane progressScroll = new JScrollPane(progressArea);
progressScroll.setBorder(BorderFactory.createTitledBorder("Progress"));
progressScroll.getVerticalScrollBar().setUnitIncrement(16);
JPanel empty = new JPanel();
progressContainer.add(progressScroll, "show");
progressContainer.add(empty, "hide");
((CardLayout) progressContainer.getLayout()).show(progressContainer, "show");
JPanel finalPanel = new JPanel(new BorderLayout());
finalPanel.add(outputCards, BorderLayout.CENTER);
finalPanel.setBorder(BorderFactory.createTitledBorder("Final Response"));
JSplitPane split = new JSplitPane(JSplitPane.VERTICAL_SPLIT, progressContainer, finalPanel);
split.setResizeWeight(0.15);
split.setBorder(null);
split.setDividerSize(6);
final int[] lastDividerLocation = new int[]{140}; // sensible default
progressToggle.addActionListener(e -> {
boolean show = progressToggle.isSelected();
progressToggle.setText(show ? "Progress ▾" : "Progress ▸");
CardLayout cl = (CardLayout) progressContainer.getLayout();
cl.show(progressContainer, show ? "show" : "hide");
if (!show) {
int current = split.getDividerLocation();
if (current > 0) {
lastDividerLocation[0] = current;
}
split.setDividerLocation(0);
split.setDividerSize(0);
} else {
split.setDividerSize(6);
// Restore previous divider location (or fallback to 20% of height)
int restore = lastDividerLocation[0];
if (restore <= 0) {
int h = split.getHeight();
restore = (h > 0) ? Math.max(80, (int) (h * 0.2)) : 140;
}
split.setDividerLocation(restore);
}
split.revalidate();
split.repaint();
});
return split;
}
private static void configureTextArea(JTextArea area, boolean monospaced) {
area.setEditable(false);
area.setLineWrap(false);
area.setWrapStyleWord(false);
if (monospaced) {
area.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12));
} else {
area.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12));
}
}
private static Color colorForStatus(String status) {
if (status == null) return new Color(120, 120, 120);
switch (status) {
case "running":
return new Color(33, 150, 243);
case "done":
return new Color(76, 175, 80);
case "error":
return new Color(244, 67, 54);
case "cancelled":
case "cancelling":
return new Color(255, 152, 0);
default:
return new Color(120, 120, 120);
}
}
private static final class DotIcon implements Icon {
private final int size;
private Color color;
DotIcon(int size, Color color) {
this.size = size;
this.color = color;
}
void setColor(Color color) {
this.color = color;
}
@Override
public int getIconWidth() {
return size;
}
@Override
public int getIconHeight() {
return size;
}
@Override
public void paintIcon(Component c, Graphics g, int x, int y) {
Graphics2D g2 = (Graphics2D) g.create();
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2.setColor(color != null ? color : Color.GRAY);
g2.fillOval(x, y, size, size);
} finally {
g2.dispose();
}
}
}
private static final class TestRunCellRenderer implements ListCellRenderer<TestRun> {
private final JPanel panel = new JPanel(new BorderLayout(8, 0));
private final JLabel dotLabel = new JLabel();
private final JLabel titleLabel = new JLabel();
private final JLabel metaLabel = new JLabel();
private final JPanel textPanel = new JPanel();
private final DotIcon dotIcon = new DotIcon(10, new Color(120, 120, 120));
TestRunCellRenderer() {
panel.setBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8));
dotLabel.setIcon(dotIcon);
textPanel.setLayout(new BoxLayout(textPanel, BoxLayout.Y_AXIS));
titleLabel.setFont(titleLabel.getFont().deriveFont(Font.BOLD));
metaLabel.setFont(metaLabel.getFont().deriveFont(Font.PLAIN, 11f));
metaLabel.setForeground(new Color(102, 102, 102));
textPanel.add(titleLabel);
textPanel.add(metaLabel);
panel.add(dotLabel, BorderLayout.WEST);
panel.add(textPanel, BorderLayout.CENTER);
panel.setOpaque(true);
textPanel.setOpaque(false);
}
@Override
public Component getListCellRendererComponent(JList<? extends TestRun> list, TestRun value, int index, boolean isSelected, boolean cellHasFocus) {
String titleText = value != null ? value.title : "";
String modeText = value != null ? value.agentMode : "";
String statusText = value != null ? value.status : "";
String shownTitle = titleText;
if (shownTitle.length() > 80) {
shownTitle = shownTitle.substring(0, 77) + "...";
}
titleLabel.setText(shownTitle);
metaLabel.setText(modeText + " · " + statusText);
dotIcon.setColor(colorForStatus(statusText));
if (isSelected) {
panel.setBackground(list.getSelectionBackground());
titleLabel.setForeground(list.getSelectionForeground());
metaLabel.setForeground(list.getSelectionForeground());
} else {
panel.setBackground(list.getBackground());
titleLabel.setForeground(list.getForeground());
metaLabel.setForeground(new Color(102, 102, 102));
}
return panel;
}
}
// right panel builds scroll panes for each tab
private void wireActions() {
validateButton.addActionListener(e -> {
validateButton.setEnabled(false);
statusLabel.setText("Validating...");
log("Validating connection...");
new Thread(() -> {
try {
CyberStrikeAIClient.Config cfg = currentConfig();
String token = client.loginAndValidate(cfg);
tokenRef.set(token);
SwingUtilities.invokeLater(() -> statusLabel.setText("OK (token saved)"));
log("Validation OK.");
} catch (Exception ex) {
tokenRef.set("");
SwingUtilities.invokeLater(() -> statusLabel.setText("Failed: " + ex.getMessage()));
log("Validation failed: " + ex.getMessage());
} finally {
SwingUtilities.invokeLater(() -> validateButton.setEnabled(true));
}
}, "CyberStrikeAI-Validate").start();
});
clearButton.addActionListener(e -> {
if (selectedRunId == null) {
progressArea.setText("");
finalRawArea.setText("");
markdownPane.setText("");
return;
}
TestRun run = runs.get(selectedRunId);
if (run == null) return;
synchronized (run) {
run.buffer.setLength(0);
run.progressBuffer.setLength(0);
run.finalBuffer.setLength(0);
}
progressArea.setText("");
finalRawArea.setText("");
markdownPane.setText("");
});
copyButton.addActionListener(e -> {
String text;
int idx = rightTabs.getSelectedIndex();
String tabName = idx >= 0 ? rightTabs.getTitleAt(idx) : "";
if ("Request".equals(tabName)) {
text = requestArea.getText();
} else if ("Response".equals(tabName)) {
text = responseArea.getText();
} else {
text = finalRawArea.getText();
}
Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new StringSelection(text == null ? "" : text), null);
});
stopButton.addActionListener(e -> {
String runId = selectedRunId;
if (runId == null) return;
TestRun run = runs.get(runId);
if (run == null) return;
String token = getToken();
if (token == null || token.trim().isEmpty()) {
appendProgressToRun(runId, "\n[error] Not validated.\n");
return;
}
String convId;
synchronized (run) {
convId = run.conversationId;
}
if (convId == null || convId.trim().isEmpty()) {
appendProgressToRun(runId, "\n[info] conversationId not available yet (wait for server to create session).\n");
return;
}
stopButton.setEnabled(false);
new Thread(() -> {
try {
CyberStrikeAIClient.Config cfg = currentConfig();
client.cancelByConversationId(cfg.baseUrl, token, convId);
appendProgressToRun(runId, "\n[info] Cancel requested.\n");
setRunStatus(runId, "cancelling");
} catch (Exception ex) {
appendProgressToRun(runId, "\n[error] Cancel failed: " + ex.getMessage() + "\n");
} finally {
SwingUtilities.invokeLater(() -> stopButton.setEnabled(true));
}
}, "CyberStrikeAI-Cancel").start();
});
searchField.getDocument().addDocumentListener(new javax.swing.event.DocumentListener() {
@Override public void insertUpdate(javax.swing.event.DocumentEvent e) { applyFilter(); }
@Override public void removeUpdate(javax.swing.event.DocumentEvent e) { applyFilter(); }
@Override public void changedUpdate(javax.swing.event.DocumentEvent e) { applyFilter(); }
});
renderMarkdownBox.addActionListener(e -> refreshOutputView());
}
CyberStrikeAIClient.Config currentConfig() {
String host = hostField.getText().trim();
String port = portField.getText().trim();
String password = new String(passwordField.getPassword());
String baseUrl = "http://" + host + ":" + port;
CyberStrikeAIClient.AgentMode mode = agentModeBox.getSelectedIndex() == 1
? CyberStrikeAIClient.AgentMode.MULTI
: CyberStrikeAIClient.AgentMode.SINGLE;
return new CyberStrikeAIClient.Config(baseUrl, password, mode);
}
String getToken() {
return tokenRef.get();
}
boolean isShowDebugEvents() {
return showDebugEventsBox.isSelected();
}
private String nextRunId() {
return "run_" + runSeq.getAndIncrement();
}
private String formatRunDisplay(String title, String agentMode, String status) {
return title + " [" + agentMode + "] - " + status;
}
String startNewRun(String title, String agentMode, IHttpRequestResponse msg) {
String id = nextRunId();
TestRun run = new TestRun(id, title, agentMode);
if (msg != null) {
run.requestRaw = bytesToString(msg.getRequest());
run.responseRaw = bytesToString(msg.getResponse());
}
runs.put(id, run);
int index = testListModel.getSize();
runIdToIndex.put(id, index);
testListModel.addElement(run);
filteredListModel.addElement(run);
selectedRunId = id;
filteredList.setSelectedIndex(filteredListModel.getSize() - 1);
progressArea.setText("");
finalRawArea.setText("");
markdownPane.setText("");
requestArea.setText(run.requestRaw);
responseArea.setText(run.responseRaw);
refreshOutputView();
return id;
}
void setRunStatus(String runId, String status) {
TestRun run = runs.get(runId);
if (run == null) return;
synchronized (run) {
run.status = status;
}
Integer index = runIdToIndex.get(runId);
if (index != null) {
SwingUtilities.invokeLater(() -> filteredList.repaint());
}
}
void setRunConversationId(String runId, String conversationId) {
if (runId == null) return;
TestRun run = runs.get(runId);
if (run == null) return;
synchronized (run) {
run.conversationId = conversationId == null ? "" : conversationId;
}
}
void appendToRun(String runId, String s) {
// Backward compatibility: default to progress bucket
appendProgressToRun(runId, s);
}
void appendProgressToRun(String runId, String s) {
if (runId == null || s == null) return;
TestRun run = runs.get(runId);
if (run == null) return;
synchronized (run) {
run.buffer.append(s);
run.progressBuffer.append(s);
}
if (runId.equals(selectedRunId)) {
SwingUtilities.invokeLater(() -> {
progressArea.append(s);
progressArea.setCaretPosition(progressArea.getDocument().getLength());
});
}
}
void resetThinkingStream(String runId) {
if (runId == null) return;
TestRun run = runs.get(runId);
if (run == null) return;
synchronized (run) {
run.thinkingPending.setLength(0);
}
appendProgressToRun(runId, "\n[thinking]\n");
}
void appendThinkingDelta(String runId, String delta) {
if (runId == null || delta == null) return;
TestRun run = runs.get(runId);
if (run == null) return;
StringBuilder toAppend = new StringBuilder();
synchronized (run) {
for (int i = 0; i < delta.length(); i++) {
char c = delta.charAt(i);
if (c == '\n') {
if (run.thinkingPending.length() > 0) {
toAppend.append(" ").append(run.thinkingPending).append("\n");
run.thinkingPending.setLength(0);
} else {
toAppend.append("\n");
}
} else if (c != '\r') {
run.thinkingPending.append(c);
}
}
}
if (toAppend.length() > 0) {
appendProgressToRun(runId, toAppend.toString());
}
}
void appendFinalToRun(String runId, String s) {
if (runId == null || s == null) return;
TestRun run = runs.get(runId);
if (run == null) return;
synchronized (run) {
run.buffer.append(s);
run.finalBuffer.append(s);
}
if (runId.equals(selectedRunId)) {
SwingUtilities.invokeLater(() -> {
finalRawArea.append(s);
finalRawArea.setCaretPosition(finalRawArea.getDocument().getLength());
});
}
}
void setFinalResponse(String runId, String finalResponse) {
if (runId == null) return;
TestRun run = runs.get(runId);
if (run == null) return;
synchronized (run) {
run.finalResponse = finalResponse == null ? "" : finalResponse;
}
if (runId.equals(selectedRunId)) {
SwingUtilities.invokeLater(this::refreshOutputView);
}
}
private String getSelectedRunIdFromList() {
TestRun run = filteredList.getSelectedValue();
return run == null ? null : run.id;
}
private void setLogAreaToRun(String runId) {
TestRun run = runs.get(runId);
if (run == null) return;
selectedRunId = runId;
String progress;
String fin;
synchronized (run) {
progress = run.progressBuffer.toString();
fin = run.finalBuffer.toString();
}
SwingUtilities.invokeLater(() -> {
progressArea.setText(progress);
progressArea.setCaretPosition(progressArea.getDocument().getLength());
finalRawArea.setText(fin);
finalRawArea.setCaretPosition(finalRawArea.getDocument().getLength());
requestArea.setText(run.requestRaw == null ? "" : run.requestRaw);
responseArea.setText(run.responseRaw == null ? "" : run.responseRaw);
refreshOutputView();
});
}
private void clearAllRuns() {
runs.clear();
runIdToIndex.clear();
testListModel.clear();
filteredListModel.clear();
selectedRunId = null;
SwingUtilities.invokeLater(() -> {
progressArea.setText("");
finalRawArea.setText("");
markdownPane.setText("");
requestArea.setText("");
responseArea.setText("");
});
}
void clearAndShowStreamHeader(String title) {
SwingUtilities.invokeLater(() -> {
progressArea.setText("");
finalRawArea.setText(title + "\n\n");
});
}
// Legacy helpers kept for Validate logging
void appendStreamLine(String s) {
if (s == null) return;
SwingUtilities.invokeLater(() -> {
progressArea.append(s);
progressArea.append("\n");
progressArea.setCaretPosition(progressArea.getDocument().getLength());
});
}
private void log(String s) {
appendStreamLine("[*] " + s);
}
private void applyFilter() {
String q = searchField.getText();
if (q == null) q = "";
String query = q.trim().toLowerCase();
filteredListModel.clear();
for (int i = 0; i < testListModel.size(); i++) {
TestRun r = testListModel.getElementAt(i);
if (query.isEmpty() || (r.title != null && r.title.toLowerCase().contains(query))) {
filteredListModel.addElement(r);
}
}
if (filteredListModel.size() > 0 && filteredList.getSelectedIndex() < 0) {
filteredList.setSelectedIndex(0);
}
}
private void refreshOutputView() {
if (!renderMarkdownBox.isSelected()) {
outputCardsLayout.show(outputCards, "raw");
return;
}
if (selectedRunId == null) {
outputCardsLayout.show(outputCards, "raw");
return;
}
TestRun run = runs.get(selectedRunId);
if (run == null) {
outputCardsLayout.show(outputCards, "raw");
return;
}
String finalResp;
synchronized (run) {
finalResp = run.finalResponse;
}
if (finalResp == null || finalResp.trim().isEmpty()) {
// while streaming, stick to raw for performance
outputCardsLayout.show(outputCards, "raw");
return;
}
String html = MarkdownRenderer.toHtml(finalResp);
markdownPane.setText(html);
markdownPane.setCaretPosition(0);
outputCardsLayout.show(outputCards, "md");
}
private static String bytesToString(byte[] bytes) {
if (bytes == null || bytes.length == 0) return "";
return new String(bytes, StandardCharsets.ISO_8859_1);
}
@Override
public String getTabCaption() {
return "CyberStrikeAI";
}
@Override
public Component getUiComponent() {
return root;
}
}
@@ -0,0 +1,66 @@
package burp;
import java.nio.charset.StandardCharsets;
import java.util.List;
final class HttpMessageFormatter {
private HttpMessageFormatter() {}
static String getRequestTitle(IExtensionHelpers helpers, IHttpRequestResponse msg) {
IRequestInfo reqInfo = helpers.analyzeRequest(msg);
String method = reqInfo.getMethod();
if (reqInfo.getUrl() == null) {
return method + " (unknown)";
}
String host = reqInfo.getUrl().getHost();
String path = reqInfo.getUrl().getPath();
if (path == null || path.isEmpty()) path = "/";
String query = reqInfo.getUrl().getQuery();
String shortPath = path;
if (shortPath.length() > 80) shortPath = shortPath.substring(0, 77) + "...";
String q = (query != null && !query.isEmpty()) ? "?" : "";
return method + " " + host + shortPath + q;
}
static String toPrompt(IExtensionHelpers helpers, IHttpRequestResponse msg) {
IRequestInfo reqInfo = helpers.analyzeRequest(msg);
String method = reqInfo.getMethod();
String url = reqInfo.getUrl() != null ? reqInfo.getUrl().toString() : "(unknown)";
byte[] reqBytes = msg.getRequest();
int bodyOffset = reqInfo.getBodyOffset();
String headers = String.join("\n", reqInfo.getHeaders());
String body = "";
if (reqBytes != null && reqBytes.length > bodyOffset) {
body = new String(reqBytes, bodyOffset, reqBytes.length - bodyOffset, StandardCharsets.ISO_8859_1);
}
// Include response summary if available
String respSnippet = "";
byte[] respBytes = msg.getResponse();
if (respBytes != null && respBytes.length > 0) {
IResponseInfo respInfo = helpers.analyzeResponse(respBytes);
List<String> respHeaders = respInfo.getHeaders();
int respBodyOffset = respInfo.getBodyOffset();
String respBody = "";
if (respBytes.length > respBodyOffset) {
int max = Math.min(respBytes.length - respBodyOffset, 4096);
respBody = new String(respBytes, respBodyOffset, max, StandardCharsets.ISO_8859_1);
}
respSnippet = "\n\n[Optional: Response (truncated)]\n"
+ String.join("\n", respHeaders)
+ "\n\n"
+ respBody;
}
return ""
+ "针对该流量做web渗透测试,并输出测试结果,要求:只针对该接口流量做测试,切勿拓展其他接口\n\n"
+ "[Target]\n"
+ method + " " + url + "\n\n"
+ "[Request]\n"
+ headers + "\n\n"
+ body
+ respSnippet;
}
}
@@ -0,0 +1,206 @@
package burp;
import java.util.ArrayList;
import java.util.List;
/**
* Minimal Markdown -> HTML renderer for Burp UI.
* Supports: headings (#..######), fenced code blocks (```), inline code (`),
* bold (**), lists (-/*), paragraphs, and basic escaping.
*
* Not a full CommonMark implementation; kept dependency-free on purpose.
*/
final class MarkdownRenderer {
private MarkdownRenderer() {}
static String toHtml(String markdown) {
if (markdown == null) markdown = "";
List<String> lines = splitLines(markdown);
StringBuilder out = new StringBuilder(4096);
out.append("<html><head><meta charset='utf-8'>")
.append("<style>")
// Swing's HTML renderer does not reliably apply default heading sizes,
// so we explicitly define font sizes to keep a clear hierarchy.
.append("body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Arial,sans-serif;font-size:13px;line-height:1.45;margin:10px;color:#111;}")
.append("code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;}")
// Keep inline code readable (Swing may render it too small otherwise).
.append("code{font-size:0.95em;background:#f6f8fa;border:1px solid #e5e7eb;border-radius:4px;padding:0 4px;}")
.append("pre{font-size:0.95em;background:#f6f8fa;border:1px solid #e5e7eb;border-radius:6px;padding:10px;overflow:auto;}")
.append("pre code{font-size:1em;background:transparent;border:none;padding:0;}")
.append("p{margin:0.55em 0;}")
.append("h1{font-size:20px;margin:0.85em 0 0.45em 0;}")
.append("h2{font-size:18px;margin:0.85em 0 0.45em 0;}")
.append("h3{font-size:16px;margin:0.8em 0 0.4em 0;}")
.append("h4{font-size:14px;margin:0.8em 0 0.4em 0;}")
.append("h5{font-size:13px;margin:0.75em 0 0.35em 0;}")
.append("h6{font-size:13px;margin:0.75em 0 0.35em 0;}")
.append("ul{margin:0.4em 0 0.6em 1.2em;padding:0;}")
.append("</style></head><body>");
boolean inCode = false;
boolean inList = false;
StringBuilder codeBuf = new StringBuilder();
for (String raw : lines) {
String line = raw == null ? "" : raw;
if (line.trim().startsWith("```")) {
if (!inCode) {
inCode = true;
codeBuf.setLength(0);
} else {
// close code
out.append("<pre><code>")
.append(escapeHtml(codeBuf.toString()))
.append("</code></pre>");
inCode = false;
}
continue;
}
if (inCode) {
codeBuf.append(line).append("\n");
continue;
}
String trimmed = line.trim();
if (trimmed.isEmpty()) {
if (inList) {
out.append("</ul>");
inList = false;
}
out.append("<div style='height:6px'></div>");
continue;
}
// headings
int h = headingLevel(trimmed);
if (h > 0) {
if (inList) {
out.append("</ul>");
inList = false;
}
String text = trimmed.substring(h).trim();
out.append("<h").append(h).append(">")
.append(inlineFormat(text))
.append("</h").append(h).append(">");
continue;
}
// list items
if (trimmed.startsWith("- ") || trimmed.startsWith("* ")) {
if (!inList) {
out.append("<ul>");
inList = true;
}
String item = trimmed.substring(2).trim();
out.append("<li>").append(inlineFormat(item)).append("</li>");
continue;
}
// normal paragraph
if (inList) {
out.append("</ul>");
inList = false;
}
out.append("<p>").append(inlineFormat(trimmed)).append("</p>");
}
if (inCode) {
out.append("<pre><code>")
.append(escapeHtml(codeBuf.toString()))
.append("</code></pre>");
}
if (inList) {
out.append("</ul>");
}
out.append("</body></html>");
return out.toString();
}
private static int headingLevel(String s) {
int i = 0;
while (i < s.length() && s.charAt(i) == '#') i++;
if (i >= 1 && i <= 6 && i < s.length() && Character.isWhitespace(s.charAt(i))) return i;
return 0;
}
private static String inlineFormat(String text) {
// escape first, then apply simple replacements using placeholders
String escaped = escapeHtml(text);
// inline code: `code`
escaped = replaceInlineCode(escaped);
// bold: **text**
escaped = replaceBold(escaped);
return escaped;
}
private static String replaceInlineCode(String s) {
StringBuilder out = new StringBuilder(s.length() + 16);
boolean in = false;
StringBuilder buf = new StringBuilder();
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '`') {
if (!in) {
in = true;
buf.setLength(0);
} else {
out.append("<code>").append(buf).append("</code>");
in = false;
}
continue;
}
if (in) buf.append(c);
else out.append(c);
}
if (in) {
// unmatched backtick: keep as literal
out.append("`").append(buf);
}
return out.toString();
}
private static String replaceBold(String s) {
// simple non-nested **...**
StringBuilder out = new StringBuilder(s.length() + 16);
int i = 0;
while (i < s.length()) {
int start = s.indexOf("**", i);
if (start < 0) {
out.append(s.substring(i));
break;
}
int end = s.indexOf("**", start + 2);
if (end < 0) {
out.append(s.substring(i));
break;
}
out.append(s.substring(i, start));
out.append("<b>").append(s, start + 2, end).append("</b>");
i = end + 2;
}
return out.toString();
}
private static String escapeHtml(String s) {
if (s == null) return "";
return s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;");
}
private static List<String> splitLines(String s) {
String[] parts = s.split("\\r?\\n", -1);
List<String> lines = new ArrayList<>(parts.length);
for (String p : parts) lines.add(p);
return lines;
}
}
@@ -0,0 +1,80 @@
package burp;
import java.util.HashMap;
import java.util.Map;
/**
* Minimal JSON extractor for the SSE payloads we emit:
* {"type":"...","message":"...","data":...}
*
* This is NOT a general-purpose JSON parser; it's intentionally small to avoid external deps.
*/
final class SimpleJson {
private SimpleJson() {}
static Map<String, String> extractTopLevelStringFields(String json, String... keys) {
Map<String, String> out = new HashMap<>();
if (json == null) return out;
for (String key : keys) {
out.put(key, extractStringField(json, key));
}
return out;
}
static String extractStringField(String json, String key) {
if (json == null || key == null) return "";
String needle = "\"" + key + "\"";
int k = json.indexOf(needle);
if (k < 0) return "";
int colon = json.indexOf(':', k + needle.length());
if (colon < 0) return "";
int i = colon + 1;
while (i < json.length() && Character.isWhitespace(json.charAt(i))) i++;
if (i >= json.length() || json.charAt(i) != '"') return "";
i++; // after opening quote
StringBuilder sb = new StringBuilder();
boolean esc = false;
while (i < json.length()) {
char c = json.charAt(i++);
if (esc) {
switch (c) {
case '"': sb.append('"'); break;
case '\\': sb.append('\\'); break;
case '/': sb.append('/'); break;
case 'b': sb.append('\b'); break;
case 'f': sb.append('\f'); break;
case 'n': sb.append('\n'); break;
case 'r': sb.append('\r'); break;
case 't': sb.append('\t'); break;
case 'u':
if (i + 3 < json.length()) {
String hex = json.substring(i, i + 4);
try {
sb.append((char) Integer.parseInt(hex, 16));
i += 4;
} catch (NumberFormatException ignored) {
// best-effort: keep raw
sb.append("\\u").append(hex);
i += 4;
}
}
break;
default:
sb.append(c);
}
esc = false;
continue;
}
if (c == '\\') {
esc = true;
continue;
}
if (c == '"') {
break;
}
sb.append(c);
}
return sb.toString();
}
}
@@ -0,0 +1,3 @@
artifactId=cyberstrikeai-burp-extension
groupId=ai.cyberstrike
version=1.0.0
@@ -0,0 +1,14 @@
burp/CyberStrikeAIClient$StreamListener.class
burp/CyberStrikeAIClient$Config.class
burp/CyberStrikeAIClient$AgentMode.class
burp/MarkdownRenderer.class
burp/SimpleJson.class
burp/CyberStrikeAIClient.class
burp/CyberStrikeAITab$DotIcon.class
burp/CyberStrikeAITab.class
burp/CyberStrikeAITab$1.class
burp/BurpExtender$1.class
burp/BurpExtender.class
burp/CyberStrikeAITab$TestRun.class
burp/CyberStrikeAITab$TestRunCellRenderer.class
burp/HttpMessageFormatter.class
@@ -0,0 +1,6 @@
/Users/temp/Downloads/CyberStrikeAI-main/plugins/burp-suite/cyberstrikeai-burp-extension/src/main/java/burp/BurpExtender.java
/Users/temp/Downloads/CyberStrikeAI-main/plugins/burp-suite/cyberstrikeai-burp-extension/src/main/java/burp/CyberStrikeAIClient.java
/Users/temp/Downloads/CyberStrikeAI-main/plugins/burp-suite/cyberstrikeai-burp-extension/src/main/java/burp/CyberStrikeAITab.java
/Users/temp/Downloads/CyberStrikeAI-main/plugins/burp-suite/cyberstrikeai-burp-extension/src/main/java/burp/HttpMessageFormatter.java
/Users/temp/Downloads/CyberStrikeAI-main/plugins/burp-suite/cyberstrikeai-burp-extension/src/main/java/burp/MarkdownRenderer.java
/Users/temp/Downloads/CyberStrikeAI-main/plugins/burp-suite/cyberstrikeai-burp-extension/src/main/java/burp/SimpleJson.java
+1 -1
View File
@@ -2,7 +2,7 @@
requests>=2.32.3
httpx>=0.27.0
charset-normalizer>=3.3.2
chardet>=5.2.0
chardet>=5.2.0,<6
# Python exploitation / analysis frameworks referenced by tool recipes
# angr>=9.2.96
+76 -28
View File
@@ -9264,9 +9264,8 @@ header {
white-space: nowrap;
}
.webshell-col-owner,
.webshell-col-group {
width: 110px;
.webshell-col-owner {
width: 150px;
color: var(--text-secondary);
font-size: 0.82rem;
white-space: nowrap;
@@ -10359,54 +10358,93 @@ header {
flex-direction: column;
gap: 10px;
}
.webshell-db-profile-modal-content {
max-width: 840px;
border-radius: 12px;
border: 1px solid rgba(15, 23, 42, 0.1);
box-shadow: 0 10px 30px rgba(2, 6, 23, 0.18);
}
#webshell-db-profile-modal .modal-header {
padding: 14px 18px;
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
background: #fff;
box-shadow: none;
}
#webshell-db-profile-modal .modal-header h2 {
font-size: 1.08rem;
font-weight: 600;
background: none;
-webkit-text-fill-color: currentColor;
color: #0f172a;
}
#webshell-db-profile-modal .modal-body {
padding: 14px 18px 10px;
}
#webshell-db-profile-modal .modal-footer {
padding: 10px 18px 14px;
border-top: 1px solid rgba(15, 23, 42, 0.08);
background: #fff;
}
#webshell-db-profile-modal .modal-footer .btn-secondary,
#webshell-db-profile-modal .modal-footer .btn-primary {
min-width: 78px;
height: 36px;
border-radius: 8px;
}
.webshell-db-toolbar {
display: grid;
grid-template-columns: repeat(4, minmax(160px, 1fr));
gap: 12px;
padding: 14px;
grid-template-columns: repeat(4, minmax(130px, 1fr));
gap: 10px;
padding: 12px;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 12px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(248, 250, 252, 0.96) 100%);
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.06), inset 0 1px 0 rgba(255, 255, 255, 0.9);
border-radius: 10px;
background: #f8fafc;
box-shadow: none;
}
.webshell-db-toolbar label {
display: flex;
flex-direction: column;
gap: 6px;
gap: 5px;
min-width: 0;
padding: 8px 10px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.82);
padding: 7px 9px;
border-radius: 8px;
background: #fff;
border: 1px solid rgba(15, 23, 42, 0.08);
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.webshell-db-toolbar label:focus-within {
border-color: rgba(0, 102, 255, 0.38);
box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.12);
transform: translateY(-1px);
border-color: rgba(0, 102, 255, 0.32);
box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.1);
}
.webshell-db-toolbar label span {
font-size: 0.75rem;
color: var(--text-secondary);
font-size: 0.72rem;
color: #64748b;
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
letter-spacing: 0.01em;
text-transform: none;
}
.webshell-db-toolbar .form-control {
height: 36px;
border-radius: 8px;
border: 1px solid rgba(15, 23, 42, 0.16);
height: 34px;
border-radius: 7px;
border: 1px solid rgba(15, 23, 42, 0.14);
background: #fff;
font-size: 0.9rem;
font-size: 0.88rem;
padding-left: 10px;
padding-right: 10px;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.webshell-db-toolbar .form-control:focus {
border-color: var(--accent-color);
box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.1);
background: #fff;
}
.webshell-db-toolbar select.form-control {
appearance: auto;
-webkit-appearance: menulist;
-moz-appearance: menulist;
padding-right: 8px;
background-image: none;
}
#webshell-db-sqlite-row {
grid-column: 1 / -1;
}
@@ -10522,7 +10560,7 @@ header {
grid-template-columns: 240px minmax(0, 1fr);
}
.webshell-db-toolbar {
grid-template-columns: repeat(3, minmax(140px, 1fr));
grid-template-columns: repeat(3, minmax(120px, 1fr));
}
}
@media (max-width: 980px) {
@@ -10533,13 +10571,23 @@ header {
min-height: 200px;
}
.webshell-db-toolbar {
grid-template-columns: repeat(2, minmax(140px, 1fr));
grid-template-columns: repeat(2, minmax(120px, 1fr));
}
}
@media (max-width: 700px) {
.webshell-db-toolbar {
grid-template-columns: 1fr;
}
#webshell-db-profile-modal .modal-content {
width: calc(100% - 24px);
margin: 32px auto;
}
#webshell-db-profile-modal .modal-header,
#webshell-db-profile-modal .modal-body,
#webshell-db-profile-modal .modal-footer {
padding-left: 14px;
padding-right: 14px;
}
}
/* 仪表盘页面样式(最佳实践布局 + 视觉增强) */
+17
View File
@@ -138,6 +138,7 @@
"deleteGroupConfirm": "Are you sure you want to delete this group? Conversations in the group will not be deleted, but will be removed from the group.",
"deleteConversationConfirm": "Are you sure you want to delete this conversation?",
"renameFailed": "Rename failed",
"downloadConversationFailed": "Failed to download conversation",
"viewAttackChainSelectConv": "Please select a conversation to view attack chain",
"viewAttackChainCurrentConv": "View attack chain of current conversation",
"executeFailed": "Execution failed",
@@ -402,7 +403,10 @@
"dbRows": "rows",
"dbColumns": "columns",
"dbSchemaFailed": "Failed to load schema",
"dbSchemaLoaded": "Schema loaded successfully",
"dbAddProfile": "Add connection",
"dbExecSuccess": "SQL executed successfully",
"dbNoOutput": "Execution completed (no output)",
"dbRenameProfile": "Rename",
"dbDeleteProfile": "Delete connection",
"dbDeleteProfileConfirm": "Delete this database connection profile?",
@@ -458,6 +462,7 @@
"searchPlaceholder": "Search connections...",
"noMatchConnections": "No matching connections",
"breadcrumbHome": "Root",
"dirTree": "Directory tree",
"back": "Back",
"moreActions": "More actions",
"batchProbe": "Batch probe",
@@ -1231,6 +1236,15 @@
"fofaApiKeyHint": "Stored in server config (config.yaml) only.",
"maxIterations": "Max iterations",
"iterationsPlaceholder": "30",
"enableMultiAgent": "Enable Eino multi-agent (DeepAgent)",
"enableMultiAgentHint": "After enabling, the chat page can use multi-agent mode; sub-agents are configured in config.yaml under multi_agent.sub_agents.",
"multiAgentDefaultMode": "Default mode on chat page",
"multiAgentModeSingle": "Single-agent (ReAct)",
"multiAgentModeMulti": "Multi-agent (Eino)",
"multiAgentRobotUse": "Use multi-agent for WeCom / DingTalk / Lark bots",
"multiAgentRobotUseHint": "Requires 'Enable multi-agent' to be checked; usage and cost will be higher.",
"multiAgentBatchUse": "Use multi-agent for batch task queues",
"multiAgentBatchUseHint": "When enabled, each sub-task executed by queue in Task Management will run through Eino DeepAgent (requires multi-agent).",
"enableKnowledge": "Enable knowledge retrieval",
"knowledgeBasePath": "Knowledge base path",
"knowledgeBasePathPlaceholder": "knowledge_base",
@@ -1437,6 +1451,9 @@
},
"contextMenu": {
"viewAttackChain": "View attack chain",
"downloadMarkdown": "Download Markdown",
"downloadMarkdownSummary": "Summary",
"downloadMarkdownFull": "Full",
"rename": "Rename",
"pinConversation": "Pin conversation",
"unpinConversation": "Unpin",
+17
View File
@@ -138,6 +138,7 @@
"deleteGroupConfirm": "确定要删除此分组吗?分组中的对话不会被删除,但会从分组中移除。",
"deleteConversationConfirm": "确定要删除此对话吗?",
"renameFailed": "重命名失败",
"downloadConversationFailed": "下载对话失败",
"viewAttackChainSelectConv": "请选择一个对话以查看攻击链",
"viewAttackChainCurrentConv": "查看当前对话的攻击链",
"executeFailed": "执行失败",
@@ -402,11 +403,15 @@
"dbRows": "行",
"dbColumns": "列",
"dbSchemaFailed": "加载数据库结构失败",
"dbSchemaLoaded": "结构加载完成",
"dbAddProfile": "新增连接",
"dbExecSuccess": "SQL 执行成功",
"dbNoOutput": "执行完成(无输出)",
"dbRenameProfile": "重命名",
"dbDeleteProfile": "删除连接",
"dbDeleteProfileConfirm": "确定删除该数据库连接配置吗?",
"dbProfileNamePrompt": "请输入连接名称",
"dbProfileName": "连接名称",
"dbProfiles": "数据库连接",
"aiSystemReadyMessage": "系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。",
"aiNewConversation": "新对话",
@@ -1231,6 +1236,15 @@
"fofaApiKeyHint": "仅保存在服务器配置中(`config.yaml`)。",
"maxIterations": "最大迭代次数",
"iterationsPlaceholder": "30",
"enableMultiAgent": "启用 Eino 多代理(DeepAgent",
"enableMultiAgentHint": "开启后对话页可选「多代理」模式;子代理在 config.yaml 的 multi_agent.sub_agents 中配置。",
"multiAgentDefaultMode": "对话页默认模式",
"multiAgentModeSingle": "单代理(ReAct",
"multiAgentModeMulti": "多代理(Eino",
"multiAgentRobotUse": "企业微信 / 钉钉 / 飞书机器人也使用多代理",
"multiAgentRobotUseHint": "需同时勾选「启用多代理」;调用量与成本更高。",
"multiAgentBatchUse": "批量任务队列也使用多代理",
"multiAgentBatchUseHint": "开启后,任务管理中按队列执行的每个子任务将走 Eino DeepAgent(需启用多代理)。",
"enableKnowledge": "启用知识检索功能",
"knowledgeBasePath": "知识库路径",
"knowledgeBasePathPlaceholder": "knowledge_base",
@@ -1437,6 +1451,9 @@
},
"contextMenu": {
"viewAttackChain": "查看攻击链",
"downloadMarkdown": "下载 Markdown",
"downloadMarkdownSummary": "简版",
"downloadMarkdownFull": "完整版",
"rename": "重命名",
"pinConversation": "置顶此对话",
"unpinConversation": "取消置顶",
+244 -14
View File
@@ -4076,6 +4076,7 @@ let contextMenuGroupId = null;
let groupsCache = [];
let conversationGroupMappingCache = {};
let pendingGroupMappings = {}; // 待保留的分组映射(用于处理后端API延迟的情况)
let conversationsListLoadSeq = 0; // 对话列表加载序号,避免并发请求导致重复渲染
// 加载分组列表
async function loadGroups() {
@@ -4170,11 +4171,14 @@ async function loadGroups() {
// 加载对话列表(修改为支持分组和置顶)
async function loadConversationsWithGroups(searchQuery = '') {
const loadSeq = ++conversationsListLoadSeq;
try {
// 总是重新加载分组列表和分组映射,确保缓存是最新的
// 这样可以正确处理分组被删除后的情况
await loadGroups();
if (loadSeq !== conversationsListLoadSeq) return;
await loadConversationGroupMapping();
if (loadSeq !== conversationsListLoadSeq) return;
// 如果有搜索关键词,使用更大的limit以获取所有匹配结果
const limit = (searchQuery && searchQuery.trim()) ? 1000 : 100;
@@ -4183,6 +4187,7 @@ async function loadConversationsWithGroups(searchQuery = '') {
url += '&search=' + encodeURIComponent(searchQuery.trim());
}
const response = await apiFetch(url);
if (loadSeq !== conversationsListLoadSeq) return;
const listContainer = document.getElementById('conversations-list');
if (!listContainer) {
@@ -4204,8 +4209,20 @@ async function loadConversationsWithGroups(searchQuery = '') {
}
const conversations = await response.json();
if (loadSeq !== conversationsListLoadSeq) return;
if (!Array.isArray(conversations) || conversations.length === 0) {
// 双重保险:后端或并发情况下若出现重复ID,前端按ID去重
const uniqueConversations = [];
const seenConversationIds = new Set();
(Array.isArray(conversations) ? conversations : []).forEach(conv => {
if (!conv || !conv.id || seenConversationIds.has(conv.id)) {
return;
}
seenConversationIds.add(conv.id);
uniqueConversations.push(conv);
});
if (uniqueConversations.length === 0) {
listContainer.innerHTML = emptyStateHtml;
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
return;
@@ -4216,7 +4233,7 @@ async function loadConversationsWithGroups(searchQuery = '') {
const normalConvs = [];
const hasSearchQuery = searchQuery && searchQuery.trim();
conversations.forEach(conv => {
uniqueConversations.forEach(conv => {
// 如果有搜索关键词,显示所有匹配的对话(全局搜索,包括分组中的)
if (hasSearchQuery) {
// 搜索时显示所有匹配的对话,不管是否在分组中
@@ -4273,6 +4290,7 @@ async function loadConversationsWithGroups(searchQuery = '') {
return;
}
if (loadSeq !== conversationsListLoadSeq) return;
listContainer.appendChild(fragment);
updateActiveConversation();
@@ -4280,10 +4298,13 @@ async function loadConversationsWithGroups(searchQuery = '') {
if (sidebarContent) {
// 使用 requestAnimationFrame 确保 DOM 已经更新
requestAnimationFrame(() => {
sidebarContent.scrollTop = savedScrollTop;
if (loadSeq === conversationsListLoadSeq) {
sidebarContent.scrollTop = savedScrollTop;
}
});
}
} catch (error) {
if (loadSeq !== conversationsListLoadSeq) return;
console.error('加载对话列表失败:', error);
// 错误时显示空状态,而不是错误提示(更友好的用户体验)
const listContainer = document.getElementById('conversations-list');
@@ -4383,9 +4404,14 @@ async function showConversationContextMenu(event) {
submenu.style.display = 'none';
submenuVisible = false;
}
const downloadSubmenu = document.getElementById('download-markdown-submenu');
if (downloadSubmenu) {
downloadSubmenu.style.display = 'none';
}
// 清除所有定时器
clearSubmenuHideTimeout();
clearSubmenuShowTimeout();
clearDownloadMarkdownSubmenuHideTimeout();
submenuLoading = false;
const convId = contextMenuConversationId;
@@ -4516,26 +4542,44 @@ async function showConversationContextMenu(event) {
menu.style.top = top + 'px';
// 如果菜单在右侧,子菜单应该在左侧显示
if (submenu && left < event.clientX) {
submenu.style.left = 'auto';
submenu.style.right = '100%';
submenu.style.marginLeft = '0';
submenu.style.marginRight = '4px';
} else if (submenu) {
submenu.style.left = '100%';
submenu.style.right = 'auto';
submenu.style.marginLeft = '4px';
submenu.style.marginRight = '0';
if (left < event.clientX) {
if (submenu) {
submenu.style.left = 'auto';
submenu.style.right = '100%';
submenu.style.marginLeft = '0';
submenu.style.marginRight = '4px';
}
if (downloadSubmenu) {
downloadSubmenu.style.left = 'auto';
downloadSubmenu.style.right = '100%';
downloadSubmenu.style.marginLeft = '0';
downloadSubmenu.style.marginRight = '4px';
}
} else {
if (submenu) {
submenu.style.left = '100%';
submenu.style.right = 'auto';
submenu.style.marginLeft = '4px';
submenu.style.marginRight = '0';
}
if (downloadSubmenu) {
downloadSubmenu.style.left = '100%';
downloadSubmenu.style.right = 'auto';
downloadSubmenu.style.marginLeft = '4px';
downloadSubmenu.style.marginRight = '0';
}
}
// 点击外部关闭菜单
const closeMenu = (e) => {
// 检查点击是否在主菜单或子菜单内
const moveToGroupSubmenuEl = document.getElementById('move-to-group-submenu');
const downloadMarkdownSubmenuEl = document.getElementById('download-markdown-submenu');
const clickedInMenu = menu.contains(e.target);
const clickedInSubmenu = moveToGroupSubmenuEl && moveToGroupSubmenuEl.contains(e.target);
const clickedInDownloadSubmenu = downloadMarkdownSubmenuEl && downloadMarkdownSubmenuEl.contains(e.target);
if (!clickedInMenu && !clickedInSubmenu) {
if (!clickedInMenu && !clickedInSubmenu && !clickedInDownloadSubmenu) {
// 使用 closeContextMenu 确保同时关闭主菜单和子菜单
closeContextMenu();
document.removeEventListener('click', closeMenu);
@@ -4929,6 +4973,8 @@ let submenuShowTimeout = null;
let submenuLoading = false;
// 子菜单是否已显示
let submenuVisible = false;
// 下载Markdown子菜单隐藏定时器
let downloadMarkdownSubmenuHideTimeout = null;
// 隐藏移动到分组子菜单
function hideMoveToGroupSubmenu() {
@@ -4955,6 +5001,45 @@ function clearSubmenuShowTimeout() {
}
}
function clearDownloadMarkdownSubmenuHideTimeout() {
if (downloadMarkdownSubmenuHideTimeout) {
clearTimeout(downloadMarkdownSubmenuHideTimeout);
downloadMarkdownSubmenuHideTimeout = null;
}
}
function showDownloadMarkdownSubmenu() {
const submenu = document.getElementById('download-markdown-submenu');
if (!submenu) return;
clearDownloadMarkdownSubmenuHideTimeout();
submenu.style.display = 'block';
}
function hideDownloadMarkdownSubmenu() {
const submenu = document.getElementById('download-markdown-submenu');
if (!submenu) return;
submenu.style.display = 'none';
}
function handleDownloadMarkdownSubmenuEnter() {
clearDownloadMarkdownSubmenuHideTimeout();
showDownloadMarkdownSubmenu();
}
function handleDownloadMarkdownSubmenuLeave(event) {
const submenu = document.getElementById('download-markdown-submenu');
if (!submenu) return;
const relatedTarget = event.relatedTarget;
if (relatedTarget && submenu.contains(relatedTarget)) {
return;
}
clearDownloadMarkdownSubmenuHideTimeout();
downloadMarkdownSubmenuHideTimeout = setTimeout(() => {
hideDownloadMarkdownSubmenu();
downloadMarkdownSubmenuHideTimeout = null;
}, 200);
}
// 处理鼠标进入"移动到分组"菜单项(带防抖)
function handleMoveToGroupSubmenuEnter() {
// 清除隐藏定时器
@@ -5157,6 +5242,146 @@ function showAttackChainFromContext() {
showAttackChain(convId);
}
function formatConversationDateForMarkdown(value) {
if (!value) return '';
const d = new Date(value);
if (isNaN(d.getTime())) return '';
const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : 'en-US';
return d.toLocaleString(locale, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
}
function getConversationRoleLabel(role) {
switch (role) {
case 'assistant':
return 'Assistant';
case 'user':
return 'User';
case 'system':
return 'System';
default:
return role || 'Unknown';
}
}
function formatConversationAsMarkdown(conversation, options = {}) {
const includeToolDetails = !!options.includeToolDetails;
const title = (conversation && conversation.title ? String(conversation.title) : '').trim() || 'Untitled Conversation';
const createdAt = formatConversationDateForMarkdown(conversation && conversation.createdAt);
const updatedAt = formatConversationDateForMarkdown(conversation && conversation.updatedAt);
const messages = Array.isArray(conversation && conversation.messages) ? conversation.messages : [];
let markdown = `# ${title}\n\n`;
markdown += `- Conversation ID: \`${conversation && conversation.id ? conversation.id : ''}\`\n`;
if (createdAt) markdown += `- Created At: ${createdAt}\n`;
if (updatedAt) markdown += `- Updated At: ${updatedAt}\n`;
markdown += `- Message Count: ${messages.length}\n\n`;
markdown += '---\n\n';
if (messages.length === 0) {
markdown += '_No messages in this conversation._\n';
return markdown;
}
messages.forEach((msg, index) => {
const role = getConversationRoleLabel(msg && msg.role);
const timestamp = formatConversationDateForMarkdown(msg && msg.createdAt);
const content = msg && typeof msg.content === 'string' ? msg.content : '';
markdown += `## ${index + 1}. ${role}`;
if (timestamp) markdown += ` (${timestamp})`;
markdown += '\n\n';
markdown += content ? `${content}\n\n` : '_[Empty message]_\n\n';
if (Array.isArray(msg && msg.processDetails) && msg.processDetails.length > 0) {
markdown += '### Process Details\n\n';
msg.processDetails.forEach((detail) => {
const detailTime = formatConversationDateForMarkdown(detail && detail.timestamp);
const eventType = detail && detail.eventType ? detail.eventType : 'event';
const detailMsg = detail && detail.message ? detail.message : '';
// Avoid "[label]:" pattern because some Markdown parsers treat it as link reference definition.
markdown += `- \`${eventType}\``;
if (detailTime) markdown += ` ${detailTime}`;
if (detailMsg) markdown += `: ${detailMsg}`;
markdown += '\n';
if (includeToolDetails && detail && detail.data && (eventType === 'tool_call' || eventType === 'tool_result')) {
const pretty = JSON.stringify(detail.data, null, 2);
markdown += '\n```json\n';
markdown += pretty || '{}';
markdown += '\n```\n';
}
});
markdown += '\n';
}
if (Array.isArray(msg && msg.mcpExecutionIds) && msg.mcpExecutionIds.length > 0) {
markdown += `- MCP Execution IDs: ${msg.mcpExecutionIds.join(', ')}\n\n`;
}
markdown += '---\n\n';
});
return markdown;
}
function buildConversationMarkdownFileName(conversation, options = {}) {
const includeToolDetails = !!options.includeToolDetails;
const title = (conversation && conversation.title ? String(conversation.title) : '').trim() || 'conversation';
const safeTitle = title
.replace(/[\\/:*?"<>|]/g, '_')
.replace(/\s+/g, '_')
.slice(0, 60) || 'conversation';
const idPart = (conversation && conversation.id ? String(conversation.id) : '').slice(0, 8) || 'export';
const modePart = includeToolDetails ? 'full' : 'summary';
return `${safeTitle}_${idPart}_${modePart}.md`;
}
// 从上下文菜单下载对话 Markdown
async function downloadConversationMarkdownFromContext(includeToolDetails = false) {
const convId = contextMenuConversationId;
if (!convId) return;
try {
const response = await apiFetch(`/api/conversations/${convId}`);
let conversation = null;
try {
conversation = await response.json();
} catch (e) {
conversation = null;
}
if (!response.ok) {
const errorMsg = conversation && conversation.error ? conversation.error : 'unknown error';
throw new Error(errorMsg);
}
const markdown = formatConversationAsMarkdown(conversation || {}, { includeToolDetails });
const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = buildConversationMarkdownFileName(conversation || {}, { includeToolDetails });
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
console.error('下载对话 Markdown 失败:', error);
const failedLabel = typeof window.t === 'function' ? window.t('chat.downloadConversationFailed') : '下载失败';
const errMsg = error && error.message ? error.message : 'unknown error';
alert(failedLabel + ': ' + errMsg);
}
closeContextMenu();
}
// 从上下文菜单删除对话
function deleteConversationFromContext() {
const convId = contextMenuConversationId;
@@ -5180,9 +5405,14 @@ function closeContextMenu() {
submenu.style.display = 'none';
submenuVisible = false;
}
const downloadSubmenu = document.getElementById('download-markdown-submenu');
if (downloadSubmenu) {
downloadSubmenu.style.display = 'none';
}
// 清除所有定时器
clearSubmenuHideTimeout();
clearSubmenuShowTimeout();
clearDownloadMarkdownSubmenuHideTimeout();
submenuLoading = false;
contextMenuConversationId = null;
}
+31 -64
View File
@@ -628,6 +628,29 @@ function handleStreamEvent(event, progressElement, progressId,
getAssistantId, setAssistantId, getMcpIds, setMcpIds) {
const timeline = document.getElementById(progressId + '-timeline');
if (!timeline) return;
// 终态事件(error/cancelled)优先复用现有助手消息,避免重复追加相同报错
const upsertTerminalAssistantMessage = (message, preferredMessageId = null) => {
const preferredIds = [];
if (preferredMessageId) preferredIds.push(preferredMessageId);
const existingAssistantId = typeof getAssistantId === 'function' ? getAssistantId() : null;
if (existingAssistantId && !preferredIds.includes(existingAssistantId)) {
preferredIds.push(existingAssistantId);
}
for (const id of preferredIds) {
const element = document.getElementById(id);
if (element) {
updateAssistantBubbleContent(id, message, true);
setAssistantId(id);
return { assistantId: id, assistantElement: element };
}
}
const assistantId = addMessage('assistant', message, null, progressId);
setAssistantId(assistantId);
return { assistantId: assistantId, assistantElement: document.getElementById(assistantId) };
};
switch (event.type) {
case 'conversation':
@@ -1033,47 +1056,19 @@ function handleStreamEvent(event, progressElement, progressId,
finalizeProgressTask(progressId, typeof window.t === 'function' ? window.t('tasks.statusCancelled') : '已取消');
}
// 如果取消事件包含messageId,说明有助手消息,需要显示取消内容
if (event.data && event.data.messageId) {
// 检查助手消息是否已存在
let assistantId = event.data.messageId;
let assistantElement = document.getElementById(assistantId);
// 如果助手消息不存在,创建它
if (!assistantElement) {
assistantId = addMessage('assistant', event.message, null, progressId);
setAssistantId(assistantId);
assistantElement = document.getElementById(assistantId);
} else {
// 如果已存在,更新内容
const bubble = assistantElement.querySelector('.message-bubble');
if (bubble) {
bubble.innerHTML = escapeHtml(event.message).replace(/\n/g, '<br>');
}
}
// 将进度详情集成到工具调用区域(如果还没有)
// 复用已有助手消息(若有),避免终态事件重复插入消息
{
const preferredMessageId = event.data && event.data.messageId ? event.data.messageId : null;
const { assistantId, assistantElement } = upsertTerminalAssistantMessage(event.message, preferredMessageId);
if (assistantElement) {
const detailsId = 'process-details-' + assistantId;
if (!document.getElementById(detailsId)) {
integrateProgressToMCPSection(progressId, assistantId, typeof getMcpIds === 'function' ? (getMcpIds() || []) : []);
}
// 立即折叠详情(取消时应该默认折叠)
setTimeout(() => {
collapseAllProgressDetails(assistantId, progressId);
}, 100);
}
} else {
// 如果没有messageId,创建助手消息并集成详情
const assistantId = addMessage('assistant', event.message, null, progressId);
setAssistantId(assistantId);
// 将进度详情集成到工具调用区域
setTimeout(() => {
integrateProgressToMCPSection(progressId, assistantId, typeof getMcpIds === 'function' ? (getMcpIds() || []) : []);
// 确保详情默认折叠
collapseAllProgressDetails(assistantId, progressId);
}, 100);
}
// 立即刷新任务状态
@@ -1232,47 +1227,19 @@ function handleStreamEvent(event, progressElement, progressId,
finalizeProgressTask(progressId, typeof window.t === 'function' ? window.t('tasks.statusFailed') : '执行失败');
}
// 如果错误事件包含messageId,说明有助手消息,需要显示错误内容
if (event.data && event.data.messageId) {
// 检查助手消息是否已存在
let assistantId = event.data.messageId;
let assistantElement = document.getElementById(assistantId);
// 如果助手消息不存在,创建它
if (!assistantElement) {
assistantId = addMessage('assistant', event.message, null, progressId);
setAssistantId(assistantId);
assistantElement = document.getElementById(assistantId);
} else {
// 如果已存在,更新内容
const bubble = assistantElement.querySelector('.message-bubble');
if (bubble) {
bubble.innerHTML = escapeHtml(event.message).replace(/\n/g, '<br>');
}
}
// 将进度详情集成到工具调用区域(如果还没有)
// 复用已有助手消息(若有),避免终态事件重复插入消息
{
const preferredMessageId = event.data && event.data.messageId ? event.data.messageId : null;
const { assistantId, assistantElement } = upsertTerminalAssistantMessage(event.message, preferredMessageId);
if (assistantElement) {
const detailsId = 'process-details-' + assistantId;
if (!document.getElementById(detailsId)) {
integrateProgressToMCPSection(progressId, assistantId, typeof getMcpIds === 'function' ? (getMcpIds() || []) : []);
}
// 立即折叠详情(错误时应该默认折叠)
setTimeout(() => {
collapseAllProgressDetails(assistantId, progressId);
}, 100);
}
} else {
// 如果没有messageId(比如任务已运行时的错误),创建助手消息并集成详情
const assistantId = addMessage('assistant', event.message, null, progressId);
setAssistantId(assistantId);
// 将进度详情集成到工具调用区域
setTimeout(() => {
integrateProgressToMCPSection(progressId, assistantId, typeof getMcpIds === 'function' ? (getMcpIds() || []) : []);
// 确保详情默认折叠
collapseAllProgressDetails(assistantId, progressId);
}, 100);
}
// 立即刷新任务状态(执行失败时任务状态会更新)
+5 -1
View File
@@ -115,11 +115,15 @@ function updateRoleSelectorDisplay() {
}
}
roleSelectorIcon.textContent = icon;
const displayName = (selectedRole.name === '默认' || !selectedRole.name) && typeof window.t === 'function'
const isDefaultRole = selectedRole.name === '默认' || !selectedRole.name;
const displayName = isDefaultRole && typeof window.t === 'function'
? window.t('chat.defaultRole') : (selectedRole.name || (typeof window.t === 'function' ? window.t('chat.defaultRole') : '默认'));
// 非默认角色时避免被 i18n 的 data-i18n 覆盖成“默认”
roleSelectorText.setAttribute('data-i18n-skip-text', isDefaultRole ? 'false' : 'true');
roleSelectorText.textContent = displayName;
} else {
// 默认角色
roleSelectorText.setAttribute('data-i18n-skip-text', 'false');
roleSelectorIcon.textContent = '🔵';
roleSelectorText.textContent = typeof window.t === 'function' ? window.t('chat.defaultRole') : '默认';
}
+50
View File
@@ -7,6 +7,9 @@ let currentEditingSkillName = null;
let isSavingSkill = false; // 防止重复提交
let skillsSearchKeyword = '';
let skillsSearchTimeout = null; // 搜索防抖定时器
let skillsAutoRefreshTimer = null;
let isAutoRefreshingSkills = false;
const SKILLS_AUTO_REFRESH_INTERVAL_MS = 5000;
let skillsPagination = {
currentPage: 1,
pageSize: 20, // 每页20条(默认值,实际从localStorage读取)
@@ -21,6 +24,49 @@ let skillsStats = {
stats: []
};
function isSkillsManagementPageActive() {
const page = document.getElementById('page-skills-management');
return !!(page && page.classList.contains('active'));
}
function shouldSkipSkillsAutoRefresh() {
if (isSavingSkill || currentEditingSkillName) {
return true;
}
const modal = document.getElementById('skill-modal');
if (modal && modal.style.display === 'flex') {
return true;
}
const searchInput = document.getElementById('skills-search');
if (skillsSearchKeyword || (searchInput && searchInput.value.trim())) {
return true;
}
return false;
}
function startSkillsAutoRefresh() {
if (skillsAutoRefreshTimer) return;
skillsAutoRefreshTimer = setInterval(async () => {
if (!isSkillsManagementPageActive() || shouldSkipSkillsAutoRefresh()) {
return;
}
if (isAutoRefreshingSkills) {
return;
}
isAutoRefreshingSkills = true;
try {
await loadSkills(skillsPagination.currentPage, skillsPagination.pageSize);
} finally {
isAutoRefreshingSkills = false;
}
}, SKILLS_AUTO_REFRESH_INTERVAL_MS);
}
// 获取保存的每页显示数量
function getSkillsPageSize() {
try {
@@ -750,3 +796,7 @@ document.addEventListener('languagechange', function () {
}
}
});
document.addEventListener('DOMContentLoaded', function () {
startSkillsAutoRefresh();
});
+92 -27
View File
@@ -127,11 +127,15 @@ function wsT(key) {
'webshell.dbRows': '行',
'webshell.dbColumns': '列',
'webshell.dbSchemaFailed': '加载数据库结构失败',
'webshell.dbSchemaLoaded': '结构加载完成',
'webshell.dbAddProfile': '新增连接',
'webshell.dbExecSuccess': 'SQL 执行成功',
'webshell.dbNoOutput': '执行完成(无输出)',
'webshell.dbRenameProfile': '重命名',
'webshell.dbDeleteProfile': '删除连接',
'webshell.dbDeleteProfileConfirm': '确定删除该数据库连接配置吗?',
'webshell.dbProfileNamePrompt': '请输入连接名称',
'webshell.dbProfileName': '连接名称',
'webshell.dbProfiles': '数据库连接',
'webshell.aiSystemReadyMessage': '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。',
'webshell.aiPlaceholder': '例如:列出当前目录下的文件',
@@ -864,14 +868,17 @@ function webshellDbGetFieldValue(id) {
}
function webshellDbCollectConfig(conn) {
var curr = getWebshellDbConfig(conn) || {};
var nameVal = webshellDbGetFieldValue('webshell-db-profile-name');
var cfg = {
name: nameVal || curr.name || 'DB-1',
type: webshellDbGetFieldValue('webshell-db-type') || 'mysql',
host: webshellDbGetFieldValue('webshell-db-host') || '127.0.0.1',
port: webshellDbGetFieldValue('webshell-db-port') || '',
username: webshellDbGetFieldValue('webshell-db-user') || '',
password: (document.getElementById('webshell-db-pass') || {}).value || '',
database: webshellDbGetFieldValue('webshell-db-name') || '',
selectedDatabase: getWebshellDbConfig(conn).selectedDatabase || '',
selectedDatabase: curr.selectedDatabase || '',
sqlitePath: webshellDbGetFieldValue('webshell-db-sqlite-path') || '/tmp/test.db',
sql: (document.getElementById('webshell-db-sql') || {}).value || ''
};
@@ -1574,7 +1581,19 @@ function selectWebshell(id, stateReady) {
'<div class="webshell-db-sidebar-hint">' + (wsT('webshell.dbSelectTableHint') || '点击表名可生成查询 SQL') + '</div>' +
'</aside>' +
'<section class="webshell-db-main">' +
'<div class="webshell-db-sql-tools"><button type="button" class="btn-ghost btn-sm" id="webshell-db-template-btn">' + (wsT('webshell.dbTemplateSql') || '示例 SQL') + '</button><button type="button" class="btn-ghost btn-sm" id="webshell-db-clear-btn">' + (wsT('webshell.dbClearSql') || '清空 SQL') + '</button></div>' +
'<textarea id="webshell-db-sql" class="webshell-db-sql form-control" rows="8" placeholder="' + (wsT('webshell.dbSqlPlaceholder') || '输入 SQL,例如:SELECT version();') + '"></textarea>' +
'<div class="webshell-db-actions">' +
'<button type="button" class="btn-ghost" id="webshell-db-test-btn">' + (wsT('webshell.dbTest') || '测试连接') + '</button>' +
'<button type="button" class="btn-primary" id="webshell-db-run-btn">' + (wsT('webshell.dbRunSql') || '执行 SQL') + '</button>' +
'</div>' +
'<div class="webshell-db-output-wrap"><div class="webshell-db-output-title">' + (wsT('webshell.dbOutput') || '执行输出') + '</div><div id="webshell-db-result-table" class="webshell-db-result-table"></div><pre id="webshell-db-output" class="webshell-db-output"></pre><div class="webshell-db-hint">' + (wsT('webshell.dbCliHint') || '如果提示命令不存在,请先在目标主机安装对应客户端(mysql/psql/sqlite3/sqlcmd') + '</div></div>' +
'<div id="webshell-db-profile-modal" class="modal">' +
'<div class="modal-content webshell-db-profile-modal-content">' +
'<div class="modal-header"><h2 id="webshell-db-profile-modal-title">' + (wsT('webshell.editConnectionTitle') || '编辑连接') + '</h2><span class="modal-close" id="webshell-db-profile-modal-close">&times;</span></div>' +
'<div class="modal-body">' +
'<div class="webshell-db-toolbar">' +
'<label><span>' + (wsT('webshell.dbProfileName') || '连接名称') + '</span><input id="webshell-db-profile-name" class="form-control" type="text" maxlength="30" /></label>' +
'<label><span>' + (wsT('webshell.dbType') || '数据库类型') + '</span><select id="webshell-db-type" class="form-control"><option value="mysql">MySQL</option><option value="pgsql">PostgreSQL</option><option value="sqlite">SQLite</option><option value="mssql">SQL Server</option></select></label>' +
'<label class="webshell-db-common-field"><span>' + (wsT('webshell.dbHost') || '主机') + '</span><input id="webshell-db-host" class="form-control" type="text" value="127.0.0.1" /></label>' +
'<label class="webshell-db-common-field"><span>' + (wsT('webshell.dbPort') || '端口') + '</span><input id="webshell-db-port" class="form-control" type="text" /></label>' +
@@ -1583,13 +1602,10 @@ function selectWebshell(id, stateReady) {
'<label class="webshell-db-common-field"><span>' + (wsT('webshell.dbName') || '数据库名') + '</span><input id="webshell-db-name" class="form-control" type="text" /></label>' +
'<label id="webshell-db-sqlite-row"><span>' + (wsT('webshell.dbSqlitePath') || 'SQLite 文件路径') + '</span><input id="webshell-db-sqlite-path" class="form-control" type="text" value="/tmp/test.db" /></label>' +
'</div>' +
'<div class="webshell-db-sql-tools"><button type="button" class="btn-ghost btn-sm" id="webshell-db-template-btn">' + (wsT('webshell.dbTemplateSql') || '示例 SQL') + '</button><button type="button" class="btn-ghost btn-sm" id="webshell-db-clear-btn">' + (wsT('webshell.dbClearSql') || '清空 SQL') + '</button></div>' +
'<textarea id="webshell-db-sql" class="webshell-db-sql form-control" rows="8" placeholder="' + (wsT('webshell.dbSqlPlaceholder') || '输入 SQL,例如:SELECT version();') + '"></textarea>' +
'<div class="webshell-db-actions">' +
'<button type="button" class="btn-ghost" id="webshell-db-test-btn">' + (wsT('webshell.dbTest') || '测试连接') + '</button>' +
'<button type="button" class="btn-primary" id="webshell-db-run-btn">' + (wsT('webshell.dbRunSql') || '执行 SQL') + '</button>' +
'</div>' +
'<div class="webshell-db-output-wrap"><div class="webshell-db-output-title">' + (wsT('webshell.dbOutput') || '执行输出') + '</div><div id="webshell-db-result-table" class="webshell-db-result-table"></div><pre id="webshell-db-output" class="webshell-db-output"></pre><div class="webshell-db-hint">' + (wsT('webshell.dbCliHint') || '如果提示命令不存在,请先在目标主机安装对应客户端(mysql/psql/sqlite3/sqlcmd') + '</div></div>' +
'<div class="modal-footer"><button type="button" class="btn-secondary" id="webshell-db-profile-cancel-btn">取消</button><button type="button" class="btn-primary" id="webshell-db-profile-save-btn">保存</button></div>' +
'</div>' +
'</div>' +
'</section>' +
'</div>' +
'</div>';
@@ -1770,6 +1786,12 @@ function selectWebshell(id, stateReady) {
var dbSchemaTreeEl = document.getElementById('webshell-db-schema-tree');
var dbProfilesEl = document.getElementById('webshell-db-profiles');
var dbAddProfileBtn = document.getElementById('webshell-db-add-profile-btn');
var dbProfileModalEl = document.getElementById('webshell-db-profile-modal');
var dbProfileModalTitleEl = document.getElementById('webshell-db-profile-modal-title');
var dbProfileModalCloseBtn = document.getElementById('webshell-db-profile-modal-close');
var dbProfileModalCancelBtn = document.getElementById('webshell-db-profile-cancel-btn');
var dbProfileModalSaveBtn = document.getElementById('webshell-db-profile-save-btn');
var dbProfileNameEl = document.getElementById('webshell-db-profile-name');
var dbHostEl = document.getElementById('webshell-db-host');
var dbPortEl = document.getElementById('webshell-db-port');
var dbUserEl = document.getElementById('webshell-db-user');
@@ -1793,9 +1815,19 @@ function selectWebshell(id, stateReady) {
if (dbAddProfileBtn) dbAddProfileBtn.disabled = disabled;
}
function setDbProfileModalVisible(visible, mode) {
if (!dbProfileModalEl) return;
dbProfileModalEl.style.display = visible ? 'block' : 'none';
if (dbProfileModalTitleEl) {
if (mode === 'add') dbProfileModalTitleEl.textContent = wsT('webshell.dbAddProfile') || '新增连接';
else dbProfileModalTitleEl.textContent = wsT('webshell.editConnectionTitle') || '编辑连接';
}
}
function applyActiveDbProfileToForm() {
var dbCfg = getWebshellDbConfig(conn);
if (!dbCfg) return;
if (dbProfileNameEl) dbProfileNameEl.value = dbCfg.name || 'DB-1';
if (dbTypeEl) dbTypeEl.value = dbCfg.type || 'mysql';
if (dbHostEl) dbHostEl.value = dbCfg.host || '127.0.0.1';
if (dbPortEl) dbPortEl.value = dbCfg.port || '';
@@ -1817,7 +1849,7 @@ function selectWebshell(id, stateReady) {
var active = p.id === state.activeProfileId;
html += '<div class="webshell-db-profile-tab' + (active ? ' active' : '') + '" data-id="' + escapeHtml(p.id) + '">' +
'<button type="button" class="webshell-db-profile-main" data-action="switch" data-id="' + escapeHtml(p.id) + '">' + escapeHtml(p.name || 'DB') + '</button>' +
'<button type="button" class="webshell-db-profile-menu" data-action="rename" data-id="' + escapeHtml(p.id) + '" title="' + escapeHtml(wsT('webshell.dbRenameProfile') || '重命名') + '"></button>' +
'<button type="button" class="webshell-db-profile-menu" data-action="edit" data-id="' + escapeHtml(p.id) + '" title="' + escapeHtml(wsT('webshell.editConnection') || '编辑') + '"></button>' +
'<button type="button" class="webshell-db-profile-menu" data-action="delete" data-id="' + escapeHtml(p.id) + '" title="' + escapeHtml(wsT('webshell.dbDeleteProfile') || '删除连接') + '">×</button>' +
'</div>';
});
@@ -1839,15 +1871,12 @@ function selectWebshell(id, stateReady) {
renderDbSchemaTree();
return;
}
if (action === 'rename') {
var curr = state.profiles[idx].name || '';
var next = prompt(wsT('webshell.dbProfileNamePrompt') || '请输入连接名称', curr);
if (next == null) return;
next = String(next || '').trim();
if (!next) return;
state.profiles[idx].name = next.slice(0, 30);
if (action === 'edit') {
state.activeProfileId = id;
saveWebshellDbState(conn, state);
applyActiveDbProfileToForm();
renderDbProfileTabs();
setDbProfileModalVisible(true, 'edit');
return;
}
if (action === 'delete') {
@@ -2090,11 +2119,11 @@ function selectWebshell(id, stateReady) {
return;
}
cfg.schema = parseWebshellDbSchema(parsed.output);
cfg.output = '结构加载完成';
cfg.output = wsT('webshell.dbSchemaLoaded') || '结构加载完成';
cfg.outputIsError = false;
saveWebshellDbConfig(conn, cfg);
renderDbSchemaTree();
webshellDbSetOutput('结构加载完成', false);
webshellDbSetOutput(wsT('webshell.dbSchemaLoaded') || '结构加载完成', false);
}).catch(function (err) {
webshellDbSetOutput((wsT('webshell.dbSchemaFailed') || '加载数据库结构失败') + ': ' + (err && err.message ? err.message : String(err)), true);
}).finally(function () {
@@ -2149,12 +2178,12 @@ function selectWebshell(id, stateReady) {
}
var hasTable = webshellDbRenderTable(content);
if (hasTable) {
cfg.output = 'SQL 执行成功';
cfg.output = wsT('webshell.dbExecSuccess') || 'SQL 执行成功';
cfg.outputIsError = false;
saveWebshellDbConfig(conn, cfg);
webshellDbSetOutput(cfg.output, false);
} else {
cfg.output = content || '执行完成(无输出)';
cfg.output = content || (wsT('webshell.dbNoOutput') || '执行完成(无输出)');
cfg.outputIsError = false;
saveWebshellDbConfig(conn, cfg);
webshellDbSetOutput(cfg.output, false);
@@ -2179,11 +2208,12 @@ function selectWebshell(id, stateReady) {
resetDbColumnLoadCache();
renderDbSchemaTree();
});
['webshell-db-host', 'webshell-db-port', 'webshell-db-user', 'webshell-db-pass', 'webshell-db-name', 'webshell-db-sqlite-path'].forEach(function (id) {
['webshell-db-profile-name', 'webshell-db-host', 'webshell-db-port', 'webshell-db-user', 'webshell-db-pass', 'webshell-db-name', 'webshell-db-sqlite-path'].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.addEventListener('change', function () {
webshellDbCollectConfig(conn);
resetDbColumnLoadCache();
renderDbProfileTabs();
});
});
if (dbSqlEl) dbSqlEl.addEventListener('change', function () { webshellDbCollectConfig(conn); });
@@ -2203,6 +2233,25 @@ function selectWebshell(id, stateReady) {
if (dbSqlEl) dbSqlEl.value = '';
webshellDbCollectConfig(conn);
});
if (dbProfileModalCloseBtn) dbProfileModalCloseBtn.addEventListener('click', function () {
setDbProfileModalVisible(false);
});
if (dbProfileModalCancelBtn) dbProfileModalCancelBtn.addEventListener('click', function () {
applyActiveDbProfileToForm();
setDbProfileModalVisible(false);
});
if (dbProfileModalSaveBtn) dbProfileModalSaveBtn.addEventListener('click', function () {
webshellDbCollectConfig(conn);
renderDbProfileTabs();
resetDbColumnLoadCache();
setDbProfileModalVisible(false);
});
if (dbProfileModalEl) dbProfileModalEl.addEventListener('click', function (evt) {
if (evt.target === dbProfileModalEl) {
applyActiveDbProfileToForm();
setDbProfileModalVisible(false);
}
});
if (dbAddProfileBtn) dbAddProfileBtn.addEventListener('click', function () {
var state = getWebshellDbState(conn);
var name = 'DB-' + (state.profiles.length + 1);
@@ -2213,10 +2262,12 @@ function selectWebshell(id, stateReady) {
applyActiveDbProfileToForm();
renderDbProfileTabs();
renderDbSchemaTree();
setDbProfileModalVisible(true, 'add');
});
renderDbProfileTabs();
applyActiveDbProfileToForm();
renderDbSchemaTree();
setDbProfileModalVisible(false);
initWebshellTerminal(conn);
}
@@ -2977,6 +3028,9 @@ function parseWebshellListItems(rawOutput) {
var items = [];
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
var trimmedLine = String(line || '').trim();
// `ls -la` 首行常见 "total 12"(中文环境为 "总计 12"),不是文件项。
if (/^(total|总计)\s+\d+$/i.test(trimmedLine)) continue;
var name = '';
var isDir = false;
var size = '';
@@ -3059,14 +3113,14 @@ function renderFileList(listEl, currentPath, rawOutput, conn, nameFilter) {
if (rawOutput.trim() && !nameFilter) {
html = '<pre class="webshell-file-raw">' + escapeHtml(rawOutput) + '</pre>';
} else {
html = '<table class="webshell-file-table"><thead><tr><th class="webshell-col-check"><input type="checkbox" id="webshell-file-select-all" title="' + (wsT('webshell.selectAll') || '全选') + '" /></th><th>' + wsT('webshell.filePath') + '</th><th class="webshell-col-size">大小</th><th class="webshell-col-mtime">' + (wsT('webshell.colModifiedAt') || '修改时间') + '</th><th class="webshell-col-owner">' + (wsT('webshell.colOwner') || '所有者') + '</th><th class="webshell-col-group">' + (wsT('webshell.colGroup') || '用户组') + '</th><th class="webshell-col-perms">' + (wsT('webshell.colPerms') || '权限') + '</th><th class="webshell-col-actions"></th></tr></thead><tbody>' +
'<tr><td colspan="8" class="webshell-file-empty-state">' + (wsT('common.noData') || '暂无文件') + '</td></tr>' +
html = '<table class="webshell-file-table"><thead><tr><th class="webshell-col-check"><input type="checkbox" id="webshell-file-select-all" title="' + (wsT('webshell.selectAll') || '全选') + '" /></th><th>' + wsT('webshell.filePath') + '</th><th class="webshell-col-size">大小</th><th class="webshell-col-mtime">' + (wsT('webshell.colModifiedAt') || '修改时间') + '</th><th class="webshell-col-owner">' + (wsT('webshell.colOwner') || '所有者') + '</th><th class="webshell-col-perms">' + (wsT('webshell.colPerms') || '权限') + '</th><th class="webshell-col-actions"></th></tr></thead><tbody>' +
'<tr><td colspan="7" class="webshell-file-empty-state">' + (wsT('common.noData') || '暂无文件') + '</td></tr>' +
'</tbody></table>';
}
} else {
html = '<table class="webshell-file-table"><thead><tr><th class="webshell-col-check"><input type="checkbox" id="webshell-file-select-all" title="' + (wsT('webshell.selectAll') || '全选') + '" /></th><th>' + wsT('webshell.filePath') + '</th><th class="webshell-col-size">大小</th><th class="webshell-col-mtime">' + (wsT('webshell.colModifiedAt') || '修改时间') + '</th><th class="webshell-col-owner">' + (wsT('webshell.colOwner') || '所有者') + '</th><th class="webshell-col-group">' + (wsT('webshell.colGroup') || '用户组') + '</th><th class="webshell-col-perms">' + (wsT('webshell.colPerms') || '权限') + '</th><th class="webshell-col-actions"></th></tr></thead><tbody>';
html = '<table class="webshell-file-table"><thead><tr><th class="webshell-col-check"><input type="checkbox" id="webshell-file-select-all" title="' + (wsT('webshell.selectAll') || '全选') + '" /></th><th>' + wsT('webshell.filePath') + '</th><th class="webshell-col-size">大小</th><th class="webshell-col-mtime">' + (wsT('webshell.colModifiedAt') || '修改时间') + '</th><th class="webshell-col-owner">' + (wsT('webshell.colOwner') || '所有者') + '</th><th class="webshell-col-perms">' + (wsT('webshell.colPerms') || '权限') + '</th><th class="webshell-col-actions"></th></tr></thead><tbody>';
if (currentPath !== '.' && currentPath !== '') {
html += '<tr><td></td><td><a href="#" class="webshell-file-link" data-path="' + escapeHtml(currentPath.replace(/\/[^/]+$/, '') || '.') + '" data-isdir="1">..</a></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>';
html += '<tr><td></td><td><a href="#" class="webshell-file-link" data-path="' + escapeHtml(currentPath.replace(/\/[^/]+$/, '') || '.') + '" data-isdir="1">..</a></td><td></td><td></td><td></td><td></td><td></td></tr>';
}
items.forEach(function (item) {
var pathNext = currentPath === '.' ? item.name : currentPath + '/' + item.name;
@@ -3077,7 +3131,6 @@ function renderFileList(listEl, currentPath, rawOutput, conn, nameFilter) {
html += '<td class="webshell-col-size">' + escapeHtml(item.size) + '</td>';
html += '<td class="webshell-col-mtime">' + escapeHtml(item.mtime || '') + '</td>';
html += '<td class="webshell-col-owner">' + escapeHtml(item.owner || '') + '</td>';
html += '<td class="webshell-col-group">' + escapeHtml(item.group || '') + '</td>';
html += '<td class="webshell-col-perms">' + escapeHtml(item.mode || '') + '</td>';
html += '<td class="webshell-col-actions">';
if (item.isDir) {
@@ -3657,9 +3710,13 @@ function refreshWebshellUIOnLanguageChange() {
setWebshellTerminalStatus(webshellTerminalRunning);
if (webshellCurrentConn) renderWebshellTerminalSessions(webshellCurrentConn);
var pathLabel = workspace.querySelector('.webshell-file-toolbar label span');
var fileSidebarTitle = workspace.querySelector('.webshell-file-sidebar-title');
var fileMoreActionsBtn = workspace.querySelector('.webshell-toolbar-actions-btn');
var listDirBtn = document.getElementById('webshell-list-dir');
var parentDirBtn = document.getElementById('webshell-parent-dir');
if (pathLabel) pathLabel.textContent = wsT('webshell.filePath');
if (fileSidebarTitle) fileSidebarTitle.textContent = wsT('webshell.dirTree') || '目录列表';
if (fileMoreActionsBtn) fileMoreActionsBtn.textContent = wsT('webshell.moreActions') || '更多操作';
if (listDirBtn) listDirBtn.textContent = wsT('webshell.listDir');
if (parentDirBtn) parentDirBtn.textContent = wsT('webshell.parentDir');
// 文件管理工具栏按钮(红框区域):切换语言时立即更新
@@ -3699,6 +3756,8 @@ function refreshWebshellUIOnLanguageChange() {
}
var dbTypeLabel = document.querySelector('#webshell-db-type') ? document.querySelector('#webshell-db-type').closest('label') : null;
if (dbTypeLabel && dbTypeLabel.querySelector('span')) dbTypeLabel.querySelector('span').textContent = wsT('webshell.dbType') || '数据库类型';
var dbProfileNameLabel = document.querySelector('#webshell-db-profile-name') ? document.querySelector('#webshell-db-profile-name').closest('label') : null;
if (dbProfileNameLabel && dbProfileNameLabel.querySelector('span')) dbProfileNameLabel.querySelector('span').textContent = wsT('webshell.dbProfileName') || '连接名称';
var dbHostLabel = document.querySelector('#webshell-db-host') ? document.querySelector('#webshell-db-host').closest('label') : null;
if (dbHostLabel && dbHostLabel.querySelector('span')) dbHostLabel.querySelector('span').textContent = wsT('webshell.dbHost') || '主机';
var dbPortLabel = document.querySelector('#webshell-db-port') ? document.querySelector('#webshell-db-port').closest('label') : null;
@@ -3733,8 +3792,14 @@ function refreshWebshellUIOnLanguageChange() {
if (dbTreeHint) dbTreeHint.textContent = wsT('webshell.dbSelectTableHint') || '点击表名可生成查询 SQL';
var dbAddProfileBtn = document.getElementById('webshell-db-add-profile-btn');
if (dbAddProfileBtn) dbAddProfileBtn.textContent = '+ ' + (wsT('webshell.dbAddProfile') || '新增连接');
document.querySelectorAll('.webshell-db-profile-menu[data-action="rename"]').forEach(function (el) {
el.title = wsT('webshell.dbRenameProfile') || '重命名';
var dbProfileModalTitle = document.getElementById('webshell-db-profile-modal-title');
if (dbProfileModalTitle) dbProfileModalTitle.textContent = wsT('webshell.editConnectionTitle') || '编辑连接';
var dbProfileCancelBtn = document.getElementById('webshell-db-profile-cancel-btn');
if (dbProfileCancelBtn) dbProfileCancelBtn.textContent = '取消';
var dbProfileSaveBtn = document.getElementById('webshell-db-profile-save-btn');
if (dbProfileSaveBtn) dbProfileSaveBtn.textContent = '保存';
document.querySelectorAll('.webshell-db-profile-menu[data-action="edit"]').forEach(function (el) {
el.title = wsT('webshell.editConnection') || '编辑';
});
document.querySelectorAll('.webshell-db-profile-menu[data-action="delete"]').forEach(function (el) {
el.title = wsT('webshell.dbDeleteProfile') || '删除连接';
+27 -9
View File
@@ -1399,32 +1399,32 @@
<label class="checkbox-label">
<input type="checkbox" id="multi-agent-enabled" class="modern-checkbox" />
<span class="checkbox-custom"></span>
<span class="checkbox-text">启用 Eino 多代理(DeepAgent</span>
<span class="checkbox-text" data-i18n="settingsBasic.enableMultiAgent">启用 Eino 多代理(DeepAgent</span>
</label>
<small class="form-hint">开启后对话页可选「多代理」模式;子代理在 config.yaml 的 multi_agent.sub_agents 中配置。</small>
<small class="form-hint" data-i18n="settingsBasic.enableMultiAgentHint">开启后对话页可选「多代理」模式;子代理在 config.yaml 的 multi_agent.sub_agents 中配置。</small>
</div>
<div class="form-group">
<label for="multi-agent-default-mode">对话页默认模式</label>
<label for="multi-agent-default-mode" data-i18n="settingsBasic.multiAgentDefaultMode">对话页默认模式</label>
<select id="multi-agent-default-mode">
<option value="single">单代理(ReAct</option>
<option value="multi">多代理(Eino</option>
<option value="single" data-i18n="settingsBasic.multiAgentModeSingle">单代理(ReAct</option>
<option value="multi" data-i18n="settingsBasic.multiAgentModeMulti">多代理(Eino</option>
</select>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="multi-agent-robot-use" class="modern-checkbox" />
<span class="checkbox-custom"></span>
<span class="checkbox-text">企业微信 / 钉钉 / 飞书机器人也使用多代理</span>
<span class="checkbox-text" data-i18n="settingsBasic.multiAgentRobotUse">企业微信 / 钉钉 / 飞书机器人也使用多代理</span>
</label>
<small class="form-hint">需同时勾选「启用多代理」;调用量与成本更高。</small>
<small class="form-hint" data-i18n="settingsBasic.multiAgentRobotUseHint">需同时勾选「启用多代理」;调用量与成本更高。</small>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="multi-agent-batch-use" class="modern-checkbox" />
<span class="checkbox-custom"></span>
<span class="checkbox-text">批量任务队列也使用多代理</span>
<span class="checkbox-text" data-i18n="settingsBasic.multiAgentBatchUse">批量任务队列也使用多代理</span>
</label>
<small class="form-hint">开启后,任务管理中按队列执行的每个子任务将走 Eino DeepAgent(需启用多代理)。</small>
<small class="form-hint" data-i18n="settingsBasic.multiAgentBatchUseHint">开启后,任务管理中按队列执行的每个子任务将走 Eino DeepAgent(需启用多代理)。</small>
</div>
</div>
</div>
@@ -2205,6 +2205,24 @@ version: 1.0.0<br>
</svg>
<span data-i18n="contextMenu.viewAttackChain">查看攻击链</span>
</div>
<div class="context-menu-item context-menu-item-has-submenu" onmouseenter="handleDownloadMarkdownSubmenuEnter()" onmouseleave="handleDownloadMarkdownSubmenuLeave(event)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 3v12m0 0l-4-4m4 4l4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span data-i18n="contextMenu.downloadMarkdown">下载 Markdown</span>
<svg class="submenu-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<div id="download-markdown-submenu" class="context-submenu" style="display: none;" onmouseenter="clearDownloadMarkdownSubmenuHideTimeout()" onmouseleave="hideDownloadMarkdownSubmenu()">
<div class="context-submenu-item" onclick="downloadConversationMarkdownFromContext(false)">
<span data-i18n="contextMenu.downloadMarkdownSummary">简版</span>
</div>
<div class="context-submenu-item" onclick="downloadConversationMarkdownFromContext(true)">
<span data-i18n="contextMenu.downloadMarkdownFull">完整版</span>
</div>
</div>
</div>
<div class="context-menu-divider"></div>
<div class="context-menu-item" onclick="renameConversation()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">