Skip to content

Commit b5f857b

Browse files
authored
Merge pull request #17081 from alebcay/formula-offline-phases
Support for opt-in network isolation in build/test sandboxes
2 parents 5ad4e6e + 4a9d757 commit b5f857b

File tree

11 files changed

+211
-6
lines changed

11 files changed

+211
-6
lines changed

Library/Homebrew/ast_constants.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@
4444
[{ name: :go_resource, type: :block_call }, { name: :resource, type: :block_call }],
4545
[{ name: :patch, type: :method_call }, { name: :patch, type: :block_call }],
4646
[{ name: :needs, type: :method_call }],
47+
[{ name: :allow_network_access!, type: :method_call }],
48+
[{ name: :deny_network_access!, type: :method_call }],
4749
[{ name: :install, type: :method_definition }],
4850
[{ name: :post_install, type: :method_definition }],
4951
[{ name: :caveats, type: :method_definition }],

Library/Homebrew/dev-cmd/test.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ def run
8080

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

83-
Utils.safe_fork do
83+
Utils.safe_fork do |error_pipe|
8484
if Sandbox.available?
8585
sandbox = Sandbox.new
8686
f.logs.mkpath
@@ -92,6 +92,7 @@ def run
9292
sandbox.allow_write_path(HOMEBREW_PREFIX/"var/homebrew/locks")
9393
sandbox.allow_write_path(HOMEBREW_PREFIX/"var/log")
9494
sandbox.allow_write_path(HOMEBREW_PREFIX/"var/run")
95+
sandbox.deny_all_network_except_pipe(error_pipe) unless f.class.network_access_allowed?(:test)
9596
sandbox.exec(*exec_args)
9697
else
9798
exec(*exec_args)

