mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-11-05 03:53:49 +08:00
feat(findings): consolidate duplicate reports into expandable cases
This commit is contained in:
1 parent
a91b27feb7
commit
24d4726aee
29 files changed
+3051
-63
No files matched your search
+39
-6
@@ -503,7 +503,21 @@ func (s *Server) runConversationTurn(ctx context.Context, cancel context.CancelC
|
||||
}()
|
||||
// Only the first turn executes a historical retest. Follow-up conversation
|
||||
// turns may explain the sealed result; the result tool refuses to overwrite it.
|
||||
finishStatus, finishReason := "failed", "复测未能启动"
|
||||
finishStatus, finishReason := "failed", "Agent 未能启动"
|
||||
if c.AgentKey == "reporter" {
|
||||
defer func() {
|
||||
state := "done"
|
||||
reason := ""
|
||||
if finishStatus != "completed" || ctx.Err() != nil {
|
||||
state = "failed"
|
||||
reason = finishReason
|
||||
if ctx.Err() != nil {
|
||||
reason = "整理已停止或服务关闭"
|
||||
}
|
||||
}
|
||||
_, _ = s.m.pg.Exec(`UPDATE finding_case_review_runs SET state=$2,error=$3 WHERE conversation_id=$1 AND state='running'`, c.ID, state, reason)
|
||||
}()
|
||||
}
|
||||
if c.AgentKey == db.FindingRetestAgentKey {
|
||||
// Read without the run cancellation so an immediate stop still seals pending.
|
||||
r, err := s.m.pg.FindingRetestForConversation(context.Background(), c.ID)
|
||||
@@ -668,11 +682,17 @@ func (s *Server) runAndPump(agentKey string, item triggeredRun) {
|
||||
// Caller holds queueMu.
|
||||
func (s *Server) nextTriggerRun(agentKey string, cfg triggerBehavior) triggeredRun {
|
||||
q := s.triggerQ[agentKey]
|
||||
if cfg.runMode == "parallel" || cfg.mergeMode == "none" {
|
||||
if q[0].conversationID > 0 || cfg.runMode == "parallel" || cfg.mergeMode == "none" {
|
||||
s.triggerQ[agentKey] = q[1:]
|
||||
return q[0]
|
||||
}
|
||||
if cfg.mergeMode == "all" {
|
||||
hasManual := false
|
||||
for _, item := range q {
|
||||
if item.conversationID > 0 {
|
||||
hasManual = true
|
||||
}
|
||||
}
|
||||
if cfg.mergeMode == "all" && !hasManual {
|
||||
s.triggerQ[agentKey] = q[:0:0]
|
||||
return mergeAllRuns(q)
|
||||
}
|
||||
@@ -685,7 +705,7 @@ func (s *Server) nextTriggerRun(agentKey string, cfg triggerBehavior) triggeredR
|
||||
group := []triggeredRun{head}
|
||||
rest := q[:0:0] // keep non-matching items in order
|
||||
for _, it := range q[1:] {
|
||||
if it.mergeable && it.taskID == head.taskID {
|
||||
if it.conversationID == 0 && it.mergeable && it.taskID == head.taskID {
|
||||
group = append(group, it)
|
||||
} else {
|
||||
rest = append(rest, it)
|
||||
@@ -798,12 +818,25 @@ func mergeAllRuns(items []triggeredRun) triggeredRun {
|
||||
func (s *Server) runTriggeredRun(item triggeredRun) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
if item.conversationID > 0 {
|
||||
_, _ = s.m.pg.Exec(`UPDATE finding_case_review_runs SET state='failed',error=$2 WHERE conversation_id=$1`, item.conversationID, fmt.Sprint(r))
|
||||
}
|
||||
log.Printf("[trigger] run for %s panicked: %v", item.agentKey, r)
|
||||
}
|
||||
}()
|
||||
pg := s.m.pg
|
||||
c, err := pg.CreateConversation(item.agentKey, firstLine(item.title, 60), nil)
|
||||
if err != nil {
|
||||
var c *db.Conversation
|
||||
var err error
|
||||
if item.conversationID > 0 {
|
||||
c, err = pg.GetConversation(item.conversationID)
|
||||
_, _ = pg.Exec(`UPDATE finding_case_review_runs SET state='running' WHERE conversation_id=$1`, item.conversationID)
|
||||
} else {
|
||||
c, err = pg.CreateConversation(item.agentKey, firstLine(item.title, 60), nil)
|
||||
}
|
||||
if err != nil || c == nil {
|
||||
if item.conversationID > 0 {
|
||||
_, _ = pg.Exec(`UPDATE finding_case_review_runs SET state='failed',error='整理会话不可用' WHERE conversation_id=$1`, item.conversationID)
|
||||
}
|
||||
log.Printf("[trigger] create conversation for %s failed: %v", item.agentKey, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ func rawString(raw json.RawMessage) string {
|
||||
|
||||
// ---- Task (frontend "Task") ---- created_at as RFC3339, plus a derived status.
|
||||
type TaskDTO struct {
|
||||
DistinctFindings *db.FindingDistinctStats `json:"distinct_findings,omitempty"`
|
||||
|
||||
ID string `json:"id"`
|
||||
ExplorationID int64 `json:"exploration_id"`
|
||||
Name string `json:"name"` // 可选任务名称;空=未命名
|
||||
@@ -303,6 +305,9 @@ func coverageAssetRefDTO(ref db.AssetRef) CoverageAssetRefDTO {
|
||||
// ---- Finding (frontend "Finding") ----
|
||||
|
||||
type FindingDTO struct {
|
||||
CaseID string `json:"case_id,omitempty"`
|
||||
MemberIDs []int64 `json:"member_ids,omitempty"`
|
||||
|
||||
TrafficCount int `json:"traffic_count"`
|
||||
EvidenceVersion int64 `json:"evidence_version"`
|
||||
ReportEvidenceVersion int64 `json:"report_evidence_version"`
|
||||
@@ -457,6 +462,12 @@ func findingFromDB(f *db.DBFinding, assets map[int64]*db.Asset) FindingDTO {
|
||||
Report: f.Report,
|
||||
TS: rfc3339(f.CreatedAt),
|
||||
}
|
||||
if f.ExportCaseID > 0 {
|
||||
d.ID = "case:" + i64s(f.ExportCaseID)
|
||||
d.FindingID = ""
|
||||
d.CaseID = i64s(f.ExportCaseID)
|
||||
d.MemberIDs = f.ExportMemberIDs
|
||||
}
|
||||
d.Assets = findingAssetDTOs(f.AssetIDs, assets)
|
||||
if f.TaskID != nil {
|
||||
d.TaskID = i64s(*f.TaskID)
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
|
||||
type findingCaseRequest struct {
|
||||
TaskID json.RawMessage `json:"task_id"`
|
||||
FindingID json.RawMessage `json:"finding_id"`
|
||||
IDs []json.RawMessage `json:"finding_ids"`
|
||||
CaseID json.RawMessage `json:"case_id"`
|
||||
Version int64 `json:"version"`
|
||||
Title string `json:"title"`
|
||||
Reason string `json:"reason"`
|
||||
Report string `json:"report"`
|
||||
Severity string `json:"severity"`
|
||||
SeverityReason string `json:"severity_reason"`
|
||||
Page int `json:"page"`
|
||||
}
|
||||
|
||||
func (a findingCaseRequest) ids() []int64 {
|
||||
out := []int64{}
|
||||
for _, raw := range a.IDs {
|
||||
out = append(out, parseProfileID(raw))
|
||||
}
|
||||
return out
|
||||
}
|
||||
func caseHTTPError(w http.ResponseWriter, err error) {
|
||||
code := 400
|
||||
if errors.Is(err, db.ErrFindingCaseConflict) {
|
||||
code = 409
|
||||
}
|
||||
writeErr(w, code, err.Error())
|
||||
}
|
||||
func decodeCaseRequest(w http.ResponseWriter, r *http.Request, a any) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||
if err := json.NewDecoder(r.Body).Decode(a); err != nil {
|
||||
writeErr(w, 400, "无效请求正文")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func (s *Server) caseFindingDTO(f *db.DBFinding) FindingDTO {
|
||||
return findingFromDB(f, s.resolveAssetIDs(f.AssetIDs))
|
||||
}
|
||||
func (s *Server) listFindingCases(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
page := findingPaginationParam(q.Get("page"), 1, 0)
|
||||
size := findingPaginationParam(q.Get("limit"), 20, 100)
|
||||
rows, total, err := s.m.pg.ListFindingCases(findingFilterFromQuery(q), page, size)
|
||||
if err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
items := []map[string]any{}
|
||||
for _, row := range rows {
|
||||
v := map[string]any{"case": row.Case, "matched_ids": row.MatchedIDs}
|
||||
if row.FindingID > 0 {
|
||||
f, err := s.m.pg.FindingCaseSummary(row.FindingID)
|
||||
if err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
if f == nil {
|
||||
continue
|
||||
}
|
||||
f.Report = ""
|
||||
v["finding"] = s.caseFindingDTO(f)
|
||||
}
|
||||
items = append(items, v)
|
||||
}
|
||||
stats, err := s.m.pg.DistinctFindingStats(q.Get("task_id"))
|
||||
if err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"items": items, "total": total, "page": page, "page_size": size, "stats": stats})
|
||||
}
|
||||
func (s *Server) findingCaseAccess(w http.ResponseWriter, r *http.Request, mutation bool) (*db.FindingCase, bool) {
|
||||
id := int64(atoiDefault(r.PathValue("id"), 0))
|
||||
c, err := s.m.pg.GetFindingCase(id)
|
||||
if err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return nil, false
|
||||
}
|
||||
if c == nil {
|
||||
writeErr(w, 404, "漏洞组不存在")
|
||||
return nil, false
|
||||
}
|
||||
if ctx := r.URL.Query().Get("context_task"); ctx != "" {
|
||||
t := s.m.ResolveTask(ctx)
|
||||
_, inherited, allowed := findingProvenanceInTask(t, c.TaskID)
|
||||
if !allowed {
|
||||
writeErr(w, 404, "任务中没有该漏洞组")
|
||||
return nil, false
|
||||
}
|
||||
if mutation && inherited {
|
||||
writeErr(w, 403, "继承漏洞组只读")
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return c, true
|
||||
}
|
||||
func (s *Server) getFindingCase(w http.ResponseWriter, r *http.Request) {
|
||||
c, ok := s.findingCaseAccess(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
events, err := s.m.pg.FindingCaseEvents(c.ID)
|
||||
if err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"case": c, "events": events})
|
||||
}
|
||||
func (s *Server) getFindingCaseMembers(w http.ResponseWriter, r *http.Request) {
|
||||
c, ok := s.findingCaseAccess(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page := findingPaginationParam(r.URL.Query().Get("page"), 1, 0)
|
||||
size := findingPaginationParam(r.URL.Query().Get("limit"), 20, 100)
|
||||
fs, total, err := s.m.pg.FindingCaseMembers(c.ID, page, size)
|
||||
if err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
assets := s.resolveFindingAssets(fs)
|
||||
items := []FindingDTO{}
|
||||
for _, f := range fs {
|
||||
dto := findingFromDB(f, assets)
|
||||
if ctx := r.URL.Query().Get("context_task"); ctx != "" {
|
||||
dto.SourceTaskID, dto.Inherited, _ = findingProvenanceInTask(s.m.ResolveTask(ctx), f.TaskID)
|
||||
}
|
||||
items = append(items, dto)
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"items": items, "total": total, "page": page, "page_size": size})
|
||||
}
|
||||
func (s *Server) removeFindingCaseMember(w http.ResponseWriter, r *http.Request) {
|
||||
c, ok := s.findingCaseAccess(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fid := int64(atoiDefault(r.PathValue("fid"), 0))
|
||||
var body struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if !decodeCaseRequest(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(body.Reason) == "" {
|
||||
body.Reason = "人工移出归并组"
|
||||
}
|
||||
if err := s.m.pg.RemoveFindingCaseMember(r.Context(), c.ID, fid, body.Reason); err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]bool{"ok": true})
|
||||
}
|
||||
func (s *Server) caseSuggestions(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := s.m.pg.FindingCaseSuggestions(int64(atoiDefault(r.URL.Query().Get("task_id"), 0)))
|
||||
if err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, items)
|
||||
}
|
||||
func (s *Server) resolveCaseSuggestion(w http.ResponseWriter, r *http.Request) {
|
||||
var a struct {
|
||||
Accept bool `json:"accept"`
|
||||
}
|
||||
if !decodeCaseRequest(w, r, &a) {
|
||||
return
|
||||
}
|
||||
id, err := s.m.pg.ResolveFindingCaseSuggestion(r.Context(), int64(atoiDefault(r.PathValue("id"), 0)), a.Accept)
|
||||
if err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]string{"case_id": i64s(id)})
|
||||
}
|
||||
func (s *Server) mergeFindingCase(w http.ResponseWriter, r *http.Request) {
|
||||
var a findingCaseRequest
|
||||
if !decodeCaseRequest(w, r, &a) {
|
||||
return
|
||||
}
|
||||
id, err := s.m.pg.MergeFindingCase(r.Context(), parseProfileID(a.TaskID), a.ids(), a.Title, a.Reason, "human")
|
||||
if err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]string{"case_id": i64s(id)})
|
||||
}
|
||||
func (s *Server) updateCaseReport(w http.ResponseWriter, r *http.Request) {
|
||||
c, ok := s.findingCaseAccess(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var a findingCaseRequest
|
||||
if !decodeCaseRequest(w, r, &a) {
|
||||
return
|
||||
}
|
||||
if err := s.m.pg.UpdateFindingCaseReport(r.Context(), c.ID, a.Version, a.Title, a.Report, a.Severity, a.SeverityReason); err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) findingCaseTools() []actool.CoreTool {
|
||||
props := map[string]any{"finding_id": idParam("独立 findings 记录 ID,不是探索节点 ID"), "finding_ids": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}, "description": "独立漏洞记录 ID 列表"}, "task_id": idParam("同一来源任务 ID"), "case_id": idParam("归并组 ID"), "version": idParam("get_finding_case 实际读取的 version"), "page": idParam("分页,从1开始,每页20条"), "title": strParam("同一实际缺陷的名称"), "reason": strParam("具体证据与归并理由,不能仅类型相同"), "report": strParam("全部成员证据的完整统一 Markdown 报告"), "severity": strParam("critical|high|medium|low"), "severity_reason": strParam("基于已验证影响的评级依据,不直接取最高等级")}
|
||||
specs := []struct {
|
||||
name, desc string
|
||||
required []string
|
||||
}{
|
||||
{"search_finding_duplicates", "只读检索同任务候选,返回摘要与资产;等级不同也可能重复。支持分页,按需读取详情。", []string{"finding_id"}},
|
||||
{"get_finding_record", "读取独立漏洞完整原始证据和报告,返回探索节点编号以便追溯执行记录。", []string{"finding_id"}},
|
||||
{"merge_finding_records", "证据明确证明同一缺陷时归并;保留所有原始记录。不跨任务、不改变原评级,不可绕过人工否决。", []string{"task_id", "finding_ids", "title", "reason"}},
|
||||
{"suggest_finding_merge", "证据不足时提交疑似重复建议,待人工确认;不是自动合并。只允许两条同任务记录。", []string{"task_id", "finding_ids", "title", "reason"}},
|
||||
{"get_finding_case", "读取归并组和成员分页。先记录version,再读取成员的原报告、证据及流量;版本变化必须重新读取。", []string{"case_id"}},
|
||||
{"update_finding_case_report", "保存统一报告及评级理由,version必须来自实际读取。保留各条原报告,不新建漏洞。", []string{"case_id", "version", "title", "report", "severity", "severity_reason"}},
|
||||
}
|
||||
out := []actool.CoreTool{}
|
||||
for _, spec := range specs {
|
||||
spec := spec
|
||||
handler := func(ctx context.Context, in json.RawMessage) (actool.Result, error) {
|
||||
var a findingCaseRequest
|
||||
if err := json.Unmarshal(in, &a); err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
}
|
||||
v, err := s.performFindingCaseTool(ctx, spec.name, a)
|
||||
if err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
}
|
||||
raw, _ := json.Marshal(v)
|
||||
return actool.Text(string(raw)), nil
|
||||
}
|
||||
schemaProps := map[string]any{}
|
||||
for _, key := range spec.required {
|
||||
schemaProps[key] = props[key]
|
||||
}
|
||||
if spec.name == "search_finding_duplicates" || spec.name == "get_finding_case" {
|
||||
schemaProps["page"] = props["page"]
|
||||
}
|
||||
schema := objSchema(schemaProps, spec.required...)
|
||||
if strings.HasPrefix(spec.name, "get_") || strings.HasPrefix(spec.name, "search_") {
|
||||
out = append(out, roTool(spec.name, spec.desc, schema, handler))
|
||||
} else {
|
||||
out = append(out, wrTool(spec.name, spec.desc, schema, handler))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (s *Server) performFindingCaseTool(ctx context.Context, name string, a findingCaseRequest) (any, error) {
|
||||
if err := s.checkCaseReviewScope(ctx, name, a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fid := parseProfileID(a.FindingID)
|
||||
cid := parseProfileID(a.CaseID)
|
||||
page := a.Page
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
switch name {
|
||||
case "search_finding_duplicates":
|
||||
items, total, err := s.m.pg.FindingCaseCandidates(fid, page, 20)
|
||||
return map[string]any{"items": items, "total": total, "page": page, "page_size": 20}, err
|
||||
case "get_finding_record":
|
||||
f, err := s.m.pg.GetFinding(fid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if f == nil {
|
||||
return nil, errors.New("漏洞不存在")
|
||||
}
|
||||
dto := s.caseFindingDTO(f)
|
||||
cid, err := s.m.pg.FindingCaseID(f.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cid > 0 {
|
||||
dto.CaseID = i64s(cid)
|
||||
}
|
||||
return map[string]any{"finding": dto, "finding_node_id": f.NodeID}, nil
|
||||
case "merge_finding_records":
|
||||
id, err := s.m.pg.MergeFindingCase(ctx, parseProfileID(a.TaskID), a.ids(), a.Title, a.Reason, "reporter")
|
||||
return map[string]string{"case_id": i64s(id)}, err
|
||||
case "suggest_finding_merge":
|
||||
ids := a.ids()
|
||||
if len(ids) != 2 {
|
||||
return nil, errors.New("疑似重复建议需要两条记录")
|
||||
}
|
||||
id, err := s.m.pg.SuggestFindingCase(ctx, parseProfileID(a.TaskID), ids[0], ids[1], a.Title, a.Reason)
|
||||
return map[string]string{"suggestion_id": i64s(id)}, err
|
||||
case "get_finding_case":
|
||||
c, err := s.m.pg.GetFindingCase(cid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c == nil {
|
||||
return nil, errors.New("漏洞组不存在")
|
||||
}
|
||||
fs, total, err := s.m.pg.FindingCaseMembers(cid, page, 20)
|
||||
members := []FindingDTO{}
|
||||
for _, f := range fs {
|
||||
members = append(members, s.caseFindingDTO(f))
|
||||
}
|
||||
return map[string]any{"case": c, "members": members, "total": total, "page": page, "page_size": 20}, err
|
||||
case "update_finding_case_report":
|
||||
err := s.m.pg.UpdateFindingCaseReport(ctx, cid, a.Version, a.Title, a.Report, a.Severity, a.SeverityReason)
|
||||
return map[string]bool{"ok": err == nil}, err
|
||||
}
|
||||
return nil, errors.New("未知归并工具")
|
||||
}
|
||||
func idParam(desc string) map[string]any {
|
||||
return map[string]any{"type": "integer", "description": desc}
|
||||
}
|
||||
func (s *Server) seedFindingCaseTools() {
|
||||
_, _ = s.m.pg.Exec(`UPDATE finding_case_review_runs SET state='failed',error='服务重启中断,请重新提交整理' WHERE state IN ('queued','running')`)
|
||||
a, err := s.m.pg.GetAgentByKey("reporter")
|
||||
if err != nil || a == nil {
|
||||
return
|
||||
}
|
||||
const flag = "finding_case_tools_v1"
|
||||
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
|
||||
return
|
||||
}
|
||||
names := []string{}
|
||||
for _, t := range s.findingCaseTools() {
|
||||
schema, _ := json.Marshal(t.InputSchema())
|
||||
if err := s.m.pg.SeedTool(t.Name(), t.Description(), schema, json.RawMessage(`["reporter"]`)); err != nil {
|
||||
return
|
||||
}
|
||||
names = append(names, t.Name())
|
||||
}
|
||||
if err := s.m.pg.AddAgentToToolBinding("reporter", names); err != nil {
|
||||
return
|
||||
}
|
||||
_ = s.m.pg.SetSetting(flag, "true")
|
||||
}
|
||||
func (s *Server) reviewFindingCases(w http.ResponseWriter, r *http.Request) {
|
||||
var a findingCaseRequest
|
||||
if !decodeCaseRequest(w, r, &a) {
|
||||
return
|
||||
}
|
||||
ids := a.ids()
|
||||
if cid := parseProfileID(a.CaseID); cid > 0 {
|
||||
c, err := s.m.pg.GetFindingCase(cid)
|
||||
if err != nil || c == nil || !c.Active {
|
||||
writeErr(w, 400, "漏洞组不可用")
|
||||
return
|
||||
}
|
||||
if ctx := r.URL.Query().Get("context_task"); ctx != "" && (c.TaskID == nil || ctx != i64s(*c.TaskID)) {
|
||||
writeErr(w, 403, "继承漏洞组只读")
|
||||
return
|
||||
}
|
||||
fs, _, err := s.m.pg.FindingCaseMembers(cid, 1, 100000)
|
||||
if err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
ids = nil
|
||||
for _, f := range fs {
|
||||
ids = append(ids, f.ID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 || (len(a.CaseID) == 0 && len(ids) > 200) {
|
||||
writeErr(w, 400, "请选择1至200条原始上报")
|
||||
return
|
||||
}
|
||||
reporter, err := s.m.pg.GetAgentByKey("reporter")
|
||||
if err != nil || reporter == nil || !reporter.Enabled || s.chatAgentRef() == nil {
|
||||
writeErr(w, 409, "报告 Agent 未启用或模型不可用")
|
||||
return
|
||||
}
|
||||
batches := map[int64][]int64{}
|
||||
for _, fid := range ids {
|
||||
f, err := s.m.pg.FindingCaseSummary(fid)
|
||||
if err != nil || f == nil || f.TaskID == nil {
|
||||
writeErr(w, 400, "所选漏洞来源任务不可用")
|
||||
return
|
||||
}
|
||||
batches[*f.TaskID] = append(batches[*f.TaskID], fid)
|
||||
}
|
||||
// Validate every source before starting any batch.
|
||||
for taskID := range batches {
|
||||
if _, ok := s.m.Task(i64s(taskID)); !ok {
|
||||
writeErr(w, 409, "任务不可用")
|
||||
return
|
||||
}
|
||||
}
|
||||
runs := []map[string]any{}
|
||||
for taskID, fids := range batches {
|
||||
t, ok := s.m.Task(strconv.FormatInt(taskID, 10))
|
||||
if !ok {
|
||||
writeErr(w, 409, "任务不可用")
|
||||
return
|
||||
}
|
||||
raw, _ := json.Marshal(fids)
|
||||
msg := fmt.Sprintf("人工选择整理:仅允许处理本任务所选 finding_ids=%s。先保留/生成各条独立报告,再在所选范围内判断同一缺陷,明确则归并,不确定提交建议,已成组则更新统一报告。不得归并未选择的记录,不探测目标。", raw)
|
||||
c, err := s.m.pg.CreateConversation("reporter", "漏洞整理 · task#"+t.ID, nil)
|
||||
if err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
if _, err := s.m.pg.Exec(`INSERT INTO finding_case_review_runs(conversation_id,task_id,finding_ids) VALUES($1,$2,$3)`, c.ID, taskID, string(raw)); err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
item := triggeredRun{agentKey: "reporter", title: c.Title, message: msg, taskID: taskID, taskDesc: t.Description, taskGoal: t.Goal, conversationID: c.ID}
|
||||
cfg := s.readTriggerBehavior("reporter")
|
||||
s.queueMu.Lock()
|
||||
s.triggerCfg["reporter"] = cfg
|
||||
s.triggerQ["reporter"] = append(s.triggerQ["reporter"], item)
|
||||
s.pumpLocked("reporter")
|
||||
s.queueMu.Unlock()
|
||||
runs = append(runs, map[string]any{"conversation_id": c.ID, "task_id": i64s(taskID)})
|
||||
}
|
||||
writeJSON(w, 202, map[string]any{"runs": runs})
|
||||
}
|
||||
func (s *Server) caseReviewRuns(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := s.m.pg.Query(`SELECT conversation_id,task_id,state,error FROM finding_case_review_runs ORDER BY conversation_id DESC LIMIT 50`)
|
||||
if err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var conv int64
|
||||
var task sql.NullInt64
|
||||
var state, msg string
|
||||
if err := rows.Scan(&conv, &task, &state, &msg); err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
items = append(items, map[string]any{"conversation_id": conv, "task_id": i64s(task.Int64), "state": state, "error": msg})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, items)
|
||||
}
|
||||
|
||||
// Manual reviews cannot mutate a finding outside the explicit selection.
|
||||
func (s *Server) checkCaseReviewScope(ctx context.Context, name string, a findingCaseRequest) error {
|
||||
conv := intercept.ConvIDFromContext(ctx)
|
||||
if conv == 0 {
|
||||
return nil
|
||||
}
|
||||
var raw json.RawMessage
|
||||
err := s.m.pg.QueryRow(`SELECT finding_ids FROM finding_case_review_runs WHERE conversation_id=$1`, conv).Scan(&raw)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
var ids []int64
|
||||
if err := json.Unmarshal(raw, &ids); err != nil {
|
||||
return err
|
||||
}
|
||||
allowed := map[int64]bool{}
|
||||
for _, id := range ids {
|
||||
allowed[id] = true
|
||||
}
|
||||
switch name {
|
||||
case "merge_finding_records", "suggest_finding_merge", "update_finding_report":
|
||||
checkIDs := a.ids()
|
||||
if name == "update_finding_report" {
|
||||
checkIDs = []int64{parseProfileID(a.FindingID)}
|
||||
}
|
||||
for _, id := range checkIDs {
|
||||
if !allowed[id] {
|
||||
return errors.New("人工整理只能归并所选记录")
|
||||
}
|
||||
}
|
||||
case "update_finding_case_report":
|
||||
fs, _, err := s.m.pg.FindingCaseMembers(parseProfileID(a.CaseID), 1, 100000)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
found := false
|
||||
for _, f := range fs {
|
||||
if allowed[f.ID] {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return errors.New("该漏洞组不属于人工所选范围")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
type findingCaseExportPlan struct {
|
||||
cases map[int64]*db.FindingCase
|
||||
membership map[int64]int64
|
||||
selected map[int64]bool
|
||||
}
|
||||
|
||||
// Expand selection to complete cases before the evidence store freezes snapshots.
|
||||
func (s *Server) prepareFindingCaseExport(fs []*db.DBFinding) ([]*db.DBFinding, *findingCaseExportPlan, error) {
|
||||
plan := &findingCaseExportPlan{cases: map[int64]*db.FindingCase{}, membership: map[int64]int64{}, selected: map[int64]bool{}}
|
||||
expanded := append([]*db.DBFinding{}, fs...)
|
||||
seen := map[int64]bool{}
|
||||
for _, f := range fs {
|
||||
seen[f.ID] = true
|
||||
plan.selected[f.ID] = true
|
||||
}
|
||||
for _, f := range fs {
|
||||
cid, err := s.m.pg.FindingCaseID(f.ID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if cid == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := plan.cases[cid]; ok {
|
||||
continue
|
||||
}
|
||||
c, err := s.m.pg.GetFindingCase(cid)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if c == nil || !c.Active {
|
||||
continue
|
||||
}
|
||||
plan.cases[cid] = c
|
||||
members, _, err := s.m.pg.FindingCaseMembers(cid, 1, 1000000)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, m := range members {
|
||||
plan.membership[m.ID] = cid
|
||||
if !seen[m.ID] {
|
||||
seen[m.ID] = true
|
||||
expanded = append(expanded, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
return expanded, plan, nil
|
||||
}
|
||||
|
||||
// Composites are built AFTER staging real records, so their traffic attachments
|
||||
// remain private frozen export copies, not aliases to live or deleted evidence.
|
||||
func (s *Server) consolidateFindingCaseExport(fs []*db.DBFinding, plan *findingCaseExportPlan, includeOriginals bool) ([]*db.DBFinding, error) {
|
||||
out := []*db.DBFinding{}
|
||||
emitted := map[int64]bool{}
|
||||
for _, f := range fs {
|
||||
cid := plan.membership[f.ID]
|
||||
if cid == 0 {
|
||||
if plan.selected[f.ID] {
|
||||
out = append(out, f)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !emitted[cid] {
|
||||
emitted[cid] = true
|
||||
c := plan.cases[cid]
|
||||
latest, err := s.m.pg.GetFindingCase(cid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stale := latest == nil || latest.Version != c.Version || c.ReportVersion != c.Version
|
||||
title := c.Title
|
||||
sev := c.Severity
|
||||
if sev == "" {
|
||||
sev = "unassessed"
|
||||
}
|
||||
composite := &db.DBFinding{ID: f.ID, ExportCaseID: c.ID, TaskID: c.TaskID, TaskDescription: f.TaskDescription, Name: title, VulnClass: "归并漏洞", Severity: sev, Status: f.Status, CreatedAt: c.CreatedAt, Report: c.Report, EvidenceVersion: c.Version, ReportEvidenceVersion: c.ReportVersion}
|
||||
assets := map[int64]bool{}
|
||||
var refs []string
|
||||
var evidence strings.Builder
|
||||
for _, member := range fs {
|
||||
if plan.membership[member.ID] != cid {
|
||||
continue
|
||||
}
|
||||
composite.ExportMemberIDs = append(composite.ExportMemberIDs, member.ID)
|
||||
for _, aid := range member.AssetIDs {
|
||||
if !assets[aid] {
|
||||
assets[aid] = true
|
||||
composite.AssetIDs = append(composite.AssetIDs, aid)
|
||||
}
|
||||
}
|
||||
refs = append(refs, fmt.Sprintf("#%d [%s] %s", member.ID, member.Severity, member.Name))
|
||||
fmt.Fprintf(&evidence, "原始上报 #%d:%s\n%s\n\n", member.ID, member.Summary, member.Evidence)
|
||||
if member.Report != "" {
|
||||
fmt.Fprintf(&evidence, "原始报告 #%d:\n%s\n\n", member.ID, member.Report)
|
||||
}
|
||||
for _, b := range member.TrafficBindings {
|
||||
b.Note = fmt.Sprintf("来自上报 #%d;%s", member.ID, b.Note)
|
||||
composite.TrafficBindings = append(composite.TrafficBindings, b)
|
||||
}
|
||||
}
|
||||
composite.Summary = fmt.Sprintf("漏洞文件夹 #%d;原始记录:%s。统一评级依据:%s", cid, strings.Join(refs, ";"), c.SeverityReason)
|
||||
composite.TrafficCount = len(composite.TrafficBindings)
|
||||
if stale || c.Report == "" {
|
||||
composite.Report = "**统一报告尚未生成或已过期,以下提供全部原始证据,不能视为完整复现已验证。**\n\n" + c.Report
|
||||
composite.Evidence = evidence.String()
|
||||
composite.ReportEvidenceVersion = -1
|
||||
}
|
||||
out = append(out, composite)
|
||||
}
|
||||
if includeOriginals {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
)
|
||||
|
||||
func testCaseServer(t *testing.T) (*Server, int64, []int64) {
|
||||
t.Helper()
|
||||
dsn, _, err := db.DSN()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pg, err := db.Open(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { pg.Close() })
|
||||
task, err := pg.CreateTask("case server", "goal", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := []int64{}
|
||||
for _, sev := range []string{"high", "medium", "low"} {
|
||||
id, err := pg.AddFinding(task.ID, 0, "IDOR", "orders", sev, "same endpoint", "proof "+sev, "worker", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = pg.Exec(`DELETE FROM findings WHERE task_id=$1`, task.ID); _ = pg.DeleteTask(task.ID) })
|
||||
return &Server{m: &Manager{pg: pg}}, task.ID, ids
|
||||
}
|
||||
func TestFindingCaseAPIPaginationAndTools(t *testing.T) {
|
||||
s, task, ids := testCaseServer(t)
|
||||
ctx := context.Background()
|
||||
a := findingCaseRequest{TaskID: json.RawMessage(fmt.Sprint(task)), Title: "订单越权", Reason: "same check"}
|
||||
for _, id := range ids {
|
||||
a.IDs = append(a.IDs, json.RawMessage(fmt.Sprint(id)))
|
||||
}
|
||||
result, err := s.performFindingCaseTool(ctx, "merge_finding_records", a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cid := result.(map[string]string)["case_id"]
|
||||
r := httptest.NewRequest("GET", fmt.Sprintf("/api/exploration/finding-cases?task_id=%d&severity=low&page=1&limit=1", task), nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.listFindingCases(w, r)
|
||||
if w.Code != 200 {
|
||||
t.Fatal(w.Code, w.Body)
|
||||
}
|
||||
var page struct {
|
||||
Total int `json:"total"`
|
||||
Items []struct {
|
||||
Case db.FindingCase `json:"case"`
|
||||
Matched []int64 `json:"matched_ids"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Total != 1 || page.Items[0].Case.Count != 3 || page.Items[0].Case.Report != "" || len(page.Items[0].Matched) != 1 {
|
||||
t.Fatalf("page %+v", page)
|
||||
}
|
||||
r = httptest.NewRequest("GET", "/", nil)
|
||||
r.SetPathValue("id", cid)
|
||||
w = httptest.NewRecorder()
|
||||
s.getFindingCaseMembers(w, r)
|
||||
if w.Code != 200 || !strings.Contains(w.Body.String(), "proof high") {
|
||||
t.Fatal(w.Code, w.Body)
|
||||
}
|
||||
a.CaseID = json.RawMessage(cid)
|
||||
result, err = s.performFindingCaseTool(ctx, "get_finding_case", a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := result.(map[string]any)["case"].(*db.FindingCase)
|
||||
a.Version = c.Version
|
||||
a.Report = "complete reproducible report"
|
||||
a.Severity = "high"
|
||||
a.SeverityReason = "verified data disclosure"
|
||||
if _, err := s.performFindingCaseTool(ctx, "update_finding_case_report", a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.m.pg.Exec(`UPDATE findings SET evidence='new data' WHERE id=$1`, ids[1]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.performFindingCaseTool(ctx, "update_finding_case_report", a); err != db.ErrFindingCaseConflict {
|
||||
t.Fatalf("stale report accepted %v", err)
|
||||
}
|
||||
}
|
||||
func TestFindingCaseConsolidatedExportPreservesScope(t *testing.T) {
|
||||
s, task, ids := testCaseServer(t)
|
||||
ctx := context.Background()
|
||||
cid, err := s.m.pg.MergeFindingCase(ctx, task, ids, "订单越权", "same", "reporter")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, _ := s.m.pg.GetFindingCase(cid)
|
||||
if err := s.m.pg.UpdateFindingCaseReport(ctx, cid, c.Version, "订单越权", "complete report", "high", "verified"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fs, err := s.m.pg.ListFindingsForExport(db.FindingFilter{}, ids[:1])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expanded, plan, err := s.prepareFindingCaseExport(fs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(expanded) != 3 {
|
||||
t.Fatal("selected one child did not include complete group")
|
||||
}
|
||||
out, err := s.consolidateFindingCaseExport(expanded, plan, false)
|
||||
if err != nil || len(out) != 1 || out[0].Report != "complete report" || !strings.Contains(out[0].Summary, fmt.Sprint(ids[2])) {
|
||||
t.Fatalf("unified %+v %v", out, err)
|
||||
}
|
||||
out, err = s.consolidateFindingCaseExport(expanded, plan, true)
|
||||
if err != nil || len(out) != 4 {
|
||||
t.Fatalf("originals lost %d %v", len(out), err)
|
||||
}
|
||||
if _, err := s.m.pg.Exec(`UPDATE findings SET severity='critical' WHERE id=$1`, ids[0]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err = s.consolidateFindingCaseExport(expanded, plan, false)
|
||||
if err != nil || !strings.Contains(out[0].Report, "已过期") || !strings.Contains(out[0].Evidence, "proof low") {
|
||||
t.Fatalf("pending export omitted evidence %+v %v", out, err)
|
||||
}
|
||||
}
|
||||
func TestFindingCaseManualSelectionScope(t *testing.T) {
|
||||
s, task, ids := testCaseServer(t)
|
||||
conv, err := s.m.pg.CreateConversation("reporter", "manual", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.m.pg.Exec(`DELETE FROM conversations WHERE id=$1`, conv.ID)
|
||||
raw, _ := json.Marshal(ids[:2])
|
||||
if _, err := s.m.pg.Exec(`INSERT INTO finding_case_review_runs(conversation_id,task_id,finding_ids) VALUES($1,$2,$3)`, conv.ID, task, string(raw)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := intercept.WithConvID(context.Background(), conv.ID)
|
||||
a := findingCaseRequest{IDs: []json.RawMessage{json.RawMessage(fmt.Sprint(ids[0])), json.RawMessage(fmt.Sprint(ids[2]))}}
|
||||
if err := s.checkCaseReviewScope(ctx, "merge_finding_records", a); err == nil {
|
||||
t.Fatal("unselected mutation allowed")
|
||||
}
|
||||
a.IDs[1] = json.RawMessage(fmt.Sprint(ids[1]))
|
||||
if err := s.checkCaseReviewScope(ctx, "merge_finding_records", a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindingCaseReviewStatusSurvivesTaskDeletion(t *testing.T) {
|
||||
s, task, ids := testCaseServer(t)
|
||||
conv, err := s.m.pg.CreateConversation("reporter", "review", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.m.pg.Exec(`DELETE FROM conversations WHERE id=$1`, conv.ID)
|
||||
if _, err := s.m.pg.Exec(`INSERT INTO finding_case_review_runs(conversation_id,task_id,finding_ids) VALUES($1,$2,$3)`, conv.ID, task, fmt.Sprintf("[%d]", ids[0])); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.m.pg.DeleteTask(task); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
for _, id := range ids {
|
||||
_, _ = s.m.pg.DeleteFinding(id)
|
||||
}
|
||||
}()
|
||||
w := httptest.NewRecorder()
|
||||
s.caseReviewRuns(w, httptest.NewRequest("GET", "/", nil))
|
||||
if w.Code != 200 || !strings.Contains(w.Body.String(), `"task_id":"0"`) {
|
||||
t.Fatal(w.Code, w.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindingCaseSelectedReviewCannotRewriteOtherOriginal(t *testing.T) {
|
||||
s, task, ids := testCaseServer(t)
|
||||
conv, err := s.m.pg.CreateConversation("reporter", "review", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.m.pg.Exec(`DELETE FROM conversations WHERE id=$1`, conv.ID)
|
||||
raw, _ := json.Marshal(ids[:1])
|
||||
if _, err := s.m.pg.Exec(`INSERT INTO finding_case_review_runs(conversation_id,task_id,finding_ids) VALUES($1,$2,$3)`, conv.ID, task, string(raw)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := intercept.WithConvID(context.Background(), conv.ID)
|
||||
if err := s.checkCaseReviewScope(ctx, "update_finding_report", findingCaseRequest{FindingID: json.RawMessage(fmt.Sprint(ids[1]))}); err == nil {
|
||||
t.Fatal("unselected original allowed")
|
||||
}
|
||||
if err := s.checkCaseReviewScope(ctx, "update_finding_report", findingCaseRequest{FindingID: json.RawMessage(fmt.Sprint(ids[0]))}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindingCaseGroupedTrafficExportAndInheritedAccess(t *testing.T) {
|
||||
s, recorded, req := trafficEvidenceServer(t)
|
||||
ctx := t.Context()
|
||||
f, err := s.m.pg.GetFinding(recorded.FindingID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task, err := s.m.pg.GetTask(*f.TaskID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := []byte("verified original response")
|
||||
seedServerEvidenceFlow(t, s, "case-proof", body)
|
||||
if w := req("POST", fmt.Sprintf("/api/exploration/findings/%d/traffic", f.ID), `{"traffic_refs":[{"traffic_id":"case-proof","role":"proof"}]}`); w.Code != 200 {
|
||||
t.Fatal(w.Code, w.Body)
|
||||
}
|
||||
other, err := s.evidenceStore().Record(ctx, db.RecordFindingInput{TaskID: task.ID, ExplorationID: task.ExplorationID, Summary: "same permission boundary", Severity: "low"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range []int64{f.ID, other.FindingID} {
|
||||
if _, err := s.m.pg.Exec(`UPDATE findings SET report=$2 WHERE id=$1`, id, fmt.Sprintf("original script #%d", id)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
cid, err := s.m.pg.MergeFindingCase(ctx, task.ID, []int64{f.ID, other.FindingID}, "orders", "same permission check", "reporter")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, _ := s.m.pg.GetFindingCase(cid)
|
||||
if err := s.m.pg.UpdateFindingCaseReport(ctx, cid, c.Version, "orders", "unified complete script", "high", "verified impact"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A binding note is evidence too: changing it makes the aggregate report stale.
|
||||
list, err := s.m.pg.GetFindingTraffic(ctx, f.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := req("PATCH", fmt.Sprintf("/api/exploration/findings/%d/traffic/%d", f.ID, list.Bindings[0].ID), fmt.Sprintf(`{"version":%d,"note":"additional verified condition"}`, list.Version))
|
||||
if w.Code != 200 {
|
||||
t.Fatal(w.Code, w.Body)
|
||||
}
|
||||
c, _ = s.m.pg.GetFindingCase(cid)
|
||||
if c.Version == c.ReportVersion {
|
||||
t.Fatal("binding evidence did not invalidate unified report")
|
||||
}
|
||||
url := fmt.Sprintf("/api/exploration/findings/export?mode=consolidated&include_originals=true&scope=selected&ids=%d&format=md-zip", f.ID)
|
||||
w = req("GET", url, "")
|
||||
if w.Code != 200 {
|
||||
t.Fatal(w.Code, w.Body)
|
||||
}
|
||||
z, err := zip.NewReader(bytes.NewReader(w.Body.Bytes()), int64(w.Body.Len()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mdCount, caseCount := 0, 0
|
||||
seen := map[string]bool{}
|
||||
recovered := false
|
||||
for _, file := range z.File {
|
||||
if seen[file.Name] {
|
||||
t.Fatalf("duplicate ZIP entry %s", file.Name)
|
||||
}
|
||||
seen[file.Name] = true
|
||||
reader, e := file.Open()
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
data, e := io.ReadAll(reader)
|
||||
reader.Close()
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if strings.HasSuffix(file.Name, ".md") {
|
||||
mdCount++
|
||||
if strings.HasPrefix(file.Name, "case_") {
|
||||
caseCount++
|
||||
if !strings.Contains(string(data), fmt.Sprintf("original script #%d", other.FindingID)) {
|
||||
t.Fatal("stale export lost an original report")
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.HasSuffix(file.Name, "response.bin") && bytes.Equal(data, body) {
|
||||
recovered = true
|
||||
}
|
||||
}
|
||||
if mdCount != 3 || caseCount != 1 || !recovered {
|
||||
t.Fatalf("ZIP reports=%d folders=%d evidence=%v", mdCount, caseCount, recovered)
|
||||
}
|
||||
w = req("GET", fmt.Sprintf("/api/exploration/findings/export?mode=consolidated&scope=selected&ids=%d&format=json", f.ID), "")
|
||||
if w.Code != 200 || !strings.Contains(w.Body.String(), `"id": "case:`) || !strings.Contains(w.Body.String(), `"member_ids"`) {
|
||||
t.Fatal(w.Code, w.Body)
|
||||
}
|
||||
child, err := s.m.CreateTaskWithOptions("inherited folders", "fixture", db.TaskCreateOptions{SourceTaskIDs: []int64{task.ID}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
childID, _ := strconv.ParseInt(child.ID, 10, 64)
|
||||
defer s.m.pg.DeleteTask(childID)
|
||||
base := fmt.Sprintf("/api/exploration/finding-cases/%d", cid)
|
||||
suffix := "?context_task=" + child.ID
|
||||
if w = req("GET", base+suffix, ""); w.Code != 200 {
|
||||
t.Fatal(w.Code, w.Body)
|
||||
}
|
||||
if w = req("GET", base+"/members"+suffix, ""); w.Code != 200 || !strings.Contains(w.Body.String(), `"inherited":true`) {
|
||||
t.Fatal(w.Code, w.Body)
|
||||
}
|
||||
if w = req("DELETE", fmt.Sprintf("%s/members/%d%s", base, f.ID, suffix), `{}`); w.Code != 403 {
|
||||
t.Fatal(w.Code, w.Body)
|
||||
}
|
||||
if w = req("PUT", base+"/report"+suffix, `{}`); w.Code != 403 {
|
||||
t.Fatal(w.Code, w.Body)
|
||||
}
|
||||
if w = req("POST", "/api/exploration/finding-case-review"+suffix, fmt.Sprintf(`{"case_id":%d}`, cid)); w.Code != 403 {
|
||||
t.Fatal(w.Code, w.Body)
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ func writeFindingsEvidenceZip(out io.Writer, findings []*db.DBFinding, stage str
|
||||
func writeFindingsZipEntries(zw *zip.Writer, findings []*db.DBFinding, stage string, now time.Time) error {
|
||||
store := evidence.New(nil, nil, stage) // private export copy; not subject to GC
|
||||
used := map[string]int{}
|
||||
evidencePaths := map[string]bool{}
|
||||
for _, f := range findings {
|
||||
base := report.FindingFilename(f)
|
||||
name := base
|
||||
@@ -43,6 +44,10 @@ func writeFindingsZipEntries(zw *zip.Writer, findings []*db.DBFinding, stage str
|
||||
}
|
||||
for _, b := range f.TrafficBindings {
|
||||
prefix := fmt.Sprintf("evidence/%d/%d/", f.ID, b.ID)
|
||||
if evidencePaths[prefix] {
|
||||
continue
|
||||
}
|
||||
evidencePaths[prefix] = true
|
||||
w, err := zw.Create(prefix + "manifest.json")
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
+13
-3
@@ -63,7 +63,7 @@ func (s *Server) hostTools() ([]actool.CoreTool, map[string][]string) {
|
||||
// orchestrationTools returns the cross-task tool set. Bound per-agent via the
|
||||
// tools table (default: no binding — opt-in for orchestration agents).
|
||||
func (s *Server) orchestrationTools() []actool.CoreTool {
|
||||
return []actool.CoreTool{
|
||||
return append([]actool.CoreTool{
|
||||
s.toolListTasks(),
|
||||
s.toolListLLMProfiles(),
|
||||
s.toolSpawnTask(),
|
||||
@@ -78,7 +78,7 @@ func (s *Server) orchestrationTools() []actool.CoreTool {
|
||||
s.toolUpdateFindingReport(),
|
||||
s.toolGetFindingTraffic(),
|
||||
s.toolBindFindingTraffic(),
|
||||
}
|
||||
}, s.findingCaseTools()...)
|
||||
}
|
||||
|
||||
// --- schema helpers ---
|
||||
@@ -442,6 +442,15 @@ func (s *Server) toolUpdateFindingReport() actool.CoreTool {
|
||||
if nodeID <= 0 {
|
||||
return actool.Errorf("finding_id 无效"), nil
|
||||
}
|
||||
fid, err := s.m.pg.FindingIDByNodeID(nodeID)
|
||||
if err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
}
|
||||
if fid > 0 {
|
||||
if err := s.checkCaseReviewScope(ctx, "update_finding_report", findingCaseRequest{FindingID: json.RawMessage(i64s(fid))}); err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
}
|
||||
}
|
||||
n, err := s.m.pg.SetFindingReportVersionByNodeID(ctx, nodeID, a.Report, a.EvidenceVersion)
|
||||
if err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
@@ -477,7 +486,7 @@ func (s *Server) seedOrchestrationTools() {
|
||||
for _, t := range s.orchestrationTools() {
|
||||
schema, _ := json.Marshal(t.InputSchema())
|
||||
bindings := autoAgents
|
||||
if t.Name() == "bind_finding_traffic" {
|
||||
if t.Name() == "bind_finding_traffic" || strings.Contains(t.Name(), "finding_case") || t.Name() == "search_finding_duplicates" || t.Name() == "get_finding_record" || t.Name() == "merge_finding_records" || t.Name() == "suggest_finding_merge" {
|
||||
bindings = json.RawMessage(`["reporter"]`)
|
||||
}
|
||||
_ = s.m.PG().SeedTool(t.Name(), t.Description(), schema, bindings)
|
||||
@@ -503,6 +512,7 @@ func (s *Server) seedOrchestrationTools() {
|
||||
s.upgradeReporterTriggerMessage() // 老库补迁移:让 reporter 回传 evidence_version(一次性)
|
||||
s.seedFindingTrafficTools() // 增加可选证据参数及只读证据工具,保留用户配置
|
||||
s.seedFindingWorkflowTools()
|
||||
s.seedFindingCaseTools()
|
||||
// 注:pentest 的默认工具绑定无需迁移——BuiltinToolSeeds 在全新初始化时就把
|
||||
// list_assets/insert_assets/report_finding/list_findings/list_companies 连同
|
||||
// pentest 一起 seed 好了(项目尚无旧库,不做迁移)。
|
||||
|
||||
@@ -131,6 +131,8 @@ type provEntry struct {
|
||||
// taskID + mergeable let the drainer coalesce several event triggers (finding/goal)
|
||||
// from the SAME task into one conversation before it starts (interval fires don't merge).
|
||||
type triggeredRun struct {
|
||||
conversationID int64 // pre-created manual finding review; never coalesced
|
||||
|
||||
agentKey string
|
||||
title string
|
||||
message string // 事件正文(触发语 + 工具/入参/返回等);不含任务描述/目标头
|
||||
@@ -755,6 +757,16 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/exploration/frontier", s.frontier)
|
||||
mux.HandleFunc("GET /api/exploration/findings", s.findings)
|
||||
mux.HandleFunc("GET /api/exploration/findings/groups", s.findingGroups)
|
||||
mux.HandleFunc("GET /api/exploration/finding-cases", s.listFindingCases)
|
||||
mux.HandleFunc("POST /api/exploration/finding-cases", s.mergeFindingCase)
|
||||
mux.HandleFunc("GET /api/exploration/finding-cases/{id}", s.getFindingCase)
|
||||
mux.HandleFunc("GET /api/exploration/finding-cases/{id}/members", s.getFindingCaseMembers)
|
||||
mux.HandleFunc("DELETE /api/exploration/finding-cases/{id}/members/{fid}", s.removeFindingCaseMember)
|
||||
mux.HandleFunc("PUT /api/exploration/finding-cases/{id}/report", s.updateCaseReport)
|
||||
mux.HandleFunc("GET /api/exploration/finding-case-suggestions", s.caseSuggestions)
|
||||
mux.HandleFunc("POST /api/exploration/finding-case-suggestions/{id}", s.resolveCaseSuggestion)
|
||||
mux.HandleFunc("POST /api/exploration/finding-case-review", s.reviewFindingCases)
|
||||
mux.HandleFunc("GET /api/exploration/finding-case-review", s.caseReviewRuns)
|
||||
mux.HandleFunc("GET /api/exploration/findings/asset-tree", s.findingAssetTree)
|
||||
mux.HandleFunc("GET /api/exploration/findings/stats", s.findingStats)
|
||||
mux.HandleFunc("GET /api/exploration/findings/export", s.findingsExport)
|
||||
@@ -1040,6 +1052,7 @@ func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
list := s.m.List()
|
||||
metrics, _ := s.m.PG().TaskListMetricsAll()
|
||||
distinct, _ := s.m.pg.DistinctFindingStatsByTask()
|
||||
archiveBlockers, _ := s.m.PG().TaskArchiveBlockers()
|
||||
dtos := make([]TaskDTO, 0, len(list))
|
||||
for _, t := range list {
|
||||
@@ -1056,6 +1069,7 @@ func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) {
|
||||
dto.GoalsTotal = metric.Goals.Total
|
||||
dto.GoalsMet = metric.Goals.Met
|
||||
dto.InFlight = metric.RunningIntents
|
||||
dto.DistinctFindings = distinct[t.ID]
|
||||
dto.Findings = FindingSeverityDTO{
|
||||
Critical: metric.Findings.Critical,
|
||||
High: metric.Findings.High,
|
||||
@@ -2052,6 +2066,11 @@ func (s *Server) findingStats(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
st.Distinct, err = s.m.pg.DistinctFindingStats("")
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, st)
|
||||
}
|
||||
|
||||
@@ -2074,6 +2093,14 @@ func (s *Server) getFinding(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
assets := s.resolveAssetIDs(f.AssetIDs)
|
||||
dto := findingFromDB(f, assets)
|
||||
cid, err := s.m.pg.FindingCaseID(f.ID)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if cid > 0 {
|
||||
dto.CaseID = i64s(cid)
|
||||
}
|
||||
if contextTaskID := strings.TrimSpace(r.URL.Query().Get("context_task")); contextTaskID != "" {
|
||||
contextTask := s.m.ResolveTask(contextTaskID)
|
||||
if contextTask == nil {
|
||||
@@ -2138,6 +2165,14 @@ func (s *Server) findingsExport(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var casePlan *findingCaseExportPlan
|
||||
if q.Get("mode") == "consolidated" {
|
||||
fs, casePlan, err = s.prepareFindingCaseExport(fs)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
stage, err := os.MkdirTemp("", "artex-finding-export-")
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
@@ -2148,6 +2183,13 @@ func (s *Server) findingsExport(w http.ResponseWriter, r *http.Request) {
|
||||
evidenceError(w, err)
|
||||
return
|
||||
}
|
||||
if casePlan != nil {
|
||||
fs, err = s.consolidateFindingCaseExport(fs, casePlan, q.Get("include_originals") == "true")
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
stamp := now.Format("20060102-150405")
|
||||
setDownload := func(contentType, filename string) {
|
||||
|
||||
Reference in new issue
Block a user