Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .claude/skills/new-fma/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ go run cmd/maintained-apps/main.go --slug="<app>/<platform>" --debug
| `program_publisher` (winget) | Overrides the exists-query publisher when registry Publisher ≠ winget locale Publisher. |
| `fuzzy_match_name` (winget) | `true` → `name LIKE '<unique_identifier> %'`. A string → `name LIKE '<that string>'` verbatim (e.g. `"Mozilla Firefox % ESR %"`, `"IntelliJ IDEA 20%"`). |
| `exists_query` (winget) | Replaces the generated exists query verbatim. The patched query is DERIVED from it (appends `AND version_compare(...) < 0`). |
| `installer_scope` | Must match the winget manifest's Scope — you can't pick machine if only user exists. |
| `installer_scope` | **Required, `machine` or `user`.** Must match the scope the installer actually uses. If the winget manifest declares distinct per-user/per-machine installers (e.g. PowerToys, GIMP), you must pick one it offers. If winget is *silent* on scope (common for NSIS/Inno/burn), the ingester trusts your value — so verify it from the installer's requested execution level (`requireAdministrator`/`highestAvailable` ⇒ machine; `asInvoker` ⇒ user) or MSI `ALLUSERS`, don't guess. Enforced non-empty by `TestInputInstallerScopeIsSet`. |

`patch_policy_path` exists in the input struct but is **dead code** (unused since the patched query became auto-generated). Don't use it; there is no patched-query override other than shaping `exists_query` or a hard-coded per-app branch in the ingester (Docker Desktop precedent).

