mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-29 01:23:50 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8572a18d2 |
No files matched your search
@@ -90,11 +90,20 @@ 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 图片链接:。
|
||||
- 截图失败时如实说明;系统设置关闭或 browser MCP 不可用时,不要伪造图片链接,也不要把文字描述冒充截图。
|
||||
|
||||
━━ 报告结构(Markdown,按需裁剪,但证据/复现/修复必须有)━━
|
||||
- ` + "`## 概述`" + `:一句话说清是什么漏洞、在哪、能造成什么。
|
||||
- ` + "`## 影响与危害`" + `:结合业务讲清最坏后果(数据泄露/接管/RCE/横向…),给出**严重等级**判断及理由。
|
||||
- ` + "`## 受影响范围`" + `:受影响的资产/接口/参数/版本。
|
||||
- ` + "`## 复现步骤`" + `:**可照做复现**的分步操作(请求/命令/参数),能贴 PoC 就贴。
|
||||
- 复现步骤必须使用明确的编号标题:` + "`### 步骤 1:准备环境`" + `、` + "`### 步骤 2:构造请求`" + `……;每一步都要写清操作、输入、预期结果、实际结果和证据来源。不要只写“步骤一”或把多步合并成一段。
|
||||
- 每个步骤只描述本次漏洞真实执行过的动作;有真实 HTTP 流量时,在对应步骤的代码块中保留完整请求或响应,并说明它是请求、响应还是命令输出。没有证据的内容标记为“待补充/未验证”。
|
||||
- 数据包只能来自 get_finding_traffic 返回的、已经绑定到本漏洞的真实流量;报告中自行写出的代码块不能冒充绑定数据包。没有绑定流量时明确写“暂无已绑定流量证据”,不要编造请求/响应。
|
||||
- 最新报告将概述、影响、复现步骤、最终结果、根因分析和修复建议合并在同一份报告中;不要再额外生成一份脱离步骤的“详细报告”。
|
||||
- ` + "`## 证据`" + `:证明漏洞真实存在的关键请求/响应片段、命令输出、回显、截图说明——用代码块贴原文。
|
||||
- ` + "`## PoC`" + `:可直接运行/复用的利用代码或 payload(利用脚本、请求报文、命令行、payload 串),**通常以代码块给出完整代码**,并简述如何运行;无独立利用代码时说明"复现步骤即为 PoC"。
|
||||
- ` + "`## 根因分析`" + `:为什么会有这个漏洞(缺校验/危险函数/配置错误…)。
|
||||
|
||||
@@ -96,6 +96,13 @@ func wireAgentAugment(pg *db.DB, skillDir string, hostTools func() ([]actool.Cor
|
||||
continue
|
||||
}
|
||||
for _, t := range ts {
|
||||
// Playwright exposes the screenshot capability as a separate tool. Keep
|
||||
// it opt-in so a normal browser MCP session does not unexpectedly send
|
||||
// image payloads to the model or consume extra tokens.
|
||||
if m.Name == browserMCPName && isBrowserScreenshotTool(t.Name()) &&
|
||||
(agentKey != "reporter" || !pg.GetBool(settingBrowserScreenshot, false)) {
|
||||
continue
|
||||
}
|
||||
extra = append(extra, t)
|
||||
allNames = append(allNames, t.Name())
|
||||
serverTools[m.Name] = append(serverTools[m.Name], t.Name())
|
||||
@@ -180,6 +187,11 @@ func wireAgentAugment(pg *db.DB, skillDir string, hostTools func() ([]actool.Cor
|
||||
}
|
||||
}
|
||||
|
||||
func isBrowserScreenshotTool(name string) bool {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
return strings.Contains(name, "screenshot") || strings.Contains(name, "take_screenshot")
|
||||
}
|
||||
|
||||
func idSet(ids []int64) map[int64]bool {
|
||||
m := make(map[int64]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
|
||||
+5
-4
@@ -264,6 +264,7 @@ type Manager struct {
|
||||
// Settings keys the UI toggles at runtime.
|
||||
const (
|
||||
settingTrafficCapture = "traffic_capture"
|
||||
settingBrowserScreenshot = "browser_screenshot_enabled"
|
||||
settingAgentTrafficBinding = "agent_traffic_binding"
|
||||
settingWebSearchOn = "web_search_enabled"
|
||||
settingWebSearchBackend = "web_search_backend"
|
||||
@@ -273,11 +274,11 @@ const (
|
||||
// settingGlobalProxy is the global egress proxy for all target traffic
|
||||
// (http/https/socks5). Empty = direct. Distinct from web_search_proxy (which
|
||||
// only routes the search backend) and the per-profile LLM proxy.
|
||||
settingGlobalProxy = "global_proxy"
|
||||
settingGlobalProxy = "global_proxy"
|
||||
settingShowEgressIP = "show_egress_ip"
|
||||
settingWorkers = "workers"
|
||||
settingWorkerPools = "worker_pools"
|
||||
settingLLMRecord = "llm_record"
|
||||
settingWorkers = "workers"
|
||||
settingWorkerPools = "worker_pools"
|
||||
settingLLMRecord = "llm_record"
|
||||
// LLM 轮询(故障转移)。默认关闭——开启后走「全局激活配置」的 agent 在当前配置
|
||||
// 不可用(余额不足/key 失效/限流/服务异常)时自动切到下一个配置。
|
||||
// settingLLMPoolBindFallback 仅在轮询开启时有意义:默认关闭,即 agent/任务显式
|
||||
|
||||
+61
-20
@@ -199,6 +199,12 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
|
||||
if err != nil || t == "" {
|
||||
return "", false
|
||||
}
|
||||
if key == "reporter" && m.pg.GetBool(settingBrowserScreenshot, false) {
|
||||
t += `
|
||||
|
||||
【报告步骤截图已开启】
|
||||
报告撰写时,针对每个复现步骤中真实出现且仍可访问的 HTTP 页面,使用 browser MCP 的 browser_navigate 和 browser_take_screenshot 截取页面证据。只导航到漏洞证据中出现的目标 URL,不要自行扩大测试范围。将截图保存到当前工作目录下的 findings/screenshots/ 目录,文件名使用 finding-<finding_id>-step-<n>.png;确认文件存在后,在对应步骤中加入 Markdown 图片:。截图失败时如实写明,不要用占位图或把截图描述冒充图片。`
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
// Wire DB-stored wrap-up (settlement) prompts. Empty column → built-in default.
|
||||
@@ -772,6 +778,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /api/workspace/mkdir", s.wsMkdir)
|
||||
mux.HandleFunc("DELETE /api/workspace/delete", s.wsDelete)
|
||||
mux.HandleFunc("GET /api/workspace/download", s.wsDownload)
|
||||
mux.HandleFunc("GET /api/workspace/preview", s.wsPreview)
|
||||
mux.HandleFunc("POST /api/workspace/upload", s.wsUpload)
|
||||
mux.HandleFunc("GET /api/tasks/{id}/scope", s.taskScopeList)
|
||||
mux.HandleFunc("POST /api/tasks/{id}/scope", s.taskScopeAdd)
|
||||
@@ -3442,26 +3449,27 @@ func (s *Server) settingsPayload() map[string]any {
|
||||
concLimit = defaultConcurrencyLimit // 关闭时也回显一个合理默认值给 UI
|
||||
}
|
||||
return map[string]any{
|
||||
"traffic_capture": s.m.TrafficEnabled(),
|
||||
"agent_traffic_binding": s.m.pg.GetBool(settingAgentTrafficBinding, false),
|
||||
"task_intelligence_enabled": s.m.pg.GetBool(db.SettingTaskIntelligence, false),
|
||||
db.SettingTaskAutoTitle: s.m.pg.GetBool(db.SettingTaskAutoTitle, false),
|
||||
db.SettingChatAutoTitle: s.m.pg.GetBool(db.SettingChatAutoTitle, false),
|
||||
db.SettingCredentialAgent: s.m.pg.GetBool(db.SettingCredentialAgent, false),
|
||||
db.SettingCredentialTraffic: s.m.pg.GetBool(db.SettingCredentialAgent, false) && s.m.pg.GetBool(db.SettingCredentialTraffic, false),
|
||||
"llm_record": s.m.LLMRecordEnabled(),
|
||||
"web_search_enabled": on,
|
||||
"web_search_backend": backend,
|
||||
"brave_key_set": strings.TrimSpace(braveKey) != "",
|
||||
"tavily_key_set": strings.TrimSpace(tavilyKey) != "",
|
||||
"web_search_proxy": proxy, // 独立出口代理(http/https/socks5),空=直连
|
||||
"global_proxy": s.m.GlobalProxy(), // 全局出口代理(http/https/socks5),所有目标流量走它,空=直连
|
||||
"show_egress_ip": s.m.pg.GetBool(settingShowEgressIP, false),
|
||||
"python_interpreter": strings.TrimSpace(pyStored), // 用户/自动设的值(空=用运行时检测)
|
||||
"workers": s.m.Workers(), // 并发工作 agent 数(默认3);对之后启动的任务生效
|
||||
"worker_pools": s.m.WorkerPools(), // 非空时按池顺序绑定 worker 会话
|
||||
"task_concurrency_enabled": concOn, // 任务并发上限开关(默认关)
|
||||
"task_concurrency_limit": concLimit, // 同时运行任务上限(开启后默认5)
|
||||
"traffic_capture": s.m.TrafficEnabled(),
|
||||
"browser_screenshot_enabled": s.m.pg.GetBool(settingBrowserScreenshot, false),
|
||||
"agent_traffic_binding": s.m.pg.GetBool(settingAgentTrafficBinding, false),
|
||||
"task_intelligence_enabled": s.m.pg.GetBool(db.SettingTaskIntelligence, false),
|
||||
db.SettingTaskAutoTitle: s.m.pg.GetBool(db.SettingTaskAutoTitle, false),
|
||||
db.SettingChatAutoTitle: s.m.pg.GetBool(db.SettingChatAutoTitle, false),
|
||||
db.SettingCredentialAgent: s.m.pg.GetBool(db.SettingCredentialAgent, false),
|
||||
db.SettingCredentialTraffic: s.m.pg.GetBool(db.SettingCredentialAgent, false) && s.m.pg.GetBool(db.SettingCredentialTraffic, false),
|
||||
"llm_record": s.m.LLMRecordEnabled(),
|
||||
"web_search_enabled": on,
|
||||
"web_search_backend": backend,
|
||||
"brave_key_set": strings.TrimSpace(braveKey) != "",
|
||||
"tavily_key_set": strings.TrimSpace(tavilyKey) != "",
|
||||
"web_search_proxy": proxy, // 独立出口代理(http/https/socks5),空=直连
|
||||
"global_proxy": s.m.GlobalProxy(), // 全局出口代理(http/https/socks5),所有目标流量走它,空=直连
|
||||
"show_egress_ip": s.m.pg.GetBool(settingShowEgressIP, false),
|
||||
"python_interpreter": strings.TrimSpace(pyStored), // 用户/自动设的值(空=用运行时检测)
|
||||
"workers": s.m.Workers(), // 并发工作 agent 数(默认3);对之后启动的任务生效
|
||||
"worker_pools": s.m.WorkerPools(), // 非空时按池顺序绑定 worker 会话
|
||||
"task_concurrency_enabled": concOn, // 任务并发上限开关(默认关)
|
||||
"task_concurrency_limit": concLimit, // 同时运行任务上限(开启后默认5)
|
||||
// LLM 轮询(故障转移)。默认关;开启后走全局激活配置的 agent 在当前配置不可用时
|
||||
// 自动切到下一个配置。bind_fallback 仅在轮询开启时有意义(默认关)。
|
||||
"llm_pool_enabled": s.m.LLMPoolEnabled(),
|
||||
@@ -3520,6 +3528,7 @@ func (s *Server) pgDetectPython(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
TrafficCapture *bool `json:"traffic_capture"`
|
||||
BrowserScreenshot *bool `json:"browser_screenshot_enabled"`
|
||||
AgentTrafficBinding *bool `json:"agent_traffic_binding"`
|
||||
TaskIntelligence *bool `json:"task_intelligence_enabled"`
|
||||
TaskAutoTitle *bool `json:"task_auto_title_enabled"`
|
||||
@@ -3715,6 +3724,16 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.BrowserScreenshot != nil {
|
||||
if err := s.setReporterBrowserScreenshotVisibility(*req.BrowserScreenshot); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.m.pg.SetBool(settingBrowserScreenshot, *req.BrowserScreenshot); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.TrafficCapture != nil {
|
||||
if err := s.m.SetTrafficEnabled(*req.TrafficCapture); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
@@ -3766,6 +3785,28 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, s.settingsPayload())
|
||||
}
|
||||
|
||||
// setReporterBrowserScreenshotVisibility keeps the report writer's browser MCP
|
||||
// permission in sync with the screenshot setting. Other agents retain their
|
||||
// explicit MCP permissions.
|
||||
func (s *Server) setReporterBrowserScreenshotVisibility(enabled bool) error {
|
||||
reporter, err := s.m.pg.GetAgentByKey("reporter")
|
||||
if err != nil || reporter == nil {
|
||||
return err
|
||||
}
|
||||
servers, err := s.m.pg.ListMCP()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, server := range servers {
|
||||
if server.Name == browserMCPName {
|
||||
return s.m.pg.ToggleVisibility(reporter.ID, "mcp", server.ID, enabled)
|
||||
}
|
||||
}
|
||||
// The built-in browser MCP may be deleted by an administrator. Keep the
|
||||
// setting writable so it takes effect automatically if the server returns.
|
||||
return nil
|
||||
}
|
||||
|
||||
// testWebSearch runs a real "test" search with the given (or currently saved)
|
||||
// backend/proxy/key to verify the config can actually reach a search backend —
|
||||
// mirroring testLLM. Backend/proxy come from the request (so the form's unsaved
|
||||
|
||||
@@ -225,6 +225,38 @@ 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,6 +75,12 @@ export interface FindingEdit {
|
||||
|
||||
export type FindingReport = { status: "loading" | "done" | "error"; text: string };
|
||||
|
||||
function isStructuredReproductionReport(report: string) {
|
||||
return (
|
||||
/^##\s+(?:复现步骤|测试过程|复现过程)\s*$/im.test(report) && /^\s*(?:###\s*)?步骤\s*\d+[.、::))]/im.test(report)
|
||||
);
|
||||
}
|
||||
|
||||
// Exploration node ids are only unique inside a task. Prefer the persisted
|
||||
// finding id and otherwise namespace the node id by task so editing one group
|
||||
// cannot update a similarly-named node in another expanded group.
|
||||
@@ -446,8 +452,8 @@ export function FindingsTable({
|
||||
{f.evidence}
|
||||
</pre>
|
||||
|
||||
{/* 详细报告(Markdown):展开时按 finding_id 懒加载,免进详情页即可查看。 */}
|
||||
{f.finding_id && (
|
||||
{/* 旧版详细报告保留;新版合并报告在详情页按概览/步骤/根因/修复展示。 */}
|
||||
{f.finding_id && !isStructuredReproductionReport(reports[rowKey]?.text ?? "") && (
|
||||
<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,6 +10,7 @@ import { toast } from "sonner";
|
||||
|
||||
import { CopyButton } from "@/components/copy-button";
|
||||
import { FindingRetestPanel } from "@/components/finding-retest-panel";
|
||||
import { FindingReproductionPanel } from "@/components/finding-reproduction-panel";
|
||||
import { FindingTrafficPanel } from "@/components/finding-traffic-panel";
|
||||
import { Markdown } from "@/components/markdown";
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
@@ -44,6 +45,12 @@ function fmtTime(ts: string) {
|
||||
return new Date(ts).toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
function usesStructuredReproduction(report: string) {
|
||||
return (
|
||||
/^##\s+(?:复现步骤|测试过程|复现过程)\s*$/im.test(report) && /^\s*(?:###\s*)?步骤\s*\d+[.、::))]/im.test(report)
|
||||
);
|
||||
}
|
||||
|
||||
// FieldRow is one label/value line in the right-hand status panel.
|
||||
function FieldRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -128,6 +135,7 @@ function FindingDetailInner() {
|
||||
}
|
||||
|
||||
const title = finding.name || finding.vulnclass || "未分类";
|
||||
const structuredReproduction = usesStructuredReproduction(finding.report || "");
|
||||
|
||||
return (
|
||||
<Tabs value={tab} onValueChange={setTab} className="flex flex-1 flex-col gap-0">
|
||||
@@ -182,47 +190,65 @@ function FindingDetailInner() {
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap">{finding.summary || "(无摘要)"}</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>
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右栏:状态区 */}
|
||||
|
||||
@@ -2,7 +2,15 @@
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { CpuIcon, FlaskConicalIcon, KeyboardIcon, RadioTowerIcon, SearchIcon, ShieldAlertIcon } from "lucide-react";
|
||||
import {
|
||||
CameraIcon,
|
||||
CpuIcon,
|
||||
FlaskConicalIcon,
|
||||
KeyboardIcon,
|
||||
RadioTowerIcon,
|
||||
SearchIcon,
|
||||
ShieldAlertIcon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -29,6 +37,7 @@ export default function SystemSettingsPage() {
|
||||
const [chatAutoTitleEnabled, setChatAutoTitleEnabled] = React.useState(false);
|
||||
const [savingIntelligence, setSavingIntelligence] = React.useState(false);
|
||||
const [trafficCapture, setTrafficCapture] = React.useState(false);
|
||||
const [browserScreenshot, setBrowserScreenshot] = React.useState(false);
|
||||
const [agentTrafficBinding, setAgentTrafficBinding] = React.useState(false);
|
||||
const [webSearch, setWebSearch] = React.useState(false);
|
||||
const [backend, setBackend] = React.useState("ddgs");
|
||||
@@ -86,6 +95,7 @@ export default function SystemSettingsPage() {
|
||||
setTaskAutoTitleEnabled(!!s.task_auto_title_enabled);
|
||||
setChatAutoTitleEnabled(!!s.chat_auto_title_enabled);
|
||||
setTrafficCapture(!!s.traffic_capture);
|
||||
setBrowserScreenshot(!!s.browser_screenshot_enabled);
|
||||
setAgentTrafficBinding(!!s.agent_traffic_binding);
|
||||
setWebSearch(!!s.web_search_enabled);
|
||||
setBackend(s.web_search_backend || "ddgs");
|
||||
@@ -213,6 +223,22 @@ export default function SystemSettingsPage() {
|
||||
.finally(() => setSaving(false));
|
||||
};
|
||||
|
||||
const toggleBrowserScreenshot = (v: boolean) => {
|
||||
setBrowserScreenshot(v);
|
||||
setSaving(true);
|
||||
api
|
||||
.setSettings({ browser_screenshot_enabled: v })
|
||||
.then((s) => {
|
||||
apply(s);
|
||||
toast.success(v ? "已开启浏览器截图工具" : "已关闭浏览器截图工具");
|
||||
})
|
||||
.catch((e) => {
|
||||
setBrowserScreenshot(!v);
|
||||
toast.error(`保存失败:${(e as Error).message}`);
|
||||
})
|
||||
.finally(() => setSaving(false));
|
||||
};
|
||||
|
||||
const toggleInjectPlanner = (v: boolean) => {
|
||||
setInjectPlanner(v); // optimistic
|
||||
api
|
||||
@@ -262,9 +288,7 @@ export default function SystemSettingsPage() {
|
||||
const toggleAutoTitle = (key: "task_auto_title_enabled" | "chat_auto_title_enabled", value: boolean) => {
|
||||
const setter = key === "task_auto_title_enabled" ? setTaskAutoTitleEnabled : setChatAutoTitleEnabled;
|
||||
const patch: Partial<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)
|
||||
@@ -497,6 +521,31 @@ 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">
|
||||
@@ -510,9 +559,8 @@ 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">
|
||||
@@ -537,12 +585,7 @@ 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>
|
||||
@@ -567,15 +610,13 @@ 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"
|
||||
@@ -587,10 +628,9 @@ 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>
|
||||
@@ -603,11 +643,7 @@ 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 ? "✓" : "✗"}
|
||||
@@ -629,18 +665,12 @@ 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>
|
||||
) : (
|
||||
@@ -733,7 +763,9 @@ 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">
|
||||
@@ -751,7 +783,9 @@ 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"
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { ChevronDownIcon, ChevronRightIcon, LoaderCircleIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { HttpCodeBlock } from "@/components/http-code-block";
|
||||
import { Markdown } from "@/components/markdown";
|
||||
import { headersFromReqHead, requestFromEvidence } from "@/components/repeater/repeater-http";
|
||||
import { seedFromEvidence } from "@/components/repeater/repeater-store";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Finding, FindingTrafficBinding, FindingTrafficDetail } from "@/lib/types";
|
||||
|
||||
type ReproductionStep = {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
function extractSection(report: string) {
|
||||
const match = report.match(/^##\s+(?:复现步骤|测试过程|复现过程)\s*$/im);
|
||||
if (!match || match.index === undefined) return "";
|
||||
const start = match.index + match[0].length;
|
||||
const tail = report.slice(start);
|
||||
const end = tail.search(/^##\s+/m);
|
||||
return (end >= 0 ? tail.slice(0, end) : tail).trim();
|
||||
}
|
||||
|
||||
function withoutPacket(text: string) {
|
||||
return text.replace(/```(?:http|bash|shell|sh|json|text)?\s*\n[\s\S]*?```/gi, "").trim();
|
||||
}
|
||||
|
||||
function parseSteps(report: string, fallback: string): ReproductionStep[] {
|
||||
const section = extractSection(report);
|
||||
if (!section) {
|
||||
return [
|
||||
{
|
||||
number: 1,
|
||||
title: "执行验证并确认结果",
|
||||
body: fallback.trim() || "暂无结构化复现步骤,查看下方原始报告。",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const markers = [...section.matchAll(/^\s*(?:###\s*)?(?:步骤\s*)?(\d+)[.、::))]\s*(.+)$/gim)];
|
||||
if (markers.length === 0) {
|
||||
return [{ number: 1, title: "复现过程", body: withoutPacket(section) }];
|
||||
}
|
||||
|
||||
return markers.map((marker, index) => {
|
||||
const start = marker.index ?? 0;
|
||||
const end = markers[index + 1]?.index ?? section.length;
|
||||
const raw = section.slice(start, end).trim();
|
||||
const firstLine = marker[2].trim();
|
||||
const rest = raw.slice(marker[0].length).trim();
|
||||
return {
|
||||
number: Number(marker[1]),
|
||||
title: firstLine,
|
||||
body: withoutPacket(rest),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function reportSection(report: string, headings: string[]) {
|
||||
const headingPattern = headings.join("|");
|
||||
const match = report.match(new RegExp(`^##\\s+(?:${headingPattern})\\s*$`, "im"));
|
||||
if (!match || match.index === undefined) return "";
|
||||
const tail = report.slice(match.index + match[0].length);
|
||||
const end = tail.search(/^##\s+/m);
|
||||
return (end >= 0 ? tail.slice(0, end) : tail).trim();
|
||||
}
|
||||
|
||||
function packetText(detail: FindingTrafficDetail, side: "request" | "response") {
|
||||
const head = side === "request" ? detail.binding.snapshot.req_head : detail.binding.snapshot.resp_head;
|
||||
const body = detail[side].content;
|
||||
return [head, body].filter(Boolean).join("\n\n").trim();
|
||||
}
|
||||
|
||||
function PacketDisclosure({
|
||||
findingId,
|
||||
bindingId,
|
||||
contextTask,
|
||||
}: {
|
||||
findingId: string;
|
||||
bindingId?: string;
|
||||
contextTask?: string;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [detail, setDetail] = React.useState<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>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,21 @@ 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} />
|
||||
@@ -42,9 +57,7 @@ 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} />,
|
||||
};
|
||||
|
||||
|
||||
@@ -970,6 +970,8 @@ export interface TrafficHost {
|
||||
// ---- App settings (runtime toggles) ----
|
||||
export interface Settings {
|
||||
traffic_capture: boolean;
|
||||
/** 是否向可见 Agent 暴露浏览器 MCP 的截图工具。默认关闭。 */
|
||||
browser_screenshot_enabled?: boolean;
|
||||
credential_agent_enabled: boolean;
|
||||
credential_traffic_binding: boolean;
|
||||
task_intelligence_enabled: boolean; // 默认关闭;关闭不注入情报工具或提示
|
||||
@@ -991,7 +993,7 @@ export interface Settings {
|
||||
// 全局出口代理(http/https/socks5,可带 user:pass),所有目标流量走它。开启流量捕获时作为
|
||||
// MITM 上游;关闭捕获时直接注入 agent 的 bash/WebFetch。空=直连。
|
||||
global_proxy?: string;
|
||||
show_egress_ip?: boolean; // 在顶栏显示直连 / 全局代理出口 IP;默认关闭
|
||||
show_egress_ip?: boolean; // 在顶栏显示直连 / 全局代理出口 IP;默认关闭
|
||||
python_interpreter?: string; // 自定义脚本工具的 python 解释器路径(空=运行时检测)
|
||||
workers?: number; // 并发工作 agent 数(默认3);对之后启动的任务生效
|
||||
worker_pools: WorkerPool[];
|
||||
|
||||
Reference in new issue
Block a user