Files
fleet/cmd/maintained-apps/validate/darwin.go
T
Allen Houchins 0e587dc80c Add Firefox Nightly and Firefox Developer Edition Fleet-maintained apps (#49501)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** #49149

Adds **Firefox Nightly** and **Firefox Developer Edition** as
Fleet-maintained apps for macOS and Windows (4 new FMAs). Mozilla VPN
(also requested in #49149) is not included, so this doesn't close the
issue.

## What's included

| App | macOS | Windows |
|-----|-------|---------|
| Firefox Nightly | Homebrew cask `firefox@nightly`, bundle ID
`org.mozilla.nightly` | MSIX `Mozilla.Firefox.Nightly.MSIX` (no exe
package exists in winget) |
| Firefox Developer Edition | Homebrew cask `firefox@developer-edition`,
bundle ID `org.mozilla.firefoxdeveloperedition` | NSIS exe
`Mozilla.Firefox.DeveloperEdition`, machine scope, `/S` |

All identity fields were verified against the real installers, not
catalog metadata: bundle IDs read from the DMGs' `Info.plist`, MSIX
identity ("Firefox Nightly" / publisher "Mozilla Corporation") read from
the package's `AppxManifest.xml`, and DevEd's registry identity
(`Firefox Developer Edition (x64 en-US)`, publisher `Mozilla`,
versionless DisplayName) confirmed in Mozilla's NSIS installer source at
the shipped release tags.

The MSIX install/uninstall scripts follow the established Slack/Claude
pattern (provision machine-wide + register for the logged-on user via
scheduled task).

## Pre-release version handling (macOS)

Mozilla's pre-release bundles report only the base version in
`CFBundleShortVersionString` (`153.0` for cask version `153.0b13`),
which would make the generated patch policy flag every up-to-date
install as outdated. Instead, the patch policies for these two channels
compare **`bundle_version`** (`CFBundleVersion` encodes the build date,
e.g. `15326.7.15`), so individual pre-release builds are
distinguishable:

- **Nightly**: derived directly from the build timestamp embedded in the
cask version (`154.0a1,2026-07-17-09-27-13` → `15426.7.17`). No external
calls.
- **Developer Edition**: build date resolved from Mozilla's
[buildhub](https://buildhub.moz.tools) (indexed as product `firefox`,
channel `aurora`). On buildhub failure the ingester logs a warning and
falls back to a base-version comparison (cycle granularity) rather than
failing the run; it self-corrects on the next scheduled ingestion.

The macOS validator gets a matching exception accepting `base + "b"`
versions for the DevEd bundle ID. Both computed values were verified
against the actual DMGs' `Info.plist`.

**Known limitation (Windows):** patch detection for Developer Edition on
Windows is only accurate to the release cycle (153 vs 154), not between
betas (b13 vs b14) — the registry `DisplayVersion` and winget's
`PackageVersion` are both the base version (`151.0`) with no beta
number, and osquery's `programs` table has nothing finer to compare.
Nightly on Windows is day-granular since its MSIX package version
encodes the build date. Documented in #49149.

## Other changes

- Tightened `firefox_uninstall.ps1` (release channel FMA): its
`*Firefox*` match would have uninstalled Developer Edition (or ESR)
first depending on registry enumeration order. Now matches `Mozilla
Firefox*` and excludes ESR; `firefox/windows` output regenerated.
- Channel-specific icons generated from the real app bundles (frontend
components + website PNGs), with map keys covering both catalog and
inventory names.

## Why Firefox Beta is not included

Beta (requested in #49149) was deliberately dropped:
- On Windows, the Beta exe registers the same identity as release
Firefox (`Mozilla Firefox (x64 en-US)`), making it indistinguishable in
inventory. The only distinct package is `Mozilla.Firefox.Beta.MSIX`,
which has no `en-US` winget locale manifest (only `en`), which the
ingester requires.
- On macOS, Beta shares both the bundle ID (`org.mozilla.firefox`) and
the install path (`/Applications/Firefox.app`) with release Firefox, so
installing it silently replaces a release install.

## Notes for reviewers

- Nightly URLs are date-stamped and refresh with the daily FMA
ingestion; within-cycle Nightly darwin builds share a `CFBundleVersion`
date so same-day rebuilds are indistinguishable.
- DevEd's winget package lags Mozilla's releases (winget latest is 151.0
vs current 153.0b13); the FMA tracks winget as usual.
- The `CFBundleVersion` format (`<major><yy>.<M>.<D>`, unpadded) is an
inferred contract of Mozilla's build system, verified against both
shipped DMGs; if it ever changes, patch status fails visibly (reports
unpatched), not silently.

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [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.
- [x] Timeouts are implemented and retries are limited to avoid infinite
loops

## Testing

- [x] Added/updated automated tests
- [ ] QA'd all new/changed functionality manually


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added deployment support for Mozilla Firefox Developer Edition and
Firefox Nightly on macOS and Windows (including Homebrew/Winget inputs
and platform-specific install/uninstall workflows).
* Added new catalog entries and dedicated UI icons for both Firefox
variants.
* **Bug Fixes**
* Improved macOS “patched” detection and version matching for Developer
Edition and Nightly builds.
* Enhanced Homebrew ingestion by resolving build information (with
fallback when unavailable).
* Refined Windows uninstallation matching to target the correct Firefox
release channels and avoid ESR.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-17 16:05:27 -05:00

340 lines
13 KiB
Go

//go:build darwin
package main
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/fleetdm/fleet/v4/orbit/pkg/constant"
"github.com/fleetdm/fleet/v4/orbit/pkg/scripts"
"github.com/fleetdm/fleet/v4/server/fleet"
queries "github.com/fleetdm/fleet/v4/server/service/osquery_utils"
)
var preInstalled = []string{
"firefox/darwin",
}
func postApplicationInstall(ctx context.Context, appLogger *slog.Logger, appPath string) error {
if appPath == "" {
return nil
}
appLogger.InfoContext(ctx, fmt.Sprintf("Forcing LaunchServices refresh for: '%s'", appPath))
err := forceLaunchServicesRefresh(appPath)
if err != nil {
return fmt.Errorf("Error forcing LaunchServices refresh: %v. Attempting to continue", err)
}
appLogger.InfoContext(ctx, fmt.Sprintf("Attempting to remove quarantine for: '%s'", appPath))
quarantineResult, err := removeAppQuarantine(appPath)
appLogger.InfoContext(ctx, fmt.Sprintf("Quarantine output error: %v", quarantineResult.QuarantineOutputError))
appLogger.InfoContext(ctx, fmt.Sprintf("Quarantine status: %s", quarantineResult.QuarantineStatus))
appLogger.InfoContext(ctx, fmt.Sprintf("Spctl output error: %v", quarantineResult.SpctlOutputError))
appLogger.InfoContext(ctx, fmt.Sprintf("spctl status: %s", quarantineResult.SpctlStatus))
if err != nil {
return fmt.Errorf("Error removing app quarantine: %v. Attempting to continue", err)
}
return nil
}
type QuarantineResult struct {
QuarantineOutputError error
QuarantineStatus string
SpctlOutputError error
SpctlStatus string
}
func removeAppQuarantine(appPath string) (QuarantineResult, error) {
var result QuarantineResult
cmd := exec.Command("xattr", "-p", "com.apple.quarantine", appPath)
output, err := cmd.CombinedOutput()
if err != nil {
result.QuarantineOutputError = fmt.Errorf("checking quarantine status: %v", err)
}
result.QuarantineStatus = fmt.Sprintf("Quarantine status: '%s'", strings.TrimSpace(string(output)))
cmd = exec.Command("spctl", "-a", "-v", appPath)
output, err = cmd.CombinedOutput()
if err != nil {
result.SpctlOutputError = fmt.Errorf("checking spctl status: %v", err)
}
result.SpctlStatus = fmt.Sprintf("spctl status: '%s'", strings.TrimSpace(string(output)))
cmd = exec.Command("sudo", "spctl", "--add", appPath)
if err := cmd.Run(); err != nil {
return result, fmt.Errorf("adding app to quarantine exceptions: %w", err)
}
cmd = exec.Command("sudo", "xattr", "-r", "-d", "com.apple.quarantine", appPath)
if err := cmd.Run(); err != nil {
return result, fmt.Errorf("removing quarantine attribute: %w", err)
}
return result, nil
}
func forceLaunchServicesRefresh(appPath string) error {
cmd := exec.Command("/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister", "-f", appPath)
if err := cmd.Run(); err != nil {
return fmt.Errorf("forcing LaunchServices refresh: %w", err)
}
time.Sleep(2 * time.Second)
return nil
}
// normalizeVersion removes common suffixes from version strings
func normalizeVersion(v string) string {
suffixes := []string{"-latest", "-beta", "-alpha", "-rc", "-pre"}
normalized := v
for _, suffix := range suffixes {
normalized = strings.TrimSuffix(normalized, suffix)
}
return normalized
}
// checkVersionMatch checks if the expected version matches any of the found versions
// using various matching strategies: exact match, normalization, concatenation, and prefix matching
func checkVersionMatch(expectedVersion, foundVersion, foundBundledVersion string) bool {
// Check exact matches first (no normalization needed)
if expectedVersion == foundVersion || expectedVersion == foundBundledVersion {
return true
}
// Only normalize if exact match failed (lazy normalization)
normalizedExpected := normalizeVersion(expectedVersion)
normalizedFound := normalizeVersion(foundVersion)
normalizedBundled := normalizeVersion(foundBundledVersion)
// Check normalized exact matches
if normalizedExpected == normalizedFound || normalizedExpected == normalizedBundled {
return true
}
// Check if expected version is a concatenation of short version + bundled version
// This handles cases like "1.4.230579" = "1.4.2" + "30579" or "1.4.2.30579" = "1.4.2" + "." + "30579"
// Only check concatenation if the expected version is longer than the short version alone,
// which indicates it might be a concatenation (avoids false positives)
if foundVersion != "" && foundBundledVersion != "" && len(expectedVersion) > len(foundVersion) {
// Try direct concatenation (no separator)
concatenated := foundVersion + foundBundledVersion
if expectedVersion == concatenated {
return true
}
// Check normalized concatenation
normalizedConcatenated := normalizedFound + normalizedBundled
if normalizedExpected == normalizedConcatenated {
return true
}
// Try concatenation with dot separator
concatenatedWithDot := foundVersion + "." + foundBundledVersion
if expectedVersion == concatenatedWithDot {
return true
}
normalizedConcatenatedWithDot := normalizedFound + "." + normalizedBundled
if normalizedExpected == normalizedConcatenatedWithDot {
return true
}
}
// Check if found version starts with expected version (handles suffixes like ".CE")
// This handles cases where the app version is "8.0.44.CE" but expected is "8.0.44"
if strings.HasPrefix(foundVersion, expectedVersion+".") ||
strings.HasPrefix(foundBundledVersion, expectedVersion+".") {
return true
}
if strings.HasPrefix(normalizedFound, normalizedExpected+".") ||
strings.HasPrefix(normalizedBundled, normalizedExpected+".") {
return true
}
// Check if expected version starts with found version (handles cases where osquery reports shorter version)
// This handles cases where expected is "2025.2.1.8" but osquery reports "2025.2"
if strings.HasPrefix(expectedVersion, foundVersion+".") {
return true
}
if strings.HasPrefix(normalizedExpected, normalizedFound+".") {
return true
}
// Also check bundled version for prefix matches
if strings.HasPrefix(expectedVersion, foundBundledVersion+".") {
return true
}
if strings.HasPrefix(normalizedExpected, normalizedBundled+".") {
return true
}
return false
}
func appExists(ctx context.Context, logger *slog.Logger, appName, uniqueAppIdentifier, appVersion, appPath string) (bool, error) {
execTimeout, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
if err := validateSqlInput(appName); err != nil {
return false, fmt.Errorf("Invalid character found in appName: '%w'. Not executing query...", err)
}
if err := validateSqlInput(uniqueAppIdentifier); err != nil {
return false, fmt.Errorf("Invalid character found in uniqueAppIdentifier: '%w'. Not executing query...", err)
}
if err := validateSqlInput(appPath); err != nil {
return false, fmt.Errorf("Invalid character found in appPath: '%w'. Not executing query...", err)
}
logger.InfoContext(ctx, fmt.Sprintf("Looking for app: %s, version: %s", appName, appVersion))
query := `
SELECT
COALESCE(NULLIF(display_name, ''), NULLIF(bundle_name, ''), NULLIF(bundle_executable, ''), TRIM(name, '.app') ) AS name,
path,
bundle_short_version,
bundle_version
FROM apps
WHERE
bundle_identifier LIKE '%` + uniqueAppIdentifier + `%' OR
LOWER(COALESCE(NULLIF(display_name, ''), NULLIF(bundle_name, ''), NULLIF(bundle_executable, ''), TRIM(name, '.app'))) LIKE LOWER('%` + appName + `%')
`
if appPath != "" {
query += fmt.Sprintf(" OR path LIKE '%%%s%%'", appPath)
}
cmd := exec.CommandContext(execTimeout, "osqueryi", "--json", query)
output, err := cmd.Output()
if err != nil {
return false, fmt.Errorf("executing osquery command: %w", err)
}
type AppResult struct {
Name string `json:"name"`
Path string `json:"path"`
Version string `json:"bundle_short_version"`
BundledVersion string `json:"bundle_version"`
}
var results []AppResult
if err := json.Unmarshal(output, &results); err != nil {
return false, fmt.Errorf("parsing osquery JSON output: %w", err)
}
if len(results) > 0 {
for _, result := range results {
software := &fleet.Software{
Name: result.Name,
Version: result.Version,
BundleIdentifier: uniqueAppIdentifier,
Source: "apps",
}
queries.MutateSoftwareOnIngestion(ctx, software, logger)
result.Version = software.Version
result.Name = software.Name
logger.InfoContext(ctx, fmt.Sprintf("Found app: '%s' at %s, Version: %s, Bundled Version: %s", result.Name, result.Path, result.Version, result.BundledVersion))
// OneDrive auto-updates immediately after installation, so the installed version
// might be newer than the installer version. For OneDrive, we only verify that
// the app exists rather than checking the version.
if uniqueAppIdentifier == "com.microsoft.OneDrive" {
logger.InfoContext(ctx, "OneDrive detected - skipping version check due to auto-update behavior")
return true, nil
}
// GPG Suite's installer version (e.g., "2023.3") doesn't match the app bundle version
// (e.g., "1.12" with bundled version "1800"). We only verify that the app exists
// rather than checking the version.
if uniqueAppIdentifier == "org.gpgtools.gpgkeychain" {
logger.InfoContext(ctx, "GPG Suite detected - skipping version check due to version mismatch between installer and app bundle")
return true, nil
}
// Adobe DNG Converter's version format includes build number in parentheses
// (e.g., "18.0 (2389)") which doesn't match the installer version (e.g., "18.0")
// Check if the version starts with the expected version to handle this case
if uniqueAppIdentifier == "com.adobe.DNGConverter" {
if strings.HasPrefix(result.Version, appVersion+" ") || strings.HasPrefix(result.Version, appVersion+"(") {
logger.InfoContext(ctx, "Adobe DNG Converter detected - version matches with build number")
return true, nil
}
}
// Ableton Live's version format includes a build identifier in parentheses
// (e.g., "12.4.1 (2026-05-20_fbe5fe99c9)") which doesn't match the installer
// version (e.g., "12.4.1"). Check if the version starts with the expected
// version to handle this case.
if uniqueAppIdentifier == "com.ableton.live" {
if strings.HasPrefix(result.Version, appVersion+" ") || strings.HasPrefix(result.Version, appVersion+"(") {
logger.InfoContext(ctx, "Ableton Live detected - version matches with build identifier")
return true, nil
}
}
// WhatsApp: Homebrew sometimes reports a newer version than what's actually available.
// If version doesn't match but app is installed, fall back to existence-only validation.
if uniqueAppIdentifier == "net.whatsapp.WhatsApp" {
if !checkVersionMatch(appVersion, result.Version, result.BundledVersion) {
logger.InfoContext(ctx, "WhatsApp detected - version mismatch but app is installed, falling back to existence-only validation")
return true, nil
}
}
// Logi Tune: the installer URL always serves the latest release, while the Homebrew
// cask version lags behind (its livecheck scrapes a Logitech support article that is
// updated less often than the download). The installed version is therefore newer
// than the manifest version. If version doesn't match but app is installed, fall
// back to existence-only validation.
if uniqueAppIdentifier == "com.logitech.logitune" {
if !checkVersionMatch(appVersion, result.Version, result.BundledVersion) {
logger.InfoContext(ctx, "Logi Tune detected - version mismatch but app is installed, falling back to existence-only validation")
return true, nil
}
}
// The Developer Edition cask version is the full beta ("153.0b13") but
// the bundle reports only the base version ("153.0"); accept base+"b".
if uniqueAppIdentifier == "org.mozilla.firefoxdeveloperedition" {
if result.Version != "" && strings.HasPrefix(appVersion, result.Version+"b") {
logger.InfoContext(ctx, "Firefox Developer Edition detected - cask version matches bundle base version with beta suffix")
return true, nil
}
}
// Check various version matching strategies
if checkVersionMatch(appVersion, result.Version, result.BundledVersion) {
return true, nil
}
}
}
return false, nil
}
func executeScript(cfg *Config, scriptContents string) (string, error) {
scriptExtension := ".sh"
scriptPath := filepath.Join(cfg.tmpDir, "script"+scriptExtension)
if err := os.WriteFile(scriptPath, []byte(scriptContents), constant.DefaultFileMode); err != nil {
return "", fmt.Errorf("writing script: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
output, exitCode, err := scripts.ExecCmd(ctx, scriptPath, cfg.env)
result := fmt.Sprintf(`
--------------------
%s
--------------------`, string(output))
if err != nil {
return result, err
}
if exitCode != 0 {
return result, fmt.Errorf("script execution failed with exit code %d: %s", exitCode, string(output))
}
return result, nil
}