fleet/ee/maintained-apps/ingesters/homebrew/ingester.go
Jonathan Katz 0d15fd6cd6
Override patch policy query (#42322)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #41815
### Changes
- Extracted patch policy creation to `pkg/patch_policy`
- Added a `patch_query` column to the `software_installers` table
- By default that column is empty, and patch policies will generate with
the default query if so
- On app manifest ingestion, the appropriate entry in
`software_installers` will save the override "patch" query from the
manifest in patch_query

# 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`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

- [ ] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.
- [ ] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes

## Testing

- [x] Added/updated automated tests
- [ ] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [ ] QA'd all new/changed functionality manually
- Relied on integration test for FMA version pinning

## Database migrations

- [x] Checked schema for all modified table for columns that will
auto-update timestamps during migration.
- [ ] Confirmed that updating the timestamps is acceptable, and will not
cause unwanted side effects.
- [x] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).
2026-03-25 10:32:41 -04:00

344 lines
12 KiB
Go

package homebrew
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"path"
"strings"
"time"
maintained_apps "github.com/fleetdm/fleet/v4/ee/maintained-apps"
external_refs "github.com/fleetdm/fleet/v4/ee/maintained-apps/ingesters/homebrew/external_refs"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/pkg/optjson"
"github.com/fleetdm/fleet/v4/pkg/patch_policy"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/ghodss/yaml"
)
func IngestApps(ctx context.Context, logger *slog.Logger, inputsPath, slugFilter string) ([]*maintained_apps.FMAManifestApp, error) {
logger.InfoContext(ctx, "starting homebrew app data ingestion")
// Read from our list of apps we should be ingesting
files, err := os.ReadDir(inputsPath)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "reading homebrew input data directory")
}
i := &brewIngester{
baseURL: baseBrewAPIURL,
logger: logger,
client: fleethttp.NewClient(fleethttp.WithTimeout(10 * time.Second)),
}
var manifestApps []*maintained_apps.FMAManifestApp
for _, f := range files {
if f.IsDir() {
continue
}
// Skip non-JSON files (e.g., .DS_Store on macOS)
if !strings.HasSuffix(f.Name(), ".json") {
continue
}
fileBytes, err := os.ReadFile(path.Join(inputsPath, f.Name()))
if err != nil {
return nil, ctxerr.WrapWithData(ctx, err, "reading app input file", map[string]any{"fileName": f.Name()})
}
var input inputApp
if err := json.Unmarshal(fileBytes, &input); err != nil {
return nil, ctxerr.WrapWithData(ctx, err, "unmarshal app input file", map[string]any{"fileName": f.Name()})
}
if input.Token == "" {
return nil, ctxerr.NewWithData(ctx, "missing token for app", map[string]any{"fileName": f.Name()})
}
if input.UniqueIdentifier == "" {
return nil, ctxerr.NewWithData(ctx, "missing unique identifier for app", map[string]any{"fileName": f.Name()})
}
if input.Name == "" {
return nil, ctxerr.NewWithData(ctx, "missing name for app", map[string]any{"fileName": f.Name()})
}
if slugFilter != "" && !strings.Contains(input.Slug, slugFilter) {
continue
}
i.logger.InfoContext(ctx, "ingesting homebrew app", "name", input.Name)
outApp, err := i.ingestOne(ctx, input)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "ingesting homebrew app")
}
manifestApps = append(manifestApps, outApp)
}
return manifestApps, nil
}
const baseBrewAPIURL = "https://formulae.brew.sh/api/"
type brewIngester struct {
baseURL string
logger *slog.Logger
client *http.Client
}
func (i *brewIngester) ingestOne(ctx context.Context, input inputApp) (*maintained_apps.FMAManifestApp, error) {
apiURL := fmt.Sprintf("%scask/%s.json", i.baseURL, input.Token)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "create http request")
}
res, err := i.client.Do(req)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "execute http request")
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "read http response body")
}
switch res.StatusCode {
case http.StatusOK:
// success, go on
case http.StatusNotFound:
return nil, ctxerr.New(ctx, "app not found in brew API")
default:
if len(body) > 512 {
body = body[:512]
}
return nil, ctxerr.Errorf(ctx, "brew API returned status %d: %s", res.StatusCode, string(body))
}
var cask brewCask
if err := json.Unmarshal(body, &cask); err != nil {
return nil, ctxerr.Wrapf(ctx, err, "unmarshal brew cask for %s", input.Token)
}
out := &maintained_apps.FMAManifestApp{}
// validate required fields
if len(cask.Name) == 0 || cask.Name[0] == "" {
return nil, ctxerr.Errorf(ctx, "missing name for cask %s", input.Token)
}
if cask.Token == "" {
return nil, ctxerr.Errorf(ctx, "missing token for cask %s", input.Token)
}
if cask.Version == "" {
return nil, ctxerr.Errorf(ctx, "missing version for cask %s", input.Token)
}
if cask.URL == "" {
return nil, ctxerr.Errorf(ctx, "missing URL for cask %s", input.Token)
}
_, err = url.Parse(cask.URL)
if err != nil {
return nil, ctxerr.Wrapf(ctx, err, "parse URL for cask %s", input.Token)
}
out.Name = input.Name
out.Version = strings.Split(cask.Version, ",")[0]
out.InstallerURL = cask.URL
out.UniqueIdentifier = input.UniqueIdentifier
out.SHA256 = cask.SHA256
out.Queries = maintained_apps.FMAQueries{Exists: fmt.Sprintf("SELECT 1 FROM apps WHERE bundle_identifier = '%s';", out.UniqueIdentifier)}
out.Slug = input.Slug
out.DefaultCategories = input.DefaultCategories
var installScript, uninstallScript string
switch input.InstallScriptPath {
case "":
installScript, err = installScriptForApp(input, &cask)
if err != nil {
return nil, ctxerr.WrapWithData(ctx, err, "generating install script for maintained app", map[string]any{"unique_identifier": input.UniqueIdentifier})
}
default:
scriptBytes, err := os.ReadFile(input.InstallScriptPath)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "reading provided install script file")
}
installScript = string(scriptBytes)
}
switch input.UninstallScriptPath {
case "":
if len(input.PreUninstallScripts) != 0 {
cask.PreUninstallScripts = input.PreUninstallScripts
}
if len(input.PostUninstallScripts) != 0 {
cask.PostUninstallScripts = input.PostUninstallScripts
}
uninstallScript = uninstallScriptForApp(&cask)
default:
if len(input.PreUninstallScripts) != 0 {
return nil, ctxerr.New(ctx, "cannot provide pre-uninstall scripts if uninstall script is provided")
}
if len(input.PostUninstallScripts) != 0 {
return nil, ctxerr.New(ctx, "cannot provide post-uninstall scripts if uninstall script is provided")
}
scriptBytes, err := os.ReadFile(input.UninstallScriptPath)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "reading provided uninstall script file")
}
uninstallScript = string(scriptBytes)
}
out.InstallScript = installScript
out.UninstallScript = uninstallScript
out.UninstallScriptRef = maintained_apps.GetScriptRef(out.UninstallScript)
out.InstallScriptRef = maintained_apps.GetScriptRef(out.InstallScript)
out.Frozen = input.Frozen
external_refs.EnrichManifest(out)
// create patch policy
if input.PatchPolicyPath != "" {
policyBytes, err := os.ReadFile(input.PatchPolicyPath)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "reading provided patch policy path")
}
p := patch_policy.PolicyData{}
if err := yaml.Unmarshal(policyBytes, &p); err != nil {
return nil, ctxerr.Wrap(ctx, err, "unmarshaling patch policy")
}
p.Platform = "darwin"
p.Version = out.Version
out.Queries.Patch, err = patch_policy.GenerateFromManifest(p)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "creating patch policy")
}
}
return out, nil
}
type inputApp struct {
// Name is the user-friendly name of the app.
Name string `json:"name"`
// Token is the identifier in the source data for the app (e.g. homebrew token).
Token string `json:"token"`
// UniqueIdentifier is the app's unique identifier on its platform (e.g. bundle ID on macOS).
UniqueIdentifier string `json:"unique_identifier"`
// InstallerFormat is the installer format used for installing this app.
InstallerFormat string `json:"installer_format"`
// Slug is an identifier that combines the app's token and the target OS.
Slug string `json:"slug"`
PreUninstallScripts []string `json:"pre_uninstall_scripts"`
PostUninstallScripts []string `json:"post_uninstall_scripts"`
DefaultCategories []string `json:"default_categories"`
Frozen bool `json:"frozen"`
InstallScriptPath string `json:"install_script_path"`
UninstallScriptPath string `json:"uninstall_script_path"`
PatchPolicyPath string `json:"patch_policy_path"`
}
type brewCask struct {
Token string `json:"token"`
FullToken string `json:"full_token"`
Tap string `json:"tap"`
Name []string `json:"name"`
Desc string `json:"desc"`
URL string `json:"url"`
Version string `json:"version"`
SHA256 string `json:"sha256"`
Artifacts []*brewArtifact `json:"artifacts"`
PreUninstallScripts []string `json:"-"`
PostUninstallScripts []string `json:"-"`
}
// brew artifacts are objects that have one and only one of their fields set.
type brewArtifact struct {
// App is an array that can contain strings or objects with a target field.
// See grammarly-desktop cask.
App []optjson.StringOr[*brewAppTarget] `json:"app"`
// Pkg is a bit like Binary, it is an array with a string and an object as
// first two elements. The object has a choices field with an array of
// objects. See Microsoft Edge.
Pkg []optjson.StringOr[*brewPkgChoices] `json:"pkg"`
// Zap and Uninstall have the same format, they support the same stanzas.
// It's just that in homebrew, Zaps are not processed by default (only when
// --zap is provided on uninstall). For our uninstall scripts, we want to
// process the zaps.
Uninstall []*brewUninstall `json:"uninstall"`
Zap []*brewUninstall `json:"zap"`
// Binary is an array with a string and an object as first two elements. See
// the "docker" and "firefox" casks.
Binary []optjson.StringOr[*brewBinaryTarget] `json:"binary"`
}
// The choiceChanges file is a property list containing an array of dictionaries. Each dictionary has the following three keys:
//
// Key Description
// choiceIdentifier Identifier for the choice to be modified (string)
// choiceAttribute One of the attribute names described below (string)
// attributeSetting A setting that depends on the choiceAttribute, described below (number or string)
//
// The choiceAttribute and attributeSetting values are as follows:
//
// choiceAttribute attributeSetting Description
// selected (number) 1 to select the choice, 0 to deselect it
// enabled (number) 1 to enable the choice, 0 to disable it
// visible (number) 1 to show the choice, 0 to hide it
// customLocation (string) path at which to install the choice (see below)
type brewPkgConfig struct {
ChoiceIdentifier string `json:"choiceIdentifier" plist:"choiceIdentifier"`
ChoiceAttribute string `json:"choiceAttribute" plist:"choiceAttribute"`
AttributeSetting int `json:"attributeSetting" plist:"attributeSetting"`
}
type brewPkgChoices struct {
Choices []brewPkgConfig `json:"choices"`
}
type brewBinaryTarget struct {
Target string `json:"target"`
}
type brewAppTarget struct {
Target string `json:"target"`
}
// unlike brewArtifact, a single brewUninstall can have many fields set.
// All fields can have one or multiple strings (string or []string).
type brewUninstall struct {
LaunchCtl optjson.StringOr[[]string] `json:"launchctl"`
Quit optjson.StringOr[[]string] `json:"quit"`
PkgUtil optjson.StringOr[[]string] `json:"pkgutil"`
// brew docs says string or hash, but our only case has a single string.
Script optjson.StringOr[map[string]any] `json:"script"`
// format: [0]=signal, [1]=process name (although the brew documentation says
// it's an array of arrays, it's not like that in our single case that uses
// it).
Signal optjson.StringOr[[]string] `json:"signal"`
Delete optjson.StringOr[[]string] `json:"delete"`
RmDir optjson.StringOr[[]string] `json:"rmdir"`
Trash optjson.StringOr[[]string] `json:"trash"`
LoginItem optjson.StringOr[[]string] `json:"login_item"`
Kext optjson.StringOr[[]string] `json:"kext"`
}