Skip to content
New issue

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

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

Already on GitHub? Sign in 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
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
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
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
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
alebcay marked this conversation as resolved.
Show resolved Hide resolved
: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
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
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
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
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
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
@@ -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
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