redhound 1.0.0 → 2.0.0.rc1

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 (230) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/README.md +97 -31
  4. data/docs/API.md +76 -0
  5. data/docs/FILTERS.md +37 -0
  6. data/docs/MIGRATION.md +23 -0
  7. data/docs/PROTOCOLS.md +45 -0
  8. data/docs/USAGE.md +82 -0
  9. data/docs/VALIDATION.md +59 -0
  10. data/docs/json-schema.json +64 -0
  11. data/exe/redhound +2 -1
  12. data/lib/redhound/analysis/flow_key.rb +53 -0
  13. data/lib/redhound/analysis/flow_table.rb +117 -0
  14. data/lib/redhound/analysis/ip_reassembler.rb +176 -0
  15. data/lib/redhound/analysis/stats.rb +138 -0
  16. data/lib/redhound/analysis/tcp_analysis.rb +43 -0
  17. data/lib/redhound/analysis/tcp_reassembler.rb +124 -0
  18. data/lib/redhound/analysis/tcp_stream.rb +151 -0
  19. data/lib/redhound/analysis.rb +129 -0
  20. data/lib/redhound/capture/bsd/bpf_device.rb +152 -0
  21. data/lib/redhound/capture/bsd/constants.rb +29 -0
  22. data/lib/redhound/capture/file_source.rb +83 -0
  23. data/lib/redhound/capture/interface.rb +66 -0
  24. data/lib/redhound/capture/linktype.rb +30 -0
  25. data/lib/redhound/capture/linux/constants.rb +37 -0
  26. data/lib/redhound/capture/linux/cooked.rb +18 -0
  27. data/lib/redhound/capture/linux/ifreq.rb +42 -0
  28. data/lib/redhound/capture/linux/packet_socket.rb +180 -0
  29. data/lib/redhound/capture/linux/sockaddr_ll.rb +33 -0
  30. data/lib/redhound/capture/linux/tpacket_v3.rb +132 -0
  31. data/lib/redhound/capture/source.rb +79 -0
  32. data/lib/redhound/capture/stats.rb +22 -0
  33. data/lib/redhound/capture.rb +69 -0
  34. data/lib/redhound/cli/command.rb +42 -0
  35. data/lib/redhound/cli/options.rb +133 -0
  36. data/lib/redhound/cli/privileges.rb +29 -0
  37. data/lib/redhound/cli/runner.rb +129 -0
  38. data/lib/redhound/context.rb +20 -0
  39. data/lib/redhound/cursor.rb +69 -0
  40. data/lib/redhound/diagnostic.rb +7 -0
  41. data/lib/redhound/dissector.rb +138 -0
  42. data/lib/redhound/engine.rb +66 -0
  43. data/lib/redhound/errors.rb +38 -0
  44. data/lib/redhound/field.rb +62 -0
  45. data/lib/redhound/file/format.rb +56 -0
  46. data/lib/redhound/file/pcap_reader.rb +43 -0
  47. data/lib/redhound/file/pcap_writer.rb +72 -0
  48. data/lib/redhound/file/pcapng_reader.rb +209 -0
  49. data/lib/redhound/file/pcapng_writer.rb +143 -0
  50. data/lib/redhound/file/rotating_writer.rb +98 -0
  51. data/lib/redhound/filter/analyzer.rb +197 -0
  52. data/lib/redhound/filter/bpf/assembler.rb +121 -0
  53. data/lib/redhound/filter/bpf/disassembler.rb +58 -0
  54. data/lib/redhound/filter/bpf/validator.rb +77 -0
  55. data/lib/redhound/filter/bpf/vm.rb +108 -0
  56. data/lib/redhound/filter/codegen.rb +413 -0
  57. data/lib/redhound/filter/lexer.rb +46 -0
  58. data/lib/redhound/filter/parser.rb +206 -0
  59. data/lib/redhound/filter/program.rb +55 -0
  60. data/lib/redhound/filter.rb +30 -0
  61. data/lib/redhound/layer.rb +75 -0
  62. data/lib/redhound/output/fields.rb +20 -0
  63. data/lib/redhound/output/follow.rb +59 -0
  64. data/lib/redhound/output/hexdump.rb +29 -0
  65. data/lib/redhound/output/json.rb +32 -0
  66. data/lib/redhound/output/summary.rb +59 -0
  67. data/lib/redhound/output/timestamp.rb +34 -0
  68. data/lib/redhound/output/tree.rb +24 -0
  69. data/lib/redhound/packet.rb +63 -0
  70. data/lib/redhound/protocols/arp.rb +39 -0
  71. data/lib/redhound/protocols/checksum.rb +38 -0
  72. data/lib/redhound/protocols/data.rb +21 -0
  73. data/lib/redhound/protocols/dhcp.rb +124 -0
  74. data/lib/redhound/protocols/dns.rb +268 -0
  75. data/lib/redhound/protocols/ethernet.rb +24 -0
  76. data/lib/redhound/protocols/http.rb +183 -0
  77. data/lib/redhound/protocols/icmp.rb +191 -0
  78. data/lib/redhound/protocols/ipv4.rb +56 -0
  79. data/lib/redhound/protocols/ipv6.rb +65 -0
  80. data/lib/redhound/protocols/link.rb +70 -0
  81. data/lib/redhound/protocols/llc.rb +37 -0
  82. data/lib/redhound/protocols/ntp.rb +47 -0
  83. data/lib/redhound/protocols/tcp.rb +80 -0
  84. data/lib/redhound/protocols/tls.rb +218 -0
  85. data/lib/redhound/protocols/tunnel.rb +48 -0
  86. data/lib/redhound/protocols/udp.rb +32 -0
  87. data/lib/redhound/protocols/vlan.rb +23 -0
  88. data/lib/redhound/reader.rb +8 -0
  89. data/lib/redhound/registry.rb +64 -0
  90. data/lib/redhound/stream_dissector.rb +168 -0
  91. data/lib/redhound/util/seq.rb +21 -0
  92. data/lib/redhound/version.rb +2 -1
  93. data/lib/redhound/writer.rb +25 -43
  94. data/lib/redhound.rb +81 -9
  95. data/sig/generated/redhound/analysis/flow_key.rbs +15 -0
  96. data/sig/generated/redhound/analysis/flow_table.rbs +98 -0
  97. data/sig/generated/redhound/analysis/ip_reassembler.rbs +40 -0
  98. data/sig/generated/redhound/analysis/stats.rbs +77 -0
  99. data/sig/generated/redhound/analysis/tcp_analysis.rbs +15 -0
  100. data/sig/generated/redhound/analysis/tcp_reassembler.rbs +27 -0
  101. data/sig/generated/redhound/analysis/tcp_stream.rbs +60 -0
  102. data/sig/generated/redhound/analysis.rbs +38 -0
  103. data/sig/generated/redhound/capture/bsd/bpf_device.rbs +39 -0
  104. data/sig/generated/redhound/capture/bsd/constants.rbs +40 -0
  105. data/sig/generated/redhound/capture/file_source.rbs +34 -0
  106. data/sig/generated/redhound/capture/interface.rbs +53 -0
  107. data/sig/generated/redhound/capture/linktype.rbs +20 -0
  108. data/sig/generated/redhound/capture/linux/constants.rbs +58 -0
  109. data/sig/generated/redhound/capture/linux/cooked.rbs +15 -0
  110. data/sig/generated/redhound/capture/linux/ifreq.rbs +18 -0
  111. data/sig/generated/redhound/capture/linux/packet_socket.rbs +57 -0
  112. data/sig/generated/redhound/capture/linux/sockaddr_ll.rbs +31 -0
  113. data/sig/generated/redhound/capture/linux/tpacket_v3.rbs +35 -0
  114. data/sig/generated/redhound/capture/source.rbs +44 -0
  115. data/sig/generated/redhound/capture/stats.rbs +25 -0
  116. data/sig/generated/redhound/capture.rbs +14 -0
  117. data/sig/generated/redhound/cli/command.rbs +12 -0
  118. data/sig/generated/redhound/cli/options.rbs +25 -0
  119. data/sig/generated/redhound/cli/privileges.rbs +12 -0
  120. data/sig/generated/redhound/cli/runner.rbs +27 -0
  121. data/sig/generated/redhound/context.rbs +27 -0
  122. data/sig/generated/redhound/cursor.rbs +59 -0
  123. data/sig/generated/redhound/diagnostic.rbs +21 -0
  124. data/sig/generated/redhound/dissector.rbs +113 -0
  125. data/sig/generated/redhound/engine.rbs +23 -0
  126. data/sig/generated/redhound/errors.rbs +56 -0
  127. data/sig/generated/redhound/field.rbs +74 -0
  128. data/sig/generated/redhound/file/format.rbs +33 -0
  129. data/sig/generated/redhound/file/pcap_reader.rbs +19 -0
  130. data/sig/generated/redhound/file/pcap_writer.rbs +38 -0
  131. data/sig/generated/redhound/file/pcapng_reader.rbs +54 -0
  132. data/sig/generated/redhound/file/pcapng_writer.rbs +50 -0
  133. data/sig/generated/redhound/file/rotating_writer.rbs +42 -0
  134. data/sig/generated/redhound/filter/analyzer.rbs +42 -0
  135. data/sig/generated/redhound/filter/bpf/assembler.rbs +39 -0
  136. data/sig/generated/redhound/filter/bpf/disassembler.rbs +18 -0
  137. data/sig/generated/redhound/filter/bpf/validator.rbs +30 -0
  138. data/sig/generated/redhound/filter/bpf/vm.rbs +26 -0
  139. data/sig/generated/redhound/filter/codegen.rbs +101 -0
  140. data/sig/generated/redhound/filter/lexer.rbs +15 -0
  141. data/sig/generated/redhound/filter/parser.rbs +65 -0
  142. data/sig/generated/redhound/filter/program.rbs +48 -0
  143. data/sig/generated/redhound/filter.rbs +10 -0
  144. data/sig/generated/redhound/layer.rbs +81 -0
  145. data/sig/generated/redhound/output/fields.rbs +15 -0
  146. data/sig/generated/redhound/output/follow.rbs +21 -0
  147. data/sig/generated/redhound/output/hexdump.rbs +15 -0
  148. data/sig/generated/redhound/output/json.rbs +18 -0
  149. data/sig/generated/redhound/output/summary.rbs +21 -0
  150. data/sig/generated/redhound/output/timestamp.rbs +21 -0
  151. data/sig/generated/redhound/output/tree.rbs +15 -0
  152. data/sig/generated/redhound/packet.rbs +77 -0
  153. data/sig/generated/redhound/protocols/arp.rbs +15 -0
  154. data/sig/generated/redhound/protocols/checksum.rbs +15 -0
  155. data/sig/generated/redhound/protocols/data.rbs +15 -0
  156. data/sig/generated/redhound/protocols/dhcp.rbs +28 -0
  157. data/sig/generated/redhound/protocols/dns.rbs +42 -0
  158. data/sig/generated/redhound/protocols/ethernet.rbs +15 -0
  159. data/sig/generated/redhound/protocols/http.rbs +36 -0
  160. data/sig/generated/redhound/protocols/icmp.rbs +41 -0
  161. data/sig/generated/redhound/protocols/ipv4.rbs +15 -0
  162. data/sig/generated/redhound/protocols/ipv6.rbs +24 -0
  163. data/sig/generated/redhound/protocols/link.rbs +33 -0
  164. data/sig/generated/redhound/protocols/llc.rbs +15 -0
  165. data/sig/generated/redhound/protocols/ntp.rbs +17 -0
  166. data/sig/generated/redhound/protocols/tcp.rbs +25 -0
  167. data/sig/generated/redhound/protocols/tls.rbs +51 -0
  168. data/sig/generated/redhound/protocols/tunnel.rbs +24 -0
  169. data/sig/generated/redhound/protocols/udp.rbs +18 -0
  170. data/sig/generated/redhound/protocols/vlan.rbs +12 -0
  171. data/sig/generated/redhound/reader.rbs +7 -0
  172. data/sig/generated/redhound/registry.rbs +45 -0
  173. data/sig/generated/redhound/stream_dissector.rbs +44 -0
  174. data/sig/generated/redhound/util/seq.rbs +20 -0
  175. data/sig/generated/redhound/version.rbs +1 -0
  176. data/sig/generated/redhound/writer.rbs +6 -19
  177. data/sig/generated/redhound.rbs +18 -0
  178. data/sig/shims/io.rbs +7 -0
  179. data/sig/shims/process.rbs +3 -0
  180. metadata +175 -55
  181. data/CODE_OF_CONDUCT.md +0 -132
  182. data/Dockerfile +0 -7
  183. data/Rakefile +0 -15
  184. data/Steepfile +0 -4
  185. data/docker-compose.yml +0 -9
  186. data/lib/redhound/analyzer.rb +0 -34
  187. data/lib/redhound/builder/packet_mreq.rb +0 -56
  188. data/lib/redhound/builder/socket.rb +0 -35
  189. data/lib/redhound/builder.rb +0 -4
  190. data/lib/redhound/command.rb +0 -67
  191. data/lib/redhound/l2/ether.rb +0 -68
  192. data/lib/redhound/l2/protocol.rb +0 -33
  193. data/lib/redhound/l2.rb +0 -4
  194. data/lib/redhound/l3/arp.rb +0 -114
  195. data/lib/redhound/l3/base.rb +0 -49
  196. data/lib/redhound/l3/ipv4.rb +0 -95
  197. data/lib/redhound/l3/ipv6.rb +0 -69
  198. data/lib/redhound/l3/protocol.rb +0 -173
  199. data/lib/redhound/l3/resolver.rb +0 -30
  200. data/lib/redhound/l3.rb +0 -8
  201. data/lib/redhound/l4/base.rb +0 -31
  202. data/lib/redhound/l4/icmp.rb +0 -71
  203. data/lib/redhound/l4/resolver.rb +0 -28
  204. data/lib/redhound/l4/udp.rb +0 -63
  205. data/lib/redhound/l4.rb +0 -6
  206. data/lib/redhound/receiver.rb +0 -45
  207. data/lib/redhound/resolver.rb +0 -22
  208. data/lib/redhound/source/socket.rb +0 -18
  209. data/lib/redhound/source.rb +0 -3
  210. data/rbs_collection.lock.yaml +0 -20
  211. data/rbs_collection.yaml +0 -17
  212. data/sig/generated/redhound/analyzer.rbs +0 -14
  213. data/sig/generated/redhound/builder/packet_mreq.rbs +0 -39
  214. data/sig/generated/redhound/builder/socket.rbs +0 -24
  215. data/sig/generated/redhound/command.rbs +0 -19
  216. data/sig/generated/redhound/l2/ether.rbs +0 -41
  217. data/sig/generated/redhound/l2/protocol.rbs +0 -15
  218. data/sig/generated/redhound/l3/arp.rbs +0 -57
  219. data/sig/generated/redhound/l3/base.rbs +0 -28
  220. data/sig/generated/redhound/l3/ipv4.rbs +0 -53
  221. data/sig/generated/redhound/l3/ipv6.rbs +0 -38
  222. data/sig/generated/redhound/l3/protocol.rbs +0 -16
  223. data/sig/generated/redhound/l3/resolver.rbs +0 -16
  224. data/sig/generated/redhound/l4/base.rbs +0 -19
  225. data/sig/generated/redhound/l4/icmp.rbs +0 -33
  226. data/sig/generated/redhound/l4/resolver.rbs +0 -16
  227. data/sig/generated/redhound/l4/udp.rbs +0 -36
  228. data/sig/generated/redhound/receiver.rbs +0 -19
  229. data/sig/generated/redhound/resolver.rbs +0 -11
  230. data/sig/generated/redhound/source/socket.rbs +0 -13
