330 lines
12 KiB
Go
330 lines
12 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, publisher
|
|
FROM programs
|
|
WHERE
|
|
LOWER(name) LIKE LOWER('%` + appName + `%')
|
|
`
|
|
// The catalog name can differ from the registry DisplayName (e.g. catalog
|
|
// "Amazon Corretto 25" vs DisplayName "Amazon Corretto (x64)"). The
|
|
// unique_identifier is the value that should match programs.name, so search
|
|
// on it as well.
|
|
if uniqueIdentifier != "" && uniqueIdentifier != appName {
|
|
if err := validateSqlInput(uniqueIdentifier); err != nil {
|
|
return false, fmt.Errorf("Invalid character found in uniqueIdentifier: '%w'. Not executing query...", err)
|
|
}
|
|
query += ` OR LOWER(name) LIKE LOWER('%` + uniqueIdentifier + `%')`
|
|
}
|
|
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"`
|
|
Publisher string `json:"publisher"`
|
|
}
|
|
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 {
|
|
// Vendor is populated so name/version sanitizers that key off the
|
|
// publisher (e.g. JetBrains build-number normalization in
|
|
// MutateSoftwareOnIngestion) behave as they do in production.
|
|
software := &fleet.Software{
|
|
Name: result.Name,
|
|
Version: result.Version,
|
|
Source: "programs",
|
|
Vendor: result.Publisher,
|
|
}
|
|
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
|
|
}
|
|
// Microsoft Office is a Click-to-Run product: the bootstrap setup.exe
|
|
// always pulls the latest channel build from Microsoft's CDN, so the
|
|
// installed version will typically be newer than the manifest version.
|
|
// Only exempt genuine Office products (e.g. "Microsoft 365 Apps for
|
|
// enterprise" or the older "Microsoft Office 365 ProPlus") — the broad
|
|
// LIKE '%Microsoft Office%' search query also matches unrelated
|
|
// Office-branded dependencies like "Open XML SDK 2.5 for Microsoft
|
|
// Office" that must not prevent the post-uninstall check from reporting
|
|
// the app as removed. The "Microsoft 365 Apps" prefix is used (not bare
|
|
// "Microsoft 365") so Store apps such as "Microsoft 365 Copilot" aren't
|
|
// treated as the Office suite. The publisher guard mirrors the
|
|
// manifest's exists/patched queries (publisher = 'Microsoft
|
|
// Corporation'), so a third-party app that happens to match a name
|
|
// prefix can't bypass the version check.
|
|
if appName == "Microsoft Office" &&
|
|
result.Publisher == "Microsoft Corporation" &&
|
|
(strings.HasPrefix(result.Name, "Microsoft 365 Apps") ||
|
|
strings.HasPrefix(result.Name, "Microsoft Office")) {
|
|
logger.InfoContext(ctx, "Microsoft Office detected - version mismatch but app is installed, skipping version check due to Click-to-Run always installing the latest build")
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
// OpenAI Codex CLI is a portable zip: it does not register in programs. Detect the binary via osquery file + PE version.
|
|
if uniqueIdentifier == "Codex CLI" {
|
|
ok, err := codexCLIExistsFromFile(execTimeout, logger, appVersion, appPath)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if ok {
|
|
return true, nil
|
|
}
|
|
}
|
|
|
|
return false, nil
|
|
}
|
|
|
|
func codexCLIExistsFromFile(ctx context.Context, logger *slog.Logger, appVersion, appPath string) (bool, error) {
|
|
candidates := make([]string, 0, 3)
|
|
if appPath != "" {
|
|
candidates = append(candidates, filepath.Join(appPath, "codex.exe"))
|
|
}
|
|
if pf := os.Getenv("ProgramFiles"); pf != "" {
|
|
candidates = append(candidates, filepath.Join(pf, "Codex CLI", "codex.exe"))
|
|
}
|
|
if la := os.Getenv("LOCALAPPDATA"); la != "" {
|
|
candidates = append(candidates, filepath.Join(la, "Programs", "Codex CLI", "codex.exe"))
|
|
}
|
|
|
|
seen := make(map[string]struct{})
|
|
for _, exePath := range candidates {
|
|
if _, dup := seen[exePath]; dup {
|
|
continue
|
|
}
|
|
seen[exePath] = struct{}{}
|
|
|
|
if err := validateSqlInput(exePath); err != nil {
|
|
continue
|
|
}
|
|
|
|
escaped := strings.ReplaceAll(exePath, "'", "''")
|
|
query := `SELECT file_version FROM file WHERE path = '` + escaped + `'`
|
|
cmd := exec.CommandContext(ctx, "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 file lookup: %w", err)
|
|
}
|
|
|
|
type fileResult struct {
|
|
FileVersion string `json:"file_version"`
|
|
}
|
|
var results []fileResult
|
|
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 || results[0].FileVersion == "" {
|
|
continue
|
|
}
|
|
|
|
fileVer := results[0].FileVersion
|
|
logger.InfoContext(ctx, fmt.Sprintf("Found Codex CLI binary at %s, file version: %s", exePath, fileVer))
|
|
|
|
if fileVer == appVersion ||
|
|
strings.HasPrefix(fileVer, appVersion+".") ||
|
|
strings.HasPrefix(appVersion, fileVer+".") {
|
|
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)
|
|
}
|
|
|
|
// Some installers (e.g. Visual Studio bootstrappers like vs_SSMS.exe)
|
|
// download a large payload at install time and legitimately take longer
|
|
// than a few minutes. Production allows up to 1 hour
|
|
// (pkgscripts.MaxHostSoftwareInstallExecutionTime); 10 minutes is a
|
|
// reasonable validator cap that covers large-payload installers without
|
|
// letting a hung script run indefinitely.
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*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
|
|
}
|