mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-11-08 12:23:49 +08:00
feat: improve finding reports and evidence exports
This commit is contained in:
1 parent
fb672389b0
commit
b0a29236f8
14 files changed
+485
-45
No files matched your search
@@ -102,7 +102,7 @@ const ReporterDefaultPrompt = `你是一个授权渗透测试系统里的**漏
|
||||
|
||||
━━ 报告步骤截图(仅在系统设置开启时)━━
|
||||
- 当系统设置开启报告步骤截图时,你会自动获得 browser MCP;此时对每个复现步骤中真实出现且仍可访问的 HTTP 页面,使用 browser_navigate 后调用 browser_take_screenshot。
|
||||
- 只访问漏洞证据中出现的 URL,不扩大测试范围。把截图保存到当前工作目录的 findings/screenshots/ 下,命名 finding-<finding_id>-step-<n>.png;确认文件存在后,在对应步骤中加入 Markdown 图片链接:。
|
||||
- 只访问漏洞证据中出现的 URL,不扩大测试范围。把截图保存到当前工作目录的 findings/screenshots/ 下,命名 finding-<finding_id>-step-<n>.png;确认文件存在后,在对应步骤中**统一使用工作区相对路径**加入 Markdown 图片链接:。禁止使用绝对路径、workspace://、file:// 或把相对路径和绝对路径混用;报告中所有截图都必须遵守这一种格式。
|
||||
- 截图失败时如实说明;系统设置关闭或 browser MCP 不可用时,不要伪造图片链接,也不要把文字描述冒充截图。
|
||||
|
||||
━━ 报告结构(Markdown,按需裁剪,但证据/复现/修复必须有)━━
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 图片:。截图失败时如实写明,不要用占位图或把截图描述冒充图片。`
|
||||
报告撰写时,针对每个复现步骤中真实出现且仍可访问的 HTTP 页面,使用 browser MCP 的 browser_navigate 和 browser_take_screenshot 截取页面证据。只导航到漏洞证据中出现的目标 URL,不要自行扩大测试范围。将截图保存到当前工作目录下的 findings/screenshots/ 目录,文件名使用 finding-<finding_id>-step-<n>.png;确认文件存在后,在对应步骤中**统一使用工作区相对路径**加入 Markdown 图片:。禁止使用绝对路径、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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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; // 默认关闭;关闭不注入情报工具或提示
|
||||
|
||||
Reference in new issue
Block a user