Generalize executable_hashes table's executable path discovery logic (#38827)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **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/<executable>` 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
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(`<?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))
|
||||
|
||||
// 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(`<?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>`, 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user