From f2547b5f66c8b4f16991cce73f277c73c1ac22bf Mon Sep 17 00:00:00 2001 From: jacobshandling <61553566+jacobshandling@users.noreply.github.com> Date: Tue, 27 Jan 2026 11:04:32 -0800 Subject: [PATCH] Generalize `executable_hashes` table's executable path discovery logic (#38827) **Related issue:** Resolves https://github.com/fleetdm/fleet/issues/33522#issuecomment-3780274767 - Removes current "get the sha256 of a binary path directly" functionality from the table as well, so it is now strictly for getting the executable hashes for application bundles with the `/Contents/Info.plist` > `CFBundleExecutable` and `/Contents/MacOS/` structure - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually For unreleased bug fixes in a release candidate, one of: - [x] Confirmed that the fix is not expected to adversely impact load test results ## fleetd/orbit/Fleet Desktop - [x] Verified compatibility with the latest released version of Fleet (see [Must rule](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/workflows/fleetd-development-and-release-strategy.md)) - [x] If the change applies to only one platform, confirmed that `runtime.GOOS` is used as needed to isolate changes --- .../executable_hashes/executable_hashes.go | 36 ++---- .../executable_hashes_test.go | 103 ++++++++++++++---- 2 files changed, 90 insertions(+), 49 deletions(-) diff --git a/orbit/pkg/table/executable_hashes/executable_hashes.go b/orbit/pkg/table/executable_hashes/executable_hashes.go index c43eb6e7e3..b861195ce5 100644 --- a/orbit/pkg/table/executable_hashes/executable_hashes.go +++ b/orbit/pkg/table/executable_hashes/executable_hashes.go @@ -1,6 +1,6 @@ //go:build darwin -// Package executable_hashes implements an extension osquery table to get information about a macOS file +// Package executable_hashes implements an extension osquery table to get information about a macOS bundle package executable_hashes import ( @@ -133,36 +133,18 @@ func computeFileSHA256(filePath string) (string, error) { } func getExecutablePath(ctx context.Context, path string) string { - if strings.HasSuffix(path, ".app") { - // Use defaults to read CFBundleExecutable from Info.plist - infoPlistPath := path + "/Contents/Info.plist" - output, err := exec.CommandContext(ctx, "/usr/bin/defaults", "read", infoPlistPath, "CFBundleExecutable").Output() - if err != nil { - // lots of helper .app bundles nested within parent .apps seem to have invalid Info.plists - warn and continue - log.Warn().Err(err).Str("path", path).Msg("failed to read CFBundleExecutable from Info.plist, returning empty binary path") - return "" - } - - executableName := strings.TrimSpace(string(output)) - if executableName == "" { - return "" - } - - return filepath.Join(path, "/Contents/MacOS/", executableName) - } - - // For non-app paths, check if it's a regular file (binary) - info, err := os.Stat(path) + infoPlistPath := filepath.Join(path, "/Contents/Info.plist") + output, err := exec.CommandContext(ctx, "/usr/bin/defaults", "read", infoPlistPath, "CFBundleExecutable").Output() if err != nil { - log.Warn().Err(err).Str("path", path).Msg("couldn't get FileInfo") + // lots of helper app bundles nested within parent bundles seem to have invalid Info.plists - warn and continue + log.Warn().Err(err).Str("path", path).Msg("failed to read CFBundleExecutable from Info.plist, returning empty binary path") return "" } - // Only return the path if it's a regular file (not a directory) - if info.Mode().IsRegular() { - return path + executableName := strings.TrimSpace(string(output)) + if executableName == "" { + return "" } - log.Warn().Str("path", path).Msg("path is not a regular file nor a .app bundle") - return "" + 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 7d51210ad1..8234dafb0c 100644 --- a/orbit/pkg/table/executable_hashes/executable_hashes_test.go +++ b/orbit/pkg/table/executable_hashes/executable_hashes_test.go @@ -6,6 +6,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "fmt" "os" "path/filepath" "testing" @@ -18,10 +19,29 @@ func TestGenerateWithExactPath(t *testing.T) { dir := t.TempDir() defer os.RemoveAll(dir) - path := filepath.Join(dir, "example.bin") - execPath := path + // 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)) + + execName := "Test" + // Create Info.plist with CFBundleExecutable key + infoPlistPath := filepath.Join(contentsDir, "Info.plist") + infoPlistContent := fmt.Sprintf(` + + + + CFBundleExecutable + %s + +`, execName) + 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(path, content, 0o600)) + require.NoError(t, os.WriteFile(execPath, content, 0o644)) h := sha256.New() h.Write(content) @@ -31,7 +51,7 @@ func TestGenerateWithExactPath(t *testing.T) { Constraints: map[string]table.ConstraintList{ colPath: { Constraints: []table.Constraint{{ - Expression: path, + Expression: bundlePath, Operator: table.OperatorEquals, }}, }, @@ -39,7 +59,7 @@ func TestGenerateWithExactPath(t *testing.T) { }) require.NoError(t, err) require.Len(t, rows, 1) - require.Equal(t, path, rows[0][colPath]) + require.Equal(t, bundlePath, rows[0][colPath]) require.Equal(t, execPath, rows[0][colExecPath]) require.Equal(t, expectedHash, rows[0][colExecHash]) } @@ -48,37 +68,76 @@ func TestGenerateWithWildcard(t *testing.T) { dir := t.TempDir() defer os.RemoveAll(dir) - testFiles := map[string][]byte{ - "foo.bin": []byte("content of foo"), - "bar.bin": []byte("content of bar"), - "baz.bin": []byte("content of baz"), + testBundles := map[string]struct { + executableName string + content []byte + }{ + "Foo.app": {"Foo", []byte("content of foo")}, + "Bar.app": {"Bar", []byte("content of bar")}, + "Baz.service": {"Baz", []byte("content of baz")}, + "Bonk.service": {"Bonk", []byte("content of bonk")}, } expectedHashByBundlePath := make(map[string]string) + expectedExecPathByBundlePath := make(map[string]string) - for filename, content := range testFiles { - path := filepath.Join(dir, filename) - require.NoError(t, os.WriteFile(path, content, 0o600)) + // Create macOS app bundle structures + for bundleName, bundleInfo := range testBundles { + bundlePath := filepath.Join(dir, bundleName) + contentsDir := filepath.Join(bundlePath, "Contents") + macosDir := filepath.Join(contentsDir, "MacOS") + require.NoError(t, os.MkdirAll(macosDir, 0o755)) + + // Create Info.plist with CFBundleExecutable key + infoPlistPath := filepath.Join(contentsDir, "Info.plist") + infoPlistContent := fmt.Sprintf(` + + + + CFBundleExecutable + %s + +`, bundleInfo.executableName) + require.NoError(t, os.WriteFile(infoPlistPath, []byte(infoPlistContent), 0o644)) + + // Create the actual executable in Contents/MacOS/ + execPath := filepath.Join(macosDir, bundleInfo.executableName) + require.NoError(t, os.WriteFile(execPath, bundleInfo.content, 0o644)) h := sha256.New() - h.Write(content) - expectedHashByBundlePath[path] = hex.EncodeToString(h.Sum(nil)) + h.Write(bundleInfo.content) + expectedHashByBundlePath[bundlePath] = hex.EncodeToString(h.Sum(nil)) + expectedExecPathByBundlePath[bundlePath] = execPath } rows, err := Generate(context.Background(), table.QueryContext{ Constraints: map[string]table.ConstraintList{ colPath: { Constraints: []table.Constraint{{ - Expression: filepath.Join(dir, "%.bin"), + Expression: filepath.Join(dir, "%.app"), Operator: table.OperatorLike, }}, }, }, }) require.NoError(t, err) - require.Len(t, rows, len(testFiles)) + require.Len(t, rows, 2) - got := make(map[string]fileInfo, len(rows)) + serviceRows, err := Generate(context.Background(), table.QueryContext{ + Constraints: map[string]table.ConstraintList{ + colPath: { + Constraints: []table.Constraint{{ + Expression: filepath.Join(dir, "%.service"), + Operator: table.OperatorLike, + }}, + }, + }, + }) + require.NoError(t, err) + require.Len(t, serviceRows, 2) + rows = append(rows, serviceRows...) + + got := make(map[string]fileInfo, 4) for _, row := range rows { got[row[colPath]] = fileInfo{ Path: row[colPath], @@ -87,11 +146,11 @@ func TestGenerateWithWildcard(t *testing.T) { } } - for path, expectedHash := range expectedHashByBundlePath { - require.Contains(t, got, path) - info := got[path] - require.Equal(t, path, info.Path) - require.Equal(t, path, info.ExecPath) + for bundlePath, expectedHash := range expectedHashByBundlePath { + require.Contains(t, got, bundlePath) + info := got[bundlePath] + require.Equal(t, bundlePath, info.Path) + require.Equal(t, expectedExecPathByBundlePath[bundlePath], info.ExecPath) require.Equal(t, expectedHash, info.ExecSha256) } }