mirror of
https://github.com/fleetdm/fleet
synced 2026-05-01 10:27:35 +00:00
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.
495 lines
15 KiB
Go
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
|
|
}
|