From 0612255893af594ffed1c531e11cfab800361531 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 15 Jun 2026 19:11:15 +0800 Subject: [PATCH 01/13] =?UTF-8?q?test:=20=E8=A1=A5=E5=85=85=E5=8D=95?= =?UTF-8?q?=E5=85=83=E6=B5=8B=E8=AF=95=E8=A6=86=E7=9B=96=E7=8E=87=2029.9%?= =?UTF-8?q?=20=E2=86=92=2036.6%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新建 18 个测试文件,追加 30 个已有测试文件,覆盖协议解析、 错误分类、CEL 表达式求值、YAML 反序列化、字节编码等纯函数。 --- common/config_builder_test.go | 256 +++++ common/output/writers_test.go | 417 ++++++++ common/output_api_test.go | 18 + common/parsers/host_iterator_test.go | 776 +++++++++++++++ common/session_test.go | 81 ++ common/state_test.go | 294 ++++++ core/adaptive_pool_test.go | 119 +++ core/base_scan_strategy_test.go | 131 +++ core/portfinger/version_parser_test.go | 69 ++ core/scan_metrics_test.go | 112 +++ core/scanner_test.go | 123 +++ core/service_scanner_test.go | 73 ++ plugins/services/activemq_test.go | 29 + plugins/services/cassandra_test.go | 72 ++ plugins/services/findnet_test.go | 183 ++++ plugins/services/ftp_test.go | 33 + plugins/services/kafka_test.go | 23 + plugins/services/ldap_test.go | 23 + plugins/services/mongodb_test.go | 23 + plugins/services/mssql_test.go | 30 + plugins/services/mysql_test.go | 49 + plugins/services/neo4j_test.go | 22 + plugins/services/netbios_test.go | 189 ++++ plugins/services/oracle_raw_test.go | 1090 +++++++++++++++++++++ plugins/services/oracle_test.go | 29 + plugins/services/postgresql_test.go | 23 + plugins/services/rabbitmq_test.go | 22 + plugins/services/rdp_test.go | 100 ++ plugins/services/redis_test.go | 22 + plugins/services/rsync_test.go | 22 + plugins/services/smb_protocol_test.go | 464 +++++++++ plugins/services/smtp_test.go | 30 + plugins/services/snmp_test.go | 156 +++ plugins/services/ssh_test.go | 60 ++ plugins/services/telnet_test.go | 22 + plugins/services/vnc_test.go | 30 + plugins/web/webtitle_test.go | 191 ++++ webscan/fingerprint/calc_priority_test.go | 191 ++++ webscan/fingerprint_scanner_test.go | 209 ++++ webscan/lib/client_test.go | 110 ++- webscan/lib/eval_crypto_test.go | 108 ++ webscan/lib/eval_encoding_test.go | 259 +++++ webscan/lib/eval_misc_test.go | 53 + webscan/lib/eval_random_test.go | 264 +++++ webscan/lib/eval_string_test.go | 337 +++++++ webscan/lib/eval_test.go | 51 + webscan/lib/poc_executor_test.go | 332 +++++++ webscan/lib/shiro_test.go | 237 +++++ 48 files changed, 7556 insertions(+), 1 deletion(-) create mode 100644 plugins/services/activemq_test.go create mode 100644 plugins/services/findnet_test.go create mode 100644 plugins/services/ftp_test.go create mode 100644 plugins/services/mssql_test.go create mode 100644 plugins/services/oracle_test.go create mode 100644 plugins/services/rdp_test.go create mode 100644 plugins/services/smtp_test.go create mode 100644 plugins/services/snmp_test.go create mode 100644 plugins/services/ssh_test.go create mode 100644 plugins/services/vnc_test.go create mode 100644 webscan/fingerprint/calc_priority_test.go create mode 100644 webscan/fingerprint_scanner_test.go create mode 100644 webscan/lib/eval_crypto_test.go create mode 100644 webscan/lib/eval_encoding_test.go create mode 100644 webscan/lib/eval_misc_test.go create mode 100644 webscan/lib/eval_random_test.go create mode 100644 webscan/lib/eval_string_test.go create mode 100644 webscan/lib/shiro_test.go 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) + } + } +} From 9b8e4f3f3b43e1aa1c75a34b06d30bb0651ceac6 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Tue, 16 Jun 2026 03:32:22 +0800 Subject: [PATCH 02/13] =?UTF-8?q?fix:=20MongoDB=20SCRAM=20=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E5=9B=A0=20BSON=20=E9=94=AE=E5=BA=8F=E9=9A=8F?= =?UTF-8?q?=E6=9C=BA=E8=80=8C=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go map 遍历顺序不确定,导致 buildBSON 输出的命令文档中 saslStart/saslContinue 不一定是第一个键,MongoDB 拒绝执行。 引入有序 []mongoKV 类型,SASL 命令改用 orderedDoc() 构造。 同时新增 6 协议集成测试框架(Docker Compose + go test -tags integration)。 --- plugins/services/mongodb.go | 124 +++++++------- tests/integration/docker-compose.yml | 89 +++++++++++ tests/integration/integration_test.go | 222 ++++++++++++++++++++++++++ 3 files changed, 374 insertions(+), 61 deletions(-) create mode 100644 tests/integration/docker-compose.yml create mode 100644 tests/integration/integration_test.go diff --git a/plugins/services/mongodb.go b/plugins/services/mongodb.go index f61fedc..2fb7d28 100644 --- a/plugins/services/mongodb.go +++ b/plugins/services/mongodb.go @@ -101,7 +101,7 @@ func (p *MongoDBPlugin) doMongoDBAuth(ctx context.Context, info *common.HostInfo defer conn.Close() // Step 1: isMaster 获取服务参数 - isMasterCmd := buildMongoCommand("admin", "isMaster", mongoDoc{}) + isMasterCmd := buildMongoCommand("admin", "isMaster") if _, err := sendMongoMsg(ctx, conn, isMasterCmd, timeout); err != nil { state.IncrementTCPFailedPacketCount() return &AuthResult{Success: false, ErrorType: classifyMongoDBErrorType(err), Error: err} @@ -117,13 +117,12 @@ func (p *MongoDBPlugin) doMongoDBAuth(ctx context.Context, info *common.HostInfo clientFirstBare := "n=" + cred.Username + ",r=" + nonce saslPayload := "n,," + clientFirstBare - saslStartBody := mongoDoc{ - "saslStart": 1, - "mechanism": "SCRAM-SHA-1", - "payload": []byte(saslPayload), - "autoAuthorize": 1, - } - saslStartCmd := buildMongoCommand("admin", saslStartBody) + saslStartCmd := buildMongoCommand("admin", orderedDoc( + kv("saslStart", 1), + kv("mechanism", "SCRAM-SHA-1"), + kv("payload", []byte(saslPayload)), + kv("autoAuthorize", 1), + )) if _, err := sendMongoMsg(ctx, conn, saslStartCmd, timeout); err != nil { state.IncrementTCPFailedPacketCount() return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err} @@ -152,12 +151,11 @@ func (p *MongoDBPlugin) doMongoDBAuth(ctx context.Context, info *common.HostInfo return &AuthResult{Success: false, ErrorType: ErrorTypeAuth, Error: err} } - saslContinueBody := mongoDoc{ - "saslContinue": 1, - "conversationId": int(startReply.conversationID), - "payload": []byte(clientFinal), - } - saslContinueCmd := buildMongoCommand("admin", saslContinueBody) + saslContinueCmd := buildMongoCommand("admin", orderedDoc( + kv("saslContinue", 1), + kv("conversationId", int(startReply.conversationID)), + kv("payload", []byte(clientFinal)), + )) if _, err := sendMongoMsg(ctx, conn, saslContinueCmd, timeout); err != nil { state.IncrementTCPFailedPacketCount() return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err} @@ -196,64 +194,64 @@ func nextRequestID() uint32 { } // buildMongoCommand 构建 MongoDB 命令的 OP_MSG body (最小 BSON 实现) -// key 为字符串时,构建 {key: value} 作为命令名 -// key 为 map 时,展开所有字段 +// MongoDB 要求命令名是 BSON 文档的第一个键,因此使用 orderedDoc 保证顺序。 func buildMongoCommand(db string, args ...interface{}) []byte { var buf []byte - // flags: 0 (ChecksumPresent=0, MoreToCome=0, ExhaustAllowed=0) - buf = append(buf, 0, 0, 0, 0) - // section kind 0: body - buf = append(buf, 0) + buf = append(buf, 0, 0, 0, 0) // flags + buf = append(buf, 0) // section kind 0: body - // 构建 BSON 文档 - if len(db) > 0 { - // {$db: "admin", ...} - docs := mongoDoc{"$db": db} - for i := 0; i < len(args); i++ { - switch v := args[i].(type) { - case string: - if i+1 < len(args) { - docs[v] = args[i+1] - i++ - } - case mongoDoc: - for k, val := range v { - docs[k] = val - } + var doc []mongoKV + + for i := 0; i < len(args); i++ { + switch v := args[i].(type) { + case string: + if i+1 < len(args) { + doc = append(doc, kv(v, args[i+1])) + i++ + } else { + doc = append(doc, kv(v, 1)) } + case mongoDoc: + for k, val := range v { + doc = append(doc, kv(k, val)) + } + case []mongoKV: + doc = append(doc, v...) } - return append(buf, buildBSON(docs)...) } - // 简单命令: {commandName: 1, $db: "admin"} - if len(args) >= 1 { - docs := mongoDoc{} - if cmdName, ok := args[0].(string); ok { - docs[cmdName] = 1 - } - if len(args) >= 2 { - switch v := args[1].(type) { - case mongoDoc: - for k, val := range v { - docs[k] = val - } - } - } - if db != "" { - docs["$db"] = db - } - return append(buf, buildBSON(docs)...) + if db != "" { + doc = append(doc, kv("$db", db)) } - return buf + return append(buf, buildBSON(doc)...) } -type mongoDoc map[string]interface{} +type mongoDoc = map[string]interface{} -// buildBSON 构建最小 BSON 文档(仅支持 string/int32/double/binary/subdocument) -func buildBSON(doc mongoDoc) []byte { +type mongoKV struct { + Key string + Value interface{} +} + +func orderedDoc(kvs ...mongoKV) []mongoKV { return kvs } +func kv(k string, v interface{}) mongoKV { return mongoKV{k, v} } + +func buildBSON(doc interface{}) []byte { + var pairs []mongoKV + switch d := doc.(type) { + case []mongoKV: + pairs = d + case mongoDoc: + for k, v := range d { + pairs = append(pairs, mongoKV{k, v}) + } + default: + return []byte{5, 0, 0, 0, 0} + } var buf []byte - for k, v := range doc { + for _, p := range pairs { + k, v := p.Key, p.Value switch val := v.(type) { case string: buf = append(buf, 0x02) // type string @@ -284,8 +282,12 @@ func buildBSON(doc mongoDoc) []byte { buf = append(buf, 0x03) // type document buf = append(buf, []byte(k)...) buf = append(buf, 0x00) - sub := buildBSON(val) - buf = append(buf, sub...) + buf = append(buf, buildBSON(val)...) + case []mongoKV: + buf = append(buf, 0x03) // type document + buf = append(buf, []byte(k)...) + buf = append(buf, 0x00) + buf = append(buf, buildBSON(val)...) case []byte: buf = append(buf, 0x05) // type binary buf = append(buf, []byte(k)...) diff --git a/tests/integration/docker-compose.yml b/tests/integration/docker-compose.yml new file mode 100644 index 0000000..6455d9f --- /dev/null +++ b/tests/integration/docker-compose.yml @@ -0,0 +1,89 @@ +services: + redis: + image: redis:7-alpine + command: redis-server --requirepass test123 + ports: + - "16379:6379" + healthcheck: + test: ["CMD", "redis-cli", "-a", "test123", "ping"] + interval: 3s + retries: 10 + + redis-noauth: + image: redis:7-alpine + ports: + - "16380:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 3s + retries: 10 + + mysql: + image: mysql:8.0 + command: --default-authentication-plugin=mysql_native_password + environment: + MYSQL_ROOT_PASSWORD: root123 + MYSQL_ROOT_HOST: "%" + MYSQL_DATABASE: testdb + ports: + - "13307:3306" + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-proot123"] + interval: 5s + retries: 20 + + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres123 + POSTGRES_DB: testdb + ports: + - "15432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 3s + retries: 10 + + ftp: + image: fauria/vsftpd + environment: + FTP_USER: ftpuser + FTP_PASS: ftp123 + PASV_MIN_PORT: 21100 + PASV_MAX_PORT: 21110 + PASV_ADDRESS: 127.0.0.1 + ports: + - "10021:21" + - "21100-21110:21100-21110" + healthcheck: + test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/localhost/21' || exit 1"] + interval: 5s + retries: 10 + + ssh: + image: lscr.io/linuxserver/openssh-server:latest + environment: + PUID: 1000 + PGID: 1000 + USER_NAME: sshuser + USER_PASSWORD: ssh123 + PASSWORD_ACCESS: "true" + ports: + - "10022:2222" + healthcheck: + test: ["CMD-SHELL", "nc -z localhost 2222 || exit 1"] + interval: 3s + retries: 10 + + mongodb: + image: mongo:4.4 + environment: + MONGO_INITDB_ROOT_USERNAME: admin + MONGO_INITDB_ROOT_PASSWORD: mongo123 + ports: + - "17017:27017" + healthcheck: + test: ["CMD", "mongo", "--eval", "db.adminCommand('ping')", "-u", "admin", "-p", "mongo123"] + interval: 5s + retries: 20 diff --git a/tests/integration/integration_test.go b/tests/integration/integration_test.go new file mode 100644 index 0000000..f8e03c8 --- /dev/null +++ b/tests/integration/integration_test.go @@ -0,0 +1,222 @@ +//go:build integration + +package integration + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/common/config" + "github.com/shadow1ng/fscan/plugins/services" +) + +const ( + testHost = "127.0.0.1" +) + +func testSession() *common.ScanSession { + cfg := common.NewConfig() + cfg.Timeout = 10 * time.Second + cfg.ModuleThreadNum = 5 + cfg.MaxRetries = 2 + cfg.Credentials.Userdict = nil + cfg.Credentials.Passwords = nil + state := common.NewState() + return common.NewScanSession(cfg, state, &common.FlagVars{}) +} + +func hostInfo(host string, port int) *common.HostInfo { + return &common.HostInfo{Host: host, Port: port} +} + +func TestMain(m *testing.M) { + fmt.Println("integration tests: ensure docker-compose services are running") + os.Exit(m.Run()) +} + +// ── Redis ────────────────────────────────────────────────────── + +func TestRedisUnauthorized(t *testing.T) { + session := testSession() + info := hostInfo(testHost, 16380) + plugin := services.NewRedisPlugin() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result := plugin.Scan(ctx, info, session) + if result == nil { + t.Fatal("result is nil") + } + if !result.Success { + t.Fatalf("expected unauthorized redis to succeed, got error: %v", result.Error) + } + t.Logf("redis noauth: %+v", result) +} + +func TestRedisBrute(t *testing.T) { + session := testSession() + session.Config.Credentials.UserPassPairs = []config.CredentialPair{ + {Username: "", Password: "wrong1"}, + {Username: "", Password: "test123"}, + } + info := hostInfo(testHost, 16379) + plugin := services.NewRedisPlugin() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result := plugin.Scan(ctx, info, session) + if result == nil { + t.Fatal("result is nil") + } + if !result.Success { + t.Fatalf("expected redis brute to succeed with test123, got error: %v", result.Error) + } + if result.Password != "test123" { + t.Errorf("expected password test123, got %q", result.Password) + } + t.Logf("redis brute: %+v", result) +} + +// ── MySQL ────────────────────────────────────────────────────── + +func TestMySQLBrute(t *testing.T) { + session := testSession() + session.Config.Credentials.UserPassPairs = []config.CredentialPair{ + {Username: "root", Password: "wrong"}, + {Username: "root", Password: "root123"}, + } + info := hostInfo(testHost, 13307) + plugin := services.NewMySQLPlugin() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result := plugin.Scan(ctx, info, session) + if result == nil { + t.Fatal("result is nil") + } + if !result.Success { + t.Fatalf("expected mysql brute to succeed, got error: %v", result.Error) + } + t.Logf("mysql brute: user=%s pass=%s", result.Username, result.Password) +} + +// ── PostgreSQL ───────────────────────────────────────────────── + +func TestPostgreSQLBrute(t *testing.T) { + session := testSession() + session.Config.Credentials.UserPassPairs = []config.CredentialPair{ + {Username: "postgres", Password: "wrong"}, + {Username: "postgres", Password: "postgres123"}, + } + info := hostInfo(testHost, 15432) + plugin := services.NewPostgreSQLPlugin() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result := plugin.Scan(ctx, info, session) + if result == nil { + t.Fatal("result is nil") + } + if !result.Success { + t.Fatalf("expected postgresql brute to succeed, got error: %v", result.Error) + } + t.Logf("postgresql brute: user=%s pass=%s", result.Username, result.Password) +} + +// ── FTP ──────────────────────────────────────────────────────── + +func TestFTPBrute(t *testing.T) { + session := testSession() + session.Config.Credentials.UserPassPairs = []config.CredentialPair{ + {Username: "ftpuser", Password: "wrong"}, + {Username: "ftpuser", Password: "ftp123"}, + } + info := hostInfo(testHost, 10021) + plugin := services.NewFTPPlugin() + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + result := plugin.Scan(ctx, info, session) + if result == nil { + t.Fatal("result is nil") + } + if !result.Success { + t.Fatalf("expected ftp brute to succeed, got error: %v", result.Error) + } + t.Logf("ftp brute: user=%s pass=%s", result.Username, result.Password) +} + +// ── SSH ──────────────────────────────────────────────────────── + +func TestSSHBrute(t *testing.T) { + session := testSession() + session.Config.Credentials.UserPassPairs = []config.CredentialPair{ + {Username: "sshuser", Password: "wrong"}, + {Username: "sshuser", Password: "ssh123"}, + } + info := hostInfo(testHost, 10022) + plugin := services.NewSSHPlugin() + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + result := plugin.Scan(ctx, info, session) + if result == nil { + t.Fatal("result is nil") + } + if !result.Success { + t.Fatalf("expected ssh brute to succeed, got error: %v", result.Error) + } + t.Logf("ssh brute: user=%s pass=%s", result.Username, result.Password) +} + +// ── MongoDB ──────────────────────────────────────────────────── + +func TestMongoDBBrute(t *testing.T) { + // Fixed: BSON key ordering was non-deterministic (Go map), MongoDB requires command name first + session := testSession() + session.Config.Credentials.UserPassPairs = []config.CredentialPair{ + {Username: "admin", Password: "wrong"}, + {Username: "admin", Password: "mongo123"}, + } + info := hostInfo(testHost, 17017) + plugin := services.NewMongoDBPlugin() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result := plugin.Scan(ctx, info, session) + if result == nil { + t.Fatal("result is nil") + } + if !result.Success { + t.Fatalf("expected mongodb brute to succeed, got error: %v", result.Error) + } + t.Logf("mongodb brute: user=%s pass=%s", result.Username, result.Password) +} + +// ── 连接失败场景 ────────────────────────────────────────────── + +func TestRedisConnectionRefused(t *testing.T) { + session := testSession() + session.Config.Timeout = 3 * time.Second + info := hostInfo(testHost, 19999) + plugin := services.NewRedisPlugin() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + result := plugin.Scan(ctx, info, session) + if result != nil && result.Success { + t.Fatal("expected failure on closed port") + } +} From 1c6f3b80d0e7bd23966a81b5dcf4b3955fc74289 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Tue, 16 Jun 2026 11:29:17 +0800 Subject: [PATCH 03/13] =?UTF-8?q?fix:=20Cassandra=20CQL=20=E5=8D=8F?= =?UTF-8?q?=E8=AE=AE=E5=A4=B4=E7=BC=BA=E5=B0=91=20flags=20=E5=AD=97?= =?UTF-8?q?=E8=8A=82=20+=20version=20=E6=96=B9=E5=90=91=E4=BD=8D=E9=94=99?= =?UTF-8?q?=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cqlSend 写 8 字节头(缺 flags),实际 CQL v4 需要 9 字节。 version byte 0x84 是 response 方向,request 应为 0x04。 同时扩展集成测试至 17 个协议:新增 Memcached、Elasticsearch、 MSSQL、RabbitMQ、MQTT、LDAP、Cassandra、Neo4j、Kafka、SMTP。 --- plugins/services/cassandra.go | 15 +- tests/integration/docker-compose.yml | 114 +++++++++++++ tests/integration/integration_test.go | 223 ++++++++++++++++++++++++++ tests/integration/mosquitto.conf | 2 + 4 files changed, 347 insertions(+), 7 deletions(-) create mode 100644 tests/integration/mosquitto.conf diff --git a/plugins/services/cassandra.go b/plugins/services/cassandra.go index 2e8a14c..bc505d3 100644 --- a/plugins/services/cassandra.go +++ b/plugins/services/cassandra.go @@ -74,7 +74,7 @@ func (p *CassandraPlugin) createAuthFunc(info *common.HostInfo, config *common.C // // [1B version|flags] [2B stream] [1B opcode] [4B length] [body] const ( - cqlVersion = 0x84 // version=4, direction=request + cqlVersion = 0x04 // version=4, direction=request cqlOpStartup = 0x01 cqlOpAuthRsp = 0x0f cqlOpQuery = 0x07 @@ -178,12 +178,13 @@ func nextCQLStreamID() uint16 { func cqlSend(conn net.Conn, opcode byte, body []byte) error { id := nextCQLStreamID() - // frame: [1B version|flags] [2B stream] [1B opcode] [4B length] [body] - header := make([]byte, 8) - header[0] = cqlVersion - binary.BigEndian.PutUint16(header[1:3], id) - header[3] = opcode - binary.BigEndian.PutUint32(header[4:8], uint32(len(body))) + // CQL v4 frame: [1B version] [1B flags] [2B stream] [1B opcode] [4B length] [body] + header := make([]byte, 9) + header[0] = cqlVersion // 0x04 = request, version 4 + header[1] = 0x00 // flags + binary.BigEndian.PutUint16(header[2:4], id) + header[4] = opcode + binary.BigEndian.PutUint32(header[5:9], uint32(len(body))) buf := append(header, body...) _, err := conn.Write(buf) diff --git a/tests/integration/docker-compose.yml b/tests/integration/docker-compose.yml index 6455d9f..47bb720 100644 --- a/tests/integration/docker-compose.yml +++ b/tests/integration/docker-compose.yml @@ -87,3 +87,117 @@ services: test: ["CMD", "mongo", "--eval", "db.adminCommand('ping')", "-u", "admin", "-p", "mongo123"] interval: 5s retries: 20 + + memcached: + image: memcached:1-alpine + ports: + - "11211:11211" + healthcheck: + test: ["CMD-SHELL", "echo stats | nc localhost 11211 | grep -q pid"] + interval: 3s + retries: 10 + + elasticsearch: + image: elasticsearch:7.17.24 + environment: + discovery.type: single-node + xpack.security.enabled: "false" + ES_JAVA_OPTS: "-Xms256m -Xmx256m" + ports: + - "19200:9200" + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:9200/_cluster/health || exit 1"] + interval: 5s + retries: 20 + + mssql: + image: mcr.microsoft.com/mssql/server:2019-latest + environment: + ACCEPT_EULA: "Y" + SA_PASSWORD: "MssqlTest123!" + MSSQL_PID: Express + ports: + - "11433:1433" + healthcheck: + test: ["CMD-SHELL", "/opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'MssqlTest123!' -Q 'SELECT 1' || exit 1"] + interval: 5s + retries: 30 + + rabbitmq: + image: rabbitmq:3-management-alpine + environment: + RABBITMQ_DEFAULT_USER: admin + RABBITMQ_DEFAULT_PASS: rabbit123 + ports: + - "15672:15672" + - "15673:5672" + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "check_running"] + interval: 5s + retries: 20 + + mqtt: + image: eclipse-mosquitto:2 + ports: + - "11883:1883" + volumes: + - ./mosquitto.conf:/mosquitto/config/mosquitto.conf:ro + healthcheck: + test: ["CMD-SHELL", "mosquitto_sub -t '$$SYS/#' -C 1 -W 2 || exit 1"] + interval: 5s + retries: 10 + +openldap: + image: osixia/openldap:1.5.0 + environment: + LDAP_ORGANISATION: "Test" + LDAP_DOMAIN: "test.local" + LDAP_ADMIN_PASSWORD: "ldap123" + ports: + - "10389:389" + healthcheck: + test: ["CMD-SHELL", "ldapsearch -x -H ldap://localhost -b 'dc=test,dc=local' -D 'cn=admin,dc=test,dc=local' -w ldap123 || exit 1"] + interval: 5s + retries: 10 + + cassandra: + image: cassandra:4.1 + environment: + CASSANDRA_AUTHENTICATOR: AllowAllAuthenticator + ports: + - "19042:9042" + healthcheck: + test: ["CMD-SHELL", "cqlsh -e 'DESCRIBE CLUSTER' || exit 1"] + interval: 10s + retries: 30 + + neo4j: + image: neo4j:5 + environment: + NEO4J_AUTH: "neo4j/neo4jtest123" + ports: + - "17687:7687" + - "17474:7474" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:7474 || exit 1"] + interval: 5s + retries: 20 + + kafka: + image: apache/kafka:3.7.0 + ports: + - "19092:9092" + healthcheck: + test: ["CMD-SHELL", "/opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --list || exit 1"] + interval: 10s + retries: 20 + + smtp: + image: mailhog/mailhog + ports: + - "11025:1025" + - "18025:8025" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:8025/api/v2/messages || exit 1"] + interval: 5s + retries: 10 diff --git a/tests/integration/integration_test.go b/tests/integration/integration_test.go index f8e03c8..fad38d2 100644 --- a/tests/integration/integration_test.go +++ b/tests/integration/integration_test.go @@ -204,6 +204,229 @@ func TestMongoDBBrute(t *testing.T) { t.Logf("mongodb brute: user=%s pass=%s", result.Username, result.Password) } +// ── Memcached ────────────────────────────────────────────────── + +func TestMemcachedUnauthorized(t *testing.T) { + session := testSession() + info := hostInfo(testHost, 11211) + plugin := services.NewMemcachedPlugin() + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + result := plugin.Scan(ctx, info, session) + if result == nil { + t.Fatal("result is nil") + } + if !result.Success { + t.Fatalf("expected memcached to succeed, got error: %v", result.Error) + } + t.Logf("memcached: type=%s banner=%s", result.Type, result.Banner) +} + +// ── Elasticsearch ────────────────────────────────────────────── + +func TestElasticsearchUnauthorized(t *testing.T) { + session := testSession() + info := hostInfo(testHost, 19200) + plugin := services.NewElasticsearchPlugin() + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + result := plugin.Scan(ctx, info, session) + if result == nil { + t.Fatal("result is nil") + } + if !result.Success { + t.Fatalf("expected elasticsearch to succeed, got error: %v", result.Error) + } + t.Logf("elasticsearch: type=%s vulinfo=%s", result.Type, result.VulInfo) +} + +// ── MSSQL ────────────────────────────────────────────────────── + +func TestMSSQLBrute(t *testing.T) { + session := testSession() + session.Config.Credentials.UserPassPairs = []config.CredentialPair{ + {Username: "sa", Password: "wrong"}, + {Username: "sa", Password: "MssqlTest123!"}, + } + info := hostInfo(testHost, 11433) + plugin := services.NewMSSQLPlugin() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result := plugin.Scan(ctx, info, session) + if result == nil { + t.Fatal("result is nil") + } + if !result.Success { + t.Fatalf("expected mssql brute to succeed, got error: %v", result.Error) + } + t.Logf("mssql brute: user=%s pass=%s", result.Username, result.Password) +} + +// ── RabbitMQ ─────────────────────────────────────────────────── + +func TestRabbitMQBrute(t *testing.T) { + session := testSession() + session.Config.Credentials.UserPassPairs = []config.CredentialPair{ + {Username: "admin", Password: "wrong"}, + {Username: "admin", Password: "rabbit123"}, + } + info := hostInfo(testHost, 15672) + plugin := services.NewRabbitMQPlugin() + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + result := plugin.Scan(ctx, info, session) + if result == nil { + t.Fatal("result is nil") + } + if !result.Success { + t.Fatalf("expected rabbitmq brute to succeed, got error: %v", result.Error) + } + t.Logf("rabbitmq brute: user=%s pass=%s", result.Username, result.Password) +} + +// ── MQTT ─────────────────────────────────────────────────────── + +func TestMQTTServiceDetect(t *testing.T) { + session := testSession() + info := hostInfo(testHost, 11883) + plugin := services.NewMQTTPlugin() + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + result := plugin.Scan(ctx, info, session) + if result == nil { + t.Fatal("result is nil") + } + if !result.Success { + t.Fatalf("expected mqtt service detect to succeed, got error: %v", result.Error) + } + t.Logf("mqtt: service=%s banner=%s", result.Service, result.Banner) +} + +// ── SMB ──────────────────────────────────────────────────────── + +func TestSMBBrute(t *testing.T) { + t.Skip("SMB requires port 445 which is reserved on WSL2") +} + +// ── LDAP ─────────────────────────────────────────────────────── + +func TestLDAPBrute(t *testing.T) { + session := testSession() + session.Config.Credentials.UserPassPairs = []config.CredentialPair{ + {Username: "cn=admin,dc=test,dc=local", Password: "wrong"}, + {Username: "cn=admin,dc=test,dc=local", Password: "ldap123"}, + } + info := hostInfo(testHost, 10389) + plugin := services.NewLDAPPlugin() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result := plugin.Scan(ctx, info, session) + if result == nil { + t.Fatal("result is nil") + } + if !result.Success { + t.Fatalf("expected ldap brute to succeed, got error: %v", result.Error) + } + t.Logf("ldap brute: user=%s pass=%s", result.Username, result.Password) +} + +// ── Cassandra ────────────────────────────────────────────────── + +func TestCassandraServiceDetect(t *testing.T) { + session := testSession() + session.Config.DisableBrute = true + info := hostInfo(testHost, 19042) + plugin := services.NewCassandraPlugin() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result := plugin.Scan(ctx, info, session) + if result == nil { + t.Fatal("result is nil") + } + if !result.Success { + t.Fatalf("expected cassandra service detect to succeed, got error: %v", result.Error) + } + t.Logf("cassandra: type=%s banner=%s", result.Type, result.Banner) +} + +// ── Neo4j ────────────────────────────────────────────────────── + +func TestNeo4jBrute(t *testing.T) { + session := testSession() + session.Config.Credentials.UserPassPairs = []config.CredentialPair{ + {Username: "neo4j", Password: "wrong"}, + {Username: "neo4j", Password: "neo4jtest123"}, + } + info := hostInfo(testHost, 17687) + plugin := services.NewNeo4jPlugin() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result := plugin.Scan(ctx, info, session) + if result == nil { + t.Fatal("result is nil") + } + if !result.Success { + t.Fatalf("expected neo4j brute to succeed, got error: %v", result.Error) + } + t.Logf("neo4j brute: user=%s pass=%s", result.Username, result.Password) +} + +// ── Kafka ────────────────────────────────────────────────────── + +func TestKafkaNoAuth(t *testing.T) { + session := testSession() + info := hostInfo(testHost, 19092) + plugin := services.NewKafkaPlugin() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result := plugin.Scan(ctx, info, session) + if result == nil { + t.Fatal("result is nil") + } + if !result.Success { + t.Fatalf("expected kafka to succeed, got error: %v", result.Error) + } + t.Logf("kafka: type=%s banner=%s", result.Type, result.Banner) +} + +// ── SMTP ─────────────────────────────────────────────────────── + +func TestSMTPServiceDetect(t *testing.T) { + session := testSession() + info := hostInfo(testHost, 11025) + plugin := services.NewSMTPPlugin() + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + result := plugin.Scan(ctx, info, session) + if result == nil { + t.Fatal("result is nil") + } + if !result.Success { + t.Fatalf("expected smtp to succeed, got error: %v", result.Error) + } + t.Logf("smtp: type=%s banner=%s", result.Type, result.Banner) +} + // ── 连接失败场景 ────────────────────────────────────────────── func TestRedisConnectionRefused(t *testing.T) { diff --git a/tests/integration/mosquitto.conf b/tests/integration/mosquitto.conf new file mode 100644 index 0000000..c8348ac --- /dev/null +++ b/tests/integration/mosquitto.conf @@ -0,0 +1,2 @@ +listener 1883 +allow_anonymous true From 65e64e89678c3df9bf46b13d4106a938ce1fb76e Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Tue, 16 Jun 2026 21:01:28 +0800 Subject: [PATCH 04/13] =?UTF-8?q?fix:=20Oracle=20TNS=20Resend=20=E9=87=8D?= =?UTF-8?q?=E8=AF=95=20+=20ANO=20=E6=A0=BC=E5=BC=8F=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=EF=BC=8C=E6=89=A9=E5=B1=95=E9=9B=86=E6=88=90=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E8=87=B3=2022=20=E5=8D=8F=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Oracle raw TNS 修复: - connect 阶段支持 Resend 包重试(Oracle 18c+ 需要) - ANO 请求补齐加密/完整性算法列表和 auth UB2 字段 - ANO length 字段修正为包含 magic 的完整长度 - Oracle 18c ANO 仍不兼容(字节级匹配 go-ora 但被拒绝),爆破标记 SKIP 新增集成测试协议: ActiveMQ, Zookeeper, Rsync, VNC, SNMP, Oracle(服务检测), Cassandra, Neo4j, Kafka, SMTP, LDAP VNC 修复:换用支持 RFB 3.8 的 debian-xfce-vnc 镜像 --- plugins/services/oracle.go | 31 +++---- plugins/services/oracle_raw.go | 93 +++++++++++++------ tests/integration/docker-compose.yml | 63 ++++++++++++- tests/integration/integration_test.go | 125 ++++++++++++++++++++++++++ tests/integration/rsyncd.conf | 11 +++ 5 files changed, 275 insertions(+), 48 deletions(-) create mode 100644 tests/integration/rsyncd.conf diff --git a/plugins/services/oracle.go b/plugins/services/oracle.go index 887bf82..106132f 100644 --- a/plugins/services/oracle.go +++ b/plugins/services/oracle.go @@ -66,34 +66,22 @@ func (p *OraclePlugin) createAuthFunc(info *common.HostInfo, config *common.Conf } } -// doOracleAuth 执行Oracle认证 +// doOracleAuth 执行 Oracle 认证(raw TNS 协议) func (p *OraclePlugin) doOracleAuth(ctx context.Context, info *common.HostInfo, cred Credential, config *common.Config, state *common.State) *AuthResult { - target := info.Target() - serviceNames := []string{"ORCL", "XE", "XEPDB1", target} + serviceNames := []string{"XE", "ORCL", "XEPDB1"} for _, serviceName := range serviceNames { connectCtx, cancel := context.WithTimeout(ctx, config.ModuleTimeout()) err := oracleRawAuth(connectCtx, info.Host, info.Port, serviceName, cred.Username, cred.Password, config.ModuleTimeout()) - if err != nil { - cancel() - errorType := classifyOracleErrorType(err) - if errorType == ErrorTypeAuth { - return &AuthResult{ - Success: false, - ErrorType: errorType, - Error: err, - } - } - continue + cancel() + if err == nil { + state.IncrementTCPSuccessPacketCount() + return &AuthResult{Success: true} } - cancel() - state.IncrementTCPSuccessPacketCount() - - return &AuthResult{ - Success: true, - ErrorType: ErrorTypeUnknown, - Error: nil, + errorType := classifyOracleErrorType(err) + if errorType == ErrorTypeAuth { + return &AuthResult{Success: false, ErrorType: errorType, Error: err} } } @@ -105,6 +93,7 @@ func (p *OraclePlugin) doOracleAuth(ctx context.Context, info *common.HostInfo, } } + // classifyOracleErrorType Oracle错误分类 func classifyOracleErrorType(err error) ErrorType { if err == nil { diff --git a/plugins/services/oracle_raw.go b/plugins/services/oracle_raw.go index f74767a..21160b2 100644 --- a/plugins/services/oracle_raw.go +++ b/plugins/services/oracle_raw.go @@ -117,7 +117,7 @@ func oracleRawAuth(ctx context.Context, host string, port int, serviceName, user } if s.acfl0&1 != 0 && s.acfl0&4 == 0 && s.acfl1&8 == 0 { if err := s.advancedNegotiation(); err != nil { - return err + return fmt.Errorf("ANO: %w", err) } } nego, err := s.protocolNegotiation() @@ -156,20 +156,36 @@ func (s *oracleSession) connect(ctx context.Context, host string, port int, serv if len(connectData) <= 230 { copy(buf[70:], connectData) } - if err := s.writeRaw(ctx, buf); err != nil { + sendConnect := func() error { + if err := s.writeRaw(ctx, buf); err != nil { + return err + } + if len(connectData) > 230 { + s.reset() + s.putBytes([]byte(connectData)...) + return s.writeData() + } + return nil + } + if err := sendConnect(); err != nil { return err } - if len(connectData) > 230 { - s.reset() - s.putBytes([]byte(connectData)...) - if err := s.writeData(); err != nil { + + var p *oraclePacket + for resends := 0; resends < 3; resends++ { + var err error + p, err = s.readPacket() + if err != nil { + return err + } + if p.typ != oraclePacketResend { + break + } + if err := sendConnect(); err != nil { return err } } - p, err := s.readPacket() - if err != nil { - return err - } + switch p.typ { case oraclePacketAccept: if len(p.raw) < 40 { @@ -188,7 +204,9 @@ func (s *oracleSession) connect(ctx context.Context, host string, port int, serv } s.acfl0 = p.raw[22] s.acfl1 = p.raw[23] - s.handshakeComplete = true + if s.version >= 315 { + s.handshakeComplete = true + } return nil case oraclePacketRefuse: return oracleRefuseError(p.raw) @@ -658,22 +676,45 @@ func toUint64(v interface{}) uint64 { } func (s *oracleSession) advancedNegotiation() error { + // 按 go-ora 参考实现,构造 ANO 请求 + // Service 4 (supervisor): version + cid + servArray + // Service 1 (auth): version + UB2(0xE0E1) + status(0xFCFF) + // Service 2 (encrypt): version + algorithms([0]=rejected) + UB1(1) + // Service 3 (data integrity): version + algorithms([0]=rejected) + + // 构建 ANO body 到临时 buffer 计算精确 length + var ab oracleSession + ab.clrChunkSize = s.clrChunkSize + + // Service 4 (supervisor): cid + service array + ab.writeANOServiceHeader(4, 3) + ab.writeANOVersion() + ab.writeANOBytes([]byte{0, 0, 16, 28, 102, 236, 40, 234}) + ab.writeANOUB2Array([]int{4, 1, 2, 3}) + + // Service 1 (auth): UB2(0xE0E1) + status(0xFCFF) + ab.writeANOServiceHeader(1, 3) + ab.writeANOVersion() + ab.writeANOPacketHeader(2, 3) + ab.putInt(0xE0E1, 2, true, false) + ab.writeANOStatus(0xfcff) + + // Service 2 (encrypt): supported algos + driver + ab.writeANOServiceHeader(2, 3) + ab.writeANOVersion() + ab.writeANOBytes([]byte{0, 1, 8, 10, 6, 2, 15, 16, 17}) + ab.writeANOUB1(1) + + // Service 3 (data integrity): supported algos + ab.writeANOServiceHeader(3, 2) + ab.writeANOVersion() + ab.writeANOBytes([]byte{0, 1, 3, 4, 5, 6}) + + body := ab.out.Bytes() s.reset() - s.writeANOHeader(101, 4, 0) - s.writeANOServiceHeader(4, 3) - s.writeANOVersion() - s.writeANOBytes([]byte{0, 0, 16, 28, 102, 236, 40, 234}) - s.writeANOUB2Array([]int{4, 1, 2, 3}) - s.writeANOServiceHeader(1, 3) - s.writeANOVersion() - s.writeANOStatus(0xfcff) - s.writeANOServiceHeader(2, 3) - s.writeANOVersion() - s.writeANOBytes([]byte{0}) - s.writeANOUB1(1) - s.writeANOServiceHeader(3, 2) - s.writeANOVersion() - s.writeANOBytes([]byte{0}) + s.writeANOHeader(13+len(body), 4, 0) + s.putBytes(body...) + if err := s.writeData(); err != nil { return err } diff --git a/tests/integration/docker-compose.yml b/tests/integration/docker-compose.yml index 47bb720..22d1b86 100644 --- a/tests/integration/docker-compose.yml +++ b/tests/integration/docker-compose.yml @@ -147,7 +147,7 @@ services: interval: 5s retries: 10 -openldap: + openldap: image: osixia/openldap:1.5.0 environment: LDAP_ORGANISATION: "Test" @@ -201,3 +201,64 @@ openldap: test: ["CMD-SHELL", "wget -qO- http://localhost:8025/api/v2/messages || exit 1"] interval: 5s retries: 10 + + oracle: + image: gvenzl/oracle-xe:18-slim + environment: + ORACLE_PASSWORD: oracle123 + ports: + - "11521:1521" + healthcheck: + test: ["CMD-SHELL", "healthcheck.sh"] + interval: 10s + retries: 30 + + activemq: + image: rmohr/activemq:5.15.9 + ports: + - "11613:61613" + - "18161:8161" + healthcheck: + test: ["CMD-SHELL", "curl -sf http://admin:admin@localhost:8161/api/jolokia || exit 1"] + interval: 5s + retries: 15 + + zookeeper: + image: zookeeper:3.9 + ports: + - "12181:2181" + healthcheck: + test: ["CMD-SHELL", "echo ruok | nc localhost 2181 | grep -q imok"] + interval: 5s + retries: 10 + + rsync: + image: vimagick/rsyncd + ports: + - "10873:873" + volumes: + - ./rsyncd.conf:/etc/rsyncd.conf:ro + healthcheck: + test: ["CMD-SHELL", "nc -z localhost 873 || exit 1"] + interval: 5s + retries: 10 + + vnc: + image: consol/debian-xfce-vnc:latest + environment: + VNC_PW: vnc123 + ports: + - "15901:5901" + healthcheck: + test: ["CMD-SHELL", "nc -z localhost 5901 || exit 1"] + interval: 5s + retries: 15 + + snmp: + image: polinux/snmpd + ports: + - "10161:161/udp" + healthcheck: + test: ["CMD-SHELL", "snmpget -v2c -c public localhost sysDescr.0 || exit 0"] + interval: 5s + retries: 10 diff --git a/tests/integration/integration_test.go b/tests/integration/integration_test.go index fad38d2..35166af 100644 --- a/tests/integration/integration_test.go +++ b/tests/integration/integration_test.go @@ -427,6 +427,131 @@ func TestSMTPServiceDetect(t *testing.T) { t.Logf("smtp: type=%s banner=%s", result.Type, result.Banner) } +// ── Oracle ───────────────────────────────────────────────────── + +func TestOracleServiceDetect(t *testing.T) { + session := testSession() + session.Config.DisableBrute = true + info := hostInfo(testHost, 11521) + plugin := services.NewOraclePlugin() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result := plugin.Scan(ctx, info, session) + if result == nil { + t.Fatal("result is nil") + } + t.Logf("oracle detect: success=%v type=%s banner=%s error=%v", result.Success, result.Type, result.Banner, result.Error) +} + +func TestOracleBrute(t *testing.T) { + t.Skip("Oracle raw TNS ANO incompatible with 18c+ — go-ora works but adds 14MB (charset tables)") +} + +// ── ActiveMQ ─────────────────────────────────────────────────── + +func TestActiveMQServiceDetect(t *testing.T) { + session := testSession() + session.Config.DisableBrute = true + info := hostInfo(testHost, 11613) + plugin := services.NewActiveMQPlugin() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result := plugin.Scan(ctx, info, session) + if result == nil { + t.Fatal("result is nil") + } + if !result.Success { + t.Fatalf("expected activemq service detect to succeed, got error: %v", result.Error) + } + t.Logf("activemq: type=%s banner=%s", result.Type, result.Banner) +} + +// ── Zookeeper ────────────────────────────────────────────────── + +func TestZookeeperServiceDetect(t *testing.T) { + session := testSession() + info := hostInfo(testHost, 12181) + plugin := services.NewZooKeeperPlugin() + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + result := plugin.Scan(ctx, info, session) + if result == nil { + t.Fatal("result is nil") + } + if !result.Success { + t.Fatalf("expected zookeeper to succeed, got error: %v", result.Error) + } + t.Logf("zookeeper: type=%s banner=%s", result.Type, result.Banner) +} + +// ── Rsync ────────────────────────────────────────────────────── + +func TestRsyncServiceDetect(t *testing.T) { + session := testSession() + session.Config.DisableBrute = true + info := hostInfo(testHost, 10873) + plugin := services.NewRsyncPlugin() + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + result := plugin.Scan(ctx, info, session) + if result == nil { + t.Fatal("result is nil") + } + if !result.Success { + t.Fatalf("expected rsync service detect to succeed, got error: %v", result.Error) + } + t.Logf("rsync: type=%s banner=%s", result.Type, result.Banner) +} + +// ── VNC ──────────────────────────────────────────────────────── + +func TestVNCBrute(t *testing.T) { + session := testSession() + session.Config.Credentials.Passwords = []string{"wrong", "vnc123"} + info := hostInfo(testHost, 15901) + plugin := services.NewVNCPlugin() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result := plugin.Scan(ctx, info, session) + if result == nil { + t.Fatal("result is nil") + } + if !result.Success { + t.Fatalf("expected vnc brute to succeed, got error: %v", result.Error) + } + t.Logf("vnc brute: pass=%s", result.Password) +} + +// ── SNMP ─────────────────────────────────────────────────────── + +func TestSNMPServiceDetect(t *testing.T) { + session := testSession() + info := hostInfo(testHost, 10161) + plugin := services.NewSNMPPlugin() + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + result := plugin.Scan(ctx, info, session) + if result == nil { + t.Fatal("result is nil") + } + if !result.Success { + t.Fatalf("expected snmp to succeed, got error: %v", result.Error) + } + t.Logf("snmp: type=%s banner=%s", result.Type, result.Banner) +} + // ── 连接失败场景 ────────────────────────────────────────────── func TestRedisConnectionRefused(t *testing.T) { diff --git a/tests/integration/rsyncd.conf b/tests/integration/rsyncd.conf new file mode 100644 index 0000000..832dbc4 --- /dev/null +++ b/tests/integration/rsyncd.conf @@ -0,0 +1,11 @@ +uid = nobody +gid = nogroup +use chroot = no +max connections = 4 +log file = /dev/stdout + +[public] + path = /data + comment = Public + read only = yes + list = yes From 34638954b8ebe99b91267c63e51b9fcbeabca441 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Tue, 16 Jun 2026 21:16:04 +0800 Subject: [PATCH 05/13] =?UTF-8?q?fix:=20=E6=B6=88=E9=99=A4=20state=5Ftest.?= =?UTF-8?q?go=20SA2001=20lint=20=E8=AD=A6=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/state_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/common/state_test.go b/common/state_test.go index 9064e43..e316c9b 100644 --- a/common/state_test.go +++ b/common/state_test.go @@ -222,8 +222,9 @@ func TestState_GetOutputMutex(t *testing.T) { if mu == nil { t.Fatal("GetOutputMutex returned nil") } - // 验证返回的指针可以正常加锁 + // 验证返回的指针可以正常加锁解锁 mu.Lock() + _ = 1 //nolint:staticcheck // SA2001: 故意测试空临界区 mu.Unlock() } From ed2f9477225c050f2cac24ba512bf5afbedb0e11 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sat, 27 Jun 2026 15:36:42 +0800 Subject: [PATCH 06/13] =?UTF-8?q?fix:=20POC=20=E6=89=AB=E6=8F=8F=E9=81=87?= =?UTF-8?q?=E5=88=B0=E9=9D=9E=20HTTP=20=E6=9C=8D=E5=8A=A1=E6=97=B6?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=BE=93=E5=87=BA=E9=94=99=E8=AF=AF=E6=97=A5?= =?UTF-8?q?=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 扫描非 HTTP 端口时 Go net/http 返回 malformed HTTP status code 等 transport 级错误,属于正常现象,不应作为 error 输出。 在 executeRule 和 clustersend 两个调用点统一过滤 transport 错误, 返回 false, nil 表示"目标不可达 = 无漏洞"。 --- webscan/lib/poc_executor.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/webscan/lib/poc_executor.go b/webscan/lib/poc_executor.go index a11a3e1..0f25879 100644 --- a/webscan/lib/poc_executor.go +++ b/webscan/lib/poc_executor.go @@ -2,9 +2,11 @@ package lib import ( "crypto/md5" //nolint:gosec // G501: MD5用于POC规则去重,非加密用途 + "errors" "fmt" "io" "math/rand" //nolint:gosec // G404: math/rand用于生成测试数据,非加密用途 + "net" "net/http" "net/url" "os" @@ -236,6 +238,9 @@ func executeRules(oReq *http.Request, p *Poc, variableMap map[string]interface{} resp, err := DoRequest(newRequest, rule.FollowRedirects, session) newRequest = nil if err != nil { + if isTransportError(err) { + return false, nil + } return false, err } @@ -793,6 +798,9 @@ func clustersend(oReq *http.Request, variableMap map[string]interface{}, req *Re // 发送请求 resp, err := DoRequest(newRequest, rule.FollowRedirects, session) if err != nil { + if isTransportError(err) { + return false, nil + } return false, fmt.Errorf("%s: %w", i18n.GetText("webscan_request_send_error"), err) } @@ -936,3 +944,20 @@ func GetHeader(header map[string]string) string { builder.WriteString("\r\n") return builder.String() } + +func isTransportError(err error) bool { + if err == nil { + return false + } + var netErr *net.OpError + if errors.As(err, &netErr) { + return true + } + s := err.Error() + return strings.Contains(s, "malformed HTTP") || + strings.Contains(s, "transport connection broken") || + strings.Contains(s, "connection reset") || + strings.Contains(s, "connection refused") || + strings.Contains(s, "i/o timeout") || + strings.Contains(s, "EOF") +} From 4976cb1f6b58f8b595eee6e52d141d7c7f845426 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sat, 27 Jun 2026 16:02:18 +0800 Subject: [PATCH 07/13] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20-nsp=20?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E7=A6=81=E7=94=A8=E7=BD=91=E6=AE=B5=E9=A2=84?= =?UTF-8?q?=E7=AD=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 大规模扫描时 probeSubnets 会自动跳过空 /24 网段, 部分场景下用户需要关闭此优化以扫描全部目标。 新增 -nsp (no subnet probe) 参数控制。 --- common/config_struct.go | 4 +++- common/flag.go | 1 + common/flag_config.go | 2 ++ common/i18n/locales/en.yaml | 2 ++ common/i18n/locales/zh.yaml | 2 ++ core/port_scan.go | 2 +- pkg/fscan/scanner.go | 1 + pkg/fscan/types.go | 7 ++++--- web/api/scan.go | 6 ++++-- 9 files changed, 20 insertions(+), 7 deletions(-) diff --git a/common/config_struct.go b/common/config_struct.go index 87d13ba..e40323c 100644 --- a/common/config_struct.go +++ b/common/config_struct.go @@ -32,6 +32,7 @@ type Config struct { DisableBrute bool // 禁用暴力破解 DisablePing bool // 禁用Ping检测 DisableTcpProbe bool // 禁用TCP补充探测 + DisableSubnetProbe bool // 禁用网段预筛 // 扫描模式 Mode string // 扫描模式 @@ -200,7 +201,8 @@ func NewConfig() *Config { ModuleThreadNum: 10, DisableBrute: false, DisablePing: false, - DisableTcpProbe: false, + DisableTcpProbe: false, + DisableSubnetProbe: false, // 扫描模式 Mode: DefaultScanMode, diff --git a/common/flag.go b/common/flag.go index f6bfd2e..a3f24d7 100644 --- a/common/flag.go +++ b/common/flag.go @@ -113,6 +113,7 @@ func Flag(Info *HostInfo) error { flag.Int64Var(&fv.GlobalTimeout, "gt", 180, i18n.GetText("flag_global_timeout")) flag.BoolVar(&fv.DisablePing, "np", false, i18n.GetText("flag_disable_ping")) flag.BoolVar(&fv.DisableTcpProbe, "ntp", false, i18n.GetText("flag_disable_tcp_probe")) + flag.BoolVar(&fv.DisableSubnetProbe, "nsp", false, i18n.GetText("flag_disable_subnet_probe")) flag.StringVar(&fv.LocalPlugin, "local", "", i18n.GetText("flag_local_plugin")) flag.BoolVar(&fv.AliveOnly, "ao", false, i18n.GetText("flag_alive_only")) diff --git a/common/flag_config.go b/common/flag_config.go index b2b4d3a..79c23da 100644 --- a/common/flag_config.go +++ b/common/flag_config.go @@ -41,6 +41,7 @@ type FlagVars struct { GlobalTimeout int64 DisablePing bool DisableTcpProbe bool + DisableSubnetProbe bool LocalPlugin string AliveOnly bool DisableBrute bool @@ -150,6 +151,7 @@ func BuildConfigFromFlags(fv *FlagVars) *Config { DisableBrute: fv.DisableBrute, DisablePing: fv.DisablePing, DisableTcpProbe: fv.DisableTcpProbe, + DisableSubnetProbe: fv.DisableSubnetProbe, // 扫描模式 Mode: fv.ScanMode, diff --git a/common/i18n/locales/en.yaml b/common/i18n/locales/en.yaml index c308cf6..8916b6b 100644 --- a/common/i18n/locales/en.yaml +++ b/common/i18n/locales/en.yaml @@ -30,6 +30,8 @@ flag_disable_ping: other: "Disable ping detection" flag_disable_tcp_probe: other: "Disable TCP supplementary probe" +flag_disable_subnet_probe: + other: "Disable subnet pre-filter (optimization that skips empty /24 subnets in large scans)" flag_local_plugin: other: "Specify local plugin name (e.g.: cleaner, systeminfo, keylogger)" flag_debug: diff --git a/common/i18n/locales/zh.yaml b/common/i18n/locales/zh.yaml index 94eb728..c4ee421 100644 --- a/common/i18n/locales/zh.yaml +++ b/common/i18n/locales/zh.yaml @@ -30,6 +30,8 @@ flag_disable_ping: other: "禁用ping探测" flag_disable_tcp_probe: other: "禁用TCP补充探测" +flag_disable_subnet_probe: + other: "禁用网段预筛(大规模扫描时跳过空 /24 网段的优化)" flag_local_plugin: other: "指定本地插件名称 (如: cleaner, systeminfo, keylogger 等)" flag_debug: diff --git a/core/port_scan.go b/core/port_scan.go index 05e2ba9..9a95820 100644 --- a/core/port_scan.go +++ b/core/port_scan.go @@ -147,7 +147,7 @@ func EnhancedPortScan(ctx context.Context, hosts []string, ports string, timeout session.LogDebug(i18n.Tr("port_scan_debug_start", len(hosts), config.ThreadNum)) // 大规模扫描预筛:跨多个 /24 时先做网段探活,跳过空网段 - if len(hosts) > subnetProbeThreshold { + if !config.DisableSubnetProbe && len(hosts) > subnetProbeThreshold { hosts = probeSubnets(ctx, hosts, time.Duration(timeout)*time.Second, session) if len(hosts) == 0 { session.LogInfo(i18n.GetText("port_scan_no_alive_subnet")) diff --git a/pkg/fscan/scanner.go b/pkg/fscan/scanner.go index 9a397b7..c5845f6 100644 --- a/pkg/fscan/scanner.go +++ b/pkg/fscan/scanner.go @@ -411,6 +411,7 @@ func buildFlagVars(config Config, target Target) *common.FlagVars { GlobalTimeout: 180, DisablePing: config.DisablePing, DisableTcpProbe: config.DisableTCPProbe, + DisableSubnetProbe: config.DisableSubnetProbe, AliveOnly: false, DisableBrute: config.DisableBrute, MaxRetries: maxRetries, diff --git a/pkg/fscan/types.go b/pkg/fscan/types.go index 615e7ff..02428a7 100644 --- a/pkg/fscan/types.go +++ b/pkg/fscan/types.go @@ -133,9 +133,10 @@ type Config struct { ModuleThreads int MaxRetries int - DisablePing bool - DisableTCPProbe bool - DisableBrute bool + DisablePing bool + DisableTCPProbe bool + DisableSubnetProbe bool + DisableBrute bool Usernames []string Passwords []string diff --git a/web/api/scan.go b/web/api/scan.go index 320908d..6858f0b 100644 --- a/web/api/scan.go +++ b/web/api/scan.go @@ -38,8 +38,9 @@ type ScanRequest struct { ThreadNum int `json:"thread_num"` Timeout int `json:"timeout"` ModuleThreadNum int `json:"module_thread_num"` - DisablePing bool `json:"disable_ping"` - DisableBrute bool `json:"disable_brute"` + DisablePing bool `json:"disable_ping"` + DisableBrute bool `json:"disable_brute"` + DisableSubnetProbe bool `json:"disable_subnet_probe"` AliveOnly bool `json:"alive_only"` // 认证 @@ -199,6 +200,7 @@ func (h *ScanHandler) runScan(req ScanRequest) { } fv.DisablePing = req.DisablePing fv.DisableBrute = req.DisableBrute + fv.DisableSubnetProbe = req.DisableSubnetProbe fv.AliveOnly = req.AliveOnly fv.Username = req.Username fv.Password = req.Password From 49221225307d796045446cda3a24cc547997d4a5 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sat, 27 Jun 2026 16:17:56 +0800 Subject: [PATCH 08/13] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20#591=20POC=20?= =?UTF-8?q?=E5=AF=B9=20HTTPS=20=E7=AB=AF=E5=8F=A3=E8=AF=AF=E7=94=A8=20HTTP?= =?UTF-8?q?=20+=20#592=20=E7=A9=BA=E6=8C=87=E9=92=88=20panic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. buildTargetURL 对 443/8443 等已知 TLS 端口默认使用 https scheme, webtitle 触发 POC 扫描前将检测到的协议写回 info.URL, 避免对 HTTPS 服务发送 HTTP 请求导致 EOF 2. GetInfo 添加 probe nil 检查,防止探针初始化失败时空指针 panic 3. 删除 test-nuclei-example.yaml 测试模板,避免 robots.txt 误报 --- core/service_probe.go | 4 ++++ plugins/web/webtitle.go | 1 + webscan/pocs/test-nuclei-example.yaml | 26 -------------------------- webscan/web_scan.go | 15 ++++++++++++++- 4 files changed, 19 insertions(+), 27 deletions(-) delete mode 100644 webscan/pocs/test-nuclei-example.yaml diff --git a/core/service_probe.go b/core/service_probe.go index 693bbe0..6335cf3 100644 --- a/core/service_probe.go +++ b/core/service_probe.go @@ -392,6 +392,10 @@ func (i *Info) tryProbes(response []byte, probes []*Probe) bool { // GetInfo 分析响应数据并提取服务信息 func (i *Info) GetInfo(response []byte, probe *Probe) { + if probe == nil { + return + } + // 响应数据有效性检查 if len(response) <= 0 { common.LogDebug(i18n.GetText("service_probe_empty_response")) diff --git a/plugins/web/webtitle.go b/plugins/web/webtitle.go index 6c192ba..825e049 100644 --- a/plugins/web/webtitle.go +++ b/plugins/web/webtitle.go @@ -270,6 +270,7 @@ func (p *WebTitlePlugin) identifyFingerprintsMulti(ctx context.Context, info *co // 非全量模式下,基于指纹触发POC扫描 if !config.POC.Full && !config.POC.Disabled { + info.URL = baseURL p.triggerPocScan(ctx, info, fingerprints, config, session) } diff --git a/webscan/pocs/test-nuclei-example.yaml b/webscan/pocs/test-nuclei-example.yaml deleted file mode 100644 index e48b4f1..0000000 --- a/webscan/pocs/test-nuclei-example.yaml +++ /dev/null @@ -1,26 +0,0 @@ -id: test-nuclei-example -info: - name: Test Nuclei Example Template - author: fscan-dev - severity: info - description: | - This is a test template to demonstrate Nuclei format support in fscan. - It will be automatically converted to fscan format during loading. - reference: - - https://github.com/shadow1ng/fscan - -http: - - method: GET - path: - - "{{BaseURL}}/robots.txt" - - matchers: - - type: word - words: - - "User-agent" - - "Disallow" - condition: and - - - type: status - status: - - 200 diff --git a/webscan/web_scan.go b/webscan/web_scan.go index 84bfc1e..6dbdfb1 100644 --- a/webscan/web_scan.go +++ b/webscan/web_scan.go @@ -97,7 +97,11 @@ func WebScan(ctx context.Context, info *common.HostInfo, cfg *common.Config, ses func buildTargetURL(info *common.HostInfo) (string, error) { // 自动构建URL if info.URL == "" { - info.URL = protocolHTTP + net.JoinHostPort(info.Host, fmt.Sprint(info.Port)) + protocol := protocolHTTP + if isTLSPort(info.Port) { + protocol = protocolHTTPS + } + info.URL = protocol + net.JoinHostPort(info.Host, fmt.Sprint(info.Port)) } else if !hasProtocolPrefix(info.URL) { info.URL = protocolHTTP + normalizeSchemelessWebTarget(info.URL) } @@ -132,6 +136,15 @@ func hasProtocolPrefix(urlStr string) bool { return strings.HasPrefix(urlStr, protocolHTTP) || strings.HasPrefix(urlStr, protocolHTTPS) } +func isTLSPort(port int) bool { + switch port { + case 443, 8443, 4443, 9443: + return true + default: + return false + } +} + func normalizeSchemelessWebTarget(rawURL string) string { authority := rawURL suffix := "" From ed45d0ead5db1391cce45a577e64666d8eb0c87e Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sat, 27 Jun 2026 16:22:31 +0800 Subject: [PATCH 09/13] =?UTF-8?q?fix:=20=E7=BB=93=E6=9E=9C=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E4=B8=AD=20POC=20=E6=BC=8F=E6=B4=9E=E5=8F=AA=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=20vulnerable=20=E4=B8=8D=E6=98=BE=E7=A4=BA=E6=BC=8F?= =?UTF-8?q?=E6=B4=9E=E5=90=8D=E7=A7=B0=20(Closes=20#591)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POC 扫描结果存入 details["vulnerability_name"], 但 TXT/CSV/NDJSON 三种输出格式只读 details["vulnerability"], key 不匹配导致漏洞名丢失,退化为显示 status 字段 "vulnerable"。 三种 writer 统一兼容两种 key。 --- common/output/stdout_writer.go | 3 +++ common/output/writers.go | 11 ++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/common/output/stdout_writer.go b/common/output/stdout_writer.go index 56d6d54..5cc7d36 100644 --- a/common/output/stdout_writer.go +++ b/common/output/stdout_writer.go @@ -98,6 +98,9 @@ func (w *StdoutNDJSONWriter) flatten(r *ScanResult) *ndjsonRecord { rec.Title = strVal(d, "title") rec.URL = strVal(d, "url") rec.Vulnerability = strVal(d, "vulnerability") + if rec.Vulnerability == "" { + rec.Vulnerability = strVal(d, "vulnerability_name") + } rec.Username = strVal(d, "username") rec.Password = strVal(d, "password") rec.Plugin = strVal(d, "plugin") diff --git a/common/output/writers.go b/common/output/writers.go index 825226b..c0013eb 100644 --- a/common/output/writers.go +++ b/common/output/writers.go @@ -283,6 +283,9 @@ func (w *TXTWriter) formatVulnLine(result *ScanResult) string { } vuln := w.getDetailStr(result, "vulnerability") + if vuln == "" { + vuln = w.getDetailStr(result, "vulnerability_name") + } if vuln != "" { return fmt.Sprintf("%s %s", result.Target, vuln) } @@ -789,12 +792,18 @@ func formatFingerprints(value interface{}) string { func (w *CSVWriter) formatVulnRecord(result *ScanResult) []string { vulnType := "" + vulnName := result.Status if result.Details != nil { if t, ok := result.Details["type"].(string); ok { vulnType = t } + if v, ok := result.Details["vulnerability"].(string); ok && v != "" { + vulnName = v + } else if v, ok := result.Details["vulnerability_name"].(string); ok && v != "" { + vulnName = v + } } - return []string{result.Target, vulnType, result.Status} + return []string{result.Target, vulnType, vulnName} } // GetFormat 获取格式类型 From a3ccc2827b9675be79266187a1c627c28637dd31 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sat, 27 Jun 2026 16:35:18 +0800 Subject: [PATCH 10/13] =?UTF-8?q?fix:=20Telnet=20=E5=BC=B1=E5=8F=A3?= =?UTF-8?q?=E4=BB=A4=E8=AF=AF=E6=8A=A5=EF=BC=8CCisco=20MOTD=20=E6=A8=AA?= =?UTF-8?q?=E5=B9=85=E8=A7=A6=E5=8F=91=20shell=20prompt=20=E8=AF=AF?= =?UTF-8?q?=E5=88=A4=20(Closes=20#590)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isShellPrompt 使用 Contains 匹配 # $ > 单字符,Cisco IOS MOTD 横幅中 的装饰线(###)和文本内容会误触发,导致未发送凭据就判定认证成功。 重写 isShellPrompt 改为行尾匹配,排除全同字符装饰线; performTelnetAuth 等待 login prompt 阶段移除 isShellPrompt 检查, 未授权检测由 testUnauthAccess 专门负责。 --- plugins/services/telnet.go | 49 ++++++++++++++++++++++++++------- plugins/services/telnet_test.go | 45 ++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 10 deletions(-) diff --git a/plugins/services/telnet.go b/plugins/services/telnet.go index 66259db..eeefd88 100644 --- a/plugins/services/telnet.go +++ b/plugins/services/telnet.go @@ -297,10 +297,6 @@ func (p *TelnetPlugin) performTelnetAuth(conn net.Conn, username, password strin cleaned := p.cleanResponse(response) cleanedLower := strings.ToLower(cleaned) - if p.isShellPrompt(cleaned) { - return true - } - if strings.Contains(cleanedLower, "login") || strings.Contains(cleanedLower, "username") || strings.Contains(cleaned, ":") { @@ -429,14 +425,47 @@ func (p *TelnetPlugin) isShellPrompt(data string) bool { return false } - data = strings.ToLower(strings.TrimSpace(data)) + data = strings.TrimSpace(data) - shellPrompts := []string{"$", "#", ">", "~$", "]$", ")#", "bash", "shell", "cmd"} - - for _, prompt := range shellPrompts { - if strings.Contains(data, prompt) { - return true + for _, line := range strings.Split(data, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue } + + lineLower := strings.ToLower(line) + + // 关键字匹配(整行包含即可) + for _, kw := range []string{"bash", "shell", "cmd"} { + if strings.Contains(lineLower, kw) { + return true + } + } + + // 行尾 prompt 符号匹配:取最后一个非空格字符 + trimmed := strings.TrimRight(line, " ") + if len(trimmed) == 0 { + continue + } + tail := trimmed[len(trimmed)-1] + + if tail != '#' && tail != '$' && tail != '>' { + continue + } + + // 排除装饰线:整行都是同一个字符(如 #### 或 >>>>) + allSame := true + for _, c := range trimmed { + if byte(c) != tail { + allSame = false + break + } + } + if allSame { + continue + } + + return true } return false diff --git a/plugins/services/telnet_test.go b/plugins/services/telnet_test.go index 8ccaeeb..3885c1f 100644 --- a/plugins/services/telnet_test.go +++ b/plugins/services/telnet_test.go @@ -37,3 +37,48 @@ func TestClassifyTelnetErrorType(t *testing.T) { }) } } + +func TestIsShellPrompt(t *testing.T) { + p := NewTelnetPlugin() + + positive := []struct { + name, data string + }{ + {"linux root", "root@host:~#"}, + {"linux user", "user@host:~$"}, + {"cisco", "Router>"}, + {"cisco enable", "Router#"}, + {"bracket prompt", "[admin@host ~]$"}, + {"paren prompt", "host(config)#"}, + {"bash keyword", "bash-4.2$"}, + {"trailing space", "root@host:~# "}, + {"multiline last", "Welcome\nroot@host:~#"}, + } + + negative := []struct { + name, data string + }{ + {"empty", ""}, + {"decoration hashes", "################"}, + {"decoration arrows", ">>>>>>>>"}, + {"decoration dollars", "$$$$$$$$"}, + {"cisco motd border", "###################################################"}, + {"motd with hash mid", "# Welcome to Cisco IOS"}, + {"plain text", "Cisco IOS Software, Version 12.2"}, + {"login prompt", "Login:"}, + {"password prompt", "Password:"}, + {"motd multiline", "##########\nWelcome to Router\n##########"}, + } + + for _, tt := range positive { + if !p.isShellPrompt(tt.data) { + t.Errorf("isShellPrompt(%q) = false, want true [%s]", tt.data, tt.name) + } + } + + for _, tt := range negative { + if p.isShellPrompt(tt.data) { + t.Errorf("isShellPrompt(%q) = true, want false [%s]", tt.data, tt.name) + } + } +} From 19805040076d0f648def02edcb03d880151c4aac Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sat, 27 Jun 2026 16:42:32 +0800 Subject: [PATCH 11/13] =?UTF-8?q?fix:=20=E5=A4=A7=E8=A7=84=E6=A8=A1?= =?UTF-8?q?=E6=89=AB=E6=8F=8F=E5=85=A8=E5=B1=80=E8=B6=85=E6=97=B6=E8=BF=87?= =?UTF-8?q?=E7=9F=AD=E5=AF=BC=E8=87=B4=E6=8F=90=E5=89=8D=E7=BB=88=E6=AD=A2?= =?UTF-8?q?=20(#588)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4 万 IP 全端口扫描默认 -gt 180s 完全不够用,3 分钟后报 "解析目标失败: context deadline exceeded" 误导用户。 1. 自适应全局超时:用户未显式指定 -gt 时,根据端口数和是否有 hosts 文件自动调大超时(最高 24h),并输出调整日志 2. 修正超时错误信息:context deadline exceeded 不再包装为 "解析目标失败",改为提示用户调大 -gt 或设为 0 禁用 --- common/config_struct.go | 3 ++- common/flag.go | 2 ++ common/flag_config.go | 4 +++- common/i18n/locales/en.yaml | 4 ++++ common/i18n/locales/zh.yaml | 4 ++++ core/alive_scanner.go | 5 +++++ core/scanner.go | 39 +++++++++++++++++++++++++++++++++++++ core/service_scanner.go | 5 +++++ 8 files changed, 64 insertions(+), 2 deletions(-) diff --git a/common/config_struct.go b/common/config_struct.go index e40323c..f7f0b08 100644 --- a/common/config_struct.go +++ b/common/config_struct.go @@ -63,7 +63,8 @@ type Config struct { Target TargetConfig // 扫描目标配置 // 全局超时 - GlobalTimeout time.Duration + GlobalTimeout time.Duration + GlobalTimeoutExplicit bool // SOCKS5代理端口配置 Socks5ProxyPort int // SOCKS5代理端口 diff --git a/common/flag.go b/common/flag.go index a3f24d7..85ef4fa 100644 --- a/common/flag.go +++ b/common/flag.go @@ -226,6 +226,8 @@ func Flag(Info *HostInfo) error { fv.ModuleThreadNumExplicit = true case "retry": fv.MaxRetriesExplicit = true + case "gt": + fv.GlobalTimeoutExplicit = true case "icmp-rate": fv.ICMPRateExplicit = true case "num": diff --git a/common/flag_config.go b/common/flag_config.go index 79c23da..160e3df 100644 --- a/common/flag_config.go +++ b/common/flag_config.go @@ -39,6 +39,7 @@ type FlagVars struct { TimeoutSec int64 // 秒,需转换为 time.Duration TimeoutExplicit bool GlobalTimeout int64 + GlobalTimeoutExplicit bool DisablePing bool DisableTcpProbe bool DisableSubnetProbe bool @@ -171,7 +172,8 @@ func BuildConfigFromFlags(fv *FlagVars) *Config { DefaultMap: cloneStringSlice(config.DefaultProbeMap), // 全局超时 - GlobalTimeout: time.Duration(fv.GlobalTimeout) * time.Second, + GlobalTimeout: time.Duration(fv.GlobalTimeout) * time.Second, + GlobalTimeoutExplicit: fv.GlobalTimeoutExplicit, // SOCKS5代理端口 Socks5ProxyPort: fv.Socks5ProxyPort, diff --git a/common/i18n/locales/en.yaml b/common/i18n/locales/en.yaml index 8916b6b..7e7d2ac 100644 --- a/common/i18n/locales/en.yaml +++ b/common/i18n/locales/en.yaml @@ -26,6 +26,10 @@ flag_module_thread_num: other: "Module thread count" flag_global_timeout: other: "Global timeout" +global_timeout_adjusted: + other: "Large scan detected, global timeout adjusted from {{.V0}}s to {{.V1}}s (use -gt to override)" +global_timeout_exceeded: + other: "Global timeout reached (-gt {{.V0}}s), scan aborted. Use -gt to increase or set to 0 to disable" flag_disable_ping: other: "Disable ping detection" flag_disable_tcp_probe: diff --git a/common/i18n/locales/zh.yaml b/common/i18n/locales/zh.yaml index c4ee421..9e40f41 100644 --- a/common/i18n/locales/zh.yaml +++ b/common/i18n/locales/zh.yaml @@ -26,6 +26,10 @@ flag_module_thread_num: other: "模块线程数" flag_global_timeout: other: "全局超时时间" +global_timeout_adjusted: + other: "扫描规模较大,全局超时从 {{.V0}}s 自动调整为 {{.V1}}s(可用 -gt 手动指定)" +global_timeout_exceeded: + other: "全局超时已到(-gt {{.V0}}s),扫描被终止。大规模扫描请用 -gt 调大超时或设为 0 禁用" flag_disable_ping: other: "禁用ping探测" flag_disable_tcp_probe: diff --git a/core/alive_scanner.go b/core/alive_scanner.go index 1d2f250..6ad8a99 100644 --- a/core/alive_scanner.go +++ b/core/alive_scanner.go @@ -88,6 +88,11 @@ func (s *AliveScanStrategy) performAliveScan(ctx context.Context, info common.Ho for { hosts, err := iter.NextBatch(ctx, targetHostBatchSize(session.Config)) if err != nil { + if ctx.Err() != nil { + session.LogError(i18n.Tr("global_timeout_exceeded", + int(session.Config.GlobalTimeout.Seconds()))) + return + } session.LogError(i18n.Tr("parse_target_failed", err)) return } diff --git a/core/scanner.go b/core/scanner.go index dd1bc9a..4f1e76f 100644 --- a/core/scanner.go +++ b/core/scanner.go @@ -13,6 +13,7 @@ import ( "github.com/shadow1ng/fscan/common" "github.com/shadow1ng/fscan/common/i18n" "github.com/shadow1ng/fscan/common/output" + "github.com/shadow1ng/fscan/common/parsers" "github.com/shadow1ng/fscan/plugins" "github.com/shadow1ng/fscan/webscan/lib" ) @@ -96,6 +97,15 @@ func RunScan(ctx context.Context, info common.HostInfo, session *common.ScanSess start := time.Now() config := session.Config + // 全局超时自适应:用户未显式指定 -gt 时,根据扫描规模自动调大 + if !config.GlobalTimeoutExplicit && config.GlobalTimeout > 0 { + if adjusted := estimateGlobalTimeout(config, session); adjusted > config.GlobalTimeout { + session.LogInfo(i18n.Tr("global_timeout_adjusted", + int(config.GlobalTimeout.Seconds()), int(adjusted.Seconds()))) + config.GlobalTimeout = adjusted + } + } + // 全局超时:-gt 参数设置整个扫描的硬性截止时间 var cancel context.CancelFunc if config.GlobalTimeout > 0 { @@ -489,3 +499,32 @@ func addCommonDetails(result *plugins.Result, details map[string]interface{}) { details["server"] = result.Server } } + +func estimateGlobalTimeout(config *common.Config, session *common.ScanSession) time.Duration { + portCount := len(parsers.ParsePort(config.Target.Ports)) + if portCount == 0 { + portCount = len(parsers.ParsePort("21,22,80,443,445,1433,3306,3389,6379,8080")) + } + + hasHostFile := session.Params != nil && session.Params.HostsFile != "" + + // 启发式:端口数越多、有文件输入(目标可能很多),超时越大 + switch { + case portCount > 10000 && hasHostFile: + return 24 * time.Hour + case portCount > 10000: + return 6 * time.Hour + case portCount > 1000 && hasHostFile: + return 6 * time.Hour + case portCount > 1000: + return 1 * time.Hour + case portCount > 100 && hasHostFile: + return 1 * time.Hour + case portCount > 100: + return 30 * time.Minute + case hasHostFile: + return 30 * time.Minute + default: + return config.GlobalTimeout + } +} diff --git a/core/service_scanner.go b/core/service_scanner.go index 320f271..4c62c92 100644 --- a/core/service_scanner.go +++ b/core/service_scanner.go @@ -161,6 +161,11 @@ func (s *ServiceScanStrategy) performHostScan(ctx context.Context, session *comm for { hosts, err := iter.NextBatch(ctx, targetHostBatchSize(config)) if err != nil { + if ctx.Err() != nil { + session.LogError(i18n.Tr("global_timeout_exceeded", + int(config.GlobalTimeout.Seconds()))) + return + } session.LogError(fmt.Sprintf("%s: %v", i18n.GetText("parse_target_failed"), err)) return } From 075bf646dcf18bd2741759bb79d38c16eee98e84 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sat, 27 Jun 2026 16:50:53 +0800 Subject: [PATCH 12/13] =?UTF-8?q?fix:=20=E5=85=A8=E5=B1=80=E8=B6=85?= =?UTF-8?q?=E6=97=B6=E6=94=B9=E4=B8=BA=E5=8A=A8=E6=80=81=E4=BC=B0=E7=AE=97?= =?UTF-8?q?=EF=BC=8C=E6=9B=BF=E4=BB=A3=E7=A1=AC=E7=BC=96=E7=A0=81=E9=98=88?= =?UTF-8?q?=E5=80=BC=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根据 hostCount × portCount / threads 计算端口扫描耗时, 结合开放率估算插件扫描耗时,加 20% 余量,上限 2h。 新增 EstimateHostCount 快速统计 CIDR/range/文件中的主机数。 --- common/parsers/host_iterator.go | 87 +++++++++++++++++++++++++++++++++ core/scanner.go | 64 +++++++++++++++--------- 2 files changed, 129 insertions(+), 22 deletions(-) diff --git a/common/parsers/host_iterator.go b/common/parsers/host_iterator.go index f937462..654cef6 100644 --- a/common/parsers/host_iterator.go +++ b/common/parsers/host_iterator.go @@ -488,3 +488,90 @@ func ipToUint32(ip net.IP) (uint32, bool) { func uint32ToIP(v uint32) string { return fmt.Sprintf("%d.%d.%d.%d", byte(v>>24), byte(v>>16), byte(v>>8), byte(v)) } + +// EstimateHostCount 快速估算主机总数(不消费 iterator) +func EstimateHostCount(host string, filename string) int64 { + var total int64 + + if filename != "" { + if f, err := os.Open(filename); err == nil { + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + total += estimateHostEntry(line) + } + _ = f.Close() + } + } + + for _, h := range strings.Split(host, ",") { + h = strings.TrimSpace(h) + if h != "" { + total += estimateHostEntry(h) + } + } + + return total +} + +func estimateHostEntry(entry string) int64 { + switch { + case entry == "192": + return 65536 // /16 + case entry == "172": + return 1 << 20 // /12 + case entry == "10": + return 1 << 24 // /8 + case strings.Contains(entry, "/"): + _, ipNet, err := net.ParseCIDR(entry) + if err != nil { + return 1 + } + ones, bits := ipNet.Mask.Size() + if bits != 32 { + return 1 + } + size := int64(1) << uint(32-ones) + if size > 2 { + size -= 2 + } + return size + case strings.Contains(entry, "-") && !strings.Contains(entry, ":") && looksLikeIPRange(entry): + parts := strings.SplitN(entry, "-", 2) + startIP := net.ParseIP(strings.TrimSpace(parts[0])) + if startIP == nil { + return 1 + } + startU, ok := ipToUint32(startIP) + if !ok { + return 1 + } + endStr := strings.TrimSpace(parts[1]) + var endU uint32 + if len(endStr) < 4 || !strings.Contains(endStr, ".") { + n, err := strconv.Atoi(endStr) + if err != nil || n > 255 { + return 1 + } + endU = (startU & 0xFFFFFF00) | uint32(n) + } else { + endIP := net.ParseIP(endStr) + if endIP == nil { + return 1 + } + endU, ok = ipToUint32(endIP) + if !ok { + return 1 + } + } + if endU < startU { + return 1 + } + return int64(endU-startU) + 1 + default: + return 1 + } +} diff --git a/core/scanner.go b/core/scanner.go index 4f1e76f..5a20436 100644 --- a/core/scanner.go +++ b/core/scanner.go @@ -501,30 +501,50 @@ func addCommonDetails(result *plugins.Result, details map[string]interface{}) { } func estimateGlobalTimeout(config *common.Config, session *common.ScanSession) time.Duration { - portCount := len(parsers.ParsePort(config.Target.Ports)) + portCount := int64(len(parsers.ParsePort(config.Target.Ports))) if portCount == 0 { - portCount = len(parsers.ParsePort("21,22,80,443,445,1433,3306,3389,6379,8080")) + portCount = 10 } - hasHostFile := session.Params != nil && session.Params.HostsFile != "" - - // 启发式:端口数越多、有文件输入(目标可能很多),超时越大 - switch { - case portCount > 10000 && hasHostFile: - return 24 * time.Hour - case portCount > 10000: - return 6 * time.Hour - case portCount > 1000 && hasHostFile: - return 6 * time.Hour - case portCount > 1000: - return 1 * time.Hour - case portCount > 100 && hasHostFile: - return 1 * time.Hour - case portCount > 100: - return 30 * time.Minute - case hasHostFile: - return 30 * time.Minute - default: - return config.GlobalTimeout + var hostFile string + var hostStr string + if session.Params != nil { + hostFile = session.Params.HostsFile + hostStr = session.Params.Host } + hostCount := parsers.EstimateHostCount(hostStr, hostFile) + if hostCount <= 0 { + hostCount = 1 + } + + totalTasks := hostCount * portCount + threads := int64(config.ThreadNum) + if threads <= 0 { + threads = 600 + } + + // 端口扫描:平均每个任务约 50ms(大部分连接快速失败) + portScanSec := float64(totalTasks) * 0.05 / float64(threads) + + // 插件扫描:开放率随端口数下降(全端口约 0.1%,少量端口约 5%) + openRate := 0.05 + if portCount > 1000 { + openRate = 0.002 + } else if portCount > 100 { + openRate = 0.01 + } + moduleThreads := float64(config.ModuleThreadNum) + if moduleThreads <= 0 { + moduleThreads = 20 + } + pluginSec := float64(totalTasks) * openRate * 2.0 / moduleThreads + // 总估算 + 20% 余量 + estimatedSec := (portScanSec + pluginSec) * 1.2 + + const maxTimeout = 2 * time.Hour + estimated := time.Duration(estimatedSec) * time.Second + if estimated > maxTimeout { + estimated = maxTimeout + } + return estimated } From fdf836f0032dc0976fc5b3fa44b7d77da7fae6cb Mon Sep 17 00:00:00 2001 From: ZacharyZcR <2903735704@qq.com> Date: Thu, 9 Jul 2026 20:41:10 +0800 Subject: [PATCH 13/13] chore: prepare v2.2.0 release --- .github/ISSUE_TEMPLATE/bug_report.yml | 3 +- .github/ISSUE_TEMPLATE/false_positive.yml | 3 +- .github/RELEASE.md | 21 ++-- .github/conf/.goreleaser.yml | 22 ++-- .github/release-notes/v2.2.0.md | 129 ++++++++++++++++++++++ .github/workflows/release.yml | 81 +++++++++++++- README.md | 2 +- README_EN.md | 2 +- common/globals.go | 2 +- 9 files changed, 236 insertions(+), 29 deletions(-) create mode 100644 .github/release-notes/v2.2.0.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 93c4609..09dc1de 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -72,7 +72,8 @@ body: attributes: label: fscan 版本 options: - - 2.2.0-rc (dev) + - 2.2.0 + - 2.2.0-rc - 2.1.3 - 2.1.2 - 2.1.0 diff --git a/.github/ISSUE_TEMPLATE/false_positive.yml b/.github/ISSUE_TEMPLATE/false_positive.yml index 883903d..d201dec 100644 --- a/.github/ISSUE_TEMPLATE/false_positive.yml +++ b/.github/ISSUE_TEMPLATE/false_positive.yml @@ -94,7 +94,8 @@ body: attributes: label: fscan 版本 options: - - 2.2.0-rc (dev) + - 2.2.0 + - 2.2.0-rc - 2.1.3 - 2.1.2 - 2.1.0 diff --git a/.github/RELEASE.md b/.github/RELEASE.md index be3d4fb..0711d12 100644 --- a/.github/RELEASE.md +++ b/.github/RELEASE.md @@ -12,6 +12,7 @@ gh workflow run release.yml -f snapshot=true # 3. 确认版本号一致 grep "version" common/globals.go grep "版本" README.md +grep "Version" README_EN.md ``` ## 发版 @@ -20,7 +21,7 @@ grep "版本" README.md # 1. 确认 release notes 已就绪 cat .github/release-notes/v.md -# 2. 打 tag(在 dev 分支打 RC,在 main 分支打正式版) +# 2. 打 tag(RC 手动打;正式版合并到 main 后由 CI 自动打 tag) git tag v git push origin v @@ -47,18 +48,14 @@ git push origin v ## 正式版发布(RC → 正式) ```bash -# 1. 合并 dev 到 main -git checkout main -git merge dev -git push - -# 2. 更新版本号去掉 -rc +# 1. 在 dev 分支准备正式版内容 # common/globals.go, README.md, README_EN.md - -# 3. 准备正式版 release notes # .github/release-notes/v2.2.0.md -# 4. 打 tag -git tag v2.2.0 -git push origin v2.2.0 +# 2. 创建 dev -> main PR +gh pr create --base main --head dev + +# 3. 合并 PR +# main push 会自动读取 common/globals.go 中的版本号,创建 v tag +# tag push 会触发 GoReleaser 构建并创建 GitHub Release ``` diff --git a/.github/conf/.goreleaser.yml b/.github/conf/.goreleaser.yml index 18ebff3..426b6d7 100644 --- a/.github/conf/.goreleaser.yml +++ b/.github/conf/.goreleaser.yml @@ -1,3 +1,5 @@ +version: 2 + project_name: "fscan" before: @@ -135,17 +137,17 @@ builds: upx: - ids: [fscan, fscan-nolocal, fscan-web] enabled: true - goos: [windows, linux, freebsd] - goarch: [amd64, "386", arm, arm64, mips, mipsle] - compress: best + goos: [windows, linux] + goarch: [amd64, "386", arm64] + compress: "6" brute: false lzma: false archives: # 标准版归档 - id: fscan - builds: [fscan] - format: binary + ids: [fscan] + formats: [binary] allow_different_binary_count: true name_template: >- fscan_{{ .Version }}_ @@ -158,8 +160,8 @@ archives: # 无本地插件版归档 - id: fscan-nolocal - builds: [fscan-nolocal] - format: binary + ids: [fscan-nolocal] + formats: [binary] allow_different_binary_count: true name_template: >- fscan-nolocal_{{ .Version }}_ @@ -172,8 +174,8 @@ archives: # WebUI版归档 - id: fscan-web - builds: [fscan-web] - format: binary + ids: [fscan-web] + formats: [binary] allow_different_binary_count: true name_template: >- fscan-web_{{ .Version }}_ @@ -238,7 +240,7 @@ release: **完整更新日志**: https://github.com/{{ .Env.GITHUB_OWNER }}/{{ .Env.GITHUB_REPO }}/compare/{{ .PreviousTag }}...{{ .Tag }} snapshot: - name_template: "{{ incpatch .Version }}-dev-{{ .ShortCommit }}" + version_template: "{{ incpatch .Version }}-dev-{{ .ShortCommit }}" metadata: mod_timestamp: "{{ .CommitTimestamp }}" diff --git a/.github/release-notes/v2.2.0.md b/.github/release-notes/v2.2.0.md new file mode 100644 index 0000000..0a7ace6 --- /dev/null +++ b/.github/release-notes/v2.2.0.md @@ -0,0 +1,129 @@ +# fscan v2.2.0 + +v2.2.0 是 v2.2 系列首个正式版,基于 v2.1.3 之后的 RC 测试和 Issue 反馈整理发布。 + +本版本重点提升大规模扫描稳定性、POC 扫描可靠性、非标准端口服务识别、插件隔离和嵌入式 SDK 能力。 + +--- + +## 重点变化 + +### 嵌入式 Scanner SDK + +新增 `pkg/fscan`,fscan 从纯 CLI 工具扩展为可嵌入的 Go 扫描引擎: + +- 支持在 Go 程序内直接调用扫描能力 +- Scanner 实例拥有独立 `config` / `state` / `session` +- 全局状态迁移到 session,改善多实例并发隔离 +- 补充 SDK 结果转换、配置校验和并发扫描测试 + +### 大规模扫描稳定性 + +- 新增流式 Host Iterator,大 CIDR 不再一次性展开到内存 +- 移除 MaxHosts 硬限制,大网段不再被静默截断 +- 新增自适应并发调度,基于 RTT、丢包率、fd limit 自动推导扫描参数 +- 线程池升级为 AIMD + 慢启动,遇到资源耗尽时自动降速 +- `-gt` 全局超时正式生效,超时后会取消扫描任务 +- 新增 `-nsp`,可禁用网段预筛 + +### 服务识别与插件调度 + +- 修复非标准端口服务无法匹配插件的问题 +- 新增服务缓存和指纹驱动插件匹配 +- `-full` 模式下 Web 插件可覆盖所有开放端口 +- 不确定服务增加 HTTP 回退探测 +- 移除误导性的“无可用插件”日志 +- 用户指定 `-p` 时 UDP 插件按端口交集正确调度 + +### Web / POC 扫描 + +- 修复默认扫描 POC 结果缺失 +- 修复 `-hf` 批量扫描时 POC 缺失 +- 修复 HTTPS 端口误用 HTTP 扫描 POC +- 修复 POC 结果文件只显示 `vulnerable` 不显示漏洞名称 +- POC 加载按 `pocpath` 隔离缓存,多 session 不再互相覆盖 +- 修复 CEL、reverseCheck、正则缓存等稳定性问题 +- `-nopoc` 禁用 POC 时不再输出误导性错误日志 + +### 新增协议插件 + +新增多种原生协议插件,覆盖邮件、Java 调试、文件共享、带外管理、UDP 和工控场景: + +| 插件 | 用途 | +|------|------| +| IMAP / POP3 | 邮件服务器检测 | +| JDWP | Java Debug 端口检测 | +| NFS / RMI | 文件共享 / Java 远程调用 | +| IPMI | 服务器带外管理 | +| SNMP / DNS / BACnet / Modbus | 网络设备、DNS、工控协议检测 | + +### Web 版 + +- 拆分 CLI / Web 入口 +- Web 版结果存储改为 SQLite 持久化 +- Web API 版本号改为动态读取 + +--- + +## Bug 修复摘要 + +- 修复 #586 默认扫描 POC 结果缺失 +- 修复 #587 `-hf` 批量扫描 POC 缺失 +- 修复 #588 非标准端口服务插件匹配问题 +- 修复 #590 Telnet Cisco MOTD 横幅误判 shell prompt +- 修复 #591 HTTPS POC 协议错误与结果名称缺失 +- 修复 #592 service probe 空指针 panic +- 修复 #593 `-ehf` 排除主机未生效,支持 IP / CIDR / range +- 修复 UDP 插件阻塞导致扫描无法结束 +- 修复 SSH goroutine 泄漏和握手 deadline 问题 +- 修复 Redis exploit 超时和非超时错误处理 +- 修复 MongoDB SCRAM、Cassandra、Oracle 等协议问题 +- 修复 SOCKS5 代理认证、LM:NT hash、逗号分隔密码等参数问题 +- 修复非终端输出 ANSI 控制码覆盖结果 +- 修复 CSV / NDJSON / TXT 输出若干字段问题 +- 修复 ARM 32 位原子计数器对齐问题 + +--- + +## 升级注意 + +- WebUI 仍建议视为实验性能力 +- 本地后渗透插件仅用于授权环境 +- v2.2.0 改动较大,建议从 v2.1.3 升级的用户先在测试环境验证扫描参数 +- 如依赖旧版本输出格式,请重点检查 POC、SERVICE、VULN 结果字段 + +--- + +## 版本说明 + +| 版本 | 说明 | +|------|------| +| **fscan** | 标准版,包含全部插件(推荐) | +| **fscan-nolocal** | 精简版,不含本地模块(体积更小) | +| **fscan-web** | WebUI 版,带 Web 管理界面(主流平台) | + +## 平台支持 + +| 平台 | 架构 | +|------|------| +| Linux | x64, x32, arm64, armv5/6/7, mips, mips64, mipsle | +| Windows | x64, x32 | +| macOS | x64, arm64 | +| FreeBSD | x64, x32, arm64, armv5/6/7 | +| Solaris | x64 | + +--- + +## 校验 + +本版本已通过: + +- `go test ./...` +- 近期 Issue 回归验证 +- 本地 HTTP / HTTPS POC 扫描验证 +- `-hf` 批量 POC 扫描验证 +- `-ehf` IP / CIDR 排除验证 + +完整变更记录见: + +https://github.com/shadow1ng/fscan/compare/v2.1.3...v2.2.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e7b8dc7..cc149e2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,6 +2,8 @@ name: 发布 on: push: + branches: + - main tags: - 'v*' workflow_dispatch: @@ -22,7 +24,60 @@ env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: + auto-tag: + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + tag: ${{ steps.version.outputs.tag }} + + steps: + - name: 检出代码 + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: 读取版本号 + id: version + shell: bash + run: | + VERSION=$(sed -n 's/^[[:space:]]*version = "\(.*\)"/\1/p' common/globals.go) + if [ -z "$VERSION" ]; then + echo "❌ 无法从 common/globals.go 读取版本号" + exit 1 + fi + TAG="v${VERSION}" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "准备发布 ${TAG}" + + - name: 创建发布标签 + shell: bash + run: | + TAG="${{ steps.version.outputs.tag }}" + + if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/tmp/tag-ref 2>/dev/null; then + git fetch --force origin "refs/tags/${TAG}:refs/tags/${TAG}" + TAG_COMMIT=$(git rev-list -n 1 "${TAG}") + HEAD_COMMIT=$(git rev-parse HEAD) + if [ "$TAG_COMMIT" = "$HEAD_COMMIT" ]; then + echo "✅ ${TAG} 已指向当前提交,跳过创建" + exit 0 + fi + echo "❌ ${TAG} 已存在,但不指向当前提交" + echo "tag: ${TAG_COMMIT}" + echo "head: ${HEAD_COMMIT}" + exit 1 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "${TAG}" -m "Release ${TAG}" + git push origin "${TAG}" + release: + needs: [auto-tag] + if: ${{ always() && (startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.auto-tag.result == 'success')) }} runs-on: ubuntu-latest timeout-minutes: 90 @@ -32,10 +87,32 @@ jobs: with: fetch-depth: 0 + - name: 解析发布标签 + id: release_tag + shell: bash + env: + AUTO_TAG: ${{ needs.auto-tag.outputs.tag }} + SNAPSHOT: ${{ inputs.snapshot }} + run: | + if [[ "${GITHUB_REF}" == refs/tags/* ]]; then + TAG="${GITHUB_REF_NAME}" + elif [ "${GITHUB_EVENT_NAME}" = "push" ] && [ "${GITHUB_REF}" = "refs/heads/main" ]; then + TAG="${AUTO_TAG}" + git fetch --force origin "refs/tags/${TAG}:refs/tags/${TAG}" + elif [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ] && [ "${SNAPSHOT}" = "true" ]; then + TAG="${GITHUB_REF_NAME}" + else + echo "❌ 非 snapshot 手动发布必须从 tag 触发" + exit 1 + fi + + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV" + - name: 准备 Release Notes if: ${{ !inputs.snapshot }} run: | - TAG="${GITHUB_REF_NAME}" + TAG="${RELEASE_TAG}" NOTES_FILE=".github/release-notes/${TAG}.md" if [ -f "$NOTES_FILE" ]; then @@ -61,7 +138,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - TAG="${GITHUB_REF_NAME}" + TAG="${RELEASE_TAG}" NOTES_FILE="${RELEASE_NOTES_FILE}" if [ -s "$NOTES_FILE" ]; then diff --git a/README.md b/README.md index 2564245..64ad461 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 内网综合扫描工具,一键自动化漏扫。 -**版本**: 2.2.0-rc.1 +**版本**: 2.2.0 ## 功能特性 diff --git a/README_EN.md b/README_EN.md index d55b95d..cbb55aa 100644 --- a/README_EN.md +++ b/README_EN.md @@ -4,7 +4,7 @@ Comprehensive intranet scanning tool for automated vulnerability assessment. -**Version**: 2.2.0-rc.1.1 +**Version**: 2.2.0 ## Features diff --git a/common/globals.go b/common/globals.go index 7a1626c..0ed94e7 100644 --- a/common/globals.go +++ b/common/globals.go @@ -69,7 +69,7 @@ const ( // 版本信息,通过 ldflags 注入 var ( - version = "2.2.0-rc.1" + version = "2.2.0" commit = "unknown" date = "unknown" )