Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ee/currentprocess/currentprocess.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Package currentprocess provides information on the running process.
package currentprocess
19 changes: 19 additions & 0 deletions ee/currentprocess/currentprocess_posix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//go:build !windows

package currentprocess

import (
"os"
"strconv"
)

// Returns whether the current process is root.
func IsElevated() (bool, error) {
return os.Geteuid() == 0, nil
}

// Returns the current process's numerical user id. All platforms
// return strings because of Windows.
func Uid() (string, error) {
return strconv.Itoa(os.Getuid()), nil
}
38 changes: 38 additions & 0 deletions ee/currentprocess/currentprocess_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package currentprocess

import (
"os/user"
"runtime"
"testing"

"github.com/stretchr/testify/require"
"go.uber.org/goleak"
)

func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}

func TestIsElevated(t *testing.T) {
t.Parallel()

// Cannot consistently assert outcome, but it should never error.
_, err := IsElevated()
require.NoError(t, err)
}

func TestUid(t *testing.T) {
t.Parallel()

currentUser, err := user.Current()
require.NoError(t, err)

expected := currentUser.Uid
if runtime.GOOS == "windows" {
expected = currentUser.Username
}

uid, err := Uid()
require.NoError(t, err)
require.Equal(t, expected, uid)
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
//go:build windows

package launcher
package currentprocess

import (
"fmt"
"os/user"
"unsafe"

"golang.org/x/sys/windows"
Expand All @@ -12,7 +13,7 @@ import (
// Detects UAC elevation or when running as LocalSystem.
// Impl is copied from windows.Token.IsElevated, but exposes the error
// on a failure to check.
func runningElevated() (bool, error) {
func IsElevated() (bool, error) {
var elevation uint32
var outLen uint32
if err := windows.GetTokenInformation(
Expand All @@ -27,3 +28,13 @@ func runningElevated() (bool, error) {

return outLen == uint32(unsafe.Sizeof(elevation)) && elevation != 0, nil
}

// Returns the current process's fully-qualified owner, DOMAIN\User.
func Uid() (string, error) {
currentUser, err := user.Current()
if err != nil {
return "", fmt.Errorf("getting current user: %w", err)
}

return currentUser.Username, nil
}
15 changes: 13 additions & 2 deletions ee/debug/checkups/flare-environment-platform-specifics_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,19 @@

package checkups

import "golang.org/x/sys/windows"
import (
"fmt"

"github.com/kolide/launcher/v2/ee/currentprocess"
)

func flareEnvironmentPlatformSpecifics(flareEnv map[string]any) {
flareEnv["invoked_with_elevated_permissions"] = windows.GetCurrentProcessToken().IsElevated()
elevated, err := currentprocess.IsElevated()

if err != nil {
flareEnv["invoked_with_elevated_permissions"] = "unknown"
flareEnv["invoked_with_elevated_permissions_err"] = fmt.Sprintf("failed to check if elevated: %v", err)
Comment thread
brhoades marked this conversation as resolved.
} else {
flareEnv["invoked_with_elevated_permissions"] = elevated
}
}
59 changes: 55 additions & 4 deletions ee/desktop/runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
"github.com/kolide/launcher/v2/ee/agent/types"
"github.com/kolide/launcher/v2/ee/allowedcmd"
"github.com/kolide/launcher/v2/ee/consoleuser"
"github.com/kolide/launcher/v2/ee/currentprocess"
runnerserver "github.com/kolide/launcher/v2/ee/desktop/runner/server"
"github.com/kolide/launcher/v2/ee/desktop/user/client"
"github.com/kolide/launcher/v2/ee/desktop/user/menu"
Expand Down Expand Up @@ -112,6 +113,11 @@ func (e NoExplorerProcessError) Error() string {
return fmt.Sprintf("no explorer process found for uid: %s", e.uid)
}

func (e NoExplorerProcessError) Is(target error) bool {
_, ok := target.(NoExplorerProcessError)
return ok
}

// DesktopUsersProcessesRunner creates a launcher desktop process each time it detects
// a new console (GUI) user. If the current console user's desktop process dies, it
// will create a new one.
Expand Down Expand Up @@ -146,8 +152,12 @@ type DesktopUsersProcessesRunner struct {
knapsack types.Knapsack
// runnerServer is a local server that desktop processes call to monitor parent
runnerServer *runnerserver.RunnerServer
// osVersion is the version of the OS cached in new
// osVersion is the version of the OS, cached in new
osVersion string
// currentUid is the process owning uid, cached in new
currentUid string
// elevated is whether the process runs elevated, cached in new
elevated bool
// cachedMenuData is the cached label values of the currently displayed menu data, used for detecting changes
cachedMenuData *menuItemCache
}
Expand Down Expand Up @@ -190,6 +200,25 @@ func New(k types.Knapsack, messenger runnerserver.Messenger, opts ...desktopUser

runner.slogger = k.Slogger().With("component", "desktop_runner")

elevated, err := currentprocess.IsElevated()
if err != nil {
runner.slogger.Log(context.TODO(), slog.LevelWarn,
"failed to check if process is elevated, will assume process is privileged",
"err", err,
)
elevated = true // fail loud: maybe succeed rather than never try
}
runner.elevated = elevated

runner.currentUid, err = currentprocess.Uid()
if err != nil {
runner.slogger.Log(context.TODO(), slog.LevelWarn,
"failed to get current process uid, will behave like system process if privileged",
"elevated", elevated,
"err", err,
)
}

for _, opt := range opts {
opt(runner)
}
Expand Down Expand Up @@ -843,6 +872,14 @@ func (r *DesktopUsersProcessesRunner) writeDefaultMenuTemplateFile() {
}
}

// isCurrentUser reports whether uid is the user this process runs as. Windows UIDs
// are fully qualified names, hence the case insensitive check.
func (r *DesktopUsersProcessesRunner) isCurrentUser(uid string) bool {
return strings.EqualFold(uid, r.currentUid)
}

// Fans out over all users on this device. We can spawn a launcher desktop subprocess
// for console users if required and we have permission. At minimum, we check for ourselves.
func (r *DesktopUsersProcessesRunner) runConsoleUserDesktop() error {
if r.knapsack.InModernStandby() {
r.slogger.Log(context.TODO(), slog.LevelDebug,
Expand All @@ -857,16 +894,30 @@ func (r *DesktopUsersProcessesRunner) runConsoleUserDesktop() error {
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
defer cancel()

consoleUsers, err := consoleuser.CurrentUids(ctx)
if err != nil {
return fmt.Errorf("getting console users: %w", err)
var consoleUsers []string

// Querying console users on Windows requires privilege, other platforms
// use session information to establish process configuration even when ran
// unprivileged
if runtime.GOOS == "windows" && !r.elevated {
consoleUsers = []string{r.currentUid}
Comment on lines +899 to +903

@brhoades brhoades Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't love this, but an alternative distracts from my changeset: extract the loop's body below and call it for !r.elevated on all platforms. I can do it if preferred.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am fine with this -- since the launcher parent process being unprivileged is a new case (i.e. we always currently expect the parent process to be privileged), we can define whatever behavior we want here.

} else {
var err error
if consoleUsers, err = consoleuser.CurrentUids(ctx); err != nil {
return fmt.Errorf("getting console users: %w", err)
}
}

for _, uid := range consoleUsers {
if r.userHasDesktopProcess(uid) {
continue
}

// Only a privileged process can start a process for somebody else.
if !r.isCurrentUser(uid) && !r.elevated {
continue
}

// Check to see if necessary dependencies are running on macOS before we spawn the desktop process.
// This will block for up to 30 seconds, at which point we proceed with trying to spawn anyway.
r.waitForReadyToSpawnDesktopState(ctx, uid)
Expand Down
22 changes: 6 additions & 16 deletions ee/desktop/runner/runner_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,16 @@ import (
"golang.org/x/sys/unix"
)

// For notifications to work, we must run in the user context with launchctl asuser.
Comment thread
brhoades marked this conversation as resolved.
// Starts the provided cmd and returns any errors from spawning the process. If the uid differs from the user
// running the current process, runAsUser uses `launchctl runas` to start cmd in the user's context (required for
// notifications to work). Otherwise it runs cmd directly.
func (r *DesktopUsersProcessesRunner) runAsUser(ctx context.Context, uid string, cmd *allowedcmd.TracedCmd) error {
_, span := observability.StartSpan(ctx, "uid", uid)
defer span.End()

// Ensure that we handle a non-root current user appropriately
currentUser, err := user.Current()
if err != nil {
return fmt.Errorf("getting current user: %w", err)
// we do not need to launch into our own user
if r.isCurrentUser(uid) {
return cmd.Start()
}

runningUser, err := user.LookupId(uid)
Expand All @@ -42,17 +43,6 @@ func (r *DesktopUsersProcessesRunner) runAsUser(ctx context.Context, uid string,
updatedCmdArgs := append([]string{"/bin/launchctl", "asuser", uid, "sudo", "--preserve-env", "-u", runningUser.Username}, cmd.Args...)
cmd.Args = updatedCmdArgs

// current user not root
if currentUser.Uid != "0" {
// if the user is running for itself, just run without setting credentials
if currentUser.Uid == runningUser.Uid {
return cmd.Start()
}

// if the user is running for another user, we have an error because we can't set credentials
return fmt.Errorf("current user %s is not root and can't start process for other user %s", currentUser.Uid, uid)
}

// the remaining code in this function is not covered by unit test since it requires root privileges
// We may be able to run passwordless sudo in GitHub actions, could possibly exec the tests as sudo.
// But we may not have a console user?
Expand Down
33 changes: 14 additions & 19 deletions ee/desktop/runner/runner_linux.go

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The root check was harmful in some cases. When ran as a systemd user unit (which seems plausible) or tmux (which is probably just me), the process won't have DISPLAY.

Also worth noting that userEnvVars below worked fine unprivileged on my box.

Original file line number Diff line number Diff line change
Expand Up @@ -30,29 +30,30 @@ const (
// Display takes the format host:displaynumber.screen
var displayRegex = regexp.MustCompile(`^[a-z]*:\d+.?\d*$`)

// Starts the provided cmd and returns any errors from spawning the process. If the uid differs from the user
// running the current process, runAsUser launches the cmd in their user and group namespaces. Otherwise,
// it directly launches the cmd.
//
// In all cases, the current process queries logind for the uid's session and sets the environment variables
// present on cmd.
func (r *DesktopUsersProcessesRunner) runAsUser(ctx context.Context, uid string, cmd *allowedcmd.TracedCmd) error {
ctx, span := observability.StartSpan(ctx, "uid", uid)
defer span.End()

currentUser, err := user.Current()
if err != nil {
return fmt.Errorf("getting current user: %w", err)
}

runningUser, err := user.LookupId(uid)
if err != nil || runningUser == nil {
return fmt.Errorf("looking up user with uid %s: %w", uid, err)
}

// current user not root
if currentUser.Uid != "0" {
// if the user is running for itself, just run without setting credentials
if currentUser.Uid == runningUser.Uid {
return cmd.Start()
}
// Set any necessary environment variables on the command (like DISPLAY)
envVars := r.userEnvVars(ctx, uid, runningUser.Username)
for k, v := range envVars {
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v))
}

// if the user is running for another user, we have an error because we can't set credentials
return fmt.Errorf("current user %s is not root and can't start process for other user %s", currentUser.Uid, uid)
// no special credentials need to be set
if r.isCurrentUser(uid) {
return cmd.Start()
}

// the remaining code in this function is not covered by unit test since it requires root privileges
Expand All @@ -76,12 +77,6 @@ func (r *DesktopUsersProcessesRunner) runAsUser(ctx context.Context, uid string,
},
}

// Set any necessary environment variables on the command (like DISPLAY)
envVars := r.userEnvVars(ctx, uid, runningUser.Username)
for k, v := range envVars {
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v))
}

return cmd.Start()
}

Expand Down
22 changes: 22 additions & 0 deletions ee/desktop/runner/runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package runner
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
Expand All @@ -21,6 +22,7 @@ import (
"github.com/kolide/launcher/v2/ee/agent/types"
"github.com/kolide/launcher/v2/ee/agent/types/mocks"
"github.com/kolide/launcher/v2/ee/consoleuser"
"github.com/kolide/launcher/v2/ee/currentprocess"
"github.com/kolide/launcher/v2/ee/desktop/user/notify"
"github.com/kolide/launcher/v2/ee/presencedetection"
"github.com/kolide/launcher/v2/pkg/backoff"
Expand Down Expand Up @@ -632,3 +634,23 @@ func Test_Ping_writesLocalizationFile(t *testing.T) {
require.NoError(t, json.Unmarshal(contents, &got))
require.Equal(t, expected.Locale, got.Locale)
}

func TestIsCurrentUser(t *testing.T) {
t.Parallel()

currentUid, err := currentprocess.Uid()
require.NoError(t, err)

r := &DesktopUsersProcessesRunner{currentUid: currentUid}
require.True(t, r.isCurrentUser(currentUid))
require.False(t, r.isCurrentUser("not-a-real-uid"))
}

func TestNoExplorerProcessError_Is(t *testing.T) {
t.Parallel()

err := fmt.Errorf("wrapped: %w", NoExplorerProcessError{uid: `DOMAIN\someuser`})
require.ErrorIs(t, err, NoExplorerProcessError{})

require.NotErrorIs(t, errors.New("unrelated"), NoExplorerProcessError{})
}
12 changes: 12 additions & 0 deletions ee/desktop/runner/runner_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,22 @@ import (
"github.com/kolide/systray"
)

// Starts the provided cmd and returns any errors from spawning the process. If the uid differs from the user
// running the current process, runAsUser binds the cmd to the secure token of the target uid's explorer
// process. Otherwise it runs cmd directly.
func (r *DesktopUsersProcessesRunner) runAsUser(ctx context.Context, uid string, cmd *allowedcmd.TracedCmd) error {
ctx, span := observability.StartSpan(ctx, "uid", uid)
defer span.End()

// ERROR_PRIVILEGE_NOT_HELD returns from cmd.Start() if the current process or token handle hit the
// wrong row of a non-trivial privilege matrix: see CreateProcessAsUser docs.
// Trying to start is safer when this process is also the target user.
//
// NB: breaks non-graphical session spawning like tasks or over ssh.
if r.isCurrentUser(uid) {
return cmd.Start()
}

explorerProc, err := consoleuser.ExplorerProcess(ctx, uid)
if err != nil {
return fmt.Errorf("getting user explorer process: %w", err)
Expand Down
Loading
Loading