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) + } + }) + } +}