feat(findings): 发现页支持导出(Markdown 单文件/分文件zip、CSV、JSON,按筛选/全部/勾选)
- 后端新增 GET /exploration/findings/export:按 scope(filtered|all|selected) × format(md-single|md-zip|csv|json) 导出,md-zip 用 archive/zip 后端打包 - db.ListFindingsForExport:带完整 report、不分页,按严重等级+时间排序 - report/findings.go:汇总/单条 Markdown、安全文件名、带 BOM 的 CSV 渲染 - 前端发现页加行勾选 + 导出弹窗(范围×格式);复用 lib/utils.copyText - 顺带修复报告复制在 HTTP 非安全上下文失效(execCommand 降级)+ 复用 CopyButton Claude-Session: https://claude.ai/code/session_01Bs2rrsVHUBTxRusm9espwB
This commit is contained in:
1 parent
ed96f256a4
commit
43f40dd74f
9 files changed
+658
-37
No files matched your search
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
@@ -677,6 +678,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/exploration/frontier", s.frontier)
|
||||
mux.HandleFunc("GET /api/exploration/findings", s.findings)
|
||||
mux.HandleFunc("GET /api/exploration/findings/stats", s.findingStats)
|
||||
mux.HandleFunc("GET /api/exploration/findings/export", s.findingsExport)
|
||||
mux.HandleFunc("GET /api/exploration/findings/{id}", s.getFinding)
|
||||
mux.HandleFunc("GET /api/exploration/findings/{id}/lineage", s.findingLineage)
|
||||
mux.HandleFunc("PATCH /api/exploration/findings/{id}", s.patchFinding)
|
||||
@@ -1895,6 +1897,110 @@ func (s *Server) getFinding(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, dto)
|
||||
}
|
||||
|
||||
// findingsExport 导出发现页的漏洞。
|
||||
//
|
||||
// scope = filtered(沿用页面筛选)| all(全部)| selected(勾选的 ids)
|
||||
// format = md-single(整合一份 .md)| md-zip(一漏洞一 .md,打包 zip)
|
||||
// | csv | json
|
||||
// ids = 逗号分隔的 finding id(scope=selected 时必填)
|
||||
// 筛选参数 severity/status/vulnclass/task_id/sort 与列表接口一致(scope=filtered 用)。
|
||||
func (s *Server) findingsExport(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
scope := q.Get("scope")
|
||||
format := q.Get("format")
|
||||
|
||||
var ids []int64
|
||||
var filter db.FindingFilter
|
||||
switch scope {
|
||||
case "selected":
|
||||
for _, part := range strings.Split(q.Get("ids"), ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
id, err := strconv.ParseInt(part, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
writeErr(w, 400, "bad finding id: "+part)
|
||||
return
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
writeErr(w, 400, "no findings selected")
|
||||
return
|
||||
}
|
||||
case "all":
|
||||
// 空 filter = 不加任何条件。
|
||||
case "filtered", "":
|
||||
filter = db.FindingFilter{
|
||||
Severity: normFilter(q.Get("severity")),
|
||||
Status: normFilter(q.Get("status")),
|
||||
VulnClass: normFilter(q.Get("vulnclass")),
|
||||
TaskID: normFilter(q.Get("task_id")),
|
||||
Sort: q.Get("sort"),
|
||||
}
|
||||
default:
|
||||
writeErr(w, 400, "bad scope: "+scope)
|
||||
return
|
||||
}
|
||||
|
||||
fs, err := s.m.pg.ListFindingsForExport(filter, ids)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
stamp := now.Format("20060102-150405")
|
||||
setDownload := func(contentType, filename string) {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
}
|
||||
|
||||
switch format {
|
||||
case "md-single":
|
||||
setDownload("text/markdown; charset=utf-8", "findings-"+stamp+".md")
|
||||
_, _ = w.Write([]byte(report.FindingsMarkdown(fs, now)))
|
||||
case "md-zip":
|
||||
setDownload("application/zip", "findings-"+stamp+".zip")
|
||||
zw := zip.NewWriter(w)
|
||||
used := map[string]int{}
|
||||
for _, f := range fs {
|
||||
base := report.FindingFilename(f)
|
||||
name := base
|
||||
// 去重:同名文件追加 -2、-3……
|
||||
if n := used[base]; n > 0 {
|
||||
name = fmt.Sprintf("%s-%d.md", strings.TrimSuffix(base, ".md"), n+1)
|
||||
}
|
||||
used[base]++
|
||||
fw, werr := zw.Create(name)
|
||||
if werr != nil {
|
||||
log.Printf("[findings-export] zip create %s: %v", name, werr)
|
||||
continue
|
||||
}
|
||||
_, _ = fw.Write([]byte(report.SingleFindingMarkdown(f, now)))
|
||||
}
|
||||
if cerr := zw.Close(); cerr != nil {
|
||||
log.Printf("[findings-export] zip close: %v", cerr)
|
||||
}
|
||||
case "csv":
|
||||
setDownload("text/csv; charset=utf-8", "findings-"+stamp+".csv")
|
||||
_, _ = w.Write(report.FindingsCSV(fs))
|
||||
case "json":
|
||||
assets := s.resolveFindingAssets(fs)
|
||||
out := make([]FindingDTO, 0, len(fs))
|
||||
for _, f := range fs {
|
||||
out = append(out, findingFromDB(f, assets))
|
||||
}
|
||||
setDownload("application/json; charset=utf-8", "findings-"+stamp+".json")
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(out)
|
||||
default:
|
||||
writeErr(w, 400, "bad format: "+format)
|
||||
}
|
||||
}
|
||||
|
||||
func findingProvenanceInTask(contextTask *Task, findingTaskID *int64) (sourceTaskID string, inherited, allowed bool) {
|
||||
if contextTask == nil || findingTaskID == nil {
|
||||
return "", false, false
|
||||
|
||||
Reference in new issue
Block a user