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.
- checksums.yaml +4 -4
- data/CONTEXT.md +535 -0
- data/README.md +16 -12
- 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 +46 -0
- data/examples/signal_kill_demo.rb +5 -1
- data/examples/ssl_write_demo.rb +37 -0
- data/examples/sudo_attempt_demo.rb +18 -0
- data/exe/vivarium +6 -0
- data/image.png +0 -0
- data/lib/vivarium/cli.rb +40 -0
- data/lib/vivarium/correlator.rb +139 -0
- data/lib/vivarium/display_filter.rb +158 -0
- data/lib/vivarium/http_decoder.rb +237 -0
- data/lib/vivarium/tree_renderer.rb +593 -0
- data/lib/vivarium/version.rb +1 -1
- data/lib/vivarium.rb +446 -175
- data/logo-simple.png +0 -0
- metadata +31 -5
- data/lib/vivarium/logger.rb +0 -80
|
@@ -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
|