fleet/cmd/maintained-apps/validate/main.go
Victor Lyuboslavsky 44c6aee5c7
Converted osquery_utils to slog (#39883)
<!-- 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 -->
2026-02-16 15:43:59 -06:00

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
}