Files
Jonathan Katz 345eb0538b Patch when closed: policy and package api (#49871)
<!-- 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 -->
2026-07-30 11:14:20 -04:00

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'",
}