Compare commits

..
Author SHA1 Message Date
ji ruo 44924ec30b feat(web): add segmented asset topology paths 2026-10-28 15:48:58 +08:00
14 changed files with 275 additions and 587 deletions

No files matched your search

+17 -15
View File
@@ -24,6 +24,16 @@ const autoDefaultTmpl = `你是 **Auto**,这个渗透测试平台的「操作
- 用人话简洁汇报你做了什么、结果如何;只根据工具真实返回作答,不臆造。
- 只在授权范围内操作。`
// assetMapperDefaultTmpl is the read-only assistant behind the topology canvas.
const assetMapperDefaultTmpl = `你是授权渗透测试平台的「资产梳理」Agent。你的职责是根据资产库中的真实数据,整理公网、边界服务和内网业务资产之间的关系,并解释用户指定的入站路径。
工作规则:
- 先使用 list_assets 和 list_companies 获取事实,再给出结论;没有证据的关系标记为“待验证”,不要臆造。
- 识别域名、子域名、IP、服务、应用和端点之间的关联,说明数据来源和最近更新时间。
- 用户询问“从哪里进入内网”时,按“公网入口 → 边界服务 → 目标资产”的顺序回答;当前资产库没有网络流量或防火墙证据时,明确说明这是资产关系推导,不把它描述成已验证的网络通路。
- 用简洁中文输出,优先给出资产清单、关系链、可信度和下一步验证建议。
- 只读分析,不主动修改资产库,不执行探测。`
// pentestDefaultTmpl is the built-in "渗透测试" (solo pentest) agent's prompt. Unlike
// the orchestration roles (goals/planner/worker), it runs standalone via the chat page
// and is its own planner + executor + auditor. Default tools: list_assets / insert_assets
@@ -90,20 +100,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"。
- ` + "`## 根因分析`" + `:为什么会有这个漏洞(缺校验/危险函数/配置错误…)。
@@ -121,11 +122,12 @@ const ReporterDefaultPrompt = `你是一个授权渗透测试系统里的**漏
// source while the same string stays as the in-code render fallback.
func BuiltinPromptSeeds() map[string]string {
return map[string]string{
"goals": goalsDefaultTmpl,
"planner": plannerDefaultTmpl,
"mainagent": mainAgentDefaultTmpl,
"worker": workerDefaultTmpl,
"auto": autoDefaultTmpl,
"pentest": pentestDefaultTmpl,
"goals": goalsDefaultTmpl,
"planner": plannerDefaultTmpl,
"mainagent": mainAgentDefaultTmpl,
"worker": workerDefaultTmpl,
"auto": autoDefaultTmpl,
"asset_mapper": assetMapperDefaultTmpl,
"pentest": pentestDefaultTmpl,
}
}
+3 -1
View File
@@ -43,6 +43,8 @@ func builtinToolsByAgent() map[string][]actool.CoreTool {
// auto 默认绑漏洞上报 + 资产管理工具,其他域工具可在 UI 按需勾选。
// 新库由此 seed 写入;老库由 seedAutoDefaultBindings 迁移。
"auto": {ts.addFinding(), ts.insertAssets(), ts.addCompanyScope(), ts.listAssets(), ts.listCompanies()},
// asset_mapper 只读资产库,供资产梳理和拓扑解释使用。
"asset_mapper": {ts.listAssets(), ts.listCompanies()},
// pentest(独立渗透 agent)默认绑:查资产 / 插资产 / 报漏洞 / 查漏洞 / 查企业。
// 新库由此 seed 写入;老库由 seedPentestDefaultBindings 迁移。
"pentest": {ts.listAssets(), ts.insertAssets(), ts.addFinding(), ts.listFindings(), ts.listCompanies()},
@@ -62,7 +64,7 @@ var defaultUnbound = map[string]bool{"goal_met": true}
// 多个 agent 都有)合成一条,Agents 取并集;defaultUnbound 里的工具则强制绑定为空。
func BuiltinToolSeeds() []ToolSeed {
byAgent := builtinToolsByAgent()
order := []string{"mainagent", "goals", "planner", "worker", "auto", "pentest"}
order := []string{"mainagent", "goals", "planner", "worker", "auto", "asset_mapper", "pentest"}
type acc struct {
tool actool.CoreTool
+2
View File
@@ -209,6 +209,8 @@ var builtinAgents = []builtinAgent{
}, false, nil},
// Auto:内置「平台操作」agent。不参与渗透编排循环,经对话页驱动,用工具操作平台。
{"auto", "Auto", "assistant", "平台操作助手:用工具管理任务(建/看/暂停/给提示)与资产,并可创建/修改 skill、自定义工具、MCP。", nil, false, nil},
// Asset mapper: read-only assistant used by the topology canvas and asset views.
{"asset_mapper", "资产梳理", "assistant", "资产梳理助手:汇总公网、边界与内网资产关系,基于真实资产数据解释入站路径,不凭空推断连接。", nil, false, nil},
// 渗透测试:内置「独立渗透」agent。经对话页驱动,一人从侦察到收尾走完整条渗透链,自己规划自己执行自己验证。默认开启交互式 shell。
{"pentest", "渗透测试", "assistant", "独立渗透 agent:一人从侦察→找攻击面→深入利用→验证→收尾走完整条链,自己规划、自己执行、自己对抗式验证。", nil, true, intp(0)},
}
-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,7 +10,6 @@ import { toast } from "sonner";
import { CopyButton } from "@/components/copy-button";
import { FindingRetestPanel } from "@/components/finding-retest-panel";
import { FindingReproductionPanel } from "@/components/finding-reproduction-panel";
import { FindingTrafficPanel } from "@/components/finding-traffic-panel";
import { Markdown } from "@/components/markdown";
import { StatusBadge } from "@/components/status-badge";
@@ -45,12 +44,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 (
@@ -135,7 +128,6 @@ 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">
@@ -190,65 +182,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>
{/* 右栏:状态区 */}
@@ -34,6 +34,34 @@ const FOLD_STEP = 20;
type Kind = CoverageGraphNode["kind"];
type Zone = "internet" | "boundary" | "internal";
const zoneMeta: Record<Zone, { label: string; description: string; className: string }> = {
internet: {
label: "互联网区",
description: "公网域名 / 外部入口",
className: "bg-sky-50/70 dark:bg-sky-950/20",
},
boundary: {
label: "边界区",
description: "服务 / 网关 / 暴露面",
className: "bg-amber-50/70 dark:bg-amber-950/15",
},
internal: {
label: "内网区",
description: "业务资产 / 应用 / 端点",
className: "bg-emerald-50/65 dark:bg-emerald-950/15",
},
};
// 当前后端资产关系以域名、服务和业务资产为主,先用类型推导可视化分区。
// 后续接入 VPC/网段/防火墙数据后,可以直接由 API 返回 zone 覆盖这里的推导结果。
function zoneOf(node: CoverageGraphNode): Zone {
if (node.kind === "root_domain" || node.kind === "subdomain") return "internet";
if (node.kind === "service") return "boundary";
return "internal";
}
type KindMeta = { label: string; icon: LucideIcon; iconBg: string; hex: string; size: number };
const kindMeta: Record<Kind, KindMeta> = {
@@ -185,8 +213,11 @@ type G6NodeDatum = {
inScope: boolean;
lbl: string;
size: number;
active: boolean;
};
type G6EdgeDatum = { source: string; target: string; active: boolean };
function trunc(s: string, n = 26): string {
return s.length > n ? `${s.slice(0, n - 1)}…` : s;
}
@@ -211,7 +242,7 @@ function graphLabel(n: CoverageGraphNode): string {
return n.label;
}
function toG6Nodes(renderNodes: RenderNode[]): G6NodeDatum[] {
function toG6Nodes(renderNodes: RenderNode[], activeKeys: Set<string>): G6NodeDatum[] {
return renderNodes.map((rn) => {
if (rn.fold) {
return {
@@ -222,6 +253,7 @@ function toG6Nodes(renderNodes: RenderNode[]): G6NodeDatum[] {
inScope: false,
lbl: `还有 ${rn.hidden.length} 个${kindMeta[rn.kind].label}`,
size: 24,
active: activeKeys.has(rn.key),
};
}
return {
@@ -232,6 +264,7 @@ function toG6Nodes(renderNodes: RenderNode[]): G6NodeDatum[] {
inScope: rn.node.in_scope,
lbl: trunc(graphLabel(rn.node) || kindMeta[rn.kind].label),
size: kindMeta[rn.kind].size,
active: activeKeys.has(rn.key),
};
});
}
@@ -248,6 +281,7 @@ function nodeFill(d: G6NodeDatum): string {
return "#94a3b8";
}
function nodeStroke(d: G6NodeDatum): string {
if (d.active) return "#0f766e";
if (d.fold || !d.inScope) return "#94a3b8";
if (d.tested) return "#0f766e";
return "#64748b";
@@ -464,16 +498,19 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
const [expanded, setExpanded] = React.useState<Map<string, number>>(new Map());
const [selectedAsset, setSelectedAsset] = React.useState<CoverageGraphNode | null>(null);
const [selectedFoldId, setSelectedFoldId] = React.useState<string | null>(null);
const [activePath, setActivePath] = React.useState<string[]>([]);
const [loading, setLoading] = React.useState(true);
const [lastSyncedAt, setLastSyncedAt] = React.useState<Date | null>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const graphRef = React.useRef<G6Graph | null>(null);
// click 处理需要最新的 key→RenderNode 映射(G6 事件回调闭包外读 ref)。
const renderMapRef = React.useRef<Map<string, RenderNode>>(new Map());
const gDataRef = React.useRef<{ nodes: G6NodeDatum[]; edges: { source: string; target: string }[] }>({
const gDataRef = React.useRef<{ nodes: G6NodeDatum[]; edges: G6EdgeDatum[] }>({
nodes: [],
edges: [],
});
const graphDataRef = React.useRef<typeof data>(null);
const fetchGraph = React.useCallback(() => {
setLoading(true);
@@ -484,6 +521,8 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
// tested 状态——不显示测试进度、不做已测高亮。
const nodes = coverageEnabled ? (g.nodes ?? []) : (g.nodes ?? []).map((n) => ({ ...n, tested: false }));
setData({ nodes, edges: g.edges ?? [] });
graphDataRef.current = { nodes, edges: g.edges ?? [] };
setLastSyncedAt(new Date());
})
.catch(() => {
/* 保留上一次数据 */
@@ -506,14 +545,18 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
// 结构签名:只在可见节点/边集合变化时重建图 + 重跑布局,避免无谓抖动。
const sig = React.useMemo(
() =>
`${renderNodes.map((n) => `${n.key}:${n.fold ? "f" : n.node.tested ? "t" : "u"}`).sort().join(",")}|${renderEdges.length}`,
[renderNodes, renderEdges],
`${renderNodes.map((n) => `${n.key}:${n.fold ? "f" : n.node.tested ? "t" : "u"}`).sort().join(",")}|${renderEdges.length}|${activePath.join(",")}`,
[activePath, renderNodes, renderEdges],
);
// 维护 gDataRef + renderMapRef(供事件与图数据应用读取)。
gDataRef.current = {
nodes: toG6Nodes(renderNodes),
edges: renderEdges.map((e) => ({ source: e.src, target: e.dst })),
nodes: toG6Nodes(renderNodes, new Set(activePath)),
edges: renderEdges.map((e) => ({
source: e.src,
target: e.dst,
active: activePath.includes(e.src) && activePath.includes(e.dst),
})),
};
const rmap = new Map<string, RenderNode>();
for (const rn of renderNodes) rmap.set(rn.key, rn);
@@ -542,7 +585,7 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
container: containerRef.current,
autoResize: true,
autoFit: "view",
background: "#f0f2f7",
background: "transparent",
node: {
style: {
size: (d: unknown) => nd(d).size,
@@ -561,10 +604,18 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
labelBackgroundFill: "rgba(255,255,255,0.75)",
labelBackgroundRadius: 3,
labelPadding: [1, 3],
opacity: (d: unknown) =>
gDataRef.current.nodes.some((node) => node.active) && !nd(d).active ? 0.28 : 1,
},
},
edge: {
style: { stroke: "#cbd5e1", lineWidth: 1, endArrow: false },
style: {
stroke: (d: unknown) => ((d as G6EdgeDatum).active ? "#0f766e" : "#cbd5e1"),
lineWidth: (d: unknown) => ((d as G6EdgeDatum).active ? 2.5 : 1),
opacity: (d: unknown) =>
gDataRef.current.edges.some((edge) => edge.active) && !(d as G6EdgeDatum).active ? 0.18 : 1,
endArrow: true,
},
},
layout: {
type: "d3-force",
@@ -590,8 +641,22 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
if (!id) return;
const rn = renderMapRef.current.get(id);
if (!rn) return;
if (rn.fold) setSelectedFoldId(rn.key);
else setSelectedAsset(rn.node);
if (rn.fold) {
setSelectedFoldId(rn.key);
return;
}
setSelectedAsset(rn.node);
const parents = new Map<string, string>();
for (const edge of graphDataRef.current?.edges ?? []) {
if (!parents.has(edge.src)) parents.set(edge.src, edge.dst);
}
const lineage: string[] = [];
let cursor: string | undefined = rn.node.key;
while (cursor && !lineage.includes(cursor)) {
lineage.push(cursor);
cursor = parents.get(cursor);
}
setActivePath(lineage);
});
graphRef.current = graph;
applyData();
@@ -636,14 +701,38 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
const inScope = data?.nodes.filter((n) => n.in_scope).length ?? 0;
return (
<div className="relative h-full w-full">
<div ref={containerRef} className="h-full w-full" />
<div className="relative h-full w-full overflow-hidden bg-slate-100/80 dark:bg-slate-950/30">
<div className="pointer-events-none absolute inset-0 z-0 grid grid-cols-3">
{(Object.keys(zoneMeta) as Zone[]).map((zone, index) => (
<div
key={zone}
className={cn("relative border-r border-slate-200/80 dark:border-slate-800/80", zoneMeta[zone].className, index === 2 && "border-r-0")}
>
<div className="absolute top-3 left-4">
<div className="text-foreground/80 text-sm font-semibold">{zoneMeta[zone].label}</div>
<div className="text-muted-foreground mt-0.5 text-[11px]">{zoneMeta[zone].description}</div>
</div>
</div>
))}
</div>
<div ref={containerRef} className="relative z-10 h-full w-full pt-12" />
{/* 图例 + 统计 + 刷新(叠加层) */}
<div className="bg-card/95 pointer-events-auto absolute top-3 left-3 flex max-w-[340px] flex-col gap-2.5 rounded-lg border p-3 text-xs shadow-sm backdrop-blur">
<div className="bg-card/95 pointer-events-auto absolute top-3 left-3 z-20 flex max-w-[360px] flex-col gap-2.5 rounded-lg border p-3 text-xs shadow-sm backdrop-blur">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-violet-100 text-violet-700 dark:bg-violet-950/60 dark:text-violet-300">
<Waypoints className="size-4" />
</span>
<div className="min-w-0">
<div className="text-foreground font-medium">资产梳理 Agent</div>
<div className="text-muted-foreground truncate text-[11px]">
{loading ? "正在更新资产关系…" : lastSyncedAt ? `最近更新 ${lastSyncedAt.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}` : "等待首次梳理"}
</div>
</div>
</div>
{total > 0 ? (
<span className="text-muted-foreground">
<span className="text-muted-foreground whitespace-nowrap">
范围内 <span className="text-foreground font-semibold tabular-nums">{inScope}</span>
{coverageEnabled && (
<>
@@ -656,8 +745,9 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
) : (
<span className="text-muted-foreground">{loading ? "加载中…" : "暂无范围内资产(先锚定任务范围)"}</span>
)}
<Button variant="ghost" size="icon" className="size-6" onClick={fetchGraph} title="刷新">
<Button variant="outline" size="sm" className="h-7 gap-1.5 px-2" onClick={fetchGraph} title="重新梳理资产">
<RefreshCw className={cn("size-3.5", loading && "animate-spin")} />
<span className="hidden sm:inline">重新梳理</span>
</Button>
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1.5">
@@ -689,11 +779,49 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
<span className="size-3 rounded-full border border-dashed border-neutral-400 bg-neutral-200" /> 范围外
</span>
</div>
<div className="border-border/60 flex flex-wrap gap-x-3 gap-y-1.5 border-t pt-2">
{(Object.keys(zoneMeta) as Zone[]).map((zone) => (
<span key={zone} className="text-muted-foreground inline-flex items-center gap-1.5">
<span className={cn("size-2.5 rounded-full", zone === "internet" && "bg-sky-400", zone === "boundary" && "bg-amber-400", zone === "internal" && "bg-emerald-400")} />
{zoneMeta[zone].label}
</span>
))}
</div>
<p className="text-muted-foreground/80 border-border/60 border-t pt-2 leading-relaxed">
力导向布局,可拖拽节点、滚轮缩放;灰色「⋯」是折叠节点,点开可展开更多。
点击节点查看详情并高亮关系链;拖拽节点、滚轮缩放。灰色「⋯」是折叠节点,可展开更多资产。
</p>
</div>
{selectedAsset && activePath.length > 0 && data && (
<div className="bg-card/95 pointer-events-auto absolute top-3 right-3 z-20 w-[min(310px,calc(100%-2rem))] rounded-lg border p-3 shadow-sm backdrop-blur">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-foreground text-sm font-semibold">关系路径</div>
<div className="text-muted-foreground mt-0.5 text-[11px]">从当前资产向上追溯关联关系</div>
</div>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => setActivePath([])}>
清除高亮
</Button>
</div>
<div className="mt-3 flex flex-col gap-1.5">
{[...activePath].reverse().map((key, index, path) => {
const node = data.nodes.find((item) => item.key === key);
if (!node) return null;
return (
<React.Fragment key={key}>
<div className="flex min-w-0 items-center gap-2 rounded-md bg-muted/60 px-2 py-1.5 text-xs">
<span className={cn("size-2 shrink-0 rounded-full", zoneOf(node) === "internet" && "bg-sky-500", zoneOf(node) === "boundary" && "bg-amber-500", zoneOf(node) === "internal" && "bg-emerald-500")} />
<span className="text-muted-foreground shrink-0">{zoneMeta[zoneOf(node)].label}</span>
<span className="min-w-0 flex-1 truncate font-mono" title={node.label}>{node.label}</span>
</div>
{index < path.length - 1 && <div className="text-muted-foreground pl-3 text-[10px]">↓</div>}
</React.Fragment>
);
})}
</div>
</div>
)}
<AssetSheet node={selectedAsset} taskId={taskId} onOpenChange={(o) => !o && setSelectedAsset(null)} />
<FoldSheet
fold={selectedFold}
+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} />,
};
+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[];