mirror of
https://github.com/Homebrew/brew.git
synced 2026-08-12 22:29:27 +04:00
- 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.
420 lines
17 KiB
Ruby
420 lines
17 KiB
Ruby
# typed: true
|
|
# frozen_string_literal: true
|
|
|
|
require "sandbox"
|
|
require "extend/os/linux/sandbox/landlock"
|
|
|
|
RSpec.describe Sandbox::Landlock do
|
|
subject(:landlock) { described_class.new(sandbox.profile) }
|
|
|
|
let(:sandbox) { Sandbox.new }
|
|
|
|
around do |example|
|
|
described_class.reset_state!
|
|
example.run
|
|
ensure
|
|
described_class.reset_state!
|
|
end
|
|
|
|
describe "::available?" do
|
|
it "declares its weaker write-isolation contract" do
|
|
expect(described_class.full_write_isolation?).to be(false)
|
|
end
|
|
|
|
it "reports the supported Landlock ABI" do
|
|
allow(described_class).to receive(:landlock_create_ruleset).with(nil, 0, 1).and_return(4)
|
|
|
|
expect(described_class).to be_available
|
|
expect(described_class.state).to eq(:available)
|
|
expect(described_class.abi_version).to eq(4)
|
|
expect(described_class.failure_reason).to be_nil
|
|
end
|
|
|
|
it "returns false for a kernel without Landlock support" do
|
|
allow(described_class).to receive(:landlock_create_ruleset).with(nil, 0, 1).and_return(-1)
|
|
allow(described_class).to receive(:last_error).and_return(Errno::ENOSYS::Errno)
|
|
|
|
expect(described_class).not_to be_available
|
|
expect(described_class.state).to eq(:unsupported)
|
|
expect(described_class.failure_reason).to include("not supported by this Linux kernel")
|
|
end
|
|
|
|
it "returns false when Landlock is disabled by the kernel configuration" do
|
|
allow(described_class).to receive(:landlock_create_ruleset).with(nil, 0, 1).and_return(-1)
|
|
allow(described_class).to receive(:last_error).and_return(Errno::EOPNOTSUPP::Errno)
|
|
|
|
expect(described_class).not_to be_available
|
|
expect(described_class.state).to eq(:disabled)
|
|
expect(described_class.failure_reason).to include("disabled by this Linux kernel")
|
|
end
|
|
|
|
it "returns false when Linux sandboxing is disabled" do
|
|
allow(Homebrew::EnvConfig).to receive(:sandbox_linux?).and_return(false)
|
|
|
|
expect(described_class).not_to be_available
|
|
expect(described_class.state).to eq(:config_disabled)
|
|
end
|
|
|
|
it "returns false when Fiddle is unavailable" do
|
|
allow(described_class).to receive(:require).with("fiddle").and_raise(LoadError)
|
|
|
|
expect(described_class).not_to be_available
|
|
expect(described_class.state).to eq(:missing_fiddle)
|
|
expect(described_class.failure_reason).to include("Fiddle")
|
|
end
|
|
|
|
it "is available for an ABI without truncate restrictions" do
|
|
allow(described_class).to receive(:landlock_create_ruleset).with(nil, 0, 1).and_return(2)
|
|
|
|
expect(described_class).to be_available
|
|
end
|
|
|
|
it "returns false for an ABI that always denies cross-directory renames" do
|
|
allow(described_class).to receive(:landlock_create_ruleset).with(nil, 0, 1).and_return(1)
|
|
|
|
expect(described_class).not_to be_available
|
|
expect(described_class.state).to eq(:unsupported_abi)
|
|
expect(described_class.abi_version).to eq(1)
|
|
expect(described_class.failure_reason).to eq("Landlock ABI 2 or later is required; found ABI 1.")
|
|
end
|
|
end
|
|
|
|
describe "::kernel_abi_version" do
|
|
it "reports the kernel ABI even when Linux sandboxing is disabled" do
|
|
allow(Homebrew::EnvConfig).to receive(:sandbox_linux?).and_return(false)
|
|
allow(described_class).to receive(:landlock_create_ruleset).with(nil, 0, 1).and_return(6)
|
|
|
|
expect(described_class.kernel_abi_version).to eq(6)
|
|
end
|
|
|
|
it "returns nil for a kernel without Landlock support" do
|
|
allow(described_class).to receive(:landlock_create_ruleset).with(nil, 0, 1).and_return(-1)
|
|
|
|
expect(described_class.kernel_abi_version).to be_nil
|
|
end
|
|
|
|
it "returns nil when Fiddle function setup fails" do
|
|
require "fiddle"
|
|
allow(described_class).to receive(:landlock_create_ruleset).and_raise(Fiddle::DLError)
|
|
|
|
expect(described_class.kernel_abi_version).to be_nil
|
|
end
|
|
end
|
|
|
|
describe "#apply!" do
|
|
let(:writable_dir) { mktmpdir }
|
|
let(:tmpdir) { mktmpdir }
|
|
|
|
before do
|
|
allow(File).to receive(:exist?).and_call_original
|
|
allow(File).to receive(:exist?).with("/dev/full").and_return(false)
|
|
allow(File).to receive(:exist?).with("/dev/mqueue").and_return(false)
|
|
allow(File).to receive(:exist?).with("/dev/ptmx").and_return(true)
|
|
allow(File).to receive(:exist?).with("/dev/pts").and_return(true)
|
|
allow(File).to receive(:exist?).with("/dev/shm").and_return(false)
|
|
allow(File).to receive(:exist?).with("/dev/tty").and_return(false)
|
|
end
|
|
|
|
it "rejects an ABI without cross-directory rename restrictions" do
|
|
allow(described_class).to receive_messages(abi_version: 1,
|
|
failure_reason: "Landlock ABI 2 or later is required; found ABI 1.")
|
|
expect(described_class).not_to receive(:landlock_create_ruleset)
|
|
|
|
expect { landlock.apply! }
|
|
.to raise_error(RuntimeError, "Landlock ABI 2 or later is required; found ABI 1.")
|
|
end
|
|
|
|
it "restricts writes and network access using the supported ABI" do
|
|
sandbox.allow_write_path writable_dir
|
|
sandbox.deny_all_network
|
|
landlock.command(["true"], tmpdir.to_s)
|
|
|
|
allow(described_class).to receive(:abi_version).and_return(10)
|
|
expect(described_class).to receive(:landlock_create_ruleset) do |attributes, size, flags|
|
|
expect(attributes.unpack("Q3")).to eq([131_058, 15, 1])
|
|
expect(size).to eq(24)
|
|
expect(flags).to eq(0)
|
|
17
|
|
end
|
|
allow(landlock).to receive(:open_path).with(writable_dir.to_s).and_return(18)
|
|
expect(landlock).to receive(:open_path).with(File::NULL).and_return(19)
|
|
allow(landlock).to receive(:open_path).with(tmpdir.to_s).and_return(20)
|
|
expect(landlock).to receive(:open_path).with("#{tmpdir}/socket").and_return(21)
|
|
expect(landlock).to receive(:open_path).with("/dev/ptmx").and_return(22)
|
|
expect(landlock).to receive(:open_path).with("/dev/pts").and_return(23)
|
|
path_rules = []
|
|
expect(described_class).to receive(:landlock_add_rule).exactly(6).times do |ruleset_fd, type, attributes, flags|
|
|
expect(ruleset_fd).to eq(17)
|
|
expect(type).to eq(1)
|
|
expect(attributes.bytesize).to eq(12)
|
|
path_rules << attributes.unpack("Ql")
|
|
expect(flags).to eq(0)
|
|
0
|
|
end
|
|
expect(described_class).to receive(:set_no_new_privileges).and_return(0)
|
|
expect(described_class).to receive(:landlock_restrict_self).with(17, 0).and_return(0)
|
|
expect(landlock).to receive(:close_file_descriptor).with(22).ordered
|
|
expect(landlock).to receive(:close_file_descriptor).with(23).ordered
|
|
expect(landlock).to receive(:close_file_descriptor).with(21).ordered
|
|
expect(landlock).to receive(:close_file_descriptor).with(18).ordered
|
|
expect(landlock).to receive(:close_file_descriptor).with(19).ordered
|
|
expect(landlock).to receive(:close_file_descriptor).with(20).ordered
|
|
expect(landlock).to receive(:close_file_descriptor).with(17).ordered
|
|
|
|
landlock.apply!
|
|
|
|
expect(path_rules).to eq([
|
|
[32_770, 22], [32_770, 23], [65_536, 21], [32_754, 18], [16_386, 19], [32_754, 20]
|
|
])
|
|
end
|
|
|
|
it "handles reads, directory listings, and execution outside denied hierarchies" do
|
|
readable_dir = mktmpdir
|
|
readable_file = readable_dir/"file"
|
|
readable_file.write("content")
|
|
denied_dir = mktmpdir
|
|
sandbox.deny_read_path denied_dir
|
|
allow(landlock).to receive(:readable_paths).with([denied_dir])
|
|
.and_return([readable_dir.to_s, readable_file.to_s])
|
|
landlock.command(["true"], tmpdir.to_s)
|
|
|
|
allow(described_class).to receive(:abi_version).and_return(10)
|
|
expect(described_class).to receive(:landlock_create_ruleset) do |attributes, size, flags|
|
|
expect(attributes.unpack("Q")).to eq([65_535])
|
|
expect(size).to eq(8)
|
|
expect(flags).to eq(0)
|
|
17
|
|
end
|
|
expect(landlock).to receive(:open_path).with(readable_dir.to_s).and_return(18)
|
|
expect(landlock).to receive(:open_path).with(readable_file.to_s).and_return(19)
|
|
expect(landlock).to receive(:open_path).with(File::NULL).and_return(20)
|
|
expect(landlock).to receive(:open_path).with(tmpdir.to_s).and_return(21)
|
|
expect(landlock).to receive(:open_path).with("/dev/ptmx").and_return(22)
|
|
expect(landlock).to receive(:open_path).with("/dev/pts").and_return(23)
|
|
path_rules = []
|
|
expect(described_class).to receive(:landlock_add_rule).exactly(6).times do |ruleset_fd, type, attributes, flags|
|
|
expect(ruleset_fd).to eq(17)
|
|
expect(type).to eq(1)
|
|
path_rules << attributes.unpack("Ql")
|
|
expect(flags).to eq(0)
|
|
0
|
|
end
|
|
expect(described_class).to receive(:set_no_new_privileges).and_return(0)
|
|
expect(described_class).to receive(:landlock_restrict_self).with(17, 0).and_return(0)
|
|
allow(landlock).to receive(:close_file_descriptor)
|
|
|
|
landlock.apply!
|
|
|
|
expect(path_rules).to eq([
|
|
[32_774, 22], [32_774, 23], [13, 18], [5, 19], [16_386, 20], [32_754, 21]
|
|
])
|
|
end
|
|
|
|
it "allows pseudo-terminal device access" do
|
|
landlock.command(["true"], tmpdir.to_s)
|
|
|
|
allow(described_class).to receive_messages(abi_version: 10, landlock_create_ruleset: 17,
|
|
landlock_add_rule: 0, set_no_new_privileges: 0,
|
|
landlock_restrict_self: 0)
|
|
allow(landlock).to receive(:open_path).and_return(18)
|
|
allow(landlock).to receive(:close_file_descriptor)
|
|
expect(landlock).to receive(:open_path).with("/dev/ptmx").and_return(19)
|
|
expect(landlock).to receive(:open_path).with("/dev/pts").and_return(20)
|
|
expect(described_class).to receive(:landlock_add_rule)
|
|
.with(17, 1, [32_770, 19].pack("Ql"), 0).and_return(0)
|
|
expect(described_class).to receive(:landlock_add_rule)
|
|
.with(17, 1, [32_770, 20].pack("Ql"), 0).and_return(0)
|
|
|
|
landlock.apply!
|
|
end
|
|
|
|
it "allows standard device and POSIX IPC paths" do
|
|
denied_dir = mktmpdir
|
|
sandbox.deny_read_path denied_dir
|
|
allow(landlock).to receive(:readable_paths).with([denied_dir]).and_return([])
|
|
landlock.command(["true"], tmpdir.to_s)
|
|
|
|
allow(File).to receive(:exist?).with("/dev/full").and_return(true)
|
|
allow(File).to receive(:exist?).with("/dev/mqueue").and_return(true)
|
|
allow(File).to receive(:exist?).with("/dev/shm").and_return(true)
|
|
allow(File).to receive(:exist?).with("/dev/tty").and_return(true)
|
|
allow(described_class).to receive_messages(abi_version: 10, landlock_create_ruleset: 17,
|
|
landlock_add_rule: 0, set_no_new_privileges: 0,
|
|
landlock_restrict_self: 0)
|
|
allow(landlock).to receive(:open_path).and_return(18)
|
|
allow(landlock).to receive(:close_file_descriptor)
|
|
{
|
|
"/dev/full" => [16_386, 19],
|
|
"/dev/mqueue" => [32_758, 20],
|
|
"/dev/shm" => [32_754, 21],
|
|
"/dev/tty" => [32_774, 22],
|
|
}.each do |path, (access, file_descriptor)|
|
|
expect(landlock).to receive(:open_path).with(path).and_return(file_descriptor)
|
|
expect(described_class).to receive(:landlock_add_rule)
|
|
.with(17, 1, [access, file_descriptor].pack("Ql"), 0).and_return(0)
|
|
end
|
|
|
|
landlock.apply!
|
|
end
|
|
|
|
it "skips unavailable device and IPC paths" do
|
|
landlock.command(["true"], tmpdir.to_s)
|
|
|
|
allow(File).to receive(:exist?).with("/dev/full").and_return(false)
|
|
allow(File).to receive(:exist?).with("/dev/mqueue").and_return(false)
|
|
allow(File).to receive(:exist?).with("/dev/ptmx").and_return(false)
|
|
allow(File).to receive(:exist?).with("/dev/pts").and_return(false)
|
|
allow(File).to receive(:exist?).with("/dev/shm").and_return(false)
|
|
allow(File).to receive(:exist?).with("/dev/tty").and_return(false)
|
|
allow(described_class).to receive_messages(abi_version: 10, landlock_create_ruleset: 17,
|
|
landlock_add_rule: 0, set_no_new_privileges: 0,
|
|
landlock_restrict_self: 0)
|
|
allow(landlock).to receive(:open_path).and_return(18)
|
|
allow(landlock).to receive(:close_file_descriptor)
|
|
expect(landlock).not_to receive(:open_path).with("/dev/full")
|
|
expect(landlock).not_to receive(:open_path).with("/dev/mqueue")
|
|
expect(landlock).not_to receive(:open_path).with("/dev/ptmx")
|
|
expect(landlock).not_to receive(:open_path).with("/dev/pts")
|
|
expect(landlock).not_to receive(:open_path).with("/dev/shm")
|
|
expect(landlock).not_to receive(:open_path).with("/dev/tty")
|
|
|
|
landlock.apply!
|
|
end
|
|
|
|
context "with an older ABI" do
|
|
before do
|
|
allow(landlock).to receive(:open_path).and_return(18)
|
|
allow(described_class).to receive_messages(abi_version: 7, landlock_create_ruleset: 17, landlock_add_rule: 0,
|
|
set_no_new_privileges: 0, landlock_restrict_self: 0)
|
|
allow(landlock).to receive(:close_file_descriptor)
|
|
end
|
|
|
|
it "warns when applying incomplete network denial" do
|
|
sandbox.deny_all_network
|
|
landlock.command(["true"], tmpdir.to_s)
|
|
|
|
expect { landlock.apply! }
|
|
.to output(/Landlock ABI 10 or later is required to deny all network access; found ABI 7/).to_stderr
|
|
end
|
|
|
|
it "does not warn before applying network denial" do
|
|
sandbox.deny_all_network
|
|
|
|
expect { landlock.command(["true"], tmpdir.to_s) }.not_to output.to_stderr
|
|
end
|
|
|
|
it "does not warn without network denial" do
|
|
landlock.command(["true"], tmpdir.to_s)
|
|
|
|
expect { landlock.apply! }.not_to output.to_stderr
|
|
end
|
|
|
|
it "uses the supported network access rights" do
|
|
sandbox.deny_all_network
|
|
landlock.command(["true"], tmpdir.to_s)
|
|
|
|
attributes, = landlock.ruleset_attributes(7)
|
|
|
|
expect(attributes.unpack("Q3")).to eq([65_522, 3, 1])
|
|
end
|
|
end
|
|
|
|
context "with the oldest supported ABI" do
|
|
before do
|
|
allow(landlock).to receive(:open_path).and_return(18)
|
|
allow(described_class).to receive_messages(abi_version: 2, landlock_create_ruleset: 17, landlock_add_rule: 0,
|
|
set_no_new_privileges: 0, landlock_restrict_self: 0)
|
|
allow(landlock).to receive(:close_file_descriptor)
|
|
end
|
|
|
|
it "does not warn without network denial" do
|
|
landlock.command(["true"], tmpdir.to_s)
|
|
|
|
expect { landlock.apply! }.not_to output.to_stderr
|
|
end
|
|
|
|
it "warns that network access cannot be restricted" do
|
|
sandbox.deny_all_network
|
|
landlock.command(["true"], tmpdir.to_s)
|
|
|
|
expect { landlock.apply! }
|
|
.to output(/found ABI 2\. This kernel cannot restrict network access/).to_stderr
|
|
end
|
|
|
|
it "omits unsupported access rights from device path rules" do
|
|
allow(File).to receive(:exist?).with("/dev/full").and_return(true)
|
|
landlock.command(["true"], tmpdir.to_s)
|
|
|
|
expect(landlock).to receive(:open_path).with("/dev/full").and_return(19)
|
|
expect(described_class).to receive(:landlock_add_rule)
|
|
.with(17, 1, [2, 19].pack("Ql"), 0).and_return(0)
|
|
|
|
landlock.apply!
|
|
end
|
|
|
|
it "handles only the supported filesystem access rights" do
|
|
sandbox.deny_all_network
|
|
landlock.command(["true"], tmpdir.to_s)
|
|
|
|
attributes, = landlock.ruleset_attributes(2)
|
|
|
|
expect(attributes.unpack("Q*")).to eq([16_370])
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "#command" do
|
|
it "prepares missing writable directories and removes them after running" do
|
|
writable_dir = mktmpdir/"created"
|
|
sandbox.allow_write_path writable_dir
|
|
|
|
expect(landlock.command(["true"], mktmpdir.to_s)).to eq(["true"])
|
|
expect(writable_dir).to be_a_directory
|
|
|
|
landlock.run { nil }
|
|
|
|
expect(writable_dir).not_to exist
|
|
end
|
|
|
|
it "rejects regex path filters" do
|
|
sandbox.allow_write path: "^/tmp/homebrew-[^/]+$", type: :regex
|
|
|
|
expect { landlock.command(["true"], mktmpdir.to_s) }
|
|
.to raise_error(ArgumentError, /Linux sandbox does not support regex path filters/)
|
|
end
|
|
|
|
it "allows reading paths outside denied hierarchies" do
|
|
root = mktmpdir
|
|
readable_dir = root/"readable"
|
|
denied_dir = root/"denied"
|
|
readable_dir.mkpath
|
|
denied_dir.mkpath
|
|
allow(landlock).to receive(:root_path).and_return(root)
|
|
|
|
expect(landlock.readable_paths([denied_dir])).to eq([readable_dir.to_s])
|
|
end
|
|
|
|
it "does not allow a symlink alias into a denied hierarchy" do
|
|
root = mktmpdir
|
|
denied_dir = root/"denied"
|
|
denied_dir.mkpath
|
|
alias_path = root/"alias"
|
|
alias_path.make_symlink(denied_dir)
|
|
allow(landlock).to receive(:root_path).and_return(root)
|
|
|
|
expect(landlock.readable_paths([denied_dir])).to be_empty
|
|
end
|
|
|
|
it "skips dangling symlinks" do
|
|
root = mktmpdir
|
|
denied_dir = root/"denied"
|
|
denied_dir.mkpath
|
|
dangling_path = root/"dangling"
|
|
dangling_path.make_symlink(root/"missing")
|
|
allow(landlock).to receive(:root_path).and_return(root)
|
|
|
|
expect(landlock.readable_paths([denied_dir])).to be_empty
|
|
end
|
|
end
|
|
end
|