brittle 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.dockerignore +7 -0
- data/Dockerfile.dev +11 -0
- data/LICENSE.txt +21 -0
- data/NOTES.md +42 -0
- data/README.md +147 -0
- data/Rakefile +8 -0
- data/exe/brittle +6 -0
- data/findings/logger-enospc-fd-leak.md +51 -0
- data/harness/csv_write.rb +19 -0
- data/harness/file_write.rb +17 -0
- data/harness/gzip_writer.rb +19 -0
- data/harness/logger.rb +21 -0
- data/harness/net_http.rb +36 -0
- data/harness/syswrite_loop.rb +21 -0
- data/harness/tempfile.rb +22 -0
- data/lib/brittle/arm_state.rb +28 -0
- data/lib/brittle/cli.rb +174 -0
- data/lib/brittle/doctor.rb +28 -0
- data/lib/brittle/injection.rb +84 -0
- data/lib/brittle/journal.rb +88 -0
- data/lib/brittle/marker.rb +10 -0
- data/lib/brittle/matcher.rb +43 -0
- data/lib/brittle/runner.rb +132 -0
- data/lib/brittle/scenario.rb +29 -0
- data/lib/brittle/version.rb +5 -0
- data/lib/brittle.rb +18 -0
- data/script/sweep_catalog +20 -0
- data/spike/benchmark.rb +28 -0
- data/spike/enospc.rb +36 -0
- data/verify/README.md +10 -0
- data/verify/logger_enospc.rb +26 -0
- metadata +104 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
|
|
5
|
+
module Brittle
|
|
6
|
+
module Doctor
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def check(output: $stdout)
|
|
10
|
+
output.puts "ruby: #{RUBY_VERSION}"
|
|
11
|
+
output.puts "kernel: #{Etc.uname.fetch(:release)}"
|
|
12
|
+
output.puts "arch: #{Etc.uname.fetch(:machine)}"
|
|
13
|
+
|
|
14
|
+
unless RUBY_PLATFORM.include?("linux")
|
|
15
|
+
output.puts "seccomp: unsupported (Linux only)"
|
|
16
|
+
return false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
require "seccomp/notify"
|
|
20
|
+
features = Seccomp::Notify.features
|
|
21
|
+
output.puts "features: #{features.map { |name, enabled| "#{name}=#{enabled}" }.join(" ")}"
|
|
22
|
+
features[:user_notif] && features[:continue]
|
|
23
|
+
rescue LoadError, StandardError => error
|
|
24
|
+
output.puts "seccomp: unavailable (#{error.message})"
|
|
25
|
+
false
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Brittle
|
|
4
|
+
class Injection
|
|
5
|
+
attr_reader :syscall, :errno, :return_value, :matcher, :port
|
|
6
|
+
|
|
7
|
+
def initialize(syscall, errno: nil, return_value: nil, at: nil, every: nil, probability: nil, seed: nil,
|
|
8
|
+
when_fd_path: nil, when_path: nil, port: nil)
|
|
9
|
+
raise ArgumentError, "choose exactly one of errno or return_value" if errno.nil? == return_value.nil?
|
|
10
|
+
unless errno.nil? || errno.is_a?(Integer) || errno.is_a?(Class) && errno < SystemCallError
|
|
11
|
+
raise ArgumentError, "errno must be an Errno class or integer"
|
|
12
|
+
end
|
|
13
|
+
raise ArgumentError, "return_value must be an integer" unless return_value.nil? || return_value.is_a?(Integer)
|
|
14
|
+
raise ArgumentError, "port must be between 1 and 65535" unless port.nil? || port.is_a?(Integer) && port.between?(1, 65_535)
|
|
15
|
+
|
|
16
|
+
@syscall = syscall.to_sym
|
|
17
|
+
@errno = errno
|
|
18
|
+
@return_value = return_value
|
|
19
|
+
@matcher = Matcher.new(at:, every:, probability:, seed:)
|
|
20
|
+
@when_fd_path = pattern(when_fd_path)
|
|
21
|
+
@when_path = pattern(when_path)
|
|
22
|
+
@port = port
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def candidate?(request, path: nil)
|
|
26
|
+
return false unless request.syscall == @syscall
|
|
27
|
+
path ||= request.read_string(request.args[1]) if @when_path && %i[openat newfstatat].include?(request.syscall)
|
|
28
|
+
return false if @when_path && !@when_path.match?(path.to_s)
|
|
29
|
+
return false if @when_fd_path && !@when_fd_path.match?(fd_path(request))
|
|
30
|
+
return false if @port && request.read_sockaddr(request.args[1], request.args[2]).ip_port != @port
|
|
31
|
+
|
|
32
|
+
true
|
|
33
|
+
rescue StandardError
|
|
34
|
+
false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def inject!(request)
|
|
38
|
+
@errno ? request.error!(@errno) : request.allow!(@return_value)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def returned
|
|
42
|
+
@errno ? errno_name : @return_value
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def to_h
|
|
46
|
+
{
|
|
47
|
+
syscall: @syscall,
|
|
48
|
+
errno: errno_name,
|
|
49
|
+
return_value: @return_value,
|
|
50
|
+
at: matcher.at.empty? ? nil : (matcher.at.length == 1 ? matcher.at.first : matcher.at),
|
|
51
|
+
every: matcher.every,
|
|
52
|
+
probability: matcher.probability,
|
|
53
|
+
seed: matcher.seed,
|
|
54
|
+
scope: {fd_path: pattern_source(@when_fd_path), path: pattern_source(@when_path), port: @port}.compact,
|
|
55
|
+
realism: realism
|
|
56
|
+
}.compact
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def fd_path(request)
|
|
62
|
+
File.readlink("/proc/#{request.tid}/fd/#{request.args[0]}")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def pattern(value)
|
|
66
|
+
value && (value.is_a?(Regexp) ? value : Regexp.new(value.to_s))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def pattern_source(value) = value&.source
|
|
70
|
+
|
|
71
|
+
def errno_name
|
|
72
|
+
@errno.is_a?(Class) ? @errno.name.delete_prefix("Errno::") : @errno
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def realism
|
|
76
|
+
return {realistic: false, note: "EINTR is injected without delivering a signal"} if errno_name == "EINTR"
|
|
77
|
+
if @return_value
|
|
78
|
+
return {realistic: false, note: "the emulated return value does not perform the partial syscall side effect"}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
{realistic: true}
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "rbconfig"
|
|
7
|
+
|
|
8
|
+
module Brittle
|
|
9
|
+
class Journal
|
|
10
|
+
SCHEMA_VERSION = 1
|
|
11
|
+
|
|
12
|
+
attr_reader :data
|
|
13
|
+
|
|
14
|
+
def initialize(scenario:, harness:, injections:, status:, stdout:, stderr:, timed_out:, arm_state:, artifacts:, command:)
|
|
15
|
+
@scenario = scenario.to_h
|
|
16
|
+
@injections = injections
|
|
17
|
+
@data = {
|
|
18
|
+
schema_version: SCHEMA_VERSION,
|
|
19
|
+
scenario: @scenario,
|
|
20
|
+
environment: {ruby: RUBY_VERSION, kernel: Etc.uname.fetch(:release), arch: Etc.uname.fetch(:machine)},
|
|
21
|
+
harness: harness,
|
|
22
|
+
injections: injections,
|
|
23
|
+
result: result(status, stdout, stderr, timed_out, arm_state, artifacts),
|
|
24
|
+
reproduce: command
|
|
25
|
+
}.compact
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def write(path)
|
|
29
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
30
|
+
File.write(path, JSON.pretty_generate(@data) << "\n")
|
|
31
|
+
path
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def result(status, stdout, stderr, timed_out, arm_state, artifacts)
|
|
37
|
+
expected = stdout.lines.filter_map do |line|
|
|
38
|
+
match = line.match(/\AOK:([^:\n]+):(\d+)(?::([0-9a-f]{64}))?\s*\z/)
|
|
39
|
+
[match[1], {size: Integer(match[2]), sha256: match[3]}] if match
|
|
40
|
+
end.to_h
|
|
41
|
+
artifacts.each do |name, details|
|
|
42
|
+
next unless expected.key?(name)
|
|
43
|
+
|
|
44
|
+
details[:expected_size] = expected[name][:size]
|
|
45
|
+
details[:expected_sha256] = expected[name][:sha256] if expected[name][:sha256]
|
|
46
|
+
details[:truncated] = details[:size] < expected[name][:size]
|
|
47
|
+
details[:mismatched] = details[:size] != expected[name][:size] ||
|
|
48
|
+
expected[name][:sha256] && details[:sha256] != expected[name][:sha256]
|
|
49
|
+
end
|
|
50
|
+
(expected.keys - artifacts.keys).each { |name| artifacts[name] = {missing: true, expected_size: expected[name][:size]} }
|
|
51
|
+
|
|
52
|
+
{
|
|
53
|
+
exit_status: status&.exitstatus,
|
|
54
|
+
signal: status&.termsig,
|
|
55
|
+
stdout: stdout,
|
|
56
|
+
stderr: stderr,
|
|
57
|
+
verdict: verdict(status, stdout, timed_out, arm_state, artifacts),
|
|
58
|
+
fd_leak: arm_state.fd_leak?,
|
|
59
|
+
fd_counts: {
|
|
60
|
+
before: arm_state.fd_before,
|
|
61
|
+
after: arm_state.fd_after,
|
|
62
|
+
delta: arm_state.fd_before && arm_state.fd_after ? arm_state.fd_after - arm_state.fd_before : nil
|
|
63
|
+
}.compact,
|
|
64
|
+
artifacts: artifacts
|
|
65
|
+
}.compact
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def verdict(status, stdout, timed_out, arm_state, artifacts)
|
|
69
|
+
return "hang" if timed_out
|
|
70
|
+
return "leak" if arm_state.fd_leak?
|
|
71
|
+
return "swallowed" if artifacts.values.any? { |artifact| artifact[:missing] } || stdout.start_with?("SWALLOWED:")
|
|
72
|
+
return "corrupt" if artifacts.values.any? { |artifact| artifact[:mismatched] } || stdout.start_with?("CORRUPT:")
|
|
73
|
+
return "crash" unless status&.success?
|
|
74
|
+
return "expected" if stdout.start_with?("EXPECTED:")
|
|
75
|
+
|
|
76
|
+
error = stdout[/^ERR:((?:\w+::)*\w+):/, 1]
|
|
77
|
+
return expected_error?(error) ? "expected" : "crash" if error
|
|
78
|
+
"expected"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def expected_error?(error)
|
|
82
|
+
return false unless error
|
|
83
|
+
|
|
84
|
+
errno = error.delete_prefix("Errno::")
|
|
85
|
+
@injections.any? { |injection| injection[:returned].to_s == errno }
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Brittle
|
|
4
|
+
class Matcher
|
|
5
|
+
attr_reader :at, :count, :every, :probability, :seed
|
|
6
|
+
|
|
7
|
+
def initialize(at: nil, every: nil, probability: nil, seed: nil)
|
|
8
|
+
selectors = [at, every, probability].compact
|
|
9
|
+
raise ArgumentError, "choose only one of at, every, or probability" if selectors.length > 1
|
|
10
|
+
|
|
11
|
+
@at = Array(at || (selectors.empty? ? 1 : nil))
|
|
12
|
+
raise ArgumentError, "at must not be empty" if at && @at.empty?
|
|
13
|
+
validate_positive_integers!(@at, "at") unless @at.empty?
|
|
14
|
+
validate_positive_integers!([every], "every") if every
|
|
15
|
+
unless probability.nil? || probability.is_a?(Numeric) && probability.between?(0, 1)
|
|
16
|
+
raise ArgumentError, "probability must be between 0 and 1"
|
|
17
|
+
end
|
|
18
|
+
raise ArgumentError, "seed is required with probability" if probability && seed.nil?
|
|
19
|
+
|
|
20
|
+
@every = every
|
|
21
|
+
@probability = probability
|
|
22
|
+
@seed = seed
|
|
23
|
+
@random = Random.new(seed) if probability
|
|
24
|
+
@count = 0
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def match?
|
|
28
|
+
@count += 1
|
|
29
|
+
return @at.include?(@count) unless @at.empty?
|
|
30
|
+
return (@count % @every).zero? if @every
|
|
31
|
+
|
|
32
|
+
@random.rand < @probability
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def validate_positive_integers!(values, name)
|
|
38
|
+
return if values.all? { |value| value.is_a?(Integer) && value.positive? }
|
|
39
|
+
|
|
40
|
+
raise ArgumentError, "#{name} must contain positive integers"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "rbconfig"
|
|
6
|
+
require "tmpdir"
|
|
7
|
+
|
|
8
|
+
module Brittle
|
|
9
|
+
class Runner
|
|
10
|
+
def initialize(harness, scenario, timeout: 10, command: nil)
|
|
11
|
+
raise ArgumentError, "timeout must be positive" unless timeout.is_a?(Numeric) && timeout.positive?
|
|
12
|
+
|
|
13
|
+
@harness = File.expand_path(harness)
|
|
14
|
+
@scenario = scenario
|
|
15
|
+
@timeout = timeout
|
|
16
|
+
@command = command
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def run
|
|
20
|
+
require "seccomp/notify"
|
|
21
|
+
raise Error, "seccomp user notification with CONTINUE is unavailable" unless Seccomp::Notify.features[:continue]
|
|
22
|
+
raise ArgumentError, "harness not found: #{@harness}" unless File.file?(@harness)
|
|
23
|
+
|
|
24
|
+
Dir.mktmpdir("brittle-") { |sandbox| supervise(sandbox) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def supervise(sandbox)
|
|
30
|
+
stdout_reader, stdout_writer = IO.pipe
|
|
31
|
+
stderr_reader, stderr_writer = IO.pipe
|
|
32
|
+
syscalls = @scenario.notify_syscalls
|
|
33
|
+
policy = Seccomp::Notify::Policy.new { notify(*syscalls) }
|
|
34
|
+
supervisor = Seccomp::Notify.spawn(policy) do
|
|
35
|
+
stdout_reader.close
|
|
36
|
+
stderr_reader.close
|
|
37
|
+
STDOUT.reopen(stdout_writer)
|
|
38
|
+
STDERR.reopen(stderr_writer)
|
|
39
|
+
stdout_writer.close
|
|
40
|
+
stderr_writer.close
|
|
41
|
+
ENV["BRITTLE_SANDBOX"] = sandbox
|
|
42
|
+
ports = @scenario.injections.filter_map(&:port).uniq
|
|
43
|
+
ENV["BRITTLE_PORT"] = ports.first.to_s if ports.one?
|
|
44
|
+
exec(RbConfig.ruby, "-I", File.expand_path("..", __dir__), @harness)
|
|
45
|
+
end
|
|
46
|
+
stdout_writer.close
|
|
47
|
+
stderr_writer.close
|
|
48
|
+
stdout_thread = Thread.new { stdout_reader.read }
|
|
49
|
+
stderr_thread = Thread.new { stderr_reader.read }
|
|
50
|
+
|
|
51
|
+
arm_state = ArmState.new
|
|
52
|
+
injections = []
|
|
53
|
+
errors = []
|
|
54
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
55
|
+
handler = request_handler(arm_state, injections, started_at)
|
|
56
|
+
syscalls.each { |syscall| supervisor.on(syscall, &handler) }
|
|
57
|
+
supervisor.on_error { |error, _request| errors << error.full_message(highlight: false, order: :top) }
|
|
58
|
+
|
|
59
|
+
timed_out = false
|
|
60
|
+
watchdog = Thread.new do
|
|
61
|
+
sleep(@timeout)
|
|
62
|
+
timed_out = true
|
|
63
|
+
Process.kill("KILL", supervisor.target_pid)
|
|
64
|
+
rescue Errno::ESRCH
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
begin
|
|
68
|
+
status = supervisor.run
|
|
69
|
+
ensure
|
|
70
|
+
watchdog.kill
|
|
71
|
+
watchdog.join
|
|
72
|
+
end
|
|
73
|
+
stderr = stderr_thread.value
|
|
74
|
+
stderr = "#{stderr}#{errors.join("\n")}" unless errors.empty?
|
|
75
|
+
Journal.new(
|
|
76
|
+
scenario: @scenario,
|
|
77
|
+
harness: @harness,
|
|
78
|
+
injections:,
|
|
79
|
+
status:,
|
|
80
|
+
stdout: stdout_thread.value,
|
|
81
|
+
stderr:,
|
|
82
|
+
timed_out:,
|
|
83
|
+
arm_state:,
|
|
84
|
+
artifacts: collect_artifacts(sandbox),
|
|
85
|
+
command: @command
|
|
86
|
+
)
|
|
87
|
+
ensure
|
|
88
|
+
[stdout_reader, stdout_writer, stderr_reader, stderr_writer].compact.each { |io| io.close unless io.closed? }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def request_handler(arm_state, injections, started_at)
|
|
92
|
+
lambda do |request|
|
|
93
|
+
path = request.read_string(request.args[1]) if request.syscall == MARKER_SYSCALL
|
|
94
|
+
transition = arm_state.transition(path, fd_count: fd_count(request.tid))
|
|
95
|
+
if transition || !arm_state.armed?
|
|
96
|
+
request.continue!(unsafe: true)
|
|
97
|
+
next
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
injection = @scenario.match(request, path:)
|
|
101
|
+
unless injection
|
|
102
|
+
request.continue!(unsafe: true)
|
|
103
|
+
next
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
injections << {
|
|
107
|
+
seq: injection.matcher.count,
|
|
108
|
+
syscall: request.syscall,
|
|
109
|
+
tid: request.tid,
|
|
110
|
+
returned: injection.returned,
|
|
111
|
+
at_ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
|
|
112
|
+
}
|
|
113
|
+
injection.inject!(request)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def fd_count(tid)
|
|
118
|
+
Dir.children("/proc/#{tid}/fd").length
|
|
119
|
+
rescue SystemCallError
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def collect_artifacts(sandbox)
|
|
124
|
+
Dir.glob("#{sandbox}/**/*", File::FNM_DOTMATCH).filter_map do |path|
|
|
125
|
+
next unless File.file?(path)
|
|
126
|
+
|
|
127
|
+
name = path.delete_prefix("#{sandbox}/")
|
|
128
|
+
[name, {size: File.size(path), sha256: Digest::SHA256.file(path).hexdigest}]
|
|
129
|
+
end.to_h
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Brittle
|
|
4
|
+
class Scenario
|
|
5
|
+
attr_reader :injections
|
|
6
|
+
|
|
7
|
+
def initialize(&block)
|
|
8
|
+
@injections = []
|
|
9
|
+
instance_eval(&block) if block
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def inject(syscall, **options)
|
|
13
|
+
@injections << Injection.new(syscall, **options)
|
|
14
|
+
self
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def notify_syscalls
|
|
18
|
+
(@injections.map(&:syscall) << MARKER_SYSCALL).uniq
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def match(request, path: nil)
|
|
22
|
+
@injections.filter_map { |injection| injection if injection.candidate?(request, path:) && injection.matcher.match? }.first
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_h
|
|
26
|
+
@injections.length == 1 ? @injections.first.to_h : {injections: @injections.map(&:to_h)}
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/brittle.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "brittle/version"
|
|
4
|
+
require_relative "brittle/marker"
|
|
5
|
+
require_relative "brittle/matcher"
|
|
6
|
+
require_relative "brittle/injection"
|
|
7
|
+
require_relative "brittle/scenario"
|
|
8
|
+
require_relative "brittle/arm_state"
|
|
9
|
+
require_relative "brittle/journal"
|
|
10
|
+
require_relative "brittle/runner"
|
|
11
|
+
|
|
12
|
+
module Brittle
|
|
13
|
+
class Error < StandardError; end
|
|
14
|
+
|
|
15
|
+
def self.scenario(&block)
|
|
16
|
+
Scenario.new(&block)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
range=${AT_RANGE:-1..20}
|
|
5
|
+
file_harnesses=(file_write tempfile logger csv_write gzip_writer)
|
|
6
|
+
|
|
7
|
+
for harness in "${file_harnesses[@]}"; do
|
|
8
|
+
for errno in ENOSPC EDQUOT; do
|
|
9
|
+
bundle exec exe/brittle sweep "harness/$harness.rb" --syscall write --errno "$errno" --fd-path /brittle- --at "$range"
|
|
10
|
+
done
|
|
11
|
+
for errno in EMFILE ENOENT; do
|
|
12
|
+
bundle exec exe/brittle sweep "harness/$harness.rb" --syscall openat --errno "$errno" --path /brittle- --at "$range"
|
|
13
|
+
done
|
|
14
|
+
done
|
|
15
|
+
|
|
16
|
+
bundle exec exe/brittle sweep harness/gzip_writer.rb --syscall read --errno EIO --at "$range"
|
|
17
|
+
for errno in ECONNREFUSED ETIMEDOUT EINTR; do
|
|
18
|
+
bundle exec exe/brittle sweep harness/net_http.rb --syscall connect --errno "$errno" --at "$range"
|
|
19
|
+
done
|
|
20
|
+
bundle exec exe/brittle sweep harness/syswrite_loop.rb --syscall write --return-value 10 --at "$range"
|
data/spike/benchmark.rb
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "seccomp/notify"
|
|
4
|
+
|
|
5
|
+
iterations = Integer(ENV.fetch("ITERATIONS", "10000"))
|
|
6
|
+
measure = lambda do |&block|
|
|
7
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
8
|
+
block.call
|
|
9
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
baseline = measure.call do
|
|
13
|
+
File.open("/dev/null", "w") { |file| iterations.times { file.syswrite("x") } }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
policy = Seccomp::Notify::Policy.new { notify :write }
|
|
17
|
+
notified = measure.call do
|
|
18
|
+
supervisor = Seccomp::Notify.spawn(policy) do
|
|
19
|
+
File.open("/dev/null", "w") { |file| iterations.times { file.syswrite("x") } }
|
|
20
|
+
end
|
|
21
|
+
supervisor.on(:write) { |request| request.continue! }
|
|
22
|
+
supervisor.run
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
puts "iterations=#{iterations}"
|
|
26
|
+
puts "baseline_ms=#{(baseline * 1000).round(2)}"
|
|
27
|
+
puts "notified_ms=#{(notified * 1000).round(2)}"
|
|
28
|
+
puts "overhead_us_per_write=#{((notified - baseline) * 1_000_000 / iterations).round(2)}"
|
data/spike/enospc.rb
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "seccomp/notify"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
def inject(response)
|
|
7
|
+
reader, writer = IO.pipe
|
|
8
|
+
policy = Seccomp::Notify::Policy.new { notify :write }
|
|
9
|
+
Dir.mktmpdir("brittle-spike-") do |directory|
|
|
10
|
+
path = File.join(directory, "out.txt")
|
|
11
|
+
supervisor = Seccomp::Notify.spawn(policy) do
|
|
12
|
+
reader.close
|
|
13
|
+
result = begin
|
|
14
|
+
written = File.write(path, "x" * 100)
|
|
15
|
+
"OK:reported=#{written}:actual=#{File.size(path)}"
|
|
16
|
+
rescue => error
|
|
17
|
+
"ERR:#{error.class}:#{error.message}"
|
|
18
|
+
end
|
|
19
|
+
writer.write(result)
|
|
20
|
+
writer.close
|
|
21
|
+
end
|
|
22
|
+
writer.close
|
|
23
|
+
count = 0
|
|
24
|
+
supervisor.on(:write) do |request|
|
|
25
|
+
count += 1
|
|
26
|
+
count == 1 ? response.call(request) : request.continue!
|
|
27
|
+
end
|
|
28
|
+
supervisor.run
|
|
29
|
+
reader.read
|
|
30
|
+
end
|
|
31
|
+
ensure
|
|
32
|
+
reader&.close
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
puts inject(->(request) { request.error!(Errno::ENOSPC) })
|
|
36
|
+
puts inject(->(request) { request.allow!(10) })
|
data/verify/README.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Real-condition verification
|
|
2
|
+
|
|
3
|
+
Fault injection finds candidates; these checks reproduce them without seccomp before they are
|
|
4
|
+
reported upstream.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
docker run --rm --tmpfs /full:rw,size=64k \
|
|
8
|
+
-v "$PWD:/app" -w /app brittle-dev \
|
|
9
|
+
bundle exec ruby verify/logger_enospc.rb /full
|
|
10
|
+
```
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
directory = ARGV.fetch(0) { abort "usage: ruby verify/logger_enospc.rb FULL_FILESYSTEM" }
|
|
6
|
+
log_path = File.join(directory, "app.log")
|
|
7
|
+
fill_path = File.join(directory, "fill")
|
|
8
|
+
|
|
9
|
+
begin
|
|
10
|
+
File.open(fill_path, "wb") { |file| loop { file.syswrite("x" * 4096) } }
|
|
11
|
+
abort "failed to fill filesystem"
|
|
12
|
+
rescue Errno::ENOSPC
|
|
13
|
+
attempts = 5
|
|
14
|
+
before = Dir.children("/proc/self/fd").length
|
|
15
|
+
failures = attempts.times.count do
|
|
16
|
+
Logger.new(log_path)
|
|
17
|
+
false
|
|
18
|
+
rescue Errno::ENOSPC
|
|
19
|
+
File.unlink(log_path) if File.exist?(log_path)
|
|
20
|
+
true
|
|
21
|
+
end
|
|
22
|
+
after = Dir.children("/proc/self/fd").length
|
|
23
|
+
|
|
24
|
+
puts "logger=#{Logger::VERSION} failures=#{failures} fd_before=#{before} fd_after=#{after} delta=#{after - before}"
|
|
25
|
+
abort "expected one leaked fd per failed initialization" unless failures == attempts && after - before == attempts
|
|
26
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: brittle
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Yudai Takada
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: seccomp-notify
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.3'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.3'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: csv
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '3.3'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '3.3'
|
|
40
|
+
description: Brittle injects errno responses into selected Linux syscalls using seccomp
|
|
41
|
+
user notifications.
|
|
42
|
+
email:
|
|
43
|
+
- t.yudai92@gmail.com
|
|
44
|
+
executables:
|
|
45
|
+
- brittle
|
|
46
|
+
extensions: []
|
|
47
|
+
extra_rdoc_files: []
|
|
48
|
+
files:
|
|
49
|
+
- ".dockerignore"
|
|
50
|
+
- Dockerfile.dev
|
|
51
|
+
- LICENSE.txt
|
|
52
|
+
- NOTES.md
|
|
53
|
+
- README.md
|
|
54
|
+
- Rakefile
|
|
55
|
+
- exe/brittle
|
|
56
|
+
- findings/logger-enospc-fd-leak.md
|
|
57
|
+
- harness/csv_write.rb
|
|
58
|
+
- harness/file_write.rb
|
|
59
|
+
- harness/gzip_writer.rb
|
|
60
|
+
- harness/logger.rb
|
|
61
|
+
- harness/net_http.rb
|
|
62
|
+
- harness/syswrite_loop.rb
|
|
63
|
+
- harness/tempfile.rb
|
|
64
|
+
- lib/brittle.rb
|
|
65
|
+
- lib/brittle/arm_state.rb
|
|
66
|
+
- lib/brittle/cli.rb
|
|
67
|
+
- lib/brittle/doctor.rb
|
|
68
|
+
- lib/brittle/injection.rb
|
|
69
|
+
- lib/brittle/journal.rb
|
|
70
|
+
- lib/brittle/marker.rb
|
|
71
|
+
- lib/brittle/matcher.rb
|
|
72
|
+
- lib/brittle/runner.rb
|
|
73
|
+
- lib/brittle/scenario.rb
|
|
74
|
+
- lib/brittle/version.rb
|
|
75
|
+
- script/sweep_catalog
|
|
76
|
+
- spike/benchmark.rb
|
|
77
|
+
- spike/enospc.rb
|
|
78
|
+
- verify/README.md
|
|
79
|
+
- verify/logger_enospc.rb
|
|
80
|
+
homepage: https://github.com/ydah/brittle
|
|
81
|
+
licenses:
|
|
82
|
+
- MIT
|
|
83
|
+
metadata:
|
|
84
|
+
allowed_push_host: https://rubygems.org
|
|
85
|
+
source_code_uri: https://github.com/ydah/brittle
|
|
86
|
+
rubygems_mfa_required: 'true'
|
|
87
|
+
rdoc_options: []
|
|
88
|
+
require_paths:
|
|
89
|
+
- lib
|
|
90
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
91
|
+
requirements:
|
|
92
|
+
- - ">="
|
|
93
|
+
- !ruby/object:Gem::Version
|
|
94
|
+
version: 3.2.0
|
|
95
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
96
|
+
requirements:
|
|
97
|
+
- - ">="
|
|
98
|
+
- !ruby/object:Gem::Version
|
|
99
|
+
version: '0'
|
|
100
|
+
requirements: []
|
|
101
|
+
rubygems_version: 4.0.19
|
|
102
|
+
specification_version: 4
|
|
103
|
+
summary: Deterministic syscall fault injection for Ruby programs
|
|
104
|
+
test_files: []
|