@@ -0,0 +1,133 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ require 'optparse'
5
+
6
+ module Redhound
7
+ # @api private
8
+ module CLI
9
+ # @api private
10
+ class Options
11
+ attr_reader :values, :parser
12
+ # @rbs (Array[String] argv) -> void
13
+ def initialize(argv)
14
+ @values = { interface: nil, read: nil, write: nil, count: nil, snaplen: 262144, promiscuous: true,
15
+ buffer_size: nil, direction: :inout, backend: :auto, filter: nil, format: nil,
16
+ packet_buffered: false, output: :summary, output_explicit: false, fields: Array.new, verbosity: 0,
17
+ link_layer: false, quick: false, timestamp: :clock, precision: :micro, resolve_names: false,
18
+ stats: Array.new, follow: nil, decode_as: Array.new, plugins: Array.new, dump: 0, debug: false, no_yjit: false }
19
+ args = normalize(argv.dup)
20
+ @parser = build_parser
21
+ @parser.order!(args)
22
+ filter = args.join(' ')
23
+ if @values[:filter_file]
24
+ raise ConfigurationError, 'cannot combine filter file and expression' unless filter.empty?
25
+ filter = ::File.read(@values[:filter_file])
26
+ end
27
+ @values[:filter] = filter unless filter.strip.empty?
28
+ validate
29
+ rescue OptionParser::ParseError, ArgumentError => e
30
+ raise ConfigurationError, e.message
31
+ end
32
+
33
+ # @rbs (Array[String] args) -> Array[String]
34
+ def normalize(args)
35
+ fields = args.each_cons(2).any? { |flag, value| %w[-T --output-format].include?(flag) && value == 'fields' } || args.include?('--output-format=fields')
36
+ result = [] #: Array[String]
37
+ while (arg = args.shift)
38
+ if /\A-v{1,3}\z/.match?(arg)
39
+ @values[:verbosity] += arg.length - 1
40
+ elsif /\A-t{1,5}\z/.match?(arg)
41
+ @values[:timestamp] = %i[none epoch delta date elapsed][arg.length - 2]
42
+ elsif %w[-x -xx -X -XX].include?(arg)
43
+ @values[:hex] = { ascii: arg.include?('X'), link_layer: arg.length == 3 }
44
+ elsif arg == '-e'
45
+ if fields
46
+ field = args.shift
47
+ raise ConfigurationError, '-e needs a field with -T fields' if !field || field.start_with?('-')
48
+ @values[:fields] << field
49
+ else
50
+ @values[:link_layer] = true
51
+ end
52
+ else
53
+ result << arg
54
+ end
55
+ end
56
+ result
57
+ end
58
+
59
+ # @rbs () -> OptionParser
60
+ def build_parser
61
+ OptionParser.new do |o|
62
+ o.banner = 'Usage: redhound [options] [filter expression]'
63
+ o.on('-i', '--interface IF', 'interface name, index or any') { |v| @values[:interface] = v }
64
+ o.on('-D', '--list-interfaces', 'list interfaces and exit') { @values[:list_interfaces] = true }
65
+ o.on('-r', '--read FILE', 'read pcap or pcapng (- for stdin)') { |v| @values[:read] = v }
66
+ o.on('-c', '--count N', Integer, 'stop after N packets') { |v| @values[:count] = v }
67
+ o.on('-s', '--snaplen N', Integer, 'capture length (default 262144)') { |v| @values[:snaplen] = v.zero? ? 262144 : v }
68
+ o.on('-p', '--no-promiscuous', 'disable promiscuous capture') { @values[:promiscuous] = false }
69
+ o.on('-B', '--buffer-size KiB', Integer, 'kernel buffer size') { |v| @values[:buffer_size] = v * 1024 }
70
+ o.on('-Q', '--direction DIR', %w[in out inout], 'capture direction') { |v| @values[:direction] = v.to_sym }
71
+ o.on('-F', '--filter-file FILE', 'read a capture filter') { |v| @values[:filter_file] = v }
72
+ o.on('--capture-backend BACKEND', %w[auto ring socket bpf], 'capture backend') { |v| @values[:backend] = v.to_sym }
73
+ o.on('-w', '--write FILE', 'write capture (- for stdout)') { |v| @values[:write] = v }
74
+ o.on('--format FORMAT', %w[pcap pcapng], 'capture file format') { |v| @values[:format] = v.to_sym }
75
+ o.on('-C MB', Float, 'rotate after MB (decimal)') { |v| @values[:max_bytes] = (v * 1_000_000).to_i }
76
+ o.on('-G SECONDS', Float, 'rotate at this interval') { |v| @values[:interval] = v }
77
+ o.on('-W N', Integer, 'maximum rotation file count') { |v| @values[:file_count] = v }
78
+ o.on('--post-rotate-command CMD', 'command to run after closing each file') { |v| @values[:post_rotate_command] = v }
79
+ o.on('-U', '--packet-buffered', 'flush each packet') { @values[:packet_buffered] = true }
80
+ o.on('-T', '--output-format FORMAT', %w[summary tree json ndjson fields], 'packet output format') do |v|
81
+ @values[:output], @values[:output_explicit] = v.to_sym, true
82
+ end
83
+ o.on('-V', 'show packet details') { @values[:output], @values[:output_explicit] = :tree, true }
84
+ o.on('-q', 'quick output and no capture statistics') { @values[:quick] = true }
85
+ o.on('--time-stamp-precision PRECISION', %w[micro nano], 'timestamp digits') { |v| @values[:precision] = v.to_sym }
86
+ o.on('-N', '--resolve-names', 'resolve addresses') { @values[:resolve_names] = true }
87
+ o.on('-n', 'disable address resolution (default)') { @values[:resolve_names] = false }
88
+ o.on('--stats SPEC', 'io,N / conv,TYPE / endpoints,TYPE / phs') { |v| @values[:stats] << v }
89
+ o.on('--follow SPEC', 'tcp,ascii|hex|raw,N') { |v| @values[:follow] = v }
90
+ o.on('--decode-as RULE', 'e.g. udp.port==8443,dns') { |v| @values[:decode_as] << v }
91
+ o.on('-I', '--require FILE', 'load a custom dissector') { |v| @values[:plugins] << v }
92
+ o.on('-d', 'dump cBPF instructions') { @values[:dump] += 1 }
93
+ o.on('-Z', '--relinquish-privileges USER', 'drop capture privileges') { |v| @values[:user] = v }
94
+ o.on('--list-protocols', 'list protocols and fields') { @values[:list_protocols] = true }
95
+ o.on('--debug', 'print error backtraces') { @values[:debug] = true }
96
+ o.on('--no-yjit', 'disable automatic YJIT activation') { @values[:no_yjit] = true }
97
+ o.on('-h', '--help', 'print help') { @values[:help] = true }
98
+ o.on('--version', 'print version') { @values[:version] = true }
99
+ o.separator ' -e [-T fields: FIELD] link header or selected field (repeatable)'
100
+ o.separator ' -v / -vv / -vvv verbosity and checksum verification'
101
+ o.separator ' -t / -tt / -ttt / -tttt / -ttttt timestamp style'
102
+ o.separator ' -x / -xx / -X / -XX hex dump, with link header / ASCII'
103
+ end
104
+ end
105
+
106
+ # @rbs () -> void
107
+ def validate
108
+ %i[count snaplen buffer_size max_bytes interval file_count].each do |key|
109
+ value = @values[key]
110
+ raise ConfigurationError, "#{key} must be positive" if value && (!value.positive? || (value.respond_to?(:finite?) && !value.finite?))
111
+ end
112
+ raise ConfigurationError, 'snaplen exceeds 16 MiB' if @values[:snaplen] > 16 * 1024 * 1024
113
+ raise ConfigurationError, 'choose either -r or -i' if @values[:read] && @values[:interface]
114
+ raise ConfigurationError, 'rotation requires -w' if !@values[:write] && %i[max_bytes interval file_count post_rotate_command].any? { |k| @values[k] }
115
+ raise ConfigurationError, '-W and post-rotate-command require -C or -G' if (@values[:file_count] || @values[:post_rotate_command]) && !@values[:max_bytes] && !@values[:interval]
116
+ raise ConfigurationError, '-T fields requires at least one -e' if @values[:output] == :fields && @values[:fields].empty?
117
+ raise ConfigurationError, 'cannot combine JSON/fields output and hex dump' if @values[:hex] && !%i[summary tree].include?(@values[:output])
118
+ raise ConfigurationError, 'cannot display packets while writing capture to stdout' if @values[:write] == '-' && (@values[:output_explicit] || @values[:hex])
119
+ if @values[:read] && @values[:write] && @values[:read] != '-' && @values[:write] != '-'
120
+ input, output = @values[:read], @values[:write]
121
+ if ::File.expand_path(input) == ::File.expand_path(output) || (::File.exist?(input) && ::File.exist?(output) && ::File.identical?(input, output))
122
+ raise ConfigurationError, 'input and output must be different files'
123
+ end
124
+ end
125
+ @values[:stats].each do |spec|
126
+ valid = spec == 'phs' || /\A(?:conv|endpoints),(?:eth|ip|ipv6|tcp|udp)\z/.match?(spec) || /\Aio,(?:\d+(?:\.\d+)?)\z/.match?(spec) && spec.split(',').last.to_f.positive?
127
+ raise ConfigurationError, "invalid statistics: #{spec}" unless valid
128
+ end
129
+ raise ConfigurationError, 'invalid TCP follow specification' if @values[:follow] && !/\Atcp,(?:ascii|hex|raw),\d+\z/.match?(@values[:follow])
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,29 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ require 'etc'
5
+
6
+ module Redhound
7
+ # @api private
8
+ module CLI
9
+ # @api private
10
+ module Privileges
11
+ # @rbs (String user) -> void
12
+ def self.drop(user)
13
+ account = Etc.getpwnam(user)
14
+ raise ConfigurationError, 'privilege target must not be root' if account.uid.zero?
15
+ Process.initgroups(account.name, account.gid)
16
+ Process::Sys.setgid(account.gid)
17
+ Process::Sys.setuid(account.uid)
18
+ begin
19
+ Process::Sys.setuid(0)
20
+ rescue Errno::EPERM
21
+ return
22
+ end
23
+ raise CaptureError, 'failed to drop root privileges permanently'
24
+ rescue ArgumentError => e
25
+ raise ConfigurationError, e.message
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,129 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module Redhound
5
+ # @api private
6
+ module CLI
7
+ # @api private
8
+ class Runner
9
+ # @rbs (Hash[Symbol, untyped] options, ?out: untyped, ?err: untyped) -> void
10
+ def initialize(options, out: $stdout, err: $stderr)
11
+ @options, @out, @err, @count = options, out, err, 0
12
+ @source, @writer, @formatter, @analysis = nil, nil, nil, nil
13
+ @handlers = {} #: Hash[String, untyped]
14
+ end
15
+ # @rbs () -> void
16
+ def run
17
+ opts = @options
18
+ install_signals
19
+ if opts[:dump].positive?
20
+ if opts[:read]
21
+ @source = read_capture { Redhound.open(opts[:read]) }
22
+ linktype = read_capture { @source.next_packet }&.linktype || @source.linktype
23
+ else
24
+ linktype = opts[:interface] ? Capture::Interface.find(opts[:interface]).linktype : 1
25
+ end
26
+ program = Filter.compile(opts[:filter] || '', linktype: linktype, live: !opts[:read])
27
+ @out.puts(program.disassemble(format: %i[text ruby decimal][[opts[:dump], 3].min - 1]))
28
+ return
29
+ end
30
+ @source = if opts[:read]
31
+ read_capture { Redhound.open(opts[:read], filter: opts[:filter]) }
32
+ else
33
+ raise ConfigurationError, 'specify an interface with -i or capture file with -r' unless opts[:interface]
34
+ Capture.open(interface: opts[:interface], backend: opts[:backend], snaplen: opts[:snaplen],
35
+ promiscuous: opts[:promiscuous], buffer_size: opts[:buffer_size], direction: opts[:direction], filter: opts[:filter])
36
+ end
37
+ if opts[:write]
38
+ @writer = Writer.open(opts[:write], linktype: @source.linktype, format: opts[:format], snaplen: opts[:snaplen],
39
+ precision: opts[:precision], packet_buffered: opts[:packet_buffered],
40
+ max_bytes: opts[:max_bytes], interval: opts[:interval], file_count: opts[:file_count],
41
+ post_rotate_command: opts[:post_rotate_command], forbidden_input: opts[:read], filter: opts[:filter])
42
+ end
43
+ display = !opts[:write] || opts[:output_explicit] || opts[:hex]
44
+ @formatter = formatter if display && !opts[:follow]
45
+ hex = opts[:hex] && Output::Hexdump.new(**opts[:hex])
46
+ registry = Registry.default.copy
47
+ opts[:decode_as].each { |rule| registry.decode_as(rule) }
48
+ engine = Engine.new(registry: registry, verify_checksums: opts[:verbosity].positive?)
49
+ if display || !opts[:stats].empty? || opts[:follow]
50
+ @analysis = Analysis::Session.new(stats: opts[:stats], follow: opts[:follow], registry: registry)
51
+ end
52
+ Privileges.drop(opts[:user]) if opts[:user]
53
+ until @stopping || @source.stopped?
54
+ if @report_requested
55
+ statistics
56
+ @analysis&.snapshot(@err)
57
+ @report_requested = false
58
+ end
59
+ packet = read_capture { @source.next_packet(timeout: 0.1) }
60
+ next unless packet
61
+ @writer&.write(packet)
62
+ packet.engine = engine
63
+ @analysis&.update(packet)
64
+ @formatter&.format(packet, @out)
65
+ hex&.format(packet, @out) if display
66
+ @out.flush if opts[:packet_buffered] || @out.tty?
67
+ @count += 1
68
+ break if @stopping || (opts[:count] && @count >= opts[:count])
69
+ end
70
+ rescue RotationComplete, Interrupt
71
+ nil
72
+ ensure
73
+ begin
74
+ begin
75
+ @formatter.finish(@out) if @formatter.respond_to?(:finish)
76
+ @analysis&.finish(@out, @err)
77
+ statistics if @source && !@options[:quick] && @options[:dump].zero?
78
+ @writer.write_stats(@source.stats) if @writer && @source && @writer.respond_to?(:write_stats)
79
+ ensure
80
+ @writer&.close
81
+ end
82
+ ensure
83
+ begin
84
+ @source&.close
85
+ ensure
86
+ @handlers.each { |signal, handler| Signal.trap(signal, handler) }
87
+ end
88
+ end
89
+ end
90
+ # @rbs () -> untyped
91
+ def formatter
92
+ opts = @options
93
+ case opts[:output]
94
+ when :tree then Output::Tree.new
95
+ when :json then Output::Json.new
96
+ when :ndjson then Output::Json.new(ndjson: true)
97
+ when :fields then Output::Fields.new(opts[:fields])
98
+ else Output::Summary.new(timestamp: opts[:timestamp], precision: opts[:precision], link_layer: opts[:link_layer],
99
+ quick: opts[:quick], verbosity: opts[:verbosity], resolve_names: opts[:resolve_names])
100
+ end
101
+ end
102
+ # @rbs () -> void
103
+ def install_signals
104
+ %w[INT TERM].each do |signal|
105
+ @handlers[signal] = Signal.trap(signal) do
106
+ @stopping = true
107
+ @source&.stop
108
+ raise Interrupt if @options[:read] && @reading_capture
109
+ end
110
+ end
111
+ %w[USR1 INFO].select { |s| Signal.list.key?(s) }.each do |signal|
112
+ @handlers[signal] = Signal.trap(signal) { @report_requested = true }
113
+ end
114
+ end
115
+ # @rbs [T] () { () -> T } -> T
116
+ def read_capture
117
+ @reading_capture = true
118
+ yield
119
+ ensure
120
+ @reading_capture = false
121
+ end
122
+ # @rbs () -> void
123
+ def statistics
124
+ stats = @source.stats
125
+ @err.puts("#{@count} packets captured", "#{stats.received} packets received by filter", "#{stats.dropped} packets dropped by kernel")
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,20 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module Redhound
5
+ # @api private
6
+ class Context
7
+ attr_reader :packet, :registry, :layers
8
+ attr_accessor :cursor, :embedded, :extension_count
9
+ # @rbs (Packet packet, registry: Registry, ?verify_checksums: bool) -> void
10
+ def initialize(packet, registry:, verify_checksums: false)
11
+ @packet, @registry, @verify_checksums = packet, registry, verify_checksums
12
+ @cursor = Cursor.new(packet.data)
13
+ @embedded, @extension_count, @layers = false, 0, []
14
+ end
15
+ # @rbs () -> bool
16
+ def verify_checksums? = @verify_checksums
17
+ # @rbs () -> Layer?
18
+ def network = @layers.reverse.find { |l| %i[ipv4 ipv6].include?(l.protocol) && l.embedded == @embedded }
19
+ end
20
+ end
@@ -0,0 +1,69 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module Redhound
5
+ # Checked binary reads within an absolute packet slice.
6
+ class Cursor
7
+ # Internal exception for a checked read beyond the captured slice.
8
+ class Truncated < StandardError; end
9
+ # Underlying data and absolute start/end bounds.
10
+ attr_reader :data, :start, :limit
11
+
12
+ # @rbs (String data, ?Integer start, ?Integer? limit) -> void
13
+ # Create a bounded view; reject invalid ranges.
14
+ def initialize(data, start = 0, limit = nil)
15
+ @data, @start, @limit = data, start, limit || data.bytesize
16
+ raise ArgumentError, 'invalid cursor bounds' if start.negative? || @limit < start || @limit > data.bytesize
17
+ end
18
+
19
+ # @rbs () -> Integer
20
+ # Remaining slice length in bytes.
21
+ def remaining = @limit - @start
22
+ # @rbs (Integer off) -> Integer
23
+ # Read an unsigned byte.
24
+ def u8(off)
25
+ check(off, 1)
26
+ @data.getbyte(@start + off) #: Integer
27
+ end
28
+ # @rbs (Integer off) -> Integer
29
+ # Read an unsigned network-order 16-bit integer.
30
+ def u16(off)
31
+ check(off, 2)
32
+ @data.unpack1('n', offset: @start + off) #: Integer
33
+ end
34
+ # @rbs (Integer off) -> Integer
35
+ # Read an unsigned network-order 32-bit integer.
36
+ def u32(off)
37
+ check(off, 4)
38
+ @data.unpack1('N', offset: @start + off) #: Integer
39
+ end
40
+ # @rbs (Integer off) -> Integer
41
+ # Read an unsigned network-order 64-bit integer.
42
+ def u64(off)
43
+ check(off, 8)
44
+ @data.unpack1('Q>', offset: @start + off) #: Integer
45
+ end
46
+ # @rbs (String template, Integer size, ?Integer off) -> Array[untyped]
47
+ # Unpack a checked fixed-width header.
48
+ def unpack(template, size, off = 0) = (check(off, size); @data.unpack(template, offset: @start + off))
49
+ # @rbs (Integer off, Integer len) -> String
50
+ # Read a checked binary substring.
51
+ def bytes(off, len)
52
+ check(off, len)
53
+ @data.byteslice(@start + off, len) #: String
54
+ end
55
+
56
+ # @rbs (Integer from, Integer to) -> Cursor
57
+ # Create a child slice that cannot escape the parent bounds.
58
+ def sub(from, to)
59
+ raise ArgumentError, 'child cursor escapes parent' if from < @start || from > @limit || to < from
60
+ Cursor.new(@data, from, [to, @limit].min)
61
+ end
62
+
63
+ # @rbs (Integer off, Integer len) -> void
64
+ # Validate a relative offset and read length.
65
+ def check(off, len)
66
+ raise Truncated, "need #{len} bytes at #{@start + off}, limit #{@limit}" if off.negative? || len.negative? || off + len > remaining
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,7 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module Redhound
5
+ # Structured protocol diagnostic with severity, stable code, message and optional field.
6
+ Diagnostic = Data.define(:severity, :code, :message, :field)
7
+ end
@@ -0,0 +1,138 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module Redhound
5
+ # Base class and declarative fixed-header DSL for custom protocols.
6
+ class Dissector
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],
9
+ mac: ['a6', 6], ipv4: ['N', 4], ipv6: ['a16', 16] }.freeze
10
+
11
+ # Build a single unpack template and its field descriptions.
12
+ class Header
13
+ # Compiled unpack template, header byte length and field definitions.
14
+ attr_reader :template, :length, :definitions
15
+ # @rbs () -> void
16
+ # Start an empty fixed-header definition.
17
+ def initialize
18
+ @template, @length, @index, @definitions = +'', 0, 0, []
19
+ end
20
+
21
+ # @rbs (Symbol type, Symbol key, String name, **untyped opts) -> void
22
+ # Append a typed fixed-width field with optional format/enum metadata.
23
+ def field(type, key, name, **opts)
24
+ fmt, size = TYPES.fetch(type)
25
+ @definitions << FieldDefinition.new(key, name, type, @length, size, @index, nil, nil, 1, opts[:format], opts[:enum])
26
+ @template << fmt
27
+ @length += size
28
+ @index += 1
29
+ end
30
+ # @rbs (Symbol key, String name, **untyped opts) -> void
31
+ # Append an unsigned 8-bit field.
32
+ def uint8(key, name, **opts) = field(:uint8, key, name, **opts)
33
+ # @rbs (Symbol key, String name, **untyped opts) -> void
34
+ # Append a network-order unsigned 16-bit field.
35
+ def uint16(key, name, **opts) = field(:uint16, key, name, **opts)
36
+ # @rbs (Symbol key, String name, **untyped opts) -> void
37
+ # Append a network-order unsigned 32-bit field.
38
+ def uint32(key, name, **opts) = field(:uint32, key, name, **opts)
39
+ # @rbs (Symbol key, String name, **untyped opts) -> void
40
+ # Append a network-order unsigned 64-bit field.
41
+ def uint64(key, name, **opts) = field(:uint64, key, name, **opts)
42
+ # @rbs (Symbol key, String name, **untyped opts) -> void
43
+ # Append a six-byte MAC address.
44
+ def mac(key, name, **opts) = field(:mac, key, name, **opts)
45
+ # @rbs (Symbol key, String name, **untyped opts) -> void
46
+ # Append a four-byte IPv4 address.
47
+ def ipv4(key, name, **opts) = field(:ipv4, key, name, **opts)
48
+ # @rbs (Symbol key, String name, **untyped opts) -> void
49
+ # Append a sixteen-byte IPv6 address.
50
+ def ipv6(key, name, **opts) = field(:ipv6, key, name, **opts)
51
+
52
+ # @rbs (Integer width) { (Header) [self: Header] -> void } -> void
53
+ # Define bit fields filling an 8/16/32/64-bit network-order word.
54
+ def bits(width, &block)
55
+ raise ArgumentError, 'bit width must be 8, 16, 32 or 64' unless [8, 16, 32, 64].include?(width)
56
+ @bit_width, @bit_left = width, width
57
+ instance_eval(&block)
58
+ raise ArgumentError, 'bit fields must fill the word' unless @bit_left.zero?
59
+ @template << TYPES.fetch("uint#{width}".to_sym).first
60
+ @length += width / 8
61
+ @index += 1
62
+ end
63
+
64
+ # @rbs (Symbol key, String name, Integer width, **untyped opts) -> void
65
+ # Append a bit field inside bits, optionally scaled/formatted.
66
+ def bit(key, name, width, **opts)
67
+ raise ArgumentError, 'invalid bit field width' unless width.positive? && width <= @bit_left
68
+ @bit_left -= width
69
+ @definitions << FieldDefinition.new(key, name, :bits, @length, @bit_width / 8, @index,
70
+ @bit_left, (1 << width) - 1, opts.fetch(:scale, 1), opts[:format], opts[:enum])
71
+ end
72
+ end
73
+
74
+ class << self
75
+ # @rbs () -> Symbol
76
+ # Registered protocol symbol.
77
+ def protocol_id = @protocol_id
78
+ # @rbs () -> String
79
+ # Human-readable protocol name.
80
+ def protocol_name = @protocol_name
81
+ # @rbs () -> String
82
+ # Short output label.
83
+ def short_name = @short_name
84
+ # @rbs () -> untyped
85
+ # Compiled header definition, if one was declared.
86
+ def compiled_header = @compiled_header
87
+ # @rbs (Symbol id, name: String, short: String) -> void
88
+ # Declare protocol identity and register the dissector.
89
+ def protocol(id, name:, short:)
90
+ @protocol_id, @protocol_name, @short_name = id, name, short
91
+ Registry.default.register_protocol(self)
92
+ end
93
+ # @rbs (String key, *Integer values) -> void
94
+ # Register this protocol under one or more dispatch values.
95
+ def dissects_on(key, *values)
96
+ values.each { |value| Registry.default.register(key, value, self) }
97
+ end
98
+ # @rbs () { (Header) [self: Header] -> void } -> void
99
+ # Compile a fixed header from the DSL block.
100
+ def header(&block)
101
+ @compiled_header = Header.new
102
+ @compiled_header.instance_eval(&block)
103
+ @compiled_header.template.freeze
104
+ end
105
+ # @rbs () -> String
106
+ # Compiled unpack template, or an empty template.
107
+ def header_template = @compiled_header&.template || ''
108
+ end
109
+
110
+ # @rbs (Context ctx, Cursor cursor) -> Layer
111
+ # Decode fixed fields then invoke the variable-header hook.
112
+ def parse(ctx, cursor)
113
+ header = self.class.compiled_header
114
+ length = header&.length || 0
115
+ raw = cursor.unpack(self.class.header_template, length)
116
+ defs = header ? header.definitions.dup : Array.new
117
+ values = {} #: Hash[Symbol, untyped]
118
+ defs.each do |d|
119
+ val = raw[d.index]
120
+ val = ((val >> d.shift) & d.mask) * d.scale if d.shift
121
+ values[d.key] = val
122
+ end
123
+ layer = Layer.new(self.class.protocol_id, cursor.start, length, cursor.limit,
124
+ definitions: defs, values: values, embedded: ctx.embedded)
125
+ dissect(ctx, layer)
126
+ layer
127
+ end
128
+ # @rbs (Context ctx, Layer layer) -> void
129
+ # Override to parse variable fields using the bounded Context cursor.
130
+ def dissect(ctx, layer); end
131
+ # @rbs (Context ctx, Layer layer) -> untyped
132
+ # Override to return the next protocol class or nil.
133
+ def next_dissector(ctx, layer) = nil
134
+ # @rbs (Layer layer) -> String
135
+ # Override to format the layer summary.
136
+ def summary(layer) = self.class.short_name || layer.protocol.to_s.upcase
137
+ end
138
+ end
@@ -0,0 +1,66 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module Redhound
5
+ # @api private
6
+ class Engine
7
+ MAX_DEPTH = 32
8
+ # @rbs () -> Engine
9
+ def self.default = (@default ||= new)
10
+ # @rbs (?registry: Registry, ?verify_checksums: bool) -> void
11
+ def initialize(registry: Registry.default, verify_checksums: false)
12
+ @registry, @verify_checksums = registry, verify_checksums
13
+ end
14
+ # @rbs (Packet packet) -> Array[Layer]
15
+ def dissect(packet)
16
+ ctx = Context.new(packet, registry: @registry, verify_checksums: @verify_checksums)
17
+ klass = @registry.lookup('linktype', packet.linktype) || Protocols::Data
18
+ loop do
19
+ cursor = ctx.cursor
20
+ layer = safely(klass, ctx, cursor)
21
+ ctx.layers << layer
22
+ break if layer.error? || layer.payload_empty? || klass == Protocols::Data
23
+ if ctx.layers.length >= MAX_DEPTH
24
+ layer.diagnose(:error, :depth_exceeded)
25
+ break
26
+ end
27
+ ctx.cursor = cursor.sub(layer.payload_offset, layer.payload_end)
28
+ next_klass = next_dissector(klass, ctx, layer)
29
+ break if layer.error?
30
+ klass = next_klass || Protocols::Data
31
+ end
32
+ ctx.layers
33
+ end
34
+
35
+ # @rbs (untyped klass, Context ctx, Layer layer) -> untyped
36
+ def next_dissector(klass, ctx, layer)
37
+ klass.new.next_dissector(ctx, layer) || @registry.heuristic(layer.protocol, ctx, ctx.cursor)
38
+ rescue Cursor::Truncated => e
39
+ layer.diagnose(:note, :truncated, e.message)
40
+ nil
41
+ rescue StandardError => e
42
+ raise if ENV['REDHOUND_STRICT'] == '1'
43
+ layer.diagnose(:error, :dissector_bug, "#{e.class}: #{e.message}")
44
+ nil
45
+ end
46
+
47
+ # @rbs (untyped klass, Context ctx, Cursor cursor) -> Layer
48
+ def safely(klass, ctx, cursor)
49
+ layer = klass.new.parse(ctx, cursor)
50
+ if layer.payload_offset < cursor.start || layer.payload_end < layer.payload_offset
51
+ layer.diagnose(:error, :bad_length)
52
+ elsif layer.payload_offset > cursor.limit || layer.payload_end > cursor.limit
53
+ layer.diagnose(:note, :truncated)
54
+ layer.payload_end = cursor.limit
55
+ end
56
+ layer
57
+ rescue Cursor::Truncated => e
58
+ Layer.new(klass.protocol_id, cursor.start, cursor.remaining, cursor.limit, embedded: ctx.embedded)
59
+ .diagnose(:note, :truncated, e.message)
60
+ rescue StandardError => e
61
+ raise if ENV['REDHOUND_STRICT'] == '1'
62
+ Layer.new(klass.protocol_id, cursor.start, 0, cursor.limit, embedded: ctx.embedded)
63
+ .diagnose(:error, :dissector_bug, "#{e.class}: #{e.message}")
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,38 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module Redhound
5
+ # Base exception for Redhound operational and configuration errors.
6
+ class Error < StandardError; end
7
+ # Capture-device or live-operation failure.
8
+ class CaptureError < Error; end
9
+ # @api private
10
+ class RotationComplete < CaptureError; end
11
+ # Insufficient permission to access the capture device.
12
+ class PermissionDenied < CaptureError; end
13
+ # Requested capture interface does not exist.
14
+ class InterfaceNotFound < CaptureError; end
15
+ # Requested platform or capture capability is unavailable.
16
+ class UnsupportedPlatform < CaptureError; end
17
+ # Malformed or unsupported capture-file structure.
18
+ class FileFormatError < Error; end
19
+ # Invalid command-line or library configuration.
20
+ class ConfigurationError < Error; end
21
+ # Invalid cBPF program or unsupported filter operation.
22
+ class FilterError < Error; end
23
+ # Filter exceeds the target platform instruction limit.
24
+ class FilterTooLarge < FilterError; end
25
+ # Capture-filter syntax error with original expression and byte position.
26
+ class FilterSyntaxError < FilterError
27
+ # Original filter expression and zero-based error position.
28
+ attr_reader :expression, :position
29
+
30
+ # @rbs (String message, ?expression: String?, ?position: Integer?) -> void
31
+ # Create a syntax diagnostic suitable for a caret display.
32
+ def initialize(message, expression: nil, position: nil)
33
+ @expression = expression
34
+ @position = position
35
+ super(message)
36
+ end
37
+ end
38
+ end