Files
ARTEX/db/finding_retests.go
T
BaaRai 1f4cb65ca6 feat(intercept): target-gate schema, opt-in flag and approval requests (S3b 1.2-1.4)
Storage, flag and request path of the approval gate for asset targets. The
gate itself is not wired into the tool hook yet (steps 1.5 to 1.7): with this
change nothing is extracted, resolved or blocked, whatever the flag says.

Schema and store (1.2)
- tasks.target_gate, conversations.target_gate (default false).
- intercept_pending.target_key and a partial unique index: one open request
  per scope (task or conversation) and host:port.
- target_pins (no foreign keys: a pin outlives its task, its conversation and
  its request row) and target_gate_exemption_log. All DDL is idempotent; no
  CHECK constraints; the cascade on intercept_pending.conversation_id stays.
- db/target_pins.go: pin lookup with port precedence, list, revoke,
  find-or-create of a request, and ResolveTargetRequest, which settles the
  row, supersedes the old pin and writes the new version in one transaction.
- Deleting a task or an archive stub revokes its pins and settles its open
  target requests in the delete transaction. DeleteConversation and
  DeleteConversations revoke the pins of the removed conversations in the
  same transaction as the delete.

Opt-in flag (1.3)
- Per task through the create and metadata-patch paths, per conversation
  through create and patch; exposed in the task DTO. A retest conversation
  takes the flag of the finding's task, never its pins. A spawned task starts
  with the gate off.
- The task flag is atomic and read on every call through TargetScope.Enabled.
  TargetScope now carries the Interceptor that owns the gate.

Request, wait, decide (1.4)
- pendingManager keeps a list of waiters per request; resolve wakes all.
- RequestTargets: find-or-create per target, one card per request, wait on
  channel, poll, context and wait bound. A plain request is never settled by
  a call that leaves. A request merged with an ask is bound to its creating
  call and a timeout never allows it.
- Decide records the approver (subject of the session token) and the remote
  address for every human decision, classic asks included. On a target
  request "allowed" pins the displayed addresses; that is the only writer of
  pins.
- A request raised while the global egress proxy is set says the approval is
  name-only and its pin is stored with name_only_effective.
- Routes: GET/POST .../target-pins and .../target-pins/{pid}/revoke under
  /api/tasks/{id} and /api/conversations/{id}; decision_source=target filter.
- ValidateExemptionHost: an exemption written as an IP literal may be private
  unicast, never loopback, link-local or metadata, unspecified or multicast.
2026-11-10 23:05:47 +01:00

292 lines
13 KiB
Go

