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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +84 -0
- data/docs/filters.md +25 -0
- data/docs/performance.md +113 -0
- data/docs/releases.md +15 -0
- data/docs/upstream.md +32 -0
- data/exe/vanken +5 -0
- data/exe/vanken-capture +6 -0
- data/lib/vanken/app/capture_controller.rb +197 -0
- data/lib/vanken/app/document.rb +305 -0
- data/lib/vanken/app/document_jobs.rb +199 -0
- data/lib/vanken/capture/analyzer.rb +44 -0
- data/lib/vanken/capture/analyzer_process.rb +91 -0
- data/lib/vanken/capture/analyzer_wire.rb +42 -0
- data/lib/vanken/capture/analyzer_worker.rb +153 -0
- data/lib/vanken/capture/control_protocol.rb +32 -0
- data/lib/vanken/capture/filter_worker.rb +180 -0
- data/lib/vanken/capture/helper_main.rb +137 -0
- data/lib/vanken/capture/helper_options.rb +70 -0
- data/lib/vanken/capture/launcher.rb +181 -0
- data/lib/vanken/capture/privileges.rb +24 -0
- data/lib/vanken/capture/receiver.rb +39 -0
- data/lib/vanken/cli.rb +68 -0
- data/lib/vanken/config/log.rb +19 -0
- data/lib/vanken/config/preferences.rb +126 -0
- data/lib/vanken/core/display_filter/compiler.rb +108 -0
- data/lib/vanken/core/display_filter/errors.rb +19 -0
- data/lib/vanken/core/display_filter/field_resolver.rb +86 -0
- data/lib/vanken/core/display_filter/lexer.rb +124 -0
- data/lib/vanken/core/display_filter/parser.rb +157 -0
- data/lib/vanken/core/display_filter/types.rb +176 -0
- data/lib/vanken/core/frame.rb +33 -0
- data/lib/vanken/core/frame_store.rb +179 -0
- data/lib/vanken/core/frame_view.rb +68 -0
- data/lib/vanken/core/stores.rb +192 -0
- data/lib/vanken/errors.rb +9 -0
- data/lib/vanken/gateway/capture_filter.rb +30 -0
- data/lib/vanken/gateway/detail_builder.rb +63 -0
- data/lib/vanken/gateway/dissector.rb +176 -0
- data/lib/vanken/gateway/file_reader.rb +153 -0
- data/lib/vanken/gateway/file_writer.rb +64 -0
- data/lib/vanken/gateway/interfaces.rb +30 -0
- data/lib/vanken/gateway/live_capture.rb +79 -0
- data/lib/vanken/gateway/packet_snapshot.rb +89 -0
- data/lib/vanken/ui/actions.rb +50 -0
- data/lib/vanken/ui/application.rb +205 -0
- data/lib/vanken/ui/dialogs.rb +89 -0
- data/lib/vanken/ui/file_operations.rb +113 -0
- data/lib/vanken/ui/filter_operations.rb +120 -0
- data/lib/vanken/ui/main_view.rb +113 -0
- data/lib/vanken/ui/packet_source.rb +161 -0
- data/lib/vanken/ui/selection.rb +91 -0
- data/lib/vanken/version.rb +5 -0
- data/lib/vanken.rb +17 -0
- data/packaging/README.md +33 -0
- data/sig/generated/vanken/app/capture_controller.rbs +58 -0
- data/sig/generated/vanken/app/document.rbs +117 -0
- data/sig/generated/vanken/app/document_jobs.rbs +27 -0
- data/sig/generated/vanken/capture/analyzer.rbs +11 -0
- data/sig/generated/vanken/capture/analyzer_process.rbs +17 -0
- data/sig/generated/vanken/capture/analyzer_wire.rbs +19 -0
- data/sig/generated/vanken/capture/analyzer_worker.rbs +45 -0
- data/sig/generated/vanken/capture/control_protocol.rbs +17 -0
- data/sig/generated/vanken/capture/filter_worker.rbs +51 -0
- data/sig/generated/vanken/capture/helper_main.rbs +23 -0
- data/sig/generated/vanken/capture/helper_options.rbs +27 -0
- data/sig/generated/vanken/capture/launcher.rbs +60 -0
- data/sig/generated/vanken/capture/privileges.rbs +12 -0
- data/sig/generated/vanken/capture/receiver.rbs +15 -0
- data/sig/generated/vanken/cli.rbs +9 -0
- data/sig/generated/vanken/config/log.rbs +9 -0
- data/sig/generated/vanken/config/preferences.rbs +49 -0
- data/sig/generated/vanken/core/display_filter/compiler.rbs +45 -0
- data/sig/generated/vanken/core/display_filter/errors.rbs +16 -0
- data/sig/generated/vanken/core/display_filter/field_resolver.rbs +48 -0
- data/sig/generated/vanken/core/display_filter/lexer.rbs +49 -0
- data/sig/generated/vanken/core/display_filter/parser.rbs +95 -0
- data/sig/generated/vanken/core/display_filter/types.rbs +45 -0
- data/sig/generated/vanken/core/frame.rbs +63 -0
- data/sig/generated/vanken/core/frame_store.rbs +65 -0
- data/sig/generated/vanken/core/frame_view.rbs +36 -0
- data/sig/generated/vanken/core/stores.rbs +110 -0
- data/sig/generated/vanken/errors.rbs +18 -0
- data/sig/generated/vanken/gateway/capture_filter.rbs +17 -0
- data/sig/generated/vanken/gateway/detail_builder.rbs +21 -0
- data/sig/generated/vanken/gateway/dissector.rbs +113 -0
- data/sig/generated/vanken/gateway/file_reader.rbs +53 -0
- data/sig/generated/vanken/gateway/file_writer.rbs +27 -0
- data/sig/generated/vanken/gateway/interfaces.rbs +16 -0
- data/sig/generated/vanken/gateway/live_capture.rbs +48 -0
- data/sig/generated/vanken/gateway/packet_snapshot.rbs +48 -0
- data/sig/generated/vanken/ui/actions.rbs +9 -0
- data/sig/generated/vanken/ui/application.rbs +85 -0
- data/sig/generated/vanken/ui/dialogs.rbs +21 -0
- data/sig/generated/vanken/ui/file_operations.rbs +25 -0
- data/sig/generated/vanken/ui/filter_operations.rbs +33 -0
- data/sig/generated/vanken/ui/main_view.rbs +21 -0
- data/sig/generated/vanken/ui/packet_source.rbs +40 -0
- data/sig/generated/vanken/ui/selection.rbs +23 -0
- data/sig/generated/vanken/version.rbs +5 -0
- data/sig/vanken.rbs +25 -0
- 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
|