diff --git a/db/findings.go b/db/findings.go index c6903ec..d0fe731 100644 --- a/db/findings.go +++ b/db/findings.go @@ -1,6 +1,7 @@ package db import ( + "database/sql" "encoding/json" "fmt" "strings" @@ -46,6 +47,23 @@ func ValidFindingStatus(s string) bool { return false } +// Finding severity levels (findings.severity). +const ( + SeverityCritical = "critical" // 严重 + SeverityHigh = "high" // 高 + SeverityMedium = "medium" // 中 + SeverityLow = "low" // 低 +) + +// ValidSeverity reports whether s is a known severity level. +func ValidSeverity(s string) bool { + switch s { + case SeverityCritical, SeverityHigh, SeverityMedium, SeverityLow: + return true + } + return false +} + // AddFinding inserts a finding into the standalone findings table. taskID and // nodeID may be 0 (stored as NULL). name may be "" (frontend falls back to // vulnclass). Returns the new finding id. @@ -226,6 +244,27 @@ func (d *DB) FindingStats() (*FindingStats, error) { return st, rows.Err() } +// GetFinding returns a single finding row (with task_description joined), or nil +// when no row has that id. +func (d *DB) GetFinding(id int64) (*DBFinding, error) { + rows, err := d.Query(`SELECT `+findingSelectCols+` + FROM findings f + LEFT JOIN tasks t ON f.task_id = t.id + WHERE f.id = $1`, id) + if err != nil { + return nil, err + } + defer rows.Close() + out, err := scanFindings(rows) + if err != nil { + return nil, err + } + if len(out) == 0 { + return nil, nil + } + return out[0], nil +} + // 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) @@ -235,6 +274,30 @@ func (d *DB) SetFindingStatus(id int64, status string) (int64, error) { return res.RowsAffected() } +// SetFindingSeverity updates one finding's severity in the standalone table AND +// keeps the originating exploration node's payload in sync (the per-task view +// reads severity from the node payload, not this table). Returns rows affected +// (0 when no finding has that id). The node sync is best-effort. +func (d *DB) SetFindingSeverity(id int64, severity string) (int64, error) { + var nodeID *int64 + err := d.QueryRow(`UPDATE findings SET severity=$1 WHERE id=$2 RETURNING node_id`, severity, id).Scan(&nodeID) + if err == sql.ErrNoRows { + return 0, nil + } + if err != nil { + return 0, err + } + if nodeID != nil { + // jsonb_set the {severity} key so the finding node (read by the per-task + // 发现 Tab) shows the same level. Ignore errors — the standalone row is the + // source of truth for the global 发现 page. + _, _ = d.Exec(`UPDATE exploration_nodes + SET payload = jsonb_set(payload, '{severity}', to_jsonb($1::text)) + WHERE id = $2`, severity, *nodeID) + } + return 1, nil +} + // FindingMeta is the standalone-row data (id, triage state, anchored assets) the // per-task view grafts onto its exploration-node findings. type FindingMeta struct { diff --git a/db/findings_test.go b/db/findings_test.go index 68b93f6..de5cd1b 100644 --- a/db/findings_test.go +++ b/db/findings_test.go @@ -100,4 +100,28 @@ func TestFindingsPageAndStats(t *testing.T) { if !slices.Contains(st.VulnClasses, vc) { t.Fatalf("stats vulnclasses missing %q", vc) } + + // GetFinding: single-row fetch round-trips id/name/severity. + one, err := d.GetFinding(ids[0]) + if err != nil { + t.Fatal(err) + } + if one == nil || one.ID != ids[0] || one.Severity != "critical" || one.Name != "严重漏洞标题" { + t.Fatalf("GetFinding mismatch: %+v", one) + } + if miss, err := d.GetFinding(-1); err != nil || miss != nil { + t.Fatalf("GetFinding(-1): want nil,nil got %+v,%v", miss, err) + } + + // SetFindingSeverity: standalone row updates; 0 rows for unknown id. + if n, err := d.SetFindingSeverity(ids[0], "high"); err != nil || n != 1 { + t.Fatalf("SetFindingSeverity: want 1,nil got %d,%v", n, err) + } + one, _ = d.GetFinding(ids[0]) + if one.Severity != "high" { + t.Fatalf("severity not updated: %q", one.Severity) + } + if n, err := d.SetFindingSeverity(-1, "low"); err != nil || n != 0 { + t.Fatalf("SetFindingSeverity(-1): want 0,nil got %d,%v", n, err) + } } diff --git a/server/server.go b/server/server.go index fe901cf..abe12f7 100644 --- a/server/server.go +++ b/server/server.go @@ -607,7 +607,8 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/exploration/frontier", s.frontier) mux.HandleFunc("GET /api/exploration/findings", s.findings) mux.HandleFunc("GET /api/exploration/findings/stats", s.findingStats) - mux.HandleFunc("PATCH /api/exploration/findings/{id}", s.setFindingStatus) + mux.HandleFunc("GET /api/exploration/findings/{id}", s.getFinding) + mux.HandleFunc("PATCH /api/exploration/findings/{id}", s.patchFinding) mux.HandleFunc("GET /api/exploration/intents", s.intents) mux.HandleFunc("GET /api/exploration/graph", s.explorationGraph) mux.HandleFunc("GET /api/exploration/activity", s.activity) @@ -1464,35 +1465,89 @@ func (s *Server) findingStats(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, st) } -// setFindingStatus updates one finding's triage state (pending / false_positive / -// ignored / resolved). The id is the standalone findings-table id (DTO finding_id). -func (s *Server) setFindingStatus(w http.ResponseWriter, r *http.Request) { +// getFinding returns one finding by its standalone-table id (DTO finding_id), +// with anchored assets resolved for display. +func (s *Server) getFinding(w http.ResponseWriter, r *http.Request) { + id := int64(atoiDefault(r.PathValue("id"), 0)) + if id <= 0 { + writeErr(w, 400, "bad finding id") + return + } + f, err := s.m.pg.GetFinding(id) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + if f == nil { + writeErr(w, 404, "finding not found") + return + } + assets := s.resolveAssetIDs(f.AssetIDs) + writeJSON(w, 200, findingFromDB(f, assets)) +} + +// patchFinding partially updates a finding: any subset of {status, severity}. +// The id is the standalone findings-table id (DTO finding_id). Severity edits are +// mirrored onto the originating exploration node so the per-task view stays in +// sync. Returns the updated finding DTO. +func (s *Server) patchFinding(w http.ResponseWriter, r *http.Request) { id := int64(atoiDefault(r.PathValue("id"), 0)) if id <= 0 { writeErr(w, 400, "bad finding id") return } var body struct { - Status string `json:"status"` + Status *string `json:"status"` + Severity *string `json:"severity"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeErr(w, 400, "bad json: "+err.Error()) return } - if !db.ValidFindingStatus(body.Status) { - writeErr(w, 400, "bad status: "+body.Status) + if body.Status == nil && body.Severity == nil { + writeErr(w, 400, "nothing to update: provide status and/or severity") return } - n, err := s.m.pg.SetFindingStatus(id, body.Status) + if body.Status != nil { + if !db.ValidFindingStatus(*body.Status) { + writeErr(w, 400, "bad status: "+*body.Status) + return + } + n, err := s.m.pg.SetFindingStatus(id, *body.Status) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + if n == 0 { + writeErr(w, 404, "finding not found") + return + } + } + if body.Severity != nil { + if !db.ValidSeverity(*body.Severity) { + writeErr(w, 400, "bad severity: "+*body.Severity) + return + } + n, err := s.m.pg.SetFindingSeverity(id, *body.Severity) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + if n == 0 { + writeErr(w, 404, "finding not found") + return + } + } + f, err := s.m.pg.GetFinding(id) if err != nil { writeErr(w, 500, err.Error()) return } - if n == 0 { + if f == nil { writeErr(w, 404, "finding not found") return } - writeJSON(w, 200, map[string]any{"id": i64s(id), "status": body.Status}) + writeJSON(w, 200, findingFromDB(f, s.resolveAssetIDs(f.AssetIDs))) } func (s *Server) intents(w http.ResponseWriter, r *http.Request) { diff --git a/web/src/app/(main)/function/findings/detail/page.tsx b/web/src/app/(main)/function/findings/detail/page.tsx new file mode 100644 index 0000000..8bb3a5e --- /dev/null +++ b/web/src/app/(main)/function/findings/detail/page.tsx @@ -0,0 +1,360 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { toast } from "sonner"; +import { ArrowLeftIcon, ArrowUpRightIcon, ShieldAlertIcon } from "lucide-react"; + +import { SidebarTrigger } from "@/components/ui/sidebar"; +import { Separator } from "@/components/ui/separator"; +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, + SelectItem, + SelectTrigger, +} from "@/components/ui/select"; +import { StatusBadge } from "@/components/status-badge"; +import { statusMeta } from "@/lib/status"; +import { api } from "@/lib/api"; +import type { Finding, FindingStatus, Severity } from "@/lib/types"; + +const SEVERITIES: Severity[] = ["critical", "high", "medium", "low"]; +const FINDING_STATUSES: FindingStatus[] = [ + "pending", + "in_progress", + "confirmed", + "resolved", + "false_positive", + "ignored", + "duplicate", + "risk_accepted", +]; + +function fmtTime(ts: string) { + return new Date(ts).toLocaleString("zh-CN"); +} + +// FieldRow is one label/value line in the right-hand status panel. +function FieldRow({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+ {loaded ? `未找到发现 ${id}` : "加载中…"} +
+ {loaded && ( + + )} +
+ #{finding.id}
+
+ + {finding.summary || "(无摘要)"} +
+
+ {finding.evidence}
+
+ ) : (
+ (无证据)
+ )} +
+ {finding.vulnclass}
+
+ ) : (
+ —
+ )}
+
+ {a.label}
+
+ ))}
+
+ {finding.evidence}
+
+ ) : (
+ 本发现暂无证据文本。
+ )} +