**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>
166 lines
4.8 KiB
Go
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)
|
|
}
|