Extract metadata, detect platform for no-payload packages (#34099)

Implements #33750, also related to #33980
This commit is contained in:
Carlo 2025-10-14 09:57:15 -04:00 committed by GitHub
parent cc7062f8f0
commit 7382a0ef7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 226 additions and 10 deletions

View file

@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"path"
@ -78,7 +79,7 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet.
//
// For "msi", addMetadataToSoftwarePayload fails before this point if product code cannot be extracted.
//
case payload.Extension == "exe" || payload.Extension == "tar.gz":
case payload.Extension == "exe" || payload.Extension == "tar.gz" || fleet.IsScriptPackage(payload.Extension):
return nil, &fleet.BadRequestError{
Message: fmt.Sprintf("Couldn't add. Fleet can't create a policy to detect existing installations for .%s packages. Please add the software, add a custom policy, and enable the install software policy automation.", payload.Extension),
}
@ -1537,6 +1538,16 @@ func (svc *Service) addMetadataToSoftwarePayload(ctx context.Context, payload *f
return "", ctxerr.New(ctx, "installer file is required")
}
ext := strings.ToLower(filepath.Ext(payload.Filename))
ext = strings.TrimPrefix(ext, ".")
if fleet.IsScriptPackage(ext) {
if err := svc.addScriptPackageMetadata(ctx, payload, ext); err != nil {
return "", err
}
return ext, nil
}
meta, err := file.ExtractInstallerMetadata(payload.InstallerFile)
if err != nil {
if errors.Is(err, file.ErrUnsupportedType) {
@ -1621,6 +1632,60 @@ func (svc *Service) addMetadataToSoftwarePayload(ctx context.Context, payload *f
return meta.Extension, nil
}
func (svc *Service) addScriptPackageMetadata(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload, extension string) error {
if payload == nil {
return ctxerr.New(ctx, "payload is required")
}
if payload.InstallerFile == nil {
return ctxerr.New(ctx, "installer file is required")
}
scriptBytes, err := io.ReadAll(payload.InstallerFile)
if err != nil {
return ctxerr.Wrap(ctx, err, "reading script file")
}
if err := payload.InstallerFile.Rewind(); err != nil {
return ctxerr.Wrap(ctx, err, "resetting script file reader")
}
scriptContents := string(scriptBytes)
if err := fleet.ValidateHostScriptContents(scriptContents, false); err != nil {
return &fleet.BadRequestError{
Message: fmt.Sprintf("Couldn't add. Script validation failed: %s", err.Error()),
InternalErr: ctxerr.Wrap(ctx, err, "validating script contents"),
}
}
shaSum, err := file.SHA256FromTempFileReader(payload.InstallerFile)
if err != nil {
return ctxerr.Wrap(ctx, err, "calculating script SHA256")
}
if payload.Title == "" {
base := filepath.Base(payload.Filename)
payload.Title = strings.TrimSuffix(base, filepath.Ext(base))
}
payload.Version = ""
payload.InstallScript = scriptContents
payload.StorageID = shaSum
payload.BundleIdentifier = ""
payload.PackageIDs = nil
payload.Extension = extension
payload.Source = "scripts"
platform, err := fleet.SoftwareInstallerPlatformFromExtension(extension)
if err != nil {
return ctxerr.Wrap(ctx, err, "determining platform from extension")
}
payload.Platform = platform
return nil
}
const (
batchSoftwarePrefix = "software_batch_"
)

View file

@ -101,7 +101,7 @@ func TestInstallUninstallAuth(t *testing.T) {
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{
ServerSettings: fleet.ServerSettings{ScriptsDisabled: true}, // global scripts being disabled shouldn't impact (un)installs
ServerSettings: fleet.ServerSettings{ScriptsDisabled: true},
}, nil
}
ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) {
@ -184,7 +184,6 @@ func TestInstallUninstallAuth(t *testing.T) {
}
}
// TestUninstallSoftwareTitle is mostly tested in enterprise integration test. This test hits a few edge cases.
func TestUninstallSoftwareTitle(t *testing.T) {
t.Parallel()
ds := new(mock.Store)
@ -200,7 +199,6 @@ func TestUninstallSoftwareTitle(t *testing.T) {
return host, nil
}
// Global scripts disabled (doesn't matter)
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{
ServerSettings: fleet.ServerSettings{
@ -209,12 +207,10 @@ func TestUninstallSoftwareTitle(t *testing.T) {
}, nil
}
// Host scripts disabled
host.ScriptsEnabled = ptr.Bool(false)
require.ErrorContains(t, svc.UninstallSoftwareTitle(context.Background(), 1, 10), fleet.RunScriptsOrbitDisabledErrMsg)
}
// TestInstallSoftwareTitle is mostly tested in enterprise integration test. This test hits is placed to hit some edge cases.
func TestInstallSoftwareTitle(t *testing.T) {
t.Parallel()
ds := new(mock.Store)
@ -312,7 +308,6 @@ func TestSoftwareInstallerPayloadFromSlug(t *testing.T) {
assert.NotEmpty(t, payload.UninstallScript)
assert.True(t, payload.FleetMaintained)
// when SHA256 is no_check
ds.GetMaintainedAppBySlugFunc = func(ctx context.Context, slug string, teamID *uint) (*fleet.MaintainedApp, error) {
return &fleet.MaintainedApp{
ID: 1,
@ -331,7 +326,6 @@ func TestSoftwareInstallerPayloadFromSlug(t *testing.T) {
assert.NotEmpty(t, payload.UninstallScript)
assert.True(t, payload.FleetMaintained)
// when a slug empty, we no-op and return the payload as is
payload = fleet.SoftwareInstallerPayload{URL: "https://fleetdm.com"}
err = svc.softwareInstallerPayloadFromSlug(context.Background(), &payload, nil)
require.NoError(t, err)
@ -377,3 +371,155 @@ func newTestServiceWithMock(t *testing.T, ds fleet.Datastore) (*Service, *svcmoc
}
return svc, baseSvc
}
func TestAddScriptPackageMetadata(t *testing.T) {
t.Parallel()
ctx := context.Background()
svc := newTestService(t, nil)
t.Run("valid shell script", func(t *testing.T) {
scriptContents := "#!/bin/bash\necho 'Installing software'\n"
tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.sh")
require.NoError(t, err)
defer tmpFile.Close()
_, err = tmpFile.WriteString(scriptContents)
require.NoError(t, err)
tfr, err := fleet.NewKeepFileReader(tmpFile.Name())
require.NoError(t, err)
defer tfr.Close()
payload := &fleet.UploadSoftwareInstallerPayload{
InstallerFile: tfr,
Filename: "install-app.sh",
}
err = svc.addScriptPackageMetadata(ctx, payload, "sh")
require.NoError(t, err)
require.Equal(t, "install-app", payload.Title)
require.Equal(t, "", payload.Version)
require.Equal(t, scriptContents, payload.InstallScript)
require.Equal(t, "linux", payload.Platform)
require.Equal(t, "scripts", payload.Source)
require.Empty(t, payload.BundleIdentifier)
require.Empty(t, payload.PackageIDs)
require.NotEmpty(t, payload.StorageID)
require.Equal(t, "sh", payload.Extension)
})
t.Run("valid powershell script", func(t *testing.T) {
scriptContents := "Write-Host 'Installing software'\n"
tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.ps1")
require.NoError(t, err)
defer tmpFile.Close()
_, err = tmpFile.WriteString(scriptContents)
require.NoError(t, err)
tfr, err := fleet.NewKeepFileReader(tmpFile.Name())
require.NoError(t, err)
defer tfr.Close()
payload := &fleet.UploadSoftwareInstallerPayload{
InstallerFile: tfr,
Filename: "install-app.ps1",
}
err = svc.addScriptPackageMetadata(ctx, payload, "ps1")
require.NoError(t, err)
require.Equal(t, "install-app", payload.Title)
require.Equal(t, "", payload.Version)
require.Equal(t, scriptContents, payload.InstallScript)
require.Equal(t, "windows", payload.Platform)
require.Equal(t, "scripts", payload.Source)
require.Empty(t, payload.BundleIdentifier)
require.Empty(t, payload.PackageIDs)
require.NotEmpty(t, payload.StorageID)
})
t.Run("invalid shebang", func(t *testing.T) {
scriptContents := "#!/usr/bin/python\nprint('hello')\n"
tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.sh")
require.NoError(t, err)
defer tmpFile.Close()
_, err = tmpFile.WriteString(scriptContents)
require.NoError(t, err)
tfr, err := fleet.NewKeepFileReader(tmpFile.Name())
require.NoError(t, err)
defer tfr.Close()
payload := &fleet.UploadSoftwareInstallerPayload{
InstallerFile: tfr,
Filename: "test.sh",
}
err = svc.addScriptPackageMetadata(ctx, payload, "sh")
require.Error(t, err)
require.Contains(t, err.Error(), "Script validation failed")
require.Contains(t, err.Error(), "Interpreter not supported")
})
t.Run("empty script", func(t *testing.T) {
tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.sh")
require.NoError(t, err)
defer tmpFile.Close()
tfr, err := fleet.NewKeepFileReader(tmpFile.Name())
require.NoError(t, err)
defer tfr.Close()
payload := &fleet.UploadSoftwareInstallerPayload{
InstallerFile: tfr,
Filename: "test.sh",
}
err = svc.addScriptPackageMetadata(ctx, payload, "sh")
require.Error(t, err)
require.Contains(t, err.Error(), "must not be empty")
})
t.Run("custom title preserved", func(t *testing.T) {
scriptContents := "#!/bin/bash\necho 'test'\n"
tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.sh")
require.NoError(t, err)
defer tmpFile.Close()
_, err = tmpFile.WriteString(scriptContents)
require.NoError(t, err)
tfr, err := fleet.NewKeepFileReader(tmpFile.Name())
require.NoError(t, err)
defer tfr.Close()
payload := &fleet.UploadSoftwareInstallerPayload{
InstallerFile: tfr,
Filename: "test.sh",
Title: "My Custom Title",
}
err = svc.addScriptPackageMetadata(ctx, payload, "sh")
require.NoError(t, err)
require.Equal(t, "My Custom Title", payload.Title)
})
t.Run("file contents preserved verbatim", func(t *testing.T) {
scriptContents := "#!/bin/bash\necho \"Test's 'quotes'\"\necho $VAR\n\n"
tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.sh")
require.NoError(t, err)
defer tmpFile.Close()
_, err = tmpFile.WriteString(scriptContents)
require.NoError(t, err)
tfr, err := fleet.NewKeepFileReader(tmpFile.Name())
require.NoError(t, err)
defer tfr.Close()
payload := &fleet.UploadSoftwareInstallerPayload{
InstallerFile: tfr,
Filename: "test.sh",
}
err = svc.addScriptPackageMetadata(ctx, payload, "sh")
require.NoError(t, err)
require.Equal(t, scriptContents, payload.InstallScript)
})
}

View file

@ -568,6 +568,11 @@ type DownloadSoftwareInstallerPayload struct {
Size int64
}
func IsScriptPackage(ext string) bool {
ext = strings.TrimPrefix(ext, ".")
return ext == "sh" || ext == "ps1"
}
func SofwareInstallerSourceFromExtensionAndName(ext, name string) (string, error) {
ext = strings.TrimPrefix(ext, ".")
switch ext {
@ -594,9 +599,9 @@ func SofwareInstallerSourceFromExtensionAndName(ext, name string) (string, error
func SoftwareInstallerPlatformFromExtension(ext string) (string, error) {
ext = strings.TrimPrefix(ext, ".")
switch ext {
case "deb", "rpm", "tar.gz":
case "deb", "rpm", "tar.gz", "sh":
return "linux", nil
case "exe", "msi":
case "exe", "msi", "ps1":
return "windows", nil
case "pkg":
return "darwin", nil