From 92e540aee57ef8519bc8a95e30d59eea9da0aef0 Mon Sep 17 00:00:00 2001 From: Roberto Dip Date: Wed, 1 May 2024 14:15:59 -0300 Subject: [PATCH] add scripts to add/remove software (#18649) for: - https://github.com/fleetdm/fleet/issues/18314 - https://github.com/fleetdm/fleet/issues/18315 - https://github.com/fleetdm/fleet/issues/18317 - https://github.com/fleetdm/fleet/issues/18316 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality --- frontend/interfaces/software.ts | 4 + .../utilities/software_install_scripts.ts | 50 +++++++++++ pkg/file/management.go | 85 +++++++++++++++++++ pkg/file/management_test.go | 73 ++++++++++++++++ pkg/file/scripts/README.md | 15 ++++ pkg/file/scripts/install_deb.sh | 1 + pkg/file/scripts/install_exe.ps1 | 21 +++++ pkg/file/scripts/install_msi.ps1 | 9 ++ pkg/file/scripts/install_pkg.sh | 1 + pkg/file/scripts/remove_deb.sh | 1 + pkg/file/scripts/remove_exe.ps1 | 14 +++ pkg/file/scripts/remove_msi.ps1 | 9 ++ pkg/file/scripts/remove_pkg.sh | 8 ++ .../testdata/scripts/install_deb.sh.golden | 1 + .../testdata/scripts/install_exe.ps1.golden | 21 +++++ .../testdata/scripts/install_msi.ps1.golden | 9 ++ .../testdata/scripts/install_pkg.sh.golden | 1 + .../testdata/scripts/remove_deb.sh.golden | 1 + .../testdata/scripts/remove_exe.ps1.golden | 14 +++ .../testdata/scripts/remove_msi.ps1.golden | 9 ++ .../testdata/scripts/remove_pkg.sh.golden | 8 ++ webpack.config.js | 5 +- 22 files changed, 359 insertions(+), 1 deletion(-) create mode 100644 frontend/utilities/software_install_scripts.ts create mode 100644 pkg/file/management.go create mode 100644 pkg/file/management_test.go create mode 100644 pkg/file/scripts/README.md create mode 100644 pkg/file/scripts/install_deb.sh create mode 100644 pkg/file/scripts/install_exe.ps1 create mode 100644 pkg/file/scripts/install_msi.ps1 create mode 100644 pkg/file/scripts/install_pkg.sh create mode 100644 pkg/file/scripts/remove_deb.sh create mode 100644 pkg/file/scripts/remove_exe.ps1 create mode 100644 pkg/file/scripts/remove_msi.ps1 create mode 100644 pkg/file/scripts/remove_pkg.sh create mode 100644 pkg/file/testdata/scripts/install_deb.sh.golden create mode 100644 pkg/file/testdata/scripts/install_exe.ps1.golden create mode 100644 pkg/file/testdata/scripts/install_msi.ps1.golden create mode 100644 pkg/file/testdata/scripts/install_pkg.sh.golden create mode 100644 pkg/file/testdata/scripts/remove_deb.sh.golden create mode 100644 pkg/file/testdata/scripts/remove_exe.ps1.golden create mode 100644 pkg/file/testdata/scripts/remove_msi.ps1.golden create mode 100644 pkg/file/testdata/scripts/remove_pkg.sh.golden diff --git a/frontend/interfaces/software.ts b/frontend/interfaces/software.ts index 3ce5f5d268..8f47761baf 100644 --- a/frontend/interfaces/software.ts +++ b/frontend/interfaces/software.ts @@ -133,3 +133,7 @@ export const formatSoftwareType = ({ } return type; }; + +// ISoftwareInstallerType defines the supported installer types for +// software uploaded by the IT admin. +export type ISoftwareInstallerType = "pkg" | "msi" | "deb" | "exe"; diff --git a/frontend/utilities/software_install_scripts.ts b/frontend/utilities/software_install_scripts.ts new file mode 100644 index 0000000000..a26fcb0a15 --- /dev/null +++ b/frontend/utilities/software_install_scripts.ts @@ -0,0 +1,50 @@ +import { ISoftwareInstallerType } from "interfaces/software"; + +// @ts-ignore +import installPkg from "../../pkg/file/scripts/install_pkg.sh"; +// @ts-ignore +import installMsi from "../../pkg/file/scripts/install_msi.ps1"; +// @ts-ignore +import installExe from "../../pkg/file/scripts/install_exe.ps1"; +// @ts-ignore +import installDeb from "../../pkg/file/scripts/install_deb.sh"; + +const replaceVariables = (rawScript: string, installerPath: string): string => { + return rawScript.replace("$INSTALLER_PATH", installerPath); +}; + +/* + * getInstallScript returns a string with a script to install the + * provided software. + * + * Note that we don't do any sanitization of the arguments here, + * delegating that to the caller which should have the right context + * about what should be escaped. + * */ +const getInstallScript = ( + filetype: ISoftwareInstallerType, + path: string +): string => { + let rawScript: string; + switch (filetype) { + case "pkg": + rawScript = installPkg; + break; + case "msi": + rawScript = installMsi; + break; + case "deb": + rawScript = installDeb; + break; + case "exe": + rawScript = installExe; + break; + default: + // this should never happen as this function is type-guarded + throw new Error(`unsupported file type: ${filetype}`); + } + + return replaceVariables(rawScript, path); +}; + +export default getInstallScript; diff --git a/pkg/file/management.go b/pkg/file/management.go new file mode 100644 index 0000000000..28d2e7710a --- /dev/null +++ b/pkg/file/management.go @@ -0,0 +1,85 @@ +package file + +import ( + _ "embed" + "strings" +) + +type InstallerType string + +const ( + InstallerTypeMsi InstallerType = "msi" + InstallerTypeDeb InstallerType = "deb" + InstallerTypePkg InstallerType = "pkg" + InstallerTypeExe InstallerType = "exe" +) + +//go:embed scripts/install_pkg.sh +var installPkgScript string + +//go:embed scripts/install_msi.ps1 +var installMsiScript string + +//go:embed scripts/install_exe.ps1 +var installExeScript string + +//go:embed scripts/install_deb.sh +var installDebScript string + +// GetInstallScript returns a script that can be used to install the given +// installer based on the provided type +func GetInstallScript(installerType InstallerType, installerPath string) string { + var rawScript string + + switch installerType { + case InstallerTypeMsi: + rawScript = installMsiScript + case InstallerTypeDeb: + rawScript = installDebScript + case InstallerTypePkg: + rawScript = installPkgScript + case InstallerTypeExe: + rawScript = installExeScript + default: + return "" + } + + return replaceVars(rawScript, installerPath) +} + +//go:embed scripts/remove_exe.ps1 +var removeExeScript string + +//go:embed scripts/remove_pkg.sh +var removePkgScript string + +//go:embed scripts/remove_msi.ps1 +var removeMsiScript string + +//go:embed scripts/remove_deb.sh +var removeDebScript string + +// GetRemoveScript returns a script that can be used to remove the given +// installer based on the provided type +func GetRemoveScript(installerType InstallerType, installerPath string) string { + var rawScript string + + switch installerType { + case InstallerTypeMsi: + rawScript = removeMsiScript + case InstallerTypeDeb: + rawScript = removeDebScript + case InstallerTypePkg: + rawScript = removePkgScript + case InstallerTypeExe: + rawScript = removeExeScript + default: + return "" + } + + return replaceVars(rawScript, installerPath) +} + +func replaceVars(rawScript string, installerPath string) string { + return strings.Replace(rawScript, "$INSTALLER_PATH", installerPath, -1) +} diff --git a/pkg/file/management_test.go b/pkg/file/management_test.go new file mode 100644 index 0000000000..1b6af00f56 --- /dev/null +++ b/pkg/file/management_test.go @@ -0,0 +1,73 @@ +package file + +import ( + "flag" + "io" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +var ( + update = flag.Bool("update", false, "update the golden files of this test") +) + +func TestMain(m *testing.M) { + flag.Parse() + os.Exit(m.Run()) +} + +// Note: to update the goldens, run the tests with `-update`: +// +// go test ./pkg/file/... -update +func TestGetInstallAndRemoveScript(t *testing.T) { + scriptsByType := map[InstallerType][2]string{ + InstallerTypeMsi: { + "./scripts/install_msi.ps1", + "./scripts/remove_msi.ps1", + }, + InstallerTypePkg: { + "./scripts/install_pkg.sh", + "./scripts/remove_pkg.sh", + }, + InstallerTypeDeb: { + "./scripts/install_deb.sh", + "./scripts/remove_deb.sh", + }, + InstallerTypeExe: { + "./scripts/install_exe.ps1", + "./scripts/remove_exe.ps1", + }, + } + + for itype, scripts := range scriptsByType { + installerPath := "./foo/bar baz.f" + + gotScript := GetInstallScript(itype, installerPath) + assertGoldenMatches(t, scripts[0], gotScript, *update) + + gotScript = GetRemoveScript(itype, installerPath) + assertGoldenMatches(t, scripts[1], gotScript, *update) + } +} + +func assertGoldenMatches(t *testing.T, goldenFile string, actual string, update bool) { + t.Helper() + goldenPath := filepath.Join("testdata", goldenFile+".golden") + + f, err := os.OpenFile(goldenPath, os.O_RDWR|os.O_CREATE, 0644) + require.NoError(t, err) + defer f.Close() + + if update { + _, err := f.WriteString(actual) + require.NoError(t, err) + return + } + + content, err := io.ReadAll(f) + require.NoError(t, err) + require.Equal(t, string(content), actual) +} diff --git a/pkg/file/scripts/README.md b/pkg/file/scripts/README.md new file mode 100644 index 0000000000..42c81fe507 --- /dev/null +++ b/pkg/file/scripts/README.md @@ -0,0 +1,15 @@ +### File scripts + +This folder contains scripts to install/remove software for different types of installers. + +Scripts are stored on their own files for two reasons: + +1. Some of them are read and displayed in the UI. +2. It's helpful to have good syntax highlighting and easy ways to run them. + +#### Variables + +Because the scripts are shared between Go and JS, the convention is to declare variables using `$VAR_NAME` and document its intended usage here. + +- `$INSTALLER_PATH` path to the installer file. + diff --git a/pkg/file/scripts/install_deb.sh b/pkg/file/scripts/install_deb.sh new file mode 100644 index 0000000000..16d246663e --- /dev/null +++ b/pkg/file/scripts/install_deb.sh @@ -0,0 +1 @@ +apt-get install -f "$INSTALLER_PATH" diff --git a/pkg/file/scripts/install_exe.ps1 b/pkg/file/scripts/install_exe.ps1 new file mode 100644 index 0000000000..4aa91f5b1c --- /dev/null +++ b/pkg/file/scripts/install_exe.ps1 @@ -0,0 +1,21 @@ +$exeFilePath = "$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) + +# Program Files is the recommended location for any third-party software on Windows. +# +# Note: a x86 binary on a x64 system is supposed to go in +# $env:ProgramFiles(x86) but I didn't find a reliable way to get this +# information from the exe file. +$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 +} + +# copy the .exe file to the new sub-directory +$destinationExePath = Join-Path -Path $destinationPath -ChildPath $exeName +Copy-Item -Path $exeFilePath -Destination $destinationExePath diff --git a/pkg/file/scripts/install_msi.ps1 b/pkg/file/scripts/install_msi.ps1 new file mode 100644 index 0000000000..e4f8d8ca90 --- /dev/null +++ b/pkg/file/scripts/install_msi.ps1 @@ -0,0 +1,9 @@ +$logFile = "${env:TEMP}/fleet-install-software.log" + +$installProcess = Start-Process msiexec.exe ` + -ArgumentList "/quiet /norestart /lv ${logFile} /i `"$INSTALLER_PATH`"" ` + -PassThru -Verb RunAs -Wait + +Get-Content $logFile -Tail 500 + +exit $instalProcess.ExitCode diff --git a/pkg/file/scripts/install_pkg.sh b/pkg/file/scripts/install_pkg.sh new file mode 100644 index 0000000000..783d2941a7 --- /dev/null +++ b/pkg/file/scripts/install_pkg.sh @@ -0,0 +1 @@ +installer -pkg "$INSTALLER_PATH" -target / diff --git a/pkg/file/scripts/remove_deb.sh b/pkg/file/scripts/remove_deb.sh new file mode 100644 index 0000000000..5de7123909 --- /dev/null +++ b/pkg/file/scripts/remove_deb.sh @@ -0,0 +1 @@ +apt-get remove -y $(dpkg -f "$INSTALLER_PATH" Package) diff --git a/pkg/file/scripts/remove_exe.ps1 b/pkg/file/scripts/remove_exe.ps1 new file mode 100644 index 0000000000..eae826c960 --- /dev/null +++ b/pkg/file/scripts/remove_exe.ps1 @@ -0,0 +1,14 @@ +$exeFilePath = "$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) + +# determine the correct Program Files directory based on OS architecture +$destinationPath = Join-Path -Path $env:ProgramFiles -ChildPath $subDir +$destinationExePath = Join-Path -Path $destinationPath -ChildPath $exeName + +# remove only the exe file, while at runtime other files could have been +# created in this folder, this is a naive approach to prevent forcing us to +# remove important folders by crafting a malicious file name. +Remove-Item -Path $destinationExePath diff --git a/pkg/file/scripts/remove_msi.ps1 b/pkg/file/scripts/remove_msi.ps1 new file mode 100644 index 0000000000..899a4c9bec --- /dev/null +++ b/pkg/file/scripts/remove_msi.ps1 @@ -0,0 +1,9 @@ +$logFile = "${env:TEMP}/fleet-remove-software.log" + +$removeProcess = Start-Process msiexec.exe ` + -ArgumentList "/quiet /norestart /lv ${logFile} /x `"$INSTALLER_PATH`"" ` + -PassThru -Verb RunAs -Wait + +Get-Content $logFile -Tail 500 + +exit $instalProcess.ExitCode diff --git a/pkg/file/scripts/remove_pkg.sh b/pkg/file/scripts/remove_pkg.sh new file mode 100644 index 0000000000..84ceb7a3fc --- /dev/null +++ b/pkg/file/scripts/remove_pkg.sh @@ -0,0 +1,8 @@ +# grab the identifier from the first PackageInfo we find. Those are placed in different locations depending on the installer +pkg_id=$(tar xOvf "$INSTALLER_PATH" --include='*PackageInfo*' 2>/dev/null | sed -n 's/.*identifier="\([^"]*\)".*/\1/p') + +# remove all the files and empty directories that were installed +pkgutil --files $pkg_id | tr '\n' '\0' | xargs -n 1 -0 rm -d + +# remove the receipt +pkgutil --forget $pkg_id diff --git a/pkg/file/testdata/scripts/install_deb.sh.golden b/pkg/file/testdata/scripts/install_deb.sh.golden new file mode 100644 index 0000000000..d0c8a10f6b --- /dev/null +++ b/pkg/file/testdata/scripts/install_deb.sh.golden @@ -0,0 +1 @@ +apt-get install -f "./foo/bar baz.f" diff --git a/pkg/file/testdata/scripts/install_exe.ps1.golden b/pkg/file/testdata/scripts/install_exe.ps1.golden new file mode 100644 index 0000000000..a3fa160bcc --- /dev/null +++ b/pkg/file/testdata/scripts/install_exe.ps1.golden @@ -0,0 +1,21 @@ +$exeFilePath = "./foo/bar baz.f" + +# 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) + +# Program Files is the recommended location for any third-party software on Windows. +# +# Note: a x86 binary on a x64 system is supposed to go in +# $env:ProgramFiles(x86) but I didn't find a reliable way to get this +# information from the exe file. +$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 +} + +# copy the .exe file to the new sub-directory +$destinationExePath = Join-Path -Path $destinationPath -ChildPath $exeName +Copy-Item -Path $exeFilePath -Destination $destinationExePath diff --git a/pkg/file/testdata/scripts/install_msi.ps1.golden b/pkg/file/testdata/scripts/install_msi.ps1.golden new file mode 100644 index 0000000000..4ffd31f6f9 --- /dev/null +++ b/pkg/file/testdata/scripts/install_msi.ps1.golden @@ -0,0 +1,9 @@ +$logFile = "${env:TEMP}/fleet-install-software.log" + +$installProcess = Start-Process msiexec.exe ` + -ArgumentList "/quiet /norestart /lv ${logFile} /i `"./foo/bar baz.f`"" ` + -PassThru -Verb RunAs -Wait + +Get-Content $logFile -Tail 500 + +exit $instalProcess.ExitCode diff --git a/pkg/file/testdata/scripts/install_pkg.sh.golden b/pkg/file/testdata/scripts/install_pkg.sh.golden new file mode 100644 index 0000000000..b736f14690 --- /dev/null +++ b/pkg/file/testdata/scripts/install_pkg.sh.golden @@ -0,0 +1 @@ +installer -pkg "./foo/bar baz.f" -target / diff --git a/pkg/file/testdata/scripts/remove_deb.sh.golden b/pkg/file/testdata/scripts/remove_deb.sh.golden new file mode 100644 index 0000000000..cdc206a19b --- /dev/null +++ b/pkg/file/testdata/scripts/remove_deb.sh.golden @@ -0,0 +1 @@ +apt-get remove -y $(dpkg -f "./foo/bar baz.f" Package) diff --git a/pkg/file/testdata/scripts/remove_exe.ps1.golden b/pkg/file/testdata/scripts/remove_exe.ps1.golden new file mode 100644 index 0000000000..0f2548bd6a --- /dev/null +++ b/pkg/file/testdata/scripts/remove_exe.ps1.golden @@ -0,0 +1,14 @@ +$exeFilePath = "./foo/bar baz.f" + +# 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) + +# determine the correct Program Files directory based on OS architecture +$destinationPath = Join-Path -Path $env:ProgramFiles -ChildPath $subDir +$destinationExePath = Join-Path -Path $destinationPath -ChildPath $exeName + +# remove only the exe file, while at runtime other files could have been +# created in this folder, this is a naive approach to prevent forcing us to +# remove important folders by crafting a malicious file name. +Remove-Item -Path $destinationExePath diff --git a/pkg/file/testdata/scripts/remove_msi.ps1.golden b/pkg/file/testdata/scripts/remove_msi.ps1.golden new file mode 100644 index 0000000000..7ae7d04ec0 --- /dev/null +++ b/pkg/file/testdata/scripts/remove_msi.ps1.golden @@ -0,0 +1,9 @@ +$logFile = "${env:TEMP}/fleet-remove-software.log" + +$removeProcess = Start-Process msiexec.exe ` + -ArgumentList "/quiet /norestart /lv ${logFile} /x `"./foo/bar baz.f`"" ` + -PassThru -Verb RunAs -Wait + +Get-Content $logFile -Tail 500 + +exit $instalProcess.ExitCode diff --git a/pkg/file/testdata/scripts/remove_pkg.sh.golden b/pkg/file/testdata/scripts/remove_pkg.sh.golden new file mode 100644 index 0000000000..73d2e32cb1 --- /dev/null +++ b/pkg/file/testdata/scripts/remove_pkg.sh.golden @@ -0,0 +1,8 @@ +# grab the identifier from the first PackageInfo we find. Those are placed in different locations depending on the installer +pkg_id=$(tar xOvf "./foo/bar baz.f" --include='*PackageInfo*' 2>/dev/null | sed -n 's/.*identifier="\([^"]*\)".*/\1/p') + +# remove all the files and empty directories that were installed +pkgutil --files $pkg_id | tr '\n' '\0' | xargs -n 1 -0 rm -d + +# remove the receipt +pkgutil --forget $pkg_id diff --git a/webpack.config.js b/webpack.config.js index 1bd6aca7e1..8b06782811 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -71,7 +71,10 @@ const config = { filename: "[name]@[hash][ext]", }, }, - + { + test: /\.(sh|ps1)$/, + type: "asset/source", + }, { test: /(\.tsx?|\.jsx?)$/, exclude: /node_modules/,