mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
Fixed a bug where the fleetd executable_hashes table failed to compute hashes for app bundles with emoji characters in their names (#41638)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #41328 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually ## fleetd/orbit/Fleet Desktop - [x] Verified compatibility with the latest released version of Fleet (see [Must rule] - [x] If the change applies to only one platform, confirmed that `runtime.GOOS` is used as needed to isolate changes - [x] Verified auto-update works from the released version of component to the new version (see [tools/tuf/test](../tools/tuf/test/README.md)) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Fixed an issue where executable hashes failed to compute for macOS app bundles with emoji or other Unicode characters in executable names, improving bundle detection and integrity checks. * **Tests** * Added comprehensive tests to ensure correct handling of Unicode escape sequences and emoji in bundle names and executables. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
parent
fe1e4d295b
commit
fa30866e40
5 changed files with 195 additions and 38 deletions
1
orbit/changes/41328-executable-hashes-emoji
Normal file
1
orbit/changes/41328-executable-hashes-emoji
Normal file
|
|
@ -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.
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
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(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>%s</string>
|
||||
</dict>
|
||||
</plist>`, execName)
|
||||
require.NoError(t, os.WriteFile(infoPlistPath, []byte(infoPlistContent), 0o644))
|
||||
</plist>`, 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{{
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue