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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +125 -0
- data/Rakefile +8 -0
- data/benchmark/require_overhead.rb +67 -0
- data/docs/index.html +186 -0
- data/docs/styles.css +718 -0
- data/examples/.straycall.yml +13 -0
- data/examples/rspec.rb +13 -0
- data/exe/straycall +6 -0
- data/exe/straycall-supervisor +29 -0
- data/lib/straycall/cli.rb +73 -0
- data/lib/straycall/config.rb +217 -0
- data/lib/straycall/handlers/exec.rb +22 -0
- data/lib/straycall/handlers/filesystem.rb +65 -0
- data/lib/straycall/in_process.rb +74 -0
- data/lib/straycall/minitest.rb +47 -0
- data/lib/straycall/policy/exec.rb +44 -0
- data/lib/straycall/policy/filesystem.rb +119 -0
- data/lib/straycall/policy/network.rb +83 -0
- data/lib/straycall/policy.rb +45 -0
- data/lib/straycall/prompt.rb +24 -0
- data/lib/straycall/recorder.rb +38 -0
- data/lib/straycall/report/console.rb +29 -0
- data/lib/straycall/report/json.rb +19 -0
- data/lib/straycall/reporter/client.rb +102 -0
- data/lib/straycall/reporter/protocol.rb +79 -0
- data/lib/straycall/reporter/symbolizer.rb +26 -0
- data/lib/straycall/reporter/thread_reporter.rb +73 -0
- data/lib/straycall/rspec.rb +51 -0
- data/lib/straycall/supervisor.rb +249 -0
- data/lib/straycall/target.rb +31 -0
- data/lib/straycall/version.rb +5 -0
- data/lib/straycall/violation.rb +9 -0
- data/lib/straycall.rb +39 -0
- metadata +94 -0
|
@@ -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
|