- 新增 agent 写工具 update_finding_report(finding_id + report[Markdown]), 整段覆盖该漏洞的详细报告;finding_id 传 report_finding 返回的 id - 绑定 worker / planner / mainagent 三个 agent,经 BuiltinToolSeeds 自动入 tools 目录(首插不覆盖,旧库升级重启即出现,可在 UI 管理/勾选) - db.SetFindingReportByNodeID / ExplorationStore.SetFindingReport:按 finding 节点 id 更新独立 findings 行的 report - 详情页概览已渲染 report,写入后即可展示 go build / agent / server 测试通过。
349 lines
11 KiB
Go
349 lines
11 KiB
Go
package db
|
|
|
|
import (
|
|
"database/sql"
|
|
"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
|
|
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
|
|
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()
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
|
|
// 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 {
|
|
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()
|
|
}
|