mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 19:50:54 +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
249 lines
8.0 KiB
Go
249 lines
8.0 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"log"
|
|
"net/http"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"github.com/Autumn-27/artex/agent"
|
|
"github.com/Autumn-27/artex/db"
|
|
"github.com/Autumn-27/artex/intercept"
|
|
actool "github.com/Autumn-27/norma/tool"
|
|
)
|
|
|
|
func (s *Server) listActiveFindingRetests(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
items, err := pg.ListActiveFindingRetests(r.Context())
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"retests": items})
|
|
}
|
|
|
|
func (s *Server) listFindingRetests(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
id, ok := pathInt(r, "id")
|
|
if !ok || id <= 0 {
|
|
writeErr(w, 400, "bad finding id")
|
|
return
|
|
}
|
|
f, err := pg.GetFinding(id)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if f == nil {
|
|
writeErr(w, 404, "finding not found")
|
|
return
|
|
}
|
|
items, err := pg.ListFindingRetests(id)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"retests": items})
|
|
}
|
|
|
|
func (s *Server) startFindingRetest(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
id, ok := pathInt(r, "id")
|
|
if !ok || id <= 0 {
|
|
writeErr(w, 400, "bad finding id")
|
|
return
|
|
}
|
|
var req struct {
|
|
Notes string `json:"notes"`
|
|
}
|
|
if !decodeConversationRequest(w, r, &req) {
|
|
return
|
|
}
|
|
req.Notes = strings.TrimSpace(req.Notes)
|
|
if utf8.RuneCountInString(req.Notes) > 4000 {
|
|
writeErr(w, 400, "retest notes are limited to 4000 characters")
|
|
return
|
|
}
|
|
f, err := pg.GetFinding(id)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if f == nil {
|
|
writeErr(w, 404, "finding not found")
|
|
return
|
|
}
|
|
a, err := pg.GetAgentByKey(db.FindingRetestAgentKey)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if a == nil || !a.Enabled {
|
|
writeErr(w, 409, "the vulnerability retest agent does not exist or is not enabled; configure retester under Agent management")
|
|
return
|
|
}
|
|
for _, key := range []string{"get_finding_retest_context", "record_finding_retest_result"} {
|
|
t, err := pg.GetTool(key)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if t == nil || !t.Enabled || !slices.Contains(t.Agents, a.Key) {
|
|
writeErr(w, 409, "enable and bind the tool for the retest agent: "+key)
|
|
return
|
|
}
|
|
}
|
|
if s.resolveChatAgent(&db.Conversation{AgentKey: a.Key}) == nil {
|
|
writeErr(w, 503, s.chatUnavailableReason())
|
|
return
|
|
}
|
|
if s.ctx.Err() != nil {
|
|
writeErr(w, 503, "server is shutting down")
|
|
return
|
|
}
|
|
retest, conv, created, err := pg.CreateFindingRetest(r.Context(), id, req.Notes)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
writeErr(w, 404, "finding not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if created {
|
|
busyKey := s.convBusyKey(conv.ID)
|
|
s.chatMu.Lock()
|
|
s.chatBusy[busyKey] = true
|
|
s.chatMu.Unlock()
|
|
s.runConversation(conv, retest.InitialMessage(), busyKey)
|
|
}
|
|
code := http.StatusOK
|
|
if created {
|
|
code = http.StatusAccepted
|
|
}
|
|
writeJSON(w, code, map[string]any{"retest": retest, "created": created})
|
|
}
|
|
|
|
// Both tools derive the finding from server-owned conversation context. Tool
|
|
// arguments cannot redirect a result into a different finding or conversation.
|
|
func (s *Server) findingRetestTools() []actool.CoreTool {
|
|
return []actool.CoreTool{
|
|
roTool("get_finding_retest_context", "Read the vulnerability evidence snapshot, retest status, supplementary notes, and current task constraints associated with the current retest session. No parameters; only the current session can be read.",
|
|
objSchema(map[string]any{}), func(ctx context.Context, _ json.RawMessage) (actool.Result, error) {
|
|
r, err := s.m.pg.FindingRetestForConversation(ctx, intercept.ConvIDFromContext(ctx))
|
|
if err != nil {
|
|
return actool.Errorf(err.Error()), nil
|
|
}
|
|
if r == nil {
|
|
return actool.Errorf("the current session is not associated with a retest record; start a retest from the finding details"), nil
|
|
}
|
|
var constraints []db.Constraint
|
|
f, err := s.m.pg.GetFinding(r.FindingID)
|
|
if err != nil {
|
|
return actool.Errorf(err.Error()), nil
|
|
}
|
|
if f != nil && f.TaskID != nil {
|
|
if task, ok := s.m.Task(strconv.FormatInt(*f.TaskID, 10)); ok {
|
|
constraints, err = task.Store.ListConstraints()
|
|
if err != nil {
|
|
return actool.Errorf(err.Error()), nil
|
|
}
|
|
}
|
|
}
|
|
return jsonResult(map[string]any{"retest": r, "current_constraints": constraints})
|
|
}),
|
|
wrTool("record_finding_retest_result", "Save the single conclusion for the current retest session; the original vulnerability evidence and report stay unchanged. When the session ends successfully with a fixed verdict, the system automatically sets the vulnerability status to Fixed; other verdicts keep the original status. Evidence from this actual check is required; when it cannot be confirmed, state the blocking reason.",
|
|
objSchema(map[string]any{
|
|
"verdict": map[string]any{"type": "string", "enum": []string{"reproduced", "fixed", "inconclusive"}},
|
|
"summary": strParam("summary of this retest conclusion"),
|
|
"evidence": strParam("Markdown: actual steps, observations, comparisons, and basis for the conclusion of this run; if unconfirmable, list what was checked and the blocking reason"),
|
|
}, "verdict", "summary", "evidence"), func(ctx context.Context, in json.RawMessage) (actool.Result, error) {
|
|
var a struct {
|
|
Verdict string `json:"verdict"`
|
|
Summary string `json:"summary"`
|
|
Evidence string `json:"evidence"`
|
|
}
|
|
if err := json.Unmarshal(in, &a); err != nil {
|
|
return actool.Errorf(err.Error()), nil
|
|
}
|
|
if err := s.m.pg.RecordFindingRetestResult(ctx, intercept.ConvIDFromContext(ctx), a.Verdict, a.Summary, a.Evidence); err != nil {
|
|
return actool.Errorf(err.Error()), nil
|
|
}
|
|
return jsonResult(map[string]any{"saved": true, "verdict": a.Verdict})
|
|
}),
|
|
}
|
|
}
|
|
|
|
// Seed the editable agent atomically, without an automatic discovery trigger.
|
|
// Once seeded, user edits/deletion survive restarts; a pre-existing key is kept.
|
|
func (s *Server) seedFindingRetester() error {
|
|
for _, t := range s.findingRetestTools() {
|
|
schema, _ := json.Marshal(t.InputSchema())
|
|
bindings, _ := json.Marshal([]string{db.FindingRetestAgentKey})
|
|
if err := s.m.pg.SeedTool(t.Name(), t.Description(), schema, bindings); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
const flag = "finding_retester_seed_v1"
|
|
tx, err := s.m.pg.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
// Lock this migration, including concurrent server initialization.
|
|
if _, err = tx.Exec(`SELECT pg_advisory_xact_lock(7337741010)`); err != nil {
|
|
return err
|
|
}
|
|
var done string
|
|
err = tx.QueryRow(`SELECT value FROM settings WHERE key=$1`, flag).Scan(&done)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return err
|
|
}
|
|
if done == "true" {
|
|
return nil
|
|
}
|
|
var id int64
|
|
err = tx.QueryRow(`INSERT INTO agents(key,name,description,role,builtin,enabled)
|
|
VALUES ($1,'Vulnerability Retest','Started manually from finding details; reads the original evidence and saves an independent retest conclusion.','assistant',false,true)
|
|
ON CONFLICT (key) DO NOTHING RETURNING id`, db.FindingRetestAgentKey).Scan(&id)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return err
|
|
}
|
|
if id > 0 {
|
|
var pid int64
|
|
if err = tx.QueryRow(`INSERT INTO agent_prompts(agent_id,version,template_text,note,updated_by)
|
|
VALUES ($1,1,$2,'built-in default','system') RETURNING id`, id, agent.RetesterDefaultPrompt).Scan(&pid); err != nil {
|
|
return err
|
|
}
|
|
if _, err = tx.Exec(`UPDATE agents SET current_prompt_id=$1 WHERE id=$2`, pid, id); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if _, err = tx.Exec(`INSERT INTO settings(key,value) VALUES ($1,'true') ON CONFLICT(key) DO UPDATE SET value='true'`, flag); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func (s *Server) finishRetest(id int64, status, reason string) {
|
|
if err := s.m.pg.FinishFindingRetest(id, status, reason); err != nil {
|
|
// Surface failure to the conversation runner rather than inventing a result.
|
|
log.Printf("[retest %d] finish: %v", id, err)
|
|
}
|
|
}
|