mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
<!-- 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`).
344 lines
12 KiB
Go
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"`
|
|
}
|