fleet/orbit/pkg/table/executable_hashes/executable_hashes.go
Victor Lyuboslavsky fa30866e40
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 -->
2026-03-13 13:26:19 -05:00

155 lines
4.1 KiB
Go

//go:build darwin
// Package executable_hashes implements an extension osquery table to get information about a macOS bundle
package executable_hashes
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/osquery/osquery-go/plugin/table"
"github.com/rs/zerolog/log"
)
const (
colPath = "path"
colExecPath = "executable_path"
colExecHash = "executable_sha256"
)
// Columns is the schema of the table.
func Columns() []table.ColumnDefinition {
return []table.ColumnDefinition{
table.TextColumn(colPath),
table.TextColumn(colExecPath),
table.TextColumn(colExecHash),
}
}
// Generate is called to return the results for the table at query time.
func Generate(ctx context.Context, queryContext table.QueryContext) ([]map[string]string, error) {
path := ""
wildcard := false
var results []map[string]string
if constraintList, present := queryContext.Constraints[colPath]; present {
// 'path' is in the where clause
for _, constraint := range constraintList.Constraints {
path = constraint.Expression
switch constraint.Operator {
case table.OperatorLike:
path = constraint.Expression
wildcard = true
case table.OperatorEquals:
path = constraint.Expression
wildcard = false
}
}
} else {
return results, errors.New("missing `path` constraint: provide a `path` in the query's `WHERE` clause")
}
processed, err := processFile(path, wildcard)
if err != nil {
return nil, err
}
for _, res := range processed {
results = append(results, map[string]string{
colPath: res.Path,
colExecPath: res.ExecPath,
colExecHash: res.ExecSha256,
})
}
return results, nil
}
type fileInfo struct {
Path string
ExecPath string
ExecSha256 string
}
func processFile(path string, wildcard bool) ([]fileInfo, error) {
var output []fileInfo
if wildcard {
replacedPath := strings.ReplaceAll(path, "%", "*")
resolvedPaths, err := filepath.Glob(replacedPath)
if err != nil {
return nil, fmt.Errorf("failed to resolve filepaths for incoming path: %w", err)
}
for _, p := range resolvedPaths {
execPath := getExecutablePath(context.Background(), p)
hash, err := computeFileSHA256(execPath)
if err != nil {
return nil, fmt.Errorf("computing executable sha256 from wildcard path: %w", err)
}
output = append(output, fileInfo{Path: p, ExecPath: execPath, ExecSha256: hash})
}
} else {
execPath := getExecutablePath(context.Background(), path)
hash, err := computeFileSHA256(execPath)
if err != nil {
return nil, fmt.Errorf("computing executable sha256 from specific path: %w", err)
}
output = append(output, fileInfo{Path: path, ExecPath: execPath, ExecSha256: hash})
}
return output, nil
}
func computeFileSHA256(filePath string) (string, error) {
if filePath == "" {
log.Warn().Msg("empty path provided, returning empty hash")
return "", nil
}
f, err := os.Open(filePath)
if err != nil {
return "", fmt.Errorf("couldn't open filepath: %w", err)
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", fmt.Errorf("computing hash: %w", err)
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func getExecutablePath(ctx context.Context, path string) string {
infoPlistPath := filepath.Join(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 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 ""
}
executableName := strings.TrimSpace(string(output))
if executableName == "" {
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)
}