diff --git a/db/finding_retests.go b/db/finding_retests.go index 05d562d..d6e43ae 100644 --- a/db/finding_retests.go +++ b/db/finding_retests.go @@ -237,7 +237,16 @@ func (d *DB) FinishFindingRetest(id int64, status, reason string) error { return err } if finalStatus == "completed" && verdict == "fixed" { - if _, err := tx.Exec(`UPDATE findings SET status=$1 WHERE id=$2`, FindingFixed, findingID); err != nil { + // 走带通知的版本,与人工在详情页改状态共用同一套语义。 + // + // 此前这里是裸的 UPDATE:复测判「已修复」时状态确实变了,但配了 + // on_status_change 的渠道完全收不到推送——状态在界面上悄悄变了, + // 运维要打开平台才知道。状态更新与推送事件必须一起落库, + // SetFindingStatusTx 内部处理了「状态没变就不登记」等细节。 + // 用 context.Background():本函数整条都是无 ctx 的旧风格(d.Begin()/ + // tx.QueryRow/tx.Exec),没有可传递的取消信号,硬加一个 ctx 参数会 + // 牵动 server 侧调用点与多处测试,超出本次改动的范围。 + if _, _, _, _, err := SetFindingStatusTx(context.Background(), tx, findingID, FindingFixed); err != nil { return err } } diff --git a/db/notification.go b/db/notification.go index a7001c0..47f0b22 100644 --- a/db/notification.go +++ b/db/notification.go @@ -463,46 +463,64 @@ func assetDisplayName(typ, domain, ip, url, appName, bundleID string) string { // 所以 notified=false 时状态已经改成功了,调用方不应因此报错。 func (d *DB) SetFindingStatusWithNotify(ctx context.Context, id int64, status string) (from string, found bool, notified bool, err error) { err = d.WithEvidenceTx(ctx, func(tx *sql.Tx) error { - var ( - vulnclass, name, severity, summary string - taskID sql.NullInt64 - assetIDs []byte - ) - scanErr := tx.QueryRowContext(ctx, `SELECT vulnclass, name, severity, summary, task_id, asset_ids, status -FROM findings WHERE id=$1 FOR UPDATE`, id). - Scan(&vulnclass, &name, &severity, &summary, &taskID, &assetIDs, &from) - if scanErr == sql.ErrNoRows { - return nil - } - if scanErr != nil { - return scanErr - } - found = true - if from == status { - return nil - } - if _, err := tx.ExecContext(ctx, `UPDATE findings SET status=$2 WHERE id=$1`, id, status); err != nil { - return err - } - var assets []int64 - _ = json.Unmarshal(assetIDs, &assets) - notified = RecordNotificationEventTx(ctx, tx, notify.EventFindingStatusChanged, id, notify.Snapshot{ - Kind: notify.EventFindingStatusChanged, - FindingID: id, - TaskID: taskID.Int64, - VulnClass: vulnclass, - Name: name, - Severity: severity, - Summary: summary, - AssetIDs: assets, - FromStatus: from, - ToStatus: status, - }) - return nil + var txErr error + from, found, _, notified, txErr = SetFindingStatusTx(ctx, tx, id, status) + return txErr }) return from, found, notified, err } +// SetFindingStatusTx 在**调用方的事务**内更新漏洞状态并登记状态变更推送事件。 +// +// 抽成事务级函数是为了让所有改状态的路径共用同一套语义——此前只有 +// patchFinding 走带通知的版本,而**复测结论为「已修复」时**(finding_retests +// 里那条 `UPDATE findings SET status=...`)是直接写库的,于是配了 +// `on_status_change` 的渠道对这类状态流转完全收不到推送:界面上状态悄悄变了, +// 运维要到打开平台才发现。 +// +// 返回 from=变更前状态、found=漏洞是否存在、changed=状态是否真的变了、 +// notified=事件是否登记成功(登记失败不影响状态更新,见 RecordNotificationEventTx)。 +func SetFindingStatusTx(ctx context.Context, tx *sql.Tx, id int64, status string) (from string, found bool, changed bool, notified bool, err error) { + var ( + vulnclass, name, severity, summary string + taskID sql.NullInt64 + assetIDs []byte + ) + scanErr := tx.QueryRowContext(ctx, `SELECT vulnclass, name, severity, summary, task_id, asset_ids, status +FROM findings WHERE id=$1 FOR UPDATE`, id). + Scan(&vulnclass, &name, &severity, &summary, &taskID, &assetIDs, &from) + if scanErr == sql.ErrNoRows { + return "", false, false, false, nil + } + if scanErr != nil { + return "", false, false, false, scanErr + } + found = true + if from == status { + // 状态没有真的变化就不登记事件:重复提交同一个值、幂等重放都不该 + // 产生推送噪音。 + return from, true, false, false, nil + } + if _, err := tx.ExecContext(ctx, `UPDATE findings SET status=$2 WHERE id=$1`, id, status); err != nil { + return from, true, false, false, err + } + var assets []int64 + _ = json.Unmarshal(assetIDs, &assets) + notified = RecordNotificationEventTx(ctx, tx, notify.EventFindingStatusChanged, id, notify.Snapshot{ + Kind: notify.EventFindingStatusChanged, + FindingID: id, + TaskID: taskID.Int64, + VulnClass: vulnclass, + Name: name, + Severity: severity, + Summary: summary, + AssetIDs: assets, + FromStatus: from, + ToStatus: status, + }) + return from, true, true, notified, nil +} + // NotificationStats 是通知页顶部的概览计数。 type NotificationStats struct { Channels int `json:"channels"` diff --git a/db/notification_test.go b/db/notification_test.go index a30d8cb..46f15bb 100644 --- a/db/notification_test.go +++ b/db/notification_test.go @@ -800,3 +800,75 @@ func TestClaimDigestBatchHonorsCallerLimit(t *testing.T) { t.Fatalf("额度为 0 时应领 0 条且不报错,得到 %d 条 err=%v", len(got), err) } } + +// TestFinishFindingRetestEmitsStatusChange 覆盖审计指出的一处完整性缺口: +// 复测结论为「已修复」时,状态确实变了,但那条 UPDATE 是直接写库的、 +// 绕过了带通知的版本——于是配了 on_status_change 的渠道对这种状态流转 +// 完全收不到推送,界面上状态悄悄变了,运维要打开平台才知道。 +// +// 这条用例锁住「所有改状态的路径都要登记状态变更事件」。 +func TestFinishFindingRetestEmitsStatusChange(t *testing.T) { + d := notifyTestDB(t) + ctx := context.Background() + + tk, err := d.CreateTask("复测推送测试", "目标", nil, 0, 0) + if err != nil { + t.Fatal(err) + } + defer d.DeleteTask(tk.ID) + es := d.Exploration(tk.ExplorationID) + f, err := es.RecordFinding(ctx, RecordFindingInput{ + TaskID: tk.ID, Worker: "test", VulnClass: "SQL注入", Name: "复测目标", + Severity: "high", Summary: "摘要", + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { d.Exec(`DELETE FROM notification_events WHERE finding_id=$1`, f.FindingID) }) + + // 建一条复测记录并直接推到完成态。 + rt, _, _, err := d.CreateFindingRetest(ctx, f.FindingID, "复核") + if err != nil { + t.Fatal(err) + } + if rt.ConversationID == nil { + t.Fatal("复测应关联一个会话") + } + // 复测必须先进入 running 才能落结论(与真实流程一致)。 + if ok, err := d.StartFindingRetest(ctx, rt.ID); err != nil || !ok { + t.Fatalf("启动复测失败: ok=%v err=%v", ok, err) + } + if err := d.RecordFindingRetestResult(ctx, *rt.ConversationID, "fixed", "已修复", "证据"); err != nil { + t.Fatal(err) + } + if err := d.FinishFindingRetest(rt.ID, "completed", ""); err != nil { + t.Fatalf("结束复测失败: %v", err) + } + + var status string + if err := d.QueryRow(`SELECT status FROM findings WHERE id=$1`, f.FindingID).Scan(&status); err != nil { + t.Fatal(err) + } + if status != FindingFixed { + t.Fatalf("复测判已修复后状态应为 fixed,得到 %s", status) + } + + // 关键断言:必须有一条状态变更事件,且 from/to 正确。 + var snapshot []byte + err = d.QueryRow(`SELECT snapshot FROM notification_events WHERE finding_id=$1 AND kind=$2 ORDER BY id DESC LIMIT 1`, + f.FindingID, notify.EventFindingStatusChanged).Scan(&snapshot) + if err != nil { + t.Fatalf("复测判已修复应登记状态变更推送事件(否则配了 on_status_change 的渠道收不到): %v", err) + } + var snap notify.Snapshot + if err := json.Unmarshal(snapshot, &snap); err != nil { + t.Fatal(err) + } + if snap.FromStatus != "pending" || snap.ToStatus != FindingFixed { + t.Fatalf("快照的状态流转不对: %s → %s", snap.FromStatus, snap.ToStatus) + } + // 快照要带渲染所需字段,否则推送出来是空壳。 + if snap.Name != "复测目标" || snap.Severity != "high" { + t.Fatalf("快照缺少渲染字段: %+v", snap) + } +}