utils: Use JSON to marshal child errors

Replaces our serialization of child process
errors via Marshal with JSON, preventing
unintentional or malicious code execution outside
of the build sandbox.

Additionally, adds tests for the new behavior.
This commit is contained in:
William Woodruff 2018-08-17 22:42:37 -04:00
parent 0cae28f13c
commit 86b9647450
No known key found for this signature in database
GPG Key ID: 600D68320BE45ACC
12 changed files with 169 additions and 18 deletions

View File

@ -190,7 +190,20 @@ begin
build = Build.new(formula, options) build = Build.new(formula, options)
build.install build.install
rescue Exception => e # rubocop:disable Lint/RescueException rescue Exception => e # rubocop:disable Lint/RescueException
Marshal.dump(e, error_pipe) error_hash = JSON.parse e.to_json
# Special case: need to recreate BuildErrors in full
# for proper analytics reporting and error messages.
# BuildErrors are specific to build processses and not other
# children, which is why we create the necessary state here
# and not in Utils.safe_fork.
if error_hash["json_class"] == "BuildError"
error_hash["cmd"] = e.cmd
error_hash["args"] = e.args
error_hash["env"] = e.env
end
error_pipe.puts error_hash.to_json
error_pipe.close error_pipe.close
exit! 1 exit! 1
end end

View File

@ -93,9 +93,6 @@ module Homebrew
exec(*args) exec(*args)
end end
end end
rescue ::Test::Unit::AssertionFailedError => e
ofail "#{f.full_name}: failed"
puts e.message
rescue Exception => e # rubocop:disable Lint/RescueException rescue Exception => e # rubocop:disable Lint/RescueException
ofail "#{f.full_name}: failed" ofail "#{f.full_name}: failed"
puts e, e.backtrace puts e, e.backtrace

View File

@ -352,14 +352,16 @@ class FormulaAmbiguousPythonError < RuntimeError
end end
class BuildError < RuntimeError class BuildError < RuntimeError
attr_reader :formula, :env attr_reader :cmd, :args, :env
attr_accessor :options attr_accessor :formula, :options
def initialize(formula, cmd, args, env) def initialize(formula, cmd, args, env)
@formula = formula @formula = formula
@cmd = cmd
@args = args
@env = env @env = env
args = args.map { |arg| arg.to_s.gsub " ", "\\ " }.join(" ") pretty_args = args.map { |arg| arg.to_s.gsub " ", "\\ " }.join(" ")
super "Failed executing: #{cmd} #{args}" super "Failed executing: #{cmd} #{pretty_args}"
end end
def issues def issues
@ -526,12 +528,22 @@ end
# raised by safe_system in utils.rb # raised by safe_system in utils.rb
class ErrorDuringExecution < RuntimeError class ErrorDuringExecution < RuntimeError
attr_reader :cmd
attr_reader :status attr_reader :status
attr_reader :output
def initialize(cmd, status:, output: nil) def initialize(cmd, status:, output: nil)
@cmd = cmd
@status = status @status = status
@output = output
s = "Failure while executing; `#{cmd.shelljoin.gsub(/\\=/, "=")}` exited with #{status.exitstatus}." exitstatus = if status.respond_to?(:exitstatus)
status.exitstatus
else
status
end
s = "Failure while executing; `#{cmd.shelljoin.gsub(/\\=/, "=")}` exited with #{exitstatus}."
unless [*output].empty? unless [*output].empty?
format_output_line = lambda do |type, line| format_output_line = lambda do |type, line|
@ -596,3 +608,22 @@ class BottleFormulaUnavailableError < RuntimeError
EOS EOS
end end
end end
# Raised when a child process sends us an exception over its error pipe.
class ChildProcessError < RuntimeError
attr_reader :inner
attr_reader :inner_class
def initialize(inner)
@inner = inner
@inner_class = Object.const_get inner["json_class"]
super <<~EOS
An exception occured within a build process:
#{inner_class}: #{inner["m"]}
EOS
# Clobber our real (but irrelevant) backtrace with that of the inner exception.
set_backtrace inner["b"]
end
end

View File

