Files
fleet/orbit/pkg/table/executable_hashes/executable_hashes.go
T
1f6f218b1d Handle missing macOS bundle executable in executable_hashes table (#47532)
**Related issue:** Resolves #45327

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

## Testing

- [x] Added/updated automated tests
- [ ] QA'd all new/changed functionality manually

## 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
- [x] Verified that fleetd runs on macOS, Linux and Windows
- [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 macOS detail query failures when application bundles declare
executable paths that don't exist on the system. Previously, missing
executables would cause queries to fail entirely. The system now handles
these gracefully by setting the SHA256 field to an empty value,
improving reliability of system queries.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Lucas Manuel Rodriguez <lucas@fleetdm.com>
2026-06-19 13:16:18 -03:00

166 lines
4.8 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 {
// The executable named in the bundle's Info.plist may not exist on disk — e.g.
// Apple system bundles like XProtect.bundle declare a CFBundleExecutable but ship
// no binary at that path. Don't fail the whole table generation (which aborts the
// detail query for the host); log at debug and return an empty hash, matching the
// empty-path behavior above. Any other open error (permission denied, transient
// I/O, etc.) is unexpected, so propagate it rather than silently masking it.
if errors.Is(err, os.ErrNotExist) {
log.Debug().Err(err).Str("path", filePath).Msg("executable not found on disk, returning empty hash")
return "", nil
}
return "", fmt.Errorf("opening executable to compute sha256: %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)
}