vivarium 0.1.1 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1bc495e3c2b8f9d99a2b1c84706b5e00913cc08831b3103b0460db0d4027c77b
4
- data.tar.gz: 1ee086243fdc153da5bac6a2bec8d0afe8e644ceac2fef2308b7723b89533338
3
+ metadata.gz: 973df86f00b8e2ca9d5e963958562e2f58822e13c0e4d33af53e85a8d38f12be
4
+ data.tar.gz: a5651655ef69c10e6b6eab89baf9f98e5d99a158a8e89a6fc940fe2d844ab636
5
5
  SHA512:
6
- metadata.gz: a203dd1cbaeff9fe0f4bf464a01ff49f131a104ca9e1eb14b6c08dd4371f49acf31c493119eee70114cafacbc6aa8a42976a9e6c230b1d828fe442e738c57c54
7
- data.tar.gz: a31a64e20f2f7d0956626658b5d6f91e0cd5333f97854ad3388c41cc0d3b0b8d233850c3bdaaf60663c9152d6cf35e4034355e37c50fc913687ad05295dffaba
6
+ metadata.gz: 3606b1f6df38a813e2b0cdf48aef48312be1a7c0bd3f20f88ca8a4c527c724733184c1c0d52f39fc9242282ab6d41ca18f6bcc977f4cafad0c5d4faa17a27d78
7
+ data.tar.gz: 4171eefdd7052d8634cd5a49267c8fe4f925e87f5b18691cb0721dd3f014efac68475196762e9ecc800627bcc529d9077e088b7d0366d91a6252867d3a0fc5f1
data/README.md CHANGED
@@ -18,10 +18,24 @@ The goal is to visualize which Ruby method context triggered low-level events.
18
18
  Implemented in this repository:
19
19
 
20
20
  - BPF LSM hook on `file_open`
21
+ - BPF LSM hooks on `inode_symlink`, `inode_link`, `inode_rename`, `path_chmod`
22
+ - BPF tracepoint on `sys_enter_getdents64`
23
+ - BPF tracepoint on `sys_enter_execve` (captures executable path and first few argv entries as `proc_exec`)
24
+ - BPF LSM hooks for suspicious behavior checks:
25
+ - `ptrace_access_check` (emits `ptrace_check`)
26
+ - `sb_mount` (emits `sb_mount`)
27
+ - `kernel_read_file` (emits `kernel_read_file`)
28
+ - `task_kill` (emits `task_kill`)
29
+ - `task_fix_setuid` (emits `setid_change`)
30
+ - `capable` for high-risk capabilities only (emits `capable_check`)
31
+ - `bprm_creds_from_file` (emits `bprm_creds`)
32
+ - BPF LSM hook on `socket_create` (flags unusual socket creation as `odd_socket`)
33
+ - BPF LSM hook on `socket_connect` (captures destination family/address/port as `sock_connect`)
34
+ - BPF tracepoints on `sys_enter_sendmsg`, `sys_enter_sendto`, `sys_enter_sendmmsg` (capture UDP/53 DNS QNAME raw bytes as `dns_req`)
21
35
  - Shared pinned maps on bpffs
22
36
  - `config_root_targets` (root PID -> 0/1)
23
37
  - `config_spawned_targets` (spawned TID -> 0/1)
24
- - `event_invoked` (array length 64 with `event_t` records)
38
+ - `event_invoked` (array length 1024 with `event_t` records)
25
39
  - `event_write_pos` (cursor for appending into `event_invoked`)
26
40
  - Ruby API `Vivarium.observe do ... end`
27
41
  - Registers current PID to `config_root_targets`
@@ -35,9 +49,10 @@ Implemented in this repository:
35
49
 
36
50
  ```c
37
51
  struct event_t {
52
+ u64 ktime_ns;
38
53
  u32 pid;
39
- char event_name[8]; // "path_open"
40
- char payload[64]; // opened path (truncated)
54
+ char event_name[16];
55
+ char payload[256];
41
56
  };
42
57
  ```
43
58
 
