fleet/cmd/maintained-apps/validate/windows.go
Allen Houchins f5b8029390
Fall back to existence validation when version validation fails for Google Chrome on Windows (#40918)
This pull request updates the application validation logic to better
handle Google Chrome's auto-update behavior on Windows. Specifically, it
ensures that the validation does not fail if Chrome's installed version
is newer than the installer version, which is a common case due to its
auto-updating nature.

Application validation improvements:

* Modified the `appExists` function in `windows.go` to skip strict
version checks for Google Chrome and log an informational message when a
version mismatch is detected, treating the app as installed if found.
2026-03-11 09:03:19 -05:00

219 lines
7.5 KiB
Go

//go:build windows
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/server/fleet"
queries "github.com/fleetdm/fleet/v4/server/service/osquery_utils"
)
var preInstalled = []string{}
func postApplicationInstall(_ context.Context, _ *slog.Logger, _ string) error {
return nil
}
// normalizeVersion normalizes version strings for comparison
// Handles cases like "11.2.1495.0" vs "11.2.1495" by padding with zeros
func normalizeVersion(version string) string {
parts := strings.Split(version, ".")
// Ensure we have at least 4 parts (Major.Minor.Build.Revision)
for len(parts) < 4 {
parts = append(parts, "0")
}
// Trim to 4 parts max
if len(parts) > 4 {
parts = parts[:4]
}
return strings.Join(parts, ".")
}
func appExists(ctx context.Context, logger *slog.Logger, appName, uniqueIdentifier, 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(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 name, install_location, version
FROM programs
WHERE
LOWER(name) LIKE LOWER('%` + appName + `%')
`
if appPath != "" {
query += fmt.Sprintf(" OR install_location LIKE '%%%s%%'", appPath)
}
cmd := exec.CommandContext(execTimeout, "osqueryi", "--json", query)
output, err := cmd.CombinedOutput()
if err != nil {
logger.ErrorContext(ctx, fmt.Sprintf("osquery output: %s", string(output)))
return false, fmt.Errorf("executing osquery command: %w", err)
}
type AppResult struct {
Name string `json:"name"`
InstallLocation string `json:"install_location"`
Version string `json:"version"`
}
var results []AppResult
if err := json.Unmarshal(output, &results); err != nil {
logger.ErrorContext(ctx, fmt.Sprintf("osquery output: %s", string(output)))
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,
Source: "programs",
}
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", result.Name, result.InstallLocation, result.Version))
// 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 == "" {
logger.InfoContext(ctx, "Sublime Text detected with empty version - skipping version check (installer may not write version to registry)")
return true, nil
}
// Check exact match first
if result.Version == appVersion {
return true, nil
}
// Check if found version starts with expected version (handles suffixes like ".0")
// This handles cases where the app version is "3.5.4.0" but expected is "3.5.4"
if strings.HasPrefix(result.Version, appVersion+".") {
return true, nil
}
// Check if expected version starts with found version (handles cases where osquery reports shorter version)
// This handles cases where expected is "6.4.0" but osquery reports "6.4"
if strings.HasPrefix(appVersion, result.Version+".") {
return true, nil
}
// Google Chrome auto-updates immediately after installation, so the
// installed version may be newer than the installer version. If
// version didn't match above, fall back to existence-only check.
if appName == "Google Chrome" {
logger.InfoContext(ctx, "Google Chrome detected - version mismatch but app is installed, skipping version check due to auto-update behavior")
return true, nil
}
}
}
// For AppX packages, check if the package is provisioned
// Provisioned packages don't show up in the programs table until a user logs in
// Since unique_identifier should match DisplayName, use it for exact match
if uniqueIdentifier == "" {
return false, nil
}
// Search by DisplayName using exact match (unique_identifier should match DisplayName)
provisionedQuery := fmt.Sprintf(`Get-AppxProvisionedPackage -Online | Where-Object { $_.DisplayName -eq '%s' } | Select-Object -First 1 | ConvertTo-Json -Depth 5`, uniqueIdentifier)
cmd = exec.CommandContext(execTimeout, "powershell", "-NoProfile", "-NonInteractive", "-Command", provisionedQuery)
output, err = cmd.CombinedOutput()
if err != nil {
return false, nil
}
if len(output) > 0 {
outputStr := strings.TrimSpace(string(output))
// Handle case where PowerShell returns an empty array []
if outputStr == "[]" || outputStr == "null" {
return false, nil
}
var provisioned struct {
DisplayName string `json:"DisplayName"`
PackageName string `json:"PackageName"`
Version string `json:"Version"` // Version is a string like "11.2.1495.0"
}
if err := json.Unmarshal([]byte(outputStr), &provisioned); err != nil {
return false, nil
}
if provisioned.DisplayName != "" || provisioned.PackageName != "" {
provisionedVersion := provisioned.Version
logger.InfoContext(ctx, fmt.Sprintf("Found provisioned AppX package: '%s', Version: %s", provisioned.DisplayName, provisionedVersion))
// Normalize both versions for comparison
normalizedProvisioned := normalizeVersion(provisionedVersion)
normalizedExpected := normalizeVersion(appVersion)
// Check if version matches (exact or prefix match)
if normalizedProvisioned == normalizedExpected ||
strings.HasPrefix(normalizedProvisioned, normalizedExpected+".") ||
strings.HasPrefix(normalizedExpected, normalizedProvisioned+".") ||
provisionedVersion == appVersion ||
strings.HasPrefix(provisionedVersion, appVersion+".") ||
strings.HasPrefix(appVersion, provisionedVersion+".") {
return true, nil
}
}
}
return false, nil
}
func executeScript(cfg *Config, scriptContents string) (string, error) {
scriptExtension := ".ps1"
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()
// Use custom execution with non-interactive flags for Windows
cmd := exec.CommandContext(ctx, "powershell", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", scriptPath)
cmd.WaitDelay = 1 * time.Minute
cmd.Env = cfg.env
cmd.Dir = filepath.Dir(scriptPath)
output, err := cmd.CombinedOutput()
exitCode := -1
// Only set exitCode if process completed and context wasn't cancelled
if cmd.ProcessState != nil {
// see orbit/pkg/scripts/exec_windows.go
// https://en.wikipedia.org/wiki/Exit_status#Windows
exitCode = int(int32(cmd.ProcessState.ExitCode())) // nolint:gosec
}
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
}