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,55 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module Redhound
5
+ # @api private
6
+ module Filter
7
+ # @api private
8
+ class Program
9
+ KernelPacket = Struct.new(:data, :original_length, :meta)
10
+ attr_reader :instructions # : Array[[Integer, Integer, Integer, Integer]]
11
+ attr_reader :linktype # : Integer?
12
+
13
+ # @rbs (Array[[Integer, Integer, Integer, Integer]] instructions, ?linktype: Integer?, ?cooked_live: bool) -> void
14
+ def initialize(instructions, linktype: nil, cooked_live: false)
15
+ BPF::Validator.validate!(instructions)
16
+ @instructions = instructions.map { |instruction| instruction.dup.freeze }.freeze
17
+ @linktype = linktype
18
+ @cooked_live = cooked_live
19
+ @vm = BPF::VM.new(@instructions)
20
+ end
21
+
22
+ # @rbs (untyped packet) -> Integer
23
+ def evaluate(packet)
24
+ return 0 if linktype && packet.linktype != linktype
25
+
26
+ if @cooked_live
27
+ return 0 if packet.data.bytesize < 20
28
+ protocol = packet.data.unpack1('n')
29
+ packet = KernelPacket.new(packet.data.byteslice(20..), packet.original_length - 20, packet.meta.merge(protocol: protocol))
30
+ end
31
+ @vm.evaluate(packet)
32
+ end
33
+
34
+ # @rbs (untyped packet) -> bool
35
+ def match?(packet) = evaluate(packet).positive?
36
+
37
+ # Native struct sock_filter / struct bpf_insn bytes.
38
+ # @rbs () -> String
39
+ def packed = instructions.map { |instruction| instruction.pack('SCCL') }.join
40
+
41
+ alias serialize packed
42
+ alias to_binary packed
43
+
44
+ # @rbs (?format: Symbol) -> String
45
+ def disassemble(format: :text) = BPF::Disassembler.disassemble(instructions, format: format)
46
+
47
+ # Includes a String reference retained by pack('P') until setsockopt/ioctl finishes.
48
+ # @rbs () -> String
49
+ def sock_fprog
50
+ pointer_size = [nil].pack('P').bytesize
51
+ [instructions.size, packed].pack("S x#{pointer_size - 2} P")
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require_relative 'errors'
5
+ require_relative 'filter/bpf/validator'
6
+ require_relative 'filter/bpf/vm'
7
+ require_relative 'filter/bpf/disassembler'
8
+ require_relative 'filter/program'
9
+ require_relative 'filter/lexer'
10
+ require_relative 'filter/parser'
11
+ require_relative 'filter/analyzer'
12
+ require_relative 'filter/bpf/assembler'
13
+ require_relative 'filter/codegen'
14
+
15
+ module Redhound
16
+ # @api private
17
+ module Filter
18
+ # Compiles a pcap-filter subset without libpcap or external commands.
19
+ # @rbs (String expression, ?linktype: Symbol | Integer, ?snaplen: Integer, ?live: bool) -> Program
20
+ def self.compile(expression, linktype: :ethernet, snaplen: 262_144, live: false)
21
+ raise ArgumentError, 'filter expression must be a String' unless expression.is_a?(String)
22
+ raise ArgumentError, 'snaplen must be between 1 and 16777216' unless snaplen.between?(1, 16_777_216)
23
+ type = linktype.is_a?(Integer) ? linktype : CodeGen::LINKTYPES.fetch(linktype) { raise FilterError, "unknown linktype #{linktype}" }
24
+ ast = Analyzer.new(expression).analyze(Parser.new(expression).parse)
25
+ CodeGen.new(type, snaplen, live, expression).compile(ast)
26
+ rescue SystemStackError
27
+ raise FilterTooLarge, 'filter expression is too deeply nested'
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,75 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module Redhound
5
+ # One decoded protocol header and its payload bounds, fields and diagnostics.
6
+ class Layer
7
+ # Protocol identity, captured offset, decoded values and diagnostics.
8
+ attr_reader :protocol, :offset, :diagnostics, :definitions, :values, :embedded
9
+ # Header size and absolute payload bounds used by child dissectors.
10
+ attr_accessor :header_length, :payload_offset, :payload_end
11
+
12
+ # @rbs (Symbol protocol, Integer offset, Integer length, Integer limit, ?definitions: Array[untyped], ?values: Hash[Symbol, untyped], ?embedded: bool) -> void
13
+ # Create a bounded header with optional fixed field definitions and values.
14
+ def initialize(protocol, offset, length, limit, definitions: [], values: {}, embedded: false)
15
+ @protocol, @offset, @header_length = protocol, offset, length
16
+ @payload_offset, @payload_end = offset + length, limit
17
+ @definitions, @values, @embedded = definitions, values, embedded
18
+ @diagnostics = [] # @rbs Array[untyped]
19
+ end
20
+
21
+ # @rbs (Symbol key) -> untyped
22
+ # Read the raw value identified by a symbolic field key.
23
+ def [](key) = @values[key]
24
+ # @rbs () -> Integer
25
+ # Decoded header length in bytes.
26
+ def length = @header_length
27
+ # @rbs () -> bool
28
+ # Whether no bytes remain for a child protocol.
29
+ def payload_empty? = @payload_offset >= @payload_end
30
+ # @rbs () -> bool
31
+ # Whether an error or truncation stops the protocol chain.
32
+ def error? = @diagnostics.any? { |d| d.severity == :error || d.code == :truncated }
33
+
34
+ # @rbs (Symbol severity, Symbol code, ?String message, ?String? field) -> Layer
35
+ # Append a structured diagnostic and return this layer.
36
+ def diagnose(severity, code, message = code.to_s, field = nil)
37
+ @diagnostics << Diagnostic.new(severity, code, message, field)
38
+ self
39
+ end
40
+
41
+ # @rbs (Symbol key, String name, untyped value, ?type: Symbol, ?offset: Integer, ?length: Integer, ?format: Symbol?, ?enum: untyped) -> Layer
42
+ # Append a dynamic field with its value, representation and location.
43
+ def add(key, name, value, type: :uint, offset: 0, length: 0, format: nil, enum: nil)
44
+ @values[key] = value
45
+ @definitions << FieldDefinition.new(key, name, type, offset, length, 0, nil, nil, 1, format, enum)
46
+ self
47
+ end
48
+
49
+ # @rbs () -> Array[Field]
50
+ # Return Field objects for all fixed and dynamic definitions.
51
+ def fields = @definitions.map { |d| Field.new(d, @values[d.key], @offset) }
52
+ # @rbs (Symbol key) -> String
53
+ # Return escaped text for a single symbolic field.
54
+ def display(key)
55
+ definition = @definitions.find { |d| d.key == key }
56
+ definition ? Field.new(definition, @values[key], @offset).display : ''
57
+ end
58
+ # @rbs (String name) -> untyped
59
+ # Read the first decoded value with this dotted field name.
60
+ def field_value(name)
61
+ definition = @definitions.find { |d| d.name == name }
62
+ definition ? Field.new(definition, @values[definition.key], @offset).value : nil
63
+ end
64
+ # @rbs (String name) -> Array[untyped]
65
+ # Read the first decoded value with this dotted field name.
66
+ def field_values(name) = fields.select { |f| f.name == name }.map(&:value)
67
+
68
+ # @rbs () -> Hash[Symbol, untyped]
69
+ # Return JSON fields, grouping repeated names into arrays.
70
+ def to_h
71
+ { protocol: @protocol, offset: @offset, length: length, embedded: @embedded,
72
+ fields: fields.group_by(&:name).to_h { |name, group| [name, group.length == 1 ? group.first.json_value : group.map(&:json_value)] } }
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,20 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module Redhound
5
+ # @api private
6
+ module Output
7
+ # @api private
8
+ class Fields
9
+ # @rbs (Array[String] names) -> void
10
+ def initialize(names) = @names = names
11
+ # @rbs (Packet packet, untyped io) -> void
12
+ def format(packet, io)
13
+ cells = @names.map do |name|
14
+ packet.layers.flat_map(&:fields).select { |f| f.name == name }.map(&:display).join(',')
15
+ end
16
+ io.write(cells.join("\t") + "\n")
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,64 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ require 'tempfile'
5
+
6
+ module Redhound
7
+ # @api private
8
+ module Output
9
+ # @api private
10
+ class Follow
11
+ # @rbs (String specification) -> void
12
+ def initialize(specification)
13
+ match = /\Atcp,(ascii|hex|raw),(\d+)\z/.match(specification)
14
+ raise ConfigurationError, 'follow must be tcp,ascii|hex|raw,N' unless match
15
+ @format, @stream_id, @offsets = match[1], match[2].to_i, [0, 0]
16
+ @file = Tempfile.new(['redhound-follow-', '.bin'])
17
+ @file.binmode
18
+ @key = nil # @rbs untyped
19
+ end
20
+ # @rbs (Analysis::Flow flow, Integer direction, String data) -> void
21
+ def write(flow, direction, data)
22
+ return unless flow.id == @stream_id && !data.empty?
23
+ @key ||= flow.key
24
+ @file.write([direction, data.bytesize].pack('CN'))
25
+ @file.write(data)
26
+ end
27
+ # @rbs (untyped io) -> void
28
+ def format(io)
29
+ @file.rewind
30
+ unless @format == 'raw'
31
+ io.puts("TCP stream #{@stream_id} (#{@format})")
32
+ if @key
33
+ io.puts("Node 0: #{@key[1]}:#{@key[2]}")
34
+ io.puts("Node 1: #{@key[3]}:#{@key[4]}")
35
+ end
36
+ end
37
+ while (header = @file.read(5))
38
+ direction, length = header.unpack('CN')
39
+ data = @file.read(length) #: String
40
+ case @format
41
+ when 'raw' then io.write(data)
42
+ when 'ascii'
43
+ io.write(direction.zero? ? '' : "\t")
44
+ io.write(data.gsub(/[^\x20-\x7e\r\n\t]/n) { |byte| Kernel.format('\\x%02x', byte.getbyte(0)) })
45
+ when 'hex'
46
+ data.bytes.each_slice(16) do |bytes|
47
+ io.puts(Kernel.format('%s%08x %s', direction.zero? ? '' : "\t", @offsets[direction], bytes.map { |byte| Kernel.format('%02x', byte) }.join(' ')))
48
+ @offsets[direction] += bytes.size
49
+ end
50
+ end
51
+ end
52
+ ensure
53
+ original_error = $!
54
+ begin
55
+ close
56
+ rescue StandardError
57
+ raise unless original_error
58
+ end
59
+ end
60
+ # @rbs () -> void
61
+ def close = @file.close!
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,29 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module Redhound
5
+ # @api private
6
+ module Output
7
+ # @api private
8
+ class Hexdump
9
+ # @rbs (?ascii: bool, ?link_layer: bool) -> void
10
+ def initialize(ascii: false, link_layer: false)
11
+ @ascii, @link_layer = ascii, link_layer
12
+ end
13
+ # @rbs (Packet packet, untyped io) -> void
14
+ def format(packet, io)
15
+ offset = @link_layer ? 0 : (packet.layers.find { |l| %i[ipv4 ipv6 arp].include?(l.protocol) }&.offset || 0)
16
+ data = packet.data.byteslice(offset..) || ''.b
17
+ pos = 0
18
+ while pos < data.bytesize
19
+ chunk = data.byteslice(pos, 16) #: String
20
+ hex = chunk.unpack('H2' * chunk.bytesize).join(' ')
21
+ line = Kernel.format(' 0x%04x: %-47s', pos, hex)
22
+ line += ' ' + chunk.gsub(/[^\x20-\x7e]/n, '.') if @ascii
23
+ io.write(line.rstrip + "\n")
24
+ pos += 16
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,32 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ require 'json'
5
+
6
+ module Redhound
7
+ # @api private
8
+ module Output
9
+ # @api private
10
+ class Json
11
+ # @rbs (?ndjson: bool) -> void
12
+ def initialize(ndjson: false)
13
+ @ndjson, @started = ndjson, false
14
+ end
15
+ # @rbs (Packet packet, untyped io) -> void
16
+ def format(packet, io)
17
+ if @ndjson
18
+ io.write(JSON.generate(packet.to_h) + "\n")
19
+ return
20
+ end
21
+ io.write(@started ? ",\n" : "[\n")
22
+ io.write(JSON.generate(packet.to_h))
23
+ @started = true
24
+ end
25
+ # @rbs (untyped io) -> void
26
+ def finish(io)
27
+ return if @ndjson
28
+ io.write(@started ? "\n]\n" : "[]\n")
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,84 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module Redhound
5
+ # @api private
6
+ module Output
7
+ # @api private
8
+ class Summary
9
+ # @rbs (?timestamp: Symbol, ?precision: Symbol, ?link_layer: bool, ?quick: bool, ?verbosity: Integer, ?resolve_names: bool) -> void
10
+ def initialize(timestamp: :clock, precision: :micro, link_layer: false, quick: false, verbosity: 0, resolve_names: false)
11
+ @timestamp = Timestamp.new(style: timestamp, precision: precision)
12
+ @link_layer, @quick, @verbosity, @resolve_names = link_layer, quick, verbosity, resolve_names
13
+ @names = {} #: Hash[String, String]
14
+ end
15
+ # @rbs (Packet packet) -> String
16
+ def line(packet)
17
+ parts = [@timestamp.format(packet)]
18
+ if packet.interface
19
+ parts << [packet.interface.name, packet.direction&.to_s&.capitalize].compact.join(' ')
20
+ end
21
+ if @link_layer && (eth = packet[:eth])
22
+ parts << "#{eth.display(:src)} > #{eth.display(:dst)}, ethertype #{eth.display(:type)}, length #{packet.original_length}:"
23
+ end
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
38
+ if network
39
+ src, dst = address(network.display(:src)), address(network.display(:dst))
40
+ src += ".#{transport[:srcport]}" if transport
41
+ dst += ".#{transport[:dstport]}" if transport
42
+ parts << "#{network.protocol == :ipv4 ? 'IP' : 'IP6'} #{src} > #{dst}:"
43
+ end
44
+ layer ||= packet.layers.last
45
+ klass = layer && Registry.default.protocols[layer.protocol]
46
+ parts << if @quick && transport
47
+ "#{transport.protocol.to_s.upcase}, length #{[transport.payload_end - transport.payload_offset, 0].max}"
48
+ elsif klass && layer && !layer.error?
49
+ klass.new.summary(layer)
50
+ else
51
+ "#{layer&.protocol.to_s.upcase}, length #{packet.caplen}"
52
+ end
53
+ if @verbosity.positive? && network
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
64
+ end
65
+ diagnostics = packet.layers.flat_map(&:diagnostics)
66
+ parts << diagnostics.map { |d| "[#{d.code}]" }.join(' ') unless diagnostics.empty?
67
+ # All protocol summaries and interface names pass this terminal boundary.
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
70
+ end
71
+ # @rbs (Packet packet, untyped io) -> void
72
+ def format(packet, io) = io.write(line(packet) + "\n")
73
+ # @rbs (String ip) -> String
74
+ def address(ip)
75
+ return ip unless @resolve_names
76
+ return @names[ip] if @names.key?(ip)
77
+ @names.shift if @names.length >= 256
78
+ @names[ip] = Addrinfo.ip(ip).getnameinfo.first
79
+ rescue SocketError
80
+ ip
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,34 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module Redhound
5
+ # @api private
6
+ module Output
7
+ # @api private
8
+ class Timestamp
9
+ # @rbs (?style: Symbol, ?precision: Symbol) -> void
10
+ def initialize(style: :clock, precision: :micro)
11
+ @style, @precision, @first, @previous = style, precision, nil, nil
12
+ end
13
+ # @rbs (Packet packet) -> String
14
+ def format(packet)
15
+ ns = packet.timestamp_ns
16
+ @first ||= ns
17
+ value = case @style
18
+ when :none then ''
19
+ when :epoch then seconds(ns)
20
+ when :delta then seconds(@previous ? ns - @previous : 0)
21
+ when :elapsed then seconds(ns - @first)
22
+ when :date then packet.time.strftime('%Y-%m-%d %H:%M:%S.') + fraction(ns)
23
+ else packet.time.strftime('%H:%M:%S.') + fraction(ns)
24
+ end
25
+ @previous = ns
26
+ value
27
+ end
28
+ # @rbs (Integer ns) -> String
29
+ def fraction(ns) = Kernel.format(@precision == :nano ? '%09d' : '%06d', @precision == :nano ? ns % 1_000_000_000 : (ns % 1_000_000_000) / 1000)
30
+ # @rbs (Integer ns) -> String
31
+ def seconds(ns) = "#{ns.negative? ? '-' : ''}#{ns.abs / 1_000_000_000}.#{fraction(ns.abs)}"
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,25 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module Redhound
5
+ # @api private
6
+ module Output
7
+ # @api private
8
+ class Tree
9
+ # @rbs (Packet packet, untyped io) -> void
10
+ def format(packet, io)
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")
13
+ packet.layers.each_with_index do |layer, depth|
14
+ klass = Registry.default.protocols[layer.protocol]
15
+ indent = ' ' * depth
16
+ io.write("#{indent}└─ #{klass&.protocol_name || layer.protocol}#{layer.embedded ? ' (embedded)' : ''}\n")
17
+ layer.fields.each { |field| io.write("#{indent} #{field.name}: #{field.display}\n") }
18
+ layer.diagnostics.each { |d| io.write("#{indent} [#{d.severity}] #{d.code}: #{safe(d.message)}\n") }
19
+ end
20
+ end
21
+ # @rbs (String text) -> String
22
+ def safe(text) = text.b.gsub(/[^\x20-\x7e]/n) { |c| Kernel.format('\\x%02x', c.getbyte(0)) }
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,77 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module Redhound
5
+ # Captured immutable bytes, metadata, and lazily decoded protocol layers.
6
+ class Packet
7
+ # Capture bytes and metadata; timestamp_ns and lengths preserve kernel/file values.
8
+ attr_reader :data, :timestamp_ns, :original_length, :linktype, :interface, :direction, :number, :meta
9
+ # Override the dissection engine before accessing layers.
10
+ attr_accessor :engine
11
+ # @rbs (String data, ?timestamp_ns: Integer, ?original_length: Integer?, ?linktype: (Integer | Symbol), ?interface: untyped, ?direction: Symbol?, ?number: Integer, ?meta: Hash[Symbol, untyped]) -> void
12
+ # Copy binary data and preserve capture metadata; reject impossible original lengths.
13
+ def initialize(data, timestamp_ns: 0, original_length: nil, linktype: 1, interface: nil, direction: nil, number: 1, meta: {})
14
+ @data = data.b.freeze
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)
20
+ raise ArgumentError, 'original length is shorter than captured length' if @original_length < data.bytesize
21
+ @linktype = Capture::Linktype.resolve(linktype)
22
+ @interface, @direction, @number, @meta = interface, direction, number, meta.dup
23
+ @engine = nil # @rbs Engine?
24
+ end
25
+ # @rbs () -> Integer
26
+ # Number of captured bytes.
27
+ def caplen = @data.bytesize
28
+ # @rbs () -> bool
29
+ # Whether capture omitted bytes from the original packet.
30
+ def truncated? = caplen < @original_length
31
+ # @rbs () -> Time
32
+ # Capture timestamp as a nanosecond-precision Time.
33
+ def time = Time.at(@timestamp_ns / 1_000_000_000, @timestamp_ns % 1_000_000_000, :nsec)
34
+ # @rbs () -> Array[Layer]
35
+ # Decode the protocol chain once and return its Layers.
36
+ def layers = (@layers ||= (@engine || Engine.default).dissect(self))
37
+ # @rbs ((Symbol | String) key) -> untyped
38
+ # Select the first layer by symbol or decoded field by dotted name.
39
+ def [](key)
40
+ return layers.find { |l| l.protocol == key } if key.is_a?(Symbol)
41
+ layers.each do |l|
42
+ val = l.field_value(key)
43
+ return val unless val.nil?
44
+ end
45
+ nil
46
+ end
47
+ # @rbs (Symbol protocol) -> Array[Layer]
48
+ # Decode the protocol chain once and return its Layers.
49
+ def layers_of(protocol) = layers.select { |l| l.protocol == protocol }
50
+ # @rbs (Symbol protocol) -> Layer?
51
+ # Return the last matching layer in the protocol chain.
52
+ def innermost(protocol) = layers_of(protocol).last
53
+ # @rbs (String name) -> Array[untyped]
54
+ # Return every decoded value of a repeated field.
55
+ def field_values(name) = layers.flat_map { |l| l.field_values(name) }
56
+ # @rbs () -> String
57
+ # Format a numeric one-line packet summary.
58
+ def summary = Output::Summary.new.line(self)
59
+ # @rbs () -> Hash[Symbol, untyped]
60
+ # Return a JSON-compatible packet matching docs/json-schema.json.
61
+ def to_h
62
+ { frame: { number: @number, time_epoch_ns: @timestamp_ns, time: time.utc.strftime('%Y-%m-%dT%H:%M:%S.') + format('%09dZ', time.nsec),
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
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,39 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module Redhound
5
+ # @api private
6
+ module Protocols
7
+ # @api private
8
+ class Arp < Dissector
9
+ protocol :arp, name: 'Address Resolution Protocol', short: 'ARP'
10
+ dissects_on 'ethertype', 0x0806
11
+ header do
12
+ uint16 :hwtype, 'arp.hw.type'
13
+ uint16 :protype, 'arp.proto.type'
14
+ uint8 :hwlen, 'arp.hw.size'
15
+ uint8 :prolen, 'arp.proto.size'
16
+ uint16 :opcode, 'arp.opcode'
17
+ end
18
+ # @rbs (Context ctx, Layer layer) -> void
19
+ def dissect(ctx, layer)
20
+ hlen, plen = layer[:hwlen], layer[:prolen]
21
+ cursor = ctx.cursor
22
+ cursor.check(8, (hlen + plen) * 2)
23
+ if hlen == 6 && plen == 4 && layer[:protype] == 0x0800
24
+ layer.add(:sha, 'arp.src.hw_mac', cursor.bytes(8, 6), type: :mac, offset: 8, length: 6)
25
+ layer.add(:spa, 'arp.src.proto_ipv4', cursor.u32(14), type: :ipv4, offset: 14, length: 4)
26
+ layer.add(:tha, 'arp.dst.hw_mac', cursor.bytes(18, 6), type: :mac, offset: 18, length: 6)
27
+ layer.add(:tpa, 'arp.dst.proto_ipv4', cursor.u32(24), type: :ipv4, offset: 24, length: 4)
28
+ end
29
+ layer.header_length = 8 + 2 * (hlen + plen)
30
+ layer.payload_end = layer.payload_offset = layer.offset + layer.header_length
31
+ end
32
+ # @rbs (Layer layer) -> String
33
+ def summary(layer)
34
+ layer[:opcode] == 1 ? "ARP, Request who-has #{layer.display(:tpa)} tell #{layer.display(:spa)}" :
35
+ "ARP, Reply #{layer.display(:spa)} is-at #{layer.display(:sha)}"
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,38 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module Redhound
5
+ # @api private
6
+ module Protocols
7
+ # @api private
8
+ module Checksum
9
+ # @rbs (String bytes) -> Integer
10
+ def self.value(bytes)
11
+ sum = bytes.unpack('n*').sum
12
+ sum += bytes.getbyte(-1).to_i << 8 if bytes.bytesize.odd?
13
+ sum = (sum & 0xffff) + (sum >> 16) while sum > 0xffff
14
+ (~sum) & 0xffff
15
+ end
16
+ # @rbs (Context ctx, Layer layer, String bytes, ?Integer? proto) -> void
17
+ def self.verify(ctx, layer, bytes, proto = nil)
18
+ return unless ctx.verify_checksums?
19
+ network = ctx.network
20
+ fragmented = network&.[](:mf) == 1 || ctx.layers.any? { |part| part.protocol == :ipv6_ext && part[:more] == 1 }
21
+ if ctx.packet.direction == :out || ctx.packet.meta[:csum_not_ready] || ctx.embedded || fragmented
22
+ layer.diagnose(:note, :checksum_unverified, 'checksum not verified (offloaded or incomplete)')
23
+ return
24
+ end
25
+ if proto && network
26
+ src, dst = network[:src], network[:dst]
27
+ pseudo = if network.protocol == :ipv4
28
+ [src, dst, 0, proto, bytes.bytesize].pack('NNCCn')
29
+ else
30
+ src + dst + [bytes.bytesize, proto].pack('Nx3C')
31
+ end
32
+ bytes = pseudo + bytes
33
+ end
34
+ layer.diagnose(:warn, :bad_checksum, 'checksum does not match') unless value(bytes).zero?
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,23 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module Redhound
5
+ # @api private
6
+ module Protocols
7
+ # @api private
8
+ class Data < Dissector
9
+ LENGTH_FIELD = FieldDefinition.new(:length, 'data.len', :uint, 0, 0, 0, nil, nil, 1, nil, nil)
10
+ protocol :data, name: 'Data', short: 'DATA'
11
+ # @rbs (Context ctx, Layer layer) -> void
12
+ def dissect(ctx, layer)
13
+ length = ctx.cursor.remaining
14
+ layer.definitions << LENGTH_FIELD
15
+ layer.values[:length] = length
16
+ layer.add(:data, 'data.data', ctx.cursor.bytes(0, length), type: :bytes, length: length)
17
+ layer.payload_offset = layer.payload_end
18
+ end
19
+ # @rbs (Layer layer) -> String
20
+ def summary(layer) = "DATA, length #{layer[:length]}"
21
+ end
22
+ end
23
+ end