redhound 2.0.0.rc1 → 2.0.0.rc2

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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/README.md +1 -1
  4. data/docs/API.md +29 -1
  5. data/docs/MIGRATION.md +1 -1
  6. data/docs/PROTOCOLS.md +10 -1
  7. data/docs/USAGE.md +5 -1
  8. data/docs/VALIDATION.md +119 -3
  9. data/docs/json-schema.json +2 -2
  10. data/lib/redhound/analysis/flow_key.rb +7 -1
  11. data/lib/redhound/analysis/flow_table.rb +12 -5
  12. data/lib/redhound/analysis/ip_reassembler.rb +44 -11
  13. data/lib/redhound/analysis/stats.rb +14 -2
  14. data/lib/redhound/analysis/tcp_reassembler.rb +68 -16
  15. data/lib/redhound/analysis/tcp_stream.rb +7 -2
  16. data/lib/redhound/analysis.rb +30 -2
  17. data/lib/redhound/capture/bsd/bpf_device.rb +3 -0
  18. data/lib/redhound/capture/bsd/ifreq.rb +41 -0
  19. data/lib/redhound/capture/file_source.rb +135 -4
  20. data/lib/redhound/capture/interface.rb +5 -1
  21. data/lib/redhound/capture/linux/packet_socket.rb +53 -9
  22. data/lib/redhound/capture/linux/tpacket_v3.rb +5 -0
  23. data/lib/redhound/capture.rb +3 -2
  24. data/lib/redhound/cli/command.rb +1 -1
  25. data/lib/redhound/dissector.rb +18 -4
  26. data/lib/redhound/engine.rb +3 -0
  27. data/lib/redhound/field.rb +1 -1
  28. data/lib/redhound/file/format.rb +8 -1
  29. data/lib/redhound/file/pcap_writer.rb +10 -3
  30. data/lib/redhound/file/pcapng_reader.rb +19 -7
  31. data/lib/redhound/file/pcapng_writer.rb +22 -14
  32. data/lib/redhound/filter/bpf/vm.rb +3 -2
  33. data/lib/redhound/output/follow.rb +6 -1
  34. data/lib/redhound/output/summary.rb +29 -4
  35. data/lib/redhound/output/timestamp.rb +2 -2
  36. data/lib/redhound/output/tree.rb +2 -1
  37. data/lib/redhound/packet.rb +18 -4
  38. data/lib/redhound/protocols/data.rb +3 -1
  39. data/lib/redhound/protocols/dhcp.rb +29 -13
  40. data/lib/redhound/protocols/http.rb +5 -3
  41. data/lib/redhound/protocols/icmp.rb +55 -41
  42. data/lib/redhound/protocols/ipv6.rb +5 -2
  43. data/lib/redhound/protocols/ntp.rb +2 -4
  44. data/lib/redhound/protocols/tcp.rb +17 -9
  45. data/lib/redhound/protocols/tunnel.rb +2 -2
  46. data/lib/redhound/stream_dissector.rb +2 -2
  47. data/lib/redhound/version.rb +1 -1
  48. data/sig/generated/redhound/analysis/flow_table.rbs +6 -2
  49. data/sig/generated/redhound/analysis/ip_reassembler.rbs +12 -0
  50. data/sig/generated/redhound/analysis/stats.rbs +3 -0
  51. data/sig/generated/redhound/analysis/tcp_reassembler.rbs +12 -0
  52. data/sig/generated/redhound/capture/bsd/ifreq.rbs +15 -0
  53. data/sig/generated/redhound/capture/file_source.rbs +33 -0
  54. data/sig/generated/redhound/capture/linux/packet_socket.rbs +7 -0
  55. data/sig/generated/redhound/dissector.rbs +4 -0
  56. data/sig/generated/redhound/file/format.rbs +6 -0
  57. data/sig/generated/redhound/file/pcapng_reader.rbs +2 -2
  58. data/sig/generated/redhound/filter/bpf/vm.rbs +2 -0
  59. data/sig/generated/redhound/packet.rbs +5 -0
  60. data/sig/generated/redhound/protocols/data.rbs +2 -0
  61. data/sig/generated/redhound/protocols/dhcp.rbs +7 -4
  62. metadata +3 -1
@@ -5,7 +5,7 @@ module Redhound
5
5
  # Base class and declarative fixed-header DSL for custom protocols.
6
6
  class Dissector
7
7
  # Unpack formats and byte lengths supported by the header DSL.
