feat(findings): group findings and add deepen workflows
This commit is contained in:
1 parent
31dc9e7446
commit
27dc6d8bad
9 files changed
+2098
-502
No files matched your search
+51
-8
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<Finding | null>(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() {
|
||||
<Separator orientation="vertical" className="mx-1 h-4" />
|
||||
<StatusBadge domain="severity" value={finding.severity} dot />
|
||||
<StatusBadge domain="finding" value={finding.status} dot />
|
||||
{finding.inherited && finding.source_task_id && (
|
||||
<Badge variant="outline">
|
||||
来源任务 #{finding.source_task_id} · 只读
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">概览</TabsTrigger>
|
||||
@@ -200,8 +209,11 @@ function FindingDetailInner() {
|
||||
</Card>
|
||||
{/* 证据下方:详细报告(Markdown 渲染) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardHeader className="flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm">详细报告</CardTitle>
|
||||
{finding.report && (
|
||||
<CopyButton text={finding.report} successMessage="已复制详细报告" />
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{finding.report ? (
|
||||
@@ -228,48 +240,60 @@ function FindingDetailInner() {
|
||||
</code>
|
||||
</FieldRow>
|
||||
|
||||
{/* 严重等级(可改) */}
|
||||
{/* 严重等级 */}
|
||||
<FieldRow label="严重等级">
|
||||
<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"
|
||||
{finding.inherited ? (
|
||||
<StatusBadge domain="severity" value={finding.severity} dot />
|
||||
) : (
|
||||
<Select
|
||||
value={finding.severity}
|
||||
onValueChange={(v) => changeSeverity(v as Severity)}
|
||||
>
|
||||
<StatusBadge domain="severity" value={finding.severity} dot />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" align="end">
|
||||
{SEVERITIES.map((sv) => (
|
||||
<SelectItem key={sv} value={sv}>
|
||||
{statusMeta("severity", sv).label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<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="处理状态">
|
||||
<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"
|
||||
{finding.inherited ? (
|
||||
<StatusBadge domain="finding" value={finding.status} dot />
|
||||
) : (
|
||||
<Select
|
||||
value={finding.status}
|
||||
onValueChange={(v) => changeStatus(v as FindingStatus)}
|
||||
>
|
||||
<StatusBadge domain="finding" value={finding.status} dot />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" align="end">
|
||||
{FINDING_STATUSES.map((st) => (
|
||||
<SelectItem key={st} value={st}>
|
||||
{statusMeta("finding", st).label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<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>
|
||||
|
||||
{/* 漏洞类型 */}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -5,10 +5,12 @@ import Link from "next/link";
|
||||
import { ArrowUpRightIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
@@ -30,9 +32,11 @@ const FINDING_STATUSES: FindingStatus[] = [
|
||||
|
||||
function Row({
|
||||
f,
|
||||
contextTaskId,
|
||||
onStatus,
|
||||
}: {
|
||||
f: Finding;
|
||||
contextTaskId: string;
|
||||
onStatus: (f: Finding, next: FindingStatus) => void;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
@@ -51,10 +55,17 @@ function Row({
|
||||
)}
|
||||
/>
|
||||
<StatusBadge domain="severity" value={f.severity} dot />
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate font-medium">
|
||||
{f.name || f.vulnclass || "未分类"}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-medium">
|
||||
{f.name || f.vulnclass || "未分类"}
|
||||
</span>
|
||||
{f.inherited && f.source_task_id && (
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
来源 #{f.source_task_id} · 只读
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{f.summary}
|
||||
</span>
|
||||
@@ -78,7 +89,7 @@ function Row({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{f.finding_id ? (
|
||||
{f.finding_id && !f.inherited ? (
|
||||
<Select
|
||||
value={f.status}
|
||||
onValueChange={(v) => onStatus(f, v as FindingStatus)}
|
||||
@@ -90,11 +101,13 @@ function Row({
|
||||
<StatusBadge domain="finding" value={f.status} dot />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" align="end">
|
||||
{FINDING_STATUSES.map((st) => (
|
||||
<SelectItem key={st} value={st}>
|
||||
{statusMeta("finding", st).label}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectGroup>
|
||||
{FINDING_STATUSES.map((st) => (
|
||||
<SelectItem key={st} value={st}>
|
||||
{statusMeta("finding", st).label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
@@ -105,7 +118,11 @@ function Row({
|
||||
</span>
|
||||
{f.finding_id && (
|
||||
<Link
|
||||
href={`/function/findings/detail?id=${f.finding_id}`}
|
||||
href={
|
||||
f.inherited
|
||||
? `/function/findings/detail?id=${f.finding_id}&context_task=${contextTaskId}`
|
||||
: `/function/findings/detail?id=${f.finding_id}`
|
||||
}
|
||||
className="text-muted-foreground hover:text-primary inline-flex shrink-0 items-center gap-0.5 text-xs"
|
||||
title="查看漏洞详情"
|
||||
>
|
||||
@@ -151,7 +168,7 @@ export function FindingsTab({ taskId }: { taskId: string }) {
|
||||
|
||||
const onStatus = React.useCallback(
|
||||
async (f: Finding, next: FindingStatus) => {
|
||||
if (!f.finding_id || next === f.status) return;
|
||||
if (f.inherited || !f.finding_id || next === f.status) return;
|
||||
const prev = f.status;
|
||||
setFindings((cur) =>
|
||||
cur.map((x) => (x.id === f.id ? { ...x, status: next } : x)),
|
||||
@@ -170,7 +187,7 @@ export function FindingsTab({ taskId }: { taskId: string }) {
|
||||
);
|
||||
|
||||
const items = findings
|
||||
.filter((f) => f.task_id === taskId)
|
||||
.filter((f) => f.task_id === taskId || f.inherited)
|
||||
.sort((a, b) => {
|
||||
const order = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||
return order[a.severity] - order[b.severity];
|
||||
@@ -180,11 +197,11 @@ export function FindingsTab({ taskId }: { taskId: string }) {
|
||||
<Card className="overflow-hidden py-0">
|
||||
<CardContent className="px-0">
|
||||
{items.map((f) => (
|
||||
<Row key={f.id} f={f} onStatus={onStatus} />
|
||||
<Row key={f.id} f={f} contextTaskId={taskId} onStatus={onStatus} />
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
本任务暂无确认发现。
|
||||
本任务及直接关联任务暂无确认发现。
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { FileTextIcon, CopyIcon, CheckIcon } from "lucide-react";
|
||||
|
||||
import { CheckIcon, CopyIcon, FileTextIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Markdown } from "@/components/markdown";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { api } from "@/lib/api";
|
||||
import { copyText } from "@/lib/utils";
|
||||
|
||||
export function ReportTab({ taskId }: { taskId: string }) {
|
||||
const [report, setReport] = React.useState<string>("");
|
||||
@@ -31,12 +35,39 @@ export function ReportTab({ taskId }: { taskId: string }) {
|
||||
};
|
||||
}, [taskId]);
|
||||
|
||||
function copy() {
|
||||
async function copy() {
|
||||
if (!report) return;
|
||||
navigator.clipboard?.writeText(report);
|
||||
setCopied(true);
|
||||
toast.success("已复制 Markdown");
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
const ok = await copyText(report);
|
||||
if (ok) {
|
||||
setCopied(true);
|
||||
toast.success("已复制 Markdown");
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} else {
|
||||
toast.error("复制失败,请手动选择文本复制");
|
||||
}
|
||||
}
|
||||
|
||||
let content: React.ReactNode;
|
||||
if (loading) {
|
||||
content = (
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-md border border-dashed py-16 text-muted-foreground text-sm">
|
||||
<FileTextIcon className="size-8 opacity-40" />
|
||||
加载中…
|
||||
</div>
|
||||
);
|
||||
} else if (report) {
|
||||
content = (
|
||||
<div className="max-h-[60vh] overflow-auto rounded-md border bg-muted/20 p-4">
|
||||
<Markdown text={report} />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-md border border-dashed py-16 text-muted-foreground text-sm">
|
||||
<FileTextIcon className="size-8 opacity-40" />
|
||||
暂无报告
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -53,23 +84,7 @@ export function ReportTab({ taskId }: { taskId: string }) {
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-md border border-dashed py-16 text-sm text-muted-foreground">
|
||||
<FileTextIcon className="size-8 opacity-40" />
|
||||
加载中…
|
||||
</div>
|
||||
) : report ? (
|
||||
<pre className="max-h-[60vh] overflow-auto rounded-md border bg-muted/40 p-4 font-mono text-xs whitespace-pre-wrap">
|
||||
{report}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-md border border-dashed py-16 text-sm text-muted-foreground">
|
||||
<FileTextIcon className="size-8 opacity-40" />
|
||||
暂无报告
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardContent>{content}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user