diff --git a/server/finding_workflow.go b/server/finding_workflow.go index 64c90a0..5d9114b 100644 --- a/server/finding_workflow.go +++ b/server/finding_workflow.go @@ -10,10 +10,22 @@ import ( "github.com/Autumn-27/artex/agent" "github.com/Autumn-27/artex/db" + "github.com/Autumn-27/artex/traffic" actool "github.com/Autumn-27/norma/tool" ) func (s *Server) seedFindingWorkflowTools() { + const hostSearchDescriptionFlag = "finding_workflow_tools_v3_host_search_description" + if value, _, _ := s.m.pg.GetSetting(hostSearchDescriptionFlag); value != "true" { + // Only replace the original built-in text. A user-edited description is + // authoritative and must survive upgrades. + legacy := "查询记录代理已抓取的目标流量(必须指定 host,可再按 URL 子串或正文关键词过滤)。body_contains 会在已抓取的请求/响应头与正文中做全文搜索,支持任意子串和中文(至少 3 个字符),可用来找响应里的密码、密钥、报错、内网地址等。仅返回极轻量索引(id/method/url/status/resp_len),不含任何响应内容。默认只返回 3 条、每页最多 10 条;结果多时用 page 翻页(page=0 起);要看某条的请求/响应原文用 traffic_get(id)。回看已访问资源、找端点先用它,避免重复 curl 同一 URL。" + if _, err := s.m.pg.Exec(`UPDATE tools SET description=$1,updated_at=now() WHERE key='traffic_search' AND system AND description=$2`, traffic.TrafficSearchDescription, legacy); err != nil { + log.Printf("[evidence] upgrade traffic_search description: %v", err) + return + } + _ = s.m.pg.SetSetting(hostSearchDescriptionFlag, "true") + } const flag = "finding_workflow_tools_v2_reporter" if value, _, _ := s.m.pg.GetSetting(flag); value == "true" { return diff --git a/server/finding_workflow_test.go b/server/finding_workflow_test.go index df26e29..ada3b10 100644 --- a/server/finding_workflow_test.go +++ b/server/finding_workflow_test.go @@ -246,6 +246,9 @@ func TestFindingWorkflowMigrationPreservesUserConfiguration(t *testing.T) { } search, _ := pg.GetTool("traffic_search") get, _ := pg.GetTool("traffic_get") + if !strings.Contains(search.Description, "支持裸主机、主机:端口或完整 URL") { + t.Fatal("traffic_search description migration missing host/port guidance") + } if search.Enabled || !contains(search.Agents, "reporter") || get.Enabled || len(get.Agents) != 1 || get.Agents[0] != "custom-agent" { t.Fatal("default/custom reader binding migration incorrect") } diff --git a/traffic/traffic.go b/traffic/traffic.go index c90fc03..b9f4baf 100644 --- a/traffic/traffic.go +++ b/traffic/traffic.go @@ -23,6 +23,7 @@ import ( "path/filepath" "regexp" "sort" + "strconv" "strings" "sync" "sync/atomic" @@ -101,6 +102,11 @@ const ( maxBlobRead = 8 * 1024 ) +// TrafficSearchDescription is persisted into the tool catalog for new and +// upgraded installations. Keep it in the traffic package so the runtime tool +// and the catalog migration cannot drift apart. +const TrafficSearchDescription = "查询记录代理已抓取的目标流量(必须指定 host;支持裸主机、主机:端口或完整 URL,可再按 URL 子串或正文关键词过滤)。指定端口时只返回该服务的流量,避免同一 IP 的不同端口串包。body_contains 会在已抓取的请求/响应头与正文中做全文搜索,支持任意子串和中文(至少 3 个字符)。仅返回极轻量索引(id/method/url/status/resp_len),不含响应内容;结果非空后必须用 traffic_get 逐条核实请求/响应,再把确实支持当前漏洞的 ID 交给 bind_finding_traffic。默认只返回 3 条、每页最多 10 条;结果多时用 page 翻页。" + // Traffic runs the recording proxy and owns the file tree + index. type Traffic struct { dir string @@ -1474,8 +1480,24 @@ func (t *Traffic) query(host, contains, bodyContains string, page, limit int) ([ q := `SELECT id,ts,host,method,url_template,url,status,content_type,resp_len,path FROM exchanges WHERE 1=1` args := []any{} if host != "" { + hostName, port, err := normalizeSearchHost(host) + if err != nil { + return nil, err + } q += ` AND host=?` - args = append(args, host) + args = append(args, hostName) + if port != "" { + // Recorded rows historically store URL.Hostname() (without the port), + // so constrain the original URL authority as a compatibility fallback. + // New and old captures therefore share the same search contract. + authority := net.JoinHostPort(hostName, port) + q += ` AND (url LIKE ? OR url LIKE ? OR url LIKE ?)` + args = append(args, + "%://"+authority+"/%", + "%://"+authority+"?%", + "%://"+authority, + ) + } } if contains != "" { q += ` AND (url LIKE ? OR url_template LIKE ?)` @@ -1510,6 +1532,42 @@ func (t *Traffic) query(host, contains, bodyContains string, page, limit int) ([ return out, rows.Err() } +// normalizeSearchHost accepts the forms agents commonly have at hand while +// keeping the stored host (URL.Hostname()) as the canonical host key. A port, +// when supplied, is applied to the URL authority so captures from different +// services on the same IP cannot be mixed. +func normalizeSearchHost(raw string) (host, port string, err error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", "", errors.New("host 为必填参数") + } + if strings.Contains(raw, "://") { + u, parseErr := url.Parse(raw) + if parseErr != nil || u.Host == "" { + return "", "", fmt.Errorf("无法解析 host:%q", raw) + } + host, port = u.Hostname(), u.Port() + } else if h, p, splitErr := net.SplitHostPort(raw); splitErr == nil { + host, port = h, p + } else if strings.HasPrefix(raw, "[") && strings.HasSuffix(raw, "]") { + host = strings.TrimSuffix(strings.TrimPrefix(raw, "["), "]") + } else { + host = raw + } + host = strings.Trim(strings.TrimSpace(host), "[]") + if host == "" { + return "", "", fmt.Errorf("无法解析 host:%q", raw) + } + if port != "" { + p, parseErr := strconv.Atoi(port) + if parseErr != nil || p < 1 || p > 65535 { + return "", "", fmt.Errorf("端口无效:%q", port) + } + port = strconv.Itoa(p) + } + return strings.ToLower(host), port, nil +} + // Tools exposes traffic lookup to work agents so they query already-captured // traffic instead of re-curling the same resource (token + dedup win). func (t *Traffic) Tools() []actool.CoreTool { @@ -1520,11 +1578,11 @@ func (t *Traffic) Tools() []actool.CoreTool { search := actool.Build(actool.Spec{ Name: "traffic_search", - Description: "查询记录代理已抓取的目标流量(必须指定 host,可再按 URL 子串或正文关键词过滤)。body_contains 会在已抓取的请求/响应头与正文中做全文搜索,支持任意子串和中文(至少 3 个字符),可用来找响应里的密码、密钥、报错、内网地址等。仅返回极轻量索引(id/method/url/status/resp_len),不含任何响应内容。默认只返回 3 条、每页最多 10 条;结果多时用 page 翻页(page=0 起);要看某条的请求/响应原文用 traffic_get(id)。回看已访问资源、找端点先用它,避免重复 curl 同一 URL。", + Description: TrafficSearchDescription, Schema: map[string]any{ "type": "object", "properties": map[string]any{ - "host": map[string]any{"type": "string", "description": "按主机过滤(必填,如 '107.172.96.177:8082')"}, + "host": map[string]any{"type": "string", "description": "按主机过滤(必填;如 '107.172.96.177'、'107.172.96.177:8082' 或 'http://107.172.96.177:8082/path')"}, "contains": map[string]any{"type": "string", "description": "URL 子串过滤(可选,如 'api' / 'login')"}, "body_contains": map[string]any{"type": "string", "description": "正文全文搜索(可选,至少 3 个字符),匹配请求/响应的头与正文,如 'password' / 'root:x:0' / '内网测试'"}, "limit": map[string]any{"type": "integer", "description": "每页条数,默认 3,最大 10"}, @@ -1543,7 +1601,7 @@ func (t *Traffic) Tools() []actool.CoreTool { } _ = json.Unmarshal(in, &a) if strings.TrimSpace(a.Host) == "" { - return actool.Errorf("host 为必填参数:请指定要查询的主机(如 '107.172.96.177:8082'),避免全库扫描。"), nil + return actool.Errorf("host 为必填参数:请指定裸主机、主机:端口或完整 URL,避免全库扫描。"), nil } rows, err := t.query(a.Host, a.Contains, a.BodyContains, a.Page, a.Limit) if err != nil { diff --git a/traffic/traffic_store_test.go b/traffic/traffic_store_test.go index 2d744c6..c13974c 100644 --- a/traffic/traffic_store_test.go +++ b/traffic/traffic_store_test.go @@ -373,6 +373,53 @@ func TestPageSearchesBodies(t *testing.T) { } } +func TestQueryHostPortAndURLForms(t *testing.T) { + tr, _ := openTraffic(t) + tr.record(newFlow("api.example.com:8082", "GET", "/admin", nil, []byte("8082"))) + tr.record(newFlow("api.example.com:8088", "GET", "/admin", nil, []byte("8088"))) + tr.record(newFlow("[2001:db8::1]:8443", "GET", "/admin", nil, []byte("8443"))) + + for _, tc := range []struct { + name string + host string + want int + }{ + {name: "bare host", host: "api.example.com", want: 2}, + {name: "host and port", host: "api.example.com:8082", want: 1}, + {name: "full URL", host: "http://api.example.com:8088/admin", want: 1}, + {name: "IPv6 host and port", host: "[2001:db8::1]:8443", want: 1}, + } { + t.Run(tc.name, func(t *testing.T) { + rows, err := tr.query(tc.host, "", "", 0, 10) + if err != nil { + t.Fatal(err) + } + if len(rows) != tc.want { + t.Fatalf("query(%q) returned %d rows, want %d", tc.host, len(rows), tc.want) + } + }) + } +} + +func TestNormalizeSearchHost(t *testing.T) { + for _, tc := range []struct { + raw, host, port string + }{ + {raw: "API.Example.com", host: "api.example.com"}, + {raw: "api.example.com:8088", host: "api.example.com", port: "8088"}, + {raw: "https://[2001:db8::1]:8443/path", host: "2001:db8::1", port: "8443"}, + {raw: "[2001:db8::1]", host: "2001:db8::1"}, + } { + host, port, err := normalizeSearchHost(tc.raw) + if err != nil { + t.Fatalf("normalizeSearchHost(%q): %v", tc.raw, err) + } + if host != tc.host || port != tc.port { + t.Fatalf("normalizeSearchHost(%q)=(%q,%q), want (%q,%q)", tc.raw, host, port, tc.host, tc.port) + } + } +} + // TestTruncateUTF8 guards the preview cut: never split a multi-byte rune. func TestTruncateUTF8(t *testing.T) { s := "内网测试账号"