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)) + }) + } }