Add read-only task context inheritance, ordered task-level LLM failover, live Worker controls, cascading task cleanup, FIFO task concurrency, cancellation diagnostics, UI updates, tests, build script, and changelog.
438 lines
15 KiB
Go
438 lines
15 KiB
Go
package db
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// DBFinding is a row in the standalone findings table. It persists across task
|
|
// deletion unless the caller explicitly requests related finding cleanup.
|
|
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
|
|
}
|
|
|
|
// DeleteFinding removes a finding entirely: the standalone findings row and its
|
|
// originating exploration node (kind='finding'), so it disappears from the findings
|
|
// list, the per-task 发现 Tab, and the exploration graph alike. Deleting the node
|
|
// cascades its edges + node_assets and nulls any activity referencing it. Returns
|
|
// rows affected (0 = no finding with that id).
|
|
func (d *DB) DeleteFinding(id int64) (int64, error) {
|
|
var nodeID *int64
|
|
err := d.QueryRow(`DELETE FROM findings WHERE id=$1 RETURNING node_id`, id).Scan(&nodeID)
|
|
if err == sql.ErrNoRows {
|
|
return 0, nil
|
|
}
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if nodeID != nil {
|
|
// best-effort: the finding is already gone; a stray node must not fail the op.
|
|
_, _ = d.Exec(`DELETE FROM exploration_nodes WHERE id=$1 AND kind='finding'`, *nodeID)
|
|
}
|
|
return 1, nil
|
|
}
|
|
|
|
// DeleteFindingsByTask removes all findings rows of a task. The originating
|
|
// exploration finding nodes are cascade-deleted separately when the task's
|
|
// exploration subgraph is dropped. Returns rows deleted.
|
|
func (d *DB) DeleteFindingsByTask(taskID int64) (int64, error) {
|
|
res, err := d.Exec(`DELETE FROM findings WHERE task_id=$1`, taskID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return res.RowsAffected()
|
|
}
|
|
|
|
// 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()
|
|
}
|