From 92aaf1de81eb748c7fcd6261300213561e59e51e Mon Sep 17 00:00:00 2001 From: Allen Houchins <32207388+allenhouchins@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:38:20 -0500 Subject: [PATCH] Fix Steam patch policy comparing an empty bundle_short_version (#50428) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Related issue:** Resolves #50408 ## What changed Steam.app ships without a `CFBundleShortVersionString`, so osquery's `apps.bundle_short_version` is an empty string: ``` $ /usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" /Applications/Steam.app/Contents/Info.plist Print: Entry, ":CFBundleShortVersionString", Does Not Exist $ /usr/libexec/PlistBuddy -c "Print :CFBundleVersion" /Applications/Steam.app/Contents/Info.plist 6.0 ``` The generated patch policy compared that column, and `version_compare('', '6.0')` returns `-1`, so the `< 0` predicate was always true. The "Steam up to date" policy could never pass on **any** host with Steam installed, at any version. Meanwhile software inventory falls back to `bundle_version` and correctly showed Steam as up to date, so the two features disagreed about the same app on the same host — and with `install_software: true` the policy repeatedly reinstalled a version that was already installed. This adds a per-app override in the homebrew ingester comparing `bundle_version` (CFBundleVersion `6.0`, which the cask version tracks), following the pattern already used for `sonos` and the Firefox pre-release channels: ```diff -version_compare(bundle_short_version, '6.0') < 0 +version_compare(bundle_version, '6.0') < 0 ``` `ee/maintained-apps/outputs/steam/darwin.json` was regenerated with `go run ./cmd/maintained-apps -slug steam/darwin` — one line changed, no upstream version drift pulled in. ## Why scoped to one app The issue suggested changing the shared darwin version column in `pkg/patch_policy` to `COALESCE(NULLIF(bundle_short_version, ''), bundle_version)`. I didn't do that. It would be a no-op for the ~300 macOS FMAs that do set a short version, but that generator is load-bearing for every one of them, and the blast radius isn't justified by a single broken app. The per-app override is the established mechanism for exactly this. Side note for a possible follow-up: `patch_policy_path` exists in both the homebrew and winget input structs but is never read anywhere — a dead field. If we want a data-driven way to express these overrides instead of token checks in Go, that's the hook. ## Reviewer note: existing deployments do not self-heal `software_installers.patch_query` is snapshotted when the installer is created, and only refreshes on an FMA version change or an "Edit software" save. **Steam's cask version is a static `6.0`**, so this manifest change alone will not fix already-deployed Steam FMAs — the admin has to re-add or re-save the app. A GitOps re-apply doesn't help either; `ApplyPolicySpecs` regenerates from the stale installer row. Closing that gap means either a migration that rewrites stored patch queries, or refreshing `patch_query` when the manifest changes at the same version. Both are broader calls than this bug, so I left them out — happy to file a follow-up if you want it tracked. The exists query is unaffected — it matches on `bundle_identifier` only, with no version predicate. That's why install detection and self-service always worked correctly for Steam. # Checklist for submitter - [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. - [x] 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. No new interpolation surface: the override formats the same bundle identifier and cask version the surrounding generator already formats. ## Testing - [x] Added/updated automated tests New `steam` case in `TestIngestApps` asserting both the patched and exists queries. - [x] QA'd all new/changed functionality manually Validated with osquery **5.23.1** — the same version as in the bug report. `version_compare('', '6.0')` returns `-1` and `version_compare('6.0', '6.0')` returns `0`, confirming the root cause directly. For an end-to-end check against the real `apps` table without planting a fake Steam.app on a Fleet-enrolled host, I used an already-installed app with the identical shape (`com.citrix.HDXCast`: empty `bundle_short_version`, `bundle_version` `24.05.0.3`): | Query | Host state | Result | |---|---|---| | exists | app installed | row → detected ✅ (unaffected by the bug) | | **old** patched | up to date | **no row → policy FAILS** ← reproduces the bug | | **new** patched | up to date | row → policy PASSES ✅ | | **new** patched | genuinely outdated (available `25.0.0`) | no row → policy FAILS ✅ | | **new** patched, verbatim from the regenerated manifest | Steam not installed | row → PASSES ✅ | The fourth row is the important one: the fix is not a blanket pass — it still fails hosts that are genuinely behind. Not verified: a live host with Steam actually installed (I don't have one). The `com.citrix.HDXCast` row has byte-identical column semantics, so I'm confident, but a QA pass on a real Steam host would close it out. `go test ./cmd/maintained-apps/... ./pkg/patch_policy/... ./ee/maintained-apps/...` passes; `go vet` and `gofmt` clean. I could not run `make lint-go-incremental` locally — it builds a custom golangci-lint via `git clone`, which my sandbox blocked, so I'm relying on CI for that. ## Summary by CodeRabbit * **Bug Fixes** * Improved Steam patch detection on macOS by using the correct application version information. * Steam updates are now accurately recognized in Fleet software inventory and Homebrew-generated patch policies. --- changes/50408-steam-patch-policy-bundle-version | 1 + ee/maintained-apps/ingesters/homebrew/ingester.go | 12 ++++++++++++ .../ingesters/homebrew/ingester_test.go | 12 +++++++++++- ee/maintained-apps/outputs/steam/darwin.json | 2 +- 4 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 changes/50408-steam-patch-policy-bundle-version diff --git a/changes/50408-steam-patch-policy-bundle-version b/changes/50408-steam-patch-policy-bundle-version new file mode 100644 index 0000000000..cf04fc2849 --- /dev/null +++ b/changes/50408-steam-patch-policy-bundle-version @@ -0,0 +1 @@ +* Fixed the macOS "Steam up to date" patch policy always failing on hosts that have the current version of Steam installed. Steam.app ships without a `CFBundleShortVersionString`, so the generated policy now compares `CFBundleVersion`, which agrees with the version Fleet reports in software inventory. diff --git a/ee/maintained-apps/ingesters/homebrew/ingester.go b/ee/maintained-apps/ingesters/homebrew/ingester.go index 03d098d4c2..dc4b8fb048 100644 --- a/ee/maintained-apps/ingesters/homebrew/ingester.go +++ b/ee/maintained-apps/ingesters/homebrew/ingester.go @@ -250,6 +250,18 @@ func (i *brewIngester) ingestOne(ctx context.Context, input inputApp) (*maintain out.UniqueIdentifier, out.Version, ) } + if input.Token == "steam" { + // Steam.app ships without a CFBundleShortVersionString, so osquery's + // bundle_short_version is empty and version_compare('', '') < 0 is + // always true — the default patch policy can never pass on a host that has + // Steam installed. Compare bundle_version (CFBundleVersion, "6.0", which the + // cask version tracks); that's also the value software inventory falls back + // to, so patch status and inventory agree. + out.Queries.Patched = fmt.Sprintf( + "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps WHERE bundle_identifier = '%s' AND version_compare(bundle_version, '%s') < 0);", + out.UniqueIdentifier, out.Version, + ) + } if input.Token == "firefox@developer-edition" { // The bundle reports only the base version ("153.0") for cask version // "153.0b13", so compare CFBundleVersion (encodes the build date, resolved diff --git a/ee/maintained-apps/ingesters/homebrew/ingester_test.go b/ee/maintained-apps/ingesters/homebrew/ingester_test.go index d701c70ef6..f258f8521a 100644 --- a/ee/maintained-apps/ingesters/homebrew/ingester_test.go +++ b/ee/maintained-apps/ingesters/homebrew/ingester_test.go @@ -87,7 +87,7 @@ func TestIngestValidations(t *testing.T) { Version: "1.0", } - case "ok", "docker-desktop", "swiftdialog", "install_script_path", "uninstall_script_path", "uninstall_script_path_with_pre", "uninstall_script_path_with_post", "patch_policy_path": + case "ok", "docker-desktop", "steam", "swiftdialog", "install_script_path", "uninstall_script_path", "uninstall_script_path_with_pre", "uninstall_script_path_with_post", "patch_policy_path": cask = brewCask{ Token: appToken, Name: []string{appToken}, @@ -145,6 +145,7 @@ func TestIngestValidations(t *testing.T) { {"", inputApp{Token: "docker-desktop", UniqueIdentifier: "com.electron.dockerdesktop", InstallerFormat: "dmg", Name: "Docker Desktop", Slug: "docker-desktop/darwin"}}, {"", inputApp{Token: "firefox@developer-edition", UniqueIdentifier: "org.mozilla.firefoxdeveloperedition", InstallerFormat: "dmg", Name: "Mozilla Firefox Developer Edition", Slug: "firefox@developer-edition/darwin"}}, {"", inputApp{Token: "firefox@nightly", UniqueIdentifier: "org.mozilla.nightly", InstallerFormat: "dmg", Name: "Mozilla Firefox Nightly", Slug: "firefox@nightly/darwin"}}, + {"", inputApp{Token: "steam", UniqueIdentifier: "com.valvesoftware.steam", InstallerFormat: "dmg", Name: "Steam", Slug: "steam/darwin"}}, {"", inputApp{Token: "swiftdialog", UniqueIdentifier: "au.csiro.dialog", InstallerFormat: "pkg", Name: "swiftDialog", Slug: "swiftdialog/darwin"}}, {"", inputApp{Token: "install_script_path", UniqueIdentifier: "abc", InstallerFormat: "pkg", InstallScriptPath: path.Join(tempDir, "install_script.sh")}}, {"", inputApp{Token: "uninstall_script_path", UniqueIdentifier: "abc", InstallerFormat: "pkg", UninstallScriptPath: path.Join(tempDir, "uninstall_script.sh")}}, @@ -200,6 +201,15 @@ func TestIngestValidations(t *testing.T) { "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps WHERE bundle_identifier = 'org.mozilla.nightly' AND version_compare(bundle_version, '15426.7.17') < 0);", out.Queries.Patched, ) + case "steam": + // Steam.app has no CFBundleShortVersionString, so the patched query + // compares CFBundleVersion; the exists query is unaffected because it + // only matches on bundle identifier. + require.Equal(t, "SELECT 1 FROM apps WHERE bundle_identifier = 'com.valvesoftware.steam';", out.Queries.Exists) + require.Equal(t, + "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps WHERE bundle_identifier = 'com.valvesoftware.steam' AND version_compare(bundle_version, '1.0') < 0);", + out.Queries.Patched, + ) case "swiftdialog": require.Equal(t, "SELECT 1 FROM apps WHERE bundle_identifier = 'au.csiro.dialog' AND path != '/opt/orbit/bin/swiftDialog/macos/stable/Dialog.app';", out.Queries.Exists) require.Equal(t, diff --git a/ee/maintained-apps/outputs/steam/darwin.json b/ee/maintained-apps/outputs/steam/darwin.json index f604c5f535..2f2c8460bc 100644 --- a/ee/maintained-apps/outputs/steam/darwin.json +++ b/ee/maintained-apps/outputs/steam/darwin.json @@ -4,7 +4,7 @@ "version": "6.0", "queries": { "exists": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.valvesoftware.steam';", - "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps WHERE bundle_identifier = 'com.valvesoftware.steam' AND version_compare(bundle_short_version, '6.0') < 0);" + "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps WHERE bundle_identifier = 'com.valvesoftware.steam' AND version_compare(bundle_version, '6.0') < 0);" }, "installer_url": "https://cdn.cloudflare.steamstatic.com/client/installer/steam.dmg", "install_script_ref": "45a6174b",