<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #49416 - Adds patch_when_closed to the new policy, update policy, and update package endpoints - Sets pre_install_query in the software title endpoint to software_installers.app_open_query if the policy is a patch when closed policy (discussed in standup) - Deletes the existing pre_install_query if setting patch_when_closed (discussed in standup) - Fixes a small error in the patch_policy package - Fixes the default macOS query in patch_policy package to use to escape symbols in a.path # Checklist for submitter If some of the following don't apply, delete the relevant line. - [ ] 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. - [ ] Timeouts are implemented and retries are limited to avoid infinite loops - [ ] 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) - [x] QA'd all new/changed functionality manually <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a “patch when closed” option for patch policies and Fleet-maintained apps. * Added installer controls for enabling patching and configuring whether apps must be closed before installation. * Automatically manages pre-install behavior and continuous automation requirements for these policies. * **Bug Fixes** * Improved app detection based on application path prefixes on macOS. * Corrected RStudio process detection on Windows. * Added validation to prevent incompatible patch policy settings. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
248 lines
9.6 KiB
Go
248 lines
9.6 KiB
Go
package patch_policy
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
)
|
|
|
|
type PolicyData struct {
|
|
Name string
|
|
Platform string
|
|
Description string
|
|
Resolution string
|
|
Query string
|
|
ExistsQuery string
|
|
Version string
|
|
}
|
|
|
|
const (
|
|
// notExistsStart is prepended to the exists query body; version_compare is appended
|
|
// to the same WHERE clause (inside NOT EXISTS), matching the pre-#45647 generator.
|
|
notExistsStart = "SELECT 1 WHERE NOT EXISTS ("
|
|
existsPrefix = "SELECT 1 FROM "
|
|
)
|
|
|
|
var (
|
|
ErrWrongPlatform = errors.New("platform should be darwin or windows")
|
|
ErrNoExistsQuery = errors.New("exists query was not provided")
|
|
)
|
|
|
|
// GenerateQueryForManifest wraps the "exists" query to create a patch policy query.
|
|
func GenerateQueryForManifest(p PolicyData) (string, error) {
|
|
if p.ExistsQuery == "" {
|
|
return "", ErrNoExistsQuery
|
|
}
|
|
|
|
suffix, err := versionCompareSuffix(p.Platform, p.ExistsQuery, p.Version)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
before, _ := strings.CutSuffix(p.ExistsQuery, ";")
|
|
before = strings.TrimSpace(before)
|
|
if strings.Contains(before, " OR ") {
|
|
before = parenthesizeWhereClause(before)
|
|
}
|
|
|
|
return notExistsStart + before + suffix, nil
|
|
}
|
|
|
|
// parenthesizeWhereClause wraps the WHERE body in parens when it contains OR so that
|
|
// the trailing AND version_compare(...) binds to the full predicate, not just the
|
|
// right-hand side of OR (SQL precedence: AND > OR).
|
|
func parenthesizeWhereClause(existsQuery string) string {
|
|
if !strings.HasPrefix(existsQuery, existsPrefix) {
|
|
return existsQuery
|
|
}
|
|
rest := strings.TrimPrefix(existsQuery, existsPrefix)
|
|
table, conditions, found := strings.Cut(rest, " WHERE ")
|
|
if !found {
|
|
return existsQuery
|
|
}
|
|
if !strings.Contains(conditions, " OR ") {
|
|
return existsQuery
|
|
}
|
|
return existsPrefix + table + " WHERE (" + conditions + ")"
|
|
}
|
|
|
|
func versionCompareSuffix(platform, existsQuery, version string) (string, error) {
|
|
column, err := versionCompareColumn(platform, existsQuery)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return fmt.Sprintf(" AND version_compare(%s, '%s') < 0);", column, version), nil
|
|
}
|
|
|
|
func versionCompareColumn(platform, existsQuery string) (string, error) {
|
|
switch platform {
|
|
case "darwin":
|
|
return "bundle_short_version", nil
|
|
case "windows":
|
|
if tableFromExistsQuery(existsQuery) == "file" {
|
|
return "file_version", nil
|
|
}
|
|
return "version", nil
|
|
default:
|
|
return "", ErrWrongPlatform
|
|
}
|
|
}
|
|
|
|
func tableFromExistsQuery(existsQuery string) string {
|
|
trimmed, _ := strings.CutSuffix(strings.TrimSpace(existsQuery), ";")
|
|
if !strings.HasPrefix(trimmed, existsPrefix) {
|
|
return ""
|
|
}
|
|
rest := strings.TrimPrefix(trimmed, existsPrefix)
|
|
if table, _, found := strings.Cut(rest, " WHERE "); found {
|
|
return table
|
|
}
|
|
if table, _, found := strings.Cut(rest, " "); found {
|
|
return table
|
|
}
|
|
return strings.TrimSpace(rest)
|
|
}
|
|
|
|
// GenerateFromInstaller creates a patch policy with all fields from an installer
|
|
func GenerateFromInstaller(p PolicyData, installer *fleet.SoftwareInstaller) (*PolicyData, error) {
|
|
// use the patch policy query from the app manifest if available
|
|
query := installer.PatchQuery
|
|
|
|
if p.Description == "" {
|
|
p.Description = "Outdated software might introduce security vulnerabilities or compatibility issues."
|
|
}
|
|
|
|
if p.Resolution == "" {
|
|
p.Resolution = "Install the latest version from self-service."
|
|
}
|
|
|
|
switch installer.Platform {
|
|
case "darwin":
|
|
if p.Name == "" {
|
|
p.Name = fmt.Sprintf("macOS - %s up to date", installer.SoftwareTitle)
|
|
}
|
|
if installer.PatchQuery == "" {
|
|
query = defaultMacOSQuery(installer.BundleIdentifier, installer.Version)
|
|
}
|
|
case "windows":
|
|
if p.Name == "" {
|
|
p.Name = fmt.Sprintf("Windows - %s up to date", installer.SoftwareTitle)
|
|
}
|
|
if installer.PatchQuery == "" {
|
|
query = defaultWindowsQuery(installer.SoftwareTitle, installer.Version)
|
|
}
|
|
default:
|
|
return nil, ErrWrongPlatform
|
|
}
|
|
|
|
return &PolicyData{Query: query, Platform: installer.Platform, Name: p.Name, Description: p.Description, Resolution: p.Resolution}, nil
|
|
}
|
|
|
|
func defaultMacOSQuery(bundleIdentifier string, version string) string {
|
|
patchTemplate := "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps WHERE bundle_identifier = '%s' AND version_compare(bundle_short_version, '%s') < 0);"
|
|
return fmt.Sprintf(patchTemplate, bundleIdentifier, version)
|
|
}
|
|
|
|
func defaultWindowsQuery(softwareTitle string, version string) string {
|
|
patchTemplate := "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = '%s' AND version_compare(version, '%s') < 0);"
|
|
return fmt.Sprintf(patchTemplate, softwareTitle, version)
|
|
}
|
|
|
|
// GenerateOpenQuery returns a pre-install query that returns a row only when the app is closed.
|
|
func GenerateOpenQuery(platform string, bundleIdentifier string, softwareTitle string) string {
|
|
switch platform {
|
|
case "darwin":
|
|
return defaultMacOSOpenQuery(bundleIdentifier)
|
|
case "windows":
|
|
return defaultWindowsOpenQuery(softwareTitle)
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func defaultMacOSOpenQuery(bundleIdentifier string) string {
|
|
// Resolve the app's install path from its bundle identifier via the apps table, then
|
|
// match any process running from inside that path.
|
|
// alternatives considered:
|
|
// - get processes by name - requires a lot of manual overrides
|
|
// - use the running_apps table - not reliable when run through orbit
|
|
// - use the "app" artifact in the homebrew cask - requires extra code to extract
|
|
openTemplate := "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps a JOIN processes p ON substr(p.path, 1, LENGTH(a.path) + 1) = concat(a.path, '/') WHERE a.bundle_identifier = '%s');"
|
|
return fmt.Sprintf(openTemplate, escapeSQLLiteral(bundleIdentifier))
|
|
}
|
|
|
|
func defaultWindowsOpenQuery(softwareTitle string) string {
|
|
if query, ok := windowsOpenQueryOverrides[softwareTitle]; ok {
|
|
windowsOpenQueryPrefix := "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM processes WHERE LOWER(name) %s);"
|
|
return fmt.Sprintf(windowsOpenQueryPrefix, query)
|
|
}
|
|
|
|
// Match a process named "<title>.exe"
|
|
// alternatives considered:
|
|
// - join programs.install_location with processes.path - install_location is unreliable (especially for MSI installers)
|
|
executable := strings.ToLower(softwareTitle) + ".exe"
|
|
openTemplate := "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM processes WHERE LOWER(name) = '%s');"
|
|
return fmt.Sprintf(openTemplate, escapeSQLLiteral(executable))
|
|
}
|
|
|
|
func escapeSQLLiteral(s string) string {
|
|
return strings.ReplaceAll(s, "'", "''")
|
|
}
|
|
|
|
// overrides based on uninstall scripts
|
|
var windowsOpenQueryOverrides = map[string]string{ //nolint:gosec // G101 false positive: values are app process names, not credentials
|
|
"1Password": "LIKE '1password%'",
|
|
"7-zip": "IN ('7zfm.exe','7zg.exe')",
|
|
"Amazon Chime": "IN ('amazon chime.exe','chime.exe')",
|
|
"Android Studio": "= 'studio64.exe'",
|
|
"Beyond Compare": "= 'bcompare.exe'",
|
|
"CLion": "IN ('clion.exe','clion64.exe')",
|
|
"DataGrip": "IN ('datagrip.exe','datagrip64.exe')",
|
|
"DataSpell": "IN ('dataspell.exe','dataspell64.exe')",
|
|
"DAX Studio": "= 'daxstudio.exe'",
|
|
"DBeaverEE": "= 'dbeaver.exe'",
|
|
"DBeaverLite": "= 'dbeaver.exe'",
|
|
"DBeaverUltimate": "= 'dbeaver.exe'",
|
|
"Dell Command Update": "IN ('dellcommandupdate.exe','dcu-cli.exe')",
|
|
"GoLand": "IN ('goland.exe','goland64.exe')",
|
|
"Google Antigravity IDE": "= 'antigravity.exe'",
|
|
"Google Chrome": "= 'chrome.exe'",
|
|
"IntelliJ IDEA CE": "IN ('idea.exe','idea64.exe')",
|
|
"IntelliJ IDEA Ultimate": "IN ('idea.exe','idea64.exe')",
|
|
"JetBrains Toolbox": "IN ('toolbox.exe','jetbrains-toolbox.exe')",
|
|
"KNIME Analytics Platform": "= 'knime.exe'",
|
|
"Lenovo Dock Manager": "= 'dockmgr.exe'",
|
|
"Microsoft Edge": "= 'msedge.exe'",
|
|
"Microsoft Remote Help": "= 'remotehelp.exe'",
|
|
"Microsoft Teams": "IN ('teams.exe','ms-teams.exe')",
|
|
"Microsoft Visual Studio Code": "= 'code.exe'",
|
|
"Node.js": "= 'node.exe'",
|
|
"Notion Calendar": "IN ('cron.exe','notion calendar.exe')",
|
|
"OBS": "IN ('obs32.exe','obs64.exe')",
|
|
"Okta Verify": "= 'oktaverify.exe'",
|
|
"Ollama": "IN ('ollama.exe','ollama app.exe')",
|
|
"OneDrive": "LIKE 'onedrive%'",
|
|
"Pale Moon": "= 'palemoon.exe'",
|
|
"pgAdmin 4": "= 'pgadmin4.exe'",
|
|
"PhpStorm": "IN ('phpstorm.exe','phpstorm64.exe')",
|
|
"Plantronics Hub": "= 'plthub.exe'",
|
|
"Portfolio Performance": "= 'portfolioperformance.exe'",
|
|
"Power Automate": "= 'pad.console.host.exe'",
|
|
"PowerShell": "= 'pwsh.exe'",
|
|
"ProtonVPN": "IN ('proton vpn.exe','protonvpn.exe')",
|
|
"PyCharm Community Edition": "IN ('pycharm.exe','pycharm64.exe')",
|
|
"PyCharm Professional": "IN ('pycharm.exe','pycharm64.exe')",
|
|
"Rider": "IN ('rider.exe','rider64.exe')",
|
|
"RStudio": "IN ('rgui.exe','rsession.exe','rstudio.exe')",
|
|
"RubyMine": "IN ('rubymine.exe','rubymine64.exe')",
|
|
"RustRover": "IN ('rustrover.exe','rustrover64.exe')",
|
|
"Spotify": "IN ('spotify.exe','spotifywebhelper.exe')",
|
|
"Sublime Text": "= 'sublime_text.exe'",
|
|
"VirtualBox": "LIKE 'virtualbox%'",
|
|
"Wacom Tablet": "IN ('wacomdesktopcenter.exe','wacom_tablet.exe')",
|
|
"WebStorm": "IN ('webstorm.exe','webstorm64.exe')",
|
|
"Windows App": "= 'windowsapp.exe'",
|
|
}
|