From efc999cc7803d589c7d1de8ce7409a713d8ed432 Mon Sep 17 00:00:00 2001 From: Allen Houchins Date: Fri, 7 Aug 2026 22:34:12 -0500 Subject: [PATCH] Add process_names input for Windows FMA "app open" queries The Windows "app open" pre-install query (FMAQueries.Open) that gates patching a running app had no per-app source of truth for the process name. It came from a hard-coded windowsOpenQueryOverrides map keyed by catalog name (51 of 434 winget inputs), falling back to guessing ".exe" for everything else. A wrong process name fails green: NOT EXISTS over a predicate that never matches is always true, so the app always reads as "closed" and Fleet patches over a running app with no error anywhere. Add a process_names array to the winget input JSON, so the names can be captured from the installer at authoring time alongside the other identity fields, instead of requiring an edit to a shared map in another package. Entries are validated at ingest (must be a bare file name ending in .exe, or in "*" for a prefix match) rather than best-effort corrected, since a malformed name is invisible at runtime. Multiple exact names collapse to IN (...); a prefix entry becomes LIKE; mixing the two yields a parenthesized OR. Precedence is process_names, then the override map, then the guess. This also folds in #50825, which touches the same function: a multi-word catalog name with no process_names and no override now emits no open query at all rather than an unmatchable guess, and Open is omitempty. That PR is superseded by this one. The two compose so that process_names is what gives a multi-word app a working gate. macOS needs none of this and passes nil: its open query joins apps.path to processes.path, so the bundle identifier already resolves the running process. Windows has no equivalent because programs.install_location is unreliable, especially for MSI installers. 7-zip is included as a worked example, with the two process names read out of its MSI File table. It regenerates byte-identically to its previous curated map entry. The new-fma skill and the contributor README document how to read process names out of each installer type (msiinfo for MSI, AppxManifest for MSIX, 7zz for NSIS), which tools fail and where (innoextract only supports through Inno 6.0.5), and to fall back to the validator log rather than guessing. --- .claude/skills/new-fma/SKILL.md | 55 ++++++++- changes/windows-fma-process-names-input | 1 + .../windows-open-query-drop-multiword-guess | 1 + ee/maintained-apps/README.md | 1 + .../ingesters/homebrew/ingester.go | 4 +- .../ingesters/winget/ingester.go | 13 ++- ee/maintained-apps/inputs/winget/7-zip.json | 1 + ee/maintained-apps/maintained_apps.go | 2 +- ee/maintained-apps/outputs/7-zip/windows.json | 3 +- pkg/patch_policy/patch_policy.go | 105 ++++++++++++++++-- pkg/patch_policy/patch_policy_test.go | 104 +++++++++++++++-- 11 files changed, 266 insertions(+), 24 deletions(-) create mode 100644 changes/windows-fma-process-names-input create mode 100644 changes/windows-open-query-drop-multiword-guess diff --git a/.claude/skills/new-fma/SKILL.md b/.claude/skills/new-fma/SKILL.md index 5ac73be5e66..a4539b20ed2 100644 --- a/.claude/skills/new-fma/SKILL.md +++ b/.claude/skills/new-fma/SKILL.md @@ -31,6 +31,8 @@ Real examples from this codebase where the metadata lied: ```bash brew install msitools # provides msiinfo for MSI inspection (macOS dev box) +brew install sevenzip # provides 7zz, for listing NSIS installer payloads +brew install innoextract # Inno installers — only supports up to Inno 6.0.5, see toolkit #4 gh auth status # gh CLI for reading winget-pkgs manifests ``` @@ -76,7 +78,44 @@ hdiutil detach "$MP" >/dev/null; rm -f app.dmg ``` (For pkg-format casks, the bundle id is harder to read offline — the cask `zap`/`uninstall` `pkgutil`/`launchctl`/`savedState` paths are strong hints, e.g. `.savedState`.) -### 4. Silent install/uninstall flags — use documented sources, never guess +### 4. Capture the process names (Windows only) — for the `open` query + +Windows FMAs need `process_names`: the executables the app runs as. Fleet uses them to build the `open` query that stops a patch from landing on top of a running app. **macOS needs nothing here** — its open query joins `apps.path` to `processes.path`, so the bundle id already covers it. Windows has no equivalent (`programs.install_location` is unreliable), so the names must be captured by hand. + +Get them from the installer you already downloaded for identity verification, by installer type: + +```bash +# msi — Shortcut table names the primary exe; File table lists all of them +msiinfo export app.msi Shortcut | awk -F'\t' '{print $3"\t"$5}' # → "7-Zip File Manager [#_7zFM.exe]" +msiinfo export app.msi File | awk -F'\t' 'tolower($3) ~ /\.exe/ {print $3"\t"$4}' | sort -k2 -rn + +# msix — AppxManifest is authoritative (msix is a zip). Check content-length first, these are big. +unzip -p app.msix AppxManifest.xml | tr '>' '>\n' | grep -i "-silent-install-how-to-guide/`. - Cross-check the vendor's own docs. @@ -92,12 +131,14 @@ hdiutil detach "$MP" >/dev/null; rm -f app.dmg ### Windows (winget) 1. Read the winget manifests (toolkit #1). Pick **machine** scope, **x64** (or the only arch available — some apps are x86-only). 2. **Inspect the MSI** (toolkit #2) to confirm DisplayName, Publisher, version, codes, and to detect bootstrappers. -3. Create `ee/maintained-apps/inputs/winget/.json`: +3. **Capture the process names** (toolkit #4) from the same installer, while you have it. +4. Create `ee/maintained-apps/inputs/winget/.json`: - `name` (catalog display, can be friendly), `slug` (`/windows`), `package_identifier`, `unique_identifier` (= verified DisplayName), `installer_arch`, `installer_type`, `installer_scope`, `default_categories`. + - `process_names` (verified in step 3). - `program_publisher` if registry Publisher ≠ winget locale Publisher. - `fuzzy_match_name` / `exists_query` as needed (below). - `install_script_path` / `uninstall_script_path` for any non-MSI-machine installer. -4. Generate, add description, check icon. +5. Generate, add description, check icon. ### Installer type mapping (winget `InstallerType` → FMA `installer_type` + silent flags) | winget type | FMA type | install silent | uninstall | @@ -129,6 +170,7 @@ go run cmd/maintained-apps/main.go --slug="/" --debug | `program_publisher` (winget) | Overrides the exists-query publisher when registry Publisher ≠ winget locale Publisher. | | `fuzzy_match_name` (winget) | `true` → `name LIKE ' %'`. A string → `name LIKE ''` 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`). | +| `process_names` (winget) | Executables the app runs as, e.g. `["7zFM.exe","7zG.exe"]`. Builds the `open` query. An entry may end in `*` for a prefix match (`"1password*"`). Every entry must end in `.exe` or `*`, with no path — the ingester hard-errors otherwise. Windows only; darwin derives it from the bundle id. | | `installer_scope` | Must match the winget manifest's Scope — you can't pick machine if only user exists. | `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). @@ -158,11 +200,16 @@ if ($u -match '^\s*"([^"]+)"\s*(.*)$') { # quoted - Corretto 21 and 25 both register as `Amazon Corretto (x64)` — pin each with `exists_query ... AND version LIKE '.%'`. - IntelliJ Ultimate's DisplayName `IntelliJ IDEA ` also matches Community's `IntelliJ IDEA Community Edition ` — exclude siblings in `exists_query` (`AND name NOT LIKE 'IntelliJ IDEA Community%'`) or use a custom `fuzzy_match_name` pattern. -**7. Non-pinned installer URLs.** Some manifests point at a "latest" redirect (e.g. `link.gotomeeting.com/latest-msi`). The pinned SHA drifts when the vendor ships a new build, breaking Fleet installs until the FMA auto-update bumps it. Note this in the PR. +**7. A wrong process name fails green.** The open query is `SELECT 1 WHERE NOT EXISTS (... FROM processes WHERE )`. If the predicate names a process that never exists, `NOT EXISTS` is always true, so the app always reads as "closed" and the patch installs over a running app — no error, anywhere. That's why `process_names` is worth the extra minute with the installer, and why the ingester rejects malformed entries instead of correcting them. + +The `.exe` fallback only fires for **single-word** catalog names; a multi-word name with no `process_names` and no override emits **no open query at all** (better no gate than a fake one). So for any app whose name contains a space, `process_names` is the only way it gets this feature. + +**8. Non-pinned installer URLs.** Some manifests point at a "latest" redirect (e.g. `link.gotomeeting.com/latest-msi`). The pinned SHA drifts when the vendor ships a new build, breaking Fleet installs until the FMA auto-update bumps it. Note this in the PR. ## Pre-ship checklist - [ ] Identity fields verified against the real installer (MSI Property table / Info.plist), not guessed. - [ ] `unique_identifier` = registry DisplayName / bundle id; `program_publisher` set if needed. +- [ ] Windows: `process_names` captured from the installer (toolkit #4), or its absence explained in the PR. - [ ] Silent install/uninstall flags from winget `InstallerSwitches` or silentinstallhq, not invented. - [ ] Custom uninstall (non-MSI-machine) uses the defensive UninstallString parser. - [ ] Version reconciles with osquery (or a documented validator exception applies — not a blanket skip). diff --git a/changes/windows-fma-process-names-input b/changes/windows-fma-process-names-input new file mode 100644 index 00000000000..d00e9b1b1a9 --- /dev/null +++ b/changes/windows-fma-process-names-input @@ -0,0 +1 @@ +- Added a `process_names` field to Windows Fleet-maintained app inputs, so an app's "app open" pre-install check is built from the executables verified in its installer instead of a guessed `.exe`. diff --git a/changes/windows-open-query-drop-multiword-guess b/changes/windows-open-query-drop-multiword-guess new file mode 100644 index 00000000000..157fda8b078 --- /dev/null +++ b/changes/windows-open-query-drop-multiword-guess @@ -0,0 +1 @@ +- Fixed Fleet-maintained apps on Windows generating an "app open" pre-install check that could never match: multi-word app names without a known process name (e.g. "Mozilla Firefox", "Microsoft Visual C++ 2015-2022 Redistributable (x64)") no longer produce a guessed `.exe` process check. Apps with a curated process-name mapping keep their check. diff --git a/ee/maintained-apps/README.md b/ee/maintained-apps/README.md index d7b933910a3..1c0476470d7 100644 --- a/ee/maintained-apps/README.md +++ b/ee/maintained-apps/README.md @@ -124,6 +124,7 @@ go run cmd/maintained-apps/main.go --slug="box-drive/windows" --debug | `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. | +| `process_names` | array of strings | The executables the app runs as, e.g. `["7zFM.exe", "7zG.exe"]`. Fleet uses these to detect that the app is open, so it can hold a patch back until the user closes it. Read them from the installer: `msiinfo export app.msi File` (msi), `unzip -p app.msix AppxManifest.xml` (msix), or `7zz l app.exe` (NSIS exe). List the executables a user actually has open, not background updaters. An entry may end in `*` to match a prefix (e.g. `"1password*"`) for apps that run many differently-named helper processes. Every entry must end in `.exe` or `*`, and must be a bare file name with no directory. If omitted, Fleet falls back to `.exe` for single-word app names, and generates no check at all for multi-word names — so an app like "Mozilla Firefox" only gets this feature if you set this field. | | `requires_client_os` | boolean | Set to `true` when the installer refuses to run on Windows Server SKUs (e.g., Dell Display and Peripheral Manager). Fleet's ingestion ignores this field; CI reads it to route validation to the `windows-11-arm` runner (the only GitHub-hosted client-OS Windows runner) instead of the default Windows Server x64 runner. | #### Windows troubleshooting diff --git a/ee/maintained-apps/ingesters/homebrew/ingester.go b/ee/maintained-apps/ingesters/homebrew/ingester.go index 0bf04648417..d63c37854e7 100644 --- a/ee/maintained-apps/ingesters/homebrew/ingester.go +++ b/ee/maintained-apps/ingesters/homebrew/ingester.go @@ -293,7 +293,9 @@ func (i *brewIngester) ingestOne(ctx context.Context, input inputApp) (*maintain } } - out.Queries.Open = patch_policy.GenerateOpenQuery("darwin", out.UniqueIdentifier, "") + // Process names are a windows-only input: darwin resolves running processes from the app + // bundle's install path, so it needs no per-app data. + out.Queries.Open = patch_policy.GenerateOpenQuery("darwin", out.UniqueIdentifier, "", nil) return out, nil } diff --git a/ee/maintained-apps/ingesters/winget/ingester.go b/ee/maintained-apps/ingesters/winget/ingester.go index 7ee5ed99da8..db63873d486 100644 --- a/ee/maintained-apps/ingesters/winget/ingester.go +++ b/ee/maintained-apps/ingesters/winget/ingester.go @@ -85,6 +85,10 @@ func IngestApps(ctx context.Context, logger *slog.Logger, inputsPath string, slu return nil, ctxerr.NewWithData(ctx, "missing package identifier for app", map[string]any{"file_name": f.Name()}) } + if err := patch_policy.ValidateProcessNames(input.ProcessNames); err != nil { + return nil, ctxerr.WrapWithData(ctx, err, "invalid process_names for app", map[string]any{"file_name": f.Name()}) + } + if slugFilter != "" && !strings.Contains(input.Slug, slugFilter) { continue } @@ -479,7 +483,7 @@ func (i *wingetIngester) ingestOne(ctx context.Context, input inputApp) (*mainta return nil, ctxerr.Wrap(ctx, err, "creating patch policy") } - out.Queries.Open = patch_policy.GenerateOpenQuery("windows", "", out.Name) + out.Queries.Open = patch_policy.GenerateOpenQuery("windows", "", out.Name, input.ProcessNames) return &out, nil } @@ -654,6 +658,13 @@ type inputApp struct { DefaultCategories []string `json:"default_categories"` Frozen bool `json:"frozen"` PatchPolicyPath string `json:"patch_policy_path"` + // ProcessNames lists the executables the app runs as, verified from the installer (MSI File + // or Shortcut table, MSIX AppxManifest, Inno/NSIS header). It drives the "open" query that + // gates patching a running app. An entry may end in "*" for a prefix match, for apps with + // many helper processes (e.g. "1password*"). When unset, the app falls back to the curated + // overrides, then to guessing ".exe" for single-word names only — so a multi-word app + // with no process_names gets no gate at all. + ProcessNames []string `json:"process_names,omitempty"` // RequiresClientOS marks installers that refuse to run on Windows Server // SKUs (e.g. Dell Display and Peripheral Manager). The ingester ignores it; // CI (.github/scripts/partition-fma-apps.sh) reads it to route validation to diff --git a/ee/maintained-apps/inputs/winget/7-zip.json b/ee/maintained-apps/inputs/winget/7-zip.json index 0142a3e21a2..1f2daa2f4d0 100644 --- a/ee/maintained-apps/inputs/winget/7-zip.json +++ b/ee/maintained-apps/inputs/winget/7-zip.json @@ -7,5 +7,6 @@ "installer_arch": "x64", "installer_type": "msi", "installer_scope": "machine", + "process_names": ["7zFM.exe", "7zG.exe"], "default_categories": ["Productivity"] } diff --git a/ee/maintained-apps/maintained_apps.go b/ee/maintained-apps/maintained_apps.go index 18a49684080..646448d23fd 100644 --- a/ee/maintained-apps/maintained_apps.go +++ b/ee/maintained-apps/maintained_apps.go @@ -19,7 +19,7 @@ const OutputPath = "ee/maintained-apps/outputs" type FMAQueries struct { Exists string `json:"exists"` Patched string `json:"patched"` - Open string `json:"open"` + Open string `json:"open,omitempty"` } type FMAManifestApp struct { diff --git a/ee/maintained-apps/outputs/7-zip/windows.json b/ee/maintained-apps/outputs/7-zip/windows.json index c0aee738e70..5ed970288a4 100644 --- a/ee/maintained-apps/outputs/7-zip/windows.json +++ b/ee/maintained-apps/outputs/7-zip/windows.json @@ -4,7 +4,8 @@ "version": "26.02", "queries": { "exists": "SELECT 1 FROM programs WHERE name LIKE '7-Zip %' AND publisher = 'Igor Pavlov';", - "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name LIKE '7-Zip %' AND publisher = 'Igor Pavlov' AND version_compare(version, '26.02') < 0);" + "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name LIKE '7-Zip %' AND publisher = 'Igor Pavlov' AND version_compare(version, '26.02') < 0);", + "open": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM processes WHERE LOWER(name) IN ('7zfm.exe','7zg.exe'));" }, "installer_url": "https://github.com/ip7z/7zip/releases/download/26.02/7z2602-x64.msi", "install_script_ref": "22e48c46", diff --git a/pkg/patch_policy/patch_policy.go b/pkg/patch_policy/patch_policy.go index fa76966ba19..adfcc9a6a7d 100644 --- a/pkg/patch_policy/patch_policy.go +++ b/pkg/patch_policy/patch_policy.go @@ -151,17 +151,44 @@ func defaultWindowsQuery(softwareTitle string, version string) string { } // 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 { +// +// processNames holds the process names verified from the app's installer (see the winget input +// field of the same name). It is only used on windows, where there is no reliable way to derive +// the running process from the installed app; darwin resolves it from the bundle identifier. +func GenerateOpenQuery(platform string, bundleIdentifier string, softwareTitle string, processNames []string) string { switch platform { case "darwin": return defaultMacOSOpenQuery(bundleIdentifier) case "windows": - return defaultWindowsOpenQuery(softwareTitle) + return defaultWindowsOpenQuery(softwareTitle, processNames) default: return "" } } +// ValidateProcessNames checks author-supplied Windows process names before they're baked into an +// open query. A malformed name yields a query that never matches a running process, which makes +// the "patch when closed" gate silently pass while the app is open, so this rejects rather than +// best-effort corrects. +func ValidateProcessNames(processNames []string) error { + for _, name := range processNames { + trimmed := strings.TrimSpace(name) + switch { + case trimmed == "": + return errors.New("process name cannot be empty") + case trimmed == "*": + return errors.New(`process name cannot be "*": it matches every process, so the app would always look open`) + case strings.ContainsAny(trimmed, `\/`): + return fmt.Errorf("process name %q cannot contain a path: osquery's processes.name is a bare file name", name) + case strings.Contains(trimmed, "%"): + return fmt.Errorf(`process name %q cannot contain "%%": use a trailing "*" for a prefix match`, name) + case !strings.HasSuffix(strings.ToLower(trimmed), ".exe") && !strings.HasSuffix(trimmed, "*"): + return fmt.Errorf(`process name %q must end in ".exe", or in "*" for a prefix match`, name) + } + } + return nil +} + 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. @@ -173,18 +200,80 @@ func defaultMacOSOpenQuery(bundleIdentifier string) string { return fmt.Sprintf(openTemplate, escapeSQLLiteral(bundleIdentifier)) } -func defaultWindowsOpenQuery(softwareTitle string) string { +const windowsOpenTemplate = "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM processes WHERE %s);" + +func defaultWindowsOpenQuery(softwareTitle string, processNames []string) string { + // Process names verified from the installer win: they're per-app data reviewed alongside the + // rest of the app's identity fields, unlike the two fallbacks below. + if condition := processNameCondition(processNames); condition != "" { + return fmt.Sprintf(windowsOpenTemplate, condition) + } + 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) + return fmt.Sprintf(windowsOpenTemplate, "LOWER(name) "+query) + } + + // Multi-word catalog names ("Mozilla Firefox", "XnSoft XnConvert", "Microsoft + // Visual C++ 2015-2022 Redistributable (x64)") almost never equal the process + // image name — vendor prefixes, editions, and version suffixes produce a query + // that can never match, which silently defeats the app-open gate. Runtime, + // driver, and redistributable packages have no user-facing process at all. + // Emit no open query rather than a wrong one; set process_names on the app's + // input (or add a windowsOpenQueryOverrides entry) when the real binary name is known. + if strings.Contains(softwareTitle, " ") { + return "" } - // Match a process named ".exe" + // Match a process named "<title>.exe". This is still a guess, and a wrong one is invisible: + // the query matches nothing, so the app always looks closed. Prefer process_names. // 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)) + return fmt.Sprintf(windowsOpenTemplate, "LOWER(name) = '"+escapeSQLLiteral(executable)+"'") +} + +// processNameCondition builds the processes.name predicate for a set of verified process names, +// or "" if none were supplied. An entry ending in "*" becomes a prefix match, for apps that spawn +// a fleet of differently-named helpers (e.g. "1password*"); every other entry matches exactly. +func processNameCondition(processNames []string) string { + var exact, prefixes []string + seen := make(map[string]struct{}, len(processNames)) + for _, name := range processNames { + name = strings.ToLower(strings.TrimSpace(name)) + if name == "" { + continue + } + if _, dup := seen[name]; dup { + continue + } + seen[name] = struct{}{} + + if prefix, isPrefix := strings.CutSuffix(name, "*"); isPrefix { + prefixes = append(prefixes, "LOWER(name) LIKE '"+escapeSQLLiteral(prefix)+"%'") + continue + } + exact = append(exact, "'"+escapeSQLLiteral(name)+"'") + } + + var clauses []string + switch len(exact) { + case 0: + case 1: + clauses = append(clauses, "LOWER(name) = "+exact[0]) + default: + clauses = append(clauses, "LOWER(name) IN ("+strings.Join(exact, ",")+")") + } + clauses = append(clauses, prefixes...) + + switch len(clauses) { + case 0: + return "" + case 1: + return clauses[0] + default: + // Parenthesized so the ORs stay grouped if this predicate ever gains a sibling clause. + return "(" + strings.Join(clauses, " OR ") + ")" + } } func escapeSQLLiteral(s string) string { diff --git a/pkg/patch_policy/patch_policy_test.go b/pkg/patch_policy/patch_policy_test.go index 2052e15de22..e1f79d94bd7 100644 --- a/pkg/patch_policy/patch_policy_test.go +++ b/pkg/patch_policy/patch_policy_test.go @@ -73,32 +73,120 @@ func TestGenerateQueryForManifest(t *testing.T) { func TestGenerateOpenQuery(t *testing.T) { // macOS resolves the app's install path from its bundle identifier and matches a process // running from inside it. - got := patch_policy.GenerateOpenQuery("darwin", "org.mozilla.firefox", "") + got := patch_policy.GenerateOpenQuery("darwin", "org.mozilla.firefox", "", nil) require.Equal(t, "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 = 'org.mozilla.firefox');", got) // Apostrophes in the bundle identifier are escaped so they can't break the literal. - got = patch_policy.GenerateOpenQuery("darwin", "com.oreilly.o'reilly", "") + got = patch_policy.GenerateOpenQuery("darwin", "com.oreilly.o'reilly", "", nil) require.Equal(t, "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 = 'com.oreilly.o''reilly');", got) // Windows matches a process named "<title>.exe". - got = patch_policy.GenerateOpenQuery("windows", "", "Slack") + got = patch_policy.GenerateOpenQuery("windows", "", "Slack", nil) require.Equal(t, "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM processes WHERE LOWER(name) = 'slack.exe');", got) // An apostrophe in the derived executable is escaped. - got = patch_policy.GenerateOpenQuery("windows", "", "O'Reilly") + got = patch_policy.GenerateOpenQuery("windows", "", "O'Reilly", nil) require.Equal(t, "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM processes WHERE LOWER(name) = 'o''reilly.exe');", got) // A per-app override (keyed by software title) supplies the process-name predicate, in any of // its forms: LIKE, exact, or IN. - got = patch_policy.GenerateOpenQuery("windows", "", "OneDrive") + got = patch_policy.GenerateOpenQuery("windows", "", "OneDrive", nil) require.Equal(t, "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM processes WHERE LOWER(name) LIKE 'onedrive%');", got) - got = patch_policy.GenerateOpenQuery("windows", "", "Google Chrome") + got = patch_policy.GenerateOpenQuery("windows", "", "Google Chrome", nil) require.Equal(t, "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM processes WHERE LOWER(name) = 'chrome.exe');", got) - got = patch_policy.GenerateOpenQuery("windows", "", "Microsoft Teams") + got = patch_policy.GenerateOpenQuery("windows", "", "Microsoft Teams", nil) require.Equal(t, "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM processes WHERE LOWER(name) IN ('teams.exe','ms-teams.exe'));", got) + // A multi-word title without an override yields no query: the derived + // "<title>.exe" ("xnsoft xnconvert.exe") would never match a real process, + // silently defeating the app-open gate. + require.Empty(t, patch_policy.GenerateOpenQuery("windows", "", "XnSoft XnConvert", nil)) + require.Empty(t, patch_policy.GenerateOpenQuery("windows", "", "Microsoft Visual C++ 2015-2022 Redistributable (x64)", nil)) + // Unknown platform yields no query. - require.Empty(t, patch_policy.GenerateOpenQuery("linux", "com.example.foo", "")) + require.Empty(t, patch_policy.GenerateOpenQuery("linux", "com.example.foo", "", nil)) +} + +func TestGenerateOpenQueryWithProcessNames(t *testing.T) { + t.Parallel() + + windows := func(title string, processNames []string) string { + return patch_policy.GenerateOpenQuery("windows", "", title, processNames) + } + + // A single verified process name. + require.Equal(t, + "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM processes WHERE LOWER(name) = 'acrobat.exe');", + windows("Adobe Acrobat Pro", []string{"Acrobat.exe"})) + + // Several collapse into an IN(...), preserving author order. + require.Equal(t, + "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM processes WHERE LOWER(name) IN ('7zfm.exe','7zg.exe'));", + windows("7-zip", []string{"7zFM.exe", "7zG.exe"})) + + // A trailing "*" becomes a prefix match. + require.Equal(t, + "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM processes WHERE LOWER(name) LIKE 'onedrive%');", + windows("OneDrive", []string{"OneDrive*"})) + + // Exact and prefix entries mix into a parenthesized OR. + require.Equal(t, + "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM processes WHERE (LOWER(name) IN ('code.exe','code - insiders.exe') OR LOWER(name) LIKE 'codehelper%'));", + windows("Microsoft Visual Studio Code", []string{"Code.exe", "Code - Insiders.exe", "CodeHelper*"})) + + // Apostrophes are escaped rather than breaking out of the literal. + require.Equal(t, + "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM processes WHERE LOWER(name) = 'o''reilly.exe');", + windows("O'Reilly", []string{"O'Reilly.exe"})) + + // Blank and duplicate entries are dropped. + require.Equal(t, + "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM processes WHERE LOWER(name) = 'slack.exe');", + windows("Slack", []string{"Slack.exe", " ", "slack.exe"})) + + // process_names beats the curated override map... + require.Equal(t, + "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM processes WHERE LOWER(name) = 'new-chrome.exe');", + windows("Google Chrome", []string{"new-chrome.exe"})) + + // ...but an all-blank list falls back to it rather than emitting an empty predicate. + require.Equal(t, + "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM processes WHERE LOWER(name) = 'chrome.exe');", + windows("Google Chrome", []string{" "})) + + // process_names is what gives a multi-word app an open query at all: without it, a title + // with a space and no override yields nothing rather than a guess that can never match. + require.Equal(t, + "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM processes WHERE LOWER(name) = 'xnconvert.exe');", + windows("XnSoft XnConvert", []string{"XnConvert.exe"})) + require.Empty(t, windows("XnSoft XnConvert", nil)) + require.Empty(t, windows("XnSoft XnConvert", []string{" "})) + + // Process names are windows-only; darwin ignores them. + require.Equal(t, + "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 = 'org.mozilla.firefox');", + patch_policy.GenerateOpenQuery("darwin", "org.mozilla.firefox", "", []string{"firefox.exe"})) +} + +func TestValidateProcessNames(t *testing.T) { + t.Parallel() + + require.NoError(t, patch_policy.ValidateProcessNames(nil)) + require.NoError(t, patch_policy.ValidateProcessNames([]string{"7zFM.exe", "7zG.exe"})) + require.NoError(t, patch_policy.ValidateProcessNames([]string{"1password*"})) + + for name, processNames := range map[string][]string{ + "empty entry": {"chrome.exe", ""}, + "bare wildcard": {"*"}, + "windows path": {`C:\Program Files\7-Zip\7zFM.exe`}, + "unix path": {"bin/chrome.exe"}, + "literal percent": {"onedrive%"}, + "missing extension": {"chrome"}, + } { + t.Run(name, func(t *testing.T) { + require.Error(t, patch_policy.ValidateProcessNames(processNames)) + }) + } }