vivarium 0.3.0 → 0.3.1
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 +4 -4
- data/README.md +14 -10
- data/examples/execve_demo.rb +4 -1
- data/examples/file_operation_demo.rb +4 -1
- data/examples/network_client_demo.rb +5 -1
- data/examples/privilege_event_demo.rb +5 -1
- data/examples/raise_demo.rb +5 -1
- data/examples/signal_kill_demo.rb +5 -1
- data/examples/ssl_write_demo.rb +37 -0
- data/lib/vivarium/correlator.rb +3 -1
- data/lib/vivarium/display_filter.rb +158 -0
- data/lib/vivarium/http_decoder.rb +237 -0
- data/lib/vivarium/tree_renderer.rb +55 -5
- data/lib/vivarium/version.rb +1 -1
- data/lib/vivarium.rb +137 -9
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 150b5b98d7555e1954b0002ca2224b1e445b799fa5d163ab4bbcbc8cac42ea74
|
|
4
|
+
data.tar.gz: 5984b875d3fab3600c86f9e1d961a0c1ab84945f1c1ea76e78e72248ec2fd9f7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 17847e5f3852628e8bc58f4a31fc4e934b0b06d35d7961ac811d27d34ffcb4a8f17fd9e9c7f61253b9e7d37db09ee9d06354be637f8953c4bd1827baf8d3e0e7
|
|
7
|
+
data.tar.gz: 1980ac521ccd83eadb68bbbb5b6cb6a6053f3737c2b8c7aaeded5b9ace3563b34666da472bfe133204caf5889a4e77febea2c9069900e4f040a728a6f609b75a
|
data/README.md
CHANGED
|
@@ -21,6 +21,7 @@ Implemented in this repository:
|
|
|
21
21
|
- BPF LSM hooks on `inode_symlink`, `inode_link`, `inode_rename`, `path_chmod`
|
|
22
22
|
- BPF tracepoint on `sys_enter_getdents64`
|
|
23
23
|
- BPF tracepoint on `sys_enter_execve` (captures executable path and first few argv entries as `proc_exec`)
|
|
24
|
+
- BPF tracepoint on `sched_process_fork` (tracks descendants and emits `proc_fork`)
|
|
24
25
|
- BPF LSM hooks for suspicious behavior checks:
|
|
25
26
|
- `ptrace_access_check` (emits `ptrace_check`)
|
|
26
27
|
- `sb_mount` (emits `sb_mount`)
|
|
@@ -32,17 +33,18 @@ Implemented in this repository:
|
|
|
32
33
|
- BPF LSM hook on `socket_create` (flags unusual socket creation as `odd_socket`)
|
|
33
34
|
- BPF LSM hook on `socket_connect` (captures destination family/address/port as `sock_connect`)
|
|
34
35
|
- BPF tracepoints on `sys_enter_sendmsg`, `sys_enter_sendto`, `sys_enter_sendmmsg` (capture UDP/53 DNS QNAME raw bytes as `dns_req`)
|
|
36
|
+
- USDT probes for method span boundaries and exceptions (`span_start`, `span_stop`, `span_raise`)
|
|
37
|
+
- OpenSSL `SSL_write` uprobe event (`ssl_write`)
|
|
35
38
|
- Shared pinned maps on bpffs
|
|
36
39
|
- `config_root_targets` (root PID -> 0/1)
|
|
37
40
|
- `config_spawned_targets` (spawned TID -> 0/1)
|
|
38
|
-
- `
|
|
39
|
-
- `event_write_pos` (cursor for appending into `event_invoked`)
|
|
41
|
+
- `events` (`BPF_RINGBUF_OUTPUT`, shared by system events and span events)
|
|
40
42
|
- Ruby API `Vivarium.observe do ... end`
|
|
41
43
|
- Registers current PID to `config_root_targets`
|
|
42
44
|
- eBPF tracks spawned descendants into `config_spawned_targets` via `sched_process_fork`
|
|
43
|
-
-
|
|
44
|
-
-
|
|
45
|
-
-
|
|
45
|
+
- `TracePoint` emits span probes on allowlisted call/return and emits `span_raise` on Ruby `:raise`
|
|
46
|
+
- Correlator thread consumes ringbuf events and joins them to spans by `tid`/time window
|
|
47
|
+
- Renders a process tree once at session end
|
|
46
48
|
- Unregisters PID on block exit
|
|
47
49
|
|
|
48
50
|
`event_t` currently:
|
|
@@ -51,6 +53,7 @@ Implemented in this repository:
|
|
|
51
53
|
struct event_t {
|
|
52
54
|
u64 ktime_ns;
|
|
53
55
|
u32 pid;
|
|
56
|
+
u32 tid;
|
|
54
57
|
char event_name[16];
|
|
55
58
|
char payload[256];
|
|
56
59
|
};
|
|
@@ -147,7 +150,7 @@ observer = Vivarium.top_observe
|
|
|
147
150
|
observer.stop
|
|
148
151
|
```
|
|
149
152
|
|
|
150
|
-
|
|
153
|
+
`Vivarium.observe` / `Vivarium.top_observe` produce one process-tree report at session end (block exit, `observer.stop`, or process exit).
|
|
151
154
|
|
|
152
155
|
You can override pin directory via `VIVARIUM_BPF_PIN_DIR` on both sides:
|
|
153
156
|
|
|
@@ -179,17 +182,18 @@ bundle exec vivariumd --pin-dir /sys/fs/bpf/vivarium
|
|
|
179
182
|
## Notes
|
|
180
183
|
|
|
181
184
|
- Thread/Ractor-awareness is not yet implemented.
|
|
182
|
-
-
|
|
185
|
+
- Current transport is ring buffer (`events`) pinned under bpffs.
|
|
186
|
+
- Ring buffer is single-consumer by nature; v1 supports a single observer per host.
|
|
183
187
|
- `payload` is 256 bytes in `event_t`; some event types intentionally use smaller structured slices inside that buffer.
|
|
184
188
|
- `proc_exec` currently stores the executable path plus up to 3 argv entries in 4 fixed 64-byte slots to keep the BPF verifier happy.
|
|
189
|
+
- `span_raise` is emitted on Ruby `:raise` and rendered as `EXCP` lines within the enclosing span.
|
|
190
|
+
- Events that do not match any real span are grouped into synthetic `<no-span>` spans.
|
|
185
191
|
- Each event is tagged with severity metadata: `high` for `setid_change`, `capable_check`, `bprm_creds`, `task_kill`, `ptrace_check`, `sb_mount`, and `kernel_read_file`; others are `medium` by default.
|
|
186
|
-
- In `human` format output, `high` severity events are rendered in red.
|
|
187
192
|
- `capable_check` is intentionally filtered to high-risk capabilities to reduce noise from extremely frequent `capable` hook calls.
|
|
188
|
-
-
|
|
193
|
+
- Output format is textual process tree with a session header and per-span relative timing.
|
|
189
194
|
- `vivariumd` resolves `struct file::f_path` offset from `/sys/kernel/btf/vmlinux` at startup.
|
|
190
195
|
- `vivariumd` also resolves `struct dentry::d_name` offset from `/sys/kernel/btf/vmlinux` at startup.
|
|
191
196
|
- You can override offsets manually with `VIVARIUM_FILE_F_PATH_OFFSET` and `VIVARIUM_DENTRY_D_NAME_OFFSET` if auto-detection fails.
|
|
192
|
-
- `vivariumd` also prints `bpf_trace_printk` lines (`vivarium: pid=... path=...`) to its own logs.
|
|
193
197
|
|
|
194
198
|
## Contributing
|
|
195
199
|
|
data/examples/execve_demo.rb
CHANGED
|
@@ -9,6 +9,9 @@ require "vivarium"
|
|
|
9
9
|
# 2) Run this script: bundle exec ruby examples/execve_demo.rb
|
|
10
10
|
|
|
11
11
|
TMP_PREFIX = "vivarium-exec-demo"
|
|
12
|
+
FILTER = {
|
|
13
|
+
include_events: %w[proc_exec]
|
|
14
|
+
}.freeze
|
|
12
15
|
|
|
13
16
|
def try_step(title)
|
|
14
17
|
puts "[exec-demo] #{title}"
|
|
@@ -20,7 +23,7 @@ end
|
|
|
20
23
|
Dir.mktmpdir(TMP_PREFIX, "/tmp") do |dir|
|
|
21
24
|
output_path = File.join(dir, "execve-demo.out")
|
|
22
25
|
|
|
23
|
-
Vivarium.observe do
|
|
26
|
+
Vivarium.observe(filter: FILTER) do
|
|
24
27
|
try_step("system echo with multiple args") do
|
|
25
28
|
system("/bin/echo", "hello", "from", "vivarium", out: File::NULL)
|
|
26
29
|
end
|
|
@@ -10,6 +10,9 @@ require "vivarium"
|
|
|
10
10
|
# 2) Run this script: bundle exec ruby examples/file_operation_demo.rb
|
|
11
11
|
|
|
12
12
|
TMP_PREFIX = "vivarium-file-demo"
|
|
13
|
+
FILTER = {
|
|
14
|
+
include_events: %w[path_open file_symlink file_hardlink file_rename file_chmod file_getdents]
|
|
15
|
+
}.freeze
|
|
13
16
|
|
|
14
17
|
def try_step(title)
|
|
15
18
|
puts "[file-demo] #{title}"
|
|
@@ -24,7 +27,7 @@ Dir.mktmpdir(TMP_PREFIX, "/tmp") do |dir|
|
|
|
24
27
|
hardlink_path = File.join(dir, "hardlink.txt")
|
|
25
28
|
symlink_path = File.join(dir, "symlink.txt")
|
|
26
29
|
|
|
27
|
-
Vivarium.observe do
|
|
30
|
+
Vivarium.observe(filter: FILTER) do
|
|
28
31
|
try_step("create source file") do
|
|
29
32
|
File.write(source_path, "vivarium sample\n")
|
|
30
33
|
File.read(source_path)
|
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
require "socket"
|
|
5
5
|
require "vivarium"
|
|
6
6
|
|
|
7
|
+
FILTER = {
|
|
8
|
+
include_events: %w[sock_connect dns_req odd_socket]
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
7
11
|
# Usage:
|
|
8
12
|
# 1) In another shell (root): sudo bundle exec vivariumd
|
|
9
13
|
# 2) Run this script: bundle exec ruby examples/network_client_demo.rb
|
|
@@ -15,7 +19,7 @@ rescue StandardError => e
|
|
|
15
19
|
puts "[client] #{title} failed: #{e.class}: #{e.message}"
|
|
16
20
|
end
|
|
17
21
|
|
|
18
|
-
Vivarium.observe do
|
|
22
|
+
Vivarium.observe(filter: FILTER) do
|
|
19
23
|
# Likely emits sock_connect and dns_req via resolver traffic.
|
|
20
24
|
try_step("system: DNS lookup") do
|
|
21
25
|
system("getent hosts example.com >/dev/null 2>&1 || true")
|
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
|
|
4
4
|
require "vivarium"
|
|
5
5
|
|
|
6
|
+
FILTER = {
|
|
7
|
+
include_events: %w[setid_change capable_check bprm_creds]
|
|
8
|
+
}.freeze
|
|
9
|
+
|
|
6
10
|
# Usage:
|
|
7
11
|
# 1) In another shell (root): sudo bundle exec vivariumd
|
|
8
12
|
# 2) Run this script: bundle exec ruby examples/privilege_event_demo.rb
|
|
@@ -14,7 +18,7 @@ rescue StandardError => e
|
|
|
14
18
|
puts "[priv-demo] #{title} failed: #{e.class}: #{e.message}"
|
|
15
19
|
end
|
|
16
20
|
|
|
17
|
-
Vivarium.observe do
|
|
21
|
+
Vivarium.observe(filter: FILTER) do
|
|
18
22
|
try_step("attempt setuid(0)") do
|
|
19
23
|
Process::UID.change_privilege(0)
|
|
20
24
|
end
|
data/examples/raise_demo.rb
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
|
|
4
4
|
require "vivarium"
|
|
5
5
|
|
|
6
|
+
FILTER = {
|
|
7
|
+
include_events: %w[span_raise]
|
|
8
|
+
}.freeze
|
|
9
|
+
|
|
6
10
|
def try_step(title)
|
|
7
11
|
puts "[priv-demo] #{title}"
|
|
8
12
|
yield
|
|
@@ -10,7 +14,7 @@ rescue StandardError => e
|
|
|
10
14
|
puts "[priv-demo] #{title} failed: #{e.class}: #{e.message}"
|
|
11
15
|
end
|
|
12
16
|
|
|
13
|
-
Vivarium.observe do
|
|
17
|
+
Vivarium.observe(filter: FILTER) do
|
|
14
18
|
try_step("raise in main") do
|
|
15
19
|
raise "error in main"
|
|
16
20
|
end
|
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
|
|
4
4
|
require "vivarium"
|
|
5
5
|
|
|
6
|
+
FILTER = {
|
|
7
|
+
include_events: %w[task_kill]
|
|
8
|
+
}.freeze
|
|
9
|
+
|
|
6
10
|
# Usage:
|
|
7
11
|
# 1) In another shell (root): sudo bundle exec vivariumd
|
|
8
12
|
# 2) Run this script: bundle exec ruby examples/signal_kill_demo.rb
|
|
@@ -16,7 +20,7 @@ end
|
|
|
16
20
|
|
|
17
21
|
child_pid = nil
|
|
18
22
|
|
|
19
|
-
Vivarium.observe do
|
|
23
|
+
Vivarium.observe(filter: FILTER) do
|
|
20
24
|
try_step("fork child process") do
|
|
21
25
|
child_pid = fork do
|
|
22
26
|
trap("TERM") { exit!(0) }
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "vivarium"
|
|
7
|
+
|
|
8
|
+
FILTER = {
|
|
9
|
+
include_events: %w[ssl_write]
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
# Usage:
|
|
13
|
+
# 1) In another shell (root): sudo bundle exec vivariumd
|
|
14
|
+
# (SSL_write uprobe is attached automatically when libssl is found)
|
|
15
|
+
# 2) Run this script: bundle exec ruby examples/ssl_write_demo.rb
|
|
16
|
+
#
|
|
17
|
+
# You can disable the SSL_write uprobe with `sudo vivariumd --no-ssl-trace`
|
|
18
|
+
# or point at a specific library with `sudo vivariumd --libssl /path/to/libssl.so.3`.
|
|
19
|
+
|
|
20
|
+
Vivarium.observe(filter: FILTER) do
|
|
21
|
+
# Net::HTTP uses libssl's SSL_write under the hood. With HTTP/1.1 the
|
|
22
|
+
# request line should appear verbatim in the SSL event payload.
|
|
23
|
+
begin
|
|
24
|
+
uri = URI("https://udzura.jp/")
|
|
25
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
|
|
26
|
+
http.get(uri.request_uri)
|
|
27
|
+
end
|
|
28
|
+
rescue StandardError => e
|
|
29
|
+
warn "[ssl_demo] Net::HTTP failed: #{e.class}: #{e.message}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# `curl --http2` should produce HTTP/2 traffic; HEADERS frames will be
|
|
33
|
+
# HPACK-decoded if the `http-2` gem is installed on the observer side.
|
|
34
|
+
system("curl -sS --http2 -o /dev/null https://nghttp2.org/ 2>/dev/null")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
puts "[ssl_demo] done"
|
data/lib/vivarium/correlator.rb
CHANGED
|
@@ -22,11 +22,12 @@ module Vivarium
|
|
|
22
22
|
|
|
23
23
|
POLL_TIMEOUT_MS = 200
|
|
24
24
|
|
|
25
|
-
def initialize(pin_dir:, observer_pid:, main_tid:, method_id_queue:, dest: $stdout)
|
|
25
|
+
def initialize(pin_dir:, observer_pid:, main_tid:, method_id_queue:, filter: nil, dest: $stdout)
|
|
26
26
|
@pin_dir = pin_dir
|
|
27
27
|
@observer_pid = observer_pid
|
|
28
28
|
@main_tid = main_tid
|
|
29
29
|
@method_id_queue = method_id_queue
|
|
30
|
+
@filter = filter
|
|
30
31
|
@dest = dest
|
|
31
32
|
|
|
32
33
|
@events = []
|
|
@@ -79,6 +80,7 @@ module Vivarium
|
|
|
79
80
|
session_start_ktime: @session_start_ktime,
|
|
80
81
|
session_stop_iso: @session_stop_iso,
|
|
81
82
|
session_stop_ktime: @session_stop_ktime,
|
|
83
|
+
filter: @filter,
|
|
82
84
|
dest: @dest
|
|
83
85
|
).render
|
|
84
86
|
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Vivarium
|
|
6
|
+
class DisplayFilter
|
|
7
|
+
attr_reader :include_events, :exclude_events, :include_severities, :include_pids, :include_tids
|
|
8
|
+
|
|
9
|
+
def self.compile(raw)
|
|
10
|
+
return new if raw.nil?
|
|
11
|
+
return raw if raw.is_a?(self)
|
|
12
|
+
|
|
13
|
+
unless raw.respond_to?(:to_h)
|
|
14
|
+
raise ArgumentError, "filter must be a Hash-compatible object"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
new(raw.to_h)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(raw = {})
|
|
21
|
+
@raw = symbolize_keys(raw || {})
|
|
22
|
+
|
|
23
|
+
@include_events = normalize_string_set(fetch_key(:include_events, :event_names, :events))
|
|
24
|
+
@exclude_events = normalize_string_set(fetch_key(:exclude_events))
|
|
25
|
+
@include_severities = normalize_string_set(fetch_key(:include_severities, :severities, :severity))
|
|
26
|
+
@include_pids = normalize_integer_set(fetch_key(:include_pids, :pids, :pid))
|
|
27
|
+
@include_tids = normalize_integer_set(fetch_key(:include_tids, :tids, :tid))
|
|
28
|
+
|
|
29
|
+
@include_span_names = normalize_string_set(fetch_key(:include_span_names, :span_names))
|
|
30
|
+
@span_pattern = normalize_pattern(fetch_key(:span, :span_pattern))
|
|
31
|
+
|
|
32
|
+
payload_value = fetch_key(:payload)
|
|
33
|
+
@payload_pattern = normalize_pattern(fetch_key(:payload_pattern))
|
|
34
|
+
@payload_patterns_by_event = {}
|
|
35
|
+
if payload_value.is_a?(Hash)
|
|
36
|
+
@payload_patterns_by_event = normalize_payload_map(payload_value)
|
|
37
|
+
else
|
|
38
|
+
@payload_pattern ||= normalize_pattern(payload_value)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def enabled?
|
|
43
|
+
!@raw.empty?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def needs_payload?
|
|
47
|
+
!@payload_pattern.nil? || !@payload_patterns_by_event.empty?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def allow_span_name?(span_name)
|
|
51
|
+
return true if @include_span_names.empty? && @span_pattern.nil?
|
|
52
|
+
|
|
53
|
+
name = span_name.to_s
|
|
54
|
+
return true if @include_span_names.include?(name)
|
|
55
|
+
return true if @span_pattern && @span_pattern.match?(name)
|
|
56
|
+
|
|
57
|
+
false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def allow_event?(event_name:, severity:, span_name:, payload: nil, pid: nil, tid: nil)
|
|
61
|
+
return false unless allow_span_name?(span_name)
|
|
62
|
+
|
|
63
|
+
name = event_name.to_s
|
|
64
|
+
sev = severity.to_s
|
|
65
|
+
|
|
66
|
+
return false if @exclude_events.include?(name)
|
|
67
|
+
return false if !@include_events.empty? && !@include_events.include?(name)
|
|
68
|
+
return false if !@include_severities.empty? && !@include_severities.include?(sev)
|
|
69
|
+
return false if !@include_pids.empty? && !@include_pids.include?(pid.to_i)
|
|
70
|
+
return false if !@include_tids.empty? && !@include_tids.include?(tid.to_i)
|
|
71
|
+
|
|
72
|
+
payload_pattern = @payload_patterns_by_event[name] || @payload_pattern
|
|
73
|
+
if payload_pattern
|
|
74
|
+
return false if payload.nil?
|
|
75
|
+
return false unless payload_pattern.match?(payload.to_s)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def fetch_key(*keys)
|
|
84
|
+
keys.each do |key|
|
|
85
|
+
return @raw[key] if @raw.key?(key)
|
|
86
|
+
end
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def symbolize_keys(hash)
|
|
91
|
+
hash.each_with_object({}) do |(k, v), out|
|
|
92
|
+
out[k.respond_to?(:to_sym) ? k.to_sym : k] = v
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def normalize_string_set(value)
|
|
97
|
+
arr = case value
|
|
98
|
+
when nil
|
|
99
|
+
[]
|
|
100
|
+
when Array
|
|
101
|
+
value
|
|
102
|
+
else
|
|
103
|
+
[value]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
arr.each_with_object(Set.new) do |item, set|
|
|
107
|
+
str = item.to_s.strip
|
|
108
|
+
set << str unless str.empty?
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def normalize_integer_set(value)
|
|
113
|
+
arr = case value
|
|
114
|
+
when nil
|
|
115
|
+
[]
|
|
116
|
+
when Array
|
|
117
|
+
value
|
|
118
|
+
else
|
|
119
|
+
[value]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
arr.each_with_object(Set.new) do |item, set|
|
|
123
|
+
begin
|
|
124
|
+
set << Integer(item)
|
|
125
|
+
rescue ArgumentError, TypeError
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def normalize_pattern(value)
|
|
132
|
+
case value
|
|
133
|
+
when nil
|
|
134
|
+
nil
|
|
135
|
+
when Regexp
|
|
136
|
+
value
|
|
137
|
+
when String
|
|
138
|
+
return nil if value.empty?
|
|
139
|
+
|
|
140
|
+
Regexp.new(Regexp.escape(value))
|
|
141
|
+
else
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def normalize_payload_map(raw_map)
|
|
147
|
+
raw_map.each_with_object({}) do |(event_name, pattern), out|
|
|
148
|
+
key = event_name.to_s.strip
|
|
149
|
+
next if key.empty?
|
|
150
|
+
|
|
151
|
+
normalized = normalize_pattern(pattern)
|
|
152
|
+
next unless normalized
|
|
153
|
+
|
|
154
|
+
out[key] = normalized
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require "http/2"
|
|
5
|
+
rescue LoadError
|
|
6
|
+
# http/2 gem is optional; without it we can still parse frame headers but not HPACK-decompress HEADERS.
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
module Vivarium
|
|
10
|
+
# Decodes payloads captured from OpenSSL `SSL_write` into a human-readable
|
|
11
|
+
# one-liner. Auto-detects HTTP/1.x request/response lines and HTTP/2 binary
|
|
12
|
+
# frames; HPACK-decompresses HEADERS / CONTINUATION when the `http-2` gem
|
|
13
|
+
# is available, otherwise reports frame types only.
|
|
14
|
+
#
|
|
15
|
+
# HPACK decompressor state is kept per pid. This is sufficient for the
|
|
16
|
+
# common "one HTTPS connection per process" case; with multiple concurrent
|
|
17
|
+
# TLS connections per pid the HPACK table can diverge and decoding may
|
|
18
|
+
# fail — in that case the decompressor for that pid is reset on the next
|
|
19
|
+
# decode error.
|
|
20
|
+
class HttpDecoder
|
|
21
|
+
HTTP1_METHODS = %w[GET POST PUT PATCH DELETE HEAD OPTIONS TRACE CONNECT].freeze
|
|
22
|
+
HTTP2_PREFACE = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".b
|
|
23
|
+
H2_FRAME_HEADER_SIZE = 9
|
|
24
|
+
H2_FLAG_END_HEADERS = 0x04
|
|
25
|
+
H2_FLAG_PADDED = 0x08
|
|
26
|
+
H2_FLAG_PRIORITY = 0x20
|
|
27
|
+
FRAME_TYPE_NAMES = {
|
|
28
|
+
0x0 => "DATA",
|
|
29
|
+
0x1 => "HEADERS",
|
|
30
|
+
0x2 => "PRIORITY",
|
|
31
|
+
0x3 => "RST_STREAM",
|
|
32
|
+
0x4 => "SETTINGS",
|
|
33
|
+
0x5 => "PUSH_PROMISE",
|
|
34
|
+
0x6 => "PING",
|
|
35
|
+
0x7 => "GOAWAY",
|
|
36
|
+
0x8 => "WINDOW_UPDATE",
|
|
37
|
+
0x9 => "CONTINUATION"
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
def initialize
|
|
41
|
+
@hpack_available = load_http2_gem
|
|
42
|
+
@decompressors = {}
|
|
43
|
+
@continuation = {}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def hpack_available?
|
|
47
|
+
@hpack_available
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def render(pid:, data:, data_len:)
|
|
51
|
+
data = data.to_s.b
|
|
52
|
+
data_len = data_len.to_i
|
|
53
|
+
|
|
54
|
+
return "data_len=0" if data_len <= 0
|
|
55
|
+
return "len=#{data_len} <no-capture>" if data.empty?
|
|
56
|
+
|
|
57
|
+
if (summary = http1_summary(data))
|
|
58
|
+
kind, line = summary
|
|
59
|
+
return "http/1.x #{kind}: #{line}#{truncation_note(data, data_len)}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
rest = data
|
|
63
|
+
preface_note = ""
|
|
64
|
+
if rest.start_with?(HTTP2_PREFACE)
|
|
65
|
+
preface_note = "preface "
|
|
66
|
+
rest = rest.byteslice(HTTP2_PREFACE.bytesize..) || "".b
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
frames = parse_h2_frames(rest)
|
|
70
|
+
if frames.empty?
|
|
71
|
+
if !preface_note.empty?
|
|
72
|
+
return "h2 preface only#{truncation_note(data, data_len)}"
|
|
73
|
+
end
|
|
74
|
+
return "binary len=#{data_len}#{truncation_note(data, data_len)}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
rendered = frames.map { |f| render_h2_frame(pid, f) }.join(" | ")
|
|
78
|
+
"h2 #{preface_note}#{rendered}#{truncation_note(data, data_len)}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def truncation_note(data, data_len)
|
|
84
|
+
return "" if data.bytesize >= data_len
|
|
85
|
+
|
|
86
|
+
" (captured #{data.bytesize}/#{data_len}B)"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def load_http2_gem
|
|
90
|
+
require "http/2"
|
|
91
|
+
true
|
|
92
|
+
rescue LoadError
|
|
93
|
+
false
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def http1_summary(data)
|
|
97
|
+
head = data.byteslice(0, 512).to_s
|
|
98
|
+
first_line = head.split("\r\n", 2).first
|
|
99
|
+
return nil if first_line.nil? || first_line.empty?
|
|
100
|
+
|
|
101
|
+
first_line = first_line.dup.force_encoding(Encoding::UTF_8)
|
|
102
|
+
return nil unless first_line.valid_encoding?
|
|
103
|
+
|
|
104
|
+
if HTTP1_METHODS.any? { |m| first_line.start_with?("#{m} ") }
|
|
105
|
+
return ["request", first_line]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
if first_line.start_with?("HTTP/1.1 ") || first_line.start_with?("HTTP/1.0 ")
|
|
109
|
+
return ["response", first_line]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @return [Array<Array(Integer, Integer, Integer, String, Boolean)>]
|
|
116
|
+
# each entry: [frame_type, flags, stream_id, frame_payload, truncated?]
|
|
117
|
+
def parse_h2_frames(payload)
|
|
118
|
+
frames = []
|
|
119
|
+
i = 0
|
|
120
|
+
total = payload.bytesize
|
|
121
|
+
|
|
122
|
+
while i + H2_FRAME_HEADER_SIZE <= total
|
|
123
|
+
length = (payload.getbyte(i) << 16) |
|
|
124
|
+
(payload.getbyte(i + 1) << 8) |
|
|
125
|
+
payload.getbyte(i + 2)
|
|
126
|
+
frame_type = payload.getbyte(i + 3)
|
|
127
|
+
flags = payload.getbyte(i + 4)
|
|
128
|
+
stream_id = payload.byteslice(i + 5, 4).unpack1("N") & 0x7fff_ffff
|
|
129
|
+
i += H2_FRAME_HEADER_SIZE
|
|
130
|
+
|
|
131
|
+
if i + length > total
|
|
132
|
+
remaining = payload.byteslice(i, total - i) || "".b
|
|
133
|
+
frames << [frame_type, flags, stream_id, remaining, true]
|
|
134
|
+
break
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
frame_payload = payload.byteslice(i, length) || "".b
|
|
138
|
+
i += length
|
|
139
|
+
frames << [frame_type, flags, stream_id, frame_payload, false]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Heuristic: if the very first "frame" doesn't look like a valid HTTP/2
|
|
143
|
+
# frame, refuse the whole parse so we fall back to "binary".
|
|
144
|
+
first_type = frames.first && frames.first[0]
|
|
145
|
+
return [] if first_type && !FRAME_TYPE_NAMES.key?(first_type)
|
|
146
|
+
|
|
147
|
+
frames
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def render_h2_frame(pid, frame)
|
|
151
|
+
frame_type, flags, stream_id, frame_payload, truncated = frame
|
|
152
|
+
frame_name = FRAME_TYPE_NAMES.fetch(frame_type, "TYPE0x#{format('%02x', frame_type)}")
|
|
153
|
+
header = "#{frame_name} stream=#{stream_id} flags=0x#{format('%02x', flags)} len=#{frame_payload.bytesize}#{truncated ? '*' : ''}"
|
|
154
|
+
|
|
155
|
+
case frame_type
|
|
156
|
+
when 0x1 # HEADERS
|
|
157
|
+
fragment = headers_fragment(flags, frame_payload)
|
|
158
|
+
return "#{header} <bad_payload>" if fragment.nil?
|
|
159
|
+
|
|
160
|
+
if (flags & H2_FLAG_END_HEADERS) != 0
|
|
161
|
+
pseudo = decode_hpack(pid, fragment)
|
|
162
|
+
"#{header}#{format_pseudo(pseudo)}"
|
|
163
|
+
else
|
|
164
|
+
@continuation[[pid, stream_id]] = fragment.dup
|
|
165
|
+
"#{header} <collecting>"
|
|
166
|
+
end
|
|
167
|
+
when 0x9 # CONTINUATION
|
|
168
|
+
key = [pid, stream_id]
|
|
169
|
+
unless @continuation.key?(key)
|
|
170
|
+
return "#{header} <orphan>"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
@continuation[key] << frame_payload
|
|
174
|
+
if (flags & H2_FLAG_END_HEADERS) == 0
|
|
175
|
+
"#{header} <collecting>"
|
|
176
|
+
else
|
|
177
|
+
buf = @continuation.delete(key)
|
|
178
|
+
pseudo = decode_hpack(pid, buf)
|
|
179
|
+
"#{header}#{format_pseudo(pseudo)}"
|
|
180
|
+
end
|
|
181
|
+
else
|
|
182
|
+
header
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def headers_fragment(flags, frame_payload)
|
|
187
|
+
start_idx = 0
|
|
188
|
+
end_idx = frame_payload.bytesize
|
|
189
|
+
|
|
190
|
+
if (flags & H2_FLAG_PADDED) != 0
|
|
191
|
+
return nil if end_idx.zero?
|
|
192
|
+
|
|
193
|
+
pad_len = frame_payload.getbyte(0)
|
|
194
|
+
start_idx += 1
|
|
195
|
+
end_idx = [start_idx, end_idx - pad_len].max
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
if (flags & H2_FLAG_PRIORITY) != 0
|
|
199
|
+
return nil if start_idx + 5 > end_idx
|
|
200
|
+
|
|
201
|
+
start_idx += 5
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
frame_payload.byteslice(start_idx, end_idx - start_idx) || "".b
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def decompressor_for(pid)
|
|
208
|
+
return nil unless @hpack_available
|
|
209
|
+
|
|
210
|
+
@decompressors[pid] ||= HTTP2::Header::Decompressor.new
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def decode_hpack(pid, header_block)
|
|
214
|
+
dec = decompressor_for(pid)
|
|
215
|
+
return { ":error" => "hpack-unavailable" } unless dec
|
|
216
|
+
|
|
217
|
+
pairs = dec.decode(header_block.b)
|
|
218
|
+
pairs.each_with_object({}) { |(k, v), h| h[k] = v }
|
|
219
|
+
rescue StandardError => e
|
|
220
|
+
@decompressors.delete(pid)
|
|
221
|
+
{ ":error" => "#{e.class}: #{e.message}" }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def format_pseudo(pseudo)
|
|
225
|
+
return " <error: #{pseudo[':error']}>" if pseudo.key?(":error")
|
|
226
|
+
|
|
227
|
+
parts = []
|
|
228
|
+
parts << ":method=#{pseudo[':method']}" if pseudo[':method']
|
|
229
|
+
parts << ":path=#{pseudo[':path']}" if pseudo[':path']
|
|
230
|
+
parts << ":authority=#{pseudo[':authority']}" if pseudo[':authority']
|
|
231
|
+
parts << ":status=#{pseudo[':status']}" if pseudo[':status']
|
|
232
|
+
return "" if parts.empty?
|
|
233
|
+
|
|
234
|
+
" #{parts.join(' ')}"
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "set"
|
|
4
|
+
require_relative "http_decoder"
|
|
4
5
|
|
|
5
6
|
module Vivarium
|
|
6
7
|
class TreeRenderer
|
|
7
8
|
SPAN_EVENT_NAMES = %w[span_start span_stop].to_set.freeze
|
|
8
9
|
FORK_EVENT_NAME = "proc_fork"
|
|
9
10
|
EXEC_EVENT_NAME = "proc_exec"
|
|
11
|
+
SSL_WRITE_EVENT_NAME = "ssl_write"
|
|
10
12
|
|
|
11
13
|
LSM_EVENT_NAMES = %w[
|
|
12
14
|
path_open sock_connect odd_socket
|
|
@@ -19,6 +21,8 @@ module Vivarium
|
|
|
19
21
|
dns_req proc_exec file_getdents proc_fork
|
|
20
22
|
].to_set.freeze
|
|
21
23
|
|
|
24
|
+
UPROBE_EVENT_NAMES = %w[ssl_write].to_set.freeze
|
|
25
|
+
|
|
22
26
|
SYNTHETIC_SPAN_NAME = "<no-span>"
|
|
23
27
|
UNRESOLVED_METHOD_PREFIX = "<method_id="
|
|
24
28
|
|
|
@@ -49,7 +53,7 @@ module Vivarium
|
|
|
49
53
|
|
|
50
54
|
def initialize(events:, method_table:, observer_pid:, main_tid:,
|
|
51
55
|
session_start_iso:, session_start_ktime:,
|
|
52
|
-
session_stop_iso:, session_stop_ktime:, dest:)
|
|
56
|
+
session_stop_iso:, session_stop_ktime:, filter: nil, dest:)
|
|
53
57
|
@events = events
|
|
54
58
|
@method_table = method_table
|
|
55
59
|
@observer_pid = observer_pid
|
|
@@ -58,6 +62,7 @@ module Vivarium
|
|
|
58
62
|
@session_start_ktime = session_start_ktime
|
|
59
63
|
@session_stop_iso = session_stop_iso
|
|
60
64
|
@session_stop_ktime = session_stop_ktime
|
|
65
|
+
@display_filter = Vivarium::DisplayFilter.compile(filter)
|
|
61
66
|
@dest = dest
|
|
62
67
|
|
|
63
68
|
@pid_comm = { observer_pid => "ruby" }
|
|
@@ -265,6 +270,7 @@ module Vivarium
|
|
|
265
270
|
@dest.puts "[PROC pid=#{@observer_pid} comm=#{@pid_comm[@observer_pid] || 'ruby'}]"
|
|
266
271
|
children = spans.reject { |s| s.synthetic && s.events.empty? }
|
|
267
272
|
.reject { |s| @child_span_set.include?(s) }
|
|
273
|
+
.select { |s| span_visible?(s) }
|
|
268
274
|
children.each_with_index do |span, idx|
|
|
269
275
|
print_span(span, prefix: "", is_last: idx == children.size - 1)
|
|
270
276
|
end
|
|
@@ -320,6 +326,14 @@ module Vivarium
|
|
|
320
326
|
root_children = []
|
|
321
327
|
|
|
322
328
|
span.events.each do |ev|
|
|
329
|
+
target_text = nil
|
|
330
|
+
if @display_filter.needs_payload?
|
|
331
|
+
target_text = render_target(ev)
|
|
332
|
+
next unless event_visible?(ev, span, target_text)
|
|
333
|
+
else
|
|
334
|
+
next unless event_visible?(ev, span)
|
|
335
|
+
end
|
|
336
|
+
|
|
323
337
|
if ev.event_name == FORK_EVENT_NAME
|
|
324
338
|
child_pid = read_proc_fork_child_pid(ev.payload)
|
|
325
339
|
child_node = ProcNode.new(
|
|
@@ -333,7 +347,7 @@ module Vivarium
|
|
|
333
347
|
ev_node = EventNode.new(
|
|
334
348
|
kind: kind_for(ev),
|
|
335
349
|
name: ev.event_name,
|
|
336
|
-
target: render_target(ev),
|
|
350
|
+
target: target_text || render_target(ev),
|
|
337
351
|
offset_ns: ev.ktime_ns - span.start_ktime,
|
|
338
352
|
child_proc: child_node
|
|
339
353
|
)
|
|
@@ -344,7 +358,7 @@ module Vivarium
|
|
|
344
358
|
ev_node = EventNode.new(
|
|
345
359
|
kind: kind_for(ev),
|
|
346
360
|
name: ev.event_name,
|
|
347
|
-
target: render_target(ev),
|
|
361
|
+
target: target_text || render_target(ev),
|
|
348
362
|
offset_ns: ev.ktime_ns - span.start_ktime,
|
|
349
363
|
child_proc: nil
|
|
350
364
|
)
|
|
@@ -416,8 +430,12 @@ module Vivarium
|
|
|
416
430
|
end
|
|
417
431
|
|
|
418
432
|
def print_nodes(nodes, prefix)
|
|
419
|
-
nodes.
|
|
420
|
-
|
|
433
|
+
visible_nodes = nodes.select do |node|
|
|
434
|
+
!node.is_a?(Span) || span_visible?(node)
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
visible_nodes.each_with_index do |node, idx|
|
|
438
|
+
is_last = idx == visible_nodes.size - 1
|
|
421
439
|
case node
|
|
422
440
|
when EventNode
|
|
423
441
|
print_event_node(node, prefix: prefix, is_last: is_last)
|
|
@@ -429,6 +447,21 @@ module Vivarium
|
|
|
429
447
|
end
|
|
430
448
|
end
|
|
431
449
|
|
|
450
|
+
def span_visible?(span)
|
|
451
|
+
@display_filter.allow_span_name?(span_display_name(span))
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def event_visible?(ev, span, target_text = nil)
|
|
455
|
+
@display_filter.allow_event?(
|
|
456
|
+
event_name: ev.event_name,
|
|
457
|
+
severity: Vivarium.event_severity(ev.event_name),
|
|
458
|
+
span_name: span_display_name(span),
|
|
459
|
+
payload: target_text,
|
|
460
|
+
pid: ev.pid,
|
|
461
|
+
tid: ev.tid
|
|
462
|
+
)
|
|
463
|
+
end
|
|
464
|
+
|
|
432
465
|
def print_event_node(node, prefix:, is_last:)
|
|
433
466
|
marker = is_last && node.child_proc.nil? ? "└─ " : "├─ "
|
|
434
467
|
marker = "└─ " if is_last && node.child_proc.nil?
|
|
@@ -457,6 +490,7 @@ module Vivarium
|
|
|
457
490
|
def kind_for(ev)
|
|
458
491
|
return "EXCP" if ev.event_name == "span_raise"
|
|
459
492
|
return "USDT" if SPAN_EVENT_NAMES.include?(ev.event_name)
|
|
493
|
+
return "SSL" if ev.event_name == SSL_WRITE_EVENT_NAME
|
|
460
494
|
return "LSM" if LSM_EVENT_NAMES.include?(ev.event_name)
|
|
461
495
|
return "TP" if TP_EVENT_NAMES.include?(ev.event_name)
|
|
462
496
|
|
|
@@ -465,12 +499,28 @@ module Vivarium
|
|
|
465
499
|
|
|
466
500
|
def render_target(ev)
|
|
467
501
|
return render_raise_target(ev) if ev.event_name == "span_raise"
|
|
502
|
+
return render_ssl_write_target(ev) if ev.event_name == SSL_WRITE_EVENT_NAME
|
|
468
503
|
|
|
469
504
|
text = Vivarium.render_event_payload(ev).to_s
|
|
470
505
|
text = text.gsub(/\s+/, " ").strip
|
|
471
506
|
text.empty? ? "-" : text
|
|
472
507
|
end
|
|
473
508
|
|
|
509
|
+
def render_ssl_write_target(ev)
|
|
510
|
+
decoded = Vivarium.decode_ssl_write_payload(ev.payload)
|
|
511
|
+
http_decoder.render(
|
|
512
|
+
pid: ev.pid,
|
|
513
|
+
data: decoded[:data],
|
|
514
|
+
data_len: decoded[:data_len]
|
|
515
|
+
)
|
|
516
|
+
rescue StandardError => e
|
|
517
|
+
"ssl_write <decode-error: #{e.class}: #{e.message}>"
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
def http_decoder
|
|
521
|
+
@http_decoder ||= Vivarium::HttpDecoder.new
|
|
522
|
+
end
|
|
523
|
+
|
|
474
524
|
def render_raise_target(ev)
|
|
475
525
|
bytes = ev.payload.to_s.b
|
|
476
526
|
return "-" if bytes.bytesize < 8
|
data/lib/vivarium/version.rb
CHANGED
data/lib/vivarium.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "fiddle"
|
|
4
4
|
require "fileutils"
|
|
5
|
+
require "net/http"
|
|
5
6
|
require "optparse"
|
|
6
7
|
require "pathname"
|
|
7
8
|
require "rbbcc"
|
|
@@ -30,6 +31,27 @@ module Vivarium
|
|
|
30
31
|
EVENT_NAME_OFFSET = 16
|
|
31
32
|
EVENT_PAYLOAD_OFFSET = 32
|
|
32
33
|
EVENTS_RINGBUF_PAGES = 256
|
|
34
|
+
|
|
35
|
+
SSL_WRITE_PAYLOAD_DATA_LEN_OFFSET = 0
|
|
36
|
+
SSL_WRITE_PAYLOAD_CAP_LEN_OFFSET = 4
|
|
37
|
+
SSL_WRITE_PAYLOAD_DATA_OFFSET = 8
|
|
38
|
+
SSL_WRITE_PAYLOAD_DATA_MAX = EVENT_PAYLOAD_SIZE - SSL_WRITE_PAYLOAD_DATA_OFFSET
|
|
39
|
+
|
|
40
|
+
LIBSSL_SEARCH_PATHS = [
|
|
41
|
+
"/lib/x86_64-linux-gnu/libssl.so.3",
|
|
42
|
+
"/lib/x86_64-linux-gnu/libssl.so.1.1",
|
|
43
|
+
"/lib/aarch64-linux-gnu/libssl.so.3",
|
|
44
|
+
"/lib/aarch64-linux-gnu/libssl.so.1.1",
|
|
45
|
+
"/usr/lib/x86_64-linux-gnu/libssl.so.3",
|
|
46
|
+
"/usr/lib/x86_64-linux-gnu/libssl.so.1.1",
|
|
47
|
+
"/usr/lib/aarch64-linux-gnu/libssl.so.3",
|
|
48
|
+
"/usr/lib/aarch64-linux-gnu/libssl.so.1.1",
|
|
49
|
+
"/usr/lib64/libssl.so.3",
|
|
50
|
+
"/usr/lib64/libssl.so.1.1",
|
|
51
|
+
"/usr/lib/libssl.so.3",
|
|
52
|
+
"/usr/lib/libssl.so.1.1"
|
|
53
|
+
].freeze
|
|
54
|
+
|
|
33
55
|
SPAN_ALLOWCLASSES = [
|
|
34
56
|
Socket,
|
|
35
57
|
BasicSocket,
|
|
@@ -43,6 +65,7 @@ module Vivarium
|
|
|
43
65
|
Process,
|
|
44
66
|
Process::UID,
|
|
45
67
|
Process::GID,
|
|
68
|
+
Net::HTTP,
|
|
46
69
|
]
|
|
47
70
|
SPAN_ALLOWLIST = [
|
|
48
71
|
"Kernel#system",
|
|
@@ -342,6 +365,17 @@ module Vivarium
|
|
|
342
365
|
result
|
|
343
366
|
end
|
|
344
367
|
|
|
368
|
+
def self.decode_ssl_write_payload(raw_payload)
|
|
369
|
+
bytes = raw_payload.to_s.b
|
|
370
|
+
return { data_len: 0, cap_len: 0, data: "".b } if bytes.bytesize < SSL_WRITE_PAYLOAD_DATA_OFFSET
|
|
371
|
+
|
|
372
|
+
data_len = bytes[SSL_WRITE_PAYLOAD_DATA_LEN_OFFSET, 4].unpack1("L<")
|
|
373
|
+
cap_len = bytes[SSL_WRITE_PAYLOAD_CAP_LEN_OFFSET, 4].unpack1("L<")
|
|
374
|
+
cap_len = SSL_WRITE_PAYLOAD_DATA_MAX if cap_len > SSL_WRITE_PAYLOAD_DATA_MAX
|
|
375
|
+
data = bytes[SSL_WRITE_PAYLOAD_DATA_OFFSET, cap_len] || "".b
|
|
376
|
+
{ data_len: data_len, cap_len: cap_len, data: data }
|
|
377
|
+
end
|
|
378
|
+
|
|
345
379
|
def self.decode_span_raise_payload(raw_payload)
|
|
346
380
|
bytes = raw_payload.to_s.b
|
|
347
381
|
return "" if bytes.bytesize < 8
|
|
@@ -426,6 +460,9 @@ module Vivarium
|
|
|
426
460
|
when "file_getdents"
|
|
427
461
|
decoded = decode_file_getdents_payload(event.payload)
|
|
428
462
|
decoded.empty? ? event.payload.inspect : decoded
|
|
463
|
+
when "ssl_write"
|
|
464
|
+
decoded = decode_ssl_write_payload(event.payload)
|
|
465
|
+
"data_len=#{decoded[:data_len]} cap_len=#{decoded[:cap_len]}"
|
|
429
466
|
else
|
|
430
467
|
strip_to_first_null(event.payload).inspect
|
|
431
468
|
end
|
|
@@ -1339,6 +1376,42 @@ module Vivarium
|
|
|
1339
1376
|
return 0;
|
|
1340
1377
|
}
|
|
1341
1378
|
|
|
1379
|
+
int on_ssl_write(struct pt_regs *ctx)
|
|
1380
|
+
{
|
|
1381
|
+
u64 pid_tgid = bpf_get_current_pid_tgid();
|
|
1382
|
+
u32 pid = pid_tgid >> 32;
|
|
1383
|
+
u32 tid = (u32)pid_tgid;
|
|
1384
|
+
|
|
1385
|
+
if (!target_enabled(pid, tid)) {
|
|
1386
|
+
return 0;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
const char *buf = (const char *)PT_REGS_PARM2(ctx);
|
|
1390
|
+
int num = (int)PT_REGS_PARM3(ctx);
|
|
1391
|
+
if (!buf || num <= 0) {
|
|
1392
|
+
return 0;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
struct event_t ev = {};
|
|
1396
|
+
ev.pid = pid;
|
|
1397
|
+
__builtin_memcpy(ev.event_name, "ssl_write", 10);
|
|
1398
|
+
|
|
1399
|
+
u32 data_len = (u32)num;
|
|
1400
|
+
u32 cap = data_len;
|
|
1401
|
+
if (cap > #{SSL_WRITE_PAYLOAD_DATA_MAX}) {
|
|
1402
|
+
cap = #{SSL_WRITE_PAYLOAD_DATA_MAX};
|
|
1403
|
+
}
|
|
1404
|
+
__builtin_memcpy(&ev.payload[#{SSL_WRITE_PAYLOAD_DATA_LEN_OFFSET}], &data_len, sizeof(data_len));
|
|
1405
|
+
__builtin_memcpy(&ev.payload[#{SSL_WRITE_PAYLOAD_CAP_LEN_OFFSET}], &cap, sizeof(cap));
|
|
1406
|
+
if (bpf_probe_read_user(&ev.payload[#{SSL_WRITE_PAYLOAD_DATA_OFFSET}], cap, buf) < 0) {
|
|
1407
|
+
u32 zero = 0;
|
|
1408
|
+
__builtin_memcpy(&ev.payload[#{SSL_WRITE_PAYLOAD_CAP_LEN_OFFSET}], &zero, sizeof(zero));
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
submit_event(&ev);
|
|
1412
|
+
return 0;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1342
1415
|
int on_span_raise(struct pt_regs *ctx)
|
|
1343
1416
|
{
|
|
1344
1417
|
u64 pid_tgid = bpf_get_current_pid_tgid();
|
|
@@ -1370,8 +1443,10 @@ module Vivarium
|
|
|
1370
1443
|
}
|
|
1371
1444
|
CLANG
|
|
1372
1445
|
|
|
1373
|
-
def initialize(pin_dir: Vivarium.bpf_pin_dir)
|
|
1446
|
+
def initialize(pin_dir: Vivarium.bpf_pin_dir, ssl_trace: true, libssl_path: nil)
|
|
1374
1447
|
@pin_dir = pin_dir
|
|
1448
|
+
@ssl_trace = ssl_trace
|
|
1449
|
+
@libssl_path = libssl_path
|
|
1375
1450
|
end
|
|
1376
1451
|
|
|
1377
1452
|
def run
|
|
@@ -1392,6 +1467,8 @@ module Vivarium
|
|
|
1392
1467
|
|
|
1393
1468
|
bpf = RbBCC::BCC.new(text: program, usdt_contexts: [usdt])
|
|
1394
1469
|
|
|
1470
|
+
attach_ssl_write_uprobe(bpf) if @ssl_trace
|
|
1471
|
+
|
|
1395
1472
|
config_root_targets = bpf["config_root_targets"]
|
|
1396
1473
|
config_spawned_targets = bpf["config_spawned_targets"]
|
|
1397
1474
|
events_ringbuf = bpf["events"]
|
|
@@ -1416,6 +1493,39 @@ module Vivarium
|
|
|
1416
1493
|
|
|
1417
1494
|
private
|
|
1418
1495
|
|
|
1496
|
+
def attach_ssl_write_uprobe(bpf)
|
|
1497
|
+
path = resolve_libssl_path
|
|
1498
|
+
unless path
|
|
1499
|
+
warn "[vivariumd] libssl not found; SSL_write uprobe disabled " \
|
|
1500
|
+
"(set --libssl PATH or VIVARIUM_LIBSSL_PATH to override)"
|
|
1501
|
+
return
|
|
1502
|
+
end
|
|
1503
|
+
|
|
1504
|
+
bpf.attach_uprobe(name: path, sym: "SSL_write", fn_name: "on_ssl_write")
|
|
1505
|
+
puts "[vivariumd] SSL_write uprobe attached via #{path}"
|
|
1506
|
+
rescue StandardError => e
|
|
1507
|
+
warn "[vivariumd] SSL_write uprobe attach failed: #{e.class}: #{e.message}"
|
|
1508
|
+
end
|
|
1509
|
+
|
|
1510
|
+
def resolve_libssl_path
|
|
1511
|
+
if @libssl_path
|
|
1512
|
+
return @libssl_path if File.exist?(@libssl_path)
|
|
1513
|
+
|
|
1514
|
+
warn "[vivariumd] --libssl path does not exist: #{@libssl_path}"
|
|
1515
|
+
return nil
|
|
1516
|
+
end
|
|
1517
|
+
|
|
1518
|
+
env_path = ENV["VIVARIUM_LIBSSL_PATH"]
|
|
1519
|
+
if env_path && !env_path.empty?
|
|
1520
|
+
return env_path if File.exist?(env_path)
|
|
1521
|
+
|
|
1522
|
+
warn "[vivariumd] VIVARIUM_LIBSSL_PATH does not exist: #{env_path}"
|
|
1523
|
+
return nil
|
|
1524
|
+
end
|
|
1525
|
+
|
|
1526
|
+
LIBSSL_SEARCH_PATHS.find { |p| File.exist?(p) }
|
|
1527
|
+
end
|
|
1528
|
+
|
|
1419
1529
|
def ensure_root!
|
|
1420
1530
|
return if Process.uid.zero?
|
|
1421
1531
|
|
|
@@ -1557,13 +1667,13 @@ module Vivarium
|
|
|
1557
1667
|
end
|
|
1558
1668
|
end
|
|
1559
1669
|
|
|
1560
|
-
def self.observe(pin_dir: bpf_pin_dir, dest: $stdout, &block)
|
|
1561
|
-
return scoped_observe(pin_dir: pin_dir, dest: dest, &block) if block_given?
|
|
1670
|
+
def self.observe(pin_dir: bpf_pin_dir, dest: $stdout, filter: nil, &block)
|
|
1671
|
+
return scoped_observe(pin_dir: pin_dir, dest: dest, filter: filter, &block) if block_given?
|
|
1562
1672
|
|
|
1563
|
-
top_observe(pin_dir: pin_dir, dest: dest)
|
|
1673
|
+
top_observe(pin_dir: pin_dir, dest: dest, filter: filter)
|
|
1564
1674
|
end
|
|
1565
1675
|
|
|
1566
|
-
def self.top_observe(pin_dir: bpf_pin_dir, dest: $stdout)
|
|
1676
|
+
def self.top_observe(pin_dir: bpf_pin_dir, dest: $stdout, filter: nil)
|
|
1567
1677
|
require "vivarium_usdt"
|
|
1568
1678
|
|
|
1569
1679
|
store = MapStore.new(pin_dir: pin_dir)
|
|
@@ -1578,6 +1688,7 @@ module Vivarium
|
|
|
1578
1688
|
observer_pid: pid,
|
|
1579
1689
|
main_tid: main_tid,
|
|
1580
1690
|
method_id_queue: method_id_queue,
|
|
1691
|
+
filter: filter,
|
|
1581
1692
|
dest: dest
|
|
1582
1693
|
)
|
|
1583
1694
|
correlator.start
|
|
@@ -1592,7 +1703,7 @@ module Vivarium
|
|
|
1592
1703
|
session
|
|
1593
1704
|
end
|
|
1594
1705
|
|
|
1595
|
-
def self.scoped_observe(pin_dir:, dest:)
|
|
1706
|
+
def self.scoped_observe(pin_dir:, dest:, filter: nil)
|
|
1596
1707
|
require "vivarium_usdt"
|
|
1597
1708
|
|
|
1598
1709
|
store = MapStore.new(pin_dir: pin_dir)
|
|
@@ -1607,6 +1718,7 @@ module Vivarium
|
|
|
1607
1718
|
observer_pid: pid,
|
|
1608
1719
|
main_tid: main_tid,
|
|
1609
1720
|
method_id_queue: method_id_queue,
|
|
1721
|
+
filter: filter,
|
|
1610
1722
|
dest: dest
|
|
1611
1723
|
)
|
|
1612
1724
|
correlator.start
|
|
@@ -1626,6 +1738,11 @@ module Vivarium
|
|
|
1626
1738
|
allowlist = SPAN_ALLOWLIST
|
|
1627
1739
|
TracePoint.new(:call, :c_call, :return, :c_return, :raise) do |tp|
|
|
1628
1740
|
if tp.event == :raise
|
|
1741
|
+
# FIXME: handle threaded events in the future
|
|
1742
|
+
if tp.raised_exception.kind_of?(ThreadError)
|
|
1743
|
+
next
|
|
1744
|
+
end
|
|
1745
|
+
|
|
1629
1746
|
Vivarium::Usdt.raise(
|
|
1630
1747
|
tp.raised_exception.class.to_s,
|
|
1631
1748
|
tp.raised_exception.message.to_s,
|
|
@@ -1677,15 +1794,26 @@ module Vivarium
|
|
|
1677
1794
|
end
|
|
1678
1795
|
|
|
1679
1796
|
def self.run_daemon!(argv = ARGV)
|
|
1680
|
-
options = { pin_dir: bpf_pin_dir }
|
|
1797
|
+
options = { pin_dir: bpf_pin_dir, ssl_trace: true, libssl_path: nil }
|
|
1681
1798
|
OptionParser.new do |opts|
|
|
1682
|
-
opts.banner = "Usage: vivariumd [--pin-dir PATH]"
|
|
1799
|
+
opts.banner = "Usage: vivariumd [--pin-dir PATH] [--no-ssl-trace] [--libssl PATH]"
|
|
1683
1800
|
opts.on("--pin-dir PATH", "Pinned map directory") { |v| options[:pin_dir] = v }
|
|
1801
|
+
opts.on("--[no-]ssl-trace", "Attach OpenSSL SSL_write uprobe (default: enabled)") do |v|
|
|
1802
|
+
options[:ssl_trace] = v
|
|
1803
|
+
end
|
|
1804
|
+
opts.on("--libssl PATH", "Path to libssl.so to attach SSL_write to") do |v|
|
|
1805
|
+
options[:libssl_path] = v
|
|
1806
|
+
end
|
|
1684
1807
|
end.parse!(argv)
|
|
1685
1808
|
|
|
1686
|
-
Daemon.new(
|
|
1809
|
+
Daemon.new(
|
|
1810
|
+
pin_dir: options[:pin_dir],
|
|
1811
|
+
ssl_trace: options[:ssl_trace],
|
|
1812
|
+
libssl_path: options[:libssl_path]
|
|
1813
|
+
).run
|
|
1687
1814
|
end
|
|
1688
1815
|
end
|
|
1689
1816
|
|
|
1690
1817
|
require_relative "vivarium/correlator"
|
|
1818
|
+
require_relative "vivarium/display_filter"
|
|
1691
1819
|
require_relative "vivarium/tree_renderer"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: vivarium
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Uchio Kondo
|
|
@@ -70,6 +70,7 @@ files:
|
|
|
70
70
|
- examples/privilege_event_demo.rb
|
|
71
71
|
- examples/raise_demo.rb
|
|
72
72
|
- examples/signal_kill_demo.rb
|
|
73
|
+
- examples/ssl_write_demo.rb
|
|
73
74
|
- examples/sudo_attempt_demo.rb
|
|
74
75
|
- exe/vivarium
|
|
75
76
|
- exe/vivariumd
|
|
@@ -77,6 +78,8 @@ files:
|
|
|
77
78
|
- lib/vivarium.rb
|
|
78
79
|
- lib/vivarium/cli.rb
|
|
79
80
|
- lib/vivarium/correlator.rb
|
|
81
|
+
- lib/vivarium/display_filter.rb
|
|
82
|
+
- lib/vivarium/http_decoder.rb
|
|
80
83
|
- lib/vivarium/tree_renderer.rb
|
|
81
84
|
- lib/vivarium/version.rb
|
|
82
85
|
- logo-simple.png
|