feat(findings): consolidate duplicate reports into expandable cases

This commit is contained in:
ji ruo committed 2026-10-09 10:43:53 +08:00
1 parent a91b27feb7
commit 24d4726aee
29 files changed
+3051 -63

No files matched your search

+7
View File
@@ -4,6 +4,13 @@
## [Unreleased]
### 同一漏洞的重复上报归并(保持程序版本 0.3.15)
- Reporter 在生成独立报告后按同任务候选证据判断同一实际缺陷,明确重复时归并,证据不足时提交人工确认建议。
- 新增可展开漏洞文件夹、统一报告、原始等级分布、独立漏洞/上报记录统计及归并历史;每条原报告、证据和流量继续保留。
- 支持手动整理所选历史记录、移出成员与防止自动再次归并,继承任务保持只读。
- 统一报告采用证据版本校验;导出默认统一报告,可包含原始子报告,未生成或过期时明确提示并保留原材料。
### 部署与发布维护(保持程序版本 0.3.15)
- 迁移至独立的 `RuoJi6/ARTEX` 仓库,保留原提交历史与分支。
+10
View File
@@ -314,6 +314,16 @@ server {
## 开发
### 同一漏洞归并与统一报告
发现页默认「按漏洞」显示。同一任务内,Reporter 依据目标、接口参数、权限边界、根因及验证证据确认同一实际缺陷后形成文件夹;仅类型或名称相同不会作为自动归并依据。严重程度不同仍可能是同一漏洞。文件夹的 `严/高/中/低` 数量按原始记录统计,例如 `0/1/1/1`;独立漏洞统计采用另行评估的统一等级,尚未评估时显示「待评估」。原始上报内容、评级、状态、报告及证据不被覆盖。
点击箭头展开原始记录,点击文件夹标题查看统一报告、原始记录及归并历史。证据不足的建议需要点击「确认归并」;否决或移出后,这些关系不会被 Agent 自动再次归并。移出成员不删除原漏洞,不足两条时解散文件夹并保留历史。继承任务的漏洞组保持只读。
新上报由现有 Reporter 生成独立报告并检查重复。升级首次补充 Reporter 的归并工具和运行指引,保留自定义提示词及触发设置;已删除或停用的 Reporter 不会重建、启用。历史记录不会自动扫描:展开并选择原始记录,点击「整理所选」,按任务分别排队执行,可查看执行记录及失败信息。文件夹详情可重新生成统一报告;需要启用 Reporter 并配置可用模型。判断和写作会消耗模型 token,候选检索先分页返回摘要,详情证据按需读取,不重新探测目标。
成员、报告或证据变化后统一报告标记待更新,写入时校验实际读取版本。导出默认每组一份统一报告及未归并记录,可勾选「包含原始子报告」;统一报告未生成或过期时附明确说明及全部原始材料。原始记录、按任务及按资产视图继续保留。升级沿用启动时幂等数据库迁移,无需执行独立 SQL。
### 手动漏洞复测
任务详情的「复测」页签可分页选择本任务的漏洞、查看历次结论和证据,并手动发起复测。启动后保留当前页签,显示转圈图标和「复测中」;确认修复后同步更新漏洞状态。
+3
View File
@@ -16,6 +16,8 @@ import (
// UnlockSkill unlocks a named skill's MCPs — hosts call it to rebuild the unlock set
// from history on a resumed session (design doc C2).
type DeferredInfo struct {
CaseGuidance string // independent of optional traffic binding
FindingGuidance string // derived from the final permitted tools, including DB overrides
Deferred []string // all MCP tool names (schema withheld)
GlobalNames []string // MCP names to list in the system-prompt block
@@ -61,6 +63,7 @@ func AugmentTools(ctx context.Context, agentKey string, base []actool.CoreTool)
out = ToolResolve(ctx, agentKey, out)
}
out, def.FindingGuidance = findingWorkflowTools(agentKey, out)
out, def.CaseGuidance = findingCaseWorkflow(agentKey, out)
for i, t := range out {
out[i] = guardPanic(t)
}
+1 -1
View File
@@ -16,7 +16,7 @@ import (
// skill loads. Returns a single plain segment + boundary 0 when there is no global
// block to add.
func deferredSystem(sysText string, def DeferredInfo) (system []string, boundary int) {
sysText += def.FindingGuidance
sysText += def.FindingGuidance + def.CaseGuidance
block := actool.RenderDeferredToolsBlock(def.GlobalNames)
if block == "" {
return []string{sysText}, 0
+22
View File
@@ -0,0 +1,22 @@
package agent
import actool "github.com/Autumn-27/norma/tool"
// This guidance is appended to the runtime prompt, including editable old prompts.
// Never reintroduce unbound tools or recreate a deleted Reporter.
func findingCaseWorkflow(key string, tools []actool.CoreTool) ([]actool.CoreTool, string) {
if key != "reporter" {
return tools, ""
}
has := map[string]bool{}
for _, t := range tools {
has[t.Name()] = true
}
if !has["search_finding_duplicates"] || !has["merge_finding_records"] || !has["get_finding_case"] || !has["update_finding_case_report"] {
return tools, ""
}
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重试。各成员仍有独立报告、评级和证据。没有明确重复则保留独立记录。工具被关闭时不绕过配置。`
}
+31
View File
@@ -0,0 +1,31 @@
package agent
import (
"context"
"encoding/json"
"strings"
"testing"
actool "github.com/Autumn-27/norma/tool"
)
func TestFindingCaseGuidanceRespectsToolsAndTrafficSwitch(t *testing.T) {
tools := []actool.CoreTool{}
for _, name := range []string{"search_finding_duplicates", "merge_finding_records", "get_finding_case", "update_finding_case_report"} {
tools = append(tools, writeTool(name, name, obj(nil), func(context.Context, json.RawMessage) (actool.Result, error) { return actool.Text("ok"), nil }))
}
_, guide := findingCaseWorkflow("reporter", tools)
if !strings.Contains(guide, "不能直接取成员最高等级") || !strings.Contains(guide, "独立报告") {
t.Fatal("missing preservation rules")
}
if _, guide := findingCaseWorkflow("worker", tools); guide != "" {
t.Fatal("worker changed")
}
if _, guide := findingCaseWorkflow("reporter", tools[:2]); guide != "" {
t.Fatal("unbound tools reintroduced")
}
system, _ := deferredSystem("CUSTOM PROMPT", DeferredInfo{CaseGuidance: guide})
if !strings.HasPrefix(system[0], "CUSTOM PROMPT") || !strings.Contains(system[0], guide) {
t.Fatal("custom prompt lost")
}
}
+567
View File
@@ -0,0 +1,567 @@
package db
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
)
var ErrFindingCaseConflict = errors.New("漏洞组已变更,请重新读取证据后再操作")
type FindingCase struct {
ID int64 `json:"id,string"`
TaskID *int64 `json:"task_id,string"`
Title string `json:"title"`
Reason string `json:"reason"`
Severity string `json:"severity"`
SeverityReason string `json:"severity_reason"`
Report string `json:"report,omitempty"`
Version int64 `json:"version"`
ReportVersion int64 `json:"report_version"`
Active bool `json:"active"`
Count int `json:"count"`
Critical int `json:"critical"`
High int `json:"high"`
Medium int `json:"medium"`
Low int `json:"low"`
CreatedAt time.Time `json:"created_at"`
}
type FindingCaseEvent struct {
ID int64 `json:"id,string"`
Action string `json:"action"`
Actor string `json:"actor"`
Reason string `json:"reason"`
FindingIDs json.RawMessage `json:"finding_ids"`
CreatedAt time.Time `json:"created_at"`
}
type FindingCaseSuggestion struct {
ID int64 `json:"id,string"`
TaskID int64 `json:"task_id,string"`
LeftID int64 `json:"left_id,string"`
RightID int64 `json:"right_id,string"`
Title string `json:"title"`
Reason string `json:"reason"`
State string `json:"state"`
}
type FindingCaseRow struct {
Case *FindingCase `json:"case,omitempty"`
FindingID int64 `json:"finding_id,string,omitempty"`
MatchedIDs []int64 `json:"matched_ids"`
}
const caseColumns = `c.id,c.task_id,c.title,c.reason,c.severity,c.severity_reason,c.report,c.version,c.report_version,c.active,c.created_at`
func scanCase(row interface{ Scan(...any) error }) (*FindingCase, error) {
c := new(FindingCase)
err := row.Scan(&c.ID, &c.TaskID, &c.Title, &c.Reason, &c.Severity, &c.SeverityReason, &c.Report, &c.Version, &c.ReportVersion, &c.Active, &c.CreatedAt)
return c, err
}
func (d *DB) GetFindingCase(id int64) (*FindingCase, error) {
c, err := scanCase(d.QueryRow(`SELECT `+caseColumns+` FROM finding_cases c WHERE id=$1`, id))
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
err = d.QueryRow(`SELECT count(*),count(*) FILTER(WHERE severity='critical'),count(*) FILTER(WHERE severity='high'),count(*) FILTER(WHERE severity='medium'),count(*) FILTER(WHERE severity='low') FROM findings f JOIN finding_case_members m ON m.finding_id=f.id WHERE m.case_id=$1`, id).Scan(&c.Count, &c.Critical, &c.High, &c.Medium, &c.Low)
return c, err
}
func (d *DB) FindingCaseID(id int64) (int64, error) {
var cid int64
err := d.QueryRow(`SELECT case_id FROM finding_case_members WHERE finding_id=$1`, id).Scan(&cid)
if errors.Is(err, sql.ErrNoRows) {
return 0, nil
}
return cid, err
}
func (d *DB) FindingCaseMembers(id int64, page, size int) ([]*DBFinding, int, error) {
var count int
if err := d.QueryRow(`SELECT count(*) FROM finding_case_members WHERE case_id=$1`, id).Scan(&count); err != nil {
return nil, 0, err
}
rows, err := d.Query(`SELECT `+findingSelectCols+` FROM findings f LEFT JOIN tasks t ON t.id=f.task_id JOIN finding_case_members m ON m.finding_id=f.id WHERE m.case_id=$1 ORDER BY f.id LIMIT $2 OFFSET $3`, id, size, (page-1)*size)
if err != nil {
return nil, 0, err
}
defer rows.Close()
fs, err := scanFindings(rows)
return fs, count, err
}
func (d *DB) FindingCaseEvents(id int64) ([]FindingCaseEvent, error) {
rows, err := d.Query(`SELECT id,action,actor,reason,finding_ids,created_at FROM finding_case_events WHERE case_id=$1 ORDER BY id DESC`, id)
if err != nil {
return nil, err
}
defer rows.Close()
out := []FindingCaseEvent{}
for rows.Next() {
var e FindingCaseEvent
if err := rows.Scan(&e.ID, &e.Action, &e.Actor, &e.Reason, &e.FindingIDs, &e.CreatedAt); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
func (d *DB) ListFindingCases(filter FindingFilter, page, size int) ([]FindingCaseRow, int, error) {
var err error
filter, err = d.applyAssetScope(filter)
if err != nil {
return nil, 0, err
}
where, args := filter.where()
base := `WITH matched AS (SELECT f.id,f.severity,f.created_at,COALESCE(m.case_id,0) cid FROM findings f LEFT JOIN tasks t ON t.id=f.task_id LEFT JOIN finding_case_members m ON m.finding_id=f.id` + where + `), entities AS (SELECT CASE WHEN cid>0 THEN 'c:'||cid ELSE 'f:'||id END key,MAX(cid) cid,MIN(id) fid,jsonb_agg(id ORDER BY id) matched_ids,MAX(created_at) created FROM matched GROUP BY key)`
var total int
if err := d.QueryRow(base+` SELECT count(*) FROM entities`, args...).Scan(&total); err != nil {
return nil, 0, err
}
order := `e.created DESC,e.fid DESC`
if filter.Sort == "severity" {
order = `CASE COALESCE(c.severity,f.severity) WHEN 'critical' THEN 4 WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'low' THEN 1 ELSE 0 END DESC,` + order
}
args = append(args, size, (page-1)*size)
rows, err := d.Query(base+fmt.Sprintf(` SELECT e.cid,e.fid,e.matched_ids FROM entities e LEFT JOIN finding_cases c ON c.id=e.cid LEFT JOIN findings f ON f.id=e.fid ORDER BY %s LIMIT $%d OFFSET $%d`, order, len(args)-1, len(args)), args...)
if err != nil {
return nil, 0, err
}
out := []FindingCaseRow{}
groups := []int64{}
for rows.Next() {
var cid, fid int64
var raw []byte
if err := rows.Scan(&cid, &fid, &raw); err != nil {
rows.Close()
return nil, 0, err
}
r := FindingCaseRow{FindingID: fid}
if err := json.Unmarshal(raw, &r.MatchedIDs); err != nil {
rows.Close()
return nil, 0, err
}
out = append(out, r)
groups = append(groups, cid)
}
err = rows.Err()
rows.Close()
if err != nil {
return nil, 0, err
}
for i, cid := range groups {
if cid > 0 {
c, err := scanCase(d.QueryRow(`SELECT `+strings.Replace(caseColumns, "c.report,", "''::text,", 1)+` FROM finding_cases c WHERE id=$1`, cid))
if err != nil {
return nil, 0, err
}
if err := d.QueryRow(`SELECT count(*),count(*) FILTER(WHERE severity='critical'),count(*) FILTER(WHERE severity='high'),count(*) FILTER(WHERE severity='medium'),count(*) FILTER(WHERE severity='low') FROM findings f JOIN finding_case_members m ON m.finding_id=f.id WHERE m.case_id=$1`, cid).Scan(&c.Count, &c.Critical, &c.High, &c.Medium, &c.Low); err != nil {
return nil, 0, err
}
out[i].Case = c
out[i].FindingID = 0
}
}
return out, total, nil
}
func sortedFindingIDs(ids []int64) ([]int64, error) {
set := map[int64]bool{}
out := []int64{}
for _, id := range ids {
if id <= 0 {
return nil, errors.New("无效漏洞编号")
}
if !set[id] {
set[id] = true
out = append(out, id)
}
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
if len(out) < 2 {
return nil, errors.New("至少需要两条漏洞记录")
}
return out, nil
}
func lockCaseTask(tx *sql.Tx, taskID int64) error {
if taskID <= 0 {
return errors.New("归并需要仍存在的同一任务")
}
if _, err := tx.Exec(`SELECT pg_advisory_xact_lock($1)`, int64(5100000000000000)+taskID); err != nil {
return err
}
var id int64
return tx.QueryRow(`SELECT id FROM tasks WHERE id=$1 FOR UPDATE`, taskID).Scan(&id)
}
func (d *DB) MergeFindingCase(ctx context.Context, taskID int64, ids []int64, title, reason, actor string) (int64, error) {
return d.mergeFindingCase(ctx, taskID, ids, title, reason, actor, 0)
}
func (d *DB) mergeFindingCase(ctx context.Context, taskID int64, ids []int64, title, reason, actor string, suggestionID int64) (int64, error) {
ids, err := sortedFindingIDs(ids)
if err != nil {
return 0, err
}
if strings.TrimSpace(title) == "" || strings.TrimSpace(reason) == "" {
return 0, errors.New("必须提供漏洞名称及基于证据的归并理由")
}
var cid int64
err = d.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
if err := lockCaseTask(tx, taskID); err != nil {
return err
}
if suggestionID > 0 {
var state string
if err := tx.QueryRow(`SELECT state FROM finding_case_suggestions WHERE id=$1 FOR UPDATE`, suggestionID).Scan(&state); err != nil {
return err
}
if state != "pending" {
return ErrFindingCaseConflict
}
}
groupSet := map[int64]bool{}
for _, id := range ids {
var tid sql.NullInt64
var group int64
if err := tx.QueryRow(`SELECT f.task_id,COALESCE(m.case_id,0) FROM findings f LEFT JOIN finding_case_members m ON m.finding_id=f.id WHERE f.id=$1`, id).Scan(&tid, &group); err != nil {
return err
}
if !tid.Valid || tid.Int64 != taskID {
return errors.New("只能归并同一任务的漏洞")
}
if group > 0 {
groupSet[group] = true
}
}
groups := []int64{}
for g := range groupSet {
groups = append(groups, g)
}
sort.Slice(groups, func(i, j int) bool { return groups[i] < groups[j] })
all := map[int64]bool{}
for _, id := range ids {
all[id] = true
}
for _, g := range groups {
rows, err := tx.Query(`SELECT finding_id FROM finding_case_members WHERE case_id=$1`, g)
if err != nil {
return err
}
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
rows.Close()
return err
}
all[id] = true
}
err = rows.Err()
rows.Close()
if err != nil {
return err
}
}
ids = ids[:0]
for id := range all {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
raw, _ := json.Marshal(ids)
var blocked bool
if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM finding_case_blocks WHERE left_id IN(SELECT value::bigint FROM jsonb_array_elements_text($1::jsonb)) AND right_id IN(SELECT value::bigint FROM jsonb_array_elements_text($1::jsonb)))`, string(raw)).Scan(&blocked); err != nil {
return err
}
if blocked {
return errors.New("人工已否决或撤销这些记录的归并")
}
if len(groups) > 0 {
cid = groups[0]
} else {
if err := tx.QueryRow(`INSERT INTO finding_cases(task_id,origin_task_id,title,reason) VALUES($1,$1,$2,$3) RETURNING id`, taskID, title, reason).Scan(&cid); err != nil {
return err
}
}
var changed int64
for _, id := range ids {
res, err := tx.Exec(`INSERT INTO finding_case_members(finding_id,case_id) VALUES($1,$2) ON CONFLICT(finding_id) DO UPDATE SET case_id=EXCLUDED.case_id WHERE finding_case_members.case_id<>EXCLUDED.case_id`, id, cid)
if err != nil {
return err
}
n, _ := res.RowsAffected()
changed += n
}
for _, g := range groups {
if g != cid {
if _, err := tx.Exec(`UPDATE finding_cases SET active=false,version=version+1 WHERE id=$1`, g); err != nil {
return err
}
}
}
if changed > 0 {
if _, err := tx.Exec(`UPDATE finding_cases SET version=version+1 WHERE id=$1`, cid); err != nil {
return err
}
if _, err := tx.Exec(`INSERT INTO finding_case_events(case_id,action,actor,reason,finding_ids) VALUES($1,'merge',$2,$3,$4)`, cid, actor, reason, string(raw)); err != nil {
return err
}
}
_, err := tx.Exec(`UPDATE finding_case_suggestions SET state='accepted' WHERE state='pending' AND left_id IN(SELECT value::bigint FROM jsonb_array_elements_text($1::jsonb)) AND right_id IN(SELECT value::bigint FROM jsonb_array_elements_text($1::jsonb))`, string(raw))
return err
})
return cid, err
}
func (d *DB) RemoveFindingCaseMember(ctx context.Context, cid, fid int64, reason string) error {
c, err := d.GetFindingCase(cid)
if err != nil {
return err
}
if c == nil {
return errors.New("归并组不存在")
}
return d.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
var origin int64
if err := tx.QueryRow(`SELECT origin_task_id FROM finding_cases WHERE id=$1`, cid).Scan(&origin); err != nil {
return err
}
if _, err := tx.Exec(`SELECT pg_advisory_xact_lock($1)`, int64(5100000000000000)+origin); err != nil {
return err
}
// Deleting a task retains its findings and folder history.
var live int64
if err := tx.QueryRow(`SELECT id FROM tasks WHERE id=$1 FOR UPDATE`, origin).Scan(&live); err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
var current int64
if err := tx.QueryRow(`SELECT case_id FROM finding_case_members WHERE finding_id=$1`, fid).Scan(&current); err != nil {
if errors.Is(err, sql.ErrNoRows) {
var removed bool
// A repeated removal, or a last member released by dissolution, is idempotent.
err = tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM finding_case_events WHERE case_id=$1 AND finding_ids @> jsonb_build_array($2::bigint))`, cid, fid).Scan(&removed)
if err == nil && removed {
return nil
}
}
return err
}
if current != cid {
return ErrFindingCaseConflict
}
if _, err := tx.Exec(`INSERT INTO finding_case_blocks(left_id,right_id,reason) SELECT LEAST($1,finding_id),GREATEST($1,finding_id),$3 FROM finding_case_members WHERE case_id=$2 AND finding_id<>$1 ON CONFLICT DO NOTHING`, fid, cid, reason); err != nil {
return err
}
if _, err := tx.Exec(`INSERT INTO finding_case_events(case_id,action,actor,reason,finding_ids) VALUES($1,'detach','human',$2,jsonb_build_array($3::bigint))`, cid, reason, fid); err != nil {
return err
}
_, err := tx.Exec(`DELETE FROM finding_case_members WHERE case_id=$1 AND finding_id=$2`, cid, fid)
return err
})
}
func (d *DB) UpdateFindingCaseReport(ctx context.Context, cid, version int64, title, report, severity, reason string) error {
if !ValidSeverity(severity) || strings.TrimSpace(report) == "" || strings.TrimSpace(reason) == "" || strings.TrimSpace(title) == "" {
return errors.New("完整报告、名称、有效等级和评级依据必填")
}
return d.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
c, err := scanCase(tx.QueryRow(`SELECT `+caseColumns+` FROM finding_cases c WHERE id=$1 FOR UPDATE`, cid))
if err != nil {
return err
}
if !c.Active || c.Version != version {
return ErrFindingCaseConflict
}
if c.Title == title && c.Report == report && c.Severity == severity && c.SeverityReason == reason && c.ReportVersion == version {
return nil
}
_, err = tx.Exec(`UPDATE finding_cases SET title=$2,report=$3,severity=$4,severity_reason=$5,report_version=$6 WHERE id=$1`, cid, title, report, severity, reason, version)
if err != nil {
return err
}
_, err = tx.Exec(`INSERT INTO finding_case_events(case_id,action,actor,reason) VALUES($1,'report_updated','reporter',$2)`, cid, "统一报告已更新;评级依据:"+reason)
return err
})
}
func (d *DB) SuggestFindingCase(ctx context.Context, taskID, left, right int64, title, reason string) (int64, error) {
if strings.TrimSpace(title) == "" || strings.TrimSpace(reason) == "" {
return 0, errors.New("建议需要名称和具体证据理由")
}
ids, err := sortedFindingIDs([]int64{left, right})
if err != nil {
return 0, err
}
var id int64
err = d.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
if err := lockCaseTask(tx, taskID); err != nil {
return err
}
for _, fid := range ids {
var tid sql.NullInt64
if err := tx.QueryRow(`SELECT task_id FROM findings WHERE id=$1`, fid).Scan(&tid); err != nil {
return err
}
if !tid.Valid || tid.Int64 != taskID {
return errors.New("只能建议同一任务的归并")
}
}
var blocked bool
if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM finding_case_blocks WHERE left_id=$1 AND right_id=$2)`, ids[0], ids[1]).Scan(&blocked); err != nil {
return err
}
if blocked {
return errors.New("人工已否决该归并")
}
return tx.QueryRow(`INSERT INTO finding_case_suggestions(task_id,left_id,right_id,title,reason) VALUES($1,$2,$3,$4,$5) ON CONFLICT(left_id,right_id) DO UPDATE SET reason=EXCLUDED.reason WHERE finding_case_suggestions.state='pending' RETURNING id`, taskID, ids[0], ids[1], title, reason).Scan(&id)
})
return id, err
}
func (d *DB) FindingCaseSuggestions(taskID int64) ([]FindingCaseSuggestion, error) {
rows, err := d.Query(`SELECT id,task_id,left_id,right_id,title,reason,state FROM finding_case_suggestions WHERE state='pending' AND ($1::bigint=0 OR task_id=$1) ORDER BY id DESC`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []FindingCaseSuggestion{}
for rows.Next() {
var s FindingCaseSuggestion
if err := rows.Scan(&s.ID, &s.TaskID, &s.LeftID, &s.RightID, &s.Title, &s.Reason, &s.State); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
func (d *DB) ResolveFindingCaseSuggestion(ctx context.Context, id int64, accept bool) (int64, error) {
var s FindingCaseSuggestion
if err := d.QueryRow(`SELECT task_id,left_id,right_id,title,reason,state FROM finding_case_suggestions WHERE id=$1`, id).Scan(&s.TaskID, &s.LeftID, &s.RightID, &s.Title, &s.Reason, &s.State); err != nil {
return 0, err
}
if s.State != "pending" {
return 0, ErrFindingCaseConflict
}
if accept {
return d.mergeFindingCase(ctx, s.TaskID, []int64{s.LeftID, s.RightID}, s.Title, s.Reason, "human", id)
}
return 0, d.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
if err := lockCaseTask(tx, s.TaskID); err != nil {
return err
}
res, err := tx.Exec(`UPDATE finding_case_suggestions SET state='rejected' WHERE id=$1 AND state='pending'`, id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n != 1 {
return ErrFindingCaseConflict
}
if _, err := tx.Exec(`INSERT INTO finding_case_blocks(left_id,right_id,reason) VALUES($1,$2,'人工否决') ON CONFLICT DO NOTHING`, s.LeftID, s.RightID); err != nil {
return err
}
return nil
})
}
type FindingDistinctStats struct {
Total int `json:"total"`
Reports int `json:"reports"`
Critical int `json:"critical"`
High int `json:"high"`
Medium int `json:"medium"`
Low int `json:"low"`
Unassessed int `json:"unassessed"`
}
func (d *DB) DistinctFindingStats(taskID string) (*FindingDistinctStats, error) {
filter := FindingFilter{TaskID: taskID}
where, args := filter.where()
s := new(FindingDistinctStats)
err := d.QueryRow(`WITH observations AS (SELECT f.id,f.severity,COALESCE(m.case_id,0) cid FROM findings f LEFT JOIN tasks t ON t.id=f.task_id LEFT JOIN finding_case_members m ON m.finding_id=f.id`+where+`), defects AS (SELECT severity FROM observations WHERE cid=0 UNION ALL SELECT c.severity FROM finding_cases c WHERE c.id IN(SELECT cid FROM observations WHERE cid>0)) SELECT count(*),(SELECT count(*) FROM observations),count(*) FILTER(WHERE severity='critical'),count(*) FILTER(WHERE severity='high'),count(*) FILTER(WHERE severity='medium'),count(*) FILTER(WHERE severity='low'),count(*) FILTER(WHERE severity='') FROM defects`, args...).Scan(&s.Total, &s.Reports, &s.Critical, &s.High, &s.Medium, &s.Low, &s.Unassessed)
return s, err
}
// 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) {
f, err := d.FindingCaseSummary(fid)
if err != nil {
return nil, 0, err
}
if f == nil || f.TaskID == nil {
return nil, 0, errors.New("漏洞任务不可用")
}
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
assets, _ := json.Marshal(f.AssetIDs)
if f.AssetIDs == nil {
assets = []byte("[]")
}
// Type/asset/name only retrieve candidates; the Reporter must still prove the root cause.
where := ` WHERE f.task_id=$1 AND f.id<>$2 AND NOT EXISTS(SELECT 1 FROM finding_case_blocks b WHERE b.left_id=LEAST(f.id,$2::bigint) AND b.right_id=GREATEST(f.id,$2::bigint))
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)}
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...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := []map[string]any{}
for rows.Next() {
var id, cid int64
var name, class, severity, summary string
var assetIDs json.RawMessage
if err := rows.Scan(&id, &name, &class, &severity, &summary, &assetIDs, &cid); err != nil {
return nil, 0, err
}
out = append(out, map[string]any{"finding_id": strconv.FormatInt(id, 10), "name": name, "vulnclass": class, "severity": severity, "summary": summary, "asset_ids": assetIDs, "case_id": strconv.FormatInt(cid, 10)})
}
return out, total, rows.Err()
}
// Batch task-list counters avoid an additional database round trip per task.
func (d *DB) DistinctFindingStatsByTask() (map[string]*FindingDistinctStats, error) {
rows, err := d.Query(`WITH observations AS (
SELECT f.task_id,CASE WHEN m.case_id IS NULL THEN 'f:'||f.id ELSE 'c:'||m.case_id END entity,
CASE WHEN m.case_id IS NULL THEN f.severity ELSE c.severity END severity
FROM findings f LEFT JOIN finding_case_members m ON m.finding_id=f.id LEFT JOIN finding_cases c ON c.id=m.case_id WHERE f.task_id IS NOT NULL)
SELECT task_id,count(DISTINCT entity),count(*),
count(DISTINCT entity) FILTER(WHERE severity='critical'),count(DISTINCT entity) FILTER(WHERE severity='high'),
count(DISTINCT entity) FILTER(WHERE severity='medium'),count(DISTINCT entity) FILTER(WHERE severity='low'),
count(DISTINCT entity) FILTER(WHERE severity='') FROM observations GROUP BY task_id`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]*FindingDistinctStats{}
for rows.Next() {
var id int64
s := new(FindingDistinctStats)
if err := rows.Scan(&id, &s.Total, &s.Reports, &s.Critical, &s.High, &s.Medium, &s.Low, &s.Unassessed); err != nil {
return nil, err
}
out[strconv.FormatInt(id, 10)] = s
}
return out, rows.Err()
}
// Folder rows and candidate searches never fetch full report/PoC bodies.
func (d *DB) FindingCaseSummary(id int64) (*DBFinding, error) {
cols := strings.Replace(findingSelectCols, "f.evidence,", "''::text,", 1)
rows, err := d.Query(`SELECT `+cols+` FROM findings f LEFT JOIN tasks t ON t.id=f.task_id WHERE f.id=$1`, id)
if err != nil {
return nil, err
}
defer rows.Close()
fs, err := scanFindings(rows)
if err != nil || len(fs) == 0 {
return nil, err
}
return fs[0], nil
}
+291
View File
@@ -0,0 +1,291 @@
package db
import (
"context"
"fmt"
"sync"
"testing"
)
func caseTestDB(t *testing.T) (*DB, *Task, []int64) {
t.Helper()
d, err := Open(testDSN(t))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { d.Close() })
task, err := d.CreateTask("case integration", "goal", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
ids := []int64{}
for _, sev := range []string{"high", "medium", "low", "high"} {
id, err := d.AddFinding(task.ID, 0, "IDOR", "order access", sev, "GET /orders/{id}", "original PoC "+sev, "worker", nil)
if err != nil {
t.Fatal(err)
}
ids = append(ids, id)
}
t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM findings WHERE task_id=$1`, task.ID); _ = d.DeleteTask(task.ID) })
return d, task, ids
}
func TestFindingCaseMergePreservesRecordsAndVersions(t *testing.T) {
d, task, ids := caseTestDB(t)
ctx := context.Background()
id, err := d.MergeFindingCase(ctx, task.ID, ids[:3], "订单越权", "同一接口同一权限检查缺失", "reporter")
if err != nil {
t.Fatal(err)
}
c, err := d.GetFindingCase(id)
if err != nil {
t.Fatal(err)
}
if c.Count != 3 || c.High != 1 || c.Medium != 1 || c.Low != 1 {
t.Fatalf("counts %+v", c)
}
stats, err := d.DistinctFindingStats(fmt.Sprint(task.ID))
if err != nil {
t.Fatal(err)
}
batch, err := d.DistinctFindingStatsByTask()
if err != nil || batch[fmt.Sprint(task.ID)] == nil || *batch[fmt.Sprint(task.ID)] != *stats {
t.Fatalf("batch stats %+v %v", batch, err)
}
if stats.Total != 2 || stats.Reports != 4 || stats.Unassessed != 1 {
t.Fatalf("stats %+v", stats)
}
again, err := d.MergeFindingCase(ctx, task.ID, ids[:3], "same", "same", "reporter")
if err != nil || again != id {
t.Fatalf("idempotency %d %v", again, err)
}
same, _ := d.GetFindingCase(id)
if same.Version != c.Version {
t.Fatal("idempotent merge invalidated report")
}
if err := d.UpdateFindingCaseReport(ctx, id, c.Version, "订单越权", "complete PoC", "high", "verified sensitive data"); err != nil {
t.Fatal(err)
}
for i, fid := range ids[:3] {
f, err := d.GetFinding(fid)
if err != nil {
t.Fatal(err)
}
if f.Severity != []string{"high", "medium", "low"}[i] || f.Evidence == "" || f.Status != FindingPending {
t.Fatalf("original overwritten %+v", f)
}
}
if _, err := d.Exec(`UPDATE findings SET evidence='new proof' WHERE id=$1`, ids[1]); err != nil {
t.Fatal(err)
}
if err := d.UpdateFindingCaseReport(ctx, id, c.Version, "x", "stale", "low", "x"); err != ErrFindingCaseConflict {
t.Fatalf("stale accepted %v", err)
}
if err := d.RemoveFindingCaseMember(ctx, id, ids[1], "different cause"); err != nil {
t.Fatal(err)
}
if _, err := d.MergeFindingCase(ctx, task.ID, ids[:3], "x", "x", "reporter"); err == nil {
t.Fatal("human veto ignored")
}
if err := d.RemoveFindingCaseMember(ctx, id, ids[2], "undo"); err != nil {
t.Fatal(err)
}
c, _ = d.GetFindingCase(id)
if c.Active {
t.Fatal("singleton remains group")
}
for _, fid := range ids[:3] {
g, err := d.FindingCaseID(fid)
if err != nil || g != 0 {
t.Fatalf("dangling membership %d %v", g, err)
}
}
}
func TestFindingCaseScopeSuggestionsAndDeletion(t *testing.T) {
d, task, ids := caseTestDB(t)
ctx := context.Background()
other, err := d.CreateTask("other", "goal", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
defer d.DeleteTask(other.ID)
fid, err := d.AddFinding(other.ID, 0, "IDOR", "other", "high", "same", "poc", "worker", nil)
if err != nil {
t.Fatal(err)
}
defer d.Exec(`DELETE FROM findings WHERE id=$1`, fid)
if _, err := d.MergeFindingCase(ctx, task.ID, []int64{ids[0], fid}, "x", "x", "reporter"); err == nil {
t.Fatal("cross task accepted")
}
sid, err := d.SuggestFindingCase(ctx, task.ID, ids[0], ids[1], "order", "uncertain")
if err != nil {
t.Fatal(err)
}
if _, err := d.ResolveFindingCaseSuggestion(ctx, sid, false); err != nil {
t.Fatal(err)
}
if _, err := d.MergeFindingCase(ctx, task.ID, ids[:2], "x", "x", "reporter"); err == nil {
t.Fatal("rejection ignored")
}
cid, err := d.MergeFindingCase(ctx, task.ID, ids[2:], "x", "proof", "reporter")
if err != nil {
t.Fatal(err)
}
if _, err := d.DeleteFinding(ids[3]); err != nil {
t.Fatal(err)
}
c, _ := d.GetFindingCase(cid)
if c.Active {
t.Fatal("deletion did not dissolve group")
}
f, err := d.GetFinding(ids[2])
if err != nil || f == nil {
t.Fatal("surviving finding lost")
}
}
func TestFindingCaseConcurrentUnion(t *testing.T) {
d, task, ids := caseTestDB(t)
ctx := context.Background()
var wg sync.WaitGroup
errs := make(chan error, 3)
for _, pair := range [][]int64{{ids[0], ids[1]}, {ids[1], ids[2]}, {ids[2], ids[3]}} {
pair := pair
wg.Add(1)
go func() {
defer wg.Done()
_, err := d.MergeFindingCase(ctx, task.ID, pair, "orders", "same authorization check", "reporter")
errs <- err
}()
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatal(err)
}
}
group := int64(0)
for _, fid := range ids {
cid, err := d.FindingCaseID(fid)
if err != nil || cid == 0 {
t.Fatalf("membership %v", err)
}
if group != 0 && cid != group {
t.Fatal("overlapping parallel groups")
}
group = cid
}
c, _ := d.GetFindingCase(group)
if c.Count != 4 {
t.Fatalf("lost member %+v", c)
}
rows, total, err := d.ListFindingCases(FindingFilter{TaskID: fmt.Sprint(task.ID), Severity: "low"}, 1, 10)
if err != nil || total != 1 || len(rows) != 1 || rows[0].Case.Count != 4 || len(rows[0].MatchedIDs) != 1 {
t.Fatalf("filter/aggregation %d %+v %v", total, rows, err)
}
}
func TestFindingCaseTaskDeletionAndRepeatedDetach(t *testing.T) {
d, task, ids := caseTestDB(t)
ctx := context.Background()
cid, err := d.MergeFindingCase(ctx, task.ID, ids[:3], "orders", "same missing permission", "reporter")
if err != nil {
t.Fatal(err)
}
// A source task can disappear without deleting standalone reports or breaking old links.
if err := d.DeleteTask(task.ID); err != nil {
t.Fatal(err)
}
defer func() {
for _, id := range ids {
_, _ = d.DeleteFinding(id)
}
}()
c, err := d.GetFindingCase(cid)
if err != nil || c == nil || c.TaskID != nil || c.Count != 3 {
t.Fatalf("retention %+v %v", c, err)
}
if err := d.RemoveFindingCaseMember(ctx, cid, ids[0], "undo"); err != nil {
t.Fatal(err)
}
if err := d.RemoveFindingCaseMember(ctx, cid, ids[0], "retry"); err != nil {
t.Fatal(err)
}
if err := d.RemoveFindingCaseMember(ctx, cid, ids[1], "undo"); err != nil {
t.Fatal(err)
}
if err := d.RemoveFindingCaseMember(ctx, cid, ids[2], "released by dissolution"); err != nil {
t.Fatal(err)
}
c, _ = d.GetFindingCase(cid)
if c.Active || c.Count != 0 {
t.Fatalf("folder not dissolved %+v", c)
}
for _, id := range ids[:3] {
f, e := d.GetFinding(id)
if e != nil || f == nil || f.Evidence == "" {
t.Fatalf("original lost %d %v", id, e)
}
}
}
func TestFindingCaseSuggestionResolutionIsAtomic(t *testing.T) {
d, task, ids := caseTestDB(t)
ctx := context.Background()
sid, err := d.SuggestFindingCase(ctx, task.ID, ids[0], ids[1], "orders", "uncertain")
if err != nil {
t.Fatal(err)
}
results := make(chan error, 2)
var wg sync.WaitGroup
for _, accept := range []bool{true, false} {
wg.Add(1)
go func(a bool) { defer wg.Done(); _, e := d.ResolveFindingCaseSuggestion(ctx, sid, a); results <- e }(accept)
}
wg.Wait()
close(results)
success := 0
for err := range results {
if err == nil {
success++
}
}
if success != 1 {
t.Fatalf("%d successful conflicting resolutions", success)
}
var state string
if err := d.QueryRow(`SELECT state FROM finding_case_suggestions WHERE id=$1`, sid).Scan(&state); err != nil {
t.Fatal(err)
}
cid, _ := d.FindingCaseID(ids[0])
if (state == "accepted") != (cid > 0) {
t.Fatalf("inconsistent resolution %s %d", state, cid)
}
}
func TestFindingCaseCandidatesAreCompactAndRelated(t *testing.T) {
d, task, ids := caseTestDB(t)
if _, err := d.Exec(`UPDATE findings SET vulnclass='SQLi',name='search injection' WHERE id=$1`, ids[3]); err != nil {
t.Fatal(err)
}
items, total, err := d.FindingCaseCandidates(ids[0], 1, 1)
if err != nil || total != 2 || len(items) != 1 {
t.Fatalf("candidate pagination %d %+v %v", total, items, err)
}
if _, ok := items[0]["report"]; ok {
t.Fatal("report included in candidate summary")
}
if _, ok := items[0]["evidence"]; ok {
t.Fatal("evidence included in candidate summary")
}
cid, err := d.MergeFindingCase(context.Background(), task.ID, ids[:2], "orders", "proof", "reporter")
if err != nil {
t.Fatal(err)
}
if err := d.RemoveFindingCaseMember(context.Background(), cid, ids[1], "different cause"); err != nil {
t.Fatal(err)
}
_, total, err = d.FindingCaseCandidates(ids[0], 1, 20)
if err != nil || total != 1 {
t.Fatalf("veto excluded %d %v", total, err)
}
}
+5
View File
@@ -15,6 +15,9 @@ import (
// DBFinding is a row in the standalone findings table. It persists across task
// deletion unless the caller explicitly requests related finding cleanup.
type DBFinding struct {
ExportCaseID int64 // transient unified-export identity, never a finding handle
ExportMemberIDs []int64
TrafficCount int
EvidenceVersion int64
ReportEvidenceVersion int64
@@ -524,6 +527,8 @@ func (d *DB) ListFindingsForExport(f FindingFilter, ids []int64) ([]*DBFinding,
// and vuln-class filter — computed server-side so it stays exact regardless of
// pagination.
type FindingStats struct {
Distinct *FindingDistinctStats `json:"distinct,omitempty"`
Total int `json:"total"`
Pending int `json:"pending"`
Critical int `json:"critical"`
+87
View File
@@ -1384,3 +1384,90 @@ CREATE INDEX IF NOT EXISTS idx_notification_deliveries_batch
ON notification_deliveries(batch_id) WHERE batch_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_channel
ON notification_deliveries(channel_id, id DESC);
-- A case collects repeated observations of one defect without rewriting findings.
CREATE TABLE IF NOT EXISTS finding_cases (
id BIGSERIAL PRIMARY KEY,
task_id BIGINT REFERENCES tasks(id) ON DELETE SET NULL,
origin_task_id BIGINT NOT NULL,
title TEXT NOT NULL,
reason TEXT NOT NULL,
severity TEXT NOT NULL DEFAULT '',
severity_reason TEXT NOT NULL DEFAULT '',
report TEXT NOT NULL DEFAULT '',
version BIGINT NOT NULL DEFAULT 1,
report_version BIGINT NOT NULL DEFAULT 0,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS finding_case_members (
finding_id BIGINT PRIMARY KEY REFERENCES findings(id) ON DELETE CASCADE,
case_id BIGINT NOT NULL REFERENCES finding_cases(id),
joined_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_finding_case_members_case ON finding_case_members(case_id);
CREATE TABLE IF NOT EXISTS finding_case_events (
id BIGSERIAL PRIMARY KEY,
case_id BIGINT NOT NULL REFERENCES finding_cases(id),
action TEXT NOT NULL,
actor TEXT NOT NULL,
reason TEXT NOT NULL,
finding_ids JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS finding_case_suggestions (
id BIGSERIAL PRIMARY KEY,
task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
left_id BIGINT NOT NULL REFERENCES findings(id) ON DELETE CASCADE,
right_id BIGINT NOT NULL REFERENCES findings(id) ON DELETE CASCADE,
title TEXT NOT NULL,
reason TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(left_id,right_id),
CHECK(left_id < right_id)
);
-- Rejected/detached pairs are a durable human veto, also checked on group unions.
CREATE TABLE IF NOT EXISTS finding_case_blocks (
left_id BIGINT NOT NULL REFERENCES findings(id) ON DELETE CASCADE,
right_id BIGINT NOT NULL REFERENCES findings(id) ON DELETE CASCADE,
reason TEXT NOT NULL,
PRIMARY KEY(left_id,right_id), CHECK(left_id < right_id)
);
CREATE OR REPLACE FUNCTION finding_case_changed() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE cid BIGINT; remaining INTEGER;
BEGIN
IF TG_TABLE_NAME = 'findings' THEN
IF OLD IS NOT DISTINCT FROM NEW THEN RETURN NEW; END IF;
SELECT case_id INTO cid FROM finding_case_members WHERE finding_id=NEW.id;
ELSE
cid := OLD.case_id;
END IF;
IF cid IS NOT NULL THEN
UPDATE finding_cases SET version=version+1 WHERE id=cid;
IF TG_OP='DELETE' THEN
SELECT count(*) INTO remaining FROM finding_case_members WHERE case_id=cid;
INSERT INTO finding_case_events(case_id,action,actor,reason,finding_ids)
VALUES(cid,'member_deleted','system','成员移出或删除',jsonb_build_array(OLD.finding_id));
IF remaining < 2 THEN
UPDATE finding_cases SET active=false WHERE id=cid;
DELETE FROM finding_case_members WHERE case_id=cid;
END IF;
END IF;
END IF;
RETURN NULL;
END $$;
DROP TRIGGER IF EXISTS finding_case_content_changed ON findings;
CREATE TRIGGER finding_case_content_changed AFTER UPDATE ON findings
FOR EACH ROW EXECUTE FUNCTION finding_case_changed();
DROP TRIGGER IF EXISTS finding_case_member_deleted ON finding_case_members;
CREATE TRIGGER finding_case_member_deleted AFTER DELETE ON finding_case_members
FOR EACH ROW EXECUTE FUNCTION finding_case_changed();
CREATE TABLE IF NOT EXISTS finding_case_review_runs (
conversation_id BIGINT PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE,
task_id BIGINT REFERENCES tasks(id) ON DELETE SET NULL,
state TEXT NOT NULL DEFAULT 'queued',
error TEXT NOT NULL DEFAULT ''
);
ALTER TABLE finding_case_review_runs ADD COLUMN IF NOT EXISTS finding_ids JSONB NOT NULL DEFAULT '[]';
+11 -1
View File
@@ -55,6 +55,9 @@ func FindingsMarkdown(fs []*db.DBFinding, generatedAt time.Time) string {
} {
fmt.Fprintf(&b, "| %s | %d |\n", s.label, counts[s.key])
}
if counts["unassessed"] > 0 {
fmt.Fprintf(&b, "| 待评估 | %d |\n", counts["unassessed"])
}
b.WriteString("\n")
if len(items) == 0 {
@@ -127,6 +130,9 @@ func FindingFilename(f *db.DBFinding) string {
sev := nz(f.Severity, "info")
title := findingTitle(f)
name := fmt.Sprintf("%s_%s_#%d", sev, title, f.ID)
if f.ExportCaseID > 0 {
name = fmt.Sprintf("case_%d_%s_%s", f.ExportCaseID, sev, title)
}
name = unsafeFilenameChars.ReplaceAllString(name, "_")
name = strings.Trim(name, "._")
if name == "" {
@@ -151,8 +157,12 @@ func FindingsCSV(fs []*db.DBFinding) []byte {
w := csv.NewWriter(&buf)
_ = w.Write([]string{"ID", "名称", "类别", "严重等级", "状态", "所属任务", "发现时间", "概述", "流量证据数量", "流量证据ID"})
for _, f := range items {
id := fmt.Sprintf("%d", f.ID)
if f.ExportCaseID > 0 {
id = fmt.Sprintf("case:%d", f.ExportCaseID)
}
_ = w.Write([]string{
fmt.Sprintf("%d", f.ID),
id,
findingTitle(f),
f.VulnClass,
nz(f.Severity, "info"),
+39 -6
View File
@@ -503,7 +503,21 @@ func (s *Server) runConversationTurn(ctx context.Context, cancel context.CancelC
}()
// Only the first turn executes a historical retest. Follow-up conversation
// turns may explain the sealed result; the result tool refuses to overwrite it.
finishStatus, finishReason := "failed", "复测未能启动"
finishStatus, finishReason := "failed", "Agent 未能启动"
if c.AgentKey == "reporter" {
defer func() {
state := "done"
reason := ""
if finishStatus != "completed" || ctx.Err() != nil {
state = "failed"
reason = finishReason
if ctx.Err() != nil {
reason = "整理已停止或服务关闭"
}
}
_, _ = s.m.pg.Exec(`UPDATE finding_case_review_runs SET state=$2,error=$3 WHERE conversation_id=$1 AND state='running'`, c.ID, state, reason)
}()
}
if c.AgentKey == db.FindingRetestAgentKey {
// Read without the run cancellation so an immediate stop still seals pending.
r, err := s.m.pg.FindingRetestForConversation(context.Background(), c.ID)
@@ -668,11 +682,17 @@ func (s *Server) runAndPump(agentKey string, item triggeredRun) {
// Caller holds queueMu.
func (s *Server) nextTriggerRun(agentKey string, cfg triggerBehavior) triggeredRun {
q := s.triggerQ[agentKey]
if cfg.runMode == "parallel" || cfg.mergeMode == "none" {
if q[0].conversationID > 0 || cfg.runMode == "parallel" || cfg.mergeMode == "none" {
s.triggerQ[agentKey] = q[1:]
return q[0]
}
if cfg.mergeMode == "all" {
hasManual := false
for _, item := range q {
if item.conversationID > 0 {
hasManual = true
}
}
if cfg.mergeMode == "all" && !hasManual {
s.triggerQ[agentKey] = q[:0:0]
return mergeAllRuns(q)
}
@@ -685,7 +705,7 @@ func (s *Server) nextTriggerRun(agentKey string, cfg triggerBehavior) triggeredR
group := []triggeredRun{head}
rest := q[:0:0] // keep non-matching items in order
for _, it := range q[1:] {
if it.mergeable && it.taskID == head.taskID {
if it.conversationID == 0 && it.mergeable && it.taskID == head.taskID {
group = append(group, it)
} else {
rest = append(rest, it)
@@ -798,12 +818,25 @@ func mergeAllRuns(items []triggeredRun) triggeredRun {
func (s *Server) runTriggeredRun(item triggeredRun) {
defer func() {
if r := recover(); r != nil {
if item.conversationID > 0 {
_, _ = s.m.pg.Exec(`UPDATE finding_case_review_runs SET state='failed',error=$2 WHERE conversation_id=$1`, item.conversationID, fmt.Sprint(r))
}
log.Printf("[trigger] run for %s panicked: %v", item.agentKey, r)
}
}()
pg := s.m.pg
c, err := pg.CreateConversation(item.agentKey, firstLine(item.title, 60), nil)
if err != nil {
var c *db.Conversation
var err error
if item.conversationID > 0 {
c, err = pg.GetConversation(item.conversationID)
_, _ = pg.Exec(`UPDATE finding_case_review_runs SET state='running' WHERE conversation_id=$1`, item.conversationID)
} else {
c, err = pg.CreateConversation(item.agentKey, firstLine(item.title, 60), nil)
}
if err != nil || c == nil {
if item.conversationID > 0 {
_, _ = pg.Exec(`UPDATE finding_case_review_runs SET state='failed',error='整理会话不可用' WHERE conversation_id=$1`, item.conversationID)
}
log.Printf("[trigger] create conversation for %s failed: %v", item.agentKey, err)
return
}
+11
View File
@@ -30,6 +30,8 @@ func rawString(raw json.RawMessage) string {
// ---- Task (frontend "Task") ---- created_at as RFC3339, plus a derived status.
type TaskDTO struct {
DistinctFindings *db.FindingDistinctStats `json:"distinct_findings,omitempty"`
ID string `json:"id"`
ExplorationID int64 `json:"exploration_id"`
Name string `json:"name"` // 可选任务名称;空=未命名
@@ -303,6 +305,9 @@ func coverageAssetRefDTO(ref db.AssetRef) CoverageAssetRefDTO {
// ---- Finding (frontend "Finding") ----
type FindingDTO struct {
CaseID string `json:"case_id,omitempty"`
MemberIDs []int64 `json:"member_ids,omitempty"`
TrafficCount int `json:"traffic_count"`
EvidenceVersion int64 `json:"evidence_version"`
ReportEvidenceVersion int64 `json:"report_evidence_version"`
@@ -457,6 +462,12 @@ func findingFromDB(f *db.DBFinding, assets map[int64]*db.Asset) FindingDTO {
Report: f.Report,
TS: rfc3339(f.CreatedAt),
}
if f.ExportCaseID > 0 {
d.ID = "case:" + i64s(f.ExportCaseID)
d.FindingID = ""
d.CaseID = i64s(f.ExportCaseID)
d.MemberIDs = f.ExportMemberIDs
}
d.Assets = findingAssetDTOs(f.AssetIDs, assets)
if f.TaskID != nil {
d.TaskID = i64s(*f.TaskID)
+506
View File
@@ -0,0 +1,506 @@
package server
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/intercept"
actool "github.com/Autumn-27/norma/tool"
)
type findingCaseRequest struct {
TaskID json.RawMessage `json:"task_id"`
FindingID json.RawMessage `json:"finding_id"`
IDs []json.RawMessage `json:"finding_ids"`
CaseID json.RawMessage `json:"case_id"`
Version int64 `json:"version"`
Title string `json:"title"`
Reason string `json:"reason"`
Report string `json:"report"`
Severity string `json:"severity"`
SeverityReason string `json:"severity_reason"`
Page int `json:"page"`
}
func (a findingCaseRequest) ids() []int64 {
out := []int64{}
for _, raw := range a.IDs {
out = append(out, parseProfileID(raw))
}
return out
}
func caseHTTPError(w http.ResponseWriter, err error) {
code := 400
if errors.Is(err, db.ErrFindingCaseConflict) {
code = 409
}
writeErr(w, code, err.Error())
}
func decodeCaseRequest(w http.ResponseWriter, r *http.Request, a any) bool {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
if err := json.NewDecoder(r.Body).Decode(a); err != nil {
writeErr(w, 400, "无效请求正文")
return false
}
return true
}
func (s *Server) caseFindingDTO(f *db.DBFinding) FindingDTO {
return findingFromDB(f, s.resolveAssetIDs(f.AssetIDs))
}
func (s *Server) listFindingCases(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
page := findingPaginationParam(q.Get("page"), 1, 0)
size := findingPaginationParam(q.Get("limit"), 20, 100)
rows, total, err := s.m.pg.ListFindingCases(findingFilterFromQuery(q), page, size)
if err != nil {
caseHTTPError(w, err)
return
}
items := []map[string]any{}
for _, row := range rows {
v := map[string]any{"case": row.Case, "matched_ids": row.MatchedIDs}
if row.FindingID > 0 {
f, err := s.m.pg.FindingCaseSummary(row.FindingID)
if err != nil {
caseHTTPError(w, err)
return
}
if f == nil {
continue
}
f.Report = ""
v["finding"] = s.caseFindingDTO(f)
}
items = append(items, v)
}
stats, err := s.m.pg.DistinctFindingStats(q.Get("task_id"))
if err != nil {
caseHTTPError(w, err)
return
}
writeJSON(w, 200, map[string]any{"items": items, "total": total, "page": page, "page_size": size, "stats": stats})
}
func (s *Server) findingCaseAccess(w http.ResponseWriter, r *http.Request, mutation bool) (*db.FindingCase, bool) {
id := int64(atoiDefault(r.PathValue("id"), 0))
c, err := s.m.pg.GetFindingCase(id)
if err != nil {
caseHTTPError(w, err)
return nil, false
}
if c == nil {
writeErr(w, 404, "漏洞组不存在")
return nil, false
}
if ctx := r.URL.Query().Get("context_task"); ctx != "" {
t := s.m.ResolveTask(ctx)
_, inherited, allowed := findingProvenanceInTask(t, c.TaskID)
if !allowed {
writeErr(w, 404, "任务中没有该漏洞组")
return nil, false
}
if mutation && inherited {
writeErr(w, 403, "继承漏洞组只读")
return nil, false
}
}
return c, true
}
func (s *Server) getFindingCase(w http.ResponseWriter, r *http.Request) {
c, ok := s.findingCaseAccess(w, r, false)
if !ok {
return
}
events, err := s.m.pg.FindingCaseEvents(c.ID)
if err != nil {
caseHTTPError(w, err)
return
}
writeJSON(w, 200, map[string]any{"case": c, "events": events})
}
func (s *Server) getFindingCaseMembers(w http.ResponseWriter, r *http.Request) {
c, ok := s.findingCaseAccess(w, r, false)
if !ok {
return
}
page := findingPaginationParam(r.URL.Query().Get("page"), 1, 0)
size := findingPaginationParam(r.URL.Query().Get("limit"), 20, 100)
fs, total, err := s.m.pg.FindingCaseMembers(c.ID, page, size)
if err != nil {
caseHTTPError(w, err)
return
}
assets := s.resolveFindingAssets(fs)
items := []FindingDTO{}
for _, f := range fs {
dto := findingFromDB(f, assets)
if ctx := r.URL.Query().Get("context_task"); ctx != "" {
dto.SourceTaskID, dto.Inherited, _ = findingProvenanceInTask(s.m.ResolveTask(ctx), f.TaskID)
}
items = append(items, dto)
}
writeJSON(w, 200, map[string]any{"items": items, "total": total, "page": page, "page_size": size})
}
func (s *Server) removeFindingCaseMember(w http.ResponseWriter, r *http.Request) {
c, ok := s.findingCaseAccess(w, r, true)
if !ok {
return
}
fid := int64(atoiDefault(r.PathValue("fid"), 0))
var body struct {
Reason string `json:"reason"`
}
if !decodeCaseRequest(w, r, &body) {
return
}
if strings.TrimSpace(body.Reason) == "" {
body.Reason = "人工移出归并组"
}
if err := s.m.pg.RemoveFindingCaseMember(r.Context(), c.ID, fid, body.Reason); err != nil {
caseHTTPError(w, err)
return
}
writeJSON(w, 200, map[string]bool{"ok": true})
}
func (s *Server) caseSuggestions(w http.ResponseWriter, r *http.Request) {
items, err := s.m.pg.FindingCaseSuggestions(int64(atoiDefault(r.URL.Query().Get("task_id"), 0)))
if err != nil {
caseHTTPError(w, err)
return
}
writeJSON(w, 200, items)
}
func (s *Server) resolveCaseSuggestion(w http.ResponseWriter, r *http.Request) {
var a struct {
Accept bool `json:"accept"`
}
if !decodeCaseRequest(w, r, &a) {
return
}
id, err := s.m.pg.ResolveFindingCaseSuggestion(r.Context(), int64(atoiDefault(r.PathValue("id"), 0)), a.Accept)
if err != nil {
caseHTTPError(w, err)
return
}
writeJSON(w, 200, map[string]string{"case_id": i64s(id)})
}
func (s *Server) mergeFindingCase(w http.ResponseWriter, r *http.Request) {
var a findingCaseRequest
if !decodeCaseRequest(w, r, &a) {
return
}
id, err := s.m.pg.MergeFindingCase(r.Context(), parseProfileID(a.TaskID), a.ids(), a.Title, a.Reason, "human")
if err != nil {
caseHTTPError(w, err)
return
}
writeJSON(w, 200, map[string]string{"case_id": i64s(id)})
}
func (s *Server) updateCaseReport(w http.ResponseWriter, r *http.Request) {
c, ok := s.findingCaseAccess(w, r, true)
if !ok {
return
}
var a findingCaseRequest
if !decodeCaseRequest(w, r, &a) {
return
}
if err := s.m.pg.UpdateFindingCaseReport(r.Context(), c.ID, a.Version, a.Title, a.Report, a.Severity, a.SeverityReason); err != nil {
caseHTTPError(w, err)
return
}
writeJSON(w, 200, map[string]bool{"ok": true})
}
func (s *Server) findingCaseTools() []actool.CoreTool {
props := map[string]any{"finding_id": idParam("独立 findings 记录 ID,不是探索节点 ID"), "finding_ids": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}, "description": "独立漏洞记录 ID 列表"}, "task_id": idParam("同一来源任务 ID"), "case_id": idParam("归并组 ID"), "version": idParam("get_finding_case 实际读取的 version"), "page": idParam("分页,从1开始,每页20条"), "title": strParam("同一实际缺陷的名称"), "reason": strParam("具体证据与归并理由,不能仅类型相同"), "report": strParam("全部成员证据的完整统一 Markdown 报告"), "severity": strParam("critical|high|medium|low"), "severity_reason": strParam("基于已验证影响的评级依据,不直接取最高等级")}
specs := []struct {
name, desc string
required []string
}{
{"search_finding_duplicates", "只读检索同任务候选,返回摘要与资产;等级不同也可能重复。支持分页,按需读取详情。", []string{"finding_id"}},
{"get_finding_record", "读取独立漏洞完整原始证据和报告,返回探索节点编号以便追溯执行记录。", []string{"finding_id"}},
{"merge_finding_records", "证据明确证明同一缺陷时归并;保留所有原始记录。不跨任务、不改变原评级,不可绕过人工否决。", []string{"task_id", "finding_ids", "title", "reason"}},
{"suggest_finding_merge", "证据不足时提交疑似重复建议,待人工确认;不是自动合并。只允许两条同任务记录。", []string{"task_id", "finding_ids", "title", "reason"}},
{"get_finding_case", "读取归并组和成员分页。先记录version,再读取成员的原报告、证据及流量;版本变化必须重新读取。", []string{"case_id"}},
{"update_finding_case_report", "保存统一报告及评级理由,version必须来自实际读取。保留各条原报告,不新建漏洞。", []string{"case_id", "version", "title", "report", "severity", "severity_reason"}},
}
out := []actool.CoreTool{}
for _, spec := range specs {
spec := spec
handler := func(ctx context.Context, in json.RawMessage) (actool.Result, error) {
var a findingCaseRequest
if err := json.Unmarshal(in, &a); err != nil {
return actool.Errorf(err.Error()), nil
}
v, err := s.performFindingCaseTool(ctx, spec.name, a)
if err != nil {
return actool.Errorf(err.Error()), nil
}
raw, _ := json.Marshal(v)
return actool.Text(string(raw)), nil
}
schemaProps := map[string]any{}
for _, key := range spec.required {
schemaProps[key] = props[key]
}
if spec.name == "search_finding_duplicates" || spec.name == "get_finding_case" {
schemaProps["page"] = props["page"]
}
schema := objSchema(schemaProps, spec.required...)
if strings.HasPrefix(spec.name, "get_") || strings.HasPrefix(spec.name, "search_") {
out = append(out, roTool(spec.name, spec.desc, schema, handler))
} else {
out = append(out, wrTool(spec.name, spec.desc, schema, handler))
}
}
return out
}
func (s *Server) performFindingCaseTool(ctx context.Context, name string, a findingCaseRequest) (any, error) {
if err := s.checkCaseReviewScope(ctx, name, a); err != nil {
return nil, err
}
fid := parseProfileID(a.FindingID)
cid := parseProfileID(a.CaseID)
page := a.Page
if page < 1 {
page = 1
}
switch name {
case "search_finding_duplicates":
items, total, err := s.m.pg.FindingCaseCandidates(fid, page, 20)
return map[string]any{"items": items, "total": total, "page": page, "page_size": 20}, err
case "get_finding_record":
f, err := s.m.pg.GetFinding(fid)
if err != nil {
return nil, err
}
if f == nil {
return nil, errors.New("漏洞不存在")
}
dto := s.caseFindingDTO(f)
cid, err := s.m.pg.FindingCaseID(f.ID)
if err != nil {
return nil, err
}
if cid > 0 {
dto.CaseID = i64s(cid)
}
return map[string]any{"finding": dto, "finding_node_id": f.NodeID}, nil
case "merge_finding_records":
id, err := s.m.pg.MergeFindingCase(ctx, parseProfileID(a.TaskID), a.ids(), a.Title, a.Reason, "reporter")
return map[string]string{"case_id": i64s(id)}, err
case "suggest_finding_merge":
ids := a.ids()
if len(ids) != 2 {
return nil, errors.New("疑似重复建议需要两条记录")
}
id, err := s.m.pg.SuggestFindingCase(ctx, parseProfileID(a.TaskID), ids[0], ids[1], a.Title, a.Reason)
return map[string]string{"suggestion_id": i64s(id)}, err
case "get_finding_case":
c, err := s.m.pg.GetFindingCase(cid)
if err != nil {
return nil, err
}
if c == nil {
return nil, errors.New("漏洞组不存在")
}
fs, total, err := s.m.pg.FindingCaseMembers(cid, page, 20)
members := []FindingDTO{}
for _, f := range fs {
members = append(members, s.caseFindingDTO(f))
}
return map[string]any{"case": c, "members": members, "total": total, "page": page, "page_size": 20}, err
case "update_finding_case_report":
err := s.m.pg.UpdateFindingCaseReport(ctx, cid, a.Version, a.Title, a.Report, a.Severity, a.SeverityReason)
return map[string]bool{"ok": err == nil}, err
}
return nil, errors.New("未知归并工具")
}
func idParam(desc string) map[string]any {
return map[string]any{"type": "integer", "description": desc}
}
func (s *Server) seedFindingCaseTools() {
_, _ = s.m.pg.Exec(`UPDATE finding_case_review_runs SET state='failed',error='服务重启中断,请重新提交整理' WHERE state IN ('queued','running')`)
a, err := s.m.pg.GetAgentByKey("reporter")
if err != nil || a == nil {
return
}
const flag = "finding_case_tools_v1"
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
return
}
names := []string{}
for _, t := range s.findingCaseTools() {
schema, _ := json.Marshal(t.InputSchema())
if err := s.m.pg.SeedTool(t.Name(), t.Description(), schema, json.RawMessage(`["reporter"]`)); err != nil {
return
}
names = append(names, t.Name())
}
if err := s.m.pg.AddAgentToToolBinding("reporter", names); err != nil {
return
}
_ = s.m.pg.SetSetting(flag, "true")
}
func (s *Server) reviewFindingCases(w http.ResponseWriter, r *http.Request) {
var a findingCaseRequest
if !decodeCaseRequest(w, r, &a) {
return
}
ids := a.ids()
if cid := parseProfileID(a.CaseID); cid > 0 {
c, err := s.m.pg.GetFindingCase(cid)
if err != nil || c == nil || !c.Active {
writeErr(w, 400, "漏洞组不可用")
return
}
if ctx := r.URL.Query().Get("context_task"); ctx != "" && (c.TaskID == nil || ctx != i64s(*c.TaskID)) {
writeErr(w, 403, "继承漏洞组只读")
return
}
fs, _, err := s.m.pg.FindingCaseMembers(cid, 1, 100000)
if err != nil {
caseHTTPError(w, err)
return
}
ids = nil
for _, f := range fs {
ids = append(ids, f.ID)
}
}
if len(ids) == 0 || (len(a.CaseID) == 0 && len(ids) > 200) {
writeErr(w, 400, "请选择1至200条原始上报")
return
}
reporter, err := s.m.pg.GetAgentByKey("reporter")
if err != nil || reporter == nil || !reporter.Enabled || s.chatAgentRef() == nil {
writeErr(w, 409, "报告 Agent 未启用或模型不可用")
return
}
batches := map[int64][]int64{}
for _, fid := range ids {
f, err := s.m.pg.FindingCaseSummary(fid)
if err != nil || f == nil || f.TaskID == nil {
writeErr(w, 400, "所选漏洞来源任务不可用")
return
}
batches[*f.TaskID] = append(batches[*f.TaskID], fid)
}
// Validate every source before starting any batch.
for taskID := range batches {
if _, ok := s.m.Task(i64s(taskID)); !ok {
writeErr(w, 409, "任务不可用")
return
}
}
runs := []map[string]any{}
for taskID, fids := range batches {
t, ok := s.m.Task(strconv.FormatInt(taskID, 10))
if !ok {
writeErr(w, 409, "任务不可用")
return
}
raw, _ := json.Marshal(fids)
msg := fmt.Sprintf("人工选择整理:仅允许处理本任务所选 finding_ids=%s。先保留/生成各条独立报告,再在所选范围内判断同一缺陷,明确则归并,不确定提交建议,已成组则更新统一报告。不得归并未选择的记录,不探测目标。", raw)
c, err := s.m.pg.CreateConversation("reporter", "漏洞整理 · task#"+t.ID, nil)
if err != nil {
caseHTTPError(w, err)
return
}
if _, err := s.m.pg.Exec(`INSERT INTO finding_case_review_runs(conversation_id,task_id,finding_ids) VALUES($1,$2,$3)`, c.ID, taskID, string(raw)); err != nil {
caseHTTPError(w, err)
return
}
item := triggeredRun{agentKey: "reporter", title: c.Title, message: msg, taskID: taskID, taskDesc: t.Description, taskGoal: t.Goal, conversationID: c.ID}
cfg := s.readTriggerBehavior("reporter")
s.queueMu.Lock()
s.triggerCfg["reporter"] = cfg
s.triggerQ["reporter"] = append(s.triggerQ["reporter"], item)
s.pumpLocked("reporter")
s.queueMu.Unlock()
runs = append(runs, map[string]any{"conversation_id": c.ID, "task_id": i64s(taskID)})
}
writeJSON(w, 202, map[string]any{"runs": runs})
}
func (s *Server) caseReviewRuns(w http.ResponseWriter, r *http.Request) {
rows, err := s.m.pg.Query(`SELECT conversation_id,task_id,state,error FROM finding_case_review_runs ORDER BY conversation_id DESC LIMIT 50`)
if err != nil {
caseHTTPError(w, err)
return
}
defer rows.Close()
items := []map[string]any{}
for rows.Next() {
var conv int64
var task sql.NullInt64
var state, msg string
if err := rows.Scan(&conv, &task, &state, &msg); err != nil {
caseHTTPError(w, err)
return
}
items = append(items, map[string]any{"conversation_id": conv, "task_id": i64s(task.Int64), "state": state, "error": msg})
}
if err := rows.Err(); err != nil {
caseHTTPError(w, err)
return
}
writeJSON(w, 200, items)
}
// Manual reviews cannot mutate a finding outside the explicit selection.
func (s *Server) checkCaseReviewScope(ctx context.Context, name string, a findingCaseRequest) error {
conv := intercept.ConvIDFromContext(ctx)
if conv == 0 {
return nil
}
var raw json.RawMessage
err := s.m.pg.QueryRow(`SELECT finding_ids FROM finding_case_review_runs WHERE conversation_id=$1`, conv).Scan(&raw)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil
}
return err
}
var ids []int64
if err := json.Unmarshal(raw, &ids); err != nil {
return err
}
allowed := map[int64]bool{}
for _, id := range ids {
allowed[id] = true
}
switch name {
case "merge_finding_records", "suggest_finding_merge", "update_finding_report":
checkIDs := a.ids()
if name == "update_finding_report" {
checkIDs = []int64{parseProfileID(a.FindingID)}
}
for _, id := range checkIDs {
if !allowed[id] {
return errors.New("人工整理只能归并所选记录")
}
}
case "update_finding_case_report":
fs, _, err := s.m.pg.FindingCaseMembers(parseProfileID(a.CaseID), 1, 100000)
if err != nil {
return err
}
found := false
for _, f := range fs {
if allowed[f.ID] {
found = true
}
}
if !found {
return errors.New("该漏洞组不属于人工所选范围")
}
}
return nil
}
+124
View File
@@ -0,0 +1,124 @@
package server
import (
"fmt"
"strings"
"github.com/Autumn-27/artex/db"
)
type findingCaseExportPlan struct {
cases map[int64]*db.FindingCase
membership map[int64]int64
selected map[int64]bool
}
// Expand selection to complete cases before the evidence store freezes snapshots.
func (s *Server) prepareFindingCaseExport(fs []*db.DBFinding) ([]*db.DBFinding, *findingCaseExportPlan, error) {
plan := &findingCaseExportPlan{cases: map[int64]*db.FindingCase{}, membership: map[int64]int64{}, selected: map[int64]bool{}}
expanded := append([]*db.DBFinding{}, fs...)
seen := map[int64]bool{}
for _, f := range fs {
seen[f.ID] = true
plan.selected[f.ID] = true
}
for _, f := range fs {
cid, err := s.m.pg.FindingCaseID(f.ID)
if err != nil {
return nil, nil, err
}
if cid == 0 {
continue
}
if _, ok := plan.cases[cid]; ok {
continue
}
c, err := s.m.pg.GetFindingCase(cid)
if err != nil {
return nil, nil, err
}
if c == nil || !c.Active {
continue
}
plan.cases[cid] = c
members, _, err := s.m.pg.FindingCaseMembers(cid, 1, 1000000)
if err != nil {
return nil, nil, err
}
for _, m := range members {
plan.membership[m.ID] = cid
if !seen[m.ID] {
seen[m.ID] = true
expanded = append(expanded, m)
}
}
}
return expanded, plan, nil
}
// Composites are built AFTER staging real records, so their traffic attachments
// remain private frozen export copies, not aliases to live or deleted evidence.
func (s *Server) consolidateFindingCaseExport(fs []*db.DBFinding, plan *findingCaseExportPlan, includeOriginals bool) ([]*db.DBFinding, error) {
out := []*db.DBFinding{}
emitted := map[int64]bool{}
for _, f := range fs {
cid := plan.membership[f.ID]
if cid == 0 {
if plan.selected[f.ID] {
out = append(out, f)
}
continue
}
if !emitted[cid] {
emitted[cid] = true
c := plan.cases[cid]
latest, err := s.m.pg.GetFindingCase(cid)
if err != nil {
return nil, err
}
stale := latest == nil || latest.Version != c.Version || c.ReportVersion != c.Version
title := c.Title
sev := c.Severity
if sev == "" {
sev = "unassessed"
}
composite := &db.DBFinding{ID: f.ID, ExportCaseID: c.ID, TaskID: c.TaskID, TaskDescription: f.TaskDescription, Name: title, VulnClass: "归并漏洞", Severity: sev, Status: f.Status, CreatedAt: c.CreatedAt, Report: c.Report, EvidenceVersion: c.Version, ReportEvidenceVersion: c.ReportVersion}
assets := map[int64]bool{}
var refs []string
var evidence strings.Builder
for _, member := range fs {
if plan.membership[member.ID] != cid {
continue
}
composite.ExportMemberIDs = append(composite.ExportMemberIDs, member.ID)
for _, aid := range member.AssetIDs {
if !assets[aid] {
assets[aid] = true
composite.AssetIDs = append(composite.AssetIDs, aid)
}
}
refs = append(refs, fmt.Sprintf("#%d [%s] %s", member.ID, member.Severity, member.Name))
fmt.Fprintf(&evidence, "原始上报 #%d:%s\n%s\n\n", member.ID, member.Summary, member.Evidence)
if member.Report != "" {
fmt.Fprintf(&evidence, "原始报告 #%d:\n%s\n\n", member.ID, member.Report)
}
for _, b := range member.TrafficBindings {
b.Note = fmt.Sprintf("来自上报 #%d;%s", member.ID, b.Note)
composite.TrafficBindings = append(composite.TrafficBindings, b)
}
}
composite.Summary = fmt.Sprintf("漏洞文件夹 #%d;原始记录:%s。统一评级依据:%s", cid, strings.Join(refs, ";"), c.SeverityReason)
composite.TrafficCount = len(composite.TrafficBindings)
if stale || c.Report == "" {
composite.Report = "**统一报告尚未生成或已过期,以下提供全部原始证据,不能视为完整复现已验证。**\n\n" + c.Report
composite.Evidence = evidence.String()
composite.ReportEvidenceVersion = -1
}
out = append(out, composite)
}
if includeOriginals {
out = append(out, f)
}
}
return out, nil
}
+323
View File
@@ -0,0 +1,323 @@
package server
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http/httptest"
"strconv"
"strings"
"testing"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/intercept"
)
func testCaseServer(t *testing.T) (*Server, int64, []int64) {
t.Helper()
dsn, _, err := db.DSN()
if err != nil {
t.Fatal(err)
}
pg, err := db.Open(dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { pg.Close() })
task, err := pg.CreateTask("case server", "goal", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
ids := []int64{}
for _, sev := range []string{"high", "medium", "low"} {
id, err := pg.AddFinding(task.ID, 0, "IDOR", "orders", sev, "same endpoint", "proof "+sev, "worker", nil)
if err != nil {
t.Fatal(err)
}
ids = append(ids, id)
}
t.Cleanup(func() { _, _ = pg.Exec(`DELETE FROM findings WHERE task_id=$1`, task.ID); _ = pg.DeleteTask(task.ID) })
return &Server{m: &Manager{pg: pg}}, task.ID, ids
}
func TestFindingCaseAPIPaginationAndTools(t *testing.T) {
s, task, ids := testCaseServer(t)
ctx := context.Background()
a := findingCaseRequest{TaskID: json.RawMessage(fmt.Sprint(task)), Title: "订单越权", Reason: "same check"}
for _, id := range ids {
a.IDs = append(a.IDs, json.RawMessage(fmt.Sprint(id)))
}
result, err := s.performFindingCaseTool(ctx, "merge_finding_records", a)
if err != nil {
t.Fatal(err)
}
cid := result.(map[string]string)["case_id"]
r := httptest.NewRequest("GET", fmt.Sprintf("/api/exploration/finding-cases?task_id=%d&severity=low&page=1&limit=1", task), nil)
w := httptest.NewRecorder()
s.listFindingCases(w, r)
if w.Code != 200 {
t.Fatal(w.Code, w.Body)
}
var page struct {
Total int `json:"total"`
Items []struct {
Case db.FindingCase `json:"case"`
Matched []int64 `json:"matched_ids"`
} `json:"items"`
}
if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil {
t.Fatal(err)
}
if page.Total != 1 || page.Items[0].Case.Count != 3 || page.Items[0].Case.Report != "" || len(page.Items[0].Matched) != 1 {
t.Fatalf("page %+v", page)
}
r = httptest.NewRequest("GET", "/", nil)
r.SetPathValue("id", cid)
w = httptest.NewRecorder()
s.getFindingCaseMembers(w, r)
if w.Code != 200 || !strings.Contains(w.Body.String(), "proof high") {
t.Fatal(w.Code, w.Body)
}
a.CaseID = json.RawMessage(cid)
result, err = s.performFindingCaseTool(ctx, "get_finding_case", a)
if err != nil {
t.Fatal(err)
}
c := result.(map[string]any)["case"].(*db.FindingCase)
a.Version = c.Version
a.Report = "complete reproducible report"
a.Severity = "high"
a.SeverityReason = "verified data disclosure"
if _, err := s.performFindingCaseTool(ctx, "update_finding_case_report", a); err != nil {
t.Fatal(err)
}
if _, err := s.m.pg.Exec(`UPDATE findings SET evidence='new data' WHERE id=$1`, ids[1]); err != nil {
t.Fatal(err)
}
if _, err := s.performFindingCaseTool(ctx, "update_finding_case_report", a); err != db.ErrFindingCaseConflict {
t.Fatalf("stale report accepted %v", err)
}
}
func TestFindingCaseConsolidatedExportPreservesScope(t *testing.T) {
s, task, ids := testCaseServer(t)
ctx := context.Background()
cid, err := s.m.pg.MergeFindingCase(ctx, task, ids, "订单越权", "same", "reporter")
if err != nil {
t.Fatal(err)
}
c, _ := s.m.pg.GetFindingCase(cid)
if err := s.m.pg.UpdateFindingCaseReport(ctx, cid, c.Version, "订单越权", "complete report", "high", "verified"); err != nil {
t.Fatal(err)
}
fs, err := s.m.pg.ListFindingsForExport(db.FindingFilter{}, ids[:1])
if err != nil {
t.Fatal(err)
}
expanded, plan, err := s.prepareFindingCaseExport(fs)
if err != nil {
t.Fatal(err)
}
if len(expanded) != 3 {
t.Fatal("selected one child did not include complete group")
}
out, err := s.consolidateFindingCaseExport(expanded, plan, false)
if err != nil || len(out) != 1 || out[0].Report != "complete report" || !strings.Contains(out[0].Summary, fmt.Sprint(ids[2])) {
t.Fatalf("unified %+v %v", out, err)
}
out, err = s.consolidateFindingCaseExport(expanded, plan, true)
if err != nil || len(out) != 4 {
t.Fatalf("originals lost %d %v", len(out), err)
}
if _, err := s.m.pg.Exec(`UPDATE findings SET severity='critical' WHERE id=$1`, ids[0]); err != nil {
t.Fatal(err)
}
out, err = s.consolidateFindingCaseExport(expanded, plan, false)
if err != nil || !strings.Contains(out[0].Report, "已过期") || !strings.Contains(out[0].Evidence, "proof low") {
t.Fatalf("pending export omitted evidence %+v %v", out, err)
}
}
func TestFindingCaseManualSelectionScope(t *testing.T) {
s, task, ids := testCaseServer(t)
conv, err := s.m.pg.CreateConversation("reporter", "manual", nil)
if err != nil {
t.Fatal(err)
}
defer s.m.pg.Exec(`DELETE FROM conversations WHERE id=$1`, conv.ID)
raw, _ := json.Marshal(ids[:2])
if _, err := s.m.pg.Exec(`INSERT INTO finding_case_review_runs(conversation_id,task_id,finding_ids) VALUES($1,$2,$3)`, conv.ID, task, string(raw)); err != nil {
t.Fatal(err)
}
ctx := intercept.WithConvID(context.Background(), conv.ID)
a := findingCaseRequest{IDs: []json.RawMessage{json.RawMessage(fmt.Sprint(ids[0])), json.RawMessage(fmt.Sprint(ids[2]))}}
if err := s.checkCaseReviewScope(ctx, "merge_finding_records", a); err == nil {
t.Fatal("unselected mutation allowed")
}
a.IDs[1] = json.RawMessage(fmt.Sprint(ids[1]))
if err := s.checkCaseReviewScope(ctx, "merge_finding_records", a); err != nil {
t.Fatal(err)
}
}
func TestFindingCaseReviewStatusSurvivesTaskDeletion(t *testing.T) {
s, task, ids := testCaseServer(t)
conv, err := s.m.pg.CreateConversation("reporter", "review", nil)
if err != nil {
t.Fatal(err)
}
defer s.m.pg.Exec(`DELETE FROM conversations WHERE id=$1`, conv.ID)
if _, err := s.m.pg.Exec(`INSERT INTO finding_case_review_runs(conversation_id,task_id,finding_ids) VALUES($1,$2,$3)`, conv.ID, task, fmt.Sprintf("[%d]", ids[0])); err != nil {
t.Fatal(err)
}
if err := s.m.pg.DeleteTask(task); err != nil {
t.Fatal(err)
}
defer func() {
for _, id := range ids {
_, _ = s.m.pg.DeleteFinding(id)
}
}()
w := httptest.NewRecorder()
s.caseReviewRuns(w, httptest.NewRequest("GET", "/", nil))
if w.Code != 200 || !strings.Contains(w.Body.String(), `"task_id":"0"`) {
t.Fatal(w.Code, w.Body)
}
}
func TestFindingCaseSelectedReviewCannotRewriteOtherOriginal(t *testing.T) {
s, task, ids := testCaseServer(t)
conv, err := s.m.pg.CreateConversation("reporter", "review", nil)
if err != nil {
t.Fatal(err)
}
defer s.m.pg.Exec(`DELETE FROM conversations WHERE id=$1`, conv.ID)
raw, _ := json.Marshal(ids[:1])
if _, err := s.m.pg.Exec(`INSERT INTO finding_case_review_runs(conversation_id,task_id,finding_ids) VALUES($1,$2,$3)`, conv.ID, task, string(raw)); err != nil {
t.Fatal(err)
}
ctx := intercept.WithConvID(context.Background(), conv.ID)
if err := s.checkCaseReviewScope(ctx, "update_finding_report", findingCaseRequest{FindingID: json.RawMessage(fmt.Sprint(ids[1]))}); err == nil {
t.Fatal("unselected original allowed")
}
if err := s.checkCaseReviewScope(ctx, "update_finding_report", findingCaseRequest{FindingID: json.RawMessage(fmt.Sprint(ids[0]))}); err != nil {
t.Fatal(err)
}
}
func TestFindingCaseGroupedTrafficExportAndInheritedAccess(t *testing.T) {
s, recorded, req := trafficEvidenceServer(t)
ctx := t.Context()
f, err := s.m.pg.GetFinding(recorded.FindingID)
if err != nil {
t.Fatal(err)
}
task, err := s.m.pg.GetTask(*f.TaskID)
if err != nil {
t.Fatal(err)
}
body := []byte("verified original response")
seedServerEvidenceFlow(t, s, "case-proof", body)
if w := req("POST", fmt.Sprintf("/api/exploration/findings/%d/traffic", f.ID), `{"traffic_refs":[{"traffic_id":"case-proof","role":"proof"}]}`); w.Code != 200 {
t.Fatal(w.Code, w.Body)
}
other, err := s.evidenceStore().Record(ctx, db.RecordFindingInput{TaskID: task.ID, ExplorationID: task.ExplorationID, Summary: "same permission boundary", Severity: "low"}, nil)
if err != nil {
t.Fatal(err)
}
for _, id := range []int64{f.ID, other.FindingID} {
if _, err := s.m.pg.Exec(`UPDATE findings SET report=$2 WHERE id=$1`, id, fmt.Sprintf("original script #%d", id)); err != nil {
t.Fatal(err)
}
}
cid, err := s.m.pg.MergeFindingCase(ctx, task.ID, []int64{f.ID, other.FindingID}, "orders", "same permission check", "reporter")
if err != nil {
t.Fatal(err)
}
c, _ := s.m.pg.GetFindingCase(cid)
if err := s.m.pg.UpdateFindingCaseReport(ctx, cid, c.Version, "orders", "unified complete script", "high", "verified impact"); err != nil {
t.Fatal(err)
}
// A binding note is evidence too: changing it makes the aggregate report stale.
list, err := s.m.pg.GetFindingTraffic(ctx, f.ID)
if err != nil {
t.Fatal(err)
}
w := req("PATCH", fmt.Sprintf("/api/exploration/findings/%d/traffic/%d", f.ID, list.Bindings[0].ID), fmt.Sprintf(`{"version":%d,"note":"additional verified condition"}`, list.Version))
if w.Code != 200 {
t.Fatal(w.Code, w.Body)
}
c, _ = s.m.pg.GetFindingCase(cid)
if c.Version == c.ReportVersion {
t.Fatal("binding evidence did not invalidate unified report")
}
url := fmt.Sprintf("/api/exploration/findings/export?mode=consolidated&include_originals=true&scope=selected&ids=%d&format=md-zip", f.ID)
w = req("GET", url, "")
if w.Code != 200 {
t.Fatal(w.Code, w.Body)
}
z, err := zip.NewReader(bytes.NewReader(w.Body.Bytes()), int64(w.Body.Len()))
if err != nil {
t.Fatal(err)
}
mdCount, caseCount := 0, 0
seen := map[string]bool{}
recovered := false
for _, file := range z.File {
if seen[file.Name] {
t.Fatalf("duplicate ZIP entry %s", file.Name)
}
seen[file.Name] = true
reader, e := file.Open()
if e != nil {
t.Fatal(e)
}
data, e := io.ReadAll(reader)
reader.Close()
if e != nil {
t.Fatal(e)
}
if strings.HasSuffix(file.Name, ".md") {
mdCount++
if strings.HasPrefix(file.Name, "case_") {
caseCount++
if !strings.Contains(string(data), fmt.Sprintf("original script #%d", other.FindingID)) {
t.Fatal("stale export lost an original report")
}
}
}
if strings.HasSuffix(file.Name, "response.bin") && bytes.Equal(data, body) {
recovered = true
}
}
if mdCount != 3 || caseCount != 1 || !recovered {
t.Fatalf("ZIP reports=%d folders=%d evidence=%v", mdCount, caseCount, recovered)
}
w = req("GET", fmt.Sprintf("/api/exploration/findings/export?mode=consolidated&scope=selected&ids=%d&format=json", f.ID), "")
if w.Code != 200 || !strings.Contains(w.Body.String(), `"id": "case:`) || !strings.Contains(w.Body.String(), `"member_ids"`) {
t.Fatal(w.Code, w.Body)
}
child, err := s.m.CreateTaskWithOptions("inherited folders", "fixture", db.TaskCreateOptions{SourceTaskIDs: []int64{task.ID}})
if err != nil {
t.Fatal(err)
}
childID, _ := strconv.ParseInt(child.ID, 10, 64)
defer s.m.pg.DeleteTask(childID)
base := fmt.Sprintf("/api/exploration/finding-cases/%d", cid)
suffix := "?context_task=" + child.ID
if w = req("GET", base+suffix, ""); w.Code != 200 {
t.Fatal(w.Code, w.Body)
}
if w = req("GET", base+"/members"+suffix, ""); w.Code != 200 || !strings.Contains(w.Body.String(), `"inherited":true`) {
t.Fatal(w.Code, w.Body)
}
if w = req("DELETE", fmt.Sprintf("%s/members/%d%s", base, f.ID, suffix), `{}`); w.Code != 403 {
t.Fatal(w.Code, w.Body)
}
if w = req("PUT", base+"/report"+suffix, `{}`); w.Code != 403 {
t.Fatal(w.Code, w.Body)
}
if w = req("POST", "/api/exploration/finding-case-review"+suffix, fmt.Sprintf(`{"case_id":%d}`, cid)); w.Code != 403 {
t.Fatal(w.Code, w.Body)
}
}
+5
View File
@@ -27,6 +27,7 @@ func writeFindingsEvidenceZip(out io.Writer, findings []*db.DBFinding, stage str
func writeFindingsZipEntries(zw *zip.Writer, findings []*db.DBFinding, stage string, now time.Time) error {
store := evidence.New(nil, nil, stage) // private export copy; not subject to GC
used := map[string]int{}
evidencePaths := map[string]bool{}
for _, f := range findings {
base := report.FindingFilename(f)
name := base
@@ -43,6 +44,10 @@ func writeFindingsZipEntries(zw *zip.Writer, findings []*db.DBFinding, stage str
}
for _, b := range f.TrafficBindings {
prefix := fmt.Sprintf("evidence/%d/%d/", f.ID, b.ID)
if evidencePaths[prefix] {
continue
}
evidencePaths[prefix] = true
w, err := zw.Create(prefix + "manifest.json")
if err != nil {
return err
+13 -3
View File
@@ -63,7 +63,7 @@ func (s *Server) hostTools() ([]actool.CoreTool, map[string][]string) {
// orchestrationTools returns the cross-task tool set. Bound per-agent via the
// tools table (default: no binding — opt-in for orchestration agents).
func (s *Server) orchestrationTools() []actool.CoreTool {
return []actool.CoreTool{
return append([]actool.CoreTool{
s.toolListTasks(),
s.toolListLLMProfiles(),
s.toolSpawnTask(),
@@ -78,7 +78,7 @@ func (s *Server) orchestrationTools() []actool.CoreTool {
s.toolUpdateFindingReport(),
s.toolGetFindingTraffic(),
s.toolBindFindingTraffic(),
}
}, s.findingCaseTools()...)
}
// --- schema helpers ---
@@ -442,6 +442,15 @@ func (s *Server) toolUpdateFindingReport() actool.CoreTool {
if nodeID <= 0 {
return actool.Errorf("finding_id 无效"), nil
}
fid, err := s.m.pg.FindingIDByNodeID(nodeID)
if err != nil {
return actool.Errorf(err.Error()), nil
}
if fid > 0 {
if err := s.checkCaseReviewScope(ctx, "update_finding_report", findingCaseRequest{FindingID: json.RawMessage(i64s(fid))}); err != nil {
return actool.Errorf(err.Error()), nil
}
}
n, err := s.m.pg.SetFindingReportVersionByNodeID(ctx, nodeID, a.Report, a.EvidenceVersion)
if err != nil {
return actool.Errorf(err.Error()), nil
@@ -477,7 +486,7 @@ func (s *Server) seedOrchestrationTools() {
for _, t := range s.orchestrationTools() {
schema, _ := json.Marshal(t.InputSchema())
bindings := autoAgents
if t.Name() == "bind_finding_traffic" {
if t.Name() == "bind_finding_traffic" || strings.Contains(t.Name(), "finding_case") || t.Name() == "search_finding_duplicates" || t.Name() == "get_finding_record" || t.Name() == "merge_finding_records" || t.Name() == "suggest_finding_merge" {
bindings = json.RawMessage(`["reporter"]`)
}
_ = s.m.PG().SeedTool(t.Name(), t.Description(), schema, bindings)
@@ -503,6 +512,7 @@ func (s *Server) seedOrchestrationTools() {
s.upgradeReporterTriggerMessage() // 老库补迁移:让 reporter 回传 evidence_version(一次性)
s.seedFindingTrafficTools() // 增加可选证据参数及只读证据工具,保留用户配置
s.seedFindingWorkflowTools()
s.seedFindingCaseTools()
// 注:pentest 的默认工具绑定无需迁移——BuiltinToolSeeds 在全新初始化时就把
// list_assets/insert_assets/report_finding/list_findings/list_companies 连同
// pentest 一起 seed 好了(项目尚无旧库,不做迁移)。
+42
View File
@@ -131,6 +131,8 @@ type provEntry struct {
// taskID + mergeable let the drainer coalesce several event triggers (finding/goal)
// from the SAME task into one conversation before it starts (interval fires don't merge).
type triggeredRun struct {
conversationID int64 // pre-created manual finding review; never coalesced
agentKey string
title string
message string // 事件正文(触发语 + 工具/入参/返回等);不含任务描述/目标头
@@ -755,6 +757,16 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/exploration/frontier", s.frontier)
mux.HandleFunc("GET /api/exploration/findings", s.findings)
mux.HandleFunc("GET /api/exploration/findings/groups", s.findingGroups)
mux.HandleFunc("GET /api/exploration/finding-cases", s.listFindingCases)
mux.HandleFunc("POST /api/exploration/finding-cases", s.mergeFindingCase)
mux.HandleFunc("GET /api/exploration/finding-cases/{id}", s.getFindingCase)
mux.HandleFunc("GET /api/exploration/finding-cases/{id}/members", s.getFindingCaseMembers)
mux.HandleFunc("DELETE /api/exploration/finding-cases/{id}/members/{fid}", s.removeFindingCaseMember)
mux.HandleFunc("PUT /api/exploration/finding-cases/{id}/report", s.updateCaseReport)
mux.HandleFunc("GET /api/exploration/finding-case-suggestions", s.caseSuggestions)
mux.HandleFunc("POST /api/exploration/finding-case-suggestions/{id}", s.resolveCaseSuggestion)
mux.HandleFunc("POST /api/exploration/finding-case-review", s.reviewFindingCases)
mux.HandleFunc("GET /api/exploration/finding-case-review", s.caseReviewRuns)
mux.HandleFunc("GET /api/exploration/findings/asset-tree", s.findingAssetTree)
mux.HandleFunc("GET /api/exploration/findings/stats", s.findingStats)
mux.HandleFunc("GET /api/exploration/findings/export", s.findingsExport)
@@ -1040,6 +1052,7 @@ func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) {
}
list := s.m.List()
metrics, _ := s.m.PG().TaskListMetricsAll()
distinct, _ := s.m.pg.DistinctFindingStatsByTask()
archiveBlockers, _ := s.m.PG().TaskArchiveBlockers()
dtos := make([]TaskDTO, 0, len(list))
for _, t := range list {
@@ -1056,6 +1069,7 @@ func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) {
dto.GoalsTotal = metric.Goals.Total
dto.GoalsMet = metric.Goals.Met
dto.InFlight = metric.RunningIntents
dto.DistinctFindings = distinct[t.ID]
dto.Findings = FindingSeverityDTO{
Critical: metric.Findings.Critical,
High: metric.Findings.High,
@@ -2052,6 +2066,11 @@ func (s *Server) findingStats(w http.ResponseWriter, r *http.Request) {
writeErr(w, 500, err.Error())
return
}
st.Distinct, err = s.m.pg.DistinctFindingStats("")
if err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, st)
}
@@ -2074,6 +2093,14 @@ func (s *Server) getFinding(w http.ResponseWriter, r *http.Request) {
}
assets := s.resolveAssetIDs(f.AssetIDs)
dto := findingFromDB(f, assets)
cid, err := s.m.pg.FindingCaseID(f.ID)
if err != nil {
writeErr(w, 500, err.Error())
return
}
if cid > 0 {
dto.CaseID = i64s(cid)
}
if contextTaskID := strings.TrimSpace(r.URL.Query().Get("context_task")); contextTaskID != "" {
contextTask := s.m.ResolveTask(contextTaskID)
if contextTask == nil {
@@ -2138,6 +2165,14 @@ func (s *Server) findingsExport(w http.ResponseWriter, r *http.Request) {
return
}
var casePlan *findingCaseExportPlan
if q.Get("mode") == "consolidated" {
fs, casePlan, err = s.prepareFindingCaseExport(fs)
if err != nil {
writeErr(w, 500, err.Error())
return
}
}
stage, err := os.MkdirTemp("", "artex-finding-export-")
if err != nil {
writeErr(w, 500, err.Error())
@@ -2148,6 +2183,13 @@ func (s *Server) findingsExport(w http.ResponseWriter, r *http.Request) {
evidenceError(w, err)
return
}
if casePlan != nil {
fs, err = s.consolidateFindingCaseExport(fs, casePlan, q.Get("include_originals") == "true")
if err != nil {
writeErr(w, 500, err.Error())
return
}
}
now := time.Now()
stamp := now.Format("20060102-150405")
setDownload := func(contentType, filename string) {
@@ -0,0 +1,242 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { toast } from "sonner";
import { FindingCaseMembers, FindingCaseReviewPanel, FindingSeverityCounts } from "@/components/finding-case-list";
import { Markdown } from "@/components/markdown";
import { StatusBadge } from "@/components/status-badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/lib/api";
import type { FindingCaseDetail } from "@/lib/types";
function CaseDetail() {
const params = useSearchParams();
const id = params.get("id") ?? "";
const contextTask = params.get("context_task") ?? undefined;
const [data, setData] = React.useState<FindingCaseDetail | null>(null);
const [error, setError] = React.useState("");
const [busy, setBusy] = React.useState(false);
const [selected, setSelected] = React.useState<Set<string>>(() => new Set());
const [refresh, setRefresh] = React.useState(0);
// biome-ignore lint/correctness/useExhaustiveDependencies: refresh explicitly reloads group after a detach.
React.useEffect(() => {
let active = true;
let timer: ReturnType<typeof setTimeout>;
async function load() {
try {
const v = await api.getFindingCase(id, contextTask);
if (active) {
setData(v);
setError("");
}
} catch (e) {
if (active) setError((e as Error).message);
} finally {
if (active) timer = setTimeout(load, 5000);
}
}
if (id) void load();
return () => {
active = false;
clearTimeout(timer);
};
}, [id, contextTask, refresh]);
const readOnly = !!contextTask && contextTask !== data?.case.task_id;
const select = (fid: string, on: boolean) =>
setSelected((v) => {
const n = new Set(v);
if (on) n.add(fid);
else n.delete(fid);
return n;
});
async function regenerate() {
setBusy(true);
try {
await api.regenerateFindingCase(id, contextTask);
toast.success("已提交重新整理,可查看执行状态");
} catch (e) {
toast.error((e as Error).message);
} finally {
setBusy(false);
}
}
async function detach() {
setBusy(true);
try {
for (const fid of selected) await api.removeFindingCaseMember(id, fid, "人工撤销归并");
setSelected(new Set());
setRefresh((v) => v + 1);
toast.success("已移出,原始记录保留");
} catch (e) {
toast.error((e as Error).message);
} finally {
setBusy(false);
}
}
if (!data)
return error ? (
<Alert>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : (
<Spinner />
);
const c = data.case;
return (
<div className="flex min-w-0 flex-col gap-4">
<div className="flex flex-wrap items-center gap-3">
<Button asChild variant="outline">
<Link href="/function/findings">返回漏洞列表</Link>
</Button>
<h1 className="font-semibold text-xl">{c.title}</h1>
<FindingSeverityCounts counts={c} />
{c.severity ? <StatusBadge domain="severity" value={c.severity} /> : <span>统一评级待评估</span>}
</div>
{error ? (
<Alert>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{!c.active ? (
<Alert>
<AlertDescription>此文件夹已解散或并入其他组,原始记录仍可从漏洞列表查看。</AlertDescription>
</Alert>
) : null}
{readOnly ? (
<Alert>
<AlertDescription>继承任务的漏洞组,只读。</AlertDescription>
</Alert>
) : (
<FindingCaseReviewPanel taskId={c.task_id ?? undefined} />
)}
<Tabs defaultValue="report">
<TabsList>
<TabsTrigger value="report">统一报告</TabsTrigger>
<TabsTrigger value="members">原始上报({c.count})</TabsTrigger>
<TabsTrigger value="history">归并历史</TabsTrigger>
</TabsList>
<TabsContent value="report">
<Card>
<CardHeader>
<CardTitle>完整复现与修复报告</CardTitle>
<CardDescription>{c.severity_reason || "统一评级尚未完成"}</CardDescription>
</CardHeader>
<CardContent className="flex min-w-0 flex-col gap-4">
{c.report_version !== c.version ? (
<Alert>
<AlertDescription>成员或证据已变化,统一报告待更新。</AlertDescription>
</Alert>
) : null}
{!readOnly && c.active ? (
<Button variant="outline" onClick={regenerate} disabled={busy}>
{busy ? <Spinner /> : null}重新生成统一报告
</Button>
) : null}
{c.report ? (
<Markdown text={c.report} />
) : (
<p className="text-muted-foreground text-sm">统一报告尚未生成,原始报告可在“原始上报”中查看。</p>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="members">
<Card>
<CardHeader>
<CardTitle>原始报告和证据完整保留</CardTitle>
<CardDescription>成员保留原评级;统一评级不覆盖原始记录。</CardDescription>
</CardHeader>
<CardContent>
{!readOnly && selected.size > 0 ? (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" disabled={busy}>
移出所选({selected.size})
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>移出归并组?</AlertDialogTitle>
<AlertDialogDescription>
原报告和证据保留,Agent 不会自动把这些关系再次归并。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={detach}>移出</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : null}
<FindingCaseMembers
caseId={id}
version={c.version}
selectedIds={selected}
onSelect={readOnly ? undefined : select}
contextTask={contextTask}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="history">
<Card>
<CardHeader>
<CardTitle>判断依据与操作记录</CardTitle>
<CardDescription>{c.reason}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{data.events.map((e) => (
<div key={e.id} className="border-b pb-3 text-sm">
<p>
{({ reporter: "报告 Agent", human: "人工", system: "系统" } as Record<string, string>)[e.actor] ??
e.actor}{" "}
·{" "}
{(
{
merge: "归并",
detach: "移出成员",
member_deleted: "成员移出或删除",
report_updated: "更新统一报告",
} as Record<string, string>
)[e.action] ?? e.action}{" "}
· {new Date(e.created_at).toLocaleString("zh-CN")}
</p>
<p className="text-muted-foreground">
{e.reason}
{e.finding_ids.length > 0 ? ` · #${e.finding_ids.join(" / #")}` : ""}
</p>
</div>
))}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}
export default function Page() {
return (
<React.Suspense fallback={<Spinner />}>
<CaseDetail />
</React.Suspense>
);
}
@@ -140,6 +140,15 @@ function FindingDetailInner() {
</Link>
</Button>
<ShieldAlertIcon className="size-4 text-muted-foreground" />
{finding.case_id ? (
<Button asChild variant="outline" size="sm">
<Link
href={`/function/findings/case?id=${finding.case_id}${contextTaskId ? `&context_task=${contextTaskId}` : ""}`}
>
所属漏洞文件夹
</Link>
</Button>
) : null}
<h1 className="max-w-md truncate text-sm font-semibold" title={title}>
{title}
</h1>
@@ -172,7 +181,7 @@ function FindingDetailInner() {
<p className="text-sm leading-relaxed whitespace-pre-wrap">{finding.summary || "(无摘要)"}</p>
</CardContent>
</Card>
<FindingRetestPanel key={id} findingId={id} readOnly={finding.inherited} onCompleted={load} />
<FindingRetestPanel key={`retest:${id}`} findingId={id} readOnly={finding.inherited} onCompleted={load} />
<Card>
<CardHeader>
<CardTitle className="text-sm">证据 / PoC</CardTitle>
@@ -188,7 +197,7 @@ function FindingDetailInner() {
</CardContent>
</Card>
<FindingTrafficPanel
key={id}
key={`traffic:${id}`}
findingId={id}
contextTask={contextTaskId || undefined}
readOnly={finding.inherited}
+54 -11
View File
@@ -17,11 +17,13 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { FindingCaseList } from "@/components/finding-case-list";
import { FindingRetestDialog } from "@/components/finding-retest-dialog";
import { StatusBadge } from "@/components/status-badge";
import { TablePagination } from "@/components/table-pagination";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
@@ -69,9 +71,9 @@ const FINDING_LIST_PREFERENCE_KEY = "artex_finding_list_preferences";
// 列表视图:flat = 跨任务平铺大表(默认);grouped = 按任务分组折叠;
// asset = 左侧资产树 + 右侧该子树下的发现。
type FindingView = "flat" | "grouped" | "asset";
type FindingView = "cases" | "flat" | "grouped" | "asset";
const FINDING_VIEWS: FindingView[] = ["flat", "grouped", "asset"];
const FINDING_VIEWS: FindingView[] = ["cases", "flat", "grouped", "asset"];
// 资产树的一次性快照。与另外两个视图不同,资产视图不轮询:进入视图、改筛选、
// 或本页改动了发现之后才重新查询。
@@ -129,7 +131,7 @@ const EMPTY_STATS: FindingStats = {
};
export default function FindingsPage() {
const [view, setView] = React.useState<FindingView>("flat");
const [view, setView] = React.useState<FindingView>("cases");
const [severity, setSeverity] = React.useState<"all" | Severity>("all");
const [status, setStatus] = React.useState<"all" | FindingStatus>("all");
const [vulnclass, setVulnclass] = React.useState<string>("all");
@@ -252,6 +254,8 @@ export default function FindingsPage() {
const [exportOpen, setExportOpen] = React.useState(false);
const [exportScope, setExportScope] = React.useState<"filtered" | "all" | "selected">("filtered");
const [exportFormat, setExportFormat] = React.useState<"md-single" | "md-zip" | "csv" | "json">("md-single");
const [includeOriginals, setIncludeOriginals] = React.useState(false);
const [reviewing, setReviewing] = React.useState(false);
const [exporting, setExporting] = React.useState(false);
const toggleSelected = React.useCallback((id: string, checked: boolean) => {
@@ -288,6 +292,7 @@ export default function FindingsPage() {
scope: exportScope,
filters: { severity, status, vulnclass, task, query, sort },
ids: [...selectedIds],
includeOriginals,
});
setExportOpen(false);
toast.success("已开始下载导出文件");
@@ -752,17 +757,18 @@ export default function FindingsPage() {
}
const statCards = [
{ label: "发现总数", value: stats.total, icon: BugIcon },
{ label: "待处理", value: stats.pending, tone: "text-amber-500", icon: ClockIcon },
{ label: "严重", value: stats.critical, tone: "text-rose-600", icon: ShieldAlertIcon },
{ label: "高危", value: stats.high, tone: "text-red-500", icon: TriangleAlertIcon },
{ label: "中危", value: stats.medium, tone: "text-amber-500", icon: TriangleAlertIcon },
{ label: "低危", value: stats.low, tone: "text-slate-500", icon: InfoIcon },
{ label: "独立漏洞", value: stats.distinct?.total ?? stats.total, icon: BugIcon },
{ label: "上报记录", value: stats.total, tone: "text-amber-500", icon: ClockIcon },
{ label: "严重", value: stats.distinct?.critical ?? stats.critical, tone: "text-rose-600", icon: ShieldAlertIcon },
{ label: "高危", value: stats.distinct?.high ?? stats.high, tone: "text-red-500", icon: TriangleAlertIcon },
{ label: "中危", value: stats.distinct?.medium ?? stats.medium, tone: "text-amber-500", icon: TriangleAlertIcon },
{ label: "低危", value: stats.distinct?.low ?? stats.low, tone: "text-slate-500", icon: InfoIcon },
];
// 导出弹窗里「当前筛选」的条数:两个视图的筛选一致,只是统计口径来源不同。
// 平铺与资产视图共用 flat 列表状态,分组视图的口径来自组接口的 finding_total。
const filteredTotal = view === "grouped" ? total : flat.total;
const [caseTotal, setCaseTotal] = React.useState(0);
const filteredTotal = view === "cases" ? caseTotal : view === "grouped" ? total : flat.total;
const assetPath = React.useMemo(
() => (view === "asset" ? assetPathOf(assetTree.nodes, assetScope) : []),
[assetScope, assetTree.nodes, view],
@@ -823,7 +829,8 @@ export default function FindingsPage() {
</div>
<Tabs value={view} onValueChange={(v) => setView(v as FindingView)}>
<TabsList>
<TabsTrigger value="flat">全部发现</TabsTrigger>
<TabsTrigger value="cases">按漏洞</TabsTrigger>
<TabsTrigger value="flat">原始上报</TabsTrigger>
<TabsTrigger value="grouped">按任务分组</TabsTrigger>
<TabsTrigger value="asset">按资产</TabsTrigger>
</TabsList>
@@ -953,6 +960,24 @@ export default function FindingsPage() {
{selectedIds.size > 0 && (
<span className="text-xs text-muted-foreground tabular-nums">已选 {selectedIds.size} 条</span>
)}
<Button
size="sm"
variant="outline"
disabled={reviewing || selectedIds.size === 0}
onClick={async () => {
setReviewing(true);
try {
await api.reviewFindingCases([...selectedIds]);
toast.success("已提交整理,按任务分别执行");
} catch (e) {
toast.error((e as Error).message);
} finally {
setReviewing(false);
}
}}
>
{reviewing ? <Spinner /> : null}整理所选({selectedIds.size})
</Button>
<Button size="sm" variant="outline" onClick={openExport}>
<DownloadIcon /> 导出
</Button>
@@ -1015,6 +1040,14 @@ export default function FindingsPage() {
</div>
)}
{view === "cases" && (
<FindingCaseList
onTotal={setCaseTotal}
query={{ severity, status, vulnclass, task, query, sort }}
selectedIds={selectedIds}
onSelect={toggleSelected}
/>
)}
{view === "grouped" && (
<div className="flex flex-col gap-3">
{groups.map((group) => {
@@ -1231,6 +1264,16 @@ export default function FindingsPage() {
<div className="flex flex-col gap-2">
<span className="text-xs text-muted-foreground">导出格式</span>
<div className="flex items-center gap-2">
<Checkbox
id="include-original-reports"
checked={includeOriginals}
onCheckedChange={(v) => setIncludeOriginals(v === true)}
/>
<label htmlFor="include-original-reports" className="text-sm">
包含原始子报告(默认每个文件夹一份统一报告)
</label>
</div>
<RadioGroup value={exportFormat} onValueChange={(v) => setExportFormat(v as typeof exportFormat)}>
<label htmlFor="export-format-md-single" className="flex items-center gap-2 text-sm">
<RadioGroupItem id="export-format-md-single" value="md-single" /> Markdown 汇总报告(单个 .md 文件)
@@ -7,6 +7,7 @@ import Link from "next/link";
import { ArrowDownIcon, ArrowUpIcon, ArrowUpRightIcon, ChevronRightIcon } from "lucide-react";
import { toast } from "sonner";
import { FindingCaseList } from "@/components/finding-case-list";
import { StatusBadge } from "@/components/status-badge";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
@@ -188,36 +189,55 @@ export function FindingsTab({ taskId }: { taskId: string }) {
});
return (
<Card className="overflow-hidden py-0">
<CardContent className="px-0">
<div className="flex items-center border-b px-4 py-2 text-xs text-muted-foreground">
<span className="min-w-0 flex-1">漏洞</span>
<button
type="button"
className="inline-flex items-center gap-1 outline-none focus-visible:underline"
aria-label={`发现时间当前${sortPreference.direction === "asc" ? "正序" : "倒序"},点击切换排序方向`}
onClick={() =>
setSortPreference((current) => ({
field: "time",
direction: current.direction === "asc" ? "desc" : "asc",
}))
}
>
<span>发现时间</span>
{sortPreference.direction === "asc" ? (
<ArrowUpIcon className="size-3.5" />
) : (
<ArrowDownIcon className="size-3.5" />
)}
</button>
<div className="flex flex-col gap-4">
<FindingCaseList query={{ task: taskId, sort: "time" }} />
{[
...new Set(
items
.filter((f) => f.inherited)
.map((f) => f.source_task_id)
.filter((id): id is string => !!id),
),
].map((source) => (
<div key={source}>
<p className="mb-2 text-sm text-muted-foreground">继承任务 #{source} · 只读</p>
<FindingCaseList query={{ task: source, sort: "time" }} contextTask={taskId} readOnly />
</div>
{items.map((f) => (
<Row key={f.id} f={f} contextTaskId={taskId} onStatus={onStatus} />
))}
{items.length === 0 && (
<p className="px-4 py-8 text-center text-sm text-muted-foreground">本任务及直接关联任务暂无确认发现。</p>
)}
</CardContent>
</Card>
))}
<details>
<summary className="cursor-pointer text-sm text-muted-foreground">查看原始上报列表</summary>
<Card className="overflow-hidden py-0">
<CardContent className="px-0">
<div className="flex items-center border-b px-4 py-2 text-xs text-muted-foreground">
<span className="min-w-0 flex-1">漏洞</span>
<button
type="button"
className="inline-flex items-center gap-1 outline-none focus-visible:underline"
aria-label={`发现时间当前${sortPreference.direction === "asc" ? "正序" : "倒序"},点击切换排序方向`}
onClick={() =>
setSortPreference((current) => ({
field: "time",
direction: current.direction === "asc" ? "desc" : "asc",
}))
}
>
<span>发现时间</span>
{sortPreference.direction === "asc" ? (
<ArrowUpIcon className="size-3.5" />
) : (
<ArrowDownIcon className="size-3.5" />
)}
</button>
</div>
{items.map((f) => (
<Row key={f.id} f={f} contextTaskId={taskId} onStatus={onStatus} />
))}
{items.length === 0 && (
<p className="px-4 py-8 text-center text-sm text-muted-foreground">本任务及直接关联任务暂无确认发现。</p>
)}
</CardContent>
</Card>
</details>
</div>
);
}
+15 -7
View File
@@ -950,7 +950,7 @@ export default function TasksPage() {
onSort={sortTasksBy}
/>
<TableHead className="text-center">目标进度</TableHead>
<TableHead className="text-center" title="严重 / 高 / 中 / 低">
<TableHead className="text-center" title="独立漏洞:严重 / 高 / 中 / 低">
漏洞 <span className="text-muted-foreground font-normal">严/高/中/低</span>
</TableHead>
<TableHead className="text-center">运行中 Worker</TableHead>
@@ -1220,14 +1220,16 @@ const TaskRow = React.memo(function TaskRow({
</TableCell>
<TableCell className="text-center text-xs tabular-nums">
{(() => {
const f = task.findings;
const f = task.distinct_findings ?? task.findings;
const total = f ? f.critical + f.high + f.medium + f.low : 0;
if (!f || total === 0) return <span className="text-muted-foreground">0</span>;
const seg = (n: number, cls: string) => (
<span className={n > 0 ? cls : "text-muted-foreground"}>{n}</span>
);
if (!f || (total === 0 && !task.distinct_findings?.total))
return <span className="text-muted-foreground">0</span>;
const seg = (n: number, cls: string) => <span className={n > 0 ? cls : "text-muted-foreground"}>{n}</span>;
return (
<span className="font-medium whitespace-nowrap" title="严重 / 高 / 中 / 低">
<span
className="font-medium whitespace-nowrap"
title={`独立漏洞:严重 / 高 / 中 / 低;上报 ${task.distinct_findings?.reports ?? total} 条;待评估 ${task.distinct_findings?.unassessed ?? 0} 个`}
>
{seg(f.critical, "text-rose-600 dark:text-rose-400")}
<span className="text-muted-foreground">/</span>
{seg(f.high, "text-red-600 dark:text-red-400")}
@@ -1235,6 +1237,12 @@ const TaskRow = React.memo(function TaskRow({
{seg(f.medium, "text-amber-600 dark:text-amber-400")}
<span className="text-muted-foreground">/</span>
{seg(f.low, "text-slate-600 dark:text-slate-400")}
{task.distinct_findings ? (
<span className="ml-1 text-muted-foreground">
· {task.distinct_findings.total} 漏洞 / {task.distinct_findings.reports} 上报
{task.distinct_findings.unassessed > 0 ? ` · 待评估 ${task.distinct_findings.unassessed}` : ""}
</span>
) : null}
</span>
);
})()}
+12
View File
@@ -10,6 +10,10 @@
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-severity-critical: var(--severity-critical);
--color-severity-high: var(--severity-high);
--color-severity-medium: var(--severity-medium);
--color-severity-low: var(--severity-low);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
@@ -55,6 +59,10 @@
/* Default theme styles (used when no data-theme-preset is set or when 'default' is selected).
These serve as the fallback; there is no separate default.css file. */
:root {
--severity-critical: #e11d48;
--severity-high: #dc2626;
--severity-medium: #d97706;
--severity-low: #475569;
--radius: 0.625rem;
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
@@ -94,6 +102,10 @@ These serve as the fallback; there is no separate default.css file. */
}
.dark {
--severity-critical: #fb7185;
--severity-high: #f87171;
--severity-medium: #fbbf24;
--severity-low: #94a3b8;
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
+425
View File
@@ -0,0 +1,425 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { ChevronRightIcon, FolderIcon } from "lucide-react";
import { toast } from "sonner";
import { StatusBadge } from "@/components/status-badge";
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 { Checkbox } from "@/components/ui/checkbox";
import { Spinner } from "@/components/ui/spinner";
import { api } from "@/lib/api";
import type {
Finding,
FindingCase,
FindingCasePage,
FindingCaseReviewRun,
FindingCaseSuggestion,
FindingQuery,
} from "@/lib/types";
import { cn } from "@/lib/utils";
export function FindingSeverityCounts({
counts,
}: {
counts: Pick<FindingCase, "critical" | "high" | "medium" | "low">;
}) {
return (
<span
className="inline-flex items-center gap-0.5 text-sm tabular-nums"
title="原始上报记录:严重 / 高 / 中 / 低"
role="img"
aria-label={`严重${counts.critical},高危${counts.high},中危${counts.medium},低危${counts.low}`}
>
<span className={counts.critical ? "text-severity-critical" : "text-muted-foreground"}>{counts.critical}</span>
<span>/</span>
<span className={counts.high ? "text-severity-high" : "text-muted-foreground"}>{counts.high}</span>
<span>/</span>
<span className={counts.medium ? "text-severity-medium" : "text-muted-foreground"}>{counts.medium}</span>
<span>/</span>
<span className={counts.low ? "text-severity-low" : "text-muted-foreground"}>{counts.low}</span>
</span>
);
}
export function FindingCaseMemberRow({
finding: f,
selected,
onSelect,
contextTask,
matched = true,
}: {
finding: Finding;
selected?: boolean;
onSelect?: (id: string, checked: boolean) => void;
contextTask?: string;
matched?: 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",
!matched && "bg-muted/30",
)}
>
{onSelect && !f.inherited ? (
<Checkbox checked={selected} onCheckedChange={(v) => onSelect(id, v === true)} aria-label={`选择上报 #${id}`} />
) : null}
<StatusBadge domain="severity" value={f.severity} />
<div className="flex min-w-0 flex-1 flex-col gap-1">
<Link
className="truncate font-medium hover:underline"
href={`/function/findings/detail?id=${id}${contextTask ? `&context_task=${contextTask}` : ""}`}
>
#{id} {[f.name, f.vulnclass].find((v) => v?.trim()) ?? "未分类"}
</Link>
<span className="truncate text-muted-foreground text-xs">{f.summary}</span>
</div>
{!matched ? <Badge variant="outline">未命中当前筛选</Badge> : null}
{f.inherited ? <Badge variant="outline">继承 · 只读</Badge> : null}
<StatusBadge domain="finding" value={f.status} />
<Button asChild variant="ghost" size="sm">
<Link href={`/function/findings/detail?id=${id}${contextTask ? `&context_task=${contextTask}` : ""}`}>
查看报告
</Link>
</Button>
</div>
);
}
export function FindingCaseMembers({
caseId,
version,
matchedIds,
selectedIds,
onSelect,
contextTask,
}: {
caseId: string;
version?: number;
matchedIds?: number[];
selectedIds?: Set<string>;
onSelect?: (id: string, checked: boolean) => void;
contextTask?: string;
}) {
const [page, setPage] = React.useState(1);
const [data, setData] = React.useState<{ items: Finding[]; total: number } | null>(null);
const [error, setError] = React.useState("");
const [retry, setRetry] = React.useState(0);
// biome-ignore lint/correctness/useExhaustiveDependencies: version and retry deliberately reload changed evidence.
React.useEffect(() => {
let active = true;
setError("");
api
.findingCaseMembers(caseId, page, contextTask)
.then((v) => {
if (active) setData(v);
})
.catch((e) => {
if (active) setError((e as Error).message);
});
return () => {
active = false;
};
}, [caseId, page, version, contextTask, retry]);
if (error)
return (
<Alert>
<AlertDescription>
成员加载失败:{error}
<Button variant="link" onClick={() => setRetry((v) => v + 1)}>
重试
</Button>
</AlertDescription>
</Alert>
);
if (!data)
return (
<div className="flex justify-center p-5">
<Spinner />
</div>
);
return (
<div className="flex min-w-0 flex-col">
{data.items.map((f) => (
<FindingCaseMemberRow
key={f.finding_id}
finding={f}
selected={selectedIds?.has(f.finding_id ?? f.id)}
onSelect={onSelect}
contextTask={contextTask}
matched={!matchedIds || matchedIds.includes(Number(f.finding_id))}
/>
))}
{data.total > 20 ? (
<TablePagination
page={page}
pageSize={20}
total={data.total}
onPageChange={setPage}
onPageSizeChange={() => setPage(1)}
pageSizeOptions={[20]}
/>
) : 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("");
React.useEffect(() => {
let active = true;
let timer: ReturnType<typeof setTimeout>;
async function load() {
try {
const [s, r] = await Promise.all([api.findingCaseSuggestions(taskId), api.findingCaseReviewRuns()]);
if (active) {
setSuggestions(s);
setRuns(r.filter((x) => !taskId || x.task_id === taskId));
setError("");
}
} catch (e) {
if (active) setError((e as Error).message);
} finally {
if (active) timer = setTimeout(load, 5000);
}
}
void load();
return () => {
active = false;
clearTimeout(timer);
};
}, [taskId]);
async function resolve(s: FindingCaseSuggestion, accept: boolean) {
try {
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("已归并,统一报告待生成");
} else {
toast.success("已否决,不会自动再次归并");
}
onChange?.();
} catch (e) {
toast.error((e as Error).message);
}
}
return (
<div className="flex flex-col gap-2">
{error ? (
<Alert>
<AlertDescription>整理状态加载失败:{error}</AlertDescription>
</Alert>
) : null}
{suggestions.map((s) => (
<Alert key={s.id}>
<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>
</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>
))}
</div>
);
}
export function FindingCaseList({
query,
selectedIds,
onSelect,
contextTask,
readOnly = false,
onTotal,
}: {
query: Omit<FindingQuery, "page" | "pageSize">;
selectedIds?: Set<string>;
onSelect?: (id: string, checked: boolean) => void;
contextTask?: string;
readOnly?: boolean;
onTotal?: (total: number) => void;
}) {
const [page, setPage] = React.useState(1);
const [data, setData] = React.useState<FindingCasePage | null>(null);
const [error, setError] = React.useState("");
const [open, setOpen] = React.useState<Set<string>>(() => new Set());
const [refresh, setRefresh] = React.useState(0);
const key = JSON.stringify(query);
// biome-ignore lint/correctness/useExhaustiveDependencies: reset pagination when the filter fingerprint changes.
React.useEffect(() => {
setPage(1);
}, [key]);
// biome-ignore lint/correctness/useExhaustiveDependencies: refresh is an explicit reload requested after grouping/retry.
React.useEffect(() => {
let active = true;
let timer: ReturnType<typeof setTimeout>;
async function load() {
try {
const v = await api.findingCases({ ...JSON.parse(key), page, pageSize: 20 });
if (active) {
setData(v);
setError("");
}
} catch (e) {
if (active) setError((e as Error).message);
} finally {
if (active) timer = setTimeout(load, 5000);
}
}
void load();
return () => {
active = false;
clearTimeout(timer);
};
}, [key, page, refresh]);
React.useEffect(() => {
if (data) onTotal?.(data.total);
}, [data, onTotal]);
const toggle = (id: string) =>
setOpen((v) => {
const next = new Set(v);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
return (
<div className="flex min-w-0 flex-col gap-3">
{!readOnly ? (
<FindingCaseReviewPanel
taskId={query.task === "all" ? undefined : query.task}
onChange={() => setRefresh((v) => v + 1)}
/>
) : null}
{error ? (
<Alert>
<AlertDescription>
加载失败:{error}
<Button variant="link" onClick={() => setRefresh((v) => v + 1)}>
重试
</Button>
</AlertDescription>
</Alert>
) : null}
{!data && !error ? (
<div className="flex justify-center p-6">
<Spinner />
</div>
) : null}
{data ? (
<p className="text-muted-foreground text-xs">
{data.stats.total} 个独立漏洞 · {data.stats.reports} 条上报
{data.stats.unassessed > 0 ? ` · ${data.stats.unassessed} 个待评估` : ""}
</p>
) : null}
{data?.items.map((row) => {
if (row.case) {
const group = row.case;
return (
<Card key={`case:${group.id}`} className="gap-0 overflow-hidden py-0">
<CardHeader className="px-4 py-3">
<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)}
onClick={() => toggle(group.id)}
>
<ChevronRightIcon className={cn(open.has(group.id) && "rotate-90")} />
</Button>
<FolderIcon className="size-4 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
className="hover:underline"
href={`/function/findings/case?id=${group.id}${contextTask ? `&context_task=${contextTask}` : ""}`}
>
{group.title}
</Link>
</CardTitle>
<CardDescription>
{group.count} 条上报 ·{" "}
{group.report_version !== group.version ? "统一报告待更新" : "统一报告已生成"}
</CardDescription>
</div>
<FindingSeverityCounts counts={group} />
<Badge variant="outline">
{group.severity
? `统一评级:${{ critical: "严重", high: "高危", medium: "中危", low: "低危" }[group.severity]}`
: "待评估"}
</Badge>
</div>
</CardHeader>
{open.has(group.id) ? (
<CardContent className="border-t px-0">
<FindingCaseMembers
caseId={group.id}
version={group.version}
matchedIds={row.matched_ids}
selectedIds={selectedIds}
onSelect={readOnly ? undefined : onSelect}
contextTask={contextTask}
/>
</CardContent>
) : null}
</Card>
);
}
if (row.finding)
return (
<Card key={`finding:${row.finding.finding_id}`} className="gap-0 py-0">
<CardContent className="px-0">
<FindingCaseMemberRow
finding={row.finding}
selected={selectedIds?.has(row.finding.finding_id ?? row.finding.id)}
onSelect={readOnly ? undefined : onSelect}
contextTask={contextTask}
/>
</CardContent>
</Card>
);
return null;
})}
{data?.total === 0 ? <p className="p-6 text-center text-muted-foreground text-sm">暂无匹配漏洞</p> : null}
{data ? (
<TablePagination
page={page}
pageSize={20}
total={data.total}
onPageChange={setPage}
onPageSizeChange={() => setPage(1)}
pageSizeOptions={[20]}
/>
) : null}
</div>
);
}
+45 -2
View File
@@ -37,6 +37,10 @@ import type {
ExplorationNodeQuery,
Finding,
FindingAssetTree,
FindingCaseDetail,
FindingCasePage,
FindingCaseReviewRun,
FindingCaseSuggestion,
FindingDeepenResponse,
FindingGroupsPage,
FindingQuery,
@@ -555,6 +559,42 @@ export const api = {
// 节点带子树聚合计数。不分页——树是导航结构,一次取完。
findingAssetTree: (q: Omit<FindingQuery, "page" | "pageSize">) =>
get<FindingAssetTree>(`/exploration/findings/asset-tree?${findingFilterParams(q).toString()}`),
findingCases: (q: FindingQuery) => {
const p = findingFilterParams(q);
p.set("page", String(q.page));
p.set("limit", String(q.pageSize));
return get<FindingCasePage>(`/exploration/finding-cases?${p}`);
},
getFindingCase: (id: string, contextTask?: string) =>
get<FindingCaseDetail>(
`/exploration/finding-cases/${id}${contextTask ? `?context_task=${encodeURIComponent(contextTask)}` : ""}`,
),
findingCaseMembers: (id: string, page = 1, contextTask?: string) =>
get<FindingsPage>(
`/exploration/finding-cases/${id}/members?page=${page}&limit=20${contextTask ? `&context_task=${encodeURIComponent(contextTask)}` : ""}`,
),
removeFindingCaseMember: (id: string, fid: string, reason: string) =>
http<{ ok: boolean }>(`/exploration/finding-cases/${id}/members/${fid}`, {
method: "DELETE",
body: JSON.stringify({ reason }),
}),
findingCaseSuggestions: (task?: string) =>
get<FindingCaseSuggestion[]>(
`/exploration/finding-case-suggestions${task ? `?task_id=${encodeURIComponent(task)}` : ""}`,
),
resolveFindingCaseSuggestion: (id: string, accept: boolean) =>
post<{ case_id: string }>(`/exploration/finding-case-suggestions/${id}`, { accept }),
reviewFindingCases: (ids: string[]) =>
post<{ runs: { conversation_id: number; task_id: string }[] }>("/exploration/finding-case-review", {
finding_ids: ids,
}),
regenerateFindingCase: (id: string, contextTask?: string) =>
post<{ runs: { conversation_id: number; task_id: string }[] }>(
`/exploration/finding-case-review${contextTask ? `?context_task=${encodeURIComponent(contextTask)}` : ""}`,
{ case_id: id },
),
findingCaseReviewRuns: () => get<FindingCaseReviewRun[]>("/exploration/finding-case-review"),
findingStats: () => get<FindingStats>("/exploration/findings/stats"),
// exportFindings 触发发现页导出并下载文件。scope=selected 时传 ids(finding_id 列表);
// scope=filtered 时传当前筛选(沿用 FindingQuery 的筛选字段);scope=all 忽略筛选。
@@ -563,8 +603,12 @@ export const api = {
scope: "filtered" | "all" | "selected";
filters?: Omit<FindingQuery, "page" | "pageSize">;
ids?: string[];
includeOriginals?: boolean;
consolidated?: boolean;
}) => {
const p = new URLSearchParams({ format: opts.format, scope: opts.scope });
p.set("mode", opts.consolidated === false ? "raw" : "consolidated");
p.set("include_originals", String(opts.includeOriginals ?? false));
if (opts.scope === "selected") {
p.set("ids", (opts.ids ?? []).join(","));
} else if (opts.scope === "filtered" && opts.filters) {
@@ -825,8 +869,7 @@ export const api = {
// 渠道是多实例资源(同一类型可配多个机器人、各有过滤规则),因此独立成组,
// 不塞进扁平的 settings 键值里。
notifyMeta: () => get<NotificationMeta>(`/notify/meta`),
notifyChannels: () =>
get<{ channels: NotificationChannel[] }>(`/notify/channels`).then((r) => arr(r.channels)),
notifyChannels: () => get<{ channels: NotificationChannel[] }>(`/notify/channels`).then((r) => arr(r.channels)),
notifyCreateChannel: (payload: {
name: string;
kind: string;
+29
View File
@@ -1843,6 +1843,35 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
return { deleted: 1, assets_deleted: assetsDeleted };
}
// Demo mode keeps raw observations as independent findings, without executing a model.
if (path === "/exploration/finding-case-suggestions" || (path === "/exploration/finding-case-review" && m === "GET"))
return [];
if (path === "/exploration/finding-case-review" && m === "POST")
throw new Error("演示模式不执行 Reporter 整理,请在已配置模型的实例中使用");
if (path === "/exploration/finding-cases" && m === "GET") {
const list = mockApplyAssetScope(mockFilterFindings(q), q.get("asset_scope"));
const ranks = { critical: 4, high: 3, medium: 2, low: 1 } as const;
list.sort(
(a, b) =>
(q.get("sort") === "severity" ? ranks[b.severity] - ranks[a.severity] : 0) || +new Date(b.ts) - +new Date(a.ts),
);
const page = Math.max(1, Number(q.get("page")) || 1);
const size = Math.min(100, Math.max(1, Number(q.get("limit")) || 20));
const scoped = mockFindings.filter((f) => !q.get("task_id") || f.task_id === q.get("task_id"));
const counts = { critical: 0, high: 0, medium: 0, low: 0 };
for (const f of scoped) counts[f.severity]++;
return {
items: list.slice((page - 1) * size, page * size).map((f) => ({
finding: { ...f, report: undefined, finding_id: f.id, traffic_count: mockTrafficCount(f.id) },
matched_ids: [],
})),
total: list.length,
page,
page_size: size,
stats: { total: scoped.length, reports: scoped.length, unassessed: 0, ...counts },
};
}
// ── exploration ──
if (path === "/exploration/frontier") return D.frontier;
if (path === "/exploration/findings/stats") {
+70
View File
@@ -5,6 +5,7 @@ export type TaskStatus = "created" | "queued" | "running" | "paused" | "done" |
export type EngineMode = "exploring" | "paused" | "stalled" | "idle";
export interface Task {
distinct_findings?: FindingDistinctStats;
id: string;
name?: string; // 可选任务名称;空/缺省=未命名,展示时回退到描述
category_id?: number;
@@ -489,6 +490,7 @@ export interface FindingAsset {
}
export interface Finding {
case_id?: string;
traffic_count?: number;
evidence_version?: number;
report_evidence_version?: number;
@@ -550,6 +552,7 @@ export interface FindingDeepenResponse {
// FindingStats 是发现全表聚合(统计卡 + 漏洞类型下拉),服务端计算,不受分页影响。
export interface FindingStats {
distinct?: FindingDistinctStats;
total: number;
pending: number;
critical: number;
@@ -1610,3 +1613,70 @@ export interface InterceptExecution {
seq: number;
items: Activity[];
}
export interface FindingDistinctStats {
total: number;
reports: number;
critical: number;
high: number;
medium: number;
low: number;
unassessed: number;
}
export interface FindingCase {
id: string;
task_id: string | null;
title: string;
reason: string;
severity: Severity | "";
severity_reason: string;
report?: string;
version: number;
report_version: number;
active: boolean;
count: number;
critical: number;
high: number;
medium: number;
low: number;
created_at: string;
}
export interface FindingCaseListRow {
case?: FindingCase | null;
finding?: Finding;
matched_ids: number[];
}
export interface FindingCasePage {
items: FindingCaseListRow[];
total: number;
page: number;
page_size: number;
stats: FindingDistinctStats;
}
export interface FindingCaseEvent {
id: string;
action: string;
actor: string;
reason: string;
finding_ids: number[];
created_at: string;
}
export interface FindingCaseDetail {
case: FindingCase;
events: FindingCaseEvent[];
}
export interface FindingCaseSuggestion {
id: string;
task_id: string;
left_id: string;
right_id: string;
title: string;
reason: string;
state: string;
}
export interface FindingCaseReviewRun {
conversation_id: number;
task_id: string;
state: "queued" | "running" | "done" | "failed";
error: string;
}