diff --git a/internal/scan/shodan.go b/internal/scan/shodan.go index 6e91c59..8d67cac 100644 --- a/internal/scan/shodan.go +++ b/internal/scan/shodan.go @@ -260,8 +260,8 @@ func truncateBanner(banner string, maxLen int) string { banner = strings.ReplaceAll(banner, "\r\n", " ") banner = strings.ReplaceAll(banner, "\n", " ") - if len(banner) > maxLen { - return banner[:maxLen] + "..." + if runes := []rune(banner); len(runes) > maxLen { + return string(runes[:maxLen]) + "..." } return banner } diff --git a/internal/scan/shodan_test.go b/internal/scan/shodan_test.go index a152b41..cebcbe2 100644 --- a/internal/scan/shodan_test.go +++ b/internal/scan/shodan_test.go @@ -18,6 +18,7 @@ import ( "net/http/httptest" "testing" "time" + "unicode/utf8" ) func TestResolveHostname_IP(t *testing.T) { @@ -50,6 +51,9 @@ func TestTruncateBanner(t *testing.T) { {"this is a long banner", 10, "this is a ..."}, {"with\nnewlines\r\n", 50, "with newlines"}, {" trimmed ", 50, "trimmed"}, + // truncation must land on a rune boundary, not split a multibyte rune + {"aaaé", 4, "aaaé"}, + {"日本語テスト", 3, "日本語..."}, } for _, tt := range tests { @@ -57,6 +61,9 @@ func TestTruncateBanner(t *testing.T) { if result != tt.expected { t.Errorf("truncateBanner(%q, %d) = %q, want %q", tt.input, tt.maxLen, result, tt.expected) } + if !utf8.ValidString(result) { + t.Errorf("truncateBanner(%q, %d) produced invalid UTF-8: %q", tt.input, tt.maxLen, result) + } } }