github.com/chainreactors/fingers@v1.2.1/fingerprinthub/fingerprinthub_test.go (about) 1 package fingerprinthub 2 3 import ( 4 "testing" 5 6 "github.com/chainreactors/fingers/resources" 7 "github.com/chainreactors/neutron/operators" 8 "github.com/chainreactors/neutron/protocols" 9 "github.com/chainreactors/neutron/templates" 10 "github.com/chainreactors/utils/encode" 11 ) 12 13 // ============================================================================ 14 // 基础功能测试 15 // ============================================================================ 16 17 func TestFingerPrintHubEngine_Basic(t *testing.T) { 18 engine, err := NewFingerPrintHubEngine(resources.FingerprinthubWebData, resources.FingerprinthubServiceData) 19 if err != nil { 20 t.Fatalf("Failed to create engine: %v", err) 21 } 22 23 if engine.Name() != "fingerprinthub" { 24 t.Errorf("Expected engine name 'fingerprinthub', got '%s'", engine.Name()) 25 } 26 27 // 引擎自动加载嵌入的指纹数据 (web + service) 28 if engine.Len() == 0 { 29 t.Error("Expected templates to be auto-loaded from embedded resources") 30 } 31 32 t.Logf("Loaded %d templates from embedded resources", engine.Len()) 33 34 capability := engine.Capability() 35 if !capability.SupportWeb { 36 t.Error("Expected engine to support web fingerprinting") 37 } 38 if !capability.SupportService { 39 t.Error("Expected engine to support service fingerprinting") 40 } 41 42 t.Logf("✅ Engine created successfully") 43 t.Logf(" Name: %s", engine.Name()) 44 t.Logf(" Capability: Web=%v, Service=%v", capability.SupportWeb, capability.SupportService) 45 } 46 47 func TestFingerPrintHubEngine_WebMatch(t *testing.T) { 48 engine, err := NewFingerPrintHubEngine(resources.FingerprinthubWebData, resources.FingerprinthubServiceData) 49 if err != nil { 50 t.Fatalf("Failed to create engine: %v", err) 51 } 52 53 // 测试空引擎 54 testResponse := []byte("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n<html><body>Test</body></html>") 55 frames := engine.WebMatch(testResponse) 56 if len(frames) != 0 { 57 t.Errorf("Expected 0 matches from empty engine, got %d", len(frames)) 58 } 59 60 t.Logf("✅ Empty engine correctly returns no matches") 61 } 62 63 // ============================================================================ 64 // CaseInsensitive 开关测试 65 // 66 // 模拟真实指纹场景,覆盖所有 matcher 类型 × match part × 两种模式。 67 // 68 // 测试用的 HTTP 响应模拟一个典型的 Nacos 控制台页面: 69 // - Server: Tengine/2.3.3 (header 混合大小写) 70 // - body 包含 <title>Nacos</title>、版本号、JS 路径等混合大小写内容 71 // ============================================================================ 72 73 func newTestEngine(caseInsensitive bool) *FingerPrintHubEngine { 74 return &FingerPrintHubEngine{ 75 CaseInsensitive: caseInsensitive, 76 webTemplates: make([]*templates.Template, 0), 77 executerOptions: &protocols.ExecuterOptions{Options: &protocols.Options{Timeout: 10}}, 78 } 79 } 80 81 const testHTTPRaw = "HTTP/1.1 200 OK\r\n" + 82 "Server: Tengine/2.3.3\r\n" + 83 "Content-Type: text/html; charset=UTF-8\r\n" + 84 "X-Powered-By: Nacos-Server/2.1.0\r\n" + 85 "\r\n" + 86 `<html><head><title>Nacos</title></head><body>` + 87 `<div id="root"><script src="console-ui/public/js/vs/loader/loader.js"></script>` + 88 `<p>Welcome to Nacos v2.1.0</p></body></html>` 89 90 func buildTestTemplates() []map[string]interface{} { 91 info := func(name string) map[string]interface{} { 92 return map[string]interface{}{"name": name, "severity": "info"} 93 } 94 httpReq := func(matchers []interface{}) []interface{} { 95 return []interface{}{ 96 map[string]interface{}{ 97 "method": "GET", "path": []interface{}{"{{BaseURL}}/"}, 98 "matchers": matchers, 99 }, 100 } 101 } 102 httpReqAnd := func(matchers []interface{}) []interface{} { 103 return []interface{}{ 104 map[string]interface{}{ 105 "method": "GET", "path": []interface{}{"{{BaseURL}}/"}, 106 "matchers-condition": "and", 107 "matchers": matchers, 108 }, 109 } 110 } 111 112 return []map[string]interface{}{ 113 // ── Word matcher: body ── 114 // keywords 用混合大小写,CaseInsensitive 模式下 neutron 会 ToLower 双方 115 { 116 "id": "word-body-mixed", "info": info("Word Body Mixed"), 117 "http": httpReq([]interface{}{ 118 map[string]interface{}{"type": "word", "words": []interface{}{"<title>Nacos</title>"}}, 119 }), 120 }, 121 // keywords 用小写,两种模式都应该匹配 122 { 123 "id": "word-body-lower", "info": info("Word Body Lower"), 124 "http": httpReq([]interface{}{ 125 map[string]interface{}{"type": "word", "words": []interface{}{"<title>nacos</title>"}}, 126 }), 127 }, 128 // keywords 用全大写,只有 CaseInsensitive 模式匹配 129 { 130 "id": "word-body-upper", "info": info("Word Body Upper"), 131 "http": httpReq([]interface{}{ 132 map[string]interface{}{"type": "word", "words": []interface{}{"<TITLE>NACOS</TITLE>"}}, 133 }), 134 }, 135 136 // ── Word matcher: header ── 137 // 检查 Server header(原始 "Tengine/2.3.3") 138 { 139 "id": "word-header-mixed", "info": info("Word Header Mixed"), 140 "http": httpReq([]interface{}{ 141 map[string]interface{}{"type": "word", "part": "header", "words": []interface{}{"Tengine/2.3.3"}}, 142 }), 143 }, 144 { 145 "id": "word-header-lower", "info": info("Word Header Lower"), 146 "http": httpReq([]interface{}{ 147 map[string]interface{}{"type": "word", "part": "header", "words": []interface{}{"tengine/2.3.3"}}, 148 }), 149 }, 150 151 // ── Word matcher: AND 条件(多关键词同时命中)── 152 { 153 "id": "word-and-body", "info": info("Word AND Body"), 154 "http": httpReqAnd([]interface{}{ 155 map[string]interface{}{"type": "word", "words": []interface{}{"nacos"}}, 156 map[string]interface{}{"type": "word", "part": "header", "words": []interface{}{"tengine"}}, 157 }), 158 }, 159 160 // ── Regex matcher: body ── 161 // 提取版本号 — 小写 pattern 162 { 163 "id": "regex-body-version", "info": info("Regex Body Version"), 164 "http": httpReq([]interface{}{ 165 map[string]interface{}{"type": "regex", "regex": []interface{}{`nacos v[\d.]+`}}, 166 }), 167 }, 168 // 混合大小写 pattern — 仅 CaseSensitive 模式匹配 169 { 170 "id": "regex-body-mixed", "info": info("Regex Body Mixed"), 171 "http": httpReq([]interface{}{ 172 map[string]interface{}{"type": "regex", "regex": []interface{}{`Nacos v[\d.]+`}}, 173 }), 174 }, 175 176 // ── Regex matcher: header ── 177 // 从 header 提取 Tengine 版本 178 { 179 "id": "regex-header-version", "info": info("Regex Header Version"), 180 "http": httpReq([]interface{}{ 181 map[string]interface{}{"type": "regex", "part": "header", "regex": []interface{}{`tengine/[\d.]+`}}, 182 }), 183 }, 184 185 // ── DSL matcher: body ── 186 // 使用 contains + status_code 组合 187 { 188 "id": "dsl-body-lower", "info": info("DSL Body Lower"), 189 "http": httpReq([]interface{}{ 190 map[string]interface{}{ 191 "type": "dsl", 192 "dsl": []interface{}{`status_code == 200 && contains(body, "<title>nacos</title>")`}, 193 }, 194 }), 195 }, 196 // 混合大小写字面量 — 仅 CaseSensitive 模式匹配 197 { 198 "id": "dsl-body-mixed", "info": info("DSL Body Mixed"), 199 "http": httpReq([]interface{}{ 200 map[string]interface{}{ 201 "type": "dsl", 202 "dsl": []interface{}{`contains(body, "<title>Nacos</title>")`}, 203 }, 204 }), 205 }, 206 207 // ── DSL matcher: header ── 208 // 检查 X-Powered-By header(通过 all_headers) 209 { 210 "id": "dsl-header", "info": info("DSL Header"), 211 "http": httpReq([]interface{}{ 212 map[string]interface{}{ 213 "type": "dsl", 214 "dsl": []interface{}{`contains(all_headers, "nacos-server")`}, 215 }, 216 }), 217 }, 218 219 // ── Status matcher(不受大小写影响)── 220 { 221 "id": "status-200", "info": info("Status 200"), 222 "http": httpReq([]interface{}{ 223 map[string]interface{}{"type": "status", "status": []interface{}{200}}, 224 }), 225 }, 226 227 // ── AND 组合:word(header) + regex(body) + status ── 228 { 229 "id": "combo-and", "info": info("Combo AND"), 230 "http": httpReqAnd([]interface{}{ 231 map[string]interface{}{"type": "status", "status": []interface{}{200}}, 232 map[string]interface{}{"type": "word", "part": "header", "words": []interface{}{"Tengine"}}, 233 map[string]interface{}{"type": "regex", "regex": []interface{}{`console-ui/public/js`}}, 234 }), 235 }, 236 } 237 } 238 239 func TestCaseInsensitive_AllMatchers(t *testing.T) { 240 engine := newTestEngine(true) 241 242 tmpls := buildTestTemplates() 243 count, errs := engine.loadTemplates(tmpls, true) 244 if count != len(tmpls) { 245 t.Fatalf("loaded %d/%d, errors: %v", count, len(tmpls), errs) 246 } 247 engine.webTemplateIndex = NewTemplateKeywordIndex(engine.webTemplates) 248 249 frames := engine.WebMatch([]byte(testHTTPRaw)) 250 matched := frameworkNames(frames) 251 252 // CaseInsensitive=true 时,所有字面量/keywords/patterns 只要大小写无关就能匹配 253 expect := map[string]bool{ 254 "word body mixed": true, 255 "word body lower": true, 256 "word body upper": true, 257 "word header mixed": true, 258 "word header lower": true, 259 "word and body": true, 260 "regex body version": true, 261 "regex body mixed": false, // regex 引擎不受 CaseInsensitive 控制,pattern "Nacos" 匹配不到小写 body 262 "regex header version": true, 263 "dsl body lower": true, 264 "dsl body mixed": false, // body 已 ToLower,"<title>Nacos" 匹配不到 265 "dsl header": true, 266 "status 200": true, 267 "combo and": true, 268 } 269 270 for name, shouldMatch := range expect { 271 _, got := frames[name] 272 if got != shouldMatch { 273 t.Errorf("CaseInsensitive=true: %q expected=%v got=%v", name, shouldMatch, got) 274 } 275 } 276 t.Logf("CaseInsensitive=true: %d matched: %v", len(matched), matched) 277 } 278 279 func TestCaseSensitive_AllMatchers(t *testing.T) { 280 engine := newTestEngine(false) 281 282 tmpls := buildTestTemplates() 283 count, errs := engine.loadTemplates(tmpls, true) 284 if count != len(tmpls) { 285 t.Fatalf("loaded %d/%d, errors: %v", count, len(tmpls), errs) 286 } 287 engine.webTemplateIndex = NewTemplateKeywordIndex(engine.webTemplates) 288 289 frames := engine.WebMatch([]byte(testHTTPRaw)) 290 matched := frameworkNames(frames) 291 292 // CaseSensitive 时 body/header 保留原始大小写 293 // body: "<title>Nacos</title>...Nacos v2.1.0" 294 // header value: "Tengine/2.3.3", "Nacos-Server/2.1.0" 295 expect := map[string]bool{ 296 "word body mixed": true, // "Nacos" 在原始 body 中存在(case-sensitive word 无 ToLower) 297 "word body lower": false, // "<title>nacos" 不在原始 body 中 298 "word body upper": false, // "<TITLE>NACOS" 不在原始 body 中 299 "word header mixed": true, // "Tengine/2.3.3" 在 header value 中 300 "word header lower": false, // "tengine/2.3.3" — header key 始终小写,但 value 保留原始 "Tengine/2.3.3" 301 "word and body": false, // "nacos" 不在原始 body (小写 n);即使 header 匹配,AND 也失败 302 "regex body version": false, // "nacos v[\d.]+" — body 中是 "Nacos v2.1.0",小写 n 匹配不到 303 "regex body mixed": true, // "Nacos v[\d.]+" 精确匹配原始大小写 304 "regex header version": false, // "tengine/[\d.]+" — header value 是 "Tengine/2.3.3" 305 "dsl body lower": false, // contains(body, "<title>nacos</title>") — body 中是 Nacos 306 "dsl body mixed": true, // contains(body, "<title>Nacos</title>") 精确匹配 307 "dsl header": false, // contains(all_headers, "nacos-server") — header value 保留了 "Nacos-Server" 308 "status 200": true, // 不受大小写影响 309 "combo and": true, // status=200 ✓, "Tengine" 在 header ✓, "console-ui" 在 body ✓ 310 } 311 312 for name, shouldMatch := range expect { 313 _, got := frames[name] 314 if got != shouldMatch { 315 t.Errorf("CaseSensitive: %q expected=%v got=%v", name, shouldMatch, got) 316 } 317 } 318 t.Logf("CaseSensitive: %d matched: %v", len(matched), matched) 319 } 320 321 // TestCaseInsensitive_LoadFromJSON 验证 LoadFromJSON 路径也正确应用 CaseInsensitive。 322 // 这是之前 LoadFromJSON 漏设 CaseInsensitive 的回归测试。 323 func TestCaseInsensitive_LoadFromJSON(t *testing.T) { 324 engine := newTestEngine(true) 325 326 jsonData := []byte(`[{ 327 "id": "json-word-mixed", 328 "info": {"name": "JSON Word Mixed", "severity": "info"}, 329 "http": [{ 330 "method": "GET", 331 "path": ["{{BaseURL}}/"], 332 "matchers": [{"type": "word", "words": ["<title>Nacos"]}] 333 }] 334 }]`) 335 336 if err := engine.LoadFromJSON(jsonData); err != nil { 337 t.Fatalf("LoadFromJSON: %v", err) 338 } 339 engine.webTemplateIndex = NewTemplateKeywordIndex(engine.webTemplates) 340 341 frames := engine.WebMatch([]byte(testHTTPRaw)) 342 if _, ok := frames["json word mixed"]; !ok { 343 t.Fatal("LoadFromJSON: word matcher with mixed-case keywords should match in CaseInsensitive mode") 344 } 345 t.Logf("LoadFromJSON CaseInsensitive=true: matched %v", frameworkNames(frames)) 346 } 347 348 // ============================================================================ 349 // Favicon Matcher 单元测试 350 // ============================================================================ 351 352 func TestCalculateFaviconHash(t *testing.T) { 353 // 测试空内容 354 hashes := calculateFaviconHash([]byte{}) 355 if hashes != nil { 356 t.Errorf("Expected nil for empty content, got %v", hashes) 357 } 358 359 // 测试有内容的情况 360 content := []byte("test favicon content") 361 hashes = calculateFaviconHash(content) 362 363 if len(hashes) != 2 { 364 t.Errorf("Expected 2 hashes (md5, mmh3), got %d", len(hashes)) 365 } 366 367 // 验证 MD5 hash 368 expectedMd5 := encode.Md5Hash(content) 369 if hashes[0] != expectedMd5 { 370 t.Errorf("MD5 hash mismatch: expected %s, got %s", expectedMd5, hashes[0]) 371 } 372 373 // 验证 MMH3 hash 374 expectedMmh3 := encode.Mmh3Hash32(content) 375 if hashes[1] != expectedMmh3 { 376 t.Errorf("MMH3 hash mismatch: expected %s, got %s", expectedMmh3, hashes[1]) 377 } 378 379 t.Logf("✅ Favicon hashes calculated correctly") 380 t.Logf(" MD5: %s", hashes[0]) 381 t.Logf(" MMH3: %s", hashes[1]) 382 } 383 384 func TestNeutronFaviconMatcher(t *testing.T) { 385 // 验证 favicon matcher 类型已注册 386 matcher := &operators.Matcher{ 387 Type: "favicon", 388 Hash: []string{"testhash1", "testhash2"}, 389 } 390 391 err := matcher.CompileMatchers() 392 if err != nil { 393 t.Fatalf("Failed to compile favicon matcher: %v", err) 394 } 395 396 if matcher.GetType() != operators.FaviconMatcher { 397 t.Errorf("Expected FaviconMatcher type, got %d", matcher.GetType()) 398 } 399 400 t.Logf("✅ Neutron favicon matcher type registered correctly") 401 } 402 403 func TestFaviconMatcher_Matching(t *testing.T) { 404 // 创建 favicon matcher 405 matcher := &operators.Matcher{ 406 Type: "favicon", 407 Hash: []string{"hash1", "hash2"}, 408 } 409 410 err := matcher.CompileMatchers() 411 if err != nil { 412 t.Fatalf("Failed to compile matcher: %v", err) 413 } 414 415 // 测试匹配成功 416 faviconData := map[string]interface{}{ 417 "http://example.com/favicon.ico": []string{"hash1", "hash3"}, 418 } 419 420 matched, matchedHashes := matcher.MatchFavicon(faviconData) 421 if !matched { 422 t.Errorf("Expected favicon to match") 423 } 424 425 if len(matchedHashes) == 0 { 426 t.Errorf("Expected matched hashes to be returned") 427 } 428 429 t.Logf("✅ Favicon matching works correctly") 430 t.Logf(" Matched: %v", matched) 431 t.Logf(" Matched hashes: %v", matchedHashes) 432 433 // 测试不匹配 434 wrongFaviconData := map[string]interface{}{ 435 "http://example.com/favicon.ico": []string{"wronghash1", "wronghash2"}, 436 } 437 438 matched, _ = matcher.MatchFavicon(wrongFaviconData) 439 if matched { 440 t.Errorf("Expected favicon not to match with wrong hashes") 441 } 442 443 t.Logf("✅ Favicon non-matching works correctly") 444 } 445 446 func TestExtractFaviconFromResponse(t *testing.T) { 447 // 测试 nil 响应 448 result := extractFaviconFromResponse(nil, []byte("test")) 449 if len(result) != 0 { 450 t.Errorf("Expected empty result for nil response, got %d items", len(result)) 451 } 452 453 t.Logf("✅ Handles nil response correctly") 454 } 455 456 func TestIsImageContent(t *testing.T) { 457 tests := []struct { 458 name string 459 contentType string 460 body string 461 expected bool 462 }{ 463 { 464 name: "Image content type", 465 contentType: "image/x-icon", 466 body: "binary image data", 467 expected: true, 468 }, 469 { 470 name: "HTML content", 471 contentType: "text/html", 472 body: "<html><head><title>Test</title></head></html>", 473 expected: false, 474 }, 475 } 476 477 for _, tt := range tests { 478 t.Run(tt.name, func(t *testing.T) { 479 // 简单验证逻辑 480 hasImageType := len(tt.contentType) > 0 && contains(tt.contentType, "image/") 481 hasHTMLTags := contains(tt.body, "<html") || contains(tt.body, "<head") 482 483 result := hasImageType || !hasHTMLTags 484 if result != tt.expected { 485 t.Errorf("Expected %v, got %v for %s", tt.expected, result, tt.name) 486 } 487 }) 488 } 489 490 t.Logf("✅ Image content detection works correctly") 491 } 492 493 func contains(s, substr string) bool { 494 return len(s) >= len(substr) && anyMatch(s, substr) 495 } 496 497 func anyMatch(s, substr string) bool { 498 for i := 0; i <= len(s)-len(substr); i++ { 499 if s[i:i+len(substr)] == substr { 500 return true 501 } 502 } 503 return false 504 }