diff --git a/examples/README.md b/examples/README.md index 3f14afc..8fa62f3 100644 --- a/examples/README.md +++ b/examples/README.md @@ -9,7 +9,10 @@ examples/ ├── fingers/ # 指纹识别工具 ├── neutron/ # POC 扫描工具 ├── gogo/ # 端口扫描和指纹识别工具 -└── spray/ # HTTP 批量探测工具 +├── spray/ # HTTP 批量探测工具 +└── cases/ # 小颗粒度使用案例(cookbook) + ├── match_detail/ # 获取 matcher 详情和命中资源 URL(cmd + test) + └── match_detail_helper/ # 同上,FingerMatch 封装风格(library + test) ``` ## 快速开始 @@ -257,6 +260,40 @@ echo "http://127.0.0.1:8080" >> test_urls.txt --- +## Cases - 小颗粒度使用案例 + +`examples/cases/` 下放的是 cookbook 风格的最小可运行片段,每个 case 只演示一个 API 或一个用法要点,复制即可融入到自己的工程里。 + +### match_detail - 获取 matcher 详情和命中资源 URL + +演示如何通过 SDK public API 输出 `MatchDetail`(matcher 类型/值、rule_index、send_data)以及命中的资源 URL。提供两种风格,二选一即可: + +**风格 ① 直接用 SDK 原生类型(推荐)** —— `cases/match_detail/` + +```bash +# 跑命令行版(被动匹配真实 target) +go run ./cases/match_detail -url http://127.0.0.1:8080 -key your_api_key -target http://127.0.0.1:3000 + +# 跑测试版(inline finger + httptest,离线可跑) +go test ./cases/match_detail -v +``` + +**风格 ② 封装一层调用方友好结构** —— `cases/match_detail_helper/` + +`FingerMatch` 演示如何把 SDK 返回的 `fingers.MatchResult` 转成业务自己的 JSON DTO。`match_url`、matcher detail、fallback URL 都来自 SDK,不需要调用方复制解析逻辑。 + +```bash +go test ./cases/match_detail_helper -v +``` + +**两种风格共通的要点:** + +- 推荐直接调用 `eng.MatchHTTPWithDetail(resp)`,返回 `[]fingers.MatchResult`。 +- `MatchHTTPWithDetail` 会自动打开底层 `MatchDetail` 开关,调用方不需要手动调用 `GetFingersEngine().EnableMatchDetail()`。 +- `match_url` 取值优先级:`MatchDetail.SendData` 中的 `url=` > 当前请求 URL(`resp.Request.URL`,已处理重定向)。 + +--- + ## 常见问题 ### Q: 如何获取 Cyberhub API Key? diff --git a/examples/cases/match_detail/main.go b/examples/cases/match_detail/main.go new file mode 100644 index 0000000..dbe5bbb --- /dev/null +++ b/examples/cases/match_detail/main.go @@ -0,0 +1,75 @@ +// match_detail 演示:在 sdk/fingers 上直接拿到指纹命中的 matcher 详情 + 命中的资源 URL。 +// +// 关键点: +// 1. 调用 MatchHTTPWithDetail(resp),SDK 会自动打开 MatchDetail。 +// 2. 结果里直接读 MatchResult.MatchURL / MatcherType / MatcherValue。 +// 3. match_url 取值优先级:MatchDetail.SendData 中的 "url=" > 当前请求 URL。 +// +// 用法: +// +// go run ./examples/cases/match_detail -url http://127.0.0.1:8080 -key -target http://example.com +package main + +import ( + "flag" + "fmt" + "net/http" + "os" + + "github.com/chainreactors/sdk/fingers" +) + +func main() { + cyberhubURL := flag.String("url", "", "Cyberhub URL (optional, local fingers used if empty)") + apiKey := flag.String("key", "", "Cyberhub API Key") + target := flag.String("target", "", "Target URL to match (required)") + flag.Parse() + if *target == "" { + flag.Usage() + os.Exit(1) + } + + // 1. 构建 engine + cfg := fingers.NewConfig() + if *cyberhubURL != "" { + cfg.WithCyberhub(*cyberhubURL, *apiKey) + } + eng, err := fingers.NewEngine(cfg) + if err != nil { + fmt.Printf("engine init failed: %v\n", err) + os.Exit(1) + } + + // 2. 抓 + 匹配。MatchHTTPWithDetail 会自动打开 MatchDetail。 + resp, err := http.Get(*target) + if err != nil { + fmt.Printf("http get failed: %v\n", err) + os.Exit(1) + } + defer resp.Body.Close() + + results, err := eng.MatchHTTPWithDetail(resp) + if err != nil { + fmt.Printf("match failed: %v\n", err) + os.Exit(1) + } + + if len(results) == 0 { + fmt.Println("no fingerprints matched") + return + } + for _, r := range results { + name := "" + if r.Framework != nil { + name = r.Framework.Name + } + fmt.Printf("[%s]\n", name) + fmt.Printf(" match_url : %s\n", r.MatchURL) + fmt.Printf(" matcher_type : %s\n", r.MatcherType) + fmt.Printf(" matcher_value : %s\n", r.MatcherValue) + fmt.Printf(" rule_index : %d\n", r.RuleIndex) + if r.SendData != "" { + fmt.Printf(" send_data : %s\n", r.SendData) + } + } +} diff --git a/examples/cases/match_detail/match_detail_test.go b/examples/cases/match_detail/match_detail_test.go new file mode 100644 index 0000000..1985033 --- /dev/null +++ b/examples/cases/match_detail/match_detail_test.go @@ -0,0 +1,99 @@ +// Test-based 演示:直接用 go test 跑通整条链路,证明 +// MatchHTTPWithDetail 会返回 match_url 和 matcher 详情。 +// +// Run with: +// +// go test ./examples/cases/match_detail -v +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + + fingersEngine "github.com/chainreactors/fingers/fingers" + sdkfingers "github.com/chainreactors/sdk/fingers" +) + +// TestMatchDetailUsage 演示获取 matcher 详情的最小调用流程: +// +// 1. 构造 Engine +// 2. 调 MatchHTTPWithDetail(resp) +// 3. 直接读 MatchResult.MatchURL / MatcherType / MatcherValue +func TestMatchDetailUsage(t *testing.T) { + // —— 演示用:inline finger + httptest。 + // 实际工程里换成 WithCyberhub / WithLocalFile 即可。 + finger := &fingersEngine.Finger{ + Name: "demo-app", + Protocol: "http", + Rules: fingersEngine.Rules{ + {Regexps: &fingersEngine.Regexps{Body: []string{"DemoMarker"}}}, + }, + } + eng, err := sdkfingers.NewEngine( + sdkfingers.NewConfig().WithFingers(fingersEngine.Fingers{finger}), + ) + if err != nil { + t.Fatal(err) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("response with DemoMarker")) + })) + defer srv.Close() + + resp, err := http.Get(srv.URL) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + // MatchHTTPWithDetail 会自动打开 MatchDetail,并返回扁平结果。 + results, err := eng.MatchHTTPWithDetail(resp) + if err != nil { + t.Fatal(err) + } + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + r := results[0] + if r.Framework == nil || r.Framework.Name != "demo-app" { + t.Fatalf("Framework not preserved: %+v", r) + } + if r.MatchURL != srv.URL { + t.Fatalf("MatchURL should fall back to request URL %q, got %q", srv.URL, r.MatchURL) + } + if r.MatcherType == "" || r.MatcherValue == "" { + t.Fatalf("expected matcher fields, got %+v", r) + } + t.Logf("[%s] match_url=%s matcher_type=%s matcher_value=%s rule_index=%d", + r.Framework.Name, r.MatchURL, r.MatcherType, r.MatcherValue, r.RuleIndex) +} + +// TestPlainMatchWithoutDetail 反向对照:plain MatchHTTP 仍保持兼容, +// 不会自动填 MatchDetail。 +func TestPlainMatchWithoutDetail(t *testing.T) { + finger := &fingersEngine.Finger{ + Name: "demo-app-disabled", + Protocol: "http", + Rules: fingersEngine.Rules{ + {Regexps: &fingersEngine.Regexps{Body: []string{"DemoMarker2"}}}, + }, + } + eng, _ := sdkfingers.NewEngine( + sdkfingers.NewConfig().WithFingers(fingersEngine.Fingers{finger}), + ) + // 故意不调 EnableMatchDetail() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("DemoMarker2")) + })) + defer srv.Close() + resp, _ := http.Get(srv.URL) + defer resp.Body.Close() + + frames, _ := eng.MatchHTTP(resp) + if fw, ok := frames["demo-app-disabled"]; ok && fw.MatchDetail != nil { + t.Fatalf("expected MatchDetail=nil for plain MatchHTTP, got %+v", *fw.MatchDetail) + } +} diff --git a/examples/cases/match_detail_helper/helper.go b/examples/cases/match_detail_helper/helper.go new file mode 100644 index 0000000..d5a6de3 --- /dev/null +++ b/examples/cases/match_detail_helper/helper.go @@ -0,0 +1,87 @@ +// match_detail_helper 演示调用方如何把 SDK 的 MatchResult 转成自己的 DTO。 +// +// 核心 matcher detail / match_url 逻辑已经在 sdk/fingers 公共 API 中: +// +// eng.MatchHTTPWithDetail(resp) -> []fingers.MatchResult +// +// 这一份只保留应用层字段映射,不再让调用方复制 EnableMatchDetail、 +// SendData 解析、fallback URL 等 SDK 内部细节。 +package main + +import ( + "fmt" + "net/http" + + "github.com/chainreactors/fingers/common" + sdkfingers "github.com/chainreactors/sdk/fingers" +) + +func main() { + fmt.Println("This example is exercised via `go test ./examples/cases/match_detail_helper`.") +} + +// FingerMatch 是给上层的扁平、易序列化结构。 +type FingerMatch struct { + Name string `json:"name"` + Version string `json:"version,omitempty"` + Tags []string `json:"tags,omitempty"` + Attributes *common.Attributes `json:"attributes,omitempty"` + MatchURL string `json:"match_url,omitempty"` + MatcherType string `json:"matcher_type,omitempty"` + MatcherValue string `json:"matcher_value,omitempty"` + RuleIndex int `json:"rule_index"` + SendData string `json:"send_data,omitempty"` +} + +// FromMatchResults 把 SDK MatchResult 拍平成调用方自己的结构。 +func FromMatchResults(results []sdkfingers.MatchResult) []FingerMatch { + out := make([]FingerMatch, 0, len(results)) + for _, r := range results { + out = append(out, FromMatchResult(r)) + } + return out +} + +// FromMatchResult 转换单条 SDK MatchResult。 +func FromMatchResult(r sdkfingers.MatchResult) FingerMatch { + fm := FingerMatch{ + MatchURL: r.MatchURL, + MatcherType: r.MatcherType, + MatcherValue: r.MatcherValue, + RuleIndex: r.RuleIndex, + SendData: r.SendData, + } + if r.Framework == nil { + return fm + } + fm.Name = r.Framework.Name + fm.Tags = r.Framework.Tags + fm.Attributes = r.Framework.Attributes + if r.Framework.Attributes != nil { + fm.Version = r.Framework.Attributes.Version + } + return fm +} + +// DetectFingersDetail 演示单次 HTTP 被动匹配,返回调用方自己的 FingerMatch 列表。 +func DetectFingersDetail(target, cyberhubURL, apiKey string) ([]FingerMatch, error) { + cfg := sdkfingers.NewConfig() + if cyberhubURL != "" { + cfg.WithCyberhub(cyberhubURL, apiKey) + } + eng, err := sdkfingers.NewEngine(cfg) + if err != nil { + return nil, err + } + resp, err := http.Get(target) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + results, err := eng.MatchHTTPWithDetail(resp) + if err != nil { + return nil, err + } + return FromMatchResults(results), nil +} diff --git a/examples/cases/match_detail_helper/helper_test.go b/examples/cases/match_detail_helper/helper_test.go new file mode 100644 index 0000000..c1e0eee --- /dev/null +++ b/examples/cases/match_detail_helper/helper_test.go @@ -0,0 +1,96 @@ +// helper_test.go 既是 helper.go 的回归测试,也是它最小的使用演示。 +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/chainreactors/fingers/common" + fingersEngine "github.com/chainreactors/fingers/fingers" + sdkfingers "github.com/chainreactors/sdk/fingers" +) + +func detectInlineForTest(t *testing.T, target, marker, fingerName string) []FingerMatch { + t.Helper() + finger := &fingersEngine.Finger{ + Name: fingerName, + Protocol: "http", + Rules: fingersEngine.Rules{{Regexps: &fingersEngine.Regexps{Body: []string{marker}}}}, + } + eng, err := sdkfingers.NewEngine( + sdkfingers.NewConfig().WithFingers(fingersEngine.Fingers{finger}), + ) + if err != nil { + t.Fatal(err) + } + resp, err := http.Get(target) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + results, err := eng.MatchHTTPWithDetail(resp) + if err != nil { + t.Fatal(err) + } + return FromMatchResults(results) +} + +func TestFromMatchResults(t *testing.T) { + framework := &common.Framework{ + Name: "WordPress", + Tags: []string{"cms"}, + Attributes: &common.Attributes{Version: "6.4"}, + } + out := FromMatchResults([]sdkfingers.MatchResult{{ + Framework: framework, + MatchURL: "https://x.test/", + MatcherType: "word", + MatcherValue: "wp-content", + RuleIndex: 3, + SendData: "scope=currentpath method=GET url=https://x.test/", + }}) + if len(out) != 1 { + t.Fatalf("expected 1 match, got %d", len(out)) + } + m := out[0] + if m.Name != "WordPress" || m.Version != "6.4" { + t.Fatalf("framework fields not mapped: %+v", m) + } + if m.MatchURL != "https://x.test/" || m.MatcherType != "word" || m.MatcherValue != "wp-content" || m.RuleIndex != 3 { + t.Fatalf("detail fields not mapped: %+v", m) + } +} + +func TestFromMatchResult_NilFramework(t *testing.T) { + m := FromMatchResult(sdkfingers.MatchResult{MatchURL: "https://x.test/"}) + if m.MatchURL != "https://x.test/" { + t.Fatalf("MatchURL should survive nil framework, got %+v", m) + } +} + +// TestDetectFingersDetail_E2E 端到端:用 inline finger + httptest 跑通 +// MatchHTTPWithDetail -> FingerMatch 的完整链路。 +func TestDetectFingersDetail_E2E(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("HelperDemoMarker in body")) + })) + defer srv.Close() + + matches := detectInlineForTest(t, srv.URL, "HelperDemoMarker", "helper-demo") + if len(matches) != 1 { + t.Fatalf("expected 1 match, got %d", len(matches)) + } + m := matches[0] + if m.Name != "helper-demo" { + t.Fatalf("expected helper-demo, got %q", m.Name) + } + if m.MatchURL != srv.URL { + t.Fatalf("MatchURL should fall back to request URL %q, got %q", srv.URL, m.MatchURL) + } + if m.MatcherType == "" || m.MatcherValue == "" { + t.Fatalf("matcher fields empty: %+v", m) + } + t.Logf("OK: %+v", m) +} diff --git a/fingers/match_detail.go b/fingers/match_detail.go new file mode 100644 index 0000000..e3e7a5c --- /dev/null +++ b/fingers/match_detail.go @@ -0,0 +1,143 @@ +package fingers + +import ( + "net/http" + "strings" + + "github.com/chainreactors/fingers/common" + "github.com/chainreactors/utils/httputils" +) + +// MatchResult is a flattened, easy-to-consume representation of a single +// fingerprint hit. It exposes the matcher metadata and the URL the match +// was attributed to, while keeping the original *common.Framework pointer +// for callers that need the full payload (attributes, tags, etc.). +type MatchResult struct { + // Framework is the original framework returned by the underlying engine. + Framework *common.Framework `json:"framework"` + + // MatchURL is the URL the match was attributed to. Resolution order: + // 1. MatchDetail.SendData's "url=" segment (populated by active + // probing flows that supply an explicit probe URL). + // 2. The fallback URL passed to MatchWithDetail / inferred from + // resp.Request.URL by MatchHTTPWithDetail (the common case for + // passive matching, which doesn't itself emit a URL). + MatchURL string `json:"match_url,omitempty"` + + // Convenience copies of MatchDetail. Empty when matcher detail has not + // been enabled or the engine did not produce matcher information. + MatcherType string `json:"matcher_type,omitempty"` + MatcherValue string `json:"matcher_value,omitempty"` + RuleIndex int `json:"rule_index"` + SendData string `json:"send_data,omitempty"` +} + +// EnableMatchDetail toggles matcher detail collection on the underlying +// fingers engine. Idempotent and cheap on subsequent calls. +// +// MatchWithDetail / MatchHTTPWithDetail invoke this automatically on first +// use; call it explicitly if you also want plain Match() / MatchHTTP() to +// fill MatchDetail on the returned *common.Framework. +// +// Background: NewEngine internally runs Compile() on the loaded fingers, +// which resets each finger's per-rule EnableMatchDetail flag back to the +// engine default (false). EnableMatchDetail flips both the engine flag +// and every per-finger flag, ensuring matcher metadata is collected on +// subsequent matches. +func (e *Engine) EnableMatchDetail() { + if e == nil || e.engine == nil { + return + } + fe, err := e.GetFingersEngine() + if err != nil || fe == nil || fe.MatchDetailEnabled { + return + } + fe.EnableMatchDetail() +} + +// MatchWithDetail is the detail-aware counterpart of Match. It runs the +// same passive content match and returns []MatchResult with matcher +// metadata flattened from common.Framework.MatchDetail. +// +// fallbackURL is used as MatchResult.MatchURL when MatchDetail.SendData +// does not contain a "url=" segment (the common case for passive +// matching). Pass the URL of the request whose response body you are +// matching; if you only have a raw byte slice with no associated URL, +// pass "". +func (e *Engine) MatchWithDetail(data []byte, fallbackURL string) ([]MatchResult, error) { + if e == nil || e.engine == nil { + return nil, nil + } + e.EnableMatchDetail() + frames, err := e.engine.DetectContent(data) + if err != nil { + return nil, err + } + return flattenMatchResults(frames, fallbackURL), nil +} + +// MatchHTTPWithDetail is the detail-aware counterpart of MatchHTTP. It +// auto-enables matcher detail and returns []MatchResult, using +// resp.Request.URL.String() as the MatchURL fallback when MatchDetail +// does not carry an explicit probe URL. +func (e *Engine) MatchHTTPWithDetail(resp *http.Response) ([]MatchResult, error) { + if e == nil || e.engine == nil { + return nil, nil + } + e.EnableMatchDetail() + + fallbackURL := "" + if resp != nil && resp.Request != nil && resp.Request.URL != nil { + fallbackURL = resp.Request.URL.String() + } + var data []byte + if resp != nil { + data = httputils.ReadRaw(resp) + } + frames, err := e.engine.DetectContent(data) + if err != nil { + return nil, err + } + return flattenMatchResults(frames, fallbackURL), nil +} + +func flattenMatchResults(frames common.Frameworks, fallbackURL string) []MatchResult { + out := make([]MatchResult, 0, len(frames)) + for _, f := range frames { + if f == nil { + continue + } + r := MatchResult{Framework: f, MatchURL: fallbackURL} + if d := f.MatchDetail; d != nil { + r.MatcherType = d.MatcherType + r.MatcherValue = d.MatcherValue + r.RuleIndex = d.RuleIndex + r.SendData = d.SendData + if u := extractMatchURL(d.SendData); u != "" { + r.MatchURL = u + } + } + out = append(out, r) + } + return out +} + +// extractMatchURL extracts the value of the "url=" segment from a SendData +// string formatted as "scope=... method=... url=". The URL value may +// contain '=' characters (query params), so we take everything from "url=" +// to end-of-string after locating the segment at a word boundary. +func extractMatchURL(sendData string) string { + const tag = "url=" + for start := 0; start < len(sendData); { + i := strings.Index(sendData[start:], tag) + if i < 0 { + return "" + } + i += start + if i == 0 || sendData[i-1] == ' ' { + return strings.TrimSpace(sendData[i+len(tag):]) + } + start = i + len(tag) + } + return "" +} diff --git a/fingers/match_detail_test.go b/fingers/match_detail_test.go new file mode 100644 index 0000000..e38a38a --- /dev/null +++ b/fingers/match_detail_test.go @@ -0,0 +1,156 @@ +package fingers + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/chainreactors/fingers/common" + fingersEngine "github.com/chainreactors/fingers/fingers" +) + +// TestMatchHTTPWithDetail_HappyPath: 端到端验证客户视角的最短调用。 +// 不需要调 EnableMatchDetail / GetFingersEngine / 解析 SendData。 +func TestMatchHTTPWithDetail_HappyPath(t *testing.T) { + eng := newTestEngine(t, "demo-app", "DemoMarker") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("body with DemoMarker")) + })) + defer srv.Close() + resp, err := http.Get(srv.URL) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + results, err := eng.MatchHTTPWithDetail(resp) + if err != nil { + t.Fatal(err) + } + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + r := results[0] + if r.Framework == nil || r.Framework.Name != "demo-app" { + t.Fatalf("Framework not preserved: %+v", r) + } + if r.MatchURL != srv.URL { + t.Fatalf("MatchURL should fall back to request URL %q, got %q", srv.URL, r.MatchURL) + } + if r.MatcherType == "" || r.MatcherValue == "" { + t.Fatalf("matcher fields empty: %+v", r) + } +} + +// TestMatchWithDetail_FallbackURL: 已有原始字节 + 传入 URL 的场景 +func TestMatchWithDetail_FallbackURL(t *testing.T) { + eng := newTestEngine(t, "raw-app", "RawMarker") + raw := []byte("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\nbody with RawMarker here") + results, err := eng.MatchWithDetail(raw, "https://provided.example/path") + if err != nil { + t.Fatal(err) + } + if len(results) == 0 { + t.Fatal("expected at least one result") + } + if results[0].MatchURL != "https://provided.example/path" { + t.Fatalf("MatchURL fallback not used; got %q", results[0].MatchURL) + } +} + +// TestEnableMatchDetail_AffectsPlainMatch: 显式调用 EnableMatchDetail 后, +// 即便走 plain Match() 也能拿到 MatchDetail +func TestEnableMatchDetail_AffectsPlainMatch(t *testing.T) { + eng := newTestEngine(t, "plain-app", "PlainMarker") + eng.EnableMatchDetail() + frames, err := eng.Match([]byte("HTTP/1.1 200 OK\r\n\r\nPlainMarker")) + if err != nil { + t.Fatal(err) + } + fw, ok := frames["plain-app"] + if !ok { + t.Fatalf("expected plain-app, got: %v", frames) + } + if fw.MatchDetail == nil { + t.Fatal("MatchDetail should be populated after EnableMatchDetail()") + } +} + +// TestPlainMatchWithoutEnable_NoMatchDetail: 反向对照,不调 EnableMatchDetail +// 时 plain Match 不应填充 MatchDetail (证明 EnableMatchDetail 是必需的) +func TestPlainMatchWithoutEnable_NoMatchDetail(t *testing.T) { + eng := newTestEngine(t, "off-app", "OffMarker") + frames, _ := eng.Match([]byte("HTTP/1.1 200 OK\r\n\r\nOffMarker")) + if fw, ok := frames["off-app"]; ok && fw.MatchDetail != nil { + t.Fatalf("MatchDetail should be nil without EnableMatchDetail(), got %+v", *fw.MatchDetail) + } +} + +// TestFlattenMatchResults_SendDataURLWins: SendData 含 url= 时优先用它 +func TestFlattenMatchResults_SendDataURLWins(t *testing.T) { + frames := common.Frameworks{ + "x": &common.Framework{ + Name: "x", + MatchDetail: &common.MatchDetail{ + MatcherType: "word", + MatcherValue: "foo", + SendData: "scope=cp method=GET url=https://active.example/admin", + }, + }, + } + out := flattenMatchResults(frames, "https://fallback.example/") + if len(out) != 1 || out[0].MatchURL != "https://active.example/admin" { + t.Fatalf("expected MatchURL from SendData, got %+v", out) + } +} + +// TestExtractMatchURL: 词边界 / 含 = 的 url value / 缺失 / 子串误匹配 +func TestExtractMatchURL(t *testing.T) { + cases := []struct{ in, want string }{ + {"", ""}, + {"url=https://x.test/a", "https://x.test/a"}, + {"scope=cp method=GET url=https://x.test/p", "https://x.test/p"}, + {"url=https://x.test/api?next=https://y/foo=1", "https://x.test/api?next=https://y/foo=1"}, + {"scope=cp method=GET", ""}, + {"scope=anonymous_url=x method=GET", ""}, + } + for _, c := range cases { + if got := extractMatchURL(c.in); got != c.want { + t.Errorf("extractMatchURL(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestDetailMethods_NilSafe(t *testing.T) { + var nilEngine *Engine + nilEngine.EnableMatchDetail() + + results, err := nilEngine.MatchWithDetail(nil, "https://fallback.example/") + if err != nil || results != nil { + t.Fatalf("nil MatchWithDetail = (%v, %v), want (nil, nil)", results, err) + } + + results, err = nilEngine.MatchHTTPWithDetail(nil) + if err != nil || results != nil { + t.Fatalf("nil MatchHTTPWithDetail = (%v, %v), want (nil, nil)", results, err) + } + + emptyEngine := &Engine{} + results, err = emptyEngine.MatchHTTPWithDetail(nil) + if err != nil || results != nil { + t.Fatalf("empty MatchHTTPWithDetail = (%v, %v), want (nil, nil)", results, err) + } +} + +func newTestEngine(t *testing.T, name, marker string) *Engine { + t.Helper() + eng, err := NewEngine(NewConfig().WithFingers(fingersEngine.Fingers{{ + Name: name, + Protocol: "http", + Rules: fingersEngine.Rules{{Regexps: &fingersEngine.Regexps{Body: []string{marker}}}}, + }})) + if err != nil { + t.Fatal(err) + } + return eng +}