diff --git a/orbit/changes/41328-executable-hashes-emoji b/orbit/changes/41328-executable-hashes-emoji new file mode 100644 index 0000000000..797241ff47 --- /dev/null +++ b/orbit/changes/41328-executable-hashes-emoji @@ -0,0 +1 @@ +* Fixed a bug where the fleetd `executable_hashes` table failed to compute hashes for app bundles with emoji characters in their names. diff --git a/orbit/pkg/table/executable_hashes/executable_hashes.go b/orbit/pkg/table/executable_hashes/executable_hashes.go index b861195ce5..eedb13c713 100644 --- a/orbit/pkg/table/executable_hashes/executable_hashes.go +++ b/orbit/pkg/table/executable_hashes/executable_hashes.go @@ -15,6 +15,7 @@ import ( "path/filepath" "strings" + "github.com/fleetdm/fleet/v4/server/fleet" "github.com/osquery/osquery-go/plugin/table" "github.com/rs/zerolog/log" ) @@ -146,5 +147,9 @@ func getExecutablePath(ctx context.Context, path string) string { return "" } + // The macOS `defaults read` command encodes supplementary Unicode characters (such as emoji) as + // \uXXXX escape sequences using UTF-16 surrogate pairs. + executableName = fleet.DecodeUnicodeEscapes(executableName) + return filepath.Join(path, "/Contents/MacOS/", executableName) } diff --git a/orbit/pkg/table/executable_hashes/executable_hashes_test.go b/orbit/pkg/table/executable_hashes/executable_hashes_test.go index 8234dafb0c..2da31364b1 100644 --- a/orbit/pkg/table/executable_hashes/executable_hashes_test.go +++ b/orbit/pkg/table/executable_hashes/executable_hashes_test.go @@ -3,7 +3,6 @@ package executable_hashes import ( - "context" "crypto/sha256" "encoding/hex" "fmt" @@ -16,52 +15,70 @@ import ( ) func TestGenerateWithExactPath(t *testing.T) { - dir := t.TempDir() - defer os.RemoveAll(dir) + tests := []struct { + name string + bundleName string + executableName string + content []byte + }{ + { + name: "ASCII app name", + bundleName: "Test.app", + executableName: "Test", + content: []byte("test file content for hashing"), + }, + { + name: "emoji app name", + bundleName: "🖨️ Printer.app", + executableName: "🖨️ Printer", + content: []byte("emoji executable content"), + }, + } - // Create a macOS app bundle structure - bundlePath := filepath.Join(dir, "Test.app") - contentsDir := filepath.Join(bundlePath, "Contents") - macosDir := filepath.Join(contentsDir, "MacOS") - require.NoError(t, os.MkdirAll(macosDir, 0o755)) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() - execName := "Test" - // Create Info.plist with CFBundleExecutable key - infoPlistPath := filepath.Join(contentsDir, "Info.plist") - infoPlistContent := fmt.Sprintf(` + bundlePath := filepath.Join(dir, tt.bundleName) + contentsDir := filepath.Join(bundlePath, "Contents") + macosDir := filepath.Join(contentsDir, "MacOS") + require.NoError(t, os.MkdirAll(macosDir, 0o755)) + + infoPlistPath := filepath.Join(contentsDir, "Info.plist") + infoPlistContent := fmt.Sprintf(` CFBundleExecutable %s -`, execName) - require.NoError(t, os.WriteFile(infoPlistPath, []byte(infoPlistContent), 0o644)) +`, tt.executableName) + require.NoError(t, os.WriteFile(infoPlistPath, []byte(infoPlistContent), 0o644)) - // Create the actual executable binary in Contents/MacOS/ - execPath := filepath.Join(macosDir, execName) - content := []byte("test file content for hashing") - require.NoError(t, os.WriteFile(execPath, content, 0o644)) + execPath := filepath.Join(macosDir, tt.executableName) + require.NoError(t, os.WriteFile(execPath, tt.content, 0o644)) - h := sha256.New() - h.Write(content) - expectedHash := hex.EncodeToString(h.Sum(nil)) + h := sha256.New() + h.Write(tt.content) + expectedHash := hex.EncodeToString(h.Sum(nil)) - rows, err := Generate(context.Background(), table.QueryContext{ - Constraints: map[string]table.ConstraintList{ - colPath: { - Constraints: []table.Constraint{{ - Expression: bundlePath, - Operator: table.OperatorEquals, - }}, - }, - }, - }) - require.NoError(t, err) - require.Len(t, rows, 1) - require.Equal(t, bundlePath, rows[0][colPath]) - require.Equal(t, execPath, rows[0][colExecPath]) - require.Equal(t, expectedHash, rows[0][colExecHash]) + rows, err := Generate(t.Context(), table.QueryContext{ + Constraints: map[string]table.ConstraintList{ + colPath: { + Constraints: []table.Constraint{{ + Expression: bundlePath, + Operator: table.OperatorEquals, + }}, + }, + }, + }) + require.NoError(t, err) + require.Len(t, rows, 1) + require.Equal(t, bundlePath, rows[0][colPath]) + require.Equal(t, execPath, rows[0][colExecPath]) + require.Equal(t, expectedHash, rows[0][colExecHash]) + }) + } } func TestGenerateWithWildcard(t *testing.T) { @@ -110,7 +127,7 @@ func TestGenerateWithWildcard(t *testing.T) { expectedExecPathByBundlePath[bundlePath] = execPath } - rows, err := Generate(context.Background(), table.QueryContext{ + rows, err := Generate(t.Context(), table.QueryContext{ Constraints: map[string]table.ConstraintList{ colPath: { Constraints: []table.Constraint{{ @@ -123,7 +140,7 @@ func TestGenerateWithWildcard(t *testing.T) { require.NoError(t, err) require.Len(t, rows, 2) - serviceRows, err := Generate(context.Background(), table.QueryContext{ + serviceRows, err := Generate(t.Context(), table.QueryContext{ Constraints: map[string]table.ConstraintList{ colPath: { Constraints: []table.Constraint{{ diff --git a/server/fleet/host_certificates.go b/server/fleet/host_certificates.go index 3191bdffd8..d4c3b391ee 100644 --- a/server/fleet/host_certificates.go +++ b/server/fleet/host_certificates.go @@ -5,6 +5,7 @@ import ( "crypto/x509" "encoding/hex" "fmt" + "strconv" "strings" "time" ) @@ -330,6 +331,68 @@ func DecodeHexEscapes(s string) string { return buf.String() } +// DecodeUnicodeEscapes replaces literal \uXXXX escape sequences (including UTF-16 surrogate pairs) with actual Unicode +// characters. For example, the string `\ud83d\udda8` (12 ASCII characters) becomes the 4-byte UTF-8 sequence for 🖨. +// Returns the original string unchanged if no escape sequences are found. +// Incomplete or invalid sequences (e.g. `\u00`, `\u` at end of string) are left as-is. +// Unpaired UTF-16 surrogates are also left as-is. +func DecodeUnicodeEscapes(s string) string { + if !strings.Contains(s, `\u`) { + return s + } + + var buf strings.Builder + buf.Grow(len(s)) + i := 0 + for i < len(s) { + r, size := parseUnicodeEscape(s, i) + if size > 0 { + buf.WriteRune(r) + i += size + } else { + buf.WriteByte(s[i]) + i++ + } + } + return buf.String() +} + +// parseUnicodeEscape attempts to parse a \uXXXX sequence at position i in s. If the parsed value is a UTF-16 high +// surrogate, it looks for an adjacent low surrogate to form a complete code point. Returns the decoded rune and the +// number of bytes consumed, or (0, 0) if no valid \uXXXX escape was found at position i. +func parseUnicodeEscape(s string, i int) (rune, int) { + hi, ok := parseHex4(s, i) + if !ok { + return 0, 0 + } + // If it's a UTF-16 surrogate, handle pairing or leave as-is. + if hi >= 0xD800 && hi <= 0xDBFF { + lo, ok := parseHex4(s, i+6) + if ok && lo >= 0xDC00 && lo <= 0xDFFF { + return 0x10000 + (hi-0xD800)*0x400 + (lo - 0xDC00), 12 + } + // Unpaired high surrogate — leave as-is. + return 0, 0 + } + if hi >= 0xDC00 && hi <= 0xDFFF { + // Standalone low surrogate — leave as-is. + return 0, 0 + } + return hi, 6 +} + +// parseHex4 tries to parse a \uXXXX sequence at position i and returns the 16-bit code unit. +func parseHex4(s string, i int) (rune, bool) { + if i+6 > len(s) || s[i] != '\\' || s[i+1] != 'u' { + return 0, false + } + val, err := strconv.ParseUint(s[i+2:i+6], 16, 16) + if err != nil { + return 0, false + } + return rune(val), true +} + func firstOrEmpty(s []string) string { if len(s) > 0 { return s[0] diff --git a/server/fleet/host_certificates_test.go b/server/fleet/host_certificates_test.go index 25d2bb3433..36385d06a5 100644 --- a/server/fleet/host_certificates_test.go +++ b/server/fleet/host_certificates_test.go @@ -295,3 +295,74 @@ func TestDecodeHexEscapes(t *testing.T) { }) } } + +func TestDecodeUnicodeEscapes(t *testing.T) { + cases := []struct { + name string + input string + expected string + }{ + { + name: "plain ASCII unchanged", + input: "Hello World", + expected: "Hello World", + }, + { + name: "empty string", + input: "", + expected: "", + }, + { + name: "BMP character", + input: `\u00e9`, + expected: "é", + }, + { + name: "surrogate pair for printer emoji", + input: `\ud83d\udda8\ufe0f Printer`, + expected: "🖨️ Printer", + }, + { + name: "mixed ASCII and escaped", + input: `App \ud83d\ude00 Name`, + expected: "App 😀 Name", + }, + { + name: "literal backslash-n preserved", + input: `app\nfile`, + expected: `app\nfile`, + }, + { + name: "literal backslash-t preserved", + input: `app\tfile`, + expected: `app\tfile`, + }, + { + name: "incomplete escape at end of string", + input: `hello\u00`, + expected: `hello\u00`, + }, + { + name: "unpaired high surrogate left as-is", + input: `\ud83d hello`, + expected: `\ud83d hello`, + }, + { + name: "standalone low surrogate left as-is", + input: `\udda8 hello`, + expected: `\udda8 hello`, + }, + { + name: "no escape sequences present", + input: `/Users/test/Applications/Test.app`, + expected: `/Users/test/Applications/Test.app`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + result := DecodeUnicodeEscapes(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +}