Files
ARTEX/server/finding_verification.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

283 lines
9.2 KiB
Go

package server
import (
"bytes"
"compress/flate"
"compress/gzip"
"compress/zlib"
"context"
"encoding/json"
"fmt"
"io"
"log"
"strings"
"unicode/utf8"
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/evidence"
)
// S1 receipt check. A finding is verified only when a proof/verification
// traffic body is bound, the agent declared POE L3 or higher, and every quote
// appears verbatim (after whitespace normalization) in one of those response
// bodies. Anything else is unverified, blocked or not_assessed, with a note.
const (
verificationMinPOE = 3
verificationMinQuoteRunes = 8 // shorter normalized quotes are ignored: "x7" proves nothing
verificationReadCap = 4 << 20 // bytes read per bound body; a quote beyond this cannot be confirmed
verificationNoteQuoteLen = 60
)
// boundBody is one bound response body, read through the evidence store and
// normalized for the substring check.
type boundBody struct {
text string // whitespace-normalized text (only when !nonText)
truncated bool // body was longer than verificationReadCap
nonText bool // binary or non-UTF-8 content: never claimed as a match
}
// normalizeEvidenceText trims and collapses every run of whitespace to one space.
// Case is kept.
func normalizeEvidenceText(s string) string {
return strings.Join(strings.Fields(s), " ")
}
// decodeEvidenceBody undoes the HTTP Content-Encoding declared in respHead on a
// captured response body, returning at most limit decoded bytes. Only identity
// and one layer of gzip or deflate are handled. Any other coding, a stacked
// coding or a stream that does not decode is an error: the raw bytes are never
// returned in place of a failed decode.
func decodeEvidenceBody(respHead string, raw []byte, limit int) (out []byte, truncated bool, err error) {
coding := ""
for _, line := range strings.Split(respHead, "\n") {
name, value, ok := strings.Cut(line, ":")
if !ok || !strings.EqualFold(strings.TrimSpace(name), "Content-Encoding") {
continue
}
for _, v := range strings.Split(value, ",") {
switch v = strings.ToLower(strings.TrimSpace(v)); v {
case "", "identity":
case "gzip", "deflate":
if coding != "" {
return nil, false, fmt.Errorf("unsupported content-encoding: more than one compressed layer")
}
coding = v
default:
return nil, false, fmt.Errorf("unsupported content-encoding: %q", v)
}
}
}
if coding == "" || len(raw) == 0 {
return raw, false, nil
}
var zr io.Reader
switch {
case coding == "gzip":
zr, err = gzip.NewReader(bytes.NewReader(raw))
case len(raw) >= 2 && raw[0]&0x0f == 8 && raw[0]>>4 <= 7 && (uint(raw[0])<<8|uint(raw[1]))%31 == 0:
// HTTP "deflate" is the zlib format (RFC 9110); some servers send a bare
// deflate stream instead, told apart by the zlib header.
zr, err = zlib.NewReader(bytes.NewReader(raw))
default:
zr = flate.NewReader(bytes.NewReader(raw))
}
if err == nil {
out, err = io.ReadAll(io.LimitReader(zr, int64(limit)+1))
}
if err != nil {
return nil, false, fmt.Errorf("%s body: %w", coding, err)
}
if len(out) > limit {
return out[:limit], true, nil
}
return out, false, nil
}
// readBoundResponse reads the response side of a bound snapshot through the
// evidence store (same bytes the UI shows), capped at verificationReadCap, and
// decodes its Content-Encoding under the same cap.
func readBoundResponse(store *evidence.Store, snap db.TrafficEvidenceSnapshot) (boundBody, error) {
f, _, err := store.OpenBody(snap, "response")
if err != nil {
return boundBody{}, err
}
defer f.Close()
raw, err := io.ReadAll(io.LimitReader(f, verificationReadCap+1))
if err != nil {
return boundBody{}, err
}
var bb boundBody
if len(raw) > verificationReadCap {
bb.truncated = true
raw = raw[:verificationReadCap]
}
raw, cut, err := decodeEvidenceBody(snap.RespHead, raw, verificationReadCap)
if err != nil {
return boundBody{}, err
}
if cut {
bb.truncated = true
}
if bb.truncated {
// The cap may cut a multi-byte rune: drop at most UTFMax-1 trailing bytes.
for i := 0; i < utf8.UTFMax-1 && !utf8.Valid(raw) && len(raw) > 0; i++ {
raw = raw[:len(raw)-1]
}
}
if bytes.IndexByte(raw, 0) >= 0 || !utf8.Valid(raw) {
bb.nonText = true
return bb, nil
}
bb.text = normalizeEvidenceText(string(raw))
return bb, nil
}
// assessFindingVerification computes the verification state of a finding from
// its bound traffic and the quotes the agent supplied. poe is the declared POE
// level (0 when absent). It never returns "verified" unless every rule passes.
func (s *Server) assessFindingVerification(ctx context.Context, findingID int64, poe int, quotes []string) (status, note string) {
tr, err := s.m.pg.GetFindingTraffic(ctx, findingID)
if err != nil {
return db.VerificationNotAssessed, "binding read error: " + err.Error()
}
var proofs []db.FindingTrafficBinding
for _, b := range tr.Bindings {
if b.Role == "proof" || b.Role == "verification" {
proofs = append(proofs, b)
}
}
if len(proofs) == 0 {
return db.VerificationUnverified, "no proof traffic bound"
}
if poe < verificationMinPOE {
return db.VerificationUnverified, fmt.Sprintf("POE L%d below L3", poe)
}
if len(quotes) == 0 {
return db.VerificationUnverified, "no evidence quotes provided"
}
var checked []string
for _, q := range quotes {
if utf8.RuneCountInString(normalizeEvidenceText(q)) >= verificationMinQuoteRunes {
checked = append(checked, q)
}
}
if len(checked) == 0 {
return db.VerificationUnverified, "evidence quote too short"
}
store := s.evidenceStore()
bodies := make([]boundBody, 0, len(proofs))
for _, b := range proofs {
bb, err := readBoundResponse(store, b.Snapshot)
if err != nil {
// Fail closed: an unreadable body is never an "unverified" verdict.
return db.VerificationNotAssessed, "evidence body unreadable: " + err.Error()
}
bodies = append(bodies, bb)
}
for _, q := range checked {
nq := normalizeEvidenceText(q)
if nq != "" && quoteInBodies(nq, bodies) {
continue
}
for _, bb := range bodies {
if bb.nonText {
return db.VerificationNotAssessed, "non-text evidence"
}
}
for _, bb := range bodies {
if bb.truncated {
return db.VerificationNotAssessed, "quote not found within read cap of bound evidence"
}
}
return db.VerificationUnverified, "quote not found in bound evidence: " + shortQuote(q)
}
return db.VerificationVerified, ""
}
func quoteInBodies(nq string, bodies []boundBody) bool {
for _, bb := range bodies {
if !bb.nonText && strings.Contains(bb.text, nq) {
return true
}
}
return false
}
// shortQuote returns the first verificationNoteQuoteLen runes of the trimmed
// quote, for use in a note.
func shortQuote(q string) string {
r := []rune(strings.TrimSpace(q))
if len(r) > verificationNoteQuoteLen {
r = r[:verificationNoteQuoteLen]
}
return string(r)
}
// seedFindingVerificationSchema adds the S1 optional properties to the
// update_finding_report schema of existing databases. Idempotent: a property is
// added only when the stored schema lacks it, and user edits are preserved.
func (s *Server) seedFindingVerificationSchema() {
raw, err := json.Marshal(s.toolUpdateFindingReport().InputSchema())
if err != nil {
log.Printf("[verification] tool schema: %v", err)
return
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil {
return
}
var properties map[string]json.RawMessage
if err := json.Unmarshal(obj["properties"], &properties); err != nil {
return
}
for _, name := range []string{"poe_level", "evidence_quotes"} {
if len(properties[name]) == 0 {
continue
}
_, err := s.m.pg.Exec(`UPDATE tools SET schema=jsonb_set(schema,ARRAY['properties',$2::text],$3::jsonb,true),updated_at=now()
WHERE key=$1 AND system AND NOT(COALESCE(schema->'properties','{}'::jsonb) ? $2)`, "update_finding_report", name, string(properties[name]))
if err != nil {
log.Printf("[verification] upgrade update_finding_report.%s: %v", name, err)
}
}
}
// seedFindingEvidenceSchema adds the optional counterevidence /
// confidence_rationale properties to the report_finding schema of existing
// databases (SeedTool is first-insert only, so without this the model would never
// see them). Idempotent: a property is added only when the stored schema lacks it,
// and user edits are preserved.
func (s *Server) seedFindingEvidenceSchema() {
for _, seed := range agent.BuiltinToolSeeds() {
if seed.Key != "report_finding" {
continue
}
raw, err := json.Marshal(seed.Schema)
if err != nil {
log.Printf("[evidence] report_finding schema: %v", err)
return
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil {
return
}
var properties map[string]json.RawMessage
if err := json.Unmarshal(obj["properties"], &properties); err != nil {
return
}
for _, name := range []string{"counterevidence", "confidence_rationale"} {
if len(properties[name]) == 0 {
continue
}
_, err := s.m.pg.Exec(`UPDATE tools SET schema=jsonb_set(schema,ARRAY['properties',$2::text],$3::jsonb,true),updated_at=now()
WHERE key=$1 AND system AND NOT(COALESCE(schema->'properties','{}'::jsonb) ? $2)`, "report_finding", name, string(properties[name]))
if err != nil {
log.Printf("[evidence] upgrade report_finding.%s: %v", name, err)
}
}
return
}
}