straycall 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.
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fcntl"
4
+
5
+ module Straycall
6
+ module Policy
7
+ class Filesystem
8
+ AT_FDCWD = -100
9
+ WRITE_FLAGS = Fcntl::O_WRONLY | Fcntl::O_RDWR | Fcntl::O_CREAT | Fcntl::O_TRUNC | Fcntl::O_APPEND
10
+
11
+ def initialize(cache_size: 4096)
12
+ raise ArgumentError, "cache_size must be positive" unless cache_size.is_a?(Integer) && cache_size.positive?
13
+
14
+ @write_default = :allow
15
+ @write_roots = []
16
+ @read_denials = []
17
+ @cache_size = cache_size
18
+ @cache = {}
19
+ @mutex = Mutex.new
20
+ end
21
+
22
+ def allow_write_under(path)
23
+ @write_roots << normalize_config_path(path)
24
+ clear_cache
25
+ end
26
+
27
+ def deny_write_elsewhere!
28
+ @write_default = :deny
29
+ clear_cache
30
+ end
31
+
32
+ def deny_read(path)
33
+ @read_denials << normalize_config_path(path)
34
+ clear_cache
35
+ end
36
+
37
+ def active?
38
+ @write_default == :deny || !@read_denials.empty?
39
+ end
40
+
41
+ def read_denials?
42
+ !@read_denials.empty?
43
+ end
44
+
45
+ def write?(flags)
46
+ (flags & WRITE_FLAGS).positive?
47
+ end
48
+
49
+ def allowed_write?(path)
50
+ cached([:write, path]) do
51
+ @write_default == :allow || path.match?(%r{\A/proc/(?:self|\d+)/task/\d+/comm\z}) || @write_roots.any? { |root| under?(path, root) }
52
+ end
53
+ end
54
+
55
+ def denied_read?(path)
56
+ cached([:read, path]) { @read_denials.any? { |root| under?(path, root) } }
57
+ end
58
+
59
+ def resolve(path, tid:, dirfd: AT_FDCWD)
60
+ return File.expand_path(path) if path.start_with?(File::SEPARATOR)
61
+
62
+ base = if signed(dirfd) == AT_FDCWD
63
+ File.readlink("/proc/#{tid}/cwd")
64
+ else
65
+ File.readlink("/proc/#{tid}/fd/#{signed(dirfd)}")
66
+ end
67
+ File.expand_path(path, base)
68
+ rescue SystemCallError => error
69
+ raise PathResolutionError, "cannot resolve target path through /proc: #{error.message}"
70
+ end
71
+
72
+ def marshal_dump
73
+ [@write_default, @write_roots, @read_denials, @cache_size]
74
+ end
75
+
76
+ def marshal_load(values)
77
+ @write_default, @write_roots, @read_denials, @cache_size = values
78
+ @cache = {}
79
+ @mutex = Mutex.new
80
+ end
81
+
82
+ private
83
+
84
+ def normalize_config_path(path)
85
+ unless path.is_a?(String) && !path.empty? && !path.include?("\0")
86
+ raise ConfigurationError, "filesystem paths must be non-empty strings without NUL bytes"
87
+ end
88
+
89
+ File.expand_path(path)
90
+ end
91
+
92
+ def signed(value)
93
+ value >= (1 << 63) ? value - (1 << 64) : value
94
+ end
95
+
96
+ def under?(path, root)
97
+ path == root || path.start_with?("#{root}#{File::SEPARATOR}")
98
+ end
99
+
100
+ def cached(key)
101
+ @mutex.synchronize do
102
+ if @cache.key?(key)
103
+ value = @cache.delete(key)
104
+ @cache[key] = value
105
+ return value
106
+ end
107
+
108
+ @cache[key] = yield
109
+ @cache.shift if @cache.length > @cache_size
110
+ @cache[key]
111
+ end
112
+ end
113
+
114
+ def clear_cache
115
+ @mutex.synchronize { @cache.clear }
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require "socket"
5
+
6
+ module Straycall
7
+ module Policy
8
+ class Network
9
+ Rule = Struct.new(:address, :ports)
10
+
11
+ def initialize
12
+ @default = :deny
13
+ @inet = []
14
+ @unix = []
15
+ end
16
+
17
+ def deny!
18
+ @default = :deny
19
+ end
20
+
21
+ def allow!
22
+ @default = :allow
23
+ end
24
+
25
+ def allow_loopback(ports: nil)
26
+ add_inet(IPAddr.new("127.0.0.0/8"), ports)
27
+ add_inet(IPAddr.new("::1"), ports)
28
+ end
29
+
30
+ def allow_unix(path)
31
+ unless path.is_a?(String) && !path.empty? && !path.include?("\0")
32
+ raise ConfigurationError, "Unix socket paths must be non-empty strings without NUL bytes"
33
+ end
34
+
35
+ @unix << File.expand_path(path)
36
+ end
37
+
38
+ def allow_host(host, ports: nil)
39
+ unless host.is_a?(String) && !host.empty? && !host.include?("\0")
40
+ raise ConfigurationError, "hosts must be non-empty strings without NUL bytes"
41
+ end
42
+
43
+ Addrinfo.getaddrinfo(host, nil, nil, Socket::SOCK_STREAM).filter_map do |address|
44
+ add_inet(IPAddr.new(address.ip_address), ports) if address.ip?
45
+ end
46
+ rescue SocketError => error
47
+ raise ConfigurationError, "cannot resolve host #{host.inspect}: #{error.message}"
48
+ end
49
+
50
+ def allowed?(address)
51
+ return true if @default == :allow
52
+ return @unix.any? { |prefix| address.unix_path.start_with?(prefix) } if address.unix?
53
+ return false unless address.ip?
54
+
55
+ ip = IPAddr.new(address.ip_address.split("%", 2).first)
56
+ @inet.any? do |rule|
57
+ rule.address.include?(ip) && (rule.ports.nil? || rule.ports.any? { |port| port === address.ip_port })
58
+ end
59
+ end
60
+
61
+ def allow_all?
62
+ @default == :allow
63
+ end
64
+
65
+ private
66
+
67
+ def add_inet(address, ports)
68
+ @inet << Rule.new(address, normalize_ports(ports))
69
+ end
70
+
71
+ def normalize_ports(ports)
72
+ return if ports.nil?
73
+
74
+ Array(ports).each do |port|
75
+ next if port.is_a?(Range) && port.begin.is_a?(Integer) && port.end.is_a?(Integer) && port.begin.between?(1, port.end) && port.end <= 65_535
76
+ next if port.is_a?(Integer) && (1..65_535).cover?(port)
77
+
78
+ raise ConfigurationError, "ports must contain integers or ranges between 1 and 65535"
79
+ end.freeze
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Straycall
4
+ module Policy
5
+ FORBIDDEN_NOTIFY = %i[read write recvfrom ppoll poll select futex clock_nanosleep rt_sigreturn].freeze
6
+
7
+ module_function
8
+
9
+ FILESYSTEM_NOTIFY = %i[openat openat2 unlinkat renameat renameat2 mkdirat].freeze
10
+ LEGACY_FILESYSTEM_NOTIFY = %i[open creat unlink rename mkdir].freeze
11
+ EXEC_NOTIFY = %i[execve execveat].freeze
12
+
13
+ def seccomp(config)
14
+ notified = notification_names(config)
15
+ validate_notified!(*notified)
16
+
17
+ conditional = []
18
+ conditional << :openat if notified.include?(:openat) && !config.filesystem.read_denials?
19
+ conditional << :open if notified.include?(:open) && !config.filesystem.read_denials?
20
+ Seccomp::Notify::Policy.new(deny_io_uring: !config.io_uring_allowed?) do
21
+ notify(*(notified - conditional))
22
+ notify_if(:openat, argument: 2, mask: Policy::Filesystem::WRITE_FLAGS) if conditional.include?(:openat)
23
+ notify_if(:open, argument: 1, mask: Policy::Filesystem::WRITE_FLAGS) if conditional.include?(:open)
24
+ deny :ptrace
25
+ end
26
+ end
27
+
28
+ def notification_names(config)
29
+ names = %i[socket connect bind listen sendto sendmsg sendmmsg]
30
+ if config.filesystem.active? || config.on_violation == :record
31
+ names.concat(FILESYSTEM_NOTIFY)
32
+ names.concat(LEGACY_FILESYSTEM_NOTIFY) if RUBY_PLATFORM.include?("linux") && RUBY_PLATFORM.include?("x86_64")
33
+ end
34
+ names.concat(EXEC_NOTIFY) if config.exec.active? || config.on_violation == :record
35
+ names
36
+ end
37
+
38
+ def validate_notified!(*names)
39
+ invalid = names.map(&:to_sym) & FORBIDDEN_NOTIFY
40
+ raise ConfigurationError, "cannot notify reporter syscall(s): #{invalid.join(", ")}" unless invalid.empty?
41
+
42
+ true
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Straycall
4
+ class Prompt
5
+ def initialize(io = nil)
6
+ @io = io
7
+ @mutex = Mutex.new
8
+ end
9
+
10
+ def allow?(syscall, target)
11
+ io = nil
12
+ @mutex.synchronize do
13
+ io = @io || File.open("/dev/tty", "r+")
14
+ io.print("straycall: allow #{syscall}(2) → #{target}? [y/N] ")
15
+ io.flush
16
+ %w[y yes].include?(io.gets.to_s.strip.downcase)
17
+ end
18
+ rescue IOError, SystemCallError
19
+ false
20
+ ensure
21
+ io&.close if !@io && io && !io.closed?
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module Straycall
7
+ class Recorder
8
+ def initialize(path = ".straycall.yml")
9
+ @path = path
10
+ end
11
+
12
+ def write(violations)
13
+ network = violations.filter_map do |item|
14
+ next unless item.target[:type] == "inet" && item.target[:host] != "?"
15
+
16
+ {"host" => item.target[:host]}.tap do |rule|
17
+ rule["ports"] = [item.target[:port]] if (1..65_535).cover?(item.target[:port])
18
+ end
19
+ end.uniq
20
+ unix = violations.filter_map { |item| item.target[:path] if item.target[:type] == "unix" && item.target[:path] != "?" }.uniq
21
+ writes = violations.filter_map do |item|
22
+ File.dirname(item.target[:path]) if item.target[:type] == "file" && item.target[:path] != "?"
23
+ end.uniq
24
+ executables = violations.filter_map do |item|
25
+ item.target[:path] if item.target[:type] == "exec" && item.target[:path] != "?"
26
+ end.uniq
27
+ policy = {
28
+ "network" => {"default" => "deny", "allow" => network + unix.map { |path| {"unix" => path} }},
29
+ "write" => {"default" => "deny", "allow" => writes},
30
+ "exec" => {"default" => "deny", "allow" => executables},
31
+ "on_violation" => "fail"
32
+ }
33
+ FileUtils.mkdir_p(File.dirname(@path))
34
+ File.write(@path, YAML.dump(policy))
35
+ @path
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Straycall
4
+ module Report
5
+ class Console
6
+ def initialize(io = $stderr)
7
+ @io = io
8
+ end
9
+
10
+ def violation(item)
11
+ @io.puts("straycall: #{item.syscall}(2) → #{target(item.target)}")
12
+ @io.puts(" #{item.example[:file]}:#{item.example[:line]} #{item.example[:description]}") if item.example
13
+ Array(item.backtrace).each { |line| @io.puts(" #{line}") }
14
+ @io.puts(" origin: #{item.origin}") if item.origin
15
+ @io.puts(" hint: #{item.suggestion}") if item.suggestion
16
+ end
17
+
18
+ private
19
+
20
+ def target(value)
21
+ return value[:path] if %w[unix file exec].include?(value[:type])
22
+ return "socket domain #{value[:domain]}" if value[:domain]
23
+ return "socket fd #{value[:fd]}" if value[:type] == "socket"
24
+
25
+ "#{value[:host]}:#{value[:port]}"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ module Straycall
7
+ module Report
8
+ class JSON
9
+ def initialize(path)
10
+ @path = path
11
+ end
12
+
13
+ def write(violations)
14
+ FileUtils.mkdir_p(File.dirname(@path))
15
+ File.write(@path, ::JSON.pretty_generate(version: 1, violations: violations.map(&:to_h)) << "\n")
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "protocol"
4
+
5
+ module Straycall
6
+ module Reporter
7
+ class Client
8
+ def initialize(socket, timeout: 0.2)
9
+ @socket = socket
10
+ @reader = Protocol::Reader.new(socket)
11
+ @timeout = timeout
12
+ @mutex = Mutex.new
13
+ @alive = true
14
+ end
15
+
16
+ def example
17
+ return @example unless @alive
18
+
19
+ @mutex.synchronize do
20
+ @socket.write(Protocol.sync_request)
21
+ read_ack
22
+ @example
23
+ end
24
+ rescue IOError, SystemCallError
25
+ @alive = false
26
+ @example
27
+ end
28
+
29
+ def backtrace(tid)
30
+ return unless @alive
31
+
32
+ @mutex.synchronize do
33
+ @socket.write(Protocol.backtrace_request(tid))
34
+ read_backtrace(tid)
35
+ end
36
+ rescue IOError, SystemCallError
37
+ @alive = false
38
+ nil
39
+ end
40
+
41
+ def notify_violation
42
+ @mutex.synchronize do
43
+ if @alive
44
+ @socket.write("VIOLATION\n")
45
+ read_ack
46
+ else
47
+ @socket.write_nonblock("VIOLATION\n", exception: false)
48
+ end
49
+ end
50
+ rescue IOError, SystemCallError
51
+ @alive = false
52
+ end
53
+
54
+ def close
55
+ @socket.close unless @socket.closed?
56
+ end
57
+
58
+ private
59
+
60
+ def read_backtrace(tid)
61
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout
62
+ loop do
63
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
64
+ return if remaining <= 0
65
+
66
+ line = @reader.read(timeout: remaining)
67
+ unless line
68
+ @alive = false
69
+ return
70
+ end
71
+
72
+ type, value, payload = Protocol.parse(line)
73
+ case type
74
+ when :backtrace
75
+ return payload if value == tid
76
+ when :example
77
+ @example = value
78
+ end
79
+ end
80
+ end
81
+
82
+ def read_ack
83
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout
84
+ loop do
85
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
86
+ return if remaining <= 0
87
+
88
+ line = @reader.read(timeout: remaining)
89
+ unless line
90
+ @alive = false
91
+ return
92
+ end
93
+
94
+ type, value = Protocol.parse(line)
95
+ return if type == :ack
96
+
97
+ @example = value if type == :example
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Straycall
6
+ module Reporter
7
+ module Protocol
8
+ class Reader
9
+ LIMIT = 64 * 1024
10
+
11
+ def initialize(socket)
12
+ @socket = socket
13
+ @buffer = String.new(capacity: LIMIT)
14
+ @chunk = String.new(capacity: 4096)
15
+ end
16
+
17
+ def read(timeout: nil)
18
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout if timeout
19
+ loop do
20
+ if (newline = @buffer.index("\n"))
21
+ return @buffer.slice!(0, newline + 1)
22
+ end
23
+ if deadline
24
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
25
+ return if remaining <= 0 || !IO.select([@socket], nil, nil, remaining)
26
+ end
27
+
28
+ @socket.readpartial(4096, @chunk)
29
+ raise IOError, "reporter message exceeds #{LIMIT} bytes" if @buffer.bytesize + @chunk.bytesize > LIMIT
30
+
31
+ @buffer << @chunk
32
+ end
33
+ rescue EOFError
34
+ nil
35
+ end
36
+ end
37
+
38
+ module_function
39
+
40
+ def backtrace_request(tid)
41
+ "BT #{Integer(tid)}\n"
42
+ end
43
+
44
+ def backtrace_response(tid, backtrace)
45
+ payload = backtrace ? JSON.generate(backtrace) : "-"
46
+ "BT #{Integer(tid)} #{payload}\n"
47
+ end
48
+
49
+ def example(value)
50
+ "EXAMPLE #{JSON.generate(value)}\n"
51
+ end
52
+
53
+ def sync_request
54
+ "SYNC\n"
55
+ end
56
+
57
+ def parse(line)
58
+ type, rest = line.to_s.chomp.split(" ", 2)
59
+ case type
60
+ when "BT"
61
+ tid, payload = rest.to_s.split(" ", 2)
62
+ [:backtrace, Integer(tid), payload && payload != "-" ? JSON.parse(payload) : nil]
63
+ when "EXAMPLE"
64
+ [:example, JSON.parse(rest, symbolize_names: true)]
65
+ when "VIOLATION"
66
+ [:violation]
67
+ when "SYNC"
68
+ [:sync]
69
+ when "OK"
70
+ [:ack]
71
+ else
72
+ [:unknown]
73
+ end
74
+ rescue ArgumentError, JSON::ParserError
75
+ [:unknown]
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Straycall
4
+ module Reporter
5
+ module Symbolizer
6
+ module_function
7
+
8
+ def call(tid, instruction_pointer, maps: nil)
9
+ lines = maps ? maps.each_line : File.foreach("/proc/#{Integer(tid)}/maps")
10
+ lines.each do |line|
11
+ range, _permissions, file_offset, _device, _inode, path = line.split(nil, 6)
12
+ start_address, end_address = range.split("-", 2).map { |value| Integer(value, 16) }
13
+ next unless (start_address...end_address).cover?(instruction_pointer)
14
+
15
+ return unless path
16
+
17
+ offset = instruction_pointer - start_address + Integer(file_offset, 16)
18
+ return "#{path.strip}+0x#{offset.to_s(16)}"
19
+ end
20
+ nil
21
+ rescue ArgumentError, Errno::ENOENT, Errno::EACCES
22
+ nil
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "protocol"
4
+
5
+ module Straycall
6
+ module Reporter
7
+ class ThreadReporter
8
+ attr_reader :thread
9
+
10
+ def initialize(socket)
11
+ @socket = socket
12
+ @reader = Protocol::Reader.new(socket)
13
+ @write_mutex = Mutex.new
14
+ @violation = false
15
+ end
16
+
17
+ def start
18
+ return self unless RUBY_ENGINE == "ruby" && Thread.current.respond_to?(:native_thread_id)
19
+
20
+ ready = Queue.new
21
+ @thread = Thread.new { ready << true; run }
22
+ @thread.name = "straycall-reporter" if @thread.respond_to?(:name=)
23
+ ready.pop
24
+ self
25
+ end
26
+
27
+ def notify_example(example)
28
+ @example = Protocol.example(example)
29
+ end
30
+
31
+ def violation?
32
+ return true if @violation
33
+
34
+ pending = @socket.recv_nonblock(Protocol::Reader::LIMIT, Socket::MSG_PEEK, exception: false)
35
+ pending.is_a?(String) && pending.match?(/(?:\A|\n)VIOLATION\n/)
36
+ rescue IOError, SystemCallError
37
+ false
38
+ end
39
+
40
+ def stop
41
+ @socket.close unless @socket.closed?
42
+ @thread&.join(0.2)
43
+ end
44
+
45
+ private
46
+
47
+ def run
48
+ while (line = @reader.read)
49
+ type, tid = Protocol.parse(line)
50
+ case type
51
+ when :backtrace
52
+ target = Thread.list.find { |candidate| candidate != Thread.current && candidate.native_thread_id == tid }
53
+ write(Protocol.backtrace_response(tid, target&.backtrace))
54
+ when :violation
55
+ @violation = true
56
+ write("OK\n")
57
+ when :sync
58
+ write(@example) if @example
59
+ write("OK\n")
60
+ end
61
+ end
62
+ rescue IOError, SystemCallError
63
+ nil
64
+ end
65
+
66
+ def write(message)
67
+ @write_mutex.synchronize { @socket.write(message) }
68
+ rescue IOError, SystemCallError
69
+ nil
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/core"
4
+ require_relative "../straycall"
5
+
6
+ module Straycall
7
+ module RSpec
8
+ module_function
9
+
10
+ def install
11
+ return unless Straycall.available?
12
+
13
+ require "seccomp/notify"
14
+ require_relative "in_process"
15
+ require_relative "target"
16
+ supervisor_socket, target_socket = UNIXSocket.pair
17
+ InProcess.install(Straycall.config, report_socket: supervisor_socket, inherited_report_socket: target_socket)
18
+ Target.start(target_socket)
19
+ configure_hooks
20
+ end
21
+
22
+ def configure_hooks
23
+ ::RSpec.configure do |config|
24
+ config.before do |example|
25
+ Straycall::RSpec.ensure_supervisor!
26
+ Target.reporter&.notify_example(
27
+ file: example.metadata[:file_path],
28
+ line: example.metadata[:line_number],
29
+ description: example.full_description
30
+ )
31
+ end
32
+ config.after(:suite) do
33
+ begin
34
+ Straycall::RSpec.ensure_supervisor!
35
+ raise Error, "syscall violations were detected; see the straycall report" if Target.violation?
36
+ ensure
37
+ Target.stop
38
+ end
39
+ end
40
+ end
41
+ end
42
+
43
+ def ensure_supervisor!
44
+ return unless Seccomp::Notify.supervisor_alive? == false
45
+
46
+ raise Error, "straycall supervisor stopped; refusing to continue with an unmonitored suite"
47
+ end
48
+ end
49
+ end
50
+
51
+ Straycall::RSpec.install