From e8572a18d294ce7483b897b4b9d5486b9eb1171b Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:08:12 +0800 Subject: [PATCH] feat: add structured finding reproduction screenshots --- agent/promptcatalog.go | 9 + server/assembly.go | 12 + server/manager.go | 9 +- server/server.go | 81 +++-- server/workspace.go | 32 ++ .../findings/_components/findings-table.tsx | 10 +- .../(main)/function/findings/detail/page.tsx | 108 ++++--- web/src/app/(main)/system/settings/page.tsx | 110 ++++--- .../components/finding-reproduction-panel.tsx | 279 ++++++++++++++++++ web/src/components/markdown.tsx | 19 +- web/src/lib/types.ts | 4 +- 11 files changed, 564 insertions(+), 109 deletions(-) create mode 100644 web/src/components/finding-reproduction-panel.tsx diff --git a/agent/promptcatalog.go b/agent/promptcatalog.go index 67f1fa7..b46a925 100644 --- a/agent/promptcatalog.go +++ b/agent/promptcatalog.go @@ -90,11 +90,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"。 - ` + "`## 根因分析`" + `:为什么会有这个漏洞(缺校验/危险函数/配置错误…)。 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..f43b3ba 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. @@ -772,6 +778,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 +3449,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 +3528,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 +3724,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 +3785,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/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)/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..0abb3ee 100644 --- a/web/src/app/(main)/function/findings/detail/page.tsx +++ b/web/src/app/(main)/function/findings/detail/page.tsx @@ -10,6 +10,7 @@ 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 { StatusBadge } from "@/components/status-badge"; @@ -44,6 +45,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 ( @@ -128,6 +135,7 @@ function FindingDetailInner() { } const title = finding.name || finding.vulnclass || "未分类"; + const structuredReproduction = usesStructuredReproduction(finding.report || ""); return ( @@ -182,47 +190,65 @@ 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 ? ( + + ) : ( +

暂无详细报告。

