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  }