redhound 1.0.1 → 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 (232) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -0
  3. data/README.md +97 -31
  4. data/docs/API.md +104 -0
  5. data/docs/FILTERS.md +37 -0
  6. data/docs/MIGRATION.md +23 -0
  7. data/docs/PROTOCOLS.md +54 -0
  8. data/docs/USAGE.md +86 -0
  9. data/docs/VALIDATION.md +175 -0
  10. data/docs/json-schema.json +64 -0
  11. data/exe/redhound +2 -1
  12. data/lib/redhound/analysis/flow_key.rb +59 -0
  13. data/lib/redhound/analysis/flow_table.rb +124 -0
  14. data/lib/redhound/analysis/ip_reassembler.rb +209 -0
  15. data/lib/redhound/analysis/stats.rb +150 -0
  16. data/lib/redhound/analysis/tcp_analysis.rb +43 -0
  17. data/lib/redhound/analysis/tcp_reassembler.rb +176 -0
  18. data/lib/redhound/analysis/tcp_stream.rb +156 -0
  19. data/lib/redhound/analysis.rb +157 -0
  20. data/lib/redhound/capture/bsd/bpf_device.rb +155 -0
  21. data/lib/redhound/capture/bsd/constants.rb +29 -0
  22. data/lib/redhound/capture/bsd/ifreq.rb +41 -0
  23. data/lib/redhound/capture/file_source.rb +214 -0
  24. data/lib/redhound/capture/interface.rb +70 -0
  25. data/lib/redhound/capture/linktype.rb +30 -0
  26. data/lib/redhound/capture/linux/constants.rb +37 -0
  27. data/lib/redhound/capture/linux/cooked.rb +18 -0
  28. data/lib/redhound/capture/linux/ifreq.rb +42 -0
  29. data/lib/redhound/capture/linux/packet_socket.rb +224 -0
  30. data/lib/redhound/capture/linux/sockaddr_ll.rb +33 -0
  31. data/lib/redhound/capture/linux/tpacket_v3.rb +137 -0
  32. data/lib/redhound/capture/source.rb +79 -0
  33. data/lib/redhound/capture/stats.rb +22 -0
  34. data/lib/redhound/capture.rb +70 -0
  35. data/lib/redhound/cli/command.rb +42 -0
  36. data/lib/redhound/cli/options.rb +133 -0
  37. data/lib/redhound/cli/privileges.rb +29 -0
  38. data/lib/redhound/cli/runner.rb +129 -0
  39. data/lib/redhound/context.rb +20 -0
  40. data/lib/redhound/cursor.rb +69 -0
  41. data/lib/redhound/diagnostic.rb +7 -0
  42. data/lib/redhound/dissector.rb +152 -0
  43. data/lib/redhound/engine.rb +69 -0
  44. data/lib/redhound/errors.rb +38 -0
  45. data/lib/redhound/field.rb +62 -0
  46. data/lib/redhound/file/format.rb +63 -0
  47. data/lib/redhound/file/pcap_reader.rb +43 -0
  48. data/lib/redhound/file/pcap_writer.rb +79 -0
  49. data/lib/redhound/file/pcapng_reader.rb +221 -0
  50. data/lib/redhound/file/pcapng_writer.rb +151 -0
  51. data/lib/redhound/file/rotating_writer.rb +98 -0
  52. data/lib/redhound/filter/analyzer.rb +197 -0
  53. data/lib/redhound/filter/bpf/assembler.rb +121 -0
  54. data/lib/redhound/filter/bpf/disassembler.rb +58 -0
  55. data/lib/redhound/filter/bpf/validator.rb +77 -0
  56. data/lib/redhound/filter/bpf/vm.rb +109 -0
  57. data/lib/redhound/filter/codegen.rb +413 -0
  58. data/lib/redhound/filter/lexer.rb +46 -0
  59. data/lib/redhound/filter/parser.rb +206 -0
  60. data/lib/redhound/filter/program.rb +55 -0
  61. data/lib/redhound/filter.rb +30 -0
  62. data/lib/redhound/layer.rb +75 -0
  63. data/lib/redhound/output/fields.rb +20 -0
  64. data/lib/redhound/output/follow.rb +64 -0
  65. data/lib/redhound/output/hexdump.rb +29 -0
  66. data/lib/redhound/output/json.rb +32 -0
  67. data/lib/redhound/output/summary.rb +84 -0
  68. data/lib/redhound/output/timestamp.rb +34 -0
  69. data/lib/redhound/output/tree.rb +25 -0
  70. data/lib/redhound/packet.rb +77 -0
  71. data/lib/redhound/protocols/arp.rb +39 -0
  72. data/lib/redhound/protocols/checksum.rb +38 -0
  73. data/lib/redhound/protocols/data.rb +23 -0
  74. data/lib/redhound/protocols/dhcp.rb +140 -0
  75. data/lib/redhound/protocols/dns.rb +268 -0
  76. data/lib/redhound/protocols/ethernet.rb +24 -0
  77. data/lib/redhound/protocols/http.rb +185 -0
  78. data/lib/redhound/protocols/icmp.rb +205 -0
  79. data/lib/redhound/protocols/ipv4.rb +56 -0
  80. data/lib/redhound/protocols/ipv6.rb +68 -0
  81. data/lib/redhound/protocols/link.rb +70 -0
  82. data/lib/redhound/protocols/llc.rb +37 -0
  83. data/lib/redhound/protocols/ntp.rb +45 -0
  84. data/lib/redhound/protocols/tcp.rb +88 -0
  85. data/lib/redhound/protocols/tls.rb +218 -0
  86. data/lib/redhound/protocols/tunnel.rb +48 -0
  87. data/lib/redhound/protocols/udp.rb +32 -0
  88. data/lib/redhound/protocols/vlan.rb +23 -0
  89. data/lib/redhound/reader.rb +8 -0
  90. data/lib/redhound/registry.rb +64 -0
  91. data/lib/redhound/stream_dissector.rb +168 -0
  92. data/lib/redhound/util/seq.rb +21 -0
  93. data/lib/redhound/version.rb +2 -1
  94. data/lib/redhound/writer.rb +25 -43
  95. data/lib/redhound.rb +81 -9
  96. data/sig/generated/redhound/analysis/flow_key.rbs +15 -0
  97. data/sig/generated/redhound/analysis/flow_table.rbs +102 -0
  98. data/sig/generated/redhound/analysis/ip_reassembler.rbs +52 -0
  99. data/sig/generated/redhound/analysis/stats.rbs +80 -0
  100. data/sig/generated/redhound/analysis/tcp_analysis.rbs +15 -0
  101. data/sig/generated/redhound/analysis/tcp_reassembler.rbs +39 -0
  102. data/sig/generated/redhound/analysis/tcp_stream.rbs +60 -0
  103. data/sig/generated/redhound/analysis.rbs +38 -0
  104. data/sig/generated/redhound/capture/bsd/bpf_device.rbs +39 -0
  105. data/sig/generated/redhound/capture/bsd/constants.rbs +40 -0
  106. data/sig/generated/redhound/capture/bsd/ifreq.rbs +15 -0
  107. data/sig/generated/redhound/capture/file_source.rbs +67 -0
  108. data/sig/generated/redhound/capture/interface.rbs +53 -0
  109. data/sig/generated/redhound/capture/linktype.rbs +20 -0
  110. data/sig/generated/redhound/capture/linux/constants.rbs +58 -0
  111. data/sig/generated/redhound/capture/linux/cooked.rbs +15 -0
  112. data/sig/generated/redhound/capture/linux/ifreq.rbs +18 -0
  113. data/sig/generated/redhound/capture/linux/packet_socket.rbs +64 -0
  114. data/sig/generated/redhound/capture/linux/sockaddr_ll.rbs +31 -0
  115. data/sig/generated/redhound/capture/linux/tpacket_v3.rbs +35 -0
  116. data/sig/generated/redhound/capture/source.rbs +44 -0
  117. data/sig/generated/redhound/capture/stats.rbs +25 -0
  118. data/sig/generated/redhound/capture.rbs +14 -0
  119. data/sig/generated/redhound/cli/command.rbs +12 -0
  120. data/sig/generated/redhound/cli/options.rbs +25 -0
  121. data/sig/generated/redhound/cli/privileges.rbs +12 -0
  122. data/sig/generated/redhound/cli/runner.rbs +27 -0
  123. data/sig/generated/redhound/context.rbs +27 -0
  124. data/sig/generated/redhound/cursor.rbs +59 -0
  125. data/sig/generated/redhound/diagnostic.rbs +21 -0
  126. data/sig/generated/redhound/dissector.rbs +117 -0
  127. data/sig/generated/redhound/engine.rbs +23 -0
  128. data/sig/generated/redhound/errors.rbs +56 -0
  129. data/sig/generated/redhound/field.rbs +74 -0
  130. data/sig/generated/redhound/file/format.rbs +39 -0
  131. data/sig/generated/redhound/file/pcap_reader.rbs +19 -0
  132. data/sig/generated/redhound/file/pcap_writer.rbs +38 -0
  133. data/sig/generated/redhound/file/pcapng_reader.rbs +54 -0
  134. data/sig/generated/redhound/file/pcapng_writer.rbs +50 -0
  135. data/sig/generated/redhound/file/rotating_writer.rbs +42 -0
  136. data/sig/generated/redhound/filter/analyzer.rbs +42 -0
  137. data/sig/generated/redhound/filter/bpf/assembler.rbs +39 -0
  138. data/sig/generated/redhound/filter/bpf/disassembler.rbs +18 -0
  139. data/sig/generated/redhound/filter/bpf/validator.rbs +30 -0
  140. data/sig/generated/redhound/filter/bpf/vm.rbs +28 -0
  141. data/sig/generated/redhound/filter/codegen.rbs +101 -0
  142. data/sig/generated/redhound/filter/lexer.rbs +15 -0
  143. data/sig/generated/redhound/filter/parser.rbs +65 -0
  144. data/sig/generated/redhound/filter/program.rbs +48 -0
  145. data/sig/generated/redhound/filter.rbs +10 -0
  146. data/sig/generated/redhound/layer.rbs +81 -0
  147. data/sig/generated/redhound/output/fields.rbs +15 -0
  148. data/sig/generated/redhound/output/follow.rbs +21 -0
  149. data/sig/generated/redhound/output/hexdump.rbs +15 -0
  150. data/sig/generated/redhound/output/json.rbs +18 -0
  151. data/sig/generated/redhound/output/summary.rbs +21 -0
  152. data/sig/generated/redhound/output/timestamp.rbs +21 -0
  153. data/sig/generated/redhound/output/tree.rbs +15 -0
  154. data/sig/generated/redhound/packet.rbs +82 -0
  155. data/sig/generated/redhound/protocols/arp.rbs +15 -0
  156. data/sig/generated/redhound/protocols/checksum.rbs +15 -0
  157. data/sig/generated/redhound/protocols/data.rbs +17 -0
  158. data/sig/generated/redhound/protocols/dhcp.rbs +31 -0
  159. data/sig/generated/redhound/protocols/dns.rbs +42 -0
  160. data/sig/generated/redhound/protocols/ethernet.rbs +15 -0
  161. data/sig/generated/redhound/protocols/http.rbs +36 -0
  162. data/sig/generated/redhound/protocols/icmp.rbs +41 -0
  163. data/sig/generated/redhound/protocols/ipv4.rbs +15 -0
  164. data/sig/generated/redhound/protocols/ipv6.rbs +24 -0
  165. data/sig/generated/redhound/protocols/link.rbs +33 -0
  166. data/sig/generated/redhound/protocols/llc.rbs +15 -0
  167. data/sig/generated/redhound/protocols/ntp.rbs +17 -0
  168. data/sig/generated/redhound/protocols/tcp.rbs +25 -0
  169. data/sig/generated/redhound/protocols/tls.rbs +51 -0
  170. data/sig/generated/redhound/protocols/tunnel.rbs +24 -0
  171. data/sig/generated/redhound/protocols/udp.rbs +18 -0
  172. data/sig/generated/redhound/protocols/vlan.rbs +12 -0
  173. data/sig/generated/redhound/reader.rbs +7 -0
  174. data/sig/generated/redhound/registry.rbs +45 -0
  175. data/sig/generated/redhound/stream_dissector.rbs +44 -0
  176. data/sig/generated/redhound/util/seq.rbs +20 -0
  177. data/sig/generated/redhound/version.rbs +1 -0
  178. data/sig/generated/redhound/writer.rbs +6 -19
  179. data/sig/generated/redhound.rbs +18 -0
  180. data/sig/shims/io.rbs +7 -0
  181. data/sig/shims/process.rbs +3 -0
  182. metadata +177 -55
  183. data/CODE_OF_CONDUCT.md +0 -132
  184. data/Dockerfile +0 -7
  185. data/Rakefile +0 -15
  186. data/Steepfile +0 -4
  187. data/docker-compose.yml +0 -9
  188. data/lib/redhound/analyzer.rb +0 -34
  189. data/lib/redhound/builder/packet_mreq.rb +0 -56
  190. data/lib/redhound/builder/socket.rb +0 -35
  191. data/lib/redhound/builder.rb +0 -4
  192. data/lib/redhound/command.rb +0 -67
  193. data/lib/redhound/l2/ether.rb +0 -68
  194. data/lib/redhound/l2/protocol.rb +0 -33
  195. data/lib/redhound/l2.rb +0 -4
  196. data/lib/redhound/l3/arp.rb +0 -114
  197. data/lib/redhound/l3/base.rb +0 -49
  198. data/lib/redhound/l3/ipv4.rb +0 -95
  199. data/lib/redhound/l3/ipv6.rb +0 -69
  200. data/lib/redhound/l3/protocol.rb +0 -173
  201. data/lib/redhound/l3/resolver.rb +0 -30
  202. data/lib/redhound/l3.rb +0 -9
  203. data/lib/redhound/l4/base.rb +0 -31
  204. data/lib/redhound/l4/icmp.rb +0 -71
  205. data/lib/redhound/l4/resolver.rb +0 -28
  206. data/lib/redhound/l4/udp.rb +0 -63
  207. data/lib/redhound/l4.rb +0 -6
  208. data/lib/redhound/receiver.rb +0 -45
  209. data/lib/redhound/resolver.rb +0 -22
  210. data/lib/redhound/source/socket.rb +0 -18
  211. data/lib/redhound/source.rb +0 -3
  212. data/rbs_collection.lock.yaml +0 -20
  213. data/rbs_collection.yaml +0 -17
  214. data/sig/generated/redhound/analyzer.rbs +0 -14
  215. data/sig/generated/redhound/builder/packet_mreq.rbs +0 -39
  216. data/sig/generated/redhound/builder/socket.rbs +0 -24
  217. data/sig/generated/redhound/command.rbs +0 -19
  218. data/sig/generated/redhound/l2/ether.rbs +0 -41
  219. data/sig/generated/redhound/l2/protocol.rbs +0 -15
  220. data/sig/generated/redhound/l3/arp.rbs +0 -57
  221. data/sig/generated/redhound/l3/base.rbs +0 -28
  222. data/sig/generated/redhound/l3/ipv4.rbs +0 -53
  223. data/sig/generated/redhound/l3/ipv6.rbs +0 -38
  224. data/sig/generated/redhound/l3/protocol.rbs +0 -16
  225. data/sig/generated/redhound/l3/resolver.rbs +0 -16
  226. data/sig/generated/redhound/l4/base.rbs +0 -19
  227. data/sig/generated/redhound/l4/icmp.rbs +0 -33
  228. data/sig/generated/redhound/l4/resolver.rbs +0 -16
  229. data/sig/generated/redhound/l4/udp.rbs +0 -36
  230. data/sig/generated/redhound/receiver.rbs +0 -19
  231. data/sig/generated/redhound/resolver.rbs +0 -11
  232. data/sig/generated/redhound/source/socket.rbs +0 -13
