mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
feat(findings): consolidate duplicate reports into expandable cases
This commit is contained in:
1 parent
a91b27feb7
commit
24d4726aee
29 files changed
+3051
-63
No files matched your search
@@ -4,6 +4,13 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 同一漏洞的重复上报归并(保持程序版本 0.3.15)
|
||||||
|
|
||||||
|
- Reporter 在生成独立报告后按同任务候选证据判断同一实际缺陷,明确重复时归并,证据不足时提交人工确认建议。
|
||||||
|
- 新增可展开漏洞文件夹、统一报告、原始等级分布、独立漏洞/上报记录统计及归并历史;每条原报告、证据和流量继续保留。
|
||||||
|
- 支持手动整理所选历史记录、移出成员与防止自动再次归并,继承任务保持只读。
|
||||||
|
- 统一报告采用证据版本校验;导出默认统一报告,可包含原始子报告,未生成或过期时明确提示并保留原材料。
|
||||||
|
|
||||||
### 部署与发布维护(保持程序版本 0.3.15)
|
### 部署与发布维护(保持程序版本 0.3.15)
|
||||||
|
|
||||||
- 迁移至独立的 `RuoJi6/ARTEX` 仓库,保留原提交历史与分支。
|
- 迁移至独立的 `RuoJi6/ARTEX` 仓库,保留原提交历史与分支。
|
||||||
|
|||||||
@@ -314,6 +314,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
|
// UnlockSkill unlocks a named skill's MCPs — hosts call it to rebuild the unlock set
|
||||||
// from history on a resumed session (design doc C2).
|
// from history on a resumed session (design doc C2).
|
||||||
type DeferredInfo struct {
|
type DeferredInfo struct {
|
||||||
|
CaseGuidance string // independent of optional traffic binding
|
||||||
|
|
||||||
FindingGuidance string // derived from the final permitted tools, including DB overrides
|
FindingGuidance string // derived from the final permitted tools, including DB overrides
|
||||||
Deferred []string // all MCP tool names (schema withheld)
|
Deferred []string // all MCP tool names (schema withheld)
|
||||||
GlobalNames []string // MCP names to list in the system-prompt block
|
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 = ToolResolve(ctx, agentKey, out)
|
||||||
}
|
}
|
||||||
out, def.FindingGuidance = findingWorkflowTools(agentKey, out)
|
out, def.FindingGuidance = findingWorkflowTools(agentKey, out)
|
||||||
|
out, def.CaseGuidance = findingCaseWorkflow(agentKey, out)
|
||||||
for i, t := range out {
|
for i, t := range out {
|
||||||
out[i] = guardPanic(t)
|
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
|
// skill loads. Returns a single plain segment + boundary 0 when there is no global
|
||||||
// block to add.
|
// block to add.
|
||||||
func deferredSystem(sysText string, def DeferredInfo) (system []string, boundary int) {
|
func deferredSystem(sysText string, def DeferredInfo) (system []string, boundary int) {
|
||||||
sysText += def.FindingGuidance
|
sysText += def.FindingGuidance + def.CaseGuidance
|
||||||
block := actool.RenderDeferredToolsBlock(def.GlobalNames)
|
block := actool.RenderDeferredToolsBlock(def.GlobalNames)
|
||||||
if block == "" {
|
if block == "" {
|
||||||
return []string{sysText}, 0
|
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
|
// DBFinding is a row in the standalone findings table. It persists across task
|
||||||
// deletion unless the caller explicitly requests related finding cleanup.
|
// deletion unless the caller explicitly requests related finding cleanup.
|
||||||
type DBFinding struct {
|
type DBFinding struct {
|
||||||
|
ExportCaseID int64 // transient unified-export identity, never a finding handle
|
||||||
|
ExportMemberIDs []int64
|
||||||
|
|
||||||
TrafficCount int
|
TrafficCount int
|
||||||
EvidenceVersion int64
|
EvidenceVersion int64
|
||||||
ReportEvidenceVersion 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
|
// and vuln-class filter — computed server-side so it stays exact regardless of
|
||||||
// pagination.
|
// pagination.
|
||||||
type FindingStats struct {
|
type FindingStats struct {
|
||||||
|
Distinct *FindingDistinctStats `json:"distinct,omitempty"`
|
||||||
|
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
Pending int `json:"pending"`
|
Pending int `json:"pending"`
|
||||||
Critical int `json:"critical"`
|
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;
|
ON notification_deliveries(batch_id) WHERE batch_id IS NOT NULL;
|
||||||
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_channel
|
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_channel
|
||||||
ON notification_deliveries(channel_id, id DESC);
|
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])
|
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")
|
b.WriteString("\n")
|
||||||
|
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
@@ -127,6 +130,9 @@ func FindingFilename(f *db.DBFinding) string {
|
|||||||
sev := nz(f.Severity, "info")
|
sev := nz(f.Severity, "info")
|
||||||
title := findingTitle(f)
|
title := findingTitle(f)
|
||||||
name := fmt.Sprintf("%s_%s_#%d", sev, title, f.ID)
|
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 = unsafeFilenameChars.ReplaceAllString(name, "_")
|
||||||
name = strings.Trim(name, "._")
|
name = strings.Trim(name, "._")
|
||||||
if name == "" {
|
if name == "" {
|
||||||
@@ -151,8 +157,12 @@ func FindingsCSV(fs []*db.DBFinding) []byte {
|
|||||||
w := csv.NewWriter(&buf)
|
w := csv.NewWriter(&buf)
|
||||||
_ = w.Write([]string{"ID", "名称", "类别", "严重等级", "状态", "所属任务", "发现时间", "概述", "流量证据数量", "流量证据ID"})
|
_ = w.Write([]string{"ID", "名称", "类别", "严重等级", "状态", "所属任务", "发现时间", "概述", "流量证据数量", "流量证据ID"})
|
||||||
for _, f := range items {
|
for _, f := range items {
|
||||||
|
id := fmt.Sprintf("%d", f.ID)
|
||||||
|
if f.ExportCaseID > 0 {
|
||||||
|
id = fmt.Sprintf("case:%d", f.ExportCaseID)
|
||||||
|
}
|
||||||
_ = w.Write([]string{
|
_ = w.Write([]string{
|
||||||
fmt.Sprintf("%d", f.ID),
|
id,
|
||||||
findingTitle(f),
|
findingTitle(f),
|
||||||
f.VulnClass,
|
f.VulnClass,
|
||||||
nz(f.Severity, "info"),
|
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
|
// Only the first turn executes a historical retest. Follow-up conversation
|
||||||
// turns may explain the sealed result; the result tool refuses to overwrite it.
|
// 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 {
|
if c.AgentKey == db.FindingRetestAgentKey {
|
||||||
// Read without the run cancellation so an immediate stop still seals pending.
|
// Read without the run cancellation so an immediate stop still seals pending.
|
||||||
r, err := s.m.pg.FindingRetestForConversation(context.Background(), c.ID)
|
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.
|
// Caller holds queueMu.
|
||||||
func (s *Server) nextTriggerRun(agentKey string, cfg triggerBehavior) triggeredRun {
|
func (s *Server) nextTriggerRun(agentKey string, cfg triggerBehavior) triggeredRun {
|
||||||
q := s.triggerQ[agentKey]
|
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:]
|
s.triggerQ[agentKey] = q[1:]
|
||||||
return q[0]
|
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]
|
s.triggerQ[agentKey] = q[:0:0]
|
||||||
return mergeAllRuns(q)
|
return mergeAllRuns(q)
|
||||||
}
|
}
|
||||||
@@ -685,7 +705,7 @@ func (s *Server) nextTriggerRun(agentKey string, cfg triggerBehavior) triggeredR
|
|||||||
group := []triggeredRun{head}
|
group := []triggeredRun{head}
|
||||||
rest := q[:0:0] // keep non-matching items in order
|
rest := q[:0:0] // keep non-matching items in order
|
||||||
for _, it := range q[1:] {
|
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)
|
group = append(group, it)
|
||||||
} else {
|
} else {
|
||||||
rest = append(rest, it)
|
rest = append(rest, it)
|
||||||
@@ -798,12 +818,25 @@ func mergeAllRuns(items []triggeredRun) triggeredRun {
|
|||||||
func (s *Server) runTriggeredRun(item triggeredRun) {
|
func (s *Server) runTriggeredRun(item triggeredRun) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
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)
|
log.Printf("[trigger] run for %s panicked: %v", item.agentKey, r)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
pg := s.m.pg
|
pg := s.m.pg
|
||||||
c, err := pg.CreateConversation(item.agentKey, firstLine(item.title, 60), nil)
|
var c *db.Conversation
|
||||||
if err != nil {
|
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)
|
log.Printf("[trigger] create conversation for %s failed: %v", item.agentKey, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ func rawString(raw json.RawMessage) string {
|
|||||||
|
|
||||||
// ---- Task (frontend "Task") ---- created_at as RFC3339, plus a derived status.
|
// ---- Task (frontend "Task") ---- created_at as RFC3339, plus a derived status.
|
||||||
type TaskDTO struct {
|
type TaskDTO struct {
|
||||||
|
DistinctFindings *db.FindingDistinctStats `json:"distinct_findings,omitempty"`
|
||||||
|
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ExplorationID int64 `json:"exploration_id"`
|
ExplorationID int64 `json:"exploration_id"`
|
||||||
Name string `json:"name"` // 可选任务名称;空=未命名
|
Name string `json:"name"` // 可选任务名称;空=未命名
|
||||||
@@ -303,6 +305,9 @@ func coverageAssetRefDTO(ref db.AssetRef) CoverageAssetRefDTO {
|
|||||||
// ---- Finding (frontend "Finding") ----
|
// ---- Finding (frontend "Finding") ----
|
||||||
|
|
||||||
type FindingDTO struct {
|
type FindingDTO struct {
|
||||||
|
CaseID string `json:"case_id,omitempty"`
|
||||||
|
MemberIDs []int64 `json:"member_ids,omitempty"`
|
||||||
|
|
||||||
TrafficCount int `json:"traffic_count"`
|
TrafficCount int `json:"traffic_count"`
|
||||||
EvidenceVersion int64 `json:"evidence_version"`
|
EvidenceVersion int64 `json:"evidence_version"`
|
||||||
ReportEvidenceVersion int64 `json:"report_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,
|
Report: f.Report,
|
||||||
TS: rfc3339(f.CreatedAt),
|
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)
|
d.Assets = findingAssetDTOs(f.AssetIDs, assets)
|
||||||
if f.TaskID != nil {
|
if f.TaskID != nil {
|
||||||
d.TaskID = i64s(*f.TaskID)
|
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 {
|
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
|
store := evidence.New(nil, nil, stage) // private export copy; not subject to GC
|
||||||
used := map[string]int{}
|
used := map[string]int{}
|
||||||
|
evidencePaths := map[string]bool{}
|
||||||
for _, f := range findings {
|
for _, f := range findings {
|
||||||
base := report.FindingFilename(f)
|
base := report.FindingFilename(f)
|
||||||
name := base
|
name := base
|
||||||
@@ -43,6 +44,10 @@ func writeFindingsZipEntries(zw *zip.Writer, findings []*db.DBFinding, stage str
|
|||||||
}
|
}
|
||||||
for _, b := range f.TrafficBindings {
|
for _, b := range f.TrafficBindings {
|
||||||
prefix := fmt.Sprintf("evidence/%d/%d/", f.ID, b.ID)
|
prefix := fmt.Sprintf("evidence/%d/%d/", f.ID, b.ID)
|
||||||
|
if evidencePaths[prefix] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
evidencePaths[prefix] = true
|
||||||
w, err := zw.Create(prefix + "manifest.json")
|
w, err := zw.Create(prefix + "manifest.json")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
// orchestrationTools returns the cross-task tool set. Bound per-agent via the
|
||||||
// tools table (default: no binding — opt-in for orchestration agents).
|
// tools table (default: no binding — opt-in for orchestration agents).
|
||||||
func (s *Server) orchestrationTools() []actool.CoreTool {
|
func (s *Server) orchestrationTools() []actool.CoreTool {
|
||||||
return []actool.CoreTool{
|
return append([]actool.CoreTool{
|
||||||
s.toolListTasks(),
|
s.toolListTasks(),
|
||||||
s.toolListLLMProfiles(),
|
s.toolListLLMProfiles(),
|
||||||
s.toolSpawnTask(),
|
s.toolSpawnTask(),
|
||||||
@@ -78,7 +78,7 @@ func (s *Server) orchestrationTools() []actool.CoreTool {
|
|||||||
s.toolUpdateFindingReport(),
|
s.toolUpdateFindingReport(),
|
||||||
s.toolGetFindingTraffic(),
|
s.toolGetFindingTraffic(),
|
||||||
s.toolBindFindingTraffic(),
|
s.toolBindFindingTraffic(),
|
||||||
}
|
}, s.findingCaseTools()...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- schema helpers ---
|
// --- schema helpers ---
|
||||||
@@ -442,6 +442,15 @@ func (s *Server) toolUpdateFindingReport() actool.CoreTool {
|
|||||||
if nodeID <= 0 {
|
if nodeID <= 0 {
|
||||||
return actool.Errorf("finding_id 无效"), nil
|
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)
|
n, err := s.m.pg.SetFindingReportVersionByNodeID(ctx, nodeID, a.Report, a.EvidenceVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return actool.Errorf(err.Error()), nil
|
return actool.Errorf(err.Error()), nil
|
||||||
@@ -477,7 +486,7 @@ func (s *Server) seedOrchestrationTools() {
|
|||||||
for _, t := range s.orchestrationTools() {
|
for _, t := range s.orchestrationTools() {
|
||||||
schema, _ := json.Marshal(t.InputSchema())
|
schema, _ := json.Marshal(t.InputSchema())
|
||||||
bindings := autoAgents
|
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"]`)
|
bindings = json.RawMessage(`["reporter"]`)
|
||||||
}
|
}
|
||||||
_ = s.m.PG().SeedTool(t.Name(), t.Description(), schema, bindings)
|
_ = s.m.PG().SeedTool(t.Name(), t.Description(), schema, bindings)
|
||||||
@@ -503,6 +512,7 @@ func (s *Server) seedOrchestrationTools() {
|
|||||||
s.upgradeReporterTriggerMessage() // 老库补迁移:让 reporter 回传 evidence_version(一次性)
|
s.upgradeReporterTriggerMessage() // 老库补迁移:让 reporter 回传 evidence_version(一次性)
|
||||||
s.seedFindingTrafficTools() // 增加可选证据参数及只读证据工具,保留用户配置
|
s.seedFindingTrafficTools() // 增加可选证据参数及只读证据工具,保留用户配置
|
||||||
s.seedFindingWorkflowTools()
|
s.seedFindingWorkflowTools()
|
||||||
|
s.seedFindingCaseTools()
|
||||||
// 注:pentest 的默认工具绑定无需迁移——BuiltinToolSeeds 在全新初始化时就把
|
// 注:pentest 的默认工具绑定无需迁移——BuiltinToolSeeds 在全新初始化时就把
|
||||||
// list_assets/insert_assets/report_finding/list_findings/list_companies 连同
|
// list_assets/insert_assets/report_finding/list_findings/list_companies 连同
|
||||||
// pentest 一起 seed 好了(项目尚无旧库,不做迁移)。
|
// pentest 一起 seed 好了(项目尚无旧库,不做迁移)。
|
||||||
|
|||||||
@@ -131,6 +131,8 @@ type provEntry struct {
|
|||||||
// taskID + mergeable let the drainer coalesce several event triggers (finding/goal)
|
// 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).
|
// from the SAME task into one conversation before it starts (interval fires don't merge).
|
||||||
type triggeredRun struct {
|
type triggeredRun struct {
|
||||||
|
conversationID int64 // pre-created manual finding review; never coalesced
|
||||||
|
|
||||||
agentKey string
|
agentKey string
|
||||||
title string
|
title string
|
||||||
message string // 事件正文(触发语 + 工具/入参/返回等);不含任务描述/目标头
|
message string // 事件正文(触发语 + 工具/入参/返回等);不含任务描述/目标头
|
||||||
@@ -755,6 +757,16 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("GET /api/exploration/frontier", s.frontier)
|
mux.HandleFunc("GET /api/exploration/frontier", s.frontier)
|
||||||
mux.HandleFunc("GET /api/exploration/findings", s.findings)
|
mux.HandleFunc("GET /api/exploration/findings", s.findings)
|
||||||
mux.HandleFunc("GET /api/exploration/findings/groups", s.findingGroups)
|
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/asset-tree", s.findingAssetTree)
|
||||||
mux.HandleFunc("GET /api/exploration/findings/stats", s.findingStats)
|
mux.HandleFunc("GET /api/exploration/findings/stats", s.findingStats)
|
||||||
mux.HandleFunc("GET /api/exploration/findings/export", s.findingsExport)
|
mux.HandleFunc("GET /api/exploration/findings/export", s.findingsExport)
|
||||||
@@ -1040,6 +1052,7 @@ func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
list := s.m.List()
|
list := s.m.List()
|
||||||
metrics, _ := s.m.PG().TaskListMetricsAll()
|
metrics, _ := s.m.PG().TaskListMetricsAll()
|
||||||
|
distinct, _ := s.m.pg.DistinctFindingStatsByTask()
|
||||||
archiveBlockers, _ := s.m.PG().TaskArchiveBlockers()
|
archiveBlockers, _ := s.m.PG().TaskArchiveBlockers()
|
||||||
dtos := make([]TaskDTO, 0, len(list))
|
dtos := make([]TaskDTO, 0, len(list))
|
||||||
for _, t := range list {
|
for _, t := range list {
|
||||||
@@ -1056,6 +1069,7 @@ func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) {
|
|||||||
dto.GoalsTotal = metric.Goals.Total
|
dto.GoalsTotal = metric.Goals.Total
|
||||||
dto.GoalsMet = metric.Goals.Met
|
dto.GoalsMet = metric.Goals.Met
|
||||||
dto.InFlight = metric.RunningIntents
|
dto.InFlight = metric.RunningIntents
|
||||||
|
dto.DistinctFindings = distinct[t.ID]
|
||||||
dto.Findings = FindingSeverityDTO{
|
dto.Findings = FindingSeverityDTO{
|
||||||
Critical: metric.Findings.Critical,
|
Critical: metric.Findings.Critical,
|
||||||
High: metric.Findings.High,
|
High: metric.Findings.High,
|
||||||
@@ -2052,6 +2066,11 @@ func (s *Server) findingStats(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, 500, err.Error())
|
writeErr(w, 500, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
st.Distinct, err = s.m.pg.DistinctFindingStats("")
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
writeJSON(w, 200, st)
|
writeJSON(w, 200, st)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2074,6 +2093,14 @@ func (s *Server) getFinding(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
assets := s.resolveAssetIDs(f.AssetIDs)
|
assets := s.resolveAssetIDs(f.AssetIDs)
|
||||||
dto := findingFromDB(f, assets)
|
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 != "" {
|
if contextTaskID := strings.TrimSpace(r.URL.Query().Get("context_task")); contextTaskID != "" {
|
||||||
contextTask := s.m.ResolveTask(contextTaskID)
|
contextTask := s.m.ResolveTask(contextTaskID)
|
||||||
if contextTask == nil {
|
if contextTask == nil {
|
||||||
@@ -2138,6 +2165,14 @@ func (s *Server) findingsExport(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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-")
|
stage, err := os.MkdirTemp("", "artex-finding-export-")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, 500, err.Error())
|
writeErr(w, 500, err.Error())
|
||||||
@@ -2148,6 +2183,13 @@ func (s *Server) findingsExport(w http.ResponseWriter, r *http.Request) {
|
|||||||
evidenceError(w, err)
|
evidenceError(w, err)
|
||||||
return
|
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()
|
now := time.Now()
|
||||||
stamp := now.Format("20060102-150405")
|
stamp := now.Format("20060102-150405")
|
||||||
setDownload := func(contentType, filename string) {
|
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>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<ShieldAlertIcon className="size-4 text-muted-foreground" />
|
<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}>
|
<h1 className="max-w-md truncate text-sm font-semibold" title={title}>
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
@@ -172,7 +181,7 @@ function FindingDetailInner() {
|
|||||||
<p className="text-sm leading-relaxed whitespace-pre-wrap">{finding.summary || "(无摘要)"}</p>
|
<p className="text-sm leading-relaxed whitespace-pre-wrap">{finding.summary || "(无摘要)"}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<FindingRetestPanel key={id} findingId={id} readOnly={finding.inherited} onCompleted={load} />
|
<FindingRetestPanel key={`retest:${id}`} findingId={id} readOnly={finding.inherited} onCompleted={load} />
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-sm">证据 / PoC</CardTitle>
|
<CardTitle className="text-sm">证据 / PoC</CardTitle>
|
||||||
@@ -188,7 +197,7 @@ function FindingDetailInner() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<FindingTrafficPanel
|
<FindingTrafficPanel
|
||||||
key={id}
|
key={`traffic:${id}`}
|
||||||
findingId={id}
|
findingId={id}
|
||||||
contextTask={contextTaskId || undefined}
|
contextTask={contextTaskId || undefined}
|
||||||
readOnly={finding.inherited}
|
readOnly={finding.inherited}
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { FindingCaseList } from "@/components/finding-case-list";
|
||||||
import { FindingRetestDialog } from "@/components/finding-retest-dialog";
|
import { FindingRetestDialog } from "@/components/finding-retest-dialog";
|
||||||
import { StatusBadge } from "@/components/status-badge";
|
import { StatusBadge } from "@/components/status-badge";
|
||||||
import { TablePagination } from "@/components/table-pagination";
|
import { TablePagination } from "@/components/table-pagination";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -69,9 +71,9 @@ const FINDING_LIST_PREFERENCE_KEY = "artex_finding_list_preferences";
|
|||||||
|
|
||||||
// 列表视图:flat = 跨任务平铺大表(默认);grouped = 按任务分组折叠;
|
// 列表视图:flat = 跨任务平铺大表(默认);grouped = 按任务分组折叠;
|
||||||
// asset = 左侧资产树 + 右侧该子树下的发现。
|
// 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() {
|
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 [severity, setSeverity] = React.useState<"all" | Severity>("all");
|
||||||
const [status, setStatus] = React.useState<"all" | FindingStatus>("all");
|
const [status, setStatus] = React.useState<"all" | FindingStatus>("all");
|
||||||
const [vulnclass, setVulnclass] = React.useState<string>("all");
|
const [vulnclass, setVulnclass] = React.useState<string>("all");
|
||||||
@@ -252,6 +254,8 @@ export default function FindingsPage() {
|
|||||||
const [exportOpen, setExportOpen] = React.useState(false);
|
const [exportOpen, setExportOpen] = React.useState(false);
|
||||||
const [exportScope, setExportScope] = React.useState<"filtered" | "all" | "selected">("filtered");
|
const [exportScope, setExportScope] = React.useState<"filtered" | "all" | "selected">("filtered");
|
||||||
const [exportFormat, setExportFormat] = React.useState<"md-single" | "md-zip" | "csv" | "json">("md-single");
|
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 [exporting, setExporting] = React.useState(false);
|
||||||
|
|
||||||
const toggleSelected = React.useCallback((id: string, checked: boolean) => {
|
const toggleSelected = React.useCallback((id: string, checked: boolean) => {
|
||||||
@@ -288,6 +292,7 @@ export default function FindingsPage() {
|
|||||||
scope: exportScope,
|
scope: exportScope,
|
||||||
filters: { severity, status, vulnclass, task, query, sort },
|
filters: { severity, status, vulnclass, task, query, sort },
|
||||||
ids: [...selectedIds],
|
ids: [...selectedIds],
|
||||||
|
includeOriginals,
|
||||||
});
|
});
|
||||||
setExportOpen(false);
|
setExportOpen(false);
|
||||||
toast.success("已开始下载导出文件");
|
toast.success("已开始下载导出文件");
|
||||||
@@ -752,17 +757,18 @@ export default function FindingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const statCards = [
|
const statCards = [
|
||||||
{ label: "发现总数", value: stats.total, icon: BugIcon },
|
{ label: "独立漏洞", value: stats.distinct?.total ?? stats.total, icon: BugIcon },
|
||||||
{ label: "待处理", value: stats.pending, tone: "text-amber-500", icon: ClockIcon },
|
{ label: "上报记录", value: stats.total, tone: "text-amber-500", icon: ClockIcon },
|
||||||
{ label: "严重", value: stats.critical, tone: "text-rose-600", icon: ShieldAlertIcon },
|
{ label: "严重", value: stats.distinct?.critical ?? stats.critical, tone: "text-rose-600", icon: ShieldAlertIcon },
|
||||||
{ label: "高危", value: stats.high, tone: "text-red-500", icon: TriangleAlertIcon },
|
{ label: "高危", value: stats.distinct?.high ?? stats.high, tone: "text-red-500", icon: TriangleAlertIcon },
|
||||||
{ label: "中危", value: stats.medium, tone: "text-amber-500", icon: TriangleAlertIcon },
|
{ label: "中危", value: stats.distinct?.medium ?? stats.medium, tone: "text-amber-500", icon: TriangleAlertIcon },
|
||||||
{ label: "低危", value: stats.low, tone: "text-slate-500", icon: InfoIcon },
|
{ label: "低危", value: stats.distinct?.low ?? stats.low, tone: "text-slate-500", icon: InfoIcon },
|
||||||
];
|
];
|
||||||
|
|
||||||
// 导出弹窗里「当前筛选」的条数:两个视图的筛选一致,只是统计口径来源不同。
|
// 导出弹窗里「当前筛选」的条数:两个视图的筛选一致,只是统计口径来源不同。
|
||||||
// 平铺与资产视图共用 flat 列表状态,分组视图的口径来自组接口的 finding_total。
|
// 平铺与资产视图共用 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(
|
const assetPath = React.useMemo(
|
||||||
() => (view === "asset" ? assetPathOf(assetTree.nodes, assetScope) : []),
|
() => (view === "asset" ? assetPathOf(assetTree.nodes, assetScope) : []),
|
||||||
[assetScope, assetTree.nodes, view],
|
[assetScope, assetTree.nodes, view],
|
||||||
@@ -823,7 +829,8 @@ export default function FindingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<Tabs value={view} onValueChange={(v) => setView(v as FindingView)}>
|
<Tabs value={view} onValueChange={(v) => setView(v as FindingView)}>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="flat">全部发现</TabsTrigger>
|
<TabsTrigger value="cases">按漏洞</TabsTrigger>
|
||||||
|
<TabsTrigger value="flat">原始上报</TabsTrigger>
|
||||||
<TabsTrigger value="grouped">按任务分组</TabsTrigger>
|
<TabsTrigger value="grouped">按任务分组</TabsTrigger>
|
||||||
<TabsTrigger value="asset">按资产</TabsTrigger>
|
<TabsTrigger value="asset">按资产</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
@@ -953,6 +960,24 @@ export default function FindingsPage() {
|
|||||||
{selectedIds.size > 0 && (
|
{selectedIds.size > 0 && (
|
||||||
<span className="text-xs text-muted-foreground tabular-nums">已选 {selectedIds.size} 条</span>
|
<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}>
|
<Button size="sm" variant="outline" onClick={openExport}>
|
||||||
<DownloadIcon /> 导出
|
<DownloadIcon /> 导出
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1015,6 +1040,14 @@ export default function FindingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{view === "cases" && (
|
||||||
|
<FindingCaseList
|
||||||
|
onTotal={setCaseTotal}
|
||||||
|
query={{ severity, status, vulnclass, task, query, sort }}
|
||||||
|
selectedIds={selectedIds}
|
||||||
|
onSelect={toggleSelected}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{view === "grouped" && (
|
{view === "grouped" && (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{groups.map((group) => {
|
{groups.map((group) => {
|
||||||
@@ -1231,6 +1264,16 @@ export default function FindingsPage() {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-xs text-muted-foreground">导出格式</span>
|
<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)}>
|
<RadioGroup value={exportFormat} onValueChange={(v) => setExportFormat(v as typeof exportFormat)}>
|
||||||
<label htmlFor="export-format-md-single" className="flex items-center gap-2 text-sm">
|
<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 文件)
|
<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 { ArrowDownIcon, ArrowUpIcon, ArrowUpRightIcon, ChevronRightIcon } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { FindingCaseList } from "@/components/finding-case-list";
|
||||||
import { StatusBadge } from "@/components/status-badge";
|
import { StatusBadge } from "@/components/status-badge";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
@@ -188,36 +189,55 @@ export function FindingsTab({ taskId }: { taskId: string }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="overflow-hidden py-0">
|
<div className="flex flex-col gap-4">
|
||||||
<CardContent className="px-0">
|
<FindingCaseList query={{ task: taskId, sort: "time" }} />
|
||||||
<div className="flex items-center border-b px-4 py-2 text-xs text-muted-foreground">
|
{[
|
||||||
<span className="min-w-0 flex-1">漏洞</span>
|
...new Set(
|
||||||
<button
|
items
|
||||||
type="button"
|
.filter((f) => f.inherited)
|
||||||
className="inline-flex items-center gap-1 outline-none focus-visible:underline"
|
.map((f) => f.source_task_id)
|
||||||
aria-label={`发现时间当前${sortPreference.direction === "asc" ? "正序" : "倒序"},点击切换排序方向`}
|
.filter((id): id is string => !!id),
|
||||||
onClick={() =>
|
),
|
||||||
setSortPreference((current) => ({
|
].map((source) => (
|
||||||
field: "time",
|
<div key={source}>
|
||||||
direction: current.direction === "asc" ? "desc" : "asc",
|
<p className="mb-2 text-sm text-muted-foreground">继承任务 #{source} · 只读</p>
|
||||||
}))
|
<FindingCaseList query={{ task: source, sort: "time" }} contextTask={taskId} readOnly />
|
||||||
}
|
|
||||||
>
|
|
||||||
<span>发现时间</span>
|
|
||||||
{sortPreference.direction === "asc" ? (
|
|
||||||
<ArrowUpIcon className="size-3.5" />
|
|
||||||
) : (
|
|
||||||
<ArrowDownIcon className="size-3.5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{items.map((f) => (
|
))}
|
||||||
<Row key={f.id} f={f} contextTaskId={taskId} onStatus={onStatus} />
|
<details>
|
||||||
))}
|
<summary className="cursor-pointer text-sm text-muted-foreground">查看原始上报列表</summary>
|
||||||
{items.length === 0 && (
|
<Card className="overflow-hidden py-0">
|
||||||
<p className="px-4 py-8 text-center text-sm text-muted-foreground">本任务及直接关联任务暂无确认发现。</p>
|
<CardContent className="px-0">
|
||||||
)}
|
<div className="flex items-center border-b px-4 py-2 text-xs text-muted-foreground">
|
||||||
</CardContent>
|
<span className="min-w-0 flex-1">漏洞</span>
|
||||||
</Card>
|
<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}
|
onSort={sortTasksBy}
|
||||||
/>
|
/>
|
||||||
<TableHead className="text-center">目标进度</TableHead>
|
<TableHead className="text-center">目标进度</TableHead>
|
||||||
<TableHead className="text-center" title="严重 / 高 / 中 / 低">
|
<TableHead className="text-center" title="独立漏洞:严重 / 高 / 中 / 低">
|
||||||
漏洞 <span className="text-muted-foreground font-normal">严/高/中/低</span>
|
漏洞 <span className="text-muted-foreground font-normal">严/高/中/低</span>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-center">运行中 Worker</TableHead>
|
<TableHead className="text-center">运行中 Worker</TableHead>
|
||||||
@@ -1220,14 +1220,16 @@ const TaskRow = React.memo(function TaskRow({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center text-xs tabular-nums">
|
<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;
|
const total = f ? f.critical + f.high + f.medium + f.low : 0;
|
||||||
if (!f || total === 0) return <span className="text-muted-foreground">0</span>;
|
if (!f || (total === 0 && !task.distinct_findings?.total))
|
||||||
const seg = (n: number, cls: string) => (
|
return <span className="text-muted-foreground">0</span>;
|
||||||
<span className={n > 0 ? cls : "text-muted-foreground"}>{n}</span>
|
const seg = (n: number, cls: string) => <span className={n > 0 ? cls : "text-muted-foreground"}>{n}</span>;
|
||||||
);
|
|
||||||
return (
|
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")}
|
{seg(f.critical, "text-rose-600 dark:text-rose-400")}
|
||||||
<span className="text-muted-foreground">/</span>
|
<span className="text-muted-foreground">/</span>
|
||||||
{seg(f.high, "text-red-600 dark:text-red-400")}
|
{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")}
|
{seg(f.medium, "text-amber-600 dark:text-amber-400")}
|
||||||
<span className="text-muted-foreground">/</span>
|
<span className="text-muted-foreground">/</span>
|
||||||
{seg(f.low, "text-slate-600 dark:text-slate-400")}
|
{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>
|
</span>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme inline {
|
@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-sm: calc(var(--radius) - 4px);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
@@ -55,6 +59,10 @@
|
|||||||
/* Default theme styles (used when no data-theme-preset is set or when 'default' is selected).
|
/* 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. */
|
These serve as the fallback; there is no separate default.css file. */
|
||||||
:root {
|
:root {
|
||||||
|
--severity-critical: #e11d48;
|
||||||
|
--severity-high: #dc2626;
|
||||||
|
--severity-medium: #d97706;
|
||||||
|
--severity-low: #475569;
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
--card-foreground: oklch(0.145 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 {
|
.dark {
|
||||||
|
--severity-critical: #fb7185;
|
||||||
|
--severity-high: #f87171;
|
||||||
|
--severity-medium: #fbbf24;
|
||||||
|
--severity-low: #94a3b8;
|
||||||
--background: oklch(0.145 0 0);
|
--background: oklch(0.145 0 0);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.205 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,
|
ExplorationNodeQuery,
|
||||||
Finding,
|
Finding,
|
||||||
FindingAssetTree,
|
FindingAssetTree,
|
||||||
|
FindingCaseDetail,
|
||||||
|
FindingCasePage,
|
||||||
|
FindingCaseReviewRun,
|
||||||
|
FindingCaseSuggestion,
|
||||||
FindingDeepenResponse,
|
FindingDeepenResponse,
|
||||||
FindingGroupsPage,
|
FindingGroupsPage,
|
||||||
FindingQuery,
|
FindingQuery,
|
||||||
@@ -555,6 +559,42 @@ export const api = {
|
|||||||
// 节点带子树聚合计数。不分页——树是导航结构,一次取完。
|
// 节点带子树聚合计数。不分页——树是导航结构,一次取完。
|
||||||
findingAssetTree: (q: Omit<FindingQuery, "page" | "pageSize">) =>
|
findingAssetTree: (q: Omit<FindingQuery, "page" | "pageSize">) =>
|
||||||
get<FindingAssetTree>(`/exploration/findings/asset-tree?${findingFilterParams(q).toString()}`),
|
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"),
|
findingStats: () => get<FindingStats>("/exploration/findings/stats"),
|
||||||
// exportFindings 触发发现页导出并下载文件。scope=selected 时传 ids(finding_id 列表);
|
// exportFindings 触发发现页导出并下载文件。scope=selected 时传 ids(finding_id 列表);
|
||||||
// scope=filtered 时传当前筛选(沿用 FindingQuery 的筛选字段);scope=all 忽略筛选。
|
// scope=filtered 时传当前筛选(沿用 FindingQuery 的筛选字段);scope=all 忽略筛选。
|
||||||
@@ -563,8 +603,12 @@ export const api = {
|
|||||||
scope: "filtered" | "all" | "selected";
|
scope: "filtered" | "all" | "selected";
|
||||||
filters?: Omit<FindingQuery, "page" | "pageSize">;
|
filters?: Omit<FindingQuery, "page" | "pageSize">;
|
||||||
ids?: string[];
|
ids?: string[];
|
||||||
|
includeOriginals?: boolean;
|
||||||
|
consolidated?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const p = new URLSearchParams({ format: opts.format, scope: opts.scope });
|
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") {
|
if (opts.scope === "selected") {
|
||||||
p.set("ids", (opts.ids ?? []).join(","));
|
p.set("ids", (opts.ids ?? []).join(","));
|
||||||
} else if (opts.scope === "filtered" && opts.filters) {
|
} else if (opts.scope === "filtered" && opts.filters) {
|
||||||
@@ -825,8 +869,7 @@ export const api = {
|
|||||||
// 渠道是多实例资源(同一类型可配多个机器人、各有过滤规则),因此独立成组,
|
// 渠道是多实例资源(同一类型可配多个机器人、各有过滤规则),因此独立成组,
|
||||||
// 不塞进扁平的 settings 键值里。
|
// 不塞进扁平的 settings 键值里。
|
||||||
notifyMeta: () => get<NotificationMeta>(`/notify/meta`),
|
notifyMeta: () => get<NotificationMeta>(`/notify/meta`),
|
||||||
notifyChannels: () =>
|
notifyChannels: () => get<{ channels: NotificationChannel[] }>(`/notify/channels`).then((r) => arr(r.channels)),
|
||||||
get<{ channels: NotificationChannel[] }>(`/notify/channels`).then((r) => arr(r.channels)),
|
|
||||||
notifyCreateChannel: (payload: {
|
notifyCreateChannel: (payload: {
|
||||||
name: string;
|
name: string;
|
||||||
kind: 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 };
|
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 ──
|
// ── exploration ──
|
||||||
if (path === "/exploration/frontier") return D.frontier;
|
if (path === "/exploration/frontier") return D.frontier;
|
||||||
if (path === "/exploration/findings/stats") {
|
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 type EngineMode = "exploring" | "paused" | "stalled" | "idle";
|
||||||
|
|
||||||
export interface Task {
|
export interface Task {
|
||||||
|
distinct_findings?: FindingDistinctStats;
|
||||||
id: string;
|
id: string;
|
||||||
name?: string; // 可选任务名称;空/缺省=未命名,展示时回退到描述
|
name?: string; // 可选任务名称;空/缺省=未命名,展示时回退到描述
|
||||||
category_id?: number;
|
category_id?: number;
|
||||||
@@ -489,6 +490,7 @@ export interface FindingAsset {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Finding {
|
export interface Finding {
|
||||||
|
case_id?: string;
|
||||||
traffic_count?: number;
|
traffic_count?: number;
|
||||||
evidence_version?: number;
|
evidence_version?: number;
|
||||||
report_evidence_version?: number;
|
report_evidence_version?: number;
|
||||||
@@ -550,6 +552,7 @@ export interface FindingDeepenResponse {
|
|||||||
|
|
||||||
// FindingStats 是发现全表聚合(统计卡 + 漏洞类型下拉),服务端计算,不受分页影响。
|
// FindingStats 是发现全表聚合(统计卡 + 漏洞类型下拉),服务端计算,不受分页影响。
|
||||||
export interface FindingStats {
|
export interface FindingStats {
|
||||||
|
distinct?: FindingDistinctStats;
|
||||||
total: number;
|
total: number;
|
||||||
pending: number;
|
pending: number;
|
||||||
critical: number;
|
critical: number;
|
||||||
@@ -1610,3 +1613,70 @@ export interface InterceptExecution {
|
|||||||
seq: number;
|
seq: number;
|
||||||
items: Activity[];
|
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