Compare commits

..

16 Commits

Author SHA1 Message Date
公明 c9f1a2001e Update config.yaml 2026-06-05 11:31:27 +08:00
公明 905dd519ed Add files via upload 2026-06-05 11:22:35 +08:00
公明 60ea106301 Add files via upload 2026-06-05 10:38:24 +08:00
公明 92c0ae19bb Add files via upload 2026-06-05 10:35:41 +08:00
公明 43c6a0648d Add files via upload 2026-06-05 10:17:10 +08:00
公明 6b96e77120 Add files via upload 2026-06-05 10:13:00 +08:00
公明 a397922361 Add files via upload 2026-06-04 17:57:12 +08:00
公明 1e6e92b4af Add files via upload 2026-06-04 13:37:24 +08:00
公明 444f85b9c4 Add files via upload 2026-06-04 13:36:46 +08:00
公明 679a8192ae Add files via upload 2026-06-04 13:34:26 +08:00
公明 9a3f5e54b0 Add files via upload 2026-06-04 10:54:16 +08:00
公明 ce2eb56253 Add files via upload 2026-06-04 10:50:00 +08:00
公明 da6cb347df Add files via upload 2026-06-04 10:48:09 +08:00
公明 fb2658b2eb Add files via upload 2026-06-04 10:44:48 +08:00
公明 e791782c46 Add files via upload 2026-06-04 10:33:39 +08:00
公明 9b0efbb90f Add files via upload 2026-06-04 10:29:42 +08:00
31 changed files with 2747 additions and 1294 deletions
+4 -7
View File
@@ -10,7 +10,7 @@
# ============================================
# 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.6.30"
version: "v1.6.31"
# 服务器配置
server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
@@ -79,7 +79,6 @@ vision:
skip_preprocess_below_bytes: 2097152 # 低于 2MB 且长边<=max_dimension 且<=max_payload 时原图直传;0=始终压缩
detail: auto # low | high | autoEino ImageURLDetail
timeout_seconds: 60
# allowed_roots: [] # 额外允许的绝对路径根目录
# ============================================
# 信息收集(FOFA)配置(可选)
# ============================================
@@ -92,7 +91,7 @@ fofa:
# Agent 配置
# 达到最大迭代次数时,AI 会自动总结测试结果
agent:
max_iterations: 12000 # 最大迭代次数AI 代理最多执行多少轮工具调用
max_iterations: 12000 # 全局最大迭代次数(单代理 / Deep / Supervisor / Plan-Execute 主执行器 / 子代理均沿用;agents/*.md 中 max_iterations>0 可单独覆盖)
large_result_threshold: 102400 # 大结果阈值(字节),默认50KB,超过此大小会自动保存到存储
result_storage_dir: tmp # 结果存储目录,大结果会保存在此目录下
tool_timeout_minutes: 60 # 单次工具执行最大时长(分钟),超时自动终止;0 表示不限制(不推荐,易出现长时间挂起)
@@ -110,10 +109,8 @@ multi_agent:
enabled: true
robot_default_agent_mode: eino_single # 企微/钉钉/飞书机器人默认:eino_single | deep | plan_execute | supervisor
batch_use_multi_agent: false # true 时「批量任务」队列中每个子任务也走 Eino 多代理(成本更高)
max_iteration: 0 # 主代理 / plan_execute 执行器最大轮次,0 表示沿用 agent.max_iterations
# plan_execute 专用:execute↔replan 外层循环上限,0 表示 Eino 默认 10。当前实现下 Executor 会挂载 patch/reduction/tool_search 等前置中间件。
# plan_execute 专用:execute↔replan 外层循环上限,0 表示 Eino 默认 10。主/子代理 ReAct 轮次见 agent.max_iterations
plan_execute_loop_max_iterations: 0
sub_agent_max_iterations: 120
sub_agent_user_context_max_runes: 0 # 子代理 task 描述中自动注入用户原始请求的字符上限;0=默认2000,负数=禁用
without_general_sub_agent: false # false 时保留 Deep 内置 general-purpose 子代理
without_write_todos: false
@@ -295,7 +292,7 @@ skills_dir: skills # Skills配置文件目录(相对于配置文件所在目
# ============================================
# 多代理子 AgentMarkdown,唯一维护处)
# ============================================
# 每个 .mdYAML front mattername / id / description / tools / bind_role / max_iterations / 可选 kind: orchestrator+ 正文为系统提示词
# 每个 .mdYAML front mattername / id / description / tools / bind_role / 可选 max_iterations>0 覆盖全局 / 可选 kind: orchestrator+ 正文为系统提示词
# 主代理:固定文件名 orchestrator.md,或任意文件名 + front matter kind: orchestrator(全目录仅允许一个);主代理不参与 task 子代理列表
# 高级用法:仍可在 multi_agent 块内写 sub_agents,会与本文目录合并且同 id 时 YAML 可被 .md 覆盖
agents_dir: agents
+1 -1
View File
@@ -12,7 +12,7 @@
| 项 | 说明 |
|----|------|
| 依赖与代理 | `go.mod` 直接依赖 `github.com/cloudwego/eino``eino-ext/.../openai``go.mod` 注释与 `scripts/bootstrap-go.sh` 指导 **GOPROXY**(如 `https://goproxy.cn,direct`)。 |
| 配置 | `config.yaml``multi_agent``enabled``robot_use_multi_agent``max_iteration``sub_agents`(含可选 `bind_role`)、`eino_skills``eino_middleware` 等;结构体见 `internal/config/config.go`。 |
| 配置 | `config.yaml``agent.max_iterations` 为全局 ReAct 上限(主/子代理统一);`multi_agent``enabled``robot_use_multi_agent``sub_agents`(含可选 `bind_role`)、`eino_skills``eino_middleware` 等;结构体见 `internal/config/config.go`。 |
| Markdown 子代理 / 主代理 | 在 `agents_dir` 下放 `*.md`。**子代理**:供 Deep `task``supervisor` `transfer`。**主代理(按模式分离)**`orchestrator.md`(或 `kind: orchestrator` 的**单个**其他 .md)→ **Deep**;固定名 `orchestrator-plan-execute.md`**plan_execute**;固定名 `orchestrator-supervisor.md`**supervisor**。正文优先于 YAML`multi_agent.orchestrator_instruction``orchestrator_instruction_plan_execute``orchestrator_instruction_supervisor`plan_execute / supervisor **不会**回退到 Deep 的 `orchestrator_instruction`。皆空时 plan_execute / supervisor 使用代码内置默认提示。管理:**Agents → Agent管理**API`/api/multi-agent/markdown-agents*`。 |
| MCP 桥 | `internal/einomcp``ToolsFromDefinitions` + 会话 ID 持有者,执行走 `Agent.ExecuteMCPToolForConversation`。 |
| 编排 | `internal/multiagent/runner.go``deep.New` + 子 `ChatModelAgent` + `adk.NewRunner``EnableStreaming: true`,可选 `CheckPointStore`),事件映射为现有 SSE `tool_call` / `response_delta` 等。 |
+2 -8
View File
@@ -22,7 +22,6 @@ vision:
skip_preprocess_below_bytes: 2097152 # 低于 2MB 且长边<=max_dimension 时原图直传;0=始终 JPEG 压缩
detail: low # low | high | auto
timeout_seconds: 60
# allowed_roots: [] # 额外绝对路径根
```
`enabled: false` 时不注册工具。
@@ -31,14 +30,9 @@ vision:
**系统设置 → 基本设置 → 视觉分析(analyze_image** 可配置启用开关、视觉模型、API Key/Base URL(留空复用 OpenAI)、预处理参数;**保存并应用** 后写入 `config.yaml` 并重新注册 MCP 工具。
## 路径白名单
## 路径
默认可读:
- 进程工作目录(`cwd`)及其子路径
- `chat_uploads/`
- `agent.result_storage_dir`(默认 `tmp/`
- `vision.allowed_roots` 中配置的绝对路径
`analyze_image` 可读取服务器上任意可读的图片文件路径(绝对路径或相对于进程工作目录的相对路径)。仍校验图片扩展名与常规文件类型。
## Agent 使用
+2
View File
@@ -880,6 +880,7 @@ func setupRoutes(
protected.DELETE("/monitor/execution/:id", monitorHandler.DeleteExecution)
protected.DELETE("/monitor/executions", monitorHandler.DeleteExecutions)
protected.GET("/monitor/stats", monitorHandler.GetStats)
protected.GET("/monitor/calls-timeline", monitorHandler.GetCallsTimeline)
protected.GET("/notifications/summary", notificationHandler.GetSummary)
protected.POST("/notifications/read", notificationHandler.MarkRead)
@@ -1065,6 +1066,7 @@ func setupRoutes(
// 漏洞管理
protected.GET("/vulnerabilities", vulnerabilityHandler.ListVulnerabilities)
protected.GET("/vulnerabilities/export", vulnerabilityHandler.ExportVulnerabilities)
protected.DELETE("/vulnerabilities/batch", vulnerabilityHandler.BatchDeleteVulnerabilities)
protected.GET("/vulnerabilities/filter-options", vulnerabilityHandler.GetVulnerabilityFilterOptions)
protected.GET("/vulnerabilities/stats", vulnerabilityHandler.GetVulnerabilityStats)
protected.GET("/vulnerabilities/:id", vulnerabilityHandler.GetVulnerability)
+5 -3
View File
@@ -72,10 +72,12 @@ type MultiAgentConfig struct {
BatchUseMultiAgent bool `yaml:"batch_use_multi_agent" json:"batch_use_multi_agent"` // 为 true 时批量任务队列中每子任务走 Eino 多代理
// Orchestration 已弃用:保留仅兼容旧版 config.yaml;编排由聊天/WebShell 请求体 orchestration 决定,未传时按 deep。
Orchestration string `yaml:"orchestration,omitempty" json:"orchestration,omitempty"`
MaxIteration int `yaml:"max_iteration" json:"max_iteration"` // 主代理 / 执行器最大推理轮次(Deep、Supervisor、plan_execute 的 Executor
// MaxIteration 已废弃:统一使用 agent.max_iterationsYAML 中保留字段仅为兼容旧配置,运行时不读取)。
MaxIteration int `yaml:"max_iteration,omitempty" json:"max_iteration,omitempty"`
// PlanExecuteLoopMaxIterations plan_execute 模式下 execute↔replan 外层循环上限;0 表示用 Eino 默认 10。
PlanExecuteLoopMaxIterations int `yaml:"plan_execute_loop_max_iterations,omitempty" json:"plan_execute_loop_max_iterations,omitempty"`
SubAgentMaxIterations int `yaml:"sub_agent_max_iterations" json:"sub_agent_max_iterations"`
PlanExecuteLoopMaxIterations int `yaml:"plan_execute_loop_max_iterations,omitempty" json:"plan_execute_loop_max_iterations,omitempty"`
// SubAgentMaxIterations 已废弃:子代理与主代理均使用 agent.max_iterationsMarkdown max_iterations>0 可覆盖)。
SubAgentMaxIterations int `yaml:"sub_agent_max_iterations,omitempty" json:"sub_agent_max_iterations,omitempty"`
WithoutGeneralSubAgent bool `yaml:"without_general_sub_agent" json:"without_general_sub_agent"`
WithoutWriteTodos bool `yaml:"without_write_todos" json:"without_write_todos"`
OrchestratorInstruction string `yaml:"orchestrator_instruction" json:"orchestrator_instruction"`
+1 -2
View File
@@ -15,8 +15,7 @@ type VisionConfig struct {
JPEGQuality int `yaml:"jpeg_quality,omitempty" json:"jpeg_quality,omitempty"`
MaxPayloadBytes int64 `yaml:"max_payload_bytes,omitempty" json:"max_payload_bytes,omitempty"`
SkipPreprocessBelowBytes int64 `yaml:"skip_preprocess_below_bytes,omitempty" json:"skip_preprocess_below_bytes,omitempty"` // 0=始终压缩;默认 2MB 且长边已<=max_dimension 时原图直传
Detail string `yaml:"detail,omitempty" json:"detail,omitempty"` // low | high | auto
AllowedRoots []string `yaml:"allowed_roots,omitempty" json:"allowed_roots,omitempty"`
Detail string `yaml:"detail,omitempty" json:"detail,omitempty"` // low | high | auto
}
func (v VisionConfig) TimeoutSecondsEffective() int {
+57
View File
@@ -493,6 +493,63 @@ func (db *DB) UpdateToolStats(toolName string, totalCalls, successCalls, failedC
return nil
}
// CallsTimelineBucket 调用趋势时间桶
type CallsTimelineBucket struct {
BucketTime time.Time
Total int
Failed int
}
// LoadCallsTimeline 按时间范围加载调用趋势(since 起至今,含边界)
func (db *DB) LoadCallsTimeline(since time.Time, dailyBuckets bool) ([]CallsTimelineBucket, error) {
var bucketExpr string
if dailyBuckets {
bucketExpr = `strftime('%Y-%m-%d 00:00:00', start_time)`
} else {
bucketExpr = `strftime('%Y-%m-%d %H:00:00', start_time)`
}
query := `
SELECT ` + bucketExpr + ` AS bucket,
COUNT(*) AS total,
SUM(CASE WHEN status IN ('failed', 'cancelled') THEN 1 ELSE 0 END) AS failed
FROM tool_executions
WHERE start_time >= ?
GROUP BY bucket
ORDER BY bucket ASC
`
rows, err := db.Query(query, since)
if err != nil {
return nil, err
}
defer rows.Close()
var buckets []CallsTimelineBucket
for rows.Next() {
var bucketStr string
var total, failed int
if err := rows.Scan(&bucketStr, &total, &failed); err != nil {
db.logger.Warn("加载调用趋势失败", zap.Error(err))
continue
}
t, parseErr := time.ParseInLocation("2006-01-02 15:04:05", bucketStr, time.Local)
if parseErr != nil {
t, parseErr = time.Parse("2006-01-02 15:04:05", bucketStr)
if parseErr != nil {
db.logger.Warn("解析趋势时间桶失败", zap.String("bucket", bucketStr), zap.Error(parseErr))
continue
}
}
buckets = append(buckets, CallsTimelineBucket{
BucketTime: t,
Total: total,
Failed: failed,
})
}
return buckets, nil
}
// DecreaseToolStats 减少工具统计信息(用于删除执行记录时)
// 如果统计信息变为0,则删除该统计记录
func (db *DB) DecreaseToolStats(toolName string, totalCalls, successCalls, failedCalls int) error {
+33
View File
@@ -263,6 +263,39 @@ func (db *DB) UpdateVulnerability(id string, vuln *Vulnerability) error {
return nil
}
// DeleteVulnerabilitiesByFilter 按筛选条件批量删除漏洞,返回实际删除条数
func (db *DB) DeleteVulnerabilitiesByFilter(filter VulnerabilityListFilter) (int64, error) {
tx, err := db.Begin()
if err != nil {
return 0, fmt.Errorf("开启事务失败: %w", err)
}
defer func() { _ = tx.Rollback() }()
where := "WHERE 1=1"
args := []interface{}{}
where, args = filter.appendWhere(where, args)
clearQuery := `UPDATE project_facts SET related_vulnerability_id = NULL
WHERE related_vulnerability_id IN (SELECT id FROM vulnerabilities ` + where + `)`
if _, err := tx.Exec(clearQuery, args...); err != nil {
return 0, fmt.Errorf("清理事实漏洞关联失败: %w", err)
}
deleteQuery := `DELETE FROM vulnerabilities ` + where
result, err := tx.Exec(deleteQuery, args...)
if err != nil {
return 0, fmt.Errorf("批量删除漏洞失败: %w", err)
}
deleted, err := result.RowsAffected()
if err != nil {
return 0, fmt.Errorf("获取删除条数失败: %w", err)
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("提交事务失败: %w", err)
}
return deleted, nil
}
// DeleteVulnerability 删除漏洞
func (db *DB) DeleteVulnerability(id string) error {
tx, err := db.Begin()
+7
View File
@@ -830,6 +830,10 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
seenToolCallSigs := make(map[string]string) // toolCallId -> payload signature
seenToolResultSigs := make(map[string]string) // toolCallId -> payload signature
// progressMu 保护闭包内 map 与聚合状态。Eino parallelRunToolCall 会在多 goroutine 中并发回调
// progressToolInvokeNotifyHolder.Fire → createProgressCallback),未加锁的 map 会触发 fatal panic。
var progressMu sync.Mutex
// response_start + response_delta:前端时间线显示为「📝 规划中」(monitor.js),不落逐条 delta
// 聚合为一条 planning 写入 process_details,刷新后与线上一致。
var respPlan responsePlanAgg
@@ -891,6 +895,9 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
}
return func(eventType, message string, data interface{}) {
progressMu.Lock()
defer progressMu.Unlock()
// 上游在重试/补偿时可能重复回调相同 tool_call/tool_result。
// 这里做幂等过滤,保证前端展示和 process_details 都以唯一事件为准。
if (eventType == "tool_call" || eventType == "tool_result") && data != nil {
@@ -0,0 +1,48 @@
package handler
import (
"context"
"fmt"
"sync"
"testing"
"cyberstrike-ai/internal/config"
"go.uber.org/zap"
)
// TestCreateProgressCallback_ConcurrentToolEvents 回归 issue #142:并行 tool 回调不得 concurrent map panic。
func TestCreateProgressCallback_ConcurrentToolEvents(t *testing.T) {
logger := zap.NewNop()
h := &AgentHandler{
logger: logger,
config: &config.Config{},
}
cb := h.createProgressCallback(context.Background(), nil, "conv-race-test", "", nil)
const workers = 64
var wg sync.WaitGroup
wg.Add(workers * 2)
for i := 0; i < workers; i++ {
i := i
go func() {
defer wg.Done()
toolCallID := fmt.Sprintf("tc-%d", i)
cb("tool_call", "calling skill", map[string]interface{}{
"toolCallId": toolCallID,
"toolName": "skill",
"argumentsObj": map[string]interface{}{"skill_name": "demo-skill"},
})
}()
go func() {
defer wg.Done()
toolCallID := fmt.Sprintf("tc-%d", i)
cb("tool_result", "skill done", map[string]interface{}{
"toolCallId": toolCallID,
"toolName": "skill",
"success": true,
})
}()
}
wg.Wait()
}
-3
View File
@@ -1548,9 +1548,6 @@ func updateVisionConfig(doc *yaml.Node, cfg config.VisionConfig) {
if strings.TrimSpace(cfg.Detail) != "" {
setStringInMap(visionNode, "detail", cfg.Detail)
}
if len(cfg.AllowedRoots) > 0 {
setStringSliceInMap(visionNode, "allowed_roots", cfg.AllowedRoots)
}
}
func updateOpenAIConfig(doc *yaml.Node, cfg config.OpenAIConfig) {
+118
View File
@@ -327,6 +327,124 @@ func (h *MonitorHandler) GetStats(c *gin.Context) {
c.JSON(http.StatusOK, stats)
}
// CallsTimelinePoint 调用趋势数据点
type CallsTimelinePoint struct {
T time.Time `json:"t"`
Total int `json:"total"`
Failed int `json:"failed"`
}
// CallsTimelineSummary 调用趋势汇总
type CallsTimelineSummary struct {
TotalCalls int `json:"totalCalls"`
Peak int `json:"peak"`
}
// CallsTimelineResponse 调用趋势响应
type CallsTimelineResponse struct {
Range string `json:"range"`
Points []CallsTimelinePoint `json:"points"`
Summary CallsTimelineSummary `json:"summary"`
}
type callsTimelineConfig struct {
rangeKey string
duration time.Duration
bucketSize time.Duration
dailyBuckets bool
}
func parseCallsTimelineRange(raw string) (callsTimelineConfig, bool) {
switch strings.TrimSpace(raw) {
case "24h":
return callsTimelineConfig{rangeKey: "24h", duration: 24 * time.Hour, bucketSize: time.Hour, dailyBuckets: false}, true
case "30d":
return callsTimelineConfig{rangeKey: "30d", duration: 30 * 24 * time.Hour, bucketSize: 24 * time.Hour, dailyBuckets: true}, true
default:
return callsTimelineConfig{rangeKey: "7d", duration: 7 * 24 * time.Hour, bucketSize: time.Hour, dailyBuckets: false}, true
}
}
func truncateToBucket(t time.Time, bucketSize time.Duration, dailyBuckets bool) time.Time {
if dailyBuckets {
y, m, d := t.Date()
return time.Date(y, m, d, 0, 0, 0, 0, t.Location())
}
return t.Truncate(bucketSize)
}
func buildCallsTimelinePoints(cfg callsTimelineConfig, buckets map[time.Time]struct{ total, failed int }) []CallsTimelinePoint {
now := time.Now()
start := truncateToBucket(now.Add(-cfg.duration), cfg.bucketSize, cfg.dailyBuckets)
end := truncateToBucket(now, cfg.bucketSize, cfg.dailyBuckets)
points := make([]CallsTimelinePoint, 0)
for current := start; !current.After(end); current = current.Add(cfg.bucketSize) {
val := buckets[current]
points = append(points, CallsTimelinePoint{
T: current,
Total: val.total,
Failed: val.failed,
})
}
return points
}
func (h *MonitorHandler) loadCallsTimeline(cfg callsTimelineConfig) []CallsTimelinePoint {
since := time.Now().Add(-cfg.duration)
bucketMap := make(map[time.Time]struct{ total, failed int })
if h.db != nil {
dbBuckets, err := h.db.LoadCallsTimeline(since, cfg.dailyBuckets)
if err != nil {
h.logger.Warn("从数据库加载调用趋势失败,回退到内存数据", zap.Error(err))
} else {
for _, b := range dbBuckets {
key := truncateToBucket(b.BucketTime, cfg.bucketSize, cfg.dailyBuckets)
entry := bucketMap[key]
entry.total += b.Total
entry.failed += b.Failed
bucketMap[key] = entry
}
return buildCallsTimelinePoints(cfg, bucketMap)
}
}
for _, exec := range h.mcpServer.GetAllExecutions() {
if exec == nil || exec.StartTime.Before(since) {
continue
}
key := truncateToBucket(exec.StartTime, cfg.bucketSize, cfg.dailyBuckets)
entry := bucketMap[key]
entry.total++
if exec.Status == "failed" || exec.Status == "cancelled" {
entry.failed++
}
bucketMap[key] = entry
}
return buildCallsTimelinePoints(cfg, bucketMap)
}
// GetCallsTimeline 获取 MCP 工具调用趋势
func (h *MonitorHandler) GetCallsTimeline(c *gin.Context) {
cfg, _ := parseCallsTimelineRange(c.Query("range"))
points := h.loadCallsTimeline(cfg)
summary := CallsTimelineSummary{}
for _, p := range points {
summary.TotalCalls += p.Total
if p.Total > summary.Peak {
summary.Peak = p.Total
}
}
c.JSON(http.StatusOK, CallsTimelineResponse{
Range: cfg.rangeKey,
Points: points,
Summary: summary,
})
}
// DeleteExecution 删除执行记录
func (h *MonitorHandler) DeleteExecution(c *gin.Context) {
id := c.Param("id")
+2 -3
View File
@@ -809,8 +809,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"jpeg_quality": map[string]interface{}{"type": "integer", "description": "JPEG 质量 60-100"},
"max_payload_bytes": map[string]interface{}{"type": "integer", "description": "送 API 体积上限(字节)"},
"skip_preprocess_below_bytes": map[string]interface{}{"type": "integer", "description": "低于该字节且尺寸合规时可原图直传;0=始终压缩"},
"detail": map[string]interface{}{"type": "string", "enum": []string{"low", "high", "auto"}, "description": "OpenAI 兼容 image detail"},
"allowed_roots": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "description": "额外允许读取的绝对路径根"},
"detail": map[string]interface{}{"type": "string", "enum": []string{"low", "high", "auto"}, "description": "OpenAI 兼容 image detail"},
},
},
"AnalyzeImageToolCall": map[string]interface{}{
@@ -819,7 +818,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"properties": map[string]interface{}{
"path": map[string]interface{}{
"type": "string",
"description": "图片路径(cwd、chat_uploads、result_storage_dir 或 allowed_roots 下)",
"description": "图片绝对路径或相对于进程工作目录的路径",
},
"question": map[string]interface{}{
"type": "string",
+32
View File
@@ -311,6 +311,38 @@ func (h *VulnerabilityHandler) DeleteVulnerability(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
}
// BatchDeleteVulnerabilities 按当前筛选条件批量删除漏洞
func (h *VulnerabilityHandler) BatchDeleteVulnerabilities(c *gin.Context) {
filter := parseVulnerabilityListFilter(c)
total, err := h.db.CountVulnerabilities(filter)
if err != nil {
h.logger.Error("统计待删除漏洞失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if total == 0 {
c.JSON(http.StatusOK, gin.H{"message": "当前筛选条件下没有可删除的漏洞", "deleted": 0})
return
}
deleted, err := h.db.DeleteVulnerabilitiesByFilter(filter)
if err != nil {
h.logger.Error("批量删除漏洞失败", zap.Error(err), zap.Int("count", total))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if h.audit != nil {
h.audit.RecordOK(c, "vulnerability", "delete_batch", "批量删除漏洞记录", "vulnerability", "", map[string]interface{}{
"deleted": deleted,
"filter": filter,
})
}
c.JSON(http.StatusOK, gin.H{"message": "批量删除成功", "deleted": deleted})
}
// GetVulnerabilityStats 获取漏洞统计
func (h *VulnerabilityHandler) GetVulnerabilityStats(c *gin.Context) {
filter := parseVulnerabilityListFilter(c)
+1 -7
View File
@@ -160,13 +160,7 @@ func RunEinoSingleChatModelAgent(
handlers = append(handlers, capMw)
}
maxIter := ma.MaxIteration
if maxIter <= 0 {
maxIter = appCfg.Agent.MaxIterations
}
if maxIter <= 0 {
maxIter = 40
}
maxIter := agentMaxIterations(appCfg)
mainToolsCfg := adk.ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
+22
View File
@@ -0,0 +1,22 @@
package multiagent
import "cyberstrike-ai/internal/config"
const defaultAgentMaxIterations = 3000
// agentMaxIterations 全局上限:仅使用 config.agent.max_iterations;≤0 时与 config 默认一致为 3000。
func agentMaxIterations(appCfg *config.Config) int {
if appCfg != nil && appCfg.Agent.MaxIterations > 0 {
return appCfg.Agent.MaxIterations
}
return defaultAgentMaxIterations
}
// resolveMaxIterations 统一迭代上限:Markdown/子代理 front matter 中 max_iterations>0 可单独覆盖,否则使用 agent.max_iterations。
// multi_agent.max_iteration 与 sub_agent_max_iterations 已废弃,不再参与计算。
func resolveMaxIterations(appCfg *config.Config, markdownOverride int) int {
if markdownOverride > 0 {
return markdownOverride
}
return agentMaxIterations(appCfg)
}
@@ -0,0 +1,31 @@
package multiagent
import (
"testing"
"cyberstrike-ai/internal/config"
)
func TestAgentMaxIterations(t *testing.T) {
if got := agentMaxIterations(nil); got != defaultAgentMaxIterations {
t.Fatalf("nil cfg: got %d want %d", got, defaultAgentMaxIterations)
}
cfg := &config.Config{Agent: config.AgentConfig{MaxIterations: 12000}}
if got := agentMaxIterations(cfg); got != 12000 {
t.Fatalf("got %d want 12000", got)
}
cfg.Agent.MaxIterations = 0
if got := agentMaxIterations(cfg); got != defaultAgentMaxIterations {
t.Fatalf("zero: got %d want %d", got, defaultAgentMaxIterations)
}
}
func TestResolveMaxIterations(t *testing.T) {
cfg := &config.Config{Agent: config.AgentConfig{MaxIterations: 12000}}
if got := resolveMaxIterations(cfg, 0); got != 12000 {
t.Fatalf("global: got %d want 12000", got)
}
if got := resolveMaxIterations(cfg, 50); got != 50 {
t.Fatalf("override: got %d want 50", got)
}
}
+2 -16
View File
@@ -170,18 +170,7 @@ func RunDeepAgent(
}
reasoning.ApplyToEinoChatModelConfig(baseModelCfg, &appCfg.OpenAI, reasoningClient)
deepMaxIter := ma.MaxIteration
if deepMaxIter <= 0 {
deepMaxIter = appCfg.Agent.MaxIterations
}
if deepMaxIter <= 0 {
deepMaxIter = 40
}
subDefaultIter := ma.SubAgentMaxIterations
if subDefaultIter <= 0 {
subDefaultIter = 20
}
deepMaxIter := agentMaxIterations(appCfg)
var subAgents []adk.Agent
if orchMode != "plan_execute" {
@@ -230,10 +219,7 @@ func RunDeepAgent(
return nil, fmt.Errorf("子代理 %q eino 中间件: %w", id, err)
}
subMax := sub.MaxIterations
if subMax <= 0 {
subMax = subDefaultIter
}
subMax := resolveMaxIterations(appCfg, sub.MaxIterations)
subSumMw, err := newEinoSummarizationMiddleware(ctx, subModel, appCfg, &ma.EinoMiddleware, conversationID, logger)
if err != nil {
+9 -79
View File
@@ -7,35 +7,26 @@ import (
"strings"
)
const chatUploadsDirName = "chat_uploads"
var allowedImageExt = map[string]struct{}{
".png": {}, ".jpg": {}, ".jpeg": {}, ".webp": {}, ".gif": {},
".bmp": {}, ".tif": {}, ".tiff": {},
}
// PathOptions 图片路径白名单根目录
type PathOptions struct {
CWD string
ResultStorageDir string // 相对 CWD,如 tmp
ExtraRoots []string // vision.allowed_roots 绝对路径
}
// ResolveImagePath 解析并校验可读图片路径(防穿越、symlink 逃逸)。
func ResolveImagePath(path string, opt PathOptions) (string, error) {
// ResolveImagePath 解析并校验可读图片路径(支持任意目录;仍校验扩展名与常规文件)
func ResolveImagePath(path string, cwd string) (string, error) {
p := strings.TrimSpace(path)
if p == "" {
return "", fmt.Errorf("path is empty")
}
cwd := strings.TrimSpace(opt.CWD)
if cwd == "" {
cwdTrim := strings.TrimSpace(cwd)
if cwdTrim == "" {
var err error
cwd, err = os.Getwd()
cwdTrim, err = os.Getwd()
if err != nil {
return "", fmt.Errorf("getwd: %w", err)
}
}
cwdAbs, err := filepath.Abs(filepath.Clean(cwd))
cwdAbs, err := filepath.Abs(filepath.Clean(cwdTrim))
if err != nil {
return "", err
}
@@ -46,22 +37,16 @@ func ResolveImagePath(path string, opt PathOptions) (string, error) {
} else {
candidate = filepath.Clean(filepath.Join(cwdAbs, p))
}
candidate = normalizeAbsPath(candidate)
if candidate == "" {
resolved := normalizeAbsPath(candidate)
if resolved == "" {
return "", fmt.Errorf("invalid path")
}
ext := strings.ToLower(filepath.Ext(candidate))
ext := strings.ToLower(filepath.Ext(resolved))
if _, ok := allowedImageExt[ext]; !ok {
return "", fmt.Errorf("unsupported image extension %q", ext)
}
roots := buildAllowedRoots(cwdAbs, opt)
resolved, err := evalUnderAllowedRoots(candidate, roots)
if err != nil {
return "", err
}
st, err := os.Stat(resolved)
if err != nil {
return "", fmt.Errorf("stat: %w", err)
@@ -85,58 +70,3 @@ func normalizeAbsPath(p string) string {
}
return abs
}
func buildAllowedRoots(cwdAbs string, opt PathOptions) []string {
seen := make(map[string]struct{})
var roots []string
add := func(r string) {
r = strings.TrimSpace(r)
if r == "" {
return
}
abs := normalizeAbsPath(r)
if abs == "" {
return
}
if _, ok := seen[abs]; ok {
return
}
seen[abs] = struct{}{}
roots = append(roots, abs)
}
add(cwdAbs)
add(filepath.Join(cwdAbs, chatUploadsDirName))
rs := strings.TrimSpace(opt.ResultStorageDir)
if rs == "" {
rs = "tmp"
}
if filepath.IsAbs(rs) {
add(rs)
} else {
add(filepath.Join(cwdAbs, rs))
}
for _, r := range opt.ExtraRoots {
add(r)
}
return roots
}
func evalUnderAllowedRoots(candidate string, roots []string) (string, error) {
check := normalizeAbsPath(candidate)
for _, root := range roots {
if isUnderRoot(check, root) {
return candidate, nil
}
}
return "", fmt.Errorf("path %q is outside allowed directories", candidate)
}
func isUnderRoot(path, root string) bool {
path = filepath.Clean(path)
root = filepath.Clean(root)
if path == root {
return true
}
sep := string(filepath.Separator)
return strings.HasPrefix(path, root+sep)
}
+15 -6
View File
@@ -12,7 +12,7 @@ func TestResolveImagePath_underCWD(t *testing.T) {
if err := os.WriteFile(img, []byte{0x89, 0x50, 0x4e, 0x47}, 0o644); err != nil {
t.Fatal(err)
}
got, err := ResolveImagePath(img, PathOptions{CWD: dir, ResultStorageDir: "tmp"})
got, err := ResolveImagePath(img, dir)
if err != nil {
t.Fatal(err)
}
@@ -22,11 +22,20 @@ func TestResolveImagePath_underCWD(t *testing.T) {
}
}
func TestResolveImagePath_rejectsTraversal(t *testing.T) {
func TestResolveImagePath_absoluteOutsideCWD(t *testing.T) {
dir := t.TempDir()
_, err := ResolveImagePath("../../../etc/passwd", PathOptions{CWD: dir})
if err == nil {
t.Fatal("expected error for path outside roots")
cwd := t.TempDir()
img := filepath.Join(dir, "remote.png")
if err := os.WriteFile(img, []byte{0x89, 0x50, 0x4e, 0x47}, 0o644); err != nil {
t.Fatal(err)
}
got, err := ResolveImagePath(img, cwd)
if err != nil {
t.Fatalf("expected absolute path outside cwd to be allowed: %v", err)
}
want := normalizeAbsPath(img)
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}
@@ -36,7 +45,7 @@ func TestResolveImagePath_rejectsNonImageExt(t *testing.T) {
if err := os.WriteFile(f, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
_, err := ResolveImagePath(f, PathOptions{CWD: dir})
_, err := ResolveImagePath(f, dir)
if err == nil {
t.Fatal("expected error for non-image extension")
}
+1 -6
View File
@@ -33,11 +33,6 @@ func RegisterAnalyzeImageTool(mcpServer *mcp.Server, cfg *config.Config, logger
return
}
pathOpt := PathOptions{
CWD: cwd,
ResultStorageDir: cfg.Agent.ResultStorageDir,
ExtraRoots: cfg.Vision.AllowedRoots,
}
preOpt := PreprocessOptions{
MaxImageBytes: cfg.Vision.MaxImageBytesEffective(),
MaxDimension: cfg.Vision.MaxDimensionEffective(),
@@ -73,7 +68,7 @@ func RegisterAnalyzeImageTool(mcpServer *mcp.Server, cfg *config.Config, logger
path, _ := args["path"].(string)
question, _ := args["question"].(string)
abs, err := ResolveImagePath(path, pathOpt)
abs, err := ResolveImagePath(path, cwd)
if err != nil {
return textResult(fmt.Sprintf("路径校验失败: %v", err), true), nil
}
+1136 -714
View File
File diff suppressed because it is too large Load Diff
+37 -4
View File
@@ -1499,9 +1499,15 @@
"loading": "Loading...",
"noStatsData": "No statistical data",
"noExecutions": "No execution records",
"emptyHint": "Execution records will appear here after you invoke MCP tools in chat or tasks",
"noRecordsWithFilter": "No records with current filter",
"paginationInfo": "Show {{start}}-{{end}} of {{total}} records",
"perPageLabel": "Per page",
"firstPage": "First",
"prevPage": "Previous",
"nextPage": "Next",
"lastPage": "Last",
"pageInfo": "Page {{page}} of {{total}}",
"loadStatsError": "Failed to load statistics",
"loadExecutionsError": "Failed to load execution records",
"totalCalls": "Total calls",
@@ -1514,6 +1520,17 @@
"unknownTool": "Unknown tool",
"successFailedRate": "Success {{success}} / Failed {{failed}} · {{rate}}% success rate",
"topToolsTitle": "Top {{n}} tools by calls",
"toolRankingTitle": "Tool call ranking",
"toolStatsTitle": "Tool statistics",
"toolStatsHint": "Click a bar segment or row to filter records below; hover to highlight",
"scopeCumulative": "All time",
"scopeTimeline": "Trend period",
"filterActive": "Filtered: {{tool}}",
"kpiScopeNote": "Lifetime totals",
"columnCalls": "Calls",
"columnShare": "Share",
"columnSuccessRate": "Success rate",
"rankingSummary": "Top {{n}} {{pct}}% · {{total}} total calls",
"barVolumeLegend": "Bar length: relative call volume; green/red: success vs failure share",
"clickToFilterTool": "Click a row to filter records below",
"toolRowAriaLabel": "{{name}}, {{total}} calls, {{rate}}% success rate. Click to filter records.",
@@ -1526,9 +1543,21 @@
"rateWarning": "Some failures detected",
"rateCritical": "High failure rate",
"statsSubtitle": "Refreshed {{time}} · {{count}} tools",
"timelineTitle": "Call trend",
"timelineHint": "All tools combined (not split by tool)",
"timelineRange24h": "24h",
"timelineRange7d": "7d",
"timelineRange30d": "30d",
"timelineSummary": "{{total}} calls in range · peak {{peak}}",
"timelineSparseHint": "Most buckets are empty; peak {{peak}} calls at {{peakTime}}",
"timelineNoData": "No calls in this period",
"timelineLoadError": "Failed to load call trend",
"timelineTotalLegend": "Total calls",
"timelineFailedLegend": "Failed",
"timelineTooltip": "{{time}}: {{total}} calls ({{failed}} failed)",
"distTitle": "Call distribution",
"distLegend": "Slice area shows share of all calls",
"distClickHint": "Click legend or slice to filter records",
"distClickHint": "Click a bar segment to filter records",
"distHeaderHint": "{{n}} total calls",
"distSegmentAria": "{{name}}, {{pct}}% of calls, {{calls}} times",
"distOthersNoFilter": "Other tools cannot be filtered individually",
@@ -1758,6 +1787,12 @@
"loadListFailed": "Failed to load",
"noRecords": "No vulnerability records",
"batchExport": "Batch export",
"batchDelete": "Batch delete",
"batchDeleteNoResults": "No vulnerabilities match the current filters to delete",
"batchDeleteConfirm": "Delete {{count}} vulnerability record(s) matching the current filters? This cannot be undone.",
"batchDeleteConfirmAll": "No filters are set. This will delete all {{count}} vulnerability record(s). This cannot be undone. Continue?",
"batchDeleteSuccess": "Successfully deleted {{count}} vulnerability record(s)",
"batchDeleteFailed": "Batch delete failed",
"downloadMarkdownTitle": "Download Markdown",
"exportNoResults": "No vulnerabilities match the current filters",
"exportStarted": "Started downloading {{count}} file(s)",
@@ -1836,7 +1871,7 @@
"descPlaceholder": "When the orchestrator should delegate to this agent",
"fieldTools": "Tools (comma-separated; same keys as role tools)",
"fieldBindRole": "Bind role (optional)",
"fieldMaxIter": "Max sub-agent iterations (0 = use global default)",
"fieldMaxIter": "Max iterations (0 = use Settings → agent.max_iterations)",
"fieldInstruction": "System prompt (Markdown body)",
"instructionPlaceholder": "You are a specialist agent...",
"nameRequired": "Display name is required",
@@ -1974,8 +2009,6 @@
"visionSkipPreprocessHint": "0 = always JPEG compress; must also fit long-edge and payload limits.",
"visionDetail": "Image detail",
"visionTimeout": "Timeout (seconds)",
"visionAllowedRoots": "Extra allowed path roots",
"visionAllowedRootsPlaceholder": "One absolute path per line, optional",
"visionTestFillRequired": "Enter vision model and ensure API Key is available (or reuse OpenAI)",
"testConnection": "Test Connection",
"testFillRequired": "Please fill in API Key and Model first",
+37 -4
View File
@@ -1488,9 +1488,15 @@
"loading": "加载中...",
"noStatsData": "暂无统计数据",
"noExecutions": "暂无执行记录",
"emptyHint": "在对话或任务中调用 MCP 工具后,执行记录将显示在此处",
"noRecordsWithFilter": "当前筛选条件下暂无记录",
"paginationInfo": "显示 {{start}}-{{end}} / 共 {{total}} 条记录",
"perPageLabel": "每页显示",
"firstPage": "首页",
"prevPage": "上一页",
"nextPage": "下一页",
"lastPage": "末页",
"pageInfo": "第 {{page}} / {{total}} 页",
"loadStatsError": "无法加载统计信息",
"loadExecutionsError": "无法加载执行记录",
"totalCalls": "总调用次数",
@@ -1503,6 +1509,17 @@
"unknownTool": "未知工具",
"successFailedRate": "成功 {{success}} / 失败 {{failed}} · 成功率 {{rate}}%",
"topToolsTitle": "工具调用 Top {{n}}",
"toolRankingTitle": "工具调用排行",
"toolStatsTitle": "工具统计",
"toolStatsHint": "点击色条或列表行筛选下方执行记录;悬停联动高亮",
"scopeCumulative": "累计",
"scopeTimeline": "趋势时段",
"filterActive": "已筛选:{{tool}}",
"kpiScopeNote": "累计统计(全时段)",
"columnCalls": "调用",
"columnShare": "占比",
"columnSuccessRate": "成功率",
"rankingSummary": "Top {{n}} 占 {{pct}}% · 共 {{total}} 次调用",
"barVolumeLegend": "条长表示相对调用量,条内绿/红为成功/失败占比",
"clickToFilterTool": "点击行筛选下方执行记录",
"toolRowAriaLabel": "{{name}}{{total}} 次调用,成功率 {{rate}}%,点击查看执行记录",
@@ -1515,9 +1532,21 @@
"rateWarning": "存在失败调用",
"rateCritical": "失败率偏高",
"statsSubtitle": "最后刷新 {{time}} · 共 {{count}} 个工具",
"timelineTitle": "调用趋势",
"timelineHint": "全部工具合计,不按工具拆分",
"timelineRange24h": "24 小时",
"timelineRange7d": "7 天",
"timelineRange30d": "30 天",
"timelineSummary": "区间内 {{total}} 次 · 峰值 {{peak}}",
"timelineSparseHint": "该时段多数时间为 0,峰值 {{peak}} 次出现在 {{peakTime}}",
"timelineNoData": "该时段暂无调用",
"timelineLoadError": "无法加载调用趋势",
"timelineTotalLegend": "总调用",
"timelineFailedLegend": "失败",
"timelineTooltip": "{{time}}{{total}} 次(失败 {{failed}}",
"distTitle": "调用分布",
"distLegend": "扇区面积为占全部调用比例",
"distClickHint": "点击图例或扇区筛选执行记录",
"distClickHint": "点击色条筛选执行记录",
"distHeaderHint": "共 {{n}} 次调用",
"distSegmentAria": "{{name}},占 {{pct}}%{{calls}} 次",
"distOthersNoFilter": "其他工具无法单独筛选",
@@ -1747,6 +1776,12 @@
"loadListFailed": "加载失败",
"noRecords": "暂无漏洞记录",
"batchExport": "批量导出",
"batchDelete": "批量删除",
"batchDeleteNoResults": "当前筛选条件下没有可删除的漏洞",
"batchDeleteConfirm": "确定要删除当前筛选条件下的 {{count}} 条漏洞吗?此操作不可恢复。",
"batchDeleteConfirmAll": "未设置筛选条件,将删除全部 {{count}} 条漏洞。此操作不可恢复,确定继续?",
"batchDeleteSuccess": "成功删除 {{count}} 条漏洞",
"batchDeleteFailed": "批量删除失败",
"downloadMarkdownTitle": "下载 Markdown",
"exportNoResults": "当前筛选条件下无可导出漏洞",
"exportStarted": "已开始下载 {{count}} 份报告",
@@ -1825,7 +1860,7 @@
"descPlaceholder": "何时由协调者调度该子代理",
"fieldTools": "可用工具(逗号分隔,与角色工具 key 一致)",
"fieldBindRole": "绑定角色(可选)",
"fieldMaxIter": "子代理最大迭代(0=使用全局默认",
"fieldMaxIter": "最大迭代(0=沿用设置页 agent.max_iterations",
"fieldInstruction": "系统提示词(Markdown 正文)",
"instructionPlaceholder": "You are a specialist agent...",
"nameRequired": "请填写显示名称",
@@ -1963,8 +1998,6 @@
"visionSkipPreprocessHint": "0 表示始终 JPEG 压缩;需同时满足长边与 payload 限制。",
"visionDetail": "Image detail",
"visionTimeout": "超时(秒)",
"visionAllowedRoots": "额外允许路径根目录",
"visionAllowedRootsPlaceholder": "每行一个绝对路径,可选",
"visionTestFillRequired": "请填写视觉模型,并确保 API Key 可用(可复用 OpenAI",
"testConnection": "测试连接",
"testFillRequired": "请先填写 API Key 和模型",
+6 -41
View File
@@ -343,48 +343,13 @@ function escapeHtml(text) {
return div.innerHTML;
}
function formatMarkdown(text) {
const sanitizeConfig = {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'a', 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr'],
ALLOWED_ATTR: ['href', 'title', 'alt', 'src', 'class'],
ALLOW_DATA_ATTR: false,
};
const raw = text == null ? '' : String(text);
const src = typeof window.normalizeAssistantMarkdownSource === 'function'
? window.normalizeAssistantMarkdownSource(raw)
: raw;
if (typeof DOMPurify !== 'undefined') {
if (typeof marked !== 'undefined' && !/<[a-z][\s\S]*>/i.test(src)) {
try {
marked.setOptions({
breaks: true,
gfm: true,
});
const parsedContent = marked.parse(src, { async: false });
return DOMPurify.sanitize(parsedContent, sanitizeConfig);
} catch (e) {
console.error('Markdown 解析失败:', e);
return DOMPurify.sanitize(src, sanitizeConfig);
}
} else {
return DOMPurify.sanitize(src, sanitizeConfig);
}
} else if (typeof marked !== 'undefined') {
try {
marked.setOptions({
breaks: true,
gfm: true,
});
return marked.parse(src, { async: false });
} catch (e) {
console.error('Markdown 解析失败:', e);
return escapeHtml(src).replace(/\n/g, '<br>');
}
} else {
return escapeHtml(src).replace(/\n/g, '<br>');
/** @param {string} text @param {{ profile?: 'chat'|'timeline' }} [options] */
function formatMarkdown(text, options) {
if (typeof window.csMarkdownSanitize !== 'undefined') {
return window.csMarkdownSanitize.formatMarkdownToHtml(text, options);
}
const raw = text == null ? '' : String(text);
return escapeHtml(raw).replace(/\n/g, '<br>');
}
function setupLoginUI() {
+10 -116
View File
@@ -1862,25 +1862,9 @@ function refreshSystemReadyMessageBubbles() {
div.textContent = s;
return div.innerHTML;
};
const defaultSanitizeConfig = {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'a', 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr'],
ALLOWED_ATTR: ['href', 'title', 'alt', 'src', 'class'],
ALLOW_DATA_ATTR: false,
};
let formattedContent;
if (typeof marked !== 'undefined') {
try {
marked.setOptions({ breaks: true, gfm: true });
const src = typeof window.normalizeAssistantMarkdownSource === 'function'
? window.normalizeAssistantMarkdownSource(text)
: text;
const parsed = marked.parse(src, { async: false });
formattedContent = typeof DOMPurify !== 'undefined'
? DOMPurify.sanitize(parsed, defaultSanitizeConfig)
: parsed;
} catch (e) {
formattedContent = escapeHtmlLocal(text).replace(/\n/g, '<br>');
}
if (typeof window.csMarkdownSanitize !== 'undefined') {
formattedContent = window.csMarkdownSanitize.formatMarkdownToHtml(text, { profile: 'chat' });
} else {
formattedContent = escapeHtmlLocal(text).replace(/\n/g, '<br>');
}
@@ -1936,13 +1920,6 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
// 解析 Markdown 或 HTML 格式
let formattedContent;
const defaultSanitizeConfig = {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'a', 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr'],
ALLOWED_ATTR: ['href', 'title', 'alt', 'src', 'class'],
ALLOW_DATA_ATTR: false,
};
// HTML实体编码函数
const escapeHtml = (text) => {
if (!text) return '';
const div = document.createElement('div');
@@ -1950,31 +1927,6 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
return div.innerHTML;
};
// 注意:代码块内容不需要转义,因为:
// 1. Markdown解析后,代码块会被包裹在<code>或<pre>标签中
// 2. 浏览器不会执行<code>和<pre>标签内的HTML(它们是文本节点)
// 3. DOMPurify会保留这些标签内的文本内容
// 这样既能防止XSS,又能正常显示代码
const parseMarkdown = (raw) => {
if (typeof marked === 'undefined') {
return null;
}
try {
marked.setOptions({
breaks: true,
gfm: true,
});
const src = typeof window.normalizeAssistantMarkdownSource === 'function'
? window.normalizeAssistantMarkdownSource(raw)
: raw;
return marked.parse(src, { async: false });
} catch (e) {
console.error('Markdown 解析失败:', e);
return null;
}
};
// 助手消息中的已知中文错误前缀做国际化替换(后端固定返回中文)
let displayContent = content;
if (role === 'assistant' && typeof displayContent === 'string' && typeof window.t === 'function') {
@@ -1989,57 +1941,11 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
// 对于用户消息,直接转义HTML,不进行Markdown解析,以保留所有特殊字符
if (role === 'user') {
formattedContent = escapeHtml(content).replace(/\n/g, '<br>');
} else if (typeof DOMPurify !== 'undefined') {
// 直接解析Markdown(代码块会被包裹在<code>/<pre>中,DOMPurify会保留其文本内容)
let parsedContent = parseMarkdown(role === 'assistant' ? displayContent : content);
if (!parsedContent) {
parsedContent = content;
}
// 使用DOMPurify清理,只添加必要的URL验证钩子(DOMPurify默认会处理事件处理器等)
if (DOMPurify.addHook) {
// 移除之前可能存在的钩子
try {
DOMPurify.removeHook('uponSanitizeAttribute');
} catch (e) {
// 钩子不存在,忽略
}
// 只验证URL属性,防止危险协议(DOMPurify默认会处理事件处理器、style等)
DOMPurify.addHook('uponSanitizeAttribute', (node, data) => {
const attrName = data.attrName.toLowerCase();
// 只验证URL属性(src, href
if ((attrName === 'src' || attrName === 'href') && data.attrValue) {
const value = data.attrValue.trim().toLowerCase();
// 禁止危险协议
if (value.startsWith('javascript:') ||
value.startsWith('vbscript:') ||
value.startsWith('data:text/html') ||
value.startsWith('data:text/javascript')) {
data.keepAttr = false;
return;
}
// 对于img的src,禁止可疑的短URL(防止404和XSS)
if (attrName === 'src' && node.tagName && node.tagName.toLowerCase() === 'img') {
if (value.length <= 2 || /^[a-z]$/i.test(value)) {
data.keepAttr = false;
return;
}
}
}
});
}
formattedContent = DOMPurify.sanitize(parsedContent, defaultSanitizeConfig);
} else if (typeof marked !== 'undefined') {
const rawForParse = role === 'assistant' ? displayContent : content;
const parsedContent = parseMarkdown(rawForParse);
if (parsedContent) {
formattedContent = parsedContent;
} else {
formattedContent = escapeHtml(rawForParse).replace(/\n/g, '<br>');
}
} else if (typeof window.csMarkdownSanitize !== 'undefined') {
formattedContent = window.csMarkdownSanitize.formatMarkdownToHtml(
role === 'assistant' ? displayContent : content,
{ profile: 'chat' }
);
} else {
const rawForEscape = role === 'assistant' ? displayContent : content;
formattedContent = escapeHtml(rawForEscape).replace(/\n/g, '<br>');
@@ -2047,21 +1953,9 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
bubble.innerHTML = formattedContent;
// 最后的安全检查:只处理明显的可疑图片(防止404和XSS)
// DOMPurify已经处理了大部分XSS向量,这里只做必要的补充
const images = bubble.querySelectorAll('img');
images.forEach(img => {
const src = img.getAttribute('src');
if (src) {
const trimmedSrc = src.trim();
// 只检查明显的可疑URL(短字符串、单个字符)
if (trimmedSrc.length <= 2 || /^[a-z]$/i.test(trimmedSrc)) {
img.remove();
}
} else {
img.remove();
}
});
if (typeof window.csMarkdownSanitize !== 'undefined') {
window.csMarkdownSanitize.stripSuspiciousImages(bubble);
}
// 为每个表格添加独立的滚动容器
wrapTablesInBubble(bubble);
+876 -249
View File
File diff suppressed because it is too large Load Diff
+181
View File
@@ -0,0 +1,181 @@
/**
* 统一的 Markdown 安全 HTML 渲染DOMPurify + marked
* 时间线/过程详情使用 stricter profile整页 HTML 回退为转义 <pre>
*/
(function (global) {
'use strict';
const CHAT_SANITIZE_CONFIG = {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre', 'blockquote',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'a', 'img',
'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr'],
ALLOWED_ATTR: ['href', 'title', 'alt', 'src', 'class'],
ALLOW_DATA_ATTR: false,
};
/** 过程详情时间线:禁止 img,减少外连与恶意资源 */
const TIMELINE_SANITIZE_CONFIG = {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre', 'blockquote',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'a',
'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr'],
ALLOWED_ATTR: ['href', 'title', 'alt', 'class'],
ALLOW_DATA_ATTR: false,
};
const DANGEROUS_URL_PREFIXES = [
'javascript:',
'vbscript:',
'data:text/html',
'data:text/javascript',
'data:application/javascript',
];
let domPurifyHooksInstalled = false;
function escapeHtmlLocal(text) {
if (text == null || text === '') return '';
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML;
}
function installDomPurifyHooks() {
if (domPurifyHooksInstalled || typeof DOMPurify === 'undefined' || !DOMPurify.addHook) {
return;
}
DOMPurify.addHook('uponSanitizeAttribute', function (node, data) {
const attrName = (data.attrName || '').toLowerCase();
if ((attrName !== 'src' && attrName !== 'href') || !data.attrValue) {
return;
}
const value = String(data.attrValue).trim().toLowerCase();
for (let i = 0; i < DANGEROUS_URL_PREFIXES.length; i++) {
if (value.indexOf(DANGEROUS_URL_PREFIXES[i]) === 0) {
data.keepAttr = false;
return;
}
}
if (value.indexOf('blob:') === 0) {
data.keepAttr = false;
return;
}
if (attrName === 'src' && node.tagName && node.tagName.toLowerCase() === 'img') {
if (value.length <= 2 || /^[a-z]$/i.test(value)) {
data.keepAttr = false;
}
}
});
domPurifyHooksInstalled = true;
}
/** 探测工具返回的整页 HTML,不宜当作富文本渲染 */
function isHeavyRawHtml(src) {
const s = String(src);
if (/<!DOCTYPE\s+html/i.test(s) || /<\s*html\b/i.test(s)) {
return true;
}
if (/<\s*(head|body|iframe|object|embed|form|script|style|meta|link|base)\b/i.test(s)) {
return true;
}
const tags = s.match(/<[a-z][^>]*>/gi);
return tags != null && tags.length >= 8;
}
function formatHtmlAsEscapedPre(text) {
return '<pre class="tool-result sanitized-raw-html-fallback">' + escapeHtmlLocal(text) + '</pre>';
}
function normalizeSource(text) {
const raw = text == null ? '' : String(text);
if (typeof global.normalizeAssistantMarkdownSource === 'function') {
return global.normalizeAssistantMarkdownSource(raw);
}
return raw;
}
function parseMarkdownSrc(src) {
if (typeof marked === 'undefined') {
return null;
}
try {
marked.setOptions({ breaks: true, gfm: true });
return marked.parse(src, { async: false });
} catch (e) {
console.error('Markdown 解析失败:', e);
return null;
}
}
function sanitizeConfigForProfile(profile) {
return profile === 'timeline' ? TIMELINE_SANITIZE_CONFIG : CHAT_SANITIZE_CONFIG;
}
/**
* @param {string|null|undefined} text
* @param {{ profile?: 'chat'|'timeline' }} [options]
* @returns {string} 安全 HTML
*/
function formatMarkdownToHtml(text, options) {
const profile = (options && options.profile === 'timeline') ? 'timeline' : 'chat';
const src = normalizeSource(text);
if (isHeavyRawHtml(src)) {
return formatHtmlAsEscapedPre(src);
}
if (typeof DOMPurify === 'undefined') {
return escapeHtmlLocal(src).replace(/\n/g, '<br>');
}
installDomPurifyHooks();
const config = sanitizeConfigForProfile(profile);
let html;
const hasHtmlTags = /<[a-z][\s\S]*>/i.test(src);
if (typeof marked !== 'undefined' && !hasHtmlTags) {
const parsed = parseMarkdownSrc(src);
html = parsed != null ? parsed : escapeHtmlLocal(src).replace(/\n/g, '<br>');
} else if (hasHtmlTags) {
html = src;
} else {
html = escapeHtmlLocal(src).replace(/\n/g, '<br>');
}
return DOMPurify.sanitize(html, config);
}
function sanitizeRichHtml(html, profile) {
if (typeof DOMPurify === 'undefined') {
return null;
}
installDomPurifyHooks();
return DOMPurify.sanitize(html, sanitizeConfigForProfile(profile || 'chat'));
}
function stripSuspiciousImages(root) {
if (!root || !root.querySelectorAll) {
return;
}
root.querySelectorAll('img').forEach(function (img) {
const src = (img.getAttribute('src') || '').trim();
if (!src || src.length <= 2 || /^[a-z]$/i.test(src)) {
img.remove();
}
});
}
global.csMarkdownSanitize = {
CHAT_SANITIZE_CONFIG: CHAT_SANITIZE_CONFIG,
TIMELINE_SANITIZE_CONFIG: TIMELINE_SANITIZE_CONFIG,
installDomPurifyHooks: installDomPurifyHooks,
formatMarkdownToHtml: formatMarkdownToHtml,
sanitizeRichHtml: sanitizeRichHtml,
isHeavyRawHtml: isHeavyRawHtml,
escapeHtmlLocal: escapeHtmlLocal,
stripSuspiciousImages: stripSuspiciousImages,
};
global.formatMarkdown = function formatMarkdown(text, options) {
return formatMarkdownToHtml(text, options);
};
})(typeof window !== 'undefined' ? window : globalThis);
+1 -9
View File
@@ -1375,11 +1375,6 @@ function fillVisionConfigFromCurrent(v) {
const d = (v.detail || 'low').toString().toLowerCase();
det.value = ['low', 'auto', 'high'].includes(d) ? d : 'low';
}
const rootsEl = document.getElementById('vision-allowed-roots');
if (rootsEl) {
const roots = Array.isArray(v.allowed_roots) ? v.allowed_roots : [];
rootsEl.value = roots.join('\n');
}
syncVisionFormEnabled();
}
@@ -1388,8 +1383,6 @@ function collectVisionConfigFromForm() {
const n = parseInt(document.getElementById(id)?.value, 10);
return Number.isNaN(n) ? fallback : n;
};
const rootsRaw = document.getElementById('vision-allowed-roots')?.value || '';
const allowed_roots = rootsRaw.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
const provider = document.getElementById('vision-provider')?.value.trim() || '';
return {
enabled: document.getElementById('vision-enabled')?.checked === true,
@@ -1403,8 +1396,7 @@ function collectVisionConfigFromForm() {
jpeg_quality: parseIntOr('vision-jpeg-quality', 82),
max_payload_bytes: parseIntOr('vision-max-payload-bytes', 524288),
skip_preprocess_below_bytes: parseIntOr('vision-skip-preprocess-bytes', 2097152),
detail: document.getElementById('vision-detail')?.value || 'low',
allowed_roots: allowed_roots
detail: document.getElementById('vision-detail')?.value || 'low'
};
}
+55 -1
View File
@@ -720,7 +720,7 @@ async function loadVulnerabilityStats() {
throw new Error('apiFetch未定义');
}
const params = buildVulnerabilityFilterParams();
const params = buildVulnerabilityDashboardStatsParams();
const response = await apiFetch(`/api/vulnerabilities/stats?${params.toString()}`);
if (!response.ok) {
@@ -1531,6 +1531,13 @@ function buildVulnerabilityFilterParams() {
return params;
}
/** 看板统计:保留项目/关键词等筛选,但不带严重度(卡片本身用于切换严重度筛选) */
function buildVulnerabilityDashboardStatsParams() {
const params = buildVulnerabilityFilterParams();
params.delete('severity');
return params;
}
function triggerTextDownload(fileName, content) {
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
@@ -1543,6 +1550,53 @@ function triggerTextDownload(fileName, content) {
URL.revokeObjectURL(url);
}
function hasActiveVulnerabilityFilters() {
const keys = ['q', 'id', 'project_id', 'conversation_id', 'task_id', 'conversation_tag', 'task_tag', 'severity', 'status'];
return keys.some(function (k) {
return Boolean(vulnerabilityFilters[k]);
});
}
async function batchDeleteVulnerabilityReports() {
try {
const params = buildVulnerabilityFilterParams();
const statsResponse = await apiFetch(`/api/vulnerabilities/stats?${params.toString()}`);
if (!statsResponse.ok) {
throw new Error(vulnT('vulnerabilityPage.deleteFailed'));
}
const stats = await statsResponse.json();
const count = stats.total || 0;
if (count <= 0) {
alert(vulnT('vulnerabilityPage.batchDeleteNoResults'));
return;
}
const confirmKey = hasActiveVulnerabilityFilters()
? 'vulnerabilityPage.batchDeleteConfirm'
: 'vulnerabilityPage.batchDeleteConfirmAll';
if (!confirm(vulnT(confirmKey, { count: count }))) {
return;
}
const response = await apiFetch(`/api/vulnerabilities/batch?${params.toString()}`, {
method: 'DELETE'
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: vulnT('vulnerabilityPage.deleteFailed') }));
throw new Error(error.error || vulnT('vulnerabilityPage.deleteFailed'));
}
const data = await response.json();
const deleted = data.deleted || 0;
alert(vulnT('vulnerabilityPage.batchDeleteSuccess', { count: deleted }));
vulnerabilityPagination.currentPage = 1;
loadVulnerabilityStats();
loadVulnerabilities();
} catch (error) {
console.error('批量删除漏洞失败:', error);
alert(vulnT('vulnerabilityPage.batchDeleteFailed') + ': ' + error.message);
}
}
async function exportVulnerabilityReports() {
try {
const params = buildVulnerabilityFilterParams();
+15 -15
View File
@@ -1114,20 +1114,22 @@
</div>
<!-- MCP状态监控页面 -->
<div id="page-mcp-monitor" class="page">
<div id="page-mcp-monitor" class="page mcp-monitor-page">
<div class="page-header">
<h2 data-i18n="mcp.monitorTitle">MCP 状态监控</h2>
<button class="btn-secondary" onclick="refreshMonitorPanel()"><span data-i18n="common.refresh">刷新</span></button>
<div class="page-header-main">
<h2 data-i18n="mcp.monitorTitle">MCP 状态监控</h2>
<p id="monitor-stats-subtitle" class="monitor-page-subtitle" hidden></p>
</div>
<div class="page-header-actions">
<button type="button" class="btn-secondary btn-icon-text" onclick="refreshMonitorPanel()" aria-label="刷新">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
<span data-i18n="common.refresh">刷新</span>
</button>
</div>
</div>
<div class="page-content">
<div class="monitor-sections">
<section class="monitor-section monitor-overview">
<div class="section-header monitor-stats-section-header">
<div class="monitor-stats-header-text">
<h3 data-i18n="mcp.execStats">执行统计</h3>
<p id="monitor-stats-subtitle" class="monitor-stats-subtitle" hidden></p>
</div>
</div>
<div id="monitor-stats" class="mcp-exec-stats-root">
<div class="monitor-empty" data-i18n="mcpMonitor.loading">加载中...</div>
</div>
@@ -1712,6 +1714,7 @@
<h2 data-i18n="vulnerability.title">漏洞管理</h2>
<div class="page-header-actions">
<button class="btn-secondary" onclick="exportVulnerabilityReports()" data-i18n="vulnerabilityPage.batchExport">批量导出</button>
<button class="btn-secondary btn-delete" onclick="batchDeleteVulnerabilityReports()" data-i18n="vulnerabilityPage.batchDelete">批量删除</button>
<button class="btn-secondary" onclick="refreshVulnerabilities()" data-i18n="common.refresh">刷新</button>
<button class="btn-primary" onclick="showAddVulnerabilityModal()" data-i18n="vulnerability.addVuln">添加漏洞</button>
</div>
@@ -2348,7 +2351,7 @@
<input type="text" id="agent-md-bind-role" placeholder="" autocomplete="off" />
</div>
<div class="form-group">
<label data-i18n="agentsPage.fieldMaxIter">子代理最大迭代(0=使用全局默认</label>
<label data-i18n="agentsPage.fieldMaxIter">最大迭代(0=沿用设置页 agent.max_iterations</label>
<input type="number" id="agent-md-max-iter" min="0" value="0" />
</div>
<div class="form-group">
@@ -2544,10 +2547,6 @@
<label for="vision-timeout-seconds" data-i18n="settingsBasic.visionTimeout">超时(秒)</label>
<input type="number" id="vision-timeout-seconds" min="5" step="1" placeholder="60" />
</div>
<div class="form-group">
<label for="vision-allowed-roots" data-i18n="settingsBasic.visionAllowedRoots">额外允许路径根目录</label>
<textarea id="vision-allowed-roots" rows="2" data-i18n="settingsBasic.visionAllowedRootsPlaceholder" data-i18n-attr="placeholder" placeholder="每行一个绝对路径,可选"></textarea>
</div>
</div>
</details>
<div style="display: flex; align-items: center; gap: 8px; margin-top: 8px;">
@@ -2564,7 +2563,7 @@
<div class="settings-form">
<div class="form-group">
<label for="agent-max-iterations" data-i18n="settingsBasic.maxIterations">最大迭代次数</label>
<input type="number" id="agent-max-iterations" min="1" max="100" data-i18n="settingsBasic.iterationsPlaceholder" data-i18n-attr="placeholder" placeholder="30" />
<input type="number" id="agent-max-iterations" min="1" data-i18n="settingsBasic.iterationsPlaceholder" data-i18n-attr="placeholder" placeholder="30" />
</div>
<div class="form-group">
<label class="checkbox-label">
@@ -3512,6 +3511,7 @@
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
<!-- DOMPurify for HTML sanitization to prevent XSS -->
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.8/dist/purify.min.js"></script>
<script src="/static/js/sanitize-markdown.js"></script>
<!-- Cytoscape.js for attack chain visualization -->
<script src="https://cdn.jsdelivr.net/npm/cytoscape@3.27.0/dist/cytoscape.min.js"></script>
<!-- ELK.js for high-quality DAG layout (reduces edge crossings) -->