@@ -0,0 +1,62 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ require 'ipaddr'
5
+
6
+ module Redhound
7
+ # Immutable description of a field location, type and display metadata.
8
+ FieldDefinition = Data.define(:key, :name, :type, :offset, :length, :index, :shift, :mask, :scale, :format, :enum)
9
+
10
+ # A decoded field with its definition, raw value and absolute location.
11
+ class Field
12
+ # Definition, raw decoded value and capture location.
13
+ attr_reader :definition, :raw_value, :offset, :length
14
+
15
+ # @rbs (untyped definition, untyped value, Integer base) -> void
16
+ # Bind a field definition to its value and layer base offset.
17
+ def initialize(definition, value, base)
18
+ @definition, @raw_value = definition, value
19
+ @offset, @length = base + definition.offset, definition.length
20
+ end
21
+
22
+ # @rbs () -> String
23
+ # Stable dotted field name.
24
+ def name = @definition.name
25
+ # @rbs () -> Symbol
26
+ # Declared field representation.
27
+ def type = @definition.type
28
+
29
+ # @rbs () -> untyped
30
+ # Decoded value; addresses are returned in human-readable text.
31
+ def value
32
+ case type
33
+ when :ipv4 then "#{(@raw_value >> 24) & 255}.#{(@raw_value >> 16) & 255}.#{(@raw_value >> 8) & 255}.#{@raw_value & 255}"
34
+ when :ipv6 then IPAddr.new_ntoh(@raw_value).to_s
35
+ when :mac then @raw_value.unpack('H2' * 6).join(':')
36
+ else @raw_value
37
+ end
38
+ end
39
+
40
+ # @rbs () -> untyped
41
+ # JSON-safe value; opaque bytes and invalid text are encoded in hexadecimal.
42
+ def json_value
43
+ return @raw_value.unpack1('H*') if type == :bytes
44
+ val = value
45
+ return val unless val.is_a?(String)
46
+ utf8 = val.dup.force_encoding(Encoding::UTF_8)
47
+ utf8.valid_encoding? ? utf8 : val.unpack1('H*')
48
+ end
49
+
50
+ # @rbs () -> String
51
+ # Terminal-safe field text with escaped control and non-ASCII bytes.
52
+ def display
53
+ val = value
54
+ return val if type == :ipv4 || type == :ipv6 || type == :mac
55
+ return "#{@definition.enum[val]} (#{val})" if @definition.enum&.key?(val)
56
+ return format('0x%x', val) if @definition.format == :hex
57
+ return @raw_value.unpack1('H*') if type == :bytes
58
+ return val.b.gsub(/[^\x20-\x7e]/n) { |c| format('\\x%02x', c.getbyte(0)) } if val.is_a?(String)
59
+ val.to_s
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,63 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module Redhound
5
+ # @api private
6
+ module File
7
+ # @api private
8
+ module Format
9
+ MAX_RECORD = 16 * 1024 * 1024
10
+ MAX_INTERFACES = 4096
11
+ MAX_INTERFACE_METADATA = 16 * 1024 * 1024
12
+ MAX_OPTIONS = 4096
13
+ PCAP_MAGICS = {
14
+ "\xd4\xc3\xb2\xa1".b => [:little, 1_000], "\xa1\xb2\xc3\xd4".b => [:big, 1_000],
15
+ "\x4d\x3c\xb2\xa1".b => [:little, 1], "\xa1\xb2\x3c\x4d".b => [:big, 1]
16
+ }.freeze
17
+ PCAPNG_MAGIC = "\x0a\x0d\x0d\x0a".b.freeze
18
+
19
+ # @rbs (untyped io, Integer length, ?eof: false) -> String
20
+ # @rbs (untyped io, Integer length, eof: true) -> String?
21
+ def self.read_exact(io, length, eof: false)
22
+ raise FileFormatError, "invalid record length #{length}" unless length.between?(0, MAX_RECORD)
23
+ return ''.b if length.zero?
24
+
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
30
+ while result.bytesize < length
31
+ part = io.read(length - result.bytesize)
32
+ if !part || part.empty?
33
+ return nil if eof && result.empty?
34
+
35
+ raise FileFormatError, "truncated capture file (wanted #{length}, read #{result.bytesize})"
36
+ end
37
+ result << part
38
+ end
39
+ result
40
+ end
41
+
42
+ # @rbs (Integer length) -> Integer
43
+ def self.padded(length) = (length + 3) & ~3
44
+
45
+ # @rbs (String data) -> String
46
+ def self.pad(data) = data + "\0" * (padded(data.bytesize) - data.bytesize)
47
+
48
+ # NULL carries a host-order address family; libpcap also normalizes it on read.
49
+ # @rbs (String data, Integer linktype, String integer_format) -> String
50
+ def self.normalize_null(data, linktype, integer_format)
51
+ data[0, 4] = [data.unpack1(integer_format)].pack('L') if linktype.zero? && data.bytesize >= 4
52
+ data
53
+ end
54
+
55
+ # @rbs (Integer caplen, Integer original_length, Integer snaplen) -> void
56
+ def self.check_lengths(caplen, original_length, snaplen)
57
+ unless caplen.between?(0, MAX_RECORD) && (snaplen.zero? || caplen <= snaplen) && original_length >= caplen
58
+ raise FileFormatError, "invalid packet lengths (captured #{caplen}, original #{original_length}, snaplen #{snaplen})"
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,43 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module Redhound
5
+ # @api private
6
+ module File
7
+ # @api private
8
+ class PcapReader
9
+ attr_reader :interfaces, :linktype
10
+
11
+ # @rbs (untyped io, String magic) -> void
12
+ def initialize(io, magic)
13
+ @io = io
14
+ endian, @multiplier = Format::PCAP_MAGICS.fetch(magic) #: [Symbol, Integer]
15
+ @integer_format = endian == :little ? 'V' : 'N'
16
+ header = Format.read_exact(io, 20)
17
+ major, minor, _zone, _accuracy, @snaplen, network = header.unpack(endian == :little ? 'vvV4' : 'nnN4') #: [Integer, Integer, Integer, Integer, Integer, Integer]
18
+ raise FileFormatError, "unsupported pcap version #{major}.#{minor}" unless major == 2 && minor == 4
19
+ raise FileFormatError, 'invalid pcap snaplen' unless @snaplen.between?(1, Format::MAX_RECORD)
20
+
21
+ @linktype = network & 0xffff
22
+ @interfaces = [Capture::Interface.new(name: 'pcap', linktype: @linktype, snaplen: @snaplen)]
23
+ @number = 0
24
+ end
25
+
26
+ # @rbs () -> Packet?
27
+ def next_packet
28
+ record = Format.read_exact(@io, 16, eof: true)
29
+ return nil unless record
30
+
31
+ seconds, fraction, caplen, original_length = record.unpack("#{@integer_format}4") #: [Integer, Integer, Integer, Integer]
32
+ Format.check_lengths(caplen, original_length, @snaplen)
33
+ raise FileFormatError, 'invalid pcap timestamp fraction' unless fraction < 1_000_000_000 / @multiplier
34
+
35
+ data = Format.normalize_null(Format.read_exact(@io, caplen), @linktype, @integer_format)
36
+ @number += 1
37
+ timestamp_ns = seconds * 1_000_000_000 + fraction * @multiplier #: Integer
38
+ Packet.new(data, timestamp_ns:,
39
+ original_length:, linktype: @linktype, interface: @interfaces.first, number: @number)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,79 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module Redhound
5
+ # @api private
6
+ module File
7
+ # @api private
8
+ class PcapWriter
9
+ attr_reader :bytes_written, :path
10
+
11
+ # @rbs (untyped output, ?linktype: Integer | Symbol | String, ?snaplen: Integer, ?precision: Symbol, ?packet_buffered: bool, **untyped) -> void
12
+ def initialize(output, linktype: :ethernet, snaplen: 262_144, precision: :nano, packet_buffered: false, **_options)
13
+ raise ArgumentError, 'snaplen must be between 1 and 16 MiB' unless snaplen.between?(1, Format::MAX_RECORD)
14
+ raise ArgumentError, 'precision must be micro or nano' unless %i[micro nano].include?(precision)
15
+
16
+ @linktype = Capture::Linktype.resolve(linktype)
17
+ @snaplen, @precision, @packet_buffered = snaplen, precision, packet_buffered
18
+ @owned = output.is_a?(String) && output != '-'
19
+ @path = output.is_a?(String) ? output : nil
20
+ @io = output.is_a?(String) ? (output == '-' ? $stdout : ::File.open(output, ::File::WRONLY | ::File::CREAT | ::File::TRUNC, 0o600)) : output
21
+ @io.chmod(0o600) if @owned
22
+ @io.binmode if @io.respond_to?(:binmode)
23
+ @bytes_written = 0
24
+ @closed = false
25
+ magic = precision == :nano ? 0xa1b23c4d : 0xa1b2c3d4
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
30
+ end
31
+
32
+ # @rbs (Packet packet) -> self
33
+ def write(packet)
34
+ raise IOError, 'writer is closed' if @closed
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
37
+
38
+ seconds, nanos = packet.timestamp_ns.divmod(1_000_000_000)
39
+ raise FileFormatError, 'pcap timestamp exceeds unsigned 32-bit seconds' unless seconds.between?(0, 0xffff_ffff)
40
+
41
+ data = packet.data.byteslice(0, @snaplen)
42
+ fraction = @precision == :nano ? nanos : nanos / 1_000
43
+ write_bytes([seconds, fraction, data.bytesize, packet.original_length].pack('V4') + data)
44
+ @io.flush if @packet_buffered
45
+ self
46
+ end
47
+
48
+ alias << write
49
+
50
+ # @rbs (Packet packet) -> Integer
51
+ def record_size(packet) = 16 + [packet.caplen, @snaplen].min
52
+
53
+ # @rbs (Capture::Stats stats, ?interface: Capture::Interface?) -> void
54
+ def write_stats(stats, interface: nil); end
55
+
56
+ # @rbs () -> void
57
+ def flush = @io.flush
58
+
59
+ # @rbs () -> void
60
+ def close
61
+ return if @closed
62
+
63
+ begin
64
+ @io.flush
65
+ ensure
66
+ @closed = true
67
+ @io.close if @owned && !@io.closed?
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ # @rbs (String bytes) -> void
74
+ def write_bytes(bytes)
75
+ @bytes_written += @io.write(bytes)
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,221 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module Redhound
5
+ # @api private
6
+ module File
7
+ # @api private
8
+ class PcapngReader
9
+ attr_reader :interfaces, :stats
10
+
11
+ # @rbs (untyped io, String magic) -> void
12
+ def initialize(io, magic)
13
+ @io = io
14
+ @interfaces = [] #: Array[Capture::Interface]
15
+ @section_interfaces = [] #: Array[Capture::Interface]
16
+ @statistics = {} #: Hash[Integer, Hash[Symbol, Integer]]
17
+ @packet_counts = {} #: Hash[Integer, Integer]
18
+ @stats = Capture::Stats.new
19
+ @number = 0
20
+ @interface_metadata = 0
21
+ @magic = magic
22
+ @integer_format = 'V'
23
+ read_block
24
+ while (block = read_block)
25
+ type, body = block
26
+ if type == 1
27
+ read_interface(body)
28
+ break
29
+ end
30
+ raise FileFormatError, 'pcapng packet precedes interface description' if [3, 5, 6].include?(type)
31
+ end
32
+ end
33
+
34
+ # @rbs () -> Integer
35
+ def linktype = @interfaces.first&.linktype || 1
36
+
37
+ # @rbs () -> bool
38
+ def statistics? = !@statistics.empty?
39
+
40
+ # @rbs () -> Packet?
41
+ def next_packet
42
+ while (block = read_block)
43
+ type, body = block
44
+ case type
45
+ when 1 then read_interface(body)
46
+ when 6 then return read_enhanced(body)
47
+ when 3 then return read_simple(body)
48
+ when 5 then read_statistics(body)
49
+ end
50
+ end
51
+ nil
52
+ end
53
+
54
+ private
55
+
56
+ # @rbs () -> [Integer, String]?
57
+ def read_block
58
+ header = @magic ? @magic + Format.read_exact(@io, 4) : Format.read_exact(@io, 8, eof: true)
59
+ @magic = nil
60
+ return nil unless header
61
+
62
+ section = header.byteslice(0, 4) == Format::PCAPNG_MAGIC
63
+ prefix = section ? Format.read_exact(@io, 4) : ''.b
64
+ if section
65
+ @integer_format = case prefix
66
+ when "\x4d\x3c\x2b\x1a".b then 'V'
67
+ when "\x1a\x2b\x3c\x4d".b then 'N'
68
+ else raise FileFormatError, 'invalid pcapng byte order magic'
69
+ end
70
+ end
71
+ type, length = header.unpack("#{@integer_format}2") #: [Integer, Integer]
72
+ minimum = section ? 28 : 12
73
+ unless length.between?(minimum, Format::MAX_RECORD) && (length & 3).zero?
74
+ raise FileFormatError, "invalid pcapng block length #{length}"
75
+ end
76
+ contents = prefix + Format.read_exact(@io, length - 8 - prefix.bytesize)
77
+ raise FileFormatError, 'pcapng block lengths do not match' unless contents.unpack1(@integer_format, offset: contents.bytesize - 4) == length
78
+
79
+ body = contents.byteslice(0, contents.bytesize - 4) #: String
80
+ if section
81
+ version = body.unpack(@integer_format == 'V' ? 'vv' : 'nn', offset: 4)
82
+ raise FileFormatError, 'unsupported pcapng section version' unless version == [1, 0]
83
+
84
+ @section_interfaces = [] #: Array[Capture::Interface]
85
+ end
86
+ [type, body]
87
+ end
88
+
89
+ # @rbs (String body) -> void
90
+ def read_interface(body)
91
+ require_size(body, 8)
92
+ raise FileFormatError, 'pcapng interface limit exceeded' if @interfaces.size >= Format::MAX_INTERFACES
93
+ linktype, _reserved, snaplen = body.unpack(@integer_format == 'V' ? 'vvV' : 'nnN') #: [Integer, Integer, Integer]
94
+ raise FileFormatError, 'invalid pcapng snaplen' if snaplen > Format::MAX_RECORD
95
+
96
+ opts = options(body, 8, [2, 3, 9, 11, 14])
97
+ resolution = opts.fetch(9, ["\x06".b]).fetch(0)
98
+ raise FileFormatError, 'invalid pcapng timestamp resolution' unless resolution.bytesize == 1
99
+
100
+ exponent = resolution.getbyte(0) #: Integer
101
+ units = exponent < 128 ? 10**exponent : 2**(exponent & 127)
102
+ offset = opts.fetch(14, ["\0" * 8]).fetch(0)
103
+ raise FileFormatError, 'invalid pcapng timestamp offset' unless offset.bytesize == 8
104
+
105
+ index = @section_interfaces.size
106
+ filter_option = opts[11]&.first
107
+ interface = Capture::Interface.new(name: opts.fetch(2, ["interface#{index}"]).fetch(0), index:, linktype:, snaplen:,
108
+ description: opts[3]&.first, filter: filter_option && filter_option.getbyte(0) == 0 ? filter_option.byteslice(1..) : nil,
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
115
+ @section_interfaces << interface
116
+ @interfaces << interface
117
+ end
118
+
119
+ # @rbs (String body) -> Packet
120
+ def read_enhanced(body)
121
+ require_size(body, 20)
122
+ index, high, low, caplen, original_length = body.unpack("#{@integer_format}5") #: [Integer, Integer, Integer, Integer, Integer]
123
+ interface = interface_at(index)
124
+ Format.check_lengths(caplen, original_length, interface.snaplen)
125
+ require_size(body, 20 + Format.padded(caplen))
126
+ opts = options(body, 20 + Format.padded(caplen), [1, 2])
127
+ flags_option = opts[2]&.first
128
+ raise FileFormatError, 'invalid pcapng packet flags' if flags_option && flags_option.bytesize != 4
129
+
130
+ flags = flags_option ? flags_option.unpack1(@integer_format) : 0 #: Integer
131
+ direction = { 1 => :in, 2 => :out }[flags & 3]
132
+ meta = { epb_flags: flags } #: Hash[Symbol, untyped]
133
+ meta[:comment] = opts[1].join("\n") if opts[1]
134
+ pkttype = direction == :out ? 4 : { 1 => 0, 2 => 2, 3 => 1, 4 => 3 }[(flags >> 2) & 7]
135
+ meta[:pkttype] = pkttype if pkttype
136
+ timestamp_ns = ((high << 32) | low) * 1_000_000_000 / interface.meta.fetch(:timestamp_units) +
137
+ interface.meta.fetch(:timestamp_offset) * 1_000_000_000
138
+ data = body.byteslice(20, caplen) #: String
139
+ make_packet(data, interface, original_length, timestamp_ns, direction, meta)
140
+ end
141
+
142
+ # @rbs (String body) -> Packet
143
+ def read_simple(body)
144
+ require_size(body, 4)
145
+ interface = interface_at(0)
146
+ original_length = body.unpack1(@integer_format) #: Integer
147
+ caplen = interface.snaplen.zero? ? original_length : [original_length, interface.snaplen].min
148
+ Format.check_lengths(caplen, original_length, interface.snaplen)
149
+ raise FileFormatError, 'invalid pcapng simple packet length' unless body.bytesize == 4 + Format.padded(caplen)
150
+
151
+ data = body.byteslice(4, caplen) #: String
152
+ make_packet(data, interface, original_length, 0, nil, {})
153
+ end
154
+
155
+ # @rbs (String body) -> void
156
+ def read_statistics(body)
157
+ require_size(body, 12)
158
+ index = body.unpack1(@integer_format) #: Integer
159
+ interface = interface_at(index)
160
+ opts = options(body, 12, [4, 5, 7, 8])
161
+ values = {} #: Hash[Symbol, Integer]
162
+ { received: 4, dropped: 5, if_dropped: 7, captured: 8 }.each do |name, code|
163
+ option = opts[code]&.first
164
+ next unless option
165
+ raise FileFormatError, 'invalid pcapng statistics counter' unless option.bytesize == 8
166
+
167
+ count = option.unpack1(@integer_format == 'V' ? 'Q<' : 'Q>') #: Integer
168
+ values[name] = count
169
+ end
170
+ @statistics[interface.object_id] = values
171
+ totals = %i[received dropped if_dropped captured].to_h { |name| [name, @statistics.values.sum { |entry| entry.fetch(name, 0) }] }
172
+ %i[received captured].each do |name|
173
+ totals[name] = @packet_counts.values.sum unless @statistics.values.any? { |entry| entry.key?(name) }
174
+ end
175
+ @stats = Capture::Stats.new(**totals)
176
+ end
177
+
178
+ # @rbs (String data, Capture::Interface interface, Integer original_length, Integer timestamp_ns, Symbol? direction, Hash[Symbol, untyped] meta) -> Packet
179
+ def make_packet(data, interface, original_length, timestamp_ns, direction, meta)
180
+ data = Format.normalize_null(data, interface.linktype, @integer_format)
181
+ @number += 1
182
+ @packet_counts[interface.object_id] = @packet_counts.fetch(interface.object_id, 0) + 1
183
+ Packet.new(data, timestamp_ns:, original_length:, linktype: interface.linktype, interface:, direction:, number: @number, meta:)
184
+ end
185
+
186
+ # @rbs (Integer index) -> Capture::Interface
187
+ def interface_at(index)
188
+ @section_interfaces.fetch(index) { raise FileFormatError, "pcapng references unknown interface #{index}" }
189
+ end
190
+
191
+ # @rbs (String body, Integer offset, Array[Integer] allowed) -> Hash[Integer, Array[String]]
192
+ def options(body, offset, allowed)
193
+ result = {} #: Hash[Integer, Array[String]]
194
+ count = 0
195
+ while offset < body.bytesize
196
+ require_size(body, offset + 4)
197
+ code, length = body.unpack(@integer_format == 'V' ? 'vv' : 'nn', offset:) #: [Integer, Integer]
198
+ offset += 4
199
+ if code.zero?
200
+ raise FileFormatError, 'invalid pcapng end of options' unless length.zero?
201
+ break
202
+ end
203
+ count += 1
204
+ raise FileFormatError, 'pcapng option count limit exceeded' if count > Format::MAX_OPTIONS
205
+ require_size(body, offset + Format.padded(length))
206
+ if allowed.include?(code)
207
+ value = body.byteslice(offset, length) #: String
208
+ (result[code] ||= []) << value
209
+ end
210
+ offset += Format.padded(length)
211
+ end
212
+ result
213
+ end
214
+
215
+ # @rbs (String body, Integer size) -> void
216
+ def require_size(body, size)
217
+ raise FileFormatError, 'truncated pcapng block' if body.bytesize < size
218
+ end
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,151 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module Redhound
5
+ # @api private
6
+ module File
7
+ # @api private
8
+ class PcapngWriter
9
+ attr_reader :bytes_written, :path
10
+
11
+ # @rbs (untyped output, ?linktype: Integer | Symbol | String, ?snaplen: Integer, ?precision: Symbol, ?packet_buffered: bool, ?filter: String?, ?comment: String?, **untyped) -> void
12
+ def initialize(output, linktype: :ethernet, snaplen: 262_144, precision: :nano, packet_buffered: false,
13
+ filter: nil, comment: nil, **_options)
14
+ raise ArgumentError, 'snaplen must be between 1 and 16 MiB' unless snaplen.between?(1, Format::MAX_RECORD)
15
+
16
+ @linktype, @snaplen, @packet_buffered = Capture::Linktype.resolve(linktype), snaplen, packet_buffered
17
+ @filter, @comment = filter, comment
18
+ @owned = output.is_a?(String) && output != '-'
19
+ @path = output.is_a?(String) ? output : nil
20
+ @io = output.is_a?(String) ? (output == '-' ? $stdout : ::File.open(output, ::File::WRONLY | ::File::CREAT | ::File::TRUNC, 0o600)) : output
21
+ @io.chmod(0o600) if @owned
22
+ @io.binmode if @io.respond_to?(:binmode)
23
+ @bytes_written = 0
24
+ @interfaces, @counts, @start_times, @end_times, @statistics = {}, {}, {}, {}, {}
25
+ @closed = false
26
+ opts = { 3 => RUBY_PLATFORM, 4 => "Redhound #{VERSION}" }
27
+ opts[1] = comment if comment
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
32
+ end
33
+
34
+ # @rbs (Packet packet) -> self
35
+ def write(packet)
36
+ raise IOError, 'writer is closed' if @closed
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
39
+
40
+ index = ensure_interface(packet)
41
+ data = packet.data.byteslice(0, @snaplen)
42
+ flags = packet_flags(packet)
43
+ opts = { 2 => [flags].pack('V') }
44
+ opts[1] = packet.meta[:comment].to_s if packet.meta[:comment]
45
+ high, low = packet.timestamp_ns >> 32, packet.timestamp_ns & 0xffff_ffff
46
+ raise FileFormatError, 'pcapng timestamp exceeds unsigned 64-bit range' if high > 0xffff_ffff
47
+
48
+ body = [index, high, low, data.bytesize, packet.original_length].pack('V5') + Format.pad(data) + options(opts)
49
+ write_block(6, body)
50
+ @counts[index] = @counts.fetch(index, 0) + 1
51
+ @start_times[index] ||= packet.timestamp_ns
52
+ @end_times[index] = packet.timestamp_ns
53
+ @io.flush if @packet_buffered
54
+ self
55
+ end
56
+
57
+ alias << write
58
+
59
+ # @rbs (Packet packet) -> Integer
60
+ def record_size(packet)
61
+ 44 + Format.padded([packet.caplen, @snaplen].min) + (packet.meta[:comment] ? 4 + Format.padded(packet.meta[:comment].to_s.bytesize) : 0)
62
+ end
63
+
64
+ # @rbs (Capture::Stats stats, ?interface: Capture::Interface?) -> void
65
+ def write_stats(stats, interface: nil)
66
+ if interface
67
+ @interfaces.each { |key, index| @statistics[index] = stats if key[0] == interface.object_id }
68
+ elsif @interfaces.size == 1
69
+ @statistics[0] = stats
70
+ elsif @interfaces.size > 1
71
+ # Source statistics describe the entire capture; record them once to avoid double counting.
72
+ @statistics[0] = stats
73
+ end
74
+ end
75
+
76
+ # @rbs () -> void
77
+ def flush = @io.flush
78
+
79
+ # @rbs () -> void
80
+ def close
81
+ return if @closed
82
+
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))
94
+ end
95
+ @io.flush
96
+ ensure
97
+ @closed = true
98
+ @io.close if @owned && !@io.closed?
99
+ end
100
+ end
101
+
102
+ private
103
+
104
+ # @rbs (Packet packet) -> Integer
105
+ def ensure_interface(packet)
106
+ interface = packet.interface
107
+ key = [interface&.object_id || 0, packet.linktype]
108
+ return @interfaces[key] if @interfaces.key?(key)
109
+ raise FileFormatError, 'pcapng interface limit exceeded' if @interfaces.size >= Format::MAX_INTERFACES
110
+
111
+ index = @interfaces.size
112
+ opts = { 2 => interface&.name || "interface#{index}", 9 => "\x09".b, 12 => RUBY_PLATFORM }
113
+ opts[3] = interface.description if interface&.description
114
+ expression = @filter || interface&.filter
115
+ opts[11] = "\0" + expression if expression
116
+ write_block(1, [packet.linktype, 0, @snaplen].pack('vvV') + options(opts))
117
+ @interfaces[key] = index
118
+ index
119
+ end
120
+
121
+ # @rbs (Packet packet) -> Integer
122
+ def packet_flags(packet)
123
+ direction = { in: 1, out: 2 }.fetch(packet.direction, 0)
124
+ pkttype = packet.meta[:pkttype]
125
+ direction = pkttype == 4 ? 2 : 1 if direction.zero? && pkttype
126
+ reception = { 0 => 1, 1 => 3, 2 => 2, 3 => 4 }.fetch(pkttype, 0)
127
+ direction | (reception << 2)
128
+ end
129
+
130
+ # @rbs (Integer timestamp) -> String
131
+ def timestamp_words(timestamp) = [timestamp >> 32, timestamp & 0xffff_ffff].pack('V2')
132
+
133
+ # @rbs (Hash[Integer, String] values) -> String
134
+ def options(values)
135
+ values.map do |code, value|
136
+ raise FileFormatError, 'pcapng option exceeds 65535 bytes' if value.bytesize > 65_535
137
+
138
+ [code, value.bytesize].pack('vv') + Format.pad(value.b)
139
+ end.join.b + "\0" * 4
140
+ end
141
+
142
+ # @rbs (Integer type, String body) -> void
143
+ def write_block(type, body)
144
+ length = body.bytesize + 12
145
+ raise FileFormatError, 'pcapng block exceeds 16 MiB' if length > Format::MAX_RECORD
146
+
147
+ @bytes_written += @io.write([type, length].pack('V2') + body + [length].pack('V'))
148
+ end
149
+ end
150
+ end
151
+ end