brew/Library/Homebrew/sandbox.rb

336 lines
10 KiB
Ruby
Raw Normal View History

2024-04-24 20:36:57 -04:00
# typed: strict
# frozen_string_literal: true
require "erb"
require "io/console"
require "pty"
require "tempfile"
2020-08-19 07:02:01 +02:00
# Helper class for running a sub-process inside of a sandboxed environment.
class Sandbox
SANDBOX_EXEC = "/usr/bin/sandbox-exec"
2020-08-19 07:02:01 +02:00
private_constant :SANDBOX_EXEC
2020-10-20 12:03:48 +02:00
sig { returns(T::Boolean) }
def self.available?
2022-11-20 14:27:28 -08:00
false
end
2020-10-20 12:03:48 +02:00
sig { void }
2015-04-13 18:05:15 +08:00
def initialize
2024-04-24 20:36:57 -04:00
@profile = T.let(SandboxProfile.new, SandboxProfile)
@failed = T.let(false, T::Boolean)
2015-04-13 18:05:15 +08:00
end
2024-04-24 20:36:57 -04:00
sig { params(file: T.any(String, Pathname)).void }
def record_log(file)
2024-04-24 20:36:57 -04:00
@logfile = T.let(file, T.nilable(T.any(String, Pathname)))
end
2024-04-24 20:36:57 -04:00
sig { params(allow: T::Boolean, operation: String, filter: T.nilable(String), modifier: T.nilable(String)).void }
def add_rule(allow:, operation:, filter: nil, modifier: nil)
rule = SandboxRule.new(allow:, operation:, filter:, modifier:)
2015-04-13 18:05:15 +08:00
@profile.add_rule(rule)
end
2024-04-24 20:36:57 -04:00
sig { params(path: T.any(String, Pathname), type: Symbol).void }
def allow_write(path:, type: :literal)
add_rule allow: true, operation: "file-write*", filter: path_filter(path, type)
add_rule allow: true, operation: "file-write-setugid", filter: path_filter(path, type)
2024-07-13 15:58:41 -04:00
add_rule allow: true, operation: "file-write-mode", filter: path_filter(path, type)
2015-04-13 18:05:15 +08:00
end
2024-04-24 20:36:57 -04:00
sig { params(path: T.any(String, Pathname), type: Symbol).void }
def deny_write(path:, type: :literal)
add_rule allow: false, operation: "file-write*", filter: path_filter(path, type)
2015-04-13 18:05:15 +08:00
end
2024-04-24 20:36:57 -04:00
sig { params(path: T.any(String, Pathname)).void }
2015-04-13 18:05:15 +08:00
def allow_write_path(path)
2024-04-24 20:36:57 -04:00
allow_write path:, type: :subpath
2015-04-13 18:05:15 +08:00
end
2024-04-24 20:36:57 -04:00
sig { params(path: T.any(String, Pathname)).void }
2015-04-13 18:05:15 +08:00
def deny_write_path(path)
2024-04-24 20:36:57 -04:00
deny_write path:, type: :subpath
2015-04-13 18:05:15 +08:00
end
2024-04-24 20:36:57 -04:00
sig { void }
2015-04-13 18:05:15 +08:00
def allow_write_temp_and_cache
allow_write_path "/private/tmp"
allow_write_path "/private/var/tmp"
2024-04-24 20:36:57 -04:00
allow_write path: "^/private/var/folders/[^/]+/[^/]+/[C,T]/", type: :regex
2015-04-13 18:05:15 +08:00
allow_write_path HOMEBREW_TEMP
allow_write_path HOMEBREW_CACHE
end
2024-04-24 20:36:57 -04:00
sig { void }
2018-07-01 23:35:29 +02:00
def allow_cvs
2020-11-23 17:31:17 +01:00
allow_write_path "#{Dir.home(ENV.fetch("USER"))}/.cvspass"
2018-07-01 23:35:29 +02:00
end
2024-04-24 20:36:57 -04:00
sig { void }
2018-07-01 23:35:29 +02:00
def allow_fossil
2020-11-23 17:31:17 +01:00
allow_write_path "#{Dir.home(ENV.fetch("USER"))}/.fossil"
allow_write_path "#{Dir.home(ENV.fetch("USER"))}/.fossil-journal"
2018-07-01 23:35:29 +02:00
end
2024-04-24 20:36:57 -04:00
sig { params(formula: Formula).void }
2015-04-13 18:05:15 +08:00
def allow_write_cellar(formula)
allow_write_path formula.rack
allow_write_path formula.etc
allow_write_path formula.var
end
# Xcode projects expect access to certain cache/archive dirs.
2024-04-24 20:36:57 -04:00
sig { void }
def allow_write_xcode
2020-11-23 17:31:17 +01:00
allow_write_path "#{Dir.home(ENV.fetch("USER"))}/Library/Developer"
allow_write_path "#{Dir.home(ENV.fetch("USER"))}/Library/Caches/org.swift.swiftpm"
end
2024-04-24 20:36:57 -04:00
sig { params(formula: Formula).void }
2015-04-13 18:05:15 +08:00
def allow_write_log(formula)
2015-04-25 22:07:06 -04:00
allow_write_path formula.logs
end
2024-04-24 20:36:57 -04:00
sig { void }
def deny_write_homebrew_repository
2024-04-24 20:36:57 -04:00
deny_write path: HOMEBREW_BREW_FILE
if HOMEBREW_PREFIX.to_s == HOMEBREW_REPOSITORY.to_s
deny_write_path HOMEBREW_LIBRARY
deny_write_path HOMEBREW_REPOSITORY/".git"
else
deny_write_path 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
2024-04-24 20:36:57 -04:00
sig { params(args: T.any(String, Pathname)).void }
def exec(*args)
seatbelt = Tempfile.new(["homebrew", ".sb"], HOMEBREW_TEMP)
seatbelt.write(@profile.dump)
seatbelt.close
2024-04-24 20:36:57 -04:00
@start = T.let(Time.now, T.nilable(Time))
2020-11-23 17:31:17 +01:00
begin
command = [SANDBOX_EXEC, "-f", seatbelt.path, *args]
2021-08-24 14:46:00 +01:00
# Start sandbox in a pseudoterminal to prevent access of the parent terminal.
2023-04-03 17:34:39 -07:00
PTY.spawn(*command) do |r, w, pid|
2021-09-07 09:44:58 -07:00
# Set the PTY's window size to match the parent terminal.
# Some formula tests are sensitive to the terminal size and fail if this is not set.
winch = proc do |_sig|
w.winsize = if $stdout.tty?
# We can only use IO#winsize if the IO object is a TTY.
$stdout.winsize
else
# Otherwise, default to tput, if available.
# This relies on ncurses rather than the system's ioctl.
[Utils.popen_read("tput", "lines").to_i, Utils.popen_read("tput", "cols").to_i]
2021-09-01 16:06:07 +01:00
end
2021-09-07 09:44:58 -07:00
end
2021-08-25 19:56:12 +01:00
2021-09-07 09:44:58 -07:00
write_to_pty = proc do
# Don't hang if stdin is not able to be used - throw EIO instead.
old_ttin = trap(:TTIN, "IGNORE")
2021-09-07 11:15:06 -07:00
# Update the window size whenever the parent terminal's window size changes.
old_winch = trap(:WINCH, &winch)
winch.call(nil)
stdin_thread = Thread.new do
IO.copy_stream($stdin, w)
rescue Errno::EIO
# stdin is unavailable - move on.
end
2021-08-25 19:56:12 +01:00
2021-09-07 11:15:06 -07:00
r.each_char { |c| print(c) }
2021-08-25 19:56:12 +01:00
2021-09-07 11:15:06 -07:00
Process.wait(pid)
ensure
stdin_thread&.kill
trap(:TTIN, old_ttin)
2021-09-07 11:15:06 -07:00
trap(:WINCH, old_winch)
2021-09-07 09:44:58 -07:00
end
if $stdin.tty?
2021-09-07 19:49:01 -07:00
# If stdin is a TTY, use io.raw to set stdin to a raw, passthrough
# mode while we copy the input/output of the process spawned in the
# PTY. After we've finished copying to/from the PTY process, io.raw
# will restore the stdin TTY to its original state.
begin
# Ignore SIGTTOU as setting raw mode will hang if the process is in the background.
old_ttou = trap(:TTOU, "IGNORE")
$stdin.raw(&write_to_pty)
ensure
trap(:TTOU, old_ttou)
end
else
write_to_pty.call
end
end
raise ErrorDuringExecution.new(command, status: $CHILD_STATUS) unless $CHILD_STATUS.success?
2020-11-23 17:31:17 +01:00
rescue
@failed = true
raise
ensure
seatbelt.unlink
sleep 0.1 # wait for a bit to let syslog catch up the latest events.
2021-08-13 13:49:52 +01:00
syslog_args = [
"-F", "$((Time)(local)) $(Sender)[$(PID)]: $(Message)",
"-k", "Time", "ge", @start.to_i.to_s,
"-k", "Message", "S", "deny",
"-k", "Sender", "kernel",
"-o",
"-k", "Time", "ge", @start.to_i.to_s,
"-k", "Message", "S", "deny",
"-k", "Sender", "sandboxd"
2020-11-23 17:31:17 +01:00
]
logs = Utils.popen_read("syslog", *syslog_args)
# These messages are confusing and non-fatal, so don't report them.
logs = logs.lines.grep_v(/^.*Python\(\d+\) deny file-write.*pyc$/).join
2020-11-23 17:31:17 +01:00
unless logs.empty?
if @logfile
File.open(@logfile, "w") do |log|
log.write logs
log.write "\nWe use time to filter sandbox log. Therefore, unrelated logs may be recorded.\n"
end
2018-03-07 16:14:55 +00:00
end
2020-11-23 17:31:17 +01:00
if @failed && Homebrew::EnvConfig.verbose?
ohai "Sandbox Log", logs
2020-11-23 17:31:17 +01:00
$stdout.flush # without it, brew test-bot would fail to catch the log
end
end
end
end
# @api private
2024-04-24 20:36:57 -04:00
sig { params(path: T.any(String, Pathname), type: Symbol).returns(String) }
2015-04-13 18:05:15 +08:00
def path_filter(path, type)
invalid_char = ['"', "'", "(", ")", "\n", "\\"].find do |c|
path.to_s.include?(c)
end
raise ArgumentError, "Invalid character #{invalid_char} in path: #{path}" if invalid_char
2015-04-13 18:05:15 +08:00
case type
2024-04-24 20:36:57 -04:00
when :regex then "regex #\"#{path}\""
when :subpath then "subpath \"#{expand_realpath(Pathname.new(path))}\""
when :literal then "literal \"#{expand_realpath(Pathname.new(path))}\""
else raise ArgumentError, "Invalid path filter type: #{type}"
end
end
private
sig { params(path: Pathname).returns(Pathname) }
def expand_realpath(path)
raise unless path.absolute?
path.exist? ? path.realpath : expand_realpath(path.parent)/path.basename
end
2024-04-24 20:36:57 -04:00
class SandboxRule
sig { returns(T::Boolean) }
attr_reader :allow
sig { returns(String) }
attr_reader :operation
sig { returns(T.nilable(String)) }
attr_reader :filter
sig { returns(T.nilable(String)) }
attr_reader :modifier
sig { params(allow: T::Boolean, operation: String, filter: T.nilable(String), modifier: T.nilable(String)).void }
def initialize(allow:, operation:, filter:, modifier:)
@allow = allow
@operation = operation
@filter = filter
@modifier = modifier
2015-04-13 18:05:15 +08:00
end
end
2024-04-24 20:36:57 -04:00
private_constant :SandboxRule
2015-04-13 18:05:15 +08:00
2020-08-19 07:02:01 +02:00
# Configuration profile for a sandbox.
class SandboxProfile
SEATBELT_ERB = <<~ERB
(version 1)
(debug deny) ; log all denied operations to /var/log/system.log
<%= rules.join("\n") %>
(allow file-write*
(literal "/dev/ptmx")
(literal "/dev/dtracehelper")
(literal "/dev/null")
(literal "/dev/random")
(literal "/dev/zero")
(regex #"^/dev/fd/[0-9]+$")
(regex #"^/dev/tty[a-z0-9]*$")
)
(deny file-write*) ; deny non-allowlist file write operations
(deny file-write-setugid) ; deny non-allowlist file write SUID/SGID operations
(deny file-write-mode) ; deny non-allowlist file write mode operations
(allow process-exec
(literal "/bin/ps")
(with no-sandbox)
) ; allow certain processes running without sandbox
(allow default) ; allow everything else
2018-07-11 15:17:40 +02:00
ERB
2024-04-24 20:36:57 -04:00
sig { returns(T::Array[String]) }
attr_reader :rules
2020-10-20 12:03:48 +02:00
sig { void }
def initialize
2024-04-24 20:36:57 -04:00
@rules = T.let([], T::Array[String])
end
2024-04-24 20:36:57 -04:00
sig { params(rule: SandboxRule).void }
def add_rule(rule)
s = +"("
2024-04-24 20:36:57 -04:00
s << (rule.allow ? "allow" : "deny")
s << " #{rule.operation}"
s << " (#{rule.filter})" if rule.filter
s << " (with #{rule.modifier})" if rule.modifier
s << ")"
@rules << s.freeze
end
2024-04-24 20:36:57 -04:00
sig { returns(String) }
def dump
ERB.new(SEATBELT_ERB).result(binding)
end
end
2020-08-19 07:02:01 +02:00
private_constant :SandboxProfile
end
2022-11-20 14:27:28 -08:00
require "extend/os/sandbox"