mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
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.
292 lines
13 KiB
Go
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
|
|
}
|