diff --git a/agent/promptcatalog.go b/agent/promptcatalog.go index 67f1fa7..cc11b89 100644 --- a/agent/promptcatalog.go +++ b/agent/promptcatalog.go @@ -24,6 +24,16 @@ const autoDefaultTmpl = `你是 **Auto**,这个渗透测试平台的「操作 - 用人话简洁汇报你做了什么、结果如何;只根据工具真实返回作答,不臆造。 - 只在授权范围内操作。` +// assetMapperDefaultTmpl is the read-only assistant behind the topology canvas. +const assetMapperDefaultTmpl = `你是授权渗透测试平台的「资产梳理」Agent。你的职责是根据资产库中的真实数据,整理公网、边界服务和内网业务资产之间的关系,并解释用户指定的入站路径。 + +工作规则: +- 先使用 list_assets 和 list_companies 获取事实,再给出结论;没有证据的关系标记为“待验证”,不要臆造。 +- 识别域名、子域名、IP、服务、应用和端点之间的关联,说明数据来源和最近更新时间。 +- 用户询问“从哪里进入内网”时,按“公网入口 → 边界服务 → 目标资产”的顺序回答;当前资产库没有网络流量或防火墙证据时,明确说明这是资产关系推导,不把它描述成已验证的网络通路。 +- 用简洁中文输出,优先给出资产清单、关系链、可信度和下一步验证建议。 +- 只读分析,不主动修改资产库,不执行探测。` + // pentestDefaultTmpl is the built-in "渗透测试" (solo pentest) agent's prompt. Unlike // the orchestration roles (goals/planner/worker), it runs standalone via the chat page // and is its own planner + executor + auditor. Default tools: list_assets / insert_assets @@ -90,11 +100,20 @@ const ReporterDefaultPrompt = `你是一个授权渗透测试系统里的**漏 4. **写报告**:综合以上,写一份结构化 Markdown 报告(见下方模板)。 5. **保存**:调用 **update_finding_report(finding_id=, report=, evidence_version=<实际读取的 version>)** 保存;未读取版本时省略 evidence_version,不得猜测。这是你的最终产物——不写进去等于没做。 +━━ 报告步骤截图(仅在系统设置开启时)━━ +- 当系统设置开启报告步骤截图时,你会自动获得 browser MCP;此时对每个复现步骤中真实出现且仍可访问的 HTTP 页面,使用 browser_navigate 后调用 browser_take_screenshot。 +- 只访问漏洞证据中出现的 URL,不扩大测试范围。把截图保存到当前工作目录的 findings/screenshots/ 下,命名 finding--step-.png;确认文件存在后,在对应步骤中加入 Markdown 图片链接:![步骤 截图](workspace://<截图绝对路径>)。 +- 截图失败时如实说明;系统设置关闭或 browser MCP 不可用时,不要伪造图片链接,也不要把文字描述冒充截图。 + ━━ 报告结构(Markdown,按需裁剪,但证据/复现/修复必须有)━━ - ` + "`## 概述`" + `:一句话说清是什么漏洞、在哪、能造成什么。 - ` + "`## 影响与危害`" + `:结合业务讲清最坏后果(数据泄露/接管/RCE/横向…),给出**严重等级**判断及理由。 - ` + "`## 受影响范围`" + `:受影响的资产/接口/参数/版本。 - ` + "`## 复现步骤`" + `:**可照做复现**的分步操作(请求/命令/参数),能贴 PoC 就贴。 +- 复现步骤必须使用明确的编号标题:` + "`### 步骤 1:准备环境`" + `、` + "`### 步骤 2:构造请求`" + `……;每一步都要写清操作、输入、预期结果、实际结果和证据来源。不要只写“步骤一”或把多步合并成一段。 +- 每个步骤只描述本次漏洞真实执行过的动作;有真实 HTTP 流量时,在对应步骤的代码块中保留完整请求或响应,并说明它是请求、响应还是命令输出。没有证据的内容标记为“待补充/未验证”。 +- 数据包只能来自 get_finding_traffic 返回的、已经绑定到本漏洞的真实流量;报告中自行写出的代码块不能冒充绑定数据包。没有绑定流量时明确写“暂无已绑定流量证据”,不要编造请求/响应。 +- 最新报告将概述、影响、复现步骤、最终结果、根因分析和修复建议合并在同一份报告中;不要再额外生成一份脱离步骤的“详细报告”。 - ` + "`## 证据`" + `:证明漏洞真实存在的关键请求/响应片段、命令输出、回显、截图说明——用代码块贴原文。 - ` + "`## PoC`" + `:可直接运行/复用的利用代码或 payload(利用脚本、请求报文、命令行、payload 串),**通常以代码块给出完整代码**,并简述如何运行;无独立利用代码时说明"复现步骤即为 PoC"。 - ` + "`## 根因分析`" + `:为什么会有这个漏洞(缺校验/危险函数/配置错误…)。 @@ -112,11 +131,12 @@ const ReporterDefaultPrompt = `你是一个授权渗透测试系统里的**漏 // source while the same string stays as the in-code render fallback. func BuiltinPromptSeeds() map[string]string { return map[string]string{ - "goals": goalsDefaultTmpl, - "planner": plannerDefaultTmpl, - "mainagent": mainAgentDefaultTmpl, - "worker": workerDefaultTmpl, - "auto": autoDefaultTmpl, - "pentest": pentestDefaultTmpl, + "goals": goalsDefaultTmpl, + "planner": plannerDefaultTmpl, + "mainagent": mainAgentDefaultTmpl, + "worker": workerDefaultTmpl, + "auto": autoDefaultTmpl, + "asset_mapper": assetMapperDefaultTmpl, + "pentest": pentestDefaultTmpl, } } diff --git a/agent/toolcatalog.go b/agent/toolcatalog.go index 77b7cb2..32b732e 100644 --- a/agent/toolcatalog.go +++ b/agent/toolcatalog.go @@ -43,6 +43,8 @@ func builtinToolsByAgent() map[string][]actool.CoreTool { // auto 默认绑漏洞上报 + 资产管理工具,其他域工具可在 UI 按需勾选。 // 新库由此 seed 写入;老库由 seedAutoDefaultBindings 迁移。 "auto": {ts.addFinding(), ts.insertAssets(), ts.addCompanyScope(), ts.listAssets(), ts.listCompanies()}, + // asset_mapper 只读资产库,供资产梳理和拓扑解释使用。 + "asset_mapper": {ts.listAssets(), ts.listCompanies()}, // pentest(独立渗透 agent)默认绑:查资产 / 插资产 / 报漏洞 / 查漏洞 / 查企业。 // 新库由此 seed 写入;老库由 seedPentestDefaultBindings 迁移。 "pentest": {ts.listAssets(), ts.insertAssets(), ts.addFinding(), ts.listFindings(), ts.listCompanies()}, @@ -62,7 +64,7 @@ var defaultUnbound = map[string]bool{"goal_met": true} // 多个 agent 都有)合成一条,Agents 取并集;defaultUnbound 里的工具则强制绑定为空。 func BuiltinToolSeeds() []ToolSeed { byAgent := builtinToolsByAgent() - order := []string{"mainagent", "goals", "planner", "worker", "auto", "pentest"} + order := []string{"mainagent", "goals", "planner", "worker", "auto", "asset_mapper", "pentest"} type acc struct { tool actool.CoreTool diff --git a/db/db.go b/db/db.go index 90f9fcb..e468045 100644 --- a/db/db.go +++ b/db/db.go @@ -209,6 +209,8 @@ var builtinAgents = []builtinAgent{ }, false, nil}, // Auto:内置「平台操作」agent。不参与渗透编排循环,经对话页驱动,用工具操作平台。 {"auto", "Auto", "assistant", "平台操作助手:用工具管理任务(建/看/暂停/给提示)与资产,并可创建/修改 skill、自定义工具、MCP。", nil, false, nil}, + // Asset mapper: read-only assistant used by the topology canvas and asset views. + {"asset_mapper", "资产梳理", "assistant", "资产梳理助手:汇总公网、边界与内网资产关系,基于真实资产数据解释入站路径,不凭空推断连接。", nil, false, nil}, // 渗透测试:内置「独立渗透」agent。经对话页驱动,一人从侦察到收尾走完整条渗透链,自己规划自己执行自己验证。默认开启交互式 shell。 {"pentest", "渗透测试", "assistant", "独立渗透 agent:一人从侦察→找攻击面→深入利用→验证→收尾走完整条链,自己规划、自己执行、自己对抗式验证。", nil, true, intp(0)}, } diff --git a/db/task_scope.go b/db/task_scope.go index e93f79d..9fb7aa8 100644 --- a/db/task_scope.go +++ b/db/task_scope.go @@ -570,7 +570,12 @@ WHERE type='root_domain' AND domain=$1 LIMIT 1`, root).Scan(&id, &companyID) link(n.Key, companyKey(n.CompanyID)) } case "service": - link(n.Key, firstOf(subByDomain[n.Domain], ipByAddr[n.IP], rootByDomain[n.RootDomain])) + host, _ := hostPortOf(n) + link(n.Key, firstOf( + subByDomain[n.Domain], subByDomain[host], + ipByAddr[n.IP], ipByAddr[host], + rootByDomain[n.RootDomain], + )) case "endpoint": host, port := hostPortOf(n) parent := firstOf( diff --git a/server/assembly.go b/server/assembly.go index 4ff8efe..32bf3d5 100644 --- a/server/assembly.go +++ b/server/assembly.go @@ -96,6 +96,13 @@ func wireAgentAugment(pg *db.DB, skillDir string, hostTools func() ([]actool.Cor continue } for _, t := range ts { + // Playwright exposes the screenshot capability as a separate tool. Keep + // it opt-in so a normal browser MCP session does not unexpectedly send + // image payloads to the model or consume extra tokens. + if m.Name == browserMCPName && isBrowserScreenshotTool(t.Name()) && + (agentKey != "reporter" || !pg.GetBool(settingBrowserScreenshot, false)) { + continue + } extra = append(extra, t) allNames = append(allNames, t.Name()) serverTools[m.Name] = append(serverTools[m.Name], t.Name()) @@ -180,6 +187,11 @@ func wireAgentAugment(pg *db.DB, skillDir string, hostTools func() ([]actool.Cor } } +func isBrowserScreenshotTool(name string) bool { + name = strings.ToLower(strings.TrimSpace(name)) + return strings.Contains(name, "screenshot") || strings.Contains(name, "take_screenshot") +} + func idSet(ids []int64) map[int64]bool { m := make(map[int64]bool, len(ids)) for _, id := range ids { diff --git a/server/manager.go b/server/manager.go index bd7b1f3..894f7ac 100644 --- a/server/manager.go +++ b/server/manager.go @@ -264,6 +264,7 @@ type Manager struct { // Settings keys the UI toggles at runtime. const ( settingTrafficCapture = "traffic_capture" + settingBrowserScreenshot = "browser_screenshot_enabled" settingAgentTrafficBinding = "agent_traffic_binding" settingWebSearchOn = "web_search_enabled" settingWebSearchBackend = "web_search_backend" @@ -273,11 +274,11 @@ const ( // settingGlobalProxy is the global egress proxy for all target traffic // (http/https/socks5). Empty = direct. Distinct from web_search_proxy (which // only routes the search backend) and the per-profile LLM proxy. - settingGlobalProxy = "global_proxy" + settingGlobalProxy = "global_proxy" settingShowEgressIP = "show_egress_ip" - settingWorkers = "workers" - settingWorkerPools = "worker_pools" - settingLLMRecord = "llm_record" + settingWorkers = "workers" + settingWorkerPools = "worker_pools" + settingLLMRecord = "llm_record" // LLM 轮询(故障转移)。默认关闭——开启后走「全局激活配置」的 agent 在当前配置 // 不可用(余额不足/key 失效/限流/服务异常)时自动切到下一个配置。 // settingLLMPoolBindFallback 仅在轮询开启时有意义:默认关闭,即 agent/任务显式 diff --git a/server/server.go b/server/server.go index 6ee0cf5..da2296b 100644 --- a/server/server.go +++ b/server/server.go @@ -199,6 +199,12 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi if err != nil || t == "" { return "", false } + if key == "reporter" && m.pg.GetBool(settingBrowserScreenshot, false) { + t += ` + +【报告步骤截图已开启】 + 报告撰写时,针对每个复现步骤中真实出现且仍可访问的 HTTP 页面,使用 browser MCP 的 browser_navigate 和 browser_take_screenshot 截取页面证据。只导航到漏洞证据中出现的目标 URL,不要自行扩大测试范围。将截图保存到当前工作目录下的 findings/screenshots/ 目录,文件名使用 finding--step-.png;确认文件存在后,在对应步骤中加入 Markdown 图片:![步骤 截图](workspace://<截图绝对路径>)。截图失败时如实写明,不要用占位图或把截图描述冒充图片。` + } return t, true } // Wire DB-stored wrap-up (settlement) prompts. Empty column → built-in default. @@ -760,6 +766,13 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /api/task-archives/delete/batch", s.deleteTaskArchivesBatch) mux.HandleFunc("GET /api/tasks/{id}/coverage", s.taskCoverage) mux.HandleFunc("GET /api/tasks/{id}/coverage-graph", s.taskCoverageGraph) + // Asset relationships are read directly from the asset store. This action + // only runs the topology explanation and produces the asset/network reports. + mux.HandleFunc("POST /api/tasks/{id}/topology-mapping", s.taskAssetMapping) + mux.HandleFunc("GET /api/tasks/{id}/topology-mapping/status", s.taskAssetMappingStatus) + // Keep the old paths for clients from earlier builds. + mux.HandleFunc("POST /api/tasks/{id}/asset-mapping", s.taskAssetMapping) + mux.HandleFunc("GET /api/tasks/{id}/asset-mapping/status", s.taskAssetMappingStatus) mux.HandleFunc("GET /api/tasks/{id}/asset-refs", s.taskAssetRefs) mux.HandleFunc("POST /api/tasks/{id}/assets", s.attachTaskAssets) mux.HandleFunc("DELETE /api/tasks/{id}/assets/{assetID}", s.detachTaskAsset) @@ -772,6 +785,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /api/workspace/mkdir", s.wsMkdir) mux.HandleFunc("DELETE /api/workspace/delete", s.wsDelete) mux.HandleFunc("GET /api/workspace/download", s.wsDownload) + mux.HandleFunc("GET /api/workspace/preview", s.wsPreview) mux.HandleFunc("POST /api/workspace/upload", s.wsUpload) mux.HandleFunc("GET /api/tasks/{id}/scope", s.taskScopeList) mux.HandleFunc("POST /api/tasks/{id}/scope", s.taskScopeAdd) @@ -3442,26 +3456,27 @@ func (s *Server) settingsPayload() map[string]any { concLimit = defaultConcurrencyLimit // 关闭时也回显一个合理默认值给 UI } return map[string]any{ - "traffic_capture": s.m.TrafficEnabled(), - "agent_traffic_binding": s.m.pg.GetBool(settingAgentTrafficBinding, false), - "task_intelligence_enabled": s.m.pg.GetBool(db.SettingTaskIntelligence, false), - db.SettingTaskAutoTitle: s.m.pg.GetBool(db.SettingTaskAutoTitle, false), - db.SettingChatAutoTitle: s.m.pg.GetBool(db.SettingChatAutoTitle, false), - db.SettingCredentialAgent: s.m.pg.GetBool(db.SettingCredentialAgent, false), - db.SettingCredentialTraffic: s.m.pg.GetBool(db.SettingCredentialAgent, false) && s.m.pg.GetBool(db.SettingCredentialTraffic, false), - "llm_record": s.m.LLMRecordEnabled(), - "web_search_enabled": on, - "web_search_backend": backend, - "brave_key_set": strings.TrimSpace(braveKey) != "", - "tavily_key_set": strings.TrimSpace(tavilyKey) != "", - "web_search_proxy": proxy, // 独立出口代理(http/https/socks5),空=直连 - "global_proxy": s.m.GlobalProxy(), // 全局出口代理(http/https/socks5),所有目标流量走它,空=直连 - "show_egress_ip": s.m.pg.GetBool(settingShowEgressIP, false), - "python_interpreter": strings.TrimSpace(pyStored), // 用户/自动设的值(空=用运行时检测) - "workers": s.m.Workers(), // 并发工作 agent 数(默认3);对之后启动的任务生效 - "worker_pools": s.m.WorkerPools(), // 非空时按池顺序绑定 worker 会话 - "task_concurrency_enabled": concOn, // 任务并发上限开关(默认关) - "task_concurrency_limit": concLimit, // 同时运行任务上限(开启后默认5) + "traffic_capture": s.m.TrafficEnabled(), + "browser_screenshot_enabled": s.m.pg.GetBool(settingBrowserScreenshot, false), + "agent_traffic_binding": s.m.pg.GetBool(settingAgentTrafficBinding, false), + "task_intelligence_enabled": s.m.pg.GetBool(db.SettingTaskIntelligence, false), + db.SettingTaskAutoTitle: s.m.pg.GetBool(db.SettingTaskAutoTitle, false), + db.SettingChatAutoTitle: s.m.pg.GetBool(db.SettingChatAutoTitle, false), + db.SettingCredentialAgent: s.m.pg.GetBool(db.SettingCredentialAgent, false), + db.SettingCredentialTraffic: s.m.pg.GetBool(db.SettingCredentialAgent, false) && s.m.pg.GetBool(db.SettingCredentialTraffic, false), + "llm_record": s.m.LLMRecordEnabled(), + "web_search_enabled": on, + "web_search_backend": backend, + "brave_key_set": strings.TrimSpace(braveKey) != "", + "tavily_key_set": strings.TrimSpace(tavilyKey) != "", + "web_search_proxy": proxy, // 独立出口代理(http/https/socks5),空=直连 + "global_proxy": s.m.GlobalProxy(), // 全局出口代理(http/https/socks5),所有目标流量走它,空=直连 + "show_egress_ip": s.m.pg.GetBool(settingShowEgressIP, false), + "python_interpreter": strings.TrimSpace(pyStored), // 用户/自动设的值(空=用运行时检测) + "workers": s.m.Workers(), // 并发工作 agent 数(默认3);对之后启动的任务生效 + "worker_pools": s.m.WorkerPools(), // 非空时按池顺序绑定 worker 会话 + "task_concurrency_enabled": concOn, // 任务并发上限开关(默认关) + "task_concurrency_limit": concLimit, // 同时运行任务上限(开启后默认5) // LLM 轮询(故障转移)。默认关;开启后走全局激活配置的 agent 在当前配置不可用时 // 自动切到下一个配置。bind_fallback 仅在轮询开启时有意义(默认关)。 "llm_pool_enabled": s.m.LLMPoolEnabled(), @@ -3520,6 +3535,7 @@ func (s *Server) pgDetectPython(w http.ResponseWriter, r *http.Request) { func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) { var req struct { TrafficCapture *bool `json:"traffic_capture"` + BrowserScreenshot *bool `json:"browser_screenshot_enabled"` AgentTrafficBinding *bool `json:"agent_traffic_binding"` TaskIntelligence *bool `json:"task_intelligence_enabled"` TaskAutoTitle *bool `json:"task_auto_title_enabled"` @@ -3715,6 +3731,16 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) { return } } + if req.BrowserScreenshot != nil { + if err := s.setReporterBrowserScreenshotVisibility(*req.BrowserScreenshot); err != nil { + writeErr(w, 500, err.Error()) + return + } + if err := s.m.pg.SetBool(settingBrowserScreenshot, *req.BrowserScreenshot); err != nil { + writeErr(w, 500, err.Error()) + return + } + } if req.TrafficCapture != nil { if err := s.m.SetTrafficEnabled(*req.TrafficCapture); err != nil { writeErr(w, 500, err.Error()) @@ -3766,6 +3792,28 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, s.settingsPayload()) } +// setReporterBrowserScreenshotVisibility keeps the report writer's browser MCP +// permission in sync with the screenshot setting. Other agents retain their +// explicit MCP permissions. +func (s *Server) setReporterBrowserScreenshotVisibility(enabled bool) error { + reporter, err := s.m.pg.GetAgentByKey("reporter") + if err != nil || reporter == nil { + return err + } + servers, err := s.m.pg.ListMCP() + if err != nil { + return err + } + for _, server := range servers { + if server.Name == browserMCPName { + return s.m.pg.ToggleVisibility(reporter.ID, "mcp", server.ID, enabled) + } + } + // The built-in browser MCP may be deleted by an administrator. Keep the + // setting writable so it takes effect automatically if the server returns. + return nil +} + // testWebSearch runs a real "test" search with the given (or currently saved) // backend/proxy/key to verify the config can actually reach a search backend — // mirroring testLLM. Backend/proxy come from the request (so the form's unsaved diff --git a/server/task_asset_mapping.go b/server/task_asset_mapping.go new file mode 100644 index 0000000..1ce5f31 --- /dev/null +++ b/server/task_asset_mapping.go @@ -0,0 +1,126 @@ +package server + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/Autumn-27/artex/db" +) + +// taskAssetMapping starts a visible topology-mapping conversation for the task. +// The asset relationship graph remains sourced directly from the database and +// does not depend on this conversation. The conversation is the audit trail for +// the two human-readable reports (asset relationships + network topology). +func (s *Server) taskAssetMapping(w http.ResponseWriter, r *http.Request) { + t, ok := s.m.Task(r.PathValue("id")) + if !ok { + writeErr(w, http.StatusNotFound, "task not found") + return + } + if s.m.pg == nil || s.chatAgentRef() == nil { + writeErr(w, http.StatusServiceUnavailable, s.chatUnavailableReason()) + return + } + if agentInfo, err := s.m.pg.GetAgentByKey("asset_mapper"); err != nil || agentInfo == nil || !agentInfo.Enabled { + writeErr(w, http.StatusServiceUnavailable, "网络拓扑梳理 Agent 未启用") + return + } + taskID, _ := strconv.ParseInt(t.ID, 10, 64) + message := fmt.Sprintf(`请重新梳理任务 #%d 的网络拓扑,并在同一次会话中输出两份相互独立的结果。 + +输出必须严格使用下面两个一级标题,不能合并成一份: +## 资产关系结果 +- 只根据当前任务及其直接关联任务的真实资产数据,保留资产库中的层级和归属关系。 +- 这部分是资产关系图的说明,不要声称本次 Agent 改写了资产关系,也不要补充摘要中没有的资产。 + +## 网络拓扑结果 +- 只整理互联网入口、边界服务、内网业务三层网络关系,并说明每个节点所属区域。 +- 只展示有真实资产证据的连接;数据库节点标注可识别的数据库类型。 + +两份结果都只能使用当前任务及其直接关联任务的真实资产数据;不要探测目标、不要修改资产库,也不要把没有证据的连接描述成已验证通路。`, taskID) + if as := s.m.Assets(); as != nil { + if graph, err := as.BuildCoverageGraph(taskID, t.ExpID); err == nil { + type nodeSample struct { + Key, Kind, Label, Domain, IP string + Port int + Tested, InScope bool + } + counts := map[string]int{} + sample := make([]nodeSample, 0, 60) + for _, node := range graph.Nodes { + counts[node.Kind]++ + if len(sample) < 60 { + sample = append(sample, nodeSample{ + Key: node.Key, Kind: node.Kind, Label: node.Label, Domain: node.Domain, + IP: node.IP, Port: node.Port, Tested: node.Tested, InScope: node.InScope, + }) + } + } + edges := graph.Edges + truncatedEdges := len(edges) > 80 + if truncatedEdges { + edges = edges[:80] + } + snapshot, _ := json.Marshal(map[string]any{ + "nodes_total": len(graph.Nodes), "edges_total": len(graph.Edges), + "counts_by_kind": counts, "node_sample": sample, "edge_sample": edges, + "truncated": len(graph.Nodes) > len(sample) || truncatedEdges, + }) + message += "\n\n页面刚读取的任务资产图真实摘要如下;只依据摘要和任务范围解释,避免把全局资产误当成本任务资产。未列出的节点不要逐一补写:\n" + string(snapshot) + } + } + title := fmt.Sprintf("资产与网络梳理 · 任务 #%d", taskID) + conversation, err := s.m.pg.CreateConversation("asset_mapper", title, nil) + if err != nil || conversation == nil { + writeErr(w, http.StatusInternalServerError, "创建拓扑梳理会话失败") + return + } + if _, err := s.m.pg.AppendConvActivity(conversation.ID, db.Activity{ + Worker: "asset_mapper", Kind: "user", Summary: title, Detail: message, + }); err != nil { + writeErr(w, http.StatusInternalServerError, "写入拓扑梳理会话失败") + return + } + busyKey := s.convBusyKey(conversation.ID) + s.chatMu.Lock() + s.chatBusy[busyKey] = true + s.chatMu.Unlock() + s.runConversation(conversation, message, busyKey, message) + writeJSON(w, http.StatusAccepted, map[string]any{ + "status": "accepted", "agent_key": "asset_mapper", "conversation_id": conversation.ID, + }) +} + +func (s *Server) taskAssetMappingStatus(w http.ResponseWriter, r *http.Request) { + t, ok := s.m.Task(r.PathValue("id")) + if !ok { + writeErr(w, http.StatusNotFound, "task not found") + return + } + conversations, err := s.m.pg.ListConversations() + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + title := fmt.Sprintf("资产与网络梳理 · 任务 #%s", t.ID) + legacyTitle := fmt.Sprintf("资产梳理 · 任务 #%s", t.ID) + for _, conversation := range conversations { + if conversation.AgentKey != "asset_mapper" || (conversation.Title != title && conversation.Title != legacyTitle) { + continue + } + busyKey := s.convBusyKey(conversation.ID) + s.chatMu.Lock() + running := s.chatBusy[busyKey] + s.chatMu.Unlock() + // A completed conversation is historical evidence, not an active run. + // Keep looking so a newer run is not hidden by an older one, and avoid + // making the client poll a completed conversation after page reload. + if running { + writeJSON(w, http.StatusOK, map[string]any{"running": true, "conversation_id": conversation.ID}) + return + } + } + writeJSON(w, http.StatusOK, map[string]any{"running": false}) +} diff --git a/server/workspace.go b/server/workspace.go index a5d8a66..e6b7918 100644 --- a/server/workspace.go +++ b/server/workspace.go @@ -225,6 +225,38 @@ func (s *Server) wsDownload(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, abs) } +// GET /api/workspace/preview?path= — serve an image inline for report +// Markdown. Unlike download, this uses an inline content disposition so a +// report can render a saved browser screenshot with a normal Markdown image URL. +func (s *Server) wsPreview(w http.ResponseWriter, r *http.Request) { + rawPath := strings.TrimSpace(r.URL.Query().Get("path")) + abs, ok := s.wsResolve(rawPath) + if filepath.IsAbs(rawPath) { + base := filepath.Clean(s.m.dir) + candidate := filepath.Clean(rawPath) + ok = candidate == base || strings.HasPrefix(candidate, base+string(os.PathSeparator)) + if ok { + abs = candidate + } + } + if !ok { + writeErr(w, 400, "非法路径") + return + } + fi, err := os.Stat(abs) + if err != nil || fi.IsDir() { + writeErr(w, 404, "文件不存在") + return + } + ext := strings.ToLower(filepath.Ext(abs)) + if ext != ".png" && ext != ".jpg" && ext != ".jpeg" && ext != ".webp" && ext != ".gif" { + writeErr(w, 415, "仅支持图片预览") + return + } + w.Header().Set("Content-Disposition", "inline; filename=\""+sanitizeFilename(filepath.Base(abs))+"\"") + http.ServeFile(w, r, abs) +} + // POST /api/workspace/upload?path= — multipart form field "file" (one or more). func (s *Server) wsUpload(w http.ResponseWriter, r *http.Request) { dirAbs, ok := s.wsResolve(r.URL.Query().Get("path")) diff --git a/web/src/app/(main)/_components/live-clock.tsx b/web/src/app/(main)/_components/live-clock.tsx new file mode 100644 index 0000000..ed19102 --- /dev/null +++ b/web/src/app/(main)/_components/live-clock.tsx @@ -0,0 +1,36 @@ +"use client"; + +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function formatClock(value: Date) { + const pad = (part: number) => String(part).padStart(2, "0"); + return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`; +} + +export function LiveClock({ className }: { className?: string }) { + const [now, setNow] = React.useState(null); + + React.useEffect(() => { + const update = () => setNow(new Date()); + update(); + const timer = window.setInterval(update, 1000); + return () => window.clearInterval(timer); + }, []); + + return ( + + ); +} diff --git a/web/src/app/(main)/_components/main-content.tsx b/web/src/app/(main)/_components/main-content.tsx index 44df7c1..d6951cf 100644 --- a/web/src/app/(main)/_components/main-content.tsx +++ b/web/src/app/(main)/_components/main-content.tsx @@ -10,12 +10,13 @@ import { useCurrentUser } from "@/hooks/use-current-user"; import { api } from "@/lib/api"; import { cn } from "@/lib/utils"; +import { EgressIpNavbar, EgressIpProvider } from "./egress-ip-banner"; +import { LiveClock } from "./live-clock"; import { AccountSwitcher } from "./sidebar/account-switcher"; import { LayoutControls } from "./sidebar/layout-controls"; import { SearchDialog } from "./sidebar/search-dialog"; import { ThemeSwitcher } from "./sidebar/theme-switcher"; import { UpdateBadge } from "./update-badge"; -import { EgressIpNavbar, EgressIpProvider } from "./egress-ip-banner"; // 任务详情页保持原样:它自带头部/Tabs 与内边距,这里不再叠加全局头部和 padding。 function isFullBleed(pathname: string) { @@ -77,6 +78,7 @@ function MainContentInner({ children }: { children: ReactNode }) {
+ {version && ( 版本 · {version} )} diff --git a/web/src/app/(main)/function/findings/_components/findings-table.tsx b/web/src/app/(main)/function/findings/_components/findings-table.tsx index a4b9cc8..b10ba5d 100644 --- a/web/src/app/(main)/function/findings/_components/findings-table.tsx +++ b/web/src/app/(main)/function/findings/_components/findings-table.tsx @@ -75,6 +75,12 @@ export interface FindingEdit { export type FindingReport = { status: "loading" | "done" | "error"; text: string }; +function isStructuredReproductionReport(report: string) { + return ( + /^##\s+(?:复现步骤|测试过程|复现过程)\s*$/im.test(report) && /^\s*(?:###\s*)?步骤\s*\d+[.、::))]/im.test(report) + ); +} + // Exploration node ids are only unique inside a task. Prefer the persisted // finding id and otherwise namespace the node id by task so editing one group // cannot update a similarly-named node in another expanded group. @@ -446,8 +452,8 @@ export function FindingsTable({ {f.evidence} - {/* 详细报告(Markdown):展开时按 finding_id 懒加载,免进详情页即可查看。 */} - {f.finding_id && ( + {/* 旧版详细报告保留;新版合并报告在详情页按概览/步骤/根因/修复展示。 */} + {f.finding_id && !isStructuredReproductionReport(reports[rowKey]?.text ?? "") && (
diff --git a/web/src/app/(main)/function/findings/detail/page.tsx b/web/src/app/(main)/function/findings/detail/page.tsx index d4a640a..2e3c93d 100644 --- a/web/src/app/(main)/function/findings/detail/page.tsx +++ b/web/src/app/(main)/function/findings/detail/page.tsx @@ -10,8 +10,10 @@ import { toast } from "sonner"; import { CopyButton } from "@/components/copy-button"; import { FindingRetestPanel } from "@/components/finding-retest-panel"; +import { FindingReproductionPanel } from "@/components/finding-reproduction-panel"; import { FindingTrafficPanel } from "@/components/finding-traffic-panel"; import { Markdown } from "@/components/markdown"; +import { SideQuestionButton, SideQuestionWorkspace } from "@/components/side-question-workspace"; import { StatusBadge } from "@/components/status-badge"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; @@ -21,6 +23,7 @@ import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger } from "@ import { Separator } from "@/components/ui/separator"; import { SidebarTrigger } from "@/components/ui/sidebar"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useSideQuestions } from "@/hooks/use-side-questions"; import { api } from "@/lib/api"; import { statusMeta } from "@/lib/status"; import type { Finding, FindingStatus, Severity } from "@/lib/types"; @@ -44,6 +47,12 @@ function fmtTime(ts: string) { return new Date(ts).toLocaleString("zh-CN"); } +function usesStructuredReproduction(report: string) { + return ( + /^##\s+(?:复现步骤|测试过程|复现过程)\s*$/im.test(report) && /^\s*(?:###\s*)?步骤\s*\d+[.、::))]/im.test(report) + ); +} + // FieldRow is one label/value line in the right-hand status panel. function FieldRow({ label, children }: { label: string; children: React.ReactNode }) { return ( @@ -62,6 +71,7 @@ function FindingDetailInner() { const [finding, setFinding] = React.useState(null); const [loaded, setLoaded] = React.useState(false); const [tab, setTab] = React.useState("overview"); + const side = useSideQuestions(finding?.task_id ? `/api/tasks/${finding.task_id}/chat` : null); const load = React.useCallback(() => { if (!id) { @@ -128,9 +138,16 @@ function FindingDetailInner() { } const title = finding.name || finding.vulnclass || "未分类"; + const structuredReproduction = usesStructuredReproduction(finding.report || ""); return ( - + + {/* Sticky header */}
@@ -157,6 +174,7 @@ function FindingDetailInner() { + {finding.inherited && finding.source_task_id && ( 来源任务 #{finding.source_task_id} · 只读 )} @@ -182,156 +200,174 @@ function FindingDetailInner() {

{finding.summary || "(无摘要)"}

- - - - 证据 / PoC - - - {finding.evidence ? ( -
-                      {finding.evidence}
-                    
- ) : ( -

(无证据)

- )} -
-
- - {/* 证据下方:详细报告(Markdown 渲染) */} - - - 详细报告 - {finding.report && } - - - {finding.report_stale ? ( - - 流量证据已变更,详细报告待更新。 - - ) : null} - {finding.report ? ( - - ) : ( -

暂无详细报告。

- )} -
-
+ {structuredReproduction ? ( + + ) : ( + <> + + + 证据 / PoC + + + {finding.evidence ? ( +
+                          {finding.evidence}
+                        
+ ) : ( +

(无证据)

+ )} +
+
+ + + + + 详细报告 + {finding.report && } + + + {finding.report_stale ? ( + + 流量证据已变更,详细报告待更新。 + + ) : null} + {finding.report ? ( + + ) : ( +

暂无详细报告。

+ )} +
+
+ + )}
{/* 右栏:状态区 */} - - - 状态 - - - {/* 漏洞 ID */} - - - #{finding.id} - - +
+ + + 状态 + + + {/* 漏洞 ID */} + + + #{finding.id} + + - {/* 严重等级 */} - - {finding.inherited ? ( - - ) : ( - - )} - + {/* 严重等级 */} + + {finding.inherited ? ( + + ) : ( + + )} + - {/* 处理状态 */} - - {finding.inherited ? ( - - ) : ( - - )} - + {/* 处理状态 */} + + {finding.inherited ? ( + + ) : ( + + )} + - {/* 漏洞类型 */} - - {finding.vulnclass ? ( - {finding.vulnclass} - ) : ( - — - )} - + {/* 漏洞类型 */} + + {finding.vulnclass ? ( + {finding.vulnclass} + ) : ( + — + )} + - {/* 涉及资产 */} - - {finding.assets && finding.assets.length > 0 ? ( -
- {finding.assets.map((a) => ( - - {a.label} - - ))} -
- ) : ( - — - )} -
+ {/* 涉及资产 */} + + {finding.assets && finding.assets.length > 0 ? ( +
+ {finding.assets.map((a) => ( + + {a.label} + + ))} +
+ ) : ( + — + )} +
- {/* 所属任务 */} - - {finding.task_id ? ( - - {finding.task_description || `#${finding.task_id}`} - - - ) : ( - —(任务已删除) - )} - + {/* 所属任务 */} + + {finding.task_id ? ( + + {finding.task_description || `#${finding.task_id}`} + + + ) : ( + —(任务已删除) + )} + - {/* 发现时间 */} - - {fmtTime(finding.ts)} - -
-
+ {/* 发现时间 */} + + {fmtTime(finding.ts)} + + + + +
@@ -340,7 +376,8 @@ function FindingDetailInner() {
- + + ); } diff --git a/web/src/app/(main)/function/llm-records/page.tsx b/web/src/app/(main)/function/llm-records/page.tsx index 5f05618..28dbe6a 100644 --- a/web/src/app/(main)/function/llm-records/page.tsx +++ b/web/src/app/(main)/function/llm-records/page.tsx @@ -29,21 +29,9 @@ import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { HttpPacketSyntax } from "@/components/http-code-block"; import { cn } from "@/lib/utils"; import { api } from "@/lib/api"; import type { LLMRecordItem, LLMRecordDetail, LLMTask } from "@/lib/types"; @@ -80,7 +68,12 @@ function tryFormatJSON(s: string): string { function CopyButton({ text }: { text: string }) { const [copied, setCopied] = React.useState(false); const timer = React.useRef | null>(null); - React.useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []); + React.useEffect( + () => () => { + if (timer.current) clearTimeout(timer.current); + }, + [], + ); const disabled = !text; const copy = async () => { @@ -95,7 +88,11 @@ function CopyButton({ text }: { text: string }) { ta.style.opacity = "0"; document.body.appendChild(ta); ta.select(); - try { document.execCommand("copy"); } catch { /* 忽略:不支持则静默 */ } + try { + document.execCommand("copy"); + } catch { + /* 忽略:不支持则静默 */ + } document.body.removeChild(ta); } setCopied(true); @@ -151,9 +148,7 @@ export default function LLMRecordsPage() { const reqText = showRaw ? detail?.raw_request && tryFormatJSON(detail.raw_request) : detail?.request_body && tryFormatJSON(detail.request_body); - const respText = showRaw - ? detail?.raw_response - : detail?.response_body && tryFormatJSON(detail.response_body); + const respText = showRaw ? detail?.raw_response : detail?.response_body && tryFormatJSON(detail.response_body); // Per-task delete (task picker + confirm dialog) const [tasks, setTasks] = React.useState([]); @@ -167,9 +162,13 @@ export default function LLMRecordsPage() { let alive = true; api .settings() - .then((s) => { if (alive) setRecEnabled(!!s.llm_record); }) + .then((s) => { + if (alive) setRecEnabled(!!s.llm_record); + }) .catch(() => {}); - return () => { alive = false; }; + return () => { + alive = false; + }; }, []); const toggleRecording = async (on: boolean) => { @@ -201,7 +200,13 @@ export default function LLMRecordsPage() { let alive = true; setLoading(true); api - .llmRecords({ model: model || undefined, session: sessionQ || undefined, task: pickedTask || undefined, page, size }) + .llmRecords({ + model: model || undefined, + session: sessionQ || undefined, + task: pickedTask || undefined, + page, + size, + }) .then((r) => { if (!alive) return; setRecords(r.records ?? []); @@ -211,9 +216,13 @@ export default function LLMRecordsPage() { .finally(() => alive && setLoading(false)); api .llmTasks() - .then((r) => { if (alive) setTasks(r.tasks ?? []); }) + .then((r) => { + if (alive) setTasks(r.tasks ?? []); + }) .catch(() => {}); - return () => { alive = false; }; + return () => { + alive = false; + }; }, [page, size, sessionQ, model, pickedTask, reloadTick]); // Delete every LLM record for the picked task, then refetch. @@ -244,10 +253,16 @@ export default function LLMRecordsPage() { setDetail(null); api .llmRecordDetail(selected.id) - .then((d) => { if (alive) setDetail(d); }) + .then((d) => { + if (alive) setDetail(d); + }) .catch(() => {}) - .finally(() => { if (alive) setDetailLoading(false); }); - return () => { alive = false; }; + .finally(() => { + if (alive) setDetailLoading(false); + }); + return () => { + alive = false; + }; }, [selected]); const totalPages = Math.max(1, Math.ceil(total / size)); @@ -276,12 +291,7 @@ export default function LLMRecordsPage() { className="h-8 pl-8" />
- setModel(e.target.value)} - /> + setModel(e.target.value)} /> - 自定义 IP 探测服务需返回纯文本 IP 或{" "} - {'{"ip":"1.2.3.4"}'} 格式的 JSON。 - 若内网环境下无法访问公网,或本机和内网代理共用同一个公网出口, - 则可替换为内网 IP 探测服务,此时 ① 和 ② 的 IP 不同,就说明流量走了内网代理。 + 自定义 IP 探测服务需返回纯文本 IP 或 {'{"ip":"1.2.3.4"}'} 格式的 JSON。 + 若内网环境下无法访问公网,或本机和内网代理共用同一个公网出口, 则可替换为内网 IP 探测服务,此时 ① 和 ② + 的 IP 不同,就说明流量走了内网代理。 @@ -603,11 +643,7 @@ export default function SystemSettingsPage() {
{c.skipped ? "—" : c.result.ok ? "✓" : "✗"} @@ -629,18 +665,12 @@ export default function SystemSettingsPage() { {c.skip_reason} ) : c.result.ok ? ( - - {c.result.exit_ip} - {" "} - ·{" "} + {c.result.exit_ip} ·{" "} {c.result.latency_ms}ms {c.result.via && c.result.via !== "direct" && !viaCapture && ( - - {" "} - · 经 {c.result.via_label ?? c.result.via} - + · 经 {c.result.via_label ?? c.result.via} )} ) : ( @@ -733,7 +763,9 @@ export default function SystemSettingsPage() { 自动标题 - 为 Agent 创建的任务和对话首条消息生成简短标题。两个开关独立生效,默认关闭。 + + 为 Agent 创建的任务和对话首条消息生成简短标题。两个开关独立生效,默认关闭。 + @@ -751,7 +783,9 @@ export default function SystemSettingsPage() { 会话标题 - 对话首条消息后自动生成会话标题;先显示消息截断文本,失败时保留截断标题。 + + 对话首条消息后自动生成会话标题;先显示消息截断文本,失败时保留截断标题。 +

绑定时保存的匹配片段

-
-              {data.reference.excerpt}
-            
+ {data.unavailable ? ( 原始流量已删除或不可用,仍可查看上方保存的匹配片段。 ) : ( - - - 请求 - 响应 - - +
+
+

请求 Request

- - +
+
+

响应 Response

- - +
+
)}
) : ( diff --git a/web/src/components/finding-reproduction-panel.tsx b/web/src/components/finding-reproduction-panel.tsx new file mode 100644 index 0000000..4018138 --- /dev/null +++ b/web/src/components/finding-reproduction-panel.tsx @@ -0,0 +1,279 @@ +"use client"; + +import * as React from "react"; + +import { useRouter } from "next/navigation"; + +import { ChevronDownIcon, ChevronRightIcon, LoaderCircleIcon } from "lucide-react"; +import { toast } from "sonner"; + +import { HttpCodeBlock } from "@/components/http-code-block"; +import { Markdown } from "@/components/markdown"; +import { headersFromReqHead, requestFromEvidence } from "@/components/repeater/repeater-http"; +import { seedFromEvidence } from "@/components/repeater/repeater-store"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { api } from "@/lib/api"; +import type { Finding, FindingTrafficBinding, FindingTrafficDetail } from "@/lib/types"; + +type ReproductionStep = { + number: number; + title: string; + body: string; +}; + +function extractSection(report: string) { + const match = report.match(/^##\s+(?:复现步骤|测试过程|复现过程)\s*$/im); + if (!match || match.index === undefined) return ""; + const start = match.index + match[0].length; + const tail = report.slice(start); + const end = tail.search(/^##\s+/m); + return (end >= 0 ? tail.slice(0, end) : tail).trim(); +} + +function withoutPacket(text: string) { + return text.replace(/```(?:http|bash|shell|sh|json|text)?\s*\n[\s\S]*?```/gi, "").trim(); +} + +function parseSteps(report: string, fallback: string): ReproductionStep[] { + const section = extractSection(report); + if (!section) { + return [ + { + number: 1, + title: "执行验证并确认结果", + body: fallback.trim() || "暂无结构化复现步骤,查看下方原始报告。", + }, + ]; + } + + const markers = [...section.matchAll(/^\s*(?:###\s*)?(?:步骤\s*)?(\d+)[.、::))]\s*(.+)$/gim)]; + if (markers.length === 0) { + return [{ number: 1, title: "复现过程", body: withoutPacket(section) }]; + } + + return markers.map((marker, index) => { + const start = marker.index ?? 0; + const end = markers[index + 1]?.index ?? section.length; + const raw = section.slice(start, end).trim(); + const firstLine = marker[2].trim(); + const rest = raw.slice(marker[0].length).trim(); + return { + number: Number(marker[1]), + title: firstLine, + body: withoutPacket(rest), + }; + }); +} + +function reportSection(report: string, headings: string[]) { + const headingPattern = headings.join("|"); + const match = report.match(new RegExp(`^##\\s+(?:${headingPattern})\\s*$`, "im")); + if (!match || match.index === undefined) return ""; + const tail = report.slice(match.index + match[0].length); + const end = tail.search(/^##\s+/m); + return (end >= 0 ? tail.slice(0, end) : tail).trim(); +} + +function packetText(detail: FindingTrafficDetail, side: "request" | "response") { + const head = side === "request" ? detail.binding.snapshot.req_head : detail.binding.snapshot.resp_head; + const body = detail[side].content; + return [head, body].filter(Boolean).join("\n\n").trim(); +} + +function PacketDisclosure({ + findingId, + bindingId, + contextTask, +}: { + findingId: string; + bindingId?: string; + contextTask?: string; +}) { + const [open, setOpen] = React.useState(false); + const [detail, setDetail] = React.useState(null); + const [error, setError] = React.useState(""); + const [repeaterBusy, setRepeaterBusy] = React.useState(false); + const router = useRouter(); + + React.useEffect(() => { + if (!open || !bindingId || detail) return; + let active = true; + setError(""); + api + .findingTrafficDetail(findingId, bindingId, contextTask) + .then((value) => { + if (active) setDetail(value); + }) + .catch((e: Error) => { + if (active) setError(e.message); + }); + return () => { + active = false; + }; + }, [bindingId, contextTask, detail, findingId, open]); + + if (!bindingId) { + return

此步骤暂无已绑定的真实流量证据。

; + } + + async function sendToRepeater() { + if (!detail) return; + setRepeaterBusy(true); + try { + if (detail.request.binary) toast.info("请求体为二进制,已留空;完整正文请先下载"); + else if (detail.request.truncated) toast.info("请求体为预览片段,完整正文请先下载"); + seedFromEvidence( + requestFromEvidence( + { + method: detail.binding.snapshot.method || "GET", + url: detail.binding.snapshot.url || "", + headersText: headersFromReqHead(detail.binding.snapshot.req_head), + body: detail.request.binary ? "" : detail.request.content || "", + }, + detail.binding.snapshot.req_head, + ), + { findingId, bindingId: detail.binding.id }, + ); + router.push("/function/repeater"); + } catch (e) { + toast.error(`送入请求重发失败:${(e as Error).message}`); + } finally { + setRepeaterBusy(false); + } + } + + return ( +
+
+ + {open && detail ? ( + + ) : null} +
+ {open ? ( +
+ {error ?

数据包加载失败:{error}

: null} + {!error && bindingId && !detail ? ( +
+ 正在读取已保存的真实流量… +
+ ) : null} + {detail ? ( +
+
+ {detail.binding.snapshot.method} + {detail.binding.snapshot.status} + {detail.binding.snapshot.url} +
+
+

请求 Request

+ +
+
+

响应 Response

+ +
+
+ ) : null} +
+ ) : null} +
+ ); +} + +export function FindingReproductionPanel({ finding, contextTask }: { finding: Finding; contextTask?: string }) { + const steps = React.useMemo( + () => parseSteps(finding.report ?? "", finding.evidence || finding.summary), + [finding.evidence, finding.report, finding.summary], + ); + const [traffic, setTraffic] = React.useState(undefined); + const overview = reportSection(finding.report ?? "", ["概述", "影响与危害", "受影响范围"]); + const outcome = reportSection(finding.report ?? "", ["最终结果"]); + const rootCause = reportSection(finding.report ?? "", ["根因分析"]); + const remediation = reportSection(finding.report ?? "", ["修复建议"]); + + React.useEffect(() => { + let active = true; + api + .findingTraffic(finding.id, contextTask) + .then((value) => { + if (active) setTraffic(value.bindings); + }) + .catch(() => { + if (active) setTraffic([]); + }); + return () => { + active = false; + }; + }, [contextTask, finding.id]); + + return ( + + +
+
+ 复现步骤 + 步骤同时展示,数据包默认隐藏;需要核对原始请求时再按步骤展开。 +
+ 0 ? "secondary" : "outline"}> + {steps.length} 个步骤 · {traffic?.length ?? finding.traffic_count ?? 0} 个数据包 + +
+
+ + {overview ? ( +
+ +
+ ) : null} + {steps.map((step, index) => { + const binding = traffic?.find((item) => item.position === index) ?? traffic?.[index]; + return ( +
+
+
+
+ + 步骤 {step.number} + +

{step.title}

+
+
+ {step.body ? : null} + +
+
+ ); + })} + {outcome || rootCause || remediation ? ( +
+ {outcome ? : null} + {rootCause ? : null} + {remediation ? : null} +
+ ) : null} +
+
+ ); +} diff --git a/web/src/components/http-code-block.tsx b/web/src/components/http-code-block.tsx index 0a24de1..6d35240 100644 --- a/web/src/components/http-code-block.tsx +++ b/web/src/components/http-code-block.tsx @@ -1,7 +1,7 @@ "use client"; import * as React from "react"; -import { CheckIcon, CopyIcon, WrapTextIcon } from "lucide-react"; +import { CheckIcon, CopyIcon, SearchIcon, WrapTextIcon } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -16,15 +16,55 @@ function statusTone(status: number) { return "text-muted-foreground"; } +const PACKET_COLORS = { + method: "font-semibold text-violet-600 dark:text-violet-400", + url: "text-sky-600 dark:text-sky-400", + dim: "text-muted-foreground", + headerName: "text-sky-700 dark:text-sky-300", + headerValue: "text-emerald-600 dark:text-emerald-400", + jsonKey: "text-sky-700 dark:text-sky-300", + jsonString: "text-amber-600 dark:text-amber-400", + jsonNumber: "text-fuchsia-600 dark:text-fuchsia-400", + jsonBoolean: "text-rose-600 dark:text-rose-400", + tag: "text-sky-600 dark:text-sky-400", +}; + +const PacketHighlightContext = React.createContext(""); + +function HighlightedText({ text, className }: { text: string; className?: string }) { + const query = React.useContext(PacketHighlightContext).trim(); + if (!query || !text) return {text}; + const needle = query.toLowerCase(); + const haystack = text.toLowerCase(); + const parts: React.ReactNode[] = []; + let cursor = 0; + let key = 0; + for (;;) { + const match = haystack.indexOf(needle, cursor); + if (match < 0) { + parts.push(text.slice(cursor)); + break; + } + if (match > cursor) parts.push(text.slice(cursor, match)); + parts.push( + + {text.slice(match, match + needle.length)} + , + ); + cursor = match + needle.length; + } + return {parts}; +} + function StartLine({ line }: { line: string }) { const response = /^(HTTP\/\S+)(\s+)(\d{3})(.*)$/.exec(line); if (response) { return ( <> - {response[1]} + {response[2]} - {response[3]} - {response[4]} + + ); } @@ -33,26 +73,26 @@ function StartLine({ line }: { line: string }) { if (request) { return ( <> - {request[1]} + {request[2]} - {request[3]} + {request[4]} - {request[5]} + ); } - return line; + return ; } function HeaderLine({ line }: { line: string }) { const separator = line.indexOf(":"); - if (separator <= 0) return line; + if (separator <= 0) return ; return ( <> - {line.slice(0, separator)} - : - {line.slice(separator + 1)} + + : + ); } @@ -63,18 +103,14 @@ function JsonBody({ body }: { body: string }) { let cursor = 0; for (const match of body.matchAll(tokens)) { const index = match.index ?? 0; - if (index > cursor) parts.push(body.slice(cursor, index)); - let className = "text-chart-4"; - if (match[1]) className = match[2] ? "text-primary" : "text-chart-2"; - else if (match[3]) className = "text-chart-3"; - parts.push( - - {match[0]} - , - ); + if (index > cursor) parts.push(); + let className = PACKET_COLORS.jsonNumber; + if (match[1]) className = match[2] ? PACKET_COLORS.jsonKey : PACKET_COLORS.jsonString; + else if (match[3]) className = PACKET_COLORS.jsonBoolean; + parts.push(); cursor = index + match[0].length; } - if (cursor < body.length) parts.push(body.slice(cursor)); + if (cursor < body.length) parts.push(); return parts; } @@ -84,15 +120,11 @@ function MarkupBody({ body }: { body: string }) { let cursor = 0; for (const match of body.matchAll(tags)) { const index = match.index ?? 0; - if (index > cursor) parts.push(body.slice(cursor, index)); - parts.push( - - {match[0]} - , - ); + if (index > cursor) parts.push(); + parts.push(); cursor = index + match[0].length; } - if (cursor < body.length) parts.push(body.slice(cursor)); + if (cursor < body.length) parts.push(); return parts; } @@ -108,7 +140,82 @@ function detectBodyFormat(body: string): BodyFormat { function HighlightedBody({ body, format }: { body: string; format: BodyFormat }) { if (format === "json") return ; if (format === "markup") return ; - return body; + return ; +} + +export function HttpPacketSyntax({ + raw, + wrapLines = true, + lineNumbers = true, + emptyFallback = "(空)", + lineClassName = "leading-relaxed", +}: { + raw: string; + wrapLines?: boolean; + lineNumbers?: boolean; + emptyFallback?: string; + lineClassName?: string; +}) { + const value = raw || emptyFallback; + const lines = value.replaceAll("\r\n", "\n").split("\n"); + const lineItems = React.useMemo(() => { + const occurrences = new Map(); + return lines.map((line) => { + const occurrence = occurrences.get(line) ?? 0; + occurrences.set(line, occurrence + 1); + return { key: `${line}\u0000${occurrence}`, line }; + }); + }, [value]); + const separator = lines.indexOf(""); + const body = separator >= 0 ? lines.slice(separator + 1).join("\n") : value; + const bodyFormat = detectBodyFormat(body); + const renderLine = (line: string, index: number) => { + if (separator < 0 && bodyFormat !== "plain") return ; + if (index === 0) return ; + if (separator < 0 || index < separator) return ; + if (index === separator) return null; + return ; + }; + return ( + <> + {lineItems.map((item, index) => ( +
+ {lineNumbers ? ( + + {index + 1} + + ) : null} + + {renderLine(item.line, index)} + +
+ ))} + + ); +} + +function countMatches(value: string, query: string) { + const needle = query.trim().toLowerCase(); + if (!needle) return 0; + const haystack = value.toLowerCase(); + let count = 0; + let cursor = 0; + while (cursor <= haystack.length - needle.length) { + const index = haystack.indexOf(needle, cursor); + if (index < 0) return count; + count += 1; + cursor = index + needle.length; + } } // Empty grid rows have no text, so native selection copying drops their newlines. @@ -148,11 +255,10 @@ function selectedPacketText(container: HTMLElement, value: string, lines: string export function HttpCodeBlock({ raw }: { raw: string }) { const [wrapLines, setWrapLines] = React.useState(true); const [copied, setCopied] = React.useState(false); + const [query, setQuery] = React.useState(""); const value = raw || "(空)"; const lines = value.replaceAll("\r\n", "\n").split("\n"); - const separator = lines.indexOf(""); - const body = separator >= 0 ? lines.slice(separator + 1).join("\n") : ""; - const bodyFormat = detectBodyFormat(body); + const matches = React.useMemo(() => countMatches(value, query), [value, query]); React.useEffect(() => { if (!copied) return; @@ -169,15 +275,8 @@ export function HttpCodeBlock({ raw }: { raw: string }) { toast.error("复制失败,请使用 Ctrl/Cmd+A 后复制"); }; - const renderLine = (line: string, index: number) => { - if (index === 0) return ; - if (separator < 0 || index < separator) return ; - if (index === separator) return null; - return ; - }; - return ( -
+
@@ -216,7 +315,7 @@ export function HttpCodeBlock({ raw }: { raw: string }) { aria-multiline="true" aria-readonly="true" tabIndex={0} - className="max-h-[calc(100vh-15rem)] min-w-0 overflow-auto bg-background py-3 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring" + className="min-h-0 max-h-[calc(100vh-15rem)] flex-1 min-w-0 overflow-auto bg-background py-2 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring" onCopy={(event) => { const text = selectedPacketText(event.currentTarget, value, lines); if (text === null) return; @@ -234,22 +333,29 @@ export function HttpCodeBlock({ raw }: { raw: string }) { selection.addRange(range); }} > - {lines.map((line, index) => ( -
+ + +
+
+ + setQuery(event.target.value)} + placeholder="搜索报文内容" + aria-label="搜索报文内容" + className="min-w-0 flex-1 bg-transparent font-mono text-xs outline-none placeholder:text-muted-foreground/60" + /> + {query.trim() ? ( + - - {renderLine(line, index)} - -
- ))} + {matches} 处命中 + + ) : null}
); diff --git a/web/src/components/markdown.tsx b/web/src/components/markdown.tsx index 53b98bb..6120ffb 100644 --- a/web/src/components/markdown.tsx +++ b/web/src/components/markdown.tsx @@ -18,6 +18,21 @@ const components: Components = { a: ({ node, ...p }) => ( ), + img: ({ node, src, alt, ...p }) => { + const resolvedSrc = + typeof src === "string" && src.startsWith("workspace://") + ? `/api/workspace/preview?path=${encodeURIComponent(src.slice("workspace://".length))}` + : src; + return ( + {alt + ); + }, hr: () =>
, blockquote: ({ node, ...p }) => (
@@ -42,9 +57,7 @@ const components: Components = { ), - th: ({ node, ...p }) => ( -
- ), + th: ({ node, ...p }) => , td: ({ node, ...p }) => , }; diff --git a/web/src/components/repeater/repeater-editor.tsx b/web/src/components/repeater/repeater-editor.tsx index 7f1fc9c..82aa831 100644 --- a/web/src/components/repeater/repeater-editor.tsx +++ b/web/src/components/repeater/repeater-editor.tsx @@ -2,7 +2,7 @@ import * as React from "react"; -import { SendIcon } from "lucide-react"; +import { SendIcon, Settings2 } from "lucide-react"; import { toast } from "sonner"; import { Alert, AlertDescription } from "@/components/ui/alert"; @@ -32,14 +32,40 @@ import { newId, type RepeaterTab } from "./repeater-state"; import { useRepeaterStore } from "./repeater-store"; import { RepeaterTextPane } from "./repeater-text-pane"; +function prettyResponse(response: NonNullable) { + const raw = buildRawResponse(response); + const separator = raw.indexOf("\n\n"); + if (separator < 0) return raw; + const body = raw.slice(separator + 2); + try { + return `${raw.slice(0, separator)}\n\n${JSON.stringify(JSON.parse(body), null, 2)}`; + } catch { + return raw; + } +} + +function responseStatusClass(status: number) { + if (status >= 500) return "border-rose-300 text-rose-600"; + if (status >= 400) return "border-amber-300 text-amber-600"; + if (status >= 300) return "border-sky-300 text-sky-600"; + return "border-emerald-300 text-emerald-600"; +} + +function responseTextFor(response: RepeaterTab["response"], mode: "raw" | "pretty") { + if (!response) return ""; + return mode === "pretty" ? prettyResponse(response) : buildRawResponse(response); +} + export function RepeaterEditor({ tab }: { tab: RepeaterTab }) { const { useProxy, insecure, response } = tab; const updateTab = useRepeaterStore((state) => state.updateTab); const busy = useRepeaterStore((state) => state.inflight[tab.id] !== undefined); const [targetOpen, setTargetOpen] = React.useState(false); const [targetDraft, setTargetDraft] = React.useState(""); + const [responseMode, setResponseMode] = React.useState<"raw" | "pretty">("pretty"); const targetOrigin = tab.targetOrigin ?? (tab.rawRequest === undefined ? connectionOrigin(tab.url) : ""); const requestText = buildRawRequest(tab); + const responseText = responseTextFor(response, responseMode); function changeRequest(rawRequest: string) { try { const { httpVersion: _version, ...parts } = parseRawRequest(rawRequest, targetOrigin); @@ -96,11 +122,15 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) { } return ( -
-
- - 发送目标:{targetOrigin || connectionOrigin(tab.url) || "按 Host 请求头(默认 HTTPS)"} - +
+
+ 目标 + + {targetOrigin || connectionOrigin(tab.url) || "按 Host 请求头(默认 HTTPS)"} + -
-
+
发送选项 {response ? ( -
- {response.status ? {response.status_text || response.status} : null} +
+ {response.status ? ( + + {response.status} {response.status_text} + + ) : null} {response.elapsed_ms} ms {response.body_bytes.toLocaleString()} 字节 {response.body_truncated ? 正文已截断 : null}
) : null}
-
+
Raw + } />
{response?.error ? ( @@ -164,8 +205,32 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) { + + +
+ } />
diff --git a/web/src/components/repeater/repeater-navigator.tsx b/web/src/components/repeater/repeater-navigator.tsx index 17047ce..dee52f3 100644 --- a/web/src/components/repeater/repeater-navigator.tsx +++ b/web/src/components/repeater/repeater-navigator.tsx @@ -32,9 +32,11 @@ type Rename = { kind: "tab" | "group"; id: string; name: string }; export function RepeaterNavigator({ workspace, onRename, + className, }: { workspace: RepeaterWorkspace; onRename: (value: Rename) => void; + className?: string; }) { const store = useRepeaterStore(); const [filters, setFilters] = React.useState(DEFAULT_FILTERS); @@ -77,7 +79,10 @@ export function RepeaterNavigator({ return (