diff --git a/common/config_builder_test.go b/common/config_builder_test.go index 729b3d9..bdf9af2 100644 --- a/common/config_builder_test.go +++ b/common/config_builder_test.go @@ -3,6 +3,7 @@ package common import ( "reflect" "testing" + "time" fscanconfig "github.com/shadow1ng/fscan/common/config" ) @@ -192,3 +193,258 @@ func TestNormalizeURLBracketsIPv6Literals(t *testing.T) { }) } } + +// TestModuleTimeout 测试模块超时计算 +func TestModuleTimeout(t *testing.T) { + tests := []struct { + name string + timeout time.Duration + want time.Duration + }{ + {"超时大于下限", 10 * time.Second, 10 * time.Second}, + {"超时等于下限", 3 * time.Second, 3 * time.Second}, + {"超时小于下限", 1 * time.Second, 3 * time.Second}, + {"零超时", 0, 3 * time.Second}, + {"负超时", -1 * time.Second, 3 * time.Second}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := NewConfig() + cfg.Timeout = tt.timeout + got := cfg.ModuleTimeout() + if got != tt.want { + t.Errorf("ModuleTimeout() = %v, want %v", got, tt.want) + } + }) + } +} + +// TestParseUserPassPairsExactMatch 测试精确单用户单密码路径 +func TestParseUserPassPairsExactMatch(t *testing.T) { + fv := &FlagVars{ + Username: "admin", + Password: "secret", + } + pairs, err := parseUserPassPairs(fv) + if err != nil { + t.Fatalf("parseUserPassPairs error = %v", err) + } + if len(pairs) != 1 { + t.Fatalf("期望 1 个 pair, 实际 %d", len(pairs)) + } + if pairs[0].Username != "admin" || pairs[0].Password != "secret" { + t.Errorf("pair = %+v, want {admin secret}", pairs[0]) + } +} + +// TestParseUserPassPairsMultiUserSkips 测试多用户时不生成精确 pair +func TestParseUserPassPairsMultiUserSkips(t *testing.T) { + fv := &FlagVars{ + Username: "admin,root", + Password: "pass", + } + pairs, err := parseUserPassPairs(fv) + if err != nil { + t.Fatalf("parseUserPassPairs error = %v", err) + } + if len(pairs) != 0 { + t.Fatalf("多用户场景不应生成精确 pair, 实际 %d 个", len(pairs)) + } +} + +// TestParseURLsEmpty 测试空输入返回空列表 +func TestParseURLsEmpty(t *testing.T) { + fv := &FlagVars{} + urls, err := parseURLs(fv) + if err != nil { + t.Fatalf("parseURLs error = %v", err) + } + if len(urls) != 0 { + t.Fatalf("空输入应返回空 url 列表, 实际 %v", urls) + } +} + +// TestParseURLsCommaSeparated 测试逗号分隔多 URL +func TestParseURLsCommaSeparated(t *testing.T) { + fv := &FlagVars{ + TargetURL: "http://a.com,http://b.com,http://a.com", // 含重复 + } + urls, err := parseURLs(fv) + if err != nil { + t.Fatalf("parseURLs error = %v", err) + } + if len(urls) != 2 { + t.Fatalf("去重后应有 2 个 url, 实际 %d: %v", len(urls), urls) + } +} + +// TestParseURLsMissingFile 测试缺失文件返回错误 +func TestParseURLsMissingFile(t *testing.T) { + fv := &FlagVars{URLsFile: "nonexistent-urls.txt"} + _, err := parseURLs(fv) + if err == nil { + t.Fatal("缺失文件应返回错误") + } +} + +// --------------------------------------------------------------------------- +// parseHashes +// --------------------------------------------------------------------------- + +// TestParseHashesEmpty 空输入返回空结果 +func TestParseHashesEmpty(t *testing.T) { + fv := &FlagVars{} + vals, bytes, err := parseHashes(fv) + if err != nil { + t.Fatalf("parseHashes error = %v", err) + } + if len(vals) != 0 || len(bytes) != 0 { + t.Fatalf("空输入应返回空结果, vals=%v bytes=%v", vals, bytes) + } +} + +// TestParseHashesValidNTLM 纯 32 字符 hex hash +func TestParseHashesValidNTLM(t *testing.T) { + hash := "aabbccddeeff00112233445566778899" + fv := &FlagVars{HashValue: hash} + vals, hashBytes, err := parseHashes(fv) + if err != nil { + t.Fatalf("parseHashes error = %v", err) + } + if len(vals) != 1 || vals[0] != hash { + t.Fatalf("vals = %v, want [%s]", vals, hash) + } + if len(hashBytes) != 1 || len(hashBytes[0]) != 16 { + t.Fatalf("hashBytes length wrong: %v", hashBytes) + } +} + +// TestParseHashesLMNTFormat LM:NT 格式,提取 NT 部分 +func TestParseHashesLMNTFormat(t *testing.T) { + lm := "aad3b435b51404eeaad3b435b51404ee" + nt := "31d6cfe0d16ae931b73c59d7e0c089c0" + fv := &FlagVars{HashValue: lm + ":" + nt} + vals, _, err := parseHashes(fv) + if err != nil { + t.Fatalf("parseHashes error = %v", err) + } + if len(vals) != 1 || vals[0] != nt { + t.Fatalf("vals = %v, want [%s]", vals, nt) + } +} + +// TestParseHashesInvalidLength hash 长度不是 32 → error +func TestParseHashesInvalidLength(t *testing.T) { + fv := &FlagVars{HashValue: "tooshort"} + _, _, err := parseHashes(fv) + if err == nil { + t.Fatal("hash 长度不足应返回错误") + } +} + +// TestParseHashesInvalidHex 32 字符但含非 hex 字符 → error +func TestParseHashesInvalidHex(t *testing.T) { + fv := &FlagVars{HashValue: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"} + _, _, err := parseHashes(fv) + if err == nil { + t.Fatal("非 hex 字符应返回错误") + } +} + +// TestParseHashesMissingFile hash 文件不存在 → error +func TestParseHashesMissingFile(t *testing.T) { + fv := &FlagVars{HashFile: "nonexistent-hashes.txt"} + _, _, err := parseHashes(fv) + if err == nil { + t.Fatal("缺失 hash 文件应返回错误") + } +} + +// --------------------------------------------------------------------------- +// parseUsernames +// --------------------------------------------------------------------------- + +// TestParseUsernamesEmpty 空输入返回空结果 +func TestParseUsernamesEmpty(t *testing.T) { + fv := &FlagVars{} + got, err := parseUsernames(fv) + if err != nil { + t.Fatalf("parseUsernames error = %v", err) + } + if len(got) != 0 { + t.Fatalf("空输入应返回空, got %v", got) + } +} + +// TestParseUsernamesCommaSeparated 逗号分隔多用户 +func TestParseUsernamesCommaSeparated(t *testing.T) { + fv := &FlagVars{Username: "admin, root, admin"} // 含重复和空格 + got, err := parseUsernames(fv) + if err != nil { + t.Fatalf("parseUsernames error = %v", err) + } + want := []string{"admin", "root"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +// TestParseUsernamesAddUsers AddUsers 追加去重 +func TestParseUsernamesAddUsers(t *testing.T) { + fv := &FlagVars{ + Username: "admin", + AddUsers: "root,admin", // admin 重复 + } + got, err := parseUsernames(fv) + if err != nil { + t.Fatalf("parseUsernames error = %v", err) + } + want := []string{"admin", "root"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +// TestParseUsernamesMissingFile 缺失用户文件 → error +func TestParseUsernamesMissingFile(t *testing.T) { + fv := &FlagVars{UsersFile: "nonexistent-users.txt"} + _, err := parseUsernames(fv) + if err == nil { + t.Fatal("缺失用户文件应返回错误") + } +} + +// --------------------------------------------------------------------------- +// cloneStringSlice +// --------------------------------------------------------------------------- + +// TestCloneStringSliceNil nil 输入返回 nil +func TestCloneStringSliceNil(t *testing.T) { + got := cloneStringSlice(nil) + if got != nil { + t.Fatalf("nil 输入应返回 nil, got %v", got) + } +} + +// TestCloneStringSliceEmpty 空切片:append 无元素结果为 nil,len 为 0 +func TestCloneStringSliceEmpty(t *testing.T) { + got := cloneStringSlice([]string{}) + if len(got) != 0 { + t.Fatalf("got len %d, want 0", len(got)) + } +} + +// TestCloneStringSliceCopiesValues 正常切片:值正确且独立 +func TestCloneStringSliceCopiesValues(t *testing.T) { + src := []string{"a", "b", "c"} + got := cloneStringSlice(src) + if !reflect.DeepEqual(got, src) { + t.Fatalf("got %v, want %v", got, src) + } + // 修改 clone 不影响原始 + got[0] = "mutated" + if src[0] != "a" { + t.Fatal("cloneStringSlice 返回的切片与源共享底层数组") + } +} diff --git a/common/output/writers_test.go b/common/output/writers_test.go index e673385..8ac2448 100644 --- a/common/output/writers_test.go +++ b/common/output/writers_test.go @@ -1728,3 +1728,420 @@ func TestManager_ConcurrentSave(t *testing.T) { t.Logf("✓ 并发保存测试通过(%d个goroutine,每个%d次,输出%d行)", numGoroutines, savesPerGoroutine, len(lines)) } + +// ============================================================================= +// TXTWriter - 内部格式化函数覆盖率测试 +// ============================================================================= + +// newTestTXTWriter 创建用于单元测试的 TXTWriter(写到临时文件,调用方负责 Close) +func newTestTXTWriter(t *testing.T) *TXTWriter { + t.Helper() + w, err := NewTXTWriter(filepath.Join(t.TempDir(), "unit.txt")) + if err != nil { + t.Fatalf("创建 TXTWriter 失败: %v", err) + } + return w +} + +// TestFormatServiceLine 覆盖 formatServiceLine 的各分支 +func TestFormatServiceLine(t *testing.T) { + w := newTestTXTWriter(t) + defer w.Close() + + tests := []struct { + name string + details map[string]interface{} + want []string // 输出中必须包含的子串 + notwant []string // 输出中不应包含的子串 + }{ + { + name: "非web服务带service和banner", + details: map[string]interface{}{ + "port": 22, + "service": "ssh", + "banner": "OpenSSH_8.0", + }, + want: []string{"ssh", "OpenSSH_8.0"}, + notwant: []string{"http://", "https://"}, + }, + { + name: "非web服务只有service", + details: map[string]interface{}{ + "port": 3306, + "service": "mysql", + }, + want: []string{"mysql"}, + notwant: []string{"http://"}, + }, + { + name: "非web服务无banner", + details: map[string]interface{}{ + "port": 21, + "service": "ftp", + }, + want: []string{"ftp"}, + }, + { + name: "service=http 走 web 分支", + details: map[string]interface{}{ + "port": 80, + "service": "http", + "title": "Home", + "status": 200, + }, + want: []string{"http://", "Home"}, + notwant: []string{"ssh"}, + }, + { + name: "service=https 走 web 分支", + details: map[string]interface{}{ + "port": 443, + "service": "https", + "title": "Secure", + "status": 200, + }, + want: []string{"https://", "Secure"}, + }, + { + name: "is_web=true 走 web 分支", + details: map[string]interface{}{ + "port": 8080, + "is_web": true, + "title": "Dashboard", + "status": 302, + }, + want: []string{"http://", "Dashboard"}, + }, + { + name: "有 status 字段触发 web 分支", + details: map[string]interface{}{ + "port": 8080, + "status": 200, + }, + want: []string{"http://"}, + }, + { + name: "有 server 字段触发 web 分支", + details: map[string]interface{}{ + "port": 8080, + "server": "nginx", + }, + want: []string{"http://", "nginx"}, + }, + { + name: "banner 含控制字符被转义", + details: map[string]interface{}{ + "port": 9999, + "service": "custom", + "banner": "hello\nworld\r\n", + }, + want: []string{"\\n", "\\r"}, + notwant: []string{"http://"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := &ScanResult{ + Target: "192.168.1.1", + Type: TypeService, + Details: tt.details, + } + got := w.formatServiceLine(result) + for _, s := range tt.want { + if !strings.Contains(got, s) { + t.Errorf("formatServiceLine() = %q,缺少 %q", got, s) + } + } + for _, s := range tt.notwant { + if strings.Contains(got, s) { + t.Errorf("formatServiceLine() = %q,不应含 %q", got, s) + } + } + }) + } +} + +// TestGetFingerprints 覆盖 getFingerprints 的各类型分支 +func TestGetFingerprints(t *testing.T) { + w := newTestTXTWriter(t) + defer w.Close() + + tests := []struct { + name string + details map[string]interface{} + want string + }{ + { + name: "nil fingerprints", + details: map[string]interface{}{}, + want: "", + }, + { + name: "[]string 非空", + details: map[string]interface{}{"fingerprints": []string{"nginx", "php"}}, + want: "[nginx,php]", + }, + { + name: "[]string 空slice", + details: map[string]interface{}{"fingerprints": []string{}}, + want: "", + }, + { + name: "[]interface{} 非空", + details: map[string]interface{}{"fingerprints": []interface{}{"wordpress", "jquery"}}, + want: "[wordpress,jquery]", + }, + { + name: "[]interface{} 含数字", + details: map[string]interface{}{"fingerprints": []interface{}{"apache", 2}}, + want: "[apache,2]", + }, + { + name: "[]interface{} 空slice", + details: map[string]interface{}{"fingerprints": []interface{}{}}, + want: "", + }, + { + name: "不支持的类型返回空", + details: map[string]interface{}{"fingerprints": "just-a-string"}, + want: "", + }, + { + name: "单个元素", + details: map[string]interface{}{"fingerprints": []string{"tomcat"}}, + want: "[tomcat]", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := &ScanResult{Target: "1.2.3.4", Details: tt.details} + got := w.getFingerprints(result) + if got != tt.want { + t.Errorf("getFingerprints() = %q,want %q", got, tt.want) + } + }) + } +} + +// TestFormatVulnLine 覆盖 formatVulnLine 的各分支 +func TestFormatVulnLine(t *testing.T) { + w := newTestTXTWriter(t) + defer w.Close() + + tests := []struct { + name string + target string + status string + details map[string]interface{} + want string + }{ + { + name: "weak_credential 带 service", + target: "192.168.1.1:22", + details: map[string]interface{}{ + "type": "weak_credential", + "service": "ssh", + "username": "root", + "password": "123456", + }, + want: "192.168.1.1:22 ssh root/123456", + }, + { + name: "weak_credential 不带 service", + target: "192.168.1.1:3306", + details: map[string]interface{}{ + "type": "weak_credential", + "username": "admin", + "password": "pass", + }, + want: "192.168.1.1:3306 admin/pass", + }, + { + name: "有 vulnerability 字段", + target: "10.0.0.1", + details: map[string]interface{}{ + "type": "poc", + "vulnerability": "CVE-2024-1234", + }, + want: "10.0.0.1 CVE-2024-1234", + }, + { + name: "无 vulnerability 字段回退到 status", + target: "10.0.0.2", + status: "VULNERABLE", + details: map[string]interface{}{ + "type": "unknown", + }, + want: "10.0.0.2 VULNERABLE", + }, + { + name: "空 details 回退到 status", + target: "10.0.0.3", + status: "poc_hit", + details: map[string]interface{}{}, + want: "10.0.0.3 poc_hit", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := &ScanResult{ + Target: tt.target, + Status: tt.status, + Type: TypeVuln, + Details: tt.details, + } + got := w.formatVulnLine(result) + if got != tt.want { + t.Errorf("formatVulnLine() = %q,want %q", got, tt.want) + } + }) + } +} + +// TestIsWebService 覆盖 isWebService 的各判断分支 +func TestIsWebService(t *testing.T) { + w := newTestTXTWriter(t) + defer w.Close() + + tests := []struct { + name string + details map[string]interface{} + want bool + }{ + { + name: "is_web=true", + details: map[string]interface{}{"is_web": true}, + want: true, + }, + { + name: "is_web=false 无其他标志", + details: map[string]interface{}{"is_web": false}, + want: false, + }, + { + name: "有 status 字段", + details: map[string]interface{}{"status": 200}, + want: true, + }, + { + name: "status=nil 不触发", + details: map[string]interface{}{}, + want: false, + }, + { + name: "有非空 server 字段", + details: map[string]interface{}{"server": "nginx"}, + want: true, + }, + { + name: "空 server 字段不触发", + details: map[string]interface{}{"server": ""}, + want: false, + }, + { + name: "service=http", + details: map[string]interface{}{"service": "http"}, + want: true, + }, + { + name: "service=https", + details: map[string]interface{}{"service": "https"}, + want: true, + }, + { + name: "service=ssh 不是 web", + details: map[string]interface{}{"service": "ssh"}, + want: false, + }, + { + name: "nil Details", + details: nil, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := &ScanResult{Target: "1.2.3.4", Details: tt.details} + got := w.isWebService(result) + if got != tt.want { + t.Errorf("isWebService() = %v,want %v", got, tt.want) + } + }) + } +} + +// TestWebProtocol 覆盖 webProtocol 的各判断分支 +func TestWebProtocol(t *testing.T) { + w := newTestTXTWriter(t) + defer w.Close() + + tests := []struct { + name string + target string + details map[string]interface{} + want string + }{ + { + name: "protocol=https 直接返回", + target: "1.2.3.4:8443", + details: map[string]interface{}{"protocol": "https"}, + want: "https", + }, + { + name: "protocol=http 直接返回", + target: "1.2.3.4:8080", + details: map[string]interface{}{"protocol": "http"}, + want: "http", + }, + { + name: "protocol=HTTPS 大小写不敏感", + target: "1.2.3.4:443", + details: map[string]interface{}{"protocol": "HTTPS"}, + want: "https", + }, + { + name: "service=https 回退", + target: "1.2.3.4:8080", + details: map[string]interface{}{"service": "https"}, + want: "https", + }, + { + name: "target 含 :443 回退 https", + target: "example.com:443", + details: map[string]interface{}{}, + want: "https", + }, + { + name: "无任何标志默认 http", + target: "1.2.3.4:8080", + details: map[string]interface{}{}, + want: "http", + }, + { + name: "service=http 默认 http", + target: "1.2.3.4:80", + details: map[string]interface{}{"service": "http"}, + want: "http", + }, + { + name: "protocol 为其他值走 service 分支", + target: "1.2.3.4:9000", + details: map[string]interface{}{"protocol": "tcp", "service": "https"}, + want: "https", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := &ScanResult{Target: tt.target, Details: tt.details} + got := w.webProtocol(result, tt.target) + if got != tt.want { + t.Errorf("webProtocol() = %q,want %q", got, tt.want) + } + }) + } +} diff --git a/common/output_api_test.go b/common/output_api_test.go index 6c2a574..41f49ec 100644 --- a/common/output_api_test.go +++ b/common/output_api_test.go @@ -105,6 +105,24 @@ func TestInitOutputValidationAndDefaultExtension(t *testing.T) { } } +func TestCloseOutputWithStdoutWriter(t *testing.T) { + preserveOutputAPIGlobals(t) + + // 初始化 silent 模式以创建 StdoutWriter + flagVars = &FlagVars{Silent: true, DisableSave: true} + if err := InitOutput(); err != nil { + t.Fatalf("InitOutput silent error = %v", err) + } + if StdoutWriter == nil { + t.Fatal("StdoutWriter 应在 Silent 模式下被初始化") + } + + // CloseOutput 应正常关闭 StdoutWriter + if err := CloseOutput(); err != nil { + t.Fatalf("CloseOutput with StdoutWriter error = %v", err) + } +} + func TestSaveResultFacadeCallbackAndDisabledSave(t *testing.T) { preserveOutputAPIGlobals(t) diff --git a/common/parsers/host_iterator_test.go b/common/parsers/host_iterator_test.go index 6a0732d..8eee26f 100644 --- a/common/parsers/host_iterator_test.go +++ b/common/parsers/host_iterator_test.go @@ -1,8 +1,10 @@ package parsers import ( + "bufio" "context" "errors" + "net" "os" "reflect" "strings" @@ -166,3 +168,777 @@ func (s *closeTrackingSource) Close() error { s.closed = true return s.err } + +// ============================================================================= +// newHostSource 分支覆盖 +// ============================================================================= + +// TestNewHostSource_Shortcuts 验证 192/172/10 快捷方式展开为正确 CIDR +func TestNewHostSource_Shortcuts(t *testing.T) { + cases := []struct { + input string + wantFirst string + }{ + {"192", "192.168.0.1"}, + {"172", "172.16.0.1"}, + {"10", "10.0.0.1"}, + } + for _, c := range cases { + t.Run(c.input, func(t *testing.T) { + src, err := newHostSource(c.input) + if err != nil { + t.Fatalf("newHostSource(%q) error = %v", c.input, err) + } + defer src.Close() + host, ok, err := src.Next() + if err != nil || !ok { + t.Fatalf("Next() = %q/%v/%v", host, ok, err) + } + if host != c.wantFirst { + t.Errorf("first host = %q, 期望 %q", host, c.wantFirst) + } + }) + } +} + +// TestNewHostSource_CIDRBranch 验证含 "/" 走 CIDR 分支 +func TestNewHostSource_CIDRBranch(t *testing.T) { + src, err := newHostSource("10.0.0.0/30") + if err != nil { + t.Fatalf("newHostSource CIDR error = %v", err) + } + defer src.Close() + host, ok, _ := src.Next() + if !ok || host != "10.0.0.1" { + t.Errorf("CIDR first host = %q, 期望 10.0.0.1", host) + } +} + +// TestNewHostSource_InvalidCIDR 无效 CIDR 返回错误 +func TestNewHostSource_InvalidCIDR(t *testing.T) { + _, err := newHostSource("999.0.0.0/24") + if err == nil { + t.Error("无效 CIDR 应返回 error") + } +} + +// TestNewHostSource_RangeBranch 验证 a-b 格式走 range 分支 +func TestNewHostSource_RangeBranch(t *testing.T) { + src, err := newHostSource("192.168.1.5-192.168.1.7") + if err != nil { + t.Fatalf("newHostSource range error = %v", err) + } + defer src.Close() + + var got []string + for { + h, ok, err := src.Next() + if err != nil { + t.Fatalf("Next() error = %v", err) + } + if !ok { + break + } + got = append(got, h) + } + want := []string{"192.168.1.5", "192.168.1.6", "192.168.1.7"} + if !reflect.DeepEqual(got, want) { + t.Errorf("range hosts = %v, 期望 %v", got, want) + } +} + +// TestNewHostSource_RangeShortTail 验证短尾写法 x.x.x.a-b +func TestNewHostSource_RangeShortTail(t *testing.T) { + src, err := newHostSource("10.0.0.3-5") + if err != nil { + t.Fatalf("newHostSource short-tail range error = %v", err) + } + defer src.Close() + + var got []string + for { + h, ok, err := src.Next() + if err != nil { + t.Fatalf("Next() error = %v", err) + } + if !ok { + break + } + got = append(got, h) + } + want := []string{"10.0.0.3", "10.0.0.4", "10.0.0.5"} + if !reflect.DeepEqual(got, want) { + t.Errorf("short-tail range = %v, 期望 %v", got, want) + } +} + +// TestNewHostSource_SingleHost 验证普通主机名走 singleHostSource 分支 +func TestNewHostSource_SingleHost(t *testing.T) { + src, err := newHostSource("example.com") + if err != nil { + t.Fatalf("newHostSource single error = %v", err) + } + defer src.Close() + + host, ok, err := src.Next() + if err != nil || !ok || host != "example.com" { + t.Errorf("single host = %q/%v/%v, 期望 example.com/true/nil", host, ok, err) + } + // 第二次应该耗尽 + _, ok, _ = src.Next() + if ok { + t.Error("singleHostSource 第二次 Next 应返回 ok=false") + } +} + +// ============================================================================= +// hostMatcher.add 分支覆盖 +// ============================================================================= + +// TestHostMatcherAdd_192Shortcut 验证 add("192") 展开为 192.168.0.0/16 +func TestHostMatcherAdd_192Shortcut(t *testing.T) { + m := newHostMatcher() + if err := m.add("192"); err != nil { + t.Fatalf("add(192) error = %v", err) + } + if !m.match("192.168.1.100") { + t.Error("192.168.1.100 应命中 192.168.0.0/16") + } + if m.match("10.0.0.1") { + t.Error("10.0.0.1 不应命中") + } +} + +// TestHostMatcherAdd_172Shortcut 验证 add("172") +func TestHostMatcherAdd_172Shortcut(t *testing.T) { + m := newHostMatcher() + if err := m.add("172"); err != nil { + t.Fatalf("add(172) error = %v", err) + } + if !m.match("172.16.0.1") { + t.Error("172.16.0.1 应命中 172.16.0.0/12") + } +} + +// TestHostMatcherAdd_10Shortcut 验证 add("10") +func TestHostMatcherAdd_10Shortcut(t *testing.T) { + m := newHostMatcher() + if err := m.add("10"); err != nil { + t.Fatalf("add(10) error = %v", err) + } + if !m.match("10.1.2.3") { + t.Error("10.1.2.3 应命中 10.0.0.0/8") + } +} + +// TestHostMatcherAdd_CIDR 验证 add 处理 CIDR 字符串 +func TestHostMatcherAdd_CIDR(t *testing.T) { + m := newHostMatcher() + if err := m.add("192.168.5.0/24"); err != nil { + t.Fatalf("add CIDR error = %v", err) + } + if !m.match("192.168.5.10") { + t.Error("192.168.5.10 应命中 /24") + } + if m.match("192.168.6.10") { + t.Error("192.168.6.10 不应命中") + } +} + +// TestHostMatcherAdd_Range 验证 add 处理 a-b 范围 +func TestHostMatcherAdd_Range(t *testing.T) { + m := newHostMatcher() + if err := m.add("10.0.0.10-10.0.0.20"); err != nil { + t.Fatalf("add range error = %v", err) + } + if !m.match("10.0.0.15") { + t.Error("10.0.0.15 应命中范围") + } + if m.match("10.0.0.9") || m.match("10.0.0.21") { + t.Error("边界外不应命中") + } +} + +// TestHostMatcherAdd_ExactHost 验证 add 处理普通主机名(exact 分支) +func TestHostMatcherAdd_ExactHost(t *testing.T) { + m := newHostMatcher() + if err := m.add("myhost.local"); err != nil { + t.Fatalf("add exact error = %v", err) + } + if !m.match("myhost.local") { + t.Error("exact 主机名应命中") + } + if m.match("other.local") { + t.Error("其他主机名不应命中") + } +} + +// TestHostMatcherAdd_MultipleComma 验证逗号分隔多个值 +func TestHostMatcherAdd_MultipleComma(t *testing.T) { + m := newHostMatcher() + if err := m.add("host1.com, host2.com, 192.168.1.0/30"); err != nil { + t.Fatalf("add comma-separated error = %v", err) + } + if !m.match("host1.com") || !m.match("host2.com") || !m.match("192.168.1.1") { + t.Error("逗号分隔的值应全部命中") + } +} + +// TestHostMatcherAdd_EmptyEntry 逗号中间空串不报错 +func TestHostMatcherAdd_EmptyEntry(t *testing.T) { + m := newHostMatcher() + if err := m.add(",,,"); err != nil { + t.Fatalf("全空逗号不应报错: %v", err) + } +} + +// TestHostMatcherAdd_InvalidCIDR 无效 CIDR 返回 error +func TestHostMatcherAdd_InvalidCIDR(t *testing.T) { + m := newHostMatcher() + if err := m.add("999.0.0.0/8"); err == nil { + t.Error("无效 CIDR 应返回 error") + } +} + +// ============================================================================= +// fileHostSource.Next 分支覆盖 +// ============================================================================= + +// TestFileHostSourceNext_SkipsEmptyAndComments 验证空行和注释行被跳过 +func TestFileHostSourceNext_SkipsEmptyAndComments(t *testing.T) { + dir := t.TempDir() + path := dir + "/hosts.txt" + content := "\n# this is a comment\n\n \n10.0.0.1\n# another comment\n10.0.0.2\n" + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile error = %v", err) + } + + iter, err := NewHostIterator("", path) + if err != nil { + t.Fatalf("NewHostIterator error = %v", err) + } + defer iter.Close() + + batch, err := iter.NextBatch(context.Background(), 10) + if err != nil { + t.Fatalf("NextBatch error = %v", err) + } + want := []string{"10.0.0.1", "10.0.0.2"} + if !reflect.DeepEqual(batch, want) { + t.Errorf("batch = %v, 期望 %v", batch, want) + } +} + +// TestFileHostSourceNext_MultipleSources 验证文件中每行多个 host(逗号分隔)走 multiHostSource 分支 +func TestFileHostSourceNext_MultipleSources(t *testing.T) { + dir := t.TempDir() + path := dir + "/hosts.txt" + // 一行两个 host,触发 multiHostSource 分支 + content := "10.0.0.1,10.0.0.2\n10.0.0.3\n" + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile error = %v", err) + } + + iter, err := NewHostIterator("", path) + if err != nil { + t.Fatalf("NewHostIterator error = %v", err) + } + defer iter.Close() + + batch, err := iter.NextBatch(context.Background(), 10) + if err != nil { + t.Fatalf("NextBatch error = %v", err) + } + want := []string{"10.0.0.1", "10.0.0.2", "10.0.0.3"} + if !reflect.DeepEqual(batch, want) { + t.Errorf("batch = %v, 期望 %v", batch, want) + } +} + +// TestFileHostSourceNext_InvalidLineSkipped 无效行(解析失败)被跳过不报错 +func TestFileHostSourceNext_InvalidLineSkipped(t *testing.T) { + dir := t.TempDir() + path := dir + "/hosts.txt" + // 包含无效 CIDR,应被跳过 + content := "999.0.0.0/8\n10.0.0.1\n" + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile error = %v", err) + } + + iter, err := NewHostIterator("", path) + if err != nil { + t.Fatalf("NewHostIterator error = %v", err) + } + defer iter.Close() + + batch, err := iter.NextBatch(context.Background(), 10) + if err != nil { + t.Fatalf("NextBatch error = %v", err) + } + // 无效行被跳过,只返回有效行 + if len(batch) != 1 || batch[0] != "10.0.0.1" { + t.Errorf("batch = %v, 期望 [10.0.0.1]", batch) + } +} + +// ============================================================================= +// NewHostIterator 错误路径 +// ============================================================================= + +// TestNewHostIterator_InvalidFilename 不存在的文件应返回 error +func TestNewHostIterator_InvalidFilename(t *testing.T) { + _, err := NewHostIterator("", "/nonexistent/path/hosts.txt") + if err == nil { + t.Error("不存在的文件应返回 error") + } +} + +// TestNewHostIterator_InvalidHost host 解析失败时应返回 error(并关闭已打开的文件 source) +func TestNewHostIterator_InvalidHost_WithFile(t *testing.T) { + dir := t.TempDir() + path := dir + "/hosts.txt" + if err := os.WriteFile(path, []byte("10.0.0.1\n"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + // 无效 CIDR 会让 newHostSources 失败 + _, err := NewHostIterator("999.0.0.0/8", path) + if err == nil { + t.Error("无效 host 应返回 error") + } +} + +// TestNewHostIterator_InvalidExclude exclude 参数无效时应返回 error +func TestNewHostIterator_InvalidExclude(t *testing.T) { + _, err := NewHostIterator("10.0.0.1", "", "999.0.0.0/8") + if err == nil { + t.Error("无效 exclude 应返回 error") + } +} + +// TestNewHostIterator_EmptyExcludeSkipped 空白 exclude 条目应被跳过,不报错 +func TestNewHostIterator_EmptyExcludeSkipped(t *testing.T) { + iter, err := NewHostIterator("10.0.0.1", "", " ", "") + if err != nil { + t.Fatalf("空白 exclude 不应报错: %v", err) + } + defer iter.Close() + host, ok, err := iter.Next() + if err != nil || !ok || host != "10.0.0.1" { + t.Errorf("Next() = %q/%v/%v", host, ok, err) + } +} + +// ============================================================================= +// Close 路径 +// ============================================================================= + +// TestClose_Nil nil HostIterator Close 不 panic +func TestClose_Nil(t *testing.T) { + var it *HostIterator + if err := it.Close(); err != nil { + t.Errorf("nil Close 应返回 nil, 得到 %v", err) + } +} + +// TestClose_WithCurrent 有 current source 时 Close 应关闭它 +func TestClose_WithCurrent(t *testing.T) { + src := &closeTrackingSource{} + it := &HostIterator{current: src} + if err := it.Close(); err != nil { + t.Errorf("Close error = %v", err) + } + if !src.closed { + t.Error("current source 应被关闭") + } + if it.current != nil { + t.Error("Close 后 current 应为 nil") + } +} + +// TestClose_SourcesError Close 中 source 返回 error 应被记录 +func TestClose_SourcesError(t *testing.T) { + errSrc := &closeTrackingSource{err: errors.New("close error")} + it := &HostIterator{sources: []hostSource{errSrc}} + err := it.Close() + if err == nil { + t.Error("source Close 失败时应返回 error") + } + if !errSrc.closed { + t.Error("出错的 source 也应被调用 Close") + } +} + +// TestClose_CurrentErrorThenSources current Close 报错,后续 source Close 成功,返回 current 的 error +func TestClose_CurrentErrorThenSources(t *testing.T) { + currentSrc := &closeTrackingSource{err: errors.New("current close error")} + otherSrc := &closeTrackingSource{} + it := &HostIterator{ + current: currentSrc, + sources: []hostSource{otherSrc}, + } + err := it.Close() + if err == nil { + t.Error("应返回 current 的 error") + } + if !currentSrc.closed || !otherSrc.closed { + t.Error("两个 source 都应被关闭") + } +} + +// ============================================================================= +// Next 错误路径 +// ============================================================================= + +// errorSource 让 Next() 返回 error +type errorSource struct { + err error +} + +func (s *errorSource) Next() (string, bool, error) { return "", false, s.err } +func (s *errorSource) Close() error { return nil } + +// errorOnCloseSource Next 返回 ok=false,Close 返回 error +type errorOnCloseSource struct { + err error +} + +func (s *errorOnCloseSource) Next() (string, bool, error) { return "", false, nil } +func (s *errorOnCloseSource) Close() error { return s.err } + +// TestNext_SourceNextError source.Next() 返回 error 时 iter.Next 应透传 +func TestNext_SourceNextError(t *testing.T) { + it := &HostIterator{ + sources: []hostSource{&errorSource{err: errors.New("next error")}}, + } + _, _, err := it.Next() + if err == nil { + t.Error("source Next error 应透传") + } +} + +// TestNext_SourceCloseError 源耗尽时 Close 报错应透传 +func TestNext_SourceCloseError(t *testing.T) { + it := &HostIterator{ + sources: []hostSource{&errorOnCloseSource{err: errors.New("close error")}}, + } + _, _, err := it.Next() + if err == nil { + t.Error("source 耗尽时 Close error 应透传") + } +} + +// ============================================================================= +// NextBatch 边界条件 +// ============================================================================= + +// TestNextBatch_ZeroSize size=0 应使用 DefaultHostBatchSize(实际受源数量限制) +func TestNextBatch_ZeroSize(t *testing.T) { + iter, err := NewHostIterator("10.0.0.1", "") + if err != nil { + t.Fatalf("NewHostIterator: %v", err) + } + defer iter.Close() + + // size=0 触发默认 DefaultHostBatchSize 分支,源只有一个 host + batch, err := iter.NextBatch(context.Background(), 0) + if err != nil { + t.Fatalf("NextBatch(0) error = %v", err) + } + if len(batch) != 1 || batch[0] != "10.0.0.1" { + t.Errorf("batch = %v, 期望 [10.0.0.1]", batch) + } +} + +// TestNextBatch_NegativeSize size<0 也应使用默认值 +func TestNextBatch_NegativeSize(t *testing.T) { + iter, err := NewHostIterator("10.0.0.2", "") + if err != nil { + t.Fatalf("NewHostIterator: %v", err) + } + defer iter.Close() + + batch, err := iter.NextBatch(context.Background(), -1) + if err != nil { + t.Fatalf("NextBatch(-1) error = %v", err) + } + if len(batch) != 1 || batch[0] != "10.0.0.2" { + t.Errorf("batch = %v, 期望 [10.0.0.2]", batch) + } +} + +// TestNextBatch_ContextCancelled context 取消应立即返回 +func TestNextBatch_ContextCancelled(t *testing.T) { + iter, err := NewHostIterator("10.0.0.0/8", "") + if err != nil { + t.Fatalf("NewHostIterator: %v", err) + } + defer iter.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // 立即取消 + + _, err = iter.NextBatch(ctx, 100) + if err == nil { + t.Error("已取消的 context 应返回 error") + } +} + +// TestNextBatch_DeduplicatesHosts 重复 host 只保留一个 +func TestNextBatch_DeduplicatesHosts(t *testing.T) { + // 两个相同的单 host source + it := &HostIterator{ + sources: []hostSource{ + &singleHostSource{host: "10.0.0.1"}, + &singleHostSource{host: "10.0.0.1"}, + }, + } + batch, err := it.NextBatch(context.Background(), 10) + if err != nil { + t.Fatalf("NextBatch error = %v", err) + } + if len(batch) != 1 || batch[0] != "10.0.0.1" { + t.Errorf("batch = %v, 期望去重为 [10.0.0.1]", batch) + } +} + +// TestNextBatch_NextError Next 报错时应透传 +func TestNextBatch_NextError(t *testing.T) { + it := &HostIterator{ + sources: []hostSource{&errorSource{err: errors.New("iter error")}}, + } + _, err := it.NextBatch(context.Background(), 10) + if err == nil { + t.Error("Next error 应透传到 NextBatch") + } +} + +// ============================================================================= +// newRangeHostSource 错误路径 +// ============================================================================= + +// TestNewRangeHostSource_TooManyDashes 超过一个 "-" 应报错(实际按首个切分:a-b-c 被 Split 成 3 段) +func TestNewRangeHostSource_TooManyDashes(t *testing.T) { + // "a-b-c" Split by "-" 得到 3 段,len != 2,应报错 + _, err := newRangeHostSource("10.0.0.1-10.0.0.5-extra") + if err == nil { + t.Error("三段格式应报错") + } +} + +// TestNewRangeHostSource_InvalidStartIP 起始 IP 无效 +func TestNewRangeHostSource_InvalidStartIP(t *testing.T) { + _, err := newRangeHostSource("notanip-10.0.0.5") + if err == nil { + t.Error("无效起始 IP 应报错") + } +} + +// TestNewRangeHostSource_InvalidShortTailNonNumeric 短尾不是数字应报错 +func TestNewRangeHostSource_InvalidShortTailNonNumeric(t *testing.T) { + // 尾部 "xyz" 不是数字 + _, err := newRangeHostSource("10.0.0.1-xyz") + if err == nil { + t.Error("非数字短尾应报错") + } +} + +// TestNewRangeHostSource_InvalidShortTailOver255 短尾超过 255 应报错 +func TestNewRangeHostSource_InvalidShortTailOver255(t *testing.T) { + _, err := newRangeHostSource("10.0.0.1-300") + if err == nil { + t.Error("短尾 >255 应报错") + } +} + +// TestNewRangeHostSource_StartGTEnd 起始 > 结束应报错 +func TestNewRangeHostSource_StartGTEnd(t *testing.T) { + _, err := newRangeHostSource("10.0.0.200-10.0.0.100") + if err == nil { + t.Error("start > end 应报错") + } +} + +// TestNewRangeHostSource_InvalidFullEndIP 完整结束 IP 无效(如 "10.0.0.999") +func TestNewRangeHostSource_InvalidFullEndIP(t *testing.T) { + // end IP 包含 "." 但无效 + _, err := newRangeHostSource("10.0.0.1-10.0.0.999") + if err == nil { + t.Error("无效结束 IP 应报错") + } +} + +// TestNewRangeHostSource_ShortTailStartGTEnd 短尾导致 start > end 应报错 +func TestNewRangeHostSource_ShortTailStartGTEnd(t *testing.T) { + _, err := newRangeHostSource("10.0.0.200-100") + if err == nil { + t.Error("短尾结果 start > end 应报错") + } +} + +// ============================================================================= +// hostMatcher.addRange 错误路径 +// ============================================================================= + +// TestAddRange_InvalidRange addRange 传入无效范围应报错 +func TestAddRange_InvalidRange(t *testing.T) { + m := newHostMatcher() + if err := m.addRange("notvalid-range"); err == nil { + t.Error("无效 range 应返回 error") + } +} + +// TestAddRange_ValidRange addRange 正常路径 +func TestAddRange_ValidRange(t *testing.T) { + m := newHostMatcher() + if err := m.addRange("10.0.0.10-10.0.0.20"); err != nil { + t.Fatalf("addRange error = %v", err) + } + if !m.match("10.0.0.10") || !m.match("10.0.0.20") { + t.Error("addRange 边界值应命中") + } +} + +// ============================================================================= +// hostMatcher.add 错误路径(shortcut 分支中 addCIDR 失败) +// ============================================================================= + +// TestHostMatcherAdd_InvalidRange add 的 range 格式无效 +func TestHostMatcherAdd_InvalidRange(t *testing.T) { + m := newHostMatcher() + // 构造一个 looksLikeIPRange 通过但 newRangeHostSource 失败的字符串 + // "10.0.0.200-10.0.0.100" start>end 会报错 + if err := m.add("10.0.0.200-10.0.0.100"); err == nil { + t.Error("无效 range (start>end) 应返回 error") + } +} + +// ============================================================================= +// newCIDRHostSource IPv6 路径 +// ============================================================================= + +// TestNewCIDRHostSource_IPv6Rejected IPv6 CIDR 应报错 +func TestNewCIDRHostSource_IPv6Rejected(t *testing.T) { + _, err := newCIDRHostSource("2001:db8::/32") + if err == nil { + t.Error("IPv6 CIDR 应被拒绝") + } +} + +// ============================================================================= +// fileHostSource.Close 路径 +// ============================================================================= + +// TestFileHostSource_CloseWithCurrent fileHostSource.Close 时 current != nil 分支 +func TestFileHostSource_CloseWithCurrent(t *testing.T) { + dir := t.TempDir() + path := dir + "/hosts.txt" + // 写入一个 CIDR,这样 fileHostSource 会持有 current source + if err := os.WriteFile(path, []byte("10.0.0.0/30\n"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + src, err := newFileHostSource(path) + if err != nil { + t.Fatalf("newFileHostSource: %v", err) + } + // 触发 current 被设置 + _, _, _ = src.Next() + // 此时 current 应非 nil,Close 应正常关闭它 + if err := src.Close(); err != nil { + t.Errorf("Close with current error = %v", err) + } +} + +// TestFileHostSource_CloseNilFile file 已经为 nil 时 Close 直接返回 nil +func TestFileHostSource_CloseNilFile(t *testing.T) { + src := &fileHostSource{file: nil} + if err := src.Close(); err != nil { + t.Errorf("nil file Close error = %v", err) + } +} + +// ============================================================================= +// multiHostSource.Close 路径 +// ============================================================================= + +// TestMultiHostSource_CloseWithCurrent Close 时 current != nil 分支 +func TestMultiHostSource_CloseWithCurrent(t *testing.T) { + inner := &closeTrackingSource{} + ms := &multiHostSource{current: inner} + if err := ms.Close(); err != nil { + t.Errorf("Close error = %v", err) + } + if !inner.closed { + t.Error("current 应被关闭") + } + if ms.current != nil { + t.Error("Close 后 current 应为 nil") + } +} + +// ============================================================================= +// ipToUint32 IPv6 路径 +// ============================================================================= + +// TestIpToUint32_IPv6ReturnsFalse IPv6 地址应返回 false +func TestIpToUint32_IPv6ReturnsFalse(t *testing.T) { + ip := net.ParseIP("2001:db8::1") + _, ok := ipToUint32(ip) + if ok { + t.Error("IPv6 地址应返回 ok=false") + } +} + +// TestIpToUint32_NilReturnsFalse nil IP 应返回 false +func TestIpToUint32_NilReturnsFalse(t *testing.T) { + _, ok := ipToUint32(nil) + if ok { + t.Error("nil IP 应返回 ok=false") + } +} + +// ============================================================================= +// 剩余未覆盖路径 +// ============================================================================= + +// TestFileHostSource_CurrentNextError fileHostSource.Next 中 current.Next() 报错应透传 +func TestFileHostSource_CurrentNextError(t *testing.T) { + src := &fileHostSource{ + current: &errorSource{err: errors.New("inner error")}, + // scanner 为 nil——不会走到 scanner 分支 + scanner: bufio.NewScanner(strings.NewReader("")), + } + _, _, err := src.Next() + if err == nil { + t.Error("current.Next() 报错应透传") + } +} + +// TestMultiHostSource_InnerNextError multiHostSource.Next 中内部 source.Next() 报错应透传 +func TestMultiHostSource_InnerNextError(t *testing.T) { + ms := &multiHostSource{ + sources: []hostSource{&errorSource{err: errors.New("inner error")}}, + } + _, _, err := ms.Next() + if err == nil { + t.Error("内部 source.Next() 报错应透传到 multiHostSource.Next") + } +} + +// TestNewHostSource_RangeError newHostSource range 分支中 newRangeHostSource 失败 +func TestNewHostSource_RangeError(t *testing.T) { + // start > end,looksLikeIPRange 通过(前半部分是有效 IP),但 newRangeHostSource 返回错误 + _, err := newHostSource("10.0.0.200-10.0.0.100") + if err == nil { + t.Error("start>end range 应返回 error") + } +} + +// TestNewCIDRHostSource_IPv6DirectCall 直接调用 newCIDRHostSource 传入 IPv6 CIDR +func TestNewCIDRHostSource_IPv6DirectCall(t *testing.T) { + // IPv6 CIDR —— bits=128 != 32,触发 line 332-334 + _, err := newCIDRHostSource("::1/128") + if err == nil { + t.Error("IPv6 CIDR 应被 newCIDRHostSource 拒绝 (bits!=32)") + } +} diff --git a/common/session_test.go b/common/session_test.go index aa7f361..1f22687 100644 --- a/common/session_test.go +++ b/common/session_test.go @@ -6,6 +6,8 @@ import ( "strings" "testing" "time" + + "github.com/shadow1ng/fscan/common/output" ) func TestScanSessionLogMethodsHonorSilentConfig(t *testing.T) { @@ -161,6 +163,85 @@ func TestParseProxyURLExtractsAuthWithoutScheme(t *testing.T) { } } +// TestScanSessionSaveResultUsesSink 测试 SaveResult 通过 ResultSink 分发 +func TestScanSessionSaveResultUsesSink(t *testing.T) { + preserveOutputAPIGlobals(t) + + cfg := NewConfig() + cfg.Output.DisableSave = true + SetGlobalConfig(cfg) + flagVars = &FlagVars{DisableSave: true} + _ = InitOutput() + + var sinkGot *output.ScanResult + session := NewScanSession(cfg, NewState(), &FlagVars{}) + session.ResultSink = func(r *output.ScanResult) error { + sinkGot = r + return nil + } + + result := &output.ScanResult{ + Type: output.TypeHost, + Target: "10.0.0.1", + Status: "ALIVE", + } + if err := session.SaveResult(result); err != nil { + t.Fatalf("session.SaveResult error = %v", err) + } + if sinkGot != result { + t.Fatalf("ResultSink 未被调用或参数不符: got %v", sinkGot) + } +} + +// TestScanSessionSaveResultFallsBackToGlobal 测试无 sink 时回退到全局 SaveResult +func TestScanSessionSaveResultFallsBackToGlobal(t *testing.T) { + preserveOutputAPIGlobals(t) + + cfg := NewConfig() + cfg.Output.DisableSave = true + SetGlobalConfig(cfg) + flagVars = &FlagVars{DisableSave: true} + _ = InitOutput() + + called := false + SetResultCallback(func(payload interface{}) { + called = true + }) + + session := NewScanSession(cfg, NewState(), &FlagVars{}) + // 不设置 ResultSink,应回退到全局 + + result := &output.ScanResult{ + Type: output.TypeHost, + Target: "10.0.0.2", + Status: "ALIVE", + } + if err := session.SaveResult(result); err != nil { + t.Fatalf("session.SaveResult (fallback) error = %v", err) + } + if !called { + t.Fatal("回退到全局 SaveResult 时应触发 ResultCallback") + } +} + +// TestScanSessionLogMethodsEnabledByDefault 测试非 Silent 配置下 Log 方法不被屏蔽 +func TestScanSessionLogMethodsEnabledByDefault(t *testing.T) { + cfg := NewConfig() + cfg.Output.Silent = false + session := NewScanSession(cfg, NewState(), &FlagVars{}) + if !session.loggingEnabled() { + t.Fatal("非 Silent 配置下 loggingEnabled 应返回 true") + } +} + +// TestNilScanSessionLoggingEnabled 测试 nil session 的 loggingEnabled +func TestNilScanSessionLoggingEnabled(t *testing.T) { + var session *ScanSession + if !session.loggingEnabled() { + t.Fatal("nil session 的 loggingEnabled 应返回 true(安全降级)") + } +} + type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { diff --git a/common/state_test.go b/common/state_test.go index 92030d1..9064e43 100644 --- a/common/state_test.go +++ b/common/state_test.go @@ -215,6 +215,300 @@ func TestState_ConcurrentTaskCounters(t *testing.T) { } } +// TestState_GetOutputMutex 测试获取输出互斥锁指针 +func TestState_GetOutputMutex(t *testing.T) { + s := NewState() + mu := s.GetOutputMutex() + if mu == nil { + t.Fatal("GetOutputMutex returned nil") + } + // 验证返回的指针可以正常加锁 + mu.Lock() + mu.Unlock() +} + +// TestState_GetICMPLimiter 测试 ICMP 限速器延迟初始化 +func TestState_GetICMPLimiter(t *testing.T) { + s := NewState() + + limiter := s.GetICMPLimiter(0.1) + if limiter == nil { + t.Fatal("GetICMPLimiter returned nil") + } + + // 再次调用应返回同一个实例(sync.Once 保证) + limiter2 := s.GetICMPLimiter(0.5) + if limiter != limiter2 { + t.Fatal("GetICMPLimiter should return the same instance on repeated calls") + } +} + +// TestState_GetICMPLimiterMinRate 测试极低速率下的 ICMP 限速器 +func TestState_GetICMPLimiterMinRate(t *testing.T) { + s := NewState() + // 极低速率(packetsPerSecond < 1)应被钳位到 1 + limiter := s.GetICMPLimiter(0.000001) + if limiter == nil { + t.Fatal("GetICMPLimiter with tiny rate returned nil") + } +} + +// TestState_GetPerfStats 测试性能统计数据 +func TestState_GetPerfStats(t *testing.T) { + s := NewState() + + // 初始状态:全零 + stats := s.GetPerfStats() + if stats.TotalPackets != 0 { + t.Errorf("初始 TotalPackets 应为 0, 实际 %d", stats.TotalPackets) + } + if stats.SuccessRate != 0 { + t.Errorf("初始 SuccessRate 应为 0, 实际 %f", stats.SuccessRate) + } + + // 增加一些计数后验证统计 + s.IncrementTCPSuccessPacketCount() + s.IncrementTCPSuccessPacketCount() + s.IncrementTCPFailedPacketCount() + s.SetNum(3) + + stats = s.GetPerfStats() + if stats.TotalPackets != 3 { + t.Errorf("TotalPackets 期望 3, 实际 %d", stats.TotalPackets) + } + if stats.TCPSuccess != 2 { + t.Errorf("TCPSuccess 期望 2, 实际 %d", stats.TCPSuccess) + } + if stats.TCPFailed != 1 { + t.Errorf("TCPFailed 期望 1, 实际 %d", stats.TCPFailed) + } + if stats.TargetsScanned != 3 { + t.Errorf("TargetsScanned 期望 3, 实际 %d", stats.TargetsScanned) + } + // success rate = 2/3 * 100 ≈ 66.67% + if stats.SuccessRate < 66 || stats.SuccessRate > 67 { + t.Errorf("SuccessRate 期望约 66.67, 实际 %f", stats.SuccessRate) + } +} + +// TestState_GetPerfStatsJSON 测试性能统计 JSON 序列化 +func TestState_GetPerfStatsJSON(t *testing.T) { + s := NewState() + s.IncrementTCPSuccessPacketCount() + + json := s.GetPerfStatsJSON() + if json == "" || json == "{}" { + t.Fatalf("GetPerfStatsJSON 返回空: %q", json) + } + if len(json) < 10 { + t.Fatalf("GetPerfStatsJSON 内容过短: %q", json) + } + // 验证包含关键字段 + for _, key := range []string{"total_packets", "tcp_success", "success_rate"} { + if !containsStr(json, key) { + t.Errorf("GetPerfStatsJSON 缺少字段 %q", key) + } + } +} + +func containsStr(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || len(s) > 0 && stringContains(s, sub)) +} + +func stringContains(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} + +// TestState_GetPacketLimiter 测试通用发包限速器 +func TestState_GetPacketLimiter(t *testing.T) { + t.Run("零速率返回nil", func(t *testing.T) { + s := NewState() + limiter := s.GetPacketLimiter(0) + if limiter != nil { + t.Fatal("零速率应返回 nil limiter") + } + }) + + t.Run("负速率返回nil", func(t *testing.T) { + s := NewState() + limiter := s.GetPacketLimiter(-1) + if limiter != nil { + t.Fatal("负速率应返回 nil limiter") + } + }) + + t.Run("正速率初始化限速器", func(t *testing.T) { + s := NewState() + limiter := s.GetPacketLimiter(600) // 600/min = 10/s + if limiter == nil { + t.Fatal("正速率应返回非 nil limiter") + } + // 再次调用返回同一实例 + limiter2 := s.GetPacketLimiter(1200) + if limiter != limiter2 { + t.Fatal("GetPacketLimiter 应通过 sync.Once 复用实例") + } + }) + + t.Run("低速率被钳位到1pps", func(t *testing.T) { + s := NewState() + // 1/min < 1/s,应被钳位 + limiter := s.GetPacketLimiter(1) + if limiter == nil { + t.Fatal("低速率钳位后应返回非 nil limiter") + } + }) +} + +// TestState_CacheService 测试服务识别缓存 +func TestState_CacheService(t *testing.T) { + s := NewState() + + // 未缓存时查询返回 false + _, ok := s.GetCachedService("192.168.1.1:80") + if ok { + t.Fatal("未缓存的 key 不应返回 ok=true") + } + + // 缓存并查询 + type fakeInfo struct{ Name string } + info := &fakeInfo{Name: "http"} + s.CacheService("192.168.1.1:80", info) + + got, ok := s.GetCachedService("192.168.1.1:80") + if !ok { + t.Fatal("已缓存的 key 应返回 ok=true") + } + if got != info { + t.Fatalf("GetCachedService 返回 %v, 期望 %v", got, info) + } + + // 不同 key 互不干扰 + _, ok = s.GetCachedService("192.168.1.1:443") + if ok { + t.Fatal("不同 key 不应命中缓存") + } +} + +// ============================================================================= +// CheckAndIncrementPacketRate 测试 +// ============================================================================= + +// TestCheckAndIncrementPacketRate_ZeroLimit 速率为 0 时无限制 +func TestCheckAndIncrementPacketRate_ZeroLimit(t *testing.T) { + s := NewState() + for i := 0; i < 1000; i++ { + ok, err := s.CheckAndIncrementPacketRate(0) + if !ok || err != nil { + t.Fatalf("零速率限制应始终允许: ok=%v err=%v", ok, err) + } + } +} + +// TestCheckAndIncrementPacketRate_NegativeLimit 负速率等同于无限制 +func TestCheckAndIncrementPacketRate_NegativeLimit(t *testing.T) { + s := NewState() + ok, err := s.CheckAndIncrementPacketRate(-1) + if !ok || err != nil { + t.Fatalf("负速率应允许: ok=%v err=%v", ok, err) + } +} + +// TestCheckAndIncrementPacketRate_AllowsWhenTokensAvailable 有令牌时返回 true +func TestCheckAndIncrementPacketRate_AllowsWhenTokensAvailable(t *testing.T) { + s := NewState() + // 600/min = 10/s,桶容量 20,初始满桶 + ok, err := s.CheckAndIncrementPacketRate(600) + if !ok || err != nil { + t.Fatalf("初始应有令牌: ok=%v err=%v", ok, err) + } +} + +// TestCheckAndIncrementPacketRate_RateLimitedAfterExhaustion 耗尽令牌后返回 false 和 PacketLimitError +func TestCheckAndIncrementPacketRate_RateLimitedAfterExhaustion(t *testing.T) { + s := NewState() + // 极低速率:1/min,桶容量为 1(钳位后 packetsPerSecond=1,capacity=2) + // 消耗掉所有令牌后应被限速 + const limit int64 = 1 + + // 初始化限速器(第一次调用触发 sync.Once) + s.GetPacketLimiter(limit) + + // 消耗完所有令牌(容量 <= 2) + for i := 0; i < 10; i++ { + s.CheckAndIncrementPacketRate(limit) //nolint: errcheck + } + + // 此时令牌应已耗尽,下一次调用应被限速 + ok, err := s.CheckAndIncrementPacketRate(limit) + if ok { + // 桶可能还剩令牌(容量 2),多耗几次再判断 + for i := 0; i < 20; i++ { + ok, err = s.CheckAndIncrementPacketRate(limit) + if !ok { + break + } + } + } + + if ok { + t.Fatal("令牌耗尽后应返回 ok=false") + } + if err == nil { + t.Fatal("令牌耗尽后应返回 error") + } + if !isPacketLimitError(err) { + t.Errorf("error 类型应为 PacketLimitError, 实际 %T: %v", err, err) + } +} + +// isPacketLimitError 检查是否为 PacketLimitError +func isPacketLimitError(err error) bool { + _, ok := err.(*PacketLimitError) + return ok +} + +// TestCheckAndIncrementPacketRate_ErrorUnwrapsToSentinel 验证 error 可 unwrap 到 sentinel +func TestCheckAndIncrementPacketRate_ErrorUnwrapsToSentinel(t *testing.T) { + s := NewState() + const limit int64 = 1 + + // 耗尽令牌 + for i := 0; i < 50; i++ { + s.CheckAndIncrementPacketRate(limit) //nolint: errcheck + } + + var lastErr error + for i := 0; i < 10; i++ { + ok, err := s.CheckAndIncrementPacketRate(limit) + if !ok { + lastErr = err + break + } + } + + if lastErr == nil { + t.Skip("未能触发限速(可能令牌桶容量较大),跳过 unwrap 测试") + } + + // 验证可 unwrap 到 ErrPacketRateLimited + pErr, ok := lastErr.(*PacketLimitError) + if !ok { + t.Fatalf("期望 *PacketLimitError, 实际 %T", lastErr) + } + if pErr.Sentinel != ErrPacketRateLimited { + t.Errorf("Sentinel = %v, 期望 ErrPacketRateLimited", pErr.Sentinel) + } + if pErr.Limit != limit { + t.Errorf("Limit = %d, 期望 %d", pErr.Limit, limit) + } +} + // TestState_OutputMutex 测试输出互斥锁 func TestState_OutputMutex(t *testing.T) { s := NewState() diff --git a/core/adaptive_pool_test.go b/core/adaptive_pool_test.go index 5873399..555c051 100644 --- a/core/adaptive_pool_test.go +++ b/core/adaptive_pool_test.go @@ -1,6 +1,7 @@ package core import ( + "sync/atomic" "testing" "time" ) @@ -152,3 +153,121 @@ func TestAdaptivePool_Wait(t *testing.T) { t.Logf("Wait 测试通过: %v", duration) } + +// ============================================================================= +// maybeReduceTarget 补充覆盖 +// ============================================================================= + +// TestMaybeReduceTarget_NoOpWhenRTTLow rttRatio <= 3.0 时不修改 target +func TestMaybeReduceTarget_NoOpWhenRTTLow(t *testing.T) { + metrics := &ScanMetrics{} + pool, err := NewAdaptivePool(100, 100, func(interface{}) {}, metrics) + if err != nil { + t.Fatalf("创建线程池失败: %v", err) + } + defer pool.Release() + + initialTarget := atomic.LoadInt32(&pool.target) + + // RTTRatio 样本不足(< 20)返回 1.0,远低于 3.0 阈值 + pool.maybeReduceTarget() + + afterTarget := atomic.LoadInt32(&pool.target) + if afterTarget != initialTarget { + t.Errorf("rttRatio <= 3.0 时 target 不应改变: %d -> %d", initialTarget, afterTarget) + } +} + +// TestMaybeReduceTarget_ReducesWhenRTTHigh rttRatio > 3.0 时压低 target 10% +func TestMaybeReduceTarget_ReducesWhenRTTHigh(t *testing.T) { + metrics := &ScanMetrics{} + pool, err := NewAdaptivePool(200, 200, func(interface{}) {}, metrics) + if err != nil { + t.Fatalf("创建线程池失败: %v", err) + } + defer pool.Release() + + // 伪造 RTT:让 fastEMA >> slowEMA,ratio > 3.0 + // 方法:先用大 RTT 建立 fastEMA,再用小 RTT 建立 slowEMA + // 更直接:直接操作 atomic 字段(包内测试可以访问) + for i := 0; i < 25; i++ { + metrics.RecordConnect(10 * time.Millisecond) // 先建 baseline + } + // 现在把 fastEMA 人为拉高(写入一个远大于 slowEMA 的值) + pool.metrics.rttFastNs.Store(int64(400 * time.Millisecond)) + pool.metrics.rttSlowNs.Store(int64(10 * time.Millisecond)) + + initialTarget := atomic.LoadInt32(&pool.target) + + pool.maybeReduceTarget() + + afterTarget := atomic.LoadInt32(&pool.target) + if afterTarget >= initialTarget { + t.Errorf("rttRatio > 3.0 时 target 应被压低: %d -> %d", initialTarget, afterTarget) + } + + // 验证是 ×0.9 + expected := int32(float64(initialTarget) * 0.9) + if afterTarget != expected { + t.Errorf("target 应为 %d (×0.9), 实际 %d", expected, afterTarget) + } +} + +// TestMaybeReduceTarget_ClampToMinTarget target 压低后不低于 ceiling/5 或 10 +func TestMaybeReduceTarget_ClampToMinTarget(t *testing.T) { + metrics := &ScanMetrics{} + // ceiling=20, minTarget = max(20/5, 10) = 10 + // target=10, newTarget = int(10*0.9) = 9 → 被 clamp 到 10 → newTarget == target → 不更新 + pool, err := NewAdaptivePool(10, 20, func(interface{}) {}, metrics) + if err != nil { + t.Fatalf("创建线程池失败: %v", err) + } + defer pool.Release() + + // 强制设置 target=10(初始值就是 10,但确认一下) + atomic.StoreInt32(&pool.target, 10) + + // 伪造 rttRatio > 3.0 + for i := 0; i < 25; i++ { + metrics.RecordConnect(10 * time.Millisecond) + } + pool.metrics.rttFastNs.Store(int64(400 * time.Millisecond)) + pool.metrics.rttSlowNs.Store(int64(10 * time.Millisecond)) + + pool.maybeReduceTarget() + + afterTarget := atomic.LoadInt32(&pool.target) + // newTarget=9 < minTarget=10 → clamp 到 10 → 10 == target → 不写入 + if afterTarget != 10 { + t.Errorf("clamp 后 target 应保持 10, 实际 %d", afterTarget) + } +} + +// TestMaybeReduceTarget_LargeCeilingMinTarget ceiling 足够大时 minTarget = ceiling/5 +func TestMaybeReduceTarget_LargeCeilingMinTarget(t *testing.T) { + metrics := &ScanMetrics{} + // ceiling=100, minTarget = 100/5 = 20 + // target=21 → newTarget = int(21*0.9) = 18 → clamp 到 20 + pool, err := NewAdaptivePool(21, 100, func(interface{}) {}, metrics) + if err != nil { + t.Fatalf("创建线程池失败: %v", err) + } + defer pool.Release() + + atomic.StoreInt32(&pool.target, 21) + atomic.StoreInt32(&pool.ceiling, 100) + + for i := 0; i < 25; i++ { + metrics.RecordConnect(10 * time.Millisecond) + } + pool.metrics.rttFastNs.Store(int64(400 * time.Millisecond)) + pool.metrics.rttSlowNs.Store(int64(10 * time.Millisecond)) + + pool.maybeReduceTarget() + + afterTarget := atomic.LoadInt32(&pool.target) + // newTarget=18 < minTarget=20 → store 20; 20 < 21 → 更新 + if afterTarget != 20 { + t.Errorf("应 clamp 到 minTarget=20, 实际 %d", afterTarget) + } +} diff --git a/core/base_scan_strategy_test.go b/core/base_scan_strategy_test.go index 132d36f..1b8b8bf 100644 --- a/core/base_scan_strategy_test.go +++ b/core/base_scan_strategy_test.go @@ -462,3 +462,134 @@ func TestBaseScanStrategy_ValidateConfiguration(t *testing.T) { t.Errorf("ValidateConfiguration 应返回 nil, 实际: %v", err) } } + +// ============================================================================= +// IsPluginApplicableByName 补充覆盖 +// ============================================================================= + +// TestIsPluginApplicableByName_FullModeWebPlugin 测试 -full 模式下 web 插件对任意端口生效 +func TestIsPluginApplicableByName_FullModeWebPlugin(t *testing.T) { + registerTestPlugins(t) + clearServiceCache() + + cfg := common.NewConfig() + cfg.POC.Full = true + + strategy := NewBaseScanStrategy("service", FilterService) + + // webtitle 是 web 插件;-full 模式下不检查 IsMarkedWebService,直接走 passesFilterType + // FilterService 不允许 local/udp,但允许 web 插件 + got := strategy.IsPluginApplicableByName("webtitle", "10.0.0.1", 12345, false, cfg) + if !got { + t.Error("full 模式下 web 插件应对任意端口返回 true") + } +} + +// TestIsPluginApplicableByName_FullModeNonWebPlugin 确认 -full 不影响非 web 插件的端口匹配 +func TestIsPluginApplicableByName_FullModeNonWebPlugin(t *testing.T) { + registerTestPlugins(t) + clearServiceCache() + + cfg := common.NewConfig() + cfg.POC.Full = true + + strategy := NewBaseScanStrategy("service", FilterService) + + // ssh 不是 web 插件,-full 无特殊逻辑,走普通端口匹配 + // ssh 默认端口 22;用 99999 端口应该不匹配 + got := strategy.IsPluginApplicableByName("ssh", "10.0.0.1", 99999, false, cfg) + if got { + t.Error("-full 模式对非 web 插件不应绕过端口匹配") + } +} + +// ============================================================================= +// isPluginApplicableToPort 补充覆盖 +// ============================================================================= + +// TestIsPluginApplicableToPort_WebPlugin web 插件忽略端口直接返回 true +func TestIsPluginApplicableToPort_WebPlugin(t *testing.T) { + registerTestPlugins(t) + strategy := NewBaseScanStrategy("service", FilterService) + + // webtitle 是 web 插件,任何端口都应返回 true + if !strategy.isPluginApplicableToPort("webtitle", 8080) { + t.Error("web 插件在任意端口应返回 true") + } + if !strategy.isPluginApplicableToPort("webtitle", 0) { + t.Error("web 插件在端口 0 也应返回 true") + } +} + +// TestIsPluginApplicableToPort_NonWebPlugin 非 web 插件走端口匹配逻辑 +func TestIsPluginApplicableToPort_NonWebPlugin(t *testing.T) { + registerTestPlugins(t) + clearServiceCache() + strategy := NewBaseScanStrategy("service", FilterService) + + // ssh 端口 22 匹配 + if !strategy.isPluginApplicableToPort("ssh", 22) { + t.Error("ssh 应匹配端口 22") + } + // ssh 端口 9999 不匹配(无服务缓存) + if strategy.isPluginApplicableToPort("ssh", 9999) { + t.Error("ssh 不应匹配端口 9999") + } +} + +// ============================================================================= +// isPluginPassesFilterType 补充覆盖 +// ============================================================================= + +// TestIsPluginPassesFilterType_CustomMode isCustomMode=true 应直接跳过过滤返回 true(非 UDP) +func TestIsPluginPassesFilterType_CustomMode(t *testing.T) { + registerTestPlugins(t) + cfg := common.NewConfig() + + // FilterLocal 策略下 custom mode 也应通过 + localStrategy := NewBaseScanStrategy("local", FilterLocal) + if !localStrategy.isPluginPassesFilterType("ssh", true, cfg) { + t.Error("custom mode 下非 UDP 插件应直接返回 true") + } + + // FilterService 策略下 custom mode 也应通过 + serviceStrategy := NewBaseScanStrategy("service", FilterService) + if !serviceStrategy.isPluginPassesFilterType("ssh", true, cfg) { + t.Error("custom mode 下 service 策略应直接返回 true") + } +} + +// TestIsPluginPassesFilterType_FilterNoneNonLocal FilterNone + 普通 TCP 插件 → true +func TestIsPluginPassesFilterType_FilterNoneNonLocal(t *testing.T) { + registerTestPlugins(t) + cfg := common.NewConfig() + + noneStrategy := NewBaseScanStrategy("none", FilterNone) + + // ssh 不是 local 插件,FilterNone 应直接返回 true + if !noneStrategy.isPluginPassesFilterType("ssh", false, cfg) { + t.Error("FilterNone + 非 local 插件应返回 true") + } + if !noneStrategy.isPluginPassesFilterType("redis", false, cfg) { + t.Error("FilterNone + 非 local 插件 redis 应返回 true") + } +} + +// TestIsPluginPassesFilterType_FilterNoneLocalPlugin FilterNone + local 插件:需要 -local 显式指定 +func TestIsPluginPassesFilterType_FilterNoneLocalPlugin(t *testing.T) { + plugins.RegisterWithOptions("core_test_local_none", func() plugins.Plugin { return nil }, nil, []string{plugins.PluginTypeLocal}, false) + cfg := common.NewConfig() + + noneStrategy := NewBaseScanStrategy("none", FilterNone) + + // 未指定 LocalPlugin,应返回 false + if noneStrategy.isPluginPassesFilterType("core_test_local_none", false, cfg) { + t.Error("FilterNone + local 插件未显式指定时应返回 false") + } + + // 指定后应返回 true + cfg.LocalPlugin = "core_test_local_none" + if !noneStrategy.isPluginPassesFilterType("core_test_local_none", false, cfg) { + t.Error("FilterNone + local 插件显式指定后应返回 true") + } +} diff --git a/core/portfinger/version_parser_test.go b/core/portfinger/version_parser_test.go index 45c4b97..4a7376d 100644 --- a/core/portfinger/version_parser_test.go +++ b/core/portfinger/version_parser_test.go @@ -529,3 +529,72 @@ func TestExtras_ToMap_EmptyStringFiltering(t *testing.T) { } }) } + +// ============================================================================= +// 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) + } + }) + } +} diff --git a/core/scan_metrics_test.go b/core/scan_metrics_test.go index 3674fd1..3289b03 100644 --- a/core/scan_metrics_test.go +++ b/core/scan_metrics_test.go @@ -2,6 +2,7 @@ package core import ( "sync" + "sync/atomic" "testing" "time" ) @@ -128,3 +129,114 @@ func TestScanMetrics_ConcurrentSafety(t *testing.T) { // 验证 RTTRatio 不 panic _ = m.RTTRatio() } + +// ============================================================================= +// 补充测试:按题目要求的函数名 +// ============================================================================= + +// TestScanMetricsTotal — 各计数器各调一次,Total() 应返回 4 +func TestScanMetricsTotal(t *testing.T) { + m := &ScanMetrics{} + m.RecordConnect(time.Millisecond) + m.RecordRefused(time.Millisecond) + m.RecordTimeout() + m.RecordExhausted() + if got := m.Total(); got != 4 { + t.Errorf("Total() = %d, want 4", got) + } +} + +// TestScanMetricsSnapshot — 记录数据后 Snapshot() 返回正确快照 +func TestScanMetricsSnapshot(t *testing.T) { + m := &ScanMetrics{} + m.RecordConnect(5 * time.Millisecond) + m.RecordConnect(10 * time.Millisecond) + m.RecordRefused(2 * time.Millisecond) + m.RecordTimeout() + m.RecordExhausted() + + snap := m.Snapshot() + tests := []struct { + name string + got int64 + want int64 + }{ + {"Connects", snap.Connects, 2}, + {"Refused", snap.Refused, 1}, + {"Timeouts", snap.Timeouts, 1}, + {"Exhausted", snap.Exhausted, 1}, + } + for _, tt := range tests { + if tt.got != tt.want { + t.Errorf("Snapshot.%s = %d, want %d", tt.name, tt.got, tt.want) + } + } + if snap.RTTFastNs <= 0 { + t.Errorf("Snapshot.RTTFastNs = %d, want > 0", snap.RTTFastNs) + } +} + +// TestScanMetricsRTTRatio — 样本不足返回 1.0;20+ 个相同 RTT 接近 1.0 +func TestScanMetricsRTTRatio(t *testing.T) { + t.Run("样本不足返回1.0", func(t *testing.T) { + m := &ScanMetrics{} + for i := 0; i < 19; i++ { + m.RecordConnect(time.Millisecond) + } + if r := m.RTTRatio(); r != 1.0 { + t.Errorf("样本不足 RTTRatio() = %f, want 1.0", r) + } + }) + + t.Run("稳定RTT接近1.0", func(t *testing.T) { + m := &ScanMetrics{} + for i := 0; i < 30; i++ { + m.RecordConnect(10 * time.Millisecond) + } + r := m.RTTRatio() + if r < 0.9 || r > 1.1 { + t.Errorf("稳定RTT下 RTTRatio() = %f, want ~1.0", r) + } + }) +} + +// TestScanMetricsRTTFast — 初始为 0,记录后非零 +func TestScanMetricsRTTFast(t *testing.T) { + m := &ScanMetrics{} + if m.RTTFast() != 0 { + t.Errorf("初始 RTTFast() = %v, want 0", m.RTTFast()) + } + m.RecordConnect(5 * time.Millisecond) + if m.RTTFast() == 0 { + t.Errorf("记录后 RTTFast() 仍为 0") + } +} + +// TestMetricsSnapshotTotal — MetricsSnapshot 各字段求和 +func TestMetricsSnapshotTotal(t *testing.T) { + snap := MetricsSnapshot{Connects: 1, Refused: 2, Timeouts: 3, Exhausted: 4} + if got := snap.Total(); got != 10 { + t.Errorf("MetricsSnapshot.Total() = %d, want 10", got) + } +} + +// TestUpdateEMA — 直接测 updateEMA 行为 +func TestUpdateEMA(t *testing.T) { + t.Run("target为0时直接设为sample", func(t *testing.T) { + var a atomic.Int64 + updateEMA(&a, 100, 10) + if got := a.Load(); got != 100 { + t.Errorf("初始为0时 updateEMA 结果 = %d, want 100", got) + } + }) + + t.Run("target非零时做EMA更新", func(t *testing.T) { + var a atomic.Int64 + a.Store(200) + // next = 200 + (100-200)/10 = 200 - 10 = 190 + updateEMA(&a, 100, 10) + if got := a.Load(); got != 190 { + t.Errorf("EMA更新结果 = %d, want 190", got) + } + }) +} diff --git a/core/scanner_test.go b/core/scanner_test.go index ddb7f81..349793b 100644 --- a/core/scanner_test.go +++ b/core/scanner_test.go @@ -5,6 +5,7 @@ import ( "fmt" "sync" "testing" + "time" "github.com/shadow1ng/fscan/common" "github.com/shadow1ng/fscan/plugins" @@ -433,6 +434,128 @@ func TestSelectStrategy_EmptyHostInfo(t *testing.T) { } } +// ============================================================================= +// buildScanReport 测试 +// ============================================================================= + +// TestBuildScanReport 验证 buildScanReport 字段映射正确 +func TestBuildScanReport(t *testing.T) { + state := common.NewState() + + // 填充各计数器 + state.SetEnd(10) + state.SetNum(7) + state.IncrementTCPSuccessPacketCount() // +1 total, +1 tcp, +1 tcpSuccess + state.IncrementTCPSuccessPacketCount() // +1 total, +1 tcp, +1 tcpSuccess + state.IncrementTCPFailedPacketCount() // +1 total, +1 tcp, +1 tcpFailed + state.IncrementUDPPacketCount() // +1 total, +1 udp + state.IncrementHTTPPacketCount() // +1 total, +1 http + state.IncrementResourceExhaustedCount() + + start := time.Now().Add(-time.Second) // 模拟 1 秒前开始 + report := buildScanReport(state, start) + + if report.TasksTotal != 10 { + t.Errorf("TasksTotal = %d, 期望 10", report.TasksTotal) + } + if report.TasksCompleted != 7 { + t.Errorf("TasksCompleted = %d, 期望 7", report.TasksCompleted) + } + if report.Packets != 5 { + t.Errorf("Packets = %d, 期望 5", report.Packets) + } + if report.TCPPackets != 3 { + t.Errorf("TCPPackets = %d, 期望 3", report.TCPPackets) + } + if report.TCPSuccessPackets != 2 { + t.Errorf("TCPSuccessPackets = %d, 期望 2", report.TCPSuccessPackets) + } + if report.TCPFailedPackets != 1 { + t.Errorf("TCPFailedPackets = %d, 期望 1", report.TCPFailedPackets) + } + if report.UDPPackets != 1 { + t.Errorf("UDPPackets = %d, 期望 1", report.UDPPackets) + } + if report.HTTPPackets != 1 { + t.Errorf("HTTPPackets = %d, 期望 1", report.HTTPPackets) + } + if report.ResourceExhausted != 1 { + t.Errorf("ResourceExhausted = %d, 期望 1", report.ResourceExhausted) + } + if report.Duration < time.Millisecond { + t.Errorf("Duration = %v, 期望 >= 1ms", report.Duration) + } +} + +// TestBuildScanReport_ZeroState 验证空 State 返回零值报告 +func TestBuildScanReport_ZeroState(t *testing.T) { + state := common.NewState() + start := time.Now() + report := buildScanReport(state, start) + + if report.TasksTotal != 0 || report.TasksCompleted != 0 || report.Packets != 0 { + t.Errorf("空 State 期望全零报告,实际 %+v", report) + } + if report.Duration < 0 { + t.Errorf("Duration 不能为负: %v", report.Duration) + } +} + +// ============================================================================= +// determineScanMode IsLocalMode 分支测试 +// ============================================================================= + +// TestDetermineScanMode_IsLocalModeCallback 覆盖 IsLocalMode 回调分支 +func TestDetermineScanMode_IsLocalModeCallback(t *testing.T) { + // 保存原始值 + origIsLocalMode := common.IsLocalMode + defer func() { common.IsLocalMode = origIsLocalMode }() + + // 注册回调:mode == "localtest" 时认为是本地模式 + common.IsLocalMode = func(mode string) bool { + return mode == "localtest" + } + + cfg := &common.Config{ + AliveOnly: false, + Mode: "localtest", + LocalMode: false, + } + state := common.NewState() + + mode := determineScanMode(cfg, state) + if mode != ScanModeLocal { + t.Errorf("determineScanMode() = %v, 期望 ScanModeLocal", mode) + } + // 回调命中后应同时设置 LocalMode 和 LocalPlugin + if !cfg.LocalMode { + t.Error("IsLocalMode 命中后应设置 cfg.LocalMode = true") + } + if cfg.LocalPlugin != "localtest" { + t.Errorf("LocalPlugin = %q, 期望 \"localtest\"", cfg.LocalPlugin) + } +} + +// TestDetermineScanMode_IsLocalModeCallbackNoMatch 回调不命中时不影响模式 +func TestDetermineScanMode_IsLocalModeCallbackNoMatch(t *testing.T) { + origIsLocalMode := common.IsLocalMode + defer func() { common.IsLocalMode = origIsLocalMode }() + + common.IsLocalMode = func(mode string) bool { return false } + + cfg := &common.Config{ + AliveOnly: false, + Mode: "something", + LocalMode: false, + } + state := common.NewState() + + mode := determineScanMode(cfg, state) + if mode != ScanModeService { + t.Errorf("回调不命中时期望 ScanModeService, 实际 %v", mode) + } +} + // TestCountApplicableTasks_EmptyPlugins 测试空插件列表 func TestCountApplicableTasks_EmptyPlugins(t *testing.T) { targets := []common.HostInfo{ diff --git a/core/service_scanner_test.go b/core/service_scanner_test.go index c1cd949..6518f73 100644 --- a/core/service_scanner_test.go +++ b/core/service_scanner_test.go @@ -862,3 +862,76 @@ func TestConvertToTargetInfos_DeepCopy(t *testing.T) { } }) } + +// ============================================================================= +// mergeHostPorts 测试 +// ============================================================================= + +func TestMergeHostPorts(t *testing.T) { + // 结果顺序不确定(map 遍历),用集合比较 + toSet := func(ss []string) map[string]struct{} { + m := make(map[string]struct{}, len(ss)) + for _, s := range ss { + m[s] = struct{}{} + } + return m + } + setsEqual := func(a, b map[string]struct{}) bool { + if len(a) != len(b) { + return false + } + for k := range a { + if _, ok := b[k]; !ok { + return false + } + } + return true + } + + tests := []struct { + name string + a []string + b []string + want []string + }{ + { + name: "两个空切片返回空", + a: []string{}, + b: []string{}, + want: []string{}, + }, + { + name: "无重复-并集", + a: []string{"1.1.1.1:80"}, + b: []string{"2.2.2.2:443"}, + want: []string{"1.1.1.1:80", "2.2.2.2:443"}, + }, + { + name: "有重复-去重", + a: []string{"1.1.1.1:80", "2.2.2.2:443"}, + b: []string{"2.2.2.2:443", "3.3.3.3:22"}, + want: []string{"1.1.1.1:80", "2.2.2.2:443", "3.3.3.3:22"}, + }, + { + name: "a为nil-返回b内容", + a: nil, + b: []string{"1.1.1.1:80", "2.2.2.2:443"}, + want: []string{"1.1.1.1:80", "2.2.2.2:443"}, + }, + { + name: "b为nil-返回a内容", + a: []string{"1.1.1.1:80"}, + b: nil, + want: []string{"1.1.1.1:80"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := mergeHostPorts(tt.a, tt.b) + if !setsEqual(toSet(got), toSet(tt.want)) { + t.Errorf("mergeHostPorts() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/plugins/services/activemq_test.go b/plugins/services/activemq_test.go new file mode 100644 index 0000000..68385e4 --- /dev/null +++ b/plugins/services/activemq_test.go @@ -0,0 +1,29 @@ +//go:build plugin_activemq || !plugin_selective + +package services + +import ( + "errors" + "testing" +) + +func TestClassifyActiveMQErrorType(t *testing.T) { + tests := []struct { + name string + err error + want ErrorType + }{ + {"nil error", nil, ErrorTypeUnknown}, + {"authentication failed", errors.New("authentication failed"), ErrorTypeAuth}, + {"connection refused", errors.New("connection refused"), ErrorTypeNetwork}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := classifyActiveMQErrorType(tt.err) + if got != tt.want { + t.Errorf("classifyActiveMQErrorType(%v) = %v, want %v", tt.err, got, tt.want) + } + }) + } +} diff --git a/plugins/services/cassandra_test.go b/plugins/services/cassandra_test.go index 553ecd6..67157a5 100644 --- a/plugins/services/cassandra_test.go +++ b/plugins/services/cassandra_test.go @@ -5,6 +5,7 @@ package services import ( "bytes" "encoding/binary" + "errors" "strings" "testing" ) @@ -47,3 +48,74 @@ func TestValidateCQLQueryResponseRejectsErrors(t *testing.T) { t.Fatal("validateCQLQueryResponse() error = nil, want unexpected opcode error") } } + +func TestClassifyCassandraErrorType(t *testing.T) { + tests := []struct { + name string + err error + want ErrorType + }{ + {"nil", nil, ErrorTypeUnknown}, + {"auth error", errors.New("authentication failed"), ErrorTypeAuth}, + {"bad credentials", errors.New("bad credentials"), ErrorTypeAuth}, + {"network error", errors.New("connection refused"), ErrorTypeNetwork}, + {"unknown", errors.New("random cassandra error"), ErrorTypeUnknown}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := classifyCassandraErrorType(tt.err); got != tt.want { + t.Errorf("classifyCassandraErrorType() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCqlShortString(t *testing.T) { + got := cqlShortString("AB") + if len(got) != 4 || binary.BigEndian.Uint16(got[:2]) != 2 || string(got[2:]) != "AB" { + t.Errorf("cqlShortString(AB) = %v", got) + } + empty := cqlShortString("") + if len(empty) != 2 || binary.BigEndian.Uint16(empty) != 0 { + t.Errorf("cqlShortString empty = %v", empty) + } +} + +func TestCqlLongString(t *testing.T) { + got := cqlLongString("XYZ") + if len(got) != 7 || binary.BigEndian.Uint32(got[:4]) != 3 || string(got[4:]) != "XYZ" { + t.Errorf("cqlLongString(XYZ) = %v", got) + } +} + +func TestCqlStringMap(t *testing.T) { + m := map[string]string{"k": "v"} + got := cqlStringMap(m) + if got[0] != 0x00 || got[1] != 0x01 { + t.Errorf("count bytes wrong: %v", got[:2]) + } + if !bytes.Contains(got, []byte("k")) || !bytes.Contains(got, []byte("v")) { + t.Errorf("missing key/value in %v", got) + } +} + +func TestExtractClusterName(t *testing.T) { + tests := []struct { + name string + data []byte + want string + }{ + {"empty", nil, "unknown"}, + {"short", []byte{0x01, 0x02}, "unknown"}, + {"printable", append([]byte{0x00, 0x00, 0x00, 0x01}, []byte("TestCluster")...), "TestCluster"}, + {"binary prefix", append([]byte{0x00, 0x00, 0x00, 0x00, 0x01, 0x02}, []byte("MyCluster")...), "MyCluster"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractClusterName(tt.data) + if !strings.Contains(got, tt.want) && got != tt.want { + t.Errorf("extractClusterName() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/plugins/services/findnet_test.go b/plugins/services/findnet_test.go new file mode 100644 index 0000000..6086c11 --- /dev/null +++ b/plugins/services/findnet_test.go @@ -0,0 +1,183 @@ +//go:build plugin_findnet || !plugin_selective + +package services + +import ( + "strings" + "testing" +) + +// --- hexUnicodeToString --- + +func TestHexUnicodeToString(t *testing.T) { + p := NewFindNetPlugin() + + cases := []struct { + name string + src string + want string + }{ + { + name: "empty string", + src: "", + want: "", + }, + { + name: "UTF-16LE TEST", + // T=0x54 E=0x45 S=0x53 T=0x54, LE pairs: 5400 4500 5300 5400 + src: "54004500530054", + want: "TEST", + }, + { + name: "odd length gets padded to 4-multiple", + // 奇数长度补0至4的倍数:"540045005300540" → "5400450053005400" → "TEST" + src: "540045005300540", // 15 hex chars + want: "TEST", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := p.hexUnicodeToString(tc.src) + if got != tc.want { + t.Errorf("got %q, want %q", got, tc.want) + } + }) + } +} + +// --- isValidHostname --- + +func TestIsValidHostname(t *testing.T) { + p := NewFindNetPlugin() + + cases := []struct { + name string + input string + want bool + }{ + {name: "empty", input: "", want: false}, + {name: "valid hostname", input: "test-pc", want: true}, + {name: "single char", input: "a", want: false}, // regex requires at least 2 chars (start+middle+end) + {name: "too long", input: strings.Repeat("a", 256), want: false}, + {name: "valid alphanumeric", input: "PC01", want: true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := p.isValidHostname(tc.input) + if got != tc.want { + t.Errorf("isValidHostname(%q) = %v, want %v", tc.input, got, tc.want) + } + }) + } +} + +// --- isValidNetworkAddress --- + +func TestIsValidNetworkAddress(t *testing.T) { + p := NewFindNetPlugin() + + cases := []struct { + name string + input string + want bool + }{ + {name: "IPv4", input: "192.168.1.1", want: true}, + {name: "IPv6 loopback", input: "::1", want: true}, + {name: "valid hostname fallback", input: "test-host", want: true}, + {name: "invalid", input: "not_an_ip!!!", want: false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := p.isValidNetworkAddress(tc.input) + if got != tc.want { + t.Errorf("isValidNetworkAddress(%q) = %v, want %v", tc.input, got, tc.want) + } + }) + } +} + +// --- cleanAndValidateAddress --- + +func TestCleanAndValidateAddress(t *testing.T) { + p := NewFindNetPlugin() + + cases := []struct { + name string + data []byte + want string + }{ + { + name: "valid IPv4 bytes", + data: []byte("192.168.1.100"), + want: "192.168.1.100", + }, + { + name: "bytes with unprintable chars around valid IP", + data: append([]byte{0x00, 0x01}, append([]byte("10.0.0.1"), 0x00)...), + want: "10.0.0.1", + }, + { + name: "invalid data returns empty", + data: []byte{0x00, 0x01, 0x02, 0x03}, + want: "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := p.cleanAndValidateAddress(tc.data) + if got != tc.want { + t.Errorf("got %q, want %q", got, tc.want) + } + }) + } +} + +// --- NetworkInfo.Summary --- + +func TestNetworkInfoSummary(t *testing.T) { + t.Run("invalid returns discovery failed text", func(t *testing.T) { + ni := &NetworkInfo{Valid: false} + got := ni.Summary() + if got == "" { + t.Error("expected non-empty text for invalid NetworkInfo") + } + // 内容是 i18n key,只验证非空即可 + }) + + t.Run("valid with hostname and IPv4", func(t *testing.T) { + ni := &NetworkInfo{ + Valid: true, + Hostname: "PC01", + IPv4Addrs: []string{"192.168.1.1", "10.0.0.1"}, + } + got := ni.Summary() + if got == "" { + t.Error("expected non-empty summary") + } + }) +} + +// --- parseNetworkInfo --- + +func TestParseNetworkInfo(t *testing.T) { + p := NewFindNetPlugin() + + t.Run("empty data returns invalid", func(t *testing.T) { + info := p.parseNetworkInfo([]byte{}) + if info.Valid { + t.Error("expected Valid=false for empty data") + } + }) + + t.Run("data without valid hostname or IP returns invalid", func(t *testing.T) { + // 全零数据,hostname 解析出空字符串,不会 Valid + info := p.parseNetworkInfo(make([]byte, 64)) + if info.Valid { + t.Error("expected Valid=false for zero data") + } + }) +} diff --git a/plugins/services/ftp_test.go b/plugins/services/ftp_test.go new file mode 100644 index 0000000..8fe605a --- /dev/null +++ b/plugins/services/ftp_test.go @@ -0,0 +1,33 @@ +//go:build plugin_ftp || !plugin_selective + +package services + +import ( + "errors" + "testing" +) + +func TestClassifyFTPErrorType(t *testing.T) { + tests := []struct { + name string + err error + want ErrorType + }{ + {"nil error", nil, ErrorTypeUnknown}, + {"530 login incorrect", errors.New("530 login incorrect"), ErrorTypeAuth}, + {"530 not logged in", errors.New("530 not logged in"), ErrorTypeAuth}, + {"authentication failed", errors.New("authentication failed"), ErrorTypeAuth}, + {"too many connections", errors.New("421 there are too many connections"), ErrorTypeNetwork}, + {"connection refused", errors.New("connection refused"), ErrorTypeNetwork}, + {"random error", errors.New("random error"), ErrorTypeUnknown}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := classifyFTPErrorType(tt.err) + if got != tt.want { + t.Errorf("classifyFTPErrorType(%v) = %v, want %v", tt.err, got, tt.want) + } + }) + } +} diff --git a/plugins/services/kafka_test.go b/plugins/services/kafka_test.go index 649cc73..0059f51 100644 --- a/plugins/services/kafka_test.go +++ b/plugins/services/kafka_test.go @@ -4,6 +4,7 @@ package services import ( "encoding/binary" + "errors" "io" "testing" ) @@ -61,3 +62,25 @@ func TestKafkaRecvRejectsShortResponse(t *testing.T) { t.Fatal("kafkaRecv() error = nil, want invalid length error") } } + +func TestClassifyKafkaErrorType(t *testing.T) { + tests := []struct { + name string + err error + want ErrorType + }{ + {"nil", nil, ErrorTypeUnknown}, + {"sasl auth failed", errors.New("sasl authentication failed"), ErrorTypeAuth}, + {"unauthorized", errors.New("unauthorized"), ErrorTypeAuth}, + {"broker not available", errors.New("broker not available"), ErrorTypeNetwork}, + {"connection refused", errors.New("connection refused"), ErrorTypeNetwork}, + {"unknown", errors.New("random kafka error"), ErrorTypeUnknown}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := classifyKafkaErrorType(tt.err); got != tt.want { + t.Errorf("classifyKafkaErrorType() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/plugins/services/ldap_test.go b/plugins/services/ldap_test.go index a8d4256..242430d 100644 --- a/plugins/services/ldap_test.go +++ b/plugins/services/ldap_test.go @@ -3,6 +3,7 @@ package services import ( + "errors" "fmt" "testing" @@ -28,3 +29,25 @@ func TestLDAPDNFormatsEscapeUsernameValue(t *testing.T) { t.Fatalf("escaped DN = %q", got[0]) } } + +func TestClassifyLDAPErrorType(t *testing.T) { + tests := []struct { + name string + err error + want ErrorType + }{ + {"nil", nil, ErrorTypeUnknown}, + {"invalid credentials", errors.New("invalid credentials"), ErrorTypeAuth}, + {"bind failed", errors.New("bind failed"), ErrorTypeAuth}, + {"ldap connection lost", errors.New("ldap: connection lost"), ErrorTypeNetwork}, + {"connection refused", errors.New("connection refused"), ErrorTypeNetwork}, + {"unknown", errors.New("random ldap error"), ErrorTypeUnknown}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := classifyLDAPErrorType(tt.err); got != tt.want { + t.Errorf("classifyLDAPErrorType() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/plugins/services/mongodb_test.go b/plugins/services/mongodb_test.go index df40fae..10e2f7f 100644 --- a/plugins/services/mongodb_test.go +++ b/plugins/services/mongodb_test.go @@ -6,6 +6,7 @@ import ( "bytes" "encoding/base64" "encoding/binary" + "errors" "strings" "testing" "time" @@ -128,3 +129,25 @@ func TestBuildMongoSCRAMClientFinalBuildsProof(t *testing.T) { t.Fatalf("client final = %q", got) } } + +func TestClassifyMongoDBErrorType(t *testing.T) { + tests := []struct { + name string + err error + want ErrorType + }{ + {"nil", nil, ErrorTypeUnknown}, + {"authentication failed", errors.New("authentication failed"), ErrorTypeAuth}, + {"bad auth", errors.New("bad auth"), ErrorTypeAuth}, + {"dial tcp", errors.New("dial tcp connection refused"), ErrorTypeNetwork}, + {"eof", errors.New("eof"), ErrorTypeNetwork}, + {"unknown", errors.New("random mongodb error"), ErrorTypeUnknown}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := classifyMongoDBErrorType(tt.err); got != tt.want { + t.Errorf("classifyMongoDBErrorType() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/plugins/services/mssql_test.go b/plugins/services/mssql_test.go new file mode 100644 index 0000000..697621f --- /dev/null +++ b/plugins/services/mssql_test.go @@ -0,0 +1,30 @@ +//go:build plugin_mssql || !plugin_selective + +package services + +import ( + "errors" + "testing" +) + +func TestClassifyMSSQLErrorType(t *testing.T) { + tests := []struct { + name string + err error + want ErrorType + }{ + {"nil error", nil, ErrorTypeUnknown}, + {"login failed", errors.New("login failed"), ErrorTypeAuth}, + {"account locked", errors.New("account locked"), ErrorTypeAuth}, + {"context deadline exceeded", errors.New("context deadline exceeded"), ErrorTypeNetwork}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := classifyMSSQLErrorType(tt.err) + if got != tt.want { + t.Errorf("classifyMSSQLErrorType(%v) = %v, want %v", tt.err, got, tt.want) + } + }) + } +} diff --git a/plugins/services/mysql_test.go b/plugins/services/mysql_test.go index 2a22d03..7ab861e 100644 --- a/plugins/services/mysql_test.go +++ b/plugins/services/mysql_test.go @@ -3,6 +3,7 @@ package services import ( + "errors" "io" "net" "strings" @@ -85,3 +86,51 @@ func TestMySQLConnStringRejectsUnsupportedUsernameDelimiters(t *testing.T) { t.Fatal("mySQLConnString() error = nil, want unsupported delimiter error") } } + +func TestMySQLConnStringRejectsAtSign(t *testing.T) { + info := &common.HostInfo{Host: "127.0.0.1", Port: 3306} + if _, err := mySQLConnString("user@host", "pass", info, time.Second); err == nil { + t.Fatal("mySQLConnString() error = nil, want unsupported delimiter error for @") + } +} + +func TestMySQLConnStringRejectsSlash(t *testing.T) { + info := &common.HostInfo{Host: "127.0.0.1", Port: 3306} + if _, err := mySQLConnString("user/name", "pass", info, time.Second); err == nil { + t.Fatal("mySQLConnString() error = nil, want unsupported delimiter error for /") + } +} + +func TestMySQLConnStringValidUser(t *testing.T) { + info := &common.HostInfo{Host: "127.0.0.1", Port: 3306} + dsn, err := mySQLConnString("root", "password", info, 3*time.Second) + if err != nil { + t.Fatalf("mySQLConnString() error = %v", err) + } + if dsn == "" { + t.Fatal("mySQLConnString() returned empty DSN") + } +} + +func TestClassifyMySQLErrorType(t *testing.T) { + tests := []struct { + name string + err error + want ErrorType + }{ + {"nil error", nil, ErrorTypeUnknown}, + {"access denied for user", errors.New("access denied for user"), ErrorTypeAuth}, + {"host is not allowed", errors.New("host is not allowed"), ErrorTypeAuth}, + {"too many connections", errors.New("too many connections"), ErrorTypeNetwork}, + {"can't connect to mysql server", errors.New("can't connect to mysql server"), ErrorTypeNetwork}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := classifyMySQLErrorType(tt.err) + if got != tt.want { + t.Errorf("classifyMySQLErrorType(%v) = %v, want %v", tt.err, got, tt.want) + } + }) + } +} diff --git a/plugins/services/neo4j_test.go b/plugins/services/neo4j_test.go index 33807eb..da42bb1 100644 --- a/plugins/services/neo4j_test.go +++ b/plugins/services/neo4j_test.go @@ -4,6 +4,7 @@ package services import ( "context" + "errors" "net/http" "net/http/httptest" "testing" @@ -32,3 +33,24 @@ func TestNeo4jUnauthorizedRequiresNeo4jBody(t *testing.T) { t.Fatalf("testUnauthorizedAccess reported generic 200 as Neo4j: %#v", result) } } + +func TestClassifyNeo4jErrorType(t *testing.T) { + tests := []struct { + name string + err error + want ErrorType + }{ + {"nil", nil, ErrorTypeUnknown}, + {"authentication failed", errors.New("authentication failed"), ErrorTypeAuth}, + {"401 unauthorized", errors.New("401 unauthorized"), ErrorTypeAuth}, + {"connection refused", errors.New("connection refused"), ErrorTypeNetwork}, + {"unknown", errors.New("random neo4j error"), ErrorTypeUnknown}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := classifyNeo4jErrorType(tt.err); got != tt.want { + t.Errorf("classifyNeo4jErrorType() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/plugins/services/netbios_test.go b/plugins/services/netbios_test.go index 5b0fbc6..8435ca5 100644 --- a/plugins/services/netbios_test.go +++ b/plugins/services/netbios_test.go @@ -38,3 +38,192 @@ func appendNTLMAVPair(dst []byte, id uint16, value string) []byte { } return append(dst, buf...) } + +// --- NetBIOSInfo.Summary --- + +func TestNetBIOSInfoSummary(t *testing.T) { + p := NewNetBIOSPlugin() + _ = p // 仅用于确认插件可实例化,Summary 是值方法 + + cases := []struct { + name string + info NetBIOSInfo + want string + }{ + { + name: "invalid returns empty", + info: NetBIOSInfo{Valid: false}, + want: "", + }, + { + name: "computer + domain no dot", + info: NetBIOSInfo{Valid: true, ComputerName: "PC01", DomainName: "CORP"}, + want: "CORP\\PC01", + }, + { + name: "computer with dot ignores domain prefix", + info: NetBIOSInfo{Valid: true, ComputerName: "pc01.corp.local", DomainName: "CORP"}, + want: "pc01.corp.local", + }, + { + name: "no computer uses server service + domain", + info: NetBIOSInfo{Valid: true, ServerService: "SRV01", DomainName: "CORP"}, + want: "CORP\\SRV01", + }, + { + name: "no computer uses workstation + netbios domain", + info: NetBIOSInfo{Valid: true, WorkstationService: "WKS01", NetBIOSDomainName: "WORKGROUP"}, + want: "WORKGROUP\\WKS01", + }, + { + name: "domain controller prefix", + info: NetBIOSInfo{Valid: true, ComputerName: "DC1", DomainName: "CORP", DomainControllers: "CORP"}, + want: "DC:CORP\\DC1", + }, + { + name: "os version appended", + info: NetBIOSInfo{Valid: true, ComputerName: "PC01", DomainName: "CORP", OSVersion: "Windows 10"}, + want: "CORP\\PC01 Windows 10", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := tc.info.Summary() + if got != tc.want { + t.Errorf("got %q, want %q", got, tc.want) + } + }) + } +} + +// --- parseNetBIOSNames --- + +func TestParseNetBIOSNames(t *testing.T) { + p := &NetBIOSPlugin{} + + t.Run("data too short", func(t *testing.T) { + _, err := p.parseNetBIOSNames(make([]byte, 40)) + if err == nil { + t.Fatal("expected error for short data") + } + }) + + t.Run("numNames zero", func(t *testing.T) { + data := make([]byte, 57) // index 56 = 0 + _, err := p.parseNetBIOSNames(data) + if err == nil { + t.Fatal("expected error for zero numNames") + } + }) + + t.Run("parses workstation and domain records", func(t *testing.T) { + header := make([]byte, 57) + header[56] = 2 // 2 records + + // Record 1: WorkstationService — flagByte=0x00, nameFlags=0x04 (unique, <128) + rec1 := make([]byte, 18) + copy(rec1, []byte("TESTPC ")) // 15 bytes + rec1[15] = 0x00 // flagByte = WorkstationService + rec1[16] = 0x04 // nameFlags unique + rec1[17] = 0x00 + + // Record 2: DomainName — flagByte=0x00, nameFlags=0x84 (group, >=128) + rec2 := make([]byte, 18) + copy(rec2, []byte("WORKGROUP ")) // 15 bytes + rec2[15] = 0x00 // flagByte = DomainName for group + rec2[16] = 0x84 // nameFlags group + rec2[17] = 0x00 + + data := append(header, rec1...) + data = append(data, rec2...) + + info, err := p.parseNetBIOSNames(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !info.Valid { + t.Fatal("expected Valid=true") + } + if info.WorkstationService != "TESTPC" { + t.Errorf("WorkstationService = %q, want TESTPC", info.WorkstationService) + } + if info.DomainName != "WORKGROUP" { + t.Errorf("DomainName = %q, want WORKGROUP", info.DomainName) + } + }) +} + +// --- cleanOSString --- + +func TestCleanOSString(t *testing.T) { + p := &NetBIOSPlugin{} + + cases := []struct { + name string + data []byte + want string + }{ + { + name: "empty", + data: []byte{}, + want: "", + }, + { + name: "plain ascii", + data: []byte("Windows Server 2019"), + want: "Windows Server 2019", + }, + { + name: "double null splits sections, first is returned", + data: append([]byte("Windows 10\x00\x00"), []byte("Service Pack 1")...), + want: "Windows 10", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := p.cleanOSString(tc.data) + if got != tc.want { + t.Errorf("got %q, want %q", got, tc.want) + } + }) + } +} + +// --- parseUnicodeString (NetBIOSPlugin) --- + +func TestNetBIOSParseUnicodeString(t *testing.T) { + p := &NetBIOSPlugin{} + + cases := []struct { + name string + data []byte + want string + }{ + { + name: "empty", + data: []byte{}, + want: "", + }, + { + name: "odd length returns empty", + data: []byte{0x41}, + want: "", + }, + { + name: "UTF-16LE AB", + data: []byte{0x41, 0x00, 0x42, 0x00}, + want: "AB", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := p.parseUnicodeString(tc.data) + if got != tc.want { + t.Errorf("got %q, want %q", got, tc.want) + } + }) + } +} diff --git a/plugins/services/oracle_raw_test.go b/plugins/services/oracle_raw_test.go index 3364510..94d8794 100644 --- a/plugins/services/oracle_raw_test.go +++ b/plugins/services/oracle_raw_test.go @@ -4,9 +4,16 @@ package services import ( "bytes" + "encoding/binary" + "encoding/hex" + "strings" "testing" ) +// --------------------------------------------------------------------------- +// oracleConnectData +// --------------------------------------------------------------------------- + func TestOracleConnectDataDoesNotExposeClientIdentity(t *testing.T) { connectData := oracleConnectData("db.example", 1521, "ORCL") @@ -16,3 +23,1086 @@ func TestOracleConnectDataDoesNotExposeClientIdentity(t *testing.T) { } } } + +func TestOracleConnectDataFormat(t *testing.T) { + cd := oracleConnectData("localhost", 1521, "XE") + if !strings.Contains(cd, "HOST=localhost") { + t.Errorf("missing host: %s", cd) + } + if !strings.Contains(cd, "PORT=1521") { + t.Errorf("missing port: %s", cd) + } + if !strings.Contains(cd, "SERVICE_NAME=XE") { + t.Errorf("missing service name: %s", cd) + } +} + +// --------------------------------------------------------------------------- +// toInt64 / toUint64 +// --------------------------------------------------------------------------- + +func TestToInt64(t *testing.T) { + cases := []struct { + in interface{} + want int64 + }{ + {int(42), 42}, + {int16(-100), -100}, + {int32(0x7fffffff), 0x7fffffff}, + {int64(-1), -1}, + {uint8(255), 255}, + {uint16(1000), 1000}, + {uint32(99999), 99999}, + {uint64(1), 1}, + {uint(7), 7}, + } + for _, c := range cases { + if got := toInt64(c.in); got != c.want { + t.Errorf("toInt64(%v) = %d, want %d", c.in, got, c.want) + } + } +} + +func TestToInt64Panic(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("expected panic for unsupported type") + } + }() + toInt64("string") +} + +func TestToUint64(t *testing.T) { + cases := []struct { + in interface{} + want uint64 + }{ + {int(5), 5}, + {int16(300), 300}, + {int32(65535), 65535}, + {int64(1 << 40), 1 << 40}, + {uint8(0xff), 0xff}, + {uint16(0xffff), 0xffff}, + {uint32(0xffffffff), 0xffffffff}, + {uint64(^uint64(0)), ^uint64(0)}, + {uint(42), 42}, + } + for _, c := range cases { + if got := toUint64(c.in); got != c.want { + t.Errorf("toUint64(%v) = %d, want %d", c.in, got, c.want) + } + } +} + +func TestToUint64Panic(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("expected panic for unsupported type") + } + }() + toUint64(3.14) +} + +// --------------------------------------------------------------------------- +// oraclePKCS5Padding +// --------------------------------------------------------------------------- + +func TestOraclePKCS5Padding(t *testing.T) { + // block size 16 — "hello" (5 bytes) → 11 bytes of padding (value 0x0b) + padded := oraclePKCS5Padding([]byte("hello"), 16) + if len(padded) != 16 { + t.Fatalf("expected length 16, got %d", len(padded)) + } + for _, b := range padded[5:] { + if b != 11 { + t.Fatalf("expected padding byte 0x0b, got 0x%02x", b) + } + } +} + +func TestOraclePKCS5PaddingAligned(t *testing.T) { + // input length == block size → adds a full block of padding + padded := oraclePKCS5Padding([]byte("1234567890123456"), 16) + if len(padded) != 32 { + t.Fatalf("expected 32, got %d", len(padded)) + } + for _, b := range padded[16:] { + if b != 16 { + t.Fatalf("bad padding byte: 0x%02x", b) + } + } +} + +// --------------------------------------------------------------------------- +// oracleExtractCode +// --------------------------------------------------------------------------- + +func TestOracleExtractCode(t *testing.T) { + cases := []struct { + msg string + want int + }{ + {"(ERR=12505)", 12505}, + {"something CODE=1017 blah", 1017}, + {"no code here", 0}, + {"err=0042 trailing", 42}, + {"CODE= 28000", 28000}, + } + for _, c := range cases { + if got := oracleExtractCode(c.msg); got != c.want { + t.Errorf("oracleExtractCode(%q) = %d, want %d", c.msg, got, c.want) + } + } +} + +// --------------------------------------------------------------------------- +// oracleRefuseError +// --------------------------------------------------------------------------- + +func TestOracleRefuseErrorShortPacket(t *testing.T) { + err := oracleRefuseError([]byte{0, 1, 2}) + if err == nil || !strings.Contains(err.Error(), "refused") { + t.Errorf("expected 'refused' error, got %v", err) + } +} + +func TestOracleRefuseErrorWithMessage(t *testing.T) { + msg := "(ERR=12505)" + raw := make([]byte, 12+len(msg)) + binary.BigEndian.PutUint16(raw[10:12], uint16(len(msg))) + copy(raw[12:], msg) + err := oracleRefuseError(raw) + if err == nil { + t.Fatal("expected non-nil error") + } + if !strings.Contains(err.Error(), "12505") { + t.Errorf("error should mention code 12505: %v", err) + } +} + +func TestOracleRefuseErrorNoCode(t *testing.T) { + msg := "connection not allowed" + raw := make([]byte, 12+len(msg)) + binary.BigEndian.PutUint16(raw[10:12], uint16(len(msg))) + copy(raw[12:], msg) + err := oracleRefuseError(raw) + if err == nil || !strings.Contains(err.Error(), msg) { + t.Errorf("expected message in error: %v", err) + } +} + +// --------------------------------------------------------------------------- +// oracleGenerateSpeedyKey +// --------------------------------------------------------------------------- + +func TestOracleGenerateSpeedyKey(t *testing.T) { + key := oracleGenerateSpeedyKey([]byte("buffer"), []byte("secret"), 1) + if len(key) != 64 { + t.Fatalf("expected 64 bytes, got %d", len(key)) + } +} + +func TestOracleGenerateSpeedyKeyDeterministic(t *testing.T) { + a := oracleGenerateSpeedyKey([]byte("buf"), []byte("key"), 10) + b := oracleGenerateSpeedyKey([]byte("buf"), []byte("key"), 10) + if !bytes.Equal(a, b) { + t.Error("speedy key should be deterministic") + } +} + +func TestOracleGenerateSpeedyKeyDiffTurns(t *testing.T) { + a := oracleGenerateSpeedyKey([]byte("buf"), []byte("key"), 1) + b := oracleGenerateSpeedyKey([]byte("buf"), []byte("key"), 2) + if bytes.Equal(a, b) { + t.Error("different turns should produce different keys") + } +} + +// --------------------------------------------------------------------------- +// oracleKeyFromUserPass +// --------------------------------------------------------------------------- + +func TestOracleKeyFromUserPass(t *testing.T) { + key, err := oracleKeyFromUserPass("scott", "tiger") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(key) != 16 { + t.Fatalf("expected 16 bytes, got %d", len(key)) + } +} + +func TestOracleKeyFromUserPassCaseInsensitive(t *testing.T) { + k1, _ := oracleKeyFromUserPass("SCOTT", "TIGER") + k2, _ := oracleKeyFromUserPass("scott", "tiger") + if !bytes.Equal(k1, k2) { + t.Error("key should be case-insensitive") + } +} + +func TestOracleKeyFromUserPassEmpty(t *testing.T) { + key, err := oracleKeyFromUserPass("", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(key) != 16 { + t.Fatalf("expected 16 bytes, got %d", len(key)) + } +} + +// --------------------------------------------------------------------------- +// oracleDecryptSessionKey / oracleEncryptSessionKey +// --------------------------------------------------------------------------- + +func TestOracleEncryptDecryptSessionKey(t *testing.T) { + encKey := bytes.Repeat([]byte{0xAB}, 16) + plain := bytes.Repeat([]byte{0x55}, 16) + + enc, err := oracleEncryptSessionKey(false, encKey, plain) + if err != nil { + t.Fatalf("encrypt error: %v", err) + } + dec, err := oracleDecryptSessionKey(false, encKey, enc) + if err != nil { + t.Fatalf("decrypt error: %v", err) + } + if !bytes.Equal(dec, plain) { + t.Errorf("round-trip failed: got %x, want %x", dec, plain) + } +} + +func TestOracleDecryptSessionKeyInvalidHex(t *testing.T) { + _, err := oracleDecryptSessionKey(false, bytes.Repeat([]byte{0}, 16), "ZZZZ") + if err == nil { + t.Error("expected error for invalid hex") + } +} + +func TestOracleEncryptSessionKeyPadding(t *testing.T) { + encKey := bytes.Repeat([]byte{0x11}, 16) + plain := bytes.Repeat([]byte{0x22}, 16) + + // with padding — result should be hex-encoded and longer (full padded block) + encPad, err := oracleEncryptSessionKey(true, encKey, plain) + if err != nil { + t.Fatalf("encrypt (padding) error: %v", err) + } + // without padding — result is hex of origLen bytes + encNoPad, err := oracleEncryptSessionKey(false, encKey, plain) + if err != nil { + t.Fatalf("encrypt (no padding) error: %v", err) + } + // padded result includes extra padding block so it's longer + if len(encPad) <= len(encNoPad) { + t.Errorf("padded result (%d) should be longer than non-padded (%d)", len(encPad), len(encNoPad)) + } +} + +func TestOracleDecryptSessionKeyWithPadding(t *testing.T) { + encKey := bytes.Repeat([]byte{0xCC}, 16) + // Use 13 bytes so PKCS5 padding is 3 bytes (0x03 0x03 0x03), well within blockSize + plain := []byte("hello world!!") + + enc, err := oracleEncryptSessionKey(true, encKey, plain) + if err != nil { + t.Fatalf("encrypt error: %v", err) + } + dec, err := oracleDecryptSessionKey(true, encKey, enc) + if err != nil { + t.Fatalf("decrypt error: %v", err) + } + if !bytes.Equal(dec, plain) { + t.Errorf("round-trip (padding) failed: got %q, want %q", dec, plain) + } +} + +// --------------------------------------------------------------------------- +// oracleAlterSession +// --------------------------------------------------------------------------- + +func TestOracleAlterSession(t *testing.T) { + s := oracleAlterSession() + if !strings.Contains(s, "ALTER SESSION") { + t.Errorf("expected ALTER SESSION: %s", s) + } + if !strings.Contains(s, "NLS_LANGUAGE='AMERICAN'") { + t.Errorf("expected NLS_LANGUAGE: %s", s) + } + // must be null-terminated + if s[len(s)-1] != 0 { + t.Error("expected null terminator") + } +} + +// --------------------------------------------------------------------------- +// oracleTZBytes +// --------------------------------------------------------------------------- + +func TestOracleTZBytes(t *testing.T) { + b := oracleTZBytes() + if len(b) != 11 { + t.Fatalf("expected 11 bytes, got %d", len(b)) + } + // first 4 bytes are always 0x80,0,0,0 + if b[0] != 0x80 { + t.Errorf("expected 0x80 at [0], got 0x%02x", b[0]) + } +} + +// --------------------------------------------------------------------------- +// oracleTypeReps +// --------------------------------------------------------------------------- + +func TestOracleTypeReps(t *testing.T) { + nego := &oracleTCPNego{} + compileCaps := make([]byte, 45) + reps := oracleTypeReps(nego, compileCaps) + if len(reps) == 0 { + t.Error("expected non-empty type reps") + } + // all values should be valid int16 + for i, v := range reps { + if v < -1 || v > 10 { + // only known sentinel values are 0, 1, 10 + _ = i // non-fatal: just make sure no panic + } + } +} + +// --------------------------------------------------------------------------- +// session buffer operations (putBytes / putInt / putUint / putClr / putKeyVal) +// --------------------------------------------------------------------------- + +func newTestSession() *oracleSession { + return &oracleSession{ + version: 315, + clrChunkSize: 0x40, + } +} + +func TestSessionPutBytes(t *testing.T) { + s := newTestSession() + s.putBytes(0x01, 0x02, 0x03) + if !bytes.Equal(s.out.Bytes(), []byte{1, 2, 3}) { + t.Errorf("unexpected output: %x", s.out.Bytes()) + } +} + +func TestSessionPutIntBigEndian(t *testing.T) { + s := newTestSession() + s.putInt(uint16(0x0102), 2, true, false) + if !bytes.Equal(s.out.Bytes(), []byte{0x01, 0x02}) { + t.Errorf("big-endian uint16: %x", s.out.Bytes()) + } +} + +func TestSessionPutIntLittleEndian(t *testing.T) { + s := newTestSession() + s.putInt(uint32(0x01020304), 4, false, false) + if !bytes.Equal(s.out.Bytes(), []byte{0x04, 0x03, 0x02, 0x01}) { + t.Errorf("little-endian uint32: %x", s.out.Bytes()) + } +} + +func TestSessionPutIntCompress(t *testing.T) { + s := newTestSession() + s.putInt(int(256), 4, true, true) + out := s.out.Bytes() + // compressed: size byte + encoded bytes + if len(out) < 2 { + t.Fatalf("compressed output too short: %x", out) + } +} + +func TestSessionPutIntCompressZero(t *testing.T) { + s := newTestSession() + s.putInt(int(0), 4, true, true) + out := s.out.Bytes() + if len(out) != 1 || out[0] != 0 { + t.Errorf("zero compressed should be single 0x00: %x", out) + } +} + +func TestSessionPutIntCompressNegative(t *testing.T) { + s := newTestSession() + s.putInt(int(-1), 4, true, true) + out := s.out.Bytes() + // high bit of size byte should be set for negative + if len(out) < 2 || out[0]&0x80 == 0 { + t.Errorf("negative compress: expected high bit set in size byte: %x", out) + } +} + +func TestSessionPutInt1Byte(t *testing.T) { + s := newTestSession() + s.putInt(uint8(0xAB), 1, true, false) + out := s.out.Bytes() + if len(out) != 1 || out[0] != 0xAB { + t.Errorf("1-byte int: %x", out) + } +} + +func TestSessionPutUintBigEndian(t *testing.T) { + s := newTestSession() + s.putUint(uint16(0xBEEF), 2, true, false) + if !bytes.Equal(s.out.Bytes(), []byte{0xBE, 0xEF}) { + t.Errorf("putUint big-endian: %x", s.out.Bytes()) + } +} + +func TestSessionPutUintCompress(t *testing.T) { + s := newTestSession() + s.putUint(uint32(0), 4, true, true) + out := s.out.Bytes() + if len(out) != 1 || out[0] != 0 { + t.Errorf("putUint compress zero: %x", out) + } +} + +func TestSessionPutUint1Byte(t *testing.T) { + s := newTestSession() + s.putUint(uint8(7), 1, true, false) + out := s.out.Bytes() + if len(out) != 1 || out[0] != 7 { + t.Errorf("putUint 1-byte: %x", out) + } +} + +func TestSessionPutClrEmpty(t *testing.T) { + s := newTestSession() + s.putClr(nil) + out := s.out.Bytes() + if len(out) != 1 || out[0] != 0 { + t.Errorf("empty clr: %x", out) + } +} + +func TestSessionPutClrShort(t *testing.T) { + s := newTestSession() + s.putClr([]byte("hello")) + out := s.out.Bytes() + if out[0] != 5 || string(out[1:]) != "hello" { + t.Errorf("short clr: %x", out) + } +} + +func TestSessionPutClrLong(t *testing.T) { + // len > 0xfc triggers chunked encoding (0xfe prefix) + s := newTestSession() + data := bytes.Repeat([]byte("A"), 300) + s.putClr(data) + out := s.out.Bytes() + if out[0] != 0xfe { + t.Errorf("expected 0xfe for long CLR, got 0x%02x", out[0]) + } +} + +func TestSessionPutString(t *testing.T) { + s := newTestSession() + s.putString("abc") + out := s.out.Bytes() + if out[0] != 3 || string(out[1:]) != "abc" { + t.Errorf("putString: %x", out) + } +} + +func TestSessionPutKeyVal(t *testing.T) { + s := newTestSession() + s.putKeyValString("KEY", "VAL", 1) + out := s.out.Bytes() + if len(out) == 0 { + t.Error("putKeyValString produced no output") + } + // must contain key and value text somewhere + if !bytes.Contains(out, []byte("KEY")) { + t.Error("KEY not found in output") + } + if !bytes.Contains(out, []byte("VAL")) { + t.Error("VAL not found in output") + } +} + +func TestSessionPutKeyValEmptyKey(t *testing.T) { + s := newTestSession() + s.putKeyVal(nil, []byte("val"), 0) + out := s.out.Bytes() + // empty key → single 0x00 byte at start + if out[0] != 0 { + t.Errorf("empty key should start with 0x00, got 0x%02x", out[0]) + } +} + +func TestSessionPutKeyValEmptyVal(t *testing.T) { + s := newTestSession() + s.putKeyVal([]byte("key"), nil, 0) + out := s.out.Bytes() + if !bytes.Contains(out, []byte("key")) { + t.Error("key not found in output") + } +} + +// --------------------------------------------------------------------------- +// session reset +// --------------------------------------------------------------------------- + +func TestSessionReset(t *testing.T) { + s := newTestSession() + s.in = []byte{1, 2, 3} + s.index = 2 + s.summary = &oracleSummary{retCode: 5} + s.putBytes(0xAA) + s.reset() + + if s.in != nil { + t.Error("in should be nil after reset") + } + if s.index != 0 { + t.Error("index should be 0 after reset") + } + if s.summary != nil { + t.Error("summary should be nil after reset") + } + if s.out.Len() != 0 { + t.Error("out buffer should be empty after reset") + } +} + +// --------------------------------------------------------------------------- +// session read (from in-memory buffer) +// --------------------------------------------------------------------------- + +func TestSessionRead(t *testing.T) { + s := newTestSession() + s.in = []byte{10, 20, 30, 40} + b, err := s.read(2) + if err != nil { + t.Fatalf("read error: %v", err) + } + if !bytes.Equal(b, []byte{10, 20}) { + t.Errorf("got %v", b) + } + b2, _ := s.read(2) + if !bytes.Equal(b2, []byte{30, 40}) { + t.Errorf("second read got %v", b2) + } +} + +func TestSessionGetByte(t *testing.T) { + s := newTestSession() + s.in = []byte{0xAB} + b, err := s.getByte() + if err != nil || b != 0xAB { + t.Errorf("getByte: %v, %v", b, err) + } +} + +func TestSessionGetBytes(t *testing.T) { + s := newTestSession() + s.in = []byte{1, 2, 3} + b, err := s.getBytes(3) + if err != nil || !bytes.Equal(b, []byte{1, 2, 3}) { + t.Errorf("getBytes: %v, %v", b, err) + } +} + +func TestSessionGetInt(t *testing.T) { + s := newTestSession() + // big-endian uint16 = 0x0102 + s.in = []byte{0x01, 0x02} + v, err := s.getInt(2, false, true) + if err != nil || v != 0x0102 { + t.Errorf("getInt BE: %d, %v", v, err) + } +} + +func TestSessionGetInt64Compress(t *testing.T) { + // compressed: size=2, value=0x0102 + s := newTestSession() + s.in = []byte{0x02, 0x01, 0x02} + v, err := s.getInt64(0, true, true) + if err != nil || v != 0x0102 { + t.Errorf("getInt64 compress: %d, %v", v, err) + } +} + +func TestSessionGetInt64CompressNegative(t *testing.T) { + // negative compressed: size byte has 0x80 set, size=1, value=1 → -1 + s := newTestSession() + s.in = []byte{0x81, 0x01} + v, err := s.getInt64(0, true, true) + if err != nil || v != -1 { + t.Errorf("getInt64 compress negative: %d, %v", v, err) + } +} + +func TestSessionGetInt64CompressZero(t *testing.T) { + s := newTestSession() + s.in = []byte{0x00} + v, err := s.getInt64(0, true, true) + if err != nil || v != 0 { + t.Errorf("getInt64 compress zero: %d, %v", v, err) + } +} + +func TestSessionGetNullTermString(t *testing.T) { + s := newTestSession() + s.in = append([]byte("hello\x00world"), make([]byte, 50)...) + str, err := s.getNullTermString(20) + if err != nil || str != "hello" { + t.Errorf("getNullTermString: %q, %v", str, err) + } +} + +func TestSessionGetNullTermStringNoNull(t *testing.T) { + s := newTestSession() + s.in = []byte("hello") + str, err := s.getNullTermString(5) + if err != nil || str != "hello" { + t.Errorf("no-null getNullTermString: %q, %v", str, err) + } +} + +func TestSessionGetClrEmpty(t *testing.T) { + s := newTestSession() + s.in = []byte{0x00} // length = 0 → nil + b, err := s.getClr() + if err != nil || b != nil { + t.Errorf("getClr empty: %v, %v", b, err) + } +} + +func TestSessionGetClrShort(t *testing.T) { + s := newTestSession() + s.in = append([]byte{0x03}, []byte("abc")...) + b, err := s.getClr() + if err != nil || string(b) != "abc" { + t.Errorf("getClr short: %v, %v", b, err) + } +} + +func TestSessionGetClrNullAndFd(t *testing.T) { + for _, marker := range []byte{0xff, 0xfd} { + s := newTestSession() + s.in = []byte{marker} + b, err := s.getClr() + if err != nil || b != nil { + t.Errorf("getClr 0x%02x: %v, %v", marker, b, err) + } + } +} + +// --------------------------------------------------------------------------- +// hasError / oracleError +// --------------------------------------------------------------------------- + +func TestHasErrorNilSummary(t *testing.T) { + s := newTestSession() + if s.hasError() { + t.Error("nil summary should not be an error") + } +} + +func TestHasErrorRetCode0(t *testing.T) { + s := newTestSession() + s.summary = &oracleSummary{retCode: 0} + if s.hasError() { + t.Error("retCode 0 should not be an error") + } +} + +func TestHasErrorRetCode1403(t *testing.T) { + s := newTestSession() + s.summary = &oracleSummary{retCode: 1403} + if s.hasError() { + t.Error("retCode 1403 (no data) should not be an error") + } +} + +func TestHasErrorRetCodeNonZero(t *testing.T) { + s := newTestSession() + s.summary = &oracleSummary{retCode: 1017} + if !s.hasError() { + t.Error("retCode 1017 should be an error") + } +} + +func TestOracleErrorNilSummary(t *testing.T) { + s := newTestSession() + err := s.oracleError() + if err == nil { + t.Error("expected error") + } +} + +func TestOracleErrorWithMessage(t *testing.T) { + s := newTestSession() + s.summary = &oracleSummary{retCode: 1017, errorMessage: []byte("ORA-01017")} + err := s.oracleError() + if err == nil || !strings.Contains(err.Error(), "ORA-01017") { + t.Errorf("expected ORA-01017 in error: %v", err) + } +} + +func TestOracleErrorNoMessage(t *testing.T) { + s := newTestSession() + s.summary = &oracleSummary{retCode: 1017} + err := s.oracleError() + if err == nil || !strings.Contains(err.Error(), "ORA-01017") { + t.Errorf("expected formatted ORA-01017: %v", err) + } +} + +// --------------------------------------------------------------------------- +// ANO write helpers (output shape verification) +// --------------------------------------------------------------------------- + +func TestWriteANOHeader(t *testing.T) { + s := newTestSession() + s.writeANOHeader(101, 4, 0) + out := s.out.Bytes() + // first 4 bytes = 0xdeadbeef big-endian + if len(out) < 4 || binary.BigEndian.Uint32(out[:4]) != 0xdeadbeef { + t.Errorf("ANO header magic wrong: %x", out[:4]) + } +} + +func TestWriteANOServiceHeader(t *testing.T) { + s := newTestSession() + s.writeANOServiceHeader(2, 3) + out := s.out.Bytes() + // 2 bytes serviceType + 2 bytes subPackets + 4 bytes zeros = 8 + if len(out) != 8 { + t.Errorf("expected 8 bytes, got %d: %x", len(out), out) + } + if binary.BigEndian.Uint16(out[0:2]) != 2 { + t.Errorf("serviceType wrong: %x", out) + } +} + +func TestWriteANOPacketHeader(t *testing.T) { + s := newTestSession() + s.writeANOPacketHeader(8, 5) + out := s.out.Bytes() + if len(out) != 4 { + t.Errorf("expected 4 bytes, got %d", len(out)) + } + if binary.BigEndian.Uint16(out[0:2]) != 8 { + t.Errorf("length field wrong: %x", out) + } + if binary.BigEndian.Uint16(out[2:4]) != 5 { + t.Errorf("type field wrong: %x", out) + } +} + +func TestWriteANOVersion(t *testing.T) { + s := newTestSession() + s.writeANOVersion() + out := s.out.Bytes() + // 4-byte header (len=4,type=5) + 4-byte version = 8 bytes + if len(out) != 8 { + t.Errorf("expected 8 bytes, got %d: %x", len(out), out) + } +} + +func TestWriteANOStatus(t *testing.T) { + s := newTestSession() + s.writeANOStatus(0xfcff) + out := s.out.Bytes() + // 4-byte header + 2-byte status = 6 bytes + if len(out) != 6 { + t.Errorf("expected 6 bytes, got %d: %x", len(out), out) + } +} + +func TestWriteANOBytes(t *testing.T) { + s := newTestSession() + s.writeANOBytes([]byte{0xAA, 0xBB}) + out := s.out.Bytes() + // 4-byte header + 2 data bytes = 6 + if len(out) != 6 { + t.Errorf("expected 6 bytes, got %d: %x", len(out), out) + } + if out[4] != 0xAA || out[5] != 0xBB { + t.Errorf("data bytes wrong: %x", out) + } +} + +func TestWriteANOUB1(t *testing.T) { + s := newTestSession() + s.writeANOUB1(0x07) + out := s.out.Bytes() + // 4-byte header + 1 byte = 5 + if len(out) != 5 { + t.Errorf("expected 5 bytes, got %d", len(out)) + } + if out[4] != 0x07 { + t.Errorf("UB1 value wrong: %x", out) + } +} + +func TestWriteANOUB2Array(t *testing.T) { + s := newTestSession() + s.writeANOUB2Array([]int{1, 2, 3, 4}) + out := s.out.Bytes() + // header 4 + deadbeef 4 + const 2 + count 4 + 4*2 = 22 + if len(out) != 4+4+2+4+4*2 { + t.Errorf("expected 22 bytes, got %d: %x", len(out), out) + } +} + +// --------------------------------------------------------------------------- +// ANO read helpers (round-trip through in-buffer) +// --------------------------------------------------------------------------- + +func TestReadANOHeader(t *testing.T) { + s := newTestSession() + // build a valid ANO header in the in buffer + var buf bytes.Buffer + binary.Write(&buf, binary.BigEndian, uint32(0xdeadbeef)) // magic + binary.Write(&buf, binary.BigEndian, uint16(101)) // length + binary.Write(&buf, binary.BigEndian, uint32(0x0b200200)) // version + binary.Write(&buf, binary.BigEndian, uint16(4)) // serviceCount + buf.WriteByte(0) // flags + s.in = buf.Bytes() + + h, err := s.readANOHeader() + if err != nil { + t.Fatalf("readANOHeader error: %v", err) + } + if h.serviceCount != 4 { + t.Errorf("serviceCount: %d", h.serviceCount) + } +} + +func TestReadANOHeaderBadMagic(t *testing.T) { + s := newTestSession() + var buf bytes.Buffer + binary.Write(&buf, binary.BigEndian, uint32(0xCAFEBABE)) // wrong magic + binary.Write(&buf, binary.BigEndian, uint16(101)) + binary.Write(&buf, binary.BigEndian, uint32(0x0b200200)) + binary.Write(&buf, binary.BigEndian, uint16(2)) + buf.WriteByte(0) + s.in = buf.Bytes() + + _, err := s.readANOHeader() + if err == nil || !strings.Contains(err.Error(), "mismatch") { + t.Errorf("expected mismatch error: %v", err) + } +} + +func TestReadANOServiceHeader(t *testing.T) { + s := newTestSession() + var buf bytes.Buffer + binary.Write(&buf, binary.BigEndian, uint16(1)) // serviceType + binary.Write(&buf, binary.BigEndian, uint16(3)) // subPackets + binary.Write(&buf, binary.BigEndian, uint32(0)) // errCode + s.in = buf.Bytes() + + svcType, subPkts, errCode, err := s.readANOServiceHeader() + if err != nil || svcType != 1 || subPkts != 3 || errCode != 0 { + t.Errorf("readANOServiceHeader: %d %d %d %v", svcType, subPkts, errCode, err) + } +} + +func TestReadANOPacketHeader(t *testing.T) { + s := newTestSession() + var buf bytes.Buffer + binary.Write(&buf, binary.BigEndian, uint16(8)) // length + binary.Write(&buf, binary.BigEndian, uint16(5)) // type + s.in = buf.Bytes() + + length, err := s.readANOPacketHeader(5) + if err != nil || length != 8 { + t.Errorf("readANOPacketHeader: %d, %v", length, err) + } +} + +func TestReadANOPacketHeaderTypeMismatch(t *testing.T) { + s := newTestSession() + var buf bytes.Buffer + binary.Write(&buf, binary.BigEndian, uint16(4)) + binary.Write(&buf, binary.BigEndian, uint16(99)) + s.in = buf.Bytes() + + _, err := s.readANOPacketHeader(5) // expect 5, got 99 + if err == nil || !strings.Contains(err.Error(), "mismatch") { + t.Errorf("expected type mismatch error: %v", err) + } +} + +func TestReadANOVersion(t *testing.T) { + s := newTestSession() + var buf bytes.Buffer + binary.Write(&buf, binary.BigEndian, uint16(4)) // length + binary.Write(&buf, binary.BigEndian, uint16(5)) // type=5 + binary.Write(&buf, binary.BigEndian, uint32(0x0b200200)) // version + s.in = buf.Bytes() + + v, err := s.readANOVersion() + if err != nil || v != 0x0b200200 { + t.Errorf("readANOVersion: 0x%x, %v", v, err) + } +} + +func TestReadANOStatus(t *testing.T) { + s := newTestSession() + var buf bytes.Buffer + binary.Write(&buf, binary.BigEndian, uint16(2)) // length + binary.Write(&buf, binary.BigEndian, uint16(6)) // type=6 + binary.Write(&buf, binary.BigEndian, uint16(0xfbff)) // status + s.in = buf.Bytes() + + status, err := s.readANOStatus() + if err != nil || status != 0xfbff { + t.Errorf("readANOStatus: 0x%x, %v", status, err) + } +} + +func TestReadANOUB1(t *testing.T) { + s := newTestSession() + var buf bytes.Buffer + binary.Write(&buf, binary.BigEndian, uint16(1)) // length + binary.Write(&buf, binary.BigEndian, uint16(2)) // type=2 + buf.WriteByte(0x42) + s.in = buf.Bytes() + + v, err := s.readANOUB1() + if err != nil || v != 0x42 { + t.Errorf("readANOUB1: 0x%x, %v", v, err) + } +} + +func TestReadANOString(t *testing.T) { + s := newTestSession() + var buf bytes.Buffer + binary.Write(&buf, binary.BigEndian, uint16(4)) // length + binary.Write(&buf, binary.BigEndian, uint16(0)) // type=0 + buf.WriteString("TEST") + s.in = buf.Bytes() + + str, err := s.readANOString() + if err != nil || str != "TEST" { + t.Errorf("readANOString: %q, %v", str, err) + } +} + +func TestReadANOBytes(t *testing.T) { + s := newTestSession() + var buf bytes.Buffer + binary.Write(&buf, binary.BigEndian, uint16(3)) // length + binary.Write(&buf, binary.BigEndian, uint16(1)) // type=1 + buf.Write([]byte{0xAA, 0xBB, 0xCC}) + s.in = buf.Bytes() + + b, err := s.readANOBytes() + if err != nil || !bytes.Equal(b, []byte{0xAA, 0xBB, 0xCC}) { + t.Errorf("readANOBytes: %x, %v", b, err) + } +} + +func TestSkipANOPacket(t *testing.T) { + s := newTestSession() + var buf bytes.Buffer + binary.Write(&buf, binary.BigEndian, uint16(3)) // length + binary.Write(&buf, binary.BigEndian, uint16(7)) // type (ignored) + buf.Write([]byte{0xAA, 0xBB, 0xCC}) + s.in = buf.Bytes() + + if err := s.skipANOPacket(); err != nil { + t.Errorf("skipANOPacket error: %v", err) + } + if s.index != len(s.in) { + t.Errorf("expected to consume all %d bytes, index=%d", len(s.in), s.index) + } +} + +func TestSkipANOPacketZeroLength(t *testing.T) { + s := newTestSession() + var buf bytes.Buffer + binary.Write(&buf, binary.BigEndian, uint16(0)) // length=0 + binary.Write(&buf, binary.BigEndian, uint16(3)) + s.in = buf.Bytes() + + if err := s.skipANOPacket(); err != nil { + t.Errorf("skipANOPacket zero-length error: %v", err) + } +} + +// --------------------------------------------------------------------------- +// getDlc / getKeyVal (compressed-length wrappers) +// --------------------------------------------------------------------------- + +func TestGetDlcZeroLength(t *testing.T) { + s := newTestSession() + // compressed int 0 → single 0x00 byte + s.in = []byte{0x00} + b, err := s.getDlc() + if err != nil || b != nil { + t.Errorf("getDlc zero: %v, %v", b, err) + } +} + +func TestGetKeyVal(t *testing.T) { + s := newTestSession() + // build: key="K", val="V", num=7 + // all DLC values use compressed encoding (putUint + putClr) + write := func(b *bytes.Buffer, data []byte) { + // compressed uint32 for len + l := len(data) + tmp := make([]byte, 8) + binary.BigEndian.PutUint64(tmp, uint64(l)) + tmp = bytes.TrimLeft(tmp, "\x00") + if len(tmp) == 0 { + b.WriteByte(0) + } else { + b.WriteByte(byte(len(tmp))) + b.Write(tmp) + } + // clr: single byte len + data + b.WriteByte(byte(l)) + b.Write(data) + } + writeCompressedInt := func(b *bytes.Buffer, n int) { + tmp := make([]byte, 8) + binary.BigEndian.PutUint64(tmp, uint64(n)) + tmp = bytes.TrimLeft(tmp, "\x00") + if len(tmp) == 0 { + b.WriteByte(0) + } else { + b.WriteByte(byte(len(tmp))) + b.Write(tmp) + } + } + var buf bytes.Buffer + write(&buf, []byte("K")) + write(&buf, []byte("V")) + writeCompressedInt(&buf, 7) + s.in = buf.Bytes() + + key, val, num, err := s.getKeyVal() + if err != nil { + t.Fatalf("getKeyVal error: %v", err) + } + if string(key) != "K" || string(val) != "V" || num != 7 { + t.Errorf("getKeyVal: key=%q val=%q num=%d", key, val, num) + } +} + +// --------------------------------------------------------------------------- +// oracleEncryptPassword (random prefix — just check it decodes and expands) +// --------------------------------------------------------------------------- + +func TestOracleEncryptPassword(t *testing.T) { + key := bytes.Repeat([]byte{0x42}, 32) // AES-256 + enc, err := oracleEncryptPassword([]byte("secret"), key, false) + if err != nil { + t.Fatalf("oracleEncryptPassword error: %v", err) + } + if len(enc) == 0 { + t.Error("expected non-empty hex output") + } + // must be valid hex + if _, err = hex.DecodeString(enc); err != nil { + t.Errorf("output not valid hex: %v", err) + } +} diff --git a/plugins/services/oracle_test.go b/plugins/services/oracle_test.go new file mode 100644 index 0000000..99cf83c --- /dev/null +++ b/plugins/services/oracle_test.go @@ -0,0 +1,29 @@ +//go:build plugin_oracle || !plugin_selective + +package services + +import ( + "errors" + "testing" +) + +func TestClassifyOracleErrorType(t *testing.T) { + tests := []struct { + name string + err error + want ErrorType + }{ + {"nil error", nil, ErrorTypeUnknown}, + {"ORA-01017 invalid username/password", errors.New("ORA-01017: invalid username/password"), ErrorTypeAuth}, + {"TNS-12541 no listener", errors.New("TNS-12541 no listener"), ErrorTypeNetwork}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := classifyOracleErrorType(tt.err) + if got != tt.want { + t.Errorf("classifyOracleErrorType(%v) = %v, want %v", tt.err, got, tt.want) + } + }) + } +} diff --git a/plugins/services/postgresql_test.go b/plugins/services/postgresql_test.go index 1bbeec6..f7dd0bd 100644 --- a/plugins/services/postgresql_test.go +++ b/plugins/services/postgresql_test.go @@ -3,6 +3,7 @@ package services import ( + "errors" "strings" "testing" @@ -30,3 +31,25 @@ func TestPostgreSQLVulnInfoTruncatesByRune(t *testing.T) { t.Fatalf("postgresql truncation helper = %q", got) } } + +func TestClassifyPostgreSQLErrorType(t *testing.T) { + tests := []struct { + name string + err error + want ErrorType + }{ + {"nil", nil, ErrorTypeUnknown}, + {"password authentication failed", errors.New("password authentication failed"), ErrorTypeAuth}, + {"pq role", errors.New("pq: role \"foo\" does not exist"), ErrorTypeAuth}, + {"dial tcp", errors.New("dial tcp connection refused"), ErrorTypeNetwork}, + {"eof", errors.New("eof"), ErrorTypeNetwork}, + {"unknown", errors.New("random pg error"), ErrorTypeUnknown}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := classifyPostgreSQLErrorType(tt.err); got != tt.want { + t.Errorf("classifyPostgreSQLErrorType() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/plugins/services/rabbitmq_test.go b/plugins/services/rabbitmq_test.go index 8610264..02dcd65 100644 --- a/plugins/services/rabbitmq_test.go +++ b/plugins/services/rabbitmq_test.go @@ -5,6 +5,7 @@ package services import ( "bytes" "context" + "errors" "io" "net/http" "net/http/httptest" @@ -60,3 +61,24 @@ func (r *chunkedByteReader) Read(p []byte) (int, error) { r.data = r.data[n:] return n, nil } + +func TestClassifyRabbitMQErrorType(t *testing.T) { + tests := []struct { + name string + err error + want ErrorType + }{ + {"nil", nil, ErrorTypeUnknown}, + {"authentication failed", errors.New("authentication failed"), ErrorTypeAuth}, + {"401 unauthorized", errors.New("401 unauthorized"), ErrorTypeAuth}, + {"connection refused", errors.New("connection refused"), ErrorTypeNetwork}, + {"unknown", errors.New("random rabbitmq error"), ErrorTypeUnknown}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := classifyRabbitMQErrorType(tt.err); got != tt.want { + t.Errorf("classifyRabbitMQErrorType() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/plugins/services/rdp_test.go b/plugins/services/rdp_test.go new file mode 100644 index 0000000..442636c --- /dev/null +++ b/plugins/services/rdp_test.go @@ -0,0 +1,100 @@ +//go:build plugin_rdp || !plugin_selective + +package services + +import ( + "testing" + + "github.com/shadow1ng/fscan/common/i18n" +) + +func TestBuildBanner(t *testing.T) { + p := &RDPPlugin{} + fallback := i18n.GetText("rdp_remote_desktop_service") + + tests := []struct { + name string + osInfo map[string]any + want string + }{ + { + name: "nil map", + osInfo: nil, + want: fallback, + }, + { + name: "empty map", + osInfo: map[string]any{}, + want: fallback, + }, + { + name: "OsVerion and NetBIOSComputerName", + osInfo: map[string]any{"OsVerion": "Windows 10", "NetBIOSComputerName": "DESKTOP-01"}, + want: "RDP (Windows 10, DESKTOP-01)", + }, + { + name: "only OsVerion", + osInfo: map[string]any{"OsVerion": "Windows Server 2019"}, + want: "RDP (Windows Server 2019)", + }, + { + name: "only NetBIOSComputerName", + osInfo: map[string]any{"NetBIOSComputerName": "MY-HOST"}, + want: "RDP (Hostname:MY-HOST)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := p.buildBanner(tt.osInfo) + if got != tt.want { + t.Errorf("buildBanner() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestExtractStringField(t *testing.T) { + p := &RDPPlugin{} + + tests := []struct { + name string + osInfo map[string]any + key string + want string + }{ + { + name: "key exists and is string", + osInfo: map[string]any{"foo": "bar"}, + key: "foo", + want: "bar", + }, + { + name: "key exists but not string", + osInfo: map[string]any{"foo": 42}, + key: "foo", + want: "", + }, + { + name: "key does not exist", + osInfo: map[string]any{"foo": "bar"}, + key: "missing", + want: "", + }, + { + name: "nil map", + osInfo: nil, + key: "foo", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := p.extractStringField(tt.osInfo, tt.key) + if got != tt.want { + t.Errorf("extractStringField(%q) = %q, want %q", tt.key, got, tt.want) + } + }) + } +} diff --git a/plugins/services/redis_test.go b/plugins/services/redis_test.go index 21e5b55..c1b4e1d 100644 --- a/plugins/services/redis_test.go +++ b/plugins/services/redis_test.go @@ -3,6 +3,7 @@ package services import ( + "errors" "net" "strings" "testing" @@ -32,3 +33,24 @@ func (c *redisReplyTestConn) RemoteAddr() net.Addr { return nil } func (c *redisReplyTestConn) SetDeadline(time.Time) error { return nil } func (c *redisReplyTestConn) SetReadDeadline(time.Time) error { return nil } func (c *redisReplyTestConn) SetWriteDeadline(time.Time) error { return nil } + +func TestClassifyRedisErrorType(t *testing.T) { + tests := []struct { + name string + err error + want ErrorType + }{ + {"nil", nil, ErrorTypeUnknown}, + {"wrongpass", errors.New("wrongpass invalid password"), ErrorTypeAuth}, + {"noauth", errors.New("noauth authentication required"), ErrorTypeAuth}, + {"connection refused", errors.New("connection refused"), ErrorTypeNetwork}, + {"unknown", errors.New("random redis error"), ErrorTypeUnknown}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := classifyRedisErrorType(tt.err); got != tt.want { + t.Errorf("classifyRedisErrorType() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/plugins/services/rsync_test.go b/plugins/services/rsync_test.go index 699159d..1387990 100644 --- a/plugins/services/rsync_test.go +++ b/plugins/services/rsync_test.go @@ -3,6 +3,7 @@ package services import ( + "errors" "io" "testing" ) @@ -37,3 +38,24 @@ func TestReadRsyncLineHandlesChunkedReads(t *testing.T) { t.Fatalf("readRsyncLine() = %q", got) } } + +func TestClassifyRsyncErrorType(t *testing.T) { + tests := []struct { + name string + err error + want ErrorType + }{ + {"nil", nil, ErrorTypeUnknown}, + {"authentication failed", errors.New("authentication failed"), ErrorTypeAuth}, + {"access denied", errors.New("access denied"), ErrorTypeAuth}, + {"connection refused", errors.New("connection refused"), ErrorTypeNetwork}, + {"unknown", errors.New("random rsync error"), ErrorTypeUnknown}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := classifyRsyncErrorType(tt.err); got != tt.want { + t.Errorf("classifyRsyncErrorType() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/plugins/services/smb_protocol_test.go b/plugins/services/smb_protocol_test.go index f1e465e..1561743 100644 --- a/plugins/services/smb_protocol_test.go +++ b/plugins/services/smb_protocol_test.go @@ -3,6 +3,7 @@ package services import ( + "fmt" "io" "net" "testing" @@ -49,3 +50,466 @@ func TestReadSMBMessageHandlesChunkedReads(t *testing.T) { t.Fatalf("readSMBMessage() = %q", got) } } + +// ---- parseUnicodeString ---- + +func TestParseUnicodeString(t *testing.T) { + tests := []struct { + name string + data []byte + want string + }{ + {"empty", []byte{}, ""}, + {"odd length", []byte{0x41}, ""}, + {"null terminated", []byte{0x41, 0x00, 0x00, 0x00}, "A"}, + {"ascii", []byte{0x41, 0x00, 0x42, 0x00, 0x43, 0x00}, "ABC"}, + {"chinese", []byte{0x2d, 0x4e, 0x87, 0x65}, "中文"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := parseUnicodeString(tt.data); got != tt.want { + t.Errorf("parseUnicodeString() = %q, want %q", got, tt.want) + } + }) + } +} + +// ---- bytesToUint16 / bytesToUint32 ---- + +func TestBytesToUint16(t *testing.T) { + if got := bytesToUint16([]byte{}); got != 0 { + t.Errorf("short data: got %d", got) + } + if got := bytesToUint16([]byte{0x01}); got != 0 { + t.Errorf("single byte: got %d", got) + } + if got := bytesToUint16([]byte{0x34, 0x12}); got != 0x1234 { + t.Errorf("LE decode: got 0x%04x", got) + } +} + +func TestBytesToUint32(t *testing.T) { + if got := bytesToUint32([]byte{}); got != 0 { + t.Errorf("empty: got %d", got) + } + if got := bytesToUint32([]byte{0x01, 0x02, 0x03}); got != 0 { + t.Errorf("short: got %d", got) + } + if got := bytesToUint32([]byte{0x78, 0x56, 0x34, 0x12}); got != 0x12345678 { + t.Errorf("LE decode: got 0x%08x", got) + } +} + +// ---- trimSMBString ---- + +func TestTrimSMBString(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"hello\x00", "hello"}, + {"\x00hello\x00", "hello"}, + {" hello ", "hello"}, + {"\x00", ""}, + {"", ""}, + } + for _, tt := range tests { + if got := trimSMBString(tt.input); got != tt.want { + t.Errorf("trimSMBString(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +// ---- parseNTLMFlags ---- + +func TestParseNTLMFlags(t *testing.T) { + // 无标志 + if got := parseNTLMFlags(0); len(got) != 0 { + t.Errorf("zero flags: want empty, got %v", got) + } + + // 单标志 NEGOTIATE_UNICODE + flags := parseNTLMFlags(0x00000001) + if len(flags) != 1 || flags[0] != "NEGOTIATE_UNICODE" { + t.Errorf("single flag: got %v", flags) + } + + // 多标志 NEGOTIATE_OEM | NEGOTIATE_NTLM + multi := parseNTLMFlags(0x00000002 | 0x00000200) + if len(multi) != 2 { + t.Errorf("multi flags: want 2, got %d: %v", len(multi), multi) + } +} + +// ---- parseOSVersion ---- + +func TestParseOSVersion(t *testing.T) { + tests := []struct { + name string + data []byte + check func(s string) bool + }{ + { + "Windows 10", + []byte{10, 0, 0x00, 0x47, 0, 0, 0, 0}, // build 18176 < 22000 + func(s string) bool { return s != "" && contains(s, "Windows 10") }, + }, + { + "Windows 11", + []byte{10, 0, 0x00, 0x5B, 0, 0, 0, 0}, // build 23296 >= 22000 + func(s string) bool { return contains(s, "Windows 11") }, + }, + { + "Windows 7", + []byte{6, 1, 0x00, 0x09, 0, 0, 0, 0}, + func(s string) bool { return contains(s, "Windows 7") }, + }, + { + "Windows XP", + []byte{5, 1, 0x00, 0x0A, 0, 0, 0, 0}, + func(s string) bool { return contains(s, "Windows XP") }, + }, + { + "Windows 2000", + []byte{5, 0, 0x00, 0x07, 0, 0, 0, 0}, + func(s string) bool { return contains(s, "Windows 2000") }, + }, + { + "unknown", + []byte{4, 0, 0x00, 0x01, 0, 0, 0, 0}, + func(s string) bool { return contains(s, "Windows 4.0") }, + }, + { + "too short", + []byte{10, 0}, + func(s string) bool { return s == "" }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + info := &SMBTarget{} + parseOSVersion(tt.data, info) + if !tt.check(info.OSVersion) { + t.Errorf("OSVersion = %q", info.OSVersion) + } + }) + } +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || len(sub) == 0 || + func() bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false + }()) +} + +// ---- parseTargetInfo ---- + +func TestParseTargetInfo(t *testing.T) { + t.Run("empty", func(t *testing.T) { + info := &SMBTarget{} + parseTargetInfo([]byte{}, info) + if info.ComputerName != "" || info.DomainName != "" { + t.Error("expected empty fields") + } + }) + + makeAVPair := func(avId uint16, value []byte) []byte { + b := []byte{ + byte(avId), byte(avId >> 8), + byte(len(value)), byte(len(value) >> 8), + } + b = append(b, value...) + // terminator + b = append(b, 0x00, 0x00, 0x00, 0x00) + return b + } + + encodeUTF16LE := func(s string) []byte { + var b []byte + for _, r := range s { + b = append(b, byte(r), byte(uint16(r)>>8)) + } + return b + } + + t.Run("MsvAvNbComputerName", func(t *testing.T) { + info := &SMBTarget{} + parseTargetInfo(makeAVPair(0x0001, encodeUTF16LE("MYPC")), info) + if info.ComputerName != "MYPC" { + t.Errorf("ComputerName = %q", info.ComputerName) + } + }) + + t.Run("MsvAvNbDomainName", func(t *testing.T) { + info := &SMBTarget{} + parseTargetInfo(makeAVPair(0x0002, encodeUTF16LE("DOMAIN")), info) + if info.DomainName != "DOMAIN" { + t.Errorf("DomainName = %q", info.DomainName) + } + }) + + t.Run("MsvAvDnsComputerName_fallback", func(t *testing.T) { + info := &SMBTarget{} + parseTargetInfo(makeAVPair(0x0003, encodeUTF16LE("dns.host")), info) + if info.ComputerName != "dns.host" { + t.Errorf("ComputerName = %q", info.ComputerName) + } + }) + + t.Run("terminator only", func(t *testing.T) { + info := &SMBTarget{} + parseTargetInfo([]byte{0x00, 0x00, 0x00, 0x00}, info) + if info.ComputerName != "" || info.DomainName != "" { + t.Error("expected empty fields") + } + }) +} + +// ---- parseNTLMChallenge ---- + +// buildNTLMChallengePacket 构建测试用 NTLM Challenge 包。 +// targetName 和 targetInfo 均为 UTF-16LE 编码字节。 +// flags 应包含 0x02000000 (NEGOTIATE_VERSION) 才会有 version 字段。 +func buildNTLMChallengePacket(targetName []byte, flags uint32, targetInfo []byte, version []byte) []byte { + // 固定头:signature(8) + msgType(4) + targetLen(2) + targetMaxLen(2) + targetOffset(4) + // + flags(4) + challenge(8) + reserved(8) + targetInfoLen(2) + targetInfoMaxLen(2) + targetInfoOffset(4) + // + version(8, optional) + payload + headerSize := 56 // 8+4+2+2+4+4+8+8+2+2+4+8 (version always included here) + targetOffset := uint32(headerSize) + targetInfoOffset := targetOffset + uint32(len(targetName)) + + buf := make([]byte, 0, headerSize+len(targetName)+len(targetInfo)) + + // signature + buf = append(buf, []byte("NTLMSSP\x00")...) + // messageType = 2 + buf = append(buf, 0x02, 0x00, 0x00, 0x00) + // targetLength + buf = append(buf, byte(len(targetName)), byte(len(targetName)>>8)) + // targetMaxLength + buf = append(buf, byte(len(targetName)), byte(len(targetName)>>8)) + // targetOffset + buf = append(buf, byte(targetOffset), byte(targetOffset>>8), byte(targetOffset>>16), byte(targetOffset>>24)) + // flags + buf = append(buf, byte(flags), byte(flags>>8), byte(flags>>16), byte(flags>>24)) + // challenge (8 bytes) + buf = append(buf, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08) + // reserved (8 bytes) + buf = append(buf, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) + // targetInfoLength + buf = append(buf, byte(len(targetInfo)), byte(len(targetInfo)>>8)) + // targetInfoMaxLength + buf = append(buf, byte(len(targetInfo)), byte(len(targetInfo)>>8)) + // targetInfoOffset + buf = append(buf, byte(targetInfoOffset), byte(targetInfoOffset>>8), byte(targetInfoOffset>>16), byte(targetInfoOffset>>24)) + // version (8 bytes) + if len(version) == 8 { + buf = append(buf, version...) + } else { + buf = append(buf, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) + } + // payload + buf = append(buf, targetName...) + buf = append(buf, targetInfo...) + + return buf +} + +func TestParseNTLMChallenge(t *testing.T) { + t.Run("too short", func(t *testing.T) { + info := &SMBTarget{} + parseNTLMChallenge(make([]byte, 10), info) + if info.DomainName != "" { + t.Error("expected no domain") + } + }) + + t.Run("bad signature", func(t *testing.T) { + data := make([]byte, 64) + copy(data, "BADMAGIC") + info := &SMBTarget{} + parseNTLMChallenge(data, info) + if info.DomainName != "" { + t.Error("expected no domain") + } + }) + + t.Run("wrong message type", func(t *testing.T) { + data := make([]byte, 64) + copy(data, "NTLMSSP\x00") + data[8] = 0x01 // messageType = 1, not 2 + info := &SMBTarget{} + parseNTLMChallenge(data, info) + if info.DomainName != "" { + t.Error("expected no domain for wrong message type") + } + }) + + t.Run("valid challenge with domain", func(t *testing.T) { + encodeUTF16LE := func(s string) []byte { + var b []byte + for _, r := range s { + b = append(b, byte(r), byte(uint16(r)>>8)) + } + return b + } + targetName := encodeUTF16LE("WORKGROUP") + flags := uint32(0x00000001 | 0x00000200) // UNICODE | NTLM, no VERSION flag + data := buildNTLMChallengePacket(targetName, flags, nil, nil) + info := &SMBTarget{} + parseNTLMChallenge(data, info) + if info.DomainName != "WORKGROUP" { + t.Errorf("DomainName = %q, want WORKGROUP", info.DomainName) + } + }) + + t.Run("valid challenge with targetInfo and version", func(t *testing.T) { + encodeUTF16LE := func(s string) []byte { + var b []byte + for _, r := range s { + b = append(b, byte(r), byte(uint16(r)>>8)) + } + return b + } + targetName := encodeUTF16LE("CORP") + + // AV_PAIR: MsvAvNbComputerName = "SERVER" + computerNameBytes := encodeUTF16LE("SERVER") + avPair := []byte{ + 0x01, 0x00, + byte(len(computerNameBytes)), byte(len(computerNameBytes) >> 8), + } + avPair = append(avPair, computerNameBytes...) + avPair = append(avPair, 0x00, 0x00, 0x00, 0x00) // terminator + + // NEGOTIATE_VERSION flag = 0x02000000 + flags := uint32(0x02000000 | 0x00000001 | 0x00000200) + // Windows 10 build 19041 + version := []byte{10, 0, 0xA1, 0x4A, 0x00, 0x00, 0x00, 0x0F} + data := buildNTLMChallengePacket(targetName, flags, avPair, version) + + info := &SMBTarget{} + parseNTLMChallenge(data, info) + + if info.DomainName != "CORP" { + t.Errorf("DomainName = %q, want CORP", info.DomainName) + } + if info.ComputerName != "SERVER" { + t.Errorf("ComputerName = %q, want SERVER", info.ComputerName) + } + if info.OSVersion == "" { + t.Error("OSVersion should not be empty") + } + if len(info.NTLMFlags) == 0 { + t.Error("NTLMFlags should not be empty") + } + }) +} + +// ---- classifySMBError ---- + +func TestClassifySMBError(t *testing.T) { + t.Run("nil error", func(t *testing.T) { + if got := classifySMBError(nil); got != ErrorTypeUnknown { + t.Errorf("nil: got %v", got) + } + }) + + t.Run("auth error keyword", func(t *testing.T) { + err := fmt.Errorf("authentication failed") + if got := classifySMBError(err); got != ErrorTypeAuth { + t.Errorf("auth keyword: got %v", got) + } + }) + + t.Run("NT status code", func(t *testing.T) { + err := fmt.Errorf("nt_status_logon_failure") + if got := classifySMBError(err); got != ErrorTypeAuth { + t.Errorf("NT status: got %v", got) + } + }) + + t.Run("network error", func(t *testing.T) { + err := fmt.Errorf("connection refused") + if got := classifySMBError(err); got != ErrorTypeNetwork { + t.Errorf("network: got %v", got) + } + }) +} + +// ---- SMBProtocol.String() ---- + +func TestSMBProtocolString(t *testing.T) { + tests := []struct { + p SMBProtocol + want string + }{ + {SMBProtocol1, "SMBv1"}, + {SMBProtocol2, "SMBv2"}, + {SMBProtocolUnknown, "Unknown"}, + {SMBProtocol(99), "Unknown"}, + } + for _, tt := range tests { + if got := tt.p.String(); got != tt.want { + t.Errorf("SMBProtocol(%d).String() = %q, want %q", tt.p, got, tt.want) + } + } +} + +// ---- SMBTarget.Summary() ---- + +func TestSMBTargetSummary(t *testing.T) { + t.Run("only protocol", func(t *testing.T) { + info := &SMBTarget{Protocol: SMBProtocol2} + if got := info.Summary(); got != "SMBv2" { + t.Errorf("got %q", got) + } + }) + + t.Run("full fields", func(t *testing.T) { + info := &SMBTarget{ + Protocol: SMBProtocol1, + OSVersion: "Windows 10 (Build 19041)", + ComputerName: "MYPC", + } + got := info.Summary() + if !contains(got, "SMBv1") || !contains(got, "Windows 10") || !contains(got, "MYPC") { + t.Errorf("Summary() = %q", got) + } + }) + + t.Run("empty optional fields", func(t *testing.T) { + info := &SMBTarget{Protocol: SMBProtocolUnknown} + if got := info.Summary(); got != "Unknown" { + t.Errorf("got %q", got) + } + }) +} + +// ---- buildNTLMSSPData ---- + +func TestBuildNTLMSSPData(t *testing.T) { + flags := []byte{0x07, 0x82, 0x08, 0xA2} + got := buildNTLMSSPData(flags) + if len(got) == 0 { + t.Fatal("buildNTLMSSPData returned empty") + } + // 长度固定(实际为158字节) + const wantLen = 158 + if len(got) != wantLen { + t.Errorf("len = %d, want %d", len(got), wantLen) + } + // flags 嵌入在偏移138处 + const flagsOffset = 138 + if got[flagsOffset] != flags[0] || got[flagsOffset+1] != flags[1] || + got[flagsOffset+2] != flags[2] || got[flagsOffset+3] != flags[3] { + t.Errorf("flags not embedded correctly at offset %d: got %x %x %x %x", + flagsOffset, got[flagsOffset], got[flagsOffset+1], got[flagsOffset+2], got[flagsOffset+3]) + } +} diff --git a/plugins/services/smtp_test.go b/plugins/services/smtp_test.go new file mode 100644 index 0000000..c0c633a --- /dev/null +++ b/plugins/services/smtp_test.go @@ -0,0 +1,30 @@ +//go:build plugin_smtp || !plugin_selective + +package services + +import ( + "errors" + "testing" +) + +func TestClassifySMTPErrorType(t *testing.T) { + tests := []struct { + name string + err error + want ErrorType + }{ + {"nil error", nil, ErrorTypeUnknown}, + {"535 authentication failed", errors.New("535 authentication failed"), ErrorTypeAuth}, + {"relay access denied", errors.New("relay access denied"), ErrorTypeAuth}, + {"connection refused", errors.New("connection refused"), ErrorTypeNetwork}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := classifySMTPErrorType(tt.err) + if got != tt.want { + t.Errorf("classifySMTPErrorType(%v) = %v, want %v", tt.err, got, tt.want) + } + }) + } +} diff --git a/plugins/services/snmp_test.go b/plugins/services/snmp_test.go new file mode 100644 index 0000000..bfd8360 --- /dev/null +++ b/plugins/services/snmp_test.go @@ -0,0 +1,156 @@ +//go:build plugin_snmp || !plugin_selective + +package services + +import ( + "encoding/asn1" + "testing" + + "github.com/shadow1ng/fscan/common" +) + +// --- buildSNMPGetRequest --- + +func TestBuildSNMPGetRequest(t *testing.T) { + oid := []int{1, 3, 6, 1, 2, 1, 1, 1, 0} + + t.Run("returns non-empty bytes starting with ASN.1 SEQUENCE", func(t *testing.T) { + pkt := buildSNMPGetRequest("public", oid) + if len(pkt) == 0 { + t.Fatal("expected non-empty packet") + } + if pkt[0] != 0x30 { + t.Errorf("first byte = 0x%02x, want 0x30 (ASN.1 SEQUENCE)", pkt[0]) + } + }) + + t.Run("different communities produce different lengths", func(t *testing.T) { + pkt1 := buildSNMPGetRequest("public", oid) + pkt2 := buildSNMPGetRequest("longercommunitystringhere", oid) + if len(pkt1) >= len(pkt2) { + t.Errorf("expected longer community to produce longer packet: len(public)=%d len(long)=%d", len(pkt1), len(pkt2)) + } + }) +} + +// --- marshalOIDWithNull --- + +func TestMarshalOIDWithNull(t *testing.T) { + oid := []int{1, 3, 6, 1, 2, 1, 1, 1, 0} + result := marshalOIDWithNull(oid) + + if len(result) == 0 { + t.Fatal("expected non-empty bytes") + } + + // 应包含 OID tag (0x06) 和 NULL tag (0x05) + foundOID := false + foundNull := false + for _, b := range result { + if b == 0x06 { + foundOID = true + } + if b == 0x05 { + foundNull = true + } + } + if !foundOID { + t.Error("expected OID tag 0x06 in output") + } + if !foundNull { + t.Error("expected NULL tag 0x05 in output") + } +} + +// --- parseSNMPResponse --- + +// buildTestSNMPResponse 构造最小合法 SNMPv2c GetResponse 包含 OctetString value +func buildTestSNMPResponse(community string, value string) []byte { + valBytes, _ := asn1.Marshal(asn1.RawValue{Class: 0, Tag: 4, Bytes: []byte(value)}) + oidBytes, _ := asn1.Marshal(asn1.ObjectIdentifier{1, 3, 6, 1, 2, 1, 1, 1, 0}) + + var vbContent []byte + vbContent = append(vbContent, oidBytes...) + vbContent = append(vbContent, valBytes...) + varbind, _ := asn1.Marshal(asn1.RawValue{Class: 0, Tag: 16, IsCompound: true, Bytes: vbContent}) + varbindList, _ := asn1.Marshal(asn1.RawValue{Class: 0, Tag: 16, IsCompound: true, Bytes: varbind}) + + reqID, _ := asn1.Marshal(12345) + errStatus, _ := asn1.Marshal(0) + errIndex, _ := asn1.Marshal(0) + + var pduContent []byte + pduContent = append(pduContent, reqID...) + pduContent = append(pduContent, errStatus...) + pduContent = append(pduContent, errIndex...) + pduContent = append(pduContent, varbindList...) + + // GetResponse PDU: context-specific tag 2 + pdu, _ := asn1.Marshal(asn1.RawValue{Class: 2, Tag: 2, IsCompound: true, Bytes: pduContent}) + + version, _ := asn1.Marshal(1) // SNMPv2c + comm, _ := asn1.Marshal([]byte(community)) + + var msgContent []byte + msgContent = append(msgContent, version...) + msgContent = append(msgContent, comm...) + msgContent = append(msgContent, pdu...) + + msg, _ := asn1.Marshal(asn1.RawValue{Class: 0, Tag: 16, IsCompound: true, Bytes: msgContent}) + return msg +} + +func TestParseSNMPResponse(t *testing.T) { + t.Run("empty data returns empty", func(t *testing.T) { + got := parseSNMPResponse([]byte{}) + if got != "" { + t.Errorf("got %q, want empty", got) + } + }) + + t.Run("invalid ASN.1 returns empty", func(t *testing.T) { + got := parseSNMPResponse([]byte{0xFF, 0xFF, 0xFF}) + if got != "" { + t.Errorf("got %q, want empty", got) + } + }) + + t.Run("valid response returns sysDescr value", func(t *testing.T) { + want := "Linux router 5.4.0" + pkt := buildTestSNMPResponse("public", want) + got := parseSNMPResponse(pkt) + if got != want { + t.Errorf("got %q, want %q", got, want) + } + }) +} + +// --- buildCommunityList --- + +func TestBuildCommunityList(t *testing.T) { + p := NewSNMPPlugin() + cfg := &common.Config{} + + list := p.buildCommunityList(cfg) + + if len(list) == 0 { + t.Fatal("community list must not be empty") + } + + hasPublic := false + hasPrivate := false + for _, c := range list { + if c == "public" { + hasPublic = true + } + if c == "private" { + hasPrivate = true + } + } + if !hasPublic { + t.Error("community list must contain 'public'") + } + if !hasPrivate { + t.Error("community list must contain 'private'") + } +} diff --git a/plugins/services/ssh_test.go b/plugins/services/ssh_test.go new file mode 100644 index 0000000..a5208a4 --- /dev/null +++ b/plugins/services/ssh_test.go @@ -0,0 +1,60 @@ +//go:build plugin_ssh || !plugin_selective + +package services + +import ( + "errors" + "testing" +) + +func TestClassifySSHErrorType(t *testing.T) { + tests := []struct { + name string + err error + want ErrorType + }{ + {"nil error", nil, ErrorTypeUnknown}, + {"unable to authenticate", errors.New("unable to authenticate"), ErrorTypeAuth}, + {"no supported methods remain", errors.New("no supported methods remain"), ErrorTypeAuth}, + {"handshake failed", errors.New("handshake failed"), ErrorTypeThrottle}, + {"ssh disconnect", errors.New("ssh: disconnect"), ErrorTypeThrottle}, + {"max startups", errors.New("max startups"), ErrorTypeThrottle}, + {"connection refused", errors.New("connection refused"), ErrorTypeNetwork}, + {"random error", errors.New("random error"), ErrorTypeUnknown}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := classifySSHErrorType(tt.err) + if got != tt.want { + t.Errorf("classifySSHErrorType(%v) = %v, want %v", tt.err, got, tt.want) + } + }) + } +} + +func TestClassifySSHError(t *testing.T) { + authKeywords := []string{"bad password", "invalid key"} + throttleKeywords := []string{"rate limited", "too fast"} + + tests := []struct { + name string + err error + want ErrorType + }{ + {"nil error", nil, ErrorTypeUnknown}, + {"custom auth keyword", errors.New("bad password provided"), ErrorTypeAuth}, + {"custom throttle keyword", errors.New("rate limited by server"), ErrorTypeThrottle}, + {"network error", errors.New("connection refused"), ErrorTypeNetwork}, + {"no match", errors.New("something else"), ErrorTypeUnknown}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := classifySSHError(tt.err, authKeywords, throttleKeywords) + if got != tt.want { + t.Errorf("classifySSHError(%v) = %v, want %v", tt.err, got, tt.want) + } + }) + } +} diff --git a/plugins/services/telnet_test.go b/plugins/services/telnet_test.go index 5d01239..8ccaeeb 100644 --- a/plugins/services/telnet_test.go +++ b/plugins/services/telnet_test.go @@ -3,6 +3,7 @@ package services import ( + "errors" "strings" "testing" "unicode/utf8" @@ -15,3 +16,24 @@ func TestTelnetExtractEvidenceTruncatesByRune(t *testing.T) { t.Fatalf("extractEvidence() = %q", got) } } + +func TestClassifyTelnetErrorType(t *testing.T) { + tests := []struct { + name string + err error + want ErrorType + }{ + {"nil", nil, ErrorTypeUnknown}, + {"login failed", errors.New("login failed"), ErrorTypeAuth}, + {"credentials rejected", errors.New("credentials rejected"), ErrorTypeAuth}, + {"connection refused", errors.New("connection refused"), ErrorTypeNetwork}, + {"unknown", errors.New("random telnet error"), ErrorTypeUnknown}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := classifyTelnetErrorType(tt.err); got != tt.want { + t.Errorf("classifyTelnetErrorType() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/plugins/services/vnc_test.go b/plugins/services/vnc_test.go new file mode 100644 index 0000000..b686d13 --- /dev/null +++ b/plugins/services/vnc_test.go @@ -0,0 +1,30 @@ +//go:build plugin_vnc || !plugin_selective + +package services + +import ( + "errors" + "testing" +) + +func TestClassifyVNCErrorType(t *testing.T) { + tests := []struct { + name string + err error + want ErrorType + }{ + {"nil error", nil, ErrorTypeUnknown}, + {"authentication failed", errors.New("authentication failed"), ErrorTypeAuth}, + {"too many authentication failures", errors.New("too many authentication failures"), ErrorTypeNetwork}, + {"connection refused", errors.New("connection refused"), ErrorTypeNetwork}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := classifyVNCErrorType(tt.err) + if got != tt.want { + t.Errorf("classifyVNCErrorType(%v) = %v, want %v", tt.err, got, tt.want) + } + }) + } +} diff --git a/plugins/web/webtitle_test.go b/plugins/web/webtitle_test.go index 9ce70e2..6d441aa 100644 --- a/plugins/web/webtitle_test.go +++ b/plugins/web/webtitle_test.go @@ -101,3 +101,194 @@ func TestReadWebTitleBodyIsBounded(t *testing.T) { t.Fatalf("body len = %d, want %d", len(got), maxWebTitleBodyBytes) } } + +func TestResolveRedirectURL(t *testing.T) { + p := NewWebTitlePlugin() + base := "http://example.com/path" + + tests := []struct { + name string + location string + want string + }{ + {"absolute http", "http://other.com/page", "http://other.com/page"}, + {"absolute https", "https://other.com/page", "https://other.com/page"}, + {"relative path", "/admin/login", "http://example.com/admin/login"}, + {"relative no slash", "login", "http://example.com/login"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := p.resolveRedirectURL(base, tt.location) + if got != tt.want { + t.Fatalf("resolveRedirectURL(%q, %q) = %q, want %q", base, tt.location, got, tt.want) + } + }) + } +} + +func TestResolveRedirectURLInvalidBase(t *testing.T) { + p := NewWebTitlePlugin() + got := p.resolveRedirectURL("://bad-url", "/path") + if got != "" { + t.Fatalf("expected empty string for invalid base, got %q", got) + } +} + +func TestResolveRedirectURLInvalidLocation(t *testing.T) { + p := NewWebTitlePlugin() + // 百分号开头的无效 URL + got := p.resolveRedirectURL("http://example.com", "://") + // net/url.Parse 对 "://" 不一定报错,只要不 panic 即可 + _ = got +} + +func TestFormatHeaders(t *testing.T) { + p := NewWebTitlePlugin() + + // 空 header + if got := p.formatHeaders(http.Header{}); got != "" { + t.Fatalf("empty headers = %q, want empty string", got) + } + + // 单个 header + h := http.Header{} + h.Set("Content-Type", "text/html") + got := p.formatHeaders(h) + if !strings.Contains(got, "Content-Type") || !strings.Contains(got, "text/html") { + t.Fatalf("formatHeaders missing expected content: %q", got) + } + + // 多值 header + h2 := http.Header{} + h2.Add("X-Custom", "val1") + h2.Add("X-Custom", "val2") + got2 := p.formatHeaders(h2) + if !strings.Contains(got2, "val1") || !strings.Contains(got2, "val2") { + t.Fatalf("formatHeaders missing multi-value: %q", got2) + } +} + +func TestURLHost(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"127.0.0.1", "127.0.0.1"}, + {"example.com", "example.com"}, + {"::1", "[::1]"}, + {"[::1]", "[::1]"}, // 已经括起来的不要双重括号 + } + for _, tt := range tests { + got := urlHost(tt.input) + if got != tt.want { + t.Fatalf("urlHost(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestTruncateRunes(t *testing.T) { + // 负数 maxRunes → 原样返回 + s := "hello" + if got := truncateRunes(s, -1); got != s { + t.Fatalf("truncateRunes negative = %q, want %q", got, s) + } + + // 短于 maxRunes → 原样返回 + if got := truncateRunes("ab", 10); got != "ab" { + t.Fatalf("truncateRunes short = %q, want %q", got, "ab") + } + + // 超过 maxRunes → 截断加 "..." + long := strings.Repeat("x", 5) + got := truncateRunes(long, 3) + if got != "xxx..." { + t.Fatalf("truncateRunes long = %q, want %q", got, "xxx...") + } + + // maxRunes=0 → 立刻截断 + if got := truncateRunes("hello", 0); got != "..." { + t.Fatalf("truncateRunes zero = %q, want %q", got, "...") + } +} + +func TestExtractTitleInvalidUTF8(t *testing.T) { + p := NewWebTitlePlugin() + // 构造含非法 UTF-8 字节的 title + html := "\xff\xfe" + got := p.extractTitle(html) + // 非法 UTF-8 应返回空 + if got != "" { + t.Fatalf("extractTitle with invalid UTF-8 = %q, want empty", got) + } +} + +func TestExtractTitleNoMatch(t *testing.T) { + p := NewWebTitlePlugin() + got := p.extractTitle("no title here") + if got != "" { + t.Fatalf("extractTitle no match = %q, want empty", got) + } +} + +func TestWebTitleHTTPClientsGM(t *testing.T) { + previousGM, previousNoRedirectGM := lib.ClientGM, lib.ClientNoRedirectGM + defer func() { + lib.ClientGM, lib.ClientNoRedirectGM = previousGM, previousNoRedirectGM + }() + + // 设置 GM 客户端为非 nil + lib.ClientGM = &http.Client{} + lib.ClientNoRedirectGM = &http.Client{ + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + clientNR, clientR := webTitleHTTPClients(true) + if clientNR == nil || clientR == nil { + t.Fatal("webTitleHTTPClients(GM) returned nil") + } +} + +func TestFirstHTTPClientAllNil(t *testing.T) { + got := firstHTTPClient(nil, nil, nil) + if got != http.DefaultClient { + t.Fatalf("firstHTTPClient all nil = %v, want http.DefaultClient", got) + } +} + +func TestFetchFaviconHashNon200(t *testing.T) { + previous := lib.Client + lib.Client = &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: http.NoBody, + }, nil + }), + } + defer func() { lib.Client = previous }() + + p := NewWebTitlePlugin() + hashes := p.fetchFaviconHash(context.Background(), "http://example.com") + if len(hashes.MMH3) != 0 || len(hashes.MD5) != 0 { + t.Fatalf("fetchFaviconHash non-200 returned hashes: %#v", hashes) + } +} + +func TestFetchFaviconHashBadURL(t *testing.T) { + p := NewWebTitlePlugin() + // 无效 URL 应返回空 hash,不 panic + hashes := p.fetchFaviconHash(context.Background(), "://bad") + if len(hashes.MMH3) != 0 || len(hashes.MD5) != 0 { + t.Fatalf("fetchFaviconHash bad URL returned hashes: %#v", hashes) + } +} + +// roundTripFunc 允许用函数实现 http.RoundTripper +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} diff --git a/webscan/fingerprint/calc_priority_test.go b/webscan/fingerprint/calc_priority_test.go new file mode 100644 index 0000000..831e358 --- /dev/null +++ b/webscan/fingerprint/calc_priority_test.go @@ -0,0 +1,191 @@ +package fingerprint + +import ( + "testing" +) + +// ============================================================================= +// calcPriority 测试 +// ============================================================================= + +func TestCalcPriority_FaviconHighest(t *testing.T) { + fp := &EnhancedFingerprint{} + p := calcPriority(fp, "favicon") + if p != 100 { + t.Errorf("favicon 优先级应为 100,实际 %d", p) + } +} + +func TestCalcPriority_RegexMedium(t *testing.T) { + fp := &EnhancedFingerprint{} + p := calcPriority(fp, "regex") + if p != 50 { + t.Errorf("regex 优先级应为 50,实际 %d", p) + } +} + +func TestCalcPriority_WordLow(t *testing.T) { + fp := &EnhancedFingerprint{} + p := calcPriority(fp, "word") + if p != 30 { + t.Errorf("word 优先级应为 30,实际 %d", p) + } +} + +func TestCalcPriority_UnknownTypeZero(t *testing.T) { + fp := &EnhancedFingerprint{} + p := calcPriority(fp, "unknown") + if p != 0 { + t.Errorf("未知类型优先级应为 0,实际 %d", p) + } +} + +func TestCalcPriority_VerifiedBonus(t *testing.T) { + fp := &EnhancedFingerprint{} + fp.Info.Metadata = map[string]interface{}{ + "verified": true, + } + p := calcPriority(fp, "word") + // word(30) + verified(20) = 50 + if p != 50 { + t.Errorf("word+verified 优先级应为 50,实际 %d", p) + } +} + +func TestCalcPriority_VerifiedFavicon(t *testing.T) { + fp := &EnhancedFingerprint{} + fp.Info.Metadata = map[string]interface{}{ + "verified": true, + } + p := calcPriority(fp, "favicon") + // favicon(100) + verified(20) = 120 + if p != 120 { + t.Errorf("favicon+verified 优先级应为 120,实际 %d", p) + } +} + +func TestCalcPriority_VerifiedFalse(t *testing.T) { + fp := &EnhancedFingerprint{} + fp.Info.Metadata = map[string]interface{}{ + "verified": false, + } + p := calcPriority(fp, "regex") + // verified=false 不加分 + if p != 50 { + t.Errorf("verified=false 时优先级应为 50,实际 %d", p) + } +} + +func TestCalcPriority_NilMetadata(t *testing.T) { + fp := &EnhancedFingerprint{} + // Metadata 为 nil,不加分 + p := calcPriority(fp, "favicon") + if p != 100 { + t.Errorf("nil metadata 时 favicon 优先级应为 100,实际 %d", p) + } +} + +// ============================================================================= +// matchRegex 测试 - 需要初始化 enhancedDB +// ============================================================================= + +func initEnhancedDBForTest(t *testing.T) { + t.Helper() + if enhancedDB == nil { + if err := LoadEnhancedFingerprints(); err != nil { + t.Fatalf("LoadEnhancedFingerprints 失败: %v", err) + } + } +} + +func TestMatchRegex_BodyMatch(t *testing.T) { + initEnhancedDBForTest(t) + + matcher := createMatcher("regex", nil, []string{`nginx/[\d.]+`}, nil, "body", "", false) + result := matchRegex(matcher, "Server: nginx/1.18.0 running", "") + if !result { + t.Error("body 中应匹配 nginx 版本正则") + } +} + +func TestMatchRegex_HeaderMatch(t *testing.T) { + initEnhancedDBForTest(t) + + matcher := createMatcher("regex", nil, []string{`X-Powered-By: PHP/[\d.]+`}, nil, "header", "", false) + result := matchRegex(matcher, "", "X-Powered-By: PHP/7.4.3") + if !result { + t.Error("header 中应匹配 PHP 版本正则") + } +} + +func TestMatchRegex_NoMatch(t *testing.T) { + initEnhancedDBForTest(t) + + matcher := createMatcher("regex", nil, []string{`apache/[\d.]+`}, nil, "body", "", false) + result := matchRegex(matcher, "nginx server running", "") + if result { + t.Error("不应匹配 apache 正则") + } +} + +func TestMatchRegex_ANDConditionAllMatch(t *testing.T) { + initEnhancedDBForTest(t) + + matcher := createMatcher("regex", nil, []string{`nginx`, `1\.18`}, nil, "body", "and", false) + result := matchRegex(matcher, "nginx/1.18.0 server", "") + if !result { + t.Error("AND 条件下两个正则都匹配应返回 true") + } +} + +func TestMatchRegex_ANDConditionPartialMatch(t *testing.T) { + initEnhancedDBForTest(t) + + matcher := createMatcher("regex", nil, []string{`nginx`, `apache`}, nil, "body", "and", false) + result := matchRegex(matcher, "nginx server", "") + if result { + t.Error("AND 条件下只有一个匹配应返回 false") + } +} + +func TestMatchRegex_ORConditionOneMatch(t *testing.T) { + initEnhancedDBForTest(t) + + matcher := createMatcher("regex", nil, []string{`nginx`, `apache`}, nil, "body", "or", false) + result := matchRegex(matcher, "apache httpd", "") + if !result { + t.Error("OR 条件下至少一个匹配应返回 true") + } +} + +func TestMatchRegex_CaseInsensitive(t *testing.T) { + initEnhancedDBForTest(t) + + matcher := createMatcher("regex", nil, []string{`NGINX`}, nil, "body", "", true) + result := matchRegex(matcher, "nginx/1.18.0", "") + if !result { + t.Error("大小写不敏感模式下应匹配") + } +} + +func TestMatchRegex_InvalidPattern(t *testing.T) { + initEnhancedDBForTest(t) + + // 无效正则不应崩溃 + matcher := createMatcher("regex", nil, []string{`[invalid regex(`}, nil, "body", "", false) + result := matchRegex(matcher, "test content", "") + if result { + t.Error("无效正则不应产生匹配") + } +} + +func TestMatchRegex_EmptyPatterns(t *testing.T) { + initEnhancedDBForTest(t) + + matcher := createMatcher("regex", nil, []string{}, nil, "body", "and", false) + result := matchRegex(matcher, "nginx", "") + // AND 条件且无 pattern:isAnd && len(Regex) > 0 为 false + if result { + t.Error("AND 条件下空 patterns 应返回 false") + } +} diff --git a/webscan/fingerprint_scanner_test.go b/webscan/fingerprint_scanner_test.go new file mode 100644 index 0000000..6a7b36c --- /dev/null +++ b/webscan/fingerprint_scanner_test.go @@ -0,0 +1,209 @@ +package WebScan + +import ( + "crypto/md5" //nolint:gosec + "fmt" + "testing" + + "github.com/shadow1ng/fscan/webscan/fingerprint" +) + +// ============================================================================= +// removeDuplicateElement 测试 +// ============================================================================= + +func TestRemoveDuplicateElement_Basic(t *testing.T) { + input := []string{"nginx", "apache", "nginx", "iis", "apache"} + result := removeDuplicateElement(input) + + if len(result) != 3 { + t.Errorf("期望3个唯一元素,实际 %d: %v", len(result), result) + } + + seen := make(map[string]int) + for _, v := range result { + seen[v]++ + if seen[v] > 1 { + t.Errorf("元素 %q 出现了多次", v) + } + } +} + +func TestRemoveDuplicateElement_Empty(t *testing.T) { + result := removeDuplicateElement([]string{}) + if len(result) != 0 { + t.Errorf("空输入应返回空切片,实际 %d", len(result)) + } +} + +func TestRemoveDuplicateElement_NoDup(t *testing.T) { + input := []string{"a", "b", "c"} + result := removeDuplicateElement(input) + if len(result) != 3 { + t.Errorf("无重复时应保留全部元素,实际 %d", len(result)) + } +} + +func TestRemoveDuplicateElement_AllSame(t *testing.T) { + input := []string{"dup", "dup", "dup", "dup"} + result := removeDuplicateElement(input) + if len(result) != 1 { + t.Errorf("全部相同时应只保留1个,实际 %d", len(result)) + } + if result[0] != "dup" { + t.Errorf("保留的元素应为 'dup',实际 %q", result[0]) + } +} + +func TestRemoveDuplicateElement_PreservesOrder(t *testing.T) { + input := []string{"c", "a", "b", "a", "c"} + result := removeDuplicateElement(input) + if len(result) != 3 { + t.Fatalf("期望3个元素,实际 %d", len(result)) + } + // 第一次出现的顺序应被保留 + if result[0] != "c" || result[1] != "a" || result[2] != "b" { + t.Errorf("顺序不符合预期: %v", result) + } +} + +// ============================================================================= +// matchByMd5 测试 +// ============================================================================= + +func TestMatchByMd5_KnownHash(t *testing.T) { + // 从真实的 Md5Datas 取第一条:{"BIG-IP", "04d9541338e525258daf47cc844d59f3"} + if len(fingerprint.Md5Datas) == 0 { + t.Skip("Md5Datas 为空,跳过测试") + } + + entry := fingerprint.Md5Datas[0] + + // 找到能产生这个 md5 的数据——直接暴力:构造一个有已知 md5 的 body + // 实际上 md5 是 favicon 的 hash,这里测试找不到匹配的情况 + emptyResult := matchByMd5([]byte("no match content here")) + if emptyResult != "" { + t.Logf("意外匹配了 %q(不影响功能,跳过断言)", emptyResult) + } + + // 验证函数正确返回空字符串——主要检测无崩溃 + _ = entry +} + +func TestMatchByMd5_NoMatch(t *testing.T) { + result := matchByMd5([]byte("definitely not matching any fingerprint 12345")) + if result != "" { + t.Errorf("不应匹配任何指纹,实际匹配了 %q", result) + } +} + +func TestMatchByMd5_Empty(t *testing.T) { + // 空 body 的 md5 固定值 d41d8cd98f00b204e9800998ecf8427e + // 检查是否在数据库中(不是,所以应返回空) + result := matchByMd5([]byte{}) + // 不强断言结果,只验证不崩溃 + _ = result +} + +// 构造一个真实 md5 让 matchByMd5 命中 +func TestMatchByMd5_ActualMatch(t *testing.T) { + if len(fingerprint.Md5Datas) == 0 { + t.Skip("Md5Datas 为空") + } + + // 找一条已知 md5,反向构造:我们不能反推原始数据 + // 但可以直接测试 md5 计算逻辑:手动计算 body 的 md5 并与函数对比 + body := []byte("test content for md5 check") + //nolint:gosec + expected := fmt.Sprintf("%x", md5.Sum(body)) + + // matchByMd5 内部会对 body 计算 md5,然后在 Md5Datas 中查找 + // 因为这个 md5 不在 Md5Datas 中,应返回 "" + result := matchByMd5(body) + if result != "" { + t.Logf("巧合命中: body_md5=%s matched=%q", expected, result) + } + // 主要验证逻辑路径可以走通 +} + +// ============================================================================= +// matchByRegex 测试 +// ============================================================================= + +func TestMatchByRegex_CodeType(t *testing.T) { + // 宝塔指纹:Type="code",匹配 body + data := CheckDatas{ + Body: []byte("app.bt.cn/static/app.png"), + Headers: "", + } + result := matchByRegex(data) + found := false + for _, name := range result { + if name == "宝塔" { + found = true + break + } + } + if !found { + t.Errorf("应匹配宝塔指纹,实际结果: %v", result) + } +} + +func TestMatchByRegex_HeaderType(t *testing.T) { + // CloudFlare 指纹:Type="headers",匹配 headers + data := CheckDatas{ + Body: []byte(""), + Headers: "CF-RAY: cloudflare-abc123", + } + result := matchByRegex(data) + found := false + for _, name := range result { + if name == "CloudFlare" { + found = true + break + } + } + if !found { + t.Errorf("应匹配CloudFlare指纹,实际结果: %v", result) + } +} + +func TestMatchByRegex_NoMatch(t *testing.T) { + data := CheckDatas{ + Body: []byte("hello world nothing special"), + Headers: "Content-Type: text/plain", + } + result := matchByRegex(data) + // 普通内容不应匹配特征指纹 + // 不强断言数量,只验证不崩溃 + _ = result +} + +func TestMatchByRegex_EmptyData(t *testing.T) { + data := CheckDatas{} + result := matchByRegex(data) + if result == nil { + result = []string{} + } + // 空数据不崩溃即可 + _ = result +} + +func TestMatchByRegex_DeepInserve(t *testing.T) { + // 深信服防火墙:body 中包含 "SANGFOR FW" + data := CheckDatas{ + Body: []byte(`SANGFOR FW product page`), + Headers: "", + } + result := matchByRegex(data) + found := false + for _, name := range result { + if name == "深信服防火墙类产品" { + found = true + break + } + } + if !found { + t.Errorf("应匹配深信服防火墙指纹,实际结果: %v", result) + } +} diff --git a/webscan/lib/client_test.go b/webscan/lib/client_test.go index 0b675fb..9b6199c 100644 --- a/webscan/lib/client_test.go +++ b/webscan/lib/client_test.go @@ -1,6 +1,114 @@ package lib -import "testing" +import ( + "testing" + + "gopkg.in/yaml.v2" +) + +// ============================================================================= +// UnmarshalYAML 测试 +// ============================================================================= + +func TestStrMapUnmarshalYAML(t *testing.T) { + t.Run("正常键值对", func(t *testing.T) { + data := []byte("key1: val1\nkey2: val2\n") + var m StrMap + if err := yaml.Unmarshal(data, &m); err != nil { + t.Fatalf("yaml.Unmarshal error = %v", err) + } + if len(m) != 2 { + t.Fatalf("len = %d, want 2", len(m)) + } + if m[0].Key != "key1" || m[0].Value != "val1" { + t.Errorf("m[0] = %+v, want {key1 val1}", m[0]) + } + if m[1].Key != "key2" || m[1].Value != "val2" { + t.Errorf("m[1] = %+v, want {key2 val2}", m[1]) + } + }) + + t.Run("单项", func(t *testing.T) { + data := []byte("only: one\n") + var m StrMap + if err := yaml.Unmarshal(data, &m); err != nil { + t.Fatalf("yaml.Unmarshal error = %v", err) + } + if len(m) != 1 || m[0].Key != "only" || m[0].Value != "one" { + t.Fatalf("m = %+v", m) + } + }) + + t.Run("randomInt 值保留为字符串", func(t *testing.T) { + data := []byte("port: randomInt(1000, 9000)\n") + var m StrMap + if err := yaml.Unmarshal(data, &m); err != nil { + t.Fatalf("yaml.Unmarshal error = %v", err) + } + if len(m) != 1 || m[0].Value != "randomInt(1000, 9000)" { + t.Fatalf("m = %+v", m) + } + }) +} + +func TestListMapUnmarshalYAML(t *testing.T) { + t.Run("正常列表值", func(t *testing.T) { + data := []byte("users:\n - admin\n - root\npasses:\n - 123\n - 456\n") + var m ListMap + if err := yaml.Unmarshal(data, &m); err != nil { + t.Fatalf("yaml.Unmarshal error = %v", err) + } + if len(m) != 2 { + t.Fatalf("len = %d, want 2", len(m)) + } + if m[0].Key != "users" || len(m[0].Value) != 2 || m[0].Value[0] != "admin" || m[0].Value[1] != "root" { + t.Errorf("m[0] = %+v", m[0]) + } + if m[1].Key != "passes" || len(m[1].Value) != 2 || m[1].Value[0] != "123" || m[1].Value[1] != "456" { + t.Errorf("m[1] = %+v", m[1]) + } + }) + + t.Run("单个列表", func(t *testing.T) { + data := []byte("cmd:\n - whoami\n") + var m ListMap + if err := yaml.Unmarshal(data, &m); err != nil { + t.Fatalf("yaml.Unmarshal error = %v", err) + } + if len(m) != 1 || m[0].Key != "cmd" || m[0].Value[0] != "whoami" { + t.Fatalf("m = %+v", m) + } + }) + + t.Run("数字值转字符串", func(t *testing.T) { + data := []byte("ports:\n - 80\n - 443\n") + var m ListMap + if err := yaml.Unmarshal(data, &m); err != nil { + t.Fatalf("yaml.Unmarshal error = %v", err) + } + if m[0].Value[0] != "80" || m[0].Value[1] != "443" { + t.Errorf("数字未转为字符串: %+v", m[0].Value) + } + }) +} + +func TestStrMapUnmarshalYAML_InvalidValue(t *testing.T) { + // value 是嵌套 map,不是字符串,应报错 + data := []byte("key:\n nested: val\n") + var m StrMap + if err := yaml.Unmarshal(data, &m); err == nil { + t.Fatal("期望错误,实际 nil") + } +} + +func TestListMapUnmarshalYAML_InvalidValue(t *testing.T) { + // value 是普通字符串而非列表,应报错 + data := []byte("key: notalist\n") + var m ListMap + if err := yaml.Unmarshal(data, &m); err == nil { + t.Fatal("期望错误,实际 nil") + } +} func TestNormalizeHTTPProxyURL(t *testing.T) { tests := []struct { diff --git a/webscan/lib/eval_crypto_test.go b/webscan/lib/eval_crypto_test.go new file mode 100644 index 0000000..82e1bed --- /dev/null +++ b/webscan/lib/eval_crypto_test.go @@ -0,0 +1,108 @@ +package lib + +import ( + "testing" + + "github.com/google/cel-go/common/types" +) + +func TestRegisterCryptoImplementations(t *testing.T) { + overloads := registerCryptoImplementations() + + // 建立 operator → index 映射 + idx := make(map[string]int, len(overloads)) + for i, o := range overloads { + idx[o.Operator] = i + } + + t.Run("md5_string", func(t *testing.T) { + i, ok := idx["md5_string"] + if !ok { + t.Fatal("overload md5_string not found") + } + unary := overloads[i].Unary + + tests := []struct { + name string + input types.String + want types.String + wantErr bool + }{ + {"hello", "hello", "5d41402abc4b2a76b9719d911017c592", false}, + {"empty", "", "d41d8cd98f00b204e9800998ecf8427e", false}, + {"abc", "abc", "900150983cd24fb0d6963f7d28e17f72", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := unary(tc.input) + if types.IsError(result) { + t.Fatalf("unexpected error: %v", result) + } + got, ok := result.(types.String) + if !ok { + t.Fatalf("expected types.String, got %T", result) + } + if got != tc.want { + t.Errorf("md5(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } + }) + + t.Run("md5_string_wrong_type", func(t *testing.T) { + i := idx["md5_string"] + result := overloads[i].Unary(types.Int(42)) + if !types.IsError(result) { + t.Errorf("expected error for non-String input, got %v", result) + } + }) + + t.Run("shiro_key_valid", func(t *testing.T) { + i, ok := idx["shiro_key"] + if !ok { + t.Fatal("overload shiro_key not found") + } + binary := overloads[i].Binary + + // kPH+bIxk5D2deZiIxcaaaA== 是常见 shiro 默认 key + result := binary(types.String("kPH+bIxk5D2deZiIxcaaaA=="), types.String("cbc")) + if types.IsError(result) { + t.Fatalf("unexpected error: %v", result) + } + got, ok := result.(types.String) + if !ok { + t.Fatalf("expected types.String, got %T", result) + } + if got == "" { + t.Error("shiro_key returned empty string") + } + }) + + t.Run("shiro_key_invalid_base64", func(t *testing.T) { + i := idx["shiro_key"] + binary := overloads[i].Binary + + // 无效 base64,GetShrioCookie 会返回 "",函数返回 NewErr + result := binary(types.String("!!!not_valid_base64!!!"), types.String("cbc")) + if !types.IsError(result) { + t.Errorf("expected error for invalid base64 key, got %v", result) + } + }) + + t.Run("shiro_key_wrong_key_type", func(t *testing.T) { + i := idx["shiro_key"] + result := overloads[i].Binary(types.Int(1), types.String("cbc")) + if !types.IsError(result) { + t.Error("expected error for non-String key") + } + }) + + t.Run("shiro_key_wrong_mode_type", func(t *testing.T) { + i := idx["shiro_key"] + result := overloads[i].Binary(types.String("kPH+bIxk5D2deZiIxcaaaA=="), types.Int(0)) + if !types.IsError(result) { + t.Error("expected error for non-String mode") + } + }) +} diff --git a/webscan/lib/eval_encoding_test.go b/webscan/lib/eval_encoding_test.go new file mode 100644 index 0000000..ef921f6 --- /dev/null +++ b/webscan/lib/eval_encoding_test.go @@ -0,0 +1,259 @@ +package lib + +import ( + "testing" + + "github.com/google/cel-go/common/types" +) + +func TestRegisterEncodingImplementations(t *testing.T) { + overloads := registerEncodingImplementations() + + idx := make(map[string]int, len(overloads)) + for i, o := range overloads { + idx[o.Operator] = i + } + + t.Run("base64_string", func(t *testing.T) { + unary := overloads[idx["base64_string"]].Unary + + tests := []struct { + input types.String + want types.String + }{ + {"hello", "aGVsbG8="}, + {"", ""}, + {"hello world", "aGVsbG8gd29ybGQ="}, + } + for _, tc := range tests { + result := unary(tc.input) + if types.IsError(result) { + t.Fatalf("base64_string(%q): unexpected error %v", tc.input, result) + } + got, ok := result.(types.String) + if !ok { + t.Fatalf("expected types.String, got %T", result) + } + if got != tc.want { + t.Errorf("base64_string(%q) = %q, want %q", tc.input, got, tc.want) + } + } + }) + + t.Run("base64_string_wrong_type", func(t *testing.T) { + result := overloads[idx["base64_string"]].Unary(types.Int(1)) + if !types.IsError(result) { + t.Error("expected error for non-String input") + } + }) + + t.Run("base64_bytes", func(t *testing.T) { + unary := overloads[idx["base64_bytes"]].Unary + + tests := []struct { + input types.Bytes + want types.String + }{ + {types.Bytes([]byte("hello")), "aGVsbG8="}, + {types.Bytes([]byte{}), ""}, + } + for _, tc := range tests { + result := unary(tc.input) + if types.IsError(result) { + t.Fatalf("base64_bytes: unexpected error %v", result) + } + got, ok := result.(types.String) + if !ok { + t.Fatalf("expected types.String, got %T", result) + } + if got != tc.want { + t.Errorf("base64_bytes(%v) = %q, want %q", []byte(tc.input), got, tc.want) + } + } + }) + + t.Run("base64_bytes_wrong_type", func(t *testing.T) { + result := overloads[idx["base64_bytes"]].Unary(types.String("hello")) + if !types.IsError(result) { + t.Error("expected error for non-Bytes input") + } + }) + + t.Run("base64Decode_string", func(t *testing.T) { + unary := overloads[idx["base64Decode_string"]].Unary + + tests := []struct { + input types.String + want types.String + }{ + {"aGVsbG8=", "hello"}, + {"", ""}, + {"aGVsbG8gd29ybGQ=", "hello world"}, + } + for _, tc := range tests { + result := unary(tc.input) + if types.IsError(result) { + t.Fatalf("base64Decode_string(%q): unexpected error %v", tc.input, result) + } + got, ok := result.(types.String) + if !ok { + t.Fatalf("expected types.String, got %T", result) + } + if got != tc.want { + t.Errorf("base64Decode_string(%q) = %q, want %q", tc.input, got, tc.want) + } + } + }) + + t.Run("base64Decode_string_invalid", func(t *testing.T) { + result := overloads[idx["base64Decode_string"]].Unary(types.String("!!!")) + if !types.IsError(result) { + t.Error("expected error for invalid base64 input") + } + }) + + t.Run("base64Decode_string_wrong_type", func(t *testing.T) { + result := overloads[idx["base64Decode_string"]].Unary(types.Bool(true)) + if !types.IsError(result) { + t.Error("expected error for non-String input") + } + }) + + t.Run("base64Decode_bytes", func(t *testing.T) { + unary := overloads[idx["base64Decode_bytes"]].Unary + + result := unary(types.Bytes([]byte("aGVsbG8="))) + if types.IsError(result) { + t.Fatalf("unexpected error: %v", result) + } + got, ok := result.(types.String) + if !ok { + t.Fatalf("expected types.String, got %T", result) + } + if got != "hello" { + t.Errorf("base64Decode_bytes = %q, want %q", got, "hello") + } + }) + + t.Run("base64Decode_bytes_invalid", func(t *testing.T) { + result := overloads[idx["base64Decode_bytes"]].Unary(types.Bytes([]byte("!!!"))) + if !types.IsError(result) { + t.Error("expected error for invalid base64 bytes") + } + }) + + t.Run("urlencode_string", func(t *testing.T) { + unary := overloads[idx["urlencode_string"]].Unary + + // url.QueryEscape: 空格 → "+" + tests := []struct { + input types.String + want types.String + }{ + {"hello world", "hello+world"}, + {"a=1&b=2", "a%3D1%26b%3D2"}, + {"", ""}, + } + for _, tc := range tests { + result := unary(tc.input) + if types.IsError(result) { + t.Fatalf("urlencode_string(%q): unexpected error %v", tc.input, result) + } + got, ok := result.(types.String) + if !ok { + t.Fatalf("expected types.String, got %T", result) + } + if got != tc.want { + t.Errorf("urlencode_string(%q) = %q, want %q", tc.input, got, tc.want) + } + } + }) + + t.Run("urlencode_string_wrong_type", func(t *testing.T) { + result := overloads[idx["urlencode_string"]].Unary(types.Int(0)) + if !types.IsError(result) { + t.Error("expected error for non-String input") + } + }) + + t.Run("urldecode_string", func(t *testing.T) { + unary := overloads[idx["urldecode_string"]].Unary + + tests := []struct { + input types.String + want types.String + }{ + {"hello%20world", "hello world"}, + {"hello+world", "hello world"}, + {"", ""}, + } + for _, tc := range tests { + result := unary(tc.input) + if types.IsError(result) { + t.Fatalf("urldecode_string(%q): unexpected error %v", tc.input, result) + } + got, ok := result.(types.String) + if !ok { + t.Fatalf("expected types.String, got %T", result) + } + if got != tc.want { + t.Errorf("urldecode_string(%q) = %q, want %q", tc.input, got, tc.want) + } + } + }) + + t.Run("urldecode_string_invalid", func(t *testing.T) { + // % 后跟非法字符 + result := overloads[idx["urldecode_string"]].Unary(types.String("hello%ZZ")) + if !types.IsError(result) { + t.Error("expected error for invalid percent-encoding") + } + }) + + t.Run("urldecode_string_wrong_type", func(t *testing.T) { + result := overloads[idx["urldecode_string"]].Unary(types.Bool(false)) + if !types.IsError(result) { + t.Error("expected error for non-String input") + } + }) + + t.Run("hexdecode", func(t *testing.T) { + unary := overloads[idx["hexdecode"]].Unary + + tests := []struct { + input types.String + want []byte + }{ + {"48656c6c6f", []byte("Hello")}, + {"", []byte{}}, + {"deadbeef", []byte{0xde, 0xad, 0xbe, 0xef}}, + } + for _, tc := range tests { + result := unary(tc.input) + if types.IsError(result) { + t.Fatalf("hexdecode(%q): unexpected error %v", tc.input, result) + } + got, ok := result.(types.Bytes) + if !ok { + t.Fatalf("expected types.Bytes, got %T", result) + } + if string(got) != string(tc.want) { + t.Errorf("hexdecode(%q) = %v, want %v", tc.input, []byte(got), tc.want) + } + } + }) + + t.Run("hexdecode_invalid", func(t *testing.T) { + result := overloads[idx["hexdecode"]].Unary(types.String("zz")) + if !types.IsError(result) { + t.Error("expected error for invalid hex input") + } + }) + + t.Run("hexdecode_wrong_type", func(t *testing.T) { + result := overloads[idx["hexdecode"]].Unary(types.Int(99)) + if !types.IsError(result) { + t.Error("expected error for non-String input") + } + }) +} diff --git a/webscan/lib/eval_misc_test.go b/webscan/lib/eval_misc_test.go new file mode 100644 index 0000000..5f041e8 --- /dev/null +++ b/webscan/lib/eval_misc_test.go @@ -0,0 +1,53 @@ +package lib + +import ( + "testing" + "unicode" + + "github.com/google/cel-go/common/types" +) + +func TestRegisterMiscImplementations_TongdaDate(t *testing.T) { + overloads := registerMiscImplementations() + + idx := make(map[string]int, len(overloads)) + for i, o := range overloads { + idx[o.Operator] = i + } + + i, ok := idx["tongda_date"] + if !ok { + t.Fatal("overload tongda_date not found") + } + fn := overloads[i].Function + if fn == nil { + t.Fatal("tongda_date Function field is nil") + } + + result := fn() + + if types.IsError(result) { + t.Fatalf("unexpected error: %v", result) + } + + got, ok := result.(types.String) + if !ok { + t.Fatalf("expected types.String, got %T", result) + } + + s := string(got) + + t.Run("length_is_4", func(t *testing.T) { + if len(s) != 4 { + t.Errorf("tongda_date returned %q, want 4-char string", s) + } + }) + + t.Run("all_digits", func(t *testing.T) { + for _, r := range s { + if !unicode.IsDigit(r) { + t.Errorf("tongda_date returned %q, contains non-digit char %q", s, r) + } + } + }) +} diff --git a/webscan/lib/eval_random_test.go b/webscan/lib/eval_random_test.go new file mode 100644 index 0000000..225292f --- /dev/null +++ b/webscan/lib/eval_random_test.go @@ -0,0 +1,264 @@ +package lib + +import ( + "testing" + "unicode" + + "github.com/google/cel-go/common/types" +) + +func TestRegisterRandomImplementations(t *testing.T) { + overloads := registerRandomImplementations() + + idx := make(map[string]int, len(overloads)) + for i, o := range overloads { + idx[o.Operator] = i + } + + t.Run("randomInt_int_int", func(t *testing.T) { + i, ok := idx["randomInt_int_int"] + if !ok { + t.Fatal("overload randomInt_int_int not found") + } + binary := overloads[i].Binary + + t.Run("returns_Int_type", func(t *testing.T) { + result := binary(types.Int(0), types.Int(100)) + if types.IsError(result) { + t.Fatalf("unexpected error: %v", result) + } + if _, ok := result.(types.Int); !ok { + t.Errorf("expected types.Int, got %T", result) + } + }) + + t.Run("value_in_range", func(t *testing.T) { + min, max := types.Int(10), types.Int(20) + for range 50 { + result := binary(min, max) + if types.IsError(result) { + t.Fatalf("unexpected error: %v", result) + } + v := int64(result.(types.Int)) + if v < 10 || v >= 20 { + t.Errorf("randomInt(10,20) = %d, out of [10,20)", v) + } + } + }) + + t.Run("max_le_min_returns_error", func(t *testing.T) { + result := binary(types.Int(5), types.Int(5)) + if !types.IsError(result) { + t.Errorf("expected error when max == min, got %v", result) + } + }) + + t.Run("wrong_lhs_type", func(t *testing.T) { + result := binary(types.String("x"), types.Int(10)) + if !types.IsError(result) { + t.Error("expected error for non-Int lhs") + } + }) + + t.Run("wrong_rhs_type", func(t *testing.T) { + result := binary(types.Int(0), types.String("x")) + if !types.IsError(result) { + t.Error("expected error for non-Int rhs") + } + }) + }) + + t.Run("randomLowercase_int", func(t *testing.T) { + i, ok := idx["randomLowercase_int"] + if !ok { + t.Fatal("overload randomLowercase_int not found") + } + unary := overloads[i].Unary + + t.Run("returns_String_type", func(t *testing.T) { + result := unary(types.Int(8)) + if types.IsError(result) { + t.Fatalf("unexpected error: %v", result) + } + if _, ok := result.(types.String); !ok { + t.Errorf("expected types.String, got %T", result) + } + }) + + t.Run("correct_length", func(t *testing.T) { + for _, n := range []int{0, 1, 8, 16} { + result := unary(types.Int(n)) + if types.IsError(result) { + t.Fatalf("unexpected error for n=%d: %v", n, result) + } + got := string(result.(types.String)) + if len(got) != n { + t.Errorf("randomLowercase(%d) returned length %d", n, len(got)) + } + } + }) + + t.Run("all_lowercase", func(t *testing.T) { + result := unary(types.Int(32)) + got := string(result.(types.String)) + for _, r := range got { + if !unicode.IsLower(r) { + t.Errorf("randomLowercase returned non-lowercase char %q in %q", r, got) + } + } + }) + + t.Run("invalid_length_negative", func(t *testing.T) { + result := unary(types.Int(-1)) + if !types.IsError(result) { + t.Error("expected error for negative length") + } + }) + + t.Run("invalid_length_too_large", func(t *testing.T) { + result := unary(types.Int(maxRandomStringLength + 1)) + if !types.IsError(result) { + t.Error("expected error for length > maxRandomStringLength") + } + }) + + t.Run("wrong_type", func(t *testing.T) { + result := unary(types.String("x")) + if !types.IsError(result) { + t.Error("expected error for non-Int input") + } + }) + }) + + t.Run("randomUppercase_int", func(t *testing.T) { + i, ok := idx["randomUppercase_int"] + if !ok { + t.Fatal("overload randomUppercase_int not found") + } + unary := overloads[i].Unary + + t.Run("returns_String_type", func(t *testing.T) { + result := unary(types.Int(8)) + if types.IsError(result) { + t.Fatalf("unexpected error: %v", result) + } + if _, ok := result.(types.String); !ok { + t.Errorf("expected types.String, got %T", result) + } + }) + + t.Run("correct_length", func(t *testing.T) { + for _, n := range []int{0, 1, 8, 16} { + result := unary(types.Int(n)) + if types.IsError(result) { + t.Fatalf("unexpected error for n=%d: %v", n, result) + } + got := string(result.(types.String)) + if len(got) != n { + t.Errorf("randomUppercase(%d) returned length %d", n, len(got)) + } + } + }) + + t.Run("all_uppercase", func(t *testing.T) { + result := unary(types.Int(32)) + got := string(result.(types.String)) + for _, r := range got { + if !unicode.IsUpper(r) { + t.Errorf("randomUppercase returned non-uppercase char %q in %q", r, got) + } + } + }) + + t.Run("wrong_type", func(t *testing.T) { + result := unary(types.String("x")) + if !types.IsError(result) { + t.Error("expected error for non-Int input") + } + }) + }) + + t.Run("randomString_int", func(t *testing.T) { + i, ok := idx["randomString_int"] + if !ok { + t.Fatal("overload randomString_int not found") + } + unary := overloads[i].Unary + + t.Run("returns_String_type", func(t *testing.T) { + result := unary(types.Int(8)) + if types.IsError(result) { + t.Fatalf("unexpected error: %v", result) + } + if _, ok := result.(types.String); !ok { + t.Errorf("expected types.String, got %T", result) + } + }) + + t.Run("correct_length", func(t *testing.T) { + for _, n := range []int{0, 1, 8, 16} { + result := unary(types.Int(n)) + if types.IsError(result) { + t.Fatalf("unexpected error for n=%d: %v", n, result) + } + got := string(result.(types.String)) + if len(got) != n { + t.Errorf("randomString(%d) returned length %d", n, len(got)) + } + } + }) + + t.Run("wrong_type", func(t *testing.T) { + result := unary(types.String("x")) + if !types.IsError(result) { + t.Error("expected error for non-Int input") + } + }) + }) +} + +func TestRandomIntSpan(t *testing.T) { + t.Run("normal_range", func(t *testing.T) { + span, err := randomIntSpan(10, 20) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if span != 10 { + t.Errorf("randomIntSpan(10,20) = %d, want 10", span) + } + }) + + t.Run("min_zero", func(t *testing.T) { + span, err := randomIntSpan(0, 100) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if span != 100 { + t.Errorf("randomIntSpan(0,100) = %d, want 100", span) + } + }) + + t.Run("negative_min", func(t *testing.T) { + span, err := randomIntSpan(-5, 5) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if span != 10 { + t.Errorf("randomIntSpan(-5,5) = %d, want 10", span) + } + }) + + t.Run("max_eq_min_returns_error", func(t *testing.T) { + _, err := randomIntSpan(7, 7) + if err == nil { + t.Error("expected error when max == min") + } + }) + + t.Run("max_lt_min_returns_error", func(t *testing.T) { + _, err := randomIntSpan(10, 5) + if err == nil { + t.Error("expected error when max < min") + } + }) +} diff --git a/webscan/lib/eval_string_test.go b/webscan/lib/eval_string_test.go new file mode 100644 index 0000000..c7bcf3e --- /dev/null +++ b/webscan/lib/eval_string_test.go @@ -0,0 +1,337 @@ +package lib + +import ( + "testing" + + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" +) + +func TestRegisterStringImplementations(t *testing.T) { + overloads := registerStringImplementations() + + idx := make(map[string]int, len(overloads)) + for i, o := range overloads { + idx[o.Operator] = i + } + + t.Run("bytes_bcontains_bytes", func(t *testing.T) { + binary := overloads[idx["bytes_bcontains_bytes"]].Binary + + tests := []struct { + name string + lhs types.Bytes + rhs types.Bytes + want types.Bool + }{ + {"contains", types.Bytes([]byte("hello world")), types.Bytes([]byte("world")), true}, + {"not_contains", types.Bytes([]byte("hello world")), types.Bytes([]byte("xyz")), false}, + {"empty_needle", types.Bytes([]byte("hello")), types.Bytes([]byte{}), true}, + {"both_empty", types.Bytes([]byte{}), types.Bytes([]byte{}), true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := binary(tc.lhs, tc.rhs) + if types.IsError(result) { + t.Fatalf("unexpected error: %v", result) + } + got, ok := result.(types.Bool) + if !ok { + t.Fatalf("expected types.Bool, got %T", result) + } + if got != tc.want { + t.Errorf("bcontains = %v, want %v", got, tc.want) + } + }) + } + }) + + t.Run("bytes_bcontains_bytes_wrong_lhs", func(t *testing.T) { + result := overloads[idx["bytes_bcontains_bytes"]].Binary(types.String("hello"), types.Bytes([]byte("x"))) + if !types.IsError(result) { + t.Error("expected error for non-Bytes lhs") + } + }) + + t.Run("bytes_bcontains_bytes_wrong_rhs", func(t *testing.T) { + result := overloads[idx["bytes_bcontains_bytes"]].Binary(types.Bytes([]byte("hello")), types.String("x")) + if !types.IsError(result) { + t.Error("expected error for non-Bytes rhs") + } + }) + + t.Run("string_bmatches_bytes", func(t *testing.T) { + binary := overloads[idx["string_bmatches_bytes"]].Binary + + tests := []struct { + name string + pattern types.String + input types.Bytes + want types.Bool + }{ + {"digits_match", `\d+`, types.Bytes([]byte("abc123")), true}, + {"digits_no_match", `\d+`, types.Bytes([]byte("abc")), false}, + {"any", `.*`, types.Bytes([]byte("hello")), true}, + {"empty_pattern", ``, types.Bytes([]byte("hello")), true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := binary(tc.pattern, tc.input) + if types.IsError(result) { + t.Fatalf("unexpected error: %v", result) + } + got, ok := result.(types.Bool) + if !ok { + t.Fatalf("expected types.Bool, got %T", result) + } + if got != tc.want { + t.Errorf("bmatches(%q, %q) = %v, want %v", tc.pattern, tc.input, got, tc.want) + } + }) + } + }) + + t.Run("string_bmatches_bytes_invalid_regex", func(t *testing.T) { + result := overloads[idx["string_bmatches_bytes"]].Binary(types.String(`[invalid`), types.Bytes([]byte("hello"))) + if !types.IsError(result) { + t.Error("expected error for invalid regex pattern") + } + }) + + t.Run("string_bmatches_bytes_wrong_lhs", func(t *testing.T) { + result := overloads[idx["string_bmatches_bytes"]].Binary(types.Int(0), types.Bytes([]byte("hello"))) + if !types.IsError(result) { + t.Error("expected error for non-String lhs") + } + }) + + t.Run("string_bmatches_bytes_wrong_rhs", func(t *testing.T) { + result := overloads[idx["string_bmatches_bytes"]].Binary(types.String(`\d+`), types.String("123")) + if !types.IsError(result) { + t.Error("expected error for non-Bytes rhs") + } + }) + + t.Run("icontains_string", func(t *testing.T) { + binary := overloads[idx["icontains_string"]].Binary + + tests := []struct { + name string + lhs types.String + rhs types.String + want types.Bool + }{ + {"case_insensitive_match", "Hello World", "hello", true}, + {"exact_match", "Hello World", "Hello", true}, + {"upper_needle", "hello world", "WORLD", true}, + {"not_contains", "hello world", "xyz", false}, + {"empty_needle", "hello", "", true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := binary(tc.lhs, tc.rhs) + if types.IsError(result) { + t.Fatalf("unexpected error: %v", result) + } + got, ok := result.(types.Bool) + if !ok { + t.Fatalf("expected types.Bool, got %T", result) + } + if got != tc.want { + t.Errorf("icontains(%q, %q) = %v, want %v", tc.lhs, tc.rhs, got, tc.want) + } + }) + } + }) + + t.Run("icontains_string_wrong_lhs", func(t *testing.T) { + result := overloads[idx["icontains_string"]].Binary(types.Bool(true), types.String("x")) + if !types.IsError(result) { + t.Error("expected error for non-String lhs") + } + }) + + t.Run("icontains_string_wrong_rhs", func(t *testing.T) { + result := overloads[idx["icontains_string"]].Binary(types.String("hello"), types.Int(1)) + if !types.IsError(result) { + t.Error("expected error for non-String rhs") + } + }) + + t.Run("substr_string_int_int", func(t *testing.T) { + fn := overloads[idx["substr_string_int_int"]].Function + + tests := []struct { + name string + str types.String + start types.Int + length types.Int + want types.String + }{ + {"basic", "hello world", 0, 5, "hello"}, + {"middle", "hello world", 6, 5, "world"}, + {"single_char", "hello", 1, 1, "e"}, + {"full", "hello", 0, 5, "hello"}, + {"zero_length", "hello", 2, 0, ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := fn(tc.str, tc.start, tc.length) + if types.IsError(result) { + t.Fatalf("substr(%q, %d, %d): unexpected error %v", tc.str, tc.start, tc.length, result) + } + got, ok := result.(types.String) + if !ok { + t.Fatalf("expected types.String, got %T", result) + } + if got != tc.want { + t.Errorf("substr(%q, %d, %d) = %q, want %q", tc.str, tc.start, tc.length, got, tc.want) + } + }) + } + }) + + t.Run("substr_out_of_bounds", func(t *testing.T) { + fn := overloads[idx["substr_string_int_int"]].Function + + oob := []struct { + name string + str types.String + start types.Int + length types.Int + }{ + {"negative_start", "hello", -1, 2}, + {"negative_length", "hello", 0, -1}, + {"start_too_large", "hello", 10, 1}, + {"length_overflow", "hello", 3, 10}, + } + for _, tc := range oob { + t.Run(tc.name, func(t *testing.T) { + result := fn(tc.str, tc.start, tc.length) + if !types.IsError(result) { + t.Errorf("expected error for substr(%q, %d, %d), got %v", tc.str, tc.start, tc.length, result) + } + }) + } + }) + + t.Run("substr_wrong_arg_count", func(t *testing.T) { + fn := overloads[idx["substr_string_int_int"]].Function + result := fn(types.String("hello"), types.Int(0)) + if !types.IsError(result) { + t.Error("expected error for wrong argument count") + } + }) + + t.Run("substr_wrong_types", func(t *testing.T) { + fn := overloads[idx["substr_string_int_int"]].Function + + cases := []struct { + name string + args []ref.Val + }{ + {"wrong_str", []ref.Val{types.Int(0), types.Int(0), types.Int(1)}}, + {"wrong_start", []ref.Val{types.String("hello"), types.String("x"), types.Int(1)}}, + {"wrong_length", []ref.Val{types.String("hello"), types.Int(0), types.String("x")}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + result := fn(tc.args...) + if !types.IsError(result) { + t.Errorf("expected error, got %v", result) + } + }) + } + }) + + t.Run("startsWith_bytes", func(t *testing.T) { + binary := overloads[idx["startsWith_bytes"]].Binary + + tests := []struct { + name string + lhs types.Bytes + rhs types.Bytes + want types.Bool + }{ + {"match", types.Bytes([]byte("hello world")), types.Bytes([]byte("hello")), true}, + {"no_match", types.Bytes([]byte("hello world")), types.Bytes([]byte("world")), false}, + {"empty_prefix", types.Bytes([]byte("hello")), types.Bytes([]byte{}), true}, + {"exact", types.Bytes([]byte("hello")), types.Bytes([]byte("hello")), true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := binary(tc.lhs, tc.rhs) + if types.IsError(result) { + t.Fatalf("unexpected error: %v", result) + } + got, ok := result.(types.Bool) + if !ok { + t.Fatalf("expected types.Bool, got %T", result) + } + if got != tc.want { + t.Errorf("startsWith_bytes = %v, want %v", got, tc.want) + } + }) + } + }) + + t.Run("startsWith_bytes_wrong_lhs", func(t *testing.T) { + result := overloads[idx["startsWith_bytes"]].Binary(types.String("hello"), types.Bytes([]byte("h"))) + if !types.IsError(result) { + t.Error("expected error for non-Bytes lhs") + } + }) + + t.Run("startsWith_bytes_wrong_rhs", func(t *testing.T) { + result := overloads[idx["startsWith_bytes"]].Binary(types.Bytes([]byte("hello")), types.String("h")) + if !types.IsError(result) { + t.Error("expected error for non-Bytes rhs") + } + }) + + t.Run("startsWith_string", func(t *testing.T) { + binary := overloads[idx["startsWith_string"]].Binary + + tests := []struct { + name string + lhs types.String + rhs types.String + want types.Bool + }{ + {"case_insensitive_match", "Hello World", "hello", true}, + {"upper_prefix", "hello world", "HELLO", true}, + {"no_match", "hello world", "world", false}, + {"empty_prefix", "hello", "", true}, + {"exact", "Hello", "Hello", true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := binary(tc.lhs, tc.rhs) + if types.IsError(result) { + t.Fatalf("unexpected error: %v", result) + } + got, ok := result.(types.Bool) + if !ok { + t.Fatalf("expected types.Bool, got %T", result) + } + if got != tc.want { + t.Errorf("startsWith_string(%q, %q) = %v, want %v", tc.lhs, tc.rhs, got, tc.want) + } + }) + } + }) + + t.Run("startsWith_string_wrong_lhs", func(t *testing.T) { + result := overloads[idx["startsWith_string"]].Binary(types.Int(0), types.String("h")) + if !types.IsError(result) { + t.Error("expected error for non-String lhs") + } + }) + + t.Run("startsWith_string_wrong_rhs", func(t *testing.T) { + result := overloads[idx["startsWith_string"]].Binary(types.String("hello"), types.Bool(true)) + if !types.IsError(result) { + t.Error("expected error for non-String rhs") + } + }) +} diff --git a/webscan/lib/eval_test.go b/webscan/lib/eval_test.go index 9097ac5..9115dd8 100644 --- a/webscan/lib/eval_test.go +++ b/webscan/lib/eval_test.go @@ -1342,3 +1342,54 @@ func TestRandomStrRejectsNegativeLength(t *testing.T) { t.Fatalf("RandomStr negative length = %q, want empty", got) } } + +// ============================================================================= +// MakeVarDecl 测试 +// ============================================================================= + +func TestMakeVarDecl(t *testing.T) { + tests := []struct { + name string + key string + value string + wantIdent string // 期望 Decl.Name + wantKind string // "int" / "string" / "object" + }{ + {"randomInt 前缀 -> Int", "myrand", "randomInt(1,100)", "myrand", "int"}, + {"newReverse 前缀 -> Object", "myrev", "newReverse()", "myrev", "object"}, + {"普通字符串 -> String", "myvar", "somevalue", "myvar", "string"}, + {"空值 -> String", "empty", "", "empty", "string"}, + {"randomIntExtra -> Int", "n", "randomInt(0, 65535)", "n", "int"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + decl := MakeVarDecl(tt.key, tt.value) + if decl == nil { + t.Fatal("MakeVarDecl() returned nil") + } + if decl.Name != tt.wantIdent { + t.Errorf("Decl.Name = %q, want %q", decl.Name, tt.wantIdent) + } + // 通过 Type 字段判断类型种类 + tp := decl.GetIdent().GetType() + if tp == nil { + t.Fatal("Decl.GetIdent().GetType() == nil") + } + switch tt.wantKind { + case "int": + if tp.GetPrimitive().String() != "INT64" { + t.Errorf("type = %v, want INT64", tp) + } + case "string": + if tp.GetPrimitive().String() != "STRING" { + t.Errorf("type = %v, want STRING", tp) + } + case "object": + if tp.GetMessageType() == "" { + t.Errorf("type = %v, want MessageType", tp) + } + } + }) + } +} diff --git a/webscan/lib/poc_executor_test.go b/webscan/lib/poc_executor_test.go index 0cb8b6e..d5ea069 100644 --- a/webscan/lib/poc_executor_test.go +++ b/webscan/lib/poc_executor_test.go @@ -517,6 +517,74 @@ func TestPocExecutorPureHelpers(t *testing.T) { }) } +// ============================================================================= +// isPlainLiteral 测试 +// ============================================================================= + +func TestIsPlainLiteral_EmptyString(t *testing.T) { + if isPlainLiteral("", nil) { + t.Error("空字符串不是字面量") + } +} + +func TestIsPlainLiteral_PlainWord(t *testing.T) { + if !isPlainLiteral("database", nil) { + t.Error("纯单词 'database' 应视为字面量") + } +} + +func TestIsPlainLiteral_WithParens(t *testing.T) { + if isPlainLiteral("func()", nil) { + t.Error("含括号的表达式不是字面量") + } +} + +func TestIsPlainLiteral_WithOperator(t *testing.T) { + for _, expr := range []string{"a+b", "a*b", "a==b", "a!=b", "ab", "a&&b", "a||b"} { + if isPlainLiteral(expr, nil) { + t.Errorf("含运算符的表达式 %q 不是字面量", expr) + } + } +} + +func TestIsPlainLiteral_WithQuotes(t *testing.T) { + if isPlainLiteral(`"hello"`, nil) { + t.Error("含引号的表达式不是字面量") + } + if isPlainLiteral("'hello'", nil) { + t.Error("含单引号的表达式不是字面量") + } +} + +func TestIsPlainLiteral_VariableRef(t *testing.T) { + // 如果 expr 是已声明变量的名字,应走 CEL 求值 + varMap := map[string]interface{}{"token": "abc123"} + if isPlainLiteral("token", varMap) { + t.Error("已声明变量不应被视为字面量") + } +} + +func TestIsPlainLiteral_UndeclaredVariable(t *testing.T) { + varMap := map[string]interface{}{"token": "abc123"} + // 未声明的变量名且无特殊字符 -> 字面量 + if !isPlainLiteral("sql", varMap) { + t.Error("未声明的纯单词 'sql' 应视为字面量") + } +} + +func TestIsPlainLiteral_WithBracket(t *testing.T) { + if isPlainLiteral("arr[0]", nil) { + t.Error("含方括号的表达式不是字面量") + } +} + +func TestIsPlainLiteral_PathLike(t *testing.T) { + // 路径中可能含 /,但 / 不在排除字符中,视为字面量 + if !isPlainLiteral("admin", nil) { + t.Error("纯字母字符串应为字面量") + } +} + func stringMatrixEqual(a, b [][]string) bool { if len(a) != len(b) { return false @@ -533,3 +601,267 @@ func stringMatrixEqual(a, b [][]string) bool { } return true } + +// ============================================================================= +// buildVulnDetails 测试 +// ============================================================================= + +func TestBuildVulnDetails(t *testing.T) { + tests := []struct { + name string + pocDef *Poc + vulName string + params StrMap + wantKeys []string + wantNoKeys []string + wantVulnType string + wantVulnName string + wantParamVal string + wantParamKey string + }{ + { + name: "最小Poc只有Name", + pocDef: &Poc{Name: "poc-yaml-test"}, + vulName: "poc-yaml-test", + params: nil, + wantKeys: []string{"vulnerability_type", "vulnerability_name"}, + wantNoKeys: []string{"author", "references", "description", "parameters"}, + wantVulnType: "poc-yaml-test", + wantVulnName: "poc-yaml-test", + }, + { + name: "完整Poc含Author+Links+Description", + pocDef: &Poc{ + Name: "poc-yaml-full", + Detail: Detail{ + Author: "kei", + Links: []string{"https://example.com"}, + Description: "test vuln", + }, + }, + vulName: "Full Vuln", + params: nil, + wantKeys: []string{"vulnerability_type", "vulnerability_name", "author", "references", "description"}, + wantNoKeys: []string{"parameters"}, + wantVulnType: "poc-yaml-full", + wantVulnName: "Full Vuln", + }, + { + name: "有params则details含parameters字段", + pocDef: &Poc{Name: "poc-yaml-params"}, + vulName: "Params Vuln", + params: StrMap{ + {Key: "user", Value: "admin"}, + {Key: "pass", Value: "123456"}, + }, + wantKeys: []string{"vulnerability_type", "vulnerability_name", "parameters"}, + wantNoKeys: []string{"author"}, + wantParamKey: "user", + wantParamVal: "admin", + }, + { + name: "空params不含parameters字段", + pocDef: &Poc{Name: "poc-yaml-empty-params"}, + vulName: "Empty Params", + params: StrMap{}, + wantKeys: []string{"vulnerability_type", "vulnerability_name"}, + wantNoKeys: []string{"parameters"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + details := buildVulnDetails(tt.pocDef, tt.vulName, tt.params) + + for _, k := range tt.wantKeys { + if _, ok := details[k]; !ok { + t.Errorf("details 缺少字段 %q", k) + } + } + for _, k := range tt.wantNoKeys { + if _, ok := details[k]; ok { + t.Errorf("details 不应含字段 %q", k) + } + } + if tt.wantVulnType != "" { + if got, _ := details["vulnerability_type"].(string); got != tt.wantVulnType { + t.Errorf("vulnerability_type = %q, want %q", got, tt.wantVulnType) + } + } + if tt.wantVulnName != "" { + if got, _ := details["vulnerability_name"].(string); got != tt.wantVulnName { + t.Errorf("vulnerability_name = %q, want %q", got, tt.wantVulnName) + } + } + if tt.wantParamKey != "" { + pm, ok := details["parameters"].(map[string]string) + if !ok { + t.Fatalf("parameters 类型错误,实际 %T", details["parameters"]) + } + if got := pm[tt.wantParamKey]; got != tt.wantParamVal { + t.Errorf("parameters[%q] = %q, want %q", tt.wantParamKey, got, tt.wantParamVal) + } + } + }) + } +} + +// ============================================================================= +// buildVulnLogMsg 测试 +// ============================================================================= + +func TestBuildVulnLogMsg(t *testing.T) { + tests := []struct { + name string + targetURL string + pocDef *Poc + vulName string + params StrMap + }{ + { + name: "backup-file名称走特殊模板", + targetURL: "http://example.com", + pocDef: &Poc{Name: "poc-yaml-backup-file"}, + vulName: "poc-yaml-backup-file", + params: nil, + }, + { + name: "sql-file名称走特殊模板", + targetURL: "http://example.com", + pocDef: &Poc{Name: "poc-yaml-sql-file"}, + vulName: "poc-yaml-sql-file", + params: nil, + }, + { + name: "有params走params模板", + targetURL: "http://example.com", + pocDef: &Poc{Name: "poc-yaml-rce"}, + vulName: "RCE", + params: StrMap{{Key: "cmd", Value: "id"}}, + }, + { + name: "无params走detail_header模板", + targetURL: "http://example.com", + pocDef: &Poc{ + Name: "poc-yaml-sqli", + Detail: Detail{ + Author: "kei", + Links: []string{"https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2024-0001"}, + Description: "SQL injection", + }, + }, + vulName: "SQLi", + params: nil, + }, + { + name: "无params无detail只走header", + targetURL: "http://example.com", + pocDef: &Poc{Name: "poc-yaml-generic"}, + vulName: "Generic", + params: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + msg := buildVulnLogMsg(tt.targetURL, tt.pocDef, tt.vulName, tt.params) + if msg == "" { + t.Errorf("buildVulnLogMsg() 返回空字符串") + } + }) + } +} + +// ============================================================================= +// collectVarDeclarations 测试 +// ============================================================================= + +func TestCollectVarDeclarations(t *testing.T) { + t.Run("空 POC 返回空切片", func(t *testing.T) { + p := &Poc{} + decls := collectVarDeclarations(p) + if len(decls) != 0 { + t.Fatalf("len = %d, want 0", len(decls)) + } + }) + + t.Run("仅 Set 字段", func(t *testing.T) { + p := &Poc{ + Set: StrMap{ + {Key: "token", Value: "randomLowercase(8)"}, + {Key: "port", Value: "randomInt(1000, 9000)"}, + }, + } + decls := collectVarDeclarations(p) + if len(decls) != 2 { + t.Fatalf("len = %d, want 2", len(decls)) + } + if decls[0].Name != "token" { + t.Errorf("decls[0].Name = %q, want token", decls[0].Name) + } + if decls[1].Name != "port" { + t.Errorf("decls[1].Name = %q, want port", decls[1].Name) + } + }) + + t.Run("仅 Sets 字段", func(t *testing.T) { + p := &Poc{ + Sets: ListMap{ + {Key: "user", Value: []string{"admin", "root"}}, + }, + } + decls := collectVarDeclarations(p) + if len(decls) != 1 { + t.Fatalf("len = %d, want 1", len(decls)) + } + if decls[0].Name != "user" { + t.Errorf("decls[0].Name = %q, want user", decls[0].Name) + } + }) + + t.Run("Sets 空值列表不 panic", func(t *testing.T) { + p := &Poc{ + Sets: ListMap{ + {Key: "empty", Value: []string{}}, + }, + } + decls := collectVarDeclarations(p) + if len(decls) != 1 { + t.Fatalf("len = %d, want 1", len(decls)) + } + if decls[0].Name != "empty" { + t.Errorf("decls[0].Name = %q, want empty", decls[0].Name) + } + }) + + t.Run("Set 和 Sets 合并", func(t *testing.T) { + p := &Poc{ + Set: StrMap{ + {Key: "a", Value: "x"}, + }, + Sets: ListMap{ + {Key: "b", Value: []string{"y"}}, + }, + } + decls := collectVarDeclarations(p) + if len(decls) != 2 { + t.Fatalf("len = %d, want 2", len(decls)) + } + }) + + t.Run("newReverse 前缀推断 Object 类型", func(t *testing.T) { + p := &Poc{ + Set: StrMap{ + {Key: "rev", Value: "newReverse()"}, + }, + } + decls := collectVarDeclarations(p) + if len(decls) != 1 { + t.Fatalf("len = %d, want 1", len(decls)) + } + tp := decls[0].GetIdent().GetType() + if tp == nil || tp.GetMessageType() == "" { + t.Errorf("期望 Object 类型,实际 %v", tp) + } + }) +} diff --git a/webscan/lib/shiro_test.go b/webscan/lib/shiro_test.go new file mode 100644 index 0000000..22a97f0 --- /dev/null +++ b/webscan/lib/shiro_test.go @@ -0,0 +1,237 @@ +package lib + +import ( + "encoding/base64" + "strings" + "testing" +) + +// ============================================================================= +// Padding 测试 +// ============================================================================= + +func TestPadding_BasicBlockAlignment(t *testing.T) { + tests := []struct { + name string + input []byte + blockSize int + wantLen int // 期望长度 + }{ + { + name: "空输入填充整个块", + input: []byte{}, + blockSize: 16, + wantLen: 16, + }, + { + name: "15字节填充1字节", + input: make([]byte, 15), + blockSize: 16, + wantLen: 16, + }, + { + name: "整块对齐追加完整块", + input: make([]byte, 16), + blockSize: 16, + wantLen: 32, + }, + { + name: "1字节填充15字节", + input: []byte{0x01}, + blockSize: 16, + wantLen: 16, + }, + { + name: "blockSize=8时的对齐", + input: make([]byte, 5), + blockSize: 8, + wantLen: 8, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Padding(tt.input, tt.blockSize) + if len(result) != tt.wantLen { + t.Errorf("Padding() len=%d, want %d", len(result), tt.wantLen) + } + // 验证填充字节值符合 PKCS7 规范 + if len(result) > 0 { + padLen := int(result[len(result)-1]) + if padLen == 0 || padLen > tt.blockSize { + t.Errorf("填充字节值 %d 超出 blockSize=%d", padLen, tt.blockSize) + } + // 验证所有填充字节相同 + for i := len(result) - padLen; i < len(result); i++ { + if result[i] != byte(padLen) { + t.Errorf("填充字节[%d]=%d 不等于 padLen=%d", i, result[i], padLen) + } + } + } + }) + } +} + +func TestPadding_ResultLength(t *testing.T) { + // 任意长度输入,结果都应该是 blockSize 的整数倍 + blockSize := 16 + for inputLen := 0; inputLen < 50; inputLen++ { + input := make([]byte, inputLen) + result := Padding(input, blockSize) + if len(result)%blockSize != 0 { + t.Errorf("输入长度 %d: 填充后长度 %d 不是 %d 的倍数", inputLen, len(result), blockSize) + } + } +} + +// ============================================================================= +// AESCBCEncrypt 测试 +// ============================================================================= + +func TestAESCBCEncrypt_ValidKey128(t *testing.T) { + // 128-bit AES key (16 bytes) + key := base64.StdEncoding.EncodeToString(make([]byte, 16)) + result := AESCBCEncrypt(key) + if result == "" { + t.Error("有效的128位密钥应返回非空结果") + } + // 结果应为有效的 base64 + _, err := base64.StdEncoding.DecodeString(result) + if err != nil { + t.Errorf("AESCBCEncrypt 结果应为有效 base64: %v", err) + } +} + +func TestAESCBCEncrypt_ValidKey256(t *testing.T) { + // 256-bit AES key (32 bytes) + key := base64.StdEncoding.EncodeToString(make([]byte, 32)) + result := AESCBCEncrypt(key) + if result == "" { + t.Error("有效的256位密钥应返回非空结果") + } +} + +func TestAESCBCEncrypt_InvalidBase64Key(t *testing.T) { + result := AESCBCEncrypt("!!!not-valid-base64!!!") + if result != "" { + t.Error("无效 base64 密钥应返回空字符串") + } +} + +func TestAESCBCEncrypt_InvalidKeySize(t *testing.T) { + // AES 要求密钥为 16/24/32 字节,10 字节无效 + key := base64.StdEncoding.EncodeToString(make([]byte, 10)) + result := AESCBCEncrypt(key) + if result != "" { + t.Error("无效密钥长度应返回空字符串") + } +} + +func TestAESCBCEncrypt_NonDeterministic(t *testing.T) { + // 因为 IV 是随机的,两次加密结果应不同 + key := base64.StdEncoding.EncodeToString(make([]byte, 16)) + r1 := AESCBCEncrypt(key) + r2 := AESCBCEncrypt(key) + if r1 == r2 { + // 极小概率相同,记录即可 + t.Log("两次加密结果相同(极低概率事件)") + } +} + +// ============================================================================= +// AESGCMEncrypt 测试 +// ============================================================================= + +func TestAESGCMEncrypt_ValidKey128(t *testing.T) { + key := base64.StdEncoding.EncodeToString(make([]byte, 16)) + result := AESGCMEncrypt(key) + if result == "" { + t.Error("有效的128位密钥应返回非空结果") + } + _, err := base64.StdEncoding.DecodeString(result) + if err != nil { + t.Errorf("AESGCMEncrypt 结果应为有效 base64: %v", err) + } +} + +func TestAESGCMEncrypt_InvalidKey(t *testing.T) { + result := AESGCMEncrypt("invalid-base64!!!") + if result != "" { + t.Error("无效 base64 密钥应返回空字符串") + } +} + +func TestAESGCMEncrypt_NonDeterministic(t *testing.T) { + key := base64.StdEncoding.EncodeToString(make([]byte, 16)) + r1 := AESGCMEncrypt(key) + r2 := AESGCMEncrypt(key) + // GCM nonce 随机,结果不应相同 + if r1 == r2 { + t.Log("两次 GCM 加密结果相同(极低概率事件)") + } +} + +// ============================================================================= +// GetShrioCookie 测试 +// ============================================================================= + +func TestGetShrioCookie_CBCMode(t *testing.T) { + key := base64.StdEncoding.EncodeToString(make([]byte, 16)) + result := GetShrioCookie(key, "cbc") + if result == "" { + t.Error("CBC 模式应返回非空 cookie") + } +} + +func TestGetShrioCookie_GCMMode(t *testing.T) { + key := base64.StdEncoding.EncodeToString(make([]byte, 16)) + result := GetShrioCookie(key, "gcm") + if result == "" { + t.Error("GCM 模式应返回非空 cookie") + } +} + +func TestGetShrioCookie_DefaultMode(t *testing.T) { + // 非 gcm 模式走 CBC + key := base64.StdEncoding.EncodeToString(make([]byte, 16)) + result := GetShrioCookie(key, "other") + cbcResult := AESCBCEncrypt(key) + // 两个结果都应为非空 base64,但由于随机 IV 不一定相同 + if result == "" { + t.Error("默认(非gcm)模式应使用 CBC 加密并返回非空结果") + } + _ = cbcResult +} + +func TestGetShrioCookie_RealShiroKey(t *testing.T) { + // 使用真实的 Shiro 默认密钥 + shiroDefaultKey := "kPH+bIxk5D2deZiIxcaaaA==" + result := GetShrioCookie(shiroDefaultKey, "cbc") + if result == "" { + t.Error("使用默认 Shiro 密钥应能生成有效 cookie") + } + // 验证结果是 base64 编码 + decoded, err := base64.StdEncoding.DecodeString(result) + if err != nil { + t.Errorf("结果应为有效 base64: %v", err) + } + // CBC 模式:IV(16字节) + 密文,结果至少 32 字节 + if len(decoded) < 32 { + t.Errorf("CBC 加密结果太短: %d 字节", len(decoded)) + } +} + +func TestGetShrioCookie_ResultIsBase64(t *testing.T) { + key := base64.StdEncoding.EncodeToString(make([]byte, 16)) + for _, mode := range []string{"cbc", "gcm"} { + result := GetShrioCookie(key, mode) + if result == "" { + t.Errorf("mode=%s: 结果不应为空", mode) + continue + } + // base64 只含 [A-Za-z0-9+/=] + if strings.ContainsAny(result, " \t\n\r") { + t.Errorf("mode=%s: base64 结果不应含空白字符", mode) + } + } +}