From 5a8583a19570c83c850fa2b8f3ee3104fc4e5abb Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sat, 9 May 2026 13:58:55 +0800 Subject: [PATCH] fix csv web title output (#575) --- common/output/buffer.go | 25 +++++++++++++- common/output/buffer_test.go | 34 +++++++++++++++++++ common/output/writers.go | 63 +++++++++++++++++++++++++++++------ common/output/writers_test.go | 44 +++++++++++++++++++++++- 4 files changed, 154 insertions(+), 12 deletions(-) diff --git a/common/output/buffer.go b/common/output/buffer.go index 28b29f2..35efaae 100644 --- a/common/output/buffer.go +++ b/common/output/buffer.go @@ -59,7 +59,8 @@ func (b *ResultBuffer) Add(result *ScanResult) { b.seenServices[key] = len(b.ServiceResults) b.ServiceResults = append(b.ServiceResults, result) } else { - // 保留信息更完整的记录 + b.mergeDetails(b.ServiceResults[idx], result) + // 保留信息更完整的记录,同时保留另一条记录补充的字段 if b.isMoreComplete(result, b.ServiceResults[idx]) { b.ServiceResults[idx] = result } @@ -72,6 +73,28 @@ func (b *ResultBuffer) Add(result *ScanResult) { } } +func (b *ResultBuffer) mergeDetails(oldResult, newResult *ScanResult) { + if oldResult == nil || newResult == nil { + return + } + if oldResult.Details == nil { + oldResult.Details = make(map[string]interface{}) + } + if newResult.Details == nil { + newResult.Details = make(map[string]interface{}) + } + for k, v := range oldResult.Details { + if _, exists := newResult.Details[k]; !exists { + newResult.Details[k] = v + } + } + for k, v := range newResult.Details { + if _, exists := oldResult.Details[k]; !exists { + oldResult.Details[k] = v + } + } +} + // generateKey 生成结果的唯一键(用于去重) func (b *ResultBuffer) generateKey(result *ScanResult) string { switch result.Type { diff --git a/common/output/buffer_test.go b/common/output/buffer_test.go index d4d36ab..86872ed 100644 --- a/common/output/buffer_test.go +++ b/common/output/buffer_test.go @@ -226,6 +226,40 @@ func TestResultBuffer_ServiceUpdate(t *testing.T) { } } +func TestResultBuffer_ServiceUpdateMergesDetails(t *testing.T) { + buf := NewResultBuffer() + + buf.Add(&ScanResult{ + Type: TypeService, + Target: "192.168.1.1:80", + Status: "identified", + Details: map[string]interface{}{ + "service": "http", + "banner": "HTTP/1.1 200 OK", + }, + }) + buf.Add(&ScanResult{ + Type: TypeService, + Target: "192.168.1.1:80", + Status: "web", + Details: map[string]interface{}{ + "title": "Home", + "status": 200, + "server": "nginx", + }, + }) + + if len(buf.ServiceResults) != 1 { + t.Fatalf("期望1条服务记录,实际 %d", len(buf.ServiceResults)) + } + details := buf.ServiceResults[0].Details + for _, key := range []string{"service", "banner", "title", "status", "server"} { + if _, ok := details[key]; !ok { + t.Errorf("合并后的服务记录缺少字段 %q: %#v", key, details) + } + } +} + // TestResultBuffer_ServiceNoDowngrade 测试不降级服务记录 // // 当新记录不如旧记录完整时,不应替换 diff --git a/common/output/writers.go b/common/output/writers.go index ee1bf63..2accf6f 100644 --- a/common/output/writers.go +++ b/common/output/writers.go @@ -13,13 +13,26 @@ import ( // escapeControlChars 转义控制字符 func escapeControlChars(s string) string { - replacer := strings.NewReplacer( - "\r\n", "\\r\\n", - "\n", "\\n", - "\r", "\\r", - "\t", "\\t", - ) - return replacer.Replace(s) + s = strings.ToValidUTF8(s, "?") + + var b strings.Builder + for _, r := range s { + switch r { + case '\n': + b.WriteString("\\n") + case '\r': + b.WriteString("\\r") + case '\t': + b.WriteString("\\t") + default: + if r < 0x20 || r == 0x7f { + fmt.Fprintf(&b, "\\x%02x", r) + continue + } + b.WriteRune(r) + } + } + return b.String() } // ============================================================================= @@ -647,7 +660,7 @@ func (w *CSVWriter) Close() error { // 写入各分类 w.writeSection("# Hosts", []string{"Target"}, w.buffer.HostResults, w.formatHostRecord) w.writeSection("# Ports", []string{"Target", "Port", "Status"}, w.buffer.PortResults, w.formatPortRecord) - w.writeSection("# Services", []string{"Target", "Service", "Version", "Banner"}, w.buffer.ServiceResults, w.formatServiceRecord) + w.writeSection("# Services", []string{"Target", "Service", "Version", "Title", "Status", "Server", "Fingerprints", "Banner"}, w.buffer.ServiceResults, w.formatServiceRecord) w.writeSection("# Vulns", []string{"Target", "Type", "Details"}, w.buffer.VulnResults, w.formatVulnRecord) w.closed = true @@ -697,7 +710,7 @@ func (w *CSVWriter) formatPortRecord(result *ScanResult) []string { } func (w *CSVWriter) formatServiceRecord(result *ScanResult) []string { - service, version, banner := "", "", "" + service, version, title, status, server, fingerprints, banner := "", "", "", "", "", "", "" if result.Details != nil { if s, ok := result.Details["service"].(string); ok { service = s @@ -705,9 +718,22 @@ func (w *CSVWriter) formatServiceRecord(result *ScanResult) []string { if s, ok := result.Details["name"].(string); ok && service == "" { service = s } + if s, ok := result.Details["plugin"].(string); ok && service == "" { + service = s + } if v, ok := result.Details["version"].(string); ok { version = v } + if t, ok := result.Details["title"].(string); ok { + title = escapeControlChars(t) + } + if s, ok := result.Details["status"]; ok && s != nil && s != 0 { + status = fmt.Sprintf("%v", s) + } + if s, ok := result.Details["server"].(string); ok { + server = escapeControlChars(s) + } + fingerprints = formatFingerprints(result.Details["fingerprints"]) if b, ok := result.Details["banner"].(string); ok { banner = escapeControlChars(b) if len(banner) > 100 { @@ -721,7 +747,24 @@ func (w *CSVWriter) formatServiceRecord(result *ScanResult) []string { target = fmt.Sprintf("%s:%v", target, p) } } - return []string{target, service, version, banner} + return []string{target, service, version, title, status, server, fingerprints, banner} +} + +func formatFingerprints(value interface{}) string { + switch v := value.(type) { + case []string: + return strings.Join(v, ",") + case []interface{}: + parts := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok && s != "" { + parts = append(parts, s) + } + } + return strings.Join(parts, ",") + default: + return "" + } } func (w *CSVWriter) formatVulnRecord(result *ScanResult) []string { diff --git a/common/output/writers_test.go b/common/output/writers_test.go index d16fb2a..04036ff 100644 --- a/common/output/writers_test.go +++ b/common/output/writers_test.go @@ -1139,7 +1139,7 @@ func TestCSVWriter_ErrorHandling(t *testing.T) { // TestCSVWriter_DetailsFormatting 测试CSV的Details字段格式化 // // CSVWriter 对不同类型有不同的格式: -// - Service类型:Target, Service, Version, Banner +// - Service类型:Target, Service, Version, Title, Status, Server, Fingerprints, Banner func TestCSVWriter_DetailsFormatting(t *testing.T) { dir := createTestDir(t) filePath := filepath.Join(dir, "test.csv") @@ -1188,6 +1188,48 @@ func TestCSVWriter_DetailsFormatting(t *testing.T) { t.Logf("✓ CSV Details格式化测试通过") } +func TestCSVWriter_WebServiceFields(t *testing.T) { + dir := createTestDir(t) + filePath := filepath.Join(dir, "test.csv") + + writer, _ := NewCSVWriter(filePath) + defer func() { _ = writer.Close() }() + + _ = writer.WriteHeader() + result := createTestResult( + TypeService, + "192.168.1.1:80", + "web", + map[string]interface{}{ + "plugin": "webtitle", + "is_web": true, + "port": 80, + "title": "Home", + "status": 200, + "server": "nginx", + "fingerprints": []string{"nginx", "php"}, + "banner": "HTTP/1.1 200 OK\x00\nServer: nginx", + }, + ) + _ = writer.Write(result) + writer.Close() + + content := readFileContent(t, filePath) + for _, want := range []string{ + "Target,Service,Version,Title,Status,Server,Fingerprints,Banner", + "webtitle", + "Home", + "200", + "nginx", + "nginx,php", + "\\x00\\nServer: nginx", + } { + if !strings.Contains(content, want) { + t.Errorf("CSV文件缺少 %q,内容:\n%s", want, content) + } + } +} + // TestJSONWriter_FlushAndFormat 测试JSON的Flush和GetFormat func TestJSONWriter_FlushAndFormat(t *testing.T) { dir := createTestDir(t)