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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 7b2628f2dc42bcd5afbb6586e92515cdbeee446aaa60fbbb4594321602068021
|
|
4
|
+
data.tar.gz: fbf4abf31d9875ddd1265ce49e3be162d7670d103ed9c06390fbf689a2355959
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 4e638214991f9572b5c270a4261de60e631720e76b331177c2d1415fe077cd8cbba82b82fd7025b8157b3eb260891916c196faf0a0b1a96e8901c4eddcb15fff
|
|
7
|
+
data.tar.gz: 5d162c0bcc823769ac498e59ace9d1d7c510184ff7ae425778e47ccd5a1bfca9cee9c75f5a1087eafee69632f1da9814f917fc2c184296142955c92c664a919c
|
data/.dockerignore
ADDED
data/Dockerfile.dev
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/NOTES.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Brittle experiment notes
|
|
2
|
+
|
|
3
|
+
Measurements below were taken on 2026-09-09 in the development container: Ruby 4.0.6,
|
|
4
|
+
Linux 6.8.0-117-generic, and aarch64.
|
|
5
|
+
|
|
6
|
+
| Check | Result |
|
|
7
|
+
| --- | --- |
|
|
8
|
+
| `error!` argument | `Errno::ENOSPC` is accepted and Ruby receives `Errno::ENOSPC`. |
|
|
9
|
+
| `allow!(10)` | Ruby reports the full 100-byte `File.write` as successful, but the file contains 90 bytes. The emulated first 10 bytes are not written. |
|
|
10
|
+
| marker syscall | `File.exist?` emits `newfstatat(AT_FDCWD, path, ...)` for both marker paths. |
|
|
11
|
+
| target output | Output while armed is also a `write`; every harness disarms before printing its result. |
|
|
12
|
+
| notify overhead | 10,000 `/dev/null` writes took 2.32 ms normally and 316.89 ms when continued through notifications: about 31.46 us per write. |
|
|
13
|
+
| kernel requirement | Pass-through notifications require `continue!`, so Brittle requires Linux 5.5 or newer despite errno responses being available since 5.0. |
|
|
14
|
+
|
|
15
|
+
`allow!(n)` is useful for observing callers' response to a short return, but it is not a
|
|
16
|
+
faithful partial-write emulator because the reported prefix is dropped. Such scenarios are marked
|
|
17
|
+
`realistic: false` and must not be reported as an upstream data-corruption bug without reproduction
|
|
18
|
+
under a real short write.
|
|
19
|
+
|
|
20
|
+
## Finding: Logger leaks a descriptor when its header write fails
|
|
21
|
+
|
|
22
|
+
`brittle sweep harness/logger.rb --syscall write --errno ENOSPC --at 1..8` found a one-fd
|
|
23
|
+
increase at `at=1`. The generated journal recorded `fd_before=7`, `fd_after=8`, and `verdict=leak`.
|
|
24
|
+
|
|
25
|
+
The behavior reproduces without seccomp on a full 64 KiB tmpfs. Five failed `Logger.new` calls
|
|
26
|
+
increase the descriptor count from 6 to 11. `Logger::LogDevice#create_logfile` opens and locks the
|
|
27
|
+
new file, then lets a header-write exception escape without closing the local `logdev`.
|
|
28
|
+
|
|
29
|
+
See [`findings/logger-enospc-fd-leak.md`](findings/logger-enospc-fd-leak.md) for the upstream report
|
|
30
|
+
draft and [`verify/logger_enospc.rb`](verify/logger_enospc.rb) for the real-condition check.
|
|
31
|
+
|
|
32
|
+
## Catalog sweep
|
|
33
|
+
|
|
34
|
+
The full `script/sweep_catalog` run completed all 500 deterministic cases (`at=1..20`):
|
|
35
|
+
|
|
36
|
+
- Logger header writes leaked one fd for both `ENOSPC` and `EDQUOT` at occurrence 1.
|
|
37
|
+
- Logger swallowed the same errors for the log-message write at occurrence 2, leaving the expected
|
|
38
|
+
message absent; the harness classified this as `corrupt`.
|
|
39
|
+
- `Net::HTTP` translated `connect` `ETIMEDOUT` into `Net::OpenTimeout`, which its harness accepts.
|
|
40
|
+
- Synthetic short `write` corrupted output and signal-less `EINTR` led to `ENOTCONN`; both scenarios
|
|
41
|
+
are marked unrealistic and were excluded from upstream findings.
|
|
42
|
+
- All remaining scoped cases were classified as expected.
|
data/README.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# Brittle
|
|
2
|
+
|
|
3
|
+
Brittle deterministically injects errno responses and synthetic return values into syscalls made
|
|
4
|
+
by Ruby programs. It uses Linux seccomp user notifications through
|
|
5
|
+
[`seccomp-notify`](https://github.com/ydah/seccomp-notify).
|
|
6
|
+
|
|
7
|
+
> [!WARNING]
|
|
8
|
+
> Brittle deliberately breaks its target process. Use it only in isolated development and test
|
|
9
|
+
> environments, never in production.
|
|
10
|
+
|
|
11
|
+
## Requirements
|
|
12
|
+
|
|
13
|
+
- Linux 5.5 or newer
|
|
14
|
+
- x86_64 or aarch64
|
|
15
|
+
- Ruby 3.2 or newer
|
|
16
|
+
- A container or host that permits seccomp user notifications
|
|
17
|
+
|
|
18
|
+
Linux 5.0 can return synthetic errno values, but Brittle also needs `continue!` to pass unmatched
|
|
19
|
+
syscalls through, which requires Linux 5.5.
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
Install the gem after its first release:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
gem install brittle
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
For development, build the included container once and run commands through `bin/dev`:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
docker build -f Dockerfile.dev -t brittle-dev .
|
|
33
|
+
bin/dev bundle exec exe/brittle doctor
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
Harnesses call `Brittle.arm!` immediately before the operation under test and always call
|
|
39
|
+
`Brittle.disarm!` afterward. Startup, requires, and result output therefore pass through without
|
|
40
|
+
fault injection.
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
require "brittle/marker"
|
|
44
|
+
|
|
45
|
+
result = nil
|
|
46
|
+
Brittle.arm!
|
|
47
|
+
begin
|
|
48
|
+
File.write(ENV.fetch("BRITTLE_SANDBOX") + "/out.txt", "hello")
|
|
49
|
+
result = "OK:out.txt:5"
|
|
50
|
+
rescue => error
|
|
51
|
+
result = "ERR:#{error.class}:#{error.message}"
|
|
52
|
+
ensure
|
|
53
|
+
Brittle.disarm!
|
|
54
|
+
end
|
|
55
|
+
puts result
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Inject `ENOSPC` into the first armed `write`:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
brittle run harness/file_write.rb --inject write --errno ENOSPC --at 1
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Search every occurrence in a deterministic range:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
brittle sweep harness/logger.rb \
|
|
68
|
+
--syscall write --errno ENOSPC --fd-path /brittle- --at 1..20
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Other selectors and scopes are available:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
# Multiple exact occurrences
|
|
75
|
+
brittle run harness/file_write.rb --inject write --errno EDQUOT --at 3,7,11
|
|
76
|
+
|
|
77
|
+
# Every fifth matching call
|
|
78
|
+
brittle run harness/file_write.rb --inject write --errno ENOSPC --every 5
|
|
79
|
+
|
|
80
|
+
# Seeded probability
|
|
81
|
+
brittle run harness/file_write.rb \
|
|
82
|
+
--inject write --errno ENOSPC --probability 0.1 --seed 42
|
|
83
|
+
|
|
84
|
+
# Path and destination scopes
|
|
85
|
+
brittle run harness/file_write.rb --inject openat --errno EMFILE --path /brittle-
|
|
86
|
+
brittle run harness/net_http.rb --inject connect --errno ECONNREFUSED --port 8080
|
|
87
|
+
|
|
88
|
+
# Synthetic successful return without executing the syscall
|
|
89
|
+
brittle run harness/syswrite_loop.rb --inject write --return-value 10 --at 1
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Every run writes a JSON journal under `results/`. Journals include the scenario, environment,
|
|
93
|
+
injection events, stdout/stderr, artifact sizes and SHA-256 hashes, fd counts, and verdict. They can
|
|
94
|
+
be summarized or converted back into a command:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
brittle report results/
|
|
98
|
+
brittle reproduce results/file_write-write-ENOSPC-1-....json
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Verdicts are `expected`, `crash`, `swallowed`, `corrupt`, `leak`, or `hang`. A harness may print
|
|
102
|
+
`EXPECTED:`, `CORRUPT:`, or `SWALLOWED:` when it has a target-specific oracle that cannot be
|
|
103
|
+
inferred from the injected errno alone.
|
|
104
|
+
|
|
105
|
+
## Scenario DSL
|
|
106
|
+
|
|
107
|
+
The same matching rules are available to Ruby callers:
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
scenario = Brittle.scenario do
|
|
111
|
+
inject :write, errno: Errno::ENOSPC, at: 3, when_fd_path: /brittle-/
|
|
112
|
+
inject :connect, errno: Errno::ECONNREFUSED, port: 8080, every: 1
|
|
113
|
+
end
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Reality checks
|
|
117
|
+
|
|
118
|
+
Fault injection identifies candidates; it does not prove that a result is an upstream bug. Re-run
|
|
119
|
+
every finding under the real failure condition before reporting it. The checks under `verify/`
|
|
120
|
+
demonstrate this workflow.
|
|
121
|
+
|
|
122
|
+
- `allow!(n)` reports success without performing the syscall. For `write`, the reported prefix is
|
|
123
|
+
not written, so this is not a faithful partial-write emulator. Such journals say
|
|
124
|
+
`realistic: false`.
|
|
125
|
+
- An injected `EINTR` has no accompanying signal. Brittle marks that combination as unrealistic.
|
|
126
|
+
- Pointer-based path and port scopes are for test targeting, not security decisions; target memory
|
|
127
|
+
may change before a continued syscall executes.
|
|
128
|
+
|
|
129
|
+
See [NOTES.md](NOTES.md) for measurements and the first real-condition finding.
|
|
130
|
+
|
|
131
|
+
## Development
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
bundle install
|
|
135
|
+
bundle exec rake
|
|
136
|
+
|
|
137
|
+
# Linux integration tests
|
|
138
|
+
docker build -f Dockerfile.dev -t brittle-dev .
|
|
139
|
+
bin/dev bundle exec rake
|
|
140
|
+
|
|
141
|
+
# Short catalog check; omit AT_RANGE for the full 1..20 sweep
|
|
142
|
+
AT_RANGE=1..2 bin/dev script/sweep_catalog
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## License
|
|
146
|
+
|
|
147
|
+
Brittle is available under the [MIT License](LICENSE.txt).
|
data/Rakefile
ADDED
data/exe/brittle
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# `Logger.new` leaks a file descriptor when writing the log header fails
|
|
2
|
+
|
|
3
|
+
## Environment
|
|
4
|
+
|
|
5
|
+
- logger 1.7.0
|
|
6
|
+
- Ruby 4.0.6
|
|
7
|
+
- Linux 6.8.0-117-generic, aarch64
|
|
8
|
+
|
|
9
|
+
## Reproduction
|
|
10
|
+
|
|
11
|
+
This repository includes a check that fills a small tmpfs, calls `Logger.new` five times, and
|
|
12
|
+
counts `/proc/self/fd` before and after. It does not use seccomp or fault injection.
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
docker run --rm --tmpfs /full:rw,size=64k \
|
|
16
|
+
-v "$PWD:/app" -w /app brittle-dev \
|
|
17
|
+
bundle exec ruby verify/logger_enospc.rb /full
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Actual output:
|
|
21
|
+
|
|
22
|
+
```text
|
|
23
|
+
logger=1.7.0 failures=5 fd_before=6 fd_after=11 delta=5
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Expected: each descriptor opened by a failed initialization is closed.
|
|
27
|
+
|
|
28
|
+
Actual: each failed initialization leaves one descriptor open until garbage collection.
|
|
29
|
+
|
|
30
|
+
## Cause
|
|
31
|
+
|
|
32
|
+
`Logger::LogDevice#create_logfile` stores the newly opened file only in its local `logdev`. If
|
|
33
|
+
`add_log_header(logdev)` raises `Errno::ENOSPC`, initialization exits before the file reaches
|
|
34
|
+
`@dev`, so callers cannot close it.
|
|
35
|
+
|
|
36
|
+
## Proposed fix
|
|
37
|
+
|
|
38
|
+
Close the local file for every exception other than the existing `EEXIST` retry:
|
|
39
|
+
|
|
40
|
+
```diff
|
|
41
|
+
rescue Errno::EEXIST
|
|
42
|
+
# file is created by another process
|
|
43
|
+
open_logfile(filename)
|
|
44
|
+
+ rescue Exception
|
|
45
|
+
+ logdev&.close
|
|
46
|
+
+ raise
|
|
47
|
+
end
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The corresponding regression test should make `add_log_header` raise, assert that the exception
|
|
51
|
+
is preserved, and assert that the opened file is closed.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "brittle/marker"
|
|
4
|
+
require "csv"
|
|
5
|
+
require "digest"
|
|
6
|
+
|
|
7
|
+
rows = [["name", "value"], ["brittle", "1"], ["ruby", "2"]]
|
|
8
|
+
payload = rows.map { |row| CSV.generate_line(row) }.join
|
|
9
|
+
path = File.join(ENV.fetch("BRITTLE_SANDBOX"), "data.csv")
|
|
10
|
+
Brittle.arm!
|
|
11
|
+
result = begin
|
|
12
|
+
CSV.open(path, "w") { |csv| rows.each { |row| csv << row } }
|
|
13
|
+
"OK:data.csv:#{payload.bytesize}:#{Digest::SHA256.hexdigest(payload)}"
|
|
14
|
+
rescue => error
|
|
15
|
+
"ERR:#{error.class}:#{error.message}"
|
|
16
|
+
ensure
|
|
17
|
+
Brittle.disarm!
|
|
18
|
+
end
|
|
19
|
+
puts result
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "brittle/marker"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
payload = "file-write\n" * 4096
|
|
7
|
+
path = File.join(ENV.fetch("BRITTLE_SANDBOX"), "file-write.txt")
|
|
8
|
+
Brittle.arm!
|
|
9
|
+
result = begin
|
|
10
|
+
File.write(path, payload)
|
|
11
|
+
"OK:file-write.txt:#{payload.bytesize}:#{Digest::SHA256.hexdigest(payload)}"
|
|
12
|
+
rescue => error
|
|
13
|
+
"ERR:#{error.class}:#{error.message}"
|
|
14
|
+
ensure
|
|
15
|
+
Brittle.disarm!
|
|
16
|
+
end
|
|
17
|
+
puts result
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "brittle/marker"
|
|
4
|
+
require "zlib"
|
|
5
|
+
|
|
6
|
+
payload = "gzip\n" * 4096
|
|
7
|
+
path = File.join(ENV.fetch("BRITTLE_SANDBOX"), "data.gz")
|
|
8
|
+
Brittle.arm!
|
|
9
|
+
result = begin
|
|
10
|
+
Zlib::GzipWriter.open(path) { |gzip| gzip.write(payload) }
|
|
11
|
+
raise "gzip content mismatch" unless Zlib::GzipReader.open(path, &:read) == payload
|
|
12
|
+
|
|
13
|
+
"OK:gzip"
|
|
14
|
+
rescue => error
|
|
15
|
+
"ERR:#{error.class}:#{error.message}"
|
|
16
|
+
ensure
|
|
17
|
+
Brittle.disarm!
|
|
18
|
+
end
|
|
19
|
+
puts result
|
data/harness/logger.rb
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "brittle/marker"
|
|
4
|
+
require "logger"
|
|
5
|
+
|
|
6
|
+
logger = nil
|
|
7
|
+
path = File.join(ENV.fetch("BRITTLE_SANDBOX"), "app.log")
|
|
8
|
+
message = "hello from brittle"
|
|
9
|
+
Brittle.arm!
|
|
10
|
+
result = begin
|
|
11
|
+
logger = Logger.new(path)
|
|
12
|
+
logger.info(message)
|
|
13
|
+
logger.close
|
|
14
|
+
File.read(path).include?(message) ? "OK:logger" : "CORRUPT:app.log:missing message"
|
|
15
|
+
rescue => error
|
|
16
|
+
"ERR:#{error.class}:#{error.message}"
|
|
17
|
+
ensure
|
|
18
|
+
logger&.close
|
|
19
|
+
Brittle.disarm!
|
|
20
|
+
end
|
|
21
|
+
puts result
|
data/harness/net_http.rb
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "brittle/marker"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "socket"
|
|
6
|
+
|
|
7
|
+
server = TCPServer.new("127.0.0.1", Integer(ENV.fetch("BRITTLE_PORT", "0")))
|
|
8
|
+
port = server.local_address.ip_port
|
|
9
|
+
thread = Thread.new do
|
|
10
|
+
socket = server.accept
|
|
11
|
+
loop do
|
|
12
|
+
line = socket.gets
|
|
13
|
+
break if line.nil? || line == "\r\n"
|
|
14
|
+
end
|
|
15
|
+
socket.write("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nOK")
|
|
16
|
+
socket.close
|
|
17
|
+
rescue IOError, Errno::EBADF
|
|
18
|
+
nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
Brittle.arm!
|
|
22
|
+
result = begin
|
|
23
|
+
body = Net::HTTP.get(URI("http://127.0.0.1:#{port}/"))
|
|
24
|
+
raise "HTTP content mismatch" unless body == "OK"
|
|
25
|
+
|
|
26
|
+
"OK:http"
|
|
27
|
+
rescue Net::OpenTimeout => error
|
|
28
|
+
"EXPECTED:#{error.class}:#{error.message}"
|
|
29
|
+
rescue => error
|
|
30
|
+
"ERR:#{error.class}:#{error.message}"
|
|
31
|
+
ensure
|
|
32
|
+
Brittle.disarm!
|
|
33
|
+
end
|
|
34
|
+
server.close
|
|
35
|
+
thread.join(1)
|
|
36
|
+
puts result
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "brittle/marker"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
payload = "syswrite\n" * 4096
|
|
7
|
+
path = File.join(ENV.fetch("BRITTLE_SANDBOX"), "syswrite.txt")
|
|
8
|
+
file = File.open(path, "w")
|
|
9
|
+
Brittle.arm!
|
|
10
|
+
result = begin
|
|
11
|
+
remaining = payload
|
|
12
|
+
remaining = remaining.byteslice(file.syswrite(remaining)..) until remaining.empty?
|
|
13
|
+
file.close
|
|
14
|
+
"OK:syswrite.txt:#{payload.bytesize}:#{Digest::SHA256.hexdigest(payload)}"
|
|
15
|
+
rescue => error
|
|
16
|
+
"ERR:#{error.class}:#{error.message}"
|
|
17
|
+
ensure
|
|
18
|
+
file.close unless file.closed?
|
|
19
|
+
Brittle.disarm!
|
|
20
|
+
end
|
|
21
|
+
puts result
|
data/harness/tempfile.rb
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "brittle/marker"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "tempfile"
|
|
6
|
+
|
|
7
|
+
payload = "tempfile\n" * 4096
|
|
8
|
+
file = nil
|
|
9
|
+
Brittle.arm!
|
|
10
|
+
result = begin
|
|
11
|
+
file = Tempfile.create("brittle", ENV.fetch("BRITTLE_SANDBOX"))
|
|
12
|
+
file.write(payload)
|
|
13
|
+
file.flush
|
|
14
|
+
file.close
|
|
15
|
+
"OK:#{File.basename(file.path)}:#{payload.bytesize}:#{Digest::SHA256.hexdigest(payload)}"
|
|
16
|
+
rescue => error
|
|
17
|
+
"ERR:#{error.class}:#{error.message}"
|
|
18
|
+
ensure
|
|
19
|
+
file&.close
|
|
20
|
+
Brittle.disarm!
|
|
21
|
+
end
|
|
22
|
+
puts result
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Brittle
|
|
4
|
+
class ArmState
|
|
5
|
+
attr_reader :fd_before, :fd_after
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@armed = false
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def armed? = @armed
|
|
12
|
+
|
|
13
|
+
def transition(path, fd_count: nil)
|
|
14
|
+
case path
|
|
15
|
+
when ARM_PATH
|
|
16
|
+
@armed = true
|
|
17
|
+
@fd_before = fd_count
|
|
18
|
+
:armed
|
|
19
|
+
when DISARM_PATH
|
|
20
|
+
@armed = false
|
|
21
|
+
@fd_after = fd_count
|
|
22
|
+
:disarmed
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def fd_leak? = @fd_before && @fd_after ? @fd_after > @fd_before : false
|
|
27
|
+
end
|
|
28
|
+
end
|
data/lib/brittle/cli.rb
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optparse"
|
|
5
|
+
require "shellwords"
|
|
6
|
+
require_relative "../brittle"
|
|
7
|
+
require_relative "doctor"
|
|
8
|
+
|
|
9
|
+
module Brittle
|
|
10
|
+
module CLI
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def start(argv = ARGV, output: $stdout, error: $stderr)
|
|
14
|
+
case argv.shift
|
|
15
|
+
when "doctor"
|
|
16
|
+
Doctor.check(output:) ? 0 : 1
|
|
17
|
+
when "run"
|
|
18
|
+
run(argv, output:)
|
|
19
|
+
when "sweep"
|
|
20
|
+
sweep(argv, output:)
|
|
21
|
+
when "report"
|
|
22
|
+
report(argv, output:)
|
|
23
|
+
when "reproduce"
|
|
24
|
+
reproduce(argv, output:)
|
|
25
|
+
else
|
|
26
|
+
error.puts usage
|
|
27
|
+
1
|
|
28
|
+
end
|
|
29
|
+
rescue Error, JSON::ParserError, OptionParser::ParseError, SystemCallError, ArgumentError => exception
|
|
30
|
+
error.puts "brittle: #{exception.message}"
|
|
31
|
+
1
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def run(argv, output:)
|
|
35
|
+
command = ["brittle", "run", *argv]
|
|
36
|
+
options, harness = parse_run_options(argv)
|
|
37
|
+
journal = Runner.new(harness, scenario(options), timeout: options[:timeout], command:).run
|
|
38
|
+
path = journal.write(options[:journal] || journal_path(harness, options))
|
|
39
|
+
result = journal.data.fetch(:result)
|
|
40
|
+
output.write(result.fetch(:stdout))
|
|
41
|
+
output.puts "verdict: #{result.fetch(:verdict)}"
|
|
42
|
+
output.puts "journal: #{path}"
|
|
43
|
+
0
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def sweep(argv, output:)
|
|
47
|
+
options, harness = parse_run_options(argv, sweep: true)
|
|
48
|
+
options.fetch(:at).each do |at|
|
|
49
|
+
run_options = options.merge(at:, journal: nil)
|
|
50
|
+
command = reproduce_command(harness, run_options)
|
|
51
|
+
journal = Runner.new(harness, scenario(run_options), timeout: options[:timeout], command:).run
|
|
52
|
+
journal.write(journal_path(harness, run_options))
|
|
53
|
+
result = journal.data.fetch(:result)
|
|
54
|
+
detail = result.fetch(:stdout).lines.grep(/\A(?:OK|ERR|EXPECTED|CORRUPT|SWALLOWED):/).last&.strip
|
|
55
|
+
output.puts ["at=#{at}", result.fetch(:verdict), detail].compact.join(" ")
|
|
56
|
+
end
|
|
57
|
+
0
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def report(argv, output:)
|
|
61
|
+
directory = argv.shift or raise ArgumentError, "usage: brittle report RESULTS_DIR"
|
|
62
|
+
raise ArgumentError, "unexpected arguments: #{argv.join(" ")}" unless argv.empty?
|
|
63
|
+
|
|
64
|
+
paths = Dir.glob(File.join(directory, "*.json")).sort
|
|
65
|
+
raise ArgumentError, "no journals found in #{directory}" if paths.empty?
|
|
66
|
+
|
|
67
|
+
paths.each do |path|
|
|
68
|
+
journal = JSON.parse(File.read(path))
|
|
69
|
+
result = journal.fetch("result")
|
|
70
|
+
output.puts [File.basename(path), result.fetch("verdict"), "exit=#{result["exit_status"]}"].join(" ")
|
|
71
|
+
end
|
|
72
|
+
0
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def reproduce(argv, output:)
|
|
76
|
+
path = argv.shift or raise ArgumentError, "usage: brittle reproduce JOURNAL"
|
|
77
|
+
raise ArgumentError, "unexpected arguments: #{argv.join(" ")}" unless argv.empty?
|
|
78
|
+
|
|
79
|
+
command = JSON.parse(File.read(path)).fetch("reproduce")
|
|
80
|
+
output.puts Shellwords.join(command)
|
|
81
|
+
0
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def parse_run_options(argv, sweep: false)
|
|
85
|
+
options = {timeout: 10}
|
|
86
|
+
parser = OptionParser.new do |opts|
|
|
87
|
+
opts.on("--inject SYSCALL", "--syscall SYSCALL") { |value| options[:syscall] = value.to_sym }
|
|
88
|
+
opts.on("--errno NAME") { |value| options[:errno] = errno(value) }
|
|
89
|
+
opts.on("--return-value INTEGER", Integer) { |value| options[:return_value] = value }
|
|
90
|
+
opts.on("--at SELECTOR") { |value| options[:at] = sweep ? occurrence_range(value) : occurrences(value) }
|
|
91
|
+
opts.on("--every INTEGER", Integer) { |value| options[:every] = value }
|
|
92
|
+
opts.on("--probability NUMBER", Float) { |value| options[:probability] = value }
|
|
93
|
+
opts.on("--seed INTEGER", Integer) { |value| options[:seed] = value }
|
|
94
|
+
opts.on("--fd-path PATTERN") { |value| options[:when_fd_path] = value }
|
|
95
|
+
opts.on("--path PATTERN") { |value| options[:when_path] = value }
|
|
96
|
+
opts.on("--port PORT", Integer) { |value| options[:port] = value }
|
|
97
|
+
opts.on("--timeout SECONDS", Float) { |value| options[:timeout] = value }
|
|
98
|
+
opts.on("--journal PATH") { |value| options[:journal] = value } unless sweep
|
|
99
|
+
end
|
|
100
|
+
parser.parse!(argv)
|
|
101
|
+
harness = argv.shift or raise OptionParser::MissingArgument, "HARNESS"
|
|
102
|
+
raise OptionParser::InvalidArgument, "unexpected arguments: #{argv.join(" ")}" unless argv.empty?
|
|
103
|
+
raise OptionParser::MissingArgument, "--inject/--syscall" unless options[:syscall]
|
|
104
|
+
if options[:errno].nil? == options[:return_value].nil?
|
|
105
|
+
raise OptionParser::InvalidArgument, "choose exactly one of --errno or --return-value"
|
|
106
|
+
end
|
|
107
|
+
if sweep
|
|
108
|
+
raise OptionParser::MissingArgument, "--at FIRST..LAST" unless options[:at].is_a?(Range)
|
|
109
|
+
raise OptionParser::InvalidArgument, "sweep only supports --at" if options[:every] || options[:probability]
|
|
110
|
+
else
|
|
111
|
+
options[:at] ||= 1 unless options[:every] || options[:probability]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
[options, harness]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def scenario(options)
|
|
118
|
+
Scenario.new.inject(options.fetch(:syscall), **options.slice(
|
|
119
|
+
:errno, :return_value, :at, :every, :probability, :seed, :when_fd_path, :when_path, :port
|
|
120
|
+
))
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def errno(value)
|
|
124
|
+
Errno.const_get(value.delete_prefix("Errno::"), false)
|
|
125
|
+
rescue NameError
|
|
126
|
+
raise OptionParser::InvalidArgument, "unknown errno: #{value}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def occurrences(value)
|
|
130
|
+
value.split(",").map { |item| Integer(item, 10) }
|
|
131
|
+
rescue ArgumentError
|
|
132
|
+
raise OptionParser::InvalidArgument, "--at must be a positive integer or comma-separated list"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def occurrence_range(value)
|
|
136
|
+
match = value.match(/\A(\d+)\.\.(\d+)\z/)
|
|
137
|
+
raise OptionParser::InvalidArgument, "--at must be FIRST..LAST" unless match
|
|
138
|
+
|
|
139
|
+
first, last = match.captures.map { |item| Integer(item, 10) }
|
|
140
|
+
raise OptionParser::InvalidArgument, "--at range must be ascending and positive" unless first.positive? && first <= last
|
|
141
|
+
|
|
142
|
+
first..last
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def journal_path(harness, options)
|
|
146
|
+
action = options[:errno]&.name&.delete_prefix("Errno::") || "return-#{options[:return_value]}"
|
|
147
|
+
at = Array(options[:at]).join("-")
|
|
148
|
+
timestamp = Time.now.strftime("%Y%m%d-%H%M%S-%6N")
|
|
149
|
+
File.join("results", "#{File.basename(harness, ".rb")}-#{options.fetch(:syscall)}-#{action}-#{at}-#{timestamp}.json")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def reproduce_command(harness, options)
|
|
153
|
+
command = ["brittle", "run", harness, "--inject", options.fetch(:syscall).to_s]
|
|
154
|
+
command.concat(options[:errno] ? ["--errno", options[:errno].name.delete_prefix("Errno::")] : ["--return-value", options[:return_value].to_s])
|
|
155
|
+
command.concat(["--at", options.fetch(:at).to_s])
|
|
156
|
+
command.concat(["--fd-path", options[:when_fd_path]]) if options[:when_fd_path]
|
|
157
|
+
command.concat(["--path", options[:when_path]]) if options[:when_path]
|
|
158
|
+
command.concat(["--port", options[:port].to_s]) if options[:port]
|
|
159
|
+
command.concat(["--timeout", options[:timeout].to_s]) if options[:timeout] != 10
|
|
160
|
+
command
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def usage
|
|
164
|
+
<<~USAGE
|
|
165
|
+
usage:
|
|
166
|
+
brittle doctor
|
|
167
|
+
brittle run HARNESS --inject SYSCALL --errno NAME --at N
|
|
168
|
+
brittle sweep HARNESS --syscall SYSCALL --errno NAME --at FIRST..LAST
|
|
169
|
+
brittle report RESULTS_DIR
|
|
170
|
+
brittle reproduce JOURNAL
|
|
171
|
+
USAGE
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|