Merge branch 'main' into feat-fleet-app-library

This commit is contained in:
Gabriel Hernandez
2024-09-13 13:54:10 +01:00
179 changed files with 6643 additions and 1996 deletions
+7 -6
View File
@@ -33,9 +33,9 @@ func ExtractDebMetadata(r io.Reader) (*InstallerMetadata, error) {
return nil, fmt.Errorf("failed to advance to next file in archive: %w", err)
}
name := path.Clean(hdr.Name)
if strings.HasPrefix(name, "control.tar") {
ext := filepath.Ext(name)
filename := path.Clean(hdr.Name)
if strings.HasPrefix(filename, "control.tar") {
ext := filepath.Ext(filename)
if ext == ".tar" {
ext = ""
}
@@ -49,9 +49,10 @@ func ExtractDebMetadata(r io.Reader) (*InstallerMetadata, error) {
return nil, fmt.Errorf("failed to read all content: %w", err)
}
return &InstallerMetadata{
Name: name,
Version: version,
SHASum: h.Sum(nil),
Name: name,
Version: version,
PackageIDs: []string{name},
SHASum: h.Sum(nil),
}, nil
}
}
+1
View File
@@ -25,6 +25,7 @@ type InstallerMetadata struct {
BundleIdentifier string
SHASum []byte
Extension string
PackageIDs []string
}
// ExtractInstallerMetadata extracts the software name and version from the
+29
View File
@@ -61,3 +61,32 @@ func GetRemoveScript(extension string) string {
return ""
}
}
//go:embed scripts/uninstall_exe.ps1
var uninstallExeScript string
//go:embed scripts/uninstall_pkg.sh
var uninstallPkgScript string
//go:embed scripts/uninstall_msi.ps1
var uninstallMsiScript string
//go:embed scripts/uninstall_deb.sh
var uninstallDebScript string
// GetUninstallScript returns a script that can be used to uninstall a
// software item with the given extension.
func GetUninstallScript(extension string) string {
switch extension {
case "msi":
return uninstallMsiScript
case "deb":
return uninstallDebScript
case "pkg":
return uninstallPkgScript
case "exe":
return uninstallExeScript
default:
return ""
}
}
+22 -13
View File
@@ -7,6 +7,7 @@ import (
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -19,35 +20,43 @@ func TestMain(m *testing.M) {
os.Exit(m.Run())
}
// Note: to update the goldens, run the tests with `-update`:
// Note: to update the goldens, delete testdata/scripts/* and run the tests with `-update`:
//
// go test ./pkg/file/... -update
func TestGetInstallAndRemoveScript(t *testing.T) {
scriptsByType := map[string][2]string{
t.Parallel()
scriptsByType := map[string]map[string]string{
"msi": {
"./scripts/install_msi.ps1",
"./scripts/remove_msi.ps1",
"install": "./scripts/install_msi.ps1",
"remove": "./scripts/remove_msi.ps1",
"uninstall": "./scripts/uninstall_msi.ps1",
},
"pkg": {
"./scripts/install_pkg.sh",
"./scripts/remove_pkg.sh",
"install": "./scripts/install_pkg.sh",
"remove": "./scripts/remove_pkg.sh",
"uninstall": "./scripts/uninstall_pkg.sh",
},
"deb": {
"./scripts/install_deb.sh",
"./scripts/remove_deb.sh",
"install": "./scripts/install_deb.sh",
"remove": "./scripts/remove_deb.sh",
"uninstall": "./scripts/uninstall_deb.sh",
},
"exe": {
"./scripts/install_exe.ps1",
"./scripts/remove_exe.ps1",
"install": "./scripts/install_exe.ps1",
"remove": "./scripts/remove_exe.ps1",
"uninstall": "./scripts/uninstall_exe.ps1",
},
}
for itype, scripts := range scriptsByType {
gotScript := GetInstallScript(itype)
assertGoldenMatches(t, scripts[0], gotScript, *update)
assertGoldenMatches(t, scripts["install"], gotScript, *update)
gotScript = GetRemoveScript(itype)
assertGoldenMatches(t, scripts[1], gotScript, *update)
assertGoldenMatches(t, scripts["remove"], gotScript, *update)
gotScript = GetUninstallScript(itype)
assertGoldenMatches(t, scripts["uninstall"], gotScript, *update)
}
}
@@ -67,5 +76,5 @@ func assertGoldenMatches(t *testing.T, goldenFile string, actual string, update
content, err := io.ReadAll(f)
require.NoError(t, err)
require.Equal(t, string(content), actual)
assert.Equal(t, string(content), actual)
}
+6 -4
View File
@@ -9,7 +9,7 @@ import (
"io"
"strings"
"github.com/sassoftware/relic/v7/lib/comdoc"
"github.com/sassoftware/relic/v8/lib/comdoc"
)
func ExtractMSIMetadata(r io.Reader) (*InstallerMetadata, error) {
@@ -77,10 +77,12 @@ func ExtractMSIMetadata(r io.Reader) (*InstallerMetadata, error) {
return nil, err
}
// MSI installer product information properties: https://learn.microsoft.com/en-us/windows/win32/msi/property-reference#product-information-properties
return &InstallerMetadata{
Name: strings.TrimSpace(props["ProductName"]),
Version: strings.TrimSpace(props["ProductVersion"]),
SHASum: h.Sum(nil),
Name: strings.TrimSpace(props["ProductName"]),
Version: strings.TrimSpace(props["ProductVersion"]),
PackageIDs: []string{strings.TrimSpace(props["ProductCode"])},
SHASum: h.Sum(nil),
}, nil
}
+5 -3
View File
@@ -50,10 +50,12 @@ func ExtractPEMetadata(r io.Reader) (*InstallerMetadata, error) {
if err != nil {
return nil, fmt.Errorf("error parsing PE version resources: %w", err)
}
name := strings.TrimSpace(v["ProductName"])
return applySpecialCases(&InstallerMetadata{
Name: strings.TrimSpace(v["ProductName"]),
Version: strings.TrimSpace(v["ProductVersion"]),
SHASum: h.Sum(nil),
Name: name,
Version: strings.TrimSpace(v["ProductVersion"]),
PackageIDs: []string{name},
SHASum: h.Sum(nil),
}, v), nil
}
+21
View File
@@ -0,0 +1,21 @@
package file
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestExtractPEMetadata(t *testing.T) {
t.Parallel()
file, err := os.Open("testdata/software-installers/hello-world-installer.exe")
require.NoError(t, err)
meta, err := ExtractPEMetadata(file)
require.NoError(t, err)
require.NotNil(t, meta)
assert.Equal(t, "Hello world", meta.Name)
assert.Equal(t, "1.0.0", meta.Version)
assert.Equal(t, []string{"Hello world"}, meta.PackageIDs)
}
+16 -12
View File
@@ -1,16 +1,20 @@
# Learn more about .exe install scripts: http://fleetdm.com/learn-more-about/exe-install-scripts
$exeFilePath = "${env:INSTALLER_PATH}"
# extract the name of the executable to use as the sub-directory name
$exeName = [System.IO.Path]::GetFileName($exeFilePath)
$subDir = [System.IO.Path]::GetFileNameWithoutExtension($exeFilePath)
$destinationPath = Join-Path -Path $env:ProgramFiles -ChildPath $subDir
# check if the directory does not exist, and create it if necessary
if (-not (Test-Path -Path $destinationPath)) {
New-Item -ItemType Directory -Path $destinationPath
# Add argument to install silently
# Argument to make install silent depends on installer,
# each installer might use different argument (usually it's "/S" or "/s")
$processOptions = @{
FilePath = "$exeFilePath"
ArgumentList = "/S"
PassThru = $true
Wait = $true
}
# Start process and track exit code
$process = Start-Process @processOptions
$exitCode = $process.ExitCode
# copy the .exe file to the new sub-directory
$destinationExePath = Join-Path -Path $destinationPath -ChildPath $exeName
Copy-Item -Path $exeFilePath -Destination $destinationExePath
# Prints the exit code
Write-Host "Install exit code: $exitCode"
+4
View File
@@ -0,0 +1,4 @@
package_name=$PACKAGE_ID
# Fleet uninstalls app using product name that's extracted on upload
apt remove "$package_name" -y
+17
View File
@@ -0,0 +1,17 @@
# Fleet extracts name from installer (EXE) and saves it to package ID variable
$softwareName = $PACKAGE_ID
# Get the list of subkeys under the Uninstall registry path
$uninstallKeys = Get-ChildItem "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall" | ForEach-Object { Get-ItemProperty $_.PSPath }
# Loop through each registry key to find the one containing "$softwareName" in DisplayName and run uninstall command from UninstallString
foreach ($key in $uninstallKeys) {
if ($key.DisplayName -like "*$softwareName*") {
# Get the uninstall command
$uninstallCommand = if ($key.QuietUninstallString) { $key.QuietUninstallString } else { $key.UninstallString }
# Run the uninstall command with arguments using the call operator &
& $uninstallCommand
break # Exit the loop once the software is found and uninstalled
}
}
+4
View File
@@ -0,0 +1,4 @@
$product_code = $PACKAGE_ID
# Fleet uninstalls app using product code that's extracted on upload
msiexec /quiet /x $product_code
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
# Fleet extracts and saves package IDs.
pkg_ids=$PACKAGE_ID
# Get all files associated with package and remove them
for pkg_id in "${pkg_ids[@]}"
do
# Get volume and location of package
volume=$(pkgutil --pkg-info "$pkg_id" | grep -i "volume" | awk '{for (i=2; i<NF; i++) printf $i " "; print $NF}')
location=$(pkgutil --pkg-info "$pkg_id" | grep -i "location" | awk '{for (i=2; i<NF; i++) printf $i " "; print $NF}')
# Check if this package id corresponds to a valid/installed package
if [[ ! -z "$volume" && ! -z "$location" ]]; then
# Remove individual files/directories belonging to package
pkgutil --files "$pkg_id" | sed -e 's@^@'"$volume""$location"'/@' | tr '\n' '\0' | xargs -n 1 -0 rm -rf
# Remove receipts
pkgutil --forget "$pkg_id"
else
echo "WARNING: volume or location are empty for package ID $pkg_id"
fi
done
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<installer-gui-script minSpecVersion="2">
<pkg-ref id="com.bozo.zeroinstallsize" installKBytes="0" packageIdentifier="com.bozo.zeroinstallsize.app">
<bundle-version>
<bundle CFBundleShortVersionString="1.2.3" CFBundleVersion="8.10.34.234040" id="com.bozo.zeroinstallsize" path="ZeroInstallSize.app"/>
</bundle-version>
</pkg-ref>
<product id="com.bozo.zeroinstallsize" version="1.2.3"/>
<title>ZeroInstallSize</title>
</installer-gui-script>
+16 -12
View File
@@ -1,16 +1,20 @@
# Learn more about .exe install scripts: http://fleetdm.com/learn-more-about/exe-install-scripts
$exeFilePath = "${env:INSTALLER_PATH}"
# extract the name of the executable to use as the sub-directory name
$exeName = [System.IO.Path]::GetFileName($exeFilePath)
$subDir = [System.IO.Path]::GetFileNameWithoutExtension($exeFilePath)
$destinationPath = Join-Path -Path $env:ProgramFiles -ChildPath $subDir
# check if the directory does not exist, and create it if necessary
if (-not (Test-Path -Path $destinationPath)) {
New-Item -ItemType Directory -Path $destinationPath
# Add argument to install silently
# Argument to make install silent depends on installer,
# each installer might use different argument (usually it's "/S" or "/s")
$processOptions = @{
FilePath = "$exeFilePath"
ArgumentList = "/S"
PassThru = $true
Wait = $true
}
# Start process and track exit code
$process = Start-Process @processOptions
$exitCode = $process.ExitCode
# copy the .exe file to the new sub-directory
$destinationExePath = Join-Path -Path $destinationPath -ChildPath $exeName
Copy-Item -Path $exeFilePath -Destination $destinationExePath
# Prints the exit code
Write-Host "Install exit code: $exitCode"
+4
View File
@@ -0,0 +1,4 @@
package_name=$PACKAGE_ID
# Fleet uninstalls app using product name that's extracted on upload
apt remove "$package_name" -y
+17
View File
@@ -0,0 +1,17 @@
# Fleet extracts name from installer (EXE) and saves it to package ID variable
$softwareName = $PACKAGE_ID
# Get the list of subkeys under the Uninstall registry path
$uninstallKeys = Get-ChildItem "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall" | ForEach-Object { Get-ItemProperty $_.PSPath }
# Loop through each registry key to find the one containing "$softwareName" in DisplayName and run uninstall command from UninstallString
foreach ($key in $uninstallKeys) {
if ($key.DisplayName -like "*$softwareName*") {
# Get the uninstall command
$uninstallCommand = if ($key.QuietUninstallString) { $key.QuietUninstallString } else { $key.UninstallString }
# Run the uninstall command with arguments using the call operator &
& $uninstallCommand
break # Exit the loop once the software is found and uninstalled
}
}
+4
View File
@@ -0,0 +1,4 @@
$product_code = $PACKAGE_ID
# Fleet uninstalls app using product code that's extracted on upload
msiexec /quiet /x $product_code
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
# Fleet extracts and saves package IDs.
pkg_ids=$PACKAGE_ID
# Get all files associated with package and remove them
for pkg_id in "${pkg_ids[@]}"
do
# Get volume and location of package
volume=$(pkgutil --pkg-info "$pkg_id" | grep -i "volume" | awk '{for (i=2; i<NF; i++) printf $i " "; print $NF}')
location=$(pkgutil --pkg-info "$pkg_id" | grep -i "location" | awk '{for (i=2; i<NF; i++) printf $i " "; print $NF}')
# Check if this package id corresponds to a valid/installed package
if [[ ! -z "$volume" && ! -z "$location" ]]; then
# Remove individual files/directories belonging to package
pkgutil --files "$pkg_id" | sed -e 's@^@'"$volume""$location"'/@' | tr '\n' '\0' | xargs -n 1 -0 rm -rf
# Remove receipts
pkgutil --forget "$pkg_id"
else
echo "WARNING: volume or location are empty for package ID $pkg_id"
fi
done
+1
View File
@@ -0,0 +1 @@
- `hello-world-installer.exe` is an installer with a text file. It was created using [Inno Setup](https://jrsoftware.org/isinfo.php) on Windows.
Binary file not shown.
+45 -5
View File
@@ -123,6 +123,7 @@ type distributionPkgRef struct {
BundleVersions []distributionBundleVersion `xml:"bundle-version"`
MustClose distributionMustClose `xml:"must-close"`
PackageIdentifier string `xml:"packageIdentifier,attr"`
InstallKBytes string `xml:"installKBytes,attr"`
}
// distributionBundleVersion represents the bundle-version element
@@ -223,21 +224,55 @@ func parseDistributionFile(rawXML []byte) (*InstallerMetadata, error) {
return nil, fmt.Errorf("unmarshal Distribution XML: %w", err)
}
name, identifier, version := getDistributionInfo(&distXML)
name, identifier, version, packageIDs := getDistributionInfo(&distXML)
return &InstallerMetadata{
Name: name,
Version: version,
BundleIdentifier: identifier,
PackageIDs: packageIDs,
}, nil
}
// getDistributionInfo gets the name, bundle identifier and version of a PKG distribution file
func getDistributionInfo(d *distributionXML) (name string, identifier string, version string) {
func getDistributionInfo(d *distributionXML) (name string, identifier string, version string, packageIDs []string) {
var appVersion string
// find the package ids that have an installation size
var packageIDSet = make(map[string]struct{}, 1)
for _, pkg := range d.PkgRefs {
if pkg.InstallKBytes != "" && pkg.InstallKBytes != "0" {
var id string
if pkg.PackageIdentifier != "" {
id = pkg.PackageIdentifier
} else if pkg.ID != "" {
id = pkg.ID
}
if id != "" {
packageIDSet[id] = struct{}{}
}
}
}
if len(packageIDSet) == 0 {
// if we didn't find any package IDs with installation size, then grab all of them
for _, pkg := range d.PkgRefs {
var id string
if pkg.PackageIdentifier != "" {
id = pkg.PackageIdentifier
} else if pkg.ID != "" {
id = pkg.ID
}
if id != "" {
packageIDSet[id] = struct{}{}
}
}
}
for id := range packageIDSet {
packageIDs = append(packageIDs, id)
}
out:
// first, look in all the bundle versions for one that has a `path` attribute
// look in all the bundle versions for one that has a `path` attribute
// that is not nested, this is generally the case for packages that distribute
// `.app` files, which are ultimately picked up as an installed app by osquery
for _, pkg := range d.PkgRefs {
@@ -284,6 +319,11 @@ out:
identifier = d.Product.ID
}
// if package IDs are still empty, use the identifier as the package ID
if len(packageIDs) == 0 && identifier != "" {
packageIDs = append(packageIDs, identifier)
}
// for the name, try to use the title and fallback to the bundle
// identifier
if name == "" && d.Title != "" {
@@ -296,7 +336,7 @@ out:
// for the version, try to use the top-level product version, if not,
// fallback to any version definition alongside the name or the first
// version in a pkg-ref we find.
if version == "" && d.Product.Version != "" {
if d.Product.Version != "" {
version = d.Product.Version
}
if version == "" && appVersion != "" {
@@ -310,7 +350,7 @@ out:
}
}
return name, identifier, version
return name, identifier, version, packageIDs
}
// isValidAppFilePath checks if the given input is a file name ending with .app
+80 -44
View File
@@ -8,6 +8,7 @@ import (
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -52,113 +53,147 @@ func TestCheckPKGSignature(t *testing.T) {
}
func TestParseRealDistributionFiles(t *testing.T) {
t.Parallel()
tests := []struct {
file string
expectedName string
expectedVersion string
expectedBundleID string
file string
expectedName string
expectedVersion string
expectedBundleID string
expectedPackageIDs []string
}{
{
file: "distribution-1password.xml",
expectedName: "1Password.app",
expectedVersion: "8.10.34",
expectedBundleID: "com.1password.1password",
file: "distribution-1password.xml",
expectedName: "1Password.app",
expectedVersion: "8.10.34",
expectedBundleID: "com.1password.1password",
expectedPackageIDs: []string{"com.1password.1password"},
},
{
file: "distribution-chrome.xml",
expectedName: "Google Chrome.app",
expectedVersion: "126.0.6478.62",
expectedBundleID: "com.google.Chrome",
file: "distribution-chrome.xml",
expectedName: "Google Chrome.app",
expectedVersion: "126.0.6478.62",
expectedBundleID: "com.google.Chrome",
expectedPackageIDs: []string{"com.google.Chrome"},
},
{
file: "distribution-edge.xml",
expectedName: "Microsoft Edge.app",
expectedVersion: "126.0.2592.56",
expectedBundleID: "com.microsoft.edgemac",
file: "distribution-edge.xml",
expectedName: "Microsoft Edge.app",
expectedVersion: "126.0.2592.56",
expectedBundleID: "com.microsoft.edgemac",
expectedPackageIDs: []string{"com.microsoft.edgemac"},
},
{
file: "distribution-firefox.xml",
expectedName: "Firefox.app",
expectedVersion: "99.0",
expectedBundleID: "org.mozilla.firefox",
file: "distribution-firefox.xml",
expectedName: "Firefox.app",
expectedVersion: "99.0",
expectedBundleID: "org.mozilla.firefox",
expectedPackageIDs: []string{"org.mozilla.firefox"},
},
{
file: "distribution-fleet.xml",
expectedName: "Fleet osquery",
expectedVersion: "42.0.0",
expectedBundleID: "com.fleetdm.orbit",
file: "distribution-fleet.xml",
expectedName: "Fleet osquery",
expectedVersion: "42.0.0",
expectedBundleID: "com.fleetdm.orbit",
expectedPackageIDs: []string{"com.fleetdm.orbit.base.pkg"},
},
{
file: "distribution-go.xml",
expectedName: "Go",
expectedVersion: "go1.22.4",
expectedBundleID: "org.golang.go",
file: "distribution-go.xml",
expectedName: "Go",
expectedVersion: "go1.22.4",
expectedBundleID: "org.golang.go",
expectedPackageIDs: []string{"org.golang.go"},
},
{
file: "distribution-microsoft-teams.xml",
expectedName: "Microsoft Teams.app",
expectedVersion: "24124.1412.2911.3341",
expectedBundleID: "com.microsoft.teams2",
expectedPackageIDs: []string{"com.microsoft.teams2", "com.microsoft.package.Microsoft_AutoUpdate.app",
"com.microsoft.MSTeamsAudioDevice"},
},
{
file: "distribution-zoom.xml",
expectedName: "zoom.us.app",
expectedVersion: "6.0.11.35001",
expectedBundleID: "us.zoom.xos",
file: "distribution-zoom.xml",
expectedName: "zoom.us.app",
expectedVersion: "6.0.11.35001",
expectedBundleID: "us.zoom.xos",
expectedPackageIDs: []string{"us.zoom.pkg.videomeeting"},
},
{
file: "distribution-acrobatreader.xml",
expectedName: "Adobe Acrobat Reader.app",
expectedVersion: "24.002.20857",
expectedBundleID: "com.adobe.Reader",
expectedPackageIDs: []string{"com.adobe.acrobat.DC.reader.app.pkg.MUI", "com.adobe.acrobat.DC.reader.appsupport.pkg.MUI",
"com.adobe.acrobat.reader.DC.reader.app.pkg.MUI", "com.adobe.armdc.app.pkg"},
},
{
file: "distribution-airtame.xml",
expectedName: "Airtame.app",
expectedVersion: "4.10.1",
expectedBundleID: "com.airtame.airtame-application",
file: "distribution-airtame.xml",
expectedName: "Airtame.app",
expectedVersion: "4.10.1",
expectedBundleID: "com.airtame.airtame-application",
expectedPackageIDs: []string{"com.airtame.airtame-application"},
},
{
file: "distribution-boxdrive.xml",
expectedName: "Box.app",
expectedVersion: "2.38.173",
expectedBundleID: "com.box.desktop",
expectedPackageIDs: []string{"com.box.desktop.installer.desktop", "com.box.desktop.installer.local.appsupport",
"com.box.desktop.installer.autoupdater", "com.box.desktop.installer.osxfuse"},
},
{
file: "distribution-iriunwebcam.xml",
expectedName: "IriunWebcam.app",
expectedVersion: "2.8.8",
expectedBundleID: "com.iriun.macwebcam",
// Note: "com.iriun.pkg.multicam" is part of the installer package, but it is not actually installed by default.
// We can't reliably determine which packages are installed by the installer, so we just list all of them.
expectedPackageIDs: []string{"com.iriun.pkg.webcam.tmp", "com.iriun.pkg.multicam"},
},
{
file: "distribution-microsoftexcel.xml",
expectedName: "Microsoft Excel.app",
expectedVersion: "16.86",
expectedBundleID: "com.microsoft.Excel",
expectedPackageIDs: []string{"com.microsoft.package.Microsoft_Excel.app", "com.microsoft.package.Microsoft_AutoUpdate.app",
"com.microsoft.pkg.licensing"},
},
{
file: "distribution-microsoftword.xml",
expectedName: "Microsoft Word.app",
expectedVersion: "16.86",
expectedBundleID: "com.microsoft.Word",
expectedPackageIDs: []string{"com.microsoft.package.Microsoft_Word.app", "com.microsoft.package.Microsoft_AutoUpdate.app",
"com.microsoft.pkg.licensing"},
},
{
file: "distribution-miscrosoftpowerpoint.xml",
expectedName: "Microsoft PowerPoint.app",
expectedVersion: "16.86",
expectedBundleID: "com.microsoft.Powerpoint",
expectedPackageIDs: []string{"com.microsoft.package.Microsoft_PowerPoint.app", "com.microsoft.package.Microsoft_AutoUpdate.app",
"com.microsoft.pkg.licensing"},
},
{
file: "distribution-ringcentral.xml",
expectedName: "RingCentral.app",
expectedVersion: "24.1.32.9774",
expectedBundleID: "com.ringcentral.glip",
file: "distribution-ringcentral.xml",
expectedName: "RingCentral.app",
expectedVersion: "24.1.32.9774",
expectedBundleID: "com.ringcentral.glip",
expectedPackageIDs: []string{"com.ringcentral.glip"},
},
{
file: "distribution-zoom-full.xml",
expectedName: "Zoom Workplace",
expectedVersion: "6.1.1.36333",
expectedBundleID: "us.zoom.xos",
file: "distribution-zoom-full.xml",
expectedName: "Zoom Workplace",
expectedVersion: "6.1.1.36333",
expectedBundleID: "us.zoom.xos",
expectedPackageIDs: []string{"us.zoom.pkg.videomeeting"},
},
{
file: "test-zero-installkbytes.xml",
expectedName: "ZeroInstallSize.app",
expectedVersion: "1.2.3",
expectedBundleID: "com.bozo.zeroinstallsize",
expectedPackageIDs: []string{"com.bozo.zeroinstallsize.app"},
},
}
@@ -168,6 +203,7 @@ func TestParseRealDistributionFiles(t *testing.T) {
require.NoError(t, err)
metadata, err := parseDistributionFile(rawXML)
require.NoError(t, err)
assert.ElementsMatch(t, tt.expectedPackageIDs, metadata.PackageIDs)
require.Equal(t, tt.expectedName, metadata.Name)
require.Equal(t, tt.expectedVersion, metadata.Version)
require.Equal(t, tt.expectedBundleID, metadata.BundleIdentifier)
+67 -11
View File
@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"slices"
"strings"
"unicode"
"github.com/fleetdm/fleet/v4/server/fleet"
@@ -36,6 +37,16 @@ type Controls struct {
EnableDiskEncryption interface{} `json:"enable_disk_encryption"`
Scripts []BaseItem `json:"scripts"`
Defined bool
}
func (c Controls) Set() bool {
return c.MacOSUpdates != nil || c.IOSUpdates != nil ||
c.IPadOSUpdates != nil || c.MacOSSettings != nil ||
c.MacOSSetup != nil || c.MacOSMigration != nil ||
c.WindowsUpdates != nil || c.WindowsSettings != nil || c.WindowsEnabledAndConfigured != nil ||
c.EnableDiskEncryption != nil || len(c.Scripts) > 0
}
type Policy struct {
@@ -88,8 +99,10 @@ type GitOpsSoftware struct {
AppStoreApps []*fleet.TeamSpecAppStoreApp
}
type Logf func(format string, a ...interface{})
// GitOpsFromFile parses a GitOps yaml file.
func GitOpsFromFile(filePath, baseDir string, appConfig *fleet.EnrichedAppConfig) (*GitOps, error) {
func GitOpsFromFile(filePath, baseDir string, appConfig *fleet.EnrichedAppConfig, logFn Logf) (*GitOps, error) {
b, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to read file: %s: %w", filePath, err)
@@ -126,17 +139,30 @@ func GitOpsFromFile(filePath, baseDir string, appConfig *fleet.EnrichedAppConfig
} else {
multiError = parseOrgSettings(orgSettingsRaw, result, baseDir, multiError)
}
} else if teamOk && teamSettingsOk {
} else if teamOk {
multiError = parseName(teamRaw, result, multiError)
multiError = parseTeamSettings(teamSettingsRaw, result, baseDir, multiError)
if result.IsNoTeam() {
if teamSettingsOk {
multiError = multierror.Append(multiError, fmt.Errorf("cannot set 'team_settings' on 'No team' file: %q", filePath))
}
if filepath.Base(filePath) != "no-team.yml" {
multiError = multierror.Append(multiError, fmt.Errorf("file %q for 'No team' must be named 'no-team.yml'", filePath))
}
} else {
if !teamSettingsOk {
multiError = multierror.Append(multiError, errors.New("'team_settings' is required when 'name' is provided"))
} else {
multiError = parseTeamSettings(teamSettingsRaw, result, baseDir, multiError)
}
}
} else {
multiError = multierror.Append(multiError, errors.New("either 'org_settings' or 'name' and 'team_settings' is required"))
}
// Validate the required top level options
multiError = parseControls(top, result, baseDir, multiError)
multiError = parseAgentOptions(top, result, baseDir, multiError)
multiError = parseQueries(top, result, baseDir, multiError)
multiError = parseAgentOptions(top, result, baseDir, logFn, multiError)
multiError = parseQueries(top, result, baseDir, logFn, multiError)
if appConfig != nil && appConfig.License.IsPremium() {
multiError = parseSoftware(top, result, baseDir, multiError)
@@ -161,6 +187,20 @@ func parseName(raw json.RawMessage, result *GitOps, multiError *multierror.Error
return multiError
}
func (g *GitOps) global() bool {
return g.TeamName == nil || *g.TeamName == ""
}
func (g *GitOps) IsNoTeam() bool {
return g.TeamName != nil && isNoTeam(*g.TeamName)
}
func isNoTeam(teamName string) bool {
return strings.ToLower(teamName) == strings.ToLower(noTeam)
}
const noTeam = "No team"
func parseOrgSettings(raw json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error {
var orgSettingsTop BaseItem
if err := json.Unmarshal(raw, &orgSettingsTop); err != nil {
@@ -314,9 +354,14 @@ func parseSecrets(result *GitOps, multiError *multierror.Error) *multierror.Erro
return multiError
}
func parseAgentOptions(top map[string]json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error {
func parseAgentOptions(top map[string]json.RawMessage, result *GitOps, baseDir string, logFn Logf, multiError *multierror.Error) *multierror.Error {
agentOptionsRaw, ok := top["agent_options"]
if !ok {
if result.IsNoTeam() {
if ok {
logFn("[!] 'agent_options' is not supported for \"No team\". This key will be ignored.")
}
return multiError
} else if !ok {
return multierror.Append(multiError, errors.New("'agent_options' is required"))
}
var agentOptionsTop BaseItem
@@ -366,12 +411,14 @@ func parseAgentOptions(top map[string]json.RawMessage, result *GitOps, baseDir s
func parseControls(top map[string]json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error {
controlsRaw, ok := top["controls"]
if !ok {
return multierror.Append(multiError, errors.New("'controls' is required"))
// Nothing to do, return.
return multiError
}
var controlsTop Controls
if err := json.Unmarshal(controlsRaw, &controlsTop); err != nil {
return multierror.Append(multiError, fmt.Errorf("failed to unmarshal controls: %v", err))
}
controlsTop.Defined = true
if controlsTop.Path == nil {
result.Controls = controlsTop
} else {
@@ -516,9 +563,14 @@ func parsePolicyInstallSoftware(baseDir string, teamName *string, policy *Policy
return nil
}
func parseQueries(top map[string]json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error {
func parseQueries(top map[string]json.RawMessage, result *GitOps, baseDir string, logFn Logf, multiError *multierror.Error) *multierror.Error {
queriesRaw, ok := top["queries"]
if !ok {
if result.IsNoTeam() {
if ok {
logFn("[!] 'queries' is not supported for \"No team\". This key will be ignored.")
}
return multiError
} else if !ok {
return multierror.Append(multiError, errors.New("'queries' key is required"))
}
var queries []Query
@@ -593,7 +645,11 @@ func parseQueries(top map[string]json.RawMessage, result *GitOps, baseDir string
func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error {
softwareRaw, ok := top["software"]
if !ok {
if result.global() {
if ok && string(softwareRaw) != "null" {
return multierror.Append(multiError, errors.New("'software' cannot be set on global file"))
}
} else if !ok {
return multierror.Append(multiError, errors.New("'software' is required"))
}
var software Software
+60 -14
View File
@@ -53,9 +53,22 @@ func createTempFile(t *testing.T, pattern, contents string) (filePath string, ba
return tmpFile.Name(), filepath.Dir(tmpFile.Name())
}
func createNamedFileOnTempDir(t *testing.T, name string, contents string) (filePath string, baseDir string) {
tmpFilePath := filepath.Join(t.TempDir(), name)
tmpFile, err := os.Create(tmpFilePath)
require.NoError(t, err)
_, err = tmpFile.WriteString(contents)
require.NoError(t, err)
require.NoError(t, tmpFile.Close())
return tmpFile.Name(), filepath.Dir(tmpFile.Name())
}
func gitOpsFromString(t *testing.T, s string) (*GitOps, error) {
path, basePath := createTempFile(t, "", s)
return GitOpsFromFile(path, basePath, nil)
return GitOpsFromFile(path, basePath, nil, nopLogf)
}
func nopLogf(_ string, _ ...interface{}) {
}
func TestValidGitOpsYaml(t *testing.T) {
@@ -106,8 +119,6 @@ func TestValidGitOpsYaml(t *testing.T) {
os.Unsetenv(k)
}
})
} else {
t.Parallel()
}
var appConfig *fleet.EnrichedAppConfig
@@ -118,7 +129,7 @@ func TestValidGitOpsYaml(t *testing.T) {
}
}
gitops, err := GitOpsFromFile(test.filePath, "./testdata", appConfig)
gitops, err := GitOpsFromFile(test.filePath, "./testdata", appConfig, nopLogf)
require.NoError(t, err)
if test.isTeam {
@@ -142,6 +153,14 @@ func TestValidGitOpsYaml(t *testing.T) {
require.Len(t, secrets.([]*fleet.EnrollSecret), 2)
assert.Equal(t, "SampleSecret123", secrets.([]*fleet.EnrollSecret)[0].Secret)
assert.Equal(t, "ABC", secrets.([]*fleet.EnrollSecret)[1].Secret)
require.Len(t, gitops.Software.Packages, 2)
for _, pkg := range gitops.Software.Packages {
if strings.Contains(pkg.URL, "MicrosoftTeams") {
assert.Equal(t, "uninstall.sh", pkg.UninstallScript.Path)
} else {
assert.Empty(t, pkg.UninstallScript.Path)
}
}
} else {
// Check org settings
serverSettings, ok := gitops.OrgSettings["server_settings"]
@@ -443,14 +462,44 @@ func TestInvalidGitOpsYaml(t *testing.T) {
_, err = gitOpsFromString(t, config)
assert.ErrorContains(t, err, "must have a 'secret' key")
// Missing team_settings.
config = getConfig([]string{"team_settings"})
_, err = gitOpsFromString(t, config)
assert.ErrorContains(t, err, "'team_settings' is required when 'name' is provided")
// team_settings set on a "no-team.yml".
config = getConfig([]string{"name"})
config += "name: No team\n"
noTeamPath1, noTeamBasePath1 := createNamedFileOnTempDir(t, "no-team.yml", config)
_, err = GitOpsFromFile(noTeamPath1, noTeamBasePath1, nil, nopLogf)
assert.ErrorContains(t, err, fmt.Sprintf("cannot set 'team_settings' on 'No team' file: %q", noTeamPath1))
// 'No team' file with invalid name.
config = getConfig([]string{"name", "team_settings"})
config += "name: No team\n"
noTeamPath2, noTeamBasePath2 := createNamedFileOnTempDir(t, "foobar.yml", config)
_, err = GitOpsFromFile(noTeamPath2, noTeamBasePath2, nil, nopLogf)
assert.ErrorContains(t, err, fmt.Sprintf("file %q for 'No team' must be named 'no-team.yml'", noTeamPath2))
// Missing secrets
config = getConfig([]string{"team_settings"})
config += "team_settings:\n"
_, err = gitOpsFromString(t, config)
assert.ErrorContains(t, err, "'team_settings.secrets' is required")
} else {
// 'software' is not allowed in global config
config := getConfig(nil)
config += "software:\n packages:\n - url: https://example.com\n"
path1, basePath1 := createTempFile(t, "", config)
appConfig := fleet.EnrichedAppConfig{}
appConfig.License = &fleet.LicenseInfo{
Tier: fleet.TierPremium,
}
_, err = GitOpsFromFile(path1, basePath1, &appConfig, nopLogf)
assert.ErrorContains(t, err, "'software' cannot be set on global file")
// Invalid org_settings
config := getConfig([]string{"org_settings"})
config = getConfig([]string{"org_settings"})
config += "org_settings:\n path: [2]\n"
_, err = gitOpsFromString(t, config)
assert.ErrorContains(t, err, "failed to unmarshal org_settings")
@@ -595,9 +644,6 @@ func TestTopLevelGitOpsValidation(t *testing.T) {
"missing_all": {
optsToExclude: []string{"controls", "queries", "policies", "agent_options", "org_settings"},
},
"missing_controls": {
optsToExclude: []string{"controls"},
},
"missing_queries": {
optsToExclude: []string{"queries"},
},
@@ -724,7 +770,7 @@ func TestGitOpsPaths(t *testing.T) {
err = os.WriteFile(mainTmpFile.Name(), []byte(config), 0o644)
require.NoError(t, err)
_, err = GitOpsFromFile(mainTmpFile.Name(), dir, nil)
_, err = GitOpsFromFile(mainTmpFile.Name(), dir, nil, nopLogf)
assert.NoError(t, err)
// Test a bad path
@@ -737,7 +783,7 @@ func TestGitOpsPaths(t *testing.T) {
err = os.WriteFile(mainTmpFile.Name(), []byte(config), 0o644)
require.NoError(t, err)
_, err = GitOpsFromFile(mainTmpFile.Name(), dir, nil)
_, err = GitOpsFromFile(mainTmpFile.Name(), dir, nil, nopLogf)
assert.ErrorContains(t, err, "no such file or directory")
// Test a bad file -- cannot be unmarshalled
@@ -772,7 +818,7 @@ func TestGitOpsPaths(t *testing.T) {
}
err = os.WriteFile(mainTmpFile.Name(), []byte(config), 0o644)
require.NoError(t, err)
_, err = GitOpsFromFile(mainTmpFile.Name(), dir, nil)
_, err = GitOpsFromFile(mainTmpFile.Name(), dir, nil, nopLogf)
assert.ErrorContains(t, err, "nested paths are not supported")
},
)
@@ -830,7 +876,7 @@ software:
Tier: fleet.TierPremium,
}
path, basePath := createTempFile(t, "", config)
_, err = GitOpsFromFile(path, basePath, &appConfig)
_, err = GitOpsFromFile(path, basePath, &appConfig, nopLogf)
assert.ErrorContains(t, err, fmt.Sprintf("software URL \"%s\" is too long, must be less than 256 characters", tooBigURL))
// Policy references a software installer not present in the team.
@@ -857,7 +903,7 @@ software:
0o755,
)
require.NoError(t, err)
_, err = GitOpsFromFile(path, basePath, &appConfig)
_, err = GitOpsFromFile(path, basePath, &appConfig, nopLogf)
assert.ErrorContains(t, err,
"install_software.package_path URL https://statics.teams.cdn.office.net/production-osx/enterprise/webview2/lkg/MicrosoftTeams.pkg not found on team",
)
@@ -889,7 +935,7 @@ software:
appConfig.License = &fleet.LicenseInfo{
Tier: fleet.TierPremium,
}
_, err = GitOpsFromFile(path, basePath, &appConfig)
_, err = GitOpsFromFile(path, basePath, &appConfig, nopLogf)
assert.ErrorContains(t, err, "failed to unmarshal install_software.package_path file")
}
+2
View File
@@ -1,2 +1,4 @@
url: https://statics.teams.cdn.office.net/production-osx/enterprise/webview2/lkg/MicrosoftTeams.pkg
self_service: false
uninstall_script:
path: uninstall.sh