vanken 0.1.0

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 (104) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +84 -0
  5. data/docs/filters.md +25 -0
  6. data/docs/performance.md +113 -0
  7. data/docs/releases.md +15 -0
  8. data/docs/upstream.md +32 -0
  9. data/exe/vanken +5 -0
  10. data/exe/vanken-capture +6 -0
  11. data/lib/vanken/app/capture_controller.rb +197 -0
  12. data/lib/vanken/app/document.rb +305 -0
  13. data/lib/vanken/app/document_jobs.rb +199 -0
  14. data/lib/vanken/capture/analyzer.rb +44 -0
  15. data/lib/vanken/capture/analyzer_process.rb +91 -0
  16. data/lib/vanken/capture/analyzer_wire.rb +42 -0
  17. data/lib/vanken/capture/analyzer_worker.rb +153 -0
  18. data/lib/vanken/capture/control_protocol.rb +32 -0
  19. data/lib/vanken/capture/filter_worker.rb +180 -0
  20. data/lib/vanken/capture/helper_main.rb +137 -0
  21. data/lib/vanken/capture/helper_options.rb +70 -0
  22. data/lib/vanken/capture/launcher.rb +181 -0
  23. data/lib/vanken/capture/privileges.rb +24 -0
  24. data/lib/vanken/capture/receiver.rb +39 -0
  25. data/lib/vanken/cli.rb +68 -0
  26. data/lib/vanken/config/log.rb +19 -0
  27. data/lib/vanken/config/preferences.rb +126 -0
  28. data/lib/vanken/core/display_filter/compiler.rb +108 -0
  29. data/lib/vanken/core/display_filter/errors.rb +19 -0
  30. data/lib/vanken/core/display_filter/field_resolver.rb +86 -0
  31. data/lib/vanken/core/display_filter/lexer.rb +124 -0
  32. data/lib/vanken/core/display_filter/parser.rb +157 -0
  33. data/lib/vanken/core/display_filter/types.rb +176 -0
  34. data/lib/vanken/core/frame.rb +33 -0
  35. data/lib/vanken/core/frame_store.rb +179 -0
  36. data/lib/vanken/core/frame_view.rb +68 -0
  37. data/lib/vanken/core/stores.rb +192 -0
  38. data/lib/vanken/errors.rb +9 -0
  39. data/lib/vanken/gateway/capture_filter.rb +30 -0
  40. data/lib/vanken/gateway/detail_builder.rb +63 -0
  41. data/lib/vanken/gateway/dissector.rb +176 -0
  42. data/lib/vanken/gateway/file_reader.rb +153 -0
  43. data/lib/vanken/gateway/file_writer.rb +64 -0
  44. data/lib/vanken/gateway/interfaces.rb +30 -0
  45. data/lib/vanken/gateway/live_capture.rb +79 -0
  46. data/lib/vanken/gateway/packet_snapshot.rb +89 -0
  47. data/lib/vanken/ui/actions.rb +50 -0
  48. data/lib/vanken/ui/application.rb +205 -0
  49. data/lib/vanken/ui/dialogs.rb +89 -0
  50. data/lib/vanken/ui/file_operations.rb +113 -0
  51. data/lib/vanken/ui/filter_operations.rb +120 -0
  52. data/lib/vanken/ui/main_view.rb +113 -0
  53. data/lib/vanken/ui/packet_source.rb +161 -0
  54. data/lib/vanken/ui/selection.rb +91 -0
  55. data/lib/vanken/version.rb +5 -0
  56. data/lib/vanken.rb +17 -0
  57. data/packaging/README.md +33 -0
  58. data/sig/generated/vanken/app/capture_controller.rbs +58 -0
  59. data/sig/generated/vanken/app/document.rbs +117 -0
  60. data/sig/generated/vanken/app/document_jobs.rbs +27 -0
  61. data/sig/generated/vanken/capture/analyzer.rbs +11 -0
  62. data/sig/generated/vanken/capture/analyzer_process.rbs +17 -0
  63. data/sig/generated/vanken/capture/analyzer_wire.rbs +19 -0
  64. data/sig/generated/vanken/capture/analyzer_worker.rbs +45 -0
  65. data/sig/generated/vanken/capture/control_protocol.rbs +17 -0
  66. data/sig/generated/vanken/capture/filter_worker.rbs +51 -0
  67. data/sig/generated/vanken/capture/helper_main.rbs +23 -0
  68. data/sig/generated/vanken/capture/helper_options.rbs +27 -0
  69. data/sig/generated/vanken/capture/launcher.rbs +60 -0
  70. data/sig/generated/vanken/capture/privileges.rbs +12 -0
  71. data/sig/generated/vanken/capture/receiver.rbs +15 -0
  72. data/sig/generated/vanken/cli.rbs +9 -0
  73. data/sig/generated/vanken/config/log.rbs +9 -0
  74. data/sig/generated/vanken/config/preferences.rbs +49 -0
  75. data/sig/generated/vanken/core/display_filter/compiler.rbs +45 -0
  76. data/sig/generated/vanken/core/display_filter/errors.rbs +16 -0
  77. data/sig/generated/vanken/core/display_filter/field_resolver.rbs +48 -0
  78. data/sig/generated/vanken/core/display_filter/lexer.rbs +49 -0
  79. data/sig/generated/vanken/core/display_filter/parser.rbs +95 -0
  80. data/sig/generated/vanken/core/display_filter/types.rbs +45 -0
  81. data/sig/generated/vanken/core/frame.rbs +63 -0
  82. data/sig/generated/vanken/core/frame_store.rbs +65 -0
  83. data/sig/generated/vanken/core/frame_view.rbs +36 -0
  84. data/sig/generated/vanken/core/stores.rbs +110 -0
  85. data/sig/generated/vanken/errors.rbs +18 -0
  86. data/sig/generated/vanken/gateway/capture_filter.rbs +17 -0
  87. data/sig/generated/vanken/gateway/detail_builder.rbs +21 -0
  88. data/sig/generated/vanken/gateway/dissector.rbs +113 -0
  89. data/sig/generated/vanken/gateway/file_reader.rbs +53 -0
  90. data/sig/generated/vanken/gateway/file_writer.rbs +27 -0
  91. data/sig/generated/vanken/gateway/interfaces.rbs +16 -0
  92. data/sig/generated/vanken/gateway/live_capture.rbs +48 -0
  93. data/sig/generated/vanken/gateway/packet_snapshot.rbs +48 -0
  94. data/sig/generated/vanken/ui/actions.rbs +9 -0
  95. data/sig/generated/vanken/ui/application.rbs +85 -0
  96. data/sig/generated/vanken/ui/dialogs.rbs +21 -0
  97. data/sig/generated/vanken/ui/file_operations.rbs +25 -0
  98. data/sig/generated/vanken/ui/filter_operations.rbs +33 -0
  99. data/sig/generated/vanken/ui/main_view.rbs +21 -0
  100. data/sig/generated/vanken/ui/packet_source.rbs +40 -0
  101. data/sig/generated/vanken/ui/selection.rbs +23 -0
  102. data/sig/generated/vanken/version.rbs +5 -0
  103. data/sig/vanken.rbs +25 -0
  104. metadata +192 -0
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "rbconfig"
5
+
6
+ module Vanken
7
+ module Capture
8
+ class Launcher
9
+ class Unavailable < StandardError; end
10
+ WRAPPER = "/usr/local/libexec/vanken/vanken-capture"
11
+ SAFE_ENV = %w[DISPLAY WAYLAND_DISPLAY XDG_RUNTIME_DIR DBUS_SESSION_BUS_ADDRESS XAUTHORITY LANG LC_ALL TERM].freeze
12
+
13
+ class Handle
14
+ attr_reader :pid, :stdin, :stdout, :stderr, :status
15
+
16
+ def initialize(pid, stdin, stdout, stderr)
17
+ @pid, @stdin, @stdout, @stderr = pid, stdin, stdout, stderr
18
+ @reap_lock = Mutex.new
19
+ end
20
+
21
+ def wait(timeout: nil)
22
+ deadline = timeout && (monotonic + timeout)
23
+ loop do
24
+ @reap_lock.synchronize do
25
+ return @status if @reaped
26
+ result = Process.waitpid2(@pid, Process::WNOHANG)
27
+ @reaped, @status = true, result.last if result
28
+ return @status if @reaped
29
+ rescue Errno::ECHILD
30
+ @reaped = true
31
+ return @status
32
+ end
33
+ return if deadline && monotonic >= deadline
34
+
35
+ sleep 0.01
36
+ end
37
+ end
38
+
39
+ def stop(timeout: 3)
40
+ @stdin.close unless @stdin.closed?
41
+ signal("TERM") unless @reaped
42
+ return @status if wait(timeout: timeout)
43
+
44
+ signal("KILL") unless @reaped
45
+ wait
46
+ end
47
+
48
+ def close
49
+ stop
50
+ [@stdout, @stderr].each { |io| io.close unless io.closed? }
51
+ end
52
+
53
+ private
54
+
55
+ def signal(signal)
56
+ @reap_lock.synchronize do
57
+ return if @reaped
58
+
59
+ result = Process.waitpid2(@pid, Process::WNOHANG)
60
+ if result
61
+ @reaped, @status = true, result.last
62
+ else
63
+ Process.kill(signal, -@pid)
64
+ end
65
+ end
66
+ rescue Errno::ECHILD
67
+ @reaped = true
68
+ rescue Errno::ESRCH
69
+ nil
70
+ end
71
+
72
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
73
+ end
74
+
75
+ def initialize(strategy: :auto, wrapper: WRAPPER, direct_command: nil, askpass: nil, env: ENV)
76
+ @strategy, @wrapper, @askpass, @env = strategy.to_sym, wrapper, askpass, env
77
+ lib = File.expand_path("../..", __dir__)
78
+ helper = File.expand_path("../../../exe/vanken-capture", __dir__)
79
+ dependency_paths = Gem.loaded_specs.fetch("redhound", nil)&.full_require_paths || []
80
+ @direct_command = direct_command || [RbConfig.ruby, "-I", ([lib] + dependency_paths).join(File::PATH_SEPARATOR), helper]
81
+ end
82
+
83
+ def launch(options = [], strategy: @strategy)
84
+ args = options.is_a?(Array) ? options : capture_argv(options.respond_to?(:capture) ? options.capture : options)
85
+ strategy = strategy.to_sym
86
+ strategy = automatic_strategy(args) if strategy == :auto
87
+ command, extra_env = command_for(strategy, args)
88
+ spawn(command, extra_env)
89
+ rescue SystemCallError => e
90
+ raise Unavailable, "cannot launch capture helper: #{e.message}"
91
+ end
92
+
93
+ def command_for(strategy, args)
94
+ return [@direct_command + args, {}] if strategy == :direct
95
+ raise Unavailable, "use direct capture or install the root-owned capture helper; developer sudo is disabled" unless %i[pkexec sudo].include?(strategy)
96
+
97
+ trusted_path!(@wrapper)
98
+ raise Unavailable, "capture must be launched by an unprivileged user" if Process.uid.zero? || Process.gid.zero?
99
+
100
+ args = args + ["--drop-to", "#{Process.uid}:#{Process.gid}"]
101
+ if strategy == :pkexec
102
+ executable = executable("pkexec") || raise(Unavailable, "pkexec is unavailable")
103
+ [[executable, @wrapper] + args, {}]
104
+ else
105
+ executable = executable("sudo") || raise(Unavailable, "sudo is unavailable")
106
+ trusted_path!(@askpass) if @askpass
107
+ [[executable, @askpass ? "-A" : "-n", "--", @wrapper] + args,
108
+ @askpass ? {"SUDO_ASKPASS" => @askpass} : {}]
109
+ end
110
+ end
111
+
112
+ def self.trusted_path?(path)
113
+ return false unless path && path.start_with?("/") && File.realpath(path) == path && File.file?(path) && File.executable?(path)
114
+
115
+ current = path
116
+ loop do
117
+ stat = File.stat(current)
118
+ return false unless stat.uid.zero? && (stat.mode & 0o022).zero?
119
+ return true if current == "/"
120
+
121
+ current = File.dirname(current)
122
+ end
123
+ rescue SystemCallError
124
+ false
125
+ end
126
+
127
+ private
128
+
129
+ def spawn(command, extra_env = {})
130
+ input_read, input_write = IO.pipe
131
+ output_read, output_write = IO.pipe
132
+ error_read, error_write = IO.pipe
133
+ env = @env.to_h.slice(*SAFE_ENV).merge("PATH" => "/usr/bin:/bin:/usr/sbin:/sbin").merge(extra_env)
134
+ pid = Process.spawn(env, [command.first, command.first], *command.drop(1),
135
+ in: input_read, out: output_write, err: error_write, unsetenv_others: true, close_others: true, pgroup: true)
136
+ handle = Handle.new(pid, input_write, output_read, error_read)
137
+ ensure
138
+ [input_write, output_read, error_read].compact.each { |io| io.close unless io.closed? } unless handle
139
+ [input_read, output_write, error_write].compact.each { |io| io.close unless io.closed? }
140
+ end
141
+
142
+ def automatic_strategy(args)
143
+ interface_index = args.index("--interface") || args.index("-i")
144
+ check_args = ["--check"]
145
+ check_args += ["--interface", args.fetch(interface_index + 1)] if interface_index
146
+ handle = spawn(@direct_command + check_args)
147
+ if handle.wait(timeout: 3)
148
+ diagnostic = JSON.parse(handle.stdout.read(65_537)) rescue {}
149
+ return :direct if diagnostic["direct"] == true
150
+ end
151
+ trusted_path!(@wrapper)
152
+ if RUBY_PLATFORM.include?("linux") && (@env["DISPLAY"] || @env["WAYLAND_DISPLAY"]) && executable("pkexec")
153
+ :pkexec
154
+ elsif executable("sudo")
155
+ :sudo
156
+ else
157
+ raise Unavailable, "capture permission is unavailable; install the root-owned capture helper"
158
+ end
159
+ ensure
160
+ handle&.close
161
+ end
162
+
163
+ def trusted_path!(path)
164
+ raise Unavailable, "capture helper must be executable in a root-owned directory without group or other write access" unless self.class.trusted_path?(path)
165
+ end
166
+
167
+ def executable(name)
168
+ %w[/usr/bin /bin].map { |directory| File.join(directory, name) }.find { |path| File.executable?(path) }
169
+ end
170
+
171
+ def capture_argv(options)
172
+ options.flat_map do |key, value|
173
+ next [] if value.nil?
174
+ next value ? [] : ["--no-promiscuous"] if key.to_sym == :promiscuous
175
+
176
+ ["--#{key.to_s.tr('_', '-')}", value.to_s]
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vanken
4
+ module Capture
5
+ module Privileges
6
+ class Error < StandardError; end
7
+
8
+ def self.drop!(uid, gid)
9
+ raise Error, "refusing a root identity" unless uid.is_a?(Integer) && gid.is_a?(Integer) && uid.positive? && gid.positive?
10
+
11
+ Process.groups = []
12
+ Process::GID.change_privilege(gid)
13
+ Process::UID.change_privilege(uid)
14
+ unless Process.uid == uid && Process.euid == uid && Process.gid == gid && Process.egid == gid && Process.groups.empty?
15
+ raise Error, "could not verify dropped privileges"
16
+ end
17
+
18
+ true
19
+ rescue SystemCallError => e
20
+ raise Error, "could not drop privileges: #{e.message}"
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module Vanken
5
+ module Capture
6
+ class Receiver
7
+ def initialize(document, source) = (@document, @source = document, source)
8
+ def run
9
+ flushed_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
10
+ each_frame do |frame|
11
+ break if @document.cancelled?
12
+ number = @document.store.append(frame) if frame
13
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
14
+ if (number && (number % 256 == 0 || number == 50)) || now - flushed_at >= 0.05
15
+ @document.store.flush
16
+ @document.signal
17
+ flushed_at = now
18
+ Thread.pass
19
+ end
20
+ end
21
+ rescue StandardError => error
22
+ @document.fail(error)
23
+ ensure
24
+ @document.store.flush
25
+ @document.receiving_done
26
+ @source.close if @source.respond_to?(:close)
27
+ end
28
+ private
29
+ def each_frame
30
+ return @source.each { |frame| yield frame } unless @source.respond_to?(:next_frame)
31
+
32
+ until @document.cancelled?
33
+ yield @source.next_frame(timeout: 0.05)
34
+ break if @source.eof?
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
data/lib/vanken/cli.rb ADDED
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Vanken
6
+ module CLI
7
+ def self.start(argv, out: $stdout, err: $stderr)
8
+ options = {}
9
+ parser = OptionParser.new do |flags|
10
+ flags.banner = "Usage: vanken [options] [capture.pcapng]"
11
+ flags.on("--version", "Show version") { out.puts(VERSION); return 0 }
12
+ flags.on("-h", "--help", "Show help") { out.puts(flags); return 0 }
13
+ flags.on("-r PATH", "--read PATH", "Open a pcap or pcapng capture") { |value| options[:path] = value }
14
+ flags.on("--headless", "Run without a native window") { options[:headless] = true }
15
+ flags.on("--print-columns", "Print tab-separated packet columns") { options[:print_columns] = true }
16
+ flags.on("--filter EXPR", "Apply a Vanken display filter") { |value| options[:filter] = value }
17
+ flags.on("--tui", "Run in a terminal") { options[:tui] = true }
18
+ flags.on("--smoke", "Draw three frames and exit") { options[:smoke] = true }
19
+ flags.on("--allow-root", "Allow privileged UI with a warning") { options[:allow_root] = true }
20
+ flags.on("--no-yjit", "Do not enable YJIT") { options[:no_yjit] = true }
21
+ flags.on("--debug", "Enable debug logging") { options[:debug] = true }
22
+ end
23
+ remaining = parser.parse(argv.dup)
24
+ raise OptionParser::InvalidArgument, "only one capture path is accepted" if remaining.size > 1 || (options[:path] && !remaining.empty?)
25
+ options[:path] ||= remaining.first
26
+ raise OptionParser::InvalidArgument, "--tui and --headless cannot be combined" if options[:tui] && options[:headless]
27
+ RubyVM::YJIT.enable if defined?(RubyVM::YJIT.enable) && !options[:no_yjit]
28
+ if options[:print_columns]
29
+ raise OptionParser::InvalidArgument, "--print-columns needs --read or a capture path" unless options[:path]
30
+ return print_columns(options, out)
31
+ end
32
+ if Process.euid.zero? && !options[:allow_root]
33
+ err.puts("Vanken refuses to run its UI as root. Run the capture helper with capture privileges instead.")
34
+ return 3
35
+ end
36
+ err.puts("Warning: running the packet analysis UI as root.") if Process.euid.zero?
37
+ require_relative "ui/application"
38
+ application = UI::Application.new(backend: options[:headless] ? :headless : options[:tui] ? :tui : nil, debug: options[:debug])
39
+ application.open_file(options[:path]) if options[:path]
40
+ application.initial_filter(options[:filter]) if options[:filter]
41
+ options[:smoke] ? application.smoke : application.run
42
+ 0
43
+ rescue OptionParser::ParseError => error
44
+ err.puts(error.message)
45
+ 2
46
+ rescue Vanken::Error, Core::DisplayFilter::SyntaxError, IOError, SystemCallError => error
47
+ err.puts(error.message)
48
+ 1
49
+ ensure
50
+ application&.close
51
+ end
52
+
53
+ def self.print_columns(options, out)
54
+ document = App::Document.new.open(options[:path]).wait
55
+ raise document.error if document.error
56
+ document.apply_filter(options[:filter]).wait if options[:filter]
57
+ raise document.error if document.error
58
+ out.puts("No.\tTime\tSource\tDestination\tProtocol\tLength\tInfo")
59
+ document.display_numbers.each do |number|
60
+ row = document.row(number)
61
+ out.puts([number, format("%.6f", document.time_value(number)), row[:source], row[:destination], row[:protocol], row[:length], row[:info]].join("\t"))
62
+ end
63
+ 0
64
+ ensure
65
+ document&.close
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "fileutils"
5
+
6
+ module Vanken
7
+ module Config
8
+ module Log
9
+ def self.open(debug: false, directory: Paths.state)
10
+ FileUtils.mkdir_p(directory, mode: 0o700)
11
+ File.chmod(0o700, directory)
12
+ path = File.join(directory, "vanken.log")
13
+ File.open(path, File::WRONLY | File::CREAT | File::APPEND, 0o600, &:close)
14
+ File.chmod(0o600, path)
15
+ Logger.new(path, 5, 1 << 20, level: debug ? Logger::DEBUG : Logger::INFO)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require "yaml"
5
+ require "fileutils"
6
+ require "logger"
7
+ require "tempfile"
8
+
9
+ module Vanken
10
+ module Config
11
+ module Paths
12
+ def self.config
13
+ case RUBY_PLATFORM
14
+ when /darwin/ then File.join(Dir.home, "Library", "Application Support", "Vanken")
15
+ when /mingw|mswin/ then File.join(ENV.fetch("APPDATA", Dir.home), "Vanken")
16
+ else File.join(ENV.fetch("XDG_CONFIG_HOME", File.join(Dir.home, ".config")), "vanken")
17
+ end
18
+ end
19
+ def self.state
20
+ return File.join(config, "state") unless RUBY_PLATFORM.include?("linux")
21
+ File.join(ENV.fetch("XDG_STATE_HOME", File.join(Dir.home, ".local", "state")), "vanken")
22
+ end
23
+ end
24
+
25
+ class Preferences
26
+ DEFAULTS = {
27
+ "schema_version" => 1,
28
+ "appearance" => {"theme" => "system", "font_size" => 13, "language" => "ja"},
29
+ "packet_list" => {"time_format" => "relative", "time_precision" => "micro", "autoscroll" => true, "row_cache_rows" => 20_000},
30
+ "capture" => {"snaplen" => 262_144, "promiscuous" => true, "buffer_size" => 4 << 20, "backend" => "auto", "launcher" => "auto", "direction" => "inout"},
31
+ "analysis" => {"verify_checksums" => false, "max_state_mib" => 256, "max_flows" => 100_000, "workers" => 4},
32
+ "history" => [], "recent_files" => [], "bookmarks" => {}, "layout" => {"ratios" => [0.55, 0.6], "width" => 1280, "height" => 800, "columns" => []}
33
+ }.freeze
34
+ attr_reader :warning, :directory
35
+
36
+ def initialize(directory: Paths.config)
37
+ @directory = directory
38
+ @values = Marshal.load(Marshal.dump(DEFAULTS))
39
+ path = File.join(directory, "preferences.yml")
40
+ return unless File.file?(path)
41
+ saved = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
42
+ raise Vanken::ConfigError, "unsupported preferences schema" unless saved.is_a?(Hash) && saved["schema_version"] == 1
43
+ merge(@values, saved)
44
+ validate
45
+ rescue Psych::Exception, Vanken::ConfigError, SystemCallError, TypeError => error
46
+ @values = Marshal.load(Marshal.dump(DEFAULTS))
47
+ @warning = error.message
48
+ end
49
+
50
+ def get(path) = path.split(".").reduce(@values) { |value, key| value.fetch(key) }
51
+ def set(path, value)
52
+ keys = path.split(".")
53
+ target = keys[0...-1].reduce(@values) { |hash, key| hash.fetch(key) }
54
+ previous = target.fetch(keys.last)
55
+ target[keys.last] = value
56
+ validate
57
+ save
58
+ value
59
+ rescue Vanken::ConfigError
60
+ target[keys.last] = previous
61
+ raise
62
+ end
63
+ def history = @values["history"].dup
64
+ def recent_files = @values["recent_files"].dup
65
+ def bookmarks = @values["bookmarks"].dup
66
+ def remember_filter(expression) = remember("history", expression, 50)
67
+ def remember_file(path) = remember("recent_files", File.expand_path(path), 10)
68
+ def bookmark(name, expression)
69
+ @values["bookmarks"][name] = expression
70
+ save
71
+ end
72
+ def save
73
+ FileUtils.mkdir_p(@directory, mode: 0o700)
74
+ File.chmod(0o700, @directory)
75
+ temporary = Tempfile.create(["preferences-", ".tmp"], @directory)
76
+ temporary.write(YAML.dump(@values))
77
+ temporary.flush
78
+ temporary.fsync
79
+ temporary.close
80
+ File.rename(temporary.path, File.join(@directory, "preferences.yml"))
81
+ ensure
82
+ temporary&.close unless temporary&.closed?
83
+ File.unlink(temporary.path) if temporary && File.exist?(temporary.path)
84
+ end
85
+
86
+ private
87
+
88
+ def remember(key, value, limit)
89
+ @values[key] = ([value] + @values[key].reject { |entry| entry == value }).first(limit)
90
+ save
91
+ end
92
+ def merge(base, saved)
93
+ saved.each do |key, value|
94
+ next unless base.key?(key)
95
+ key == "bookmarks" || !base[key].is_a?(Hash) || !value.is_a?(Hash) ? base[key] = value : merge(base[key], value)
96
+ end
97
+ end
98
+ def validate
99
+ validate_shape(DEFAULTS, @values)
100
+ raise Vanken::ConfigError, "invalid theme" unless %w[system dark light high_contrast].include?(get("appearance.theme"))
101
+ raise Vanken::ConfigError, "invalid font size" unless get("appearance.font_size").is_a?(Integer) && get("appearance.font_size").between?(8, 32)
102
+ raise Vanken::ConfigError, "invalid time format" unless %w[relative absolute delta delta_displayed epoch].include?(get("packet_list.time_format"))
103
+ raise Vanken::ConfigError, "invalid time precision" unless %w[milli micro nano].include?(get("packet_list.time_precision"))
104
+ raise Vanken::ConfigError, "invalid history" unless history.all? { |item| item.is_a?(String) } && recent_files.all? { |item| item.is_a?(String) } && bookmarks.all? { |name, expression| name.is_a?(String) && expression.is_a?(String) }
105
+ {"analysis.workers" => 1..32, "analysis.max_state_mib" => 1..65_536, "analysis.max_flows" => 1..10_000_000,
106
+ "capture.snaplen" => 1..16_777_216, "capture.buffer_size" => 65_536..268_435_456,
107
+ "packet_list.row_cache_rows" => 1..100_000, "layout.width" => 320..65_536, "layout.height" => 240..65_536}.each do |path, range|
108
+ raise Vanken::ConfigError, "invalid #{path}" unless range.cover?(get(path))
109
+ end
110
+ raise Vanken::ConfigError, "invalid split ratios" unless get("layout.ratios").size == 2 && get("layout.ratios").all? { |ratio| ratio.is_a?(Numeric) && ratio.finite? && ratio.between?(0.1, 0.9) }
111
+ columns = get("layout.columns")
112
+ raise Vanken::ConfigError, "invalid columns" unless columns.all? { |column| column.is_a?(Hash) && %w[no time source destination protocol length info].include?(column["key"]) && column["width"].is_a?(Numeric) && column["width"].finite? && column["width"].between?(40, 4096) && [true, false].include?(column["visible"]) } && columns.map { |column| column["key"] }.uniq.size == columns.size
113
+ raise Vanken::ConfigError, "invalid capture settings" unless %w[auto direct sudo pkexec].include?(get("capture.launcher")) && %w[auto ring socket bpf].include?(get("capture.backend")) && %w[in out inout].include?(get("capture.direction"))
114
+ rescue NoMethodError, KeyError
115
+ raise Vanken::ConfigError, "invalid preferences shape"
116
+ end
117
+ def validate_shape(default, value)
118
+ valid = [true, false].include?(default) ? [true, false].include?(value) : value.is_a?(default.class)
119
+ raise Vanken::ConfigError, "invalid preferences shape" unless valid
120
+ if default.is_a?(Hash)
121
+ default.each { |key, expected| validate_shape(expected, value.fetch(key)) }
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "parser"
4
+ require_relative "field_resolver"
5
+ require_relative "types"
6
+
7
+ module Vanken
8
+ module Core
9
+ module DisplayFilter
10
+ class Program
11
+ attr_reader :expression #: String
12
+ attr_reader :fields #: Array[String]
13
+ attr_reader :sources #: Array[Symbol]
14
+ attr_reader :warnings #: Array[String]
15
+
16
+ # @rbs (expression: String, fields: Array[String], sources: Array[Symbol], warnings: Array[String], evaluator: ^(untyped) -> bool) -> void
17
+ def initialize(expression:, fields:, sources:, warnings:, evaluator:)
18
+ @expression = expression.freeze
19
+ @fields = fields.uniq.freeze
20
+ @sources = sources.uniq.freeze
21
+ @warnings = warnings.uniq.freeze
22
+ @evaluator = evaluator
23
+ freeze
24
+ end
25
+
26
+ # @rbs (_FilterView view) -> bool
27
+ def match?(view) = !!@evaluator.call(view)
28
+ # @rbs () -> bool
29
+ def fast? = !sources.include?(:dissect)
30
+ end
31
+
32
+ class Compiler
33
+ # @rbs (?catalog: untyped) -> void
34
+ def initialize(catalog: nil)
35
+ @resolver = FieldResolver.new(catalog)
36
+ @fields = [] #: Array[String]
37
+ @sources = [] #: Array[Symbol]
38
+ @warnings = [] #: Array[String]
39
+ end
40
+
41
+ # @rbs (String expression) -> Program
42
+ def compile(expression)
43
+ @fields = [] #: Array[String]
44
+ @sources = [] #: Array[Symbol]
45
+ @warnings = [] #: Array[String]
46
+ parser = Parser.new(expression)
47
+ ast = parser.parse
48
+ evaluator = compile_node(ast)
49
+ Program.new(expression: expression.dup, fields: @fields, sources: @sources,
50
+ warnings: parser.warnings + @warnings, evaluator: evaluator)
51
+ end
52
+
53
+ private
54
+
55
+ # @rbs (Node node) -> ^(untyped) -> bool
56
+ def compile_node(node)
57
+ case node.kind
58
+ when :true then ->(_) { true }
59
+ when :not
60
+ child = compile_node(node.left)
61
+ ->(view) { !child.call(view) }
62
+ when :and, :or
63
+ left = compile_node(node.left)
64
+ right = compile_node(node.right)
65
+ node.kind == :and ? ->(view) { left.call(view) && right.call(view) } :
66
+ ->(view) { left.call(view) || right.call(view) }
67
+ when :test then compile_test(node)
68
+ else raise ArgumentError, "Unknown filter node: #{node.kind}"
69
+ end
70
+ end
71
+
72
+ # @rbs (Node node) -> ^(untyped) -> bool
73
+ def compile_test(node)
74
+ reference = @resolver.resolve(node.left.value)
75
+ @fields << reference.name
76
+ @sources << reference.source
77
+ @warnings << "Unknown field: #{reference.name}" unless reference.known
78
+ if reference.protocol
79
+ Types.fail_at("Protocols only support presence tests", node.right.token) if node.right
80
+ return ->(view) { view.layer?(reference.name) }
81
+ end
82
+ return ->(view) { !Array(view.values(reference.name)).compact.empty? } unless node.right
83
+
84
+ operator = node.right.operator
85
+ predicate = %i[ne any_ne].include?(operator) ?
86
+ Predicate.new(:eq, node.right.values, node.right.token) : node.right
87
+ comparison = Types.predicate(predicate, reference.type)
88
+ lambda do |view|
89
+ values = Array(view.values(reference.name)).compact
90
+ type = @resolver.normalize_type(view.field_type(reference.name)) if !reference.type && view.respond_to?(:field_type)
91
+ if operator == :ne
92
+ values.none? { |value| comparison.call(value, type) }
93
+ elsif operator == :any_ne
94
+ values.any? { |value| !comparison.call(value, type) }
95
+ else
96
+ values.any? { |value| comparison.call(value, type) }
97
+ end
98
+ end
99
+ end
100
+ end
101
+
102
+ # @rbs (String expression, ?catalog: untyped) -> Program
103
+ def self.compile(expression, catalog: nil)
104
+ Compiler.new(catalog: catalog).compile(expression)
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vanken
4
+ module Core
5
+ module DisplayFilter
6
+ class SyntaxError < StandardError
7
+ attr_reader :position #: Integer
8
+ attr_reader :length #: Integer
9
+
10
+ # @rbs (String message, position: Integer, ?length: Integer) -> void
11
+ def initialize(message, position:, length: 1)
12
+ super(message)
13
+ @position = position
14
+ @length = length
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../stores"
4
+
5
+ module Vanken
6
+ module Core
7
+ module DisplayFilter
8
+ Reference = Data.define(
9
+ :name, #: String
10
+ :type, #: field_type?
11
+ :source, #: Symbol
12
+ :protocol, #: bool
13
+ :known #: bool
14
+ )
15
+
16
+ class FieldResolver
17
+ PROTOCOLS = %w[eth vlan arp ip ipv6 icmp icmpv6 igmp tcp udp gre vxlan dns dhcp ntp http tls data].freeze
18
+ FRAME_FIELDS = {
19
+ "frame.number" => :integer, "frame.len" => :integer, "frame.cap_len" => :integer,
20
+ "frame.time_epoch" => :float, "frame.time_relative" => :float, "frame.time_delta" => :float,
21
+ "frame.time_delta_displayed" => :float, "frame.interface_name" => :string,
22
+ "frame.direction" => :string, "frame.marked" => :boolean, "frame.ignored" => :boolean
23
+ }.freeze
24
+ VIRTUAL_FIELDS = {
25
+ "frame.protocols" => :string, "ip.addr" => :address, "ipv6.addr" => :address,
26
+ "eth.addr" => :mac, "tcp.port" => :integer, "udp.port" => :integer,
27
+ "tcp.stream" => :integer, "expert.severity" => :severity, "expert.code" => :string,
28
+ **%w[syn ack fin rst psh urg].to_h { |flag| ["tcp.flags.#{flag}", :boolean] }
29
+ }.freeze
30
+
31
+ # @rbs (?untyped catalog) -> void
32
+ def initialize(catalog = nil)
33
+ @catalog = catalog
34
+ end
35
+
36
+ # @rbs (String name) -> Reference
37
+ def resolve(name)
38
+ entry = @catalog&.lookup(name)
39
+ type = FRAME_FIELDS[name] || VIRTUAL_FIELDS[name] || metadata(entry, :type)
40
+ if name == "tcp.analysis.flags" || Core::AnnotationStore::FLAGS.any? { |flag| name == "tcp.analysis.#{flag}" }
41
+ type = :boolean
42
+ end
43
+ protocol = !name.include?(".") && (PROTOCOLS.include?(name) || @catalog&.protocol?(name))
44
+ source = if FRAME_FIELDS.key?(name)
45
+ :frame
46
+ elsif name == "tcp.stream" || name.start_with?("tcp.analysis.", "expert.")
47
+ :annotation
48
+ elsif protocol || %w[tcp.port udp.port].include?(name)
49
+ :column
50
+ else
51
+ metadata(entry, :source) || :dissect
52
+ end
53
+ Reference.new(name, normalize_type(type), source.to_sym, !!protocol, !!(type || entry || protocol))
54
+ end
55
+
56
+ # @rbs (untyped type) -> field_type?
57
+ def normalize_type(type)
58
+ return nil unless type
59
+
60
+ name = type.to_s.downcase
61
+ return :integer if /\A(?:u?int\d*|integer|bitfield|bit_field|bits)\z/.match?(name)
62
+ return :float if %w[float double time timestamp number].include?(name)
63
+ return :boolean if %w[boolean bool].include?(name)
64
+ return :address if %w[ipv4 ipv6 ipaddr address].include?(name)
65
+ return :mac if %w[mac macaddr mac_address].include?(name)
66
+ return :bytes if %w[bytes byte_array raw binary].include?(name)
67
+ return :string if %w[string str text].include?(name)
68
+ return :severity if name == "severity"
69
+
70
+ nil
71
+ end
72
+
73
+ private
74
+
75
+ # @rbs (untyped entry, Symbol key) -> untyped
76
+ def metadata(entry, key)
77
+ return nil unless entry
78
+ return entry[key] || entry[key.to_s] if entry.is_a?(Hash)
79
+ return entry.public_send(key) if entry.respond_to?(key)
80
+
81
+ nil
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end