mirror of
https://github.com/fleetdm/fleet
synced 2026-05-20 07:29:08 +00:00
For #26623 - Updated `github.com/groob/plist` to `github.com/micromdm/plist` -- it was renamed - Added validation that restricts DigiCert Fleet variables to `com.apple.security.pkcs12` payloads plus additional restrictions - Added validation that restricts Custom SCEP Fleet variables to `com.apple.security.scep` payloads plus additional restrictions - Enabled multiple CAs (Fleet variables) to be present in an Apple MDM profile. But each CA can only be used once. For example, we can have DigiCert CA and Custom SCEP CA in one Apple profile. # Checklist for submitter - [x] If database migrations are included, checked table schema to confirm autoupdate - For database migrations: - [x] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [x] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [x] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). - [x] Added/updated automated tests - [x] A detailed QA plan exists on the associated ticket (if it isn't there, work with the product group's QA engineer to add it) - [x] Manual QA for all new/changed functionality
529 lines
15 KiB
Go
529 lines
15 KiB
Go
package maintainedapps
|
|
|
|
import (
|
|
"fmt"
|
|
"path/filepath"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/fleetdm/fleet/v4/pkg/optjson"
|
|
"github.com/micromdm/plist"
|
|
)
|
|
|
|
func installScriptForApp(app maintainedApp, cask *brewCask) (string, error) {
|
|
sb := newScriptBuilder()
|
|
|
|
sb.AddVariable("TMPDIR", `$(dirname "$(realpath $INSTALLER_PATH)")`)
|
|
sb.AddVariable("APPDIR", `"/Applications/"`)
|
|
|
|
formats := strings.Split(app.InstallerFormat, ":")
|
|
sb.Extract(formats[0])
|
|
|
|
var includeQuitFunc bool
|
|
for _, artifact := range cask.Artifacts {
|
|
switch {
|
|
case len(artifact.App) > 0:
|
|
sb.Write("# copy to the applications folder")
|
|
sb.Writef("quit_application '%s'", app.BundleIdentifier)
|
|
if cask.Token == "docker" {
|
|
sb.Writef("quit_application 'com.electron.dockerdesktop'")
|
|
}
|
|
includeQuitFunc = true
|
|
for _, appPath := range artifact.App {
|
|
sb.Writef(`sudo [ -d "$APPDIR/%[1]s" ] && sudo mv "$APPDIR/%[1]s" "$TMPDIR/%[1]s.bkp"`, appPath)
|
|
sb.Copy(appPath, "$APPDIR")
|
|
}
|
|
|
|
case len(artifact.Pkg) > 0:
|
|
sb.Write("# install pkg files")
|
|
switch len(artifact.Pkg) {
|
|
case 1:
|
|
if err := sb.InstallPkg(artifact.Pkg[0].String); err != nil {
|
|
return "", fmt.Errorf("building statement to install pkg: %w", err)
|
|
}
|
|
case 2:
|
|
if err := sb.InstallPkg(artifact.Pkg[0].String, artifact.Pkg[1].Other.Choices); err != nil {
|
|
return "", fmt.Errorf("building statement to install pkg with choices: %w", err)
|
|
}
|
|
default:
|
|
return "", fmt.Errorf("application %s has unknown directive format for pkg", app.Identifier)
|
|
}
|
|
|
|
case len(artifact.Binary) > 0:
|
|
if len(artifact.Binary) == 2 {
|
|
source := artifact.Binary[0].String
|
|
target := artifact.Binary[1].Other.Target
|
|
|
|
if !strings.Contains(target, "$HOMEBREW_PREFIX") &&
|
|
!strings.Contains(source, "$HOMEBREW_PREFIX") {
|
|
sb.Symlink(source, target)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if includeQuitFunc {
|
|
sb.AddFunction("quit_application", quitApplicationFunc)
|
|
}
|
|
|
|
return sb.String(), nil
|
|
}
|
|
|
|
func uninstallScriptForApp(cask *brewCask) string {
|
|
sb := newScriptBuilder()
|
|
|
|
for _, artifact := range cask.Artifacts {
|
|
switch {
|
|
case len(artifact.App) > 0:
|
|
sb.AddVariable("APPDIR", `"/Applications/"`)
|
|
for _, appPath := range artifact.App {
|
|
sb.RemoveFile(fmt.Sprintf(`"$APPDIR/%s"`, appPath))
|
|
}
|
|
case len(artifact.Binary) > 0:
|
|
if len(artifact.Binary) == 2 {
|
|
target := artifact.Binary[1].Other.Target
|
|
if !strings.Contains(target, "$HOMEBREW_PREFIX") {
|
|
sb.RemoveFile(fmt.Sprintf(`'%s'`, target))
|
|
}
|
|
}
|
|
case len(artifact.Uninstall) > 0:
|
|
sortUninstall(artifact.Uninstall)
|
|
if len(cask.PreUninstallScripts) > 0 {
|
|
sb.Write(strings.Join(cask.PreUninstallScripts, "\n"))
|
|
}
|
|
for _, u := range artifact.Uninstall {
|
|
processUninstallArtifact(u, sb)
|
|
}
|
|
if len(cask.PostUninstallScripts) > 0 {
|
|
sb.Write(strings.Join(cask.PostUninstallScripts, "\n"))
|
|
}
|
|
case len(artifact.Zap) > 0:
|
|
sortUninstall(artifact.Zap)
|
|
for _, z := range artifact.Zap {
|
|
processUninstallArtifact(z, sb)
|
|
}
|
|
}
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// priority of uninstall directives is defined by homebrew here:
|
|
// https://github.com/Homebrew/brew/blob/e1ff668957dd8a66304c0290dfa66083e6c7444e/Library/Homebrew/cask/artifact/abstract_uninstall.rb#L18-L30
|
|
const (
|
|
PriorityEarlyScript = iota
|
|
PriorityLaunchctl
|
|
PriorityQuit
|
|
PrioritySignal
|
|
PriorityLoginItem
|
|
PriorityKext
|
|
PriorityScript
|
|
PriorityPkgutil
|
|
PriorityDelete
|
|
PriorityTrash
|
|
PriorityRmdir
|
|
)
|
|
|
|
// uninstallArtifactOrder returns an integer representing the priority of the
|
|
// artifact based on the uninstall directives it contains. Lower number means
|
|
// higher priority
|
|
func uninstallArtifactOrder(artifact *brewUninstall) int {
|
|
switch {
|
|
case len(artifact.LaunchCtl.String)+len(artifact.LaunchCtl.Other) > 0:
|
|
return PriorityLaunchctl
|
|
case len(artifact.Quit.String)+len(artifact.Quit.Other) > 0:
|
|
return PriorityQuit
|
|
case len(artifact.Signal.String)+len(artifact.Signal.Other) > 0:
|
|
return PrioritySignal
|
|
case len(artifact.LoginItem.String)+len(artifact.LoginItem.Other) > 0:
|
|
return PriorityLoginItem
|
|
case len(artifact.Kext.String)+len(artifact.Kext.Other) > 0:
|
|
return PriorityKext
|
|
case len(artifact.Script.String)+len(artifact.Script.Other) > 0:
|
|
return PriorityScript
|
|
case len(artifact.PkgUtil.String)+len(artifact.PkgUtil.Other) > 0:
|
|
return PriorityPkgutil
|
|
case len(artifact.Delete.String)+len(artifact.Delete.Other) > 0:
|
|
return PriorityDelete
|
|
case len(artifact.Trash.String)+len(artifact.Trash.Other) > 0:
|
|
return PriorityTrash
|
|
case len(artifact.RmDir.String)+len(artifact.RmDir.Other) > 0:
|
|
return PriorityRmdir
|
|
default:
|
|
return 999
|
|
}
|
|
}
|
|
|
|
func sortUninstall(artifacts []*brewUninstall) {
|
|
slices.SortFunc(artifacts, func(a, b *brewUninstall) int {
|
|
return uninstallArtifactOrder(a) - uninstallArtifactOrder(b)
|
|
})
|
|
}
|
|
|
|
func processUninstallArtifact(u *brewUninstall, sb *scriptBuilder) {
|
|
process := func(target optjson.StringOr[[]string], f func(path string)) {
|
|
if target.IsOther {
|
|
for _, path := range target.Other {
|
|
f(path)
|
|
}
|
|
} else if len(target.String) > 0 {
|
|
f(target.String)
|
|
}
|
|
}
|
|
|
|
addUserVar := func() {
|
|
sb.AddVariable("LOGGED_IN_USER", `$(scutil <<< "show State:/Users/ConsoleUser" | awk '/Name :/ { print $3 }')`)
|
|
}
|
|
|
|
process(u.LaunchCtl, func(lc string) {
|
|
sb.AddFunction("remove_launchctl_service", removeLaunchctlServiceFunc)
|
|
sb.Writef("remove_launchctl_service '%s'", lc)
|
|
})
|
|
|
|
process(u.Quit, func(appName string) {
|
|
sb.AddFunction("quit_application", quitApplicationFunc)
|
|
sb.Writef("quit_application '%s'", appName)
|
|
if appName == "com.docker.docker" {
|
|
sb.Writef("quit_application 'com.electron.dockerdesktop'")
|
|
}
|
|
})
|
|
|
|
// per the spec, signals can't have a different format. In the homebrew
|
|
// source code an error is raised when the format is different.
|
|
if u.Signal.IsOther && len(u.Signal.Other) == 2 {
|
|
addUserVar()
|
|
sb.AddFunction("send_signal", sendSignalFunc)
|
|
sb.Writef(`send_signal '%s' '%s' "$LOGGED_IN_USER"`, u.Signal.Other[0], u.Signal.Other[1])
|
|
}
|
|
|
|
if u.Script.IsOther {
|
|
addUserVar()
|
|
for _, path := range u.Script.Other {
|
|
sb.Writef(`(cd /Users/$LOGGED_IN_USER && sudo -u "$LOGGED_IN_USER" '%s')`, path)
|
|
}
|
|
} else if len(u.Script.String) > 0 {
|
|
addUserVar()
|
|
sb.Writef(`(cd /Users/$LOGGED_IN_USER && sudo -u "$LOGGED_IN_USER" '%s')`, u.Script.String)
|
|
}
|
|
|
|
process(u.PkgUtil, func(pkgID string) {
|
|
sb.Writef("sudo pkgutil --forget '%s'", pkgID)
|
|
})
|
|
|
|
process(u.Delete, func(path string) {
|
|
sb.RemoveFile(fmt.Sprintf("'%s'", path))
|
|
})
|
|
|
|
process(u.RmDir, func(dir string) {
|
|
sb.Writef("sudo rmdir '%s'", dir)
|
|
})
|
|
|
|
process(u.Trash, func(path string) {
|
|
addUserVar()
|
|
sb.AddFunction("trash", trashFunc)
|
|
sb.Writef("trash $LOGGED_IN_USER '%s'", path)
|
|
})
|
|
}
|
|
|
|
type scriptBuilder struct {
|
|
statements []string
|
|
variables map[string]string
|
|
functions map[string]string
|
|
}
|
|
|
|
func newScriptBuilder() *scriptBuilder {
|
|
return &scriptBuilder{
|
|
statements: []string{},
|
|
variables: map[string]string{},
|
|
functions: map[string]string{},
|
|
}
|
|
}
|
|
|
|
// AddVariable adds a variable definition to the script
|
|
func (s *scriptBuilder) AddVariable(name, definition string) {
|
|
s.variables[name] = definition
|
|
}
|
|
|
|
// AddFunction adds a shell function to the script.
|
|
func (s *scriptBuilder) AddFunction(name, definition string) {
|
|
s.functions[name] = definition
|
|
}
|
|
|
|
// Write appends a raw shell command or statement to the script.
|
|
func (s *scriptBuilder) Write(in string) {
|
|
s.statements = append(s.statements, in)
|
|
}
|
|
|
|
// Writef formats a string according to the specified format and arguments,
|
|
// then appends it to the script.
|
|
func (s *scriptBuilder) Writef(format string, args ...any) {
|
|
s.statements = append(s.statements, fmt.Sprintf(format, args...))
|
|
}
|
|
|
|
// Extract writes shell commands to extract the contents of an installer based
|
|
// on the given format.
|
|
//
|
|
// Supported formats are "dmg" and "zip". It adds the necessary extraction
|
|
// commands to the script.
|
|
func (s *scriptBuilder) Extract(format string) {
|
|
switch format {
|
|
case "dmg":
|
|
s.Write("# extract contents")
|
|
s.Write(`MOUNT_POINT=$(mktemp -d /tmp/dmg_mount_XXXXXX)
|
|
hdiutil attach -plist -nobrowse -readonly -mountpoint "$MOUNT_POINT" "$INSTALLER_PATH"
|
|
sudo cp -R "$MOUNT_POINT"/* "$TMPDIR"
|
|
hdiutil detach "$MOUNT_POINT"`)
|
|
|
|
case "zip":
|
|
s.Write("# extract contents")
|
|
s.Write(`unzip "$INSTALLER_PATH" -d "$TMPDIR"`)
|
|
}
|
|
}
|
|
|
|
// Copy writes a command to copy a file from the temporary directory to a
|
|
// destination.
|
|
func (s *scriptBuilder) Copy(file, dest string) {
|
|
s.Writef(`sudo cp -R "$TMPDIR/%s" "%s"`, file, dest)
|
|
}
|
|
|
|
// RemoveFile writes a command to remove a file or directory with sudo
|
|
// privileges.
|
|
func (s *scriptBuilder) RemoveFile(file string) {
|
|
s.Writef(`sudo rm -rf %s`, file)
|
|
}
|
|
|
|
// InstallPkg writes a command to install a package using the macOS `installer` utility.
|
|
// 'pkg' is the package file to install. Optionally, 'choices' can be provided to specify
|
|
// installation options.
|
|
//
|
|
// If no choices are provided, a simple install command is written.
|
|
//
|
|
// Returns an error if generating the XML for choices fails.
|
|
func (s *scriptBuilder) InstallPkg(pkg string, choices ...[]brewPkgConfig) error {
|
|
if len(choices) == 0 {
|
|
s.Writef(`sudo installer -pkg "$TMPDIR/%s" -target /`, pkg)
|
|
return nil
|
|
}
|
|
|
|
choiceXML, err := plist.MarshalIndent(choices[0], " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
s.Writef(`
|
|
CHOICE_XML=$(mktemp /tmp/choice_xml_XXX)
|
|
|
|
cat << EOF > "$CHOICE_XML"
|
|
%s
|
|
EOF
|
|
|
|
sudo installer -pkg "$TMPDIR"/%s -target / -applyChoiceChangesXML "$CHOICE_XML"
|
|
`, choiceXML, pkg)
|
|
|
|
return nil
|
|
}
|
|
|
|
// Symlink writes a command to create a symbolic link from 'source' to 'target'.
|
|
func (s *scriptBuilder) Symlink(source, target string) {
|
|
pathname := filepath.Dir(target)
|
|
s.Writef(`[ -d "%s" ] && /bin/ln -h -f -s -- "%s" "%s"`, pathname, source, target)
|
|
}
|
|
|
|
// String generates the final script as a string.
|
|
//
|
|
// It includes the shebang, any variables, functions, and statements in the
|
|
// correct order.
|
|
func (s *scriptBuilder) String() string {
|
|
var script strings.Builder
|
|
script.WriteString("#!/bin/sh\n\n")
|
|
|
|
if len(s.variables) > 0 {
|
|
// write variables, order them alphabetically to produce deterministic
|
|
// scripts.
|
|
script.WriteString("# variables\n")
|
|
keys := make([]string, 0, len(s.variables))
|
|
for name := range s.variables {
|
|
keys = append(keys, name)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, name := range keys {
|
|
script.WriteString(fmt.Sprintf("%s=%s\n", name, s.variables[name]))
|
|
}
|
|
}
|
|
|
|
if len(s.functions) > 0 {
|
|
// write functions, order them alphabetically to produce deterministic
|
|
// scripts.
|
|
script.WriteString("# functions\n")
|
|
keys := make([]string, 0, len(s.functions))
|
|
for name := range s.functions {
|
|
keys = append(keys, name)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, name := range keys {
|
|
script.WriteString("\n")
|
|
script.WriteString(s.functions[name])
|
|
script.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
// write any statements
|
|
if len(s.statements) > 0 {
|
|
script.WriteString("\n")
|
|
script.WriteString(strings.Join(s.statements, "\n"))
|
|
script.WriteString("\n")
|
|
}
|
|
|
|
return script.String()
|
|
}
|
|
|
|
// removeLaunchctlServiceFunc removes a launchctl service, it's a direct port
|
|
// of the homebrew implementation
|
|
// https://github.com/Homebrew/brew/blob/e1ff668957dd8a66304c0290dfa66083e6c7444e/Library/Homebrew/cask/artifact/abstract_uninstall.rb#L92
|
|
const removeLaunchctlServiceFunc = `remove_launchctl_service() {
|
|
local service="$1"
|
|
local booleans=("true" "false")
|
|
local plist_status
|
|
local paths
|
|
local should_sudo
|
|
|
|
echo "Removing launchctl service ${service}"
|
|
|
|
for should_sudo in "${booleans[@]}"; do
|
|
plist_status=$(launchctl list "${service}" 2>/dev/null)
|
|
|
|
if [[ $plist_status == \{* ]]; then
|
|
if [[ $should_sudo == "true" ]]; then
|
|
sudo launchctl remove "${service}"
|
|
else
|
|
launchctl remove "${service}"
|
|
fi
|
|
sleep 1
|
|
fi
|
|
|
|
paths=(
|
|
"/Library/LaunchAgents/${service}.plist"
|
|
"/Library/LaunchDaemons/${service}.plist"
|
|
)
|
|
|
|
# if not using sudo, prepend the home directory to the paths
|
|
if [[ $should_sudo == "false" ]]; then
|
|
for i in "${!paths[@]}"; do
|
|
paths[i]="${HOME}${paths[i]}"
|
|
done
|
|
fi
|
|
|
|
for path in "${paths[@]}"; do
|
|
if [[ -e "$path" ]]; then
|
|
if [[ $should_sudo == "true" ]]; then
|
|
sudo rm -f -- "$path"
|
|
else
|
|
rm -f -- "$path"
|
|
fi
|
|
fi
|
|
done
|
|
done
|
|
}`
|
|
|
|
// quitApplicationFunc quits a running application. It's a direct port of the
|
|
// homebrew implementation
|
|
// https://github.com/Homebrew/brew/blob/e1ff668957dd8a66304c0290dfa66083e6c7444e/Library/Homebrew/cask/artifact/abstract_uninstall.rb#L192
|
|
const quitApplicationFunc = `quit_application() {
|
|
local bundle_id="$1"
|
|
local timeout_duration=10
|
|
|
|
# check if the application is running
|
|
if ! osascript -e "application id \"$bundle_id\" is running" 2>/dev/null; then
|
|
return
|
|
fi
|
|
|
|
local console_user
|
|
console_user=$(stat -f "%Su" /dev/console)
|
|
if [[ $EUID -eq 0 && "$console_user" == "root" ]]; then
|
|
echo "Not logged into a non-root GUI; skipping quitting application ID '$bundle_id'."
|
|
return
|
|
fi
|
|
|
|
echo "Quitting application '$bundle_id'..."
|
|
|
|
# try to quit the application within the timeout period
|
|
local quit_success=false
|
|
SECONDS=0
|
|
while (( SECONDS < timeout_duration )); do
|
|
if osascript -e "tell application id \"$bundle_id\" to quit" >/dev/null 2>&1; then
|
|
if ! pgrep -f "$bundle_id" >/dev/null 2>&1; then
|
|
echo "Application '$bundle_id' quit successfully."
|
|
quit_success=true
|
|
break
|
|
fi
|
|
fi
|
|
sleep 1
|
|
done
|
|
|
|
if [[ "$quit_success" = false ]]; then
|
|
echo "Application '$bundle_id' did not quit."
|
|
fi
|
|
}
|
|
`
|
|
|
|
const trashFunc = `trash() {
|
|
local logged_in_user="$1"
|
|
local target_file="$2"
|
|
local timestamp="$(date +%Y-%m-%d-%s)"
|
|
local rand="$(jot -r 1 0 99999)"
|
|
|
|
# replace ~ with /Users/$logged_in_user
|
|
if [[ "$target_file" == ~* ]]; then
|
|
target_file="/Users/$logged_in_user${target_file:1}"
|
|
fi
|
|
|
|
local trash="/Users/$logged_in_user/.Trash"
|
|
local file_name="$(basename "${target_file}")"
|
|
|
|
if [[ -e "$target_file" ]]; then
|
|
echo "removing $target_file."
|
|
mv -f "$target_file" "$trash/${file_name}_${timestamp}_${rand}"
|
|
else
|
|
echo "$target_file doesn't exist."
|
|
fi
|
|
}`
|
|
|
|
const sendSignalFunc = `send_signal() {
|
|
local signal="$1"
|
|
local bundle_id="$2"
|
|
local logged_in_user="$3"
|
|
local logged_in_uid pids
|
|
|
|
if [ -z "$signal" ] || [ -z "$bundle_id" ] || [ -z "$logged_in_user" ]; then
|
|
echo "Usage: uninstall_signal <signal> <bundle_id> <logged_in_user>"
|
|
return 1
|
|
fi
|
|
|
|
logged_in_uid=$(id -u "$logged_in_user")
|
|
if [ -z "$logged_in_uid" ]; then
|
|
echo "Could not find UID for user '$logged_in_user'."
|
|
return 1
|
|
fi
|
|
|
|
echo "Signalling '$signal' to application ID '$bundle_id' for user '$logged_in_user'"
|
|
|
|
pids=$(/bin/launchctl asuser "$logged_in_uid" sudo -iu "$logged_in_user" /bin/launchctl list | awk -v bundle_id="$bundle_id" '
|
|
$3 ~ bundle_id { print $1 }')
|
|
|
|
if [ -z "$pids" ]; then
|
|
echo "No processes found for bundle ID '$bundle_id'."
|
|
return 0
|
|
fi
|
|
|
|
echo "Unix PIDs are $pids for processes with bundle identifier $bundle_id"
|
|
for pid in $pids; do
|
|
if kill -s "$signal" "$pid" 2>/dev/null; then
|
|
echo "Successfully signaled PID $pid with signal $signal."
|
|
else
|
|
echo "Failed to kill PID $pid with signal $signal. Check permissions."
|
|
fi
|
|
done
|
|
|
|
sleep 3
|
|
}`
|