Library/Homebrew/env_config.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,21 @@ module EnvConfig
228228
"of Ruby is new enough.",
229229
boolean: true,
230230
},
231+
HOMEBREW_FORMULA_BUILD_NETWORK: {
232+
description: "If set, controls network access to the sandbox for formulae builds. Overrides any " \
233+
"controls set through DSL usage inside formulae. Must be `allow` or `deny`. If no value is " \
234+
"set through this environment variable or DSL usage, the default behavior is `allow`.",
235+
},
236+
HOMEBREW_FORMULA_POSTINSTALL_NETWORK: {
237+
description: "If set, controls network access to the sandbox for formulae postinstall. Overrides any " \
238+
"controls set through DSL usage inside formulae. Must be `allow` or `deny`. If no value is " \
239+
"set through this environment variable or DSL usage, the default behavior is `allow`.",
240+
},
241+
HOMEBREW_FORMULA_TEST_NETWORK: {
242+
description: "If set, controls network access to the sandbox for formulae test. Overrides any " \
243+
"controls set through DSL usage inside formulae. Must be `allow` or `deny`. If no value is " \
244+
"set through this environment variable or DSL usage, the default behavior is `allow`.",
245+
},
231246
HOMEBREW_GITHUB_API_TOKEN: {
232247
description: "Use this personal access token for the GitHub API, for features such as " \
233248
"`brew search`. You can create one at <https://github.com/settings/tokens>. If set, " \

Library/Homebrew/formula.rb

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@ class Formula
7070
extend Attrable
7171
extend APIHashable
7272

73+
SUPPORTED_NETWORK_ACCESS_PHASES = [:build, :test, :postinstall].freeze
74+
DEFAULT_NETWORK_ACCESS_ALLOWED = true
75+
private_constant :SUPPORTED_NETWORK_ACCESS_PHASES
76+
private_constant :DEFAULT_NETWORK_ACCESS_ALLOWED
77+
7378
# The name of this {Formula}.
7479
# e.g. `this-formula`
7580
#
@@ -410,6 +415,7 @@ def head_only?
410415
!!head && !stable
411416
end
412417

418+
# Stop RuboCop from erroneously indenting hash target
413419
delegate [ # rubocop:disable Layout/HashAlignment
414420
:bottle_defined?,
415421
:bottle_tag?,
@@ -469,6 +475,13 @@ def bottle_for_tag(tag = nil)
469475
# @see .version
470476
delegate version: :active_spec
471477

478+
# Stop RuboCop from erroneously indenting hash target
479+
delegate [ # rubocop:disable Layout/HashAlignment
480+
:allow_network_access!,
481+
:deny_network_access!,
482+
:network_access_allowed?,
483+
] => :"self.class"
484+
472485
# Whether this formula was loaded using the formulae.brew.sh API
473486
# @!method loaded_from_api?
474487
# @private
@@ -3145,6 +3158,9 @@ def inherited(child)
31453158
@skip_clean_paths = Set.new
31463159
@link_overwrite_paths = Set.new
31473160
@loaded_from_api = false
3161+
@network_access_allowed = SUPPORTED_NETWORK_ACCESS_PHASES.to_h do |phase|
3162+
[phase, DEFAULT_NETWORK_ACCESS_ALLOWED]
3163+
end
31483164
end
31493165
end
31503166

@@ -3225,6 +3241,59 @@ def license(args = nil)
32253241
end
32263242
end
32273243

3244+
# @!attribute [w] allow_network_access!
3245+
# The phases for which network access is allowed. By default, network
3246+
# access is allowed for all phases. Valid phases are `:build`, `:test`,
3247+
# and `:postinstall`. When no argument is passed, network access will be
3248+
# allowed for all phases.
3249+
# <pre>allow_network_access!</pre>
3250+
# <pre>allow_network_access! :build</pre>
3251+
# <pre>allow_network_access! [:build, :test]</pre>
3252+
sig { params(phases: T.any(Symbol, T::Array[Symbol])).void }
3253+
def allow_network_access!(phases = [])
3254+
phases_array = Array(phases)
3255+
if phases_array.empty?
3256+
@network_access_allowed.each_key { |phase| @network_access_allowed[phase] = true }
3257+
else
3258+
phases_array.each do |phase|
3259+
raise ArgumentError, "Unknown phase: #{phase}" unless SUPPORTED_NETWORK_ACCESS_PHASES.include?(phase)
3260+
3261+
@network_access_allowed[phase] = true
3262+
end
3263+
end
3264+
end
3265+
3266+
# @!attribute [w] deny_network_access!
3267+
# The phases for which network access is denied. By default, network
3268+
# access is allowed for all phases. Valid phases are `:build`, `:test`,
3269+
# and `:postinstall`. When no argument is passed, network access will be
3270+
# denied for all phases.
3271+
# <pre>deny_network_access!</pre>
3272+
# <pre>deny_network_access! :build</pre>
3273+
# <pre>deny_network_access! [:build, :test]</pre>
3274+
sig { params(phases: T.any(Symbol, T::Array[Symbol])).void }
3275+
def deny_network_access!(phases = [])
3276+
phases_array = Array(phases)
3277+
if phases_array.empty?
3278+
@network_access_allowed.each_key { |phase| @network_access_allowed[phase] = false }
3279+
else
3280+
phases_array.each do |phase|
3281+
raise ArgumentError, "Unknown phase: #{phase}" unless SUPPORTED_NETWORK_ACCESS_PHASES.include?(phase)
3282+
3283+
@network_access_allowed[phase] = false
3284+
end
3285+
end
3286+
end
3287+
3288+
# Whether the specified phase should be forced offline.
3289+
sig { params(phase: Symbol).returns(T::Boolean) }
3290+
def network_access_allowed?(phase)
3291+
raise ArgumentError, "Unknown phase: #{phase}" unless SUPPORTED_NETWORK_ACCESS_PHASES.include?(phase)
3292+
3293+
env_var = Homebrew::EnvConfig.send(:"formula_#{phase}_network")
3294+
env_var.nil? ? @network_access_allowed[phase] : env_var == "allow"
3295+
end
3296+
32283297
# @!attribute [w] homepage
32293298
# The homepage for the software. Used by users to get more information
32303299
# about the software and Homebrew maintainers as a point of contact for

Library/Homebrew/formula_installer.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -925,7 +925,7 @@ def build
925925
formula.specified_path,
926926
].concat(build_argv)
927927

928-
Utils.safe_fork do
928+
Utils.safe_fork do |error_pipe|
929929
if Sandbox.available?
930930
sandbox = Sandbox.new
931931
formula.logs.mkpath
@@ -937,6 +937,7 @@ def build
937937
sandbox.allow_fossil
938938
sandbox.allow_write_xcode
939939
sandbox.allow_write_cellar(formula)
940+
sandbox.deny_all_network_except_pipe(error_pipe) unless formula.network_access_allowed?(:build)
940941
sandbox.exec(*args)
941942
else
942943
exec(*args)
@@ -1151,7 +1152,7 @@ def post_install
11511152

11521153
args << post_install_formula_path
11531154

1154-
Utils.safe_fork do
1155+
Utils.safe_fork do |error_pipe|
11551156
if Sandbox.available?
11561157
sandbox = Sandbox.new
11571158
formula.logs.mkpath
@@ -1161,6 +1162,7 @@ def post_install
11611162
sandbox.allow_write_xcode
11621163
sandbox.deny_write_homebrew_repository
11631164
sandbox.allow_write_cellar(formula)
1165+
sandbox.deny_all_network_except_pipe(error_pipe) unless formula.network_access_allowed?(:postinstall)
11641166
Keg::KEG_LINK_DIRECTORIES.each do |dir|
11651167
sandbox.allow_write_path "#{HOMEBREW_PREFIX}/#{dir}"
11661168
end

Library/Homebrew/sandbox.rb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,32 @@ def deny_write_homebrew_repository
9191
end
9292
end
9393

94+
sig { params(path: T.any(String, Pathname), type: Symbol).void }
95+
def allow_network(path:, type: :literal)
96+
add_rule allow: true, operation: "network*", filter: path_filter(path, type)
97+
end
98+
99+
sig { params(path: T.any(String, Pathname), type: Symbol).void }
100+
def deny_network(path:, type: :literal)
101+
add_rule allow: false, operation: "network*", filter: path_filter(path, type)
102+
end
103+
104+
sig { void }
105+
def allow_all_network
106+
add_rule allow: true, operation: "network*"
107+
end
108+
109+
sig { void }
110+
def deny_all_network
111+
add_rule allow: false, operation: "network*"
112+
end
113+
114+
sig { params(path: T.any(String, Pathname)).void }
115+
def deny_all_network_except_pipe(path)
116+
deny_all_network
117+
allow_network path:, type: :literal
118+
end
119+
94120
def exec(*args)
95121
seatbelt = Tempfile.new(["homebrew", ".sb"], HOMEBREW_TEMP)
96122
seatbelt.write(@profile.dump)

Library/Homebrew/test/dev-cmd/test_spec.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
require "cmd/shared_examples/args_parse"
44
require "dev-cmd/test"
5+
require "sandbox"
56

67
RSpec.describe Homebrew::DevCmd::Test do
78
it_behaves_like "parseable arguments"
@@ -18,4 +19,19 @@
1819
.and not_to_output.to_stderr
1920
.and be_a_success
2021
end
22+
23+
it "blocks network access when test phase is offline", :integration_test do
24+
if Sandbox.available?
25+
install_test_formula "testball_offline_test", <<~RUBY
26+
deny_network_access! :test
27+
test do
28+
system "curl", "example.org"
29+
end
30+
RUBY
31+
32+
expect { brew "test", "--verbose", "testball_offline_test" }
33+
.to output(/curl: \(6\) Could not resolve host: example\.org/).to_stdout
34+
.and be_a_failure
35+
end
36+
end
2137
end

Library/Homebrew/test/formula_installer_spec.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
require "formula"
44
require "formula_installer"
55
require "keg"
6+
require "sandbox"
67
require "tab"
78
require "cmd/install"
89
require "test/support/fixtures/testball"
910
require "test/support/fixtures/testball_bottle"
1011
require "test/support/fixtures/failball"
12+
require "test/support/fixtures/failball_offline_install"
1113

1214
RSpec.describe FormulaInstaller do
1315
matcher :be_poured_from_bottle do
@@ -70,6 +72,10 @@ def temporary_install(formula, **options)
7072
end
7173
end
7274

75+
specify "offline installation" do
76+
expect { temporary_install(FailballOfflineInstall.new) }.to raise_error(BuildError) if Sandbox.available?
77+
end
78+
7379
specify "Formula is not poured from bottle when compiler specified" do
7480
temporary_install(TestballBottle.new, cc: "clang") do |f|
7581
tab = Tab.for_formula(f)

Library/Homebrew/test/formula_spec.rb

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
expect(f.alias_name).to be_nil
4343
expect(f.full_alias_name).to be_nil
4444
expect(f.specified_path).to eq(path)
45+
[:build, :test, :postinstall].each { |phase| expect(f.network_access_allowed?(phase)).to be(true) }
4546
expect { klass.new }.to raise_error(ArgumentError)
4647
end
4748

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

@@ -1895,4 +1897,39 @@ def install
18951897
expect(f.fish_completion/"testball.fish").to be_a_file
18961898
end
18971899
end
1900+
1901+
describe "{allow,deny}_network_access" do
1902+
phases = [:build, :postinstall, :test].freeze
1903+
actions = %w[allow deny].freeze
1904+
phases.each do |phase|
1905+
actions.each do |action|
1906+
it "can #{action} network access for #{phase}" do
1907+
f = Class.new(Testball) do
1908+
send(:"#{action}_network_access!", phase)
1909+
end
1910+
1911+
expect(f.network_access_allowed?(phase)).to be(action == "allow")
1912+
end
1913+
end
1914+
end
1915+
1916+
actions.each do |action|
1917+
it "can #{action} network access for all phases" do
1918+
f = Class.new(Testball) do
1919+
send(:"#{action}_network_access!")
1920+
end
1921+
1922+
phases.each do |phase|
1923+
expect(f.network_access_allowed?(phase)).to be(action == "allow")
1924+
end
1925+
end
1926+
end
1927+
end
1928+
1929+
describe "#network_access_allowed?" do
1930+
it "throws an error when passed an invalid symbol" do
1931+
f = Testball.new
1932+
expect { f.network_access_allowed?(:foo) }.to raise_error(ArgumentError)
1933+
end
1934+
end
18981935
end
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# typed: true
2+
# frozen_string_literal: true
3+
4+
class FailballOfflineInstall < Formula
5+
def initialize(name = "failball_offline_install", path = Pathname.new(__FILE__).expand_path, spec = :stable,
6+
alias_path: nil, tap: nil, force_bottle: false)
7+
super
8+
end
9+
10+
DSL_PROC = proc do
11+
url "file://#{TEST_FIXTURE_DIR}/tarballs/testball-0.1.tbz"
12+
sha256 TESTBALL_SHA256
13+
deny_network_access! :build
14+
end.freeze
15+
private_constant :DSL_PROC
16+
17+
DSL_PROC.call
18+
19+
def self.inherited(other)
20+
super
21+
other.instance_eval(&DSL_PROC)
22+
end
23+
24+
def install
25+
system "curl", "example.org"
26+
27+
prefix.install "bin"
28+
prefix.install "libexec"
29+
Dir.chdir "doc"
30+
end
31+
end

0 commit comments

Comments
 (0)