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