fix(findings): protect historical reports and verify consolidation results

This commit is contained in:
ji ruo committed 2026-10-09 12:23:11 +08:00
1 parent 71ec0f0efd
commit 7db7b71cdb
15 files changed
+906 -74

No files matched your search

+2 -2
View File
@@ -335,11 +335,11 @@ server {
### 同一漏洞归并与统一报告
发现页默认「按漏洞」显示。同一任务内,Reporter 依据目标、接口参数、权限边界、根因及验证证据确认同一实际缺陷后形成文件夹;仅类型或名称相同不会作为自动归并依据。严重程度不同仍可能是同一漏洞。文件夹的 `严/高/中/低` 数量按原始记录统计,例如 `0/1/1/1`;独立漏洞统计采用另行评估的统一等级,尚未评估时显示「待评估」。原始上报内容、评级、状态、报告及证据不被覆盖。
发现页默认「按漏洞」显示。同一任务内,Reporter 依据目标、接口参数、权限边界、根因及验证证据确认同一实际缺陷后形成文件夹;仅类型或名称相同不会作为自动归并依据。严重程度、接口、后端或读写影响不同仍可能是同一漏洞;反之,共用登录入口或令牌不能单独证明同一根因。有关联但共同根因无法确认时提交人工建议。文件夹的 `严/高/中/低` 数量按原始记录统计,例如 `0/1/1/1`;独立漏洞统计采用另行评估的统一等级,尚未评估时显示「待评估」。原始上报内容、评级、状态、报告及证据不被覆盖。
点击箭头展开原始记录,点击文件夹标题查看统一报告、原始记录及归并历史。证据不足的建议需要点击「确认归并」;否决或移出后,这些关系不会被 Agent 自动再次归并。移出成员不删除原漏洞,不足两条时解散文件夹并保留历史。继承任务的漏洞组保持只读。
新上报由现有 Reporter 生成独立报告并检查重复。升级首次补充 Reporter 的归并工具和运行指引,保留自定义提示词及触发设置;已删除或停用的 Reporter 不会重建、启用。历史记录不会自动扫描:展开并选择原始记录,点击「整理所选」,按任务分别排队执行,可查看执行记录及失败信息。文件夹详情可重新生成统一报告;需要启用 Reporter 并配置可用模型。判断和写作会消耗模型 token,候选检索先分页返回摘要,详情证据按需读取,不重新探测目标。
新上报由现有 Reporter 生成独立报告并检查重复。升级首次补充 Reporter 的归并工具和运行指引,保留自定义提示词及触发设置;已删除或停用的 Reporter 不会重建、启用。历史记录不会自动扫描:展开并选择原始记录,点击「整理所选」,按任务分别排队执行,可查看执行记录及失败信息。同一任务已有排队或运行中的整理时拒绝重复提交。历史整理以只读方式复用原报告,禁止覆盖原报告或补绑证据;已有报告为空或过期时如实说明,单独通过报告撰写流程处理。结束前需提交每条所选记录的根因判断,检查读取范围、建议、统一报告完整性及版本,通过验收才显示完成。文件夹详情可重新生成统一报告;需要启用 Reporter 并配置可用模型。判断和写作会消耗模型 token,候选检索先分页返回摘要,详情证据按需读取,不重新探测目标。
成员、报告或证据变化后统一报告标记待更新,写入时校验实际读取版本。导出默认每组一份统一报告及未归并记录,可勾选「包含原始子报告」;统一报告未生成或过期时附明确说明及全部原始材料。原始记录、按任务及按资产视图继续保留。升级沿用启动时幂等数据库迁移,无需执行独立 SQL。
+7
View File
@@ -62,8 +62,15 @@ func AugmentTools(ctx context.Context, agentKey string, base []actool.CoreTool)
if ToolResolve != nil {
out = ToolResolve(ctx, agentKey, out)
}
if agentKey == "reporter" && isFindingCaseReview(ctx) {
out = historicalFindingCaseTools(out)
}
out, def.FindingGuidance = findingWorkflowTools(agentKey, out)
out, def.CaseGuidance = findingCaseWorkflow(agentKey, out)
if agentKey == "reporter" && isFindingCaseReview(ctx) {
def.FindingGuidance = ""
def.CaseGuidance += historicalFindingReviewGuidance
}
for i, t := range out {
out[i] = guardPanic(t)
}
+38 -3
View File
@@ -1,6 +1,34 @@
package agent
import actool "github.com/Autumn-27/norma/tool"
import (
"context"
actool "github.com/Autumn-27/norma/tool"
)
type findingReviewKey struct{}
// Historical consolidation has a narrower capability set than new reporting.
func WithFindingCaseReview(ctx context.Context) context.Context {
return context.WithValue(ctx, findingReviewKey{}, true)
}
func isFindingCaseReview(ctx context.Context) bool {
v, _ := ctx.Value(findingReviewKey{}).(bool)
return v
}
func historicalFindingCaseTools(tools []actool.CoreTool) []actool.CoreTool {
allowed := map[string]bool{}
for _, name := range []string{"search_finding_duplicates", "get_finding_record", "merge_finding_records", "suggest_finding_merge", "get_finding_case", "update_finding_case_report", "complete_finding_case_review", "get_finding_traffic", "traffic_search", "traffic_get", "traffic_blob", "get_task_node_detail", "list_task_findings", "get_task_graph", "list_task_worker_traces", "get_task_worker_trace", "Read"} {
allowed[name] = true
}
out := make([]actool.CoreTool, 0, len(tools))
for _, tool := range tools {
if allowed[tool.Name()] {
out = append(out, tool)
}
}
return out
}
// This guidance is appended to the runtime prompt, including editable old prompts.
// Never reintroduce unbound tools or recreate a deleted Reporter.
@@ -17,6 +45,13 @@ func findingCaseWorkflow(key string, tools []actool.CoreTool) ([]actool.CoreTool
}
return tools, `
【同一漏洞的多次上报整理】保留本次独立报告,不覆盖其他原报告。默认报告完成即停止的旧指引在此扩展:先保存本次报告,然后 search_finding_duplicates(finding_id=独立记录ID) 分页读取同任务候选摘要,按需 get_finding_record 查看证据。仅凭漏洞类型/标题相似或高中低等级不能归并;须核对目标、接口、参数、权限边界、根因及实际验证证据。明确同一缺陷时 merge_finding_records(task_id,finding_ids,title,reason),不确定时 suggest_finding_merge 留待人工确认,不能强行归并。不重复上报、不删除记录、不重新探测目标。人工选择整理时只能判断所选记录,不能加入未选记录(既有组成员可作为报告上下文)。
归并成功或已有case_id时,get_finding_case 记录实际version,分页读取全部成员,按需get_finding_record、get_finding_traffic读取原报告、PoC及已保存流量。update_finding_case_report保存完整统一报告和基于已验证影响的severity及severity_reason;不能直接取成员最高等级。集中给出前置条件、完整执行顺序、输入输出如何衔接、可运行的完整脚本及命令,不把必要步骤分散到原报告;缺失条件、未验证步骤如实标明。版本冲突重新读取材料并生成,不能仅换version重试。各成员仍有独立报告、评级和证据。没有明确重复则保留独立记录。工具被关闭时不绕过配置。`
【同一漏洞的多次上报整理】保留本次独立报告,不覆盖其他原报告。默认报告完成即停止的旧指引在此扩展:先保存本次报告,然后 search_finding_duplicates(finding_id=独立记录ID) 分页读取同任务候选摘要,按需 get_finding_record 查看证据。仅凭漏洞类型/标题相似或高中低等级不能归并;须核对目标、接口、参数、权限边界、根因及实际验证证据。
【判断准则】接口、路径、后端、读/写影响、评级不同,都不能单独作为不重复的理由;同一登录入口、同一令牌或同一漏洞类型也不能单独证明同一根因。比较实际缺失的鉴权检查、对象权限边界、会话门或共同组件,并引用每条记录中的具体证据。读和写可能是同一权限检查缺失的不同影响;也可能是独立 handler 的独立缺陷。只有证据证明共同实际根因才自动归并,证据证明不同根因才判独立;存在关联但无法确认时必须 suggest_finding_merge,说明已知共同点、缺失证据和待确认问题,不用“路径不同/影响不同”代替根因判断。明确同一缺陷时 merge_finding_records(task_id,finding_ids,title,reason),不确定时 suggest_finding_merge 留待人工确认,不能强行归并。不重复上报、不删除记录、不重新探测目标。人工选择整理时只能判断所选记录,不能加入未选记录(既有组成员可作为报告上下文)。
归并成功或已有case_id时,get_finding_case 记录实际version,分页读取全部成员,按需get_finding_record、get_finding_traffic读取原报告、PoC及已保存流量。update_finding_case_report保存完整统一报告(包含有内容的概述、影响、前置条件、复现步骤、证据、PoC、根因分析、修复建议章节,不得半篇截断)和基于已验证影响的severity及severity_reason;不能直接取成员最高等级。集中给出前置条件、完整执行顺序、输入输出如何衔接、可运行的完整脚本及命令,不把必要步骤分散到原报告;缺失条件、未验证步骤如实标明。版本冲突重新读取材料并生成,不能仅换version重试。各成员仍有独立报告、评级和证据。没有明确重复则保留独立记录。工具被关闭时不绕过配置。`
}
const historicalFindingReviewGuidance = `
【历史整理模式,优先于旧报告撰写及自动绑流量指引】本次只整理已经存在的上报。原始报告即使为空或待更新,也只记录缺失情况,绝不调用 update_finding_report、report_finding、bind_finding_traffic,不修改原始标题、评级、状态、证据或报告,不执行 Bash、写文件或重新探测目标。
先逐条 get_finding_record 读取所选记录,已有原报告直接作为判断材料复用,不重复撰写。只在所选范围内检查同一实际缺陷,可按需读取已存在的证据。按上述根因准则形成明确归并/独立/疑似建议;无法确认时保存建议供人工确认。
已有或新建文件夹必须读取全部成员并写入完整、最新的统一报告;统一报告是单独的材料,不覆盖原报告。最后必须调用 complete_finding_case_review,为每条所选 finding_id 提交 verdict(independent/merged/suggested)、reason 和 root_cause_evidence,并提供总结。根因证据应说明具体相同/不同的检查及证据来源;疑似建议明确待补证据。服务端验收通过后才可宣布完成。工具不可用或验收失败时如实报告,不能只用自然语言声称完成。`
+30
View File
@@ -29,3 +29,33 @@ func TestFindingCaseGuidanceRespectsToolsAndTrafficSwitch(t *testing.T) {
t.Fatal("custom prompt lost")
}
}
func TestHistoricalFindingReviewFiltersMutationAndProbeTools(t *testing.T) {
oldAugment, oldResolve := ToolAugment, ToolResolve
ToolAugment, ToolResolve = nil, nil
t.Cleanup(func() { ToolAugment, ToolResolve = oldAugment, oldResolve })
tools := []actool.CoreTool{}
for _, name := range []string{"update_finding_report", "bind_finding_traffic", "report_finding", "Bash", "Write", "Edit", "mcp__custom__write", "get_finding_record", "merge_finding_records", "search_finding_duplicates", "get_finding_case", "update_finding_case_report", "complete_finding_case_review"} {
tools = append(tools, writeTool(name, name, obj(nil), func(context.Context, json.RawMessage) (actool.Result, error) { return actool.Text("ok"), nil }))
}
out, def, cleanup := AugmentTools(WithFindingCaseReview(t.Context()), "reporter", tools)
defer cleanup()
for _, tool := range out {
switch tool.Name() {
case "update_finding_report", "bind_finding_traffic", "report_finding", "Bash", "Write", "Edit", "mcp__custom__write":
t.Fatal("historical mutation capability", tool.Name())
}
}
if len(out) != 6 || def.FindingGuidance != "" || !strings.Contains(def.CaseGuidance, "历史整理模式") || !strings.Contains(def.CaseGuidance, "接口、路径、后端、读/写影响、评级不同") {
t.Fatal("missing historical isolation", def)
}
normal, _, cl := AugmentTools(t.Context(), "reporter", tools)
defer cl()
found := map[string]bool{}
for _, tool := range normal {
found[tool.Name()] = true
}
if !found["update_finding_report"] || !found["Bash"] || !found["report_finding"] {
t.Fatal("normal reporting was restricted")
}
}
+81
View File
@@ -0,0 +1,81 @@
package db
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"sort"
)
var ErrFindingCaseReviewBusy = errors.New("该任务已有排队或运行中的漏洞整理,请等待完成或停止后重试")
type FindingCaseReviewReservation struct {
TaskID int64
ConversationID int64
FindingIDs []int64
}
// Reserve all source tasks atomically, so a concurrent click cannot start a
// second review and a mixed-task request cannot partially enqueue its work.
func (d *DB) CreateFindingCaseReviews(ctx context.Context, batches map[int64][]int64) ([]FindingCaseReviewReservation, error) {
tasks := make([]int64, 0, len(batches))
for task := range batches {
tasks = append(tasks, task)
}
sort.Slice(tasks, func(i, j int) bool { return tasks[i] < tasks[j] })
out := []FindingCaseReviewReservation{}
err := d.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
for _, task := range tasks {
if err := lockCaseTask(tx, task); err != nil {
return err
}
var busy bool
if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM finding_case_review_runs WHERE task_id=$1 AND state IN ('queued','running'))`, task).Scan(&busy); err != nil {
return err
}
if busy {
return fmt.Errorf("%w(任务 #%d)", ErrFindingCaseReviewBusy, task)
}
}
for _, task := range tasks {
ids := []int64{}
seen := map[int64]bool{}
for _, id := range batches[task] {
if seen[id] {
continue
}
seen[id] = true
var source int64
if err := tx.QueryRow(`SELECT task_id FROM findings WHERE id=$1`, id).Scan(&source); err != nil {
return err
}
if source != task {
return errors.New("所选漏洞不属于来源任务")
}
ids = append(ids, id)
}
if len(ids) == 0 {
return errors.New("请选择原始上报")
}
raw, err := json.Marshal(ids)
if err != nil {
return err
}
var conv int64
if err := tx.QueryRow(`INSERT INTO conversations(agent_key,title) VALUES('reporter',$1) RETURNING id`, fmt.Sprintf("漏洞整理 · task#%d", task)).Scan(&conv); err != nil {
return err
}
if _, err := tx.Exec(`INSERT INTO finding_case_review_runs(conversation_id,task_id,finding_ids) VALUES($1,$2,$3)`, conv, task, string(raw)); err != nil {
return err
}
out = append(out, FindingCaseReviewReservation{TaskID: task, ConversationID: conv, FindingIDs: ids})
}
return nil
})
if err != nil {
return nil, err
}
return out, nil
}
+86
View File
@@ -0,0 +1,86 @@
package db
import (
"context"
"errors"
"sync"
"testing"
)
func TestFindingCaseReviewConcurrentReservation(t *testing.T) {
d, task, ids := caseTestDB(t)
var wg sync.WaitGroup
results := make(chan error, 8)
for range 8 {
wg.Add(1)
go func() {
defer wg.Done()
_, err := d.CreateFindingCaseReviews(context.Background(), map[int64][]int64{task.ID: ids[:2]})
results <- err
}()
}
wg.Wait()
close(results)
success := 0
for err := range results {
if err == nil {
success++
} else if !errors.Is(err, ErrFindingCaseReviewBusy) {
t.Fatal(err)
}
}
if success != 1 {
t.Fatalf("created %d reviews instead of one", success)
}
rows, err := d.Query(`SELECT conversation_id FROM finding_case_review_runs WHERE task_id=$1`, task.ID)
if err != nil {
t.Fatal(err)
}
var convs []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
t.Fatal(err)
}
convs = append(convs, id)
}
rows.Close()
t.Cleanup(func() {
for _, id := range convs {
d.Exec(`DELETE FROM conversations WHERE id=$1`, id)
}
})
if _, err := d.Exec(`UPDATE finding_case_review_runs SET state='done' WHERE task_id=$1`, task.ID); err != nil {
t.Fatal(err)
}
next, err := d.CreateFindingCaseReviews(t.Context(), map[int64][]int64{task.ID: ids[2:]})
if err != nil {
t.Fatal(err)
}
convs = append(convs, next[0].ConversationID)
}
func TestFindingCaseReviewMixedRequestRollsBack(t *testing.T) {
d, task, ids := caseTestDB(t)
other, err := d.CreateTask("other review", "goal", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { d.Exec(`DELETE FROM findings WHERE task_id=$1`, other.ID); d.DeleteTask(other.ID) })
fid, err := d.AddFinding(other.ID, 0, "IDOR", "other", "low", "other", "proof", "worker", nil)
if err != nil {
t.Fatal(err)
}
current, err := d.CreateFindingCaseReviews(t.Context(), map[int64][]int64{other.ID: {fid}})
if err != nil {
t.Fatal(err)
}
defer d.Exec(`DELETE FROM conversations WHERE id=$1`, current[0].ConversationID)
if _, err := d.CreateFindingCaseReviews(t.Context(), map[int64][]int64{task.ID: ids, other.ID: {fid}}); !errors.Is(err, ErrFindingCaseReviewBusy) {
t.Fatal(err)
}
var count int
if err := d.QueryRow(`SELECT count(*) FROM finding_case_review_runs WHERE task_id=$1`, task.ID).Scan(&count); err != nil || count != 0 {
t.Fatalf("partial enqueue: %d %v", count, err)
}
}
+10 -2
View File
@@ -479,7 +479,7 @@ func (d *DB) DistinctFindingStats(taskID string) (*FindingDistinctStats, error)
}
// Compact candidate pages exclude PoC/report bodies and do not treat severity as identity.
func (d *DB) FindingCaseCandidates(fid int64, page, size int) ([]map[string]any, int, error) {
func (d *DB) FindingCaseCandidates(fid int64, page, size int, scope ...[]int64) ([]map[string]any, int, error) {
f, err := d.FindingCaseSummary(fid)
if err != nil {
return nil, 0, err
@@ -502,12 +502,20 @@ func (d *DB) FindingCaseCandidates(fid int64, page, size int) ([]map[string]any,
AND ((trim($3::text)<>'' AND lower(f.vulnclass)=lower($3)) OR (trim($4::text)<>'' AND lower(f.name)=lower($4))
OR EXISTS(SELECT 1 FROM jsonb_array_elements(COALESCE(NULLIF(f.asset_ids,'null'::jsonb),'[]'::jsonb)) a WHERE a.value IN(SELECT value FROM jsonb_array_elements($5::jsonb))))`
args := []any{*f.TaskID, fid, f.VulnClass, f.Name, string(assets)}
if len(scope) > 0 {
raw, err := json.Marshal(scope[0])
if err != nil {
return nil, 0, err
}
where += ` AND f.id IN (SELECT jsonb_array_elements_text($6::jsonb)::bigint)`
args = append(args, string(raw))
}
var total int
if err := d.QueryRow(`SELECT count(*) FROM findings f`+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
args = append(args, size, (page-1)*size)
rows, err := d.Query(`SELECT f.id,f.name,f.vulnclass,f.severity,left(f.summary,400),f.asset_ids,COALESCE(m.case_id,0) FROM findings f LEFT JOIN finding_case_members m ON m.finding_id=f.id`+where+` ORDER BY f.id DESC LIMIT $6 OFFSET $7`, args...)
rows, err := d.Query(`SELECT f.id,f.name,f.vulnclass,f.severity,left(f.summary,400),f.asset_ids,COALESCE(m.case_id,0) FROM findings f LEFT JOIN finding_case_members m ON m.finding_id=f.id`+where+fmt.Sprintf(` ORDER BY f.id DESC LIMIT $%d OFFSET $%d`, len(args)-1, len(args)), args...)
if err != nil {
return nil, 0, err
}
+4
View File
@@ -1471,3 +1471,7 @@ CREATE TABLE IF NOT EXISTS finding_case_review_runs (
);
ALTER TABLE finding_case_review_runs ADD COLUMN IF NOT EXISTS finding_ids JSONB NOT NULL DEFAULT '[]';
ALTER TABLE finding_case_review_runs ADD COLUMN IF NOT EXISTS reviewed_ids JSONB NOT NULL DEFAULT '[]';
ALTER TABLE finding_case_review_runs ADD COLUMN IF NOT EXISTS conclusion JSONB NOT NULL DEFAULT '{}';
CREATE INDEX IF NOT EXISTS idx_finding_case_reviews_active_task ON finding_case_review_runs(task_id) WHERE state IN ('queued','running');
+14
View File
@@ -505,6 +505,15 @@ func (s *Server) runConversationTurn(ctx context.Context, cancel context.CancelC
// turns may explain the sealed result; the result tool refuses to overwrite it.
finishStatus, finishReason := "failed", "Agent 未能启动"
if c.AgentKey == "reporter" {
manualReview, err := s.isHistoricalFindingReview(c.ID)
if err != nil {
finishReason = err.Error()
_, _ = s.m.pg.Exec(`UPDATE finding_case_review_runs SET state='failed',error=$2 WHERE conversation_id=$1`, c.ID, finishReason)
return
}
if manualReview {
ctx = agent.WithFindingCaseReview(ctx)
}
defer func() {
state := "done"
reason := ""
@@ -515,6 +524,11 @@ func (s *Server) runConversationTurn(ctx context.Context, cancel context.CancelC
reason = "整理已停止或服务关闭"
}
}
if state == "done" && manualReview {
if err := s.validateFindingReviewCompletion(c.ID); err != nil {
state, reason = "failed", err.Error()
}
}
_, _ = 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)
}()
}
+250
View File
@@ -0,0 +1,250 @@
package server
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/Autumn-27/artex/intercept"
actool "github.com/Autumn-27/norma/tool"
)
type findingReviewDecision struct {
FindingID int64 `json:"finding_id"`
Verdict string `json:"verdict"`
Reason string `json:"reason"`
RootCauseEvidence string `json:"root_cause_evidence"`
}
type findingReviewConclusion struct {
Summary string `json:"summary"`
Decisions []findingReviewDecision `json:"decisions"`
}
func (s *Server) isHistoricalFindingReview(conv int64) (bool, error) {
var exists bool
err := s.m.pg.QueryRow(`SELECT EXISTS(SELECT 1 FROM finding_case_review_runs WHERE conversation_id=$1)`, conv).Scan(&exists)
return exists, err
}
func (s *Server) toolCompleteFindingCaseReview() actool.CoreTool {
decision := objSchema(map[string]any{
"finding_id": idParam("所选独立漏洞记录 ID"),
"verdict": map[string]any{"type": "string", "enum": []string{"independent", "merged", "suggested"}},
"reason": strParam("判断理由,接口或读写影响不同不能单独证明不同根因"),
"root_cause_evidence": strParam("原始报告/证据中支持实际共同或不同根因的具体材料;不确定则说明缺失证据及已保存建议")}, "finding_id", "verdict", "reason", "root_cause_evidence")
return wrTool("complete_finding_case_review", "历史整理结束前提交每条所选记录的根因判断并验收:必须已读取全部记录,疑似必须已有建议,归并必须已有完整最新统一报告。验收失败不能宣布完成。", objSchema(map[string]any{"summary": strParam("整理结果总结"), "decisions": map[string]any{"type": "array", "items": decision}}, "summary", "decisions"), func(ctx context.Context, raw json.RawMessage) (actool.Result, error) {
var a findingReviewConclusion
if err := json.Unmarshal(raw, &a); err != nil {
return actool.Errorf(err.Error()), nil
}
conv := intercept.ConvIDFromContext(ctx)
if err := s.saveFindingReviewConclusion(ctx, conv, a); err != nil {
return actool.Errorf(err.Error()), nil
}
return actool.Text("历史整理验收通过;原始记录和报告保留,逐条根因判断已保存。"), nil
})
}
func (s *Server) saveFindingReviewConclusion(ctx context.Context, conv int64, a findingReviewConclusion) error {
if conv <= 0 {
return errors.New("仅用于有上下文的历史整理会话")
}
return s.m.pg.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
var idsRaw, readsRaw json.RawMessage
var state string
if err := tx.QueryRow(`SELECT finding_ids,reviewed_ids,state FROM finding_case_review_runs WHERE conversation_id=$1 FOR UPDATE`, conv).Scan(&idsRaw, &readsRaw, &state); err != nil {
return err
}
if state != "running" {
return errors.New("整理会话未运行,不能提交验收")
}
var ids, reads []int64
if err := json.Unmarshal(idsRaw, &ids); err != nil {
return err
}
if err := json.Unmarshal(readsRaw, &reads); err != nil {
return err
}
read := map[int64]bool{}
for _, id := range reads {
read[id] = true
}
decisions := map[int64]findingReviewDecision{}
for _, d := range a.Decisions {
if _, ok := decisions[d.FindingID]; ok {
return errors.New("重复的判断编号")
}
decisions[d.FindingID] = d
}
if strings.TrimSpace(a.Summary) == "" || len(ids) != len(decisions) {
return errors.New("须提供总结和覆盖全部所选记录的逐条判断")
}
for _, id := range ids {
d, ok := decisions[id]
if !ok || !read[id] {
return fmt.Errorf("请先 get_finding_record 读取并判断所选记录 #%d", id)
}
if strings.TrimSpace(d.Reason) == "" || strings.TrimSpace(d.RootCauseEvidence) == "" {
return fmt.Errorf("记录 #%d 缺少判断理由或根因证据", id)
}
var cid sql.NullInt64
if err := tx.QueryRow(`SELECT m.case_id FROM findings f LEFT JOIN finding_case_members m ON m.finding_id=f.id WHERE f.id=$1`, id).Scan(&cid); err != nil {
return err
}
switch d.Verdict {
case "merged":
if !cid.Valid {
return fmt.Errorf("记录 #%d 尚未归并", id)
}
case "suggested":
var pending bool
if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM finding_case_suggestions WHERE state='pending' AND (left_id=$1 OR right_id=$1))`, id).Scan(&pending); err != nil {
return err
}
if !pending {
return fmt.Errorf("记录 #%d 须先保存待确认建议", id)
}
case "independent":
if cid.Valid {
return fmt.Errorf("记录 #%d 已在文件夹中,不能声明为独立", id)
}
default:
return errors.New("无效判断类型")
}
if cid.Valid {
var report string
var version, rv int64
var active bool
if err := tx.QueryRow(`SELECT report,version,report_version,active FROM finding_cases WHERE id=$1 FOR SHARE`, cid.Int64).Scan(&report, &version, &rv, &active); err != nil {
return err
}
if !active || rv != version {
return fmt.Errorf("文件夹 #%d 的统一报告待更新,请重新读取后更新", cid.Int64)
}
if err := validateUnifiedFindingReport(report); err != nil {
return err
}
}
}
raw, err := json.Marshal(a)
if err != nil {
return err
}
_, err = tx.Exec(`UPDATE finding_case_review_runs SET conclusion=$2 WHERE conversation_id=$1`, conv, string(raw))
return err
})
}
func (s *Server) validateFindingReviewCompletion(conv int64) error {
var raw json.RawMessage
if err := s.m.pg.QueryRow(`SELECT conclusion FROM finding_case_review_runs WHERE conversation_id=$1`, conv).Scan(&raw); err != nil {
return err
}
var a findingReviewConclusion
if err := json.Unmarshal(raw, &a); err != nil {
return err
}
if len(a.Decisions) == 0 {
return errors.New("整理未通过验收:未提交 complete_finding_case_review 逐条根因判断;请查看执行记录")
}
// Recheck after the last model turn; concurrent evidence changes invalidate completion.
return s.saveFindingReviewConclusion(context.Background(), conv, a)
}
// Require substantive sections and balanced fenced blocks. This validates report
// completeness, not the truth of a model's root-cause claim or a live exploit.
func validateUnifiedFindingReport(report string) error {
aliases := [][]string{{"概述", "overview", "summary"}, {"影响", "impact"}, {"前置", "prerequisite", "precondition"}, {"复现", "reproduction", "reproduce"}, {"证据", "evidence"}, {"poc", "完整脚本", "验证脚本", "proof of concept"}, {"根因", "root cause"}, {"修复", "remediation", "recommendation", "fix"}}
names := []string{"概述", "影响", "前置条件", "复现步骤", "证据", "PoC", "根因分析", "修复建议"}
found := make([]bool, len(aliases))
current := -1
hasContent := false
fence := ""
finish := func() {
if current >= 0 && hasContent {
found[current] = true
}
}
for _, line := range strings.Split(report, "\n") {
t := strings.TrimSpace(line)
if strings.HasPrefix(t, "```") || strings.HasPrefix(t, "~~~") {
marker := t[:3]
if fence == "" {
fence = marker
} else if fence == marker {
fence = ""
}
continue
}
if fence != "" {
if t != "" {
hasContent = true
}
continue
}
if strings.HasPrefix(t, "#") {
heading := strings.TrimLeft(t, "#")
if len(heading) == len(t) || !strings.HasPrefix(heading, " ") {
continue
}
// Subheadings belong to the current section unless they start another required section.
next := -1
lower := strings.ToLower(heading)
for i, words := range aliases {
for _, word := range words {
if strings.Contains(lower, word) {
next = i
break
}
}
if next >= 0 {
break
}
}
if next >= 0 {
finish()
current = next
hasContent = false
}
continue
}
if t != "" && t != "---" && t != "***" && t != ">" {
hasContent = true
}
}
finish()
if fence != "" {
return errors.New("统一报告代码块未闭合,可能截断;请补全后再保存")
}
missing := []string{}
for i, ok := range found {
if !ok {
missing = append(missing, names[i])
}
}
if len(missing) > 0 {
return fmt.Errorf("统一报告缺少有内容的章节:%s;请补全后保存", strings.Join(missing, "、"))
}
return nil
}
func (s *Server) historicalFindingReviewSelection(ctx context.Context) ([]int64, error) {
conv := intercept.ConvIDFromContext(ctx)
if conv == 0 {
return nil, 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 errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
ids := []int64{}
err = json.Unmarshal(raw, &ids)
return ids, err
}
+170
View File
@@ -0,0 +1,170 @@
package server
import (
"context"
"encoding/json"
"fmt"
"github.com/Autumn-27/artex/intercept"
"strings"
"testing"
)
const completeFindingReviewReport = `# 同一权限缺陷
## 概述
同一对象权限检查缺失。
## 影响
已验证读取其他账号的订单。
## 前置条件
已存在的低权会话和订单编号。
## 复现步骤
对照自己的订单,再查看其他账号订单,响应证明同一检查缺失。
## 证据
原报告的保存响应及命令输出,未重新探测。
## PoC
完整命令在原始证据中,此测试使用隔离材料。
## 根因分析
同一订单 handler 未验证订单所有者。
## 修复建议
统一验证请求者与订单所有者,并回归对象权限检查。
`
func TestUnifiedFindingReportRejectsIncompleteAndTruncated(t *testing.T) {
if err := validateUnifiedFindingReport(completeFindingReviewReport); err != nil {
t.Fatal(err)
}
for _, s := range []string{strings.Split(completeFindingReviewReport, "## 根因分析")[0], completeFindingReviewReport + "\n```bash\nunclosed", strings.Replace(completeFindingReviewReport, "统一验证请求者与订单所有者,并回归对象权限检查。", "", 1)} {
if err := validateUnifiedFindingReport(s); err == nil {
t.Fatal("incomplete report accepted")
}
}
// Section labels appearing inside a script are not real report sections.
if err := validateUnifiedFindingReport("## 概述\ntext\n```bash\n" + completeFindingReviewReport + "\n```"); err == nil {
t.Fatal("script comments counted as sections")
}
}
func TestFindingCaseReviewCompletionRequiresReadsAndFreshReport(t *testing.T) {
s, task, ids := testCaseServer(t)
runs, err := s.m.pg.CreateFindingCaseReviews(t.Context(), map[int64][]int64{task: ids})
if err != nil {
t.Fatal(err)
}
conv := runs[0].ConversationID
defer s.m.pg.Exec(`DELETE FROM conversations WHERE id=$1`, conv)
if _, err := s.m.pg.Exec(`UPDATE finding_case_review_runs SET state='running' WHERE conversation_id=$1`, conv); err != nil {
t.Fatal(err)
}
ctx := intercept.WithConvID(context.Background(), conv)
a := findingReviewConclusion{Summary: "根据对象权限检查的具体证据判断"}
for _, id := range ids {
a.Decisions = append(a.Decisions, findingReviewDecision{FindingID: id, Verdict: "merged", Reason: "同一 handler", RootCauseEvidence: "各记录的 baseline/proof 指向同一对象所有者校验缺失"})
}
if err := s.saveFindingReviewConclusion(ctx, conv, a); err == nil {
t.Fatal("unread findings accepted")
}
for _, id := range ids {
if _, err := s.performFindingCaseTool(ctx, "get_finding_record", findingCaseRequest{FindingID: json.RawMessage(fmt.Sprint(id))}); err != nil {
t.Fatal(err)
}
}
cid, err := s.m.pg.MergeFindingCase(ctx, task, ids, "orders", "same concrete check", "reporter")
if err != nil {
t.Fatal(err)
}
if err := s.saveFindingReviewConclusion(ctx, conv, a); err == nil {
t.Fatal("missing unified report accepted")
}
c, _ := s.m.pg.GetFindingCase(cid)
if err := s.m.pg.UpdateFindingCaseReport(ctx, cid, c.Version, "orders", completeFindingReviewReport, "high", "verified impact"); err != nil {
t.Fatal(err)
}
if err := s.saveFindingReviewConclusion(ctx, conv, a); err != nil {
t.Fatal(err)
}
if err := s.validateFindingReviewCompletion(conv); err != nil {
t.Fatal(err)
}
if err := s.checkCaseReviewScope(ctx, "update_finding_case_report", findingCaseRequest{CaseID: json.RawMessage(fmt.Sprint(cid))}); err == nil {
t.Fatal("sealed review can still mutate")
}
if _, err := s.m.pg.Exec(`UPDATE findings SET evidence='new proof' WHERE id=$1`, ids[0]); err != nil {
t.Fatal(err)
}
if err := s.validateFindingReviewCompletion(conv); err == nil {
t.Fatal("stale group reported completed")
}
}
func TestFindingCaseReviewProtectsOriginalToolWrites(t *testing.T) {
s, f, _ := trafficEvidenceServer(t)
original, err := s.m.pg.GetFinding(f.FindingID)
if err != nil {
t.Fatal(err)
}
runs, err := s.m.pg.CreateFindingCaseReviews(t.Context(), map[int64][]int64{*original.TaskID: {original.ID}})
if err != nil {
t.Fatal(err)
}
conv := runs[0].ConversationID
defer s.m.pg.Exec(`DELETE FROM conversations WHERE id=$1`, conv)
ctx := intercept.WithConvID(context.Background(), conv)
workflowCall(t, ctx, s.toolUpdateFindingReport(), map[string]any{"finding_id": f.NodeID, "report": "OVERWRITE", "evidence_version": original.EvidenceVersion}, true)
if err := s.agentFindingTrafficAccess(ctx, f.FindingID, true); err == nil {
t.Fatal("historical review can change evidence")
}
after, _ := s.m.pg.GetFinding(original.ID)
if after.Report != original.Report || after.EvidenceVersion != original.EvidenceVersion || after.Status != original.Status || after.Severity != original.Severity {
t.Fatal("original changed")
}
// Normal reporting is still able to write its own standalone report.
workflowCall(t, context.Background(), s.toolUpdateFindingReport(), map[string]any{"finding_id": f.NodeID, "report": "normal report", "evidence_version": original.EvidenceVersion}, false)
}
func TestFindingCaseReviewSuggestionNeedsSavedSuggestion(t *testing.T) {
s, task, ids := testCaseServer(t)
runs, err := s.m.pg.CreateFindingCaseReviews(t.Context(), map[int64][]int64{task: ids[:2]})
if err != nil {
t.Fatal(err)
}
conv := runs[0].ConversationID
defer s.m.pg.Exec(`DELETE FROM conversations WHERE id=$1`, conv)
s.m.pg.Exec(`UPDATE finding_case_review_runs SET state='running' WHERE conversation_id=$1`, conv)
ctx := intercept.WithConvID(context.Background(), conv)
a := findingReviewConclusion{Summary: "需人工确认共同组件"}
for _, id := range ids[:2] {
s.performFindingCaseTool(ctx, "get_finding_record", findingCaseRequest{FindingID: json.RawMessage(fmt.Sprint(id))})
a.Decisions = append(a.Decisions, findingReviewDecision{FindingID: id, Verdict: "suggested", Reason: "同一边界但根因未确认", RootCauseEvidence: "原报告指向对象权限边界,缺少共同组件证据"})
}
if err := s.saveFindingReviewConclusion(ctx, conv, a); err == nil {
t.Fatal("unpersisted suggestion accepted")
}
if _, err := s.m.pg.SuggestFindingCase(ctx, task, ids[0], ids[1], "orders", "common component unknown"); err != nil {
t.Fatal(err)
}
if err := s.saveFindingReviewConclusion(ctx, conv, a); err != nil {
t.Fatal(err)
}
}
func TestFindingCaseReviewCandidatesStayWithinSelection(t *testing.T) {
s, task, ids := testCaseServer(t)
runs, err := s.m.pg.CreateFindingCaseReviews(t.Context(), map[int64][]int64{task: ids[:2]})
if err != nil {
t.Fatal(err)
}
conv := runs[0].ConversationID
defer s.m.pg.Exec(`DELETE FROM conversations WHERE id=$1`, conv)
ctx := intercept.WithConvID(context.Background(), conv)
a := findingCaseRequest{FindingID: json.RawMessage(fmt.Sprint(ids[0]))}
v, err := s.performFindingCaseTool(ctx, "search_finding_duplicates", a)
if err != nil {
t.Fatal(err)
}
if v.(map[string]any)["total"].(int) != 1 {
t.Fatal("unselected candidates leaked", v)
}
v, err = s.performFindingCaseTool(context.Background(), "search_finding_duplicates", a)
if err != nil || v.(map[string]any)["total"].(int) != 2 {
t.Fatal("normal candidate search changed", v, err)
}
}
+58 -32
View File
@@ -7,7 +7,6 @@ import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/Autumn-27/artex/db"
@@ -38,7 +37,7 @@ func (a findingCaseRequest) ids() []int64 {
}
func caseHTTPError(w http.ResponseWriter, err error) {
code := 400
if errors.Is(err, db.ErrFindingCaseConflict) {
if errors.Is(err, db.ErrFindingCaseConflict) || errors.Is(err, db.ErrFindingCaseReviewBusy) {
code = 409
}
writeErr(w, code, err.Error())
@@ -211,6 +210,10 @@ func (s *Server) updateCaseReport(w http.ResponseWriter, r *http.Request) {
if !decodeCaseRequest(w, r, &a) {
return
}
if err := validateUnifiedFindingReport(a.Report); err != nil {
caseHTTPError(w, err)
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
@@ -260,6 +263,7 @@ func (s *Server) findingCaseTools() []actool.CoreTool {
out = append(out, wrTool(spec.name, spec.desc, schema, handler))
}
}
out = append(out, s.toolCompleteFindingCaseReview())
return out
}
func (s *Server) performFindingCaseTool(ctx context.Context, name string, a findingCaseRequest) (any, error) {
@@ -274,7 +278,15 @@ func (s *Server) performFindingCaseTool(ctx context.Context, name string, a find
}
switch name {
case "search_finding_duplicates":
items, total, err := s.m.pg.FindingCaseCandidates(fid, page, 20)
scope, err := s.historicalFindingReviewSelection(ctx)
if err != nil {
return nil, err
}
var scopes [][]int64
if scope != nil {
scopes = append(scopes, scope)
}
items, total, err := s.m.pg.FindingCaseCandidates(fid, page, 20, scopes...)
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)
@@ -292,6 +304,11 @@ func (s *Server) performFindingCaseTool(ctx context.Context, name string, a find
if cid > 0 {
dto.CaseID = i64s(cid)
}
if conv := intercept.ConvIDFromContext(ctx); conv > 0 {
if _, err := s.m.pg.Exec(`UPDATE finding_case_review_runs SET reviewed_ids=CASE WHEN reviewed_ids @> jsonb_build_array($2::bigint) THEN reviewed_ids ELSE reviewed_ids || jsonb_build_array($2::bigint) END WHERE conversation_id=$1 AND finding_ids @> jsonb_build_array($2::bigint)`, conv, f.ID); err != nil {
return nil, err
}
}
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")
@@ -318,6 +335,9 @@ func (s *Server) performFindingCaseTool(ctx context.Context, name string, a find
}
return map[string]any{"case": c, "members": members, "total": total, "page": page, "page_size": 20}, err
case "update_finding_case_report":
if err := validateUnifiedFindingReport(a.Report); err != nil {
return nil, err
}
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
}
@@ -332,17 +352,20 @@ func (s *Server) seedFindingCaseTools() {
if err != nil || a == nil {
return
}
const flag = "finding_case_tools_v1"
const flag = "finding_case_tools_v2_review"
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
return
}
names := []string{}
previous, _, _ := s.m.pg.GetSetting("finding_case_tools_v1")
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 previous != "true" || t.Name() == "complete_finding_case_review" {
names = append(names, t.Name())
}
}
if err := s.m.pg.AddAgentToToolBinding("reporter", names); err != nil {
return
@@ -393,39 +416,36 @@ func (s *Server) reviewFindingCases(w http.ResponseWriter, r *http.Request) {
}
batches[*f.TaskID] = append(batches[*f.TaskID], fid)
}
// Validate every source before starting any batch.
// Validate and snapshot every source before starting any batch.
taskContexts := map[int64]triggeredRun{}
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))
t, ok := s.m.Task(i64s(taskID))
if !ok {
writeErr(w, 409, "任务不可用")
return
}
taskContexts[taskID] = triggeredRun{taskDesc: t.Description, taskGoal: t.Goal}
}
reservations, err := s.m.pg.CreateFindingCaseReviews(r.Context(), batches)
if err != nil {
caseHTTPError(w, err)
return
}
runs := []map[string]any{}
for _, reservation := range reservations {
taskID, fids := reservation.TaskID, reservation.FindingIDs
taskContext := taskContexts[taskID]
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}
msg := fmt.Sprintf("人工选择整理(只读原始记录):本任务所选 finding_ids=%s。逐条读取已有报告,仅判断同一实际根因,明确则归并,不确定提交建议;已有组更新统一报告。禁止生成或覆盖原始报告、改评级状态、补绑证据,不归并未选记录,不探测目标。结束前调用 complete_finding_case_review 提交逐条判断并通过验收。", raw)
item := triggeredRun{agentKey: "reporter", title: "漏洞整理 · task#" + i64s(taskID), message: msg, taskID: taskID, taskDesc: taskContext.taskDesc, taskGoal: taskContext.taskGoal, conversationID: reservation.ConversationID}
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)})
runs = append(runs, map[string]any{"conversation_id": reservation.ConversationID, "task_id": i64s(taskID)})
}
writeJSON(w, 202, map[string]any{"runs": runs})
}
@@ -460,8 +480,8 @@ func (s *Server) checkCaseReviewScope(ctx context.Context, name string, a findin
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)
var raw, conclusion json.RawMessage
err := s.m.pg.QueryRow(`SELECT finding_ids,conclusion FROM finding_case_review_runs WHERE conversation_id=$1`, conv).Scan(&raw, &conclusion)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil
@@ -472,16 +492,22 @@ func (s *Server) checkCaseReviewScope(ctx context.Context, name string, a findin
if err := json.Unmarshal(raw, &ids); err != nil {
return err
}
if string(conclusion) != "{}" && (name == "merge_finding_records" || name == "suggest_finding_merge" || name == "update_finding_case_report") {
return errors.New("整理判断已提交,不能在验收后继续修改")
}
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)}
case "search_finding_duplicates":
if !allowed[parseProfileID(a.FindingID)] {
return errors.New("历史整理只能检索所选记录的候选")
}
case "update_finding_report", "bind_finding_traffic", "report_finding":
return errors.New("历史整理禁止修改原始报告、上报或证据;只能写归并关系及统一报告")
case "merge_finding_records", "suggest_finding_merge":
checkIDs := a.ids()
for _, id := range checkIDs {
if !allowed[id] {
return errors.New("人工整理只能归并所选记录")
+3 -3
View File
@@ -87,7 +87,7 @@ func TestFindingCaseAPIPaginationAndTools(t *testing.T) {
}
c := result.(map[string]any)["case"].(*db.FindingCase)
a.Version = c.Version
a.Report = "complete reproducible report"
a.Report = completeFindingReviewReport
a.Severity = "high"
a.SeverityReason = "verified data disclosure"
if _, err := s.performFindingCaseTool(ctx, "update_finding_case_report", a); err != nil {
@@ -200,8 +200,8 @@ func TestFindingCaseSelectedReviewCannotRewriteOtherOriginal(t *testing.T) {
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)
if err := s.checkCaseReviewScope(ctx, "update_finding_report", findingCaseRequest{FindingID: json.RawMessage(fmt.Sprint(ids[0]))}); err == nil {
t.Fatal("selected original was allowed to be overwritten")
}
}
+5
View File
@@ -116,6 +116,11 @@ func objectProperty(parent map[string]any, key string) map[string]any {
}
func (s *Server) agentFindingTrafficAccess(ctx context.Context, id int64, write bool) error {
if write {
if err := s.checkCaseReviewScope(ctx, "bind_finding_traffic", findingCaseRequest{}); err != nil {
return err
}
}
if id <= 0 {
return errors.New("finding_id 必须为独立漏洞记录 ID;不是探索节点 ID")
}
+148 -32
View File
@@ -4,7 +4,7 @@ import * as React from "react";
import Link from "next/link";
import { ChevronRightIcon, FolderIcon } from "lucide-react";
import { ChevronRightIcon, FolderIcon, FolderOpenIcon } from "lucide-react";
import { toast } from "sonner";
import { StatusBadge } from "@/components/status-badge";
@@ -12,8 +12,9 @@ import { TablePagination } from "@/components/table-pagination";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Spinner } from "@/components/ui/spinner";
import { api } from "@/lib/api";
import type {
@@ -55,18 +56,23 @@ export function FindingCaseMemberRow({
onSelect,
contextTask,
matched = true,
nested = false,
}: {
finding: Finding;
selected?: boolean;
onSelect?: (id: string, checked: boolean) => void;
contextTask?: string;
matched?: boolean;
nested?: boolean;
}) {
const id = f.finding_id ?? f.id;
return (
<div
className={cn(
"flex min-w-0 flex-wrap items-center gap-3 border-b px-4 py-3 text-sm last:border-b-0",
"flex min-w-0 flex-wrap items-center gap-3 py-3 text-sm",
nested
? "relative rounded-lg border bg-background px-3 before:absolute before:top-1/2 before:-left-4 before:w-4 before:border-border before:border-t sm:before:-left-5 sm:before:w-5"
: "border-b px-4 last:border-b-0",
!matched && "bg-muted/30",
)}
>
@@ -102,6 +108,7 @@ export function FindingCaseMembers({
selectedIds,
onSelect,
contextTask,
nested = false,
}: {
caseId: string;
version?: number;
@@ -109,6 +116,7 @@ export function FindingCaseMembers({
selectedIds?: Set<string>;
onSelect?: (id: string, checked: boolean) => void;
contextTask?: string;
nested?: boolean;
}) {
const [page, setPage] = React.useState(1);
const [data, setData] = React.useState<{ items: Finding[]; total: number } | null>(null);
@@ -148,11 +156,12 @@ export function FindingCaseMembers({
</div>
);
return (
<div className="flex min-w-0 flex-col">
<div className={cn("flex min-w-0 flex-col", nested && "gap-2 border-primary/25 border-l-2 pl-4 sm:pl-5")}>
{data.items.map((f) => (
<FindingCaseMemberRow
key={f.finding_id}
finding={f}
nested={nested}
selected={selectedIds?.has(f.finding_id ?? f.id)}
onSelect={onSelect}
contextTask={contextTask}
@@ -173,10 +182,91 @@ export function FindingCaseMembers({
);
}
function FindingMergeSuggestionCard({
suggestion: s,
onResolve,
}: {
suggestion: FindingCaseSuggestion;
onResolve: (s: FindingCaseSuggestion, accept: boolean) => Promise<void>;
}) {
const [expanded, setExpanded] = React.useState(false);
const [busy, setBusy] = React.useState(false);
async function resolve(accept: boolean) {
if (busy) return;
setBusy(true);
try {
await onResolve(s, accept);
} finally {
setBusy(false);
}
}
return (
<Card size="sm" className="min-w-0">
<CardHeader>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">疑似重复 · 待确认</Badge>
<CardDescription>任务 #{s.task_id}</CardDescription>
</div>
<CardTitle className="break-words">{s.title}</CardTitle>
<CardDescription className="flex flex-wrap gap-3">
<Link className="underline underline-offset-4" href={`/function/findings/detail/?id=${s.left_id}`}>
查看原报告 #{s.left_id}
</Link>
<Link className="underline underline-offset-4" href={`/function/findings/detail/?id=${s.right_id}`}>
查看原报告 #{s.right_id}
</Link>
</CardDescription>
</CardHeader>
<CardContent>
<Collapsible open={expanded} onOpenChange={setExpanded}>
{!expanded ? (
<p className="line-clamp-2 whitespace-pre-wrap break-words text-muted-foreground text-sm">{s.reason}</p>
) : null}
<CollapsibleContent className="whitespace-pre-wrap break-words text-muted-foreground text-sm">
{s.reason}
</CollapsibleContent>
<CollapsibleTrigger asChild>
<Button variant="link" size="sm">
{expanded ? "收起判断依据" : "展开判断依据"}
</Button>
</CollapsibleTrigger>
</Collapsible>
</CardContent>
<CardFooter className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" disabled={busy} onClick={() => resolve(true)}>
{busy ? <Spinner /> : null}确认归并
</Button>
<Button variant="ghost" size="sm" disabled={busy} onClick={() => resolve(false)}>
不是同一漏洞
</Button>
</CardFooter>
</Card>
);
}
function FindingReviewRunRow({ run: r }: { run: FindingCaseReviewRun }) {
return (
<div className="flex min-w-0 flex-wrap items-center gap-2 text-sm">
{r.state === "running" ? <Spinner /> : null}
<span>
任务 #{r.task_id} · 整理 #{r.conversation_id}
</span>
<Badge variant={r.state === "failed" ? "destructive" : "secondary"}>
{{ queued: "排队中", running: "运行中", done: "已完成", failed: "失败" }[r.state]}
</Badge>
<Link className="underline underline-offset-4" href={`/chat?c=${r.conversation_id}`}>
查看执行记录
</Link>
{r.error ? <p className="w-full break-words text-destructive">{r.error}</p> : null}
</div>
);
}
export function FindingCaseReviewPanel({ taskId, onChange }: { taskId?: string; onChange?: () => void }) {
const [suggestions, setSuggestions] = React.useState<FindingCaseSuggestion[]>([]);
const [runs, setRuns] = React.useState<FindingCaseReviewRun[]>([]);
const [error, setError] = React.useState("");
const [historyOpen, setHistoryOpen] = React.useState(false);
React.useEffect(() => {
let active = true;
let timer: ReturnType<typeof setTimeout>;
@@ -205,7 +295,7 @@ export function FindingCaseReviewPanel({ taskId, onChange }: { taskId?: string;
const result = await api.resolveFindingCaseSuggestion(s.id, accept);
setSuggestions((v) => v.filter((x) => x.id !== s.id));
if (accept && result.case_id !== "0") {
toast.success("已归并,统一报告待生成");
toast.success("已归并,请进入文件夹生成统一报告");
} else {
toast.success("已否决,不会自动再次归并");
}
@@ -214,6 +304,9 @@ export function FindingCaseReviewPanel({ taskId, onChange }: { taskId?: string;
toast.error((e as Error).message);
}
}
const latestByTask = new Map<string, FindingCaseReviewRun>();
for (const run of runs) if (!latestByTask.has(run.task_id)) latestByTask.set(run.task_id, run);
const latestFailures = [...latestByTask.values()].filter((run) => run.state === "failed");
return (
<div className="flex flex-col gap-2">
{error ? (
@@ -221,34 +314,45 @@ export function FindingCaseReviewPanel({ taskId, onChange }: { taskId?: string;
<AlertDescription>整理状态加载失败:{error}</AlertDescription>
</Alert>
) : null}
{suggestions.length > 0 ? (
<p className="text-muted-foreground text-sm">待确认归并建议({suggestions.length})· 确认前原始记录分别保留</p>
) : null}
{suggestions.map((s) => (
<Alert key={s.id}>
<FindingMergeSuggestionCard key={s.id} suggestion={s} onResolve={resolve} />
))}
{runs.some((r) => r.state === "queued" || r.state === "running") ? (
<div className="flex flex-col gap-2">
{runs
.filter((r) => r.state === "queued" || r.state === "running")
.map((r) => (
<FindingReviewRunRow key={r.conversation_id} run={r} />
))}
</div>
) : null}
{latestFailures.map((r) => (
<Alert key={r.conversation_id} variant="destructive">
<AlertDescription>
<div className="flex flex-wrap items-center gap-2">
<span className="flex-1">
疑似重复:#{s.left_id} / #{s.right_id} · {s.title}。{s.reason}
</span>
<Button variant="outline" size="sm" onClick={() => resolve(s, true)}>
确认归并
</Button>
<Button variant="ghost" size="sm" onClick={() => resolve(s, false)}>
不是同一漏洞
</Button>
</div>
<FindingReviewRunRow run={r} />
</AlertDescription>
</Alert>
))}
{runs.slice(0, 5).map((r) => (
<div key={r.conversation_id} className="flex flex-wrap items-center gap-2 text-muted-foreground text-xs">
<span>
任务 #{r.task_id} 整理:{{ queued: "排队中", running: "运行中", done: "已完成", failed: "失败" }[r.state]}
</span>
{r.error ? <span className="text-destructive">{r.error}</span> : null}
<Link className="underline" href={`/chat?c=${r.conversation_id}`}>
查看执行记录
</Link>
</div>
))}
{runs.some((r) => r.state === "done" || r.state === "failed") ? (
<Collapsible open={historyOpen} onOpenChange={setHistoryOpen}>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm">
{historyOpen ? "收起整理历史" : "查看整理历史"}(
{runs.filter((r) => r.state === "done" || r.state === "failed").length})
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="flex flex-col gap-2 py-2">
{runs
.filter((r) => r.state === "done" || r.state === "failed")
.map((r) => (
<FindingReviewRunRow key={r.conversation_id} run={r} />
))}
</CollapsibleContent>
</Collapsible>
) : null}
</div>
);
}
@@ -345,18 +449,23 @@ export function FindingCaseList({
const group = row.case;
return (
<Card key={`case:${group.id}`} className="gap-0 overflow-hidden py-0">
<CardHeader className="px-4 py-3">
<CardHeader className={cn("px-4 py-3", open.has(group.id) && "bg-muted/60")}>
<div className="flex min-w-0 flex-wrap items-center gap-3">
<Button
variant="ghost"
size="icon-sm"
aria-label={`${open.has(group.id) ? "收起" : "展开"}${group.title}`}
aria-expanded={open.has(group.id)}
aria-controls={`finding-case-members-${group.id}`}
onClick={() => toggle(group.id)}
>
<ChevronRightIcon className={cn(open.has(group.id) && "rotate-90")} />
</Button>
<FolderIcon className="size-4 shrink-0 text-muted-foreground" />
{open.has(group.id) ? (
<FolderOpenIcon className="size-5 shrink-0 text-primary" />
) : (
<FolderIcon className="size-5 shrink-0 text-muted-foreground" />
)}
<div className="flex min-w-0 flex-1 basis-2/3 flex-col gap-1 sm:basis-auto">
<CardTitle className="truncate text-sm">
<Link
@@ -367,7 +476,7 @@ export function FindingCaseList({
</Link>
</CardTitle>
<CardDescription>
{group.count} 条上报 ·{" "}
漏洞文件夹 · {group.count} 条原始上报 ·{" "}
{group.report_version !== group.version ? "统一报告待更新" : "统一报告已生成"}
</CardDescription>
</div>
@@ -380,8 +489,15 @@ export function FindingCaseList({
</div>
</CardHeader>
{open.has(group.id) ? (
<CardContent className="border-t px-0">
<CardContent
id={`finding-case-members-${group.id}`}
role="region"
aria-label={`${group.title}的原始子报告`}
className="border-t bg-muted/20 px-4 py-4 sm:px-6"
>
<p className="mb-3 text-muted-foreground text-xs">原始子报告 · 各自等级和证据保留</p>
<FindingCaseMembers
nested
caseId={group.id}
version={group.version}
matchedIds={row.matched_ids}