vivarium 0.2.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.
@@ -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
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "vivarium"
5
+
6
+ FILTER = {
7
+ include_events: %w[span_raise]
8
+ }.freeze
9
+
10
+ def try_step(title)
11
+ puts "[priv-demo] #{title}"
12
+ yield
13
+ rescue StandardError => e
14
+ puts "[priv-demo] #{title} failed: #{e.class}: #{e.message}"
15
+ end
16
+
17
+ Vivarium.observe(filter: FILTER) do
18
+ try_step("raise in main") do
19
+ raise "error in main"
20
+ end
21
+
22
+ try_step("raise in eval") do
23
+ eval("raise 'error in eval'")
24
+ end
25
+
26
+ try_step("raise in nested eval") do
27
+ eval(<<~RUBY)
28
+ eval(<<~INNER_RUBY)
29
+ begin
30
+ eval(<<~INNER_INNER_RUBY)
31
+ puts "Hi"
32
+ raise "error in nested nested eval"
33
+ INNER_INNER_RUBY
34
+ rescue StandardError => _
35
+ puts "Rescued in nested eval"
36
+ end
37
+ File.open("/etc/hosts")
38
+ INNER_RUBY
39
+ RUBY
40
+ end
41
+
42
+
43
+ try_step("raise in method") do
44
+ File.open("notfound")
45
+ end
46
+ 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"
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ def debug_output(msg)
4
+ $stderr.puts("[DEBUG] #{msg}") if ENV["VIVARIUM_DEBUG"]
5
+ end
6
+
7
+ debug_output "=== sudo attempt demo ==="
8
+
9
+ debug_output "[1] Attempting: sudo id"
10
+ system("sudo", "-n", "id")
11
+
12
+ debug_output "[2] Attempting: sudo cat /etc/shadow"
13
+ system("sudo", "-n", "cat", "/etc/shadow")
14
+
15
+ debug_output "[3] Attempting: sudo cat /proc/1/environ"
16
+ system("sudo", "-n", "cat", "/proc/1/environ")
17
+
18
+ debug_output "=== done ==="
data/exe/vivarium ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "vivarium"
5
+
6
+ Vivarium::CLI.run!
data/image.png ADDED
Binary file
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Vivarium
6
+ module CLI
7
+ def self.run!(argv = ARGV)
8
+ options = { pin_dir: Vivarium.bpf_pin_dir, dest: $stdout }
9
+ parser = OptionParser.new do |opts|
10
+ opts.banner = "Usage: vivarium [options] <command> [args]"
11
+ opts.separator ""
12
+ opts.separator "Commands:"
13
+ opts.separator " load <script> Load and observe a Ruby script"
14
+ opts.separator ""
15
+ opts.separator "Options:"
16
+ opts.on("--pin-dir PATH", "Pinned map directory") { |v| options[:pin_dir] = v }
17
+ opts.on("-o", "--output PATH", "Log output file (default: stdout)") { |v| options[:dest] = File.open(v, "a") }
18
+ end
19
+ parser.order!(argv)
20
+
21
+ command = argv.shift
22
+ case command
23
+ when "load"
24
+ run_load!(argv, options)
25
+ else
26
+ abort parser.help
27
+ end
28
+ end
29
+
30
+ def self.run_load!(argv, options)
31
+ script = argv.shift
32
+ abort "Usage: vivarium load <script>" unless script
33
+ abort "File not found: #{script}" unless File.exist?(script)
34
+
35
+ Vivarium.observe(pin_dir: options[:pin_dir], dest: options[:dest]) do
36
+ Kernel.load(File.expand_path(script))
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbbcc"
4
+ require "time"
5
+
6
+ module Vivarium
7
+ class Correlator
8
+ RawEvent = Struct.new(
9
+ :ktime_ns, :pid, :tid, :event_name, :payload,
10
+ keyword_init: true
11
+ )
12
+
13
+ EVENT_C_TYPE = <<~C
14
+ struct event_t {
15
+ u64 ktime_ns;
16
+ u32 pid;
17
+ u32 tid;
18
+ char event_name[16];
19
+ char payload[256];
20
+ };
21
+ C
22
+
23
+ POLL_TIMEOUT_MS = 200
24
+
25
+ def initialize(pin_dir:, observer_pid:, main_tid:, method_id_queue:, filter: nil, dest: $stdout)
26
+ @pin_dir = pin_dir
27
+ @observer_pid = observer_pid
28
+ @main_tid = main_tid
29
+ @method_id_queue = method_id_queue
30
+ @filter = filter
31
+ @dest = dest
32
+
33
+ @events = []
34
+ @events_mutex = Mutex.new
35
+ @method_table = {}
36
+ @stop_flag = false
37
+ @started = false
38
+
39
+ @ringbuf = RbBCC::RingBuf.from_pin(
40
+ File.join(@pin_dir, "events"),
41
+ EVENT_C_TYPE,
42
+ Vivarium::EVENTS_RINGBUF_PAGES
43
+ )
44
+ @ringbuf.open_ring_buffer do |_ctx, data, size|
45
+ capture_event(data, size)
46
+ end
47
+ end
48
+
49
+ def start
50
+ return if @started
51
+
52
+ @session_start_iso = Time.now.utc.iso8601(3)
53
+ @session_start_ktime = Vivarium.monotonic_ktime_ns
54
+ @thread = Thread.new { run }
55
+ @started = true
56
+ end
57
+
58
+ def stop
59
+ return unless @started
60
+ return if @stopped
61
+
62
+ @stop_flag = true
63
+ @thread&.join(POLL_TIMEOUT_MS * 4 / 1000.0 + 1)
64
+ @session_stop_iso = Time.now.utc.iso8601(3)
65
+ @session_stop_ktime = Vivarium.monotonic_ktime_ns
66
+
67
+ 3.times { safe_poll(50) }
68
+ drain_method_id_queue
69
+
70
+ events_snapshot = @events_mutex.synchronize { @events.dup }
71
+ method_table_snapshot = @method_table.dup
72
+ @stopped = true
73
+
74
+ TreeRenderer.new(
75
+ events: events_snapshot,
76
+ method_table: method_table_snapshot,
77
+ observer_pid: @observer_pid,
78
+ main_tid: @main_tid,
79
+ session_start_iso: @session_start_iso,
80
+ session_start_ktime: @session_start_ktime,
81
+ session_stop_iso: @session_stop_iso,
82
+ session_stop_ktime: @session_stop_ktime,
83
+ filter: @filter,
84
+ dest: @dest
85
+ ).render
86
+ end
87
+
88
+ private
89
+
90
+ def run
91
+ until @stop_flag
92
+ safe_poll(POLL_TIMEOUT_MS)
93
+ drain_method_id_queue
94
+ end
95
+ end
96
+
97
+ def safe_poll(timeout_ms)
98
+ @ringbuf.ring_buffer_poll(timeout_ms)
99
+ rescue StandardError => e
100
+ warn "[vivarium correlator] poll error: #{e.class}: #{e.message}"
101
+ end
102
+
103
+ def capture_event(data, size)
104
+ bytes = data[0, size].to_s.b
105
+ bytes = bytes.ljust(Vivarium::EVENT_STRUCT_SIZE, "\x00") if bytes.bytesize < Vivarium::EVENT_STRUCT_SIZE
106
+
107
+ ktime_ns = bytes[Vivarium::EVENT_TS_OFFSET, Vivarium::EVENT_TS_SIZE].unpack1("Q<")
108
+ pid = bytes[Vivarium::EVENT_PID_OFFSET, 4].unpack1("L<")
109
+ tid = bytes[Vivarium::EVENT_TID_OFFSET, 4].unpack1("L<")
110
+ event_name = Vivarium.c_string(bytes[Vivarium::EVENT_NAME_OFFSET, Vivarium::EVENT_NAME_SIZE])
111
+ payload = bytes[Vivarium::EVENT_PAYLOAD_OFFSET, Vivarium::EVENT_PAYLOAD_SIZE].to_s.b
112
+
113
+ @events_mutex.synchronize do
114
+ @events << RawEvent.new(
115
+ ktime_ns: ktime_ns,
116
+ pid: pid,
117
+ tid: tid,
118
+ event_name: event_name,
119
+ payload: payload
120
+ )
121
+ end
122
+ rescue StandardError => e
123
+ warn "[vivarium correlator] capture error: #{e.class}: #{e.message}"
124
+ end
125
+
126
+ def drain_method_id_queue
127
+ loop do
128
+ msg = begin
129
+ @method_id_queue.pop(true)
130
+ rescue ThreadError
131
+ return
132
+ end
133
+
134
+ method_id, signature = msg
135
+ @method_table[method_id] = signature
136
+ end
137
+ end
138
+ end
139
+ 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