@ -744,14 +744,18 @@ class FormulaInstaller
raise "Empty installation" raise "Empty installation"
end end
rescue Exception => e # rubocop:disable Lint/RescueException rescue Exception => e # rubocop:disable Lint/RescueException
e.options = display_options(formula) if e.is_a?(BuildError) if e.is_a? BuildError
e.formula = formula
e.options = options
end
ignore_interrupts do ignore_interrupts do
# any exceptions must leave us with nothing installed # any exceptions must leave us with nothing installed
formula.update_head_version formula.update_head_version
formula.prefix.rmtree if formula.prefix.directory? formula.prefix.rmtree if formula.prefix.directory?
formula.rack.rmdir_if_possible formula.rack.rmdir_if_possible
end end
raise raise e
end end
def link(keg) def link(keg)

View File

@ -1,5 +1,7 @@
require "pathname" require "pathname"
require "English" require "English"
require "json"
require "json/add/core"
HOMEBREW_LIBRARY_PATH = Pathname.new(__FILE__).realpath.parent HOMEBREW_LIBRARY_PATH = Pathname.new(__FILE__).realpath.parent

View File

@ -15,7 +15,7 @@ begin
formula.extend(Debrew::Formula) if ARGV.debug? formula.extend(Debrew::Formula) if ARGV.debug?
formula.run_post_install formula.run_post_install
rescue Exception => e # rubocop:disable Lint/RescueException rescue Exception => e # rubocop:disable Lint/RescueException
Marshal.dump(e, error_pipe) error_pipe.puts e.to_json
error_pipe.close error_pipe.close
exit! 1 exit! 1
end end

View File

@ -28,7 +28,7 @@ begin
raise "test returned false" if formula.run_test == false raise "test returned false" if formula.run_test == false
end end
rescue Exception => e # rubocop:disable Lint/RescueException rescue Exception => e # rubocop:disable Lint/RescueException
Marshal.dump(e, error_pipe) error_pipe.puts e.to_json
error_pipe.close error_pipe.close
exit! 1 exit! 1
end end

View File

@ -4,6 +4,7 @@ require "keg"
require "tab" require "tab"
require "test/support/fixtures/testball" require "test/support/fixtures/testball"
require "test/support/fixtures/testball_bottle" require "test/support/fixtures/testball_bottle"
require "test/support/fixtures/failball"
describe FormulaInstaller do describe FormulaInstaller do
define_negated_matcher :need_bottle, :be_bottle_unneeded define_negated_matcher :need_bottle, :be_bottle_unneeded
@ -28,7 +29,7 @@ describe FormulaInstaller do
Tab.clear_cache Tab.clear_cache
expect(Tab.for_keg(keg)).not_to be_poured_from_bottle expect(Tab.for_keg(keg)).not_to be_poured_from_bottle
yield formula yield formula if block_given?
ensure ensure
Tab.clear_cache Tab.clear_cache
keg.unlink keg.unlink
@ -132,4 +133,21 @@ describe FormulaInstaller do
fi.check_install_sanity fi.check_install_sanity
}.to raise_error(CannotInstallFormulaError) }.to raise_error(CannotInstallFormulaError)
end end
specify "install fails with BuildError when a system() call fails" do
ENV["HOMEBREW_TEST_NO_EXIT_CLEANUP"] = "1"
ENV["FAILBALL_BUILD_ERROR"] = "1"
expect {
temporary_install(Failball.new)
}.to raise_error(BuildError)
end
specify "install fails with a RuntimeError when #install raises" do
ENV["HOMEBREW_TEST_NO_EXIT_CLEANUP"] = "1"
expect {
temporary_install(Failball.new)
}.to raise_error(RuntimeError)
end
end end

View File

@ -0,0 +1,20 @@
class Failball < Formula
def initialize(name = "failball", path = Pathname.new(__FILE__).expand_path, spec = :stable, alias_path: nil)
self.class.instance_eval do
stable.url "file://#{TEST_FIXTURE_DIR}/tarballs/testball-0.1.tbz"
stable.sha256 TESTBALL_SHA256
end
super
end
def install
prefix.install "bin"
prefix.install "libexec"
# This should get marshalled into a BuildError.
system "/usr/bin/false" if ENV["FAILBALL_BUILD_ERROR"]
# This should get marshalled into a RuntimeError.
raise "something that isn't a build error happened!"
end
end

View File

