fix(findings): address Grok review r1 (Codex gpt-6-astra)

Authored by Codex CLI (gpt-6-astra, reasoning high) after 3 gpt-6.1-sol
capacity failures. Scope sweep aggregates per suggestion, veto only kept
suggestion members on exclusion, refresh finding-case tool schemas on existing
DBs, clarify weak tier wording, keep card state across reloads, filter stale
exclusions, always reload after confirm, guard task-switch toasts.
This commit is contained in:
RuoJi6 committed 2026-10-24 22:47:16 +08:00
1 parent 09e90b4170
commit 5b342f9f78
6 files changed
+109 -24

No files matched your search

+1 -1
View File
@@ -45,7 +45,7 @@ func findingCaseWorkflow(key string, tools []actool.CoreTool) ([]actool.CoreTool
}
return tools, `
【同一漏洞的多次上报整理】保留本次独立报告,不覆盖其他原报告。默认报告完成即停止的旧指引在此扩展:先保存本次报告,然后 search_finding_duplicates(finding_id=独立记录ID) 分页读取同任务候选摘要(按 score 排序;strong=同漏洞类型且资产重叠,优先核对;weak=单一条件命中,仅作参考),按需 get_finding_record 查看证据。仅凭漏洞类型、标题、令牌、登录入口相似或高中低等级不能归并;须核对目标、接口、参数、权限边界、根因及实际验证证据。只能处理同一任务,尊重人工否决和 finding_case_blocks。
【同一漏洞的多次上报整理】保留本次独立报告,不覆盖其他原报告。默认报告完成即停止的旧指引在此扩展:先保存本次报告,然后 search_finding_duplicates(finding_id=独立记录ID) 分页读取同任务候选摘要(按 score 排序;strong=同漏洞类型且资产重叠,优先核对;weak=未同时具备同漏洞类型和资产重叠,结合 hit_reasons 核对,仅作参考),按需 get_finding_record 查看证据。仅凭漏洞类型、标题、令牌、登录入口相似或高中低等级不能归并;须核对目标、接口、参数、权限边界、根因及实际验证证据。只能处理同一任务,尊重人工否决和 finding_case_blocks。
【判断准则】若证据表明端点/路由、处理器或后端组件、易受影响参数,或缺失检查属于不同代码路径,可以据此判定 independent;必须说明各自证据。仅凭路径、接口、登录入口、令牌、标题或漏洞类型相同/不同,都不能单独证明同一根因或不同根因。只有根因明确相同才直接调用 merge_finding_records(task_id,finding_ids,title,reason)。只有存在具体共同点(两条记录都证据化地指向同一端点、参数、组件或缺失检查),但缺少确认共同根因的关键证据时,才调用 suggest_finding_merge;一次提交覆盖2..20条同任务记录的一个分组,不要拆成两两建议。仅共享漏洞类型或资产没有具体共同点,不得提交建议;没有具体共同点时保持 independent,不要因为不确定就 suggest。不得重复上报、删除记录或重新探测目标。人工选择整理时只能判断所选记录,不能加入未选记录(既有组成员可作为报告上下文)。
归并成功或已有case_id时,get_finding_case 记录实际version,分页读取全部成员,按需 get_finding_record、get_finding_traffic 读取原报告、PoC及已保存流量。根因明确时归并不被统一报告阻塞,应随后尽力用 update_finding_case_report 保存统一报告;如果本次无法完成,文件夹显示“统一报告待更新”,由后续 reporter 运行或人工补齐。统一报告在保存时仍须包含有内容的概述、影响、前置条件、复现步骤、证据、PoC、根因分析、修复建议章节,不得半篇截断;评级及 severity_reason 必须基于已验证影响,不能直接取成员最高等级。集中给出前置条件、完整执行顺序、输入输出如何衔接、可运行的完整脚本及命令,不把必要步骤分散到原报告;缺失条件、未验证步骤如实标明。版本冲突重新读取材料并生成,不能仅换version重试。各成员仍有独立报告、评级和证据。工具被关闭时不绕过配置。`
}
+13 -5
View File
@@ -209,6 +209,7 @@ func (d *DB) mergeFindingCase(ctx context.Context, taskID int64, ids []int64, ti
if strings.TrimSpace(title) == "" || strings.TrimSpace(reason) == "" {
return 0, errors.New("必须提供漏洞名称及基于证据的归并理由")
}
keptIDs := append([]int64(nil), ids...)
var cid int64
err = d.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
if err := lockCaseTask(tx, taskID); err != nil {
@@ -313,7 +314,7 @@ func (d *DB) mergeFindingCase(ctx context.Context, taskID int64, ids []int64, ti
if all[excluded] {
return fmt.Errorf("排除的记录 #%d 已与所选记录在同一文件夹,请先移出该文件夹后再确认", excluded)
}
for _, kept := range ids {
for _, kept := range keptIDs {
if _, err := tx.Exec(`INSERT INTO finding_case_blocks(left_id,right_id,reason)
VALUES(LEAST($1::bigint,$2::bigint),GREATEST($1::bigint,$2::bigint),'人工确认分组时排除')
ON CONFLICT DO NOTHING`, excluded, kept); err != nil {
@@ -337,24 +338,31 @@ func (d *DB) mergeFindingCase(ctx context.Context, taskID int64, ids []int64, ti
func sweepFindingCaseSuggestions(tx *sql.Tx, taskID int64) error {
if _, err := tx.Exec(`UPDATE finding_case_suggestions s SET state='accepted'
WHERE s.task_id=$1 AND s.state='pending'
AND (SELECT count(*) FROM finding_case_suggestion_members sm
WHERE sm.suggestion_id=s.id
AND EXISTS (SELECT 1 FROM findings f WHERE f.id=sm.finding_id AND f.task_id=s.task_id)) >= 2
AND (SELECT count(*) FROM finding_case_suggestion_members sm
JOIN findings f ON f.id=sm.finding_id AND f.task_id=s.task_id
JOIN finding_case_members cm ON cm.finding_id=sm.finding_id
JOIN finding_cases c ON c.id=cm.case_id AND c.active) >= 2
JOIN finding_cases c ON c.id=cm.case_id AND c.active
WHERE sm.suggestion_id=s.id) >= 2
AND (SELECT count(DISTINCT cm.case_id) FROM finding_case_suggestion_members sm
JOIN findings f ON f.id=sm.finding_id AND f.task_id=s.task_id
JOIN finding_case_members cm ON cm.finding_id=sm.finding_id
JOIN finding_cases c ON c.id=cm.case_id AND c.active) = 1
JOIN finding_cases c ON c.id=cm.case_id AND c.active
WHERE sm.suggestion_id=s.id) = 1
AND NOT EXISTS (SELECT 1 FROM finding_case_suggestion_members sm
JOIN findings f ON f.id=sm.finding_id AND f.task_id=s.task_id
LEFT JOIN finding_case_members cm ON cm.finding_id=sm.finding_id
WHERE cm.finding_id IS NULL)`, taskID); err != nil {
LEFT JOIN finding_cases c ON c.id=cm.case_id AND c.active
WHERE sm.suggestion_id=s.id AND c.id IS NULL)`, taskID); err != nil {
return err
}
_, err := tx.Exec(`UPDATE finding_case_suggestions s SET state='obsolete'
WHERE s.task_id=$1 AND s.state='pending' AND (
(SELECT count(*) FROM finding_case_suggestion_members sm
JOIN findings f ON f.id=sm.finding_id AND f.task_id=s.task_id) < 2
JOIN findings f ON f.id=sm.finding_id AND f.task_id=s.task_id
WHERE sm.suggestion_id=s.id) < 2
OR EXISTS (SELECT 1
FROM finding_case_suggestion_members a
JOIN finding_case_suggestion_members b
+51
View File
@@ -388,6 +388,32 @@ func TestFindingCaseSuggestionClusterLifecycle(t *testing.T) {
}
}
func TestFindingCaseSuggestionSweepScopesEachCard(t *testing.T) {
d, task, ids := caseTestDB(t)
ctx := context.Background()
first, err := d.SuggestFindingCase(ctx, task.ID, ids[:2], "first", "first group")
if err != nil {
t.Fatal(err)
}
second, err := d.SuggestFindingCase(ctx, task.ID, ids[2:], "second", "second group")
if err != nil {
t.Fatal(err)
}
if _, err := d.MergeFindingCase(ctx, task.ID, ids[:2], "first", "same root cause", "human"); err != nil {
t.Fatal(err)
}
var firstState, secondState string
if err := d.QueryRow(`SELECT state FROM finding_case_suggestions WHERE id=$1`, first).Scan(&firstState); err != nil {
t.Fatal(err)
}
if err := d.QueryRow(`SELECT state FROM finding_case_suggestions WHERE id=$1`, second).Scan(&secondState); err != nil {
t.Fatal(err)
}
if firstState != "accepted" || secondState != "pending" {
t.Fatalf("suggestion states first=%q second=%q", firstState, secondState)
}
}
func TestFindingCaseSuggestionSubsetSuperseded(t *testing.T) {
d, task, ids := caseTestDB(t)
ctx := context.Background()
@@ -427,6 +453,31 @@ func TestFindingCaseSuggestionAcceptExclusionAndReject(t *testing.T) {
}
}
func TestFindingCaseSuggestionExclusionBlocksOnlyKeptSuggestionMembers(t *testing.T) {
d, task, ids := caseTestDB(t)
ctx := context.Background()
if _, err := d.MergeFindingCase(ctx, task.ID, ids[:2], "existing folder", "same root cause", "human"); err != nil {
t.Fatal(err)
}
sid, err := d.SuggestFindingCase(ctx, task.ID, []int64{ids[0], ids[2], ids[3]}, "orders", "same component")
if err != nil {
t.Fatal(err)
}
if _, err := d.ResolveFindingCaseSuggestion(ctx, sid, true, []int64{ids[3]}); err != nil {
t.Fatal(err)
}
var folderMateBlock, keptBlock int
if err := d.QueryRow(`SELECT count(*) FROM finding_case_blocks WHERE left_id=$1 AND right_id=$2`, ids[1], ids[3]).Scan(&folderMateBlock); err != nil {
t.Fatal(err)
}
if err := d.QueryRow(`SELECT count(*) FROM finding_case_blocks WHERE left_id=$1 AND right_id=$2`, ids[0], ids[3]).Scan(&keptBlock); err != nil {
t.Fatal(err)
}
if folderMateBlock != 0 || keptBlock != 1 {
t.Fatalf("exclusion blocks folder mate=%d kept=%d", folderMateBlock, keptBlock)
}
}
func TestFindingCaseSuggestionRejectVetoesAllPairs(t *testing.T) {
d, task, ids := caseTestDB(t)
ctx := context.Background()
+7 -3
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strconv"
"strings"
@@ -264,7 +265,7 @@ func (s *Server) findingCaseTools() []actool.CoreTool {
name, desc string
required []string
}{
{"search_finding_duplicates", "只读检索同任务候选摘要与资产,按分数排序并返回 score、tier、hit_reasons:strong=同漏洞类型且资产重叠,weak=单一条件命中仅作参考。等级不同也可能重复。支持分页,按需读取详情。", []string{"finding_id"}},
{"search_finding_duplicates", "只读检索同任务候选摘要与资产,按分数排序并返回 score、tier、hit_reasons:strong=同漏洞类型且资产重叠,weak=未同时具备同漏洞类型和资产重叠,请结合 hit_reasons 核对,仅作参考。等级不同也可能重复。支持分页,按需读取详情。", []string{"finding_id"}},
{"get_finding_record", "读取独立漏洞完整原始证据和报告,返回探索节点编号以便追溯执行记录。", []string{"finding_id"}},
{"merge_finding_records", "证据明确证明同一缺陷时归并;保留所有原始记录。不跨任务、不改变原评级,不可绕过人工否决。", []string{"task_id", "finding_ids", "title", "reason"}},
{"suggest_finding_merge", "证据存在具体共同点但缺少关键证据时,提交一个疑似重复分组(2..20 条同任务记录,一次覆盖全部疑似成员,不要拆成两两建议),待人工确认;不是自动合并。仅类型/资产相同不应提交。", []string{"task_id", "finding_ids", "title", "reason"}},
@@ -325,7 +326,7 @@ func (s *Server) performFindingCaseTool(ctx context.Context, name string, a find
}
items, total, strongTotal, err := s.m.pg.FindingCaseCandidates(fid, page, 20, scopes...)
return map[string]any{"items": items, "total": total, "strong_total": strongTotal, "page": page, "page_size": 20,
"note": "按分数排序;weak 为单一条件命中,仅作参考,通常不应据此建议归并"}, err
"note": "按分数排序;weak 表示未同时具备同漏洞类型和资产重叠,请结合 hit_reasons 核对,仅作参考,通常不应据此建议归并"}, err
case "get_finding_record":
f, err := s.m.pg.GetFinding(fid)
if err != nil {
@@ -390,7 +391,7 @@ func (s *Server) seedFindingCaseTools() {
if err != nil || a == nil {
return
}
const flag = "finding_case_tools_v2_review"
const flag = "finding_case_tools_v3_review"
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
return
}
@@ -398,6 +399,9 @@ func (s *Server) seedFindingCaseTools() {
previous, _, _ := s.m.pg.GetSetting("finding_case_tools_v1")
for _, t := range s.findingCaseTools() {
schema, _ := json.Marshal(t.InputSchema())
if err := s.m.pg.RefreshToolDefaults(t.Name(), t.Description(), schema); err != nil {
log.Printf("[finding-cases] refresh %s schema failed: %v", t.Name(), err)
}
if err := s.m.pg.SeedTool(t.Name(), t.Description(), schema, json.RawMessage(`["reporter"]`)); err != nil {
return
}
+1 -1
View File
@@ -524,7 +524,7 @@ func (s *Server) seedOrchestrationTools() {
// reaches an old DB otherwise. Preserves each tool's agent binding + enabled flag.
// Bump the flag whenever these tools' schemas/descriptions change in code.
func (s *Server) refreshBuiltinToolSchemas() {
const flag = "tool_schema_refresh_v7_list_facts_paging"
const flag = "tool_schema_refresh_v8_finding_case_tools"
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
return
}
+36 -14
View File
@@ -196,15 +196,25 @@ function FindingMergeSuggestionCard({
}) {
const [expanded, setExpanded] = React.useState(false);
const [busy, setBusy] = React.useState(false);
const ids = Array.isArray(s.finding_ids) && s.finding_ids.length > 0 ? s.finding_ids : [s.left_id, s.right_id];
const ids = React.useMemo(
() => (Array.isArray(s.finding_ids) && s.finding_ids.length > 0 ? s.finding_ids : [s.left_id, s.right_id]),
[s.finding_ids, s.left_id, s.right_id],
);
const [excluded, setExcluded] = React.useState<string[]>([]);
const included = ids.filter((id) => !excluded.includes(id));
React.useEffect(() => {
setExcluded((current) => {
const next = current.filter((id) => ids.includes(id));
return next.length === current.length ? current : next;
});
}, [ids]);
const validExcluded = excluded.filter((id) => ids.includes(id));
const included = ids.filter((id) => !validExcluded.includes(id));
async function resolve(accept: boolean) {
if (busy) return;
if (accept && included.length < 2) return;
setBusy(true);
try {
await onResolve(s, accept, accept ? excluded : []);
await onResolve(s, accept, accept ? validExcluded : []);
} finally {
setBusy(false);
}
@@ -294,13 +304,20 @@ export function FindingCaseReviewPanel({ taskId, onChange }: { taskId?: string;
const taskKey = taskId ?? "";
const taskKeyRef = React.useRef(taskKey);
taskKeyRef.current = taskKey;
const previousTaskKey = React.useRef(taskKey);
React.useEffect(() => {
if (previousTaskKey.current === taskKey) return;
previousTaskKey.current = taskKey;
setSuggestions([]);
setRuns([]);
setError("");
}, [taskKey]);
React.useEffect(() => {
const seq = ++requestSeq.current;
const requestedTask = taskKey;
void reload;
let active = true;
let timer: ReturnType<typeof setTimeout>;
setSuggestions([]);
async function load() {
try {
const [s, r] = await Promise.all([api.findingCaseSuggestions(taskId), api.findingCaseReviewRuns()]);
@@ -328,18 +345,23 @@ export function FindingCaseReviewPanel({ taskId, onChange }: { taskId?: string;
const requestedTask = taskKey;
try {
const result = await api.resolveFindingCaseSuggestion(s.id, accept, excludeIds);
if (seq !== requestSeq.current || requestedTask !== taskKeyRef.current) return;
setSuggestions((v) => v.filter((x) => x.id !== s.id));
setReload((v) => v + 1);
if (accept && result.case_id !== "0") {
toast.success("已归并,请进入文件夹生成统一报告");
} else {
toast.success("已否决,不会自动再次归并");
if (requestedTask === taskKeyRef.current) {
setReload((v) => v + 1);
if (seq === requestSeq.current) {
setSuggestions((v) => v.filter((x) => x.id !== s.id));
if (accept && result.case_id !== "0") {
toast.success("已归并,请进入文件夹生成统一报告");
} else {
toast.success("已否决,不会自动再次归并");
}
onChange?.();
}
}
onChange?.();
} catch (e) {
if (seq === requestSeq.current && requestedTask === taskKeyRef.current) setReload((v) => v + 1);
toast.error((e as Error).message);
if (requestedTask === taskKeyRef.current) {
setReload((v) => v + 1);
if (seq === requestSeq.current) toast.error((e as Error).message);
}
}
}
return (