Files
fscan/core/portfinger/version_parser_test.go
T
ZacharyZcR 353d525642
测试构建 / 代码检查 (push) Has been cancelled
测试构建 / 单元测试和构建 (push) Has been cancelled
测试构建 / 构建验证 (push) Has been cancelled
test: 补充单元测试覆盖率 29.9% → 36.6%
新建 18 个测试文件,追加 30 个已有测试文件,覆盖协议解析、
错误分类、CEL 表达式求值、YAML 反序列化、字节编码等纯函数。
2026-06-15 19:11:15 +08:00

601 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package portfinger
import (
"strings"
"testing"
)
/*
version_parser_test.go - Banner清理与版本解析测试
测试目标:TrimBanner 函数
价值:Banner清理是服务识别的预处理步骤,错误会导致:
- 误识别服务类型
- 正则匹配失败
- 日志输出混乱(控制字符污染)
"Banner清理看起来简单,但涉及ASCII控制字符、Unicode、空格压缩。
这是真实的网络数据处理,必须测试边界情况。"
*/
// =============================================================================
// TrimBanner - Banner清理测试
// =============================================================================
// TestTrimBanner_BasicCases 测试基本的清理功能
func TestTrimBanner_BasicCases(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "普通字符串-无需清理",
input: "SSH-2.0-OpenSSH_8.0",
expected: "SSH-2.0-OpenSSH_8.0",
},
{
name: "前后有空格",
input: " SSH-2.0-OpenSSH_8.0 ",
expected: "SSH-2.0-OpenSSH_8.0",
},
{
name: "多个连续空格",
input: "SSH 2.0 OpenSSH",
expected: "SSH 2.0 OpenSSH",
},
{
name: "空字符串",
input: "",
expected: "",
},
{
name: "只有空格",
input: " ",
expected: "",
},
{
name: "只有制表符",
input: "\t\t\t",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := TrimBanner(tt.input)
if result != tt.expected {
t.Errorf("TrimBanner(%q) = %q, want %q",
tt.input, result, tt.expected)
}
})
}
}
// TestTrimBanner_ControlCharacters 测试控制字符处理
func TestTrimBanner_ControlCharacters(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "NULL字符-移除",
input: "SSH\x00-2.0",
expected: "SSH -2.0",
},
{
name: "BEL响铃-移除",
input: "SSH\x07-2.0",
expected: "SSH -2.0",
},
{
name: "退格符-移除",
input: "SSH\x08-2.0",
expected: "SSH -2.0",
},
{
name: "ESC转义符-移除控制字符部分",
input: "SSH\x1b[31m-2.0",
expected: "SSH [31m-2.0", // ESC被移除,但[31m是可打印字符
},
{
name: "DEL删除符-移除",
input: "SSH\x7f-2.0",
expected: "SSH -2.0",
},
{
name: "多个控制字符",
input: "\x01\x02SSH\x03\x04-2.0\x05\x06",
expected: "SSH -2.0",
},
{
name: "只有控制字符",
input: "\x00\x01\x02\x03\x04\x05",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := TrimBanner(tt.input)
if result != tt.expected {
t.Errorf("TrimBanner(%q) = %q, want %q",
tt.input, result, tt.expected)
}
})
}
}
// TestTrimBanner_PreservedCharacters 测试保留的特殊字符
func TestTrimBanner_PreservedCharacters(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "保留换行符",
input: "SSH-2.0\nOpenSSH_8.0",
expected: "SSH-2.0 OpenSSH_8.0", // 连续空白被压缩
},
{
name: "保留制表符",
input: "SSH-2.0\tOpenSSH_8.0",
expected: "SSH-2.0 OpenSSH_8.0", // 制表符被压缩为空格
},
{
name: "混合换行符和制表符",
input: "SSH\n\t2.0\n\tOpenSSH",
expected: "SSH 2.0 OpenSSH",
},
{
name: "多个连续换行符",
input: "SSH\n\n\n2.0",
expected: "SSH 2.0",
},
{
name: "Windows换行符CRLF",
input: "SSH-2.0\r\nOpenSSH_8.0",
expected: "SSH-2.0 OpenSSH_8.0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := TrimBanner(tt.input)
if result != tt.expected {
t.Errorf("TrimBanner(%q) = %q, want %q",
tt.input, result, tt.expected)
}
})
}
}
// TestTrimBanner_SpaceCompression 测试空格压缩
func TestTrimBanner_SpaceCompression(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "两个空格",
input: "SSH 2.0",
expected: "SSH 2.0",
},
{
name: "多个空格",
input: "SSH 2.0 OpenSSH",
expected: "SSH 2.0 OpenSSH",
},
{
name: "混合空白字符",
input: "SSH \t \n 2.0",
expected: "SSH 2.0",
},
{
name: "开头多个空格",
input: " SSH-2.0",
expected: "SSH-2.0",
},
{
name: "结尾多个空格",
input: "SSH-2.0 ",
expected: "SSH-2.0",
},
{
name: "前后和中间都有多余空格",
input: " SSH 2.0 OpenSSH ",
expected: "SSH 2.0 OpenSSH",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := TrimBanner(tt.input)
if result != tt.expected {
t.Errorf("TrimBanner(%q) = %q, want %q",
tt.input, result, tt.expected)
}
})
}
}
// TestTrimBanner_ProductionScenarios 测试生产环境真实场景
func TestTrimBanner_ProductionScenarios(t *testing.T) {
t.Run("SSH服务Banner", func(t *testing.T) {
// 真实的SSH banner,可能包含控制字符
input := "\x00\x00SSH-2.0-OpenSSH_8.0 Ubuntu\x00\x00"
expected := "SSH-2.0-OpenSSH_8.0 Ubuntu"
result := TrimBanner(input)
if result != expected {
t.Errorf("SSH banner清理失败: got %q, want %q", result, expected)
}
})
t.Run("HTTP服务Banner", func(t *testing.T) {
// HTTP响应可能包含多余空白
input := " HTTP/1.1 200 OK\r\nServer: nginx/1.18.0 "
expected := "HTTP/1.1 200 OK Server: nginx/1.18.0"
result := TrimBanner(input)
if result != expected {
t.Errorf("HTTP banner清理失败: got %q, want %q", result, expected)
}
})
t.Run("FTP服务Banner", func(t *testing.T) {
// FTP欢迎消息,可能包含换行符
input := "220\tProFTPD Server\n(Welcome)\n"
expected := "220 ProFTPD Server (Welcome)"
result := TrimBanner(input)
if result != expected {
t.Errorf("FTP banner清理失败: got %q, want %q", result, expected)
}
})
t.Run("MySQL服务Banner", func(t *testing.T) {
// MySQL握手包可能包含二进制数据
input := "\x00\x00\x005.7.30-log\x00"
expected := "5.7.30-log"
result := TrimBanner(input)
if result != expected {
t.Errorf("MySQL banner清理失败: got %q, want %q", result, expected)
}
})
t.Run("Telnet服务Banner", func(t *testing.T) {
// Telnet可能包含ANSI转义序列
// 注意:当前实现只移除控制字符,ANSI序列的参数部分(可打印字符)会保留
input := "\x1b[2J\x1b[HWelcome to Linux\x1b[0m"
expected := "[2J [HWelcome to Linux [0m" // ESC被移除,参数保留
result := TrimBanner(input)
if result != expected {
t.Errorf("Telnet banner清理失败: got %q, want %q", result, expected)
}
})
}
// TestTrimBanner_EdgeCases 测试边界情况
func TestTrimBanner_EdgeCases(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "单个字符",
input: "S",
expected: "S",
},
{
name: "单个空格",
input: " ",
expected: "",
},
{
name: "单个控制字符",
input: "\x00",
expected: "",
},
{
name: "所有可打印ASCII字符",
input: " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~",
expected: "!\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~",
},
{
name: "混合可打印和不可打印字符",
input: "A\x00B\x01C\x1fD E",
expected: "A B C D E",
},
{
name: "长Banner-1000字符",
input: strings.Repeat("SSH-2.0 ", 125), // 1000字符
expected: strings.TrimSpace(strings.Repeat("SSH-2.0 ", 125)),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := TrimBanner(tt.input)
if result != tt.expected {
t.Errorf("TrimBanner(%q) = %q, want %q",
tt.input, result, tt.expected)
}
})
}
}
// TestTrimBanner_ASCIIRanges 测试ASCII范围边界
func TestTrimBanner_ASCIIRanges(t *testing.T) {
t.Run("ASCII-31-控制字符边界", func(t *testing.T) {
// ASCII 0-31 是控制字符(除了\n和\t)
input := string([]byte{31, 32, 33}) // US控制符, 空格, !
expected := "!" // 31被移除,32变空格被trim33保留
result := TrimBanner(input)
if result != expected {
t.Errorf("ASCII 31边界测试失败: got %q, want %q", result, expected)
}
})
t.Run("ASCII-32-空格-最小可打印字符", func(t *testing.T) {
input := string([]byte{32}) // 空格
expected := "" // trim掉
result := TrimBanner(input)
if result != expected {
t.Errorf("ASCII 32测试失败: got %q, want %q", result, expected)
}
})
t.Run("ASCII-126-波浪号-最大可打印字符", func(t *testing.T) {
input := string([]byte{126}) // ~
expected := "~"
result := TrimBanner(input)
if result != expected {
t.Errorf("ASCII 126测试失败: got %q, want %q", result, expected)
}
})
t.Run("ASCII-127-DEL-控制字符", func(t *testing.T) {
input := string([]byte{127}) // DEL
expected := "" // 被移除
result := TrimBanner(input)
if result != expected {
t.Errorf("ASCII 127测试失败: got %q, want %q", result, expected)
}
})
}
// TestTrimBanner_SpecialCases 测试特殊场景
func TestTrimBanner_SpecialCases(t *testing.T) {
t.Run("换行符保留-但被压缩", func(t *testing.T) {
input := "Line1\nLine2"
result := TrimBanner(input)
// 换行符应该被保留,但被压缩为空格
if !strings.Contains(result, "Line1") || !strings.Contains(result, "Line2") {
t.Errorf("换行符处理错误: got %q", result)
}
})
t.Run("制表符保留-但被压缩", func(t *testing.T) {
input := "Col1\tCol2"
result := TrimBanner(input)
// 制表符应该被保留,但被压缩为空格
if !strings.Contains(result, "Col1") || !strings.Contains(result, "Col2") {
t.Errorf("制表符处理错误: got %q", result)
}
})
t.Run("连续控制字符-被替换为单个空格", func(t *testing.T) {
input := "SSH\x00\x01\x02-2.0"
result := TrimBanner(input)
// 多个控制字符应该被压缩
expected := "SSH -2.0"
if result != expected {
t.Errorf("控制字符压缩错误: got %q, want %q", result, expected)
}
})
t.Run("空字符串不panic", func(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("空字符串导致panic: %v", r)
}
}()
result := TrimBanner("")
if result != "" {
t.Errorf("空字符串处理错误: got %q", result)
}
})
}
// TestTrimBanner_PerformanceBaseline 性能基准测试
func TestTrimBanner_PerformanceBaseline(t *testing.T) {
// 测试大字符串不会超时
largeInput := strings.Repeat("SSH-2.0-OpenSSH_8.0 ", 10000) // ~200KB
result := TrimBanner(largeInput)
if len(result) == 0 {
t.Error("大字符串处理失败")
}
}
// =============================================================================
// ToMap - 结构体转Map测试
// =============================================================================
// TestExtras_ToMap_BasicCases 测试基本的ToMap功能
func TestExtras_ToMap_BasicCases(t *testing.T) {
tests := []struct {
name string
extras Extras
expected map[string]string
}{
{
name: "所有字段都有值",
extras: Extras{
VendorProduct: "Apache httpd",
Version: "2.4.41",
Info: "Ubuntu",
Hostname: "web-server",
OperatingSystem: "Linux",
DeviceType: "general purpose",
CPE: "cpe:/a:apache:http_server:2.4.41",
},
expected: map[string]string{
"vendor_product": "Apache httpd",
"version": "2.4.41",
"info": "Ubuntu",
"hostname": "web-server",
"os": "Linux",
"device_type": "general purpose",
"cpe": "cpe:/a:apache:http_server:2.4.41",
},
},
{
name: "所有字段都为空",
extras: Extras{},
expected: map[string]string{},
},
{
name: "只有部分字段有值",
extras: Extras{
VendorProduct: "OpenSSH",
Version: "8.0",
},
expected: map[string]string{
"vendor_product": "OpenSSH",
"version": "8.0",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.extras.ToMap()
// 验证长度
if len(result) != len(tt.expected) {
t.Errorf("ToMap() 返回map长度 = %d, want %d",
len(result), len(tt.expected))
}
// 验证每个字段
for key, expectedValue := range tt.expected {
if actualValue, ok := result[key]; !ok {
t.Errorf("ToMap() 缺少字段 %q", key)
} else if actualValue != expectedValue {
t.Errorf("ToMap()[%q] = %q, want %q",
key, actualValue, expectedValue)
}
}
// 验证没有多余字段
for key := range result {
if _, ok := tt.expected[key]; !ok {
t.Errorf("ToMap() 包含意外字段 %q = %q",
key, result[key])
}
}
})
}
}
// TestExtras_ToMap_EmptyStringFiltering 测试空字符串过滤
func TestExtras_ToMap_EmptyStringFiltering(t *testing.T) {
t.Run("空字符串不应出现在map中", func(t *testing.T) {
extras := Extras{
VendorProduct: "Apache",
Version: "", // 空
Info: "Ubuntu",
Hostname: "", // 空
OperatingSystem: "",
DeviceType: "",
CPE: "",
}
result := extras.ToMap()
// 应该只有两个非空字段
if len(result) != 2 {
t.Errorf("ToMap() 应该过滤空字符串, got length %d, want 2", len(result))
}
// 验证空字段不存在
emptyFields := []string{"version", "hostname", "os", "device_type", "cpe"}
for _, field := range emptyFields {
if _, exists := result[field]; exists {
t.Errorf("ToMap() 不应包含空字段 %q", field)
}
}
})
}
// =============================================================================
// ParseVersionInfo 测试
// =============================================================================
func TestParseVersionInfo(t *testing.T) {
tests := []struct {
name string
versionInfo string
foundItems []string
wantVP string // VendorProduct
wantVer string // Version
wantCPE string
}{
{
name: "只有product-斜线分隔符",
versionInfo: " p/Apache/",
wantVP: "Apache",
},
{
name: "product和version-斜线分隔符",
versionInfo: " p/nginx/ v/1.18.0/",
wantVP: "nginx",
wantVer: "1.18.0",
},
{
name: "pipe分隔符",
versionInfo: " p|OpenSSH| v|8.2p1|",
wantVP: "OpenSSH",
wantVer: "8.2p1",
},
{
name: "含$1占位符替换后解析",
versionInfo: " p/OpenSSH/ v/$1/",
foundItems: []string{"8.2p1"},
wantVP: "OpenSSH",
wantVer: "8.2p1",
},
{
name: "CPE解析",
versionInfo: " cpe:/a:apache:httpd:2.4.41",
wantCPE: "a:apache:httpd:2.4.41",
},
{
name: "空VersionInfo返回全空Extras",
versionInfo: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := &Match{
VersionInfo: tt.versionInfo,
FoundItems: tt.foundItems,
}
got := m.ParseVersionInfo(nil)
if got.VendorProduct != tt.wantVP {
t.Errorf("VendorProduct = %q, want %q", got.VendorProduct, tt.wantVP)
}
if got.Version != tt.wantVer {
t.Errorf("Version = %q, want %q", got.Version, tt.wantVer)
}
if got.CPE != tt.wantCPE {
t.Errorf("CPE = %q, want %q", got.CPE, tt.wantCPE)
}
})
}
}