mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-11-02 02:43:50 +08:00
fix(findings): protect historical reports and verify consolidation results
This commit is contained in:
1 parent
71ec0f0efd
commit
7db7b71cdb
15 files changed
+906
-74
No files matched your search
@@ -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。
|
||||
|
||||
|
||||
@@ -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
@@ -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,并提供总结。根因证据应说明具体相同/不同的检查及证据来源;疑似建议明确待补证据。服务端验收通过后才可宣布完成。工具不可用或验收失败时如实报告,不能只用自然语言声称完成。`
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
@@ -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)
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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("人工整理只能归并所选记录")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user