Compare commits

..
Author SHA1 Message Date
ji ruo 404c4bb248 feat: add finding side question panel 2026-10-28 13:17:38 +08:00
12 changed files with 125 additions and 569 deletions

No files matched your search

-9
View File
@@ -90,20 +90,11 @@ const ReporterDefaultPrompt = `你是一个授权渗透测试系统里的**漏
4. **写报告**:综合以上,写一份结构化 Markdown 报告(见下方模板)。
5. **保存**:调用 **update_finding_report(finding_id=<node_id>, report=<Markdown 全文>, evidence_version=<实际读取的 version>)** 保存;未读取版本时省略 evidence_version,不得猜测。这是你的最终产物——不写进去等于没做。
━━ 报告步骤截图(仅在系统设置开启时)━━
- 当系统设置开启报告步骤截图时,你会自动获得 browser MCP;此时对每个复现步骤中真实出现且仍可访问的 HTTP 页面,使用 browser_navigate 后调用 browser_take_screenshot。
- 只访问漏洞证据中出现的 URL,不扩大测试范围。把截图保存到当前工作目录的 findings/screenshots/ 下,命名 finding-<finding_id>-step-<n>.png;确认文件存在后,在对应步骤中加入 Markdown 图片链接:![步骤 <n> 截图](workspace://<截图绝对路径>)。
- 截图失败时如实说明;系统设置关闭或 browser MCP 不可用时,不要伪造图片链接,也不要把文字描述冒充截图。
━━ 报告结构(Markdown,按需裁剪,但证据/复现/修复必须有)━━
- ` + "`## 概述`" + `:一句话说清是什么漏洞、在哪、能造成什么。
- ` + "`## 影响与危害`" + `:结合业务讲清最坏后果(数据泄露/接管/RCE/横向…),给出**严重等级**判断及理由。
- ` + "`## 受影响范围`" + `:受影响的资产/接口/参数/版本。
- ` + "`## 复现步骤`" + `:**可照做复现**的分步操作(请求/命令/参数),能贴 PoC 就贴。
- 复现步骤必须使用明确的编号标题:` + "`### 步骤 1:准备环境`" + `、` + "`### 步骤 2:构造请求`" + `……;每一步都要写清操作、输入、预期结果、实际结果和证据来源。不要只写“步骤一”或把多步合并成一段。
- 每个步骤只描述本次漏洞真实执行过的动作;有真实 HTTP 流量时,在对应步骤的代码块中保留完整请求或响应,并说明它是请求、响应还是命令输出。没有证据的内容标记为“待补充/未验证”。
- 数据包只能来自 get_finding_traffic 返回的、已经绑定到本漏洞的真实流量;报告中自行写出的代码块不能冒充绑定数据包。没有绑定流量时明确写“暂无已绑定流量证据”,不要编造请求/响应。
- 最新报告将概述、影响、复现步骤、最终结果、根因分析和修复建议合并在同一份报告中;不要再额外生成一份脱离步骤的“详细报告”。
- ` + "`## 证据`" + `:证明漏洞真实存在的关键请求/响应片段、命令输出、回显、截图说明——用代码块贴原文。
- ` + "`## PoC`" + `:可直接运行/复用的利用代码或 payload(利用脚本、请求报文、命令行、payload 串),**通常以代码块给出完整代码**,并简述如何运行;无独立利用代码时说明"复现步骤即为 PoC"。
- ` + "`## 根因分析`" + `:为什么会有这个漏洞(缺校验/危险函数/配置错误…)。
-12
View File
@@ -96,13 +96,6 @@ 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())
@@ -187,11 +180,6 @@ 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 {
+4 -5
View File
@@ -264,7 +264,6 @@ 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"
@@ -274,11 +273,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/任务显式
+20 -61
View File
@@ -199,12 +199,6 @@ 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-<finding_id>-step-<n>.png;确认文件存在后,在对应步骤中加入 Markdown 图片:![步骤 <n> 截图](workspace://<截图绝对路径>)。截图失败时如实写明,不要用占位图或把截图描述冒充图片。`
}
return t, true
}
// Wire DB-stored wrap-up (settlement) prompts. Empty column → built-in default.
@@ -778,7 +772,6 @@ 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)
@@ -3449,27 +3442,26 @@ 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(),
"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(),
@@ -3528,7 +3520,6 @@ 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"`
@@ -3724,16 +3715,6 @@ 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())
@@ -3785,28 +3766,6 @@ 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
-32
View File
@@ -225,38 +225,6 @@ func (s *Server) wsDownload(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, abs)
}
// GET /api/workspace/preview?path=<rel> — 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=<dir> — 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"))
@@ -75,12 +75,6 @@ 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.
@@ -452,8 +446,8 @@ export function FindingsTable({
{f.evidence}
</pre>
{/* 旧版详细报告保留;新版合并报告在详情页按概览/步骤/根因/修复展示。 */}
{f.finding_id && !isStructuredReproductionReport(reports[rowKey]?.text ?? "") && (
{/* 详细报告(Markdown):展开时按 finding_id 懒加载,免进详情页即可查看。 */}
{f.finding_id && (
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span className="flex items-center gap-2">
@@ -10,9 +10,9 @@ import { toast } from "sonner";
import { CopyButton } from "@/components/copy-button";
import { FindingRetestPanel } from "@/components/finding-retest-panel";
import { FindingReproductionPanel } from "@/components/finding-reproduction-panel";
import { FindingTrafficPanel } from "@/components/finding-traffic-panel";
import { Markdown } from "@/components/markdown";
import { SideQuestionButton, SideQuestionWorkspace } from "@/components/side-question-workspace";
import { StatusBadge } from "@/components/status-badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
@@ -22,6 +22,7 @@ import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger } from "@
import { Separator } from "@/components/ui/separator";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useSideQuestions } from "@/hooks/use-side-questions";
import { api } from "@/lib/api";
import { statusMeta } from "@/lib/status";
import type { Finding, FindingStatus, Severity } from "@/lib/types";
@@ -45,12 +46,6 @@ 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 (
@@ -69,6 +64,7 @@ function FindingDetailInner() {
const [finding, setFinding] = React.useState<Finding | null>(null);
const [loaded, setLoaded] = React.useState(false);
const [tab, setTab] = React.useState("overview");
const side = useSideQuestions(finding?.task_id ? `/api/tasks/${finding.task_id}/chat` : null);
const load = React.useCallback(() => {
if (!id) {
@@ -135,10 +131,15 @@ function FindingDetailInner() {
}
const title = finding.name || finding.vulnclass || "未分类";
const structuredReproduction = usesStructuredReproduction(finding.report || "");
return (
<Tabs value={tab} onValueChange={setTab} className="flex flex-1 flex-col gap-0">
<SideQuestionWorkspace
side={side}
label={`漏洞 #${finding.id} · ${title}`}
suggestions={["请解释这个漏洞的影响范围和利用条件。", "请根据当前证据检查这个漏洞是否足以确认。", "请给出可执行的修复建议。"]}
placeholder="询问当前漏洞…"
>
<Tabs value={tab} onValueChange={setTab} className="flex flex-1 flex-col gap-0">
{/* Sticky header */}
<header className="sticky top-0 z-10 flex flex-col gap-2 border-b bg-background/95 px-4 py-2.5 backdrop-blur lg:px-6">
<div className="flex flex-wrap items-center gap-2">
@@ -165,6 +166,7 @@ function FindingDetailInner() {
<Separator orientation="vertical" className="mx-1 h-4" />
<StatusBadge domain="severity" value={finding.severity} dot />
<StatusBadge domain="finding" value={finding.status} dot />
<SideQuestionButton side={side} label="询问漏洞" />
{finding.inherited && finding.source_task_id && (
<Badge variant="outline">来源任务 #{finding.source_task_id} · 只读</Badge>
)}
@@ -190,65 +192,47 @@ function FindingDetailInner() {
<p className="text-sm leading-relaxed whitespace-pre-wrap">{finding.summary || "(无摘要)"}</p>
</CardContent>
</Card>
{structuredReproduction ? (
<>
<FindingReproductionPanel finding={finding} contextTask={contextTaskId || undefined} />
<FindingRetestPanel
key={`retest:${id}`}
findingId={id}
readOnly={finding.inherited}
onCompleted={load}
/>
</>
) : (
<>
<Card>
<CardHeader>
<CardTitle className="text-sm">证据 / PoC</CardTitle>
</CardHeader>
<CardContent>
{finding.evidence ? (
<pre className="max-h-[46vh] overflow-auto rounded-md bg-muted px-3 py-2 font-mono text-xs whitespace-pre-wrap">
{finding.evidence}
</pre>
) : (
<p className="text-sm text-muted-foreground">(无证据)</p>
)}
</CardContent>
</Card>
<FindingTrafficPanel
key={`traffic:${id}`}
findingId={id}
contextTask={contextTaskId || undefined}
readOnly={finding.inherited}
onChanged={load}
/>
<FindingRetestPanel
key={`retest:${id}`}
findingId={id}
readOnly={finding.inherited}
onCompleted={load}
/>
<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle className="text-sm">详细报告</CardTitle>
{finding.report && <CopyButton text={finding.report} successMessage="已复制详细报告" />}
</CardHeader>
<CardContent>
{finding.report_stale ? (
<Alert>
<AlertDescription>流量证据已变更,详细报告待更新。</AlertDescription>
</Alert>
) : null}
{finding.report ? (
<Markdown text={finding.report} />
) : (
<p className="text-sm text-muted-foreground">暂无详细报告。</p>
)}
</CardContent>
</Card>
</>
)}
<FindingRetestPanel key={`retest:${id}`} findingId={id} readOnly={finding.inherited} onCompleted={load} />
<Card>
<CardHeader>
<CardTitle className="text-sm">证据 / PoC</CardTitle>
</CardHeader>
<CardContent>
{finding.evidence ? (
<pre className="max-h-[46vh] overflow-auto rounded-md bg-muted px-3 py-2 font-mono text-xs whitespace-pre-wrap">
{finding.evidence}
</pre>
) : (
<p className="text-sm text-muted-foreground">(无证据)</p>
)}
</CardContent>
</Card>
<FindingTrafficPanel
key={`traffic:${id}`}
findingId={id}
contextTask={contextTaskId || undefined}
readOnly={finding.inherited}
onChanged={load}
/>
{/* 证据下方:详细报告(Markdown 渲染) */}
<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle className="text-sm">详细报告</CardTitle>
{finding.report && <CopyButton text={finding.report} successMessage="已复制详细报告" />}
</CardHeader>
<CardContent>
{finding.report_stale ? (
<Alert>
<AlertDescription>流量证据已变更,详细报告待更新。</AlertDescription>
</Alert>
) : null}
{finding.report ? (
<Markdown text={finding.report} />
) : (
<p className="text-sm text-muted-foreground">暂无详细报告。</p>
)}
</CardContent>
</Card>
</div>
{/* 右栏:状态区 */}
@@ -366,7 +350,8 @@ function FindingDetailInner() {
<FindingLineageView findingId={finding.id} />
</TabsContent>
</div>
</Tabs>
</Tabs>
</SideQuestionWorkspace>
);
}
+38 -72
View File
@@ -2,15 +2,7 @@
import * as React from "react";
import {
CameraIcon,
CpuIcon,
FlaskConicalIcon,
KeyboardIcon,
RadioTowerIcon,
SearchIcon,
ShieldAlertIcon,
} from "lucide-react";
import { CpuIcon, FlaskConicalIcon, KeyboardIcon, RadioTowerIcon, SearchIcon, ShieldAlertIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -37,7 +29,6 @@ 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");
@@ -95,7 +86,6 @@ 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");
@@ -223,22 +213,6 @@ 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
@@ -288,7 +262,9 @@ 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<Settings> =
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)
@@ -521,31 +497,6 @@ export default function SystemSettingsPage() {
</CardContent>
</Card>
<Card className="mb-4 break-inside-avoid md:mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CameraIcon className="size-4" />
浏览器截图
</CardTitle>
<CardDescription>
开启后,报告撰写 Agent 会自动获得 browser
MCP,并在复现步骤对应的页面上保存截图,报告中直接显示对应步骤截图。 截图会增加 Token
消耗;关闭后不会自动给报告撰写 Agent 授权截图能力,已有历史报告保留原截图。
</CardDescription>
</CardHeader>
<CardContent className="flex items-center justify-between gap-4">
<Label htmlFor="browser-screenshot" className="text-sm font-normal text-muted-foreground">
{browserScreenshot ? "已开启 · 报告撰写 Agent 自动截图" : "已关闭 · 报告不自动截图"}
</Label>
<Switch
id="browser-screenshot"
checked={browserScreenshot}
disabled={!loaded || saving}
onCheckedChange={toggleBrowserScreenshot}
/>
</CardContent>
</Card>
<Card className="mb-4 break-inside-avoid md:mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
@@ -559,8 +510,9 @@ export default function SystemSettingsPage() {
开启<b>流量捕获</b>时,它作为记录代理的<b>上游</b>(流量仍全量落库,再经此代理出网);关闭捕获时,直接注入
Agent 的 bash / WebFetch 出网。与网络搜索代理、LLM 代理相互独立。
<br />
<b>提示</b>:socks5 在<b>关闭捕获</b>时依赖各命令行工具对 <code>ALL_PROXY</code> 的支持——curl 可用,python
urllib 等会直接报错; 若主要用 socks5,建议开启流量捕获——此路径由 MITM 亲自拨号,工具无感知、稳定生效。
<b>提示</b>:socks5 在<b>关闭捕获</b>时依赖各命令行工具对 <code>ALL_PROXY</code> 的支持——curl
可用,python urllib 等会直接报错; 若主要用 socks5,建议开启流量捕获——此路径由 MITM
亲自拨号,工具无感知、稳定生效。
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-2">
@@ -585,7 +537,12 @@ export default function SystemSettingsPage() {
保存
</Button>
{/* 测表单里当前的值,不要求先保存。 */}
<Button type="button" variant="outline" onClick={testEgress} disabled={!loaded || egressTesting}>
<Button
type="button"
variant="outline"
onClick={testEgress}
disabled={!loaded || egressTesting}
>
{egressTesting ? "测试中…" : "测试"}
</Button>
</div>
@@ -610,13 +567,15 @@ export default function SystemSettingsPage() {
</div>
{/* 紧贴结果区:用户看两行时正好需要它。细节放文档。 */}
<p className="text-muted-foreground text-xs leading-relaxed">
点<b>测试</b>会访问 IP 回显服务(默认 <code>api.ipify.org</code>,失败自动换 <code>ifconfig.me</code>
),它返回"这个请求从哪个 IP 来"。 ① 是不走代理时的 IP,② 是经全局代理出网时的
IP。改完不用先保存,直接测。
点<b>测试</b>会访问 IP 回显服务(默认 <code>api.ipify.org</code>,失败自动换{" "}
<code>ifconfig.me</code>),它返回"这个请求从哪个 IP 来"。
① 是不走代理时的 IP,② 是经全局代理出网时的 IP。改完不用先保存,直接测。
</p>
{/* 内网里公网回显不可达,两行都会失败——不是代理坏了。 */}
<details className="text-xs">
<summary className="text-muted-foreground cursor-pointer select-none">自定义 IP 探测服务(可选)</summary>
<summary className="text-muted-foreground cursor-pointer select-none">
自定义 IP 探测服务(可选)
</summary>
<div className="mt-2 flex flex-col gap-1">
<Input
autoComplete="off"
@@ -628,9 +587,10 @@ export default function SystemSettingsPage() {
}}
/>
<span className="text-muted-foreground leading-relaxed">
自定义 IP 探测服务需返回纯文本 IP 或 <code>{'{"ip":"1.2.3.4"}'}</code> 格式的 JSON。
若内网环境下无法访问公网,或本机和内网代理共用同一个公网出口, 则可替换为内网 IP 探测服务,此时 ① 和 ②
的 IP 不同,就说明流量走了内网代理。
自定义 IP 探测服务需返回纯文本 IP 或{" "}
<code>{'{"ip":"1.2.3.4"}'}</code> 格式的 JSON。
若内网环境下无法访问公网,或本机和内网代理共用同一个公网出口,
则可替换为内网 IP 探测服务,此时 ① 和 ② 的 IP 不同,就说明流量走了内网代理。
</span>
</div>
</details>
@@ -643,7 +603,11 @@ export default function SystemSettingsPage() {
<div key={c.key} className="flex flex-wrap items-center gap-x-2">
<span
className={
c.skipped ? "text-muted-foreground" : c.result.ok ? "text-emerald-600" : "text-destructive"
c.skipped
? "text-muted-foreground"
: c.result.ok
? "text-emerald-600"
: "text-destructive"
}
>
{c.skipped ? "—" : c.result.ok ? "✓" : "✗"}
@@ -665,12 +629,18 @@ export default function SystemSettingsPage() {
<span className="text-muted-foreground">{c.skip_reason}</span>
) : c.result.ok ? (
<span className="font-mono">
<span title="外界看到的出口 IP(探测站点回显的客户端地址)">{c.result.exit_ip}</span> ·{" "}
<span title="外界看到的出口 IP(探测站点回显的客户端地址)">
{c.result.exit_ip}
</span>{" "}
·{" "}
<span title="从 ARTEX 发出请求到拿到响应的完整往返耗时;经代理时包含代理那一跳">
{c.result.latency_ms}ms
</span>
{c.result.via && c.result.via !== "direct" && !viaCapture && (
<span className="text-muted-foreground"> · 经 {c.result.via_label ?? c.result.via}</span>
<span className="text-muted-foreground">
{" "}
· 经 {c.result.via_label ?? c.result.via}
</span>
)}
</span>
) : (
@@ -763,9 +733,7 @@ export default function SystemSettingsPage() {
<Card className="mb-4 break-inside-avoid md:mb-6">
<CardHeader>
<CardTitle>自动标题</CardTitle>
<CardDescription>
为 Agent 创建的任务和对话首条消息生成简短标题。两个开关独立生效,默认关闭。
</CardDescription>
<CardDescription>为 Agent 创建的任务和对话首条消息生成简短标题。两个开关独立生效,默认关闭。</CardDescription>
</CardHeader>
<CardContent>
<Field orientation="horizontal">
@@ -783,9 +751,7 @@ export default function SystemSettingsPage() {
<Field orientation="horizontal" className="mt-5">
<FieldContent>
<FieldLabel htmlFor="chat-auto-title">会话标题</FieldLabel>
<FieldDescription>
对话首条消息后自动生成会话标题;先显示消息截断文本,失败时保留截断标题。
</FieldDescription>
<FieldDescription>对话首条消息后自动生成会话标题;先显示消息截断文本,失败时保留截断标题。</FieldDescription>
</FieldContent>
<Switch
id="chat-auto-title"
@@ -1,279 +0,0 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { ChevronDownIcon, ChevronRightIcon, LoaderCircleIcon } from "lucide-react";
import { toast } from "sonner";
import { HttpCodeBlock } from "@/components/http-code-block";
import { Markdown } from "@/components/markdown";
import { headersFromReqHead, requestFromEvidence } from "@/components/repeater/repeater-http";
import { seedFromEvidence } from "@/components/repeater/repeater-store";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { api } from "@/lib/api";
import type { Finding, FindingTrafficBinding, FindingTrafficDetail } from "@/lib/types";
type ReproductionStep = {
number: number;
title: string;
body: string;
};
function extractSection(report: string) {
const match = report.match(/^##\s+(?:复现步骤|测试过程|复现过程)\s*$/im);
if (!match || match.index === undefined) return "";
const start = match.index + match[0].length;
const tail = report.slice(start);
const end = tail.search(/^##\s+/m);
return (end >= 0 ? tail.slice(0, end) : tail).trim();
}
function withoutPacket(text: string) {
return text.replace(/```(?:http|bash|shell|sh|json|text)?\s*\n[\s\S]*?```/gi, "").trim();
}
function parseSteps(report: string, fallback: string): ReproductionStep[] {
const section = extractSection(report);
if (!section) {
return [
{
number: 1,
title: "执行验证并确认结果",
body: fallback.trim() || "暂无结构化复现步骤,查看下方原始报告。",
},
];
}
const markers = [...section.matchAll(/^\s*(?:###\s*)?(?:步骤\s*)?(\d+)[.、::))]\s*(.+)$/gim)];
if (markers.length === 0) {
return [{ number: 1, title: "复现过程", body: withoutPacket(section) }];
}
return markers.map((marker, index) => {
const start = marker.index ?? 0;
const end = markers[index + 1]?.index ?? section.length;
const raw = section.slice(start, end).trim();
const firstLine = marker[2].trim();
const rest = raw.slice(marker[0].length).trim();
return {
number: Number(marker[1]),
title: firstLine,
body: withoutPacket(rest),
};
});
}
function reportSection(report: string, headings: string[]) {
const headingPattern = headings.join("|");
const match = report.match(new RegExp(`^##\\s+(?:${headingPattern})\\s*$`, "im"));
if (!match || match.index === undefined) return "";
const tail = report.slice(match.index + match[0].length);
const end = tail.search(/^##\s+/m);
return (end >= 0 ? tail.slice(0, end) : tail).trim();
}
function packetText(detail: FindingTrafficDetail, side: "request" | "response") {
const head = side === "request" ? detail.binding.snapshot.req_head : detail.binding.snapshot.resp_head;
const body = detail[side].content;
return [head, body].filter(Boolean).join("\n\n").trim();
}
function PacketDisclosure({
findingId,
bindingId,
contextTask,
}: {
findingId: string;
bindingId?: string;
contextTask?: string;
}) {
const [open, setOpen] = React.useState(false);
const [detail, setDetail] = React.useState<FindingTrafficDetail | null>(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 <p className="mt-3 border-t pt-3 text-xs text-muted-foreground">此步骤暂无已绑定的真实流量证据。</p>;
}
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 (
<div className="mt-3 border-t pt-3">
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="h-7 gap-1.5 text-xs"
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
>
{open ? <ChevronDownIcon /> : <ChevronRightIcon />}
{open ? "收起数据包" : "查看数据包"}
</Button>
{open && detail ? (
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs"
disabled={repeaterBusy}
onClick={() => void sendToRepeater()}
>
{repeaterBusy ? "准备中…" : "送入请求重发"}
</Button>
) : null}
</div>
{open ? (
<div className="mt-3 rounded-lg border bg-muted/20 p-3">
{error ? <p className="text-destructive text-xs">数据包加载失败:{error}</p> : null}
{!error && bindingId && !detail ? (
<div className="flex items-center gap-2 text-muted-foreground text-xs">
<LoaderCircleIcon className="size-3.5 animate-spin" /> 正在读取已保存的真实流量…
</div>
) : null}
{detail ? (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2 text-xs">
<Badge variant="secondary">{detail.binding.snapshot.method}</Badge>
<Badge variant="outline">{detail.binding.snapshot.status}</Badge>
<span className="break-all text-muted-foreground">{detail.binding.snapshot.url}</span>
</div>
<div>
<p className="mb-1 text-[11px] text-muted-foreground">请求 Request</p>
<HttpCodeBlock raw={packetText(detail, "request")} />
</div>
<div>
<p className="mb-1 text-[11px] text-muted-foreground">响应 Response</p>
<HttpCodeBlock raw={packetText(detail, "response")} />
</div>
</div>
) : null}
</div>
) : null}
</div>
);
}
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<FindingTrafficBinding[] | undefined>(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 (
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<CardTitle>复现步骤</CardTitle>
<CardDescription>步骤同时展示,数据包默认隐藏;需要核对原始请求时再按步骤展开。</CardDescription>
</div>
<Badge variant={steps.length > 0 ? "secondary" : "outline"}>
{steps.length} 个步骤 · {traffic?.length ?? finding.traffic_count ?? 0} 个数据包
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3">
{overview ? (
<div className="rounded-lg bg-muted/30 px-3 py-2.5 text-sm">
<Markdown text={overview} />
</div>
) : null}
{steps.map((step, index) => {
const binding = traffic?.find((item) => item.position === index) ?? traffic?.[index];
return (
<div key={`${step.number}-${step.title}`} className="relative rounded-lg border bg-background p-4">
<div className="min-w-0">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<Badge variant="outline" className="shrink-0 font-medium">
步骤 {step.number}
</Badge>
<h3 className="min-w-0 break-words font-semibold text-sm leading-5">{step.title}</h3>
</div>
</div>
{step.body ? <Markdown text={step.body} /> : null}
<PacketDisclosure findingId={finding.id} bindingId={binding?.id} contextTask={contextTask} />
</div>
</div>
);
})}
{outcome || rootCause || remediation ? (
<div className="space-y-3 border-t pt-3">
{outcome ? <Markdown text={`## 最终结果\n\n${outcome}`} /> : null}
{rootCause ? <Markdown text={`## 根因分析\n\n${rootCause}`} /> : null}
{remediation ? <Markdown text={`## 修复建议\n\n${remediation}`} /> : null}
</div>
) : null}
</CardContent>
</Card>
);
}
+3 -16
View File
@@ -18,21 +18,6 @@ const components: Components = {
a: ({ node, ...p }) => (
<a className="text-primary underline underline-offset-2" target="_blank" rel="noreferrer" {...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 (
<img
className="my-2 max-h-[520px] max-w-full rounded-md border object-contain"
src={resolvedSrc}
alt={alt || "报告截图"}
loading="lazy"
{...p}
/>
);
},
hr: () => <hr className="my-2 border-border" />,
blockquote: ({ node, ...p }) => (
<blockquote className="my-1 border-l-2 border-border pl-2.5 text-muted-foreground" {...p} />
@@ -57,7 +42,9 @@ const components: Components = {
<table className="w-full border-collapse text-xs" {...p} />
</div>
),
th: ({ node, ...p }) => <th className="border border-border bg-muted/60 px-2 py-1 text-left font-medium" {...p} />,
th: ({ node, ...p }) => (
<th className="border border-border bg-muted/60 px-2 py-1 text-left font-medium" {...p} />
),
td: ({ node, ...p }) => <td className="border border-border px-2 py-1 align-top" {...p} />,
};
@@ -37,12 +37,12 @@ const preparationLabels = {
answering: "正在回答…",
};
export function SideQuestionButton({ side }: { side: SideQuestions }) {
export function SideQuestionButton({ side, label = "旁路提问" }: { side: SideQuestions; label?: string }) {
if (!side.enabled) return null;
return (
<Button variant="outline" size="sm" onClick={() => side.setOpen(true)} title="/btw 旁路提问">
<Button variant="outline" size="sm" onClick={() => side.setOpen(true)} title={`/btw ${label}`}>
<MessageCircleQuestionIcon data-icon="inline-start" />
旁路提问
{label}
</Button>
);
}
+1 -3
View File
@@ -970,8 +970,6 @@ 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; // 默认关闭;关闭不注入情报工具或提示
@@ -993,7 +991,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[];