Files
ARTEX/db/finding_reasoning_test.go
BaaRai 8c9c1fc0ab feat(findings): optional counterevidence and confidence_rationale
Add two optional free-text fields to findings so a reporter can record
what was tested and did not work (counterevidence) and why the
conclusion holds (confidence_rationale).

- db: idempotent ADD COLUMN IF NOT EXISTS, TEXT NOT NULL DEFAULT '',
  no CHECK; RecordFindingInput, the INSERT, the shared select list and
  all three scan sites carry them.
- agent: report_finding accepts both as optional strings; the worker
  and pentest prompts say to fill them when confidence is not high.
- server: FindingDTO (list and detail) carries both, omitted when empty;
  existing databases get the two schema properties added to the stored
  report_finding row once, without overwriting user edits.
- web: the finding detail page shows each section only when non-empty.

Both fields stay optional: a missing field never refuses a finding.
2026-11-11 23:43:12 +01:00

68 lines
2.1 KiB
Go

package db
import (
"strconv"
"testing"
)
// counterevidence / confidence_rationale are optional: they round-trip through
// RecordFinding and every read path, and default to "" when not given.
func TestFindingReasoningFieldsRoundTrip(t *testing.T) {
d, err := Open(testDSN(t))
if err != nil {
t.Skipf("postgres unavailable (%v) — skipping", err)
}
defer d.Close()
tk, err := d.CreateTask("finding reasoning fields", "goal", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
defer d.DeleteTask(tk.ID)
es := d.Exploration(tk.ExplorationID)
with, err := es.RecordFinding(t.Context(), RecordFindingInput{TaskID: tk.ID, VulnClass: "IDOR", Severity: "medium", Summary: "with reasoning", Worker: "test",
Counterevidence: "UUID guessing returned 404", ConfidenceRationale: "two accounts, same response body"})
if err != nil {
t.Fatal(err)
}
without, err := es.RecordFinding(t.Context(), RecordFindingInput{TaskID: tk.ID, VulnClass: "IDOR", Severity: "low", Summary: "without reasoning", Worker: "test"})
if err != nil {
t.Fatal(err)
}
want := map[int64][2]string{
with.FindingID: {"UUID guessing returned 404", "two accounts, same response body"},
without.FindingID: {"", ""},
}
check := func(src string, f *DBFinding) {
t.Helper()
w, ok := want[f.ID]
if !ok {
return
}
if f.Counterevidence != w[0] || f.ConfidenceRationale != w[1] {
t.Fatalf("%s finding %d: got (%q, %q), want (%q, %q)", src, f.ID, f.Counterevidence, f.ConfidenceRationale, w[0], w[1])
}
}
for id := range want {
f, err := d.GetFinding(id)
if err != nil || f == nil {
t.Fatalf("GetFinding(%d): %v %v", id, f, err)
}
check("GetFinding", f)
}
filter := FindingFilter{TaskID: strconv.FormatInt(tk.ID, 10)}
page, total, err := d.ListFindingsPage(filter, 1, 50)
if err != nil || total != 2 {
t.Fatalf("ListFindingsPage: total=%d err=%v", total, err)
}
for _, f := range page {
check("ListFindingsPage", f)
}
exported, err := d.ListFindingsForExport(filter, nil)
if err != nil || len(exported) != 2 {
t.Fatalf("ListFindingsForExport: n=%d err=%v", len(exported), err)
}
for _, f := range exported {
check("ListFindingsForExport", f)
}
}