fleet/orbit/pkg/table/executable_hashes/executable_hashes.go
jacobshandling f2547b5f66
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
2026-01-27 11:04:32 -08:00

150 lines
3.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/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 ""
}
return filepath.Join(path, "/Contents/MacOS/", executableName)
}