8
- TYPES = { uint8: ['C', 1], uint16: ['n', 2], uint32: ['N', 4], uint64: ['Q>', 8],
8
+ TYPES = { int8: ['c', 1], uint8: ['C', 1], uint16: ['n', 2], uint32: ['N', 4], uint64: ['Q>', 8],
9
9
  mac: ['a6', 6], ipv4: ['N', 4], ipv6: ['a16', 16] }.freeze
10
10
 
11
11
  # Build a single unpack template and its field descriptions.
@@ -28,6 +28,9 @@ module Redhound
28
28
  @index += 1
29
29
  end
30
30
  # @rbs (Symbol key, String name, **untyped opts) -> void
31
+ # Append a signed 8-bit field.
32
+ def int8(key, name, **opts) = field(:int8, key, name, **opts)
33
+ # @rbs (Symbol key, String name, **untyped opts) -> void
31
34
  # Append an unsigned 8-bit field.
32
35
  def uint8(key, name, **opts) = field(:uint8, key, name, **opts)
33
36
  # @rbs (Symbol key, String name, **untyped opts) -> void
@@ -112,17 +115,28 @@ module Redhound
112
115
  def parse(ctx, cursor)
113
116
  header = self.class.compiled_header
114
117
  length = header&.length || 0
115
- raw = cursor.unpack(self.class.header_template, length)
118
+ truncated = cursor.remaining < length
119
+ raw = if truncated
120
+ cursor.bytes(0, cursor.remaining).unpack(self.class.header_template)
121
+ else
122
+ cursor.unpack(self.class.header_template, length)
123
+ end
116
124
  defs = header ? header.definitions.dup : Array.new
125
+ defs.select! { |d| d.offset + d.length <= cursor.remaining } if truncated
117
126
  values = {} #: Hash[Symbol, untyped]
118
127
  defs.each do |d|
119
128
  val = raw[d.index]
120
129
  val = ((val >> d.shift) & d.mask) * d.scale if d.shift
121
130
  values[d.key] = val
122
131
  end
123
- layer = Layer.new(self.class.protocol_id, cursor.start, length, cursor.limit,
132
+ layer = Layer.new(self.class.protocol_id, cursor.start, truncated ? cursor.remaining : length, cursor.limit,
124
133
  definitions: defs, values: values, embedded: ctx.embedded)
125
- dissect(ctx, layer)
134
+ return layer.diagnose(:note, :truncated, "need #{length} header bytes, have #{cursor.remaining}") if truncated
135
+ begin
136
+ dissect(ctx, layer)
137
+ rescue Cursor::Truncated => e
138
+ layer.diagnose(:note, :truncated, e.message)
139
+ end
126
140
  layer
127
141
  end
128
142
  # @rbs (Context ctx, Layer layer) -> void
@@ -29,6 +29,9 @@ module Redhound
29
29
  break if layer.error?
30
30
  klass = next_klass || Protocols::Data
31
31
  end
32
+ if packet.meta[:possible_truncation]
33
+ ctx.layers.first.diagnose(:warn, :capture_truncated, 'receive buffer limit reached; original packet length is unknown')
34
+ end
32
35
  ctx.layers
33
36
  end
34
37
 
@@ -30,7 +30,7 @@ module Redhound
30
30
  # Decoded value; addresses are returned in human-readable text.
31
31
  def value
32
32
  case type
33
- when :ipv4 then [@raw_value].pack('N').unpack('C4').join('.')
33
+ when :ipv4 then "#{(@raw_value >> 24) & 255}.#{(@raw_value >> 16) & 255}.#{(@raw_value >> 8) & 255}.#{@raw_value & 255}"
34
34
  when :ipv6 then IPAddr.new_ntoh(@raw_value).to_s
35
35
  when :mac then @raw_value.unpack('H2' * 6).join(':')
36
36
  else @raw_value
@@ -7,6 +7,9 @@ module Redhound
7
7
  # @api private
8
8
  module Format
9
9
  MAX_RECORD = 16 * 1024 * 1024
10
+ MAX_INTERFACES = 4096
11
+ MAX_INTERFACE_METADATA = 16 * 1024 * 1024
12
+ MAX_OPTIONS = 4096
10
13
  PCAP_MAGICS = {
11
14
  "\xd4\xc3\xb2\xa1".b => [:little, 1_000], "\xa1\xb2\xc3\xd4".b => [:big, 1_000],
12
15
  "\x4d\x3c\xb2\xa1".b => [:little, 1], "\xa1\xb2\x3c\x4d".b => [:big, 1]
@@ -19,7 +22,11 @@ module Redhound
19
22
  raise FileFormatError, "invalid record length #{length}" unless length.between?(0, MAX_RECORD)
20
23
  return ''.b if length.zero?
21
24
 
22
- result = ''.b
25
+ result = io.read(length)
26
+ return result if result && result.bytesize == length
27
+ return nil if eof && (!result || result.empty?)
28
+
29
+ result ||= ''.b
23
30
  while result.bytesize < length
24
31
  part = io.read(length - result.bytesize)
25
32
  if !part || part.empty?
@@ -24,12 +24,16 @@ module Redhound
24
24
  @closed = false
25
25
  magic = precision == :nano ? 0xa1b23c4d : 0xa1b2c3d4
26
26
  write_bytes([magic, 2, 4, 0, 0, snaplen, @linktype].pack('VvvV4'))
27
+ rescue StandardError, Interrupt
28
+ @io.close if @owned && @io && !@io.closed?
29
+ raise
27
30
  end
28
31
 
29
32
  # @rbs (Packet packet) -> self
30
33
  def write(packet)
31
34
  raise IOError, 'writer is closed' if @closed
32
35
  raise FileFormatError, 'pcap cannot store multiple link types; use pcapng' unless packet.linktype == @linktype
36
+ raise FileFormatError, 'packet original length exceeds unsigned 32-bit range' unless packet.original_length <= 0xffff_ffff
33
37
 
34
38
  seconds, nanos = packet.timestamp_ns.divmod(1_000_000_000)
35
39
  raise FileFormatError, 'pcap timestamp exceeds unsigned 32-bit seconds' unless seconds.between?(0, 0xffff_ffff)
@@ -56,9 +60,12 @@ module Redhound
56
60
  def close
57
61
  return if @closed
58
62
 
59
- @io.flush
60
- @io.close if @owned
61
- @closed = true
63
+ begin
64
+ @io.flush
65
+ ensure
66
+ @closed = true
67
+ @io.close if @owned && !@io.closed?
68
+ end
62
69
  end
63
70
 
64
71
  private
@@ -17,6 +17,7 @@ module Redhound
17
17
  @packet_counts = {} #: Hash[Integer, Integer]
18
18
  @stats = Capture::Stats.new
19
19
  @number = 0
20
+ @interface_metadata = 0
20
21
  @magic = magic
21
22
  @integer_format = 'V'
22
23
  read_block
@@ -88,10 +89,11 @@ module Redhound
88
89
  # @rbs (String body) -> void
89
90
  def read_interface(body)
90
91
  require_size(body, 8)
92
+ raise FileFormatError, 'pcapng interface limit exceeded' if @interfaces.size >= Format::MAX_INTERFACES
91
93
  linktype, _reserved, snaplen = body.unpack(@integer_format == 'V' ? 'vvV' : 'nnN') #: [Integer, Integer, Integer]
92
94
  raise FileFormatError, 'invalid pcapng snaplen' if snaplen > Format::MAX_RECORD
93
95
 
94
- opts = options(body, 8)
96
+ opts = options(body, 8, [2, 3, 9, 11, 14])
95
97
  resolution = opts.fetch(9, ["\x06".b]).fetch(0)
96
98
  raise FileFormatError, 'invalid pcapng timestamp resolution' unless resolution.bytesize == 1
97
99
 
@@ -105,6 +107,11 @@ module Redhound
105
107
  interface = Capture::Interface.new(name: opts.fetch(2, ["interface#{index}"]).fetch(0), index:, linktype:, snaplen:,
106
108
  description: opts[3]&.first, filter: filter_option && filter_option.getbyte(0) == 0 ? filter_option.byteslice(1..) : nil,
107
109
  meta: { timestamp_units: units, timestamp_offset: offset.unpack1(@integer_format == 'V' ? 'q<' : 'q>') })
110
+ metadata_size = 512 + interface.name.bytesize + (interface.description&.bytesize || 0) + (interface.filter&.bytesize || 0)
111
+ if @interface_metadata + metadata_size > Format::MAX_INTERFACE_METADATA
112
+ raise FileFormatError, 'pcapng interface metadata limit exceeded'
113
+ end
114
+ @interface_metadata += metadata_size
108
115
  @section_interfaces << interface
109
116
  @interfaces << interface
110
117
  end
@@ -116,7 +123,7 @@ module Redhound
116
123
  interface = interface_at(index)
117
124
  Format.check_lengths(caplen, original_length, interface.snaplen)
118
125
  require_size(body, 20 + Format.padded(caplen))
119
- opts = options(body, 20 + Format.padded(caplen))
126
+ opts = options(body, 20 + Format.padded(caplen), [1, 2])
120
127
  flags_option = opts[2]&.first
121
128
  raise FileFormatError, 'invalid pcapng packet flags' if flags_option && flags_option.bytesize != 4
122
129
 
@@ -150,7 +157,7 @@ module Redhound
150
157
  require_size(body, 12)
151
158
  index = body.unpack1(@integer_format) #: Integer
152
159
  interface = interface_at(index)
153
- opts = options(body, 12)
160
+ opts = options(body, 12, [4, 5, 7, 8])
154
161
  values = {} #: Hash[Symbol, Integer]
155
162
  { received: 4, dropped: 5, if_dropped: 7, captured: 8 }.each do |name, code|
156
163
  option = opts[code]&.first
@@ -181,9 +188,10 @@ module Redhound
181
188
  @section_interfaces.fetch(index) { raise FileFormatError, "pcapng references unknown interface #{index}" }
182
189
  end
183
190
 
184
- # @rbs (String body, Integer offset) -> Hash[Integer, Array[String]]
185
- def options(body, offset)
191
+ # @rbs (String body, Integer offset, Array[Integer] allowed) -> Hash[Integer, Array[String]]
192
+ def options(body, offset, allowed)
186
193
  result = {} #: Hash[Integer, Array[String]]
194
+ count = 0
187
195
  while offset < body.bytesize
188
196
  require_size(body, offset + 4)
189
197
  code, length = body.unpack(@integer_format == 'V' ? 'vv' : 'nn', offset:) #: [Integer, Integer]
@@ -192,9 +200,13 @@ module Redhound
192
200
  raise FileFormatError, 'invalid pcapng end of options' unless length.zero?
193
201
  break
194
202
  end
203
+ count += 1
204
+ raise FileFormatError, 'pcapng option count limit exceeded' if count > Format::MAX_OPTIONS
195
205
  require_size(body, offset + Format.padded(length))
196
- value = body.byteslice(offset, length) #: String
197
- (result[code] ||= []) << value
206
+ if allowed.include?(code)
207
+ value = body.byteslice(offset, length) #: String
208
+ (result[code] ||= []) << value
209
+ end
198
210
  offset += Format.padded(length)
199
211
  end
200
212
  result
@@ -26,12 +26,16 @@ module Redhound
26
26
  opts = { 3 => RUBY_PLATFORM, 4 => "Redhound #{VERSION}" }
27
27
  opts[1] = comment if comment
28
28
  write_block(0x0a0d0d0a, [0x1a2b3c4d, 1, 0, 0xffff_ffff_ffff_ffff].pack('VvvQ<') + options(opts))
29
+ rescue StandardError, Interrupt
30
+ @io.close if @owned && @io && !@io.closed?
31
+ raise
29
32
  end
30
33
 
31
34
  # @rbs (Packet packet) -> self
32
35
  def write(packet)
33
36
  raise IOError, 'writer is closed' if @closed
34
37
  raise FileFormatError, 'pcapng timestamp must be nonnegative' if packet.timestamp_ns.negative?
38
+ raise FileFormatError, 'packet original length exceeds unsigned 32-bit range' unless packet.original_length <= 0xffff_ffff
35
39
 
36
40
  index = ensure_interface(packet)
37
41
  data = packet.data.byteslice(0, @snaplen)
@@ -76,20 +80,23 @@ module Redhound
76
80
  def close
77
81
  return if @closed
78
82
 
79
- @interfaces.each_value do |index|
80
- stats = @statistics[index]
81
- start_time, end_time = @start_times.fetch(index, 0), @end_times.fetch(index, 0)
82
- opts = { 2 => timestamp_words(start_time), 3 => timestamp_words(end_time),
83
- 8 => [@counts.fetch(index, 0)].pack('Q<') }
84
- if stats
85
- opts[4], opts[5], opts[7] = [stats.received].pack('Q<'), [stats.dropped].pack('Q<'), [stats.if_dropped].pack('Q<')
86
- opts[6] = [stats.captured].pack('Q<')
83
+ begin
84
+ @interfaces.each_value do |index|
85
+ stats = @statistics[index]
86
+ start_time, end_time = @start_times.fetch(index, 0), @end_times.fetch(index, 0)
87
+ opts = { 2 => timestamp_words(start_time), 3 => timestamp_words(end_time),
88
+ 8 => [@counts.fetch(index, 0)].pack('Q<') }
89
+ if stats
90
+ opts[4], opts[5], opts[7] = [stats.received].pack('Q<'), [stats.dropped].pack('Q<'), [stats.if_dropped].pack('Q<')
91
+ opts[6] = [stats.captured].pack('Q<')
92
+ end
93
+ write_block(5, [index].pack('V') + timestamp_words(end_time) + options(opts))
87
94
  end
88
- write_block(5, [index].pack('V') + timestamp_words(end_time) + options(opts))
95
+ @io.flush
96
+ ensure
97
+ @closed = true
98
+ @io.close if @owned && !@io.closed?
89
99
  end
90
- @io.flush
91
- @io.close if @owned
92
- @closed = true
93
100
  end
94
101
 
95
102
  private
@@ -99,14 +106,15 @@ module Redhound
99
106
  interface = packet.interface
100
107
  key = [interface&.object_id || 0, packet.linktype]
101
108
  return @interfaces[key] if @interfaces.key?(key)
109
+ raise FileFormatError, 'pcapng interface limit exceeded' if @interfaces.size >= Format::MAX_INTERFACES
102
110
 
103
111
  index = @interfaces.size
104
- @interfaces[key] = index
105
112
  opts = { 2 => interface&.name || "interface#{index}", 9 => "\x09".b, 12 => RUBY_PLATFORM }
106
113
  opts[3] = interface.description if interface&.description
107
- expression = interface&.filter || @filter
114
+ expression = @filter || interface&.filter
108
115
  opts[11] = "\0" + expression if expression
109
116
  write_block(1, [packet.linktype, 0, @snaplen].pack('vvV') + options(opts))
117
+ @interfaces[key] = index
110
118
  index
111
119
  end
112
120
 
@@ -8,6 +8,7 @@ module Redhound
8
8
  module BPF
9
9
  # @api private
10
10
  class VM
11
+ LOAD_FORMATS = { 0x00 => 'N', 0x08 => 'n', 0x10 => 'C' }.freeze
11
12
  # @rbs (Array[[Integer, Integer, Integer, Integer]] instructions) -> void
12
13
  def initialize(instructions)
13
14
  Validator.validate!(instructions)
@@ -34,9 +35,9 @@ module Redhound
34
35
  if k >= 0xfffff000 && (code & 0xe0) == 0x20
35
36
  ancillary(k, packet.meta)
36
37
  else
37
- size = { 0x00 => 4, 0x08 => 2, 0x10 => 1 }.fetch(code & 0x18)
38
+ size = 4 >> ((code & 0x18) >> 3)
38
39
  return 0 if offset.negative? || offset + size > packet.data.bytesize
39
- packet.data.unpack1({ 4 => 'N', 2 => 'n', 1 => 'C' }.fetch(size), offset: offset)
40
+ packet.data.unpack1(LOAD_FORMATS.fetch(code & 0x18), offset: offset)
40
41
  end
41
42
  end
42
43
  if code == 0xb1
@@ -50,7 +50,12 @@ module Redhound
50
50
  end
51
51
  end
52
52
  ensure
53
- close
53
+ original_error = $!
54
+ begin
55
+ close
56
+ rescue StandardError
57
+ raise unless original_error
58
+ end
54
59
  end
55
60
  # @rbs () -> void
56
61
  def close = @file.close!
@@ -15,18 +15,33 @@ module Redhound
15
15
  # @rbs (Packet packet) -> String
16
16
  def line(packet)
17
17
  parts = [@timestamp.format(packet)]
18
+ if packet.interface
19
+ parts << [packet.interface.name, packet.direction&.to_s&.capitalize].compact.join(' ')
20
+ end
18
21
  if @link_layer && (eth = packet[:eth])
19
22
  parts << "#{eth.display(:src)} > #{eth.display(:dst)}, ethertype #{eth.display(:type)}, length #{packet.original_length}:"
20
23
  end
21
- network = packet.layers.find { |l| %i[ipv4 ipv6].include?(l.protocol) && !l.embedded }
22
- transport = packet.layers.find { |l| %i[tcp udp].include?(l.protocol) && !l.embedded }
24
+ network = nil #: Layer?
25
+ transport = nil #: Layer?
26
+ layer = nil #: Layer?
27
+ packet.layers.each do |current|
28
+ next if current.embedded
29
+ case current.protocol
30
+ when :ipv4, :ipv6
31
+ network, transport, layer = current, nil, nil
32
+ when :tcp, :udp
33
+ transport = layer = current
34
+ when :data, :raw, :eth, :vlan, :ipv6_ext, :sll, :sll2, :null
35
+ else layer = current
36
+ end
37
+ end
23
38
  if network
24
39
  src, dst = address(network.display(:src)), address(network.display(:dst))
25
40
  src += ".#{transport[:srcport]}" if transport
26
41
  dst += ".#{transport[:dstport]}" if transport
27
42
  parts << "#{network.protocol == :ipv4 ? 'IP' : 'IP6'} #{src} > #{dst}:"
28
43
  end
29
- layer = packet.layers.reverse.find { |l| !%i[data raw eth vlan ipv4 ipv6 ipv6_ext sll sll2 null].include?(l.protocol) && !l.embedded } || packet.layers.last
44
+ layer ||= packet.layers.last
30
45
  klass = layer && Registry.default.protocols[layer.protocol]
31
46
  parts << if @quick && transport
32
47
  "#{transport.protocol.to_s.upcase}, length #{[transport.payload_end - transport.payload_offset, 0].max}"
@@ -37,11 +52,21 @@ module Redhound
37
52
  end
38
53
  if @verbosity.positive? && network
39
54
  parts << "ttl #{network[:ttl] || network[:hlim]}"
55
+ parts << "id #{network.display(:id)}, length #{network[:len]}" if network.protocol == :ipv4
56
+ parts << "checksum #{network.display(:checksum)}" if network[:checksum]
57
+ if transport
58
+ parts << "#{transport.protocol} checksum #{transport.display(:checksum)}"
59
+ options = transport.fields.select { |field| field.name.start_with?('tcp.options.') }
60
+ unless options.empty?
61
+ parts << 'options [' + options.map { |field| "#{field.name.delete_prefix('tcp.options.')} #{field.display}" }.join(', ') + ']'
62
+ end
63
+ end
40
64
  end
41
65
  diagnostics = packet.layers.flat_map(&:diagnostics)
42
66
  parts << diagnostics.map { |d| "[#{d.code}]" }.join(' ') unless diagnostics.empty?
43
67
  # All protocol summaries and interface names pass this terminal boundary.
44
- parts.reject(&:empty?).join(' ').b.gsub(/[^\x20-\x7e]/n) { |c| Kernel.format('\\x%02x', c.getbyte(0)) }
68
+ text = parts.reject(&:empty?).join(' ').b
69
+ text.match?(/[^\x20-\x7e]/n) ? text.gsub(/[^\x20-\x7e]/n) { |c| Kernel.format('\\x%02x', c.getbyte(0)) } : text
45
70
  end
46
71
  # @rbs (Packet packet, untyped io) -> void
47
72
  def format(packet, io) = io.write(line(packet) + "\n")
@@ -26,9 +26,9 @@ module Redhound
26
26
  value
27
27
  end
28
28
  # @rbs (Integer ns) -> String
29
- def fraction(ns) = Kernel.format(@precision == :nano ? '%09d' : '%06d', @precision == :nano ? ns.abs % 1_000_000_000 : (ns.abs % 1_000_000_000) / 1000)
29
+ def fraction(ns) = Kernel.format(@precision == :nano ? '%09d' : '%06d', @precision == :nano ? ns % 1_000_000_000 : (ns % 1_000_000_000) / 1000)
30
30
  # @rbs (Integer ns) -> String
31
- def seconds(ns) = "#{ns.negative? ? '-' : ''}#{ns.abs / 1_000_000_000}.#{fraction(ns)}"
31
+ def seconds(ns) = "#{ns.negative? ? '-' : ''}#{ns.abs / 1_000_000_000}.#{fraction(ns.abs)}"
32
32
  end
33
33
  end
34
34
  end
@@ -8,7 +8,8 @@ module Redhound
8
8
  class Tree
9
9
  # @rbs (Packet packet, untyped io) -> void
10
10
  def format(packet, io)
11
- io.write("Frame #{packet.number}: #{packet.original_length} bytes on wire, #{packet.caplen} captured, #{packet.time.utc.strftime('%Y-%m-%d %H:%M:%S')}.#{Kernel.format('%09d', packet.time.nsec)}\n")
11
+ interface = packet.interface ? safe([packet.interface.name, packet.direction&.to_s&.capitalize].compact.join(' ')) + ', ' : ''
12
+ io.write("Frame #{packet.number}: #{packet.original_length} bytes on wire, #{packet.caplen} captured, #{interface}#{packet.time.utc.strftime('%Y-%m-%d %H:%M:%S')}.#{Kernel.format('%09d', packet.time.nsec)}\n")
12
13
  packet.layers.each_with_index do |layer, depth|
13
14
  klass = Registry.default.protocols[layer.protocol]
14
15
  indent = ' ' * depth
@@ -12,9 +12,13 @@ module Redhound
12
12
  # Copy binary data and preserve capture metadata; reject impossible original lengths.
13
13
  def initialize(data, timestamp_ns: 0, original_length: nil, linktype: 1, interface: nil, direction: nil, number: 1, meta: {})
14
14
  @data = data.b.freeze
15
- @timestamp_ns, @original_length = timestamp_ns, original_length || data.bytesize
15
+ raise ArgumentError, 'timestamp_ns must be an Integer' unless timestamp_ns.is_a?(Integer)
16
+ raise ArgumentError, 'number must be a positive Integer' unless number.is_a?(Integer) && number.positive?
17
+ raise ArgumentError, 'direction must be in, out or nil' unless direction.nil? || direction == :in || direction == :out
18
+ @timestamp_ns, @original_length = timestamp_ns, original_length.nil? ? data.bytesize : original_length
19
+ raise ArgumentError, 'original length must be an Integer' unless @original_length.is_a?(Integer)
16
20
  raise ArgumentError, 'original length is shorter than captured length' if @original_length < data.bytesize
17
- @linktype = linktype.is_a?(Integer) ? linktype : Capture::Linktype.resolve(linktype)
21
+ @linktype = Capture::Linktype.resolve(linktype)
18
22
  @interface, @direction, @number, @meta = interface, direction, number, meta.dup
19
23
  @engine = nil # @rbs Engine?
20
24
  end
@@ -56,8 +60,18 @@ module Redhound
56
60
  # Return a JSON-compatible packet matching docs/json-schema.json.
57
61
  def to_h
58
62
  { frame: { number: @number, time_epoch_ns: @timestamp_ns, time: time.utc.strftime('%Y-%m-%dT%H:%M:%S.') + format('%09dZ', time.nsec),
59
- caplen: caplen, len: @original_length, interface: @interface&.name&.dup&.force_encoding(Encoding::UTF_8)&.scrub, direction: @direction, linktype: @linktype },
60
- layers: layers.map(&:to_h), diagnostics: layers.flat_map { |l| l.diagnostics.map { |d| { layer: l.protocol, severity: d.severity, code: d.code, message: d.message, field: d.field } } } }
63
+ caplen: caplen, len: @original_length, interface: json_text(@interface&.name), direction: @direction, linktype: @linktype },
64
+ layers: layers.map(&:to_h), diagnostics: layers.flat_map { |l| l.diagnostics.map { |d| { layer: l.protocol, severity: d.severity, code: d.code, message: json_text(d.message), field: json_text(d.field) } } } }
65
+ end
66
+
67
+ private
68
+
69
+ # @rbs (String? text) -> String?
70
+ def json_text(text)
71
+ return nil unless text
72
+ utf8 = text.dup.force_encoding(Encoding::UTF_8)
73
+ return utf8 if utf8.valid_encoding?
74
+ text.unpack1('H*') #: String
61
75
  end
62
76
  end
63
77
  end
@@ -6,11 +6,13 @@ module Redhound
6
6
  module Protocols
7
7
  # @api private
8
8
  class Data < Dissector
9
+ LENGTH_FIELD = FieldDefinition.new(:length, 'data.len', :uint, 0, 0, 0, nil, nil, 1, nil, nil)
9
10
  protocol :data, name: 'Data', short: 'DATA'
10
11
  # @rbs (Context ctx, Layer layer) -> void
11
12
  def dissect(ctx, layer)
12
13
  length = ctx.cursor.remaining
13
- layer.add(:length, 'data.len', length)
14
+ layer.definitions << LENGTH_FIELD
15
+ layer.values[:length] = length
14
16
  layer.add(:data, 'data.data', ctx.cursor.bytes(0, length), type: :bytes, length: length)
15
17
  layer.payload_offset = layer.payload_end
16
18
  end
@@ -53,7 +53,7 @@ module Redhound
53
53
  end
54
54
  options.each do |code, entries|
55
55
  value = entries.map { |entry| entry[0] }.join
56
- decode_option(layer, code, value, entries.first[1])
56
+ decode_option(layer, code, value, entries)
57
57
  end
58
58
  rescue Cursor::Truncated => e
59
59
  layer.diagnose(:note, :truncated, e.message)
@@ -83,37 +83,53 @@ module Redhound
83
83
  end
84
84
  end
85
85
 
86
- # @rbs (Layer layer, Integer code, String value, Integer offset) -> void
87
- def decode_option(layer, code, value, offset)
86
+ # @rbs (Layer layer, Integer code, String value, untyped entries) -> void
87
+ def decode_option(layer, code, value, entries)
88
+ offset = entries.first[1]
88
89
  length = value.bytesize
89
90
  if ADDRESS_OPTIONS.key?(code)
90
91
  valid = length.positive? && length % 4 == 0 && (![1, 50, 54].include?(code) || length == 4)
91
92
  return layer.diagnose(:error, :bad_length, "invalid DHCP option #{code} length") unless valid
92
93
  (length / 4).times do |i|
93
- put(layer, "dhcp.option.#{ADDRESS_OPTIONS[code]}", value.unpack1('N', offset: i * 4), :ipv4, offset + i * 4, 4)
94
+ put(layer, "dhcp.option.#{ADDRESS_OPTIONS[code]}", value.unpack1('N', offset: i * 4), :ipv4, offset + i * 4, 4, entries)
94
95
  end
95
96
  else
96
97
  case code
97
98
  when 53, 52
98
99
  return layer.diagnose(:error, :bad_length, "invalid DHCP option #{code} length") unless length == 1
99
- put(layer, code == 53 ? 'dhcp.option.dhcp' : 'dhcp.option.overload', value.getbyte(0), :uint, offset, 1)
100
+ put(layer, code == 53 ? 'dhcp.option.dhcp' : 'dhcp.option.overload', value.getbyte(0), :uint, offset, 1, entries)
100
101
  when 51
101
102
  return layer.diagnose(:error, :bad_length, 'invalid DHCP lease length') unless length == 4
102
- put(layer, 'dhcp.option.ip_address_lease_time', value.unpack1('N'), :uint, offset, 4)
103
+ put(layer, 'dhcp.option.ip_address_lease_time', value.unpack1('N'), :uint, offset, 4, entries)
103
104
  when 55
104
- length.times { |i| put(layer, 'dhcp.option.request_list_item', value.getbyte(i), :uint, offset + i, 1) }
105
+ length.times { |i| put(layer, 'dhcp.option.request_list_item', value.getbyte(i), :uint, offset + i, 1, entries) }
105
106
  when 61
106
107
  return layer.diagnose(:error, :bad_length, 'DHCP client ID must include type and identifier') if length < 2
107
- put(layer, 'dhcp.option.client_id', value, :bytes, offset, length)
108
- put(layer, 'dhcp.option.client_id.type', value.getbyte(0), :uint, offset, 1)
109
- put(layer, 'dhcp.option.client_id.hw_mac_addr', value.byteslice(1, 6), :mac, offset + 1, 6) if length == 7 && value.getbyte(0) == 1
110
- when 12 then put(layer, 'dhcp.option.hostname', value, :string, offset, length)
108
+ put(layer, 'dhcp.option.client_id', value, :bytes, offset, length, entries)
109
+ put(layer, 'dhcp.option.client_id.type', value.getbyte(0), :uint, offset, 1, entries)
110
+ put(layer, 'dhcp.option.client_id.hw_mac_addr', value.byteslice(1, 6), :mac, offset + 1, 6, entries) if length == 7 && value.getbyte(0) == 1
111
+ when 12 then put(layer, 'dhcp.option.hostname', value, :string, offset, length, entries)
111
112
  end
112
113
  end
113
114
  end
114
115
 
115
- # @rbs (Layer layer, String name, untyped value, Symbol type, Integer offset, Integer length) -> void
116
- def put(layer, name, value, type, offset, length)
116
+ # @rbs (untyped entries, Integer logical) -> Integer
117
+ def wire_offset(entries, logical)
118
+ entries.each do |bytes, pos|
119
+ return pos + logical if logical < bytes.bytesize
120
+ logical -= bytes.bytesize
121
+ end
122
+ entries.last[1] + entries.last[0].bytesize
123
+ end
124
+
125
+ # @rbs (Layer layer, String name, untyped value, Symbol type, Integer offset, Integer length, ?untyped entries) -> void
126
+ def put(layer, name, value, type, offset, length, entries = nil)
127
+ if entries
128
+ logical = offset - entries.first[1]
129
+ first = wire_offset(entries, logical)
130
+ ending = length.zero? ? first : wire_offset(entries, logical + length - 1) + 1
131
+ offset, length = first, ending - first
132
+ end
117
133
  layer.add("#{name}_#{layer.definitions.length}".to_sym, name, value, type: type, offset: offset, length: length)
118
134
  end
119
135
 
@@ -37,7 +37,7 @@ module Redhound
37
37
  headers = parse_headers(layer, bytes, first + 2, ending + 2)
38
38
  return if layer.error?
39
39
  body_start = ending + 4
40
- length_values = headers.fetch('content-length', []).flat_map { |value| value.split(',').map(&:strip) }
40
+ length_values = headers.fetch('content-length', []).flat_map { |value| value.empty? ? [''] : value.split(',', -1).map(&:strip) }
41
41
  unless length_values.all? { |value| value.match?(/\A[0-9]+\z/n) } && length_values.uniq.length <= 1
42
42
  return layer.diagnose(:error, :malformed, 'conflicting or invalid Content-Length')
43
43
  end
@@ -108,13 +108,15 @@ module Redhound
108
108
  end
109
109
  name = name.downcase
110
110
  value = line.byteslice(colon + 1..) #: String
111
- value = value.sub(/\A[ \t]*/n, '').sub(/[ \t]*\z/n, '')
111
+ leading = (value[/\A[ \t]*/n] || '').bytesize
112
+ trailing = value.rindex(/[^ \t]/n)
113
+ value = value.byteslice(leading, trailing ? trailing + 1 - leading : 0) #: String
112
114
  (headers[name] ||= []) << value
113
115
  key = name.tr('-', '_')
114
116
  if HEADERS.include?(key)
115
117
  numeric = key == 'content_length' && value.match?(/\A[0-9]+\z/n)
116
118
  put(layer, "http.#{key}", numeric ? value.to_i : value, numeric ? :uint : :string,
117
- pos + colon + 1, line.bytesize - colon - 1)
119
+ pos + colon + 1 + leading, value.bytesize)
118
120
  else
119
121
  put(layer, 'http.request.line', line, :string, pos, line.bytesize)
120
122
  end