package db import ( "encoding/json" "fmt" "strings" "time" ) // DBFinding is a row in the standalone findings table (persists across task deletion). type DBFinding struct { ID int64 TaskID *int64 NodeID *int64 VulnClass string Name string // 漏洞名称(可读标题);为空时前端回退展示 VulnClass Severity string Summary string Evidence string Worker string AssetIDs []int64 Status string CreatedAt time.Time TaskDescription string // populated via LEFT JOIN on tasks } // Finding triage states (findings.status). const ( FindingPending = "pending" // 待处理 FindingInProgress = "in_progress" // 处理中 FindingConfirmed = "confirmed" // 已确认(真实漏洞,未修复) FindingResolved = "resolved" // 已处理(已修复) FindingFalsePositive = "false_positive" // 误报 FindingIgnored = "ignored" // 忽略 FindingDuplicate = "duplicate" // 重复 FindingRiskAccepted = "risk_accepted" // 风险接受 ) // ValidFindingStatus reports whether s is a known triage state. func ValidFindingStatus(s string) bool { switch s { case FindingPending, FindingInProgress, FindingConfirmed, FindingResolved, FindingFalsePositive, FindingIgnored, FindingDuplicate, FindingRiskAccepted: 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. func (d *DB) AddFinding(taskID, nodeID int64, vulnclass, name, severity, summary, evidence, worker string, assetIDs []int64) (int64, error) { aidsJSON, _ := json.Marshal(assetIDs) if assetIDs == nil { aidsJSON = []byte("[]") } var tid, nid *int64 if taskID > 0 { tid = &taskID } if nodeID > 0 { nid = &nodeID } var id int64 err := d.QueryRow( `INSERT INTO findings (task_id, node_id, vulnclass, name, severity, summary, evidence, worker, asset_ids) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`, tid, nid, vulnclass, name, severity, summary, evidence, worker, string(aidsJSON), ).Scan(&id) return id, err } // findingSelectCols is the column list (with task_description join) every finding // list query selects, so scanFinding stays in sync across callers. const findingSelectCols = `f.id, f.task_id, f.node_id, f.vulnclass, COALESCE(f.name, ''), f.severity, f.summary, f.evidence, f.worker, f.asset_ids, COALESCE(f.status, 'pending'), f.created_at, COALESCE(t.description, '') AS task_description` // scanFindings materializes rows selected via findingSelectCols. func scanFindings(rows interface { Next() bool Scan(...any) error Err() error }) ([]*DBFinding, error) { var out []*DBFinding for rows.Next() { f := &DBFinding{} var aidsJSON string if err := rows.Scan(&f.ID, &f.TaskID, &f.NodeID, &f.VulnClass, &f.Name, &f.Severity, &f.Summary, &f.Evidence, &f.Worker, &aidsJSON, &f.Status, &f.CreatedAt, &f.TaskDescription); err != nil { return nil, err } _ = json.Unmarshal([]byte(aidsJSON), &f.AssetIDs) out = append(out, f) } return out, rows.Err() } // ListFindings returns all findings (newest first), joined with task description. // Kept for the dashboard's summary; the paginated 发现 page uses ListFindingsPage. func (d *DB) ListFindings(limit int) ([]*DBFinding, error) { if limit <= 0 { limit = 500 } rows, err := d.Query(` SELECT `+findingSelectCols+` FROM findings f LEFT JOIN tasks t ON f.task_id = t.id ORDER BY f.created_at DESC LIMIT $1`, limit) if err != nil { return nil, err } defer rows.Close() return scanFindings(rows) } // FindingFilter narrows a paginated findings query. Empty-string fields mean "no // filter on that column". Sort is "severity" (severity desc, then newest) or // anything else (newest first). type FindingFilter struct { Severity string // high | medium | low Status string // pending | false_positive | ignored | resolved VulnClass string Sort string // "severity" | "time" } // 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) { var conds []string var args []any add := func(col, val string) { if val == "" { return } args = append(args, val) conds = append(conds, fmt.Sprintf("f.%s = $%d", col, len(args))) } add("severity", f.Severity) add("status", f.Status) add("vulnclass", f.VulnClass) if len(conds) == 0 { return "", args } return " WHERE " + strings.Join(conds, " AND "), args } // ListFindingsPage returns one page of findings matching the filter, plus the // total count of matching rows (for the frontend pager). page is 1-based. func (d *DB) ListFindingsPage(f FindingFilter, page, pageSize int) ([]*DBFinding, int, error) { if page <= 0 { page = 1 } if pageSize <= 0 { pageSize = 20 } where, args := f.where() var total int if err := d.QueryRow(`SELECT COUNT(*) FROM findings f`+where, args...).Scan(&total); err != nil { return nil, 0, err } order := "f.created_at 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` } pageArgs := append(append([]any{}, args...), pageSize, (page-1)*pageSize) q := fmt.Sprintf(` SELECT %s FROM findings f LEFT JOIN tasks t ON f.task_id = t.id%s ORDER BY %s LIMIT $%d OFFSET $%d`, findingSelectCols, where, order, len(args)+1, len(args)+2) rows, err := d.Query(q, pageArgs...) if err != nil { return nil, 0, err } defer rows.Close() out, err := scanFindings(rows) return out, total, err } // 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. type FindingStats struct { Total int `json:"total"` Pending int `json:"pending"` Critical int `json:"critical"` High int `json:"high"` Medium int `json:"medium"` Low int `json:"low"` VulnClasses []string `json:"vulnclasses"` } // FindingStats returns whole-table counts (by severity + pending) and the sorted // set of distinct vuln classes. func (d *DB) FindingStats() (*FindingStats, error) { st := &FindingStats{VulnClasses: []string{}} err := d.QueryRow(`SELECT COUNT(*), COUNT(*) FILTER (WHERE status = 'pending'), COUNT(*) FILTER (WHERE severity = 'critical'), COUNT(*) FILTER (WHERE severity = 'high'), COUNT(*) FILTER (WHERE severity = 'medium'), COUNT(*) FILTER (WHERE severity = 'low') FROM findings`).Scan(&st.Total, &st.Pending, &st.Critical, &st.High, &st.Medium, &st.Low) if err != nil { return nil, err } rows, err := d.Query(`SELECT DISTINCT vulnclass FROM findings WHERE vulnclass <> '' ORDER BY vulnclass`) if err != nil { return nil, err } defer rows.Close() for rows.Next() { var vc string if err := rows.Scan(&vc); err != nil { return nil, err } st.VulnClasses = append(st.VulnClasses, vc) } return st, rows.Err() } // 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) if err != nil { return 0, err } return res.RowsAffected() } // FindingMeta is the standalone-row data (id, triage state, anchored assets) the // per-task view grafts onto its exploration-node findings. type FindingMeta struct { ID int64 Status string AssetIDs []int64 } // FindingMetaByNodeID maps a task's finding node ids to their standalone-row // metadata, so the per-task view (which reads exploration nodes) can show and // edit the same status — and the same anchored assets — as the global 发现 page. func (d *DB) FindingMetaByNodeID(taskID int64) (map[int64]FindingMeta, error) { out := map[int64]FindingMeta{} if taskID <= 0 { return out, nil } rows, err := d.Query(`SELECT node_id, id, COALESCE(status,'pending'), asset_ids FROM findings WHERE task_id=$1 AND node_id IS NOT NULL`, taskID) if err != nil { return out, err } defer rows.Close() for rows.Next() { var nid int64 var m FindingMeta var aidsJSON string if err := rows.Scan(&nid, &m.ID, &m.Status, &aidsJSON); err != nil { return out, err } _ = json.Unmarshal([]byte(aidsJSON), &m.AssetIDs) out[nid] = m } return out, rows.Err() }