package db
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
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.
// Snapshot is only loaded for the agent, never sent with the history list.
type FindingRetest struct {
ID int64 `json:"id"`
FindingID int64 `json:"finding_id"`
ConversationID *int64 `json:"conversation_id"`
Status string `json:"status"`
Verdict string `json:"verdict"`
Notes string `json:"notes"`
Snapshot json.RawMessage `json:"snapshot,omitempty"`
Summary string `json:"summary"`
Evidence string `json:"evidence"`
Error string `json:"error"`
CreatedAt time.Time `json:"created_at"`
StartedAt *time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at"`
}
const retestCols = `id, finding_id, conversation_id, status, verdict, notes, summary, evidence, error, created_at, started_at, finished_at`
// ActiveFindingRetest is the small status payload polled by the findings list.
// Finding IDs use the same string representation as the findings API.
type ActiveFindingRetest struct {
ID int64 `json:"id"`
FindingID int64 `json:"finding_id,string"`
ConversationID int64 `json:"conversation_id"`
Status string `json:"status"`
}
func (d *DB) ListActiveFindingRetests(ctx context.Context) ([]ActiveFindingRetest, error) {
rows, err := d.QueryContext(ctx, `SELECT id, finding_id, conversation_id, status FROM finding_retests
WHERE status IN ('pending','running') AND conversation_id IS NOT NULL ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ActiveFindingRetest{}
for rows.Next() {
var item ActiveFindingRetest
if err := rows.Scan(&item.ID, &item.FindingID, &item.ConversationID, &item.Status); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func scanRetest(row interface{ Scan(...any) error }) (*FindingRetest, error) {
r := &FindingRetest{}
err := row.Scan(&r.ID, &r.FindingID, &r.ConversationID, &r.Status, &r.Verdict, &r.Notes,
&r.Summary, &r.Evidence, &r.Error, &r.CreatedAt, &r.StartedAt, &r.FinishedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return r, err
}
// CreateFindingRetest atomically snapshots the source, creates its conversation
// and persists the first message. A finding row lock deduplicates simultaneous
// clicks across clients; an existing active run is returned without dispatching.
func (d *DB) CreateFindingRetest(ctx context.Context, findingID int64, notes string) (*FindingRetest, *Conversation, bool, error) {
tx, err := d.BeginTx(ctx, nil)
if err != nil {
return nil, nil, false, err
}
defer tx.Rollback()
var title string
var snapshot []byte
err = tx.QueryRowContext(ctx, `SELECT COALESCE(NULLIF(f.name,''), NULLIF(f.vulnclass,''), 'Uncategorized'),
jsonb_build_object('finding', to_jsonb(f),
'assets', COALESCE((SELECT jsonb_agg(to_jsonb(a)) FROM assets a WHERE f.asset_ids @> to_jsonb(ARRAY[a.id])), '[]'::jsonb),
'constraints', COALESCE((SELECT jsonb_agg(to_jsonb(c)) FROM task_constraints c JOIN tasks t ON t.exploration_id=c.exploration_id WHERE t.id=f.task_id), '[]'::jsonb))
FROM findings f WHERE f.id=$1 FOR UPDATE OF f`, findingID).Scan(&title, &snapshot)
if err != nil {
return nil, nil, false, err
}
r, err := scanRetest(tx.QueryRowContext(ctx, `SELECT `+retestCols+` FROM finding_retests WHERE finding_id=$1 AND status IN ('pending','running')`, findingID))
if err != nil {
return nil, nil, false, err
}
if r != nil {
return r, nil, false, nil
}
// Keep the title within the same limit as ordinary conversations.
if runes := []rune(title); len(runes) > 100 {
title = string(runes[:100])
}
// The retest conversation takes the target-gate opt-in of the finding's task.
// Pins are never copied: a pin belongs to the scope it was approved in.
c, err := scanConv(tx.QueryRowContext(ctx, `INSERT INTO conversations(agent_key,title,target_gate) VALUES ($1,$2,
COALESCE((SELECT t.target_gate FROM findings f JOIN tasks t ON t.id=f.task_id WHERE f.id=$3), false)) RETURNING `+convCols,
FindingRetestAgentKey, fmt.Sprintf("Retest #%d · %s", findingID, title), findingID))
if err != nil {
return nil, nil, false, err
}
r, err = scanRetest(tx.QueryRowContext(ctx, `INSERT INTO finding_retests(finding_id,conversation_id,notes,snapshot) VALUES ($1,$2,$3,$4) RETURNING `+retestCols,
findingID, c.ID, strings.TrimSpace(notes), snapshot))
if err != nil {
return nil, nil, false, err
}
msg := r.InitialMessage()
_, err = tx.ExecContext(ctx, `INSERT INTO conversation_activities(conversation_id,worker,kind,summary,detail) VALUES ($1,$2,'user',$3,$4)`,
c.ID, FindingRetestAgentKey, fmt.Sprintf("Please retest vulnerability #%d", findingID), msg)
if err != nil {
return nil, nil, false, err
}
if err = tx.Commit(); err != nil {
return nil, nil, false, err
}
return r, &c, true, nil
}
func (r *FindingRetest) InitialMessage() string {
msg := fmt.Sprintf("Please retest vulnerability #%d. First call get_finding_retest_context to read the original evidence and constraints associated with this conversation, then perform targeted verification, and finally call record_finding_retest_result to save the conclusion.", r.FindingID)
if r.Notes != "" {
msg += "\n\nAdditional notes for this retest:\n" + r.Notes
}
return msg
}
func (d *DB) ListFindingRetests(findingID int64) ([]*FindingRetest, error) {
rows, err := d.Query(`SELECT `+retestCols+` FROM finding_retests WHERE finding_id=$1 ORDER BY id DESC`, findingID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []*FindingRetest{}
for rows.Next() {
r, err := scanRetest(rows)
if err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
func (d *DB) FindingRetestForConversation(ctx context.Context, conversationID int64) (*FindingRetest, error) {
r, err := scanRetest(d.QueryRowContext(ctx, `SELECT `+retestCols+` FROM finding_retests WHERE conversation_id=$1`, conversationID))
if err != nil || r == nil {
return r, err
}
err = d.QueryRowContext(ctx, `SELECT snapshot FROM finding_retests WHERE id=$1`, r.ID).Scan(&r.Snapshot)
return r, err
}
// FailPendingRetestForConversation seals a conversation's unfinished retest when
// the runner could not even load it — the retest ID is unknown on that path, so
// the conversation ID is the only handle. Without it a transient read error
// leaves the row 'pending' forever: the findings list keeps showing "retesting"
// and every later retest launch is deduped against a run that is not happening,
// with only a process restart (RecoverFindingRetests) able to clear it.
func (d *DB) FailPendingRetestForConversation(conversationID int64, reason string) error {
_, err := d.Exec(`UPDATE finding_retests SET status='failed', error=$2, finished_at=now()
WHERE conversation_id=$1 AND status IN ('pending','running')`, conversationID, reason)
return err
}
func (d *DB) StartFindingRetest(ctx context.Context, id int64) (bool, error) {
res, err := d.ExecContext(ctx, `UPDATE finding_retests SET status='running', started_at=now() WHERE id=$1 AND status='pending'`, id)
if err != nil {
return false, err
}
n, err := res.RowsAffected()
return n == 1, err
}
// RecordFindingRetestResult never accepts a finding ID: ownership comes from the
// runtime conversation. Identical retries are safe; a second verdict is refused.
func (d *DB) RecordFindingRetestResult(ctx context.Context, conversationID int64, verdict, summary, evidence string) error {
if verdict != "reproduced" && verdict != "fixed" && verdict != "inconclusive" {
return errors.New("verdict must be reproduced / fixed / inconclusive")
}
summary, evidence = strings.TrimSpace(summary), strings.TrimSpace(evidence)
if summary == "" || evidence == "" {
return errors.New("summary and evidence must not be empty; when the result cannot be confirmed, describe the actual checks and the blocking reason")
}
if len(summary) > 16000 || len(evidence) > 128000 {
return errors.New("retest conclusion is too long (summary ≤ 16KB, evidence ≤ 128KB)")
}
res, err := d.ExecContext(ctx, `UPDATE finding_retests SET verdict=$2,summary=$3,evidence=$4
WHERE conversation_id=$1 AND status='running' AND (verdict='' OR (verdict=$2 AND summary=$3 AND evidence=$4))`, conversationID, verdict, summary, evidence)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err != nil {
return err
} else if n == 0 {
return ErrRetestNotRunning
}
return nil
}
// FinishFindingRetest seals the result. Cancellation/failure takes precedence
// over a staged verdict so an interrupted test cannot appear successfully fixed.
// Only a newly completed fixed verdict updates triage, in the same transaction.
func (d *DB) FinishFindingRetest(id int64, status, reason string) error {
if status != "completed" && status != "failed" && status != "stopped" {
return errors.New("invalid terminal retest status")
}
tx, err := d.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// Lock the finding before the retest, matching creation and cascading deletion.
var findingID int64
err = tx.QueryRow(`SELECT f.id FROM findings f WHERE f.id=(SELECT finding_id FROM finding_retests WHERE id=$1) FOR UPDATE OF f`, id).Scan(&findingID)
if errors.Is(err, sql.ErrNoRows) {
return nil // Finding/retest already deleted.
}
if err != nil {
return err
}
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,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.
//
// This used to be a bare UPDATE: when a retest verdict was "Fixed" the
// status did change, but channels configured with on_status_change never
// got a push — the status changed silently in the UI and operators only
// found out by opening the platform. The status update and the push
// event must be persisted together; SetFindingStatusTx internally
// handles details like "do not register when the status is unchanged".
// context.Background() is used: this whole function is the old ctx-free
// style (d.Begin()/tx.QueryRow/tx.Exec) with no cancellation signal to
// pass; bolting on a ctx parameter would drag in server-side call sites
// and several tests, beyond the scope of this change.
if _, _, _, _, err := SetFindingStatusTx(context.Background(), tx, findingID, FindingFixed); err != nil {
return err
}
}
return tx.Commit()
}
func (d *DB) RecoverFindingRetests() error {
_, err := d.Exec(`UPDATE finding_retests SET status='stopped', error='Server restarted; the retest was interrupted, please start it again', finished_at=now() WHERE status IN ('pending','running')`)
return err
}