+ )} +
+
+ + )}
{/* 右栏:状态区 */} diff --git a/web/src/app/(main)/system/settings/page.tsx b/web/src/app/(main)/system/settings/page.tsx index ebb8564..b664ff7 100644 --- a/web/src/app/(main)/system/settings/page.tsx +++ b/web/src/app/(main)/system/settings/page.tsx @@ -2,7 +2,15 @@ import * as React from "react"; -import { CpuIcon, FlaskConicalIcon, KeyboardIcon, RadioTowerIcon, SearchIcon, ShieldAlertIcon } from "lucide-react"; +import { + CameraIcon, + CpuIcon, + FlaskConicalIcon, + KeyboardIcon, + RadioTowerIcon, + SearchIcon, + ShieldAlertIcon, +} from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -29,6 +37,7 @@ export default function SystemSettingsPage() { const [chatAutoTitleEnabled, setChatAutoTitleEnabled] = React.useState(false); const [savingIntelligence, setSavingIntelligence] = React.useState(false); const [trafficCapture, setTrafficCapture] = React.useState(false); + const [browserScreenshot, setBrowserScreenshot] = React.useState(false); const [agentTrafficBinding, setAgentTrafficBinding] = React.useState(false); const [webSearch, setWebSearch] = React.useState(false); const [backend, setBackend] = React.useState("ddgs"); @@ -86,6 +95,7 @@ export default function SystemSettingsPage() { setTaskAutoTitleEnabled(!!s.task_auto_title_enabled); setChatAutoTitleEnabled(!!s.chat_auto_title_enabled); setTrafficCapture(!!s.traffic_capture); + setBrowserScreenshot(!!s.browser_screenshot_enabled); setAgentTrafficBinding(!!s.agent_traffic_binding); setWebSearch(!!s.web_search_enabled); setBackend(s.web_search_backend || "ddgs"); @@ -213,6 +223,22 @@ export default function SystemSettingsPage() { .finally(() => setSaving(false)); }; + const toggleBrowserScreenshot = (v: boolean) => { + setBrowserScreenshot(v); + setSaving(true); + api + .setSettings({ browser_screenshot_enabled: v }) + .then((s) => { + apply(s); + toast.success(v ? "已开启浏览器截图工具" : "已关闭浏览器截图工具"); + }) + .catch((e) => { + setBrowserScreenshot(!v); + toast.error(`保存失败:${(e as Error).message}`); + }) + .finally(() => setSaving(false)); + }; + const toggleInjectPlanner = (v: boolean) => { setInjectPlanner(v); // optimistic api @@ -262,9 +288,7 @@ export default function SystemSettingsPage() { const toggleAutoTitle = (key: "task_auto_title_enabled" | "chat_auto_title_enabled", value: boolean) => { const setter = key === "task_auto_title_enabled" ? setTaskAutoTitleEnabled : setChatAutoTitleEnabled; const patch: Partial = - key === "task_auto_title_enabled" - ? { task_auto_title_enabled: value } - : { chat_auto_title_enabled: value }; + key === "task_auto_title_enabled" ? { task_auto_title_enabled: value } : { chat_auto_title_enabled: value }; setter(value); api .setSettings(patch) @@ -497,6 +521,31 @@ export default function SystemSettingsPage() { + + + + + 浏览器截图 + + + 开启后,报告撰写 Agent 会自动获得 browser + MCP,并在复现步骤对应的页面上保存截图,报告中直接显示对应步骤截图。 截图会增加 Token + 消耗;关闭后不会自动给报告撰写 Agent 授权截图能力,已有历史报告保留原截图。 + + + + + + + + @@ -510,9 +559,8 @@ export default function SystemSettingsPage() { 开启流量捕获时,它作为记录代理的上游(流量仍全量落库,再经此代理出网);关闭捕获时,直接注入 Agent 的 bash / WebFetch 出网。与网络搜索代理、LLM 代理相互独立。
- 提示:socks5 在关闭捕获时依赖各命令行工具对 ALL_PROXY 的支持——curl - 可用,python urllib 等会直接报错; 若主要用 socks5,建议开启流量捕获——此路径由 MITM - 亲自拨号,工具无感知、稳定生效。 + 提示:socks5 在关闭捕获时依赖各命令行工具对 ALL_PROXY 的支持——curl 可用,python + urllib 等会直接报错; 若主要用 socks5,建议开启流量捕获——此路径由 MITM 亲自拨号,工具无感知、稳定生效。
@@ -537,12 +585,7 @@ export default function SystemSettingsPage() { 保存 {/* 测表单里当前的值,不要求先保存。 */} -
@@ -567,15 +610,13 @@ export default function SystemSettingsPage() { {/* 紧贴结果区:用户看两行时正好需要它。细节放文档。 */}

- 点测试会访问 IP 回显服务(默认 api.ipify.org,失败自动换{" "} - ifconfig.me),它返回"这个请求从哪个 IP 来"。 - ① 是不走代理时的 IP,② 是经全局代理出网时的 IP。改完不用先保存,直接测。 + 点测试会访问 IP 回显服务(默认 api.ipify.org,失败自动换 ifconfig.me + ),它返回"这个请求从哪个 IP 来"。 ① 是不走代理时的 IP,② 是经全局代理出网时的 + IP。改完不用先保存,直接测。

{/* 内网里公网回显不可达,两行都会失败——不是代理坏了。 */}
- - 自定义 IP 探测服务(可选) - + 自定义 IP 探测服务(可选)
- 自定义 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() { 会话标题 - 对话首条消息后自动生成会话标题;先显示消息截断文本,失败时保留截断标题。 + + 对话首条消息后自动生成会话标题;先显示消息截断文本,失败时保留截断标题。 + = 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/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/lib/types.ts b/web/src/lib/types.ts index 2ff5cd1..129726a 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -970,6 +970,8 @@ export interface TrafficHost { // ---- App settings (runtime toggles) ---- export interface Settings { traffic_capture: boolean; + /** 是否向可见 Agent 暴露浏览器 MCP 的截图工具。默认关闭。 */ + browser_screenshot_enabled?: boolean; credential_agent_enabled: boolean; credential_traffic_binding: boolean; task_intelligence_enabled: boolean; // 默认关闭;关闭不注入情报工具或提示 @@ -991,7 +993,7 @@ export interface Settings { // 全局出口代理(http/https/socks5,可带 user:pass),所有目标流量走它。开启流量捕获时作为 // MITM 上游;关闭捕获时直接注入 agent 的 bash/WebFetch。空=直连。 global_proxy?: string; - show_egress_ip?: boolean; // 在顶栏显示直连 / 全局代理出口 IP;默认关闭 + show_egress_ip?: boolean; // 在顶栏显示直连 / 全局代理出口 IP;默认关闭 python_interpreter?: string; // 自定义脚本工具的 python 解释器路径(空=运行时检测) workers?: number; // 并发工作 agent 数(默认3);对之后启动的任务生效 worker_pools: WorkerPool[];