feat: improve finding reports and evidence exports

This commit is contained in:
ji ruo committed 2026-10-29 01:25:32 +08:00
1 parent fb672389b0
commit b0a29236f8
14 files changed
+485 -45

No files matched your search

+1 -1
View File
@@ -102,7 +102,7 @@ const ReporterDefaultPrompt = `你是一个授权渗透测试系统里的**漏
━━ 报告步骤截图(仅在系统设置开启时)━━
- 当系统设置开启报告步骤截图时,你会自动获得 browser MCP;此时对每个复现步骤中真实出现且仍可访问的 HTTP 页面,使用 browser_navigate 后调用 browser_take_screenshot。
- 只访问漏洞证据中出现的 URL,不扩大测试范围。把截图保存到当前工作目录的 findings/screenshots/ 下,命名 finding-<finding_id>-step-<n>.png;确认文件存在后,在对应步骤中加入 Markdown 图片链接:![步骤 <n> 截图](workspace://<截图绝对路径>)。
- 只访问漏洞证据中出现的 URL,不扩大测试范围。把截图保存到当前工作目录的 findings/screenshots/ 下,命名 finding-<finding_id>-step-<n>.png;确认文件存在后,在对应步骤中**统一使用工作区相对路径**加入 Markdown 图片链接:![步骤 <n> 截图](findings/screenshots/finding-<finding_id>-step-<n>.png)。禁止使用绝对路径、workspace://、file:// 或把相对路径和绝对路径混用;报告中所有截图都必须遵守这一种格式。
- 截图失败时如实说明;系统设置关闭或 browser MCP 不可用时,不要伪造图片链接,也不要把文字描述冒充截图。
━━ 报告结构(Markdown,按需裁剪,但证据/复现/修复必须有)━━
+15
View File
@@ -704,6 +704,13 @@ func (s *Server) StartTriggeredRun(agentKey, title, message string, taskID int64
if s.m.pg == nil || s.chatAgentRef() == nil {
return
}
// A trigger body can outlive a prompt/trigger migration when it was queued
// before a restart. Never let the retired structured-reproduction contract
// enter a new reporter conversation; keep the tool event context after the
// marker so the report still has the original finding payload.
if agentKey == "reporter" && strings.Contains(message, "update_finding_reproduction") {
message = normalizeReporterTriggerMessage(message)
}
cfg := s.readTriggerBehavior(agentKey) // DB read BEFORE the lock (never under queueMu)
s.queueMu.Lock()
s.triggerCfg[agentKey] = cfg
@@ -712,6 +719,14 @@ func (s *Server) StartTriggeredRun(agentKey, title, message string, taskID int64
s.queueMu.Unlock()
}
func normalizeReporterTriggerMessage(message string) string {
const marker = "【本次由工具调用触发】"
if i := strings.Index(message, marker); i >= 0 {
return reporterToolCallMessage + message[i:]
}
return reporterToolCallMessage
}
// pumpLocked launches queued fires for one agent up to its concurrency limit, then
// returns. Serial → limit 1; parallel → limit = maxParallel (<=0 → unlimited). Each
// launched run re-pumps on completion (runAndPump) to fill the freed slot. Caller
+124 -6
View File
@@ -7,6 +7,8 @@ import (
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"time"
@@ -15,16 +17,25 @@ import (
"github.com/Autumn-27/artex/report"
)
func writeFindingsEvidenceZip(out io.Writer, findings []*db.DBFinding, stage string, now time.Time) error {
type exportedScreenshot struct {
ReportPath string `json:"report_path"`
ArchivePath string `json:"archive_path"`
SourcePath string `json:"-"`
CapturedAt string `json:"captured_at"`
}
var screenshotMarkdownLink = regexp.MustCompile(`!\[([^\]]*)\]\(([^)\s]+)\)`)
func writeFindingsEvidenceZip(out io.Writer, findings []*db.DBFinding, stage string, now time.Time, workspaceDir string) error {
zw := zip.NewWriter(out)
if err := writeFindingsZipEntries(zw, findings, stage, now); err != nil {
if err := writeFindingsZipEntries(zw, findings, stage, now, workspaceDir); err != nil {
zw.Close()
return err
}
return zw.Close()
}
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, workspaceDir string) error {
store := evidence.New(nil, nil, stage) // private export copy; not subject to GC
used := map[string]int{}
evidencePaths := map[string]bool{}
@@ -35,13 +46,32 @@ func writeFindingsZipEntries(zw *zip.Writer, findings []*db.DBFinding, stage str
name = fmt.Sprintf("%s-%d.md", strings.TrimSuffix(base, ".md"), n+1)
}
used[base]++
reportText, screenshots, err := prepareFindingExportReport(report.SingleFindingMarkdown(f, now), f.ID, workspaceDir)
if err != nil {
return err
}
w, err := zw.Create(name)
if err != nil {
return err
}
if _, err = io.WriteString(w, report.SingleFindingMarkdown(f, now)); err != nil {
if _, err = io.WriteString(w, reportText); err != nil {
return err
}
for _, screenshot := range screenshots {
if err := copyZipFile(zw, screenshot.ArchivePath, screenshot.SourcePath); err != nil {
return err
}
}
if len(screenshots) > 0 {
manifestPath := fmt.Sprintf("screenshots/%d/manifest.json", f.ID)
w, err := zw.Create(manifestPath)
if err != nil {
return err
}
if err := json.NewEncoder(w).Encode(map[string]any{"finding_id": f.ID, "screenshots": screenshots}); err != nil {
return err
}
}
for _, b := range f.TrafficBindings {
prefix := fmt.Sprintf("evidence/%d/%d/", f.ID, b.ID)
if evidencePaths[prefix] {
@@ -94,12 +124,12 @@ func writeFindingsZipEntries(zw *zip.Writer, findings []*db.DBFinding, stage str
return nil
}
func buildFindingsEvidenceZip(path string, findings []*db.DBFinding, stage string, now time.Time) error {
func buildFindingsEvidenceZip(path string, findings []*db.DBFinding, stage string, now time.Time, workspaceDir string) error {
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return err
}
writeErr := writeFindingsEvidenceZip(f, findings, stage, now)
writeErr := writeFindingsEvidenceZip(f, findings, stage, now, workspaceDir)
syncErr := f.Sync()
closeErr := f.Close()
if err := errors.Join(writeErr, syncErr, closeErr); err != nil {
@@ -124,3 +154,91 @@ func buildFindingsEvidenceZip(path string, findings []*db.DBFinding, stage strin
}
return nil
}
func copyZipFile(zw *zip.Writer, archivePath, sourcePath string) error {
in, err := os.Open(sourcePath)
if err != nil {
return err
}
defer in.Close()
out, err := zw.Create(archivePath)
if err != nil {
return err
}
_, err = io.Copy(out, in)
return err
}
func prepareFindingExportReport(markdown string, findingID int64, workspaceDir string) (string, []exportedScreenshot, error) {
var out strings.Builder
var screenshots []exportedScreenshot
seen := map[string]bool{}
last := 0
for _, match := range screenshotMarkdownLink.FindAllStringSubmatchIndex(markdown, -1) {
out.WriteString(markdown[last:match[0]])
raw := markdown[match[4]:match[5]]
source, ok := resolveExportScreenshot(workspaceDir, raw)
if !ok {
out.WriteString(markdown[match[0]:match[1]])
last = match[1]
continue
}
archivePath := filepath.ToSlash(fmt.Sprintf("screenshots/%d/%s", findingID, filepath.Base(source)))
if !seen[archivePath] {
info, statErr := os.Stat(source)
if statErr != nil {
return "", nil, statErr
}
screenshots = append(screenshots, exportedScreenshot{ReportPath: raw, ArchivePath: archivePath, SourcePath: source, CapturedAt: info.ModTime().Format("2006-01-02 15:04:05")})
seen[archivePath] = true
}
// Preserve the original alt text and only replace the image URL.
out.WriteString(markdown[match[0]:match[4]])
out.WriteString(archivePath)
out.WriteString(markdown[match[5]:match[1]])
last = match[1]
}
out.WriteString(markdown[last:])
return out.String(), screenshots, nil
}
func resolveExportScreenshot(workspaceDir, raw string) (string, bool) {
value := strings.TrimSpace(raw)
value = strings.TrimPrefix(value, "workspace://")
value = strings.TrimPrefix(value, "file://")
if value == "" {
return "", false
}
if filepath.IsAbs(value) {
candidate := filepath.Clean(value)
base := filepath.Clean(workspaceDir)
if candidate != base && !strings.HasPrefix(candidate, base+string(os.PathSeparator)) {
return "", false
}
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate, true
}
return "", false
}
rel := filepath.Clean(strings.TrimPrefix(value, "/"))
const prefix = "findings" + string(os.PathSeparator) + "screenshots" + string(os.PathSeparator)
if rel == "." || !strings.HasPrefix(rel, prefix) || strings.Contains(rel, ".."+string(os.PathSeparator)) {
return "", false
}
matches, err := filepath.Glob(filepath.Join(filepath.Clean(workspaceDir), "sessions", "*", rel))
if err != nil || len(matches) == 0 {
return "", false
}
var newest string
var newestTime int64
for _, candidate := range matches {
info, statErr := os.Stat(candidate)
if statErr != nil || info.IsDir() {
continue
}
if newest == "" || info.ModTime().UnixNano() > newestTime {
newest, newestTime = candidate, info.ModTime().UnixNano()
}
}
return newest, newest != ""
}
+34 -1
View File
@@ -265,6 +265,7 @@ type Manager struct {
const (
settingTrafficCapture = "traffic_capture"
settingBrowserScreenshot = "browser_screenshot_enabled"
settingScreenshotTimestamp = "browser_screenshot_timestamp_enabled"
settingAgentTrafficBinding = "agent_traffic_binding"
settingWebSearchOn = "web_search_enabled"
settingWebSearchBackend = "web_search_backend"
@@ -835,8 +836,13 @@ func (m *Manager) syncBrowserMCPProxy() {
proxy := m.ProxyAddr() // "" when capture off
cert := m.ProxyCACert() // "" when capture off
args := stripProxyArgs(decodeStrSlice(srv.Args))
args := ensureBrowserMCPArgs(stripProxyArgs(decodeStrSlice(srv.Args)), m.pg.GetBool(settingBrowserScreenshot, false))
env := decodeStrMap(srv.Env)
if m.pg.GetBool(settingBrowserScreenshot, false) {
// A persisted screenshot opt-in must survive restart even if an older
// database had the seeded MCP disabled.
srv.Enabled = true
}
delete(env, "NODE_EXTRA_CA_CERTS")
if proxy != "" {
args = append(args, "--proxy-server", proxy)
@@ -857,6 +863,33 @@ func (m *Manager) syncBrowserMCPProxy() {
}
}
// ensureBrowserMCPArgs keeps the built-in Playwright MCP runnable in the
// application container. The production image intentionally runs the service
// as root; Chromium refuses to start in that mode unless --no-sandbox is
// passed. Keep this migration idempotent so an existing database is repaired
// as soon as the browser MCP is reconciled (including after a restart).
func ensureBrowserMCPArgs(args []string, ignoreHTTPSErrors bool) []string {
out := append([]string(nil), args...)
has := func(want string) bool {
for _, arg := range out {
if arg == want || strings.HasPrefix(arg, want+"=") {
return true
}
}
return false
}
if !has("--headless") {
out = append(out, "--headless")
}
if !has("--no-sandbox") {
out = append(out, "--no-sandbox")
}
if ignoreHTTPSErrors && !has("--ignore-https-errors") {
out = append(out, "--ignore-https-errors")
}
return out
}
// stripProxyArgs removes any --proxy-server/--proxy-bypass flags (both "--flag val"
// and "--flag=val" forms) so they can be re-added cleanly from current state,
// without mutating the input slice.
+64 -1
View File
@@ -534,7 +534,15 @@ func (s *Server) seedOrchestrationTools() {
s.reseedWorkerPrompt() // worker 提示词:加否定结论证据门槛(一次性)
s.seedReporterAgent() // 预置「报告撰写」agent + 工具绑定 + finding 触发器(一次性)
s.upgradeReporterTriggerMessage() // 老库补迁移:让 reporter 回传 evidence_version(一次性)
s.seedFindingTrafficTools() // 增加可选证据参数及只读证据工具,保留用户配置
s.migrateReporterWorkflow() // 修复旧库残留的 reproduction 工具/提示词绑定
if s.m.pg.GetBool(settingBrowserScreenshot, false) {
// Re-apply the opt-in after startup seeding so an existing database gets
// both reporter visibility and the repaired Playwright launch arguments.
if err := s.setReporterBrowserScreenshotVisibility(true); err != nil {
log.Printf("[reporter] 截图 MCP 启动配置同步失败: %v", err)
}
}
s.seedFindingTrafficTools() // 增加可选证据参数及只读证据工具,保留用户配置
s.seedFindingWorkflowTools()
s.seedFindingCaseTools()
// 注:pentest 的默认工具绑定无需迁移——BuiltinToolSeeds 在全新初始化时就把
@@ -542,6 +550,61 @@ func (s *Server) seedOrchestrationTools() {
// pentest 一起 seed 好了(项目尚无旧库,不做迁移)。
}
// migrateReporterWorkflow repairs databases created by the short-lived
// structured-reproduction reporter. That workflow referenced
// update_finding_reproduction, which is no longer a runtime tool; leaving the
// old prompt/trigger in place makes a newly reported vulnerability fail after
// all evidence has already been collected. User-authored reporter prompts are
// preserved unless they contain the removed tool contract.
func (s *Server) migrateReporterWorkflow() {
const flag = "reporter_workflow_markdown_v2"
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
return
}
defer func() { _ = s.m.pg.SetSetting(flag, "true") }()
reporter, err := s.m.pg.GetAgentByKey("reporter")
if err != nil || reporter == nil {
return
}
if current, err := s.m.pg.CurrentPrompt(reporter.ID); err == nil &&
(strings.Contains(current, "update_finding_reproduction") || strings.Contains(current, "漏洞步骤编写 agent")) {
if _, err := s.m.pg.ResetPromptToDefault(reporter.ID, agent.ReporterDefaultPrompt); err != nil {
log.Printf("[reporter] 旧版提示词迁移失败: %v", err)
} else {
log.Printf("[reporter] 已将旧版复现步骤提示词迁移为 Markdown 报告流程")
}
}
// 只补齐当前报告流程需要的绑定,不覆盖管理员已有的其它配置。
_ = s.m.pg.AddAgentToToolBinding("reporter", []string{
"update_finding_report", "get_finding_traffic", "bind_finding_traffic",
"get_task_node_detail", "list_task_findings", "get_task_worker_trace",
"list_task_worker_traces", "search_task_worker_traces", "get_task_graph",
})
// 旧行保留在工具目录里供历史审计,但不能继续被运行时广告或调用。
if _, err := s.m.pg.Exec(`UPDATE tools SET enabled=false, updated_at=now() WHERE key='update_finding_reproduction' AND system`); err != nil {
log.Printf("[reporter] 旧 reproduction 工具停用失败: %v", err)
}
triggers, err := s.m.pg.ListTriggersFor("reporter")
if err != nil {
log.Printf("[reporter] 读取触发器失败: %v", err)
return
}
for _, t := range triggers {
if !t.OnToolCall || !strings.Contains(t.ToolCallMessage, "update_finding_reproduction") {
continue
}
t.ToolCallMessage = reporterToolCallMessage
if err := s.m.pg.UpdateTrigger(t); err != nil {
log.Printf("[reporter] 旧触发器迁移失败: %v", err)
continue
}
log.Printf("[reporter] 已将旧 reproduction 触发器迁移为报告保存流程")
}
}
// refreshBuiltinToolSchemas propagates code schema/description changes on the
// orchestration + platform tools into already-seeded rows ONCE per version flag —
// SeedTool is first-insert-only, so a new param (e.g. spawn_task 的 llm_profile) never
+46 -23
View File
@@ -203,7 +203,7 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
t += `
【报告步骤截图已开启】
报告撰写时,针对每个复现步骤中真实出现且仍可访问的 HTTP 页面,使用 browser MCP 的 browser_navigate 和 browser_take_screenshot 截取页面证据。只导航到漏洞证据中出现的目标 URL,不要自行扩大测试范围。将截图保存到当前工作目录下的 findings/screenshots/ 目录,文件名使用 finding-<finding_id>-step-<n>.png;确认文件存在后,在对应步骤中加入 Markdown 图片:![步骤 <n> 截图](workspace://<截图绝对路径>)。截图失败时如实写明,不要用占位图或把截图描述冒充图片。`
报告撰写时,针对每个复现步骤中真实出现且仍可访问的 HTTP 页面,使用 browser MCP 的 browser_navigate 和 browser_take_screenshot 截取页面证据。只导航到漏洞证据中出现的目标 URL,不要自行扩大测试范围。将截图保存到当前工作目录下的 findings/screenshots/ 目录,文件名使用 finding-<finding_id>-step-<n>.png;确认文件存在后,在对应步骤中**统一使用工作区相对路径**加入 Markdown 图片:![步骤 <n> 截图](findings/screenshots/finding-<finding_id>-step-<n>.png)。禁止使用绝对路径、workspace://、file://,也不要混用相对路径和绝对路径;报告中所有截图都必须使用这一种格式。截图失败时如实写明,不要用占位图或把截图描述冒充图片。`
}
return t, true
}
@@ -2327,7 +2327,7 @@ func (s *Server) findingsExport(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(report.FindingsMarkdown(fs, now)))
case "md-zip":
path := filepath.Join(stage, "findings.zip")
if err := buildFindingsEvidenceZip(path, fs, stage, now); err != nil {
if err := buildFindingsEvidenceZip(path, fs, stage, now, s.m.dir); err != nil {
writeErr(w, 500, err.Error())
return
}
@@ -3456,27 +3456,28 @@ func (s *Server) settingsPayload() map[string]any {
concLimit = defaultConcurrencyLimit // 关闭时也回显一个合理默认值给 UI
}
return map[string]any{
"traffic_capture": s.m.TrafficEnabled(),
"browser_screenshot_enabled": s.m.pg.GetBool(settingBrowserScreenshot, false),
"agent_traffic_binding": s.m.pg.GetBool(settingAgentTrafficBinding, false),
"task_intelligence_enabled": s.m.pg.GetBool(db.SettingTaskIntelligence, false),
db.SettingTaskAutoTitle: s.m.pg.GetBool(db.SettingTaskAutoTitle, false),
db.SettingChatAutoTitle: s.m.pg.GetBool(db.SettingChatAutoTitle, false),
db.SettingCredentialAgent: s.m.pg.GetBool(db.SettingCredentialAgent, false),
db.SettingCredentialTraffic: s.m.pg.GetBool(db.SettingCredentialAgent, false) && s.m.pg.GetBool(db.SettingCredentialTraffic, false),
"llm_record": s.m.LLMRecordEnabled(),
"web_search_enabled": on,
"web_search_backend": backend,
"brave_key_set": strings.TrimSpace(braveKey) != "",
"tavily_key_set": strings.TrimSpace(tavilyKey) != "",
"web_search_proxy": proxy, // 独立出口代理(http/https/socks5),空=直连
"global_proxy": s.m.GlobalProxy(), // 全局出口代理(http/https/socks5),所有目标流量走它,空=直连
"show_egress_ip": s.m.pg.GetBool(settingShowEgressIP, false),
"python_interpreter": strings.TrimSpace(pyStored), // 用户/自动设的值(空=用运行时检测)
"workers": s.m.Workers(), // 并发工作 agent 数(默认3);对之后启动的任务生效
"worker_pools": s.m.WorkerPools(), // 非空时按池顺序绑定 worker 会话
"task_concurrency_enabled": concOn, // 任务并发上限开关(默认关)
"task_concurrency_limit": concLimit, // 同时运行任务上限(开启后默认5)
"traffic_capture": s.m.TrafficEnabled(),
"browser_screenshot_enabled": s.m.pg.GetBool(settingBrowserScreenshot, false),
"browser_screenshot_timestamp_enabled": s.m.pg.GetBool(settingScreenshotTimestamp, false),
"agent_traffic_binding": s.m.pg.GetBool(settingAgentTrafficBinding, false),
"task_intelligence_enabled": s.m.pg.GetBool(db.SettingTaskIntelligence, false),
db.SettingTaskAutoTitle: s.m.pg.GetBool(db.SettingTaskAutoTitle, false),
db.SettingChatAutoTitle: s.m.pg.GetBool(db.SettingChatAutoTitle, false),
db.SettingCredentialAgent: s.m.pg.GetBool(db.SettingCredentialAgent, false),
db.SettingCredentialTraffic: s.m.pg.GetBool(db.SettingCredentialAgent, false) && s.m.pg.GetBool(db.SettingCredentialTraffic, false),
"llm_record": s.m.LLMRecordEnabled(),
"web_search_enabled": on,
"web_search_backend": backend,
"brave_key_set": strings.TrimSpace(braveKey) != "",
"tavily_key_set": strings.TrimSpace(tavilyKey) != "",
"web_search_proxy": proxy, // 独立出口代理(http/https/socks5),空=直连
"global_proxy": s.m.GlobalProxy(), // 全局出口代理(http/https/socks5),所有目标流量走它,空=直连
"show_egress_ip": s.m.pg.GetBool(settingShowEgressIP, false),
"python_interpreter": strings.TrimSpace(pyStored), // 用户/自动设的值(空=用运行时检测)
"workers": s.m.Workers(), // 并发工作 agent 数(默认3);对之后启动的任务生效
"worker_pools": s.m.WorkerPools(), // 非空时按池顺序绑定 worker 会话
"task_concurrency_enabled": concOn, // 任务并发上限开关(默认关)
"task_concurrency_limit": concLimit, // 同时运行任务上限(开启后默认5)
// LLM 轮询(故障转移)。默认关;开启后走全局激活配置的 agent 在当前配置不可用时
// 自动切到下一个配置。bind_fallback 仅在轮询开启时有意义(默认关)。
"llm_pool_enabled": s.m.LLMPoolEnabled(),
@@ -3536,6 +3537,7 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
var req struct {
TrafficCapture *bool `json:"traffic_capture"`
BrowserScreenshot *bool `json:"browser_screenshot_enabled"`
ScreenshotTimestamp *bool `json:"browser_screenshot_timestamp_enabled"`
AgentTrafficBinding *bool `json:"agent_traffic_binding"`
TaskIntelligence *bool `json:"task_intelligence_enabled"`
TaskAutoTitle *bool `json:"task_auto_title_enabled"`
@@ -3740,6 +3742,16 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
writeErr(w, 500, err.Error())
return
}
// The reporter's MCP visibility and prompt are assembled when agents are
// built. Rebuild immediately so the setting applies to the next run,
// rather than only after a later unrelated LLM/settings change.
changed = true
}
if req.ScreenshotTimestamp != nil {
if err := s.m.pg.SetBool(settingScreenshotTimestamp, *req.ScreenshotTimestamp); err != nil {
writeErr(w, 500, err.Error())
return
}
}
if req.TrafficCapture != nil {
if err := s.m.SetTrafficEnabled(*req.TrafficCapture); err != nil {
@@ -3806,6 +3818,17 @@ func (s *Server) setReporterBrowserScreenshotVisibility(enabled bool) error {
}
for _, server := range servers {
if server.Name == browserMCPName {
// Turning screenshots on is an explicit opt-in. Make the browser MCP
// usable in the shipped root-based container at the same time as the
// permission toggle, so the next reporter run gets a working browser
// instead of a deterministic Chromium sandbox error.
if enabled {
server.Args = encodeJSON(ensureBrowserMCPArgs(decodeStrSlice(server.Args), true))
server.Enabled = true
if _, err := s.m.pg.SaveMCP(server); err != nil {
return err
}
}
return s.m.pg.ToggleVisibility(reporter.ID, "mcp", server.ID, enabled)
}
}
+45
View File
@@ -244,6 +244,16 @@ func (s *Server) wsPreview(w http.ResponseWriter, r *http.Request) {
return
}
fi, err := os.Stat(abs)
// Reports use the workspace-relative canonical path
// findings/screenshots/finding-<node>-step-<n>.png, while the files live
// under the per-conversation session directory. Resolve that exact suffix
// in the newest matching session so the links stay portable across reports.
if (err != nil || fi.IsDir()) && !filepath.IsAbs(rawPath) {
if fallback, found := s.findSessionPreview(rawPath); found {
abs = fallback
fi, err = os.Stat(abs)
}
}
if err != nil || fi.IsDir() {
writeErr(w, 404, "文件不存在")
return
@@ -253,10 +263,45 @@ func (s *Server) wsPreview(w http.ResponseWriter, r *http.Request) {
writeErr(w, 415, "仅支持图片预览")
return
}
if s.m.pg.GetBool(settingScreenshotTimestamp, false) {
rawRel := filepath.ToSlash(strings.TrimPrefix(strings.TrimSpace(rawPath), "/"))
rel := filepath.ToSlash(s.wsRel(abs))
if strings.HasPrefix(rawRel, "findings/screenshots/") || strings.HasPrefix(rel, "findings/screenshots/") || strings.Contains(rel, "/findings/screenshots/") {
// The frontend renders this as a small top-right overlay. Use the
// file mtime so the label reflects capture/save time, not view time.
w.Header().Set("X-Report-Screenshot-Timestamp", fi.ModTime().Format("2006-01-02 15:04:05"))
}
}
w.Header().Set("Content-Disposition", "inline; filename=\""+sanitizeFilename(filepath.Base(abs))+"\"")
http.ServeFile(w, r, abs)
}
func (s *Server) findSessionPreview(rawPath string) (string, bool) {
rel := filepath.Clean(strings.TrimPrefix(strings.TrimSpace(rawPath), "/"))
const prefix = "findings" + string(os.PathSeparator) + "screenshots" + string(os.PathSeparator)
if rel == "." || !strings.HasPrefix(rel, prefix) || strings.Contains(rel, ".."+string(os.PathSeparator)) {
return "", false
}
base := filepath.Join(filepath.Clean(s.m.dir), "sessions")
matches, err := filepath.Glob(filepath.Join(base, "*", filepath.FromSlash(rel)))
if err != nil || len(matches) == 0 {
return "", false
}
var newest string
var newestTime int64
for _, candidate := range matches {
info, statErr := os.Stat(candidate)
if statErr != nil || info.IsDir() {
continue
}
mtime := info.ModTime().UnixNano()
if newest == "" || mtime > newestTime {
newest, newestTime = candidate, mtime
}
}
return newest, newest != ""
}
// POST /api/workspace/upload?path=<dir> — multipart form field "file" (one or more).
func (s *Server) wsUpload(w http.ResponseWriter, r *http.Request) {
dirAbs, ok := s.wsResolve(r.URL.Query().Get("path"))
@@ -144,6 +144,7 @@ function FindingDetailInner() {
<SideQuestionWorkspace
side={side}
label={`漏洞 #${finding.id} · ${title}`}
mode="floating"
suggestions={["请解释这个漏洞的影响范围和利用条件。", "请根据当前证据检查这个漏洞是否足以确认。", "请给出可执行的修复建议。"]}
placeholder="询问当前漏洞…"
>
@@ -787,6 +787,11 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
graph = new Graph({
container: containerRef.current,
autoResize: true,
// Keep wheel/trackpad zoom within a readable range. Very small zoom
// values make labels and the fixed network zones appear to collapse
// into a single strip; an upper bound also prevents focusElement from
// pushing the selected node underneath the overlay panel.
zoomRange: [0.35, 3],
// Asset mode explicitly centers after rendering. Network mode keeps
// its fixed world coordinates so the three background zones align.
background: "transparent",
@@ -824,6 +829,7 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
behaviors: ["drag-element", "drag-canvas", "zoom-canvas"],
});
graph.on("aftertransform", syncViewport);
graph.on("afterresize", syncViewport);
graph.on("node:click", (evt: unknown) => {
const id = (evt as { target?: { id?: string } }).target?.id;
if (!id) return;
@@ -861,7 +867,7 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
graph?.destroy();
graphRef.current = null;
};
}, [applyData, syncViewport]);
}, [applyData, resetView, syncViewport]);
// 可见集合变化 → 重新灌数据 + 布局。sig 只作为重排触发器(applyData 读 gDataRef)。
// biome-ignore lint/correctness/useExhaustiveDependencies: sig 是刻意的重排触发依赖
@@ -1075,7 +1081,7 @@ function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; covera
className="h-7 px-2 text-xs"
onClick={() => {
setActivePath([]);
void graphRef.current?.fitCenter();
void resetView();
}}
>
清除高亮
@@ -38,6 +38,7 @@ export default function SystemSettingsPage() {
const [savingIntelligence, setSavingIntelligence] = React.useState(false);
const [trafficCapture, setTrafficCapture] = React.useState(false);
const [browserScreenshot, setBrowserScreenshot] = React.useState(false);
const [browserScreenshotTimestamp, setBrowserScreenshotTimestamp] = React.useState(false);
const [agentTrafficBinding, setAgentTrafficBinding] = React.useState(false);
const [webSearch, setWebSearch] = React.useState(false);
const [backend, setBackend] = React.useState("ddgs");
@@ -96,6 +97,7 @@ export default function SystemSettingsPage() {
setChatAutoTitleEnabled(!!s.chat_auto_title_enabled);
setTrafficCapture(!!s.traffic_capture);
setBrowserScreenshot(!!s.browser_screenshot_enabled);
setBrowserScreenshotTimestamp(!!s.browser_screenshot_timestamp_enabled);
setAgentTrafficBinding(!!s.agent_traffic_binding);
setWebSearch(!!s.web_search_enabled);
setBackend(s.web_search_backend || "ddgs");
@@ -239,6 +241,22 @@ export default function SystemSettingsPage() {
.finally(() => setSaving(false));
};
const toggleBrowserScreenshotTimestamp = (v: boolean) => {
setBrowserScreenshotTimestamp(v);
setSaving(true);
api
.setSettings({ browser_screenshot_timestamp_enabled: v })
.then((s) => {
apply(s);
toast.success(v ? "已开启截图时间标记" : "已关闭截图时间标记");
})
.catch((e) => {
setBrowserScreenshotTimestamp(!v);
toast.error(`保存失败:${(e as Error).message}`);
})
.finally(() => setSaving(false));
};
const toggleInjectPlanner = (v: boolean) => {
setInjectPlanner(v); // optimistic
api
@@ -493,6 +511,29 @@ export default function SystemSettingsPage() {
</CardContent>
</Card>
<Card className="mb-4 break-inside-avoid md:mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CameraIcon className="size-4" />
截图时间标记
</CardTitle>
<CardDescription>
开启后,报告中的浏览器截图会在右上角显示截图保存时间,格式为“年月日 时:分:秒”(例如 2026-10-29 01:14:11)。该标记只影响截图展示,不改变原始截图文件。
</CardDescription>
</CardHeader>
<CardContent className="flex items-center justify-between gap-4">
<Label htmlFor="browser-screenshot-timestamp" className="text-sm font-normal text-muted-foreground">
{browserScreenshotTimestamp ? "已开启 · 显示截图时间" : "已关闭 · 使用原始截图"}
</Label>
<Switch
id="browser-screenshot-timestamp"
checked={browserScreenshotTimestamp}
disabled={!loaded || saving}
onCheckedChange={toggleBrowserScreenshotTimestamp}
/>
</CardContent>
</Card>
<Card className="mb-4 break-inside-avoid md:mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
@@ -208,7 +208,9 @@ export function FindingReproductionPanel({ finding, contextTask }: { finding: Fi
[finding.evidence, finding.report, finding.summary],
);
const [traffic, setTraffic] = React.useState<FindingTrafficBinding[] | undefined>(undefined);
const overview = reportSection(finding.report ?? "", ["概述", "影响与危害", "受影响范围"]);
const overviewSections = ["概述", "影响与危害", "受影响范围"]
.map((heading) => ({ heading, content: reportSection(finding.report ?? "", [heading]) }))
.filter((item) => item.content);
const outcome = reportSection(finding.report ?? "", ["最终结果"]);
const rootCause = reportSection(finding.report ?? "", ["根因分析"]);
const remediation = reportSection(finding.report ?? "", ["修复建议"]);
@@ -242,11 +244,11 @@ export function FindingReproductionPanel({ finding, contextTask }: { finding: Fi
</div>
</CardHeader>
<CardContent className="space-y-3">
{overview ? (
<div className="rounded-lg bg-muted/30 px-3 py-2.5 text-sm">
<Markdown text={overview} />
{overviewSections.map((section) => (
<div key={section.heading} className="rounded-lg bg-muted/30 px-3 py-2.5 text-sm">
<Markdown text={`## ${section.heading}\n\n${section.content}`} />
</div>
) : null}
))}
{steps.map((step, index) => {
const binding = traffic?.find((item) => item.position === index) ?? traffic?.[index];
return (
+78 -5
View File
@@ -1,9 +1,70 @@
"use client";
import * as React from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import ReactMarkdown, { defaultUrlTransform, type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { auth } from "@/lib/auth";
function AuthenticatedImage({
src,
alt,
...props
}: React.ImgHTMLAttributes<HTMLImageElement>) {
const [objectURL, setObjectURL] = React.useState<string | null>(null);
const [captureTimestamp, setCaptureTimestamp] = React.useState<string | null>(null);
const imageSrc = typeof src === "string" ? src : undefined;
React.useEffect(() => {
let cancelled = false;
let url: string | null = null;
if (!imageSrc || !imageSrc.startsWith("/api/workspace/preview")) {
setObjectURL(null);
setCaptureTimestamp(null);
return () => {
cancelled = true;
};
}
const token = auth.getToken();
setCaptureTimestamp(null);
fetch(imageSrc, {
credentials: "same-origin",
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
})
.then((response) => {
if (!response.ok) throw new Error(`图片读取失败: ${response.status}`);
setCaptureTimestamp(response.headers.get("X-Report-Screenshot-Timestamp"));
return response.blob();
})
.then((blob) => {
if (cancelled) return;
url = URL.createObjectURL(blob);
setObjectURL(url);
})
.catch(() => {
if (!cancelled) setObjectURL(null);
});
return () => {
cancelled = true;
if (url) URL.revokeObjectURL(url);
};
}, [imageSrc]);
return (
<span className="relative my-2 inline-block max-w-full">
<img {...props} src={objectURL ?? imageSrc} alt={alt || "报告截图"} />
{captureTimestamp && (
<span className="pointer-events-none absolute right-2 top-2 rounded bg-black/70 px-2 py-1 font-mono text-[11px] text-white shadow">
{captureTimestamp}
</span>
)}
</span>
);
}
// Tailwind-styled element overrides (no typography plugin in this project, so we
// style each element). `node` is stripped — it's not a valid DOM attribute.
const components: Components = {
@@ -22,14 +83,20 @@ const components: Components = {
const resolvedSrc =
typeof src === "string" && src.startsWith("workspace://")
? `/api/workspace/preview?path=${encodeURIComponent(src.slice("workspace://".length))}`
: typeof src === "string" && src.startsWith("findings/screenshots/")
? `/api/workspace/preview?path=${encodeURIComponent(src)}`
: src;
return (
<img
<AuthenticatedImage
{...p}
className="my-2 max-h-[520px] max-w-full rounded-md border object-contain"
src={resolvedSrc}
alt={alt || "报告截图"}
loading="lazy"
{...p}
// Report screenshots are evidence and must be fetched while the report is
// rendered; Edge may replace lazy images with placeholders indefinitely
// when the markdown section is outside the initial viewport.
loading="eager"
decoding="async"
/>
);
},
@@ -64,7 +131,13 @@ const components: Components = {
export function Markdown({ text }: { text: string }) {
return (
<div className="min-w-0 space-y-1.5 text-sm [&>*:first-child]:mt-0">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={components}
urlTransform={(url) =>
url.startsWith("workspace://") || url.startsWith("findings/screenshots/") ? url : defaultUrlTransform(url)
}
>
{text}
</ReactMarkdown>
</div>
+19 -1
View File
@@ -248,6 +248,7 @@ export function SideQuestionWorkspace({
side,
label,
children,
mode = "split",
composerLayout = "stacked",
suggestions,
placeholder,
@@ -257,6 +258,7 @@ export function SideQuestionWorkspace({
side: SideQuestions;
label: string;
children: ReactNode;
mode?: "split" | "floating";
composerLayout?: ComposerLayout;
suggestions?: string[];
placeholder?: string;
@@ -265,9 +267,25 @@ export function SideQuestionWorkspace({
}) {
const mobile = useIsMobile();
const desktopSideOpen = side.open && side.enabled && !mobile;
const floatingSideOpen = desktopSideOpen && mode === "floating";
return (
<>
{desktopSideOpen ? (
{floatingSideOpen ? (
<>
<div className={cn("min-h-0 min-w-0 flex-1", className, mainClassName)}>{children}</div>
<div className="pointer-events-none fixed inset-x-0 top-20 z-40 flex justify-end px-4 pb-4 sm:px-6">
<div className="pointer-events-auto h-[calc(100svh-6rem)] w-[min(420px,calc(100vw-2rem))] overflow-hidden rounded-xl border bg-background shadow-2xl">
<SidePanel
side={side}
label={label}
composerLayout={composerLayout}
suggestions={suggestions}
placeholder={placeholder}
/>
</div>
</div>
</>
) : desktopSideOpen ? (
<ResizablePanelGroup orientation="horizontal" className={cn("min-h-0 min-w-0 flex-1", className)}>
<ResizablePanel
id="main-conversation"
+2
View File
@@ -972,6 +972,8 @@ export interface Settings {
traffic_capture: boolean;
/** 是否向可见 Agent 暴露浏览器 MCP 的截图工具。默认关闭。 */
browser_screenshot_enabled?: boolean;
/** 是否在报告截图右上角显示截图保存时间。默认关闭。 */
browser_screenshot_timestamp_enabled?: boolean;
credential_agent_enabled: boolean;
credential_traffic_binding: boolean;
task_intelligence_enabled: boolean; // 默认关闭;关闭不注入情报工具或提示