mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
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:
1 parent
ee8929115e
commit
f2f35ebc2e
3 files changed
+136
-37
No files matched your search
+10
-1
@@ -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
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user