From b0a29236f85869c81331caf85dcd161078279a84 Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:25:32 +0800 Subject: [PATCH 1/2] feat: improve finding reports and evidence exports --- agent/promptcatalog.go | 2 +- server/conversations.go | 15 ++ server/finding_traffic_export.go | 130 +++++++++++++++++- server/manager.go | 35 ++++- server/orchestration.go | 65 ++++++++- server/server.go | 69 ++++++---- server/workspace.go | 45 ++++++ .../(main)/function/findings/detail/page.tsx | 1 + .../tasks/detail/_tabs/coverage-graph-tab.tsx | 10 +- web/src/app/(main)/system/settings/page.tsx | 41 ++++++ .../components/finding-reproduction-panel.tsx | 12 +- web/src/components/markdown.tsx | 83 ++++++++++- .../components/side-question-workspace.tsx | 20 ++- web/src/lib/types.ts | 2 + 14 files changed, 485 insertions(+), 45 deletions(-) diff --git a/agent/promptcatalog.go b/agent/promptcatalog.go index cc11b89..b0e9eab 100644 --- a/agent/promptcatalog.go +++ b/agent/promptcatalog.go @@ -102,7 +102,7 @@ const ReporterDefaultPrompt = `你是一个授权渗透测试系统里的**漏 ━━ 报告步骤截图(仅在系统设置开启时)━━ - 当系统设置开启报告步骤截图时,你会自动获得 browser MCP;此时对每个复现步骤中真实出现且仍可访问的 HTTP 页面,使用 browser_navigate 后调用 browser_take_screenshot。 -- 只访问漏洞证据中出现的 URL,不扩大测试范围。把截图保存到当前工作目录的 findings/screenshots/ 下,命名 finding--step-.png;确认文件存在后,在对应步骤中加入 Markdown 图片链接:![步骤 截图](workspace://<截图绝对路径>)。 +- 只访问漏洞证据中出现的 URL,不扩大测试范围。把截图保存到当前工作目录的 findings/screenshots/ 下,命名 finding--step-.png;确认文件存在后,在对应步骤中**统一使用工作区相对路径**加入 Markdown 图片链接:![步骤 截图](findings/screenshots/finding--step-.png)。禁止使用绝对路径、workspace://、file:// 或把相对路径和绝对路径混用;报告中所有截图都必须遵守这一种格式。 - 截图失败时如实说明;系统设置关闭或 browser MCP 不可用时,不要伪造图片链接,也不要把文字描述冒充截图。 ━━ 报告结构(Markdown,按需裁剪,但证据/复现/修复必须有)━━ diff --git a/server/conversations.go b/server/conversations.go index f396647..69e3839 100644 --- a/server/conversations.go +++ b/server/conversations.go @@ -704,6 +704,13 @@ func (s *Server) StartTriggeredRun(agentKey, title, message string, taskID int64 if s.m.pg == nil || s.chatAgentRef() == nil { return } + // A trigger body can outlive a prompt/trigger migration when it was queued + // before a restart. Never let the retired structured-reproduction contract + // enter a new reporter conversation; keep the tool event context after the + // marker so the report still has the original finding payload. + if agentKey == "reporter" && strings.Contains(message, "update_finding_reproduction") { + message = normalizeReporterTriggerMessage(message) + } cfg := s.readTriggerBehavior(agentKey) // DB read BEFORE the lock (never under queueMu) s.queueMu.Lock() s.triggerCfg[agentKey] = cfg @@ -712,6 +719,14 @@ func (s *Server) StartTriggeredRun(agentKey, title, message string, taskID int64 s.queueMu.Unlock() } +func normalizeReporterTriggerMessage(message string) string { + const marker = "【本次由工具调用触发】" + if i := strings.Index(message, marker); i >= 0 { + return reporterToolCallMessage + message[i:] + } + return reporterToolCallMessage +} + // pumpLocked launches queued fires for one agent up to its concurrency limit, then // returns. Serial → limit 1; parallel → limit = maxParallel (<=0 → unlimited). Each // launched run re-pumps on completion (runAndPump) to fill the freed slot. Caller diff --git a/server/finding_traffic_export.go b/server/finding_traffic_export.go index 740e41b..2064e17 100644 --- a/server/finding_traffic_export.go +++ b/server/finding_traffic_export.go @@ -7,6 +7,8 @@ import ( "fmt" "io" "os" + "path/filepath" + "regexp" "strings" "time" @@ -15,16 +17,25 @@ import ( "github.com/Autumn-27/artex/report" ) -func writeFindingsEvidenceZip(out io.Writer, findings []*db.DBFinding, stage string, now time.Time) error { +type exportedScreenshot struct { + ReportPath string `json:"report_path"` + ArchivePath string `json:"archive_path"` + SourcePath string `json:"-"` + CapturedAt string `json:"captured_at"` +} + +var screenshotMarkdownLink = regexp.MustCompile(`!\[([^\]]*)\]\(([^)\s]+)\)`) + +func writeFindingsEvidenceZip(out io.Writer, findings []*db.DBFinding, stage string, now time.Time, workspaceDir string) error { zw := zip.NewWriter(out) - if err := writeFindingsZipEntries(zw, findings, stage, now); err != nil { + if err := writeFindingsZipEntries(zw, findings, stage, now, workspaceDir); err != nil { zw.Close() return err } return zw.Close() } -func writeFindingsZipEntries(zw *zip.Writer, findings []*db.DBFinding, stage string, now time.Time) error { +func writeFindingsZipEntries(zw *zip.Writer, findings []*db.DBFinding, stage string, now time.Time, workspaceDir string) error { store := evidence.New(nil, nil, stage) // private export copy; not subject to GC used := map[string]int{} evidencePaths := map[string]bool{} @@ -35,13 +46,32 @@ func writeFindingsZipEntries(zw *zip.Writer, findings []*db.DBFinding, stage str name = fmt.Sprintf("%s-%d.md", strings.TrimSuffix(base, ".md"), n+1) } used[base]++ + reportText, screenshots, err := prepareFindingExportReport(report.SingleFindingMarkdown(f, now), f.ID, workspaceDir) + if err != nil { + return err + } w, err := zw.Create(name) if err != nil { return err } - if _, err = io.WriteString(w, report.SingleFindingMarkdown(f, now)); err != nil { + if _, err = io.WriteString(w, reportText); err != nil { return err } + for _, screenshot := range screenshots { + if err := copyZipFile(zw, screenshot.ArchivePath, screenshot.SourcePath); err != nil { + return err + } + } + if len(screenshots) > 0 { + manifestPath := fmt.Sprintf("screenshots/%d/manifest.json", f.ID) + w, err := zw.Create(manifestPath) + if err != nil { + return err + } + if err := json.NewEncoder(w).Encode(map[string]any{"finding_id": f.ID, "screenshots": screenshots}); err != nil { + return err + } + } for _, b := range f.TrafficBindings { prefix := fmt.Sprintf("evidence/%d/%d/", f.ID, b.ID) if evidencePaths[prefix] { @@ -94,12 +124,12 @@ func writeFindingsZipEntries(zw *zip.Writer, findings []*db.DBFinding, stage str return nil } -func buildFindingsEvidenceZip(path string, findings []*db.DBFinding, stage string, now time.Time) error { +func buildFindingsEvidenceZip(path string, findings []*db.DBFinding, stage string, now time.Time, workspaceDir string) error { f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) if err != nil { return err } - writeErr := writeFindingsEvidenceZip(f, findings, stage, now) + writeErr := writeFindingsEvidenceZip(f, findings, stage, now, workspaceDir) syncErr := f.Sync() closeErr := f.Close() if err := errors.Join(writeErr, syncErr, closeErr); err != nil { @@ -124,3 +154,91 @@ func buildFindingsEvidenceZip(path string, findings []*db.DBFinding, stage strin } return nil } + +func copyZipFile(zw *zip.Writer, archivePath, sourcePath string) error { + in, err := os.Open(sourcePath) + if err != nil { + return err + } + defer in.Close() + out, err := zw.Create(archivePath) + if err != nil { + return err + } + _, err = io.Copy(out, in) + return err +} + +func prepareFindingExportReport(markdown string, findingID int64, workspaceDir string) (string, []exportedScreenshot, error) { + var out strings.Builder + var screenshots []exportedScreenshot + seen := map[string]bool{} + last := 0 + for _, match := range screenshotMarkdownLink.FindAllStringSubmatchIndex(markdown, -1) { + out.WriteString(markdown[last:match[0]]) + raw := markdown[match[4]:match[5]] + source, ok := resolveExportScreenshot(workspaceDir, raw) + if !ok { + out.WriteString(markdown[match[0]:match[1]]) + last = match[1] + continue + } + archivePath := filepath.ToSlash(fmt.Sprintf("screenshots/%d/%s", findingID, filepath.Base(source))) + if !seen[archivePath] { + info, statErr := os.Stat(source) + if statErr != nil { + return "", nil, statErr + } + screenshots = append(screenshots, exportedScreenshot{ReportPath: raw, ArchivePath: archivePath, SourcePath: source, CapturedAt: info.ModTime().Format("2006-01-02 15:04:05")}) + seen[archivePath] = true + } + // Preserve the original alt text and only replace the image URL. + out.WriteString(markdown[match[0]:match[4]]) + out.WriteString(archivePath) + out.WriteString(markdown[match[5]:match[1]]) + last = match[1] + } + out.WriteString(markdown[last:]) + return out.String(), screenshots, nil +} + +func resolveExportScreenshot(workspaceDir, raw string) (string, bool) { + value := strings.TrimSpace(raw) + value = strings.TrimPrefix(value, "workspace://") + value = strings.TrimPrefix(value, "file://") + if value == "" { + return "", false + } + if filepath.IsAbs(value) { + candidate := filepath.Clean(value) + base := filepath.Clean(workspaceDir) + if candidate != base && !strings.HasPrefix(candidate, base+string(os.PathSeparator)) { + return "", false + } + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + return candidate, true + } + return "", false + } + rel := filepath.Clean(strings.TrimPrefix(value, "/")) + const prefix = "findings" + string(os.PathSeparator) + "screenshots" + string(os.PathSeparator) + if rel == "." || !strings.HasPrefix(rel, prefix) || strings.Contains(rel, ".."+string(os.PathSeparator)) { + return "", false + } + matches, err := filepath.Glob(filepath.Join(filepath.Clean(workspaceDir), "sessions", "*", rel)) + if err != nil || len(matches) == 0 { + return "", false + } + var newest string + var newestTime int64 + for _, candidate := range matches { + info, statErr := os.Stat(candidate) + if statErr != nil || info.IsDir() { + continue + } + if newest == "" || info.ModTime().UnixNano() > newestTime { + newest, newestTime = candidate, info.ModTime().UnixNano() + } + } + return newest, newest != "" +} diff --git a/server/manager.go b/server/manager.go index 894f7ac..b55d131 100644 --- a/server/manager.go +++ b/server/manager.go @@ -265,6 +265,7 @@ type Manager struct { const ( settingTrafficCapture = "traffic_capture" settingBrowserScreenshot = "browser_screenshot_enabled" + settingScreenshotTimestamp = "browser_screenshot_timestamp_enabled" settingAgentTrafficBinding = "agent_traffic_binding" settingWebSearchOn = "web_search_enabled" settingWebSearchBackend = "web_search_backend" @@ -835,8 +836,13 @@ func (m *Manager) syncBrowserMCPProxy() { proxy := m.ProxyAddr() // "" when capture off cert := m.ProxyCACert() // "" when capture off - args := stripProxyArgs(decodeStrSlice(srv.Args)) + args := ensureBrowserMCPArgs(stripProxyArgs(decodeStrSlice(srv.Args)), m.pg.GetBool(settingBrowserScreenshot, false)) env := decodeStrMap(srv.Env) + if m.pg.GetBool(settingBrowserScreenshot, false) { + // A persisted screenshot opt-in must survive restart even if an older + // database had the seeded MCP disabled. + srv.Enabled = true + } delete(env, "NODE_EXTRA_CA_CERTS") if proxy != "" { args = append(args, "--proxy-server", proxy) @@ -857,6 +863,33 @@ func (m *Manager) syncBrowserMCPProxy() { } } +// ensureBrowserMCPArgs keeps the built-in Playwright MCP runnable in the +// application container. The production image intentionally runs the service +// as root; Chromium refuses to start in that mode unless --no-sandbox is +// passed. Keep this migration idempotent so an existing database is repaired +// as soon as the browser MCP is reconciled (including after a restart). +func ensureBrowserMCPArgs(args []string, ignoreHTTPSErrors bool) []string { + out := append([]string(nil), args...) + has := func(want string) bool { + for _, arg := range out { + if arg == want || strings.HasPrefix(arg, want+"=") { + return true + } + } + return false + } + if !has("--headless") { + out = append(out, "--headless") + } + if !has("--no-sandbox") { + out = append(out, "--no-sandbox") + } + if ignoreHTTPSErrors && !has("--ignore-https-errors") { + out = append(out, "--ignore-https-errors") + } + return out +} + // stripProxyArgs removes any --proxy-server/--proxy-bypass flags (both "--flag val" // and "--flag=val" forms) so they can be re-added cleanly from current state, // without mutating the input slice. diff --git a/server/orchestration.go b/server/orchestration.go index 33052c6..e7515c4 100644 --- a/server/orchestration.go +++ b/server/orchestration.go @@ -534,7 +534,15 @@ func (s *Server) seedOrchestrationTools() { s.reseedWorkerPrompt() // worker 提示词:加否定结论证据门槛(一次性) s.seedReporterAgent() // 预置「报告撰写」agent + 工具绑定 + finding 触发器(一次性) s.upgradeReporterTriggerMessage() // 老库补迁移:让 reporter 回传 evidence_version(一次性) - s.seedFindingTrafficTools() // 增加可选证据参数及只读证据工具,保留用户配置 + s.migrateReporterWorkflow() // 修复旧库残留的 reproduction 工具/提示词绑定 + if s.m.pg.GetBool(settingBrowserScreenshot, false) { + // Re-apply the opt-in after startup seeding so an existing database gets + // both reporter visibility and the repaired Playwright launch arguments. + if err := s.setReporterBrowserScreenshotVisibility(true); err != nil { + log.Printf("[reporter] 截图 MCP 启动配置同步失败: %v", err) + } + } + s.seedFindingTrafficTools() // 增加可选证据参数及只读证据工具,保留用户配置 s.seedFindingWorkflowTools() s.seedFindingCaseTools() // 注:pentest 的默认工具绑定无需迁移——BuiltinToolSeeds 在全新初始化时就把 @@ -542,6 +550,61 @@ func (s *Server) seedOrchestrationTools() { // pentest 一起 seed 好了(项目尚无旧库,不做迁移)。 } +// migrateReporterWorkflow repairs databases created by the short-lived +// structured-reproduction reporter. That workflow referenced +// update_finding_reproduction, which is no longer a runtime tool; leaving the +// old prompt/trigger in place makes a newly reported vulnerability fail after +// all evidence has already been collected. User-authored reporter prompts are +// preserved unless they contain the removed tool contract. +func (s *Server) migrateReporterWorkflow() { + const flag = "reporter_workflow_markdown_v2" + if v, _, _ := s.m.pg.GetSetting(flag); v == "true" { + return + } + defer func() { _ = s.m.pg.SetSetting(flag, "true") }() + + reporter, err := s.m.pg.GetAgentByKey("reporter") + if err != nil || reporter == nil { + return + } + if current, err := s.m.pg.CurrentPrompt(reporter.ID); err == nil && + (strings.Contains(current, "update_finding_reproduction") || strings.Contains(current, "漏洞步骤编写 agent")) { + if _, err := s.m.pg.ResetPromptToDefault(reporter.ID, agent.ReporterDefaultPrompt); err != nil { + log.Printf("[reporter] 旧版提示词迁移失败: %v", err) + } else { + log.Printf("[reporter] 已将旧版复现步骤提示词迁移为 Markdown 报告流程") + } + } + + // 只补齐当前报告流程需要的绑定,不覆盖管理员已有的其它配置。 + _ = s.m.pg.AddAgentToToolBinding("reporter", []string{ + "update_finding_report", "get_finding_traffic", "bind_finding_traffic", + "get_task_node_detail", "list_task_findings", "get_task_worker_trace", + "list_task_worker_traces", "search_task_worker_traces", "get_task_graph", + }) + // 旧行保留在工具目录里供历史审计,但不能继续被运行时广告或调用。 + if _, err := s.m.pg.Exec(`UPDATE tools SET enabled=false, updated_at=now() WHERE key='update_finding_reproduction' AND system`); err != nil { + log.Printf("[reporter] 旧 reproduction 工具停用失败: %v", err) + } + + triggers, err := s.m.pg.ListTriggersFor("reporter") + if err != nil { + log.Printf("[reporter] 读取触发器失败: %v", err) + return + } + for _, t := range triggers { + if !t.OnToolCall || !strings.Contains(t.ToolCallMessage, "update_finding_reproduction") { + continue + } + t.ToolCallMessage = reporterToolCallMessage + if err := s.m.pg.UpdateTrigger(t); err != nil { + log.Printf("[reporter] 旧触发器迁移失败: %v", err) + continue + } + log.Printf("[reporter] 已将旧 reproduction 触发器迁移为报告保存流程") + } +} + // refreshBuiltinToolSchemas propagates code schema/description changes on the // orchestration + platform tools into already-seeded rows ONCE per version flag — // SeedTool is first-insert-only, so a new param (e.g. spawn_task 的 llm_profile) never diff --git a/server/server.go b/server/server.go index da2296b..3f8b2e6 100644 --- a/server/server.go +++ b/server/server.go @@ -203,7 +203,7 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi t += ` 【报告步骤截图已开启】 - 报告撰写时,针对每个复现步骤中真实出现且仍可访问的 HTTP 页面,使用 browser MCP 的 browser_navigate 和 browser_take_screenshot 截取页面证据。只导航到漏洞证据中出现的目标 URL,不要自行扩大测试范围。将截图保存到当前工作目录下的 findings/screenshots/ 目录,文件名使用 finding--step-.png;确认文件存在后,在对应步骤中加入 Markdown 图片:![步骤 截图](workspace://<截图绝对路径>)。截图失败时如实写明,不要用占位图或把截图描述冒充图片。` + 报告撰写时,针对每个复现步骤中真实出现且仍可访问的 HTTP 页面,使用 browser MCP 的 browser_navigate 和 browser_take_screenshot 截取页面证据。只导航到漏洞证据中出现的目标 URL,不要自行扩大测试范围。将截图保存到当前工作目录下的 findings/screenshots/ 目录,文件名使用 finding--step-.png;确认文件存在后,在对应步骤中**统一使用工作区相对路径**加入 Markdown 图片:![步骤 截图](findings/screenshots/finding--step-.png)。禁止使用绝对路径、workspace://、file://,也不要混用相对路径和绝对路径;报告中所有截图都必须使用这一种格式。截图失败时如实写明,不要用占位图或把截图描述冒充图片。` } return t, true } @@ -2327,7 +2327,7 @@ func (s *Server) findingsExport(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(report.FindingsMarkdown(fs, now))) case "md-zip": path := filepath.Join(stage, "findings.zip") - if err := buildFindingsEvidenceZip(path, fs, stage, now); err != nil { + if err := buildFindingsEvidenceZip(path, fs, stage, now, s.m.dir); err != nil { writeErr(w, 500, err.Error()) return } @@ -3456,27 +3456,28 @@ func (s *Server) settingsPayload() map[string]any { concLimit = defaultConcurrencyLimit // 关闭时也回显一个合理默认值给 UI } return map[string]any{ - "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) + "traffic_capture": s.m.TrafficEnabled(), + "browser_screenshot_enabled": s.m.pg.GetBool(settingBrowserScreenshot, false), + "browser_screenshot_timestamp_enabled": s.m.pg.GetBool(settingScreenshotTimestamp, 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(), @@ -3536,6 +3537,7 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) { var req struct { TrafficCapture *bool `json:"traffic_capture"` BrowserScreenshot *bool `json:"browser_screenshot_enabled"` + ScreenshotTimestamp *bool `json:"browser_screenshot_timestamp_enabled"` AgentTrafficBinding *bool `json:"agent_traffic_binding"` TaskIntelligence *bool `json:"task_intelligence_enabled"` TaskAutoTitle *bool `json:"task_auto_title_enabled"` @@ -3740,6 +3742,16 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) { writeErr(w, 500, err.Error()) return } + // The reporter's MCP visibility and prompt are assembled when agents are + // built. Rebuild immediately so the setting applies to the next run, + // rather than only after a later unrelated LLM/settings change. + changed = true + } + if req.ScreenshotTimestamp != nil { + if err := s.m.pg.SetBool(settingScreenshotTimestamp, *req.ScreenshotTimestamp); err != nil { + writeErr(w, 500, err.Error()) + return + } } if req.TrafficCapture != nil { if err := s.m.SetTrafficEnabled(*req.TrafficCapture); err != nil { @@ -3806,6 +3818,17 @@ func (s *Server) setReporterBrowserScreenshotVisibility(enabled bool) error { } for _, server := range servers { if server.Name == browserMCPName { + // Turning screenshots on is an explicit opt-in. Make the browser MCP + // usable in the shipped root-based container at the same time as the + // permission toggle, so the next reporter run gets a working browser + // instead of a deterministic Chromium sandbox error. + if enabled { + server.Args = encodeJSON(ensureBrowserMCPArgs(decodeStrSlice(server.Args), true)) + server.Enabled = true + if _, err := s.m.pg.SaveMCP(server); err != nil { + return err + } + } return s.m.pg.ToggleVisibility(reporter.ID, "mcp", server.ID, enabled) } } diff --git a/server/workspace.go b/server/workspace.go index e6b7918..dcd504a 100644 --- a/server/workspace.go +++ b/server/workspace.go @@ -244,6 +244,16 @@ func (s *Server) wsPreview(w http.ResponseWriter, r *http.Request) { return } fi, err := os.Stat(abs) + // Reports use the workspace-relative canonical path + // findings/screenshots/finding--step-.png, while the files live + // under the per-conversation session directory. Resolve that exact suffix + // in the newest matching session so the links stay portable across reports. + if (err != nil || fi.IsDir()) && !filepath.IsAbs(rawPath) { + if fallback, found := s.findSessionPreview(rawPath); found { + abs = fallback + fi, err = os.Stat(abs) + } + } if err != nil || fi.IsDir() { writeErr(w, 404, "文件不存在") return @@ -253,10 +263,45 @@ func (s *Server) wsPreview(w http.ResponseWriter, r *http.Request) { writeErr(w, 415, "仅支持图片预览") return } + if s.m.pg.GetBool(settingScreenshotTimestamp, false) { + rawRel := filepath.ToSlash(strings.TrimPrefix(strings.TrimSpace(rawPath), "/")) + rel := filepath.ToSlash(s.wsRel(abs)) + if strings.HasPrefix(rawRel, "findings/screenshots/") || strings.HasPrefix(rel, "findings/screenshots/") || strings.Contains(rel, "/findings/screenshots/") { + // The frontend renders this as a small top-right overlay. Use the + // file mtime so the label reflects capture/save time, not view time. + w.Header().Set("X-Report-Screenshot-Timestamp", fi.ModTime().Format("2006-01-02 15:04:05")) + } + } w.Header().Set("Content-Disposition", "inline; filename=\""+sanitizeFilename(filepath.Base(abs))+"\"") http.ServeFile(w, r, abs) } +func (s *Server) findSessionPreview(rawPath string) (string, bool) { + rel := filepath.Clean(strings.TrimPrefix(strings.TrimSpace(rawPath), "/")) + const prefix = "findings" + string(os.PathSeparator) + "screenshots" + string(os.PathSeparator) + if rel == "." || !strings.HasPrefix(rel, prefix) || strings.Contains(rel, ".."+string(os.PathSeparator)) { + return "", false + } + base := filepath.Join(filepath.Clean(s.m.dir), "sessions") + matches, err := filepath.Glob(filepath.Join(base, "*", filepath.FromSlash(rel))) + if err != nil || len(matches) == 0 { + return "", false + } + var newest string + var newestTime int64 + for _, candidate := range matches { + info, statErr := os.Stat(candidate) + if statErr != nil || info.IsDir() { + continue + } + mtime := info.ModTime().UnixNano() + if newest == "" || mtime > newestTime { + newest, newestTime = candidate, mtime + } + } + return newest, newest != "" +} + // 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/detail/page.tsx b/web/src/app/(main)/function/findings/detail/page.tsx index 2e3c93d..f724e92 100644 --- a/web/src/app/(main)/function/findings/detail/page.tsx +++ b/web/src/app/(main)/function/findings/detail/page.tsx @@ -144,6 +144,7 @@ function FindingDetailInner() { diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx index 44fee86..b6c2a14 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx @@ -787,6 +787,11 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera graph = new Graph({ container: containerRef.current, autoResize: true, + // Keep wheel/trackpad zoom within a readable range. Very small zoom + // values make labels and the fixed network zones appear to collapse + // into a single strip; an upper bound also prevents focusElement from + // pushing the selected node underneath the overlay panel. + zoomRange: [0.35, 3], // Asset mode explicitly centers after rendering. Network mode keeps // its fixed world coordinates so the three background zones align. background: "transparent", @@ -824,6 +829,7 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera behaviors: ["drag-element", "drag-canvas", "zoom-canvas"], }); graph.on("aftertransform", syncViewport); + graph.on("afterresize", syncViewport); graph.on("node:click", (evt: unknown) => { const id = (evt as { target?: { id?: string } }).target?.id; if (!id) return; @@ -861,7 +867,7 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera graph?.destroy(); graphRef.current = null; }; - }, [applyData, syncViewport]); + }, [applyData, resetView, syncViewport]); // 可见集合变化 → 重新灌数据 + 布局。sig 只作为重排触发器(applyData 读 gDataRef)。 // biome-ignore lint/correctness/useExhaustiveDependencies: sig 是刻意的重排触发依赖 @@ -1075,7 +1081,7 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera className="h-7 px-2 text-xs" onClick={() => { setActivePath([]); - void graphRef.current?.fitCenter(); + void resetView(); }} > 清除高亮 diff --git a/web/src/app/(main)/system/settings/page.tsx b/web/src/app/(main)/system/settings/page.tsx index b664ff7..57faffd 100644 --- a/web/src/app/(main)/system/settings/page.tsx +++ b/web/src/app/(main)/system/settings/page.tsx @@ -38,6 +38,7 @@ export default function SystemSettingsPage() { const [savingIntelligence, setSavingIntelligence] = React.useState(false); const [trafficCapture, setTrafficCapture] = React.useState(false); const [browserScreenshot, setBrowserScreenshot] = React.useState(false); + const [browserScreenshotTimestamp, setBrowserScreenshotTimestamp] = React.useState(false); const [agentTrafficBinding, setAgentTrafficBinding] = React.useState(false); const [webSearch, setWebSearch] = React.useState(false); const [backend, setBackend] = React.useState("ddgs"); @@ -96,6 +97,7 @@ export default function SystemSettingsPage() { setChatAutoTitleEnabled(!!s.chat_auto_title_enabled); setTrafficCapture(!!s.traffic_capture); setBrowserScreenshot(!!s.browser_screenshot_enabled); + setBrowserScreenshotTimestamp(!!s.browser_screenshot_timestamp_enabled); setAgentTrafficBinding(!!s.agent_traffic_binding); setWebSearch(!!s.web_search_enabled); setBackend(s.web_search_backend || "ddgs"); @@ -239,6 +241,22 @@ export default function SystemSettingsPage() { .finally(() => setSaving(false)); }; + const toggleBrowserScreenshotTimestamp = (v: boolean) => { + setBrowserScreenshotTimestamp(v); + setSaving(true); + api + .setSettings({ browser_screenshot_timestamp_enabled: v }) + .then((s) => { + apply(s); + toast.success(v ? "已开启截图时间标记" : "已关闭截图时间标记"); + }) + .catch((e) => { + setBrowserScreenshotTimestamp(!v); + toast.error(`保存失败:${(e as Error).message}`); + }) + .finally(() => setSaving(false)); + }; + const toggleInjectPlanner = (v: boolean) => { setInjectPlanner(v); // optimistic api @@ -493,6 +511,29 @@ export default function SystemSettingsPage() { + + + + + 截图时间标记 + + + 开启后,报告中的浏览器截图会在右上角显示截图保存时间,格式为“年月日 时:分:秒”(例如 2026-10-29 01:14:11)。该标记只影响截图展示,不改变原始截图文件。 + + + + + + + + diff --git a/web/src/components/finding-reproduction-panel.tsx b/web/src/components/finding-reproduction-panel.tsx index 4018138..bbb3d30 100644 --- a/web/src/components/finding-reproduction-panel.tsx +++ b/web/src/components/finding-reproduction-panel.tsx @@ -208,7 +208,9 @@ export function FindingReproductionPanel({ finding, contextTask }: { finding: Fi [finding.evidence, finding.report, finding.summary], ); const [traffic, setTraffic] = React.useState(undefined); - const overview = reportSection(finding.report ?? "", ["概述", "影响与危害", "受影响范围"]); + const overviewSections = ["概述", "影响与危害", "受影响范围"] + .map((heading) => ({ heading, content: reportSection(finding.report ?? "", [heading]) })) + .filter((item) => item.content); const outcome = reportSection(finding.report ?? "", ["最终结果"]); const rootCause = reportSection(finding.report ?? "", ["根因分析"]); const remediation = reportSection(finding.report ?? "", ["修复建议"]); @@ -242,11 +244,11 @@ export function FindingReproductionPanel({ finding, contextTask }: { finding: Fi - {overview ? ( -
- + {overviewSections.map((section) => ( +
+
- ) : null} + ))} {steps.map((step, index) => { const binding = traffic?.find((item) => item.position === index) ?? traffic?.[index]; return ( diff --git a/web/src/components/markdown.tsx b/web/src/components/markdown.tsx index 6120ffb..031fadf 100644 --- a/web/src/components/markdown.tsx +++ b/web/src/components/markdown.tsx @@ -1,9 +1,70 @@ "use client"; import * as React from "react"; -import ReactMarkdown, { type Components } from "react-markdown"; +import ReactMarkdown, { defaultUrlTransform, type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; +import { auth } from "@/lib/auth"; + +function AuthenticatedImage({ + src, + alt, + ...props +}: React.ImgHTMLAttributes) { + const [objectURL, setObjectURL] = React.useState(null); + const [captureTimestamp, setCaptureTimestamp] = React.useState(null); + const imageSrc = typeof src === "string" ? src : undefined; + + React.useEffect(() => { + let cancelled = false; + let url: string | null = null; + + if (!imageSrc || !imageSrc.startsWith("/api/workspace/preview")) { + setObjectURL(null); + setCaptureTimestamp(null); + return () => { + cancelled = true; + }; + } + + const token = auth.getToken(); + setCaptureTimestamp(null); + fetch(imageSrc, { + credentials: "same-origin", + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }) + .then((response) => { + if (!response.ok) throw new Error(`图片读取失败: ${response.status}`); + setCaptureTimestamp(response.headers.get("X-Report-Screenshot-Timestamp")); + return response.blob(); + }) + .then((blob) => { + if (cancelled) return; + url = URL.createObjectURL(blob); + setObjectURL(url); + }) + .catch(() => { + if (!cancelled) setObjectURL(null); + }); + + return () => { + cancelled = true; + if (url) URL.revokeObjectURL(url); + }; + }, [imageSrc]); + + return ( + + {alt + {captureTimestamp && ( + + {captureTimestamp} + + )} + + ); +} + // Tailwind-styled element overrides (no typography plugin in this project, so we // style each element). `node` is stripped — it's not a valid DOM attribute. const components: Components = { @@ -22,14 +83,20 @@ const components: Components = { const resolvedSrc = typeof src === "string" && src.startsWith("workspace://") ? `/api/workspace/preview?path=${encodeURIComponent(src.slice("workspace://".length))}` + : typeof src === "string" && src.startsWith("findings/screenshots/") + ? `/api/workspace/preview?path=${encodeURIComponent(src)}` : src; return ( - {alt ); }, @@ -64,7 +131,13 @@ const components: Components = { export function Markdown({ text }: { text: string }) { return (
- + + url.startsWith("workspace://") || url.startsWith("findings/screenshots/") ? url : defaultUrlTransform(url) + } + > {text}
diff --git a/web/src/components/side-question-workspace.tsx b/web/src/components/side-question-workspace.tsx index f59e814..1832860 100644 --- a/web/src/components/side-question-workspace.tsx +++ b/web/src/components/side-question-workspace.tsx @@ -248,6 +248,7 @@ export function SideQuestionWorkspace({ side, label, children, + mode = "split", composerLayout = "stacked", suggestions, placeholder, @@ -257,6 +258,7 @@ export function SideQuestionWorkspace({ side: SideQuestions; label: string; children: ReactNode; + mode?: "split" | "floating"; composerLayout?: ComposerLayout; suggestions?: string[]; placeholder?: string; @@ -265,9 +267,25 @@ export function SideQuestionWorkspace({ }) { const mobile = useIsMobile(); const desktopSideOpen = side.open && side.enabled && !mobile; + const floatingSideOpen = desktopSideOpen && mode === "floating"; return ( <> - {desktopSideOpen ? ( + {floatingSideOpen ? ( + <> +
{children}
+
+
+ +
+
+ + ) : desktopSideOpen ? ( Date: Thu, 29 Oct 2026 10:18:32 +0800 Subject: [PATCH 2/2] feat: add task traffic history and report recovery --- db/task_assets_context.go | 50 +- server/finding_report.go | 88 ++ server/server.go | 59 +- traffic/traffic.go | 82 +- traffic/traffic_store_test.go | 19 + .../(main)/function/findings/detail/page.tsx | 45 +- .../app/(main)/function/tasks/detail/page.tsx | 14 +- web/src/app/(main)/function/traffic/page.tsx | 874 +---------------- web/src/components/traffic-history.tsx | 894 ++++++++++++++++++ web/src/lib/api.ts | 10 +- 10 files changed, 1236 insertions(+), 899 deletions(-) create mode 100644 server/finding_report.go create mode 100644 web/src/components/traffic-history.tsx diff --git a/db/task_assets_context.go b/db/task_assets_context.go index cb2ddbe..aed6076 100644 --- a/db/task_assets_context.go +++ b/db/task_assets_context.go @@ -229,7 +229,6 @@ FROM assets a JOIN context_assets ctx ON ctx.id=a.id`, taskID) if err != nil { return nil, err } - defer rows.Close() hosts := map[string]struct{}{} add := func(host string) { host = strings.TrimSpace(strings.ToLower(host)) @@ -251,6 +250,55 @@ FROM assets a JOIN context_assets ctx ON ctx.id=a.id`, taskID) if err := rows.Err(); err != nil { return nil, err } + if err := rows.Close(); err != nil { + return nil, err + } + // A task may be scoped to domains before the asset graph has materialized + // every matching subdomain/service row. Include the declared scope values so + // task HTTP history still sees the real recorded traffic. Root domains use a + // wildcard marker understood by traffic.Page and therefore include subdomains. + scopes, err := s.ListTaskScopeWithSources(taskID) + if err != nil { + return nil, err + } + rootDomains := map[string]bool{} + for _, scope := range scopes { + domain := strings.TrimSpace(strings.ToLower(scope.Domain)) + switch scope.Kind { + case "root_domain": + if domain != "" { + add(domain) + rootDomains[domain] = false + } + case "subdomain": + if domain != "" { + add(domain) + } + case "ip", "cidr": + add(strings.TrimSpace(strings.ToLower(scope.Value))) + network := strings.TrimSpace(strings.ToLower(scope.Net)) + add(network) + if slash := strings.IndexByte(network, '/'); slash > 0 { + add(network[:slash]) + } + } + } + for _, scope := range scopes { + if scope.Kind != "subdomain" { + continue + } + domain := strings.TrimSpace(strings.ToLower(scope.Domain)) + for root := range rootDomains { + if domain == root || strings.HasSuffix(domain, "."+root) { + rootDomains[root] = true + } + } + } + for root, hasChildren := range rootDomains { + if !hasChildren { + add("*." + root) + } + } out := make([]string, 0, len(hosts)) for host := range hosts { out = append(out, host) diff --git a/server/finding_report.go b/server/finding_report.go new file mode 100644 index 0000000..6a66c86 --- /dev/null +++ b/server/finding_report.go @@ -0,0 +1,88 @@ +package server + +import ( + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/Autumn-27/artex/db" +) + +// generateFindingReport requeues the existing reporter workflow for a finding. +// This is intentionally separate from report_finding: a restart can leave a +// finding persisted while the one-shot trigger conversation is gone. +func (s *Server) generateFindingReport(w http.ResponseWriter, r *http.Request) { + id, ok := pathInt(r, "id") + if !ok || id <= 0 { + writeErr(w, http.StatusBadRequest, "bad finding id") + return + } + f, err := s.m.pg.GetFinding(id) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + if f == nil { + writeErr(w, http.StatusNotFound, "finding not found") + return + } + if f.NodeID == nil || *f.NodeID <= 0 { + writeErr(w, http.StatusConflict, "漏洞没有可用的探索节点,无法生成复现步骤") + return + } + reporter, err := s.m.pg.GetAgentByKey("reporter") + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + if reporter == nil || !reporter.Enabled { + writeErr(w, http.StatusConflict, "报告 Agent 未启用,请在 Agent 管理中启用 reporter") + return + } + if s.resolveChatAgent(&db.Conversation{AgentKey: reporter.Key}) == nil { + writeErr(w, http.StatusServiceUnavailable, s.chatUnavailableReason()) + return + } + if s.ctx.Err() != nil { + writeErr(w, http.StatusServiceUnavailable, "服务正在停止") + return + } + + var taskID int64 + var taskDesc, taskGoal string + if f.TaskID != nil { + taskID = *f.TaskID + if task := s.m.ResolveTask(strconv.FormatInt(taskID, 10)); task != nil { + taskDesc, taskGoal = task.Description, task.Goal + } + } + message := manualFindingReportMessage(f, taskID) + s.StartTriggeredRun( + "reporter", + fmt.Sprintf("人工生成步骤与报告 · finding#%d", f.ID), + message, + taskID, + false, + taskDesc, + taskGoal, + ) + writeJSON(w, http.StatusAccepted, map[string]any{ + "queued": true, + "finding_id": f.ID, + "finding_node_id": *f.NodeID, + }) +} + +func manualFindingReportMessage(f *db.DBFinding, taskID int64) string { + var b strings.Builder + b.WriteString("【人工重新生成步骤与报告】这是一个已经登记的漏洞,请补生成该漏洞的结构化复现步骤和完整详细报告;不要创建新的漏洞记录,也不要修改或删除原始证据。\n") + fmt.Fprintf(&b, "独立漏洞记录 ID(finding_id)=%d;探索节点 ID(finding_node_id)=%d。\n", f.ID, *f.NodeID) + if taskID > 0 { + fmt.Fprintf(&b, "来源任务 ID=%d。\n", taskID) + } + b.WriteString("先读取 get_finding_traffic(finding_id) 的当前证据清单及 version,再读取节点详情和任务执行记录;只使用实际保存的请求、响应和执行证据,不臆造步骤或数据包。\n") + b.WriteString("报告必须包含“概述”“影响与危害”“受影响范围”“复现步骤”“根因分析”“修复建议”,并在复现步骤中按“步骤 1、步骤 2 …”完整展开。数据包只能引用该漏洞已绑定的真实流量。\n") + b.WriteString("最后调用 update_finding_report:finding_id 使用探索节点 ID,evidence_version 使用刚读取的证据版本;报告正文保存为 Markdown。") + return b.String() +} diff --git a/server/server.go b/server/server.go index 3f8b2e6..723895e 100644 --- a/server/server.go +++ b/server/server.go @@ -846,6 +846,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/exploration/findings/{id}", s.getFinding) mux.HandleFunc("GET /api/exploration/findings/{id}/lineage", s.findingLineage) mux.HandleFunc("POST /api/exploration/findings/{id}/deepen", s.deepenFinding) + mux.HandleFunc("POST /api/exploration/findings/{id}/generate-report", s.generateFindingReport) mux.HandleFunc("GET /api/exploration/findings/{id}/retests", s.listFindingRetests) mux.HandleFunc("GET /api/exploration/findings/retests/active", s.listActiveFindingRetests) mux.HandleFunc("POST /api/exploration/findings/{id}/retests", s.startFindingRetest) @@ -3273,7 +3274,30 @@ func (s *Server) getTraffic(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() page := atoiDefault(q.Get("page"), 0) size := atoiDefault(q.Get("size"), 100) - ex, matched, _ := tr.Page(traffic.PageQuery{ + var taskHosts []string + taskScoped := false + if rawTaskID := strings.TrimSpace(q.Get("task_id")); rawTaskID != "" { + taskScoped = true + taskID, err := strconv.ParseInt(rawTaskID, 10, 64) + if err != nil || taskID <= 0 || s.m.ResolveTask(rawTaskID) == nil { + writeErr(w, http.StatusNotFound, "task not found") + return + } + taskHosts, err = s.m.Assets().HostsByTaskWithSources(taskID) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + } + if taskScoped && len(taskHosts) == 0 { + writeJSON(w, 200, map[string]any{ + "enabled": s.m.TrafficEnabled(), "proxy": s.m.ProxyAddr(), "count": 0, + "total": 0, "page": page, "size": size, "exchanges": []any{}, + }) + return + } + pageQuery := traffic.PageQuery{ + Hosts: taskHosts, Host: q.Get("host"), Method: q.Get("method"), Query: q.Get("q"), @@ -3284,8 +3308,14 @@ func (s *Server) getTraffic(w http.ResponseWriter, r *http.Request) { RespMax: int64(atoiDefault(q.Get("resp_max"), -1)), Sort: q.Get("sort"), Order: q.Get("order"), - }, page, size) + } + ex, matched, _ := tr.Page(pageQuery, page, size) count, _ := tr.Count() // global total, for the stat card + if taskScoped { + // matched already represents the current task scope plus all active + // filters. Reusing it avoids a second full SQLite scan on every refresh. + count = matched + } writeJSON(w, 200, map[string]any{ "enabled": s.m.TrafficEnabled(), // reflect the capture toggle "proxy": s.m.ProxyAddr(), @@ -3305,12 +3335,33 @@ func (s *Server) getTrafficHosts(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"hosts": []any{}}) return } - hosts, err := tr.Hosts() + var hosts []string + if rawTaskID := strings.TrimSpace(r.URL.Query().Get("task_id")); rawTaskID != "" { + taskID, err := strconv.ParseInt(rawTaskID, 10, 64) + if err != nil || taskID <= 0 || s.m.ResolveTask(rawTaskID) == nil { + writeErr(w, http.StatusNotFound, "task not found") + return + } + hosts, err = s.m.Assets().HostsByTaskWithSources(taskID) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + } + var result []traffic.HostCount + var err error + if len(hosts) > 0 { + result, err = tr.HostsForScope(hosts) + } else if r.URL.Query().Get("task_id") != "" { + result = []traffic.HostCount{} + } else { + result, err = tr.Hosts() + } if err != nil { writeErr(w, 500, err.Error()) return } - writeJSON(w, 200, map[string]any{"hosts": hosts}) + writeJSON(w, 200, map[string]any{"hosts": result}) } // deleteTraffic removes recorded traffic for every host containing the query's diff --git a/traffic/traffic.go b/traffic/traffic.go index ecd846b..bebdb1e 100644 --- a/traffic/traffic.go +++ b/traffic/traffic.go @@ -815,16 +815,51 @@ func ftsQuote(s string) string { // of -1 skip the response-size bounds. An empty Sort/Order defaults to // newest-first by timestamp. type PageQuery struct { - Host string // host substring - Method string // exact method (case-insensitive) - Query string // broad search box: metadata columns OR captured text - Body string // content box: captured request/response text only (full-text) - Path string // url_template substring — the vulnerability's path - Status string // exact code ("404") or class bucket ("4xx") - RespMin int64 // minimum resp_len, or -1 when unset - RespMax int64 // maximum resp_len, or -1 when unset - Sort string // ts | status | resp_len (default ts) - Order string // asc | desc (default desc) + Host string // host substring + Hosts []string // task-scoped host candidates (exact host or host:port) + Method string // exact method (case-insensitive) + Query string // broad search box: metadata columns OR captured text + Body string // content box: captured request/response text only (full-text) + Path string // url_template substring — the vulnerability's path + Status string // exact code ("404") or class bucket ("4xx") + RespMin int64 // minimum resp_len, or -1 when unset + RespMax int64 // maximum resp_len, or -1 when unset + Sort string // ts | status | resp_len (default ts) + Order string // asc | desc (default desc) +} + +// hostScopeClause limits traffic to the hosts attached to a task. Captures +// store the request Host header, which may include a port, while asset rows +// store normalized hostnames; match both forms without widening to subdomains. +func hostScopeClause(hosts []string) (string, []any) { + conditions := make([]string, 0, len(hosts)) + args := make([]any, 0, len(hosts)*2) + seen := make(map[string]struct{}, len(hosts)) + for _, raw := range hosts { + host := strings.ToLower(strings.TrimSpace(raw)) + if host == "" { + continue + } + if _, ok := seen[host]; ok { + continue + } + seen[host] = struct{}{} + if strings.HasPrefix(host, "*.") { + base := strings.TrimPrefix(host, "*.") + if base == "" { + continue + } + conditions = append(conditions, "(host=? OR host LIKE ? OR host LIKE ?)") + args = append(args, base, base+":%", "%."+base) + continue + } + conditions = append(conditions, "(host=? OR host LIKE ?)") + args = append(args, host, host+":%") + } + if len(conditions) == 0 { + return "", nil + } + return "(" + strings.Join(conditions, " OR ") + ")", args } // sortColumns whitelists the columns Page may order by, so the caller-supplied @@ -877,6 +912,9 @@ func (t *Traffic) Page(f PageQuery, page, size int) (rows []ExchangeMeta, total if h := strings.TrimSpace(f.Host); h != "" { add("host LIKE ?", "%"+h+"%") } + if cond, scopeArgs := hostScopeClause(f.Hosts); cond != "" { + add(cond, scopeArgs...) + } if m := strings.TrimSpace(f.Method); m != "" { add("method=?", strings.ToUpper(m)) } @@ -1014,6 +1052,30 @@ func (t *Traffic) Hosts() ([]HostCount, error) { return out, rows.Err() } +// HostsForScope returns recorded hosts that belong to a task's normalized host +// scope. It is used by the task HTTP history picker and never mutates traffic. +func (t *Traffic) HostsForScope(scope []string) ([]HostCount, error) { + cond, args := hostScopeClause(scope) + if cond == "" { + return []HostCount{}, nil + } + rows, err := t.db.Query(`SELECT host, COUNT(*) AS n, MAX(ts) AS last FROM exchanges WHERE `+cond+` GROUP BY host ORDER BY last DESC`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []HostCount + for rows.Next() { + var h HostCount + var last int64 + if err := rows.Scan(&h.Host, &h.Count, &last); err != nil { + return nil, err + } + out = append(out, h) + } + return out, rows.Err() +} + // Count returns total recorded exchanges. func (t *Traffic) Count() (int, error) { var n int diff --git a/traffic/traffic_store_test.go b/traffic/traffic_store_test.go index 647a6e2..718176f 100644 --- a/traffic/traffic_store_test.go +++ b/traffic/traffic_store_test.go @@ -414,6 +414,25 @@ func TestPageFiltersAndSort(t *testing.T) { } } +func TestPageHostScopeMatchesTaskHostsAndPorts(t *testing.T) { + tr, _ := openTraffic(t) + tr.record(newFlow("api.example.com", "GET", "/", nil, []byte("bare"))) + tr.record(newFlow("api.example.com:8443", "GET", "/", nil, []byte("port"))) + tr.record(newFlow("other.example.com", "GET", "/", nil, []byte("other"))) + rows, total, err := tr.Page(PageQuery{Hosts: []string{"api.example.com"}, RespMin: -1, RespMax: -1}, 0, 100) + if err != nil || total != 2 || len(rows) != 2 { + t.Fatalf("task host scope should match bare host and host:port, total=%d rows=%d err=%v", total, len(rows), err) + } + hosts, err := tr.HostsForScope([]string{"api.example.com"}) + if err != nil || len(hosts) != 1 { + t.Fatalf("scoped host picker should return 1 normalized host, len=%d err=%v", len(hosts), err) + } + rows, total, err = tr.Page(PageQuery{Hosts: []string{"*.example.com"}, RespMin: -1, RespMax: -1}, 0, 100) + if err != nil || total != 3 || len(rows) != 3 { + t.Fatalf("wildcard root scope should include subdomains, total=%d rows=%d err=%v", total, len(rows), err) + } +} + func TestQueryHostPortAndURLForms(t *testing.T) { tr, _ := openTraffic(t) tr.record(newFlow("api.example.com:8082", "GET", "/admin", nil, []byte("8082"))) diff --git a/web/src/app/(main)/function/findings/detail/page.tsx b/web/src/app/(main)/function/findings/detail/page.tsx index f724e92..bbd241c 100644 --- a/web/src/app/(main)/function/findings/detail/page.tsx +++ b/web/src/app/(main)/function/findings/detail/page.tsx @@ -5,7 +5,7 @@ import * as React from "react"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; -import { ArrowLeftIcon, ArrowUpRightIcon, ShieldAlertIcon } from "lucide-react"; +import { ArrowLeftIcon, ArrowUpRightIcon, FileTextIcon, Loader2Icon, ShieldAlertIcon } from "lucide-react"; import { toast } from "sonner"; import { CopyButton } from "@/components/copy-button"; @@ -71,6 +71,7 @@ function FindingDetailInner() { const [finding, setFinding] = React.useState(null); const [loaded, setLoaded] = React.useState(false); const [tab, setTab] = React.useState("overview"); + const [reportGenerating, setReportGenerating] = React.useState(false); const side = useSideQuestions(finding?.task_id ? `/api/tasks/${finding.task_id}/chat` : null); const load = React.useCallback(() => { @@ -122,6 +123,23 @@ function FindingDetailInner() { [finding, id], ); + const generateReport = React.useCallback(async () => { + if (!finding || finding.inherited || reportGenerating) return; + setReportGenerating(true); + try { + await api.generateFindingReport(id); + toast.success("已提交生成请求,报告 Agent 将补全步骤与详细报告"); + // The reporter runs asynchronously. Refresh once after it has had a + // chance to persist the report, while keeping the button available if + // the run is delayed or interrupted. + window.setTimeout(() => void load(), 2000); + } catch (e) { + toast.error("提交生成请求失败:" + (e as Error).message); + } finally { + setReportGenerating(false); + } + }, [finding, id, load, reportGenerating]); + if (!finding) { return (
@@ -226,16 +244,27 @@ function FindingDetailInner() { readOnly={finding.inherited} onChanged={load} /> - 详细报告 - {finding.report && } +
+ {finding.report && } + {!finding.inherited ? ( + + ) : null} +
{finding.report_stale ? ( diff --git a/web/src/app/(main)/function/tasks/detail/page.tsx b/web/src/app/(main)/function/tasks/detail/page.tsx index db99f6c..be52acc 100644 --- a/web/src/app/(main)/function/tasks/detail/page.tsx +++ b/web/src/app/(main)/function/tasks/detail/page.tsx @@ -9,6 +9,7 @@ import { ArchiveIcon, ArrowLeftIcon, BrainIcon, CheckIcon, CircleAlertIcon, Paus import { toast } from "sonner"; import { IntelligencePanel } from "@/components/intelligence-panel"; +import { TrafficHistory } from "@/components/traffic-history"; import { StatusBadge } from "@/components/status-badge"; import { TaskLLMProfileChain } from "@/components/task-llm-profile-chain"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; @@ -59,6 +60,7 @@ const TABS = [ { value: "findings", label: "发现" }, { value: "intelligence", label: "任务情报" }, { value: "retests", label: "复测" }, + { value: "traffic", label: "流量" }, { value: "assets", label: "测试资产" }, { value: "coverage", label: "资产覆盖图" }, { value: "intercept", label: "拦截审批" }, @@ -212,10 +214,13 @@ function TaskDetailInner() { const [paused, setPaused] = React.useState(false); const [loaded, setLoaded] = React.useState(false); const requestedTab = searchParams.get("tab") ?? "sessions"; - const [tab, setTab] = React.useState(TABS.some((item) => item.value === requestedTab) ? requestedTab : "sessions"); + const normalizedRequestedTab = requestedTab === "http-history" ? "traffic" : requestedTab; + const [tab, setTab] = React.useState( + TABS.some((item) => item.value === normalizedRequestedTab) ? normalizedRequestedTab : "sessions", + ); React.useEffect(() => { - if (TABS.some((item) => item.value === requestedTab)) setTab(requestedTab); - }, [requestedTab]); + if (TABS.some((item) => item.value === normalizedRequestedTab)) setTab(normalizedRequestedTab); + }, [normalizedRequestedTab]); const [interceptPendingCount, setInterceptPendingCount] = React.useState(0); const [profiles, setProfiles] = React.useState([]); const [archiving, setArchiving] = React.useState(false); @@ -441,6 +446,9 @@ function TaskDetailInner() { + + + diff --git a/web/src/app/(main)/function/traffic/page.tsx b/web/src/app/(main)/function/traffic/page.tsx index 21306c1..7b6b520 100644 --- a/web/src/app/(main)/function/traffic/page.tsx +++ b/web/src/app/(main)/function/traffic/page.tsx @@ -1,877 +1,7 @@ "use client"; -import * as React from "react"; - -import { useRouter } from "next/navigation"; - -import { - ArrowDownWideNarrowIcon, - ArrowUpNarrowWideIcon, - ChevronLeftIcon, - ChevronRightIcon, - EraserIcon, - FilterXIcon, - ListChecksIcon, - Loader2Icon, - RadioTowerIcon, - SearchIcon, - Trash2Icon, -} from "lucide-react"; -import { toast } from "sonner"; - -import { HttpCodeBlock } from "@/components/http-code-block"; -import { LinkTrafficDialog } from "@/components/link-traffic-dialog"; -import { requestFromTraffic } from "@/components/repeater/repeater-http"; -import { seedFromEvidence } from "@/components/repeater/repeater-store"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Input } from "@/components/ui/input"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; -import { SortableHead } from "@/components/ui/sortable-head"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { api } from "@/lib/api"; -import { useStoredSortPreference } from "@/lib/sort-preference"; -import type { TrafficDetail, TrafficExchange, TrafficHost, TrafficResp } from "@/lib/types"; -import { cn } from "@/lib/utils"; - -function fmtTime(ts: string) { - return new Date(ts).toLocaleString("zh-CN", { - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); -} - -function fmtBytes(n: number) { - if (n <= 0) return "0 B"; - // GB matters for the reclaimed-space figure a full purge reports; a capture-heavy - // instance can hand back several. - const units = ["B", "KB", "MB", "GB"]; - const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), units.length - 1); - const v = n / 1024 ** i; - return `${i === 0 ? v : v.toFixed(1)} ${units[i]}`; -} - -function statusTone(status: number) { - if (status >= 500) return "text-red-500"; - if (status >= 400) return "text-amber-500"; - if (status >= 300) return "text-blue-500"; - if (status >= 200) return "text-emerald-500"; - return "text-muted-foreground"; -} - -function MethodBadge({ method }: { method: string }) { - return {method}; -} - -// Older captures may predate Host persistence because net/http keeps Host -// outside Request.Header. Fill it for display while newly recorded traffic is -// fixed at the recorder layer as well. -function requestWithHost(raw: string, exchange: TrafficExchange): string { - if (!raw.trim() || /^host\s*:/im.test(raw)) return raw; - let host = exchange.host; - try { - host = new URL(exchange.url).host || host; - } catch { - // Relative or legacy URLs fall back to the indexed host. - } - const newline = raw.includes("\r\n") ? "\r\n" : "\n"; - const firstLineEnd = raw.indexOf(newline); - if (firstLineEnd < 0) return `${raw}${newline}Host: ${host}`; - return `${raw.slice(0, firstLineEnd + newline.length)}Host: ${host}${newline}${raw.slice(firstLineEnd + newline.length)}`; -} - -// Fixed method set (server filters exact-match); avoids deriving options from a -// single page, which would only ever list the methods on that page. -const METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]; -const PAGE_SIZES = [25, 50, 100, 200]; -type HostCountSortDirection = "asc" | "desc"; - -// Server-sortable columns. The list is sent to the backend verbatim as `sort`, -// which whitelists these same names, so keep them in sync with traffic.Page. -const SORT_FIELDS = ["ts", "status", "resp_len"] as const; -type SortField = (typeof SORT_FIELDS)[number]; -const SORT_STORAGE_KEY = "traffic-sort"; - -// Status-class buckets for the filter dropdown; the value is sent as `status`, -// which the backend reads as either an exact code or an "Nxx" class band. -const STATUS_BUCKETS = ["2xx", "3xx", "4xx", "5xx"]; +import { TrafficHistory } from "@/components/traffic-history"; export default function TrafficPage() { - const [selectedFlows, setSelectedFlows] = React.useState>(() => new Map()); - const router = useRouter(); - const [importing, setImporting] = React.useState(false); - const importLock = React.useRef(false); - const [linking, setLinking] = React.useState(false); - const [page, setPage] = React.useState(0); - const [size, setSize] = React.useState(50); - const [host, setHost] = React.useState(""); // raw host input - const [hostQ, setHostQ] = React.useState(""); // debounced → server - const [query, setQuery] = React.useState(""); // raw free-text input - const [queryQ, setQueryQ] = React.useState(""); // debounced → server - const [method, setMethod] = React.useState("all"); - - // Advanced filters (issue #177): response-body content, path, status class and - // response-size range. Text inputs are debounced like host/query; the status - // select applies immediately. - const [body, setBody] = React.useState(""); - const [bodyQ, setBodyQ] = React.useState(""); - const [path, setPath] = React.useState(""); - const [pathQ, setPathQ] = React.useState(""); - const [statusFilter, setStatusFilter] = React.useState("all"); - const [respMin, setRespMin] = React.useState(""); - const [respMinQ, setRespMinQ] = React.useState(""); - const [respMax, setRespMax] = React.useState(""); - const [respMaxQ, setRespMaxQ] = React.useState(""); - const [sort, setSort] = useStoredSortPreference(SORT_STORAGE_KEY, SORT_FIELDS, "ts", "desc"); - - const [traffic, setTraffic] = React.useState(null); - const [selected, setSelected] = React.useState(null); - const [detail, setDetail] = React.useState(null); - const [detailLoading, setDetailLoading] = React.useState(false); - - const [hosts, setHosts] = React.useState([]); // target picker - const [selectedHosts, setSelectedHosts] = React.useState([]); // checked in picker - const [pickerOpen, setPickerOpen] = React.useState(false); - const [hostCountSortDirection, setHostCountSortDirection] = React.useState("desc"); - - const [deleteMode, setDeleteMode] = React.useState<"filter" | "selected" | "all" | null>(null); // null = dialog closed - const [deleting, setDeleting] = React.useState(false); - const [reloadTick, setReloadTick] = React.useState(0); // manual refetch trigger - - // Debounce both filters so we don't refetch on every keystroke. - React.useEffect(() => { - const t = setTimeout(() => setHostQ(host.trim()), 300); - return () => clearTimeout(t); - }, [host]); - React.useEffect(() => { - const t = setTimeout(() => setQueryQ(query.trim()), 300); - return () => clearTimeout(t); - }, [query]); - React.useEffect(() => { - const t = setTimeout(() => setBodyQ(body.trim()), 300); - return () => clearTimeout(t); - }, [body]); - React.useEffect(() => { - const t = setTimeout(() => setPathQ(path.trim()), 300); - return () => clearTimeout(t); - }, [path]); - React.useEffect(() => { - const t = setTimeout(() => setRespMinQ(respMin.trim()), 300); - return () => clearTimeout(t); - }, [respMin]); - React.useEffect(() => { - const t = setTimeout(() => setRespMaxQ(respMax.trim()), 300); - return () => clearTimeout(t); - }, [respMax]); - - const hasAdvancedFilter = Boolean(bodyQ || pathQ || respMinQ || respMaxQ) || statusFilter !== "all"; - const resetAdvancedFilters = () => { - setBody(""); - setBodyQ(""); - setPath(""); - setPathQ(""); - setStatusFilter("all"); - setRespMin(""); - setRespMinQ(""); - setRespMax(""); - setRespMaxQ(""); - }; - - // Any filter/size/sort change resets to the first page. - // biome-ignore lint/correctness/useExhaustiveDependencies: these values intentionally trigger a page reset. - React.useEffect(() => { - setPage(0); - }, [hostQ, queryQ, method, size, bodyQ, pathQ, statusFilter, respMinQ, respMaxQ, sort]); - - // Load the current page. Auto-refresh only on page 0 (newest) so paging back - // through history isn't yanked out from under the user. - // biome-ignore lint/correctness/useExhaustiveDependencies: reloadTick is an explicit manual-refetch trigger. - React.useEffect(() => { - let alive = true; - const load = () => { - api - .traffic(page, size, hostQ, method, queryQ, { - body: bodyQ, - path: pathQ, - status: statusFilter, - respMin: respMinQ, - respMax: respMaxQ, - sort: sort.field, - order: sort.direction, - }) - .then((r) => { - if (alive) setTraffic(r); - }) - .catch(() => { - // Keep the last successful snapshot during transient refresh failures. - }); - api - .trafficHosts() - .then((r) => { - if (alive) setHosts(r.hosts ?? []); - }) - .catch(() => { - // Keep the last successful host list during transient refresh failures. - }); - }; - load(); - const t = setInterval(() => { - if (page !== 0) return; // only auto-refresh the newest page - load(); - }, 5000); - return () => { - alive = false; - clearInterval(t); - }; - }, [page, size, hostQ, method, queryQ, bodyQ, pathQ, statusFilter, respMinQ, respMaxQ, sort, reloadTick]); - - // Delete traffic for the current host filter (substring) or the checked - // hosts (exact batch), then refetch. - const allSelected = hosts.length > 0 && hosts.every((h) => selectedHosts.includes(h.host)); - const sortedHosts = React.useMemo( - () => - [...hosts].sort((a, b) => { - const countOrder = hostCountSortDirection === "asc" ? a.count - b.count : b.count - a.count; - return countOrder || a.host.localeCompare(b.host); - }), - [hosts, hostCountSortDirection], - ); - - // "清空" for the unfiltered purge, "删除" for the host-scoped ones — the dialog's - // title and its confirm button both follow from which is in play. - const deleteVerb = deleteMode === "all" ? "清空" : "删除"; - const deleteTitle = deleteMode - ? { - all: "清空全部流量记录?", - selected: `删除选中的 ${selectedHosts.length} 个目标的全部流量?`, - filter: "删除该目标的全部流量?", - }[deleteMode] - : ""; - - // `reclaimed` only comes back from the full purge; the host-scoped deletions - // report the row count alone. - const requestDelete = (mode: "filter" | "selected" | "all"): Promise<{ deleted: number; reclaimed?: number }> => { - if (mode === "all") return api.trafficDeleteAll(); - if (mode === "selected") return api.trafficDeleteHosts(selectedHosts); - return api.trafficDeleteHost(hostQ); - }; - - const confirmDelete = () => { - if (!deleteMode) return; - setDeleting(true); - const mode = deleteMode; - requestDelete(mode) - .then((r) => { - setDeleteMode(null); - setSelected(null); - setDetail(null); - if (mode !== "filter") { - setSelectedHosts([]); - setPickerOpen(false); - } - if (mode === "all") { - // Reclaimed space is the whole point of compacting an emptied index, so say so. - const reclaimed = r.reclaimed ?? 0; - const freed = reclaimed > 0 ? `,释放 ${fmtBytes(reclaimed)} 存储` : ""; - toast.success(`已清空 ${r.deleted} 条流量${freed}`); - } - setPage(0); - setReloadTick((t) => t + 1); - }) - .catch((e) => { - // Keep the confirmation open so the user can retry a failed deletion. - if (mode === "all") toast.error(`清空失败:${(e as Error).message}`); - }) - .finally(() => setDeleting(false)); - }; - - // Lazy-load the raw request/response for the selected exchange. - React.useEffect(() => { - if (!selected) { - setDetail(null); - return; - } - let alive = true; - setDetailLoading(true); - setDetail(null); - api - .trafficExchange(selected.id) - .then((d) => { - if (alive) setDetail(d); - }) - .catch(() => { - if (alive) setDetail({ req: "(无法加载报文)", resp: "" }); - }) - .finally(() => { - if (alive) setDetailLoading(false); - }); - return () => { - alive = false; - }; - }, [selected]); - - async function openRepeater(flows: TrafficExchange[]) { - if (!flows.length || importLock.current) return; - importLock.current = true; - setImporting(true); - let imported = 0; - try { - // Sequential loading limits pressure when a whole page is selected. A bad - // capture does not discard drafts imported successfully before it. - for (const flow of flows) { - try { - const raw = await api.trafficExchange(flow.id); - seedFromEvidence( - requestFromTraffic(raw.req, flow), - { label: `流量 ${flow.id}` }, - { title: `${flow.method} ${flow.host}` }, - ); - imported += 1; - } catch (error) { - toast.error(`${flow.host}:${(error as Error).message}`); - } - } - if (imported) { - toast.success(`已送入 ${imported} 条请求,可编辑后发送`); - setSelected(null); - router.push("/function/repeater"); - } - } finally { - importLock.current = false; - setImporting(false); - } - } - - // Toggle direction when re-clicking the active column, else sort the new column - // newest/largest-first. - const toggleSort = (field: SortField) => - setSort((prev) => - prev.field === field - ? { field, direction: prev.direction === "asc" ? "desc" : "asc" } - : { field, direction: "desc" }, - ); - - const exchanges = React.useMemo(() => traffic?.exchanges ?? [], [traffic]); - const total = traffic?.total ?? exchanges.length; - const pageCount = Math.max(1, Math.ceil(total / size)); - const rangeStart = total === 0 ? 0 : page * size + 1; - const rangeEnd = page * size + exchanges.length; - - return ( -
-
-
-

流量

-

全局录制代理 · 所有 HTTP 往来

-
-
- - - {traffic?.enabled ? "录制中" : "已停用"} - - {traffic?.proxy && {traffic.proxy}} - - 共 {traffic?.count ?? 0} 条 - -
-
- - {/* Toolbar */} -
- - - - - -
- 按目标批量删除 -
- {hosts.length > 0 && ( - - - - - 按数据包数量{hostCountSortDirection === "desc" ? "倒序" : "正序"} - - )} - {hosts.length > 0 && ( - - )} -
-
-
- {hosts.length === 0 ? ( -
暂无流量记录
- ) : ( - sortedHosts.map((h, index) => ( - - )) - )} -
-
- -
-
-
-
- setHost(e.target.value)} className="h-8" /> -
- - {/* Outline rather than a second destructive button: this one ignores every - filter, so it must not look one mis-click away from "删除该目标". */} - -
- - setQuery(e.target.value)} - className="h-8 pl-8" - /> -
- - - -
- - {rangeStart}–{rangeEnd} / {total} - - - - {page + 1} / {pageCount} - - -
-
- - {/* Advanced filters (issue #177): narrow 660k+ exchanges down to the one packet. */} -
- 高级筛选 -
- setBody(e.target.value)} - className="h-8" - /> -
-
- setPath(e.target.value)} - className="h-8" - /> -
- -
- 响应长度 - setRespMin(e.target.value)} - className="h-8 w-24" - /> - – - setRespMax(e.target.value)} - className="h-8 w-24" - /> -
- {hasAdvancedFilter ? ( - - ) : null} -
- -
- 已选 {selectedFlows.size} 条流量 - - - {selectedFlows.size > 0 ? ( - - ) : null} -
- {/* History table */} -
- -
- - - - - 0 && exchanges.every((e) => selectedFlows.has(e.id))} - onCheckedChange={(checked) => - setSelectedFlows((previous) => { - const next = new Map(previous); - for (const e of exchanges) { - if (checked === true) next.set(e.id, e); - else next.delete(e.id); - } - return next; - }) - } - /> - - - host - 方法 - URL - - content-type - - - - - {exchanges.map((e) => ( - setSelected(e)} - > - - event.stopPropagation()} - onCheckedChange={(checked) => - setSelectedFlows((previous) => { - const next = new Map(previous); - if (checked === true) next.set(e.id, e); - else next.delete(e.id); - return next; - }) - } - /> - - {fmtTime(e.ts)} - {e.host} - - - - - {e.url} - - - - {e.status} - - - {e.content_type} - {fmtBytes(e.resp_len)} - - ))} - {exchanges.length === 0 && ( - - - {traffic === null ? "加载中…" : "没有匹配的流量。"} - - - )} - -
-
-
-
- - {linking ? ( - setLinking(false)} - onBound={() => setSelectedFlows(new Map())} - /> - ) : null} - !open && setSelected(null)}> - - {selected && ( - <> - -
- - - {selected.status} - - {fmtTime(selected.ts)} -
- {selected.host} - {selected.url} - -
-
- {[ - ["request", "请求 Request", requestWithHost(detail?.req ?? "", selected)], - ["response", "响应 Response", detail?.resp ?? ""], - ].map(([key, label, raw]) => ( -
- {label} - {detailLoading ? ( -
- - 加载报文… -
- ) : ( - - )} -
- ))} -
- - )} -
-
- - { - if (!o) setDeleteMode(null); - }} - > - - - {deleteTitle} - - {deleteMode === "all" && ( - <> - 将永久删除全部 {traffic?.count ?? 0}{" "} - 条流量记录(含请求/响应原文),忽略当前的筛选条件,此操作不可撤销。已绑定到漏洞的流量证据保存在独立的证据库中,不受影响。 -
- - 清空后会顺带压实存储,把索引占用的磁盘空间还给系统;这期间流量录制会短暂暂停。 - - - )} - {deleteMode === "selected" && ( - <> - 将永久删除 {selectedHosts.length} 个目标( - - {selectedHosts.slice(0, 3).join("、")} - {selectedHosts.length > 3 ? "…" : ""} - - )的所有流量记录(含请求/响应原文),此操作不可撤销。 - - )} - {deleteMode === "filter" && ( - <> - 将永久删除 host 包含 {hostQ}{" "} - 的所有流量记录(含请求/响应原文),此操作不可撤销。 - - )} -
-
- - 取消 - { - e.preventDefault(); - confirmDelete(); - }} - disabled={deleting} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - {deleting ? `${deleteVerb}中…` : `确认${deleteVerb}`} - - -
-
-
- ); + return ; } diff --git a/web/src/components/traffic-history.tsx b/web/src/components/traffic-history.tsx new file mode 100644 index 0000000..47efb5b --- /dev/null +++ b/web/src/components/traffic-history.tsx @@ -0,0 +1,894 @@ +"use client"; + +import * as React from "react"; + +import { useRouter } from "next/navigation"; + +import { + ArrowDownWideNarrowIcon, + ArrowUpNarrowWideIcon, + ChevronLeftIcon, + ChevronRightIcon, + EraserIcon, + FilterXIcon, + ListChecksIcon, + Loader2Icon, + RadioTowerIcon, + SearchIcon, + Trash2Icon, +} from "lucide-react"; +import { toast } from "sonner"; + +import { HttpCodeBlock } from "@/components/http-code-block"; +import { LinkTrafficDialog } from "@/components/link-traffic-dialog"; +import { requestFromTraffic } from "@/components/repeater/repeater-http"; +import { seedFromEvidence } from "@/components/repeater/repeater-store"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; +import { SortableHead } from "@/components/ui/sortable-head"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { api } from "@/lib/api"; +import { useStoredSortPreference } from "@/lib/sort-preference"; +import type { TrafficDetail, TrafficExchange, TrafficHost, TrafficResp } from "@/lib/types"; +import { cn } from "@/lib/utils"; + +function fmtTime(ts: string) { + return new Date(ts).toLocaleString("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +function fmtBytes(n: number) { + if (n <= 0) return "0 B"; + // GB matters for the reclaimed-space figure a full purge reports; a capture-heavy + // instance can hand back several. + const units = ["B", "KB", "MB", "GB"]; + const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), units.length - 1); + const v = n / 1024 ** i; + return `${i === 0 ? v : v.toFixed(1)} ${units[i]}`; +} + +function statusTone(status: number) { + if (status >= 500) return "text-red-500"; + if (status >= 400) return "text-amber-500"; + if (status >= 300) return "text-blue-500"; + if (status >= 200) return "text-emerald-500"; + return "text-muted-foreground"; +} + +function MethodBadge({ method }: { method: string }) { + return {method}; +} + +// Older captures may predate Host persistence because net/http keeps Host +// outside Request.Header. Fill it for display while newly recorded traffic is +// fixed at the recorder layer as well. +function requestWithHost(raw: string, exchange: TrafficExchange): string { + if (!raw.trim() || /^host\s*:/im.test(raw)) return raw; + let host = exchange.host; + try { + host = new URL(exchange.url).host || host; + } catch { + // Relative or legacy URLs fall back to the indexed host. + } + const newline = raw.includes("\r\n") ? "\r\n" : "\n"; + const firstLineEnd = raw.indexOf(newline); + if (firstLineEnd < 0) return `${raw}${newline}Host: ${host}`; + return `${raw.slice(0, firstLineEnd + newline.length)}Host: ${host}${newline}${raw.slice(firstLineEnd + newline.length)}`; +} + +// Fixed method set (server filters exact-match); avoids deriving options from a +// single page, which would only ever list the methods on that page. +const METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]; +const PAGE_SIZES = [25, 50, 100, 200]; +type HostCountSortDirection = "asc" | "desc"; + +// Server-sortable columns. The list is sent to the backend verbatim as `sort`, +// which whitelists these same names, so keep them in sync with traffic.Page. +const SORT_FIELDS = ["ts", "status", "resp_len"] as const; +type SortField = (typeof SORT_FIELDS)[number]; +const SORT_STORAGE_KEY = "traffic-sort"; + +// Status-class buckets for the filter dropdown; the value is sent as `status`, +// which the backend reads as either an exact code or an "Nxx" class band. +const STATUS_BUCKETS = ["2xx", "3xx", "4xx", "5xx"]; + +export function TrafficHistory({ taskId }: { taskId?: string }) { + const taskScoped = Boolean(taskId); + const [selectedFlows, setSelectedFlows] = React.useState>(() => new Map()); + const router = useRouter(); + const [importing, setImporting] = React.useState(false); + const importLock = React.useRef(false); + const [linking, setLinking] = React.useState(false); + const [page, setPage] = React.useState(0); + const [size, setSize] = React.useState(50); + const [host, setHost] = React.useState(""); // raw host input + const [hostQ, setHostQ] = React.useState(""); // debounced → server + const [query, setQuery] = React.useState(""); // raw free-text input + const [queryQ, setQueryQ] = React.useState(""); // debounced → server + const [method, setMethod] = React.useState("all"); + + // Advanced filters (issue #177): response-body content, path, status class and + // response-size range. Text inputs are debounced like host/query; the status + // select applies immediately. + const [body, setBody] = React.useState(""); + const [bodyQ, setBodyQ] = React.useState(""); + const [path, setPath] = React.useState(""); + const [pathQ, setPathQ] = React.useState(""); + const [statusFilter, setStatusFilter] = React.useState("all"); + const [respMin, setRespMin] = React.useState(""); + const [respMinQ, setRespMinQ] = React.useState(""); + const [respMax, setRespMax] = React.useState(""); + const [respMaxQ, setRespMaxQ] = React.useState(""); + const [sort, setSort] = useStoredSortPreference(SORT_STORAGE_KEY, SORT_FIELDS, "ts", "desc"); + + const [traffic, setTraffic] = React.useState(null); + const [selected, setSelected] = React.useState(null); + const [detail, setDetail] = React.useState(null); + const [detailLoading, setDetailLoading] = React.useState(false); + + const [hosts, setHosts] = React.useState([]); // target picker + const [selectedHosts, setSelectedHosts] = React.useState([]); // checked in picker + const [pickerOpen, setPickerOpen] = React.useState(false); + const [hostCountSortDirection, setHostCountSortDirection] = React.useState("desc"); + + const [deleteMode, setDeleteMode] = React.useState<"filter" | "selected" | "all" | null>(null); // null = dialog closed + const [deleting, setDeleting] = React.useState(false); + const [reloadTick, setReloadTick] = React.useState(0); // manual refetch trigger + + // Debounce both filters so we don't refetch on every keystroke. + React.useEffect(() => { + const t = setTimeout(() => setHostQ(host.trim()), 300); + return () => clearTimeout(t); + }, [host]); + React.useEffect(() => { + const t = setTimeout(() => setQueryQ(query.trim()), 300); + return () => clearTimeout(t); + }, [query]); + React.useEffect(() => { + const t = setTimeout(() => setBodyQ(body.trim()), 300); + return () => clearTimeout(t); + }, [body]); + React.useEffect(() => { + const t = setTimeout(() => setPathQ(path.trim()), 300); + return () => clearTimeout(t); + }, [path]); + React.useEffect(() => { + const t = setTimeout(() => setRespMinQ(respMin.trim()), 300); + return () => clearTimeout(t); + }, [respMin]); + React.useEffect(() => { + const t = setTimeout(() => setRespMaxQ(respMax.trim()), 300); + return () => clearTimeout(t); + }, [respMax]); + + const hasAdvancedFilter = Boolean(bodyQ || pathQ || respMinQ || respMaxQ) || statusFilter !== "all"; + const resetAdvancedFilters = () => { + setBody(""); + setBodyQ(""); + setPath(""); + setPathQ(""); + setStatusFilter("all"); + setRespMin(""); + setRespMinQ(""); + setRespMax(""); + setRespMaxQ(""); + }; + + // Any filter/size/sort change resets to the first page. + // biome-ignore lint/correctness/useExhaustiveDependencies: these values intentionally trigger a page reset. + React.useEffect(() => { + setPage(0); + }, [hostQ, queryQ, method, size, bodyQ, pathQ, statusFilter, respMinQ, respMaxQ, sort, taskId]); + + // Load the current page. Auto-refresh only on page 0 (newest) so paging back + // through history isn't yanked out from under the user. + // biome-ignore lint/correctness/useExhaustiveDependencies: reloadTick is an explicit manual-refetch trigger. + React.useEffect(() => { + let alive = true; + const load = () => { + api + .traffic(page, size, hostQ, method, queryQ, { + taskId, + body: bodyQ, + path: pathQ, + status: statusFilter, + respMin: respMinQ, + respMax: respMaxQ, + sort: sort.field, + order: sort.direction, + }) + .then((r) => { + if (alive) setTraffic(r); + }) + .catch(() => { + // Keep the last successful snapshot during transient refresh failures. + }); + if (!taskScoped) { + api + .trafficHosts() + .then((r) => { + if (alive) setHosts(r.hosts ?? []); + }) + .catch(() => { + // Keep the last successful host list during transient refresh failures. + }); + } else if (alive) { + setHosts([]); + } + }; + load(); + const t = setInterval(() => { + if (page !== 0) return; // only auto-refresh the newest page + load(); + }, 5000); + return () => { + alive = false; + clearInterval(t); + }; + }, [page, size, hostQ, method, queryQ, bodyQ, pathQ, statusFilter, respMinQ, respMaxQ, sort, reloadTick, taskId, taskScoped]); + + // Delete traffic for the current host filter (substring) or the checked + // hosts (exact batch), then refetch. + const allSelected = hosts.length > 0 && hosts.every((h) => selectedHosts.includes(h.host)); + const sortedHosts = React.useMemo( + () => + [...hosts].sort((a, b) => { + const countOrder = hostCountSortDirection === "asc" ? a.count - b.count : b.count - a.count; + return countOrder || a.host.localeCompare(b.host); + }), + [hosts, hostCountSortDirection], + ); + + // "清空" for the unfiltered purge, "删除" for the host-scoped ones — the dialog's + // title and its confirm button both follow from which is in play. + const deleteVerb = deleteMode === "all" ? "清空" : "删除"; + const deleteTitle = deleteMode + ? { + all: "清空全部流量记录?", + selected: `删除选中的 ${selectedHosts.length} 个目标的全部流量?`, + filter: "删除该目标的全部流量?", + }[deleteMode] + : ""; + + // `reclaimed` only comes back from the full purge; the host-scoped deletions + // report the row count alone. + const requestDelete = (mode: "filter" | "selected" | "all"): Promise<{ deleted: number; reclaimed?: number }> => { + if (mode === "all") return api.trafficDeleteAll(); + if (mode === "selected") return api.trafficDeleteHosts(selectedHosts); + return api.trafficDeleteHost(hostQ); + }; + + const confirmDelete = () => { + if (!deleteMode) return; + setDeleting(true); + const mode = deleteMode; + requestDelete(mode) + .then((r) => { + setDeleteMode(null); + setSelected(null); + setDetail(null); + if (mode !== "filter") { + setSelectedHosts([]); + setPickerOpen(false); + } + if (mode === "all") { + // Reclaimed space is the whole point of compacting an emptied index, so say so. + const reclaimed = r.reclaimed ?? 0; + const freed = reclaimed > 0 ? `,释放 ${fmtBytes(reclaimed)} 存储` : ""; + toast.success(`已清空 ${r.deleted} 条流量${freed}`); + } + setPage(0); + setReloadTick((t) => t + 1); + }) + .catch((e) => { + // Keep the confirmation open so the user can retry a failed deletion. + if (mode === "all") toast.error(`清空失败:${(e as Error).message}`); + }) + .finally(() => setDeleting(false)); + }; + + // Lazy-load the raw request/response for the selected exchange. + React.useEffect(() => { + if (!selected) { + setDetail(null); + return; + } + let alive = true; + setDetailLoading(true); + setDetail(null); + api + .trafficExchange(selected.id) + .then((d) => { + if (alive) setDetail(d); + }) + .catch(() => { + if (alive) setDetail({ req: "(无法加载报文)", resp: "" }); + }) + .finally(() => { + if (alive) setDetailLoading(false); + }); + return () => { + alive = false; + }; + }, [selected]); + + async function openRepeater(flows: TrafficExchange[]) { + if (!flows.length || importLock.current) return; + importLock.current = true; + setImporting(true); + let imported = 0; + try { + // Sequential loading limits pressure when a whole page is selected. A bad + // capture does not discard drafts imported successfully before it. + for (const flow of flows) { + try { + const raw = await api.trafficExchange(flow.id); + seedFromEvidence( + requestFromTraffic(raw.req, flow), + { label: `流量 ${flow.id}` }, + { title: `${flow.method} ${flow.host}` }, + ); + imported += 1; + } catch (error) { + toast.error(`${flow.host}:${(error as Error).message}`); + } + } + if (imported) { + toast.success(`已送入 ${imported} 条请求,可编辑后发送`); + setSelected(null); + router.push("/function/repeater"); + } + } finally { + importLock.current = false; + setImporting(false); + } + } + + // Toggle direction when re-clicking the active column, else sort the new column + // newest/largest-first. + const toggleSort = (field: SortField) => + setSort((prev) => + prev.field === field + ? { field, direction: prev.direction === "asc" ? "desc" : "asc" } + : { field, direction: "desc" }, + ); + + const exchanges = React.useMemo(() => traffic?.exchanges ?? [], [traffic]); + const total = traffic?.total ?? exchanges.length; + const pageCount = Math.max(1, Math.ceil(total / size)); + const rangeStart = total === 0 ? 0 : page * size + 1; + const rangeEnd = page * size + exchanges.length; + + return ( +
+
+
+

流量

+

+ {taskScoped ? "当前任务资产范围内的 HTTP 往来" : "全局录制代理 · 所有 HTTP 往来"} +

+ {taskScoped && traffic && !traffic.enabled ? ( +

流量录制已关闭,历史记录仍可查看。

+ ) : null} +
+
+ + + {traffic?.enabled ? "录制中" : "已停用"} + + {traffic?.proxy && {traffic.proxy}} + + 共 {traffic?.count ?? 0} 条 + +
+
+ + {/* Toolbar */} +
+ {!taskScoped && ( + + + + + +
+ 按目标批量删除 +
+ {hosts.length > 0 && ( + + + + + 按数据包数量{hostCountSortDirection === "desc" ? "倒序" : "正序"} + + )} + {hosts.length > 0 && ( + + )} +
+
+
+ {hosts.length === 0 ? ( +
暂无流量记录
+ ) : ( + sortedHosts.map((h, index) => ( + + )) + )} +
+
+ +
+
+
+ )} +
+ setHost(e.target.value)} className="h-8" /> +
+ {!taskScoped && ( + + )} + {/* Outline rather than a second destructive button: this one ignores every + filter, so it must not look one mis-click away from "删除该目标". */} + {!taskScoped && ( + + )} +
+ + setQuery(e.target.value)} + className="h-8 pl-8" + /> +
+ + + +
+ + {rangeStart}–{rangeEnd} / {total} + + + + {page + 1} / {pageCount} + + +
+
+ + {/* Advanced filters (issue #177): narrow 660k+ exchanges down to the one packet. */} +
+ 高级筛选 +
+ setBody(e.target.value)} + className="h-8" + /> +
+
+ setPath(e.target.value)} + className="h-8" + /> +
+ +
+ 响应长度 + setRespMin(e.target.value)} + className="h-8 w-24" + /> + – + setRespMax(e.target.value)} + className="h-8 w-24" + /> +
+ {hasAdvancedFilter ? ( + + ) : null} +
+ +
+ 已选 {selectedFlows.size} 条流量 + + + {selectedFlows.size > 0 ? ( + + ) : null} +
+ {/* History table */} +
+ +
+ + + + + 0 && exchanges.every((e) => selectedFlows.has(e.id))} + onCheckedChange={(checked) => + setSelectedFlows((previous) => { + const next = new Map(previous); + for (const e of exchanges) { + if (checked === true) next.set(e.id, e); + else next.delete(e.id); + } + return next; + }) + } + /> + + + host + 方法 + URL + + content-type + + + + + {exchanges.map((e) => ( + setSelected(e)} + > + + event.stopPropagation()} + onCheckedChange={(checked) => + setSelectedFlows((previous) => { + const next = new Map(previous); + if (checked === true) next.set(e.id, e); + else next.delete(e.id); + return next; + }) + } + /> + + {fmtTime(e.ts)} + {e.host} + + + + + {e.url} + + + + {e.status} + + + {e.content_type} + {fmtBytes(e.resp_len)} + + ))} + {exchanges.length === 0 && ( + + + {traffic === null ? "加载中…" : "没有匹配的流量。"} + + + )} + +
+
+
+
+ + {linking ? ( + setLinking(false)} + onBound={() => setSelectedFlows(new Map())} + /> + ) : null} + !open && setSelected(null)}> + + {selected && ( + <> + +
+ + + {selected.status} + + {fmtTime(selected.ts)} +
+ {selected.host} + {selected.url} + +
+
+ {[ + ["request", "请求 Request", requestWithHost(detail?.req ?? "", selected)], + ["response", "响应 Response", detail?.resp ?? ""], + ].map(([key, label, raw]) => ( +
+ {label} + {detailLoading ? ( +
+ + 加载报文… +
+ ) : ( + + )} +
+ ))} +
+ + )} +
+
+ + { + if (!o) setDeleteMode(null); + }} + > + + + {deleteTitle} + + {deleteMode === "all" && ( + <> + 将永久删除全部 {traffic?.count ?? 0}{" "} + 条流量记录(含请求/响应原文),忽略当前的筛选条件,此操作不可撤销。已绑定到漏洞的流量证据保存在独立的证据库中,不受影响。 +
+ + 清空后会顺带压实存储,把索引占用的磁盘空间还给系统;这期间流量录制会短暂暂停。 + + + )} + {deleteMode === "selected" && ( + <> + 将永久删除 {selectedHosts.length} 个目标( + + {selectedHosts.slice(0, 3).join("、")} + {selectedHosts.length > 3 ? "…" : ""} + + )的所有流量记录(含请求/响应原文),此操作不可撤销。 + + )} + {deleteMode === "filter" && ( + <> + 将永久删除 host 包含 {hostQ}{" "} + 的所有流量记录(含请求/响应原文),此操作不可撤销。 + + )} +
+
+ + 取消 + { + e.preventDefault(); + confirmDelete(); + }} + disabled={deleting} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {deleting ? `${deleteVerb}中…` : `确认${deleteVerb}`} + + +
+
+
+ ); +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 39b48f3..65914bc 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -714,6 +714,11 @@ export const api = { get( `/exploration/findings/${id}${contextTaskId ? `?context_task=${encodeURIComponent(contextTaskId)}` : ""}`, ), + generateFindingReport: (id: string) => + post<{ queued: boolean; finding_id: number; finding_node_id: number }>( + `/exploration/findings/${encodeURIComponent(id)}/generate-report`, + {}, + ), findingTraffic: (id: string, contextTask?: string) => get( `/exploration/findings/${id}/traffic${contextTask ? `?context_task=${encodeURIComponent(contextTask)}` : ""}`, @@ -902,6 +907,7 @@ export const api = { method = "", q = "", opts: { + taskId?: string; body?: string; path?: string; status?: string; @@ -913,6 +919,7 @@ export const api = { ) => get( `/traffic?page=${page}&size=${size}` + + (opts.taskId ? `&task_id=${encodeURIComponent(opts.taskId)}` : "") + (host ? `&host=${encodeURIComponent(host)}` : "") + (method && method !== "all" ? `&method=${encodeURIComponent(method)}` : "") + (q ? `&q=${encodeURIComponent(q)}` : "") + @@ -925,7 +932,8 @@ export const api = { (opts.order ? `&order=${encodeURIComponent(opts.order)}` : ""), ), trafficExchange: (id: string) => get(`/traffic/exchange?id=${encodeURIComponent(id)}`), - trafficHosts: () => get<{ hosts: TrafficHost[] }>(`/traffic/hosts`), + trafficHosts: (taskId?: string) => + get<{ hosts: TrafficHost[] }>(`/traffic/hosts${taskId ? `?task_id=${encodeURIComponent(taskId)}` : ""}`), trafficDeleteHost: (host: string) => del<{ deleted: number }>(`/traffic?host=${encodeURIComponent(host)}`), trafficDeleteHosts: (hosts: string[]) => del<{ deleted: number }>(`/traffic/hosts`, { hosts }), // Purges every exchange and compacts the index; `reclaimed` is the bytes of