mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-29 01:23:50 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb672389b0 | ||
|
|
21646d5d29 | ||
|
|
7d5c7101b7 | ||
|
|
5472330de4 | ||
|
|
6fc7317e52 | ||
|
|
67c0aee483 | ||
|
|
3e04b68e38 | ||
|
|
14ebf44362 | ||
|
|
495ab57ba7 | ||
|
|
d51bf259e4 | ||
|
|
6b0acf20f6 | ||
|
|
fb6942f2b2 | ||
|
|
fe939800d9 | ||
|
|
248b4d1319 | ||
|
|
252e83f13c | ||
|
|
b71c757055 | ||
|
|
d85ba36068 | ||
|
|
85ce36202c | ||
|
|
e8572a18d2 | ||
|
|
44924ec30b | ||
|
|
3c6070a8d8 | ||
|
|
76c76eefa9 | ||
|
|
404c4bb248 | ||
|
|
a999ed7cfd |
No files matched your search
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -19,15 +18,6 @@ func (failingFindingRecorder) Record(context.Context, db.RecordFindingInput, []d
|
||||
return nil, errors.New("fixture persistence failed")
|
||||
}
|
||||
|
||||
type capturingFindingRecorder struct {
|
||||
refs []db.TrafficRef
|
||||
}
|
||||
|
||||
func (r *capturingFindingRecorder) Record(_ context.Context, _ db.RecordFindingInput, refs []db.TrafficRef) (*db.RecordedFinding, error) {
|
||||
r.refs = append([]db.TrafficRef(nil), refs...)
|
||||
return &db.RecordedFinding{Traffic: &db.FindingTraffic{}}, nil
|
||||
}
|
||||
|
||||
func TestReportFindingOptionalTrafficWithoutCapture(t *testing.T) {
|
||||
d := testDB(t)
|
||||
defer d.Close()
|
||||
@@ -60,84 +50,6 @@ func TestReportFindingOptionalTrafficWithoutCapture(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportFindingPersistsStructuredReproduction(t *testing.T) {
|
||||
d := testDB(t)
|
||||
defer d.Close()
|
||||
task, err := d.CreateTask("structured reproduction", "fixture", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.DeleteTask(task.ID)
|
||||
ts := NewToolSet(d.Exploration(task.ExplorationID), "fixture")
|
||||
ts.taskID = task.ID
|
||||
ts.SetFindingRecorder(evidence.New(d, nil, t.TempDir()))
|
||||
reproduction := `{"entry_point":"POST /upload.php","prerequisites":["anonymous access"],"steps":[{"request":"POST /upload.php HTTP/1.1","response":"HTTP/1.1 201 Created","expected":"201 Created","proves":"upload accepted","traffic_ids":[]}]}`
|
||||
result, err := ts.addFinding().Call(t.Context(), json.RawMessage(`{"vulnclass":"upload","severity":"high","summary":"structured fixture","evidence":"proof","reproduction":`+reproduction+`}`), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var recorded db.RecordedFinding
|
||||
if err := json.Unmarshal([]byte(strings.SplitN(result.Flatten(), "\n", 2)[1]), &recorded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
finding, err := d.GetFinding(recorded.FindingID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wantReproduction, gotReproduction map[string]any
|
||||
if err := json.Unmarshal([]byte(reproduction), &wantReproduction); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if finding == nil {
|
||||
t.Fatalf("reproduction was not persisted: %#v", finding)
|
||||
}
|
||||
if err := json.Unmarshal(finding.Reproduction, &gotReproduction); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(gotReproduction, wantReproduction) {
|
||||
t.Fatalf("reproduction was not persisted: %#v", finding)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportFindingStepTrafficPreservesExplicitReferenceMetadata(t *testing.T) {
|
||||
old := FindingTrafficBindingEnabled
|
||||
FindingTrafficBindingEnabled = func() bool { return true }
|
||||
t.Cleanup(func() { FindingTrafficBindingEnabled = old })
|
||||
d := testDB(t)
|
||||
defer d.Close()
|
||||
task, err := d.CreateTask("step traffic merge", "fixture", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.DeleteTask(task.ID)
|
||||
recorder := &capturingFindingRecorder{}
|
||||
ts := NewToolSet(d.Exploration(task.ExplorationID), "fixture")
|
||||
ts.taskID = task.ID
|
||||
ts.SetFindingRecorder(recorder)
|
||||
reproduction := `{"steps":[{"traffic_ids":["shared"]},{"traffic_ids":["new-step"]}]}`
|
||||
input := `{"vulnclass":"TEST","severity":"low","summary":"step merge","traffic_refs":[{"traffic_id":"shared","role":"proof","note":"preserve this note"},{"traffic_id":"overview","role":"baseline","note":"overview note"}],"reproduction":` + reproduction + `}`
|
||||
result, err := ts.addFinding().Call(t.Context(), json.RawMessage(input), nil)
|
||||
if err != nil || result.IsError {
|
||||
t.Fatalf("report failed: %v %s", err, result.Flatten())
|
||||
}
|
||||
if len(recorder.refs) != 3 {
|
||||
t.Fatalf("unexpected refs: %#v", recorder.refs)
|
||||
}
|
||||
byID := make(map[string]db.TrafficRef, len(recorder.refs))
|
||||
for _, ref := range recorder.refs {
|
||||
byID[ref.TrafficID] = ref
|
||||
}
|
||||
if got := byID["shared"]; got.Role != "proof" || got.Note != "preserve this note" || got.StepIndex != 1 {
|
||||
t.Fatalf("overlapping ref metadata changed: %#v", got)
|
||||
}
|
||||
if got := byID["overview"]; got.Role != "baseline" || got.Note != "overview note" || got.StepIndex != 0 {
|
||||
t.Fatalf("overview ref changed: %#v", got)
|
||||
}
|
||||
if got := byID["new-step"]; got.Role != "supporting" || got.Note != "" || got.StepIndex != 2 {
|
||||
t.Fatalf("new step ref not defaulted: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportFindingAtomicContract(t *testing.T) {
|
||||
old := FindingTrafficBindingEnabled
|
||||
FindingTrafficBindingEnabled = func() bool { return true }
|
||||
|
||||
+27
-7
@@ -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
|
||||
@@ -54,7 +64,7 @@ const pentestDefaultTmpl = `你是一个授权渗透测试系统的"独立渗透
|
||||
- **发现新资产/入口** → insert_assets(资产本身:endpoint/parameter/tech 指纹/service/凭据/子域等,结构化属性写在资产 props 上)。回看已登记资产用 list_assets,避免重复登记。
|
||||
- **确认漏洞** → report_finding(含可复现 PoC)。**只有你在本次运行里真实触发过、拿到可复现证据(请求/响应或命令输出)才用它**;回看已报漏洞用 list_findings。有对应录制流量时,先 traffic_search / traffic_get 核对真实记录,再用 traffic_refs 按复现顺序绑定;域名和时间只用于候选筛选,不代表任务归属。严禁把仅凭版本/CVE 匹配、"看起来可注入"、外部漏洞库/更新日志/代码 diff 推断的东西当已确认漏洞上报。**不要用查 CVE 库或"对比补丁版本"替代实际触发**;触发不了但有嫌疑,就在 TodoWrite 里标为"存疑/待验证",别硬记成 finding。
|
||||
|
||||
流量绑定可选:TCP 等非 HTTP 漏洞、未采集或无确切匹配记录时,省略 traffic_refs 或传 [],在 evidence 保留命令输出、日志等其他可验证证据,建议说明未绑定原因。不要猜测 ID,也不要仅为补包重复探测。report_finding 还应填写 reproduction:entry_point、prerequisites、每步真实 request/response、expected、proves;有来源时填写 provenance 和步骤 traffic_ids,保留请求原文,不臆造或改写。
|
||||
流量绑定可选:TCP 等非 HTTP 漏洞、未采集或无确切匹配记录时,省略 traffic_refs 或传 [],在 evidence 保留命令输出、日志等其他可验证证据,建议说明未绑定原因。不要猜测 ID,也不要仅为补包重复探测。
|
||||
|
||||
━━ 判定与收尾 ━━
|
||||
- 随时对照任务目标:已被你**验证过**的成果满足了目标,就据此判定达成并说明依据。判"达成"的前提是心法 4 的自检已通过——没独立复现过的战果不算达成依据。
|
||||
@@ -90,11 +100,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"。
|
||||
- ` + "`## 根因分析`" + `:为什么会有这个漏洞(缺校验/危险函数/配置错误…)。
|
||||
@@ -112,11 +131,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,
|
||||
}
|
||||
}
|
||||
@@ -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
-51
@@ -1222,24 +1222,13 @@ func (t *ToolSet) goalMet() actool.CoreTool {
|
||||
// --- worker write tools ---
|
||||
|
||||
func (t *ToolSet) addFinding() actool.CoreTool {
|
||||
return writeTool("report_finding", "记录确认的漏洞,用 evidence 提供命令输出、日志等可验证证据。请务必填写结构化 reproduction:entry_point、prerequisites,以及每一步真实发出的 HTTP request/可选 response、expected、proves;不要改写请求原文。任务上下文传当前 intent_id。返回的 finding_id 是独立漏洞记录 ID,finding_node_id 是探索节点 ID(第一行保留该节点编号)。", obj(map[string]any{
|
||||
return writeTool("report_finding", "记录确认的漏洞,用 evidence 提供命令输出、日志等可验证证据。任务上下文传当前 intent_id。返回的 finding_id 是独立漏洞记录 ID,finding_node_id 是探索节点 ID(第一行保留该节点编号)。", obj(map[string]any{
|
||||
"vulnclass": str("漏洞类别"), "name": str("漏洞名称"), "severity": str("critical|high|medium|low"), "summary": str("发现摘要"),
|
||||
"intent_id": idp("当前任务的意图 id"), "asset_ids": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}, "description": "受影响资产 id"},
|
||||
"evidence": str("证据/PoC 文本"),
|
||||
"evidence_hint_id": idp("可选:本任务中对应此漏洞的提示节点 ID,自动携带其结构化 traffic_refs;不能引用继承提示或其他漏洞的提示"),
|
||||
"traffic_refs": map[string]any{"type": "array", "description": "可选;HTTP/HTTPS 漏洞先检索并逐条核实请求/响应确实支持漏洞结论,再按复现顺序填写真实 ID。TCP 等非 HTTP 漏洞、未采集或找不到确切记录时省略或传 [],不阻止上报;可在 evidence 说明原因并提供其他可验证证据。不要猜测 ID、按域名/时间推定关联或仅为补包重复探测。用途 baseline 正常对照 / proof 漏洞证明 / verification 补充验证 / supporting 辅助证据。",
|
||||
"items": obj(map[string]any{"traffic_id": str("traffic_search 返回的真实流量 ID"), "role": map[string]any{"type": "string", "enum": []string{"baseline", "proof", "verification", "supporting"}}, "note": str("该流量如何支持漏洞结论")}, "traffic_id")},
|
||||
"reproduction": map[string]any{"type": "object", "description": "结构化、可从零复现的真实过程;有 HTTP 时每一步都填原始请求,响应填关键证明片段。",
|
||||
"properties": map[string]any{
|
||||
"entry_point": str("入口接口,例如 POST /upload.php"),
|
||||
"prerequisites": map[string]any{"type": "array", "items": str("前置条件,例如匿名访问或测试账号注册方式")},
|
||||
"steps": map[string]any{"type": "array", "items": obj(map[string]any{
|
||||
"request": str("真实发出的标准 HTTP 请求原文,逐字保留"), "response": str("可选:真实 HTTP 响应原文或关键证明片段"), "expected": str("该步预期结果"), "proves": str("该步证明了什么"),
|
||||
"traffic_ids": map[string]any{"type": "array", "items": str("可选:traffic_search 返回且已核实的真实流量 ID")},
|
||||
}, "request", "expected", "proves")},
|
||||
"provenance": str("攻击链来源或发现该漏洞的意图"),
|
||||
},
|
||||
},
|
||||
}, "vulnclass", "severity", "summary"), func(ctx context.Context, in json.RawMessage) (actool.Result, error) {
|
||||
var a struct {
|
||||
VulnClass, Name, Severity, Summary, Evidence string
|
||||
@@ -1247,7 +1236,6 @@ func (t *ToolSet) addFinding() actool.CoreTool {
|
||||
AssetIDs []json.RawMessage `json:"asset_ids"`
|
||||
TrafficRefs []db.TrafficRef `json:"traffic_refs"`
|
||||
EvidenceHintID json.RawMessage `json:"evidence_hint_id"`
|
||||
Reproduction json.RawMessage `json:"reproduction"`
|
||||
}
|
||||
if err := json.Unmarshal(in, &a); err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
@@ -1270,44 +1258,7 @@ func (t *ToolSet) addFinding() actool.CoreTool {
|
||||
if err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
}
|
||||
// Step traffic_ids are resolved at report time. Keep metadata supplied by
|
||||
// traffic_refs when an id is mentioned by a reproduction step; only ids
|
||||
// that are absent from traffic_refs get the supporting default.
|
||||
if findingTrafficBindingEnabled() && len(a.Reproduction) > 0 && string(a.Reproduction) != "null" {
|
||||
var recipe struct {
|
||||
Steps []struct {
|
||||
TrafficIDs []string `json:"traffic_ids"`
|
||||
} `json:"steps"`
|
||||
}
|
||||
if err := json.Unmarshal(a.Reproduction, &recipe); err != nil {
|
||||
return actool.Errorf(fmt.Sprintf("reproduction JSON 无效: %v", err)), nil
|
||||
}
|
||||
refIndex := make(map[string]int, len(refs))
|
||||
for i := range refs {
|
||||
id := strings.TrimSpace(refs[i].TrafficID)
|
||||
if id != "" {
|
||||
refs[i].TrafficID = id
|
||||
if _, exists := refIndex[id]; !exists {
|
||||
refIndex[id] = i
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, step := range recipe.Steps {
|
||||
for _, id := range step.TrafficIDs {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if ref, exists := refIndex[id]; exists {
|
||||
refs[ref].StepIndex = i + 1
|
||||
continue
|
||||
}
|
||||
refIndex[id] = len(refs)
|
||||
refs = append(refs, db.TrafficRef{TrafficID: id, Role: "supporting", StepIndex: i + 1})
|
||||
}
|
||||
}
|
||||
}
|
||||
input := db.RecordFindingInput{TaskID: t.taskID, ExplorationID: t.ts.ID(), IntentID: pid(a.IntentID), VulnClass: a.VulnClass, Name: a.Name, Severity: a.Severity, Summary: a.Summary, Evidence: a.Evidence, Worker: t.worker, AssetIDs: pidList(a.AssetIDs), Reproduction: a.Reproduction}
|
||||
input := db.RecordFindingInput{TaskID: t.taskID, ExplorationID: t.ts.ID(), IntentID: pid(a.IntentID), VulnClass: a.VulnClass, Name: a.Name, Severity: a.Severity, Summary: a.Summary, Evidence: a.Evidence, Worker: t.worker, AssetIDs: pidList(a.AssetIDs)}
|
||||
var recorded *db.RecordedFinding
|
||||
if t.findingRecorder != nil {
|
||||
recorded, err = t.findingRecorder.Record(ctx, input, refs)
|
||||
|
||||
@@ -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)},
|
||||
}
|
||||
|
||||
+2
-2
@@ -2016,6 +2016,6 @@ func (s *ExplorationStore) ActivityByIDsForTerminalIntents(ids []int64) ([]Activ
|
||||
// AddStandaloneFinding writes a finding to the standalone findings table, which
|
||||
// persists across task deletion (task_id / node_id become NULL when the task or
|
||||
// exploration node is deleted). taskID and nodeID may be 0 (stored as NULL).
|
||||
func (s *ExplorationStore) AddStandaloneFinding(taskID, nodeID int64, vulnclass, name, severity, summary, evidence, worker string, assetIDs []int64, reproduction ...json.RawMessage) (int64, error) {
|
||||
return s.db.AddFinding(taskID, nodeID, vulnclass, name, severity, summary, evidence, worker, assetIDs, reproduction...)
|
||||
func (s *ExplorationStore) AddStandaloneFinding(taskID, nodeID int64, vulnclass, name, severity, summary, evidence, worker string, assetIDs []int64) (int64, error) {
|
||||
return s.db.AddFinding(taskID, nodeID, vulnclass, name, severity, summary, evidence, worker, assetIDs)
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFindingReproductionAndTrafficStepIndex(t *testing.T) {
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
repro := json.RawMessage(`{"entry_point":"POST /upload.php","steps":[{"request":"POST /upload.php HTTP/1.1","expected":"201","proves":"upload accepted"}]}`)
|
||||
fid, err := d.AddFinding(0, 0, "upload", "upload", SeverityHigh, "summary", "poc", "test", nil, repro)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.DeleteFinding(fid)
|
||||
f, err := d.GetFinding(fid)
|
||||
if err != nil {
|
||||
t.Fatalf("reproduction round trip: %#v, %v", f, err)
|
||||
}
|
||||
var wantRepro, gotRepro map[string]any
|
||||
if err := json.Unmarshal(repro, &wantRepro); err != nil {
|
||||
t.Fatalf("unmarshal input reproduction: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(f.Reproduction, &gotRepro); err != nil {
|
||||
t.Fatalf("unmarshal round-trip reproduction: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(gotRepro, wantRepro) {
|
||||
t.Fatalf("reproduction round trip: got %#v, want %#v", gotRepro, wantRepro)
|
||||
}
|
||||
snapshot := TrafficEvidenceSnapshot{SourceTrafficID: "source-1", CapturedAt: 1, URL: "https://example.test/upload.php", Method: "POST", Status: 201, ReqHash: "req", RespHash: "resp"}
|
||||
snapshot.ID = TrafficSnapshotID(snapshot)
|
||||
prepared := []PreparedTrafficEvidence{{Ref: TrafficRef{TrafficID: "source-1", StepIndex: 2, Role: "proof"}, Snapshot: snapshot}}
|
||||
if err := d.WithEvidenceTx(t.Context(), func(tx *sql.Tx) error {
|
||||
if err := InsertEvidenceSnapshotTx(tx, snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
return AddFindingTrafficTx(tx, fid, prepared)
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
traffic, err := d.GetFindingTraffic(t.Context(), fid)
|
||||
if err != nil || len(traffic.Bindings) != 1 || traffic.Bindings[0].StepIndex != 2 {
|
||||
t.Fatalf("step binding round trip: %#v, %v", traffic, err)
|
||||
}
|
||||
zero := 0
|
||||
if err := d.EditFindingTrafficStep(t.Context(), fid, traffic.Bindings[0].ID, traffic.Version, nil, nil, &zero, false, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
traffic, err = d.GetFindingTraffic(t.Context(), fid)
|
||||
if err != nil || traffic.Bindings[0].StepIndex != 0 {
|
||||
t.Fatalf("step edit: %#v, %v", traffic, err)
|
||||
}
|
||||
if err := d.EditFindingTraffic(t.Context(), fid, traffic.Bindings[0].ID, traffic.Version, nil, nil, true, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindingWithoutReproductionLoadsNull(t *testing.T) {
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
fid, err := d.AddFinding(0, 0, "legacy", "legacy", SeverityLow, "summary", "evidence", "test", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.DeleteFinding(fid)
|
||||
f, err := d.GetFinding(fid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.Reproduction != nil {
|
||||
t.Fatalf("legacy reproduction = %s, want nil", f.Reproduction)
|
||||
}
|
||||
}
|
||||
+8
-33
@@ -43,7 +43,6 @@ type TrafficRef struct {
|
||||
TrafficID string `json:"traffic_id"`
|
||||
Role string `json:"role"`
|
||||
Note string `json:"note"`
|
||||
StepIndex int `json:"step_index,omitempty"`
|
||||
}
|
||||
|
||||
func NormalizeTrafficRefs(refs []TrafficRef) ([]TrafficRef, error) {
|
||||
@@ -57,9 +56,6 @@ func NormalizeTrafficRefs(refs []TrafficRef) ([]TrafficRef, error) {
|
||||
if ref.Role == "" {
|
||||
ref.Role = "supporting"
|
||||
}
|
||||
if ref.StepIndex < 0 {
|
||||
return nil, errors.New("step_index 不能为负数")
|
||||
}
|
||||
if !ValidTrafficRole(ref.Role) {
|
||||
return nil, fmt.Errorf("无效的流量用途 %q", ref.Role)
|
||||
}
|
||||
@@ -99,7 +95,6 @@ type FindingTrafficBinding struct {
|
||||
Role string `json:"role"`
|
||||
Note string `json:"note"`
|
||||
Position int `json:"position"`
|
||||
StepIndex int `json:"step_index"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Snapshot TrafficEvidenceSnapshot `json:"snapshot"`
|
||||
}
|
||||
@@ -220,8 +215,8 @@ func AddFindingTrafficTx(tx *sql.Tx, findingID int64, prepared []PreparedTraffic
|
||||
if _, err := tx.Exec(`UPDATE traffic_evidence_snapshots SET unreferenced_at=NULL WHERE id=$1`, item.Snapshot.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := tx.Exec(`INSERT INTO finding_traffic_bindings(finding_id,snapshot_id,role,note,position,step_index)
|
||||
VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(finding_id,snapshot_id) DO NOTHING`, findingID, item.Snapshot.ID, item.Ref.Role, item.Ref.Note, position, item.Ref.StepIndex)
|
||||
res, err := tx.Exec(`INSERT INTO finding_traffic_bindings(finding_id,snapshot_id,role,note,position)
|
||||
VALUES($1,$2,$3,$4,$5) ON CONFLICT(finding_id,snapshot_id) DO NOTHING`, findingID, item.Snapshot.ID, item.Ref.Role, item.Ref.Note, position)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -253,7 +248,7 @@ func FindingTrafficTx(tx *sql.Tx, findingID int64) (*FindingTraffic, error) {
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
rows, err := tx.Query(`SELECT b.id,b.finding_id,b.snapshot_id,b.role,b.note,b.position,b.step_index,b.created_at,to_jsonb(s)
|
||||
rows, err := tx.Query(`SELECT b.id,b.finding_id,b.snapshot_id,b.role,b.note,b.position,b.created_at,to_jsonb(s)
|
||||
FROM finding_traffic_bindings b JOIN traffic_evidence_snapshots s ON s.id=b.snapshot_id WHERE b.finding_id=$1 ORDER BY b.position,b.id`, findingID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -262,7 +257,7 @@ FROM finding_traffic_bindings b JOIN traffic_evidence_snapshots s ON s.id=b.snap
|
||||
for rows.Next() {
|
||||
var b FindingTrafficBinding
|
||||
var raw []byte
|
||||
if err := rows.Scan(&b.ID, &b.FindingID, &b.SnapshotID, &b.Role, &b.Note, &b.Position, &b.StepIndex, &b.CreatedAt, &raw); err != nil {
|
||||
if err := rows.Scan(&b.ID, &b.FindingID, &b.SnapshotID, &b.Role, &b.Note, &b.Position, &b.CreatedAt, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(raw, &b.Snapshot); err != nil {
|
||||
@@ -279,16 +274,9 @@ func (d *DB) GetFindingTraffic(ctx context.Context, findingID int64) (out *Findi
|
||||
}
|
||||
|
||||
func (d *DB) EditFindingTraffic(ctx context.Context, findingID, bindingID, version int64, role, note *string, remove bool, order []int64) error {
|
||||
return d.EditFindingTrafficStep(ctx, findingID, bindingID, version, role, note, nil, remove, order)
|
||||
}
|
||||
|
||||
func (d *DB) EditFindingTrafficStep(ctx context.Context, findingID, bindingID, version int64, role, note *string, stepIndex *int, remove bool, order []int64) error {
|
||||
if role != nil && !ValidTrafficRole(*role) {
|
||||
return errors.New("无效的流量用途")
|
||||
}
|
||||
if stepIndex != nil && *stepIndex < 0 {
|
||||
return errors.New("step_index 不能为负数")
|
||||
}
|
||||
return d.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
|
||||
if err := LockFindingEvidenceTx(tx, findingID, &version); err != nil {
|
||||
return err
|
||||
@@ -320,7 +308,7 @@ func (d *DB) EditFindingTrafficStep(ctx context.Context, findingID, bindingID, v
|
||||
if remove {
|
||||
res, err = tx.Exec(`DELETE FROM finding_traffic_bindings WHERE finding_id=$1 AND id=$2`, findingID, bindingID)
|
||||
} else {
|
||||
res, err = tx.Exec(`UPDATE finding_traffic_bindings SET role=COALESCE($3,role),note=COALESCE($4,note),step_index=COALESCE($5,step_index) WHERE finding_id=$1 AND id=$2`, findingID, bindingID, role, note, stepIndex)
|
||||
res, err = tx.Exec(`UPDATE finding_traffic_bindings SET role=COALESCE($3,role),note=COALESCE($4,note) WHERE finding_id=$1 AND id=$2`, findingID, bindingID, role, note)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -341,7 +329,6 @@ type RecordFindingInput struct {
|
||||
TaskID, ExplorationID, IntentID int64
|
||||
VulnClass, Name, Severity, Summary, Evidence, Worker string
|
||||
AssetIDs []int64
|
||||
Reproduction json.RawMessage
|
||||
}
|
||||
|
||||
type RecordedFinding struct {
|
||||
@@ -374,15 +361,7 @@ func RecordFindingTx(ctx context.Context, tx *sql.Tx, in RecordFindingInput, pre
|
||||
return nil, errors.New("intent_id 必须是本任务的意图(关联任务意图只读)")
|
||||
}
|
||||
}
|
||||
payloadMap := map[string]any{"vulnclass": in.VulnClass, "name": in.Name, "severity": in.Severity, "summary": in.Summary, "evidence": map[string]any{"by": in.Worker, "poc": in.Evidence}}
|
||||
if len(in.Reproduction) > 0 && string(in.Reproduction) != "null" {
|
||||
var reproduction any
|
||||
if err := json.Unmarshal(in.Reproduction, &reproduction); err != nil {
|
||||
return nil, fmt.Errorf("reproduction JSON 无效: %w", err)
|
||||
}
|
||||
payloadMap["reproduction"] = reproduction
|
||||
}
|
||||
payload, _ := json.Marshal(payloadMap)
|
||||
payload, _ := json.Marshal(map[string]any{"vulnclass": in.VulnClass, "name": in.Name, "severity": in.Severity, "summary": in.Summary, "evidence": map[string]any{"by": in.Worker, "poc": in.Evidence}})
|
||||
out := &RecordedFinding{}
|
||||
if err := tx.QueryRow(`INSERT INTO exploration_nodes(exploration_id,kind,payload,priority,state,origin)
|
||||
VALUES($1,'finding',$2,9,'confirmed',$3) RETURNING id`, in.ExplorationID, string(payload), in.Worker).Scan(&out.NodeID); err != nil {
|
||||
@@ -403,12 +382,8 @@ VALUES($1,'finding',$2,9,'confirmed',$3) RETURNING id`, in.ExplorationID, string
|
||||
assets = []int64{}
|
||||
}
|
||||
raw, _ := json.Marshal(assets)
|
||||
var repro any
|
||||
if len(in.Reproduction) > 0 && string(in.Reproduction) != "null" {
|
||||
repro = string(in.Reproduction)
|
||||
}
|
||||
if err := tx.QueryRow(`INSERT INTO findings(task_id,node_id,vulnclass,name,severity,summary,evidence,worker,asset_ids,reproduction)
|
||||
VALUES(NULLIF($1,0),$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING id`, in.TaskID, out.NodeID, in.VulnClass, in.Name, in.Severity, in.Summary, in.Evidence, in.Worker, string(raw), repro).Scan(&out.FindingID); err != nil {
|
||||
if err := tx.QueryRow(`INSERT INTO findings(task_id,node_id,vulnclass,name,severity,summary,evidence,worker,asset_ids)
|
||||
VALUES(NULLIF($1,0),$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`, in.TaskID, out.NodeID, in.VulnClass, in.Name, in.Severity, in.Summary, in.Evidence, in.Worker, string(raw)).Scan(&out.FindingID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 在**同一事务**里登记一条推送事件:提交即保证「漏洞落库」与「推送任务存在」
|
||||
|
||||
@@ -51,12 +51,6 @@ func restoreFindingTrafficTx(tx *sql.Tx, snapshot *TaskArchiveSnapshot) error {
|
||||
if !allowed[sid] || !ValidTrafficRole(role) {
|
||||
return errors.New("invalid archived evidence binding")
|
||||
}
|
||||
if rawStep, exists := row["step_index"]; exists && rawStep != nil {
|
||||
step, ok := jsonInt64(rawStep)
|
||||
if !ok || step < 0 {
|
||||
return errors.New("invalid archived evidence step index")
|
||||
}
|
||||
}
|
||||
var owned bool
|
||||
if err = tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM findings WHERE id=$1 AND task_id=$2)`, fid, snapshot.TaskID).Scan(&owned); err != nil {
|
||||
return err
|
||||
@@ -67,10 +61,7 @@ func restoreFindingTrafficTx(tx *sql.Tx, snapshot *TaskArchiveSnapshot) error {
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
raw, _ := json.Marshal(rows)
|
||||
if _, err = tx.Exec(`INSERT INTO finding_traffic_bindings
|
||||
(id,finding_id,snapshot_id,role,note,position,created_at,step_index)
|
||||
SELECT id,finding_id,snapshot_id,role,note,position,created_at,COALESCE(step_index,0)
|
||||
FROM json_to_recordset($1::json) AS x(id bigint,finding_id bigint,snapshot_id text,role text,note text,position integer,created_at timestamptz,step_index integer)`, string(raw)); err != nil {
|
||||
if _, err = tx.Exec(`INSERT INTO finding_traffic_bindings SELECT * FROM json_populate_recordset(NULL::finding_traffic_bindings,$1::json)`, string(raw)); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`UPDATE traffic_evidence_snapshots s SET unreferenced_at=NULL WHERE EXISTS(SELECT 1 FROM finding_traffic_bindings b WHERE b.snapshot_id=s.id)`)
|
||||
|
||||
+8
-34
@@ -34,30 +34,11 @@ type DBFinding struct {
|
||||
Worker string
|
||||
AssetIDs []int64
|
||||
Status string
|
||||
Report string // 详细报告(Markdown);仅 GetFinding 填充,列表查询不带
|
||||
Reproduction json.RawMessage // 结构化复现过程; NULL 表示未提供
|
||||
Report string // 详细报告(Markdown);仅 GetFinding 填充,列表查询不带
|
||||
CreatedAt time.Time
|
||||
TaskDescription string // populated via LEFT JOIN on tasks
|
||||
}
|
||||
|
||||
// Reproduction is the structured reproduction recipe captured by report_finding.
|
||||
// It is kept as JSON in the findings table so older rows remain nullable while
|
||||
// callers that need the shape can decode it without inventing steps from prose.
|
||||
type Reproduction struct {
|
||||
EntryPoint string `json:"entry_point,omitempty"`
|
||||
Prerequisites []string `json:"prerequisites,omitempty"`
|
||||
Steps []ReproStep `json:"steps,omitempty"`
|
||||
Provenance string `json:"provenance,omitempty"`
|
||||
}
|
||||
|
||||
type ReproStep struct {
|
||||
Request string `json:"request"`
|
||||
Response string `json:"response,omitempty"`
|
||||
Expected string `json:"expected,omitempty"`
|
||||
Proves string `json:"proves,omitempty"`
|
||||
TrafficIDs []string `json:"traffic_ids,omitempty"`
|
||||
}
|
||||
|
||||
// Finding triage states (findings.status).
|
||||
const (
|
||||
FindingPending = "pending" // 待处理
|
||||
@@ -101,7 +82,7 @@ func ValidSeverity(s string) bool {
|
||||
// AddFinding inserts a finding into the standalone findings table. taskID and
|
||||
// nodeID may be 0 (stored as NULL). name may be "" (frontend falls back to
|
||||
// vulnclass). Returns the new finding id.
|
||||
func (d *DB) AddFinding(taskID, nodeID int64, vulnclass, name, severity, summary, evidence, worker string, assetIDs []int64, reproduction ...json.RawMessage) (int64, error) {
|
||||
func (d *DB) AddFinding(taskID, nodeID int64, vulnclass, name, severity, summary, evidence, worker string, assetIDs []int64) (int64, error) {
|
||||
aidsJSON, _ := json.Marshal(assetIDs)
|
||||
if assetIDs == nil {
|
||||
aidsJSON = []byte("[]")
|
||||
@@ -113,15 +94,11 @@ func (d *DB) AddFinding(taskID, nodeID int64, vulnclass, name, severity, summary
|
||||
if nodeID > 0 {
|
||||
nid = &nodeID
|
||||
}
|
||||
var repro any
|
||||
if len(reproduction) > 0 && len(reproduction[0]) > 0 && string(reproduction[0]) != "null" {
|
||||
repro = string(reproduction[0])
|
||||
}
|
||||
var id int64
|
||||
err := d.QueryRow(
|
||||
`INSERT INTO findings (task_id, node_id, vulnclass, name, severity, summary, evidence, worker, asset_ids, reproduction)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`,
|
||||
tid, nid, vulnclass, name, severity, summary, evidence, worker, string(aidsJSON), repro,
|
||||
`INSERT INTO findings (task_id, node_id, vulnclass, name, severity, summary, evidence, worker, asset_ids)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`,
|
||||
tid, nid, vulnclass, name, severity, summary, evidence, worker, string(aidsJSON),
|
||||
).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
@@ -669,14 +646,14 @@ func (d *DB) FindingStats() (*FindingStats, error) {
|
||||
// it also selects `report` — that column is only needed on the detail page.
|
||||
func (d *DB) GetFinding(id int64) (*DBFinding, error) {
|
||||
f := &DBFinding{}
|
||||
var aidsJSON, reproduction string
|
||||
err := d.QueryRow(`SELECT `+findingSelectCols+`, COALESCE(f.report, ''), COALESCE(f.reproduction::text, '')
|
||||
var aidsJSON string
|
||||
err := d.QueryRow(`SELECT `+findingSelectCols+`, COALESCE(f.report, '')
|
||||
FROM findings f
|
||||
LEFT JOIN tasks t ON f.task_id = t.id
|
||||
WHERE f.id = $1`, id).Scan(
|
||||
&f.ID, &f.TaskID, &f.NodeID, &f.VulnClass, &f.Name, &f.Severity,
|
||||
&f.Summary, &f.Evidence, &f.Worker, &aidsJSON, &f.Status, &f.CreatedAt,
|
||||
&f.TaskDescription, &f.EvidenceVersion, &f.ReportEvidenceVersion, &f.TrafficCount, &f.Report, &reproduction)
|
||||
&f.TaskDescription, &f.EvidenceVersion, &f.ReportEvidenceVersion, &f.TrafficCount, &f.Report)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -684,9 +661,6 @@ func (d *DB) GetFinding(id int64) (*DBFinding, error) {
|
||||
return nil, err
|
||||
}
|
||||
_ = json.Unmarshal([]byte(aidsJSON), &f.AssetIDs)
|
||||
if reproduction != "" {
|
||||
f.Reproduction = json.RawMessage(reproduction)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1167,7 +1167,6 @@ CREATE TABLE IF NOT EXISTS findings (
|
||||
ALTER TABLE findings ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'pending';
|
||||
ALTER TABLE findings ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE findings ADD COLUMN IF NOT EXISTS report TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE findings ADD COLUMN IF NOT EXISTS reproduction JSONB;
|
||||
CREATE INDEX IF NOT EXISTS idx_findings_task ON findings(task_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_findings_time ON findings(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_findings_status ON findings(status, created_at DESC);
|
||||
@@ -1232,14 +1231,11 @@ CREATE TABLE IF NOT EXISTS finding_traffic_bindings (
|
||||
snapshot_id TEXT NOT NULL REFERENCES traffic_evidence_snapshots(id),
|
||||
role TEXT NOT NULL DEFAULT 'supporting',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
step_index INTEGER NOT NULL DEFAULT 0,
|
||||
position INTEGER NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(finding_id, snapshot_id)
|
||||
);
|
||||
ALTER TABLE finding_traffic_bindings ADD COLUMN IF NOT EXISTS step_index INTEGER NOT NULL DEFAULT 0;
|
||||
CREATE INDEX IF NOT EXISTS idx_finding_traffic_order ON finding_traffic_bindings(finding_id, position, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_finding_traffic_step ON finding_traffic_bindings(finding_id, step_index, position, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_finding_traffic_snapshot ON finding_traffic_bindings(snapshot_id);
|
||||
|
||||
-- =====================================================================
|
||||
|
||||
+6
-1
@@ -570,7 +570,12 @@ WHERE type='root_domain' AND domain=$1 LIMIT 1`, root).Scan(&id, &companyID)
|
||||
link(n.Key, companyKey(n.CompanyID))
|
||||
}
|
||||
case "service":
|
||||
link(n.Key, firstOf(subByDomain[n.Domain], ipByAddr[n.IP], rootByDomain[n.RootDomain]))
|
||||
host, _ := hostPortOf(n)
|
||||
link(n.Key, firstOf(
|
||||
subByDomain[n.Domain], subByDomain[host],
|
||||
ipByAddr[n.IP], ipByAddr[host],
|
||||
rootByDomain[n.RootDomain],
|
||||
))
|
||||
case "endpoint":
|
||||
host, port := hostPortOf(n)
|
||||
parent := firstOf(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+21
-25
@@ -324,7 +324,6 @@ type FindingDTO struct {
|
||||
ReportEvidenceVersion int64 `json:"report_evidence_version"`
|
||||
ReportStale bool `json:"report_stale"`
|
||||
TrafficBindings []db.FindingTrafficBinding `json:"traffic_bindings,omitempty"`
|
||||
Reproduction json.RawMessage `json:"reproduction"`
|
||||
|
||||
ID string `json:"id"`
|
||||
FindingID string `json:"finding_id,omitempty"` // standalone findings-table id — the handle for status updates
|
||||
@@ -387,27 +386,25 @@ func assetLabel(a *db.Asset) string {
|
||||
// findingPayload mirrors the JSON written by the worker's report_finding tool
|
||||
// (agent/tools.go addFinding): {vulnclass, severity, summary, evidence:{by,poc}}.
|
||||
type findingPayload struct {
|
||||
VulnClass string `json:"vulnclass"`
|
||||
Name string `json:"name"`
|
||||
Severity string `json:"severity"`
|
||||
Summary string `json:"summary"`
|
||||
Evidence json.RawMessage `json:"evidence"`
|
||||
Reproduction json.RawMessage `json:"reproduction"`
|
||||
VulnClass string `json:"vulnclass"`
|
||||
Name string `json:"name"`
|
||||
Severity string `json:"severity"`
|
||||
Summary string `json:"summary"`
|
||||
Evidence json.RawMessage `json:"evidence"`
|
||||
}
|
||||
|
||||
func findingDTO(n *db.Node) FindingDTO {
|
||||
var p findingPayload
|
||||
_ = json.Unmarshal(n.Payload, &p)
|
||||
d := FindingDTO{
|
||||
ID: i64s(n.ID),
|
||||
VulnClass: p.VulnClass,
|
||||
Name: p.Name,
|
||||
Severity: p.Severity,
|
||||
Status: db.FindingPending,
|
||||
Summary: p.Summary,
|
||||
Evidence: rawString(p.Evidence),
|
||||
Reproduction: p.Reproduction,
|
||||
TS: rfc3339(n.CreatedAt),
|
||||
ID: i64s(n.ID),
|
||||
VulnClass: p.VulnClass,
|
||||
Name: p.Name,
|
||||
Severity: p.Severity,
|
||||
Status: db.FindingPending,
|
||||
Summary: p.Summary,
|
||||
Evidence: rawString(p.Evidence),
|
||||
TS: rfc3339(n.CreatedAt),
|
||||
}
|
||||
if n.SourceTaskID > 0 {
|
||||
d.SourceTaskID = i64s(n.SourceTaskID)
|
||||
@@ -467,15 +464,14 @@ func findingFromDB(f *db.DBFinding, assets map[int64]*db.Asset) FindingDTO {
|
||||
FindingID: i64s(f.ID),
|
||||
TrafficCount: f.TrafficCount, EvidenceVersion: f.EvidenceVersion, ReportEvidenceVersion: f.ReportEvidenceVersion,
|
||||
ReportStale: f.Report != "" && f.EvidenceVersion != f.ReportEvidenceVersion, TrafficBindings: f.TrafficBindings,
|
||||
Reproduction: f.Reproduction,
|
||||
VulnClass: f.VulnClass,
|
||||
Name: f.Name,
|
||||
Severity: f.Severity,
|
||||
Status: status,
|
||||
Summary: f.Summary,
|
||||
Evidence: f.Evidence,
|
||||
Report: f.Report,
|
||||
TS: rfc3339(f.CreatedAt),
|
||||
VulnClass: f.VulnClass,
|
||||
Name: f.Name,
|
||||
Severity: f.Severity,
|
||||
Status: status,
|
||||
Summary: f.Summary,
|
||||
Evidence: f.Evidence,
|
||||
Report: f.Report,
|
||||
TS: rfc3339(f.CreatedAt),
|
||||
}
|
||||
if f.ExportCaseID > 0 {
|
||||
d.ID = "case:" + i64s(f.ExportCaseID)
|
||||
|
||||
@@ -164,8 +164,7 @@ func (s *Server) bindFindingTraffic(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Refs []db.TrafficRef `json:"traffic_refs"`
|
||||
StepIndex *int `json:"step_index"`
|
||||
Refs []db.TrafficRef `json:"traffic_refs"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body); err != nil {
|
||||
writeErr(w, 400, "invalid body")
|
||||
@@ -175,15 +174,6 @@ func (s *Server) bindFindingTraffic(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 400, "请选择流量")
|
||||
return
|
||||
}
|
||||
if body.StepIndex != nil {
|
||||
if *body.StepIndex < 0 {
|
||||
writeErr(w, 400, "step_index 不能为负数")
|
||||
return
|
||||
}
|
||||
for i := range body.Refs {
|
||||
body.Refs[i].StepIndex = *body.StepIndex
|
||||
}
|
||||
}
|
||||
out, err := s.evidenceStore().Bind(r.Context(), id, body.Refs)
|
||||
if err != nil {
|
||||
evidenceError(w, err)
|
||||
@@ -198,20 +188,15 @@ func (s *Server) editFindingTraffic(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Version *int64 `json:"version"`
|
||||
Role *string `json:"role"`
|
||||
Note *string `json:"note"`
|
||||
Order []string `json:"binding_ids"`
|
||||
StepIndex *int `json:"step_index"`
|
||||
Version *int64 `json:"version"`
|
||||
Role *string `json:"role"`
|
||||
Note *string `json:"note"`
|
||||
Order []string `json:"binding_ids"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body); err != nil || body.Version == nil {
|
||||
writeErr(w, 400, "version 和有效请求体必填")
|
||||
return
|
||||
}
|
||||
if body.StepIndex != nil && *body.StepIndex < 0 {
|
||||
writeErr(w, 400, "step_index 不能为负数")
|
||||
return
|
||||
}
|
||||
var order []int64
|
||||
bindingID := int64(0)
|
||||
if r.Method == http.MethodPut {
|
||||
@@ -236,7 +221,7 @@ func (s *Server) editFindingTraffic(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
err := s.m.pg.EditFindingTrafficStep(r.Context(), id, bindingID, *body.Version, body.Role, body.Note, body.StepIndex, r.Method == http.MethodDelete, order)
|
||||
err := s.m.pg.EditFindingTraffic(r.Context(), id, bindingID, *body.Version, body.Role, body.Note, r.Method == http.MethodDelete, order)
|
||||
if err != nil {
|
||||
evidenceError(w, err)
|
||||
return
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
)
|
||||
|
||||
func (s *Server) seedFindingWorkflowTools() {
|
||||
s.migrateFindingWorkflowSchemas()
|
||||
const hostSearchDescriptionFlag = "finding_workflow_tools_v3_host_search_description"
|
||||
if value, _, _ := s.m.pg.GetSetting(hostSearchDescriptionFlag); value != "true" {
|
||||
// Only replace the original built-in text. A user-edited description is
|
||||
@@ -107,51 +106,6 @@ func (s *Server) seedFindingWorkflowTools() {
|
||||
_ = s.m.pg.SetSetting(flag, "true")
|
||||
}
|
||||
|
||||
// migrateFindingWorkflowSchemas adds schema fields introduced after the
|
||||
// original finding workflow seed. It patches only missing properties so a
|
||||
// user's description, agent bindings, enabled state, and other schema edits
|
||||
// remain untouched.
|
||||
func (s *Server) migrateFindingWorkflowSchemas() {
|
||||
const flag = "finding_workflow_tools_v3_reproduction_step_index"
|
||||
if value, _, _ := s.m.pg.GetSetting(flag); value == "true" {
|
||||
return
|
||||
}
|
||||
properties := map[string]json.RawMessage{}
|
||||
for _, seed := range agent.BuiltinToolSeeds() {
|
||||
if seed.Key != "report_finding" {
|
||||
continue
|
||||
}
|
||||
if raw, err := json.Marshal(objectProperty(seed.Schema, "properties")["reproduction"]); err == nil && string(raw) != "null" {
|
||||
properties["report_finding"] = raw
|
||||
}
|
||||
break
|
||||
}
|
||||
for _, tool := range s.orchestrationTools() {
|
||||
if tool.Name() != "bind_finding_traffic" {
|
||||
continue
|
||||
}
|
||||
schema := tool.InputSchema()
|
||||
if raw, err := json.Marshal(objectProperty(schema, "properties")["step_index"]); err == nil && string(raw) != "null" {
|
||||
properties["bind_finding_traffic"] = raw
|
||||
}
|
||||
break
|
||||
}
|
||||
for key, property := range map[string]string{"report_finding": "reproduction", "bind_finding_traffic": "step_index"} {
|
||||
raw := properties[key]
|
||||
if len(raw) == 0 {
|
||||
log.Printf("[evidence] missing code schema property %s.%s", key, property)
|
||||
continue
|
||||
}
|
||||
if _, err := s.m.pg.Exec(`UPDATE tools
|
||||
SET schema=jsonb_set(COALESCE(schema,'{}'::jsonb),ARRAY['properties',$2::text],$3::jsonb,true),updated_at=now()
|
||||
WHERE key=$1 AND system AND NOT(COALESCE(schema->'properties','{}'::jsonb) ? $2)`, key, property, string(raw)); err != nil {
|
||||
log.Printf("[evidence] upgrade %s.%s: %v", key, property, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
_ = s.m.pg.SetSetting(flag, "true")
|
||||
}
|
||||
|
||||
func objectProperty(parent map[string]any, key string) map[string]any {
|
||||
value, ok := parent[key].(map[string]any)
|
||||
if !ok {
|
||||
@@ -195,7 +149,7 @@ func (s *Server) agentFindingTrafficAccess(ctx context.Context, id int64, write
|
||||
|
||||
func (s *Server) toolBindFindingTraffic() actool.CoreTool {
|
||||
return wrTool("bind_finding_traffic", "为已登记漏洞补绑经核实的真实 HTTP 流量。finding_id 使用独立漏洞记录 ID;不要传探索节点 ID。同批引用全部成功或全部失败,重复引用不覆盖已有说明。补绑会使已有报告标记待更新;不要为补包重新探测或重复创建漏洞。",
|
||||
objSchema(map[string]any{"finding_id": strParam("独立漏洞记录 ID,从 list_task_findings / get_task_node_detail 的 finding_id 字段读取"), "traffic_refs": agent.HintTrafficSchema(), "step_index": map[string]any{"type": "integer", "minimum": 0, "description": "可选:绑定到复现步骤序号;0 表示概览"}}, "finding_id", "traffic_refs"),
|
||||
objSchema(map[string]any{"finding_id": strParam("独立漏洞记录 ID,从 list_task_findings / get_task_node_detail 的 finding_id 字段读取"), "traffic_refs": agent.HintTrafficSchema()}, "finding_id", "traffic_refs"),
|
||||
func(ctx context.Context, raw json.RawMessage) (actool.Result, error) {
|
||||
if !s.m.pg.GetBool(settingAgentTrafficBinding, false) {
|
||||
return actool.Errorf("Agent 自动绑定流量已关闭;请在系统设置开启,或使用页面人工绑定。"), nil
|
||||
@@ -203,7 +157,6 @@ func (s *Server) toolBindFindingTraffic() actool.CoreTool {
|
||||
var args struct {
|
||||
FindingID json.RawMessage `json:"finding_id"`
|
||||
Refs []db.TrafficRef `json:"traffic_refs"`
|
||||
StepIndex *int `json:"step_index"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
@@ -215,14 +168,6 @@ func (s *Server) toolBindFindingTraffic() actool.CoreTool {
|
||||
if len(args.Refs) == 0 {
|
||||
return actool.Errorf("补绑需要至少一条已核实的 traffic_refs;无流量无需调用此工具"), nil
|
||||
}
|
||||
if args.StepIndex != nil {
|
||||
if *args.StepIndex < 0 {
|
||||
return actool.Errorf("step_index 不能为负数"), nil
|
||||
}
|
||||
for i := range args.Refs {
|
||||
args.Refs[i].StepIndex = *args.StepIndex
|
||||
}
|
||||
}
|
||||
list, err := s.evidenceStore().Bind(ctx, id, args.Refs)
|
||||
if err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
|
||||
@@ -227,9 +227,6 @@ func TestFindingWorkflowMigrationPreservesUserConfiguration(t *testing.T) {
|
||||
if err := pg.SetSetting("finding_workflow_tools_v2_reporter", "false"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pg.SetSetting("finding_workflow_tools_v3_reproduction_step_index", "false"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.seedFindingWorkflowTools()
|
||||
for _, key := range []string{"report_finding", "add_hint", "add_task_hint"} {
|
||||
row, _ := pg.GetTool(key)
|
||||
@@ -246,13 +243,6 @@ func TestFindingWorkflowMigrationPreservesUserConfiguration(t *testing.T) {
|
||||
if !strings.Contains(string(row.Schema), field) {
|
||||
t.Fatal("missing optional field", key)
|
||||
}
|
||||
if key == "report_finding" && !strings.Contains(string(row.Schema), "reproduction") {
|
||||
t.Fatal("missing reproduction schema migration")
|
||||
}
|
||||
}
|
||||
bindSchema, _ := pg.GetTool("bind_finding_traffic")
|
||||
if !strings.Contains(string(bindSchema.Schema), "step_index") {
|
||||
t.Fatal("missing bind_finding_traffic step_index migration")
|
||||
}
|
||||
search, _ := pg.GetTool("traffic_search")
|
||||
get, _ := pg.GetTool("traffic_get")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -49,17 +48,6 @@ func TestInheritedDTOProvenance(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindingDTONilReproductionIsSafe(t *testing.T) {
|
||||
dto := findingFromDB(&db.DBFinding{ID: 9, VulnClass: "legacy", Severity: db.SeverityLow, Status: db.FindingPending}, nil)
|
||||
raw, err := json.Marshal(dto)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(dto.Reproduction) != "" || !bytes.Contains(raw, []byte(`"reproduction":null`)) {
|
||||
t.Fatalf("legacy reproduction should remain null: dto=%+v json=%s", dto, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInheritedIntentResultOnlyAllowsHistory(t *testing.T) {
|
||||
for _, state := range []string{"done", "blocked", "exhausted", "stopped"} {
|
||||
if !inheritedIntentResult(state) {
|
||||
|
||||
+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/任务显式
|
||||
|
||||
+68
-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.
|
||||
@@ -760,6 +766,13 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /api/task-archives/delete/batch", s.deleteTaskArchivesBatch)
|
||||
mux.HandleFunc("GET /api/tasks/{id}/coverage", s.taskCoverage)
|
||||
mux.HandleFunc("GET /api/tasks/{id}/coverage-graph", s.taskCoverageGraph)
|
||||
// Asset relationships are read directly from the asset store. This action
|
||||
// only runs the topology explanation and produces the asset/network reports.
|
||||
mux.HandleFunc("POST /api/tasks/{id}/topology-mapping", s.taskAssetMapping)
|
||||
mux.HandleFunc("GET /api/tasks/{id}/topology-mapping/status", s.taskAssetMappingStatus)
|
||||
// Keep the old paths for clients from earlier builds.
|
||||
mux.HandleFunc("POST /api/tasks/{id}/asset-mapping", s.taskAssetMapping)
|
||||
mux.HandleFunc("GET /api/tasks/{id}/asset-mapping/status", s.taskAssetMappingStatus)
|
||||
mux.HandleFunc("GET /api/tasks/{id}/asset-refs", s.taskAssetRefs)
|
||||
mux.HandleFunc("POST /api/tasks/{id}/assets", s.attachTaskAssets)
|
||||
mux.HandleFunc("DELETE /api/tasks/{id}/assets/{assetID}", s.detachTaskAsset)
|
||||
@@ -772,6 +785,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 +3456,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 +3535,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 +3731,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 +3792,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
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
// taskAssetMapping starts a visible topology-mapping conversation for the task.
|
||||
// The asset relationship graph remains sourced directly from the database and
|
||||
// does not depend on this conversation. The conversation is the audit trail for
|
||||
// the two human-readable reports (asset relationships + network topology).
|
||||
func (s *Server) taskAssetMapping(w http.ResponseWriter, r *http.Request) {
|
||||
t, ok := s.m.Task(r.PathValue("id"))
|
||||
if !ok {
|
||||
writeErr(w, http.StatusNotFound, "task not found")
|
||||
return
|
||||
}
|
||||
if s.m.pg == nil || s.chatAgentRef() == nil {
|
||||
writeErr(w, http.StatusServiceUnavailable, s.chatUnavailableReason())
|
||||
return
|
||||
}
|
||||
if agentInfo, err := s.m.pg.GetAgentByKey("asset_mapper"); err != nil || agentInfo == nil || !agentInfo.Enabled {
|
||||
writeErr(w, http.StatusServiceUnavailable, "网络拓扑梳理 Agent 未启用")
|
||||
return
|
||||
}
|
||||
taskID, _ := strconv.ParseInt(t.ID, 10, 64)
|
||||
message := fmt.Sprintf(`请重新梳理任务 #%d 的网络拓扑,并在同一次会话中输出两份相互独立的结果。
|
||||
|
||||
输出必须严格使用下面两个一级标题,不能合并成一份:
|
||||
## 资产关系结果
|
||||
- 只根据当前任务及其直接关联任务的真实资产数据,保留资产库中的层级和归属关系。
|
||||
- 这部分是资产关系图的说明,不要声称本次 Agent 改写了资产关系,也不要补充摘要中没有的资产。
|
||||
|
||||
## 网络拓扑结果
|
||||
- 只整理互联网入口、边界服务、内网业务三层网络关系,并说明每个节点所属区域。
|
||||
- 只展示有真实资产证据的连接;数据库节点标注可识别的数据库类型。
|
||||
|
||||
两份结果都只能使用当前任务及其直接关联任务的真实资产数据;不要探测目标、不要修改资产库,也不要把没有证据的连接描述成已验证通路。`, taskID)
|
||||
if as := s.m.Assets(); as != nil {
|
||||
if graph, err := as.BuildCoverageGraph(taskID, t.ExpID); err == nil {
|
||||
type nodeSample struct {
|
||||
Key, Kind, Label, Domain, IP string
|
||||
Port int
|
||||
Tested, InScope bool
|
||||
}
|
||||
counts := map[string]int{}
|
||||
sample := make([]nodeSample, 0, 60)
|
||||
for _, node := range graph.Nodes {
|
||||
counts[node.Kind]++
|
||||
if len(sample) < 60 {
|
||||
sample = append(sample, nodeSample{
|
||||
Key: node.Key, Kind: node.Kind, Label: node.Label, Domain: node.Domain,
|
||||
IP: node.IP, Port: node.Port, Tested: node.Tested, InScope: node.InScope,
|
||||
})
|
||||
}
|
||||
}
|
||||
edges := graph.Edges
|
||||
truncatedEdges := len(edges) > 80
|
||||
if truncatedEdges {
|
||||
edges = edges[:80]
|
||||
}
|
||||
snapshot, _ := json.Marshal(map[string]any{
|
||||
"nodes_total": len(graph.Nodes), "edges_total": len(graph.Edges),
|
||||
"counts_by_kind": counts, "node_sample": sample, "edge_sample": edges,
|
||||
"truncated": len(graph.Nodes) > len(sample) || truncatedEdges,
|
||||
})
|
||||
message += "\n\n页面刚读取的任务资产图真实摘要如下;只依据摘要和任务范围解释,避免把全局资产误当成本任务资产。未列出的节点不要逐一补写:\n" + string(snapshot)
|
||||
}
|
||||
}
|
||||
title := fmt.Sprintf("资产与网络梳理 · 任务 #%d", taskID)
|
||||
conversation, err := s.m.pg.CreateConversation("asset_mapper", title, nil)
|
||||
if err != nil || conversation == nil {
|
||||
writeErr(w, http.StatusInternalServerError, "创建拓扑梳理会话失败")
|
||||
return
|
||||
}
|
||||
if _, err := s.m.pg.AppendConvActivity(conversation.ID, db.Activity{
|
||||
Worker: "asset_mapper", Kind: "user", Summary: title, Detail: message,
|
||||
}); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "写入拓扑梳理会话失败")
|
||||
return
|
||||
}
|
||||
busyKey := s.convBusyKey(conversation.ID)
|
||||
s.chatMu.Lock()
|
||||
s.chatBusy[busyKey] = true
|
||||
s.chatMu.Unlock()
|
||||
s.runConversation(conversation, message, busyKey, message)
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{
|
||||
"status": "accepted", "agent_key": "asset_mapper", "conversation_id": conversation.ID,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) taskAssetMappingStatus(w http.ResponseWriter, r *http.Request) {
|
||||
t, ok := s.m.Task(r.PathValue("id"))
|
||||
if !ok {
|
||||
writeErr(w, http.StatusNotFound, "task not found")
|
||||
return
|
||||
}
|
||||
conversations, err := s.m.pg.ListConversations()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
title := fmt.Sprintf("资产与网络梳理 · 任务 #%s", t.ID)
|
||||
legacyTitle := fmt.Sprintf("资产梳理 · 任务 #%s", t.ID)
|
||||
for _, conversation := range conversations {
|
||||
if conversation.AgentKey != "asset_mapper" || (conversation.Title != title && conversation.Title != legacyTitle) {
|
||||
continue
|
||||
}
|
||||
busyKey := s.convBusyKey(conversation.ID)
|
||||
s.chatMu.Lock()
|
||||
running := s.chatBusy[busyKey]
|
||||
s.chatMu.Unlock()
|
||||
// A completed conversation is historical evidence, not an active run.
|
||||
// Keep looking so a newer run is not hidden by an older one, and avoid
|
||||
// making the client poll a completed conversation after page reload.
|
||||
if running {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"running": true, "conversation_id": conversation.ID})
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"running": false})
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function formatClock(value: Date) {
|
||||
const pad = (part: number) => String(part).padStart(2, "0");
|
||||
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`;
|
||||
}
|
||||
|
||||
export function LiveClock({ className }: { className?: string }) {
|
||||
const [now, setNow] = React.useState<Date | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const update = () => setNow(new Date());
|
||||
update();
|
||||
const timer = window.setInterval(update, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<time
|
||||
role="timer"
|
||||
className={cn(
|
||||
"hidden rounded-md border bg-muted/40 px-2 py-1 font-mono text-[11px] text-muted-foreground tabular-nums sm:inline-flex",
|
||||
className,
|
||||
)}
|
||||
dateTime={now?.toISOString()}
|
||||
aria-label="当前时间"
|
||||
title="当前本地时间"
|
||||
>
|
||||
{now ? formatClock(now) : "---- -- -- --:--:--"}
|
||||
</time>
|
||||
);
|
||||
}
|
||||
@@ -10,12 +10,13 @@ import { useCurrentUser } from "@/hooks/use-current-user";
|
||||
import { api } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { EgressIpNavbar, EgressIpProvider } from "./egress-ip-banner";
|
||||
import { LiveClock } from "./live-clock";
|
||||
import { AccountSwitcher } from "./sidebar/account-switcher";
|
||||
import { LayoutControls } from "./sidebar/layout-controls";
|
||||
import { SearchDialog } from "./sidebar/search-dialog";
|
||||
import { ThemeSwitcher } from "./sidebar/theme-switcher";
|
||||
import { UpdateBadge } from "./update-badge";
|
||||
import { EgressIpNavbar, EgressIpProvider } from "./egress-ip-banner";
|
||||
|
||||
// 任务详情页保持原样:它自带头部/Tabs 与内边距,这里不再叠加全局头部和 padding。
|
||||
function isFullBleed(pathname: string) {
|
||||
@@ -77,6 +78,7 @@ function MainContentInner({ children }: { children: ReactNode }) {
|
||||
<EgressIpNavbar />
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<LiveClock />
|
||||
{version && (
|
||||
<span className="font-medium text-muted-foreground text-xs tabular-nums">版本 · {version}</span>
|
||||
)}
|
||||
|
||||
@@ -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,9 +10,10 @@ 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 { FindingReproductionSection, hasStructuredReproduction } from "@/components/finding-reproduction-section";
|
||||
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 +23,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,6 +47,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 (
|
||||
@@ -63,7 +71,7 @@ function FindingDetailInner() {
|
||||
const [finding, setFinding] = React.useState<Finding | null>(null);
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const [tab, setTab] = React.useState("overview");
|
||||
const [trafficReload, setTrafficReload] = React.useState(0);
|
||||
const side = useSideQuestions(finding?.task_id ? `/api/tasks/${finding.task_id}/chat` : null);
|
||||
|
||||
const load = React.useCallback(() => {
|
||||
if (!id) {
|
||||
@@ -80,11 +88,6 @@ function FindingDetailInner() {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const handleTrafficChanged = React.useCallback(() => {
|
||||
setTrafficReload((value) => value + 1);
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const changeSeverity = React.useCallback(
|
||||
async (next: Severity) => {
|
||||
if (!finding || finding.inherited || next === finding.severity) return;
|
||||
@@ -135,10 +138,16 @@ function FindingDetailInner() {
|
||||
}
|
||||
|
||||
const title = finding.name || finding.vulnclass || "未分类";
|
||||
const structuredReproduction = hasStructuredReproduction(finding.reproduction);
|
||||
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 +174,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,169 +200,174 @@ 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} />
|
||||
{structuredReproduction && finding.reproduction ? (
|
||||
<FindingReproductionSection
|
||||
findingId={id}
|
||||
reproduction={finding.reproduction}
|
||||
contextTask={contextTaskId || undefined}
|
||||
readOnly={finding.inherited}
|
||||
onChanged={handleTrafficChanged}
|
||||
trafficReload={trafficReload}
|
||||
/>
|
||||
) : null}
|
||||
<details open={!structuredReproduction} className="group">
|
||||
<summary className="cursor-pointer list-none">
|
||||
{structuredReproduction ? (
|
||||
<FindingReproductionPanel finding={finding} contextTask={contextTaskId || undefined} />
|
||||
) : (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-sm">证据 / PoC</CardTitle></CardHeader>
|
||||
<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>
|
||||
</summary>
|
||||
<Card className="mt-2">
|
||||
<CardContent className="pt-4">
|
||||
{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>
|
||||
</details>
|
||||
<FindingTrafficPanel
|
||||
key={`traffic:${id}`}
|
||||
findingId={id}
|
||||
contextTask={contextTaskId || undefined}
|
||||
readOnly={finding.inherited}
|
||||
stepIndex={structuredReproduction ? 0 : undefined}
|
||||
title={structuredReproduction ? "未归入步骤的流量" : undefined}
|
||||
onChanged={handleTrafficChanged}
|
||||
trafficReload={trafficReload}
|
||||
/>
|
||||
{/* 证据下方:详细报告(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>
|
||||
<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>
|
||||
|
||||
{/* 右栏:状态区 */}
|
||||
<Card className="h-fit lg:sticky lg:top-24">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">状态</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="divide-y">
|
||||
{/* 漏洞 ID */}
|
||||
<FieldRow label="漏洞 ID">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground">
|
||||
#{finding.id}
|
||||
</code>
|
||||
</FieldRow>
|
||||
<div className="flex min-w-0 flex-col gap-4 lg:sticky lg:top-24 lg:self-start">
|
||||
<Card className="h-fit">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">状态</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="divide-y">
|
||||
{/* 漏洞 ID */}
|
||||
<FieldRow label="漏洞 ID">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground">
|
||||
#{finding.id}
|
||||
</code>
|
||||
</FieldRow>
|
||||
|
||||
{/* 严重等级 */}
|
||||
<FieldRow label="严重等级">
|
||||
{finding.inherited ? (
|
||||
<StatusBadge domain="severity" value={finding.severity} dot />
|
||||
) : (
|
||||
<Select value={finding.severity} onValueChange={(v) => changeSeverity(v as Severity)}>
|
||||
<SelectTrigger size="sm" className="h-7 w-auto border-none px-1 shadow-none focus-visible:ring-0">
|
||||
<StatusBadge domain="severity" value={finding.severity} dot />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" align="end">
|
||||
<SelectGroup>
|
||||
{SEVERITIES.map((sv) => (
|
||||
<SelectItem key={sv} value={sv}>
|
||||
{statusMeta("severity", sv).label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</FieldRow>
|
||||
{/* 严重等级 */}
|
||||
<FieldRow label="严重等级">
|
||||
{finding.inherited ? (
|
||||
<StatusBadge domain="severity" value={finding.severity} dot />
|
||||
) : (
|
||||
<Select value={finding.severity} onValueChange={(v) => changeSeverity(v as Severity)}>
|
||||
<SelectTrigger size="sm" className="h-7 w-auto border-none px-1 shadow-none focus-visible:ring-0">
|
||||
<StatusBadge domain="severity" value={finding.severity} dot />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" align="end">
|
||||
<SelectGroup>
|
||||
{SEVERITIES.map((sv) => (
|
||||
<SelectItem key={sv} value={sv}>
|
||||
{statusMeta("severity", sv).label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</FieldRow>
|
||||
|
||||
{/* 处理状态 */}
|
||||
<FieldRow label="处理状态">
|
||||
{finding.inherited ? (
|
||||
<StatusBadge domain="finding" value={finding.status} dot />
|
||||
) : (
|
||||
<Select value={finding.status} onValueChange={(v) => changeStatus(v as FindingStatus)}>
|
||||
<SelectTrigger size="sm" className="h-7 w-auto border-none px-1 shadow-none focus-visible:ring-0">
|
||||
<StatusBadge domain="finding" value={finding.status} dot />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" align="end">
|
||||
<SelectGroup>
|
||||
{FINDING_STATUSES.map((st) => (
|
||||
<SelectItem key={st} value={st}>
|
||||
{statusMeta("finding", st).label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</FieldRow>
|
||||
{/* 处理状态 */}
|
||||
<FieldRow label="处理状态">
|
||||
{finding.inherited ? (
|
||||
<StatusBadge domain="finding" value={finding.status} dot />
|
||||
) : (
|
||||
<Select value={finding.status} onValueChange={(v) => changeStatus(v as FindingStatus)}>
|
||||
<SelectTrigger size="sm" className="h-7 w-auto border-none px-1 shadow-none focus-visible:ring-0">
|
||||
<StatusBadge domain="finding" value={finding.status} dot />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" align="end">
|
||||
<SelectGroup>
|
||||
{FINDING_STATUSES.map((st) => (
|
||||
<SelectItem key={st} value={st}>
|
||||
{statusMeta("finding", st).label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</FieldRow>
|
||||
|
||||
{/* 漏洞类型 */}
|
||||
<FieldRow label="漏洞类型">
|
||||
{finding.vulnclass ? (
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">{finding.vulnclass}</code>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</FieldRow>
|
||||
{/* 漏洞类型 */}
|
||||
<FieldRow label="漏洞类型">
|
||||
{finding.vulnclass ? (
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">{finding.vulnclass}</code>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</FieldRow>
|
||||
|
||||
{/* 涉及资产 */}
|
||||
<FieldRow label="涉及资产">
|
||||
{finding.assets && finding.assets.length > 0 ? (
|
||||
<div className="flex flex-wrap justify-end gap-1">
|
||||
{finding.assets.map((a) => (
|
||||
<code
|
||||
key={a.id}
|
||||
className="max-w-[16rem] truncate rounded bg-muted px-1.5 py-0.5 font-mono text-xs"
|
||||
title={`${a.type} · ${a.label}`}
|
||||
>
|
||||
{a.label}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</FieldRow>
|
||||
{/* 涉及资产 */}
|
||||
<FieldRow label="涉及资产">
|
||||
{finding.assets && finding.assets.length > 0 ? (
|
||||
<div className="flex flex-wrap justify-end gap-1">
|
||||
{finding.assets.map((a) => (
|
||||
<code
|
||||
key={a.id}
|
||||
className="max-w-[16rem] truncate rounded bg-muted px-1.5 py-0.5 font-mono text-xs"
|
||||
title={`${a.type} · ${a.label}`}
|
||||
>
|
||||
{a.label}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</FieldRow>
|
||||
|
||||
{/* 所属任务 */}
|
||||
<FieldRow label="所属任务">
|
||||
{finding.task_id ? (
|
||||
<Link
|
||||
href={`/function/tasks/detail?id=${finding.task_id}`}
|
||||
className="inline-flex max-w-[16rem] items-center gap-1 text-primary hover:underline"
|
||||
title={finding.task_description}
|
||||
>
|
||||
<span className="truncate">{finding.task_description || `#${finding.task_id}`}</span>
|
||||
<ArrowUpRightIcon className="size-3 shrink-0" />
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—(任务已删除)</span>
|
||||
)}
|
||||
</FieldRow>
|
||||
{/* 所属任务 */}
|
||||
<FieldRow label="所属任务">
|
||||
{finding.task_id ? (
|
||||
<Link
|
||||
href={`/function/tasks/detail?id=${finding.task_id}`}
|
||||
className="inline-flex max-w-[16rem] items-center gap-1 text-primary hover:underline"
|
||||
title={finding.task_description}
|
||||
>
|
||||
<span className="truncate">{finding.task_description || `#${finding.task_id}`}</span>
|
||||
<ArrowUpRightIcon className="size-3 shrink-0" />
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—(任务已删除)</span>
|
||||
)}
|
||||
</FieldRow>
|
||||
|
||||
{/* 发现时间 */}
|
||||
<FieldRow label="发现时间">
|
||||
<span className="tabular-nums">{fmtTime(finding.ts)}</span>
|
||||
</FieldRow>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* 发现时间 */}
|
||||
<FieldRow label="发现时间">
|
||||
<span className="tabular-nums">{fmtTime(finding.ts)}</span>
|
||||
</FieldRow>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FindingRetestPanel
|
||||
key={`retest:${id}`}
|
||||
findingId={id}
|
||||
readOnly={finding.inherited}
|
||||
onCompleted={load}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -361,7 +376,8 @@ function FindingDetailInner() {
|
||||
<FindingLineageView findingId={finding.id} />
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</Tabs>
|
||||
</SideQuestionWorkspace>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,21 +29,9 @@ import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { HttpPacketSyntax } from "@/components/http-code-block";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/lib/api";
|
||||
import type { LLMRecordItem, LLMRecordDetail, LLMTask } from "@/lib/types";
|
||||
@@ -80,7 +68,12 @@ function tryFormatJSON(s: string): string {
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
React.useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const disabled = !text;
|
||||
const copy = async () => {
|
||||
@@ -95,7 +88,11 @@ function CopyButton({ text }: { text: string }) {
|
||||
ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand("copy"); } catch { /* 忽略:不支持则静默 */ }
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
} catch {
|
||||
/* 忽略:不支持则静默 */
|
||||
}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
setCopied(true);
|
||||
@@ -151,9 +148,7 @@ export default function LLMRecordsPage() {
|
||||
const reqText = showRaw
|
||||
? detail?.raw_request && tryFormatJSON(detail.raw_request)
|
||||
: detail?.request_body && tryFormatJSON(detail.request_body);
|
||||
const respText = showRaw
|
||||
? detail?.raw_response
|
||||
: detail?.response_body && tryFormatJSON(detail.response_body);
|
||||
const respText = showRaw ? detail?.raw_response : detail?.response_body && tryFormatJSON(detail.response_body);
|
||||
|
||||
// Per-task delete (task picker + confirm dialog)
|
||||
const [tasks, setTasks] = React.useState<LLMTask[]>([]);
|
||||
@@ -167,9 +162,13 @@ export default function LLMRecordsPage() {
|
||||
let alive = true;
|
||||
api
|
||||
.settings()
|
||||
.then((s) => { if (alive) setRecEnabled(!!s.llm_record); })
|
||||
.then((s) => {
|
||||
if (alive) setRecEnabled(!!s.llm_record);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { alive = false; };
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleRecording = async (on: boolean) => {
|
||||
@@ -201,7 +200,13 @@ export default function LLMRecordsPage() {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
api
|
||||
.llmRecords({ model: model || undefined, session: sessionQ || undefined, task: pickedTask || undefined, page, size })
|
||||
.llmRecords({
|
||||
model: model || undefined,
|
||||
session: sessionQ || undefined,
|
||||
task: pickedTask || undefined,
|
||||
page,
|
||||
size,
|
||||
})
|
||||
.then((r) => {
|
||||
if (!alive) return;
|
||||
setRecords(r.records ?? []);
|
||||
@@ -211,9 +216,13 @@ export default function LLMRecordsPage() {
|
||||
.finally(() => alive && setLoading(false));
|
||||
api
|
||||
.llmTasks()
|
||||
.then((r) => { if (alive) setTasks(r.tasks ?? []); })
|
||||
.then((r) => {
|
||||
if (alive) setTasks(r.tasks ?? []);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { alive = false; };
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [page, size, sessionQ, model, pickedTask, reloadTick]);
|
||||
|
||||
// Delete every LLM record for the picked task, then refetch.
|
||||
@@ -244,10 +253,16 @@ export default function LLMRecordsPage() {
|
||||
setDetail(null);
|
||||
api
|
||||
.llmRecordDetail(selected.id)
|
||||
.then((d) => { if (alive) setDetail(d); })
|
||||
.then((d) => {
|
||||
if (alive) setDetail(d);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => { if (alive) setDetailLoading(false); });
|
||||
return () => { alive = false; };
|
||||
.finally(() => {
|
||||
if (alive) setDetailLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [selected]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / size));
|
||||
@@ -276,12 +291,7 @@ export default function LLMRecordsPage() {
|
||||
className="h-8 pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Model"
|
||||
className="h-8 w-48"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
/>
|
||||
<Input placeholder="Model" className="h-8 w-48" value={model} onChange={(e) => setModel(e.target.value)} />
|
||||
<Select value={pickedTask} onValueChange={setPickedTask}>
|
||||
<SelectTrigger size="sm" className="w-56">
|
||||
<SelectValue placeholder="选择任务…" />
|
||||
@@ -407,15 +417,10 @@ export default function LLMRecordsPage() {
|
||||
records.map((rec) => (
|
||||
<TableRow
|
||||
key={rec.id}
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
selected?.id === rec.id && "bg-accent hover:bg-accent",
|
||||
)}
|
||||
className={cn("cursor-pointer", selected?.id === rec.id && "bg-accent hover:bg-accent")}
|
||||
onClick={() => setSelected(rec)}
|
||||
>
|
||||
<TableCell className="text-xs text-muted-foreground tabular-nums">
|
||||
{fmtTime(rec.ts)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground tabular-nums">{fmtTime(rec.ts)}</TableCell>
|
||||
<TableCell className="text-xs font-mono text-muted-foreground">
|
||||
{rec.task_id ? `#${rec.task_id}` : "-"}
|
||||
</TableCell>
|
||||
@@ -442,9 +447,13 @@ export default function LLMRecordsPage() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{rec.status === "ok" ? (
|
||||
<Badge variant="secondary" className="text-xs text-emerald-600">OK</Badge>
|
||||
<Badge variant="secondary" className="text-xs text-emerald-600">
|
||||
OK
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive" className="text-xs">Error</Badge>
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Error
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -474,16 +483,18 @@ export default function LLMRecordsPage() {
|
||||
任务 #{selected.task_id}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{fmtTime(selected.ts)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{fmtTime(selected.ts)}</span>
|
||||
<span className={cn("text-xs", selected.latency_ms > 30000 && "text-amber-500")}>
|
||||
{fmtLatency(selected.latency_ms)}
|
||||
</span>
|
||||
{selected.status === "ok" ? (
|
||||
<Badge variant="secondary" className="text-xs text-emerald-600">OK</Badge>
|
||||
<Badge variant="secondary" className="text-xs text-emerald-600">
|
||||
OK
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive" className="text-xs">Error</Badge>
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Error
|
||||
</Badge>
|
||||
)}
|
||||
{/* 原文视图开关。旧记录没有原文,此时禁用而非静默回退,避免看着像
|
||||
「原文与解析一致」。 */}
|
||||
@@ -497,12 +508,7 @@ export default function LLMRecordsPage() {
|
||||
>
|
||||
原文
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0"
|
||||
onClick={() => setSelected(null)}
|
||||
>
|
||||
<Button variant="ghost" size="icon" className="size-7 shrink-0" onClick={() => setSelected(null)}>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -521,7 +527,7 @@ export default function LLMRecordsPage() {
|
||||
</div>
|
||||
) : (
|
||||
<pre className="p-3 font-mono text-xs break-all whitespace-pre-wrap">
|
||||
{reqText || "(空)"}
|
||||
<HttpPacketSyntax raw={reqText || ""} lineNumbers={false} emptyFallback="(空)" />
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
@@ -538,11 +544,13 @@ export default function LLMRecordsPage() {
|
||||
加载…
|
||||
</div>
|
||||
) : (
|
||||
<pre className={cn(
|
||||
"p-3 font-mono text-xs break-all whitespace-pre-wrap",
|
||||
selected.status !== "ok" && "text-red-600 dark:text-red-400",
|
||||
)}>
|
||||
{respText || "(空)"}
|
||||
<pre
|
||||
className={cn(
|
||||
"p-3 font-mono text-xs break-all whitespace-pre-wrap",
|
||||
selected.status !== "ok" && "text-red-600 dark:text-red-400",
|
||||
)}
|
||||
>
|
||||
<HttpPacketSyntax raw={respText || ""} lineNumbers={false} emptyFallback="(空)" />
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,10 +5,13 @@ import type { Graph as G6Graph } from "@antv/g6";
|
||||
import {
|
||||
AppWindow,
|
||||
Building2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Globe,
|
||||
Link2,
|
||||
type LucideIcon,
|
||||
Radio,
|
||||
RotateCcw,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Waypoints,
|
||||
@@ -29,11 +32,42 @@ import type { CoverageAssetRef, CoverageAssetRefs, CoverageGraphEdge, CoverageGr
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// 每个父节点下、同一类型的子节点默认展示的数量;超出折叠,"展示更多"每次再拉这么多。
|
||||
const FOLD_LIMIT = 20;
|
||||
const FOLD_STEP = 20;
|
||||
const FOLD_LIMIT = 12;
|
||||
const FOLD_STEP = 12;
|
||||
|
||||
type Kind = CoverageGraphNode["kind"];
|
||||
|
||||
type Zone = "internet" | "boundary" | "internal";
|
||||
type CoverageViewMode = "asset" | "network";
|
||||
|
||||
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 === "company" || node.kind === "root_domain" || node.kind === "subdomain" || node.kind === "ip") {
|
||||
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> = {
|
||||
@@ -81,6 +115,26 @@ const KIND_ICON: Record<Kind, string> = {
|
||||
),
|
||||
};
|
||||
|
||||
type DatabaseEngine = "mysql" | "sqlserver" | "postgres" | "redis" | "database";
|
||||
|
||||
const DATABASE_ICON: Record<DatabaseEngine, string> = {
|
||||
mysql: svgUri('<text x="12" y="15" text-anchor="middle" font-size="8" font-weight="700">MY</text>', true),
|
||||
sqlserver: svgUri('<text x="12" y="15" text-anchor="middle" font-size="7" font-weight="700">MS</text>', true),
|
||||
postgres: svgUri('<text x="12" y="15" text-anchor="middle" font-size="7" font-weight="700">PG</text>', true),
|
||||
redis: svgUri('<text x="12" y="15" text-anchor="middle" font-size="8" font-weight="700">RD</text>', true),
|
||||
database: svgUri('<ellipse cx="12" cy="6" rx="7" ry="3"/><path d="M5 6v9c0 1.7 3.1 3 7 3s7-1.3 7-3V6"/><path d="M5 10c0 1.7 3.1 3 7 3s7-1.3 7-3"/>'),
|
||||
};
|
||||
|
||||
function databaseEngine(node: CoverageGraphNode): DatabaseEngine | null {
|
||||
const raw = `${node.service_type ?? ""} ${node.label} ${node.page_title ?? ""}`.toLowerCase();
|
||||
if (/mysql|mariadb/.test(raw)) return "mysql";
|
||||
if (/sql\s*server|mssql|microsoft sql/.test(raw)) return "sqlserver";
|
||||
if (/postgres|postgresql/.test(raw)) return "postgres";
|
||||
if (/redis/.test(raw)) return "redis";
|
||||
if (/database|数据库|dbms|oracle|mongodb|mongo|clickhouse/.test(raw)) return "database";
|
||||
return null;
|
||||
}
|
||||
|
||||
const FOLD_ICON = svgUri(
|
||||
'<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>',
|
||||
true,
|
||||
@@ -185,15 +239,34 @@ type G6NodeDatum = {
|
||||
inScope: boolean;
|
||||
lbl: string;
|
||||
size: number;
|
||||
active: boolean;
|
||||
iconSrc: string;
|
||||
x: number;
|
||||
y: number;
|
||||
// G6 v5 reads the initial position from style.x/style.y. Keeping the
|
||||
// coordinates on the datum alone makes every node start at (0, 0), after
|
||||
// which fitView magnifies one node and hides the rest.
|
||||
style: { x: number; y: number };
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 图里的节点文案:服务不显示完整 URL,只显示 端口·标题·状态码;端点只显示 path。
|
||||
// 其余类型沿用后端给的 label。
|
||||
function graphLabel(n: CoverageGraphNode): string {
|
||||
function graphLabel(n: CoverageGraphNode, viewMode: CoverageViewMode = "asset"): string {
|
||||
if (viewMode === "network") {
|
||||
if (n.kind === "endpoint") return "";
|
||||
if (n.kind === "service") {
|
||||
const engine = databaseEngine(n);
|
||||
const host = n.domain || n.ip || n.label;
|
||||
return engine ? `${engine.toUpperCase()} · ${host}${n.port ? `:${n.port}` : ""}` : `${host}${n.port ? `:${n.port}` : ""}`;
|
||||
}
|
||||
return n.label || n.domain || n.ip || n.app_name || kindMeta[n.kind].label;
|
||||
}
|
||||
if (n.kind === "service") {
|
||||
const parts: string[] = [];
|
||||
if (n.port) parts.push(`:${n.port}`);
|
||||
@@ -211,8 +284,39 @@ function graphLabel(n: CoverageGraphNode): string {
|
||||
return n.label;
|
||||
}
|
||||
|
||||
function toG6Nodes(renderNodes: RenderNode[]): G6NodeDatum[] {
|
||||
function toG6Nodes(
|
||||
renderNodes: RenderNode[],
|
||||
activeKeys: Set<string>,
|
||||
viewMode: CoverageViewMode,
|
||||
): G6NodeDatum[] {
|
||||
const buckets: Record<Zone, RenderNode[]> = { internet: [], boundary: [], internal: [] };
|
||||
for (const rn of renderNodes) {
|
||||
buckets[rn.fold ? "boundary" : zoneOf(rn.node)].push(rn);
|
||||
}
|
||||
const maxCount = Math.max(buckets.internet.length, buckets.boundary.length, buckets.internal.length, 1);
|
||||
const virtualHeight = Math.max(760, maxCount * 42);
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
const xByZone: Record<Zone, number> = { internet: 150, boundary: 600, internal: 1050 };
|
||||
const xByAssetKind: Partial<Record<Kind, number>> = {
|
||||
company: 120,
|
||||
root_domain: 180,
|
||||
subdomain: 400,
|
||||
ip: 400,
|
||||
service: 680,
|
||||
app: 680,
|
||||
endpoint: 1000,
|
||||
};
|
||||
for (const zone of Object.keys(buckets) as Zone[]) {
|
||||
const items = buckets[zone];
|
||||
items.forEach((rn, index) => {
|
||||
positions.set(rn.key, {
|
||||
x: viewMode === "network" ? xByZone[zone] : (xByAssetKind[rn.kind] ?? 600),
|
||||
y: 70 + ((index + 1) * (virtualHeight - 140)) / (items.length + 1),
|
||||
});
|
||||
});
|
||||
}
|
||||
return renderNodes.map((rn) => {
|
||||
const position = positions.get(rn.key) ?? { x: 600, y: virtualHeight / 2 };
|
||||
if (rn.fold) {
|
||||
return {
|
||||
id: rn.key,
|
||||
@@ -220,8 +324,12 @@ function toG6Nodes(renderNodes: RenderNode[]): G6NodeDatum[] {
|
||||
fold: true,
|
||||
tested: false,
|
||||
inScope: false,
|
||||
lbl: `还有 ${rn.hidden.length} 个${kindMeta[rn.kind].label}`,
|
||||
lbl: viewMode === "network" && rn.kind === "endpoint" ? "" : `还有 ${rn.hidden.length} 个${kindMeta[rn.kind].label}`,
|
||||
size: 24,
|
||||
active: activeKeys.has(rn.key),
|
||||
iconSrc: FOLD_ICON,
|
||||
style: { x: position.x, y: position.y },
|
||||
...position,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -230,8 +338,14 @@ function toG6Nodes(renderNodes: RenderNode[]): G6NodeDatum[] {
|
||||
fold: false,
|
||||
tested: rn.node.tested,
|
||||
inScope: rn.node.in_scope,
|
||||
lbl: trunc(graphLabel(rn.node) || kindMeta[rn.kind].label),
|
||||
lbl: trunc(graphLabel(rn.node, viewMode) || kindMeta[rn.kind].label),
|
||||
size: kindMeta[rn.kind].size,
|
||||
active: activeKeys.has(rn.key),
|
||||
iconSrc: viewMode === "network" && databaseEngine(rn.node)
|
||||
? DATABASE_ICON[databaseEngine(rn.node) as DatabaseEngine]
|
||||
: KIND_ICON[rn.kind],
|
||||
style: { x: position.x, y: position.y },
|
||||
...position,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -248,6 +362,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 +579,55 @@ 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 [mapping, setMapping] = React.useState(false);
|
||||
const [mappingConversationId, setMappingConversationId] = React.useState<number | null>(null);
|
||||
const [networkReportAt, setNetworkReportAt] = React.useState<Date | null>(null);
|
||||
const [lastSyncedAt, setLastSyncedAt] = React.useState<Date | null>(null);
|
||||
const [viewMode, setViewMode] = React.useState<CoverageViewMode>("asset");
|
||||
const [panelCollapsed, setPanelCollapsed] = React.useState(false);
|
||||
const [viewport, setViewport] = React.useState<{ zoom: number; position: [number, number] }>({
|
||||
zoom: 1,
|
||||
position: [0, 0],
|
||||
});
|
||||
const viewModeRef = React.useRef<CoverageViewMode>(viewMode);
|
||||
viewModeRef.current = viewMode;
|
||||
|
||||
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 syncViewport = React.useCallback(() => {
|
||||
const graph = graphRef.current;
|
||||
if (!graph || graph.destroyed) return;
|
||||
const position = graph.getPosition();
|
||||
setViewport({ zoom: graph.getZoom(), position: [position[0], position[1]] });
|
||||
}, []);
|
||||
|
||||
const resetView = React.useCallback(async (mode: CoverageViewMode = viewModeRef.current) => {
|
||||
setActivePath([]);
|
||||
setSelectedAsset(null);
|
||||
setSelectedFoldId(null);
|
||||
const graph = graphRef.current;
|
||||
if (!graph || graph.destroyed) return;
|
||||
await graph.zoomTo(1);
|
||||
if (mode === "network") {
|
||||
// Network coordinates are already assigned to the three fixed zone
|
||||
// columns. Centering a graph with an empty zone shifts every node into
|
||||
// the next visible column and makes the background regions misleading.
|
||||
await graph.translateTo([0, 0]);
|
||||
} else {
|
||||
await graph.fitCenter();
|
||||
}
|
||||
syncViewport();
|
||||
}, [syncViewport]);
|
||||
|
||||
const fetchGraph = React.useCallback(() => {
|
||||
setLoading(true);
|
||||
@@ -484,6 +638,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(() => {
|
||||
/* 保留上一次数据 */
|
||||
@@ -491,10 +647,67 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
|
||||
.finally(() => setLoading(false));
|
||||
}, [taskId, coverageEnabled]);
|
||||
|
||||
const remapNetwork = React.useCallback(async () => {
|
||||
setMapping(true);
|
||||
setNetworkReportAt(null);
|
||||
try {
|
||||
const run = await api.startTopologyMapping(taskId);
|
||||
setMappingConversationId(run.conversation_id);
|
||||
} catch {
|
||||
setMapping(false);
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!mappingConversationId) return;
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const state = await api.conversationMessages(mappingConversationId, 0);
|
||||
if (cancelled) return;
|
||||
if (!state.running) {
|
||||
setMapping(false);
|
||||
setMappingConversationId(null);
|
||||
// The asset relationship graph is read directly from the asset store;
|
||||
// completing the network report must not rebuild or replace it.
|
||||
setNetworkReportAt(new Date());
|
||||
}
|
||||
} catch {
|
||||
// Keep the spinner while the conversation endpoint is temporarily unavailable.
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
const timer = window.setInterval(() => void poll(), 2000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [mappingConversationId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchGraph();
|
||||
}, [fetchGraph]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
.topologyMappingStatus(taskId)
|
||||
.then((status) => {
|
||||
if (cancelled) return;
|
||||
setMapping(status.running);
|
||||
setMappingConversationId(status.conversation_id ?? null);
|
||||
})
|
||||
.catch(() => {
|
||||
/* status is best effort; the graph remains usable */
|
||||
});
|
||||
setActivePath([]);
|
||||
setSelectedAsset(null);
|
||||
setSelectedFoldId(null);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [taskId]);
|
||||
|
||||
const { renderNodes, renderEdges } = React.useMemo(
|
||||
() =>
|
||||
data
|
||||
@@ -503,33 +716,66 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
|
||||
[data, expanded],
|
||||
);
|
||||
|
||||
const displayNodes = React.useMemo(
|
||||
() => viewMode === "network"
|
||||
? renderNodes.filter((node) => node.kind !== "endpoint")
|
||||
: renderNodes,
|
||||
[renderNodes, viewMode],
|
||||
);
|
||||
const displayNodeKeys = React.useMemo(() => new Set(displayNodes.map((node) => node.key)), [displayNodes]);
|
||||
const displayEdges = React.useMemo(
|
||||
() => renderEdges.filter((edge) => displayNodeKeys.has(edge.src) && displayNodeKeys.has(edge.dst)),
|
||||
[displayNodeKeys, renderEdges],
|
||||
);
|
||||
|
||||
// 结构签名:只在可见节点/边集合变化时重建图 + 重跑布局,避免无谓抖动。
|
||||
const sig = React.useMemo(
|
||||
() =>
|
||||
`${renderNodes.map((n) => `${n.key}:${n.fold ? "f" : n.node.tested ? "t" : "u"}`).sort().join(",")}|${renderEdges.length}`,
|
||||
[renderNodes, renderEdges],
|
||||
`${displayNodes.map((n) => `${n.key}:${n.fold ? "f" : n.node.tested ? "t" : "u"}`).sort().join(",")}|${displayEdges.length}|${activePath.join(",")}|${viewMode}`,
|
||||
[activePath, displayEdges, displayNodes, viewMode],
|
||||
);
|
||||
|
||||
// 维护 gDataRef + renderMapRef(供事件与图数据应用读取)。
|
||||
gDataRef.current = {
|
||||
nodes: toG6Nodes(renderNodes),
|
||||
edges: renderEdges.map((e) => ({ source: e.src, target: e.dst })),
|
||||
nodes: toG6Nodes(displayNodes, new Set(activePath), viewMode),
|
||||
edges: displayEdges.map((e) => ({
|
||||
// The API stores child → parent containment. Topology reads naturally
|
||||
// from internet entry → boundary service → internal asset, so reverse
|
||||
// the visual arrow without changing the source graph or lineage logic.
|
||||
source: e.dst,
|
||||
target: e.src,
|
||||
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);
|
||||
for (const rn of displayNodes) rmap.set(rn.key, rn);
|
||||
renderMapRef.current = rmap;
|
||||
|
||||
const applyData = React.useCallback(() => {
|
||||
const graph = graphRef.current;
|
||||
if (!graph || graph.destroyed) return;
|
||||
graph.setData(gDataRef.current);
|
||||
// render() 异步跑 d3-force 布局;若组件在布局落地前被卸载/销毁,g6 会在已清空的
|
||||
// context 上访问 transform 抛错(见 runtime/layout transformDataAfterLayout)。这是纯
|
||||
// teardown 竞态,吞掉它,不影响功能;真正的渲染错误(图未销毁)仍打日志。
|
||||
void graph.render().catch((err) => {
|
||||
if (!graph.destroyed) console.error("[coverage-graph] render:", err);
|
||||
});
|
||||
}, []);
|
||||
// render() 异步跑布局;若组件在布局落地前被卸载/销毁,g6 会在已清空的
|
||||
// context 上访问 transform 抛错。这是纯 teardown 竞态,吞掉它,不影响功能。
|
||||
void graph
|
||||
.render()
|
||||
.then(() => {
|
||||
if (graph.destroyed) return;
|
||||
const active = gDataRef.current.nodes.filter((node) => node.active).map((node) => node.id);
|
||||
// Keep the zone columns at their assigned coordinates. Centering a
|
||||
// network graph with an empty zone causes the remaining nodes to slide
|
||||
// into the next column, so network mode starts at the fixed origin.
|
||||
const result = active.length > 0
|
||||
? graph.focusElement(active)
|
||||
: viewModeRef.current === "network"
|
||||
? graph.translateTo([0, 0])
|
||||
: graph.fitCenter();
|
||||
return result.then(() => syncViewport());
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!graph.destroyed) console.error("[coverage-graph] render:", err);
|
||||
});
|
||||
}, [syncViewport]);
|
||||
|
||||
// 建图(一次)。动态 import 避开 SSR/静态导出期的 window 依赖。
|
||||
React.useEffect(() => {
|
||||
@@ -541,8 +787,9 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
|
||||
graph = new Graph({
|
||||
container: containerRef.current,
|
||||
autoResize: true,
|
||||
autoFit: "view",
|
||||
background: "#f0f2f7",
|
||||
// Asset mode explicitly centers after rendering. Network mode keeps
|
||||
// its fixed world coordinates so the three background zones align.
|
||||
background: "transparent",
|
||||
node: {
|
||||
style: {
|
||||
size: (d: unknown) => nd(d).size,
|
||||
@@ -550,7 +797,7 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
|
||||
stroke: (d: unknown) => nodeStroke(nd(d)),
|
||||
lineWidth: (d: unknown) => (nd(d).fold || !nd(d).inScope ? 1 : 1.5),
|
||||
lineDash: (d: unknown) => (nd(d).fold || !nd(d).inScope ? [3, 3] : [0]),
|
||||
iconSrc: (d: unknown) => (nd(d).fold ? FOLD_ICON : KIND_ICON[nd(d).kind]),
|
||||
iconSrc: (d: unknown) => nd(d).iconSrc,
|
||||
iconWidth: (d: unknown) => Math.max(12, nd(d).size * 0.55),
|
||||
iconHeight: (d: unknown) => Math.max(12, nd(d).size * 0.55),
|
||||
labelText: (d: unknown) => nd(d).lbl,
|
||||
@@ -561,37 +808,44 @@ 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.5 : 1,
|
||||
},
|
||||
},
|
||||
edge: {
|
||||
style: { stroke: "#cbd5e1", lineWidth: 1, endArrow: false },
|
||||
},
|
||||
layout: {
|
||||
type: "d3-force",
|
||||
collide: { radius: (d: unknown) => (nd(d).size ? nd(d).size : 20) + 8 },
|
||||
link: {
|
||||
distance: (edge: unknown) => {
|
||||
// 顶层(公司/根域名)离子节点远一点,叶子近一点。edge.source 可能是 id 或已解析节点。
|
||||
const s = (edge as { source: string | { id?: string } }).source;
|
||||
const srcId = typeof s === "string" ? s : (s?.id ?? "");
|
||||
const src = renderMapRef.current.get(srcId);
|
||||
const k = src && !src.fold ? src.kind : "endpoint";
|
||||
return k === "company" || k === "root_domain" ? 120 : 60;
|
||||
},
|
||||
},
|
||||
manyBody: {
|
||||
strength: (d: unknown) => (nd(d).kind === "endpoint" || nd(d).fold ? -60 : -200),
|
||||
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,
|
||||
},
|
||||
},
|
||||
behaviors: ["drag-element-force", "drag-canvas", "zoom-canvas"],
|
||||
behaviors: ["drag-element", "drag-canvas", "zoom-canvas"],
|
||||
});
|
||||
graph.on("aftertransform", syncViewport);
|
||||
graph.on("node:click", (evt: unknown) => {
|
||||
const id = (evt as { target?: { id?: string } }).target?.id;
|
||||
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);
|
||||
void graph?.focusElement(lineage).then(() => syncViewport());
|
||||
});
|
||||
graphRef.current = graph;
|
||||
applyData();
|
||||
@@ -607,7 +861,7 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
|
||||
graph?.destroy();
|
||||
graphRef.current = null;
|
||||
};
|
||||
}, [applyData]);
|
||||
}, [applyData, syncViewport]);
|
||||
|
||||
// 可见集合变化 → 重新灌数据 + 布局。sig 只作为重排触发器(applyData 读 gDataRef)。
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: sig 是刻意的重排触发依赖
|
||||
@@ -636,14 +890,73 @@ 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">
|
||||
{viewMode === "network" ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-0 overflow-hidden">
|
||||
<div
|
||||
className="absolute top-12 left-0 grid h-[8000px] w-[1350px] grid-cols-3"
|
||||
style={{
|
||||
transform: `translate(${viewport.position[0]}px, ${viewport.position[1]}px) scale(${viewport.zoom})`,
|
||||
transformOrigin: "0 0",
|
||||
}}
|
||||
>
|
||||
{(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>
|
||||
<div className="absolute top-3 left-0 z-10 grid w-full grid-cols-3 px-4">
|
||||
{(Object.keys(zoneMeta) as Zone[]).map((zone) => (
|
||||
<div key={zone} className="min-w-0">
|
||||
<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 className="pointer-events-none absolute top-3 left-4 z-0">
|
||||
<div className="text-foreground/80 text-sm font-semibold">资产关系图</div>
|
||||
<div className="text-muted-foreground mt-0.5 text-[11px]">按资产层级展示关联关系</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={cn(
|
||||
"bg-card/95 pointer-events-auto absolute bottom-3 left-3 z-20 rounded-lg border shadow-sm backdrop-blur",
|
||||
panelCollapsed
|
||||
? "flex items-center gap-2 px-2.5 py-2 text-xs"
|
||||
: "flex w-[min(480px,calc(100%-1.5rem))] max-h-[calc(100%-1.5rem)] flex-col gap-2.5 overflow-auto p-3 text-xs",
|
||||
)}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 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 flex-1">
|
||||
<div className="text-foreground truncate font-medium">
|
||||
{viewMode === "network" ? "网络拓扑梳理 Agent" : "资产关系图"}
|
||||
</div>
|
||||
<div className="text-muted-foreground truncate text-[11px]">
|
||||
{viewMode === "network"
|
||||
? mapping
|
||||
? "正在梳理网络拓扑,并生成两份结果…"
|
||||
: networkReportAt
|
||||
? `最近梳理 ${networkReportAt.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}`
|
||||
: "网络拓扑等待梳理"
|
||||
: 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,12 +969,54 @@ 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="刷新">
|
||||
<RefreshCw className={cn("size-3.5", loading && "animate-spin")} />
|
||||
{!panelCollapsed && <div className="flex shrink-0 items-center gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-1.5 px-2" onClick={() => setPanelCollapsed(true)} title="收起面板">
|
||||
<ChevronDown className="size-3.5" />
|
||||
<span className="hidden sm:inline">收起</span>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-1.5 px-2" onClick={() => void resetView()} title="重置视图">
|
||||
<RotateCcw className="size-3.5" />
|
||||
<span className="hidden sm:inline">重置视图</span>
|
||||
</Button>
|
||||
{viewMode === "network" && (
|
||||
<Button variant="outline" size="sm" className="h-7 gap-1.5 px-2" onClick={() => void remapNetwork()} disabled={mapping} title="重新梳理网络拓扑">
|
||||
<RefreshCw className={cn("size-3.5", (loading || mapping) && "animate-spin")} />
|
||||
<span className="hidden sm:inline">{mapping ? "正在梳理网络…" : "梳理网络拓扑"}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>}
|
||||
{panelCollapsed && <Button variant="ghost" size="sm" className="h-7 gap-1 px-2" onClick={() => setPanelCollapsed(false)} title="展开面板">
|
||||
<ChevronUp className="size-3.5" />
|
||||
<span className="hidden sm:inline">展开</span>
|
||||
</Button>}
|
||||
</div>
|
||||
{!panelCollapsed && <>
|
||||
<div className="bg-muted/60 flex items-center gap-1 rounded-md p-1">
|
||||
<Button
|
||||
variant={viewMode === "asset" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="h-7 flex-1 px-2 text-xs"
|
||||
onClick={() => {
|
||||
setViewMode("asset");
|
||||
void resetView("asset");
|
||||
}}
|
||||
>
|
||||
资产关系图
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "network" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="h-7 flex-1 px-2 text-xs"
|
||||
onClick={() => {
|
||||
setViewMode("network");
|
||||
void resetView("network");
|
||||
}}
|
||||
>
|
||||
网络拓扑
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1.5">
|
||||
{(Object.keys(kindMeta) as Kind[]).map((k) => {
|
||||
{(Object.keys(kindMeta) as Kind[]).filter((k) => viewMode === "asset" || k !== "endpoint").map((k) => {
|
||||
const m = kindMeta[k];
|
||||
const Icon = m.icon;
|
||||
return (
|
||||
@@ -689,11 +1044,62 @@ 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>
|
||||
{viewMode === "network" && (
|
||||
<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">
|
||||
力导向布局,可拖拽节点、滚轮缩放;灰色「⋯」是折叠节点,点开可展开更多。
|
||||
{viewMode === "network"
|
||||
? "网络拓扑梳理会话会输出资产关系结果和网络拓扑结果;图中只展示有真实资产证据的节点及区域。"
|
||||
: "资产关系图直接读取资产库,不需要额外梳理;点击节点查看详情并高亮关系链。灰色「⋯」可展开更多资产。"}
|
||||
</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([]);
|
||||
void graphRef.current?.fitCenter();
|
||||
}}
|
||||
>
|
||||
清除高亮
|
||||
</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}
|
||||
|
||||
@@ -43,7 +43,6 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { SortableHead } from "@/components/ui/sortable-head";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { api } from "@/lib/api";
|
||||
import { useStoredSortPreference } from "@/lib/sort-preference";
|
||||
@@ -774,7 +773,7 @@ export default function TrafficPage() {
|
||||
/>
|
||||
) : null}
|
||||
<Sheet open={selected !== null} onOpenChange={(open) => !open && setSelected(null)}>
|
||||
<SheetContent className="w-full! max-w-none! gap-0 p-0 sm:w-[48rem]! sm:max-w-[48rem]!">
|
||||
<SheetContent className="w-full! max-w-none! gap-0 p-0 sm:w-[72rem]! sm:max-w-[72rem]!">
|
||||
{selected && (
|
||||
<>
|
||||
<SheetHeader className="border-b px-5 py-4">
|
||||
@@ -797,32 +796,24 @@ export default function TrafficPage() {
|
||||
{importing ? "转入中…" : "送入请求重发"}
|
||||
</Button>
|
||||
</SheetHeader>
|
||||
<Tabs defaultValue="request" className="min-h-0 flex-1 gap-0">
|
||||
<TabsList className="mx-5 mt-4 grid w-auto grid-cols-2">
|
||||
<TabsTrigger value="request">请求 Request</TabsTrigger>
|
||||
<TabsTrigger value="response">响应 Response</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="request" className="min-h-0 overflow-auto">
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center gap-2 p-5 text-xs text-muted-foreground">
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
加载报文…
|
||||
</div>
|
||||
) : (
|
||||
<HttpCodeBlock raw={requestWithHost(detail?.req ?? "", selected)} />
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="response" className="min-h-0 overflow-auto">
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center gap-2 p-5 text-xs text-muted-foreground">
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
加载报文…
|
||||
</div>
|
||||
) : (
|
||||
<HttpCodeBlock raw={detail?.resp ?? ""} />
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 gap-2 overflow-hidden p-2 sm:grid-cols-2">
|
||||
{[
|
||||
["request", "请求 Request", requestWithHost(detail?.req ?? "", selected)],
|
||||
["response", "响应 Response", detail?.resp ?? ""],
|
||||
].map(([key, label, raw]) => (
|
||||
<div key={key} className="flex min-h-0 min-w-0 flex-col gap-1 overflow-hidden">
|
||||
<span className="px-1 text-[11px] font-medium text-muted-foreground">{label}</span>
|
||||
{detailLoading ? (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center gap-2 rounded-md border bg-muted/10 text-xs text-muted-foreground">
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
加载报文…
|
||||
</div>
|
||||
) : (
|
||||
<HttpCodeBlock raw={raw} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { api } from "@/lib/api";
|
||||
import { toneClasses } from "@/lib/status";
|
||||
import type { CredentialAccount, CredentialTraffic, CredentialTrafficDetail, TaskIntelligence } from "@/lib/types";
|
||||
@@ -344,26 +343,22 @@ export function CredentialTrafficViewer({
|
||||
{data.reference.side === "request" ? "请求包含匹配凭据" : "响应包含匹配凭据"},不代表认证验证成功。
|
||||
</p>
|
||||
<p className="text-sm">绑定时保存的匹配片段</p>
|
||||
<pre className="max-h-52 overflow-auto whitespace-pre-wrap break-all rounded bg-muted p-3 text-xs">
|
||||
{data.reference.excerpt}
|
||||
</pre>
|
||||
<HttpCodeBlock raw={data.reference.excerpt} />
|
||||
{data.unavailable ? (
|
||||
<Alert>
|
||||
<AlertDescription>原始流量已删除或不可用,仍可查看上方保存的匹配片段。</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Tabs defaultValue={data.reference.side}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="request">请求</TabsTrigger>
|
||||
<TabsTrigger value="response">响应</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="request">
|
||||
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="min-w-0">
|
||||
<p className="mb-1 px-1 text-sm font-medium">请求 Request</p>
|
||||
<HttpCodeBlock raw={data.req ?? ""} />
|
||||
</TabsContent>
|
||||
<TabsContent value="response">
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="mb-1 px-1 text-sm font-medium">响应 Response</p>
|
||||
<HttpCodeBlock raw={data.resp ?? ""} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,302 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { ExternalLinkIcon, PlusIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { HttpCodeBlock } from "@/components/http-code-block";
|
||||
import { TrafficEvidenceViewer } from "@/components/traffic-evidence-viewer";
|
||||
import { TrafficPickerDialog } from "@/components/traffic-picker-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/lib/api";
|
||||
import type { FindingTraffic, FindingTrafficBinding, Reproduction, TrafficEvidenceRole } from "@/lib/types";
|
||||
|
||||
const ROLES: Record<TrafficEvidenceRole, string> = {
|
||||
baseline: "正常对照",
|
||||
proof: "漏洞证明",
|
||||
verification: "补充验证",
|
||||
supporting: "辅助证据",
|
||||
};
|
||||
|
||||
export function hasStructuredReproduction(reproduction?: Reproduction | null) {
|
||||
return Boolean(reproduction && ((reproduction.steps?.length ?? 0) > 0 || reproduction.entry_point));
|
||||
}
|
||||
|
||||
export function FindingReproductionSection({
|
||||
findingId,
|
||||
reproduction,
|
||||
contextTask,
|
||||
readOnly,
|
||||
onChanged,
|
||||
trafficReload = 0,
|
||||
}: {
|
||||
findingId: string;
|
||||
reproduction: Reproduction;
|
||||
contextTask?: string;
|
||||
readOnly?: boolean;
|
||||
onChanged?: () => void;
|
||||
trafficReload?: number;
|
||||
}) {
|
||||
const [traffic, setTraffic] = React.useState<FindingTraffic | null>(null);
|
||||
const [pickerStep, setPickerStep] = React.useState<number | null>(null);
|
||||
const [preview, setPreview] = React.useState<string | null>(null);
|
||||
const [editing, setEditing] = React.useState<FindingTrafficBinding | null>(null);
|
||||
const [role, setRole] = React.useState<TrafficEvidenceRole>("supporting");
|
||||
const [note, setNote] = React.useState("");
|
||||
const [editingStepIndex, setEditingStepIndex] = React.useState("0");
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [reload, setReload] = React.useState(0);
|
||||
React.useEffect(() => {
|
||||
let active = true;
|
||||
api
|
||||
.findingTraffic(findingId, contextTask)
|
||||
.then((value) => {
|
||||
if (active) setTraffic(value);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setTraffic(null);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [findingId, contextTask, reload, trafficReload]);
|
||||
|
||||
const steps = reproduction.steps ?? [];
|
||||
const allBindings = traffic?.bindings ?? [];
|
||||
async function mutate(action: () => Promise<FindingTraffic>) {
|
||||
setBusy(true);
|
||||
try {
|
||||
setTraffic(await action());
|
||||
setEditing(null);
|
||||
onChanged?.();
|
||||
toast.success("流量证据已更新");
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message);
|
||||
setReload((value) => value + 1);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
function editBinding(item: FindingTrafficBinding) {
|
||||
setEditing(item);
|
||||
setRole(item.role);
|
||||
setNote(item.note);
|
||||
setEditingStepIndex(String(item.step_index ?? 0));
|
||||
}
|
||||
function bindingActions(item: FindingTrafficBinding) {
|
||||
return (
|
||||
<div key={item.id} className="flex flex-wrap items-center gap-1 rounded border px-2 py-1">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{ROLES[item.role]}
|
||||
</Badge>
|
||||
{item.note ? <span className="max-w-64 truncate text-muted-foreground text-xs">{item.note}</span> : null}
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => setPreview(item.id)}>
|
||||
<ExternalLinkIcon className="size-3" /> 查看流量
|
||||
</Button>
|
||||
{!readOnly ? (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" disabled={busy} onClick={() => editBinding(item)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={busy || !traffic}
|
||||
onClick={() => {
|
||||
if (traffic)
|
||||
void mutate(() => api.removeFindingTraffic(findingId, item.id, traffic.version, contextTask));
|
||||
}}
|
||||
>
|
||||
解除绑定
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">测试过程 / 复现</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{reproduction.entry_point ? (
|
||||
<div className="rounded-md border bg-muted/30 px-3 py-2 text-sm">
|
||||
<span className="text-muted-foreground">入口:</span>
|
||||
<code className="break-all font-mono">{reproduction.entry_point}</code>
|
||||
</div>
|
||||
) : null}
|
||||
{(reproduction.prerequisites?.length ?? 0) > 0 ? (
|
||||
<div className="rounded-md border bg-muted/30 px-3 py-2 text-sm">
|
||||
<div className="text-muted-foreground">前置条件</div>
|
||||
<ul className="ml-5 list-disc">
|
||||
{reproduction.prerequisites?.map((item, index) => (
|
||||
<li key={`${index}-${item}`}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
{steps.map((step, index) => {
|
||||
const stepIndex = index + 1;
|
||||
const bound = allBindings.filter((item) => (item.step_index ?? 0) === stepIndex);
|
||||
return (
|
||||
<div key={stepIndex} className="space-y-2 rounded-md border p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">步骤 {stepIndex}</Badge>
|
||||
{step.expected ? <span className="text-sm">预期:{step.expected}</span> : null}
|
||||
{step.proves ? <span className="text-muted-foreground text-sm">证明:{step.proves}</span> : null}
|
||||
<div className="ml-auto flex gap-1">
|
||||
{bound.map(bindingActions)}
|
||||
{!readOnly ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setPickerStep(stepIndex)}
|
||||
>
|
||||
<PlusIcon className="size-3" /> 绑定流量
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{step.request?.trim() ? <HttpCodeBlock raw={step.request} /> : null}
|
||||
{step.response?.trim() ? (
|
||||
<div>
|
||||
<div className="mb-1 text-muted-foreground text-xs">响应</div>
|
||||
<HttpCodeBlock raw={step.response} />
|
||||
</div>
|
||||
) : null}
|
||||
{bound.length > 0 ? (
|
||||
<div className="text-muted-foreground text-xs">已绑定 {bound.length} 条流量</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{allBindings.some((item) => (item.step_index ?? 0) > steps.length) ? (
|
||||
<div className="space-y-2 rounded-md border border-dashed p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">超出步骤范围</Badge>
|
||||
<span className="text-muted-foreground text-sm">这些绑定仍可编辑、解绑或改回总览/有效步骤。</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{allBindings.filter((item) => (item.step_index ?? 0) > steps.length).map(bindingActions)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{reproduction.provenance ? (
|
||||
<p className="text-muted-foreground text-xs">来源:{reproduction.provenance}</p>
|
||||
) : null}
|
||||
{pickerStep !== null ? (
|
||||
<TrafficPickerDialog
|
||||
findingId={findingId}
|
||||
contextTask={contextTask}
|
||||
bound={allBindings}
|
||||
stepIndex={pickerStep}
|
||||
onClose={() => setPickerStep(null)}
|
||||
onBound={() => {
|
||||
setPickerStep(null);
|
||||
setReload((value) => value + 1);
|
||||
onChanged?.();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<TrafficEvidenceViewer
|
||||
findingId={findingId}
|
||||
bindingId={preview}
|
||||
contextTask={contextTask}
|
||||
onClose={() => setPreview(null)}
|
||||
/>
|
||||
<Dialog
|
||||
open={editing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !busy) setEditing(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑流量证据</DialogTitle>
|
||||
<DialogDescription>修改用途、说明或所属复现步骤。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="reproduction-evidence-role">用途</FieldLabel>
|
||||
<Select value={role} onValueChange={(value) => setRole(value as TrafficEvidenceRole)}>
|
||||
<SelectTrigger id="reproduction-evidence-role">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{Object.entries(ROLES).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="reproduction-evidence-note">证据说明</FieldLabel>
|
||||
<Textarea
|
||||
id="reproduction-evidence-note"
|
||||
value={note}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="reproduction-evidence-step">复现步骤序号</FieldLabel>
|
||||
<Input
|
||||
id="reproduction-evidence-step"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={editingStepIndex}
|
||||
onChange={(event) => setEditingStepIndex(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" disabled={busy} onClick={() => setEditing(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy || !editing || !traffic}
|
||||
onClick={() => {
|
||||
if (editing && traffic) {
|
||||
void mutate(() =>
|
||||
api.editFindingTraffic(
|
||||
findingId,
|
||||
editing.id,
|
||||
traffic.version,
|
||||
{ role, note, step_index: Math.max(0, Number.parseInt(editingStepIndex, 10) || 0) },
|
||||
contextTask,
|
||||
),
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -44,17 +43,11 @@ export function FindingTrafficPanel({
|
||||
contextTask,
|
||||
readOnly,
|
||||
onChanged,
|
||||
stepIndex: filterStepIndex,
|
||||
title,
|
||||
trafficReload = 0,
|
||||
}: {
|
||||
findingId: string;
|
||||
contextTask?: string;
|
||||
readOnly?: boolean;
|
||||
onChanged: () => void;
|
||||
stepIndex?: number;
|
||||
title?: string;
|
||||
trafficReload?: number;
|
||||
}) {
|
||||
const [data, setData] = React.useState<FindingTraffic | null>(null);
|
||||
const [error, setError] = React.useState("");
|
||||
@@ -65,7 +58,6 @@ export function FindingTrafficPanel({
|
||||
const [editing, setEditing] = React.useState<FindingTrafficBinding | null>(null);
|
||||
const [role, setRole] = React.useState<TrafficEvidenceRole>("supporting");
|
||||
const [note, setNote] = React.useState("");
|
||||
const [editingStepIndex, setEditingStepIndex] = React.useState("0");
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [reload, setReload] = React.useState(0);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: retry token intentionally refreshes this request.
|
||||
@@ -76,9 +68,7 @@ export function FindingTrafficPanel({
|
||||
api
|
||||
.findingTraffic(findingId, contextTask)
|
||||
.then((value) => {
|
||||
if (active) {
|
||||
setData(value);
|
||||
}
|
||||
if (active) setData(value);
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
if (active) setError(e.message);
|
||||
@@ -86,7 +76,7 @@ export function FindingTrafficPanel({
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [findingId, contextTask, reload, filterStepIndex, trafficReload]);
|
||||
}, [findingId, contextTask, reload]);
|
||||
|
||||
async function mutate(action: () => Promise<FindingTraffic>) {
|
||||
setBusy(true);
|
||||
@@ -105,116 +95,11 @@ export function FindingTrafficPanel({
|
||||
|
||||
function move(index: number, delta: number) {
|
||||
if (!data) return;
|
||||
const visibleBindings = data.bindings.filter(
|
||||
(b) => filterStepIndex === undefined || (b.step_index ?? 0) === filterStepIndex,
|
||||
);
|
||||
const target = visibleBindings[index + delta];
|
||||
if (!target) return;
|
||||
const ids = data.bindings.map((b) => b.id);
|
||||
const from = ids.indexOf(visibleBindings[index].id);
|
||||
const to = ids.indexOf(target.id);
|
||||
if (from < 0 || to < 0) return;
|
||||
[ids[from], ids[to]] = [ids[to], ids[from]];
|
||||
[ids[index], ids[index + delta]] = [ids[index + delta], ids[index]];
|
||||
void mutate(() => api.orderFindingTraffic(findingId, ids, data.version, contextTask));
|
||||
}
|
||||
|
||||
const visibleBindings =
|
||||
data?.bindings.filter((b) => filterStepIndex === undefined || (b.step_index ?? 0) === filterStepIndex) ?? [];
|
||||
const addStepIndex = filterStepIndex ?? 0;
|
||||
|
||||
function renderBindings() {
|
||||
if (!data) return <Skeleton className="h-28 w-full" />;
|
||||
if (visibleBindings.length === 0) {
|
||||
return (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>暂无关联流量</EmptyTitle>
|
||||
<EmptyDescription>可绑定正常对照、漏洞证明和补充验证请求。</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
return visibleBindings.map((b, index) => (
|
||||
<div key={b.id} className="flex min-w-0 flex-col gap-2 rounded-md border p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
{index + 1}. {ROLES[b.role]}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
{b.snapshot.method} · {b.snapshot.status}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">证据 #{b.id}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto justify-start whitespace-normal p-0 text-left"
|
||||
onClick={() => setPreview(b.id)}
|
||||
>
|
||||
<span className="break-all font-mono text-xs">{b.snapshot.url}</span>
|
||||
</Button>
|
||||
{b.note ? <p className="whitespace-pre-wrap text-sm">{b.note}</p> : null}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{new Date(b.snapshot.captured_at * 1000).toLocaleString("zh-CN")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Button variant="outline" size="sm" onClick={() => setPreview(b.id)}>
|
||||
查看报文
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={repeaterBusy} onClick={() => void seedRepeater(b.id)}>
|
||||
送入请求重发
|
||||
</Button>
|
||||
{!readOnly ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setEditing(b);
|
||||
setRole(b.role);
|
||||
setNote(b.note);
|
||||
setEditingStepIndex(String(b.step_index ?? 0));
|
||||
}}
|
||||
>
|
||||
编辑说明
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`上移证据 ${b.id}`}
|
||||
disabled={busy || index === 0}
|
||||
onClick={() => move(index, -1)}
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`下移证据 ${b.id}`}
|
||||
disabled={busy || index === visibleBindings.length - 1}
|
||||
onClick={() => move(index, 1)}
|
||||
>
|
||||
<ArrowDownIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void mutate(() => api.removeFindingTraffic(findingId, b.id, data.version, contextTask))
|
||||
}
|
||||
>
|
||||
解除绑定
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
async function seedRepeater(bindingId: string) {
|
||||
setRepeaterBusy(true);
|
||||
try {
|
||||
@@ -246,9 +131,7 @@ export function FindingTrafficPanel({
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<CardTitle>
|
||||
{title ?? "关联流量"} {data ? `(${visibleBindings.length})` : ""}
|
||||
</CardTitle>
|
||||
<CardTitle>关联流量 {data ? `(${data.bindings.length})` : ""}</CardTitle>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -283,7 +166,95 @@ export function FindingTrafficPanel({
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{renderBindings()}
|
||||
{!data ? (
|
||||
<Skeleton className="h-28 w-full" />
|
||||
) : data.bindings.length === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>暂无关联流量</EmptyTitle>
|
||||
<EmptyDescription>可绑定正常对照、漏洞证明和补充验证请求。</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
) : (
|
||||
data.bindings.map((b, index) => (
|
||||
<div key={b.id} className="flex min-w-0 flex-col gap-2 rounded-md border p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
{index + 1}. {ROLES[b.role]}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
{b.snapshot.method} · {b.snapshot.status}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">证据 #{b.id}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto justify-start whitespace-normal p-0 text-left"
|
||||
onClick={() => setPreview(b.id)}
|
||||
>
|
||||
<span className="break-all font-mono text-xs">{b.snapshot.url}</span>
|
||||
</Button>
|
||||
{b.note ? <p className="whitespace-pre-wrap text-sm">{b.note}</p> : null}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{new Date(b.snapshot.captured_at * 1000).toLocaleString("zh-CN")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Button variant="outline" size="sm" onClick={() => setPreview(b.id)}>
|
||||
查看报文
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={repeaterBusy} onClick={() => void seedRepeater(b.id)}>
|
||||
送入请求重发
|
||||
</Button>
|
||||
{!readOnly ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setEditing(b);
|
||||
setRole(b.role);
|
||||
setNote(b.note);
|
||||
}}
|
||||
>
|
||||
编辑说明
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`上移证据 ${b.id}`}
|
||||
disabled={busy || index === 0}
|
||||
onClick={() => move(index, -1)}
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`下移证据 ${b.id}`}
|
||||
disabled={busy || index === data.bindings.length - 1}
|
||||
onClick={() => move(index, 1)}
|
||||
>
|
||||
<ArrowDownIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void mutate(() => api.removeFindingTraffic(findingId, b.id, data.version, contextTask))
|
||||
}
|
||||
>
|
||||
解除绑定
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{adding && data ? (
|
||||
@@ -291,7 +262,6 @@ export function FindingTrafficPanel({
|
||||
findingId={findingId}
|
||||
contextTask={contextTask}
|
||||
bound={data.bindings}
|
||||
stepIndex={addStepIndex}
|
||||
onClose={() => setAdding(false)}
|
||||
onBound={() => {
|
||||
setReload((n) => n + 1);
|
||||
@@ -338,17 +308,6 @@ export function FindingTrafficPanel({
|
||||
<FieldLabel htmlFor="evidence-note">证据说明</FieldLabel>
|
||||
<Textarea id="evidence-note" value={note} onChange={(e) => setNote(e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="evidence-step">复现步骤序号</FieldLabel>
|
||||
<Input
|
||||
id="evidence-step"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={editingStepIndex}
|
||||
onChange={(e) => setEditingStepIndex(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" disabled={busy} onClick={() => setEditing(null)}>
|
||||
@@ -359,13 +318,7 @@ export function FindingTrafficPanel({
|
||||
onClick={() => {
|
||||
if (editing && data)
|
||||
void mutate(() =>
|
||||
api.editFindingTraffic(
|
||||
findingId,
|
||||
editing.id,
|
||||
data.version,
|
||||
{ role, note, step_index: Math.max(0, Number.parseInt(editingStepIndex, 10) || 0) },
|
||||
contextTask,
|
||||
),
|
||||
api.editFindingTraffic(findingId, editing.id, data.version, { role, note }, contextTask),
|
||||
);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import * as React from "react";
|
||||
|
||||
import { CheckIcon, CopyIcon, WrapTextIcon } from "lucide-react";
|
||||
import { CheckIcon, CopyIcon, SearchIcon, WrapTextIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -16,15 +16,55 @@ function statusTone(status: number) {
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
const PACKET_COLORS = {
|
||||
method: "font-semibold text-violet-600 dark:text-violet-400",
|
||||
url: "text-sky-600 dark:text-sky-400",
|
||||
dim: "text-muted-foreground",
|
||||
headerName: "text-sky-700 dark:text-sky-300",
|
||||
headerValue: "text-emerald-600 dark:text-emerald-400",
|
||||
jsonKey: "text-sky-700 dark:text-sky-300",
|
||||
jsonString: "text-amber-600 dark:text-amber-400",
|
||||
jsonNumber: "text-fuchsia-600 dark:text-fuchsia-400",
|
||||
jsonBoolean: "text-rose-600 dark:text-rose-400",
|
||||
tag: "text-sky-600 dark:text-sky-400",
|
||||
};
|
||||
|
||||
const PacketHighlightContext = React.createContext("");
|
||||
|
||||
function HighlightedText({ text, className }: { text: string; className?: string }) {
|
||||
const query = React.useContext(PacketHighlightContext).trim();
|
||||
if (!query || !text) return <span className={className}>{text}</span>;
|
||||
const needle = query.toLowerCase();
|
||||
const haystack = text.toLowerCase();
|
||||
const parts: React.ReactNode[] = [];
|
||||
let cursor = 0;
|
||||
let key = 0;
|
||||
for (;;) {
|
||||
const match = haystack.indexOf(needle, cursor);
|
||||
if (match < 0) {
|
||||
parts.push(text.slice(cursor));
|
||||
break;
|
||||
}
|
||||
if (match > cursor) parts.push(text.slice(cursor, match));
|
||||
parts.push(
|
||||
<mark key={key++} className="rounded-[2px] bg-yellow-300 text-black">
|
||||
{text.slice(match, match + needle.length)}
|
||||
</mark>,
|
||||
);
|
||||
cursor = match + needle.length;
|
||||
}
|
||||
return <span className={className}>{parts}</span>;
|
||||
}
|
||||
|
||||
function StartLine({ line }: { line: string }) {
|
||||
const response = /^(HTTP\/\S+)(\s+)(\d{3})(.*)$/.exec(line);
|
||||
if (response) {
|
||||
return (
|
||||
<>
|
||||
<span className="text-muted-foreground">{response[1]}</span>
|
||||
<HighlightedText text={response[1]} className={PACKET_COLORS.dim} />
|
||||
{response[2]}
|
||||
<span className={statusTone(Number(response[3]))}>{response[3]}</span>
|
||||
{response[4]}
|
||||
<HighlightedText text={response[3]} className={statusTone(Number(response[3]))} />
|
||||
<HighlightedText text={response[4]} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -33,26 +73,26 @@ function StartLine({ line }: { line: string }) {
|
||||
if (request) {
|
||||
return (
|
||||
<>
|
||||
<span className="font-semibold text-primary">{request[1]}</span>
|
||||
<HighlightedText text={request[1]} className={PACKET_COLORS.method} />
|
||||
{request[2]}
|
||||
<span className="text-chart-2">{request[3]}</span>
|
||||
<HighlightedText text={request[3]} className={PACKET_COLORS.url} />
|
||||
{request[4]}
|
||||
<span className="text-muted-foreground">{request[5]}</span>
|
||||
<HighlightedText text={request[5]} className={PACKET_COLORS.dim} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return line;
|
||||
return <HighlightedText text={line} />;
|
||||
}
|
||||
|
||||
function HeaderLine({ line }: { line: string }) {
|
||||
const separator = line.indexOf(":");
|
||||
if (separator <= 0) return line;
|
||||
if (separator <= 0) return <HighlightedText text={line} />;
|
||||
return (
|
||||
<>
|
||||
<span className="text-primary">{line.slice(0, separator)}</span>
|
||||
<span className="text-muted-foreground">:</span>
|
||||
<span className="text-chart-2">{line.slice(separator + 1)}</span>
|
||||
<HighlightedText text={line.slice(0, separator)} className={PACKET_COLORS.headerName} />
|
||||
<span className={PACKET_COLORS.dim}>:</span>
|
||||
<HighlightedText text={line.slice(separator + 1)} className={PACKET_COLORS.headerValue} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -63,18 +103,14 @@ function JsonBody({ body }: { body: string }) {
|
||||
let cursor = 0;
|
||||
for (const match of body.matchAll(tokens)) {
|
||||
const index = match.index ?? 0;
|
||||
if (index > cursor) parts.push(body.slice(cursor, index));
|
||||
let className = "text-chart-4";
|
||||
if (match[1]) className = match[2] ? "text-primary" : "text-chart-2";
|
||||
else if (match[3]) className = "text-chart-3";
|
||||
parts.push(
|
||||
<span key={`${index}-${match[0].length}`} className={className}>
|
||||
{match[0]}
|
||||
</span>,
|
||||
);
|
||||
if (index > cursor) parts.push(<HighlightedText key={`plain-${cursor}`} text={body.slice(cursor, index)} />);
|
||||
let className = PACKET_COLORS.jsonNumber;
|
||||
if (match[1]) className = match[2] ? PACKET_COLORS.jsonKey : PACKET_COLORS.jsonString;
|
||||
else if (match[3]) className = PACKET_COLORS.jsonBoolean;
|
||||
parts.push(<HighlightedText key={`${index}-${match[0].length}`} text={match[0]} className={className} />);
|
||||
cursor = index + match[0].length;
|
||||
}
|
||||
if (cursor < body.length) parts.push(body.slice(cursor));
|
||||
if (cursor < body.length) parts.push(<HighlightedText key={`plain-${cursor}`} text={body.slice(cursor)} />);
|
||||
return parts;
|
||||
}
|
||||
|
||||
@@ -84,15 +120,11 @@ function MarkupBody({ body }: { body: string }) {
|
||||
let cursor = 0;
|
||||
for (const match of body.matchAll(tags)) {
|
||||
const index = match.index ?? 0;
|
||||
if (index > cursor) parts.push(body.slice(cursor, index));
|
||||
parts.push(
|
||||
<span key={`${index}-${match[0].length}`} className="text-primary">
|
||||
{match[0]}
|
||||
</span>,
|
||||
);
|
||||
if (index > cursor) parts.push(<HighlightedText key={`plain-${cursor}`} text={body.slice(cursor, index)} />);
|
||||
parts.push(<HighlightedText key={`${index}-${match[0].length}`} text={match[0]} className={PACKET_COLORS.tag} />);
|
||||
cursor = index + match[0].length;
|
||||
}
|
||||
if (cursor < body.length) parts.push(body.slice(cursor));
|
||||
if (cursor < body.length) parts.push(<HighlightedText key={`plain-${cursor}`} text={body.slice(cursor)} />);
|
||||
return parts;
|
||||
}
|
||||
|
||||
@@ -108,7 +140,82 @@ function detectBodyFormat(body: string): BodyFormat {
|
||||
function HighlightedBody({ body, format }: { body: string; format: BodyFormat }) {
|
||||
if (format === "json") return <JsonBody body={body} />;
|
||||
if (format === "markup") return <MarkupBody body={body} />;
|
||||
return body;
|
||||
return <HighlightedText text={body} />;
|
||||
}
|
||||
|
||||
export function HttpPacketSyntax({
|
||||
raw,
|
||||
wrapLines = true,
|
||||
lineNumbers = true,
|
||||
emptyFallback = "(空)",
|
||||
lineClassName = "leading-relaxed",
|
||||
}: {
|
||||
raw: string;
|
||||
wrapLines?: boolean;
|
||||
lineNumbers?: boolean;
|
||||
emptyFallback?: string;
|
||||
lineClassName?: string;
|
||||
}) {
|
||||
const value = raw || emptyFallback;
|
||||
const lines = value.replaceAll("\r\n", "\n").split("\n");
|
||||
const lineItems = React.useMemo(() => {
|
||||
const occurrences = new Map<string, number>();
|
||||
return lines.map((line) => {
|
||||
const occurrence = occurrences.get(line) ?? 0;
|
||||
occurrences.set(line, occurrence + 1);
|
||||
return { key: `${line}\u0000${occurrence}`, line };
|
||||
});
|
||||
}, [value]);
|
||||
const separator = lines.indexOf("");
|
||||
const body = separator >= 0 ? lines.slice(separator + 1).join("\n") : value;
|
||||
const bodyFormat = detectBodyFormat(body);
|
||||
const renderLine = (line: string, index: number) => {
|
||||
if (separator < 0 && bodyFormat !== "plain") return <HighlightedBody body={line} format={bodyFormat} />;
|
||||
if (index === 0) return <StartLine line={line} />;
|
||||
if (separator < 0 || index < separator) return <HeaderLine line={line} />;
|
||||
if (index === separator) return null;
|
||||
return <HighlightedBody body={line} format={bodyFormat} />;
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{lineItems.map((item, index) => (
|
||||
<div
|
||||
key={item.key}
|
||||
data-line={index + 1}
|
||||
className={cn("grid min-w-0", lineClassName, lineNumbers && "grid-cols-[2.25rem_minmax(0,1fr)]")}
|
||||
>
|
||||
{lineNumbers ? (
|
||||
<span className="sticky left-0 self-stretch border-r bg-muted/20 px-1 text-right text-[11px] text-muted-foreground/50">
|
||||
{index + 1}
|
||||
</span>
|
||||
) : null}
|
||||
<code
|
||||
className={cn(
|
||||
"min-h-[1lh] min-w-0 [tab-size:4]",
|
||||
lineNumbers ? "pr-14 pl-2" : "",
|
||||
wrapLines ? "break-words whitespace-pre-wrap" : "whitespace-pre",
|
||||
)}
|
||||
>
|
||||
{renderLine(item.line, index)}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function countMatches(value: string, query: string) {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) return 0;
|
||||
const haystack = value.toLowerCase();
|
||||
let count = 0;
|
||||
let cursor = 0;
|
||||
while (cursor <= haystack.length - needle.length) {
|
||||
const index = haystack.indexOf(needle, cursor);
|
||||
if (index < 0) return count;
|
||||
count += 1;
|
||||
cursor = index + needle.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Empty grid rows have no text, so native selection copying drops their newlines.
|
||||
@@ -148,11 +255,10 @@ function selectedPacketText(container: HTMLElement, value: string, lines: string
|
||||
export function HttpCodeBlock({ raw }: { raw: string }) {
|
||||
const [wrapLines, setWrapLines] = React.useState(true);
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const [query, setQuery] = React.useState("");
|
||||
const value = raw || "(空)";
|
||||
const lines = value.replaceAll("\r\n", "\n").split("\n");
|
||||
const separator = lines.indexOf("");
|
||||
const body = separator >= 0 ? lines.slice(separator + 1).join("\n") : "";
|
||||
const bodyFormat = detectBodyFormat(body);
|
||||
const matches = React.useMemo(() => countMatches(value, query), [value, query]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!copied) return;
|
||||
@@ -169,15 +275,8 @@ export function HttpCodeBlock({ raw }: { raw: string }) {
|
||||
toast.error("复制失败,请使用 Ctrl/Cmd+A 后复制");
|
||||
};
|
||||
|
||||
const renderLine = (line: string, index: number) => {
|
||||
if (index === 0) return <StartLine line={line} />;
|
||||
if (separator < 0 || index < separator) return <HeaderLine line={line} />;
|
||||
if (index === separator) return null;
|
||||
return <HighlightedBody body={line} format={bodyFormat} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative mx-3 my-4 overflow-hidden rounded-md border bg-background shadow-xs">
|
||||
<div className="relative mx-3 my-4 flex min-h-0 flex-col overflow-hidden rounded-md border bg-background shadow-xs">
|
||||
<div className="absolute top-2 right-2 flex items-center gap-0.5 rounded-md border bg-background/95 p-0.5 shadow-xs">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -216,7 +315,7 @@ export function HttpCodeBlock({ raw }: { raw: string }) {
|
||||
aria-multiline="true"
|
||||
aria-readonly="true"
|
||||
tabIndex={0}
|
||||
className="max-h-[calc(100vh-15rem)] min-w-0 overflow-auto bg-background py-3 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
className="min-h-0 max-h-[calc(100vh-15rem)] flex-1 min-w-0 overflow-auto bg-background py-2 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
onCopy={(event) => {
|
||||
const text = selectedPacketText(event.currentTarget, value, lines);
|
||||
if (text === null) return;
|
||||
@@ -234,22 +333,29 @@ export function HttpCodeBlock({ raw }: { raw: string }) {
|
||||
selection.addRange(range);
|
||||
}}
|
||||
>
|
||||
{lines.map((line, index) => (
|
||||
<div
|
||||
key={`${index}-${line}`}
|
||||
data-line={index + 1}
|
||||
className="grid min-w-0 grid-cols-[1.5rem_minmax(0,1fr)] leading-relaxed before:sticky before:left-0 before:self-stretch before:border-r before:bg-muted/20 before:px-1 before:text-right before:text-muted-foreground/60 before:content-[attr(data-line)]"
|
||||
<PacketHighlightContext.Provider value={query}>
|
||||
<HttpPacketSyntax raw={value} wrapLines={wrapLines} />
|
||||
</PacketHighlightContext.Provider>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 border-t bg-muted/20 px-2 py-1">
|
||||
<SearchIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="搜索报文内容"
|
||||
aria-label="搜索报文内容"
|
||||
className="min-w-0 flex-1 bg-transparent font-mono text-xs outline-none placeholder:text-muted-foreground/60"
|
||||
/>
|
||||
{query.trim() ? (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-xs tabular-nums",
|
||||
matches ? "font-medium text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<code
|
||||
className={cn(
|
||||
"min-h-[1lh] min-w-0 pr-16 pl-1.5 [tab-size:4]",
|
||||
wrapLines ? "break-words whitespace-pre-wrap" : "whitespace-pre",
|
||||
)}
|
||||
>
|
||||
{renderLine(line, index)}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
{matches} 处命中
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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} />,
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { SendIcon } from "lucide-react";
|
||||
import { SendIcon, Settings2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
@@ -32,14 +32,40 @@ import { newId, type RepeaterTab } from "./repeater-state";
|
||||
import { useRepeaterStore } from "./repeater-store";
|
||||
import { RepeaterTextPane } from "./repeater-text-pane";
|
||||
|
||||
function prettyResponse(response: NonNullable<RepeaterTab["response"]>) {
|
||||
const raw = buildRawResponse(response);
|
||||
const separator = raw.indexOf("\n\n");
|
||||
if (separator < 0) return raw;
|
||||
const body = raw.slice(separator + 2);
|
||||
try {
|
||||
return `${raw.slice(0, separator)}\n\n${JSON.stringify(JSON.parse(body), null, 2)}`;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function responseStatusClass(status: number) {
|
||||
if (status >= 500) return "border-rose-300 text-rose-600";
|
||||
if (status >= 400) return "border-amber-300 text-amber-600";
|
||||
if (status >= 300) return "border-sky-300 text-sky-600";
|
||||
return "border-emerald-300 text-emerald-600";
|
||||
}
|
||||
|
||||
function responseTextFor(response: RepeaterTab["response"], mode: "raw" | "pretty") {
|
||||
if (!response) return "";
|
||||
return mode === "pretty" ? prettyResponse(response) : buildRawResponse(response);
|
||||
}
|
||||
|
||||
export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
|
||||
const { useProxy, insecure, response } = tab;
|
||||
const updateTab = useRepeaterStore((state) => state.updateTab);
|
||||
const busy = useRepeaterStore((state) => state.inflight[tab.id] !== undefined);
|
||||
const [targetOpen, setTargetOpen] = React.useState(false);
|
||||
const [targetDraft, setTargetDraft] = React.useState("");
|
||||
const [responseMode, setResponseMode] = React.useState<"raw" | "pretty">("pretty");
|
||||
const targetOrigin = tab.targetOrigin ?? (tab.rawRequest === undefined ? connectionOrigin(tab.url) : "");
|
||||
const requestText = buildRawRequest(tab);
|
||||
const responseText = responseTextFor(response, responseMode);
|
||||
function changeRequest(rawRequest: string) {
|
||||
try {
|
||||
const { httpVersion: _version, ...parts } = parseRawRequest(rawRequest, targetOrigin);
|
||||
@@ -96,11 +122,15 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label="请求编辑器" className="flex min-h-0 min-w-0 flex-1 flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-muted-foreground text-xs" title={targetOrigin || tab.url}>
|
||||
发送目标:{targetOrigin || connectionOrigin(tab.url) || "按 Host 请求头(默认 HTTPS)"}
|
||||
</span>
|
||||
<section aria-label="请求编辑器" className="flex min-h-0 min-w-0 flex-1 flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2 border-b px-1 pb-2">
|
||||
<span className="text-muted-foreground text-xs">目标</span>
|
||||
<code
|
||||
className="min-w-0 max-w-[min(60vw,44rem)] truncate rounded bg-muted px-2 py-1 font-mono text-xs"
|
||||
title={targetOrigin || tab.url}
|
||||
>
|
||||
{targetOrigin || connectionOrigin(tab.url) || "按 Host 请求头(默认 HTTPS)"}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -110,14 +140,15 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
|
||||
setTargetOpen(true);
|
||||
}}
|
||||
>
|
||||
修改目标
|
||||
<Settings2 data-icon="inline-start" />
|
||||
目标设置
|
||||
</Button>
|
||||
<Button disabled={busy} onClick={() => void send()}>
|
||||
<Button className="ml-auto" disabled={busy} onClick={() => void send()}>
|
||||
<SendIcon data-icon="inline-start" />
|
||||
{busy ? "发送中…" : "发送"}
|
||||
</Button>
|
||||
</div>
|
||||
<FieldSet className="flex-row flex-wrap items-center gap-4 text-sm">
|
||||
<FieldSet className="flex-row flex-wrap items-center gap-3 border-b px-1 pb-2 text-xs">
|
||||
<FieldLegend className="sr-only">发送选项</FieldLegend>
|
||||
<FieldLabel className="flex items-center gap-2" htmlFor="repeater-use-proxy">
|
||||
<Checkbox
|
||||
@@ -138,15 +169,19 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
|
||||
跳过 TLS 校验
|
||||
</FieldLabel>
|
||||
{response ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-xs">
|
||||
{response.status ? <Badge variant="secondary">{response.status_text || response.status}</Badge> : null}
|
||||
<div className="ml-auto flex items-center gap-2 text-muted-foreground text-xs">
|
||||
{response.status ? (
|
||||
<Badge variant="outline" className={responseStatusClass(response.status)}>
|
||||
{response.status} {response.status_text}
|
||||
</Badge>
|
||||
) : null}
|
||||
<span>{response.elapsed_ms} ms</span>
|
||||
<span>{response.body_bytes.toLocaleString()} 字节</span>
|
||||
{response.body_truncated ? <Badge variant="outline">正文已截断</Badge> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</FieldSet>
|
||||
<div className="grid min-h-0 flex-1 auto-rows-[minmax(300px,1fr)] gap-3 overflow-y-auto xl:auto-rows-auto xl:grid-cols-2">
|
||||
<div className="grid min-h-0 flex-1 auto-rows-[minmax(300px,1fr)] gap-2 overflow-hidden lg:auto-rows-auto lg:grid-cols-2">
|
||||
<RepeaterTextPane
|
||||
id="repeater-request"
|
||||
label="请求"
|
||||
@@ -154,6 +189,12 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
|
||||
onChange={changeRequest}
|
||||
disabled={busy}
|
||||
placeholder="POST /path HTTP/1.1"
|
||||
syntaxHighlight
|
||||
panelClassName="bg-background"
|
||||
showClock
|
||||
toolbar={
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">Raw</span>
|
||||
}
|
||||
/>
|
||||
<div className="flex min-h-[300px] min-w-0 flex-col gap-2 xl:min-h-0">
|
||||
{response?.error ? (
|
||||
@@ -164,8 +205,32 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
|
||||
<RepeaterTextPane
|
||||
id="repeater-response"
|
||||
label="响应"
|
||||
value={response ? buildRawResponse(response) : ""}
|
||||
value={responseText}
|
||||
placeholder="发送后显示原始响应"
|
||||
syntaxHighlight
|
||||
panelClassName="bg-background"
|
||||
toolbar={
|
||||
<div className="ml-auto flex items-center gap-0.5 rounded border bg-muted/50 p-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant={responseMode === "raw" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-[10px]"
|
||||
onClick={() => setResponseMode("raw")}
|
||||
>
|
||||
Raw
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={responseMode === "pretty" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-[10px]"
|
||||
onClick={() => setResponseMode("pretty")}
|
||||
>
|
||||
Pretty
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,9 +32,11 @@ type Rename = { kind: "tab" | "group"; id: string; name: string };
|
||||
export function RepeaterNavigator({
|
||||
workspace,
|
||||
onRename,
|
||||
className,
|
||||
}: {
|
||||
workspace: RepeaterWorkspace;
|
||||
onRename: (value: Rename) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const store = useRepeaterStore();
|
||||
const [filters, setFilters] = React.useState(DEFAULT_FILTERS);
|
||||
@@ -77,7 +79,10 @@ export function RepeaterNavigator({
|
||||
return (
|
||||
<nav
|
||||
aria-label="请求重发分组和标签"
|
||||
className="flex h-[28rem] min-h-0 w-full shrink-0 flex-col gap-3 overflow-y-auto rounded-md border p-3 lg:h-[calc(100dvh-14rem)] lg:min-h-[480px] lg:w-80 xl:h-auto xl:min-h-0"
|
||||
className={cn(
|
||||
"flex h-[28rem] min-h-0 w-full shrink-0 flex-col gap-3 overflow-y-auto rounded-md border p-3 lg:h-[calc(100dvh-14rem)] lg:min-h-[480px] lg:w-80 xl:h-auto xl:min-h-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex shrink-0 flex-col gap-2">
|
||||
<Input
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { WrapTextIcon } from "lucide-react";
|
||||
|
||||
import { LiveClock } from "@/app/(main)/_components/live-clock";
|
||||
import { CopyButton } from "@/components/copy-button";
|
||||
import { HttpPacketSyntax } from "@/components/http-code-block";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function RepeaterTextPane({
|
||||
id,
|
||||
@@ -12,6 +17,10 @@ export function RepeaterTextPane({
|
||||
onChange,
|
||||
disabled,
|
||||
placeholder,
|
||||
syntaxHighlight = false,
|
||||
toolbar,
|
||||
panelClassName,
|
||||
showClock = false,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -19,20 +28,43 @@ export function RepeaterTextPane({
|
||||
onChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
syntaxHighlight?: boolean;
|
||||
toolbar?: React.ReactNode;
|
||||
panelClassName?: string;
|
||||
showClock?: boolean;
|
||||
}) {
|
||||
const gutter = React.useRef<HTMLPreElement>(null);
|
||||
const syntax = React.useRef<HTMLDivElement>(null);
|
||||
const [wrapLines, setWrapLines] = React.useState(true);
|
||||
const lines = React.useMemo(() => value.split(/\r\n|\r|\n/).length, [value]);
|
||||
const numbers = React.useMemo(() => Array.from({ length: lines }, (_, index) => index + 1).join("\n"), [lines]);
|
||||
return (
|
||||
<div className="flex min-h-[280px] min-w-0 flex-1 flex-col gap-2 xl:min-h-0">
|
||||
<section
|
||||
className={cn(
|
||||
"flex min-h-[280px] min-w-0 flex-1 flex-col gap-2 rounded-md border bg-background p-2 xl:min-h-0",
|
||||
panelClassName,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor={id} className="font-medium text-xs">
|
||||
{label}
|
||||
</label>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">{lines} 行</span>
|
||||
<CopyButton text={value} label={`复制${label}`} className="ml-auto" />
|
||||
{showClock ? <LiveClock className="ml-auto" /> : null}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-6 items-center gap-1 rounded border px-1.5 text-[10px] text-muted-foreground hover:bg-muted"
|
||||
onClick={() => setWrapLines((value) => !value)}
|
||||
title={wrapLines ? "切换为不换行" : "切换为自动换行"}
|
||||
aria-label={wrapLines ? "切换为不换行" : "切换为自动换行"}
|
||||
>
|
||||
<WrapTextIcon className="size-3" />
|
||||
{wrapLines ? "换行" : "不换行"}
|
||||
</button>
|
||||
{toolbar}
|
||||
<CopyButton text={value} label={`复制${label}`} className={showClock ? "ml-0" : "ml-auto"} />
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden rounded-lg border border-input focus-within:border-ring">
|
||||
<div className="relative flex min-h-0 flex-1 overflow-hidden rounded border border-input focus-within:border-ring">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="w-12 shrink-0 overflow-hidden border-r bg-muted/30 text-right text-muted-foreground"
|
||||
@@ -47,17 +79,41 @@ export function RepeaterTextPane({
|
||||
onChange={(event) => onChange?.(event.target.value)}
|
||||
onScroll={(event) => {
|
||||
if (gutter.current) gutter.current.style.transform = `translateY(-${event.currentTarget.scrollTop}px)`;
|
||||
if (syntax.current) {
|
||||
syntax.current.scrollTop = event.currentTarget.scrollTop;
|
||||
syntax.current.scrollLeft = event.currentTarget.scrollLeft;
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
readOnly={!onChange}
|
||||
placeholder={placeholder}
|
||||
wrap="off"
|
||||
wrap={wrapLines ? "soft" : "off"}
|
||||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
className="field-sizing-fixed h-full max-h-none min-h-0 w-0 flex-1 resize-none rounded-none border-0 px-3 py-2 font-mono text-xs leading-5 focus-visible:ring-0 md:text-xs"
|
||||
className={
|
||||
"field-sizing-fixed relative z-10 h-full max-h-none min-h-0 w-0 flex-1 resize-none rounded-none border-0 px-3 py-2 font-mono text-xs leading-5 focus-visible:ring-0 md:text-xs" +
|
||||
(syntaxHighlight
|
||||
? " bg-transparent text-transparent caret-foreground selection:bg-primary/20 selection:text-transparent placeholder:text-muted-foreground"
|
||||
: "")
|
||||
}
|
||||
/>
|
||||
{syntaxHighlight ? (
|
||||
<div
|
||||
ref={syntax}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-0 right-0 left-12 overflow-hidden px-3 py-2 font-mono text-xs leading-5"
|
||||
>
|
||||
<HttpPacketSyntax
|
||||
raw={value}
|
||||
lineNumbers={false}
|
||||
wrapLines={wrapLines}
|
||||
emptyFallback=""
|
||||
lineClassName="leading-5"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { FolderPlus } from "lucide-react";
|
||||
import { Library, Plus, X } from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -27,16 +27,39 @@ import {
|
||||
import { Field, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { RepeaterEditor } from "./repeater-editor";
|
||||
import { RepeaterNavigator } from "./repeater-navigator";
|
||||
import { useRepeaterStore } from "./repeater-store";
|
||||
|
||||
const METHOD_COLORS: Record<string, string> = {
|
||||
GET: "text-sky-600 dark:text-sky-400",
|
||||
POST: "text-emerald-600 dark:text-emerald-400",
|
||||
PUT: "text-amber-600 dark:text-amber-400",
|
||||
PATCH: "text-amber-600 dark:text-amber-400",
|
||||
DELETE: "text-rose-600 dark:text-rose-400",
|
||||
};
|
||||
|
||||
function methodColor(method: string) {
|
||||
return METHOD_COLORS[method.toUpperCase()] ?? "text-muted-foreground";
|
||||
}
|
||||
|
||||
function statusColor(status: number) {
|
||||
if (status >= 500) return "text-rose-600 dark:text-rose-400";
|
||||
if (status >= 400) return "text-amber-600 dark:text-amber-400";
|
||||
if (status >= 300) return "text-sky-600 dark:text-sky-400";
|
||||
if (status >= 200) return "text-emerald-600 dark:text-emerald-400";
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
export function RepeaterWorkspace() {
|
||||
const store = useRepeaterStore();
|
||||
const { workspace, hydrate } = store;
|
||||
const [rename, setRename] = React.useState<{ kind: "tab" | "group"; id: string; name: string } | null>(null);
|
||||
const [libraryOpen, setLibraryOpen] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
hydrate();
|
||||
}, [hydrate]);
|
||||
@@ -52,16 +75,20 @@ export function RepeaterWorkspace() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[calc(100dvh-7rem)] min-w-0 flex-col gap-4 xl:h-[calc(100dvh-8rem)] xl:min-h-[600px]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="font-semibold text-xl">请求重发</h1>
|
||||
<p className="text-muted-foreground text-sm">编辑、发送并比较请求 · 草稿与近期响应保存在此浏览器</p>
|
||||
<div className="flex min-h-[calc(100dvh-7rem)] min-w-0 flex-col gap-2 xl:h-[calc(100dvh-8rem)] xl:min-h-[600px]">
|
||||
<div className="flex flex-wrap items-center gap-2 border-b pb-2">
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-semibold text-lg tracking-tight">请求重发</h1>
|
||||
<p className="text-muted-foreground text-xs">Repeater · 草稿与历史响应保存在此浏览器</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => store.createGroup()}>
|
||||
<FolderPlus data-icon="inline-start" />
|
||||
新建分组
|
||||
<div className="ml-auto flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setLibraryOpen(true)}>
|
||||
<Library data-icon="inline-start" />
|
||||
请求库
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => store.createTab()}>
|
||||
<Plus data-icon="inline-start" />
|
||||
新建请求
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
@@ -84,40 +111,98 @@ export function RepeaterWorkspace() {
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 lg:flex-row">
|
||||
<RepeaterNavigator workspace={workspace} onRename={setRename} />
|
||||
<main className="flex min-h-0 min-w-0 flex-1 flex-col gap-3">
|
||||
{active ? (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="min-w-0 flex-1 truncate font-medium text-sm" title={active.title}>
|
||||
{active.title}
|
||||
</h2>
|
||||
<Field orientation="horizontal" className="w-auto">
|
||||
<FieldLabel htmlFor="repeater-group">移入分组</FieldLabel>
|
||||
<Select value={active.groupId} onValueChange={(id) => store.setTabGroup(active.id, id)}>
|
||||
<SelectTrigger id="repeater-group" className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{workspace.groups.map((group) => (
|
||||
<SelectItem key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<div className="flex min-h-10 min-w-0 items-center rounded-md border bg-muted/20 p-1">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
|
||||
{workspace.tabs.map((tab) => {
|
||||
const selected = tab.id === workspace.activeTabId;
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={cn(
|
||||
"group flex min-w-0 max-w-64 shrink-0 items-center rounded-sm border border-transparent",
|
||||
selected && "border-border bg-background shadow-xs",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-w-0 justify-start gap-2 rounded-sm px-2 text-xs"
|
||||
aria-current={selected ? "page" : undefined}
|
||||
onClick={() => store.setActiveTab(tab.id)}
|
||||
onDoubleClick={() => setRename({ kind: "tab", id: tab.id, name: tab.title })}
|
||||
title={`${tab.title}\n${tab.url}`}
|
||||
>
|
||||
<span className={cn("font-mono font-semibold", methodColor(tab.method))}>{tab.method}</span>
|
||||
<span className="truncate">{tab.title}</span>
|
||||
{tab.response?.status ? (
|
||||
<span className={cn("font-mono text-[10px]", statusColor(tab.response.status))}>
|
||||
{tab.response.status}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="mr-0.5 opacity-60 hover:opacity-100"
|
||||
aria-label={`关闭请求 ${tab.title}`}
|
||||
onClick={() => store.closeTab(tab.id)}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
<RepeaterEditor key={active.id} tab={active} />
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={() => store.createTab()}>新建标签</Button>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button variant="ghost" size="icon-sm" aria-label="新建请求" onClick={() => store.createTab()}>
|
||||
<Plus />
|
||||
</Button>
|
||||
</div>
|
||||
<main className="flex min-h-0 min-w-0 flex-1 flex-col rounded-md border bg-muted/10 p-2">
|
||||
{active ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2 px-1">
|
||||
<span className="truncate font-medium text-sm" title={active.title}>
|
||||
{active.title}
|
||||
</span>
|
||||
<Field orientation="horizontal" className="ml-auto w-auto gap-2">
|
||||
<FieldLabel htmlFor="repeater-group" className="text-muted-foreground text-xs">
|
||||
分组
|
||||
</FieldLabel>
|
||||
<Select value={active.groupId} onValueChange={(id) => store.setTabGroup(active.id, id)}>
|
||||
<SelectTrigger id="repeater-group" className="h-7 w-32 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{workspace.groups.map((group) => (
|
||||
<SelectItem key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<RepeaterEditor key={active.id} tab={active} />
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={() => store.createTab()}>新建请求</Button>
|
||||
)}
|
||||
</main>
|
||||
<Sheet open={libraryOpen} onOpenChange={setLibraryOpen}>
|
||||
<SheetContent side="left" className="w-[min(92vw,380px)] max-w-none p-3">
|
||||
<SheetHeader className="px-1 pb-1">
|
||||
<SheetTitle>请求库</SheetTitle>
|
||||
<SheetDescription>搜索、分组和恢复请求历史。</SheetDescription>
|
||||
</SheetHeader>
|
||||
<RepeaterNavigator
|
||||
workspace={workspace}
|
||||
onRename={setRename}
|
||||
className="h-full min-h-0 w-full rounded-md border-0 p-1 lg:h-full lg:min-h-0 lg:w-full xl:h-full"
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<Dialog
|
||||
open={rename !== null}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { api } from "@/lib/api";
|
||||
import type { FindingTrafficDetail, TrafficDetail } from "@/lib/types";
|
||||
|
||||
@@ -114,17 +113,16 @@ export function TrafficEvidenceViewer({
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : detail ? (
|
||||
<Tabs defaultValue="request">
|
||||
<TabsList>
|
||||
<TabsTrigger value="request">请求 Request</TabsTrigger>
|
||||
<TabsTrigger value="response">响应 Response</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{(["request", "response"] as const).map((side) => (
|
||||
<TabsContent key={side} value={side}>
|
||||
<div key={side} className="flex min-w-0 flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
正文 {detail[side].total.toLocaleString()} 字节{detail[side].truncated ? " · 当前为预览" : ""}
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{side === "request" ? "请求 Request" : "响应 Response"}</p>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
正文 {detail[side].total.toLocaleString()} 字节{detail[side].truncated ? " · 当前为预览" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -134,7 +132,7 @@ export function TrafficEvidenceViewer({
|
||||
.catch((e: Error) => toast.error(e.message))
|
||||
}
|
||||
>
|
||||
下载完整{side === "request" ? "请求" : "响应"}正文
|
||||
下载完整正文
|
||||
</Button>
|
||||
</div>
|
||||
<HttpCodeBlock
|
||||
@@ -145,9 +143,9 @@ export function TrafficEvidenceViewer({
|
||||
加载更多正文
|
||||
</Button>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
</div>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
) : (
|
||||
<Skeleton className="h-56 w-full" />
|
||||
)}
|
||||
@@ -193,18 +191,16 @@ export function CapturedTrafficViewer({ id, onClose }: { id: string | null; onCl
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : detail ? (
|
||||
<Tabs defaultValue="request">
|
||||
<TabsList>
|
||||
<TabsTrigger value="request">请求 Request</TabsTrigger>
|
||||
<TabsTrigger value="response">响应 Response</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="request">
|
||||
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="min-w-0">
|
||||
<p className="mb-1 px-1 text-sm font-medium">请求 Request</p>
|
||||
<HttpCodeBlock raw={detail.req} />
|
||||
</TabsContent>
|
||||
<TabsContent value="response">
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="mb-1 px-1 text-sm font-medium">响应 Response</p>
|
||||
<HttpCodeBlock raw={detail.resp} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Skeleton className="h-56 w-full" />
|
||||
)}
|
||||
|
||||
@@ -30,14 +30,12 @@ export function TrafficPickerDialog({
|
||||
findingId,
|
||||
contextTask,
|
||||
bound,
|
||||
stepIndex,
|
||||
onClose,
|
||||
onBound,
|
||||
}: {
|
||||
findingId: string;
|
||||
contextTask?: string;
|
||||
bound: FindingTrafficBinding[];
|
||||
stepIndex?: number;
|
||||
onClose: () => void;
|
||||
onBound: () => void;
|
||||
}) {
|
||||
@@ -93,7 +91,6 @@ export function TrafficPickerDialog({
|
||||
findingId,
|
||||
[...selected].map((traffic_id) => ({ traffic_id })),
|
||||
contextTask,
|
||||
stepIndex,
|
||||
);
|
||||
toast.success(`已绑定 ${selected.size} 条流量`);
|
||||
onBound();
|
||||
|
||||
+7
-3
@@ -451,6 +451,10 @@ export const api = {
|
||||
}>(`/tasks/${id}/coverage`),
|
||||
// 资产覆盖图:范围内全部资产 + 连接用的根域名/公司节点,含 tested/in_scope。
|
||||
taskCoverageGraph: (id: string) => get<CoverageGraphData>(`/tasks/${id}/coverage-graph`),
|
||||
startTopologyMapping: (id: string) =>
|
||||
post<{ status: string; agent_key: string; conversation_id: number }>(`/tasks/${id}/topology-mapping`, {}),
|
||||
topologyMappingStatus: (id: string) =>
|
||||
get<{ running: boolean; conversation_id?: number }>(`/tasks/${id}/topology-mapping/status`),
|
||||
// 全局 llm_usage 聚合(仪表盘新版 token 视图):按 profile 总量 + 按天分桶。
|
||||
usageStats: (days = 365) => get<UsageStats>(`/tokens/usage?days=${days}`),
|
||||
// ---- 目标管理(总览)----
|
||||
@@ -714,16 +718,16 @@ export const api = {
|
||||
get<FindingTraffic>(
|
||||
`/exploration/findings/${id}/traffic${contextTask ? `?context_task=${encodeURIComponent(contextTask)}` : ""}`,
|
||||
),
|
||||
bindFindingTraffic: (id: string, traffic_refs: TrafficEvidenceRef[], contextTask?: string, step_index?: number) =>
|
||||
bindFindingTraffic: (id: string, traffic_refs: TrafficEvidenceRef[], contextTask?: string) =>
|
||||
post<FindingTraffic>(
|
||||
`/exploration/findings/${id}/traffic${contextTask ? `?context_task=${encodeURIComponent(contextTask)}` : ""}`,
|
||||
{ traffic_refs, ...(step_index !== undefined ? { step_index } : {}) },
|
||||
{ traffic_refs },
|
||||
),
|
||||
editFindingTraffic: (
|
||||
id: string,
|
||||
bindingId: string,
|
||||
version: number,
|
||||
fields: { role: TrafficEvidenceRole; note: string; step_index?: number },
|
||||
fields: { role: TrafficEvidenceRole; note: string },
|
||||
contextTask?: string,
|
||||
) =>
|
||||
patch<FindingTraffic>(
|
||||
|
||||
+3
-19
@@ -512,21 +512,6 @@ export interface FindingAsset {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ReproStep {
|
||||
request: string;
|
||||
response?: string;
|
||||
expected?: string;
|
||||
proves?: string;
|
||||
traffic_ids?: string[];
|
||||
}
|
||||
|
||||
export interface Reproduction {
|
||||
entry_point?: string;
|
||||
prerequisites?: string[];
|
||||
steps?: ReproStep[];
|
||||
provenance?: string;
|
||||
}
|
||||
|
||||
export interface Finding {
|
||||
case_id?: string;
|
||||
traffic_count?: number;
|
||||
@@ -542,7 +527,6 @@ export interface Finding {
|
||||
summary: string;
|
||||
evidence: string;
|
||||
report?: string; // 详细报告(Markdown);仅详情接口返回,列表为空
|
||||
reproduction?: Reproduction | null;
|
||||
intent_id?: string;
|
||||
param_id?: string;
|
||||
task_id?: string;
|
||||
@@ -986,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; // 默认关闭;关闭不注入情报工具或提示
|
||||
@@ -1007,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[];
|
||||
@@ -1620,7 +1606,6 @@ export interface TrafficEvidenceRef {
|
||||
traffic_id: string;
|
||||
role?: TrafficEvidenceRole;
|
||||
note?: string;
|
||||
step_index?: number;
|
||||
}
|
||||
export interface TrafficEvidenceSnapshot {
|
||||
id: string;
|
||||
@@ -1644,7 +1629,6 @@ export interface FindingTrafficBinding {
|
||||
role: TrafficEvidenceRole;
|
||||
note: string;
|
||||
position: number;
|
||||
step_index?: number;
|
||||
created_at: string;
|
||||
snapshot: TrafficEvidenceSnapshot;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user