ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
detections / detections (push) Waiting to run
docs / links (push) Waiting to run
web / web (push) Waiting to run
263 lines
8.7 KiB
Go
263 lines
8.7 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"
|
|
)
|
|
|
|
// 사용자 노출 HTTP 에러 응답 문구. 아래 네 문구는 startFindingRetest HTTP 핸들러에서만
|
|
// 반환되고 에이전트 도구 경로(findingRetestTools 의 roTool/wrTool 설명·파라미터 설명·
|
|
// actool.Errorf)에는 닿지 않으므로 한국어로 번역한다. 반대로 도구 설명
|
|
// (get_finding_retest_context·record_finding_retest_result)·파라미터 설명·
|
|
// actool.Errorf(153행 회차 미연결 안내)와 seedFindingRetester 의 DB 시드 에이전트
|
|
// 이름(漏洞复测)·프로필·note(内置默认)는 에이전트가 읽는 두뇌 입력이거나 시드라
|
|
// 원문을 보존한다(각 지점 주석 참조, F16 두뇌 경계 계열).
|
|
const (
|
|
errFindingRetestNotesTooLong = "复测补充说明最多可输入 4000 个字符"
|
|
errFindingRetestAgentMissing = "漏洞复测 Agent 不存在或已禁用。请在 Agent 管理中设置 retester"
|
|
errFindingRetestToolRequired = "请为复测 Agent 启用并连接工具: "
|
|
errFindingRetestServiceStopping = "服务正在关闭"
|
|
)
|
|
|
|
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, errFindingRetestNotesTooLong)
|
|
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, errFindingRetestAgentMissing)
|
|
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, errFindingRetestToolRequired+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, errFindingRetestServiceStopping)
|
|
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", "读取当前复测会话关联的漏洞证据快照、复测状态、补充说明与当前任务约束。无参数,只能读取本会话。",
|
|
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("当前会话未关联复测记录,请从漏洞详情发起复测"), 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", "为当前复测会话保存唯一结论;原漏洞证据与报告保持不变。会话成功结束且结论为 fixed 时,系统自动将漏洞状态改为已修复;其他结论保留原状态。必须提供本次实际检查的证据,无法确认时写明阻塞原因。",
|
|
objSchema(map[string]any{
|
|
"verdict": map[string]any{"type": "string", "enum": []string{"reproduced", "fixed", "inconclusive"}},
|
|
"summary": strParam("本次复测结论摘要"),
|
|
"evidence": strParam("Markdown:本次实际步骤、观察、对照、结论依据;无法确认则列出已检查内容和阻塞原因"),
|
|
}, "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,'漏洞复测','从漏洞详情手动启动,读取原证据并保存独立复测结论。','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,'内置默认','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)
|
|
}
|
|
}
|