@@ -45,7 +60,7 @@ struct event_t {
45
60
 
46
61
  - Linux kernel/environment supporting BPF LSM
47
62
  - `libbcc` installed
48
- - `bpftool` installed (used to resolve `struct file::f_path` offset from BTF)
63
+ - `bpftool` installed (used to resolve `struct file::f_path` and `struct dentry::d_name` offsets from BTF)
49
64
  - root privileges for `vivariumd`
50
65
  - bpffs mounted (typically `/sys/fs/bpf`)
51
66
 
@@ -81,6 +96,46 @@ Vivarium.observe do
81
96
  end
82
97
  ```
83
98
 
99
+ 3) Network monitoring demo client:
100
+
101
+ ```bash
102
+ bundle exec ruby examples/network_client_demo.rb
103
+ ```
104
+
105
+ This demo intentionally triggers `sock_connect`, `dns_req`, and `odd_socket` events.
106
+
107
+ 4) File operation demo client (only touches `/tmp`):
108
+
109
+ ```bash
110
+ bundle exec ruby examples/file_operation_demo.rb
111
+ ```
112
+
113
+ This demo intentionally triggers `path_open`, `file_symlink`, `file_hardlink`, `file_rename`, `file_chmod`, and `file_getdents` events under `/tmp`.
114
+
115
+ 5) Execve demo client:
116
+
117
+ ```bash
118
+ bundle exec ruby examples/execve_demo.rb
119
+ ```
120
+
121
+ This demo intentionally triggers `proc_exec` with several argument patterns using direct `execve`-style process launches.
122
+
123
+ 6) Signal demo client:
124
+
125
+ ```bash
126
+ bundle exec ruby examples/signal_kill_demo.rb
127
+ ```
128
+
129
+ This demo forks a child process and sends `TERM` with `Process.kill`, which is useful for triggering `task_kill`.
130
+
131
+ 7) Privilege-related event demo client:
132
+
133
+ ```bash
134
+ bundle exec ruby examples/privilege_event_demo.rb
135
+ ```
136
+
137
+ This demo attempts setuid/setgid changes, sensitive file access, and `sudo` exec to trigger privilege-related events such as `setid_change`, `capable_check`, and `bprm_creds`.
138
+
84
139
  You can also start top-level observation without a block (it keeps observing until process exit):
85
140
 
86
141
  ```ruby
@@ -124,11 +179,16 @@ bundle exec vivariumd --pin-dir /sys/fs/bpf/vivarium
124
179
  ## Notes
125
180
 
126
181
  - Thread/Ractor-awareness is not yet implemented.
127
- - `event_invoked` uses fixed 64 slots and wraps around when full.
128
- - Payload is truncated to 64 bytes in kernel space.
182
+ - `event_invoked` uses fixed 1024 slots and wraps around when full.
183
+ - `payload` is 256 bytes in `event_t`; some event types intentionally use smaller structured slices inside that buffer.
184
+ - `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.
185
+ - 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
+ - `capable_check` is intentionally filtered to high-risk capabilities to reduce noise from extremely frequent `capable` hook calls.
129
188
  - Current output format is textual and intended for iteration.
130
189
  - `vivariumd` resolves `struct file::f_path` offset from `/sys/kernel/btf/vmlinux` at startup.
131
- - You can override offset manually with `VIVARIUM_FILE_F_PATH_OFFSET` if auto-detection fails.
190
+ - `vivariumd` also resolves `struct dentry::d_name` offset from `/sys/kernel/btf/vmlinux` at startup.
191
+ - You can override offsets manually with `VIVARIUM_FILE_F_PATH_OFFSET` and `VIVARIUM_DENTRY_D_NAME_OFFSET` if auto-detection fails.
132
192
  - `vivariumd` also prints `bpf_trace_printk` lines (`vivarium: pid=... path=...`) to its own logs.
133
193
 
134
194
  ## Contributing
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "tmpdir"
5
+ require "vivarium"
6
+
7
+ # Usage:
8
+ # 1) In another shell (root): sudo bundle exec vivariumd
9
+ # 2) Run this script: bundle exec ruby examples/execve_demo.rb
10
+
11
+ TMP_PREFIX = "vivarium-exec-demo"
12
+
13
+ def try_step(title)
14
+ puts "[exec-demo] #{title}"
15
+ yield
16
+ rescue StandardError => e
17
+ puts "[exec-demo] #{title} failed: #{e.class}: #{e.message}"
18
+ end
19
+
20
+ Dir.mktmpdir(TMP_PREFIX, "/tmp") do |dir|
21
+ output_path = File.join(dir, "execve-demo.out")
22
+
23
+ Vivarium.observe do
24
+ try_step("system echo with multiple args") do
25
+ system("/bin/echo", "hello", "from", "vivarium", out: File::NULL)
26
+ end
27
+
28
+ try_step("spawn env with explicit argv") do
29
+ pid = Process.spawn(
30
+ "/usr/bin/env",
31
+ "env",
32
+ "printf",
33
+ "execve-demo\n",
34
+ out: output_path,
35
+ err: File::NULL
36
+ )
37
+ Process.wait(pid)
38
+ end
39
+
40
+ try_step("spawn sleep with flag") do
41
+ pid = Process.spawn("/bin/sleep", "0")
42
+ Process.wait(pid)
43
+ end
44
+ end
45
+
46
+ puts "[exec-demo] output file: #{output_path}" if File.exist?(output_path)
47
+ end
48
+
49
+ puts "[exec-demo] done"
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+ require "tmpdir"
6
+ require "vivarium"
7
+
8
+ # Usage:
9
+ # 1) In another shell (root): sudo bundle exec vivariumd
10
+ # 2) Run this script: bundle exec ruby examples/file_operation_demo.rb
11
+
12
+ TMP_PREFIX = "vivarium-file-demo"
13
+
14
+ def try_step(title)
15
+ puts "[file-demo] #{title}"
16
+ yield
17
+ rescue StandardError => e
18
+ puts "[file-demo] #{title} failed: #{e.class}: #{e.message}"
19
+ end
20
+
21
+ Dir.mktmpdir(TMP_PREFIX, "/tmp") do |dir|
22
+ source_path = File.join(dir, "source.txt")
23
+ renamed_path = File.join(dir, "renamed.txt")
24
+ hardlink_path = File.join(dir, "hardlink.txt")
25
+ symlink_path = File.join(dir, "symlink.txt")
26
+
27
+ Vivarium.observe do
28
+ try_step("create source file") do
29
+ File.write(source_path, "vivarium sample\n")
30
+ File.read(source_path)
31
+ end
32
+
33
+ try_step("directory listing") do
34
+ Dir.children(dir)
35
+ end
36
+
37
+ try_step("rename file") do
38
+ File.rename(source_path, renamed_path)
39
+ File.read(renamed_path)
40
+ end
41
+
42
+ try_step("create hardlink") do
43
+ File.link(renamed_path, hardlink_path)
44
+ File.read(hardlink_path)
45
+ end
46
+
47
+ try_step("create symlink") do
48
+ File.symlink(renamed_path, symlink_path)
49
+ File.read(symlink_path)
50
+ end
51
+
52
+ try_step("chmod file") do
53
+ File.chmod(0o640, renamed_path)
54
+ File.stat(renamed_path)
55
+ end
56
+
57
+ try_step("list directory again") do
58
+ Dir.children(dir)
59
+ end
60
+ end
61
+
62
+ FileUtils.rm_f(symlink_path)
63
+ FileUtils.rm_f(hardlink_path)
64
+ FileUtils.rm_f(renamed_path)
65
+ FileUtils.rm_f(source_path)
66
+ end
67
+
68
+ puts "[file-demo] done"
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "socket"
5
+ require "vivarium"
6
+
7
+ # Usage:
8
+ # 1) In another shell (root): sudo bundle exec vivariumd
9
+ # 2) Run this script: bundle exec ruby examples/network_client_demo.rb
10
+
11
+ def try_step(title)
12
+ puts "[client] #{title}"
13
+ yield
14
+ rescue StandardError => e
15
+ puts "[client] #{title} failed: #{e.class}: #{e.message}"
16
+ end
17
+
18
+ Vivarium.observe do
19
+ # Likely emits sock_connect and dns_req via resolver traffic.
20
+ try_step("system: DNS lookup") do
21
+ system("getent hosts example.com >/dev/null 2>&1 || true")
22
+ system("getent hosts unknown.example.com >/dev/null 2>&1 || true")
23
+ end
24
+
25
+ # Likely emits sock_connect through HTTPS connection attempts.
26
+ try_step("system: curl") do
27
+ system("curl -I https://example.com >/dev/null 2>&1 || true")
28
+ end
29
+
30
+ # ICMP example (may require CAP_NET_RAW / root depending on environment).
31
+ try_step("system: ping") do
32
+ system("ping -c 1 example.com >/dev/null 2>&1 || true")
33
+ end
34
+
35
+ # Explicit connect path.
36
+ try_step("Ruby TCP connect") do
37
+ sock = TCPSocket.new("example.com", 80)
38
+ sock.close
39
+ end
40
+
41
+ # Raw DNS query payload, useful for dns_req decode testing.
42
+ try_step("Ruby UDP DNS query") do
43
+ dns_query = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" +
44
+ "\x09udp-query\x07example\x03com\x00" +
45
+ "\x00\x01\x00\x01"
46
+
47
+ udp = UDPSocket.new
48
+ begin
49
+ udp.connect("127.0.0.53", 53)
50
+ rescue StandardError
51
+ udp.connect("8.8.8.8", 53)
52
+ end
53
+ udp.send(dns_query, 0)
54
+ udp.close
55
+ end
56
+
57
+ # Explicit sendto path for DNS payload visibility.
58
+ try_step("Ruby UDP sendto DNS query") do
59
+ dns_query = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" +
60
+ "\x06sendto\x07example\x03com\x00" +
61
+ "\x00\x01\x00\x01"
62
+
63
+ udp = UDPSocket.new
64
+ udp.send(dns_query, 0, "127.0.0.53", 53)
65
+ udp.close
66
+ end
67
+
68
+ # Intentionally unusual socket type to trigger odd_socket.
69
+ try_step("Ruby odd socket attempt") do
70
+ af_packet = Socket.const_defined?(:AF_PACKET) ? Socket::AF_PACKET : 17
71
+ raw = Socket.new(af_packet, Socket::SOCK_RAW, 0)
72
+ raw.close
73
+ end
74
+ end
75
+
76
+ puts "[client] done"
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "vivarium"
5
+
6
+ # Usage:
7
+ # 1) In another shell (root): sudo bundle exec vivariumd
8
+ # 2) Run this script: bundle exec ruby examples/privilege_event_demo.rb
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 do
18
+ try_step("attempt setuid(0)") do
19
+ Process::UID.change_privilege(0)
20
+ end
21
+
22
+ try_step("attempt setgid(0)") do
23
+ Process::GID.change_privilege(0)
24
+ end
25
+
26
+ try_step("attempt opening /etc/shadow") do
27
+ File.read("/etc/shadow")
28
+ end
29
+
30
+ try_step("exec setuid-related binary") do
31
+ pid = Process.spawn("/usr/bin/sudo", "-n", "true", out: File::NULL, err: File::NULL)
32
+ Process.wait(pid)
33
+ rescue Errno::ENOENT
34
+ puts "[priv-demo] sudo not found; skipped"
35
+ end
36
+ end
37
+
38
+ puts "[priv-demo] done"
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "vivarium"
5
+
6
+ # Usage:
7
+ # 1) In another shell (root): sudo bundle exec vivariumd
8
+ # 2) Run this script: bundle exec ruby examples/signal_kill_demo.rb
9
+
10
+ def try_step(title)
11
+ puts "[signal-demo] #{title}"
12
+ yield
13
+ rescue StandardError => e
14
+ puts "[signal-demo] #{title} failed: #{e.class}: #{e.message}"
15
+ end
16
+
17
+ child_pid = nil
18
+
19
+ Vivarium.observe do
20
+ try_step("fork child process") do
21
+ child_pid = fork do
22
+ trap("TERM") { exit!(0) }
23
+ loop { sleep 1 }
24
+ end
25
+ puts "[signal-demo] child pid=#{child_pid}"
26
+ end
27
+
28
+ try_step("send TERM signal to child") do
29
+ sleep 0.1
30
+ Process.kill("TERM", child_pid)
31
+ end
32
+
33
+ try_step("wait child process") do
34
+ Process.wait(child_pid)
35
+ end
36
+ end
37
+
38
+ puts "[signal-demo] done"
@@ -5,6 +5,8 @@ require "json"
5
5
  module Vivarium
6
6
  class Logger
7
7
  FORMATS = %i[human json].freeze
8
+ ANSI_RED = "\e[31m"
9
+ ANSI_RESET = "\e[0m"
8
10
 
9
11
  # dest: IO object or file path string
10
12
  # format: :human or :json
@@ -45,7 +47,9 @@ module Vivarium
45
47
  @io.puts "[vivarium] #{events.size} event(s) at #{tp.defined_class}##{tp.method_id} (#{tp.event})"
46
48
  @io.puts " location: #{tp.path}:#{tp.lineno}"
47
49
  events.each do |event|
48
- @io.puts " pid=#{event.pid} #{event.event_name} payload=#{event.payload.inspect}"
50
+ severity = event.respond_to?(:severity) ? event.severity : Vivarium.event_severity(event.event_name)
51
+ line = " ktime_ns=#{event.ktime_ns} pid=#{event.pid} severity=#{severity} #{event.event_name} payload=#{Vivarium.render_event_payload(event)}"
52
+ @io.puts(severity == "high" ? "#{ANSI_RED}#{line}#{ANSI_RESET}" : line)
49
53
  end
50
54
  @io.puts " stack:"
51
55
  stack.each do |loc|
@@ -59,7 +63,15 @@ module Vivarium
59
63
  event: tp.event.to_s,
60
64
  path: tp.path,
61
65
  lineno: tp.lineno,
62
- events: events.map { |e| { pid: e.pid, event_name: e.event_name, payload: e.payload } },
66
+ events: events.map do |e|
67
+ {
68
+ ktime_ns: e.ktime_ns,
69
+ pid: e.pid,
70
+ severity: (e.respond_to?(:severity) ? e.severity : Vivarium.event_severity(e.event_name)),
71
+ event_name: e.event_name,
72
+ payload: Vivarium.render_event_payload(e)
73
+ }
74
+ end,
63
75
  stack: stack.map { |loc| "#{loc.path}:#{loc.lineno}:in #{loc.base_label}" }
64
76
  }
65
77
  @io.puts JSON.generate(entry)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Vivarium
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end