fleet/cmd/maintained-apps/validate/main.go
Allen Houchins 4c4aa1d4c5
Cleanup temp installer files after download (#42463)
Ensure downloaded installer files are removed after validation. Add
cleanupInstaller to remove the installer file (ignoring missing files
and logging failures). Propagate a downloaded installer path from
DownloadMaintainedApp (signature now returns the TempFileReader, the
saved file path, and error), write the installer into cfg.tmpDir and set
INSTALLER_PATH in cfg.env. Call cleanupInstaller on error paths and
after successful validation to avoid leftover temp files.
2026-03-30 10:14:36 -05:00

495 lines
15 KiB
Go

//go:build darwin || windows
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
"runtime"
"slices"
"strings"
"time"
maintained_apps "github.com/fleetdm/fleet/v4/ee/maintained-apps"
"github.com/fleetdm/fleet/v4/pkg/file"
"github.com/fleetdm/fleet/v4/server/fleet"
mdm_maintained_apps "github.com/fleetdm/fleet/v4/server/mdm/maintainedapps"
)
type Config struct {
tmpDir string
env []string
installationSearchDirectory string
operatingSystem string
logger *slog.Logger
logLevel string
inputsPath string
outputsPath string
}
func detachAllDMGs(ctx context.Context, logger *slog.Logger, tmpDir string) {
if runtime.GOOS != "darwin" {
return
}
output, err := exec.CommandContext(ctx, "hdiutil", "info").CombinedOutput()
if err != nil {
return
}
var currentImage string
for line := range strings.SplitSeq(string(output), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "image-path") {
if parts := strings.SplitN(line, ":", 2); len(parts) == 2 {
currentImage = strings.TrimSpace(parts[1])
}
}
if strings.HasPrefix(line, "/dev/") {
var mountPoint string
for part := range strings.SplitSeq(line, "\t") {
part = strings.TrimSpace(part)
if strings.HasPrefix(part, "/") && !strings.HasPrefix(part, "/dev/") {
mountPoint = part
}
}
if mountPoint == "" {
continue
}
if tmpDir == "" || !strings.HasPrefix(currentImage, tmpDir+string(os.PathSeparator)) {
continue
}
logger.InfoContext(ctx, fmt.Sprintf("Force-detaching DMG: %s (image: %s)", mountPoint, currentImage))
if out, err := exec.CommandContext(ctx, "hdiutil", "detach", mountPoint, "-force").CombinedOutput(); err != nil {
logger.WarnContext(ctx, fmt.Sprintf("Failed to detach %s: %v (%s)", mountPoint, err, strings.TrimSpace(string(out))))
}
}
}
}
func run(cfg *Config) error {
ctx := context.Background()
cleanupTmpDir := func() {
detachAllDMGs(ctx, cfg.logger, cfg.tmpDir)
entries, err := os.ReadDir(cfg.tmpDir)
if err != nil {
cfg.logger.WarnContext(ctx, fmt.Sprintf("failed to read tmpDir for cleanup: %v", err))
return
}
for _, e := range entries {
if err := os.RemoveAll(filepath.Join(cfg.tmpDir, e.Name())); err != nil {
cfg.logger.WarnContext(ctx, fmt.Sprintf("failed to remove %s: %v", e.Name(), err))
}
}
}
apps, err := getListOfApps(cfg.outputsPath)
if err != nil {
cfg.logger.ErrorContext(ctx, fmt.Sprintf("Error getting list of apps: %v", err))
return err
}
cfg.tmpDir, err = os.MkdirTemp("", "fma-validate-")
if err != nil {
cfg.logger.ErrorContext(ctx, fmt.Sprintf("Error creating temporary directory: %v", err))
return err
}
origTmpDir := cfg.tmpDir
cfg.tmpDir, err = filepath.EvalSymlinks(cfg.tmpDir)
if err != nil {
cfg.logger.ErrorContext(ctx, fmt.Sprintf("Error resolving temporary directory path: %v", err))
os.RemoveAll(origTmpDir)
return err
}
defer func() {
detachAllDMGs(ctx, cfg.logger, cfg.tmpDir)
if err := os.RemoveAll(cfg.tmpDir); err != nil {
cfg.logger.ErrorContext(ctx, fmt.Sprintf("warning failed to remove temporary directory: %v", err))
}
}()
totalApps := 0
successfulApps := 0
appWithError := []string{}
appWithWarning := []string{}
frozenApps := []string{}
for _, app := range apps {
if app.Platform != cfg.operatingSystem {
continue
}
totalApps++
cfg.logger.InfoContext(ctx, fmt.Sprintf("Validating app: %s (%s)", app.Name, app.Slug))
appLogger := cfg.logger.With("app", app.Name)
ac := &AppCommander{cfg: cfg, appLogger: appLogger}
appJson, err := getAppJson(cfg.outputsPath, app.Slug)
if err != nil {
appLogger.ErrorContext(ctx, fmt.Sprintf("Error getting app json manifest: %v", err))
appWithError = append(appWithError, app.Name)
continue
}
maintainedApp, err := appFromJson(appJson)
if err != nil {
appLogger.ErrorContext(ctx, fmt.Sprintf("Error parsing app manifest: %v", err))
appWithError = append(appWithError, app.Name)
continue
}
ac.Name = app.Name
ac.Slug = app.Slug
ac.UniqueIdentifier = app.UniqueIdentifier
// default version to maintained app version
ac.Version = maintainedApp.Version
ac.InstallScript = maintainedApp.InstallScript
ac.UninstallScript = maintainedApp.UninstallScript
isFrozen, err := ac.isFrozen()
if err != nil {
appLogger.ErrorContext(ctx, fmt.Sprintf("Error checking if app is frozen: %v", err))
appWithError = append(appWithError, ac.Name)
continue
}
if isFrozen {
appLogger.InfoContext(ctx, "App is frozen, skipping validation...")
frozenApps = append(frozenApps, ac.Name)
continue
}
installerTFR, _, err := DownloadMaintainedApp(cfg, maintainedApp)
if err != nil {
appLogger.ErrorContext(ctx, fmt.Sprintf("Error downloading maintained app: %v", err))
appWithError = append(appWithError, ac.Name)
cleanupTmpDir()
continue
}
hadWarning := false
warnApp := func() {
if !hadWarning {
appWithWarning = append(appWithWarning, ac.Name)
hadWarning = true
}
}
err = ac.extractAppVersion(installerTFR)
if err != nil {
appLogger.WarnContext(ctx, fmt.Sprintf("Error extracting installer version: %v. Using '%s'", err, ac.Version))
warnApp()
}
hash, err := file.SHA256FromTempFileReader(installerTFR)
installerTFR.Close()
if err != nil {
appLogger.ErrorContext(ctx, fmt.Sprintf("Error checking if sha256 hash matches: %v", err))
appWithError = append(appWithError, ac.Name)
cleanupTmpDir()
continue
}
if hash != maintainedApp.SHA256 && maintainedApp.SHA256 != "no_check" {
appLogger.ErrorContext(ctx, "SHA256 hash in manifest does not match installer file hash")
appWithError = append(appWithError, ac.Name)
cleanupTmpDir()
continue
}
// If application is already installed, attempt to uninstall it
if slices.Contains(preInstalled, ac.Slug) {
ac.uninstallPreInstalled(ctx)
}
appPath, changerError, listError := ac.expectToChangeFileSystem(ctx,
func() error {
appLogger.InfoContext(ctx, "Executing install script...")
output, err := executeScript(cfg, ac.InstallScript)
if err != nil {
appLogger.ErrorContext(ctx, fmt.Sprintf("Error executing install script: %v", err))
appLogger.ErrorContext(ctx, fmt.Sprintf("Output: %s", output))
return err
}
appLogger.DebugContext(ctx, fmt.Sprintf("Output: %s", output))
return nil
},
)
if listError != nil {
appLogger.ErrorContext(ctx, fmt.Sprintf("Error listing directory contents: %v", listError))
}
if changerError != nil {
appWithError = append(appWithError, ac.Name)
cleanupTmpDir()
continue
}
ac.AppPath = appPath
if ac.AppPath == "" {
warnApp()
}
err = postApplicationInstall(ctx, appLogger, ac.AppPath)
if err != nil {
appLogger.WarnContext(ctx, fmt.Sprintf("Error detected in post-installation steps: %v", err))
warnApp()
}
existance, err := appExists(ctx, appLogger, ac.Name, ac.UniqueIdentifier, ac.Version, ac.AppPath)
if err != nil {
appLogger.ErrorContext(ctx, fmt.Sprintf("Error checking if app exists: %v", err))
appWithError = append(appWithError, ac.Name)
cleanupTmpDir()
continue
}
if !existance {
appLogger.ErrorContext(ctx, fmt.Sprintf("App version '%s' was not found by osquery", ac.Version))
appWithError = append(appWithError, ac.Name)
cleanupTmpDir()
continue
}
// Uninstall
uninstalled := ac.uninstallApp(ctx)
if !uninstalled {
appWithError = append(appWithError, ac.Name)
cleanupTmpDir()
continue
}
cleanupTmpDir()
cfg.logger.InfoContext(ctx, fmt.Sprintf("All checks passed for app: %s (%s)", ac.Name, ac.Slug))
successfulApps++
}
if len(frozenApps) > 0 {
cfg.logger.InfoContext(ctx, fmt.Sprintf("Some apps were skipped: %v", frozenApps))
}
errorSet := make(map[string]bool, len(appWithError))
for _, name := range appWithError {
errorSet[name] = true
}
warningsOnly := appWithWarning[:0]
for _, name := range appWithWarning {
if !errorSet[name] {
warningsOnly = append(warningsOnly, name)
}
}
if len(warningsOnly) > 0 {
cfg.logger.WarnContext(ctx, fmt.Sprintf("Some apps were validated with warnings: %v", warningsOnly))
}
if successfulApps == totalApps-len(frozenApps) {
// All apps were successfully validated!
cfg.logger.InfoContext(ctx, fmt.Sprintf("All %d apps were successfully validated.", totalApps))
return nil
}
cfg.logger.InfoContext(ctx, fmt.Sprintf("Validated %d out of %d apps successfully.", successfulApps, totalApps))
cfg.logger.InfoContext(ctx, fmt.Sprintf("Apps with errors: %v", appWithError))
return errors.New("Some maintained apps failed validation")
}
func main() {
cfg := &Config{}
// logger
cfg.logLevel = os.Getenv("LOG_LEVEL")
if cfg.logLevel == "" {
cfg.logLevel = "info"
}
var slogLevel slog.Level
switch strings.ToLower(cfg.logLevel) {
case "debug":
slogLevel = slog.LevelDebug
case "error":
slogLevel = slog.LevelError
default:
slogLevel = slog.LevelInfo
}
cfg.logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slogLevel}))
ctx := context.Background()
// os detection
cfg.operatingSystem = strings.ToLower(os.Getenv("GOOS"))
if cfg.operatingSystem == "" {
cfg.operatingSystem = runtime.GOOS
cfg.logger.InfoContext(ctx, fmt.Sprintf("GOOS environment variable is not set. Using system detected: '%s'", cfg.operatingSystem))
}
if cfg.operatingSystem != "darwin" && cfg.operatingSystem != "windows" {
cfg.logger.ErrorContext(ctx, fmt.Sprintf("Unsupported operating system: %s", cfg.operatingSystem))
os.Exit(1)
}
// installation directory detection
cfg.installationSearchDirectory = os.Getenv("INSTALLATION_SEARCH_DIRECTORY")
if cfg.installationSearchDirectory == "" {
switch cfg.operatingSystem {
case "darwin":
cfg.installationSearchDirectory = "/Applications"
case "windows":
cfg.installationSearchDirectory = "C:\\Program Files"
}
cfg.logger.InfoContext(ctx, fmt.Sprintf("INSTALLATION_SEARCH_DIRECTORY environment variable is not set. Using default: '%s'", cfg.installationSearchDirectory))
}
// paths
switch cfg.operatingSystem {
case "darwin":
cfg.inputsPath = "ee/maintained-apps/inputs/homebrew"
case "windows":
cfg.inputsPath = "ee\\maintained-apps\\inputs\\winget"
}
cfg.outputsPath = maintained_apps.OutputPath
err := run(cfg)
if err != nil {
os.Exit(1)
}
}
func getListOfApps(outputPath string) ([]maintained_apps.FMAListFileApp, error) {
appListFilePath := path.Join(outputPath, "apps.json")
inputJson, err := os.ReadFile(appListFilePath)
if err != nil {
return nil, fmt.Errorf("reading output apps list file: %w", err)
}
var outputAppsFile maintained_apps.FMAListFile
if err := json.Unmarshal(inputJson, &outputAppsFile); err != nil {
return nil, fmt.Errorf("unmarshaling output apps list file: %w", err)
}
return outputAppsFile.Apps, nil
}
func getAppJson(outputPath string, slug string) (*maintained_apps.FMAManifestFile, error) {
appJsonFilePath := path.Join(outputPath, fmt.Sprintf("%s.json", slug))
inputJson, err := os.ReadFile(appJsonFilePath)
if err != nil {
return nil, fmt.Errorf("reading app '%s' json manifest: %w", slug, err)
}
var manifest maintained_apps.FMAManifestFile
if err := json.Unmarshal(inputJson, &manifest); err != nil {
return nil, fmt.Errorf("unmarshaling app '%s' json manifest: %w", slug, err)
}
return &manifest, nil
}
func appFromJson(manifest *maintained_apps.FMAManifestFile) (fleet.MaintainedApp, error) {
if len(manifest.Versions) == 0 {
return fleet.MaintainedApp{}, errors.New("manifest has no versions")
}
v := manifest.Versions[0]
app := fleet.MaintainedApp{
Version: v.Version,
Platform: v.Platform(),
InstallerURL: v.InstallerURL,
SHA256: v.SHA256,
InstallScript: manifest.Refs[v.InstallScriptRef],
UninstallScript: manifest.Refs[v.UninstallScriptRef],
AutomaticInstallQuery: v.Queries.Exists,
Categories: v.DefaultCategories,
}
return app, nil
}
func DownloadMaintainedApp(cfg *Config, app fleet.MaintainedApp) (*fleet.TempFileReader, string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
cfg.logger.InfoContext(ctx, "Downloading...")
installerTFR, filename, err := mdm_maintained_apps.DownloadInstaller(ctx, app.InstallerURL, http.DefaultClient)
if err != nil {
return nil, "", fmt.Errorf("downloading installer: %w", err)
}
cleanFilename := filepath.Base(filename)
if cleanFilename == "." || cleanFilename == ".." {
cleanFilename = fmt.Sprintf("installer_%d", time.Now().UnixNano())
}
filePath := filepath.Join(cfg.tmpDir, cleanFilename)
out, err := os.Create(filePath)
if err != nil {
installerTFR.Close()
return nil, "", fmt.Errorf("creating file: %w", err)
}
defer out.Close()
_, err = io.Copy(out, installerTFR)
if err != nil {
installerTFR.Close()
out.Close()
os.Remove(filePath)
return nil, "", fmt.Errorf("failed to save file: %w", err)
}
err = installerTFR.Rewind()
if err != nil {
installerTFR.Close()
out.Close()
os.Remove(filePath)
return nil, "", fmt.Errorf("rewinding temp file: %w", err)
}
cfg.env = os.Environ()
installerPathEnv := fmt.Sprintf("INSTALLER_PATH=%s", filePath)
cfg.env = append(cfg.env, installerPathEnv)
return installerTFR, filePath, nil
}
func listDirectoryContents(dir string) (map[string]struct{}, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("reading directory %s: %w", dir, err)
}
contents := make(map[string]struct{})
for _, entry := range entries {
if entry.IsDir() && entry.Type()&os.ModeSymlink == 0 {
contents[entry.Name()] = struct{}{}
}
}
return contents, nil
}
var pathJoin = filepath.Join
func filepathJoin(parts ...string) string {
return pathJoin(parts...)
}
func detectApplicationChange(installationSearchDirectory string, appListPre, appListPost map[string]struct{}) (string, bool) {
// Check for added applications
for app := range appListPost {
if _, exists := appListPre[app]; !exists {
return filepathJoin(installationSearchDirectory, app), true // true = added
}
}
// Check for removed applications
for app := range appListPre {
if _, exists := appListPost[app]; !exists {
return filepathJoin(installationSearchDirectory, app), false // false = removed
}
}
return "", false // no change detected
}
func validateSqlInput(input string) error {
// Allow alphanumeric, spaces, dots, hyphens, underscores, forward/back slashes, colons, parentheses, pluses.
if matched, _ := regexp.MatchString(`^[a-zA-Z0-9\s.\-_/\\:()+]*$`, input); !matched {
return fmt.Errorf("invalid characters in input: %s", input)
}
return nil
}