mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-11-09 20:23:50 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ee94f2215 | ||
|
|
459a484237 | ||
|
|
25d7b29b12 | ||
|
|
4868b1816f | ||
|
|
4586634d80 |
No files matched your search
@@ -12,6 +12,8 @@ AI 自主渗透测试系统(Go 后端 + Next.js 前端)
|
||||
本仓库为独立维护的 [RuoJi6/ARTEX](https://github.com/RuoJi6/ARTEX),保留 https://github.com/Autumn-27/ARTEX 的原始提交历史与 AGPL-3.0 许可证。程序发布包从本仓库 Releases 下载,Docker 镜像 [ruoji6/artex](https://hub.docker.com/r/ruoji6/artex) 由本仓库 GitHub Actions 构建并发布。
|
||||
|
||||
---
|
||||
## 致谢
|
||||
致谢米斯特安全团队
|
||||
|
||||
## 截图预览
|
||||
|
||||
@@ -392,6 +394,12 @@ server {
|
||||
|
||||
本版历史记录通过漏洞详情和会话查看,暂未纳入漏洞报告导出或任务归档包,也未自动关联流量包。演示模式只生成明确标注的模拟记录,不请求真实目标。
|
||||
|
||||
### 自定义 IP 探测服务
|
||||
|
||||
在「系统配置 → 流量与安全」展开「自定义 IP 探测服务(可选)」,填写可访问的 HTTP / HTTPS 回显服务后点击「保存探测服务」。服务需返回纯文本 IP 或 `{"ip":"1.2.3.4"}` 格式的 JSON。支持内网地址,保留对本机、链路本地地址及跨主机重定向的限制。
|
||||
|
||||
保存后,顶栏出口 IP、代理测试、代理池健康检查和自动重连均使用该地址,刷新页面或重启后仍生效;自定义服务失败时不会再尝试默认公网服务。清空地址并保存可恢复默认的 api.ipify.org / ifconfig.me。修改后也可先点击「测试代理」临时验证,测试不会保存地址。
|
||||
|
||||
### 本地运行与测试
|
||||
|
||||
```bash
|
||||
|
||||
@@ -77,7 +77,7 @@ func findingWorkflowTools(agentKey string, tools []actool.CoreTool) ([]actool.Co
|
||||
guidance += "\n\n**报告前自动关联流量(已开启)**:你负责为本次触发的漏洞核对并绑定流量,再撰写报告。先从 report_finding 返回 JSON 或 get_task_node_detail / list_task_findings 取得明确的 finding_id 与 finding_node_id。读取漏洞详情、对应意图的执行记录及已有证据清单,优先使用上报者交接的真实 ID。若本次验证为 HTTP 且流量工具可用,用 traffic_search 筛选候选,再用 traffic_get 逐条核实请求/响应确实支持该漏洞;域名和时间只用于筛选,不证明归属。将确认的证据按复现顺序用 bind_finding_traffic(finding_id, traffic_refs) 关联,选择 baseline / proof / verification / supporting 并说明用途。只能操作本次漏洞,不重复创建漏洞或重新探测目标。绑定成功后重新调用 get_finding_traffic 获取最新 version,读取所需正文,再将实际读取的 version 作为 evidence_version 传给 update_finding_report(其 finding_id 参数仍用 finding_node_id)。已有绑定不必重复追加。TCP、未采集、工具不可用或没有确切匹配时,跳过自动绑定,依据文字/命令证据正常写报告并说明原因,不得为凑齐流量而猜测。绑定失败不宣称成功;保留已有证据并在报告说明未绑定原因。"
|
||||
}
|
||||
if guidance != "" || has["get_finding_traffic"] || has["update_finding_report"] {
|
||||
guidance += findingIDGuidance
|
||||
guidance += "\n\n**流量 ID 约定**:流量页面展示/复制的流量 ID 就是 traffic_search 返回的 id,也是 traffic_get(id) 和 traffic_refs[].traffic_id 使用的原始字符串。它不是列表行号,不随分页、排序或筛选变化。用户交接完整 ID 时优先直接用 traffic_get 读取并核实,不要改写、截短或猜测 ID;报告引用流量证据时保留对应完整 ID,方便从流量页面检索。" + findingIDGuidance
|
||||
}
|
||||
return out, guidance
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ func TestFindingWorkflowSharedAssemblyAndSwitch(t *testing.T) {
|
||||
if on && (!strings.Contains(system[0], "TCP") || !strings.Contains(system[0], "evidence_hint_id")) {
|
||||
t.Fatal("missing optional/handoff contract")
|
||||
}
|
||||
if on && (!strings.Contains(system[0], "不是列表行号") || !strings.Contains(system[0], "完整 ID")) {
|
||||
t.Fatal("missing stable traffic ID handoff contract")
|
||||
}
|
||||
for _, tool := range out {
|
||||
if !strings.HasPrefix(tool.Description(), "CUSTOM DESCRIPTION") {
|
||||
t.Fatal("custom description replaced")
|
||||
|
||||
@@ -656,6 +656,42 @@ func (d *DB) FindingStats() (*FindingStats, error) {
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// FilteredFindingStats uses the same row and asset filters as the findings list.
|
||||
// Options remain global so choosing a filter never removes other choices.
|
||||
// Counts cover matching hot rows, exactly like ListFindingCases; archive-only
|
||||
// aggregates cannot be filtered by record content and are not list results.
|
||||
func (d *DB) FilteredFindingStats(filter FindingFilter) (*FindingStats, error) {
|
||||
st, err := d.FindingStats()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filter, err = d.applyAssetScope(filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
where, args := filter.where()
|
||||
st.Distinct = new(FindingDistinctStats)
|
||||
err = d.QueryRow(`SELECT COUNT(*),
|
||||
COUNT(*) FILTER (WHERE f.status='pending'),
|
||||
COUNT(*) FILTER (WHERE f.severity='critical'),
|
||||
COUNT(*) FILTER (WHERE f.severity='high'),
|
||||
COUNT(*) FILTER (WHERE f.severity='medium'),
|
||||
COUNT(*) FILTER (WHERE f.severity='low'),
|
||||
COUNT(DISTINCT CASE WHEN m.case_id IS NULL THEN 'f:'||f.id ELSE 'c:'||m.case_id END)
|
||||
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, args...).Scan(
|
||||
&st.Total, &st.Pending, &st.Critical, &st.High, &st.Medium, &st.Low, &st.Distinct.Total)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Severity buckets describe matching original reports, not a folder's rating.
|
||||
st.Distinct.Reports = st.Total
|
||||
st.Distinct.Critical, st.Distinct.High = st.Critical, st.High
|
||||
st.Distinct.Medium, st.Distinct.Low = st.Medium, st.Low
|
||||
st.Distinct.Unassessed = st.Total - st.Critical - st.High - st.Medium - st.Low
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// GetFinding returns a single finding row (with task_description joined and the
|
||||
// full Markdown report), or nil when no row has that id. Unlike the list queries
|
||||
// it also selects `report` — that column is only needed on the detail page.
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFilteredFindingStatsMatchesList(t *testing.T) {
|
||||
d, task, ids := timeSortFixture(t)
|
||||
if _, err := d.MergeFindingCase(context.Background(), task.ID, ids[:3], "same defect", "verified shared root cause", "test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := d.SetFindingStatus(ids[0], FindingResolved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := d.Exec(`UPDATE findings SET name='needle' WHERE id=$1`, ids[1]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
asset := seedTreeAsset(t, d, "root_domain", map[string]any{"domain": "issue125.example", "root_domain": "issue125.example"})
|
||||
t.Cleanup(func() { d.Exec(`DELETE FROM assets WHERE id=$1`, asset) })
|
||||
if _, err := d.Exec(`UPDATE findings SET asset_ids=$2::jsonb WHERE id=$1`, ids[1], fmt.Sprintf("[%d]", asset)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := FindingFilter{TaskID: fmt.Sprint(task.ID)}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
filter FindingFilter
|
||||
reports, defects, critical, high, low int
|
||||
}{
|
||||
{"task", base, 4, 2, 1, 2, 1},
|
||||
{"severity", FindingFilter{TaskID: base.TaskID, Severity: "high"}, 2, 2, 0, 2, 0},
|
||||
{"status", FindingFilter{TaskID: base.TaskID, Status: FindingResolved}, 1, 1, 1, 0, 0},
|
||||
{"query", FindingFilter{TaskID: base.TaskID, Query: "needle"}, 1, 1, 0, 1, 0},
|
||||
{"class", FindingFilter{TaskID: base.TaskID, VulnClass: "IDOR"}, 4, 2, 1, 2, 1},
|
||||
{"dates", FindingFilter{TaskID: base.TaskID, From: "2026-10-26T00:00:00Z", To: "2026-10-27T23:59:59Z"}, 2, 1, 0, 1, 1},
|
||||
{"combined", FindingFilter{TaskID: base.TaskID, Severity: "high", Status: FindingPending, Query: "needle", VulnClass: "IDOR", From: "2026-10-26T00:00:00Z"}, 1, 1, 0, 1, 0},
|
||||
{"asset", FindingFilter{TaskID: base.TaskID, AssetScope: fmt.Sprintf("a:%d", asset)}, 1, 1, 0, 1, 0},
|
||||
{"unassigned asset", FindingFilter{TaskID: base.TaskID, AssetScope: FindingUnassignedAsset}, 3, 2, 1, 1, 1},
|
||||
{"empty", FindingFilter{TaskID: base.TaskID, Query: "no matching finding"}, 0, 0, 0, 0, 0},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
st, err := d.FilteredFindingStats(tc.filter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Total != tc.reports || st.Distinct.Total != tc.defects || st.Critical != tc.critical || st.High != tc.high || st.Low != tc.low {
|
||||
t.Fatalf("stats=%+v distinct=%+v", st, st.Distinct)
|
||||
}
|
||||
_, total, err := d.ListFindingCases(tc.filter, 1, 1)
|
||||
if err != nil || total != st.Distinct.Total {
|
||||
t.Fatalf("list total=%d stats=%d err=%v", total, st.Distinct.Total, err)
|
||||
}
|
||||
_, reports, err := d.ListFindingsPage(tc.filter, 1, 1)
|
||||
if err != nil || reports != st.Total {
|
||||
t.Fatalf("reports=%d stats=%d err=%v", reports, st.Total, err)
|
||||
}
|
||||
if !slices.Contains(st.VulnClasses, "IDOR") || !slices.ContainsFunc(st.Tasks, func(option FindingTaskOption) bool { return option.ID == task.ID && option.Count == 4 }) {
|
||||
t.Fatal("filtered counts removed global options")
|
||||
}
|
||||
})
|
||||
}
|
||||
// Deleted-task records remain selectable through the unassigned sentinel.
|
||||
orphan, err := d.AddFinding(0, 0, "__issue125_orphan__", "orphan", "medium", "", "", "test", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { d.DeleteFinding(orphan) })
|
||||
st, err := d.FilteredFindingStats(FindingFilter{TaskID: FindingUnassignedTask, VulnClass: "__issue125_orphan__"})
|
||||
if err != nil || st.Total != 1 || st.Medium != 1 || st.Distinct.Total != 1 {
|
||||
t.Fatalf("orphan stats=%+v err=%v", st, err)
|
||||
}
|
||||
}
|
||||
+34
-13
@@ -26,12 +26,30 @@ import (
|
||||
"github.com/Autumn-27/artex/traffic"
|
||||
)
|
||||
|
||||
// defaultEgressProbes 按顺序尝试,第一个成功即停;probe_url 可覆盖,供隔离网使用。
|
||||
const settingEgressProbeURL = "egress_probe_url"
|
||||
|
||||
// defaultEgressProbes 按顺序尝试,第一个成功即停;自定义地址为空时使用。
|
||||
var defaultEgressProbes = []string{
|
||||
"https://api.ipify.org?format=json", // 返回 {"ip":"1.2.3.4"}
|
||||
"https://ifconfig.me/ip", // 返回纯文本 1.2.3.4
|
||||
}
|
||||
|
||||
func (m *Manager) EgressProbeURL() string {
|
||||
if m == nil || m.pg == nil {
|
||||
return ""
|
||||
}
|
||||
raw, _, _ := m.pg.GetSetting(settingEgressProbeURL)
|
||||
return strings.TrimSpace(raw)
|
||||
}
|
||||
|
||||
// egressProbes reads settings for every probe so saves take effect without a restart.
|
||||
func (m *Manager) egressProbes() []string {
|
||||
if raw := m.EgressProbeURL(); raw != "" {
|
||||
return []string{raw}
|
||||
}
|
||||
return defaultEgressProbes
|
||||
}
|
||||
|
||||
// validateProbeURL 只放行指向"非本机、非链路本地"的绝对 http(s) 地址。
|
||||
//
|
||||
// 这个接口要登录,但仍要挡:agent 可能被目标页面诱导去调它,而"本机"在容器里就是 ARTEX
|
||||
@@ -339,9 +357,9 @@ func egressTarget(cfg string, capture, pending bool, agentProxy, agentCA string)
|
||||
// 空 = 测直连、永远返回 200 用 ok 表达结果。
|
||||
func (s *Server) testEgressProxy(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
GlobalProxy string `json:"global_proxy"`
|
||||
ProbeURL string `json:"probe_url"` // 可选:覆盖默认探测地址
|
||||
Resume bool `json:"resume"` // manual settings probe may release paused_by_proxy tasks
|
||||
GlobalProxy string `json:"global_proxy"`
|
||||
ProbeURL *string `json:"probe_url"` // 省略=已保存地址;空串=默认;非空=临时覆盖
|
||||
Resume bool `json:"resume"` // manual settings probe may release paused_by_proxy tasks
|
||||
}
|
||||
// 请求体只有一个地址,给个上限,免得被塞大 payload。
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
|
||||
@@ -351,14 +369,17 @@ func (s *Server) testEgressProxy(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
probes := defaultEgressProbes
|
||||
if u := strings.TrimSpace(req.ProbeURL); u != "" {
|
||||
checked, err := validateProbeURL(u)
|
||||
if err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
probes := s.m.egressProbes()
|
||||
if req.ProbeURL != nil {
|
||||
probes = defaultEgressProbes
|
||||
if u := strings.TrimSpace(*req.ProbeURL); u != "" {
|
||||
checked, err := validateProbeURL(u)
|
||||
if err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
probes = []string{checked}
|
||||
}
|
||||
probes = []string{checked}
|
||||
}
|
||||
|
||||
// 这三个调用决定 agent 到底会走哪条路。
|
||||
@@ -391,7 +412,7 @@ func (s *Server) testEgressProxy(w http.ResponseWriter, r *http.Request) {
|
||||
rep.Checks = append(rep.Checks, egressCheck{
|
||||
Key: "direct",
|
||||
Label: "① 直连 IP(不经过代理)",
|
||||
Result: probeEgress(ctx, "", "", probes, 10*time.Second),
|
||||
Result: s.runProxyProbe(ctx, "", "", probes, 10*time.Second),
|
||||
})
|
||||
|
||||
if configuredProxy != "" {
|
||||
@@ -405,7 +426,7 @@ func (s *Server) testEgressProxy(w http.ResponseWriter, r *http.Request) {
|
||||
Label: "② 经全局代理的出口 IP",
|
||||
// Path 是实际探测地址(经 MITM 时是 8788),排查时看这个。
|
||||
Path: maskSecrets(probeAddr),
|
||||
Result: probeEgress(ctx, probeAddr, probeCA, probes, timeout),
|
||||
Result: s.runProxyProbe(ctx, probeAddr, probeCA, probes, timeout),
|
||||
})
|
||||
} else {
|
||||
skipReason := "输入框为空,未配置代理"
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func preserveEgressProbeSetting(t *testing.T, m *Manager) {
|
||||
t.Helper()
|
||||
old, existed, err := m.pg.GetSetting(settingEgressProbeURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if existed {
|
||||
if err := m.pg.SetSetting(settingEgressProbeURL, old); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
} else if _, err := m.pg.Exec("DELETE FROM settings WHERE key=$1", settingEgressProbeURL); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestEgressProbeSettingsPersistence(t *testing.T) {
|
||||
m := proxyTestManager(t)
|
||||
preserveEgressProbeSetting(t, m)
|
||||
if _, err := m.pg.Exec("DELETE FROM settings WHERE key=$1", settingEgressProbeURL); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := New(ctx, m, dir, dir, dir)
|
||||
defer func() {
|
||||
cancel()
|
||||
s.Close()
|
||||
}()
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := func(method, body string, authenticated bool) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, "/api/settings", strings.NewReader(body))
|
||||
if authenticated {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
for _, method := range []string{http.MethodGet, http.MethodPut} {
|
||||
if w := request(method, `{"egress_probe_url":"http://10.0.0.5/ip"}`, false); w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthenticated %s: %d", method, w.Code)
|
||||
}
|
||||
}
|
||||
assertResponse := func(method, body, want string) {
|
||||
t.Helper()
|
||||
w := request(method, body, true)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("%s: status=%d body=%s", method, w.Code, w.Body.String())
|
||||
}
|
||||
var payload map[string]json.RawMessage
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got string
|
||||
if err := json.Unmarshal(payload[settingEgressProbeURL], &got); err != nil || got != want {
|
||||
t.Fatalf("egress_probe_url=%q want=%q err=%v", got, want, err)
|
||||
}
|
||||
}
|
||||
assertResponse(http.MethodGet, "", "")
|
||||
if got := m.egressProbes(); !reflect.DeepEqual(got, defaultEgressProbes) {
|
||||
t.Fatalf("default probes=%v", got)
|
||||
}
|
||||
const custom = "http://192.168.132.130:19000/ip"
|
||||
assertResponse(http.MethodPut, `{"egress_probe_url":" `+custom+` "}`, custom)
|
||||
assertResponse(http.MethodGet, "", custom)
|
||||
assertResponse(http.MethodPut, `{}`, custom)
|
||||
assertResponse(http.MethodPut, `{"egress_probe_url":null}`, custom)
|
||||
for _, invalid := range []string{"ftp://example.com/ip", "/relative", "http://127.0.0.1:8787", "http://169.254.169.254/latest", "http://[::1]/"} {
|
||||
body, _ := json.Marshal(map[string]string{settingEgressProbeURL: invalid})
|
||||
if w := request(http.MethodPut, string(body), true); w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid URL %q accepted: %d %s", invalid, w.Code, w.Body.String())
|
||||
}
|
||||
assertResponse(http.MethodGet, "", custom)
|
||||
}
|
||||
// A fresh manager must read the persisted value, without sharing runtime state.
|
||||
reloaded := proxyTestManager(t)
|
||||
if got := reloaded.egressProbes(); !reflect.DeepEqual(got, []string{custom}) {
|
||||
t.Fatalf("reloaded probes=%v", got)
|
||||
}
|
||||
assertResponse(http.MethodPut, `{"egress_probe_url":" "}`, "")
|
||||
if got := reloaded.egressProbes(); !reflect.DeepEqual(got, defaultEgressProbes) {
|
||||
t.Fatalf("cleared probes=%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEgressProbeSavedAndTemporaryURLs(t *testing.T) {
|
||||
m := proxyTestManager(t)
|
||||
preserveEgressProbeSetting(t, m)
|
||||
const saved = "http://10.0.0.5/ip"
|
||||
if err := m.pg.SetSetting(settingEgressProbeURL, saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &Server{m: m}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body string
|
||||
want []string
|
||||
}{
|
||||
{"navbar uses saved", `{}`, []string{saved}},
|
||||
{"null uses saved", `{"probe_url":null}`, []string{saved}},
|
||||
{"unsaved override", `{"probe_url":" https://example.com/ip "}`, []string{"https://example.com/ip"}},
|
||||
{"empty previews defaults", `{"probe_url":""}`, defaultEgressProbes},
|
||||
{"whitespace previews defaults", `{"probe_url":" "}`, defaultEgressProbes},
|
||||
{"global proxy uses saved", `{"global_proxy":"http://10.0.0.9:8080"}`, []string{saved}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
calls := 0
|
||||
s.proxyProbe = func(_ context.Context, _, _ string, probes []string, _ time.Duration) egressProbeResult {
|
||||
calls++
|
||||
if !reflect.DeepEqual(probes, tc.want) {
|
||||
t.Fatalf("probes=%v want=%v", probes, tc.want)
|
||||
}
|
||||
return egressProbeResult{OK: true, ExitIP: "203.0.113.7", Probes: []egressAttempt{}}
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
s.testEgressProxy(w, httptest.NewRequest(http.MethodPost, "/api/settings/proxy/test", strings.NewReader(tc.body)))
|
||||
wantCalls := 1
|
||||
if strings.Contains(tc.body, "global_proxy") {
|
||||
wantCalls = 2
|
||||
}
|
||||
if w.Code != http.StatusOK || calls != wantCalls {
|
||||
t.Fatalf("status=%d calls=%d body=%s", w.Code, calls, w.Body.String())
|
||||
}
|
||||
if got := m.EgressProbeURL(); got != saved {
|
||||
t.Fatalf("temporary probe changed saved setting: %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
s.proxyProbe = func(context.Context, string, string, []string, time.Duration) egressProbeResult {
|
||||
t.Fatal("invalid URL must not send a probe")
|
||||
return egressProbeResult{}
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
s.testEgressProxy(w, httptest.NewRequest(http.MethodPost, "/api/settings/proxy/test", strings.NewReader(`{"probe_url":"http://127.0.0.1"}`)))
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid probe status=%d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyPoolHealthUsesSavedEgressProbe(t *testing.T) {
|
||||
m := proxyTestManager(t)
|
||||
preserveEgressProbeSetting(t, m)
|
||||
const custom = "http://10.0.0.5/ip"
|
||||
if err := m.pg.SetSetting(settingEgressProbeURL, custom); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.globalProxy = "http://proxy.example:8080"
|
||||
m.proxyPool = &proxyPoolState{active: "a", entries: []proxyPoolEntry{{
|
||||
ProxyPoolEntry: ProxyPoolEntry{ID: "a", Enabled: true}, RawURL: m.globalProxy,
|
||||
}}}
|
||||
s := &Server{m: m, ctx: context.Background(), proxyPoolProbeSem: make(chan struct{}, proxyPoolMaxProbeConcurrency)}
|
||||
calls := 0
|
||||
s.proxyProbe = func(_ context.Context, _, _ string, probes []string, _ time.Duration) egressProbeResult {
|
||||
calls++
|
||||
if !reflect.DeepEqual(probes, []string{custom}) {
|
||||
t.Errorf("health probes=%v want=%s", probes, custom)
|
||||
}
|
||||
return egressProbeResult{Error: "offline test"}
|
||||
}
|
||||
s.proxyPoolTickLocked(true)
|
||||
if calls != 1 {
|
||||
t.Fatalf("health probe calls=%d", calls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
func TestFindingStatsFiltersHTTP(t *testing.T) {
|
||||
s, recorded, request := trafficEvidenceServer(t)
|
||||
finding, err := s.m.pg.GetFinding(recorded.FindingID)
|
||||
if err != nil || finding == nil {
|
||||
t.Fatal("missing fixture finding", err)
|
||||
}
|
||||
filter := fmt.Sprintf("task_id=%d&severity=low&status=pending&vulnclass=TEST&q=bound&from=2000-01-01T00:00:00Z&to=2100-01-01T00:00:00Z", *finding.TaskID)
|
||||
for _, suffix := range []string{"", "&page=2&limit=1"} {
|
||||
r := request("GET", "/api/exploration/findings/stats?"+filter+suffix, "")
|
||||
var st db.FindingStats
|
||||
if r.Code != 200 || json.Unmarshal(r.Body.Bytes(), &st) != nil || st.Total != 1 || st.Low != 1 || st.High != 0 || st.Distinct == nil || st.Distinct.Total != 1 {
|
||||
t.Fatalf("status=%d stats=%s", r.Code, r.Body)
|
||||
}
|
||||
}
|
||||
r := request("GET", "/api/exploration/findings/stats?"+filter+"&asset_scope=__none__", "")
|
||||
if r.Code != 200 {
|
||||
t.Fatal(r.Code, r.Body)
|
||||
}
|
||||
r = request("GET", fmt.Sprintf("/api/exploration/findings/stats?task_id=%d&severity=high", *finding.TaskID), "")
|
||||
var st db.FindingStats
|
||||
if r.Code != 200 || json.Unmarshal(r.Body.Bytes(), &st) != nil || st.Total != 0 || st.Distinct.Total != 0 {
|
||||
t.Fatalf("empty filtered stats=%s", r.Body)
|
||||
}
|
||||
if len(st.Tasks) == 0 || len(st.VulnClasses) == 0 {
|
||||
t.Fatal("empty result removed filter choices")
|
||||
}
|
||||
}
|
||||
@@ -145,7 +145,7 @@ func (s *Server) handleProxyFailure(taskID string, cause error) {
|
||||
if s.handleProxyPoolFailure(cause) {
|
||||
return
|
||||
}
|
||||
if probe := s.runProxyProbe(s.ctx, s.m.GlobalProxy(), "", defaultEgressProbes, 10*time.Second); probe.OK {
|
||||
if probe := s.runProxyProbe(s.ctx, s.m.GlobalProxy(), "", s.m.egressProbes(), 10*time.Second); probe.OK {
|
||||
return
|
||||
}
|
||||
s.pauseProxyTask(taskID, cause)
|
||||
@@ -200,7 +200,7 @@ func (s *Server) handleProxyTrafficFailure(cause error) {
|
||||
}
|
||||
// Confirm the saved global proxy itself before pausing. MITM RequestError can
|
||||
// also represent a target-side failure, and those must not pause a task.
|
||||
if probe := probeEgress(context.Background(), s.m.GlobalProxy(), "", defaultEgressProbes, 10*time.Second); probe.OK {
|
||||
if probe := probeEgress(context.Background(), s.m.GlobalProxy(), "", s.m.egressProbes(), 10*time.Second); probe.OK {
|
||||
return
|
||||
}
|
||||
for _, t := range s.m.List() {
|
||||
@@ -219,7 +219,7 @@ func (s *Server) handleProxyToolFailure(ctx context.Context, cause error) {
|
||||
if !isGlobalProxyDialFailure(cause) || strings.TrimSpace(s.m.GlobalProxy()) == "" {
|
||||
return
|
||||
}
|
||||
if probe := probeEgress(context.Background(), s.m.GlobalProxy(), "", defaultEgressProbes, 10*time.Second); probe.OK {
|
||||
if probe := probeEgress(context.Background(), s.m.GlobalProxy(), "", s.m.egressProbes(), 10*time.Second); probe.OK {
|
||||
return
|
||||
}
|
||||
if info := agent.RunInfoFrom(ctx); info.TaskID > 0 {
|
||||
@@ -302,7 +302,7 @@ func (s *Server) proxyReconnectTick() {
|
||||
if !claimed {
|
||||
continue
|
||||
}
|
||||
res := s.runProxyProbe(context.Background(), s.m.GlobalProxy(), "", defaultEgressProbes, 10*time.Second)
|
||||
res := s.runProxyProbe(context.Background(), s.m.GlobalProxy(), "", s.m.egressProbes(), 10*time.Second)
|
||||
if res.OK {
|
||||
s.proxyReconnectSuccessWithGuards(map[string]proxyReconnectGuard{
|
||||
t.ID: {generation: round, attempt: attempt, checkAttempt: true},
|
||||
@@ -431,7 +431,7 @@ func (s *Server) testTaskProxy(w http.ResponseWriter, r *http.Request) {
|
||||
s.proxyReconnectSuccessWithGuards(guards, nil)
|
||||
return
|
||||
}
|
||||
res := s.runProxyProbe(r.Context(), proxy, "", defaultEgressProbes, 10*time.Second)
|
||||
res := s.runProxyProbe(r.Context(), proxy, "", s.m.egressProbes(), 10*time.Second)
|
||||
if res.OK {
|
||||
s.proxyReconnectSuccessWithGuards(guards, nil)
|
||||
}
|
||||
|
||||
@@ -578,7 +578,7 @@ func (s *Server) proxyPoolTickLocked(forceHealth bool) proxyPoolTickOutcome {
|
||||
return
|
||||
}
|
||||
defer func() { <-sem }()
|
||||
res := s.runProxyProbe(serverContext(s), e.RawURL, "", defaultEgressProbes, proxyPoolProbeTimeout)
|
||||
res := s.runProxyProbe(serverContext(s), e.RawURL, "", s.m.egressProbes(), proxyPoolProbeTimeout)
|
||||
s.m.markProxyProbe(e.ID, res)
|
||||
probeMu.Lock()
|
||||
probeResults[e.ID] = res.OK
|
||||
@@ -695,7 +695,7 @@ func (s *Server) handleProxyPoolFailure(cause error) bool {
|
||||
if cur == "" {
|
||||
return false
|
||||
}
|
||||
res := s.runProxyProbe(serverContext(s), cur, "", defaultEgressProbes, proxyPoolProbeTimeout)
|
||||
res := s.runProxyProbe(serverContext(s), cur, "", s.m.egressProbes(), proxyPoolProbeTimeout)
|
||||
if res.OK {
|
||||
return false
|
||||
}
|
||||
@@ -1134,7 +1134,7 @@ func (s *Server) testProxyPoolEntry(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
for _, e := range s.m.proxyPoolEntries() {
|
||||
if e.ID == id && e.Enabled && strings.TrimSpace(e.RawURL) != "" {
|
||||
res := s.runProxyProbe(r.Context(), e.RawURL, "", defaultEgressProbes, proxyPoolProbeTimeout)
|
||||
res := s.runProxyProbe(r.Context(), e.RawURL, "", s.m.egressProbes(), proxyPoolProbeTimeout)
|
||||
s.m.markProxyProbe(id, res)
|
||||
if res.OK {
|
||||
s.reconcileProxyPoolAfterProbe(id == s.m.proxyPoolActiveID())
|
||||
|
||||
+22
-8
@@ -2337,15 +2337,9 @@ func (s *Server) resolveAssetIDs(ids []int64) map[int64]*db.Asset {
|
||||
return assets
|
||||
}
|
||||
|
||||
// findingStats serves the whole-table aggregates (stat cards + vuln-class filter)
|
||||
// for the paginated 发现 page.
|
||||
// findingStats serves filtered, pagination-independent counts and global options.
|
||||
func (s *Server) findingStats(w http.ResponseWriter, r *http.Request) {
|
||||
st, err := s.m.pg.FindingStats()
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
st.Distinct, err = s.m.pg.DistinctFindingStats("")
|
||||
st, err := s.m.pg.FilteredFindingStats(findingFilterFromQuery(r.URL.Query()))
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
@@ -3701,6 +3695,7 @@ func (s *Server) settingsPayload() map[string]any {
|
||||
"proxy_reconnect_max_attempts": func() int { n, _ := s.proxyReconnectConfig(); return n }(),
|
||||
"proxy_reconnect_interval_seconds": func() int { _, d := s.proxyReconnectConfig(); return int(d / time.Second) }(),
|
||||
"show_egress_ip": s.m.pg.GetBool(settingShowEgressIP, false),
|
||||
"egress_probe_url": s.m.EgressProbeURL(),
|
||||
"python_interpreter": strings.TrimSpace(pyStored), // 用户/自动设的值(空=用运行时检测)
|
||||
"workers": s.m.Workers(), // 并发工作 agent 数(默认3);对之后启动的任务生效
|
||||
"worker_pools": s.m.WorkerPools(), // 非空时按池顺序绑定 worker 会话
|
||||
@@ -3793,6 +3788,7 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
|
||||
ProxyReconnectMax *int `json:"proxy_reconnect_max_attempts"`
|
||||
ProxyReconnectInterval *int `json:"proxy_reconnect_interval_seconds"`
|
||||
ShowEgressIP *bool `json:"show_egress_ip"` // 在顶栏显示直连/全局代理出口 IP;默认关
|
||||
EgressProbeURL *string `json:"egress_probe_url"` // null=不改;空串=默认公共探测服务
|
||||
PythonInterp *string `json:"python_interpreter"` // 自定义脚本工具的 python 解释器路径
|
||||
Workers *int `json:"workers"` // 并发工作 agent 数(>0);对之后启动的任务生效
|
||||
WorkerPools *[]WorkerPool `json:"worker_pools"`
|
||||
@@ -3818,6 +3814,18 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if req.EgressProbeURL != nil {
|
||||
value := strings.TrimSpace(*req.EgressProbeURL)
|
||||
if value != "" {
|
||||
checked, err := validateProbeURL(value)
|
||||
if err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
value = checked
|
||||
}
|
||||
req.EgressProbeURL = &value
|
||||
}
|
||||
if req.ConstraintsInjectPlanner != nil {
|
||||
if err := s.m.pg.SetBool(settingConstraintsInjectPlanner, *req.ConstraintsInjectPlanner); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
@@ -4062,6 +4070,12 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
|
||||
default:
|
||||
}
|
||||
}
|
||||
if req.EgressProbeURL != nil {
|
||||
if err := s.m.pg.SetSetting(settingEgressProbeURL, *req.EgressProbeURL); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.ShowEgressIP != nil {
|
||||
if err := s.m.pg.SetBool(settingShowEgressIP, *req.ShowEgressIP); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
|
||||
+5
-5
@@ -962,13 +962,13 @@ func (t *Traffic) Page(f PageQuery, page, size int) (rows []ExchangeMeta, total
|
||||
}
|
||||
if s := strings.TrimSpace(f.Query); s != "" {
|
||||
like := "%" + s + "%"
|
||||
const meta = "host LIKE ? OR url LIKE ? OR url_template LIKE ? OR method LIKE ? OR content_type LIKE ? OR CAST(status AS TEXT) LIKE ?"
|
||||
const meta = "id LIKE ? OR host LIKE ? OR url LIKE ? OR url_template LIKE ? OR method LIKE ? OR content_type LIKE ? OR CAST(status AS TEXT) LIKE ?"
|
||||
// Metadata match OR full-text match: one search box, widest recall. Terms
|
||||
// too short for trigram silently fall back to metadata only.
|
||||
if cond, arg, ok := t.ftsFilter(s); ok {
|
||||
add("(("+meta+") OR "+cond+")", like, like, like, like, like, like, arg)
|
||||
add("(("+meta+") OR "+cond+")", like, like, like, like, like, like, like, arg)
|
||||
} else {
|
||||
add("("+meta+")", like, like, like, like, like, like)
|
||||
add("("+meta+")", like, like, like, like, like, like, like)
|
||||
}
|
||||
}
|
||||
// Body is the dedicated content box: it only searches captured request/response
|
||||
@@ -2148,10 +2148,10 @@ func (t *Traffic) Tools() []actool.CoreTool {
|
||||
|
||||
get := actool.Build(actool.Spec{
|
||||
Name: "traffic_get",
|
||||
Description: "按 id 取一条已抓流量的请求/响应原文(过大会截断)。配合 traffic_search 用,避免重复 curl。",
|
||||
Description: "按 id 取一条已抓流量的请求/响应原文(过大会截断)。id 与流量页面展示的流量 ID、traffic_search 返回的 id 相同,须完整保留,不是列表行号。已知 ID 时直接读取,无需重新搜索或请求目标。",
|
||||
Schema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"id": map[string]any{"type": "string", "description": "traffic_search 返回的 id"}},
|
||||
"properties": map[string]any{"id": map[string]any{"type": "string", "description": "流量页面或 traffic_search 返回的完整流量 ID(不是列表行号)"}},
|
||||
"required": []any{"id"},
|
||||
},
|
||||
ReadOnly: ro,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package traffic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTrafficIDSearchAndToolReference(t *testing.T) {
|
||||
tr, _ := openTraffic(t)
|
||||
tr.record(newFlow("id.example", "GET", "/proof", nil, []byte("verified proof")))
|
||||
tr.record(newFlow("other.example", "GET", "/other", nil, []byte("other response")))
|
||||
rows, _, err := tr.Page(PageQuery{Host: "id.example", RespMin: -1, RespMax: -1}, 0, 10)
|
||||
if err != nil || len(rows) != 1 {
|
||||
t.Fatalf("rows=%v err=%v", rows, err)
|
||||
}
|
||||
id := rows[0].ID
|
||||
for _, fts := range []bool{true, false} {
|
||||
if fts && !tr.fts {
|
||||
continue
|
||||
}
|
||||
previous := tr.fts
|
||||
tr.fts = fts
|
||||
for _, query := range []string{id, id[len(id)-2:]} {
|
||||
found, total, err := tr.Page(PageQuery{Host: "id.example", Query: query, RespMin: -1, RespMax: -1}, 0, 1)
|
||||
if err != nil || total != 1 || len(found) != 1 || found[0].ID != id {
|
||||
t.Fatalf("fts=%v query=%s rows=%v total=%d err=%v", fts, query, found, total, err)
|
||||
}
|
||||
}
|
||||
tr.fts = previous
|
||||
}
|
||||
for _, tool := range tr.Tools() {
|
||||
if tool.Name() != "traffic_get" {
|
||||
continue
|
||||
}
|
||||
input, _ := json.Marshal(map[string]string{"id": id})
|
||||
result, err := tool.Call(context.Background(), input, nil)
|
||||
if err != nil || result.IsError || !strings.Contains(result.Flatten(), "verified proof") {
|
||||
t.Fatalf("traffic_get result=%s err=%v", result.Flatten(), err)
|
||||
}
|
||||
}
|
||||
// An ID search still respects host and method filters.
|
||||
_, total, err := tr.Page(PageQuery{Query: id, Host: "other.example", RespMin: -1, RespMax: -1}, 0, 10)
|
||||
if err != nil || total != 0 {
|
||||
t.Fatalf("cross-host ID search total=%d err=%v", total, err)
|
||||
}
|
||||
}
|
||||
@@ -630,30 +630,38 @@ export default function FindingsPage() {
|
||||
if (flatPage > lastPage) setFlatPage(lastPage);
|
||||
}, [flat.loaded, flat.total, flatPage, flatPageSize]);
|
||||
|
||||
// Whole-table aggregates (stat cards + vuln-class options) — independent of the
|
||||
// current page, so they stay exact.
|
||||
const statsQuery = React.useMemo(
|
||||
() => ({ severity, status, vulnclass, task, query, ...dateRange, assetScope: activeAssetScope ?? undefined }),
|
||||
[severity, status, vulnclass, task, query, dateRange, activeAssetScope],
|
||||
);
|
||||
const statsFingerprint = JSON.stringify(statsQuery);
|
||||
const activeStatsFingerprint = React.useRef(statsFingerprint);
|
||||
activeStatsFingerprint.current = statsFingerprint;
|
||||
const statsRequest = React.useRef(0);
|
||||
const refreshStats = React.useCallback(async () => {
|
||||
if (activeStatsFingerprint.current !== statsFingerprint) return;
|
||||
const request = ++statsRequest.current;
|
||||
try {
|
||||
const result = await api.findingStats(statsQuery);
|
||||
if (request !== statsRequest.current || activeStatsFingerprint.current !== statsFingerprint) return;
|
||||
setStats(result);
|
||||
setStatsLoaded(true);
|
||||
} catch {
|
||||
// Retain the previous snapshot during transient refresh failures.
|
||||
}
|
||||
}, [statsQuery, statsFingerprint]);
|
||||
|
||||
// Counts use the current filters and asset subtree, independently of pagination.
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
const load = () => {
|
||||
api
|
||||
.findingStats()
|
||||
.then((s) => {
|
||||
if (alive) {
|
||||
setStats(s);
|
||||
setStatsLoaded(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep the previous aggregate snapshot until the next poll.
|
||||
});
|
||||
};
|
||||
load();
|
||||
const t = setInterval(load, 5000);
|
||||
if (!preferencesHydrated) return;
|
||||
void activeRetestFingerprint;
|
||||
void refreshStats();
|
||||
const t = setInterval(() => void refreshStats(), 5000);
|
||||
return () => {
|
||||
alive = false;
|
||||
statsRequest.current++;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, []);
|
||||
}, [preferencesHydrated, refreshStats, activeRetestFingerprint]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!statsLoaded) return;
|
||||
@@ -677,12 +685,7 @@ export default function FindingsPage() {
|
||||
await api.setFindingStatus(f.finding_id, next);
|
||||
toast.success(`已标记为「${statusMeta("finding", next).label}」`);
|
||||
// refresh stat cards (pending count) and drop the row if it no longer matches the status filter
|
||||
api
|
||||
.findingStats()
|
||||
.then(setStats)
|
||||
.catch(() => {
|
||||
// The row update remains valid even if the aggregate refresh fails.
|
||||
});
|
||||
void refreshStats();
|
||||
if (status !== "all" && next !== status) {
|
||||
setFindings((cur) => cur.filter((x) => !isSameFinding(x, f)));
|
||||
setTotal((t) => Math.max(0, t - 1));
|
||||
@@ -694,7 +697,7 @@ export default function FindingsPage() {
|
||||
toast.error(`更新失败:${(e as Error).message}`);
|
||||
}
|
||||
},
|
||||
[refreshAfterMutation, setFindings, status],
|
||||
[refreshAfterMutation, refreshStats, setFindings, status],
|
||||
);
|
||||
|
||||
// 行内展开的详细报告缓存按全局稳定行键存。report 是大段 Markdown,列表查询不带它,
|
||||
@@ -747,12 +750,7 @@ export default function FindingsPage() {
|
||||
),
|
||||
);
|
||||
toast.success("已保存");
|
||||
api
|
||||
.findingStats()
|
||||
.then(setStats)
|
||||
.catch(() => {
|
||||
// The edit remains valid even if the aggregate refresh fails.
|
||||
});
|
||||
void refreshStats();
|
||||
refreshAfterMutation(f);
|
||||
} catch (e) {
|
||||
toast.error(`保存失败:${(e as Error).message}`);
|
||||
@@ -760,7 +758,7 @@ export default function FindingsPage() {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[edit, refreshAfterMutation, setFindings],
|
||||
[edit, refreshAfterMutation, refreshStats, setFindings],
|
||||
);
|
||||
|
||||
// deleteFinding 删除一个漏洞(需二次确认):删成功后从列表移除、收起行、刷新统计。
|
||||
@@ -780,18 +778,13 @@ export default function FindingsPage() {
|
||||
const rowKey = findingRowKey(f);
|
||||
setExpanded((cur) => (cur === rowKey ? null : cur));
|
||||
toast.success("已删除漏洞");
|
||||
api
|
||||
.findingStats()
|
||||
.then(setStats)
|
||||
.catch(() => {
|
||||
// The deletion remains valid even if the aggregate refresh fails.
|
||||
});
|
||||
void refreshStats();
|
||||
refreshAfterMutation(f, true);
|
||||
} catch (e) {
|
||||
toast.error(`删除失败:${(e as Error).message}`);
|
||||
}
|
||||
},
|
||||
[refreshAfterMutation, setFindings],
|
||||
[refreshAfterMutation, refreshStats, setFindings],
|
||||
);
|
||||
|
||||
const openDeepen = React.useCallback((f: Finding) => {
|
||||
@@ -822,10 +815,10 @@ export default function FindingsPage() {
|
||||
const statCards = [
|
||||
{ label: "独立漏洞", value: stats.distinct?.total ?? stats.total, icon: BugIcon },
|
||||
{ label: "上报记录", value: stats.total, tone: "text-amber-500", icon: ClockIcon },
|
||||
{ label: "严重", value: stats.distinct?.critical ?? stats.critical, tone: "text-rose-600", icon: ShieldAlertIcon },
|
||||
{ label: "高危", value: stats.distinct?.high ?? stats.high, tone: "text-red-500", icon: TriangleAlertIcon },
|
||||
{ label: "中危", value: stats.distinct?.medium ?? stats.medium, tone: "text-amber-500", icon: TriangleAlertIcon },
|
||||
{ label: "低危", value: stats.distinct?.low ?? stats.low, tone: "text-slate-500", icon: InfoIcon },
|
||||
{ label: "严重", value: stats.critical, tone: "text-rose-600", icon: ShieldAlertIcon },
|
||||
{ label: "高危", value: stats.high, tone: "text-red-500", icon: TriangleAlertIcon },
|
||||
{ label: "中危", value: stats.medium, tone: "text-amber-500", icon: TriangleAlertIcon },
|
||||
{ label: "低危", value: stats.low, tone: "text-slate-500", icon: InfoIcon },
|
||||
];
|
||||
|
||||
// 导出弹窗里「当前筛选」的条数:两个视图的筛选一致,只是统计口径来源不同。
|
||||
|
||||
@@ -85,9 +85,11 @@ export default function SystemSettingsPage() {
|
||||
const clearEgressReport = () => {
|
||||
egressSeq.current += 1;
|
||||
setEgressReport(null);
|
||||
setEgressTesting(false);
|
||||
};
|
||||
// 探测地址:留空用公网回显;隔离网填内网回显服务。
|
||||
const [egressProbeUrl, setEgressProbeUrl] = React.useState("");
|
||||
const [savingEgressProbeUrl, setSavingEgressProbeUrl] = React.useState(false);
|
||||
const [testing, setTesting] = React.useState(false);
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const [settingsLoaded, setSettingsLoaded] = React.useState(false);
|
||||
@@ -143,6 +145,7 @@ export default function SystemSettingsPage() {
|
||||
setProxyPoolLogs(s.proxy_pool_switch_log ?? []);
|
||||
setProxyReconnectMax(String(s.proxy_reconnect_max_attempts ?? 5));
|
||||
setProxyReconnectInterval(String(s.proxy_reconnect_interval_seconds ?? 30));
|
||||
setEgressProbeUrl(s.egress_probe_url ?? "");
|
||||
setPyInterp(s.python_interpreter ?? "");
|
||||
setWorkers(String(s.workers ?? 3));
|
||||
setWorkerPools(s.worker_pools ?? []);
|
||||
@@ -567,6 +570,21 @@ export default function SystemSettingsPage() {
|
||||
.catch((e) => toast.error("保存失败:" + (e as Error).message));
|
||||
};
|
||||
|
||||
const saveEgressProbeUrl = async () => {
|
||||
setSavingEgressProbeUrl(true);
|
||||
try {
|
||||
const settings = await api.setSettings({ egress_probe_url: egressProbeUrl.trim() });
|
||||
setEgressProbeUrl(settings.egress_probe_url ?? "");
|
||||
clearEgressReport();
|
||||
toast.success(settings.egress_probe_url ? "已保存 IP 探测服务" : "已恢复默认 IP 探测服务");
|
||||
if (showEgressIp) void probeEgress().catch(() => undefined);
|
||||
} catch (e) {
|
||||
toast.error(`保存 IP 探测服务失败:${(e as Error).message}`);
|
||||
} finally {
|
||||
setSavingEgressProbeUrl(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 测表单里当前的值,不要求先保存;后端返回直连 / 经代理两条。
|
||||
const testEgress = () => {
|
||||
const seq = ++egressSeq.current;
|
||||
@@ -576,8 +594,7 @@ export default function SystemSettingsPage() {
|
||||
.testEgressProxy({
|
||||
global_proxy: globalProxyInput.trim(),
|
||||
resume: true,
|
||||
|
||||
...(egressProbeUrl.trim() ? { probe_url: egressProbeUrl.trim() } : {}),
|
||||
probe_url: egressProbeUrl.trim(),
|
||||
})
|
||||
.then((rep) => {
|
||||
if (seq !== egressSeq.current) return; // 期间改过配置,这份结果已经过期
|
||||
@@ -952,8 +969,8 @@ export default function SystemSettingsPage() {
|
||||
<details className="text-xs">
|
||||
<summary className="text-muted-foreground cursor-pointer select-none">出口 IP 测试说明</summary>
|
||||
<p className="text-muted-foreground mt-2 leading-relaxed">
|
||||
测试会访问 IP 回显服务(默认 api.ipify.org,失败时改用 ifconfig.me)。结果中的直连 IP 与代理 IP
|
||||
可用于确认代理是否生效。修改地址后无需保存即可测试。
|
||||
测试会访问 IP 回显服务。未设置自定义地址时,默认访问 api.ipify.org,失败时改用 ifconfig.me。
|
||||
配置自定义地址后仅使用该服务。结果中的直连 IP 与代理 IP 可用于确认代理是否生效。修改地址后无需保存即可测试。
|
||||
</p>
|
||||
</details>
|
||||
{/* 内网里公网回显不可达,两行都会失败——不是代理坏了。 */}
|
||||
@@ -961,16 +978,32 @@ export default function SystemSettingsPage() {
|
||||
<summary className="text-muted-foreground cursor-pointer select-none">
|
||||
自定义 IP 探测服务(可选)
|
||||
</summary>
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
<Input
|
||||
autoComplete="off"
|
||||
placeholder="留空 = 使用默认的公共 IP 回显服务;内网可填 http://<内网IP>:<端口>/"
|
||||
value={egressProbeUrl}
|
||||
onChange={(e) => {
|
||||
setEgressProbeUrl(e.target.value);
|
||||
clearEgressReport();
|
||||
}}
|
||||
/>
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
<Label htmlFor="egress-probe-url">IP 探测服务地址</Label>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
id="egress-probe-url"
|
||||
autoComplete="off"
|
||||
placeholder="留空使用默认服务;内网可填 http://<内网IP>:<端口>/"
|
||||
value={egressProbeUrl}
|
||||
disabled={!settingsLoaded || savingEgressProbeUrl}
|
||||
onChange={(e) => {
|
||||
setEgressProbeUrl(e.target.value);
|
||||
clearEgressReport();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!settingsLoaded || savingEgressProbeUrl}
|
||||
onClick={() => void saveEgressProbeUrl()}
|
||||
>
|
||||
{savingEgressProbeUrl ? "保存中…" : "保存探测服务"}
|
||||
</Button>
|
||||
</div>
|
||||
<span className="text-muted-foreground leading-relaxed">
|
||||
保存后用于顶栏出口 IP、代理测试、健康检查和自动重连,刷新页面或重启后仍生效。清空地址并保存可恢复默认服务。
|
||||
</span>
|
||||
<span className="text-muted-foreground leading-relaxed">
|
||||
自定义 IP 探测服务需返回纯文本 IP 或 <code>{'{"ip":"1.2.3.4"}'}</code> 格式的 JSON。
|
||||
若内网环境下无法访问公网,或本机和内网代理共用同一个公网出口, 则可替换为内网 IP 探测服务,此时 ①
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ArrowUpNarrowWideIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
EraserIcon,
|
||||
FilterXIcon,
|
||||
ListChecksIcon,
|
||||
@@ -81,6 +82,30 @@ function MethodBadge({ method }: { method: string }) {
|
||||
return <Badge className="shrink-0 font-mono">{method}</Badge>;
|
||||
}
|
||||
|
||||
function TrafficID({ id }: { id: string }) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-1 font-mono text-xs"
|
||||
title="复制流量 ID,可用于搜索或交给报告 Agent"
|
||||
aria-label={`复制流量 ID ${id}`}
|
||||
onClick={async (event) => {
|
||||
event.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(id);
|
||||
toast.success("已复制流量 ID");
|
||||
} catch {
|
||||
toast.error("复制失败,请手动选择流量 ID");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{id}
|
||||
<CopyIcon className="size-3 shrink-0" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Older captures may predate Host persistence because net/http keeps Host
|
||||
// outside Request.Header. Fill it for display while newly recorded traffic is
|
||||
// fixed at the recorder layer as well.
|
||||
@@ -247,7 +272,22 @@ export function TrafficHistory({ taskId }: { taskId?: string }) {
|
||||
alive = false;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, [page, size, hostQ, method, queryQ, bodyQ, pathQ, statusFilter, respMinQ, respMaxQ, sort, reloadTick, taskId, taskScoped]);
|
||||
}, [
|
||||
page,
|
||||
size,
|
||||
hostQ,
|
||||
method,
|
||||
queryQ,
|
||||
bodyQ,
|
||||
pathQ,
|
||||
statusFilter,
|
||||
respMinQ,
|
||||
respMaxQ,
|
||||
sort,
|
||||
reloadTick,
|
||||
taskId,
|
||||
taskScoped,
|
||||
]);
|
||||
|
||||
// Delete traffic for the current host filter (substring) or the checked
|
||||
// hosts (exact batch), then refetch.
|
||||
@@ -537,7 +577,7 @@ export function TrafficHistory({ taskId }: { taskId?: string }) {
|
||||
<div className="relative max-w-sm flex-1">
|
||||
<SearchIcon className="absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索全部(URL / 方法 / 类型 / 状态码…)"
|
||||
placeholder="搜索全部(ID / URL / 方法 / 类型 / 状态码…)"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="h-8 pl-8"
|
||||
@@ -699,6 +739,7 @@ export function TrafficHistory({ taskId }: { taskId?: string }) {
|
||||
}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-44">流量 ID</TableHead>
|
||||
<SortableHead
|
||||
field="ts"
|
||||
label="时间"
|
||||
@@ -752,6 +793,9 @@ export function TrafficHistory({ taskId }: { taskId?: string }) {
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TrafficID id={e.id} />
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground tabular-nums">{fmtTime(e.ts)}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{e.host}</TableCell>
|
||||
<TableCell>
|
||||
@@ -771,7 +815,7 @@ export function TrafficHistory({ taskId }: { taskId?: string }) {
|
||||
))}
|
||||
{exchanges.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="py-12 text-center text-sm text-muted-foreground">
|
||||
<TableCell colSpan={9} className="py-12 text-center text-sm text-muted-foreground">
|
||||
{traffic === null ? "加载中…" : "没有匹配的流量。"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -803,6 +847,10 @@ export function TrafficHistory({ taskId }: { taskId?: string }) {
|
||||
</div>
|
||||
<SheetTitle className="break-all font-mono">{selected.host}</SheetTitle>
|
||||
<SheetDescription className="break-all font-mono">{selected.url}</SheetDescription>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
流量 ID:
|
||||
<TrafficID id={selected.id} />
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
+3
-2
@@ -708,7 +708,8 @@ export const api = {
|
||||
{ case_id: id },
|
||||
),
|
||||
findingCaseReviewRuns: () => get<FindingCaseReviewRun[]>("/exploration/finding-case-review"),
|
||||
findingStats: () => get<FindingStats>("/exploration/findings/stats"),
|
||||
findingStats: (q: Omit<FindingQuery, "page" | "pageSize"> = {}) =>
|
||||
get<FindingStats>(`/exploration/findings/stats?${findingFilterParams(q).toString()}`),
|
||||
// exportFindings 触发发现页导出并下载文件。scope=selected 时传 ids(finding_id 列表);
|
||||
// scope=filtered 时传当前筛选(沿用 FindingQuery 的筛选字段);scope=all 忽略筛选。
|
||||
exportFindings: async (opts: {
|
||||
@@ -1002,7 +1003,7 @@ export const api = {
|
||||
deepseek_search_api_key?: string;
|
||||
}) => post<{ ok: boolean; error?: string; count?: number; backend?: string }>(`/settings/web-search/test`, patch),
|
||||
|
||||
// global_proxy 传空 = 测直连;probe_url 可覆盖默认探测地址(隔离网用)。
|
||||
// global_proxy 传空=测直连;probe_url 省略=已保存地址,空串=默认,非空=临时覆盖。
|
||||
testEgressProxy: (patch: { global_proxy?: string; probe_url?: string; resume?: boolean }) =>
|
||||
post<EgressReport>(`/settings/proxy/test`, patch),
|
||||
proxyPool: () =>
|
||||
|
||||
@@ -3361,6 +3361,7 @@ export const settings: Settings = {
|
||||
proxy_health_cooldown_seconds: 60,
|
||||
proxy_health_check_interval_seconds: 30,
|
||||
show_egress_ip: false,
|
||||
egress_probe_url: "",
|
||||
python_interpreter: "/usr/bin/python3",
|
||||
workers: 3,
|
||||
worker_pools: [],
|
||||
|
||||
@@ -132,3 +132,30 @@ test("group dates use the latest matching finding after filters", async () => {
|
||||
assert.equal(result.items[0].task_id, "10");
|
||||
assert.equal(result.items[0].last_found_at, fixtures[8].ts);
|
||||
});
|
||||
|
||||
test("finding statistics follow combined filters and retain global choices", async () => {
|
||||
const route = { path: "/exploration/findings/stats" };
|
||||
const all = await read(route);
|
||||
const filtered = await read(route, {
|
||||
task_id: "10",
|
||||
severity: "high",
|
||||
status: "pending",
|
||||
vulnclass: "Demo",
|
||||
q: "needle",
|
||||
from: "2026-01-04T00:00:00Z",
|
||||
to: "2026-01-04T23:59:59Z",
|
||||
page: "2",
|
||||
limit: "1",
|
||||
});
|
||||
assert.equal(filtered.total, 1);
|
||||
assert.equal(filtered.distinct.total, 1);
|
||||
assert.equal(filtered.high, 1);
|
||||
assert.equal(filtered.critical, 0);
|
||||
assert.deepEqual(filtered.tasks, all.tasks);
|
||||
assert.deepEqual(filtered.vulnclasses, all.vulnclasses);
|
||||
const empty = await read(route, { q: "no matching fixture" });
|
||||
assert.equal(empty.total, 0);
|
||||
assert.equal(empty.distinct.total, 0);
|
||||
assert.equal(empty.high + empty.medium + empty.low + empty.critical, 0);
|
||||
assert.deepEqual(empty.tasks, all.tasks);
|
||||
});
|
||||
@@ -2001,6 +2001,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
// ── exploration ──
|
||||
if (path === "/exploration/frontier") return D.frontier;
|
||||
if (path === "/exploration/findings/stats") {
|
||||
const list = mockApplyAssetScope(mockFilterFindings(q), q.get("asset_scope"));
|
||||
const vulnclasses = Array.from(new Set(mockFindings.map((f) => f.vulnclass))).sort();
|
||||
// 「按任务」下拉:有漏洞的任务 + 描述 + 条数(mock 任务 id 是字符串,直接当 id 用)。
|
||||
const taskMap = new Map<string, { name: string; description: string; count: number }>();
|
||||
@@ -2017,12 +2018,13 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
}
|
||||
const tasks = Array.from(taskMap, ([id, v]) => ({ id, name: v.name, description: v.description, count: v.count }));
|
||||
return {
|
||||
total: mockFindings.length,
|
||||
pending: mockFindings.filter((f) => f.status === "pending").length,
|
||||
critical: mockFindings.filter((f) => f.severity === "critical").length,
|
||||
high: mockFindings.filter((f) => f.severity === "high").length,
|
||||
medium: mockFindings.filter((f) => f.severity === "medium").length,
|
||||
low: mockFindings.filter((f) => f.severity === "low").length,
|
||||
total: list.length,
|
||||
pending: list.filter((f) => f.status === "pending").length,
|
||||
critical: list.filter((f) => f.severity === "critical").length,
|
||||
high: list.filter((f) => f.severity === "high").length,
|
||||
medium: list.filter((f) => f.severity === "medium").length,
|
||||
low: list.filter((f) => f.severity === "low").length,
|
||||
distinct: { total: list.length, reports: list.length },
|
||||
vulnclasses,
|
||||
tasks,
|
||||
};
|
||||
|
||||
@@ -579,7 +579,7 @@ export interface FindingDeepenResponse {
|
||||
queued: boolean;
|
||||
}
|
||||
|
||||
// FindingStats 是发现全表聚合(统计卡 + 漏洞类型下拉),服务端计算,不受分页影响。
|
||||
// FindingStats 的计数跟随列表筛选,不受分页影响;类型和任务选项保留全局范围。
|
||||
export interface FindingStats {
|
||||
distinct?: FindingDistinctStats;
|
||||
total: number;
|
||||
@@ -1020,6 +1020,7 @@ export interface Settings {
|
||||
proxy_reconnect_max_attempts?: number;
|
||||
proxy_reconnect_interval_seconds?: number;
|
||||
show_egress_ip?: boolean; // 在顶栏显示直连 / 全局代理出口 IP;默认关闭
|
||||
egress_probe_url?: string; // 自定义 IP 探测服务;空=默认公共回显服务
|
||||
python_interpreter?: string; // 自定义脚本工具的 python 解释器路径(空=运行时检测)
|
||||
workers?: number; // 并发工作 agent 数(默认3);对之后启动的任务生效
|
||||
worker_pools: WorkerPool[];
|
||||
|
||||
Reference in new issue
Block a user