Files
brew/Library/Homebrew/extend/os/linux/sandbox/landlock.rb
T
Mike McQuaid 377e4f740b Remove Bubblewrap, use Landlock for Linux sandbox
- Landlock needs no separate executable, installation or `sysctl`
  configuration, so use it as the only Linux sandbox implementation
  rather than an opt-in behind `$HOMEBREW_SANDBOX_LINUX_LANDLOCK`.
- Delete `Sandbox::Bubblewrap`, the `brew setup-sandbox` command and
  the implicit `bubblewrap` dependency, none of which Landlock needs.
- Remove the Bubblewrap-era `Sandbox` API (`ensure_sandbox_installed!`,
  `configure!`, `configuration_commands`, `sandbox_install_command`)
  and its call sites now that no backend needs installing or
  configuring.
- Simplify `brew doctor`'s `check_linux_sandbox` to report the
  Landlock failure reason with the `$HOMEBREW_NO_SANDBOX_LINUX`
  workaround.
2026-08-04 11:55:32 +01:00

480 lines
18 KiB
Ruby

# typed: strict
# frozen_string_literal: true
require "fileutils"
require "env_config"
require "utils/output"
require "extend/os/linux/sandbox/backend"
class Sandbox
class Landlock < LinuxBackend
include Utils::Output::Mixin
# Keep these constants and structure layouts in sync with Linux's Landlock UAPI:
# https://github.com/torvalds/linux/blob/master/include/uapi/linux/landlock.h
CREATE_RULESET_VERSION = 1
RULE_PATH_BENEATH = 1
# Linux's `prctl(2)` operation and `open(2)` flags come from these UAPI headers:
# https://github.com/torvalds/linux/blob/master/include/uapi/linux/prctl.h
# https://github.com/torvalds/linux/blob/master/include/uapi/asm-generic/fcntl.h
PR_SET_NO_NEW_PRIVS = 38
O_PATH = 010000000
O_CLOEXEC = 02000000
# Landlock has no dedicated libc wrappers, so call `syscall(2)` as documented:
# https://man7.org/linux/man-pages/man2/landlock_create_ruleset.2.html
# Homebrew's x86_64 and arm64 Linux architectures both use these numbers:
# https://github.com/torvalds/linux/blob/master/arch/x86/entry/syscalls/syscall_64.tbl
# https://github.com/torvalds/linux/blob/master/include/uapi/asm-generic/unistd.h
CREATE_RULESET_SYSCALL = 444
ADD_RULE_SYSCALL = 445
RESTRICT_SELF_SYSCALL = 446
ACCESS_FS_EXECUTE = 0x0001
ACCESS_FS_WRITE_FILE = 0x0002
ACCESS_FS_READ_FILE = 0x0004
ACCESS_FS_READ_DIR = 0x0008
ACCESS_FS_REMOVE_DIR = 0x0010
ACCESS_FS_REMOVE_FILE = 0x0020
ACCESS_FS_MAKE_CHAR = 0x0040
ACCESS_FS_MAKE_DIR = 0x0080
ACCESS_FS_MAKE_REG = 0x0100
ACCESS_FS_MAKE_SOCK = 0x0200
ACCESS_FS_MAKE_FIFO = 0x0400
ACCESS_FS_MAKE_BLOCK = 0x0800
ACCESS_FS_MAKE_SYM = 0x1000
ACCESS_FS_REFER = 0x2000
ACCESS_FS_TRUNCATE = 0x4000
ACCESS_FS_IOCTL_DEV = 0x8000
ACCESS_FS_RESOLVE_UNIX = 0x10000
ACCESS_NET_BIND_TCP = 0x01
ACCESS_NET_CONNECT_TCP = 0x02
ACCESS_NET_BIND_UDP = 0x04
ACCESS_NET_CONNECT_SEND_UDP = 0x08
SCOPE_ABSTRACT_UNIX_SOCKET = 0x01
# ABI 2 is the first version that can permit cross-directory renames and
# links, which earlier ABIs always deny for sandboxed processes:
# https://www.kernel.org/doc/html/latest/userspace-api/landlock.html#previous-limitations
MINIMUM_ABI = 2
# File truncation cannot be restricted before ABI 3, letting sandboxed
# processes truncate files outside allowed write paths. Every profile
# needs write isolation, so unlike `@deny_all_network` there is no
# profile-specific check that could gate a warning:
# https://www.kernel.org/doc/html/latest/userspace-api/landlock.html#previous-limitations
MINIMUM_TRUNCATE_ABI = 3
# TCP bind and connect restrictions were added in ABI 4:
# https://www.kernel.org/doc/html/latest/userspace-api/landlock.html#network-flags
MINIMUM_NETWORK_ABI = 4
# UDP controls required to block all network access were added in ABI 10:
# https://www.kernel.org/doc/html/latest/userspace-api/landlock.html#network-flags
MINIMUM_FULL_NETWORK_ABI = 10
WRITE_ACCESS_FS = T.let(
(ACCESS_FS_WRITE_FILE | ACCESS_FS_REMOVE_DIR | ACCESS_FS_REMOVE_FILE | ACCESS_FS_MAKE_CHAR |
ACCESS_FS_MAKE_DIR | ACCESS_FS_MAKE_REG | ACCESS_FS_MAKE_SOCK | ACCESS_FS_MAKE_FIFO |
ACCESS_FS_MAKE_BLOCK | ACCESS_FS_MAKE_SYM).freeze,
Integer,
)
FILE_WRITE_ACCESS_FS = T.let((ACCESS_FS_WRITE_FILE | ACCESS_FS_TRUNCATE).freeze, Integer)
FILE_READ_ACCESS_FS = T.let((ACCESS_FS_EXECUTE | ACCESS_FS_READ_FILE).freeze, Integer)
READ_ACCESS_FS = T.let((ACCESS_FS_EXECUTE | ACCESS_FS_READ_FILE | ACCESS_FS_READ_DIR).freeze, Integer)
private_constant :CREATE_RULESET_VERSION, :RULE_PATH_BENEATH, :PR_SET_NO_NEW_PRIVS, :O_PATH, :O_CLOEXEC,
:CREATE_RULESET_SYSCALL, :ADD_RULE_SYSCALL, :RESTRICT_SELF_SYSCALL,
:ACCESS_FS_EXECUTE, :ACCESS_FS_WRITE_FILE, :ACCESS_FS_READ_FILE, :ACCESS_FS_READ_DIR,
:ACCESS_FS_REMOVE_DIR, :ACCESS_FS_REMOVE_FILE, :ACCESS_FS_MAKE_CHAR, :ACCESS_FS_MAKE_DIR,
:ACCESS_FS_MAKE_REG, :ACCESS_FS_MAKE_SOCK, :ACCESS_FS_MAKE_FIFO, :ACCESS_FS_MAKE_BLOCK,
:ACCESS_FS_MAKE_SYM, :ACCESS_FS_REFER, :ACCESS_FS_TRUNCATE, :ACCESS_FS_RESOLVE_UNIX,
:ACCESS_FS_IOCTL_DEV,
:ACCESS_NET_BIND_TCP, :ACCESS_NET_CONNECT_TCP, :ACCESS_NET_BIND_UDP,
:ACCESS_NET_CONNECT_SEND_UDP, :SCOPE_ABSTRACT_UNIX_SOCKET, :MINIMUM_ABI,
:MINIMUM_TRUNCATE_ABI, :MINIMUM_NETWORK_ABI, :MINIMUM_FULL_NETWORK_ABI,
:WRITE_ACCESS_FS, :FILE_WRITE_ACCESS_FS, :FILE_READ_ACCESS_FS, :READ_ACCESS_FS
class << self
# Landlock cannot restrict chmod, chown, extended attributes or timestamp
# changes. Callers requiring full write isolation must compensate for
# these limitations:
# https://www.kernel.org/doc/html/latest/userspace-api/landlock.html#filesystem-flags
sig { returns(T::Boolean) }
def full_write_isolation? = false
sig { returns(T::Boolean) }
def available?
state == :available
end
sig { returns(Symbol) }
def state
@state ||= T.let(compute_state, T.nilable(Symbol))
end
sig { returns(T.nilable(Integer)) }
def abi_version
state
@abi_version
end
# The Landlock ABI version provided by the running kernel, regardless of
# whether Homebrew's Linux sandbox is enabled or usable.
sig { returns(T.nilable(Integer)) }
def kernel_abi_version
require "fiddle"
version = landlock_create_ruleset(nil, 0, CREATE_RULESET_VERSION)
version if version.positive?
rescue LoadError, Fiddle::DLError
nil
end
sig { returns(T.nilable(String)) }
def failure_reason
case state
when :available
nil
when :config_disabled
"Landlock cannot be used because Linux sandboxing is disabled."
when :missing_fiddle
"Landlock requires Ruby's bundled Fiddle library."
when :unsupported
"Landlock is not supported by this Linux kernel."
when :disabled
"Landlock is disabled by this Linux kernel."
when :unsupported_abi
abi = @abi_version
if abi
"Landlock ABI #{MINIMUM_ABI} or later is required; found ABI #{abi}."
else
"Landlock ABI #{MINIMUM_ABI} or later is required."
end
else
"Landlock is not available."
end
end
sig { void }
def reset_state!
@state = T.let(nil, T.nilable(Symbol))
@abi_version = T.let(nil, T.nilable(Integer))
end
sig { params(attributes: T.nilable(String), size: Integer, flags: Integer).returns(Integer) }
def landlock_create_ruleset(attributes, size, flags)
@landlock_create_ruleset ||= T.let(
Fiddle::Function.new(
Fiddle.dlopen(nil)["syscall"],
[Fiddle::TYPE_LONG, Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T, Fiddle::TYPE_UINT],
Fiddle::TYPE_LONG,
),
T.nilable(Fiddle::Function),
)
@landlock_create_ruleset.call(CREATE_RULESET_SYSCALL, attributes, size, flags)
end
sig { params(ruleset_fd: Integer, type: Integer, attributes: String, flags: Integer).returns(Integer) }
def landlock_add_rule(ruleset_fd, type, attributes, flags)
@landlock_add_rule ||= T.let(
Fiddle::Function.new(
Fiddle.dlopen(nil)["syscall"],
[Fiddle::TYPE_LONG, Fiddle::TYPE_INT, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_UINT],
Fiddle::TYPE_LONG,
),
T.nilable(Fiddle::Function),
)
@landlock_add_rule.call(ADD_RULE_SYSCALL, ruleset_fd, type, attributes, flags)
end
sig { params(ruleset_fd: Integer, flags: Integer).returns(Integer) }
def landlock_restrict_self(ruleset_fd, flags)
@landlock_restrict_self ||= T.let(
Fiddle::Function.new(
Fiddle.dlopen(nil)["syscall"],
[Fiddle::TYPE_LONG, Fiddle::TYPE_INT, Fiddle::TYPE_UINT],
Fiddle::TYPE_LONG,
),
T.nilable(Fiddle::Function),
)
@landlock_restrict_self.call(RESTRICT_SELF_SYSCALL, ruleset_fd, flags)
end
sig { returns(Integer) }
def set_no_new_privileges
@prctl ||= T.let(
Fiddle::Function.new(
Fiddle.dlopen(nil)["prctl"],
[Fiddle::TYPE_INT, Fiddle::TYPE_ULONG, Fiddle::TYPE_ULONG, Fiddle::TYPE_ULONG, Fiddle::TYPE_ULONG],
Fiddle::TYPE_INT,
),
T.nilable(Fiddle::Function),
)
@prctl.call(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)
end
sig { params(path: String).returns(Integer) }
def open_path(path)
@open ||= T.let(
Fiddle::Function.new(
Fiddle.dlopen(nil)["open"],
[Fiddle::TYPE_CONST_STRING, Fiddle::TYPE_INT],
Fiddle::TYPE_INT,
),
T.nilable(Fiddle::Function),
)
@open.call(path, O_PATH | O_CLOEXEC)
end
sig { params(file_descriptor: Integer).returns(Integer) }
def close_file_descriptor(file_descriptor)
@close ||= T.let(
Fiddle::Function.new(
Fiddle.dlopen(nil)["close"],
[Fiddle::TYPE_INT],
Fiddle::TYPE_INT,
),
T.nilable(Fiddle::Function),
)
@close.call(file_descriptor)
end
sig { returns(Integer) }
def last_error
Fiddle.last_error
end
private
sig { returns(Symbol) }
def compute_state
return :config_disabled unless Homebrew::EnvConfig.sandbox_linux?
begin
require "fiddle"
rescue LoadError
return :missing_fiddle
end
version = kernel_abi_version
if version
@abi_version = T.let(version, T.nilable(Integer))
if version >= MINIMUM_ABI
:available
else
:unsupported_abi
end
else
case last_error
when Errno::ENOSYS::Errno then :unsupported
when Errno::EOPNOTSUPP::Errno then :disabled
else :unavailable
end
end
end
end
sig { params(profile: SandboxProfile).void }
def initialize(profile)
super
@writable_paths = T.let([], T::Array[String])
@readable_paths = T.let([], T::Array[String])
@error_pipe_path = T.let(nil, T.nilable(String))
@deny_all_network = T.let(false, T::Boolean)
@deny_read = T.let(false, T::Boolean)
end
sig { params(args: T::Array[T.any(String, ::Pathname)], tmpdir: String).returns(T::Array[T.any(String, ::Pathname)]) }
def command(args, tmpdir)
paths = writable_paths
@writable_paths = paths.keys | [File::NULL, tmpdir]
@writable_paths.each { |path| prepare_writable_path(path, paths.fetch(path, :subpath)) }
denied_read_paths = self.denied_read_paths
@readable_paths = readable_paths(denied_read_paths)
@deny_read = denied_read_paths.any?
@deny_all_network = deny_all_network?
@error_pipe_path = File.join(tmpdir, "socket")
args
end
sig { void }
def apply!
abi = self.class.abi_version
if !abi || abi < MINIMUM_ABI
raise self.class.failure_reason || "Landlock ABI #{MINIMUM_ABI} or later is required."
end
if @deny_all_network && abi < MINIMUM_FULL_NETWORK_ABI
network_restrictions = if abi >= MINIMUM_NETWORK_ABI
"Applying the network restrictions supported by this kernel."
else
"This kernel cannot restrict network access."
end
opoo "Landlock ABI #{MINIMUM_FULL_NETWORK_ABI} or later is required to deny all network access; " \
"found ABI #{abi}. #{network_restrictions}"
end
attributes, handled_access_fs, allowed_write_access_fs = ruleset_attributes(abi)
ruleset_fd = self.class.landlock_create_ruleset(attributes, attributes.bytesize, 0)
raise_system_call_error("landlock_create_ruleset") if ruleset_fd.negative?
begin
# PTY allocation opens `/dev/ptmx` read-write, then configures its
# dynamically allocated `/dev/pts/*` slave with device ioctls:
# https://github.com/torvalds/linux/blob/master/drivers/tty/pty.c
# https://www.kernel.org/doc/html/latest/userspace-api/landlock.html#ioctl-support
pty_access = ACCESS_FS_WRITE_FILE
pty_access |= ACCESS_FS_IOCTL_DEV if abi >= 5
pty_access |= ACCESS_FS_READ_FILE if @deny_read
# `/dev/full` is Linux's standard ENOSPC test device. Opening it with
# `fopen(..., "w")` also requires Landlock's truncate right:
# https://github.com/torvalds/linux/blob/master/drivers/char/mem.c
# POSIX shared memory and message queues use `/dev/shm` and
# `/dev/mqueue`. These grants retain normal kernel permissions but do
# not provide a private IPC namespace:
# https://github.com/bminor/glibc/blob/master/sysdeps/posix/shm-directory.c
# https://www.kernel.org/doc/html/latest/filesystems/mqueue.html
device_path_rules = T.let({
"/dev/full" => FILE_WRITE_ACCESS_FS,
"/dev/mqueue" => allowed_write_access_fs | (@deny_read ? ACCESS_FS_READ_FILE : 0),
"/dev/ptmx" => pty_access,
"/dev/pts" => pty_access,
"/dev/shm" => allowed_write_access_fs,
"/dev/tty" => pty_access,
}, T::Hash[String, Integer])
device_path_rules.each do |path, allowed_access|
next unless File.exist?(path)
add_path_rule(ruleset_fd, path, allowed_access & handled_access_fs)
end
error_pipe_path = @error_pipe_path
if @deny_all_network && abi >= 9 && error_pipe_path
add_path_rule(ruleset_fd, error_pipe_path, ACCESS_FS_RESOLVE_UNIX)
end
@readable_paths.each do |path|
allowed_access = File.directory?(path) ? READ_ACCESS_FS : FILE_READ_ACCESS_FS
add_path_rule(ruleset_fd, path, allowed_access)
end
@writable_paths.each do |path|
allowed_access = if File.directory?(path)
allowed_write_access_fs
else
allowed_write_access_fs & FILE_WRITE_ACCESS_FS
end
add_path_rule(ruleset_fd, path, allowed_access & handled_access_fs)
end
raise_system_call_error("prctl") if self.class.set_no_new_privileges.negative?
if self.class.landlock_restrict_self(ruleset_fd, 0).negative?
raise_system_call_error("landlock_restrict_self")
end
ensure
close_file_descriptor(ruleset_fd)
end
end
sig { params(abi: Integer).returns([String, Integer, Integer]) }
def ruleset_attributes(abi)
allowed_access_fs = WRITE_ACCESS_FS
allowed_access_fs |= ACCESS_FS_REFER if abi >= MINIMUM_ABI
allowed_access_fs |= ACCESS_FS_TRUNCATE if abi >= MINIMUM_TRUNCATE_ABI
handled_access_fs = allowed_access_fs
# IOCTL_DEV is available from ABI 5 and deliberately remains absent from
# allowed path rules, denying device ioctls opened inside the sandbox:
# https://www.kernel.org/doc/html/latest/userspace-api/landlock.html#ioctl-support
handled_access_fs |= ACCESS_FS_IOCTL_DEV if abi >= 5
handled_access_fs |= READ_ACCESS_FS if @deny_read
handled_access_fs |= ACCESS_FS_RESOLVE_UNIX if @deny_all_network && abi >= 9
# Optional ruleset fields are appended as `__u64` members, so only pass
# the prefix needed for features supported by the running kernel.
attributes = [handled_access_fs]
if @deny_all_network && abi >= MINIMUM_NETWORK_ABI
handled_access_net = ACCESS_NET_BIND_TCP | ACCESS_NET_CONNECT_TCP
handled_access_net |= ACCESS_NET_BIND_UDP | ACCESS_NET_CONNECT_SEND_UDP if abi >= MINIMUM_FULL_NETWORK_ABI
attributes << handled_access_net
attributes << SCOPE_ABSTRACT_UNIX_SOCKET if abi >= 6
end
[attributes.pack("Q*"), handled_access_fs, allowed_access_fs]
end
sig { params(denied_paths: T::Array[::Pathname]).returns(T::Array[String]) }
def readable_paths(denied_paths)
return [] if denied_paths.empty? || denied_paths.include?(root_path)
root_path.children.sort.each_with_object([]) do |path, paths|
add_readable_path(path, denied_paths, paths)
end
end
private
sig { params(ruleset_fd: Integer, path: String, allowed_access: Integer).void }
def add_path_rule(ruleset_fd, path, allowed_access)
path_fd = open_path(path)
result = self.class.landlock_add_rule(
ruleset_fd,
RULE_PATH_BENEATH,
# The packed UAPI struct has no padding or reserved field: one `__u64`
# access mask followed by one `__s32` file descriptor.
[allowed_access, path_fd].pack("Ql"),
0,
)
raise_system_call_error("landlock_add_rule") if result.negative?
ensure
close_file_descriptor(path_fd) if path_fd
end
sig { returns(T::Array[::Pathname]) }
def denied_read_paths
profile_paths(allow: false, operation: "file-read").filter_map do |path|
pathname = ::Pathname.new(path)
pathname.lstat
pathname
rescue Errno::ENOENT
nil
end
end
sig { params(path: ::Pathname, denied_paths: T::Array[::Pathname], paths: T::Array[String]).void }
def add_readable_path(path, denied_paths, paths)
return if denied_paths.include?(path)
path_stat = path.lstat
return if path_stat.symlink?
if path_stat.directory? && denied_paths.any? { |denied_path| denied_path.ascend.include?(path) }
path.children.sort.each { |child| add_readable_path(child, denied_paths, paths) }
else
paths << path.to_s
end
rescue Errno::EACCES, Errno::ENOENT
nil
end
sig { returns(::Pathname) }
def root_path
::Pathname.new("/")
end
sig { params(path: String).returns(Integer) }
def open_path(path)
file_descriptor = self.class.open_path(path)
raise_system_call_error("open") if file_descriptor.negative?
file_descriptor
end
sig { params(file_descriptor: Integer).void }
def close_file_descriptor(file_descriptor)
raise_system_call_error("close") if self.class.close_file_descriptor(file_descriptor).negative?
end
sig { params(operation: String).void }
def raise_system_call_error(operation)
raise SystemCallError.new(operation, self.class.last_error)
end
end
end