From 8634ddbf2330894631a351cf3e5c8fb2b2180d59 Mon Sep 17 00:00:00 2001 From: BaaRai <130491490+BaaRai@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:47:40 +0100 Subject: [PATCH] feat: a completed retest updates the verification state A completed retest with a reproduced or fixed verdict is an independent proof, so it sets verification_status to verified with a note naming the retest, its verdict and the start of its summary. The upgrade applies only from unverified, not_assessed or blocked: a verified finding keeps its POE level and note. Inconclusive, failed and stopped retests never change verification_status; their verdict stays in the retest history. The human status is untouched by this path. The existing fixed path (status set to Fixed with notifications) is unchanged. Tests cover reproduced, fixed (status stays Fixed), inconclusive on a verified finding, failed and stopped, and the upgrade from not_assessed and blocked. --- db/finding_retests.go | 29 ++++++- db/finding_retests_verification_test.go | 107 ++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 db/finding_retests_verification_test.go diff --git a/db/finding_retests.go b/db/finding_retests.go index 3559c0a..16e32cd 100644 --- a/db/finding_retests.go +++ b/db/finding_retests.go @@ -12,6 +12,19 @@ import ( const FindingRetestAgentKey = "retester" +// retestNoteSummaryRunes caps the retest summary copied into verification_note. +const retestNoteSummaryRunes = 200 + +// truncateRunes returns s trimmed to at most n runes, with an ellipsis when cut. +func truncateRunes(s string, n int) string { + s = strings.TrimSpace(s) + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) + "…" +} + var ErrRetestNotRunning = errors.New("this retest has ended or has not started; please start a new retest from the finding details") // FindingRetest is an immutable historical test once its conversation turn ends. @@ -225,17 +238,29 @@ func (d *DB) FinishFindingRetest(id int64, status, reason string) error { if err != nil { return err } - var finalStatus, verdict string + var finalStatus, verdict, summary string err = tx.QueryRow(`UPDATE finding_retests SET status=CASE WHEN $2='completed' AND verdict='' THEN 'failed' ELSE $2 END, error=CASE WHEN $2='completed' AND verdict='' THEN 'Agent did not save the retest conclusion; please review the conversation and retest again' ELSE $3 END, - finished_at=now() WHERE id=$1 AND status IN ('pending','running') RETURNING status,verdict`, id, status, reason).Scan(&finalStatus, &verdict) + finished_at=now() WHERE id=$1 AND status IN ('pending','running') RETURNING status,verdict,summary`, id, status, reason).Scan(&finalStatus, &verdict, &summary) if errors.Is(err, sql.ErrNoRows) { return nil // A replay must not overwrite a later manual triage decision. } if err != nil { return err } + // A completed reproduced/fixed retest is an independent proof: it upgrades the + // machine verification state, but only from unverified / not_assessed / blocked. + // A verified finding keeps its POE level and note. An inconclusive, failed or + // stopped retest never downgrades anything; its verdict stays in finding_retests. + // The human disposition (status) is not touched here. + if finalStatus == "completed" && (verdict == "reproduced" || verdict == "fixed") { + note := fmt.Sprintf("retest #%d: %s — %s", id, verdict, truncateRunes(summary, retestNoteSummaryRunes)) + if _, err := tx.Exec(`UPDATE findings SET verification_status='verified', verification_note=$2 + WHERE id=$1 AND verification_status IN ('unverified','not_assessed','blocked')`, findingID, note); err != nil { + return err + } + } if finalStatus == "completed" && verdict == "fixed" { // Go through the notifying version, sharing the same semantics as a // manual status change on the details page. diff --git a/db/finding_retests_verification_test.go b/db/finding_retests_verification_test.go new file mode 100644 index 0000000..c05f770 --- /dev/null +++ b/db/finding_retests_verification_test.go @@ -0,0 +1,107 @@ +package db + +import ( + "strings" + "testing" +) + +// runRetest drives one retest session to its terminal state with the given +// staged verdict, using the same calls the server makes, and returns its ID. +func runRetest(t *testing.T, d *DB, fid int64, verdict, summary, finish string) int64 { + t.Helper() + ctx := t.Context() + r, c, _, err := d.CreateFindingRetest(ctx, fid, "") + if err != nil { + t.Fatal(err) + } + if ok, err := d.StartFindingRetest(ctx, r.ID); err != nil || !ok { + t.Fatalf("start retest: ok=%v err=%v", ok, err) + } + if verdict != "" { + if err := d.RecordFindingRetestResult(ctx, c.ID, verdict, summary, "retest evidence"); err != nil { + t.Fatal(err) + } + } + if err := d.FinishFindingRetest(r.ID, finish, ""); err != nil { + t.Fatal(err) + } + return r.ID +} + +func verificationOf(t *testing.T, d *DB, fid int64) (status string, poe int, note string) { + t.Helper() + if err := d.QueryRow(`SELECT verification_status, poe_level, verification_note FROM findings WHERE id=$1`, fid). + Scan(&status, &poe, ¬e); err != nil { + t.Fatal(err) + } + return +} + +func TestRetestReproducedVerifiesUnverifiedFinding(t *testing.T) { + d, fid := retestDB(t) + runRetest(t, d, fid, "reproduced", "still exploitable with the original PoC", "completed") + status, _, note := verificationOf(t, d, fid) + if status != VerificationVerified || !strings.Contains(note, "retest") || !strings.Contains(note, "reproduced") { + t.Fatalf("verification after reproduced: status=%s note=%q", status, note) + } +} + +func TestRetestFixedVerifiesAndKeepsFixedStatus(t *testing.T) { + d, fid := retestDB(t) + runRetest(t, d, fid, "fixed", "payload now rejected, control still works", "completed") + status, _, note := verificationOf(t, d, fid) + if status != VerificationVerified || !strings.Contains(note, "retest") || !strings.Contains(note, "fixed") { + t.Fatalf("verification after fixed: status=%s note=%q", status, note) + } + f, err := d.GetFinding(fid) + if err != nil || f == nil { + t.Fatalf("get finding: %v", err) + } + if f.Status != FindingFixed { + t.Fatalf("human status after fixed retest = %q, want %q", f.Status, FindingFixed) + } +} + +func TestRetestInconclusiveDoesNotDowngradeVerified(t *testing.T) { + d, fid := retestDB(t) + if err := d.SetFindingVerification(fid, VerificationVerified, 3, ""); err != nil { + t.Fatal(err) + } + runRetest(t, d, fid, "inconclusive", "target unreachable from the retest session", "completed") + status, poe, note := verificationOf(t, d, fid) + if status != VerificationVerified || poe != 3 || note != "" { + t.Fatalf("verified finding after inconclusive: status=%s poe=%d note=%q", status, poe, note) + } +} + +func TestRetestFailedOrStoppedLeavesVerificationUnchanged(t *testing.T) { + for _, finish := range []string{"failed", "stopped"} { + t.Run(finish, func(t *testing.T) { + d, fid := retestDB(t) + if err := d.SetFindingVerification(fid, VerificationNotAssessed, 3, "earlier note"); err != nil { + t.Fatal(err) + } + // A verdict staged before the session was cut off must not count. + runRetest(t, d, fid, "reproduced", "staged before interruption", finish) + status, poe, note := verificationOf(t, d, fid) + if status != VerificationNotAssessed || poe != 3 || note != "earlier note" { + t.Fatalf("after %s: status=%s poe=%d note=%q", finish, status, poe, note) + } + }) + } +} + +func TestRetestUpgradesFromNotAssessedAndBlocked(t *testing.T) { + for _, from := range []string{VerificationNotAssessed, VerificationBlocked} { + t.Run(from, func(t *testing.T) { + d, fid := retestDB(t) + if err := d.SetFindingVerification(fid, from, 0, ""); err != nil { + t.Fatal(err) + } + runRetest(t, d, fid, "reproduced", "reproduced on retest", "completed") + if status, _, _ := verificationOf(t, d, fid); status != VerificationVerified { + t.Fatalf("from %s after reproduced: status=%s", from, status) + } + }) + } +}