mirror of
https://github.com/fleetdm/fleet
synced 2026-04-28 17:07:43 +00:00
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #38889 Plan was to convert `osquery_utils` package to slog. Picked up some additional code that was related. # Checklist for submitter - [ ] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. - Already have changes ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit # Release Notes ## Refactor * Updated internal logging infrastructure to use improved system-level logging utilities ## Tests * Updated test suite to align with internal logging changes --- **Note:** This release contains internal infrastructure improvements with no user-facing changes or new features. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
395 lines
12 KiB
Go
395 lines
12 KiB
Go
//go:build darwin || windows
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"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 run(cfg *Config) error {
|
|
ctx := context.Background()
|
|
|
|
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
|
|
}
|
|
defer func() {
|
|
err := os.RemoveAll(cfg.tmpDir)
|
|
if 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 := appFromJson(appJson)
|
|
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)
|
|
continue
|
|
}
|
|
|
|
err = ac.extractAppVersion(installerTFR)
|
|
if err != nil {
|
|
appLogger.ErrorContext(ctx, fmt.Sprintf("Error extracting installer version: %v. Using '%s'", err, ac.Version))
|
|
appWithError = append(appWithError, ac.Name)
|
|
}
|
|
|
|
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)
|
|
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)
|
|
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)
|
|
continue
|
|
}
|
|
ac.AppPath = appPath
|
|
if ac.AppPath == "" {
|
|
appWithWarning = append(appWithWarning, ac.Name)
|
|
}
|
|
|
|
err = postApplicationInstall(ctx, appLogger, ac.AppPath)
|
|
if err != nil {
|
|
appLogger.WarnContext(ctx, fmt.Sprintf("Error detected in post-installation steps: %v", err))
|
|
appWithWarning = append(appWithWarning, ac.Name)
|
|
}
|
|
|
|
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)
|
|
continue
|
|
}
|
|
if !existance {
|
|
appLogger.ErrorContext(ctx, fmt.Sprintf("App version '%s' was not found by osquery", ac.Version))
|
|
appWithError = append(appWithError, ac.Name)
|
|
continue
|
|
}
|
|
|
|
// Uninstall
|
|
uninstalled := ac.uninstallApp(ctx)
|
|
if !uninstalled {
|
|
appWithError = append(appWithError, ac.Name)
|
|
continue
|
|
}
|
|
|
|
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))
|
|
}
|
|
if len(appWithWarning) > 0 {
|
|
cfg.logger.WarnContext(ctx, fmt.Sprintf("Some apps were validated with warnings: %v", appWithWarning))
|
|
}
|
|
|
|
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 {
|
|
var app fleet.MaintainedApp
|
|
app.Version = manifest.Versions[0].Version
|
|
app.Platform = manifest.Versions[0].Platform()
|
|
app.InstallerURL = manifest.Versions[0].InstallerURL
|
|
app.SHA256 = manifest.Versions[0].SHA256
|
|
app.InstallScript = manifest.Refs[manifest.Versions[0].InstallScriptRef]
|
|
app.UninstallScript = manifest.Refs[manifest.Versions[0].UninstallScriptRef]
|
|
app.AutomaticInstallQuery = manifest.Versions[0].Queries.Exists
|
|
app.Categories = manifest.Versions[0].DefaultCategories
|
|
|
|
return app
|
|
}
|
|
|
|
func DownloadMaintainedApp(cfg *Config, app fleet.MaintainedApp) (*fleet.TempFileReader, 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)
|
|
}
|
|
|
|
// Create a file in tmpDir for the installer
|
|
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 {
|
|
return nil, fmt.Errorf("creating file: %w", err)
|
|
}
|
|
defer out.Close()
|
|
|
|
// Copy from TempFileReader to our file
|
|
_, err = io.Copy(out, installerTFR)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to save file: %w", err)
|
|
}
|
|
|
|
// Rewind the TempFileReader for future use
|
|
err = installerTFR.Rewind()
|
|
if err != nil {
|
|
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, 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
|
|
}
|