From 27dc6d8badc4bebfdbf75c827b516bea32a3e5e0 Mon Sep 17 00:00:00 2001 From: pphua Date: Mon, 24 Aug 2026 19:29:24 +0800 Subject: [PATCH] feat(findings): group findings and add deepen workflows --- db/findings.go | 59 +- db/findings_test.go | 346 +++++ report/findings.go | 165 ++ server/findings_groups.go | 166 ++ server/findings_groups_test.go | 321 ++++ .../(main)/function/findings/detail/page.tsx | 106 +- web/src/app/(main)/function/findings/page.tsx | 1329 ++++++++++++----- .../tasks/detail/_tabs/findings-tab.tsx | 47 +- .../tasks/detail/_tabs/report-tab.tsx | 61 +- 9 files changed, 2098 insertions(+), 502 deletions(-) create mode 100644 report/findings.go create mode 100644 server/findings_groups.go create mode 100644 server/findings_groups_test.go diff --git a/db/findings.go b/db/findings.go index 515d2e9..b79f00c 100644 --- a/db/findings.go +++ b/db/findings.go @@ -3,13 +3,15 @@ package db import ( "database/sql" "encoding/json" + "errors" "fmt" "strconv" "strings" "time" ) -// DBFinding is a row in the standalone findings table (persists across task deletion). +// DBFinding is a row in the standalone findings table. It persists across task +// deletion unless the caller explicitly requests related finding cleanup. type DBFinding struct { ID int64 TaskID *int64 @@ -146,6 +148,11 @@ type FindingFilter struct { Sort string // "severity" | "time" } +// FindingUnassignedTask is the task filter sentinel for findings whose task is +// absent. That includes rows created without a task and rows retained after their +// originating task was deleted (the findings FK is ON DELETE SET NULL). +const FindingUnassignedTask = "__unassigned__" + // where builds the WHERE clause (shared by the page and count queries) plus its // positional args. Only equality filters, all parameterized. func (f FindingFilter) where() (string, []any) { @@ -162,7 +169,9 @@ func (f FindingFilter) where() (string, []any) { add("status", f.Status) add("vulnclass", f.VulnClass) // task_id 是 bigint 列,按整数比较(不能走上面的文本 add);空/非法值忽略。 - if tid, err := strconv.ParseInt(f.TaskID, 10, 64); err == nil && tid > 0 { + if f.TaskID == FindingUnassignedTask { + conds = append(conds, "(f.task_id IS NULL OR t.id IS NULL)") + } else if tid, err := strconv.ParseInt(f.TaskID, 10, 64); err == nil && tid > 0 { args = append(args, tid) conds = append(conds, fmt.Sprintf("f.task_id = $%d", len(args))) } @@ -184,14 +193,19 @@ func (d *DB) ListFindingsPage(f FindingFilter, page, pageSize int) ([]*DBFinding where, args := f.where() var total int - if err := d.QueryRow(`SELECT COUNT(*) FROM findings f`+where, args...).Scan(&total); err != nil { + if err := d.QueryRow(`SELECT COUNT(*) FROM findings f LEFT JOIN tasks t ON f.task_id=t.id`+where, args...).Scan(&total); err != nil { return nil, 0, err } + // Avoid overflowing (page-1)*pageSize for an arbitrarily large page number. + // Once the requested page is beyond the exact count, no data query is needed. + if total == 0 || page > (total-1)/pageSize+1 { + return []*DBFinding{}, total, nil + } - order := "f.created_at DESC" + order := "f.created_at DESC, f.id DESC" if f.Sort == "severity" { // critical > high > medium > low > 其它, then newest first. - order = `CASE f.severity WHEN 'critical' THEN 4 WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'low' THEN 1 ELSE 0 END DESC, f.created_at DESC` + order = `CASE f.severity WHEN 'critical' THEN 4 WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'low' THEN 1 ELSE 0 END DESC, f.created_at DESC, f.id DESC` } pageArgs := append(append([]any{}, args...), pageSize, (page-1)*pageSize) q := fmt.Sprintf(` @@ -209,8 +223,6 @@ func (d *DB) ListFindingsPage(f FindingFilter, page, pageSize int) ([]*DBFinding return out, total, err } -<<<<<<< Updated upstream -======= // FindingGroup is one task-level bucket in the global findings view. TaskID is // nil for both findings that never had a task and findings retained after task // deletion; those records intentionally share one "unassigned/deleted" bucket. @@ -446,7 +458,6 @@ func (d *DB) ListFindingsForExport(f FindingFilter, ids []int64) ([]*DBFinding, return out, rows.Err() } ->>>>>>> Stashed changes // FindingStats is the whole-table aggregate powering the 发现 page's stat cards // and vuln-class filter — computed server-side so it stays exact regardless of // pagination. @@ -547,6 +558,38 @@ func (d *DB) GetFinding(id int64) (*DBFinding, error) { return f, nil } +// DeleteFinding removes a finding entirely: the standalone findings row and its +// originating exploration node (kind='finding'), so it disappears from the findings +// list, the per-task 发现 Tab, and the exploration graph alike. Deleting the node +// cascades its edges + node_assets and nulls any activity referencing it. Returns +// rows affected (0 = no finding with that id). +func (d *DB) DeleteFinding(id int64) (int64, error) { + var nodeID *int64 + err := d.QueryRow(`DELETE FROM findings WHERE id=$1 RETURNING node_id`, id).Scan(&nodeID) + if err == sql.ErrNoRows { + return 0, nil + } + if err != nil { + return 0, err + } + if nodeID != nil { + // best-effort: the finding is already gone; a stray node must not fail the op. + _, _ = d.Exec(`DELETE FROM exploration_nodes WHERE id=$1 AND kind='finding'`, *nodeID) + } + return 1, nil +} + +// DeleteFindingsByTask removes all findings rows of a task. The originating +// exploration finding nodes are cascade-deleted separately when the task's +// exploration subgraph is dropped. Returns rows deleted. +func (d *DB) DeleteFindingsByTask(taskID int64) (int64, error) { + res, err := d.Exec(`DELETE FROM findings WHERE task_id=$1`, taskID) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + // SetFindingStatus updates one finding's triage state. Returns rows affected. func (d *DB) SetFindingStatus(id int64, status string) (int64, error) { res, err := d.Exec(`UPDATE findings SET status=$1 WHERE id=$2`, status, id) diff --git a/db/findings_test.go b/db/findings_test.go index a978701..4ed3ce2 100644 --- a/db/findings_test.go +++ b/db/findings_test.go @@ -1,10 +1,62 @@ package db import ( + "encoding/json" + "errors" + "fmt" "slices" "testing" + "time" ) +// TestDeleteFinding verifies删除漏洞 removes both the findings row and its +// originating exploration node (kind='finding'). +func TestDeleteFinding(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Skipf("postgres unavailable (%v) — skipping", err) + } + defer d.Close() + + tk, err := d.CreateTask("删除漏洞测试", "目标", nil, 0, 0) + if err != nil { + t.Fatal(err) + } + defer d.DeleteTask(tk.ID) + + // seed a finding node in the task's exploration graph, then a findings row on it + es := d.Exploration(tk.ExplorationID) + nodeID, err := es.AddNode(KindFinding, map[string]any{"summary": "x", "severity": "high"}, 5, "confirmed", "worker", nil) + if err != nil { + t.Fatal(err) + } + fid, err := d.AddFinding(tk.ID, nodeID, "XSS", "反射型 XSS", "high", "summary", "poc", "worker", nil) + if err != nil { + t.Fatal(err) + } + + n, err := d.DeleteFinding(fid) + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("DeleteFinding rows: want 1, got %d", n) + } + if f, _ := d.GetFinding(fid); f != nil { + t.Fatalf("finding row should be gone, got %+v", f) + } + var cnt int + d.QueryRow(`SELECT count(*) FROM exploration_nodes WHERE id=$1`, nodeID).Scan(&cnt) + if cnt != 0 { + t.Fatalf("originating finding node should be deleted, still %d", cnt) + } + + // deleting a non-existent finding is a no-op (0 rows), not an error + if n, err := d.DeleteFinding(fid); err != nil || n != 0 { + t.Fatalf("re-delete: want (0,nil), got (%d,%v)", n, err) + } +} + // TestFindingsPageAndStats exercises ListFindingsPage (filter/sort/paging) and // FindingStats against the live dev PG. It tags its rows with a unique vulnclass // so assertions are isolated from any pre-existing data, and cleans up after. @@ -135,3 +187,297 @@ func TestFindingsPageAndStats(t *testing.T) { t.Fatalf("SetFindingSeverity(-1): want 0,nil got %d,%v", n, err) } } + +func TestListFindingsPageUsesStableIDTieBreaker(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Skipf("postgres unavailable (%v) — skipping", err) + } + defer d.Close() + + vc := fmt.Sprintf("__test_finding_stable_page_%d__", time.Now().UnixNano()) + defer d.Exec(`DELETE FROM findings WHERE vulnclass=$1`, vc) + ids := make([]int64, 0, 7) + for i := 0; i < 7; i++ { + id, addErr := d.AddFinding(0, 0, vc, "", SeverityHigh, fmt.Sprintf("finding %d", i), "", "test", nil) + if addErr != nil { + t.Fatalf("AddFinding[%d]: %v", i, addErr) + } + ids = append(ids, id) + } + sharedCreatedAt := time.Date(2026, time.August, 21, 8, 30, 0, 0, time.UTC) + if _, err := d.Exec(`UPDATE findings SET created_at=$1 WHERE vulnclass=$2`, sharedCreatedAt, vc); err != nil { + t.Fatal(err) + } + + want := make([]int64, len(ids)) + for i := range ids { + want[i] = ids[len(ids)-1-i] + } + for _, sort := range []string{"time", "severity"} { + t.Run(sort, func(t *testing.T) { + var got []int64 + for page := 1; page <= 3; page++ { + items, total, pageErr := d.ListFindingsPage(FindingFilter{VulnClass: vc, Sort: sort}, page, 3) + if pageErr != nil { + t.Fatal(pageErr) + } + if total != len(ids) { + t.Fatalf("page %d total=%d, want %d", page, total, len(ids)) + } + for _, item := range items { + got = append(got, item.ID) + } + } + if !slices.Equal(got, want) { + t.Fatalf("same-timestamp pagination was unstable: got=%v want=%v", got, want) + } + }) + } +} + +func TestFindingGroupsAndUnassignedPaging(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Skipf("postgres unavailable (%v) — skipping", err) + } + defer d.Close() + + vc := fmt.Sprintf("__test_finding_groups_%d__", time.Now().UnixNano()) + cleanupFindings := func() { _, _ = d.Exec(`DELETE FROM findings WHERE vulnclass=$1`, vc) } + defer cleanupFindings() + + taskA, err := d.CreateTask("group A", "goal", nil, 0, 0) + if err != nil { + t.Fatal(err) + } + defer d.DeleteTask(taskA.ID) + taskB, err := d.CreateTask("group B", "goal", nil, 0, 0) + if err != nil { + t.Fatal(err) + } + defer d.DeleteTask(taskB.ID) + + seed := []struct { + taskID int64 + severity string + status string + }{ + {taskA.ID, SeverityCritical, FindingPending}, + {taskA.ID, SeverityHigh, FindingResolved}, + {taskB.ID, SeverityLow, FindingPending}, + {0, SeverityMedium, FindingPending}, + } + for i, item := range seed { + id, addErr := d.AddFinding(item.taskID, 0, vc, "", item.severity, fmt.Sprintf("summary %d", i), "", "test", nil) + if addErr != nil { + t.Fatalf("AddFinding[%d]: %v", i, addErr) + } + if _, setErr := d.SetFindingStatus(id, item.status); setErr != nil { + t.Fatalf("SetFindingStatus[%d]: %v", i, setErr) + } + } + // Retained findings from deleted tasks join the same bucket as findings that + // were created without any task. + if err := d.DeleteTask(taskB.ID); err != nil { + t.Fatal(err) + } + + page, groupTotal, findingTotal, err := d.ListFindingGroups(FindingFilter{VulnClass: vc, Sort: "severity"}, 1, 1) + if err != nil { + t.Fatal(err) + } + if len(page) != 1 || groupTotal != 2 || findingTotal != 4 { + t.Fatalf("page totals: items=%d groups=%d findings=%d", len(page), groupTotal, findingTotal) + } + groups, _, _, err := d.ListFindingGroups(FindingFilter{VulnClass: vc}, 1, 10) + if err != nil { + t.Fatal(err) + } + var live, unassigned *FindingGroup + for i := range groups { + if groups[i].TaskID == nil { + unassigned = &groups[i] + } else if *groups[i].TaskID == taskA.ID { + live = &groups[i] + } + } + if live == nil || live.Count != 2 || live.Critical != 1 || live.High != 1 || live.TaskDescription != "group A" { + t.Fatalf("live group mismatch: %+v", live) + } + if unassigned == nil || unassigned.Count != 2 || unassigned.Medium != 1 || unassigned.Low != 1 { + t.Fatalf("unassigned group mismatch: %+v", unassigned) + } + groupForTask := func(items []FindingGroup, taskID int64) *FindingGroup { + for i := range items { + if items[i].TaskID != nil && *items[i].TaskID == taskID { + return &items[i] + } + } + return nil + } + if err := d.SetPaused(taskA.ID, true); err != nil { + t.Fatal(err) + } + groups, _, _, err = d.ListFindingGroups(FindingFilter{VulnClass: vc}, 1, 10) + if err != nil { + t.Fatal(err) + } + if group := groupForTask(groups, taskA.ID); group == nil || group.TaskStatus != "paused" { + t.Fatalf("paused task group status mismatch: %+v", groups) + } + if err := d.SetPaused(taskA.ID, false); err != nil { + t.Fatal(err) + } + if err := d.Enqueue(taskA.ID, "resume"); err != nil { + t.Fatal(err) + } + groups, _, _, err = d.ListFindingGroups(FindingFilter{VulnClass: vc}, 1, 10) + if err != nil { + t.Fatal(err) + } + if group := groupForTask(groups, taskA.ID); group == nil || group.TaskStatus != "queued" { + t.Fatalf("queued task group status mismatch: %+v", groups) + } + if err := d.SetStatus(taskA.ID, "done"); err != nil { + t.Fatal(err) + } + groups, _, _, err = d.ListFindingGroups(FindingFilter{VulnClass: vc}, 1, 10) + if err != nil { + t.Fatal(err) + } + if group := groupForTask(groups, taskA.ID); group == nil || group.TaskStatus != "done" { + t.Fatalf("terminal task status must win over queued flag: %+v", groups) + } + + orphans, total, err := d.ListFindingsPage(FindingFilter{VulnClass: vc, TaskID: FindingUnassignedTask}, 1, 10) + if err != nil || total != 2 || len(orphans) != 2 { + t.Fatalf("unassigned page: len=%d total=%d err=%v", len(orphans), total, err) + } + maxPage := int(^uint(0) >> 1) + farFindings, farTotal, err := d.ListFindingsPage(FindingFilter{VulnClass: vc}, maxPage, 200) + if err != nil || farTotal != len(seed) || len(farFindings) != 0 { + t.Fatalf("far finding page: len=%d total=%d err=%v", len(farFindings), farTotal, err) + } + farGroups, farGroupTotal, farFindingTotal, err := d.ListFindingGroups(FindingFilter{VulnClass: vc}, maxPage, 100) + if err != nil || farGroupTotal != 2 || farFindingTotal != len(seed) || len(farGroups) != 0 { + t.Fatalf("far group page: len=%d groups=%d findings=%d err=%v", + len(farGroups), farGroupTotal, farFindingTotal, err) + } + filtered, filteredGroups, filteredFindings, err := d.ListFindingGroups( + FindingFilter{VulnClass: vc, Status: FindingResolved}, 1, 10, + ) + if err != nil || filteredGroups != 1 || filteredFindings != 1 || len(filtered) != 1 || filtered[0].High != 1 { + t.Fatalf("filtered groups: %+v groups=%d findings=%d err=%v", filtered, filteredGroups, filteredFindings, err) + } +} + +func TestAddFindingFollowUpIntent(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Skipf("postgres unavailable (%v) — skipping", err) + } + defer d.Close() + + task, err := d.CreateTask("finding follow-up", "goal", nil, 0, 0) + if err != nil { + t.Fatal(err) + } + defer d.DeleteTask(task.ID) + assetID, err := d.Assets().UpsertRootDomain(UpsertRootDomainReq{ + Domain: fmt.Sprintf("follow-up-%d.example.test", time.Now().UnixNano()), + TaskID: task.ID, + }) + if err != nil { + t.Fatal(err) + } + defer d.Exec(`DELETE FROM assets WHERE id=$1`, assetID) + + store := d.Exploration(task.ExplorationID) + findingNodeID, err := store.AddNode(KindFinding, map[string]any{"summary": "source"}, 5, "confirmed", "worker", []int64{assetID}) + if err != nil { + t.Fatal(err) + } + findingID, err := d.AddFinding(task.ID, findingNodeID, "test", "source", SeverityHigh, "source", "", "worker", []int64{assetID}) + if err != nil { + t.Fatal(err) + } + + auditInput := Activity{Worker: "system", Kind: "text", Summary: "人工提交漏洞深入利用意图", Detail: "验证可利用性并形成证据链"} + intentID, audit, err := store.AddFindingFollowUpIntent(findingID, findingNodeID, "验证可利用性并形成证据链", auditInput) + if err != nil { + t.Fatal(err) + } + secondID, _, err := store.AddFindingFollowUpIntent(findingID, findingNodeID, "从另一条路径深入", Activity{ + Worker: "system", Kind: "text", Summary: "人工提交漏洞深入利用意图", Detail: "从另一条路径深入", + }) + if err != nil { + t.Fatal(err) + } + if secondID == intentID { + t.Fatal("repeated follow-up submissions must create distinct intents") + } + expectedAuditSummary := fmt.Sprintf("%s #%d", auditInput.Summary, intentID) + if audit.ID <= 0 || audit.NodeID == nil || *audit.NodeID != intentID || audit.CreatedAt.IsZero() || audit.Summary != expectedAuditSummary { + t.Fatalf("persisted audit mismatch: %+v", audit) + } + node, err := store.GetNode(intentID) + if err != nil || node == nil { + t.Fatalf("GetNode: node=%+v err=%v", node, err) + } + if node.Kind != KindIntent || node.Priority != 10 || node.State != "open" || node.Origin != "human" { + t.Fatalf("follow-up intent metadata: %+v", node) + } + var anchorCount, edgeCount int + if err := d.QueryRow(`SELECT COUNT(*) FROM exploration_anchors WHERE node_id=$1 AND asset_id=$2`, intentID, assetID).Scan(&anchorCount); err != nil { + t.Fatal(err) + } + if err := d.QueryRow(`SELECT COUNT(*) FROM exploration_edges + WHERE exploration_id=$1 AND src_id=$2 AND rel=$3 AND dst_id=$4`, + task.ExplorationID, findingNodeID, RelDerivedFrom, intentID).Scan(&edgeCount); err != nil { + t.Fatal(err) + } + if anchorCount != 1 || edgeCount != 1 { + t.Fatalf("follow-up lineage: anchors=%d edges=%d", anchorCount, edgeCount) + } + var persistedAuditCount int + if err := d.QueryRow(`SELECT COUNT(*) FROM activity + WHERE id=$1 AND exploration_id=$2 AND node_id=$3 AND worker='system' AND summary=$4`, + audit.ID, task.ExplorationID, intentID, expectedAuditSummary).Scan(&persistedAuditCount); err != nil { + t.Fatal(err) + } + if persistedAuditCount != 1 { + t.Fatalf("atomic audit count=%d, want 1", persistedAuditCount) + } + + var nodesBefore, activitiesBefore int + if err := d.QueryRow(`SELECT COUNT(*) FROM exploration_nodes WHERE exploration_id=$1`, task.ExplorationID).Scan(&nodesBefore); err != nil { + t.Fatal(err) + } + if err := d.QueryRow(`SELECT COUNT(*) FROM activity WHERE exploration_id=$1`, task.ExplorationID).Scan(&activitiesBefore); err != nil { + t.Fatal(err) + } + if _, _, err := store.AddFindingFollowUpIntent(findingID, findingNodeID, "must roll back", Activity{ + Worker: "system", Kind: "text", Summary: "invalid audit", Metadata: json.RawMessage(`{`), + }); err == nil { + t.Fatal("invalid activity metadata should fail the transaction") + } + var nodesAfter, activitiesAfter int + if err := d.QueryRow(`SELECT COUNT(*) FROM exploration_nodes WHERE exploration_id=$1`, task.ExplorationID).Scan(&nodesAfter); err != nil { + t.Fatal(err) + } + if err := d.QueryRow(`SELECT COUNT(*) FROM activity WHERE exploration_id=$1`, task.ExplorationID).Scan(&activitiesAfter); err != nil { + t.Fatal(err) + } + if nodesAfter != nodesBefore || activitiesAfter != activitiesBefore { + t.Fatalf("activity failure did not roll back graph: nodes %d->%d activities %d->%d", + nodesBefore, nodesAfter, activitiesBefore, activitiesAfter) + } + + if _, err := d.Exec(`DELETE FROM exploration_nodes WHERE id=$1`, findingNodeID); err != nil { + t.Fatal(err) + } + if _, _, err := store.AddFindingFollowUpIntent(findingID, findingNodeID, "should fail", auditInput); !errors.Is(err, ErrFindingOriginUnavailable) { + t.Fatalf("missing source node: want ErrFindingOriginUnavailable, got %v", err) + } +} diff --git a/report/findings.go b/report/findings.go new file mode 100644 index 0000000..265bfd3 --- /dev/null +++ b/report/findings.go @@ -0,0 +1,165 @@ +package report + +import ( + "bytes" + "encoding/csv" + "fmt" + "path" + "regexp" + "sort" + "strings" + "time" + + "github.com/Autumn-27/artex/db" +) + +// 发现页「导出」用的渲染:把一批 findings 表行渲染成汇总 Markdown、单条 Markdown、 +// 或 CSV。JSON 由 server 层直接用 DTO 序列化,不在此处。 + +// sortFindingsForExport 按严重等级降序、再按时间倒序排,与汇总报告的分组一致。 +func sortFindingsForExport(fs []*db.DBFinding) { + sort.SliceStable(fs, func(i, j int) bool { + ri, rj := sevRank[fs[i].Severity], sevRank[fs[j].Severity] + if ri != rj { + return ri < rj // sevRank 越小越严重 + } + return fs[i].CreatedAt.After(fs[j].CreatedAt) + }) +} + +// findingTitle 取漏洞可读标题:名称 → 类别 → 「未分类」。 +func findingTitle(f *db.DBFinding) string { + return nz(f.Name, nz(f.VulnClass, "未分类")) +} + +// FindingsMarkdown 把一批 findings 整合成一份汇总报告(摘要 + 按严重等级分组, +// 每条含类别/状态/所属任务/证据/详细报告)。 +func FindingsMarkdown(fs []*db.DBFinding, generatedAt time.Time) string { + items := append([]*db.DBFinding(nil), fs...) + sortFindingsForExport(items) + + var b strings.Builder + b.WriteString("# 漏洞发现汇总报告\n\n") + fmt.Fprintf(&b, "- **生成时间**:%s\n", generatedAt.Format("2006-01-02 15:04:05")) + fmt.Fprintf(&b, "- **发现总数**:%d 个\n\n", len(items)) + + // 摘要:各严重等级计数。 + counts := map[string]int{} + for _, f := range items { + counts[f.Severity]++ + } + b.WriteString("## 摘要\n\n") + b.WriteString("| 严重等级 | 数量 |\n| --- | --- |\n") + for _, s := range []struct{ key, label string }{ + {"critical", "严重"}, {"high", "高危"}, {"medium", "中危"}, {"low", "低危"}, + } { + fmt.Fprintf(&b, "| %s | %d |\n", s.label, counts[s.key]) + } + b.WriteString("\n") + + if len(items) == 0 { + b.WriteString("_无匹配的漏洞。_\n") + return b.String() + } + + b.WriteString("## 漏洞明细\n\n") + for i, f := range items { + fmt.Fprintf(&b, "### %d. [%s] %s\n\n", i+1, strings.ToUpper(nz(f.Severity, "info")), findingTitle(f)) + if f.VulnClass != "" { + fmt.Fprintf(&b, "- **类别**:%s\n", f.VulnClass) + } + fmt.Fprintf(&b, "- **状态**:%s\n", nz(f.Status, "pending")) + if desc := strings.TrimSpace(f.TaskDescription); desc != "" { + fmt.Fprintf(&b, "- **所属任务**:%s\n", desc) + } + fmt.Fprintf(&b, "- **发现时间**:%s\n\n", f.CreatedAt.Format("2006-01-02 15:04:05")) + if s := strings.TrimSpace(f.Summary); s != "" { + fmt.Fprintf(&b, "%s\n\n", s) + } + if e := strings.TrimSpace(f.Evidence); e != "" { + fmt.Fprintf(&b, "**证据:**\n\n```\n%s\n```\n\n", e) + } + if rep := strings.TrimSpace(f.Report); rep != "" { + b.WriteString("**详细报告:**\n\n") + b.WriteString(rep) + b.WriteString("\n\n") + } + b.WriteString("---\n\n") + } + return b.String() +} + +// SingleFindingMarkdown 渲染单条漏洞为一份独立 Markdown(用于「一漏洞一文件」打包)。 +func SingleFindingMarkdown(f *db.DBFinding, generatedAt time.Time) string { + var b strings.Builder + fmt.Fprintf(&b, "# [%s] %s\n\n", strings.ToUpper(nz(f.Severity, "info")), findingTitle(f)) + if f.VulnClass != "" { + fmt.Fprintf(&b, "- **类别**:%s\n", f.VulnClass) + } + fmt.Fprintf(&b, "- **严重等级**:%s\n", nz(f.Severity, "info")) + fmt.Fprintf(&b, "- **状态**:%s\n", nz(f.Status, "pending")) + if desc := strings.TrimSpace(f.TaskDescription); desc != "" { + fmt.Fprintf(&b, "- **所属任务**:%s\n", desc) + } + fmt.Fprintf(&b, "- **发现时间**:%s\n", f.CreatedAt.Format("2006-01-02 15:04:05")) + fmt.Fprintf(&b, "- **生成时间**:%s\n\n", generatedAt.Format("2006-01-02 15:04:05")) + if s := strings.TrimSpace(f.Summary); s != "" { + fmt.Fprintf(&b, "## 概述\n\n%s\n\n", s) + } + if e := strings.TrimSpace(f.Evidence); e != "" { + fmt.Fprintf(&b, "## 证据\n\n```\n%s\n```\n\n", e) + } + if rep := strings.TrimSpace(f.Report); rep != "" { + b.WriteString("## 详细报告\n\n") + b.WriteString(rep) + b.WriteString("\n") + } + return b.String() +} + +var unsafeFilenameChars = regexp.MustCompile(`[^\p{Han}\p{L}\p{N}._-]+`) + +// FindingFilename 为「一漏洞一文件」生成安全的 .md 文件名,形如 +// `critical_SQL注入_#123.md`。去掉路径分隔符与控制字符,避免 zip 内非法路径。 +func FindingFilename(f *db.DBFinding) string { + sev := nz(f.Severity, "info") + title := findingTitle(f) + name := fmt.Sprintf("%s_%s_#%d", sev, title, f.ID) + name = unsafeFilenameChars.ReplaceAllString(name, "_") + name = strings.Trim(name, "._") + if name == "" { + name = fmt.Sprintf("finding_%d", f.ID) + } + // 防御性:再剥一层路径,杜绝 zip slip。 + name = path.Base(name) + if len(name) > 120 { + name = name[:120] + } + return name + ".md" +} + +// FindingsCSV 把一批 findings 渲染成 CSV(带 UTF-8 BOM,便于 Excel 正确识别中文)。 +// 不含大段 report/evidence 全文,只放摘要类字段;需要全文用 Markdown/JSON 导出。 +func FindingsCSV(fs []*db.DBFinding) []byte { + items := append([]*db.DBFinding(nil), fs...) + sortFindingsForExport(items) + + var buf bytes.Buffer + buf.WriteString("\xEF\xBB\xBF") // UTF-8 BOM + w := csv.NewWriter(&buf) + _ = w.Write([]string{"ID", "名称", "类别", "严重等级", "状态", "所属任务", "发现时间", "概述"}) + for _, f := range items { + _ = w.Write([]string{ + fmt.Sprintf("%d", f.ID), + findingTitle(f), + f.VulnClass, + nz(f.Severity, "info"), + nz(f.Status, "pending"), + f.TaskDescription, + f.CreatedAt.Format("2006-01-02 15:04:05"), + strings.TrimSpace(f.Summary), + }) + } + w.Flush() + return buf.Bytes() +} diff --git a/server/findings_groups.go b/server/findings_groups.go new file mode 100644 index 0000000..86f0972 --- /dev/null +++ b/server/findings_groups.go @@ -0,0 +1,166 @@ +package server + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "unicode/utf8" + + "github.com/Autumn-27/artex/db" +) + +const maxFindingFollowUpRunes = 4000 + +func findingPaginationParam(raw string, fallback, upperBound int) int { + value := atoiDefault(raw, fallback) + if value <= 0 { + value = fallback + } + if upperBound > 0 && value > upperBound { + value = upperBound + } + return value +} + +// findingGroups serves the task-group page used by the global findings view. +// Findings inside a group continue to use the existing flat findings endpoint, +// preserving dashboard/export clients and giving every expanded group its own +// independent page cursor. +func (s *Server) findingGroups(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + page := findingPaginationParam(q.Get("page"), 1, 0) + limit := findingPaginationParam(q.Get("limit"), 10, 100) + filter := db.FindingFilter{ + Severity: normFilter(q.Get("severity")), + Status: normFilter(q.Get("status")), + VulnClass: normFilter(q.Get("vulnclass")), + TaskID: normFilter(q.Get("task_id")), + Sort: q.Get("sort"), + } + groups, total, findingTotal, err := s.m.pg.ListFindingGroups(filter, page, limit) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + // PostgreSQL owns paused/queued persistence. The live engine adds the same + // derived running/idle view used by task DTOs, so finding groups do not lag + // behind the task list while an Agent is actively executing. + if s.engine != nil { + for i := range groups { + if groups[i].TaskID == nil { + continue + } + if task, ok := s.m.Task(i64s(*groups[i].TaskID)); ok { + groups[i].TaskStatus = s.resolvedTaskStatus(task) + } + } + } + writeJSON(w, 200, map[string]any{ + "items": groups, + "total": total, + "finding_total": findingTotal, + "page": page, + "page_size": limit, + }) +} + +// deepenFinding creates a high-priority human Worker intent under the finding's +// original task. The DB write validates and links the live finding node; task +// admission then revives or queues the task through the shared concurrency path. +func (s *Server) deepenFinding(w http.ResponseWriter, r *http.Request) { + id := int64(atoiDefault(r.PathValue("id"), 0)) + if id <= 0 { + writeErr(w, 400, "bad finding id") + return + } + r.Body = http.MaxBytesReader(w, r.Body, 32<<10) + var req struct { + Description string `json:"description"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + writeErr(w, http.StatusRequestEntityTooLarge, "请求正文过大") + } else { + writeErr(w, http.StatusBadRequest, "bad json: "+err.Error()) + } + return + } + description := strings.TrimSpace(req.Description) + switch { + case description == "": + writeErr(w, 400, "description is required") + return + case utf8.RuneCountInString(description) > maxFindingFollowUpRunes: + writeErr(w, 400, fmt.Sprintf("description must be at most %d characters", maxFindingFollowUpRunes)) + return + } + + finding, err := s.m.pg.GetFinding(id) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + if finding == nil { + writeErr(w, 404, "finding not found") + return + } + if finding.TaskID == nil || finding.NodeID == nil { + writeErr(w, 409, "finding origin task or node is no longer available") + return + } + t, ok := s.m.Task(i64s(*finding.TaskID)) + if !ok || t == nil { + writeErr(w, 409, "finding origin task is no longer available") + return + } + if !s.engine.beginTaskOperation(t.ID) { + writeErr(w, 409, "task is being deleted") + return + } + defer s.engine.decInflight(t.ID) + + audit := db.Activity{ + Worker: "system", + Kind: "text", + Summary: "人工提交漏洞深入利用意图", + Detail: description, + } + intentID, audit, err := t.Store.AddFindingFollowUpIntent(id, *finding.NodeID, description, audit) + if errors.Is(err, db.ErrFindingOriginUnavailable) { + writeErr(w, 409, "finding origin task or node is no longer available") + return + } + if err != nil { + writeErr(w, 500, err.Error()) + return + } + queued, err := s.admitTask(t, "resume") + if err != nil { + if rollbackErr := t.Store.DiscardOpenIntent(intentID); rollbackErr != nil { + err = errors.Join(err, fmt.Errorf("discard follow-up intent %d: %w", intentID, rollbackErr)) + } + writeErr(w, 500, err.Error()) + return + } + // The audit row committed atomically with the intent. Publish that exact row; + // emitActivity would insert a second copy and break DB/SSE correspondence. + s.engine.Broadcaster().Publish(t.ID, audit) + s.engine.touch(t.ID) + // Admission can wake a Worker immediately. Report the persisted intent state + // observed at response time instead of always claiming it is still open. The + // task-level queued flag remains separate because a queued task intentionally + // keeps its Worker intent open until it receives a concurrency slot. + state := "open" + if current, stateErr := t.Store.GetNode(intentID); stateErr == nil && current != nil { + state = current.State + } + writeJSON(w, 200, map[string]any{ + "task_id": t.ID, + "intent_id": i64s(intentID), + "state": state, + "queued": queued, + }) +} diff --git a/server/findings_groups_test.go b/server/findings_groups_test.go new file mode 100644 index 0000000..5cb44ed --- /dev/null +++ b/server/findings_groups_test.go @@ -0,0 +1,321 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/Autumn-27/artex/db" +) + +func TestFindingPaginationParam(t *testing.T) { + for _, test := range []struct { + raw, name string + fallback, upper, want int + }{ + {name: "missing", raw: "", fallback: 10, upper: 100, want: 10}, + {name: "invalid", raw: "not-a-number", fallback: 10, upper: 100, want: 10}, + {name: "zero", raw: "0", fallback: 10, upper: 100, want: 10}, + {name: "negative", raw: "-2", fallback: 1, upper: 0, want: 1}, + {name: "bounded", raw: "500", fallback: 10, upper: 100, want: 100}, + {name: "valid", raw: "25", fallback: 10, upper: 100, want: 25}, + } { + t.Run(test.name, func(t *testing.T) { + if got := findingPaginationParam(test.raw, test.fallback, test.upper); got != test.want { + t.Fatalf("findingPaginationParam(%q,%d,%d)=%d, want %d", + test.raw, test.fallback, test.upper, got, test.want) + } + }) + } +} + +func TestFindingGroupsReturnsTaskBucketsAndNormalizesPagination(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Skipf("postgres unavailable (%v) — skipping", err) + } + defer m.Close() + marker := fmt.Sprintf("__server_finding_groups_%d__", time.Now().UnixNano()) + createdTaskIDs := []int64{} + defer func() { + _, _ = m.pg.Exec(`DELETE FROM findings WHERE vulnclass=$1`, marker) + for _, taskID := range createdTaskIDs { + _ = m.pg.DeleteTask(taskID) + } + }() + + taskA, err := m.CreateTask("finding group A", "goal", nil, 0, 0) + if err != nil { + t.Fatal(err) + } + createdTaskIDs = append(createdTaskIDs, mustTaskID(t, taskA.ID)) + taskB, err := m.CreateTask("finding group B", "goal", nil, 0, 0) + if err != nil { + t.Fatal(err) + } + createdTaskIDs = append(createdTaskIDs, mustTaskID(t, taskB.ID)) + + for i, seed := range []struct { + taskID string + severity string + }{ + {taskA.ID, db.SeverityCritical}, + {taskA.ID, db.SeverityHigh}, + {taskB.ID, db.SeverityLow}, + } { + if _, err := m.pg.AddFinding(mustTaskID(t, seed.taskID), 0, marker, "", seed.severity, + fmt.Sprintf("finding %d", i), "", "test", nil); err != nil { + t.Fatalf("AddFinding[%d]: %v", i, err) + } + } + + s := &Server{m: m} + request := httptest.NewRequest(http.MethodGet, + "/api/exploration/findings/groups?vulnclass="+marker+"&sort=severity&page=1&limit=1", nil) + recorder := httptest.NewRecorder() + s.findingGroups(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("groups status=%d body=%s", recorder.Code, recorder.Body.String()) + } + var page struct { + Items []db.FindingGroup `json:"items"` + Total int `json:"total"` + FindingTotal int `json:"finding_total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &page); err != nil { + t.Fatal(err) + } + if len(page.Items) != 1 || page.Total != 2 || page.FindingTotal != 3 || page.Page != 1 || page.PageSize != 1 { + t.Fatalf("unexpected grouped page: %+v", page) + } + if page.Items[0].TaskID == nil || *page.Items[0].TaskID != mustTaskID(t, taskA.ID) || + page.Items[0].Count != 2 || page.Items[0].Critical != 1 || page.Items[0].High != 1 { + t.Fatalf("unexpected first group: %+v", page.Items[0]) + } + + request = httptest.NewRequest(http.MethodGet, + "/api/exploration/findings/groups?vulnclass="+marker+"&page=-4&limit=0", nil) + recorder = httptest.NewRecorder() + s.findingGroups(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("normalized groups status=%d body=%s", recorder.Code, recorder.Body.String()) + } + page = struct { + Items []db.FindingGroup `json:"items"` + Total int `json:"total"` + FindingTotal int `json:"finding_total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + }{} + if err := json.Unmarshal(recorder.Body.Bytes(), &page); err != nil { + t.Fatal(err) + } + if page.Page != 1 || page.PageSize != 10 || len(page.Items) != 2 { + t.Fatalf("pagination was not normalized: %+v", page) + } +} + +func TestDeepenFindingCreatesAuditedIntentAndRevivesTask(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Skipf("postgres unavailable (%v) — skipping", err) + } + defer m.Close() + + task, err := m.CreateTask("deepen finding", "goal", nil, 0, 0) + if err != nil { + t.Fatal(err) + } + store := task.Store + findingNodeID, err := store.AddNode(db.KindFinding, map[string]any{"summary": "source"}, 5, "confirmed", "worker", nil) + if err != nil { + t.Fatal(err) + } + taskID, _ := strconv.ParseInt(task.ID, 10, 64) + defer m.pg.DeleteTask(taskID) + findingID, err := m.pg.AddFinding(taskID, findingNodeID, "test", "source", db.SeverityHigh, "source", "", "worker", nil) + if err != nil { + t.Fatal(err) + } + defer m.pg.DeleteFinding(findingID) + if err := m.SetTaskStatus(task.ID, "done"); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // admission is exercised without starting background planner/worker work + s := &Server{m: m, engine: NewEngine(m), ctx: ctx} + live, unsubscribe := s.engine.Broadcaster().Subscribe(task.ID) + defer unsubscribe() + body := bytes.NewBufferString(`{"description":"验证完整利用链并保留可复现证据"}`) + req := httptest.NewRequest(http.MethodPost, "/api/exploration/findings/1/deepen", body) + req.SetPathValue("id", strconv.FormatInt(findingID, 10)) + rec := httptest.NewRecorder() + s.deepenFinding(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("deepen status=%d body=%s", rec.Code, rec.Body.String()) + } + var response struct { + TaskID string `json:"task_id"` + IntentID string `json:"intent_id"` + State string `json:"state"` + Queued bool `json:"queued"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + intentID, err := strconv.ParseInt(response.IntentID, 10, 64) + if err != nil || intentID <= 0 || response.TaskID != task.ID || response.State != "open" || response.Queued { + t.Fatalf("unexpected response: %+v", response) + } + intent, err := store.GetNode(intentID) + if err != nil || intent == nil { + t.Fatalf("intent=%+v err=%v", intent, err) + } + if intent.Priority != 10 || intent.Origin != "human" || intent.State != "open" { + t.Fatalf("intent metadata: %+v", intent) + } + if task.Status != "running" || task.Paused || task.Queued { + t.Fatalf("task was not revived through admission: %+v", task) + } + activity, _, err := store.ActivityList(&intentID, 0, 50) + if err != nil { + t.Fatal(err) + } + var persistedAudit db.Activity + auditCount := 0 + for _, item := range activity { + if item.Worker == "system" && strings.Contains(item.Summary, "人工提交漏洞深入利用意图") { + persistedAudit = item + auditCount++ + } + } + if auditCount != 1 || persistedAudit.ID <= 0 { + t.Fatalf("atomic system audit count=%d activity=%+v", auditCount, activity) + } + select { + case broadcast := <-live: + if broadcast.ID != persistedAudit.ID || broadcast.NodeID == nil || *broadcast.NodeID != intentID || + broadcast.CreatedAt.IsZero() { + t.Fatalf("broadcast must reuse committed activity: broadcast=%+v persisted=%+v", broadcast, persistedAudit) + } + default: + t.Fatal("committed follow-up audit was not broadcast") + } + select { + case duplicate := <-live: + t.Fatalf("follow-up audit was broadcast more than once: %+v", duplicate) + default: + } + + // The retained finding can no longer be deepened after its task and node are + // deleted; this must be a conflict rather than silently creating orphan work. + if err := m.pg.DeleteTask(taskID); err != nil { + t.Fatal(err) + } + req = httptest.NewRequest(http.MethodPost, "/api/exploration/findings/1/deepen", bytes.NewBufferString(`{"description":"retry"}`)) + req.SetPathValue("id", strconv.FormatInt(findingID, 10)) + rec = httptest.NewRecorder() + s.deepenFinding(rec, req) + if rec.Code != http.StatusConflict { + t.Fatalf("deleted origin status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestDeepenFindingValidatesDescription(t *testing.T) { + s := &Server{} + tooLong, _ := json.Marshal(map[string]string{"description": strings.Repeat("x", maxFindingFollowUpRunes+1)}) + for _, body := range []string{`{}`, `{"description":" "}`, string(tooLong)} { + req := httptest.NewRequest(http.MethodPost, "/api/exploration/findings/1/deepen", bytes.NewBufferString(body)) + req.SetPathValue("id", "1") + rec := httptest.NewRecorder() + s.deepenFinding(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("body=%s status=%d response=%s", body, rec.Code, rec.Body.String()) + } + } + oversized := `{"description":"` + strings.Repeat("x", (32<<10)+1) + `"}` + req := httptest.NewRequest(http.MethodPost, "/api/exploration/findings/1/deepen", strings.NewReader(oversized)) + req.SetPathValue("id", "1") + rec := httptest.NewRecorder() + s.deepenFinding(rec, req) + if rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("oversized status=%d response=%s", rec.Code, rec.Body.String()) + } +} + +func TestDeepenAdmissionFailureDiscardsFollowUpIntent(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Skipf("postgres unavailable (%v) - skipping", err) + } + defer m.Close() + task, err := m.CreateTask("deepen rollback", "leave no orphan intent", nil, 0, 0) + if err != nil { + t.Fatal(err) + } + taskID, _ := strconv.ParseInt(task.ID, 10, 64) + defer func() { + _, _ = m.pg.Exec(`UPDATE tasks SET deleted_at=NULL WHERE id=$1`, taskID) + _, _ = m.DeleteTask(task.ID, DeleteTaskOptions{}) + }() + findingNodeID, err := task.Store.AddNode(db.KindFinding, map[string]any{"summary": "source"}, 5, "confirmed", "worker", nil) + if err != nil { + t.Fatal(err) + } + findingID, err := m.pg.AddFinding(taskID, findingNodeID, "test", "source", db.SeverityHigh, "source", "", "worker", nil) + if err != nil { + t.Fatal(err) + } + before, err := task.Store.ListByKind(db.KindIntent, 1000) + if err != nil { + t.Fatal(err) + } + var activitiesBefore int + if err := m.pg.QueryRow(`SELECT COUNT(*) FROM activity WHERE exploration_id=$1`, task.Store.ID()).Scan(&activitiesBefore); err != nil { + t.Fatal(err) + } + if err := m.SetTaskStatus(task.ID, "done"); err != nil { + t.Fatal(err) + } + // Keep the in-memory handle and graph available while forcing the atomic + // admission UPDATE to reject this soft-deleted row. + if _, err := m.pg.Exec(`UPDATE tasks SET deleted_at=now() WHERE id=$1`, taskID); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + s := &Server{m: m, engine: NewEngine(m), ctx: ctx} + req := httptest.NewRequest(http.MethodPost, "/api/exploration/findings/1/deepen", + bytes.NewBufferString(`{"description":"this intent must be rolled back"}`)) + req.SetPathValue("id", strconv.FormatInt(findingID, 10)) + rec := httptest.NewRecorder() + s.deepenFinding(rec, req) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + after, err := task.Store.ListByKind(db.KindIntent, 1000) + if err != nil { + t.Fatal(err) + } + if len(after) != len(before) { + t.Fatalf("failed admission left an orphan follow-up: before=%d after=%d nodes=%+v", len(before), len(after), after) + } + var activitiesAfter int + if err := m.pg.QueryRow(`SELECT COUNT(*) FROM activity WHERE exploration_id=$1`, task.Store.ID()).Scan(&activitiesAfter); err != nil { + t.Fatal(err) + } + if activitiesAfter != activitiesBefore { + t.Fatalf("failed admission left an orphan audit: before=%d after=%d", activitiesBefore, activitiesAfter) + } +} diff --git a/web/src/app/(main)/function/findings/detail/page.tsx b/web/src/app/(main)/function/findings/detail/page.tsx index c456ee4..7d36f5c 100644 --- a/web/src/app/(main)/function/findings/detail/page.tsx +++ b/web/src/app/(main)/function/findings/detail/page.tsx @@ -8,16 +8,19 @@ import { ArrowLeftIcon, ArrowUpRightIcon, ShieldAlertIcon } from "lucide-react"; import { SidebarTrigger } from "@/components/ui/sidebar"; import { Separator } from "@/components/ui/separator"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Select, SelectContent, + SelectGroup, SelectItem, SelectTrigger, } from "@/components/ui/select"; import { StatusBadge } from "@/components/status-badge"; +import { CopyButton } from "@/components/copy-button"; import { Markdown } from "@/components/markdown"; import { FindingLineageView } from "./lineage"; import { statusMeta } from "@/lib/status"; @@ -63,6 +66,7 @@ function FieldRow({ function FindingDetailInner() { const searchParams = useSearchParams(); const id = searchParams.get("id") ?? ""; + const contextTaskId = searchParams.get("context_task") ?? ""; const [finding, setFinding] = React.useState(null); const [loaded, setLoaded] = React.useState(false); const [tab, setTab] = React.useState("overview"); @@ -73,18 +77,18 @@ function FindingDetailInner() { return; } api - .getFinding(id) + .getFinding(id, contextTaskId || undefined) .then((f) => setFinding(f)) .catch(() => setFinding(null)) .finally(() => setLoaded(true)); - }, [id]); + }, [contextTaskId, id]); React.useEffect(() => { load(); }, [load]); const changeSeverity = React.useCallback( async (next: Severity) => { - if (!finding || next === finding.severity) return; + if (!finding || finding.inherited || next === finding.severity) return; const prev = finding.severity; setFinding({ ...finding, severity: next }); try { @@ -101,7 +105,7 @@ function FindingDetailInner() { const changeStatus = React.useCallback( async (next: FindingStatus) => { - if (!finding || next === finding.status) return; + if (!finding || finding.inherited || next === finding.status) return; const prev = finding.status; setFinding({ ...finding, status: next }); try { @@ -160,6 +164,11 @@ function FindingDetailInner() { + {finding.inherited && finding.source_task_id && ( + + 来源任务 #{finding.source_task_id} · 只读 + + )} 概览 @@ -200,8 +209,11 @@ function FindingDetailInner() { {/* 证据下方:详细报告(Markdown 渲染) */} - + 详细报告 + {finding.report && ( + + )} {finding.report ? ( @@ -228,48 +240,60 @@ function FindingDetailInner() { - {/* 严重等级(可改) */} + {/* 严重等级 */} - changeSeverity(v as Severity)} > - - - - {SEVERITIES.map((sv) => ( - - {statusMeta("severity", sv).label} - - ))} - - + + + + + + {SEVERITIES.map((sv) => ( + + {statusMeta("severity", sv).label} + + ))} + + + + )} - {/* 处理状态(可改) */} + {/* 处理状态 */} - changeStatus(v as FindingStatus)} > - - - - {FINDING_STATUSES.map((st) => ( - - {statusMeta("finding", st).label} - - ))} - - + + + + + + {FINDING_STATUSES.map((st) => ( + + {statusMeta("finding", st).label} + + ))} + + + + )} {/* 漏洞类型 */} diff --git a/web/src/app/(main)/function/findings/page.tsx b/web/src/app/(main)/function/findings/page.tsx index f857716..0ba7988 100644 --- a/web/src/app/(main)/function/findings/page.tsx +++ b/web/src/app/(main)/function/findings/page.tsx @@ -1,47 +1,63 @@ "use client"; import * as React from "react"; -import Link from "next/link"; -import { - ChevronRightIcon, - ShieldAlertIcon, - ArrowUpRightIcon, - FileTextIcon, -} from "lucide-react"; -import { StatusBadge } from "@/components/status-badge"; +import Link from "next/link"; + +import { + ArrowUpRightIcon, + BugIcon, + ChevronRightIcon, + ClockIcon, + DownloadIcon, + FileTextIcon, + FlaskConicalIcon, + InfoIcon, + ShieldAlertIcon, + Trash2Icon, + TriangleAlertIcon, +} from "lucide-react"; +import { toast } from "sonner"; + +import { CopyButton } from "@/components/copy-button"; import { Markdown } from "@/components/markdown"; +import { StatusBadge } from "@/components/status-badge"; +import { TablePagination } from "@/components/table-pagination"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { TablePagination } from "@/components/table-pagination"; -import { cn } from "@/lib/utils"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Spinner } from "@/components/ui/spinner"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Textarea } from "@/components/ui/textarea"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { api } from "@/lib/api"; -import { toast } from "sonner"; import { statusMeta } from "@/lib/status"; -import type { Finding, FindingStats, FindingStatus, Severity } from "@/lib/types"; +import type { Finding, FindingGroup, FindingStats, FindingStatus, Severity } from "@/lib/types"; +import { cn } from "@/lib/utils"; const SEVERITIES: Severity[] = ["critical", "high", "medium", "low"]; @@ -56,6 +72,33 @@ const FINDING_STATUSES: FindingStatus[] = [ "risk_accepted", ]; +const UNASSIGNED_TASK = "__unassigned__"; + +interface GroupFindingsState { + items: Finding[]; + total: number; + page: number; + pageSize: number; + loaded: boolean; + loading: boolean; +} + +function findingGroupKey(group: FindingGroup) { + return group.task_id === null ? UNASSIGNED_TASK : String(group.task_id); +} + +// 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. +function findingRowKey(finding: Finding): string { + if (finding.finding_id) return `finding:${finding.finding_id}`; + return `node:${finding.task_id ?? UNASSIGNED_TASK}:${finding.id}`; +} + +function isSameFinding(left: Finding, right: Finding): boolean { + return findingRowKey(left) === findingRowKey(right); +} + const EMPTY_STATS: FindingStats = { total: 0, pending: 0, @@ -83,38 +126,238 @@ export default function FindingsPage() { const [task, setTask] = React.useState("all"); const [sort, setSort] = React.useState<"severity" | "time">("severity"); const [expanded, setExpanded] = React.useState(null); - const [findings, setFindings] = React.useState([]); + const [groups, setGroups] = React.useState([]); + const [groupTotal, setGroupTotal] = React.useState(0); + const [expandedGroups, setExpandedGroups] = React.useState>(() => new Set()); + const [groupFindings, setGroupFindings] = React.useState>({}); const [total, setTotal] = React.useState(0); const [stats, setStats] = React.useState(EMPTY_STATS); const [page, setPage] = React.useState(1); - const [pageSize, setPageSize] = React.useState(20); + const [pageSize, setPageSize] = React.useState(10); + const [deepenFinding, setDeepenFinding] = React.useState(null); + const [deepenDescription, setDeepenDescription] = React.useState(""); + const [deepening, setDeepening] = React.useState(false); + const filterFingerprint = `${severity}:${status}:${vulnclass}:${task}:${sort}`; + const activeFilterFingerprint = React.useRef(filterFingerprint); + activeFilterFingerprint.current = filterFingerprint; + + const setFindings = React.useCallback((update: (current: Finding[]) => Finding[]) => { + setGroupFindings((current) => { + const next: Record = {}; + for (const [key, state] of Object.entries(current)) { + next[key] = { ...state, items: update(state.items) }; + } + return next; + }); + }, []); + + // 勾选导出:按 finding_id(独立表 id)记选中项,跨页保留。 + const [selectedIds, setSelectedIds] = React.useState>(() => new Set()); + // 导出弹窗状态:范围(当前筛选/全部/选中) × 格式(md 单文件/md 分文件 zip/csv/json)。 + const [exportOpen, setExportOpen] = React.useState(false); + const [exportScope, setExportScope] = React.useState<"filtered" | "all" | "selected">("filtered"); + const [exportFormat, setExportFormat] = React.useState<"md-single" | "md-zip" | "csv" | "json">("md-single"); + const [exporting, setExporting] = React.useState(false); + + const toggleSelected = React.useCallback((id: string, checked: boolean) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (checked) next.add(id); + else next.delete(id); + return next; + }); + }, []); + + const toggleSelectedPage = React.useCallback((ids: string[], checked: boolean) => { + setSelectedIds((prev) => { + const next = new Set(prev); + for (const id of ids) { + if (checked) next.add(id); + else next.delete(id); + } + return next; + }); + }, []); + + // 打开导出弹窗时,若有勾选项则默认范围切到「选中」,否则「当前筛选」。 + function openExport() { + setExportScope(selectedIds.size > 0 ? "selected" : "filtered"); + setExportOpen(true); + } + + async function doExport() { + setExporting(true); + try { + await api.exportFindings({ + format: exportFormat, + scope: exportScope, + filters: { severity, status, vulnclass, task, sort }, + ids: [...selectedIds], + }); + setExportOpen(false); + toast.success("已开始下载导出文件"); + } catch (e) { + toast.error(`导出失败:${(e as Error).message}`); + } finally { + setExporting(false); + } + } + + const groupRequests = React.useRef>({}); + const groupsRequest = React.useRef(0); + const expandedGroupsRef = React.useRef(expandedGroups); + const groupFindingsRef = React.useRef(groupFindings); + const visibleGroupKeysRef = React.useRef>(new Set()); + expandedGroupsRef.current = expandedGroups; + groupFindingsRef.current = groupFindings; + visibleGroupKeysRef.current = new Set(groups.map(findingGroupKey)); + + const refreshGroups = React.useCallback(async () => { + const requestFilter = filterFingerprint; + if (activeFilterFingerprint.current !== requestFilter) return; + const request = ++groupsRequest.current; + try { + const result = await api.findingGroups({ + page, + pageSize, + severity, + status, + vulnclass, + task, + sort, + }); + if (request !== groupsRequest.current || activeFilterFingerprint.current !== requestFilter) return; + setGroups(result.items); + setGroupTotal(result.total); + setTotal(result.finding_total); + } catch { + // Polling keeps the last successful snapshot visible. + } + }, [filterFingerprint, page, pageSize, severity, status, vulnclass, task, sort]); + + const loadGroup = React.useCallback( + async (key: string, groupPage: number, groupPageSize: number) => { + const request = (groupRequests.current[key] ?? 0) + 1; + const requestFilter = filterFingerprint; + if (activeFilterFingerprint.current !== requestFilter) return; + groupRequests.current[key] = request; + setGroupFindings((current) => ({ + ...current, + [key]: { + items: current[key]?.items ?? [], + total: current[key]?.total ?? 0, + page: groupPage, + pageSize: groupPageSize, + loaded: current[key]?.loaded ?? false, + loading: true, + }, + })); + try { + const result = await api.findingsPage({ + page: groupPage, + pageSize: groupPageSize, + severity, + status, + vulnclass, + task: key, + sort, + }); + if (groupRequests.current[key] !== request || activeFilterFingerprint.current !== requestFilter) return; + setGroupFindings((current) => ({ + ...current, + [key]: { + items: result.items, + total: result.total, + page: result.page, + pageSize: result.page_size, + loaded: true, + loading: false, + }, + })); + } catch { + if (groupRequests.current[key] !== request || activeFilterFingerprint.current !== requestFilter) return; + setGroupFindings((current) => ({ + ...current, + [key]: { + ...(current[key] ?? { + items: [], + total: 0, + page: groupPage, + pageSize: groupPageSize, + loaded: false, + }), + loading: false, + }, + })); + } + }, + [filterFingerprint, severity, status, vulnclass, sort], + ); - // reset to page 1 whenever filters change React.useEffect(() => { + for (const [key, state] of Object.entries(groupFindings)) { + if (!state.loaded || state.loading) continue; + const lastPage = Math.max(1, Math.ceil(state.total / state.pageSize)); + if (state.page > lastPage) void loadGroup(key, lastPage, state.pageSize); + } + }, [groupFindings, loadGroup]); + + const toggleGroup = React.useCallback( + (key: string) => { + const opening = !expandedGroups.has(key); + const next = new Set(expandedGroups); + if (opening) next.add(key); + else next.delete(key); + setExpandedGroups(next); + const state = groupFindings[key]; + if (opening && !state?.loaded && !state?.loading) { + void loadGroup(key, state?.page ?? 1, state?.pageSize ?? 10); + } + }, + [expandedGroups, groupFindings, loadGroup], + ); + + const reloadGroupForFinding = React.useCallback( + (finding: Finding, removed = false) => { + const key = finding.task_id ?? UNASSIGNED_TASK; + const state = groupFindings[key]; + if (state?.loaded) { + const nextTotal = Math.max(0, state.total - (removed ? 1 : 0)); + const lastPage = Math.max(1, Math.ceil(nextTotal / state.pageSize)); + void loadGroup(key, Math.min(state.page, lastPage), state.pageSize); + } + }, + [groupFindings, loadGroup], + ); + + // Reset both pagination levels when a shared finding filter changes. + React.useEffect(() => { + void filterFingerprint; setPage(1); - }, [severity, status, vulnclass, task, sort]); + setExpanded(null); + setExpandedGroups(new Set()); + setGroupFindings({}); + }, [filterFingerprint]); - // Server-driven list: paging + filtering + sorting all happen in SQL. Polls the - // current page every 5s, and refetches immediately when any query input changes. + // Task-group paging is independent from every expanded group's finding page. React.useEffect(() => { - let alive = true; - const load = () => { - api - .findingsPage({ page, pageSize, severity, status, vulnclass, task, sort }) - .then((res) => { - if (!alive) return; - setFindings(res.items); - setTotal(res.total); - }) - .catch(() => {}); + const refresh = () => { + void refreshGroups(); + for (const key of expandedGroupsRef.current) { + if (!visibleGroupKeysRef.current.has(key)) continue; + const state = groupFindingsRef.current[key]; + if (state?.loaded && !state.loading) void loadGroup(key, state.page, state.pageSize); + } }; - load(); - const t = setInterval(load, 5000); - return () => { - alive = false; - clearInterval(t); - }; - }, [page, pageSize, severity, status, vulnclass, task, sort]); + refresh(); + const timer = setInterval(refresh, 5000); + return () => clearInterval(timer); + }, [loadGroup, refreshGroups]); + + React.useEffect(() => { + const lastPage = Math.max(1, Math.ceil(groupTotal / pageSize)); + if (page > lastPage) setPage(lastPage); + }, [groupTotal, page, pageSize]); // Whole-table aggregates (stat cards + vuln-class options) — independent of the // current page, so they stay exact. @@ -126,7 +369,9 @@ export default function FindingsPage() { .then((s) => { if (alive) setStats(s); }) - .catch(() => {}); + .catch(() => { + // Keep the previous aggregate snapshot until the next poll. + }); }; load(); const t = setInterval(load, 5000); @@ -141,63 +386,60 @@ export default function FindingsPage() { async (f: Finding, next: FindingStatus) => { if (!f.finding_id || next === f.status) return; const prev = f.status; - setFindings((cur) => - cur.map((x) => (x.id === f.id ? { ...x, status: next } : x)), - ); + setFindings((cur) => cur.map((x) => (isSameFinding(x, f) ? { ...x, status: next } : x))); try { await api.setFindingStatus(f.finding_id, next); toast.success(`已标记为「${statusMeta("finding", next).label}」`); // refresh stat cards (pending count) and drop the row if it no longer matches the status filter - api.findingStats().then(setStats).catch(() => {}); + api + .findingStats() + .then(setStats) + .catch(() => { + // The row update remains valid even if the aggregate refresh fails. + }); if (status !== "all" && next !== status) { - setFindings((cur) => cur.filter((x) => x.id !== f.id)); + setFindings((cur) => cur.filter((x) => !isSameFinding(x, f))); setTotal((t) => Math.max(0, t - 1)); } + void refreshGroups(); + reloadGroupForFinding(f); } catch (e) { - setFindings((cur) => - cur.map((x) => (x.id === f.id ? { ...x, status: prev } : x)), - ); - toast.error("更新失败:" + (e as Error).message); + setFindings((cur) => cur.map((x) => (isSameFinding(x, f) ? { ...x, status: prev } : x))); + toast.error(`更新失败:${(e as Error).message}`); } }, - [status], + [reloadGroupForFinding, refreshGroups, setFindings, status], ); - // 行内展开的详细报告缓存,按行 key(f.id)存。report 是大段 Markdown,列表查询不带它, + // 行内展开的详细报告缓存按全局稳定行键存。report 是大段 Markdown,列表查询不带它, // 故展开时才按 finding_id 单独拉取一次;done 且文本为空 = 该漏洞暂无报告。 - const [reports, setReports] = React.useState< - Record - >({}); + const [reports, setReports] = React.useState>( + {}, + ); // 行内可编辑缓冲:当前展开行的名称/类别/严重等级,展开时用该行数据初始化,收起清空。 // 单行展开,故一份缓冲即可。 - const [edit, setEdit] = React.useState< - { name: string; vulnclass: string; severity: Severity } | null - >(null); + const [edit, setEdit] = React.useState<{ name: string; vulnclass: string; severity: Severity } | null>(null); const [saving, setSaving] = React.useState(false); // toggle 展开/收起一行;新展开时初始化编辑缓冲,并(尚未取过时)按 finding_id 拉一次报告缓存。 const toggleRow = React.useCallback( (f: Finding) => { - const willOpen = expanded !== f.id; - setExpanded(willOpen ? f.id : null); + const key = findingRowKey(f); + const willOpen = expanded !== key; + setExpanded(willOpen ? key : null); if (!willOpen) { setEdit(null); return; } - setEdit({ name: f.name ?? "", vulnclass: f.vulnclass ?? "", severity: f.severity }); - if (!f.finding_id || reports[f.id]) return; + setEdit({ name: f.name ?? "", vulnclass: f.vulnclass, severity: f.severity }); + if (!f.finding_id || reports[key]) return; const fid = f.finding_id; - const key = f.id; setReports((r) => ({ ...r, [key]: { status: "loading", text: "" } })); api .getFinding(fid) - .then((full) => - setReports((r) => ({ ...r, [key]: { status: "done", text: full.report ?? "" } })), - ) - .catch(() => - setReports((r) => ({ ...r, [key]: { status: "error", text: "" } })), - ); + .then((full) => setReports((r) => ({ ...r, [key]: { status: "done", text: full.report ?? "" } }))) + .catch(() => setReports((r) => ({ ...r, [key]: { status: "error", text: "" } }))); }, [expanded, reports], ); @@ -215,29 +457,88 @@ export default function FindingsPage() { }); setFindings((cur) => cur.map((x) => - x.id === f.id + isSameFinding(x, f) ? { ...x, name: updated.name, vulnclass: updated.vulnclass, severity: updated.severity } : x, ), ); toast.success("已保存"); - api.findingStats().then(setStats).catch(() => {}); + api + .findingStats() + .then(setStats) + .catch(() => { + // The edit remains valid even if the aggregate refresh fails. + }); + void refreshGroups(); + reloadGroupForFinding(f); } catch (e) { - toast.error("保存失败:" + (e as Error).message); + toast.error(`保存失败:${(e as Error).message}`); } finally { setSaving(false); } }, - [edit], + [edit, reloadGroupForFinding, refreshGroups, setFindings], ); - const statCards: { label: string; value: number; tone?: string }[] = [ - { label: "发现总数", value: stats.total }, - { label: "待处理", value: stats.pending, tone: "text-amber-500" }, - { label: "严重", value: stats.critical, tone: "text-rose-600" }, - { label: "高危", value: stats.high, tone: "text-red-500" }, - { label: "中危", value: stats.medium, tone: "text-amber-500" }, - { label: "低危", value: stats.low, tone: "text-slate-500" }, + // deleteFinding 删除一个漏洞(需二次确认):删成功后从列表移除、收起行、刷新统计。 + const deleteFinding = React.useCallback( + async (f: Finding) => { + if (!f.finding_id) return; + try { + await api.deleteFinding(f.finding_id); + setFindings((cur) => cur.filter((x) => !isSameFinding(x, f))); + setSelectedIds((current) => { + const next = new Set(current); + next.delete(f.finding_id as string); + return next; + }); + setTotal((t) => Math.max(0, t - 1)); + const rowKey = findingRowKey(f); + setExpanded((cur) => (cur === rowKey ? null : cur)); + toast.success("已删除漏洞"); + api + .findingStats() + .then(setStats) + .catch(() => { + // The deletion remains valid even if the aggregate refresh fails. + }); + void refreshGroups(); + reloadGroupForFinding(f, true); + } catch (e) { + toast.error(`删除失败:${(e as Error).message}`); + } + }, + [reloadGroupForFinding, refreshGroups, setFindings], + ); + + async function submitDeepen() { + if (!deepenFinding?.finding_id || !deepenDescription.trim() || deepening) return; + setDeepening(true); + try { + const result = await api.deepenFinding(deepenFinding.finding_id, deepenDescription.trim()); + toast.success( + result.queued + ? `深入意图 #${result.intent_id} 已进入任务队列` + : `已创建高优先级 Worker 意图 #${result.intent_id}`, + ); + setDeepenFinding(null); + setDeepenDescription(""); + reloadGroupForFinding(deepenFinding); + void refreshGroups(); + } catch (error) { + toast.error(`提交失败:${(error as Error).message}`); + } finally { + setDeepening(false); + } + } + + const statCards = [ + { label: "发现总数", value: stats.total, icon: BugIcon }, + { label: "待处理", value: stats.pending, tone: "text-amber-500", icon: ClockIcon }, + { label: "严重", value: stats.critical, tone: "text-rose-600", icon: ShieldAlertIcon }, + { label: "高危", value: stats.high, tone: "text-red-500", icon: TriangleAlertIcon }, + { label: "中危", value: stats.medium, tone: "text-amber-500", icon: TriangleAlertIcon }, + { label: "低危", value: stats.low, tone: "text-slate-500", icon: InfoIcon }, ]; return ( @@ -248,20 +549,31 @@ export default function FindingsPage() {
- {statCards.map((s) => ( - - - {s.label} - - {s.value} - - - - ))} + {statCards.map((stat) => { + const StatIcon = stat.icon; + return ( + + + {stat.label} + + + + + ); + })}
-
+ value && setSeverity(value as "all" | Severity)} + variant="outline" + size="sm" + spacing={0} + > {( [ ["all", "全部"], @@ -271,26 +583,13 @@ export default function FindingsPage() { ["low", "低危"], ] as const ).map(([val, label]) => ( - + ))} -
+ - setStatus(v as "all" | FindingStatus)}> @@ -324,6 +623,7 @@ export default function FindingsPage() { 全部任务 + 未关联 / 任务已删除 {(stats.tasks ?? []).map((t) => { const id = String(t.id); const label = t.name || t.description || `任务 #${id}(已删除)`; @@ -333,7 +633,8 @@ export default function FindingsPage() { {label} - + + @@ -343,10 +644,7 @@ export default function FindingsPage() { - setSort(v as "severity" | "time")}> @@ -356,16 +654,16 @@ export default function FindingsPage() { - - 共 {total} 条 - +
+ {selectedIds.size > 0 && ( + 已选 {selectedIds.size} 条 + )} + +
-<<<<<<< Updated upstream - - - {/* table-fixed:列宽由表头锁定,展开行那个 colSpan 单元格再宽也只能在固定宽度内 -=======
{groups.map((group) => { const key = findingGroupKey(group); @@ -452,288 +750,489 @@ export default function FindingsPage() { ) : ( <> {/* table-fixed:列宽由表头锁定,展开行那个 colSpan 单元格再宽也只能在固定宽度内 ->>>>>>> Stashed changes 换行/内部滚动,不会把整张表撑出横向滚动条。 */} - - - - - 严重度 - 漏洞名称 - 资产 - 状态 - 所属任务 - 时间 - - - - {findings.map((f) => { - const open = expanded === f.id; - return ( - - toggleRow(f)} - > - - - - - - - -
- {f.finding_id ? ( - e.stopPropagation()} - className="truncate font-medium hover:text-primary hover:underline" - title="查看发现详情" - > - {f.name || f.vulnclass || "未分类"} - - ) : ( - - {f.name || f.vulnclass || "未分类"} - - )} - - {f.summary} - -
-
- - {f.assets && f.assets.length > 0 ? ( -
- {f.assets.slice(0, 3).map((a) => ( - - {a.label} - - ))} - {f.assets.length > 3 && ( - - +{f.assets.length - 3} - - )} -
- ) : ( - — - )} -
- e.stopPropagation()}> - {f.finding_id ? ( - - ) : ( - - )} - - - {f.task_id ? ( - e.stopPropagation()} - className="inline-flex max-w-full items-center gap-1 text-primary hover:underline" - title={f.task_description} - > - - {f.task_description} - - - - ) : ( - — - )} - - - {fmtTime(f.ts)} - -
- {open && ( - - {/* whitespace-normal 覆盖 TableCell 默认的 nowrap,否则展开区文字 - 被强制单行、直接溢出单元格。 */} - -
- {/* 行内编辑:名称/类别/严重等级,可改并保存(仅独立 finding 行)。 */} - {f.finding_id && edit && ( -
-
- - - setEdit((s) => (s ? { ...s, name: e.target.value } : s)) - } - placeholder="可读标题,留空回退类别" - /> -
-
- - - setEdit((s) => (s ? { ...s, vulnclass: e.target.value } : s)) - } - placeholder="如 SQL Injection" - /> -
-
- - -
- -
- )} -
- - 证据 - {f.vulnclass && ( - - · 类型: - - {f.vulnclass} - - - )} - {f.param_id && ( - - {f.param_id} - - )} - {f.assets && f.assets.length > 0 && ( - - · 资产: - {f.assets.map((a) => ( - - {a.label} - - ))} - - )} -
-
-                                {f.evidence}
-                              
- - {/* 详细报告(Markdown):展开时按 finding_id 懒加载,免进详情页即可查看。 */} - {f.finding_id && ( -
-
- - 详细报告 -
- {(() => { - const rep = reports[f.id]; - if (!rep || rep.status === "loading") - return ( -

加载中…

- ); - if (rep.status === "error") - return ( -

- 报告加载失败。 -

- ); - if (!rep.text.trim()) - return ( -

- 暂无详细报告。 -

- ); - return ( - // break-words 会继承到段落/列表,pre 另加 - // whitespace-pre-wrap 让代码块也换行——否则长代码行/长 URL - // 会撑宽 colSpan 单元格,把整张表挤出横向滚动条。 -
- + + + 严重度 + 漏洞名称 + 资产 + 状态 + 所属任务 + 时间 + 操作 + + + + {state.items.map((f) => { + const rowKey = findingRowKey(f); + const open = expanded === rowKey; + return ( + + toggleRow(f)} + onKeyDown={(event) => { + if ( + event.target !== event.currentTarget || + (event.key !== "Enter" && event.key !== " ") + ) + return; + event.preventDefault(); + toggleRow(f); + }} + > + e.stopPropagation()}> + {f.finding_id && ( + toggleSelected(f.finding_id as string, c === true)} + aria-label="选择该漏洞" + /> + )} + + + + + + + + +
+ {f.finding_id ? ( + e.stopPropagation()} + className="truncate font-medium hover:text-primary hover:underline" + title="查看发现详情" + > + {f.name || f.vulnclass || "未分类"} + + ) : ( + + {f.name || f.vulnclass || "未分类"} + + )} + {f.summary}
- ); - })()} -
- )} -
- - - )} - - ); - })} - {findings.length === 0 && ( - - - 没有匹配的发现。 - - + + + {f.assets && f.assets.length > 0 ? ( +
+ {f.assets.slice(0, 3).map((a) => ( + + {a.label} + + ))} + {f.assets.length > 3 && ( + + +{f.assets.length - 3} + + )} +
+ ) : ( + — + )} +
+ e.stopPropagation()}> + {f.finding_id ? ( + + ) : ( + + )} + + + {f.task_id ? ( + e.stopPropagation()} + className="inline-flex max-w-full items-center gap-1 text-primary hover:underline" + title={f.task_description} + > + {f.task_description} + + + ) : ( + — + )} + + + {fmtTime(f.ts)} + + e.stopPropagation()}> +
+ {f.finding_id && f.task_id && ( + + )} + {f.finding_id && ( + + + + + + + 确认删除该漏洞? + + 「 + + {f.name || f.vulnclass || f.summary || `#${f.finding_id}`} + + 」将被永久删除, 同时从发现列表、任务发现 Tab + 与探索图中移除,此操作不可撤销。 + + + + 取消 + deleteFinding(f)}> + 删除 + + + + + )} +
+
+ + {open && ( + + {/* whitespace-normal 覆盖 TableCell 默认的 nowrap,否则展开区文字 + 被强制单行、直接溢出单元格。 */} + +
+ {/* 行内编辑:名称/类别/严重等级,可改并保存(仅独立 finding 行)。 */} + {f.finding_id && edit && ( +
+
+ + + setEdit((s) => (s ? { ...s, name: e.target.value } : s)) + } + placeholder="可读标题,留空回退类别" + /> +
+
+ + + setEdit((s) => (s ? { ...s, vulnclass: e.target.value } : s)) + } + placeholder="如 SQL Injection" + /> +
+
+ + +
+ +
+ )} +
+ + 证据 + {f.vulnclass && ( + + · 类型: + + {f.vulnclass} + + + )} + {f.param_id && ( + + {f.param_id} + + )} + {f.assets && f.assets.length > 0 && ( + + · 资产: + {f.assets.map((a) => ( + + {a.label} + + ))} + + )} +
+
+                                            {f.evidence}
+                                          
+ + {/* 详细报告(Markdown):展开时按 finding_id 懒加载,免进详情页即可查看。 */} + {f.finding_id && ( +
+
+ + + 详细报告 + + {reports[rowKey]?.status === "done" && reports[rowKey]?.text.trim() && ( + + )} +
+ {(() => { + const rep = reports[rowKey]; + if (!rep || rep.status === "loading") + return

加载中…

; + if (rep.status === "error") + return ( +

报告加载失败。

+ ); + if (!rep.text.trim()) + return ( +

暂无详细报告。

+ ); + return ( + // break-words 会继承到段落/列表,pre 另加 + // whitespace-pre-wrap 让代码块也换行——否则长代码行/长 URL + // 会撑宽 colSpan 单元格,把整张表挤出横向滚动条。 +
+ +
+ ); + })()} +
+ )} +
+
+
+ )} + + ); + })} + {state.items.length === 0 && ( + + + 没有匹配的发现。 + + + )} + +
+ void loadGroup(key, nextPage, state.pageSize)} + onPageSizeChange={(nextSize) => void loadGroup(key, 1, nextSize)} + /> + + )} + )} - - - - - + + ); + })} + {groups.length === 0 && ( + + 没有匹配的发现。 + + )} + { + setPageSize(nextPageSize); + setPage(1); + }} + pageSizeOptions={[5, 10, 20]} + /> +
+ + { + if (open || deepening) return; + setDeepenFinding(null); + setDeepenDescription(""); + }} + > + + + 深入利用漏洞 + + 将在原任务 #{deepenFinding?.task_id} 中创建优先级 10 的 Worker 意图,基于当前漏洞开展二次验证: + {deepenFinding?.name || deepenFinding?.vulnclass || deepenFinding?.summary} + + + + + 利用描述 +