mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
feat(findings): show consolidation across report task and asset views
This commit is contained in:
1 parent
d68594e81a
commit
d36812b7e1
10 files changed
+627
-462
No files matched your search
@@ -335,19 +335,19 @@ server {
|
||||
|
||||
### 同一漏洞归并与统一报告
|
||||
|
||||
发现页默认「按漏洞」显示。同一任务内,Reporter 依据目标、接口参数、权限边界、根因及验证证据确认同一实际缺陷后形成文件夹;仅类型或名称相同不会作为自动归并依据。严重程度、接口、后端或读写影响不同仍可能是同一漏洞;反之,共用登录入口或令牌不能单独证明同一根因。有关联但共同根因无法确认时提交人工建议。文件夹的 `严/高/中/低` 数量按原始记录统计,例如 `0/1/1/1`;独立漏洞统计采用另行评估的统一等级,尚未评估时显示「待评估」。原始上报内容、评级、状态、报告及证据不被覆盖。
|
||||
发现页默认「原始上报」,原始上报、按漏洞、按任务及按资产视图均展示可展开的归并文件夹。同一任务内,Reporter 依据目标、接口参数、权限边界、根因及验证证据确认同一实际缺陷后形成文件夹;仅类型或名称相同不会作为自动归并依据。严重程度、接口、后端或读写影响不同仍可能是同一漏洞;反之,共用登录入口或令牌不能单独证明同一根因。有关联但共同根因无法确认时提交人工建议。文件夹的 `严/高/中/低` 数量按原始记录统计,例如 `0/1/1/1`;发现页的独立漏洞数按文件夹去重,等级数量按原始上报统计,列表及文件夹详情不显示统一评级标签。原始上报内容、评级、状态、报告及证据不被覆盖。
|
||||
|
||||
点击箭头展开原始记录,点击文件夹标题查看统一报告、原始记录及归并历史。证据不足的建议需要点击「确认归并」;否决或移出后,这些关系不会被 Agent 自动再次归并。移出成员不删除原漏洞,不足两条时解散文件夹并保留历史。继承任务的漏洞组保持只读。
|
||||
|
||||
新上报由现有 Reporter 生成独立报告并检查重复。升级首次补充 Reporter 的归并工具和运行指引,保留自定义提示词及触发设置;已删除或停用的 Reporter 不会重建、启用。历史记录不会自动扫描:展开并选择原始记录,点击「整理所选」,按任务分别排队执行,可查看执行记录及失败信息。同一任务已有排队或运行中的整理时拒绝重复提交。历史整理以只读方式复用原报告,禁止覆盖原报告或补绑证据;已有报告为空或过期时如实说明,单独通过报告撰写流程处理。结束前需提交每条所选记录的根因判断,检查读取范围、建议、统一报告完整性及版本,通过验收才显示完成。文件夹详情可重新生成统一报告;需要启用 Reporter 并配置可用模型。判断和写作会消耗模型 token,候选检索先分页返回摘要,详情证据按需读取,不重新探测目标。
|
||||
|
||||
成员、报告或证据变化后统一报告标记待更新,写入时校验实际读取版本。导出默认每组一份统一报告及未归并记录,可勾选「包含原始子报告」;统一报告未生成或过期时附明确说明及全部原始材料。原始记录、按任务及按资产视图继续保留。升级沿用启动时幂等数据库迁移,无需执行独立 SQL。
|
||||
成员、报告或证据变化后统一报告标记待更新,写入时校验实际读取版本。导出默认每组一份统一报告及未归并记录,可勾选「包含原始子报告」;统一报告未生成或过期时附明确说明及全部原始材料。原始上报显示所属任务、资产及时间;按任务先展开任务,再展开漏洞文件夹;按资产保留资产树,命中任一成员即展示整个文件夹,展开时标明未命中筛选的成员。折叠状态只读取轻量摘要,不加载成员报告。升级沿用启动时幂等数据库迁移,无需执行独立 SQL。
|
||||
|
||||
### 手动漏洞复测
|
||||
|
||||
任务详情的「复测」页签可分页选择本任务的漏洞、查看历次结论和证据,并手动发起复测。启动后保留当前页签,显示转圈图标和「复测中」;确认修复后同步更新漏洞状态。
|
||||
|
||||
在漏洞列表每行操作区点击「复测」,或在漏洞详情的「漏洞复测」区域点击「发起复测」,填写可选的修复版本、测试条件或限制,系统会创建独立的复测 Agent 会话,启动后保留当前页面。列表的平铺、按任务分组和资产视图均支持该入口;复测运行时显示转圈图标和「复测中」,需要查看时点击进入对应会话,结束后恢复「复测」。复测无需重新启动原扫描任务,结论分为「仍可复现」「已修复」「无法确认」,每次的结论、证据和会话链接保存在漏洞详情中。
|
||||
在漏洞列表原始记录的「更多操作」中点击「复测漏洞」,或在漏洞详情的「漏洞复测」区域点击「发起复测」,填写可选的修复版本、测试条件或限制,系统会创建独立的复测 Agent 会话,启动后保留当前页面。列表的平铺、按任务分组和资产视图均支持该入口;复测运行时显示转圈图标和「复测中」,需要查看时点击进入对应会话,结束后恢复「复测」。复测无需重新启动原扫描任务,结论分为「仍可复现」「已修复」「无法确认」,每次的结论、证据和会话链接保存在漏洞详情中。
|
||||
|
||||
新版后端首次启动会预置可编辑的「漏洞复测」(`retester`)Agent,可在 Agent 管理中配置提示词、LLM、运行预算和工具。默认使用其绑定的 LLM,未绑定则使用全局激活配置。复测会话成功完成且结论为「已修复」时,系统自动将漏洞处置状态改为「已修复」;执行中、失败、停止或其他结论保留原状态。原始证据和报告始终保留。也可在状态下拉菜单中手动选择「已修复」。同一漏洞正在复测时复用已有会话,停止、失败或服务重启后可重新发起。
|
||||
|
||||
|
||||
+61
-3
@@ -117,17 +117,17 @@ func (d *DB) ListFindingCases(filter FindingFilter, page, size int) ([]FindingCa
|
||||
return nil, 0, err
|
||||
}
|
||||
where, args := filter.where()
|
||||
base := `WITH matched AS (SELECT f.id,f.severity,f.created_at,COALESCE(m.case_id,0) cid FROM findings f LEFT JOIN tasks t ON t.id=f.task_id LEFT JOIN finding_case_members m ON m.finding_id=f.id` + where + `), entities AS (SELECT CASE WHEN cid>0 THEN 'c:'||cid ELSE 'f:'||id END key,MAX(cid) cid,MIN(id) fid,jsonb_agg(id ORDER BY id) matched_ids,MAX(created_at) created FROM matched GROUP BY key)`
|
||||
base := `WITH matched AS (SELECT f.id,f.severity,f.created_at,COALESCE(m.case_id,0) cid FROM findings f LEFT JOIN tasks t ON t.id=f.task_id LEFT JOIN finding_case_members m ON m.finding_id=f.id` + where + `), entities AS (SELECT CASE WHEN cid>0 THEN 'c:'||cid ELSE 'f:'||id END key,MAX(cid) cid,MIN(id) fid,jsonb_agg(id ORDER BY id) matched_ids,MAX(created_at) created,MAX(CASE severity WHEN 'critical' THEN 4 WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'low' THEN 1 ELSE 0 END) severity_rank FROM matched GROUP BY key)`
|
||||
var total int
|
||||
if err := d.QueryRow(base+` SELECT count(*) FROM entities`, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
order := `e.created DESC,e.fid DESC`
|
||||
if filter.Sort == "severity" {
|
||||
order = `CASE COALESCE(c.severity,f.severity) WHEN 'critical' THEN 4 WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'low' THEN 1 ELSE 0 END DESC,` + order
|
||||
order = `e.severity_rank DESC,` + order
|
||||
}
|
||||
args = append(args, size, (page-1)*size)
|
||||
rows, err := d.Query(base+fmt.Sprintf(` SELECT e.cid,e.fid,e.matched_ids FROM entities e LEFT JOIN finding_cases c ON c.id=e.cid LEFT JOIN findings f ON f.id=e.fid ORDER BY %s LIMIT $%d OFFSET $%d`, order, len(args)-1, len(args)), args...)
|
||||
rows, err := d.Query(base+fmt.Sprintf(` SELECT e.cid,e.fid,e.matched_ids FROM entities e ORDER BY %s LIMIT $%d OFFSET $%d`, order, len(args)-1, len(args)), args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -573,3 +573,61 @@ func (d *DB) FindingCaseSummary(id int64) (*DBFinding, error) {
|
||||
}
|
||||
return fs[0], nil
|
||||
}
|
||||
|
||||
// FindingCaseListContext contains bounded metadata for collapsed folder rows.
|
||||
// Reports and member evidence are still fetched only when opened.
|
||||
type FindingCaseListContext struct {
|
||||
TaskName string
|
||||
TaskDescription string
|
||||
AssetIDs []int64
|
||||
AssetCount int
|
||||
LastFoundAt time.Time
|
||||
}
|
||||
|
||||
func (d *DB) FindingCaseListContexts(ids []int64) (map[int64]FindingCaseListContext, error) {
|
||||
result := map[int64]FindingCaseListContext{}
|
||||
if len(ids) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
raw, err := json.Marshal(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := d.Query(`SELECT c.id,COALESCE(t.name,''),COALESCE(t.description,''),
|
||||
COALESCE((SELECT MAX(f.created_at) FROM finding_case_members m JOIN findings f ON f.id=m.finding_id WHERE m.case_id=c.id),c.created_at),
|
||||
COALESCE((SELECT jsonb_agg(v.id ORDER BY v.id) FROM (
|
||||
SELECT DISTINCT a.id FROM finding_case_members m JOIN findings f ON f.id=m.finding_id
|
||||
CROSS JOIN LATERAL jsonb_array_elements_text(f.asset_ids) e(v) JOIN assets a ON a.id=e.v::bigint
|
||||
WHERE m.case_id=c.id ORDER BY a.id LIMIT 4) v),'[]'::jsonb),
|
||||
(SELECT COUNT(DISTINCT a.id) FROM finding_case_members m JOIN findings f ON f.id=m.finding_id
|
||||
CROSS JOIN LATERAL jsonb_array_elements_text(f.asset_ids) e(v) JOIN assets a ON a.id=e.v::bigint WHERE m.case_id=c.id)
|
||||
FROM finding_cases c LEFT JOIN tasks t ON t.id=c.task_id
|
||||
WHERE c.id IN (SELECT jsonb_array_elements_text($1::jsonb)::bigint)`, string(raw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var c FindingCaseListContext
|
||||
var assets []byte
|
||||
if err := rows.Scan(&id, &c.TaskName, &c.TaskDescription, &c.LastFoundAt, &assets, &c.AssetCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(assets, &c.AssetIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[id] = c
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
func (d *DB) MatchingFindingReportCount(filter FindingFilter) (int, error) {
|
||||
filter, err := d.applyAssetScope(filter)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
where, args := filter.where()
|
||||
var n int
|
||||
err = d.QueryRow(`SELECT COUNT(*) FROM findings f LEFT JOIN tasks t ON t.id=f.task_id`+where, args...).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
@@ -2,7 +2,9 @@ package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
@@ -289,3 +291,67 @@ func TestFindingCaseCandidatesAreCompactAndRelated(t *testing.T) {
|
||||
t.Fatalf("veto excluded %d %v", total, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindingCaseViewContextAndAssetFiltering(t *testing.T) {
|
||||
d, task, ids := caseTestDB(t)
|
||||
if _, err := d.Exec(`UPDATE tasks SET name='Order API' WHERE id=$1`, task.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assets := []int64{}
|
||||
for i := 0; i < 6; i++ {
|
||||
aid := seedTreeAsset(t, d, "service", map[string]any{"url": fmt.Sprintf("https://view-context-%d-%d.example", task.ID, i)})
|
||||
assets = append(assets, aid)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
for _, a := range assets {
|
||||
_, _ = d.Exec(`DELETE FROM assets WHERE id=$1`, a)
|
||||
}
|
||||
})
|
||||
for i, id := range ids[:3] {
|
||||
raw, _ := json.Marshal(assets[i*2 : i*2+2])
|
||||
if _, err := d.Exec(`UPDATE findings SET asset_ids=$2 WHERE id=$1`, id, string(raw)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
cid, err := d.MergeFindingCase(t.Context(), task.ID, ids[:3], "order access", "same permission check", "human")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contexts, err := d.FindingCaseListContexts([]int64{cid})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := contexts[cid]
|
||||
if c.TaskName != "Order API" || c.TaskDescription != task.Description || c.AssetCount != 6 || !reflect.DeepEqual(c.AssetIDs, assets[:4]) || c.LastFoundAt.IsZero() {
|
||||
t.Fatalf("context %+v", c)
|
||||
}
|
||||
filter := FindingFilter{TaskID: fmt.Sprint(task.ID), AssetScope: fmt.Sprintf("a:%d", assets[0])}
|
||||
rows, total, err := d.ListFindingCases(filter, 1, 20)
|
||||
if err != nil || total != 1 || len(rows) != 1 || rows[0].Case == nil || rows[0].Case.ID != cid || !reflect.DeepEqual(rows[0].MatchedIDs, ids[:1]) {
|
||||
t.Fatalf("filtered rows %+v total %d err %v", rows, total, err)
|
||||
}
|
||||
members, n, err := d.FindingCaseMembers(cid, 1, 20)
|
||||
if err != nil || n != 3 || len(members) != 3 {
|
||||
t.Fatalf("all folder members %d %v", n, err)
|
||||
}
|
||||
matches, err := d.MatchingFindingReportCount(filter)
|
||||
if err != nil || matches != 1 {
|
||||
t.Fatalf("matching reports %d %v", matches, err)
|
||||
}
|
||||
rows, total, err = d.ListFindingCases(FindingFilter{TaskID: fmt.Sprint(task.ID), Severity: "medium"}, 1, 20)
|
||||
if err != nil || total != 1 || len(rows) != 1 || rows[0].Case.Count != 3 || rows[0].Case.High != 1 || rows[0].Case.Medium != 1 || rows[0].Case.Low != 1 {
|
||||
t.Fatalf("severity filter %+v %v", rows, err)
|
||||
}
|
||||
matches, err = d.MatchingFindingReportCount(FindingFilter{TaskID: fmt.Sprint(task.ID), Severity: "medium"})
|
||||
if err != nil || matches != 1 {
|
||||
t.Fatalf("severity reports %d %v", matches, err)
|
||||
}
|
||||
// Group has no unified severity yet; it must sort by its original high report.
|
||||
if _, err := d.Exec(`UPDATE findings SET severity='low' WHERE id=$1`, ids[3]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, total, err = d.ListFindingCases(FindingFilter{TaskID: fmt.Sprint(task.ID), Sort: "severity"}, 1, 1)
|
||||
if err != nil || total != 2 || len(rows) != 1 || rows[0].Case == nil || rows[0].Case.ID != cid {
|
||||
t.Fatalf("original severity order %+v %v", rows, err)
|
||||
}
|
||||
}
|
||||
+27
-1
@@ -62,9 +62,30 @@ func (s *Server) listFindingCases(w http.ResponseWriter, r *http.Request) {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
caseIDs := []int64{}
|
||||
for _, row := range rows {
|
||||
if row.Case != nil {
|
||||
caseIDs = append(caseIDs, row.Case.ID)
|
||||
}
|
||||
}
|
||||
contexts, err := s.m.pg.FindingCaseListContexts(caseIDs)
|
||||
if err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
assetIDs := []int64{}
|
||||
for _, c := range contexts {
|
||||
assetIDs = append(assetIDs, c.AssetIDs...)
|
||||
}
|
||||
assets := s.resolveAssetIDs(assetIDs)
|
||||
items := []map[string]any{}
|
||||
for _, row := range rows {
|
||||
v := map[string]any{"case": row.Case, "matched_ids": row.MatchedIDs}
|
||||
if row.Case != nil {
|
||||
c := contexts[row.Case.ID]
|
||||
v["task_name"], v["task_description"], v["last_found_at"], v["asset_count"] = c.TaskName, c.TaskDescription, c.LastFoundAt, c.AssetCount
|
||||
v["assets"] = append([]FindingAssetDTO{}, findingFromDB(&db.DBFinding{AssetIDs: c.AssetIDs}, assets).Assets...)
|
||||
}
|
||||
if row.FindingID > 0 {
|
||||
f, err := s.m.pg.FindingCaseSummary(row.FindingID)
|
||||
if err != nil {
|
||||
@@ -84,7 +105,12 @@ func (s *Server) listFindingCases(w http.ResponseWriter, r *http.Request) {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"items": items, "total": total, "page": page, "page_size": size, "stats": stats})
|
||||
matchingReports, err := s.m.pg.MatchingFindingReportCount(findingFilterFromQuery(q))
|
||||
if err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"items": items, "total": total, "matching_reports": matchingReports, "page": page, "page_size": size, "stats": stats})
|
||||
}
|
||||
func (s *Server) findingCaseAccess(w http.ResponseWriter, r *http.Request, mutation bool) (*db.FindingCase, bool) {
|
||||
id := int64(atoiDefault(r.PathValue("id"), 0))
|
||||
|
||||
@@ -321,3 +321,34 @@ func TestFindingCaseGroupedTrafficExportAndInheritedAccess(t *testing.T) {
|
||||
t.Fatal(w.Code, w.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindingCaseListIncludesContextAndFilteredCounts(t *testing.T) {
|
||||
s, task, ids := testCaseServer(t)
|
||||
cid, err := s.m.pg.MergeFindingCase(t.Context(), task, ids[:3], "same defect", "verified root cause", "human")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := httptest.NewRequest("GET", fmt.Sprintf("/api/finding-cases?task_id=%d&severity=medium", task), nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.listFindingCases(w, r)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var page struct {
|
||||
Total int `json:"total"`
|
||||
MatchingReports int `json:"matching_reports"`
|
||||
Items []struct {
|
||||
Case *db.FindingCase `json:"case"`
|
||||
TaskDescription string `json:"task_description"`
|
||||
Assets []FindingAssetDTO `json:"assets"`
|
||||
AssetCount int `json:"asset_count"`
|
||||
LastFoundAt string `json:"last_found_at"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Total != 1 || page.MatchingReports != 1 || len(page.Items) != 1 || page.Items[0].Case.ID != cid || page.Items[0].Case.Count != 3 || page.Items[0].Case.Report != "" || page.Items[0].TaskDescription == "" || page.Items[0].LastFoundAt == "" || page.Items[0].Assets == nil {
|
||||
t.Fatalf("list payload %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import { toast } from "sonner";
|
||||
|
||||
import { FindingCaseMembers, FindingCaseReviewPanel, FindingSeverityCounts } from "@/components/finding-case-list";
|
||||
import { Markdown } from "@/components/markdown";
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -109,7 +108,6 @@ function CaseDetail() {
|
||||
</Button>
|
||||
<h1 className="font-semibold text-xl">{c.title}</h1>
|
||||
<FindingSeverityCounts counts={c} />
|
||||
{c.severity ? <StatusBadge domain="severity" value={c.severity} /> : <span>统一评级待评估</span>}
|
||||
</div>
|
||||
{error ? (
|
||||
<Alert>
|
||||
@@ -138,7 +136,7 @@ function CaseDetail() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>完整复现与修复报告</CardTitle>
|
||||
<CardDescription>{c.severity_reason || "统一评级尚未完成"}</CardDescription>
|
||||
<CardDescription>集中查看所有成员的复现步骤、证据与修复建议。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex min-w-0 flex-col gap-4">
|
||||
{c.report_version !== c.version ? (
|
||||
@@ -163,7 +161,7 @@ function CaseDetail() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>原始报告和证据完整保留</CardTitle>
|
||||
<CardDescription>成员保留原评级;统一评级不覆盖原始记录。</CardDescription>
|
||||
<CardDescription>各条原始上报保留各自的等级、报告和证据。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!readOnly && selected.size > 0 ? (
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { FindingCaseList } from "@/components/finding-case-list";
|
||||
import { FindingCaseList, FindingCaseReviewPanel } from "@/components/finding-case-list";
|
||||
import { FindingRetestDialog } from "@/components/finding-retest-dialog";
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { TablePagination } from "@/components/table-pagination";
|
||||
@@ -55,22 +55,11 @@ import type {
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { AssetTree, assetPathOf } from "./_components/asset-tree";
|
||||
import {
|
||||
FINDING_STATUSES,
|
||||
type FindingEdit,
|
||||
type FindingReport,
|
||||
FindingsTable,
|
||||
findingRowKey,
|
||||
fmtTime,
|
||||
isSameFinding,
|
||||
SEVERITIES,
|
||||
UNASSIGNED_TASK,
|
||||
} from "./_components/findings-table";
|
||||
import { FINDING_STATUSES, fmtTime, SEVERITIES, UNASSIGNED_TASK } from "./_components/findings-table";
|
||||
|
||||
const FINDING_LIST_PREFERENCE_KEY = "artex_finding_list_preferences";
|
||||
|
||||
// 列表视图:flat = 跨任务平铺大表(默认);grouped = 按任务分组折叠;
|
||||
// asset = 左侧资产树 + 右侧该子树下的发现。
|
||||
// 所有视图共用漏洞文件夹;原始上报展示更多来源信息,任务和资产视图增加外层导航。
|
||||
type FindingView = "cases" | "flat" | "grouped" | "asset";
|
||||
|
||||
const FINDING_VIEWS: FindingView[] = ["cases", "flat", "grouped", "asset"];
|
||||
@@ -95,26 +84,6 @@ const EMPTY_ASSET_TREE: AssetTreeState = {
|
||||
loading: false,
|
||||
};
|
||||
|
||||
// 分组视图里每个已展开任务组自带一份分页状态,彼此独立。
|
||||
interface GroupFindingsState {
|
||||
items: Finding[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
loaded: boolean;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
// 平铺视图的页码单独放 state(而非塞进快照),筛选一变就能连带重置并触发重新加载。
|
||||
interface FlatFindingsState {
|
||||
items: Finding[];
|
||||
total: number;
|
||||
loaded: boolean;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_FLAT_STATE: FlatFindingsState = { items: [], total: 0, loaded: false, loading: false };
|
||||
|
||||
function findingGroupKey(group: FindingGroup) {
|
||||
return group.task_id === null ? UNASSIGNED_TASK : String(group.task_id);
|
||||
}
|
||||
@@ -131,7 +100,7 @@ const EMPTY_STATS: FindingStats = {
|
||||
};
|
||||
|
||||
export default function FindingsPage() {
|
||||
const [view, setView] = React.useState<FindingView>("cases");
|
||||
const [view, setView] = React.useState<FindingView>("flat");
|
||||
const [severity, setSeverity] = React.useState<"all" | Severity>("all");
|
||||
const [status, setStatus] = React.useState<"all" | FindingStatus>("all");
|
||||
const [vulnclass, setVulnclass] = React.useState<string>("all");
|
||||
@@ -139,16 +108,12 @@ export default function FindingsPage() {
|
||||
const [sort, setSort] = React.useState<"severity" | "time">("severity");
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [expanded, setExpanded] = React.useState<string | null>(null);
|
||||
const [flat, setFlat] = React.useState<FlatFindingsState>(EMPTY_FLAT_STATE);
|
||||
const [flatPage, setFlatPage] = React.useState(1);
|
||||
const [flatPageSize, setFlatPageSize] = React.useState(20);
|
||||
const [assetTree, setAssetTree] = React.useState<AssetTreeState>(EMPTY_ASSET_TREE);
|
||||
const [assetScope, setAssetScope] = React.useState<string | null>(null);
|
||||
const [groups, setGroups] = React.useState<FindingGroup[]>([]);
|
||||
const [groupTotal, setGroupTotal] = React.useState(0);
|
||||
const [expandedGroups, setExpandedGroups] = React.useState<Set<string>>(() => new Set());
|
||||
const [groupFindings, setGroupFindings] = React.useState<Record<string, GroupFindingsState>>({});
|
||||
const [listRefresh, setListRefresh] = React.useState(0);
|
||||
const [total, setTotal] = React.useState(0);
|
||||
const [stats, setStats] = React.useState<FindingStats>(EMPTY_STATS);
|
||||
const [statsLoaded, setStatsLoaded] = React.useState(false);
|
||||
@@ -236,18 +201,6 @@ export default function FindingsPage() {
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
// setFindings 同时改写两个视图缓存里的同一条发现,切换视图不会看到过期状态。
|
||||
const setFindings = React.useCallback((update: (current: Finding[]) => Finding[]) => {
|
||||
setFlat((current) => ({ ...current, items: update(current.items) }));
|
||||
setGroupFindings((current) => {
|
||||
const next: Record<string, GroupFindingsState> = {};
|
||||
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<Set<string>>(() => new Set());
|
||||
// 导出弹窗状态:范围(当前筛选/全部/选中) × 格式(md 单文件/md 分文件 zip/csv/json)。
|
||||
@@ -267,17 +220,6 @@ export default function FindingsPage() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
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");
|
||||
@@ -290,7 +232,15 @@ export default function FindingsPage() {
|
||||
await api.exportFindings({
|
||||
format: exportFormat,
|
||||
scope: exportScope,
|
||||
filters: { severity, status, vulnclass, task, query, sort },
|
||||
filters: {
|
||||
severity,
|
||||
status,
|
||||
vulnclass,
|
||||
task,
|
||||
query,
|
||||
sort,
|
||||
assetScope: view === "asset" ? (assetScope ?? undefined) : undefined,
|
||||
},
|
||||
ids: [...selectedIds],
|
||||
includeOriginals,
|
||||
});
|
||||
@@ -303,48 +253,8 @@ export default function FindingsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const flatRequest = React.useRef(0);
|
||||
const assetTreeRequest = React.useRef(0);
|
||||
const groupRequests = React.useRef<Record<string, number>>({});
|
||||
const groupsRequest = React.useRef(0);
|
||||
const flatStateRef = React.useRef(flat);
|
||||
const expandedGroupsRef = React.useRef(expandedGroups);
|
||||
const groupFindingsRef = React.useRef(groupFindings);
|
||||
const visibleGroupKeysRef = React.useRef<Set<string>>(new Set());
|
||||
flatStateRef.current = flat;
|
||||
expandedGroupsRef.current = expandedGroups;
|
||||
groupFindingsRef.current = groupFindings;
|
||||
visibleGroupKeysRef.current = new Set(groups.map(findingGroupKey));
|
||||
|
||||
// 资产视图右侧列表 = 平铺列表 + 选中子树的筛选,所以两个视图共用一份列表状态。
|
||||
const activeAssetScope = view === "asset" ? assetScope : null;
|
||||
|
||||
// loadFlat 拉取平铺视图的当前页;task 筛选交给后端,与分组视图共用同一批筛选条件。
|
||||
const loadFlat = React.useCallback(async () => {
|
||||
const requestFilter = filterFingerprint;
|
||||
if (activeFilterFingerprint.current !== requestFilter) return;
|
||||
const request = ++flatRequest.current;
|
||||
setFlat((current) => ({ ...current, loading: true }));
|
||||
try {
|
||||
const result = await api.findingsPage({
|
||||
page: flatPage,
|
||||
pageSize: flatPageSize,
|
||||
severity,
|
||||
status,
|
||||
vulnclass,
|
||||
task,
|
||||
query,
|
||||
sort,
|
||||
assetScope: activeAssetScope ?? undefined,
|
||||
});
|
||||
if (request !== flatRequest.current || activeFilterFingerprint.current !== requestFilter) return;
|
||||
setFlat({ items: result.items, total: result.total, loaded: true, loading: false });
|
||||
} catch {
|
||||
if (request !== flatRequest.current || activeFilterFingerprint.current !== requestFilter) return;
|
||||
// Polling keeps the last successful snapshot visible.
|
||||
setFlat((current) => ({ ...current, loading: false }));
|
||||
}
|
||||
}, [activeAssetScope, filterFingerprint, flatPage, flatPageSize, severity, status, vulnclass, task, query, sort]);
|
||||
|
||||
// loadAssetTree 取整棵资产树。树不随选中节点变化(否则选一下就塌成一条链),
|
||||
// 所以这里不带 assetScope。
|
||||
@@ -395,178 +305,57 @@ export default function FindingsPage() {
|
||||
}
|
||||
}, [filterFingerprint, page, pageSize, severity, status, vulnclass, task, query, 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,
|
||||
query,
|
||||
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, query, sort],
|
||||
);
|
||||
const toggleGroup = React.useCallback((key: string) => {
|
||||
setExpandedGroups((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
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 refreshAfterMutation = React.useCallback(
|
||||
(finding: Finding, removed = false) => {
|
||||
if (view === "asset") {
|
||||
// 资产视图不轮询,所以改完要顺带把树的计数也重新算一次。
|
||||
void loadFlat();
|
||||
void loadAssetTree();
|
||||
return;
|
||||
}
|
||||
if (view === "flat") {
|
||||
// 删空最后一页时,页码由越界修正 effect 回退并连带重新加载。
|
||||
void loadFlat();
|
||||
return;
|
||||
}
|
||||
void refreshGroups();
|
||||
const key = finding.task_id ?? UNASSIGNED_TASK;
|
||||
const state = groupFindingsRef.current[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);
|
||||
}
|
||||
(_finding?: Finding, _removed = false) => {
|
||||
setListRefresh((v) => v + 1);
|
||||
if (view === "asset") void loadAssetTree();
|
||||
if (view === "grouped") void refreshGroups();
|
||||
void api
|
||||
.findingStats()
|
||||
.then(setStats)
|
||||
.catch(() => {
|
||||
/* Keep aggregate snapshot until the next poll. */
|
||||
});
|
||||
},
|
||||
[loadAssetTree, loadFlat, loadGroup, refreshGroups, view],
|
||||
[loadAssetTree, refreshGroups, view],
|
||||
);
|
||||
|
||||
// Reset every view's pagination and expansion when a shared finding filter changes.
|
||||
React.useEffect(() => {
|
||||
void filterFingerprint;
|
||||
setPage(1);
|
||||
setExpanded(null);
|
||||
setExpandedGroups(new Set());
|
||||
setGroupFindings({});
|
||||
setFlatPage(1);
|
||||
setFlat(EMPTY_FLAT_STATE);
|
||||
// 筛选变了树也会变,原先选中的节点可能已经不在树里,退回「全部资产」。
|
||||
setAssetScope(null);
|
||||
setAssetTree(EMPTY_ASSET_TREE);
|
||||
}, [filterFingerprint]);
|
||||
|
||||
// 换资产节点等于换了一份结果集,回到第一页。
|
||||
React.useEffect(() => {
|
||||
void assetScope;
|
||||
setFlatPage(1);
|
||||
}, [assetScope]);
|
||||
|
||||
// 资产树只在进入视图 / 筛选变化时查一次(以及本页改动发现后由
|
||||
// refreshAfterMutation 主动重拉),不做轮询。
|
||||
React.useEffect(() => {
|
||||
if (!preferencesHydrated || view !== "asset") return;
|
||||
void activeRetestFingerprint; // 复测结束可能改变状态筛选下的资产计数。
|
||||
void activeRetestFingerprint;
|
||||
void loadAssetTree();
|
||||
}, [activeRetestFingerprint, loadAssetTree, preferencesHydrated, view]);
|
||||
|
||||
// 只轮询当前视图:平铺视图刷当前页,分组视图刷组头与每个已展开的组(其分页彼此独立)。
|
||||
// 资产视图只查一次(见下面的 return),它的左树是导航结构,没必要每 5 秒重算。
|
||||
// 等偏好水合后再发首个请求,否则会先按默认视图/筛选白拉一次。
|
||||
React.useEffect(() => {
|
||||
if (!preferencesHydrated) return;
|
||||
void activeRetestFingerprint; // 包括不定时轮询的资产视图,也在复测结束后刷新处置状态。
|
||||
const refresh = () => {
|
||||
if (view === "flat" || view === "asset") {
|
||||
if (!flatStateRef.current.loading) void loadFlat();
|
||||
return;
|
||||
}
|
||||
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);
|
||||
}
|
||||
};
|
||||
refresh();
|
||||
if (view === "asset") return;
|
||||
const timer = setInterval(refresh, 5000);
|
||||
if (!preferencesHydrated || view !== "grouped") return;
|
||||
void activeRetestFingerprint;
|
||||
void refreshGroups();
|
||||
const timer = setInterval(() => void refreshGroups(), 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, [activeRetestFingerprint, loadFlat, loadGroup, preferencesHydrated, refreshGroups, view]);
|
||||
}, [activeRetestFingerprint, preferencesHydrated, refreshGroups, view]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const lastPage = Math.max(1, Math.ceil(groupTotal / pageSize));
|
||||
if (page > lastPage) setPage(lastPage);
|
||||
}, [groupTotal, page, pageSize]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!flat.loaded) return;
|
||||
const lastPage = Math.max(1, Math.ceil(flat.total / flatPageSize));
|
||||
if (flatPage > lastPage) setFlatPage(lastPage);
|
||||
}, [flat.loaded, flat.total, flatPage, flatPageSize]);
|
||||
|
||||
// Whole-table aggregates (stat cards + vuln-class options) — independent of the
|
||||
// current page, so they stay exact.
|
||||
React.useEffect(() => {
|
||||
@@ -604,118 +393,30 @@ export default function FindingsPage() {
|
||||
}
|
||||
}, [stats, statsLoaded, task, vulnclass]);
|
||||
|
||||
// updateStatus optimistically flips one finding's triage state, reverting on error.
|
||||
const updateStatus = React.useCallback(
|
||||
async (f: Finding, next: FindingStatus) => {
|
||||
if (!f.finding_id || next === f.status) return;
|
||||
const prev = f.status;
|
||||
setFindings((cur) => cur.map((x) => (isSameFinding(x, f) ? { ...x, status: next } : x)));
|
||||
if (!f.finding_id || f.inherited || next === f.status) return;
|
||||
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(() => {
|
||||
// The row update remains valid even if the aggregate refresh fails.
|
||||
});
|
||||
if (status !== "all" && next !== status) {
|
||||
setFindings((cur) => cur.filter((x) => !isSameFinding(x, f)));
|
||||
setTotal((t) => Math.max(0, t - 1));
|
||||
setFlat((cur) => ({ ...cur, total: Math.max(0, cur.total - 1) }));
|
||||
}
|
||||
refreshAfterMutation(f);
|
||||
} catch (e) {
|
||||
setFindings((cur) => cur.map((x) => (isSameFinding(x, f) ? { ...x, status: prev } : x)));
|
||||
toast.error(`更新失败:${(e as Error).message}`);
|
||||
}
|
||||
},
|
||||
[refreshAfterMutation, setFindings, status],
|
||||
[refreshAfterMutation],
|
||||
);
|
||||
|
||||
// 行内展开的详细报告缓存按全局稳定行键存。report 是大段 Markdown,列表查询不带它,
|
||||
// 故展开时才按 finding_id 单独拉取一次;done 且文本为空 = 该漏洞暂无报告。
|
||||
const [reports, setReports] = React.useState<Record<string, FindingReport>>({});
|
||||
|
||||
// 行内可编辑缓冲:当前展开行的名称/类别/严重等级,展开时用该行数据初始化,收起清空。
|
||||
// 单行展开,故一份缓冲即可。
|
||||
const [edit, setEdit] = React.useState<FindingEdit | null>(null);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
|
||||
// toggle 展开/收起一行;新展开时初始化编辑缓冲,并(尚未取过时)按 finding_id 拉一次报告缓存。
|
||||
const toggleRow = React.useCallback(
|
||||
(f: Finding) => {
|
||||
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[key]) return;
|
||||
const fid = f.finding_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: "" } })));
|
||||
},
|
||||
[expanded, reports],
|
||||
);
|
||||
|
||||
// saveEdit 保存当前展开行的名称/类别/严重等级,回写本地列表并刷新统计(类别下拉/严重计数可能变)。
|
||||
const saveEdit = React.useCallback(
|
||||
async (f: Finding) => {
|
||||
if (!f.finding_id || !edit) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await api.updateFinding(f.finding_id, {
|
||||
name: edit.name.trim(),
|
||||
vulnclass: edit.vulnclass.trim(),
|
||||
severity: edit.severity,
|
||||
});
|
||||
setFindings((cur) =>
|
||||
cur.map((x) =>
|
||||
isSameFinding(x, f)
|
||||
? { ...x, name: updated.name, vulnclass: updated.vulnclass, severity: updated.severity }
|
||||
: x,
|
||||
),
|
||||
);
|
||||
toast.success("已保存");
|
||||
api
|
||||
.findingStats()
|
||||
.then(setStats)
|
||||
.catch(() => {
|
||||
// The edit remains valid even if the aggregate refresh fails.
|
||||
});
|
||||
refreshAfterMutation(f);
|
||||
} catch (e) {
|
||||
toast.error(`保存失败:${(e as Error).message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[edit, refreshAfterMutation, setFindings],
|
||||
);
|
||||
|
||||
// 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));
|
||||
setFlat((cur) => ({ ...cur, total: Math.max(0, cur.total - 1) }));
|
||||
const rowKey = findingRowKey(f);
|
||||
setExpanded((cur) => (cur === rowKey ? null : cur));
|
||||
toast.success("已删除漏洞");
|
||||
api
|
||||
.findingStats()
|
||||
@@ -728,7 +429,7 @@ export default function FindingsPage() {
|
||||
toast.error(`删除失败:${(e as Error).message}`);
|
||||
}
|
||||
},
|
||||
[refreshAfterMutation, setFindings],
|
||||
[refreshAfterMutation],
|
||||
);
|
||||
|
||||
const openDeepen = React.useCallback((f: Finding) => {
|
||||
@@ -759,72 +460,59 @@ export default function FindingsPage() {
|
||||
const statCards = [
|
||||
{ label: "独立漏洞", value: stats.distinct?.total ?? stats.total, icon: BugIcon },
|
||||
{ label: "上报记录", value: stats.total, tone: "text-amber-500", icon: ClockIcon },
|
||||
{ label: "严重", value: stats.distinct?.critical ?? stats.critical, tone: "text-rose-600", icon: ShieldAlertIcon },
|
||||
{ label: "高危", value: stats.distinct?.high ?? stats.high, tone: "text-red-500", icon: TriangleAlertIcon },
|
||||
{ label: "中危", value: stats.distinct?.medium ?? stats.medium, tone: "text-amber-500", icon: TriangleAlertIcon },
|
||||
{ label: "低危", value: stats.distinct?.low ?? stats.low, tone: "text-slate-500", icon: InfoIcon },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
// 导出弹窗里「当前筛选」的条数:两个视图的筛选一致,只是统计口径来源不同。
|
||||
// 平铺与资产视图共用 flat 列表状态,分组视图的口径来自组接口的 finding_total。
|
||||
const [caseTotal, setCaseTotal] = React.useState(0);
|
||||
const filteredTotal = view === "cases" ? caseTotal : view === "grouped" ? total : flat.total;
|
||||
const [caseReportTotal, setCaseReportTotal] = React.useState(0);
|
||||
const filteredTotal = view === "grouped" ? total : caseReportTotal;
|
||||
const assetPath = React.useMemo(
|
||||
() => (view === "asset" ? assetPathOf(assetTree.nodes, assetScope) : []),
|
||||
[assetScope, assetTree.nodes, view],
|
||||
);
|
||||
|
||||
const rowProps = {
|
||||
selectedIds,
|
||||
onToggleSelected: toggleSelected,
|
||||
onToggleSelectedPage: toggleSelectedPage,
|
||||
expandedKey: expanded,
|
||||
onToggleRow: toggleRow,
|
||||
reports,
|
||||
edit,
|
||||
onEditChange: setEdit,
|
||||
saving,
|
||||
onSave: saveEdit,
|
||||
const recordActions = {
|
||||
onUpdated: refreshAfterMutation,
|
||||
onStatusChange: updateStatus,
|
||||
onRetest: setRetestFinding,
|
||||
activeRetests,
|
||||
onDeepen: openDeepen,
|
||||
onDelete: deleteFinding,
|
||||
activeRetests,
|
||||
};
|
||||
const sharedListProps = {
|
||||
selectedIds,
|
||||
onSelect: toggleSelected,
|
||||
actions: recordActions,
|
||||
refreshVersion: listRefresh,
|
||||
showReview: false,
|
||||
};
|
||||
|
||||
// 平铺视图与资产视图右侧是同一张表 + 同一份分页,只是筛选条件不同。
|
||||
const flatListCard = (
|
||||
<Card className="gap-0 py-0">
|
||||
<CardContent className="px-0">
|
||||
{flat.loading && !flat.loaded ? (
|
||||
<div className="flex min-h-36 items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<FindingsTable items={flat.items} selectAllLabel="选择当前页全部" {...rowProps} />
|
||||
<TablePagination
|
||||
page={flatPage}
|
||||
pageSize={flatPageSize}
|
||||
total={flat.total}
|
||||
onPageChange={setFlatPage}
|
||||
onPageSizeChange={(nextSize) => {
|
||||
setFlatPageSize(nextSize);
|
||||
setFlatPage(1);
|
||||
}}
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FindingCaseList
|
||||
key={`${view}:${assetScope ?? "all"}`}
|
||||
{...sharedListProps}
|
||||
presentation={view === "asset" ? "asset" : "records"}
|
||||
query={{
|
||||
severity,
|
||||
status,
|
||||
vulnclass,
|
||||
task,
|
||||
query,
|
||||
sort,
|
||||
assetScope: view === "asset" ? (assetScope ?? undefined) : undefined,
|
||||
}}
|
||||
onTotal={setCaseTotal}
|
||||
onReportTotal={setCaseReportTotal}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 md:gap-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">发现</h1>
|
||||
<h1 className="font-semibold text-xl tracking-tight">发现</h1>
|
||||
<p className="text-muted-foreground text-sm">跨任务漏洞汇总</p>
|
||||
</div>
|
||||
<Tabs value={view} onValueChange={(v) => setView(v as FindingView)}>
|
||||
@@ -958,7 +646,7 @@ export default function FindingsPage() {
|
||||
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{selectedIds.size > 0 && (
|
||||
<span className="text-xs text-muted-foreground tabular-nums">已选 {selectedIds.size} 条</span>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">已选 {selectedIds.size} 条</span>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -984,6 +672,8 @@ export default function FindingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FindingCaseReviewPanel taskId={task === "all" ? undefined : task} onChange={() => refreshAfterMutation()} />
|
||||
|
||||
{view === "flat" && flatListCard}
|
||||
|
||||
{view === "asset" && (
|
||||
@@ -1009,7 +699,7 @@ export default function FindingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1 text-sm text-muted-foreground">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1 text-muted-foreground text-sm">
|
||||
<button
|
||||
type="button"
|
||||
className={cn("hover:text-foreground", assetScope === null && "font-medium text-foreground")}
|
||||
@@ -1033,7 +723,9 @@ export default function FindingsPage() {
|
||||
</button>
|
||||
</React.Fragment>
|
||||
))}
|
||||
<span className="ml-auto shrink-0 text-xs tabular-nums">共 {flat.total} 条</span>
|
||||
<span className="ml-auto shrink-0 text-xs tabular-nums">
|
||||
{caseTotal} 个漏洞 · {caseReportTotal} 条上报
|
||||
</span>
|
||||
</div>
|
||||
{flatListCard}
|
||||
</div>
|
||||
@@ -1042,10 +734,10 @@ export default function FindingsPage() {
|
||||
|
||||
{view === "cases" && (
|
||||
<FindingCaseList
|
||||
{...sharedListProps}
|
||||
onTotal={setCaseTotal}
|
||||
onReportTotal={setCaseReportTotal}
|
||||
query={{ severity, status, vulnclass, task, query, sort }}
|
||||
selectedIds={selectedIds}
|
||||
onSelect={toggleSelected}
|
||||
/>
|
||||
)}
|
||||
{view === "grouped" && (
|
||||
@@ -1053,14 +745,6 @@ export default function FindingsPage() {
|
||||
{groups.map((group) => {
|
||||
const key = findingGroupKey(group);
|
||||
const groupOpen = expandedGroups.has(key);
|
||||
const state = groupFindings[key] ?? {
|
||||
items: [],
|
||||
total: group.count,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
loaded: false,
|
||||
loading: false,
|
||||
};
|
||||
return (
|
||||
<Card key={key} className="gap-0 py-0">
|
||||
<CardHeader className="px-4 py-3">
|
||||
@@ -1081,9 +765,7 @@ export default function FindingsPage() {
|
||||
<CardTitle className="truncate text-sm">
|
||||
{group.task_id === null
|
||||
? "未关联 / 任务已删除"
|
||||
: group.task_name
|
||||
? `${group.task_name}(任务 #${group.task_id})`
|
||||
: `任务 #${group.task_id}`}
|
||||
: `${group.task_name || "任务"}(#${group.task_id})`}
|
||||
</CardTitle>
|
||||
<CardDescription className="truncate" title={group.task_description}>
|
||||
{group.task_description || "来源任务不可用"}
|
||||
@@ -1098,11 +780,11 @@ export default function FindingsPage() {
|
||||
return (
|
||||
<span key={level} className="inline-flex items-center gap-1">
|
||||
<StatusBadge domain="severity" value={level} dot />
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{count}</span>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">{count}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{fmtTime(group.last_found_at)}
|
||||
</span>
|
||||
{group.task_id !== null && (
|
||||
@@ -1119,23 +801,12 @@ export default function FindingsPage() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
{groupOpen && (
|
||||
<CardContent className="px-0">
|
||||
{state.loading && !state.loaded ? (
|
||||
<div className="flex min-h-36 items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<FindingsTable items={state.items} selectAllLabel="选择本组当前页全部" {...rowProps} />
|
||||
<TablePagination
|
||||
page={state.page}
|
||||
pageSize={state.pageSize}
|
||||
total={state.total}
|
||||
onPageChange={(nextPage) => void loadGroup(key, nextPage, state.pageSize)}
|
||||
onPageSizeChange={(nextSize) => void loadGroup(key, 1, nextSize)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<CardContent className="px-4 pb-4">
|
||||
<FindingCaseList
|
||||
{...sharedListProps}
|
||||
presentation="task"
|
||||
query={{ severity, status, vulnclass, task: key, query, sort }}
|
||||
/>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
@@ -1143,7 +814,7 @@ export default function FindingsPage() {
|
||||
})}
|
||||
{groups.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-sm text-muted-foreground">没有匹配的发现。</CardContent>
|
||||
<CardContent className="py-12 text-center text-muted-foreground text-sm">没有匹配的发现。</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<TablePagination
|
||||
@@ -1243,7 +914,7 @@ export default function FindingsPage() {
|
||||
|
||||
<div className="flex flex-col gap-5 py-1">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs text-muted-foreground">导出范围</span>
|
||||
<span className="text-muted-foreground text-xs">导出范围</span>
|
||||
<RadioGroup value={exportScope} onValueChange={(v) => setExportScope(v as typeof exportScope)}>
|
||||
<label htmlFor="export-scope-filtered" className="flex items-center gap-2 text-sm">
|
||||
<RadioGroupItem id="export-scope-filtered" value="filtered" /> 导出当前筛选结果(共 {filteredTotal}{" "}
|
||||
@@ -1263,7 +934,7 @@ export default function FindingsPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs text-muted-foreground">导出格式</span>
|
||||
<span className="text-muted-foreground text-xs">导出格式</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="include-original-reports"
|
||||
|
||||
@@ -72,7 +72,7 @@ function Row({
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate text-xs text-muted-foreground">{f.summary}</span>
|
||||
<span className="truncate text-muted-foreground text-xs">{f.summary}</span>
|
||||
</div>
|
||||
</button>
|
||||
<Badge variant="outline">流量证据 {f.traffic_count ?? 0} 条</Badge>
|
||||
@@ -87,7 +87,7 @@ function Row({
|
||||
{a.label}
|
||||
</code>
|
||||
))}
|
||||
{f.assets.length > 2 && <span className="text-xs text-muted-foreground">+{f.assets.length - 2}</span>}
|
||||
{f.assets.length > 2 && <span className="text-muted-foreground text-xs">+{f.assets.length - 2}</span>}
|
||||
</div>
|
||||
)}
|
||||
{f.finding_id && !f.inherited ? (
|
||||
@@ -108,7 +108,7 @@ function Row({
|
||||
) : (
|
||||
<StatusBadge domain="finding" value={f.status} dot />
|
||||
)}
|
||||
<span className="hidden shrink-0 text-xs text-muted-foreground md:block">
|
||||
<span className="hidden shrink-0 text-muted-foreground text-xs md:block">
|
||||
{new Date(f.ts).toLocaleString("zh-CN")}
|
||||
</span>
|
||||
{f.finding_id && (
|
||||
@@ -118,7 +118,7 @@ function Row({
|
||||
? `/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"
|
||||
className="inline-flex shrink-0 items-center gap-0.5 text-muted-foreground text-xs hover:text-primary"
|
||||
title="查看漏洞详情"
|
||||
>
|
||||
详情
|
||||
@@ -128,8 +128,8 @@ function Row({
|
||||
</div>
|
||||
{open && (
|
||||
<div className="bg-muted/30 px-4 pb-4 pl-11">
|
||||
<div className="mb-1 text-xs font-medium text-muted-foreground">证据 / PoC</div>
|
||||
<pre className="overflow-auto rounded-md border bg-background p-3 font-mono text-xs whitespace-pre-wrap">
|
||||
<div className="mb-1 font-medium text-muted-foreground text-xs">证据 / PoC</div>
|
||||
<pre className="overflow-auto whitespace-pre-wrap rounded-md border bg-background p-3 font-mono text-xs">
|
||||
{f.evidence}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -190,7 +190,11 @@ export function FindingsTab({ taskId }: { taskId: string }) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<FindingCaseList query={{ task: taskId, sort: "time" }} />
|
||||
<FindingCaseList
|
||||
query={{ task: taskId, sort: "time" }}
|
||||
presentation="task"
|
||||
actions={{ onStatusChange: onStatus }}
|
||||
/>
|
||||
{[
|
||||
...new Set(
|
||||
items
|
||||
@@ -200,15 +204,15 @@ export function FindingsTab({ taskId }: { taskId: string }) {
|
||||
),
|
||||
].map((source) => (
|
||||
<div key={source}>
|
||||
<p className="mb-2 text-sm text-muted-foreground">继承任务 #{source} · 只读</p>
|
||||
<FindingCaseList query={{ task: source, sort: "time" }} contextTask={taskId} readOnly />
|
||||
<p className="mb-2 text-muted-foreground text-sm">继承任务 #{source} · 只读</p>
|
||||
<FindingCaseList query={{ task: source, sort: "time" }} contextTask={taskId} presentation="task" readOnly />
|
||||
</div>
|
||||
))}
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm text-muted-foreground">查看原始上报列表</summary>
|
||||
<summary className="cursor-pointer text-muted-foreground text-sm">查看原始上报列表</summary>
|
||||
<Card className="overflow-hidden py-0">
|
||||
<CardContent className="px-0">
|
||||
<div className="flex items-center border-b px-4 py-2 text-xs text-muted-foreground">
|
||||
<div className="flex items-center border-b px-4 py-2 text-muted-foreground text-xs">
|
||||
<span className="min-w-0 flex-1">漏洞</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -233,7 +237,7 @@ export function FindingsTab({ taskId }: { taskId: string }) {
|
||||
<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>
|
||||
<p className="px-4 py-8 text-center text-muted-foreground text-sm">本任务及直接关联任务暂无确认发现。</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -4,26 +4,58 @@ import * as React from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { ChevronRightIcon, FolderIcon, FolderOpenIcon } from "lucide-react";
|
||||
import { ChevronRightIcon, FolderIcon, FolderOpenIcon, MoreHorizontalIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { TablePagination } from "@/components/table-pagination";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { api } from "@/lib/api";
|
||||
import { statusMeta } from "@/lib/status";
|
||||
import type {
|
||||
ActiveFindingRetest,
|
||||
Finding,
|
||||
FindingAsset,
|
||||
FindingCase,
|
||||
FindingCasePage,
|
||||
FindingCaseReviewRun,
|
||||
FindingCaseSuggestion,
|
||||
FindingQuery,
|
||||
FindingStatus,
|
||||
Severity,
|
||||
} from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -50,6 +82,205 @@ export function FindingSeverityCounts({
|
||||
);
|
||||
}
|
||||
|
||||
export interface FindingRecordActions {
|
||||
onUpdated?: () => void;
|
||||
onStatusChange?: (finding: Finding, status: FindingStatus) => void;
|
||||
onRetest?: (finding: Finding) => void;
|
||||
onDeepen?: (finding: Finding) => void;
|
||||
onDelete?: (finding: Finding) => void;
|
||||
activeRetests?: Record<string, ActiveFindingRetest>;
|
||||
}
|
||||
export type FindingListPresentation = "compact" | "records" | "task" | "asset";
|
||||
|
||||
function FindingContext({
|
||||
taskId,
|
||||
taskLabel,
|
||||
assets = [],
|
||||
assetCount = assets.length,
|
||||
ts,
|
||||
showTask = true,
|
||||
}: {
|
||||
taskId?: string | null;
|
||||
taskLabel?: string;
|
||||
assets?: FindingAsset[];
|
||||
assetCount?: number;
|
||||
ts?: string;
|
||||
showTask?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-muted-foreground text-xs">
|
||||
{showTask && taskId ? (
|
||||
<Link
|
||||
className="max-w-full truncate hover:text-primary hover:underline"
|
||||
title={taskLabel}
|
||||
href={`/function/tasks/detail?id=${taskId}`}
|
||||
>
|
||||
所属任务 #{taskId}
|
||||
{taskLabel ? ` · ${taskLabel}` : ""}
|
||||
</Link>
|
||||
) : null}
|
||||
{showTask && !taskId ? <span>未关联任务 / 任务已删除</span> : null}
|
||||
{assets.length ? (
|
||||
<span className="inline-flex min-w-0 flex-wrap items-center gap-1">
|
||||
资产:
|
||||
{assets.slice(0, 4).map((a) => (
|
||||
<code key={a.id} className="max-w-48 truncate rounded bg-muted px-1" title={`${a.type} · ${a.label}`}>
|
||||
{a.label}
|
||||
</code>
|
||||
))}
|
||||
{assetCount > 4 ? <span>+{assetCount - 4}</span> : null}
|
||||
</span>
|
||||
) : (
|
||||
<span>未关联资产</span>
|
||||
)}
|
||||
{ts ? (
|
||||
<time dateTime={ts} title={new Date(ts).toLocaleString("zh-CN")}>
|
||||
最近上报{" "}
|
||||
{new Date(ts).toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</time>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function FindingRecordMenu({ finding: f, actions }: { finding: Finding; actions: FindingRecordActions }) {
|
||||
const [confirmDelete, setConfirmDelete] = React.useState(false);
|
||||
const [editing, setEditing] = React.useState(false);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [name, setName] = React.useState("");
|
||||
const [vulnclass, setVulnclass] = React.useState("");
|
||||
const [severity, setSeverity] = React.useState<Severity>(f.severity);
|
||||
async function saveEdit() {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.updateFinding(f.finding_id ?? f.id, { name: name.trim(), vulnclass: vulnclass.trim(), severity });
|
||||
toast.success("已保存原始上报");
|
||||
setEditing(false);
|
||||
actions.onUpdated?.();
|
||||
} catch (e) {
|
||||
toast.error(`保存失败:${(e as Error).message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const active = actions.activeRetests?.[f.finding_id ?? f.id];
|
||||
return (
|
||||
<>
|
||||
{active ? (
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href={`/chat?c=${active.conversation_id}`}>
|
||||
<Spinner />
|
||||
复测中
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon-sm" aria-label={`上报 #${f.finding_id ?? f.id}的更多操作`}>
|
||||
<MoreHorizontalIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/function/findings/detail?id=${f.finding_id ?? f.id}`}>查看详情</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setName(f.name ?? "");
|
||||
setVulnclass(f.vulnclass);
|
||||
setSeverity(f.severity);
|
||||
setEditing(true);
|
||||
}}
|
||||
>
|
||||
编辑原始上报
|
||||
</DropdownMenuItem>
|
||||
{actions.onRetest ? (
|
||||
<DropdownMenuItem disabled={!!active} onSelect={() => actions.onRetest?.(f)}>
|
||||
复测漏洞
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{actions.onDeepen && f.task_id ? (
|
||||
<DropdownMenuItem onSelect={() => actions.onDeepen?.(f)}>深入利用</DropdownMenuItem>
|
||||
) : null}
|
||||
{actions.onDelete ? (
|
||||
<DropdownMenuItem variant="destructive" onSelect={() => setConfirmDelete(true)}>
|
||||
删除原始上报
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Dialog
|
||||
open={editing}
|
||||
onOpenChange={(open) => {
|
||||
if (!saving) setEditing(open);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑原始上报 #{f.finding_id ?? f.id}</DialogTitle>
|
||||
<DialogDescription>修改这条记录的名称、类型和等级;所属文件夹的统一报告将标记待更新。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor={`finding-name-${f.finding_id ?? f.id}`}>漏洞名称</Label>
|
||||
<Input
|
||||
id={`finding-name-${f.finding_id ?? f.id}`}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<Label htmlFor={`finding-type-${f.finding_id ?? f.id}`}>漏洞类型</Label>
|
||||
<Input
|
||||
id={`finding-type-${f.finding_id ?? f.id}`}
|
||||
value={vulnclass}
|
||||
onChange={(e) => setVulnclass(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<Label>原始等级</Label>
|
||||
<Select value={severity} onValueChange={(v) => setSeverity(v as Severity)} disabled={saving}>
|
||||
<SelectTrigger aria-label="原始上报等级">
|
||||
<StatusBadge domain="severity" value={severity} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(["critical", "high", "medium", "low"] as Severity[]).map((level) => (
|
||||
<SelectItem key={level} value={level}>
|
||||
{statusMeta("severity", level).label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditing(false)} disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={saveEdit} disabled={saving}>
|
||||
{saving ? <Spinner /> : null}保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>删除这条原始上报?</AlertDialogTitle>
|
||||
<AlertDialogDescription>报告、证据绑定和文件夹成员关系会一同移除,此操作不可撤销。</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => actions.onDelete?.(f)}>删除</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function FindingCaseMemberRow({
|
||||
finding: f,
|
||||
selected,
|
||||
@@ -57,6 +288,8 @@ export function FindingCaseMemberRow({
|
||||
contextTask,
|
||||
matched = true,
|
||||
nested = false,
|
||||
presentation = "records",
|
||||
actions,
|
||||
}: {
|
||||
finding: Finding;
|
||||
selected?: boolean;
|
||||
@@ -64,6 +297,8 @@ export function FindingCaseMemberRow({
|
||||
contextTask?: string;
|
||||
matched?: boolean;
|
||||
nested?: boolean;
|
||||
presentation?: FindingListPresentation;
|
||||
actions?: FindingRecordActions;
|
||||
}) {
|
||||
const id = f.finding_id ?? f.id;
|
||||
return (
|
||||
@@ -88,10 +323,45 @@ export function FindingCaseMemberRow({
|
||||
#{id} {[f.name, f.vulnclass].find((v) => v?.trim()) ?? "未分类"}
|
||||
</Link>
|
||||
<span className="truncate text-muted-foreground text-xs">{f.summary}</span>
|
||||
<FindingContext
|
||||
taskId={f.task_id}
|
||||
taskLabel={f.task_description}
|
||||
assets={f.assets}
|
||||
ts={presentation === "compact" ? undefined : f.ts}
|
||||
showTask={presentation !== "task" || !!f.inherited}
|
||||
/>
|
||||
</div>
|
||||
{!matched ? <Badge variant="outline">未命中当前筛选</Badge> : null}
|
||||
{f.inherited ? <Badge variant="outline">继承 · 只读</Badge> : null}
|
||||
<StatusBadge domain="finding" value={f.status} />
|
||||
{actions?.onStatusChange && !f.inherited ? (
|
||||
<Select value={f.status} onValueChange={(v) => actions.onStatusChange?.(f, v as FindingStatus)}>
|
||||
<SelectTrigger size="sm" className="w-28" aria-label={`上报 #${id}的状态`}>
|
||||
<StatusBadge domain="finding" value={f.status} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(
|
||||
[
|
||||
"pending",
|
||||
"in_progress",
|
||||
"confirmed",
|
||||
"resolved",
|
||||
"fixed",
|
||||
"false_positive",
|
||||
"ignored",
|
||||
"duplicate",
|
||||
"risk_accepted",
|
||||
] as FindingStatus[]
|
||||
).map((st) => (
|
||||
<SelectItem key={st} value={st}>
|
||||
{statusMeta("finding", st).label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<StatusBadge domain="finding" value={f.status} />
|
||||
)}
|
||||
{actions && !f.inherited ? <FindingRecordMenu finding={f} actions={actions} /> : null}
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href={`/function/findings/detail?id=${id}${contextTask ? `&context_task=${contextTask}` : ""}`}>
|
||||
查看报告
|
||||
@@ -109,6 +379,8 @@ export function FindingCaseMembers({
|
||||
onSelect,
|
||||
contextTask,
|
||||
nested = false,
|
||||
presentation = "records",
|
||||
actions,
|
||||
}: {
|
||||
caseId: string;
|
||||
version?: number;
|
||||
@@ -117,6 +389,8 @@ export function FindingCaseMembers({
|
||||
onSelect?: (id: string, checked: boolean) => void;
|
||||
contextTask?: string;
|
||||
nested?: boolean;
|
||||
presentation?: FindingListPresentation;
|
||||
actions?: FindingRecordActions;
|
||||
}) {
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [data, setData] = React.useState<{ items: Finding[]; total: number } | null>(null);
|
||||
@@ -162,6 +436,8 @@ export function FindingCaseMembers({
|
||||
key={f.finding_id}
|
||||
finding={f}
|
||||
nested={nested}
|
||||
presentation={presentation}
|
||||
actions={actions}
|
||||
selected={selectedIds?.has(f.finding_id ?? f.id)}
|
||||
onSelect={onSelect}
|
||||
contextTask={contextTask}
|
||||
@@ -336,6 +612,11 @@ export function FindingCaseList({
|
||||
contextTask,
|
||||
readOnly = false,
|
||||
onTotal,
|
||||
onReportTotal,
|
||||
presentation = "compact",
|
||||
showReview = true,
|
||||
actions,
|
||||
refreshVersion,
|
||||
}: {
|
||||
query: Omit<FindingQuery, "page" | "pageSize">;
|
||||
selectedIds?: Set<string>;
|
||||
@@ -343,6 +624,11 @@ export function FindingCaseList({
|
||||
contextTask?: string;
|
||||
readOnly?: boolean;
|
||||
onTotal?: (total: number) => void;
|
||||
onReportTotal?: (total: number) => void;
|
||||
presentation?: FindingListPresentation;
|
||||
showReview?: boolean;
|
||||
actions?: FindingRecordActions;
|
||||
refreshVersion?: number;
|
||||
}) {
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [data, setData] = React.useState<FindingCasePage | null>(null);
|
||||
@@ -353,6 +639,8 @@ export function FindingCaseList({
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reset pagination when the filter fingerprint changes.
|
||||
React.useEffect(() => {
|
||||
setPage(1);
|
||||
setOpen(new Set());
|
||||
setData(null);
|
||||
}, [key]);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: refresh is an explicit reload requested after grouping/retry.
|
||||
React.useEffect(() => {
|
||||
@@ -376,10 +664,18 @@ export function FindingCaseList({
|
||||
active = false;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [key, page, refresh]);
|
||||
}, [key, page, refresh, refreshVersion]);
|
||||
React.useEffect(() => {
|
||||
if (data) onTotal?.(data.total);
|
||||
}, [data, onTotal]);
|
||||
if (data) {
|
||||
onTotal?.(data.total);
|
||||
onReportTotal?.(data.matching_reports ?? data.stats.reports);
|
||||
}
|
||||
}, [data, onTotal, onReportTotal]);
|
||||
React.useEffect(() => {
|
||||
if (!data) return;
|
||||
const lastPage = Math.max(1, Math.ceil(data.total / 20));
|
||||
if (page > lastPage) setPage(lastPage);
|
||||
}, [data, page]);
|
||||
const toggle = (id: string) =>
|
||||
setOpen((v) => {
|
||||
const next = new Set(v);
|
||||
@@ -389,7 +685,7 @@ export function FindingCaseList({
|
||||
});
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
{!readOnly ? (
|
||||
{!readOnly && showReview ? (
|
||||
<FindingCaseReviewPanel
|
||||
taskId={query.task === "all" ? undefined : query.task}
|
||||
onChange={() => setRefresh((v) => v + 1)}
|
||||
@@ -412,8 +708,7 @@ export function FindingCaseList({
|
||||
) : null}
|
||||
{data ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{data.stats.total} 个独立漏洞 · {data.stats.reports} 条上报
|
||||
{data.stats.unassessed > 0 ? ` · ${data.stats.unassessed} 个待评估` : ""}
|
||||
{data.total} 个漏洞(归并后) · {data.matching_reports ?? data.stats.reports} 条命中上报
|
||||
</p>
|
||||
) : null}
|
||||
{data?.items.map((row) => {
|
||||
@@ -451,13 +746,19 @@ export function FindingCaseList({
|
||||
漏洞文件夹 · {group.count} 条原始上报 ·{" "}
|
||||
{group.report_version !== group.version ? "统一报告待更新" : "统一报告已生成"}
|
||||
</CardDescription>
|
||||
<FindingContext
|
||||
taskId={group.task_id}
|
||||
taskLabel={row.task_name || row.task_description}
|
||||
assets={row.assets ?? []}
|
||||
assetCount={row.asset_count}
|
||||
ts={presentation === "compact" ? undefined : row.last_found_at}
|
||||
showTask={presentation !== "task"}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col gap-1">
|
||||
<span className="text-muted-foreground text-xs">严 / 高 / 中 / 低</span>
|
||||
<FindingSeverityCounts counts={group} />
|
||||
</div>
|
||||
<FindingSeverityCounts counts={group} />
|
||||
<Badge variant="outline">
|
||||
{group.severity
|
||||
? `统一评级:${{ critical: "严重", high: "高危", medium: "中危", low: "低危" }[group.severity]}`
|
||||
: "待评估"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{open.has(group.id) ? (
|
||||
@@ -470,6 +771,8 @@ export function FindingCaseList({
|
||||
<p className="mb-3 text-muted-foreground text-xs">原始子报告 · 各自等级和证据保留</p>
|
||||
<FindingCaseMembers
|
||||
nested
|
||||
presentation={presentation}
|
||||
actions={readOnly ? undefined : actions}
|
||||
caseId={group.id}
|
||||
version={group.version}
|
||||
matchedIds={row.matched_ids}
|
||||
@@ -488,6 +791,8 @@ export function FindingCaseList({
|
||||
<CardContent className="px-0">
|
||||
<FindingCaseMemberRow
|
||||
finding={row.finding}
|
||||
presentation={presentation}
|
||||
actions={readOnly ? undefined : actions}
|
||||
selected={selectedIds?.has(row.finding.finding_id ?? row.finding.id)}
|
||||
onSelect={readOnly ? undefined : onSelect}
|
||||
contextTask={contextTask}
|
||||
|
||||
@@ -1642,11 +1642,17 @@ export interface FindingCase {
|
||||
created_at: string;
|
||||
}
|
||||
export interface FindingCaseListRow {
|
||||
task_name?: string;
|
||||
task_description?: string;
|
||||
assets?: FindingAsset[];
|
||||
asset_count?: number;
|
||||
last_found_at?: string;
|
||||
case?: FindingCase | null;
|
||||
finding?: Finding;
|
||||
matched_ids: number[];
|
||||
}
|
||||
export interface FindingCasePage {
|
||||
matching_reports?: number;
|
||||
items: FindingCaseListRow[];
|
||||
total: number;
|
||||
page: number;
|
||||
|
||||
Reference in new issue
Block a user