Files
ARTEX/db/findings.go
T
Autumn-27 d9474814b5 feat: 发现页支持按任务筛选/行内看报告与改名称类别严重度;任务列表输入去卡顿
- 发现列表新增「按任务」下拉(有漏洞的任务+条数),task_id 下推 SQL 筛选
- 展开行懒加载并渲染详细报告(Markdown),修 table 溢出:table-fixed +
  whitespace-normal + break-words + pre 换行
- 展开行前置「名称/类别/严重等级」可编辑并保存:patchFinding 增 name/vulnclass,
  DB 抽 setFindingCol 同步节点 payload
- 详情页状态区加「漏洞 ID」,列表证据行去掉裸 id
- 任务新建表单拆出独立组件消除输入卡顿;轮询 3s→10s+去重,时长 tick 仅 running 时跑
2026-08-18 13:27:46 -04:00

405 lines
14 KiB
Go

package db
import (
"database/sql"
"encoding/json"
"fmt"
"strconv"
"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
Report string // 详细报告(Markdown);仅 GetFinding 填充,列表查询不带
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
}
// 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.
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
TaskID string // 任务 id(字符串形式;空/非法 = 不按任务筛选)
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)
// task_id 是 bigint 列,按整数比较(不能走上面的文本 add);空/非法值忽略。
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)))
}
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"`
Tasks []FindingTaskOption `json:"tasks"` // 有漏洞的任务(供「按任务」下拉)
}
// FindingTaskOption is one entry in the 发现 page's 任务 filter: a task that has at
// least one finding, with its description and finding count. Description is empty when
// the task has since been deleted (finding rows persist), so the frontend falls back to
// the id.
type FindingTaskOption struct {
ID int64 `json:"id"`
Description string `json:"description"`
Count int `json:"count"`
}
// 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{}, Tasks: []FindingTaskOption{}}
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)
}
if err := rows.Err(); err != nil {
return nil, err
}
// 任务下拉:有漏洞的任务,带描述(任务删除后为空,前端回退 id)和条数,最新有漏洞的排前。
trows, err := d.Query(`SELECT f.task_id, COALESCE(t.description, ''), COUNT(*)
FROM findings f
LEFT JOIN tasks t ON f.task_id = t.id
WHERE f.task_id IS NOT NULL
GROUP BY f.task_id, t.description
ORDER BY MAX(f.created_at) DESC`)
if err != nil {
return nil, err
}
defer trows.Close()
for trows.Next() {
var opt FindingTaskOption
if err := trows.Scan(&opt.ID, &opt.Description, &opt.Count); err != nil {
return nil, err
}
st.Tasks = append(st.Tasks, opt)
}
return st, trows.Err()
}
// GetFinding returns a single finding row (with task_description joined and the
// full Markdown report), or nil when no row has that id. Unlike the list queries
// it also selects `report` — that column is only needed on the detail page.
func (d *DB) GetFinding(id int64) (*DBFinding, error) {
f := &DBFinding{}
var aidsJSON string
err := d.QueryRow(`SELECT `+findingSelectCols+`, COALESCE(f.report, '')
FROM findings f
LEFT JOIN tasks t ON f.task_id = t.id
WHERE f.id = $1`, id).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, &f.Report)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(aidsJSON), &f.AssetIDs)
return f, 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)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// SetFindingReportByNodeID sets the Markdown report on the standalone finding row
// whose node_id matches — report_finding returns that node id, so an agent tool
// can address the finding it just created. Returns rows affected (0 when no row).
func (d *DB) SetFindingReportByNodeID(nodeID int64, report string) (int64, error) {
res, err := d.Exec(`UPDATE findings SET report=$1 WHERE node_id=$2`, report, nodeID)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// setFindingCol updates one text column on the standalone finding row AND mirrors
// the new value into the originating exploration node's payload under jsonKey, so the
// per-task 发现 Tab (which reads the node payload, not this table) stays in sync.
// Returns rows affected (0 when no finding has that id); the node sync is best-effort.
// col and jsonKey MUST be trusted constants (they are interpolated into SQL) — never
// pass user input.
func (d *DB) setFindingCol(id int64, col, jsonKey, val string) (int64, error) {
var nodeID *int64
err := d.QueryRow(`UPDATE findings SET `+col+`=$1 WHERE id=$2 RETURNING node_id`, val, id).Scan(&nodeID)
if err == sql.ErrNoRows {
return 0, nil
}
if err != nil {
return 0, err
}
if nodeID != nil {
_, _ = d.Exec(`UPDATE exploration_nodes
SET payload = jsonb_set(payload, '{`+jsonKey+`}', to_jsonb($1::text))
WHERE id = $2`, val, *nodeID)
}
return 1, nil
}
// SetFindingSeverity updates one finding's severity (+ node payload sync). Returns
// rows affected (0 when no finding has that id).
func (d *DB) SetFindingSeverity(id int64, severity string) (int64, error) {
return d.setFindingCol(id, "severity", "severity", severity)
}
// SetFindingName updates one finding's 漏洞名称 (+ node payload sync). Empty name is
// allowed — the frontend falls back to the vuln class for display.
func (d *DB) SetFindingName(id int64, name string) (int64, error) {
return d.setFindingCol(id, "name", "name", name)
}
// SetFindingVulnClass updates one finding's 漏洞类别 (+ node payload sync).
func (d *DB) SetFindingVulnClass(id int64, vulnclass string) (int64, error) {
return d.setFindingCol(id, "vulnclass", "vulnclass", vulnclass)
}
// 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()
}