diff --git a/Library/Homebrew/ast_constants.rb b/Library/Homebrew/ast_constants.rb index 9093aeab2b099..38df04869e65d 100644 --- a/Library/Homebrew/ast_constants.rb +++ b/Library/Homebrew/ast_constants.rb @@ -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 }], diff --git a/Library/Homebrew/dev-cmd/test.rb b/Library/Homebrew/dev-cmd/test.rb index 06c4027e3d190..38e07963002e7 100644 --- a/Library/Homebrew/dev-cmd/test.rb +++ b/Library/Homebrew/dev-cmd/test.rb @@ -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 @@ -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) diff --git a/Library/Homebrew/env_config.rb b/Library/Homebrew/env_config.rb index c7eccd09e3978..5cdd67eb80db1 100644 --- a/Library/Homebrew/env_config.rb +++ b/Library/Homebrew/env_config.rb @@ -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 . If set, " \ diff --git a/Library/Homebrew/formula.rb b/Library/Homebrew/formula.rb index 5f83c4c7e10e7..e3eb453bc7621 100644 --- a/Library/Homebrew/formula.rb +++ b/Library/Homebrew/formula.rb @@ -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) } @@ -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?, @@ -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 @@ -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 @@ -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. + #
allow_network_access!
+ #
allow_network_access! :build
+ #
allow_network_access! [:build, :test]
+ 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. + #
deny_network_access!
+ #
deny_network_access! :build
+ #
deny_network_access! [:build, :test]
+ 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 diff --git a/Library/Homebrew/formula_installer.rb b/Library/Homebrew/formula_installer.rb index 4c1503033ba19..2964234b2b930 100644 --- a/Library/Homebrew/formula_installer.rb +++ b/Library/Homebrew/formula_installer.rb @@ -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 @@ -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) @@ -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 @@ -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 diff --git a/Library/Homebrew/sandbox.rb b/Library/Homebrew/sandbox.rb index 4aa6126590bd8..0157982a4960b 100644 --- a/Library/Homebrew/sandbox.rb +++ b/Library/Homebrew/sandbox.rb @@ -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) diff --git a/Library/Homebrew/test/dev-cmd/test_spec.rb b/Library/Homebrew/test/dev-cmd/test_spec.rb index 0c7c9d6c12ba1..0eb0dc8b2151f 100644 --- a/Library/Homebrew/test/dev-cmd/test_spec.rb +++ b/Library/Homebrew/test/dev-cmd/test_spec.rb @@ -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" @@ -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 diff --git a/Library/Homebrew/test/formula_installer_spec.rb b/Library/Homebrew/test/formula_installer_spec.rb index 67806d34c60f1..b09c85e7cf376 100644 --- a/Library/Homebrew/test/formula_installer_spec.rb +++ b/Library/Homebrew/test/formula_installer_spec.rb @@ -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 @@ -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) diff --git a/Library/Homebrew/test/formula_spec.rb b/Library/Homebrew/test/formula_spec.rb index 73d1584bf90a6..6fc08f61d1215 100644 --- a/Library/Homebrew/test/formula_spec.rb +++ b/Library/Homebrew/test/formula_spec.rb @@ -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 @@ -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 @@ -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 diff --git a/Library/Homebrew/test/support/fixtures/failball_offline_install.rb b/Library/Homebrew/test/support/fixtures/failball_offline_install.rb new file mode 100644 index 0000000000000..011b452ae3651 --- /dev/null +++ b/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 diff --git a/Library/Homebrew/utils/fork.rb b/Library/Homebrew/utils/fork.rb index 15cf1db4dba9b..c6b06876db338 100644 --- a/Library/Homebrew/utils/fork.rb +++ b/Library/Homebrew/utils/fork.rb @@ -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