Skip to content

Support restricted sudoers rules #1302

Description

@TyceHerrman

Problem(s) addressed

request is for a least-privilege boundary for running specific mas install or update operations non-interactively.

The released 7.0 implementation and the privilege implementation currently on main expose different problems for restricted NOPASSWD configurations:

  • 7.0 re-executes mas through sudo with an internal environment assignment, which requires an additional sudoers environment exception.
  • Current main now invokes privileged system tools directly. That removes the environment-assignment failure, but an exact rule authorizing the requested mas command no longer authorizes the commands that mas passes to sudo.

The default interactive behavior can remain unchanged. This request is for a supported interface for automation that deliberately grants only a specific MAS operation, rather than broad passwordless root access.

Observed behavior in 7.0

I use an exact rule shaped like this for a declared application:

<user> ALL=(root) NOPASSWD: <mas-path> update --force 803453959

When an update requires elevation, MAS 7.0 actually executes the equivalent of:

$ sudo MAS_NO_AUTO_INDEX=1 <mas-path> update --force 803453959

sudo therefore rejects the internal environment assignment even though the mas command itself is authorized:

sudo: sorry, you are not allowed to set the following environment variables: MAS_NO_AUTO_INDEX

This comes from the 7.0 elevation implementation:

https://github.com/mas-cli/mas/blob/v7.0.0/Sources/mas/Utilities/Processes/Sudo.swift#L20-L29

A command-specific workaround is possible:

Defaults!<mas-path> env_keep += "MAS_NO_AUTO_INDEX"

However, that makes the external privilege policy depend on an internal mas optimization and does not address the privilege boundary used by current main.

Remaining gap on current main

The privilege-handling changes merged for the 7.1 release (as it currently stands from what I see) no longer re-execute the MAS binary as root.

$ sudo /usr/sbin/installer -dumplog -pkg <dynamic-temporary-path> -target /
$ sudo /bin/sh -c '<copy/chown/chmod receipt script>' -- <dynamic-paths...>

Relevant code:

  • if
    (try? await run(.path("/usr/bin/sudo"), arguments: ["-n", "true"], output: .discarded))?
    .terminationStatus
    .isSuccess != true
    {
    MAS.printer.info()
    }
    let (_, standardErrorString) = try await run(
    .path("/usr/bin/sudo"),
    "/usr/sbin/installer",
    "-dumplog",
    "-pkg",
    pkgHardLinkPath,
    "-target",
    "/",
    errorMessage: "Failed to \(self) \(appNameAndVersion) from \(pkgHardLinkPath)",
    )
    guard
    let appFolderURLSubstring = standardErrorString
    .matches(of: appFolderURLRegex)
    .compactMap(\.1)
    .min(by: { $0.count < $1.count })
    else {
    throw MASError.error(
    "Failed to find app folder URL in installer output for \(appNameAndVersion)",
    cause: standardErrorString,
    )
    }
    guard let appFolderURL = URL(string: .init(appFolderURLSubstring)), appFolderURL.isFileURL else {
    throw MASError.error(
    "Failed to parse app folder URL for \(appNameAndVersion) from \(appFolderURLSubstring)",
    cause: standardErrorString,
    )
    }
    let receiptURL = appFolderURL.appending(path: "Contents/_MASReceipt/receipt", directoryHint: .notDirectory)
    let receiptPath = receiptURL.filePath
    let receiptHardLinkPath = receiptHardLinkURL.filePath
    _ = try await run(
    .path("/usr/bin/sudo"),
    "/bin/sh",
    "-c",
    #"/bin/mkdir -pm 755 "$1" && /bin/cp -cf "$2" "$3" && /usr/sbin/chown 0:0 "$3" && /bin/chmod 644 "$3""#,
    "--",
    receiptURL.deletingLastPathComponent().filePath,
    receiptHardLinkPath,
    receiptPath,
    errorMessage: // swiftformat:disable:next indent
    "Failed to copy receipt for \(appNameAndVersion) from \(receiptHardLinkPath.quoted) to \(receiptPath.quoted)",
    )
  • 7.1.0 omnibus #1276

Based on that merged code, the exact NOPASSWD rule for
<mas-path> update --force 803453959 cannot match these child sudo commands.

A policy would instead need to authorize installer with dynamic package paths and a root shell command with dynamic receipt paths. Such rules are broader, more brittle across MAS releases, and no longer express the intended policy:
"allow MAS to update this app ID."

This also leaves the restricted rule suggested in the discussion of #1095 without an equivalent supported shape for the new privilege model:

#1095 (comment)

Motivation

This matters for brew bundle, declarative workstation configuration, and other unattended update workflows. Homebrew itself cannot safely be run as root, while granting broad passwordless access to generic system tools defeats the purpose of using an app-specific sudoers rule.

Proposed solution(s)

a supported way to authorize a specific MAS operation and app ID without requiring any of the following:

  • SETENV or a global environment exception;
  • broad passwordless access to /usr/sbin/installer;
  • passwordless access to /bin/sh with dynamic arguments;
  • running Homebrew or the entire caller as root; or
  • policies coupled to MAS temporary paths and internal subprocesses.

For example, MAS could expose a root-owned privileged helper whose stable command line contains the requested operation and app IDs. The helper could validate and manage installer packages and receipts internally. A restricted policy could then remain shaped like:

<user> ALL=(root) NOPASSWD: <mas-helper-path> update 803453959

Related

Metadata

Metadata

Assignees

Labels

Type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions