Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Support for opt-in network isolation in build/test sandboxes #17081

Merged
merged 7 commits into from
Apr 23, 2024
2 changes: 2 additions & 0 deletions Library/Homebrew/ast_constants.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
[{ name: :go_resource, type: :block_call }, { name: :resource, type: :block_call }],
[{ name: :patch, type: :method_call }, { name: :patch, type: :block_call }],
[{ name: :needs, type: :method_call }],
[{ name: :allow_network_access!, type: :method_call }],
[{ name: :deny_network_access!, type: :method_call }],
[{ name: :install, type: :method_definition }],
[{ name: :post_install, type: :method_definition }],
[{ name: :caveats, type: :method_definition }],
Expand Down
3 changes: 2 additions & 1 deletion Library/Homebrew/dev-cmd/test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ def run

exec_args << "--HEAD" if f.head?

Utils.safe_fork do
Utils.safe_fork do |error_pipe|
if Sandbox.available?
sandbox = Sandbox.new
f.logs.mkpath
Expand All @@ -92,6 +92,7 @@ def run
sandbox.allow_write_path(HOMEBREW_PREFIX/"var/homebrew/locks")
sandbox.allow_write_path(HOMEBREW_PREFIX/"var/log")
sandbox.allow_write_path(HOMEBREW_PREFIX/"var/run")
sandbox.deny_all_network_except_pipe(error_pipe) unless f.class.network_access_allowed?(:test)
sandbox.exec(*exec_args)
else
exec(*exec_args)
Expand Down
15 changes: 15 additions & 0 deletions Library/Homebrew/env_config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,21 @@ module EnvConfig
"of Ruby is new enough.",
boolean: true,
},
HOMEBREW_FORMULA_BUILD_NETWORK: {
description: "If set, controls network access to the sandbox for formulae builds. Overrides any " \
"controls set through DSL usage inside formulae. Must be `allow` or `deny`. If no value is " \
"set through this environment variable or DSL usage, the default behavior is `allow`.",
},
HOMEBREW_FORMULA_POSTINSTALL_NETWORK: {
description: "If set, controls network access to the sandbox for formulae postinstall. Overrides any " \
"controls set through DSL usage inside formulae. Must be `allow` or `deny`. If no value is " \
"set through this environment variable or DSL usage, the default behavior is `allow`.",
},
HOMEBREW_FORMULA_TEST_NETWORK: {
description: "If set, controls network access to the sandbox for formulae test. Overrides any " \
"controls set through DSL usage inside formulae. Must be `allow` or `deny`. If no value is " \
"set through this environment variable or DSL usage, the default behavior is `allow`.",
},
HOMEBREW_GITHUB_API_TOKEN: {
description: "Use this personal access token for the GitHub API, for features such as " \
"`brew search`. You can create one at <https://github.com/settings/tokens>. If set, " \
Expand Down
69 changes: 69 additions & 0 deletions Library/Homebrew/formula.rb
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ class Formula
extend Attrable
extend APIHashable

SUPPORTED_NETWORK_ACCESS_PHASES = [:build, :test, :postinstall].freeze
DEFAULT_NETWORK_ACCESS_ALLOWED = true
private_constant :SUPPORTED_NETWORK_ACCESS_PHASES
private_constant :DEFAULT_NETWORK_ACCESS_ALLOWED

# The name of this {Formula}.
# e.g. `this-formula`
sig { returns(String) }
Expand Down Expand Up @@ -400,6 +405,7 @@ def head_only?
!!head && !stable
end

# Stop RuboCop from erroneously indenting hash target
delegate [ # rubocop:disable Layout/HashAlignment
:bottle_defined?,
:bottle_tag?,
Expand Down Expand Up @@ -459,6 +465,13 @@ def bottle_for_tag(tag = nil)
# @see .version
delegate version: :active_spec

# Stop RuboCop from erroneously indenting hash target
delegate [ # rubocop:disable Layout/HashAlignment
:allow_network_access!,
:deny_network_access!,
:network_access_allowed?,
] => :"self.class"

# Whether this formula was loaded using the formulae.brew.sh API
# @!method loaded_from_api?
# @private
Expand Down Expand Up @@ -3028,6 +3041,9 @@ def inherited(child)
@skip_clean_paths = Set.new
@link_overwrite_paths = Set.new
@loaded_from_api = false
@network_access_allowed = SUPPORTED_NETWORK_ACCESS_PHASES.to_h do |phase|
[phase, DEFAULT_NETWORK_ACCESS_ALLOWED]
end
end
end

Expand Down Expand Up @@ -3104,6 +3120,59 @@ def license(args = nil)
end
end

# @!attribute [w] allow_network_access!
# The phases for which network access is allowed. By default, network
# access is allowed for all phases. Valid phases are `:build`, `:test`,
# and `:postinstall`. When no argument is passed, network access will be
# allowed for all phases.
# <pre>allow_network_access!</pre>
# <pre>allow_network_access! :build</pre>
# <pre>allow_network_access! [:build, :test]</pre>
sig { params(phases: T.any(Symbol, T::Array[Symbol])).void }
def allow_network_access!(phases = [])
phases_array = Array(phases)
if phases_array.empty?
@network_access_allowed.each_key { |phase| @network_access_allowed[phase] = true }
else
phases_array.each do |phase|
raise ArgumentError, "Unknown phase: #{phase}" unless SUPPORTED_NETWORK_ACCESS_PHASES.include?(phase)

@network_access_allowed[phase] = true
end
end
end

# @!attribute [w] deny_network_access!
# The phases for which network access is denied. By default, network
# access is allowed for all phases. Valid phases are `:build`, `:test`,
# and `:postinstall`. When no argument is passed, network access will be
# denied for all phases.
# <pre>deny_network_access!</pre>
# <pre>deny_network_access! :build</pre>
# <pre>deny_network_access! [:build, :test]</pre>
sig { params(phases: T.any(Symbol, T::Array[Symbol])).void }
def deny_network_access!(phases = [])
phases_array = Array(phases)
if phases_array.empty?
@network_access_allowed.each_key { |phase| @network_access_allowed[phase] = false }
else
phases_array.each do |phase|
raise ArgumentError, "Unknown phase: #{phase}" unless SUPPORTED_NETWORK_ACCESS_PHASES.include?(phase)

@network_access_allowed[phase] = false
end
end
end

# Whether the specified phase should be forced offline.
sig { params(phase: Symbol).returns(T::Boolean) }
def network_access_allowed?(phase)
raise ArgumentError, "Unknown phase: #{phase}" unless SUPPORTED_NETWORK_ACCESS_PHASES.include?(phase)

env_var = Homebrew::EnvConfig.send(:"formula_#{phase}_network")
env_var.nil? ? @network_access_allowed[phase] : env_var == "allow"
end

# @!attribute [w] homepage
# The homepage for the software. Used by users to get more information
# about the software and Homebrew maintainers as a point of contact for
Expand Down
6 changes: 4 additions & 2 deletions Library/Homebrew/formula_installer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -925,7 +925,7 @@ def build
formula.specified_path,
].concat(build_argv)

Utils.safe_fork do
Utils.safe_fork do |error_pipe|
if Sandbox.available?
sandbox = Sandbox.new
formula.logs.mkpath
Expand All @@ -937,6 +937,7 @@ def build
sandbox.allow_fossil
sandbox.allow_write_xcode
sandbox.allow_write_cellar(formula)
sandbox.deny_all_network_except_pipe(error_pipe) unless formula.network_access_allowed?(:build)
sandbox.exec(*args)
else
exec(*args)
Expand Down Expand Up @@ -1151,7 +1152,7 @@ def post_install

args << post_install_formula_path

Utils.safe_fork do
Utils.safe_fork do |error_pipe|
if Sandbox.available?
sandbox = Sandbox.new
formula.logs.mkpath
Expand All @@ -1161,6 +1162,7 @@ def post_install
sandbox.allow_write_xcode
sandbox.deny_write_homebrew_repository
sandbox.allow_write_cellar(formula)
sandbox.deny_all_network_except_pipe(error_pipe) unless formula.network_access_allowed?(:postinstall)
Keg::KEG_LINK_DIRECTORIES.each do |dir|
sandbox.allow_write_path "#{HOMEBREW_PREFIX}/#{dir}"
end
Expand Down
26 changes: 26 additions & 0 deletions Library/Homebrew/sandbox.rb
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,32 @@ def deny_write_homebrew_repository
end
end

sig { params(path: T.any(String, Pathname), type: Symbol).void }
def allow_network(path:, type: :literal)
add_rule allow: true, operation: "network*", filter: path_filter(path, type)
end

sig { params(path: T.any(String, Pathname), type: Symbol).void }
def deny_network(path:, type: :literal)
add_rule allow: false, operation: "network*", filter: path_filter(path, type)
end

sig { void }
def allow_all_network
add_rule allow: true, operation: "network*"
end

sig { void }
def deny_all_network
add_rule allow: false, operation: "network*"
end

sig { params(path: T.any(String, Pathname)).void }
def deny_all_network_except_pipe(path)
deny_all_network
allow_network path:, type: :literal
end

def exec(*args)
seatbelt = Tempfile.new(["homebrew", ".sb"], HOMEBREW_TEMP)
seatbelt.write(@profile.dump)
Expand Down
16 changes: 16 additions & 0 deletions Library/Homebrew/test/dev-cmd/test_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

require "cmd/shared_examples/args_parse"
require "dev-cmd/test"
require "sandbox"

RSpec.describe Homebrew::DevCmd::Test do
it_behaves_like "parseable arguments"
Expand All @@ -18,4 +19,19 @@
.and not_to_output.to_stderr
.and be_a_success
end

it "blocks network access when test phase is offline", :integration_test do
if Sandbox.available?
install_test_formula "testball_offline_test", <<~RUBY
deny_network_access! :test
test do
system "curl", "example.org"
end
RUBY

expect { brew "test", "--verbose", "testball_offline_test" }
.to output(/curl: \(6\) Could not resolve host: example\.org/).to_stdout
.and be_a_failure
end
end
end
6 changes: 6 additions & 0 deletions Library/Homebrew/test/formula_installer_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
require "formula"
require "formula_installer"
require "keg"
require "sandbox"
require "tab"
require "cmd/install"
require "test/support/fixtures/testball"
require "test/support/fixtures/testball_bottle"
require "test/support/fixtures/failball"
require "test/support/fixtures/failball_offline_install"

RSpec.describe FormulaInstaller do
matcher :be_poured_from_bottle do
Expand Down Expand Up @@ -70,6 +72,10 @@ def temporary_install(formula, **options)
end
end

specify "offline installation" do
expect { temporary_install(FailballOfflineInstall.new) }.to raise_error(BuildError) if Sandbox.available?
end

specify "Formula is not poured from bottle when compiler specified" do
temporary_install(TestballBottle.new, cc: "clang") do |f|
tab = Tab.for_formula(f)
Expand Down
37 changes: 37 additions & 0 deletions Library/Homebrew/test/formula_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
expect(f.alias_name).to be_nil
expect(f.full_alias_name).to be_nil
expect(f.specified_path).to eq(path)
[:build, :test, :postinstall].each { |phase| expect(f.network_access_allowed?(phase)).to be(true) }
expect { klass.new }.to raise_error(ArgumentError)
end

Expand All @@ -55,6 +56,7 @@
expect(f_alias.specified_path).to eq(Pathname(alias_path))
expect(f_alias.full_alias_name).to eq(alias_name)
expect(f_alias.full_specified_name).to eq(alias_name)
[:build, :test, :postinstall].each { |phase| expect(f_alias.network_access_allowed?(phase)).to be(true) }
expect { klass.new }.to raise_error(ArgumentError)
end

Expand Down Expand Up @@ -1895,4 +1897,39 @@ def install
expect(f.fish_completion/"testball.fish").to be_a_file
end
end

describe "{allow,deny}_network_access" do
phases = [:build, :postinstall, :test].freeze
actions = %w[allow deny].freeze
phases.each do |phase|
actions.each do |action|
it "can #{action} network access for #{phase}" do
f = Class.new(Testball) do
send(:"#{action}_network_access!", phase)
end

expect(f.network_access_allowed?(phase)).to be(action == "allow")
end
end
end

actions.each do |action|
it "can #{action} network access for all phases" do
f = Class.new(Testball) do
send(:"#{action}_network_access!")
end

phases.each do |phase|
expect(f.network_access_allowed?(phase)).to be(action == "allow")
end
end
end
end

describe "#network_access_allowed?" do
it "throws an error when passed an invalid symbol" do
f = Testball.new
expect { f.network_access_allowed?(:foo) }.to raise_error(ArgumentError)
end
end
end
31 changes: 31 additions & 0 deletions Library/Homebrew/test/support/fixtures/failball_offline_install.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# typed: true
# frozen_string_literal: true

class FailballOfflineInstall < Formula
def initialize(name = "failball_offline_install", path = Pathname.new(__FILE__).expand_path, spec = :stable,
alias_path: nil, tap: nil, force_bottle: false)
super
end

DSL_PROC = proc do
url "file://#{TEST_FIXTURE_DIR}/tarballs/testball-0.1.tbz"
sha256 TESTBALL_SHA256
deny_network_access! :build
end.freeze
private_constant :DSL_PROC

DSL_PROC.call

def self.inherited(other)
super
other.instance_eval(&DSL_PROC)
end

def install
system "curl", "example.org"

prefix.install "bin"
prefix.install "libexec"
Dir.chdir "doc"
end
end
6 changes: 3 additions & 3 deletions Library/Homebrew/utils/fork.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,15 @@ def self.safe_fork
pid = fork do
# bootsnap doesn't like these forked processes
ENV["HOMEBREW_NO_BOOTSNAP"] = "1"

ENV["HOMEBREW_ERROR_PIPE"] = server.path
error_pipe = server.path
ENV["HOMEBREW_ERROR_PIPE"] = error_pipe
server.close
read.close
write.fcntl(Fcntl::F_SETFD, Fcntl::FD_CLOEXEC)

Process::UID.change_privilege(Process.euid) if Process.euid != Process.uid

yield
yield(error_pipe)
rescue Exception => e # rubocop:disable Lint/RescueException
error_hash = JSON.parse e.to_json

Expand Down
Loading