@ -8,7 +8,11 @@ HOMEBREW_BREW_FILE = Pathname.new(ENV["HOMEBREW_BREW_FILE"])
TEST_TMPDIR = ENV.fetch("HOMEBREW_TEST_TMPDIR") do |k| TEST_TMPDIR = ENV.fetch("HOMEBREW_TEST_TMPDIR") do |k|
dir = Dir.mktmpdir("homebrew-tests-", ENV["HOMEBREW_TEMP"] || "/tmp") dir = Dir.mktmpdir("homebrew-tests-", ENV["HOMEBREW_TEMP"] || "/tmp")
at_exit { FileUtils.remove_entry(dir) } at_exit do
# Child processes inherit this at_exit handler, but we don't want them
# to clean TEST_TMPDIR up prematurely (i.e., when they exit early for a test).
FileUtils.remove_entry(dir) unless ENV["HOMEBREW_TEST_NO_EXIT_CLEANUP"]
end
ENV[k] = dir ENV[k] = dir
end end

View File

@ -0,0 +1,21 @@
require "utils/fork"
describe Utils do
describe "#safe_fork" do
it "raises a RuntimeError on an error that isn't ErrorDuringExecution" do
expect {
described_class.safe_fork do
raise "this is an exception in the child"
end
}.to raise_error(RuntimeError)
end
it "raises an ErrorDuringExecution on one in the child" do
expect {
described_class.safe_fork do
safe_system "/usr/bin/false"
end
}.to raise_error(ErrorDuringExecution)
end
end
end

View File

@ -2,6 +2,26 @@ require "fcntl"
require "socket" require "socket"
module Utils module Utils
def self.rewrite_child_error(child_error)
error = if child_error.inner_class == ErrorDuringExecution
ErrorDuringExecution.new(child_error.inner["cmd"],
status: child_error.inner["status"],
output: child_error.inner["output"])
elsif child_error.inner_class == BuildError
# We fill `BuildError#formula` and `BuildError#options` in later,
# when we rescue this in `FormulaInstaller#build`.
BuildError.new(nil, child_error.inner["cmd"],
child_error.inner["args"], child_error.inner["env"])
else
# Everything other error in the child just becomes a RuntimeError.
RuntimeError.new(child_error.message)
end
error.set_backtrace child_error.backtrace
error
end
def self.safe_fork(&_block) def self.safe_fork(&_block)
Dir.mktmpdir("homebrew", HOMEBREW_TEMP) do |tmpdir| Dir.mktmpdir("homebrew", HOMEBREW_TEMP) do |tmpdir|
UNIXServer.open("#{tmpdir}/socket") do |server| UNIXServer.open("#{tmpdir}/socket") do |server|
@ -15,7 +35,18 @@ module Utils
write.fcntl(Fcntl::F_SETFD, Fcntl::FD_CLOEXEC) write.fcntl(Fcntl::F_SETFD, Fcntl::FD_CLOEXEC)
yield yield
rescue Exception => e # rubocop:disable Lint/RescueException rescue Exception => e # rubocop:disable Lint/RescueException
Marshal.dump(e, write) error_hash = JSON.parse e.to_json
# Special case: We need to recreate ErrorDuringExecutions
# for proper error messages and because other code expects
# to rescue them further down.
if e.is_a?(ErrorDuringExecution)
error_hash["cmd"] = e.cmd
error_hash["status"] = e.status.exitstatus
error_hash["output"] = e.output
end
write.puts error_hash.to_json
write.close write.close
exit! exit!
else else
@ -33,10 +64,20 @@ module Utils
socket.close socket.close
end end
write.close write.close
data = read.read # Each line on the error pipe contains a JSON-serialized exception.
# We read the first, since only one is necessary for a failure.
data = read.gets
read.close read.close
Process.wait(pid) unless socket.nil? Process.wait(pid) unless socket.nil?
raise Marshal.load(data) unless data.nil? || data.empty? # rubocop:disable Security/MarshalLoad
if data && !data.empty?
error_hash = JSON.parse(data) unless data.nil? || data.empty?
e = ChildProcessError.new(error_hash)
raise rewrite_child_error(e)
end
raise Interrupt if $CHILD_STATUS.exitstatus == 130 raise Interrupt if $CHILD_STATUS.exitstatus == 130
raise "Forked child process failed: #{$CHILD_STATUS}" unless $CHILD_STATUS.success? raise "Forked child process failed: #{$CHILD_STATUS}" unless $CHILD_STATUS.success?
end end