Expand All @@ -154,6 +154,8 @@ if ($u -match '^\s*"([^"]+)"\s*(.*)$') { # quoted

**5. Scope / SYSTEM context.** Fleet runs installs as SYSTEM (elevated). A per-user installer lands in the SYSTEM profile (useless). Force machine-wide with the installer's all-users switch (`ALLUSERS=1`/`2`, `/ALLUSERS`, `G2MINSTALLFORALLUSERS=1`). A per-user uninstaller likewise can't be reached from a SYSTEM-context script (its ARP entry is in the logged-in user's HKCU).

**5a. Duplicate/stale copies across scopes — never scope-narrow detection ([#48248](https://github.com/fleetdm/fleet/issues/48248)).** `programs` reads both `HKLM` and per-user hives, so the `exists` query (and the patched query derived from it) matches an app at *either* scope. If a host has the app at one scope and the FMA installs at the other, a second copy appears, the stale copy lingers unmanaged, and the scope-blind policy keeps reporting not-patched. **Do NOT fix this by narrowing the query to one hive** — that makes the policy go false-green (reports patched while an unmanaged, unpatched copy remains). Fix it in remediation: the install script removes any *other-scope* copy before installing one canonical copy at the target scope (Pattern A; see PowerToys/GIMP uninstall scripts enumerating both hives), or upgrades the existing scope's copy in place (Pattern B). Flag cross-scope data-loss risk in the PR. Keep `installer_scope`, the scripts, and the detection query consistent. Full guidance: `ee/maintained-apps/README.md` → "Install scope vs. detection".

**6. Multi-version / sibling products sharing a DisplayName.**
- Corretto 21 and 25 both register as `Amazon Corretto (x64)` — pin each with `exists_query ... AND version LIKE '<major>.%'`.
- IntelliJ Ultimate's DisplayName `IntelliJ IDEA <ver>` also matches Community's `IntelliJ IDEA Community Edition <ver>` — exclude siblings in `exists_query` (`AND name NOT LIKE 'IntelliJ IDEA Community%'`) or use a custom `fuzzy_match_name` pattern.
Expand Down
2 changes: 2 additions & 0 deletions changes/48248-windows-fma-install-scope
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- Fixed Windows Fleet-maintained apps installing a duplicate copy at a different scope (per-user vs. per-machine), which left a stale copy behind and kept the patch policy reporting the host as not patched.
- Fixed Windows Fleet-maintained apps packaged as MSIX (Slack, Microsoft Teams, Arc, Claude, Affinity, Windows App) leaving a legacy Win32 copy behind: the install script now removes leftover exe/MSI copies at both scopes before provisioning the MSIX package.
12 changes: 12 additions & 0 deletions cmd/maintained-apps/validate/windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,18 @@ func appExists(ctx context.Context, logger *slog.Logger, appName, uniqueIdentifi

logger.InfoContext(ctx, fmt.Sprintf("Found app: '%s' at %s, Version: %s", result.Name, result.InstallLocation, result.Version))

// "Microsoft Edge WebView2 Runtime" and "Microsoft Edge Update" are
// separate products picked up by the broad LIKE '%Microsoft Edge%'
// search, and Microsoft version-locks WebView2 releases to Edge
// releases. When the runner image's preinstalled WebView2 version
// coincides with the manifest version, the post-uninstall check would
// falsely report Edge as still installed.
if appName == "Microsoft Edge" &&
(strings.Contains(result.Name, "WebView2") || strings.Contains(result.Name, "Edge Update")) {
logger.InfoContext(ctx, fmt.Sprintf("Ignoring '%s': separate product matched by the broad name search", result.Name))
continue
}

// Sublime Text's Inno Setup installer may not write version to registry properly
// If app is found but version is empty, check if it's Sublime Text and skip version check
if appName == "Sublime Text" && result.Version == "" {
Expand Down
20 changes: 19 additions & 1 deletion ee/maintained-apps/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,12 +119,30 @@ go run cmd/maintained-apps/main.go --slug="box-drive/windows" --debug
| `slug` | string | **Required.** Identifies the app/platform combination (e.g., `box-drive/windows`). Used to name manifest files and reference the app in [Fleet's best practice GitOps](https://fleetdm.com/docs/configuration/yaml-files#fleet-maintained-apps). Format: `<app-name>/<platform>`, where app name is filesystem-friendly and platform is `darwin`. |
| `installer_arch` | string | **Required.** `x64` or `x86` (most apps use `x64`). |
| `installer_type` | string | **Required.** `exe`, `msi`, or `msix` (file type, not vendor tech like "wix") |
| `installer_scope` | string | **Required.** `machine` or `user` (prefer `machine` for managed installs) |
| `installer_scope` | string | **Required.** `machine` or `user`. Prefer `machine` for managed installs, but it must match the scope the installer actually uses (see [Install scope vs. detection](#install-scope-vs-detection-per-user-vs-per-machine)). Verify with the installer's requested execution level (`requireAdministrator`/`highestAvailable` ⇒ machine; `asInvoker` ⇒ user) or the winget manifest `Scope`; don't guess. |
| `default_categories` | string | **Required.** Default categories for self-service if none are specified. Valid values: `Browsers`, `Communication`, `Developer Tools`, `Productivity`. |
| `install_script_path` | string | Filepath to a custom install script (`.ps1`). Overrides the generated install script. Script must be placed in `inputs/winget/scripts/`. For `.msi` apps, the ingestor automatically generates install scripts. Do not add scripts unless you need to override the generated behavior. For `.exe` apps, you must provide PowerShell scripts that run the installer file directly. Fleet stores the installer and sends it to the host at install time; your script must execute it using the `INSTALLER_PATH` environment variable. |
| `uninstall_script_path` | string | Filepath to a custom uninstall script (`.ps1`). Overrides the generated uninstall script. Script must be placed in `inputs/winget/scripts/`. For `.msi` apps, the ingestor automatically generates uninstall scripts. Do not add scripts unless you need to override the generated behavior. For `.exe` apps, you must provide a script to uninstall the app. Scripts for `.exe` apps are vendor-specific. Use the vendor’s documented silent uninstall switch or the registered UninstallString (if available), ensuring the script runs silently and returns the installer’s exit code. |
| `fuzzy_match_name` | boolean | If the `unique_identifier` doesn't match the `DisplayName`, use `fuzzy_match_name` to specify that Fleet uses "fuzzy matching" to match the Fleet-maintained app and the inventoried software. For example, for Pritunl, the `unique_identifier` is "Pritunl" and the inventories software's `DisplayName` is "Pritunl Client". With `fuzzy_match_name` set to true, Pritunl app will be matched to the inventories software. |

#### Install scope vs. detection (per-user vs. per-machine)

Windows apps can be installed **per-user** (`HKCU`, `%LOCALAPPDATA%`) or **per-machine** (`HKLM`, `Program Files`), and some ship both. A Fleet-maintained app's patch policy is its `exists` detection query with a version comparison appended, so **detection scope and "is it patched?" scope are the same predicate.** The `programs` osquery table reads both the machine and per-user registry hives, so the default `exists` query matches an app at *either* scope.

This creates a trap when a host has the app at one scope and the FMA installs at the other: a second copy is installed, the stale copy is left behind unmanaged, and because the scope-blind query still sees the stale copy, the policy never reports patched and installs may keep retrying. Reported for Slack, PowerToys, and GIMP (see [#48248](https://github.com/fleetdm/fleet/issues/48248)).

Follow these guardrails:

- **Never scope-narrow the detection query.** Do not "fix" duplicate copies by restricting the `exists` query to one hive (e.g. system-only). That makes the policy go **false-green**: it stops seeing the stale copy and reports patched while an unmanaged, unpatched copy remains on the device. The hard requirement is that the policy must **never** report "patched" while a stale copy remains at *any* scope. Keep the `exists`/`patched` query scope-blind.
- **Fix scope in remediation, not the query.** Set `installer_scope` to the scope the installer actually uses (verify it — see the schema note), and make the install/uninstall scripts converge the device on a single canonical copy:
- **Pattern A — remove-and-replace (default):** the install script first removes any *other-scope* copy, then installs one copy at the target scope. The scope-blind policy then truthfully goes green. (PowerToys and GIMP install scripts enumerate both `HKLM` and `HKCU` to do this.)
- **Pattern B — update-in-place per scope:** detect which scope the existing copy lives in and upgrade *that* one. More data-safe; per-user installs from the SYSTEM context are the hard case (run in the logged-on user's session via a scheduled task — see the Slack MSIX script).
- **MSIX-managed apps: every Win32 copy is legacy.** For apps Fleet manages as MSIX (e.g. Slack, Microsoft Teams), there is no "same-scope" Win32 copy to leave for the installer — a leftover exe/MSI copy at *either* scope keeps the scope-blind policy red while the MSIX is current. Their install scripts sweep **both** Win32 uninstall hives before provisioning, with guards: never touch PackageFullName-style keys or entries under `\WindowsApps\` (so a re-run can't remove the MSIX itself), skip entries with no quiet uninstall path (a raw `UninstallString` run as SYSTEM can hang on UI), and delete a per-user `HKEY_USERS` uninstall key only after verifying the uninstaller removed itself from disk (per-user uninstallers run as SYSTEM can't clean their own key — but deleting the key while files remain would be false-green).
- **Keep installer ↔ detection ↔ uninstaller consistent.** `installer_scope`, the install/uninstall scripts, and the detection query must agree about the app. Avoid over-narrow `name = '…'` matches that could miss a scope variant; use `fuzzy_match_name` or a custom `exists_query` where a package's `DisplayName` differs by scope.
- **Data preservation is a separate guarantee from "no false-green."** A same-scope upgrade preserves data normally. Cross-scope removal (Pattern A) can lose data: config in `%APPDATA%`/`HKCU` for the same packaging is usually **preserved**, Squirrel/Electron local session/cache may be **partially** lost, and cross-packaging to MSIX generally **resets** data. Call this out in the PR when it applies.

`installer_scope` is enforced to be `machine` or `user` for every winget input by `TestInputInstallerScopeIsSet`.

#### Windows troubleshooting

- App not found in Fleet UI: ensure `apps.json` was updated by the generator and your override URL is correct
Expand Down
11 changes: 10 additions & 1 deletion ee/maintained-apps/ingesters/winget/ingester.go
Original file line number Diff line number Diff line change
Expand Up @@ -299,9 +299,18 @@ func (i *wingetIngester) ingestOne(ctx context.Context, input inputApp) (*mainta
installer.InstallerLocale = ""
}

// Scope disambiguates packages that ship distinct per-user and per-machine
// installers (e.g. PowerToys, GIMP): those declare Scope on each installer, so
// exact matching selects the right variant. When the winget manifest is silent
// about scope (derived scope == ""), there is nothing to disambiguate, so we
// trust the input's installer_scope as authoritative. This lets an app set
// installer_scope for correctness/detection even though winget doesn't declare
// it, without failing to find an installer.
scopeMatches := scope == input.InstallerScope || scope == ""

// Check if this installer matches our criteria
matches := installer.Architecture == input.InstallerArch &&
scope == input.InstallerScope &&
scopeMatches &&
installer.InstallerLocale == input.InstallerLocale &&
installerType == input.InstallerType

Expand Down
46 changes: 46 additions & 0 deletions ee/maintained-apps/ingesters/winget/ingester_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,52 @@ func TestIngestValidations(t *testing.T) {
upgradeCode: "{ABCDEF}",
},
},
{
// Many winget manifests (e.g. NSIS/Inno/burn apps like descript,
// electrum, protopie) don't declare a Scope. The input's
// installer_scope must still be honored so scope can be set for
// correctness without failing to find an installer.
name: "scope honored when winget manifest omits scope",
wantErr: "",
inputApp: inputApp{
Name: "Foo",
UniqueIdentifier: "Foo",
PackageIdentifier: "Foo",
InstallerArch: "x64",
Slug: "foo/windows",
InstallScriptPath: path.Join(tempDir, "install_script.ps1"),
UninstallScriptPath: path.Join(tempDir, "uninstall_script.ps1"),
InstallerType: "exe",
InstallerScope: "machine",
},
cfg: serverConfig{
installerType: "exe",
installerScope: "", // winget silent on scope
installerArch: "x64",
},
},
{
// When winget DOES declare a scope, an input scope that disagrees
// must not silently select the wrong-scope installer.
name: "scope mismatch fails when winget declares scope",
wantErr: "failed to find installer for app",
inputApp: inputApp{
Name: "Foo",
UniqueIdentifier: "Foo",
PackageIdentifier: "Foo",
InstallerArch: "x64",
Slug: "foo/windows",
InstallScriptPath: path.Join(tempDir, "install_script.ps1"),
UninstallScriptPath: path.Join(tempDir, "uninstall_script.ps1"),
InstallerType: "exe",
InstallerScope: "machine",
},
cfg: serverConfig{
installerType: "exe",
installerScope: "user", // winget declares user, input wants machine
installerArch: "x64",
},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
Expand Down
51 changes: 51 additions & 0 deletions ee/maintained-apps/ingesters/winget/input_scope_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package winget

import (
"encoding/json"
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// wingetInputsDir is the real inputs directory relative to this package.
const wingetInputsDir = "../../inputs/winget"

// TestInputInstallerScopeIsSet enforces that every winget input declares a valid
// installer_scope ("machine" or "user").
//
// Scope must be explicit because Windows apps can install per-user (HKCU,
// %LOCALAPPDATA%) or per-machine (HKLM, Program Files). An unset scope leaves the
// ingester guessing and, more importantly, prevents remediation scripts from
// knowing which scope's copy to remove/upgrade — the root cause of duplicate/stale
// copies (see https://github.com/fleetdm/fleet/issues/48248).
//
// The detection ("exists") query must stay scope-blind; scope correctness lives in
// the installer_scope field and the install/uninstall scripts, never by narrowing
// the query. See the FMA authoring guide.
func TestInputInstallerScopeIsSet(t *testing.T) {
entries, err := os.ReadDir(wingetInputsDir)
require.NoError(t, err)

checked := 0
for _, e := range entries {
if e.IsDir() || filepath.Ext(e.Name()) != ".json" {
continue
}

path := filepath.Join(wingetInputsDir, e.Name())
b, err := os.ReadFile(path)
require.NoErrorf(t, err, "reading %s", e.Name())

var input inputApp
require.NoErrorf(t, json.Unmarshal(b, &input), "unmarshaling %s", e.Name())

assert.Containsf(t, []string{machineScope, userScope}, input.InstallerScope,
"%s: installer_scope must be %q or %q, got %q", e.Name(), machineScope, userScope, input.InstallerScope)
checked++
}

require.NotZero(t, checked, "expected to check at least one winget input file")
}
7 changes: 5 additions & 2 deletions ee/maintained-apps/inputs/winget/1password.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
"installer_arch": "x64",
"installer_type": "msi",
"installer_scope": "machine",
"default_categories": ["Productivity"],
"default_categories": [
"Productivity"
],
"uninstall_script_path": "ee/maintained-apps/inputs/winget/scripts/1password_uninstall.ps1",
"program_publisher": "Agilebits Inc."
"program_publisher": "Agilebits Inc.",
"install_script_path": "ee/maintained-apps/inputs/winget/scripts/1password_install.ps1"
}
5 changes: 4 additions & 1 deletion ee/maintained-apps/inputs/winget/box-drive.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,8 @@
"installer_arch": "x64",
"installer_type": "msi",
"installer_scope": "machine",
"default_categories": ["Productivity"]
"default_categories": [
"Productivity"
],
"install_script_path": "ee/maintained-apps/inputs/winget/scripts/box-drive_install.ps1"
}
2 changes: 1 addition & 1 deletion ee/maintained-apps/inputs/winget/descript.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"fuzzy_match_name": true,
"installer_arch": "x64",
"installer_type": "exe",
"installer_scope": "",
"installer_scope": "user",
"install_script_path": "ee/maintained-apps/inputs/winget/scripts/descript_install.ps1",
"uninstall_script_path": "ee/maintained-apps/inputs/winget/scripts/descript_uninstall.ps1",
"default_categories": [
Expand Down
5 changes: 4 additions & 1 deletion ee/maintained-apps/inputs/winget/dropbox.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,8 @@
"installer_arch": "x64",
"installer_type": "msi",
"installer_scope": "machine",
"default_categories": ["Productivity"]
"default_categories": [
"Productivity"
],
"install_script_path": "ee/maintained-apps/inputs/winget/scripts/dropbox_install.ps1"
}
2 changes: 1 addition & 1 deletion ee/maintained-apps/inputs/winget/electrum.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"unique_identifier": "Electrum",
"installer_arch": "x64",
"installer_type": "exe",
"installer_scope": "",
"installer_scope": "machine",
"install_script_path": "ee/maintained-apps/inputs/winget/scripts/electrum_install.ps1",
"uninstall_script_path": "ee/maintained-apps/inputs/winget/scripts/electrum_uninstall.ps1",
"default_categories": [
Expand Down
5 changes: 4 additions & 1 deletion ee/maintained-apps/inputs/winget/github-desktop.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,8 @@
"installer_arch": "x64",
"installer_type": "msi",
"installer_scope": "machine",
"default_categories": ["Developer tools"]
"default_categories": [
"Developer tools"
],
"install_script_path": "ee/maintained-apps/inputs/winget/scripts/github-desktop_install.ps1"
}
5 changes: 4 additions & 1 deletion ee/maintained-apps/inputs/winget/google-chrome.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,8 @@
"installer_type": "msi",
"installer_scope": "machine",
"ignore_hash": true,
"default_categories": ["Browsers"]
"default_categories": [
"Browsers"
],
"install_script_path": "ee/maintained-apps/inputs/winget/scripts/google-chrome_install.ps1"
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"exists_query": "SELECT 1 FROM programs WHERE name LIKE 'Microsoft .NET Runtime - 10.%' AND name LIKE '%(x64)' AND publisher = 'Microsoft Corporation';",
"installer_arch": "x64",
"installer_type": "exe",
"installer_scope": "",
"installer_scope": "machine",
"install_script_path": "ee/maintained-apps/inputs/winget/scripts/microsoft_dotnet_runtime_install.ps1",
"uninstall_script_path": "ee/maintained-apps/inputs/winget/scripts/microsoft_dotnet_runtime_uninstall.ps1",
"default_categories": ["Developer tools"]
Expand Down
Loading
Loading