mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
Comments, doc comments, UI labels, log and error messages, LLM prompt strings, markdown docs, test data and assertions. Identifiers, enum keys, routes, placeholders and code structure are unchanged. Notes: - docs/漏洞流量证据.md renamed to docs/vulnerability-traffic-evidence.md - tests that exercised CJK specific handling (GBK zip names, Chinese full text search) now use ASCII equivalents; go build passes and the test suite matches the pre translation baseline (remaining failures need a PostgreSQL DSN and fail identically on the original) - frontend mention syntax now uses English kinds (finding, asset, ...) matching the backend, chat mentions tests pass
264 lines
11 KiB
Go
264 lines
11 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const FindingRetestAgentKey = "retester"
|
|
|
|
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])
|
|
}
|
|
c, err := scanConv(tx.QueryRowContext(ctx, `INSERT INTO conversations(agent_key,title) VALUES ($1,$2) RETURNING `+convCols,
|
|
FindingRetestAgentKey, fmt.Sprintf("Retest #%d · %s", findingID, title)))
|
|
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 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)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil // A replay must not overwrite a later manual triage decision.
|
|
}
|
|
if 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
|
|
}
|