fix(notify): 复测判定「已修复」时也要推送状态变更

复测结论为 fixed 时,db/finding_retests.go 里是裸的
`UPDATE findings SET status=...`,绕过了带通知的版本——于是配了
on_status_change 的渠道对这种状态流转完全收不到推送:状态在界面上悄悄变了,
运维要打开平台才知道。

把「更新状态 + 登记状态变更事件」抽成事务级的 SetFindingStatusTx,供
patchFinding 与复测路径共用,两者的语义(状态没变就不登记、快照带全渲染字段)
由同一份代码保证,不会再出现只改一处的情况。

附回归用例:复测判 fixed 后必须有一条 finding_status_changed 事件,
且快照里的 from/to 与渲染字段正确。

Co-Authored-By: Claude Code <[email protected]>
This commit is contained in:
luckoneandClaude Code committed 2026-09-30 16:25:06 +08:00
1 parent ee8929115e
commit f2f35ebc2e
3 files changed
+136 -37

No files matched your search

+10 -1
View File
@@ -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
}
}
+54 -36
View File
@@ -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"`
+72
View File
@@ -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)
}
}