bonebed 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/LICENSE.txt +21 -0
- data/README.md +78 -0
- data/exe/bonebed +6 -0
- data/lib/bonebed/baseline.rb +76 -0
- data/lib/bonebed/cli.rb +104 -0
- data/lib/bonebed/collector.rb +76 -0
- data/lib/bonebed/decoder/connect.rb +42 -0
- data/lib/bonebed/decoder/execve.rb +31 -0
- data/lib/bonebed/decoder/openat.rb +17 -0
- data/lib/bonebed/difference.rb +28 -0
- data/lib/bonebed/dig.rb +128 -0
- data/lib/bonebed/doctor.rb +74 -0
- data/lib/bonebed/path_normalizer.rb +36 -0
- data/lib/bonebed/report.rb +65 -0
- data/lib/bonebed/session.rb +93 -0
- data/lib/bonebed/survey.rb +80 -0
- data/lib/bonebed/version.rb +5 -0
- data/lib/bonebed.rb +12 -0
- metadata +77 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 1b1a9dbb4f564259898f381389f21c8d4321c0f8e2f4cd7b35b4c08c8bf46db0
|
|
4
|
+
data.tar.gz: d175c95050c37077606b573d8d74517d951fcc368a0430666bb820a75b24e97c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 214234c1ff5aa7c66925864c081d35737b1d6348fcc41ea7c6f7ca76dca43467371cfca9793b780a0555f9949812b99f0519b80dfcf8d2e1d58e721041f0b8da
|
|
7
|
+
data.tar.gz: 64fdf0c3e20628da22a8a598bfc3e4fecf11bef329817a3339a0947528f4542ddb79eadd67b0e70b2399df8f8e259092a2bb52263bb49f84f03156fb60694b69
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Bonebed
|
|
2
|
+
|
|
3
|
+
Bonebed observes the files, network addresses, and external commands touched while a Ruby gem is installed or required. It produces a capability manifest from Linux seccomp user notifications.
|
|
4
|
+
|
|
5
|
+
Bonebed is an observation tool, not a security boundary. Pointer arguments can change between inspection and syscall continuation (TOCTOU), so its output describes what was observed rather than guaranteeing what happened.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- Linux 5.5 or newer on x86_64 or aarch64
|
|
10
|
+
- Ruby 3.2 or newer
|
|
11
|
+
- Permission to install a seccomp user-notification filter
|
|
12
|
+
|
|
13
|
+
Docker users must run with `--security-opt seccomp=unconfined`. Bonebed does not support macOS directly; use the development container below.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
Bonebed is not published yet. Build and install the current checkout with:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
gem build bonebed.gemspec
|
|
21
|
+
gem install ./bonebed-0.1.0.gem
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Diagnose the environment
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
bonebed doctor
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Observe a gem
|
|
31
|
+
|
|
32
|
+
Run Bonebed inside the development container so third-party code is not executed directly on the host:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
bin/dev bundle exec exe/bonebed dig json
|
|
36
|
+
bin/dev bundle exec exe/bonebed dig json --phase install
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Manifests are written to `results/`. The first run records a Ruby/Bundler baseline in `.bonebed/baselines`; refresh it after environment changes with:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
bin/dev bundle exec exe/bonebed baseline --refresh
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Use `--offline` to return `ENETUNREACH` for every observed connection. This is a compatibility check, not a security sandbox.
|
|
46
|
+
|
|
47
|
+
## Survey and report
|
|
48
|
+
|
|
49
|
+
Survey RubyGems.org's all-time download ranking, a newline-separated gem list, or a lockfile. Existing result files are skipped so interrupted surveys can resume.
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
bin/dev bundle exec exe/bonebed survey --top 100
|
|
53
|
+
bin/dev bundle exec exe/bonebed survey --gemfile Gemfile.lock --phase require
|
|
54
|
+
bin/dev bundle exec exe/bonebed report results --format md
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Top surveys default to the install phase because those gems need not already be installed. A list file accepts `NAME` or `NAME VERSION` on each line.
|
|
58
|
+
|
|
59
|
+
## Development
|
|
60
|
+
|
|
61
|
+
Build the Linux development image once, install dependencies, and run the checks inside it:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
docker build -f Dockerfile.dev -t bonebed-dev .
|
|
65
|
+
bin/dev bundle install
|
|
66
|
+
bin/dev bundle exec rake
|
|
67
|
+
bin/dev bundle exec exe/bonebed doctor
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The image includes `strace` for cross-checking noteworthy observations.
|
|
71
|
+
|
|
72
|
+
## Contributing
|
|
73
|
+
|
|
74
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/ydah/bonebed.
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/exe/bonebed
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "rbconfig"
|
|
7
|
+
require_relative "path_normalizer"
|
|
8
|
+
require_relative "session"
|
|
9
|
+
|
|
10
|
+
module Bonebed
|
|
11
|
+
class Baseline
|
|
12
|
+
Result = Struct.new(:id, :observation, keyword_init: true)
|
|
13
|
+
|
|
14
|
+
def initialize(cache_dir: ".bonebed/baselines", timeout: 30)
|
|
15
|
+
@cache_dir = cache_dir
|
|
16
|
+
@timeout = timeout
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def capture(refresh: false)
|
|
20
|
+
return load unless refresh || !File.file?(path)
|
|
21
|
+
|
|
22
|
+
collector = Session.new([RbConfig.ruby, "-e", ""], timeout: @timeout).run
|
|
23
|
+
observation = collector.snapshot(PathNormalizer.new)
|
|
24
|
+
raise Error, observation[:errors].join("; ") unless observation[:errors].empty?
|
|
25
|
+
|
|
26
|
+
FileUtils.mkdir_p(@cache_dir)
|
|
27
|
+
File.write(path, "#{JSON.pretty_generate(encode(observation))}\n")
|
|
28
|
+
Result.new(id:, observation:)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def id
|
|
34
|
+
bundle = ENV["BUNDLE_GEMFILE"] ? "bundler" : "nobundler"
|
|
35
|
+
gems = Gem.loaded_specs.values.map { |specification| "#{specification.name}-#{specification.version}" }.sort
|
|
36
|
+
local_files = %w[Gemfile.lock bonebed.gemspec].filter_map { |path| File.read(path) if File.file?(path) }
|
|
37
|
+
digest = Digest::SHA256.hexdigest([*gems, *local_files].join("\0"))[0, 8]
|
|
38
|
+
"ruby-#{RUBY_VERSION}-#{bundle}-#{RbConfig::CONFIG.fetch("host_cpu")}-#{digest}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def path
|
|
42
|
+
File.join(@cache_dir, "#{id}.json")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def load
|
|
46
|
+
Result.new(id:, observation: decode(JSON.parse(File.read(path))))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def encode(observation)
|
|
50
|
+
observation.merge(
|
|
51
|
+
network: entries(observation.fetch(:network)),
|
|
52
|
+
exec: entries(observation.fetch(:exec))
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def entries(values)
|
|
57
|
+
values.map { |event, count| {event:, count:} }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def decode(observation)
|
|
61
|
+
{
|
|
62
|
+
files: observation.fetch("files").to_h { |mode, values| [mode.to_sym, values] },
|
|
63
|
+
network: decode_entries(observation.fetch("network")),
|
|
64
|
+
exec: decode_entries(observation.fetch("exec")),
|
|
65
|
+
stats: observation.fetch("stats").transform_keys(&:to_sym),
|
|
66
|
+
errors: observation.fetch("errors")
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def decode_entries(entries)
|
|
71
|
+
entries.to_h do |entry|
|
|
72
|
+
[entry.fetch("event").transform_keys(&:to_sym), entry.fetch("count")]
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
data/lib/bonebed/cli.rb
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bonebed"
|
|
4
|
+
require "optparse"
|
|
5
|
+
|
|
6
|
+
module Bonebed
|
|
7
|
+
class CLI
|
|
8
|
+
def self.start(arguments = ARGV)
|
|
9
|
+
case arguments.shift
|
|
10
|
+
when "doctor"
|
|
11
|
+
Doctor.new.run ? 0 : 1
|
|
12
|
+
when "baseline"
|
|
13
|
+
baseline(arguments)
|
|
14
|
+
when "dig"
|
|
15
|
+
dig(arguments)
|
|
16
|
+
when "survey"
|
|
17
|
+
survey(arguments)
|
|
18
|
+
when "report"
|
|
19
|
+
report(arguments)
|
|
20
|
+
when nil, "help", "--help", "-h"
|
|
21
|
+
puts <<~HELP
|
|
22
|
+
Usage:
|
|
23
|
+
bonebed doctor
|
|
24
|
+
bonebed baseline [--refresh]
|
|
25
|
+
bonebed dig GEM [--phase require|install] [--version VERSION] [--offline]
|
|
26
|
+
bonebed dig --gemfile Gemfile.lock [--phase require|install]
|
|
27
|
+
bonebed survey (--top N | --file FILE | --gemfile FILE) [--phase require|install]
|
|
28
|
+
bonebed report RESULTS_DIR [--format md]
|
|
29
|
+
HELP
|
|
30
|
+
0
|
|
31
|
+
else
|
|
32
|
+
warn "Unknown command. Run `bonebed help` for usage."
|
|
33
|
+
1
|
|
34
|
+
end
|
|
35
|
+
rescue OptionParser::ParseError, ArgumentError, Gem::LoadError => error
|
|
36
|
+
warn error.message
|
|
37
|
+
1
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.dig(arguments)
|
|
41
|
+
options = {phase: "require", results_dir: "results", timeout: 30, offline: false, gemfile: nil}
|
|
42
|
+
OptionParser.new do |parser|
|
|
43
|
+
parser.on("--phase PHASE") { |value| options[:phase] = value }
|
|
44
|
+
parser.on("--version VERSION") { |value| options[:version] = value }
|
|
45
|
+
parser.on("--results DIR") { |value| options[:results_dir] = value }
|
|
46
|
+
parser.on("--timeout SECONDS", Integer) { |value| options[:timeout] = value }
|
|
47
|
+
parser.on("--offline") { options[:offline] = true }
|
|
48
|
+
parser.on("--gemfile FILE") { |value| options[:gemfile] = value }
|
|
49
|
+
end.parse!(arguments)
|
|
50
|
+
name = arguments.shift
|
|
51
|
+
raise ArgumentError, "unexpected arguments: #{arguments.join(" ")}" unless arguments.empty?
|
|
52
|
+
|
|
53
|
+
dig = Dig.new(**options.slice(:results_dir, :timeout, :offline))
|
|
54
|
+
if options[:gemfile]
|
|
55
|
+
raise ArgumentError, "GEM cannot be combined with --gemfile" if name
|
|
56
|
+
|
|
57
|
+
Survey.new(dig:).run(Survey.lockfile(options[:gemfile]), phase: options[:phase])
|
|
58
|
+
else
|
|
59
|
+
puts dig.run(name, phase: options[:phase], version: options[:version])
|
|
60
|
+
end
|
|
61
|
+
0
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.survey(arguments)
|
|
65
|
+
options = {phase: "install", results_dir: "results", timeout: 30, offline: false}
|
|
66
|
+
OptionParser.new do |parser|
|
|
67
|
+
parser.on("--top N", Integer) { |value| options[:top] = value }
|
|
68
|
+
parser.on("--file FILE") { |value| options[:file] = value }
|
|
69
|
+
parser.on("--gemfile FILE") { |value| options[:gemfile] = value }
|
|
70
|
+
parser.on("--phase PHASE") { |value| options[:phase] = value }
|
|
71
|
+
parser.on("--results DIR") { |value| options[:results_dir] = value }
|
|
72
|
+
parser.on("--timeout SECONDS", Integer) { |value| options[:timeout] = value }
|
|
73
|
+
parser.on("--offline") { options[:offline] = true }
|
|
74
|
+
end.parse!(arguments)
|
|
75
|
+
raise ArgumentError, "unexpected arguments: #{arguments.join(" ")}" unless arguments.empty?
|
|
76
|
+
raise ArgumentError, "choose exactly one of --top, --file, or --gemfile" unless options.values_at(:top, :file, :gemfile).compact.one?
|
|
77
|
+
raise ArgumentError, "phase must be require or install" unless Dig::PHASES.include?(options[:phase])
|
|
78
|
+
|
|
79
|
+
entries = options[:top] ? Survey.top(options[:top]) : options[:file] ? Survey.file(options[:file]) : Survey.lockfile(options[:gemfile])
|
|
80
|
+
dig = Dig.new(**options.slice(:results_dir, :timeout, :offline))
|
|
81
|
+
Survey.new(dig:).run(entries, phase: options[:phase])
|
|
82
|
+
0
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.report(arguments)
|
|
86
|
+
format = "md"
|
|
87
|
+
OptionParser.new { |parser| parser.on("--format FORMAT") { |value| format = value } }.parse!(arguments)
|
|
88
|
+
raise ArgumentError, "format must be md" unless format == "md"
|
|
89
|
+
raise ArgumentError, "results directory is required" unless arguments.one?
|
|
90
|
+
|
|
91
|
+
puts Report.new(arguments.first).markdown
|
|
92
|
+
0
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def self.baseline(arguments)
|
|
96
|
+
refresh = false
|
|
97
|
+
OptionParser.new { |parser| parser.on("--refresh") { refresh = true } }.parse!(arguments)
|
|
98
|
+
raise ArgumentError, "unexpected arguments: #{arguments.join(" ")}" unless arguments.empty?
|
|
99
|
+
|
|
100
|
+
puts Baseline.new.capture(refresh:).id
|
|
101
|
+
0
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bonebed
|
|
4
|
+
class Collector
|
|
5
|
+
attr_reader :errors, :wall_ms, :status
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@files = {read: Hash.new(0), write: Hash.new(0)}
|
|
9
|
+
@network = Hash.new(0)
|
|
10
|
+
@executions = Hash.new(0)
|
|
11
|
+
@errors = []
|
|
12
|
+
@roundtrips = 0
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def record_open(event)
|
|
16
|
+
@files.fetch(event.fetch(:mode))[event.fetch(:path)] += 1
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def record_network(event)
|
|
20
|
+
@network[event.freeze] += 1
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def record_exec(event)
|
|
24
|
+
@executions[[event.fetch(:path), event.fetch(:argv).freeze].freeze] += 1
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def record_notification
|
|
28
|
+
@roundtrips += 1
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def record_error(context, error)
|
|
32
|
+
@errors << "#{context}: #{error.class}: #{error.message}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def finish(started_at, status)
|
|
36
|
+
@wall_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
|
|
37
|
+
@status = status
|
|
38
|
+
return if status&.success?
|
|
39
|
+
|
|
40
|
+
@errors << if status&.signaled?
|
|
41
|
+
"target terminated by signal #{status.termsig}"
|
|
42
|
+
else
|
|
43
|
+
"target exited with status #{status&.exitstatus || "unknown"}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def snapshot(normalizer)
|
|
48
|
+
{
|
|
49
|
+
files: @files.transform_values { |entries| normalize_counts(entries, normalizer) },
|
|
50
|
+
network: normalize_network(normalizer),
|
|
51
|
+
exec: normalize_exec(normalizer),
|
|
52
|
+
stats: {openat_total: @files.values.sum { |entries| entries.values.sum }, notify_roundtrips: @roundtrips, wall_ms: @wall_ms},
|
|
53
|
+
errors: @errors.dup
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def normalize_counts(entries, normalizer)
|
|
60
|
+
entries.each_with_object(Hash.new(0)) { |(path, count), result| result[normalizer.call(path)] += count }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def normalize_network(normalizer)
|
|
64
|
+
@network.each_with_object(Hash.new(0)) do |(event, count), result|
|
|
65
|
+
normalized = event[:family] == "unix" ? event.merge(path: normalizer.call(event[:path])) : event
|
|
66
|
+
result[normalized] += count
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def normalize_exec(normalizer)
|
|
71
|
+
@executions.each_with_object(Hash.new(0)) do |((path, argv), count), result|
|
|
72
|
+
result[{path: normalizer.call(path), argv:}] += count
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
|
|
5
|
+
module Bonebed
|
|
6
|
+
module Decoder
|
|
7
|
+
module Connect
|
|
8
|
+
MAX_LENGTH = 128
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def call(bytes)
|
|
13
|
+
raise ArgumentError, "sockaddr length must be between 2 and #{MAX_LENGTH}" unless (2..MAX_LENGTH).cover?(bytes.bytesize)
|
|
14
|
+
|
|
15
|
+
case bytes.unpack1("S<")
|
|
16
|
+
when 0 then nil
|
|
17
|
+
when 1 then {family: "unix", path: unix_path(bytes.byteslice(2..))}
|
|
18
|
+
when 2 then internet_address("inet", bytes, 4)
|
|
19
|
+
when 10 then internet_address("inet6", bytes, 8)
|
|
20
|
+
else raise ArgumentError, "unsupported sockaddr family #{bytes.unpack1("S<")}"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def internet_address(family, bytes, address_offset)
|
|
25
|
+
length = family == "inet" ? 4 : 16
|
|
26
|
+
minimum = family == "inet" ? 16 : 28
|
|
27
|
+
raise ArgumentError, "short AF_#{family.upcase} sockaddr" if bytes.bytesize < minimum
|
|
28
|
+
|
|
29
|
+
address = IPAddr.new_ntoh(bytes.byteslice(address_offset, length)).to_s
|
|
30
|
+
scope = bytes.unpack1("@24L<") if family == "inet6"
|
|
31
|
+
address = "#{address}%#{scope}" if scope&.positive?
|
|
32
|
+
{family:, addr: address, port: bytes.unpack1("@2n")}
|
|
33
|
+
end
|
|
34
|
+
private_class_method :internet_address
|
|
35
|
+
|
|
36
|
+
def unix_path(bytes)
|
|
37
|
+
bytes.start_with?("\0") ? bytes.sub(/\0+\z/, "") : bytes.split("\0", 2).first
|
|
38
|
+
end
|
|
39
|
+
private_class_method :unix_path
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bonebed
|
|
4
|
+
module Decoder
|
|
5
|
+
module Execve
|
|
6
|
+
POINTER_FORMAT = "J"
|
|
7
|
+
POINTER_SIZE = [0].pack(POINTER_FORMAT).bytesize
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def call(request, limit: 64)
|
|
12
|
+
{
|
|
13
|
+
path: request.read_string(request.args.fetch(0)),
|
|
14
|
+
argv: read_argv(request, request.args.fetch(1), limit:)
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def read_argv(request, address, limit:)
|
|
19
|
+
arguments = []
|
|
20
|
+
limit.times do |index|
|
|
21
|
+
pointer = request.read(address + (index * POINTER_SIZE), POINTER_SIZE).unpack1(POINTER_FORMAT)
|
|
22
|
+
break if pointer.zero?
|
|
23
|
+
|
|
24
|
+
arguments << request.read_string(pointer)
|
|
25
|
+
end
|
|
26
|
+
arguments
|
|
27
|
+
end
|
|
28
|
+
private_class_method :read_argv
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bonebed
|
|
4
|
+
module Decoder
|
|
5
|
+
module Openat
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def call(request, syscall: request.syscall)
|
|
9
|
+
path_argument, flags_argument = syscall == :open ? [0, 1] : [1, 2]
|
|
10
|
+
{
|
|
11
|
+
path: request.read_string(request.args.fetch(path_argument)),
|
|
12
|
+
mode: (request.args.fetch(flags_argument) & (File::WRONLY | File::RDWR)).zero? ? :read : :write
|
|
13
|
+
}
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bonebed
|
|
4
|
+
module Difference
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def call(observed, baseline)
|
|
8
|
+
files = observed.fetch(:files).to_h do |mode, entries|
|
|
9
|
+
[mode, subtract(entries, baseline.dig(:files, mode) || {})]
|
|
10
|
+
end
|
|
11
|
+
{
|
|
12
|
+
files:,
|
|
13
|
+
network: subtract(observed.fetch(:network), baseline.fetch(:network, {})),
|
|
14
|
+
exec: subtract(observed.fetch(:exec), baseline.fetch(:exec, {})),
|
|
15
|
+
stats: observed.fetch(:stats).merge(openat_after_baseline: files.values.sum { |entries| entries.values.sum }),
|
|
16
|
+
errors: observed.fetch(:errors)
|
|
17
|
+
}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def subtract(observed, baseline)
|
|
21
|
+
observed.each_with_object({}) do |(event, count), result|
|
|
22
|
+
remaining = count - baseline.fetch(event, 0)
|
|
23
|
+
result[event] = remaining if remaining.positive?
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
private_class_method :subtract
|
|
27
|
+
end
|
|
28
|
+
end
|
data/lib/bonebed/dig.rb
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "rbconfig"
|
|
6
|
+
require "tmpdir"
|
|
7
|
+
require "bundler"
|
|
8
|
+
require_relative "baseline"
|
|
9
|
+
require_relative "difference"
|
|
10
|
+
require_relative "path_normalizer"
|
|
11
|
+
require_relative "session"
|
|
12
|
+
|
|
13
|
+
module Bonebed
|
|
14
|
+
class Dig
|
|
15
|
+
PHASES = %w[require install].freeze
|
|
16
|
+
attr_reader :results_dir
|
|
17
|
+
|
|
18
|
+
def initialize(results_dir: "results", timeout: 30, offline: false, baseline: Baseline.new(timeout:))
|
|
19
|
+
raise ArgumentError, "timeout must be positive" unless timeout.positive?
|
|
20
|
+
|
|
21
|
+
@results_dir = results_dir
|
|
22
|
+
@timeout = timeout
|
|
23
|
+
@offline = offline
|
|
24
|
+
@baseline = baseline
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def run(name, phase: "require", version: nil)
|
|
28
|
+
validate!(name, phase, version)
|
|
29
|
+
manifest = if phase == "install"
|
|
30
|
+
Bundler.with_unbundled_env { install(name, version, @baseline.capture) }
|
|
31
|
+
else
|
|
32
|
+
require_gem(name, version, @baseline.capture)
|
|
33
|
+
end
|
|
34
|
+
FileUtils.mkdir_p(@results_dir)
|
|
35
|
+
path = File.join(@results_dir, "#{name}-#{safe_component(manifest.dig("gem", "version"))}-#{phase}.json")
|
|
36
|
+
File.write(path, "#{JSON.pretty_generate(manifest)}\n")
|
|
37
|
+
path
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def write_failure(name, phase:, version:, error:)
|
|
41
|
+
validate!(name, phase)
|
|
42
|
+
FileUtils.mkdir_p(@results_dir)
|
|
43
|
+
path = File.join(@results_dir, "#{name}-#{safe_component(version)}-#{phase}.json")
|
|
44
|
+
data = manifest(name, version, phase, empty_observation(error), Baseline::Result.new(id: nil, observation: empty_observation))
|
|
45
|
+
File.write(path, "#{JSON.pretty_generate(data)}\n")
|
|
46
|
+
path
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def result_exists?(name, phase:, version: nil)
|
|
50
|
+
validate!(name, phase, version)
|
|
51
|
+
suffix = version ? safe_component(version) : "*"
|
|
52
|
+
!Dir[File.join(@results_dir, "#{name}-#{suffix}-#{phase}.json")].empty?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def validate!(name, phase, version = nil)
|
|
58
|
+
raise ArgumentError, "gem name is required" unless name&.match?(/\A[a-zA-Z0-9_-]+\z/)
|
|
59
|
+
raise ArgumentError, "phase must be require or install" unless PHASES.include?(phase)
|
|
60
|
+
raise ArgumentError, "invalid gem version" if version && !Gem::Version.correct?(version)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def require_gem(name, version, baseline)
|
|
64
|
+
specification = Gem::Specification.find_by_name(name, version ? "=#{version}" : Gem::Requirement.default)
|
|
65
|
+
code = 'gem ARGV[0], "=#{ARGV[1]}"; require ARGV[0]'
|
|
66
|
+
collector = Session.new([RbConfig.ruby, "-e", code, name, specification.version.to_s], timeout: @timeout, offline: @offline).run
|
|
67
|
+
normalizer = PathNormalizer.new
|
|
68
|
+
manifest(name, specification.version.to_s, "require", collector.snapshot(normalizer), baseline)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def install(name, version, baseline)
|
|
72
|
+
Dir.mktmpdir("bonebed-install-") do |root|
|
|
73
|
+
gem_home = File.join(root, "gems")
|
|
74
|
+
home = File.join(root, "home")
|
|
75
|
+
FileUtils.mkdir_p(home)
|
|
76
|
+
requested = version ? "#{name}:#{version}" : name
|
|
77
|
+
command = [RbConfig.ruby, "-S", "gem", "install", requested, "--no-document", "--install-dir", gem_home]
|
|
78
|
+
env = {"GEM_HOME" => gem_home, "GEM_PATH" => gem_home, "HOME" => home}
|
|
79
|
+
collector = Session.new(command, env:, timeout: @timeout, offline: @offline).run
|
|
80
|
+
installed_version = installed_version(gem_home, name) || version || "unknown"
|
|
81
|
+
normalizer = PathNormalizer.new(home:, gem_paths: [gem_home, *Gem.path], tmpdir: root)
|
|
82
|
+
manifest(name, installed_version, "install", collector.snapshot(normalizer), baseline)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def installed_version(gem_home, name)
|
|
87
|
+
path = Dir[File.join(gem_home, "specifications", "#{name}-*.gemspec")].max
|
|
88
|
+
File.basename(path, ".gemspec").delete_prefix("#{name}-") if path
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def manifest(name, version, phase, observation, baseline)
|
|
92
|
+
observation = Difference.call(observation, baseline.observation)
|
|
93
|
+
files = observation.fetch(:files).transform_values { |entries| entries.keys.sort }
|
|
94
|
+
files[:notable] = (files[:read].grep(/\A\$HOME\//) + files[:write].grep(/\A(?:\$HOME|\$TMPDIR)\//)).uniq.sort
|
|
95
|
+
{
|
|
96
|
+
"schema_version" => 1,
|
|
97
|
+
"gem" => {"name" => name, "version" => version},
|
|
98
|
+
"phase" => phase,
|
|
99
|
+
"environment" => {"ruby" => RUBY_VERSION, "arch" => RbConfig::CONFIG.fetch("host_cpu"), "kernel" => `uname -r`.strip, "baseline_id" => baseline.id},
|
|
100
|
+
"files" => stringify_keys(files),
|
|
101
|
+
"network" => counted_entries(observation.fetch(:network)),
|
|
102
|
+
"exec" => counted_entries(observation.fetch(:exec)),
|
|
103
|
+
"stats" => stringify_keys(observation.fetch(:stats)),
|
|
104
|
+
"errors" => observation.fetch(:errors)
|
|
105
|
+
}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def counted_entries(entries)
|
|
109
|
+
entries.map { |event, count| stringify_keys(event).merge("count" => count) }.sort_by(&:to_s)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def stringify_keys(hash)
|
|
113
|
+
hash.to_h { |key, value| [key.to_s, value] }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def empty_observation(error = nil)
|
|
117
|
+
{
|
|
118
|
+
files: {read: {}, write: {}}, network: {}, exec: {},
|
|
119
|
+
stats: {openat_total: 0, notify_roundtrips: 0, wall_ms: 0},
|
|
120
|
+
errors: error ? ["#{error.class}: #{error.message}"] : []
|
|
121
|
+
}
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def safe_component(value)
|
|
125
|
+
value.to_s.gsub(/[^0-9A-Za-z._-]/, "_")
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
module Bonebed
|
|
6
|
+
class Doctor
|
|
7
|
+
SUPPORTED_ARCHES = %w[x86_64 aarch64].freeze
|
|
8
|
+
|
|
9
|
+
def initialize(output: $stdout)
|
|
10
|
+
@output = output
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def run
|
|
14
|
+
features = seccomp_features
|
|
15
|
+
checks = [
|
|
16
|
+
["kernel", kernel_release, linux? && kernel_supported?],
|
|
17
|
+
["arch", arch, linux? && SUPPORTED_ARCHES.include?(arch)],
|
|
18
|
+
["CONFIG_SECCOMP_FILTER", config_status(features), features[:user_notif]],
|
|
19
|
+
["SECCOMP_RET_USER_NOTIF", enabled(features[:user_notif]), features[:user_notif]],
|
|
20
|
+
["continue (5.5+)", enabled(features[:continue]), features[:continue]],
|
|
21
|
+
["addfd (5.9+)", enabled(features[:addfd]), features[:addfd]],
|
|
22
|
+
["container seccomp profile", container_status, true]
|
|
23
|
+
]
|
|
24
|
+
checks.each { |name, value, ok| @output.puts(format("%-30s %-24s %s", name, value, ok ? "OK" : "NG")) }
|
|
25
|
+
checks.first(6).all?(&:last)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def linux?
|
|
31
|
+
RUBY_PLATFORM.include?("linux")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def kernel_release
|
|
35
|
+
`uname -r`.strip
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def kernel_supported?
|
|
39
|
+
Gem::Version.new(kernel_release[/\A\d+(?:\.\d+)+/] || "0") >= Gem::Version.new("5.5")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def arch
|
|
43
|
+
RbConfig::CONFIG.fetch("host_cpu").sub("arm64", "aarch64")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def seccomp_features
|
|
47
|
+
return {} unless linux?
|
|
48
|
+
|
|
49
|
+
require "seccomp/notify"
|
|
50
|
+
Seccomp::Notify.features
|
|
51
|
+
rescue LoadError, StandardError
|
|
52
|
+
{}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def enabled(value)
|
|
56
|
+
value ? "available" : "unavailable"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def config_status(features)
|
|
60
|
+
return "enabled" if features[:user_notif]
|
|
61
|
+
|
|
62
|
+
linux? ? "unavailable" : "Linux only"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def container_status
|
|
66
|
+
return "not Linux" unless linux?
|
|
67
|
+
|
|
68
|
+
mode = File.read("/proc/self/status")[/^Seccomp:\s+(\d+)/, 1]
|
|
69
|
+
mode == "0" ? "unconfined" : "filter active"
|
|
70
|
+
rescue Errno::ENOENT
|
|
71
|
+
"unknown"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
|
|
5
|
+
module Bonebed
|
|
6
|
+
class PathNormalizer
|
|
7
|
+
def initialize(home: Dir.home, gem_paths: Gem.path, tmpdir: Dir.tmpdir)
|
|
8
|
+
@home = clean(home)
|
|
9
|
+
@gem_paths = gem_paths.map { |path| clean(path) }.sort_by { |path| -path.length }
|
|
10
|
+
@tmpdir = clean(tmpdir)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(path)
|
|
14
|
+
gem_path = @gem_paths.find { |root| inside?(path, root) }
|
|
15
|
+
return replace(path, gem_path, "$GEM_HOME") if gem_path
|
|
16
|
+
return replace(path, @home, "$HOME") if inside?(path, @home)
|
|
17
|
+
return path == @tmpdir ? "$TMPDIR" : "$TMPDIR/<random>" if inside?(path, @tmpdir)
|
|
18
|
+
|
|
19
|
+
path.sub(%r{\A/proc/\d+(?=/|\z)}, "/proc/<pid>")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def clean(path)
|
|
25
|
+
path.to_s.sub(%r{/+\z}, "")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def inside?(path, root)
|
|
29
|
+
!root.empty? && (path == root || path.start_with?("#{root}/"))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def replace(path, root, marker)
|
|
33
|
+
"#{marker}#{path.delete_prefix(root)}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Bonebed
|
|
6
|
+
class Report
|
|
7
|
+
def initialize(directory)
|
|
8
|
+
raise ArgumentError, "results directory does not exist: #{directory}" unless Dir.exist?(directory)
|
|
9
|
+
|
|
10
|
+
@manifests = Dir[File.join(directory, "*.json")].map { |path| JSON.parse(File.read(path)) }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def markdown
|
|
14
|
+
successful = @manifests.count { |manifest| manifest.fetch("errors", []).empty? }
|
|
15
|
+
lines = [
|
|
16
|
+
"# Bonebed survey",
|
|
17
|
+
"",
|
|
18
|
+
"- Manifests: #{@manifests.size}",
|
|
19
|
+
"- Successful: #{successful}",
|
|
20
|
+
"- With errors: #{@manifests.size - successful}",
|
|
21
|
+
"- Require phase with network: #{network_count("require")}",
|
|
22
|
+
"- Install phase with network: #{network_count("install")}",
|
|
23
|
+
"",
|
|
24
|
+
ranking("Home files read", home_reads),
|
|
25
|
+
ranking("Commands observed", commands),
|
|
26
|
+
openat_ranking
|
|
27
|
+
]
|
|
28
|
+
lines.join("\n").rstrip << "\n"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def network_count(phase)
|
|
34
|
+
@manifests.count { |manifest| manifest["phase"] == phase && !manifest.fetch("network", []).empty? }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def home_reads
|
|
38
|
+
counts = Hash.new(0)
|
|
39
|
+
@manifests.each { |manifest| manifest.dig("files", "read")&.grep(/\A\$HOME\//)&.each { |path| counts[path] += 1 } }
|
|
40
|
+
counts
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def commands
|
|
44
|
+
@manifests.each_with_object(Hash.new(0)) do |manifest, counts|
|
|
45
|
+
manifest.fetch("exec", []).each { |entry| counts[entry.fetch("path")] += entry.fetch("count", 1) }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def ranking(title, counts)
|
|
50
|
+
rows = counts.sort_by { |name, count| [-count, name] }.first(10)
|
|
51
|
+
return "## #{title}\n\nNone observed.\n" if rows.empty?
|
|
52
|
+
|
|
53
|
+
"## #{title}\n\n| Item | Count |\n| --- | ---: |\n#{rows.map { |name, count| "| `#{name}` | #{count} |" }.join("\n")}\n"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def openat_ranking
|
|
57
|
+
rows = @manifests.sort_by { |manifest| -manifest.dig("stats", "openat_after_baseline").to_i }.first(10)
|
|
58
|
+
ranking("Open calls after baseline", rows.to_h { |manifest| [label(manifest), manifest.dig("stats", "openat_after_baseline").to_i] })
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def label(manifest)
|
|
62
|
+
"#{manifest.dig("gem", "name")} #{manifest.dig("gem", "version")} (#{manifest["phase"]})"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
require_relative "collector"
|
|
5
|
+
require_relative "decoder/openat"
|
|
6
|
+
require_relative "decoder/connect"
|
|
7
|
+
require_relative "decoder/execve"
|
|
8
|
+
|
|
9
|
+
module Bonebed
|
|
10
|
+
class Session
|
|
11
|
+
def initialize(command, env: {}, timeout: 30, offline: false, collector: Collector.new)
|
|
12
|
+
@command = command
|
|
13
|
+
@env = env
|
|
14
|
+
@timeout = timeout
|
|
15
|
+
@offline = offline
|
|
16
|
+
@collector = collector
|
|
17
|
+
@bootstrap_exec = true
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run
|
|
21
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
22
|
+
supervisor = build_supervisor
|
|
23
|
+
status = Timeout.timeout(@timeout) { supervisor.run }
|
|
24
|
+
@collector.finish(started_at, status)
|
|
25
|
+
@collector
|
|
26
|
+
rescue Timeout::Error
|
|
27
|
+
terminate(supervisor&.target_pid)
|
|
28
|
+
@collector.record_error("session", Timeout::Error.new("timed out after #{@timeout} seconds"))
|
|
29
|
+
@collector.finish(started_at, nil)
|
|
30
|
+
@collector
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def build_supervisor
|
|
36
|
+
require "seccomp/notify"
|
|
37
|
+
open_syscalls = RUBY_PLATFORM.include?("x86_64") ? %i[open openat] : %i[openat]
|
|
38
|
+
policy = Seccomp::Notify::Policy.new { notify(*open_syscalls, :connect, :execve) }
|
|
39
|
+
supervisor = Seccomp::Notify.spawn(policy) { exec(@env, *@command) }
|
|
40
|
+
open_syscalls.each { |syscall| supervisor.on(syscall) { |request| handle_open(request, syscall) } }
|
|
41
|
+
supervisor.on(:connect) { |request| handle_connect(request) }
|
|
42
|
+
supervisor.on(:execve) { |request| handle_execve(request) }
|
|
43
|
+
supervisor.on_error { |error, request| @collector.record_error(request&.syscall || "supervisor", error) }
|
|
44
|
+
supervisor
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def handle_open(request, syscall)
|
|
48
|
+
@collector.record_notification
|
|
49
|
+
@collector.record_open(Decoder::Openat.call(request, syscall:))
|
|
50
|
+
rescue StandardError => error
|
|
51
|
+
@collector.record_error(syscall, error)
|
|
52
|
+
ensure
|
|
53
|
+
request.continue!(unsafe: true) unless request.responded?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def handle_connect(request)
|
|
57
|
+
@collector.record_notification
|
|
58
|
+
bytes = request.read(request.args.fetch(1), request.args.fetch(2))
|
|
59
|
+
event = Decoder::Connect.call(bytes)
|
|
60
|
+
@collector.record_network(event) if event
|
|
61
|
+
rescue StandardError => error
|
|
62
|
+
@collector.record_error(:connect, error)
|
|
63
|
+
ensure
|
|
64
|
+
unless request.responded?
|
|
65
|
+
@offline ? request.error!(Errno::ENETUNREACH) : request.continue!(unsafe: true)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def handle_execve(request)
|
|
70
|
+
@collector.record_notification
|
|
71
|
+
if @bootstrap_exec
|
|
72
|
+
@bootstrap_exec = false
|
|
73
|
+
else
|
|
74
|
+
event = Decoder::Execve.call(request)
|
|
75
|
+
# ponytail: same-mount check filters failed PATH lookups; retain attempts if targets gain separate mounts.
|
|
76
|
+
@collector.record_exec(event) if File.executable?(event[:path])
|
|
77
|
+
end
|
|
78
|
+
rescue StandardError => error
|
|
79
|
+
@collector.record_error(:execve, error)
|
|
80
|
+
ensure
|
|
81
|
+
request.continue!(unsafe: true) unless request.responded?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def terminate(pid)
|
|
85
|
+
return unless pid
|
|
86
|
+
|
|
87
|
+
Process.kill("KILL", pid)
|
|
88
|
+
Process.waitpid(pid)
|
|
89
|
+
rescue Errno::ECHILD, Errno::ESRCH
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler"
|
|
4
|
+
require "json"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module Bonebed
|
|
9
|
+
class Survey
|
|
10
|
+
STATS_URL = "https://rubygems.org/stats"
|
|
11
|
+
|
|
12
|
+
def initialize(dig:, output: $stdout)
|
|
13
|
+
@dig = dig
|
|
14
|
+
@output = output
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def run(entries, phase:)
|
|
18
|
+
entries.each_with_index do |entry, index|
|
|
19
|
+
name, version = entry.values_at(:name, :version)
|
|
20
|
+
if @dig.result_exists?(name, phase:, version:)
|
|
21
|
+
@output.puts "[#{index + 1}/#{entries.size}] skip #{name}"
|
|
22
|
+
next
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
@output.puts "[#{index + 1}/#{entries.size}] #{phase} #{name}"
|
|
26
|
+
@dig.run(name, phase:, version:)
|
|
27
|
+
rescue StandardError => error
|
|
28
|
+
@output.puts " failed: #{error.message}"
|
|
29
|
+
write_failure(name, version, phase, error)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.top(limit)
|
|
34
|
+
raise ArgumentError, "top must be between 1 and 100" unless (1..100).cover?(limit)
|
|
35
|
+
|
|
36
|
+
names = (1..(limit / 10.0).ceil).flat_map { |page| names_from(fetch_page(page)) }.uniq
|
|
37
|
+
raise Error, "RubyGems stats returned only #{names.size} gem names" if names.size < limit
|
|
38
|
+
|
|
39
|
+
names.first(limit).map { |name| {name:, version: nil} }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.file(path)
|
|
43
|
+
File.readlines(path, chomp: true).filter_map do |line|
|
|
44
|
+
name, version = line.sub(/#.*/, "").split
|
|
45
|
+
{name:, version:} if name
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.lockfile(path)
|
|
50
|
+
parser = Bundler::LockfileParser.new(Bundler.read_file(path))
|
|
51
|
+
parser.specs.group_by(&:name).map do |name, specifications|
|
|
52
|
+
{name:, version: specifications.max_by(&:version).version.to_s}
|
|
53
|
+
end.sort_by { |entry| entry[:name] }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.names_from(html)
|
|
57
|
+
html.scan(%r{href="/gems/([^"?]+)}).flatten.map { |name| URI::DEFAULT_PARSER.unescape(name) }
|
|
58
|
+
end
|
|
59
|
+
private_class_method :names_from
|
|
60
|
+
|
|
61
|
+
def self.fetch_page(page)
|
|
62
|
+
uri = URI("#{STATS_URL}?page=#{page}")
|
|
63
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 10) do |http|
|
|
64
|
+
response = http.get(uri.request_uri)
|
|
65
|
+
raise Error, "RubyGems stats returned HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
66
|
+
|
|
67
|
+
response.body
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
private_class_method :fetch_page
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def write_failure(name, version, phase, error)
|
|
75
|
+
@dig.write_failure(name, phase:, version: version || "unknown", error:)
|
|
76
|
+
rescue ArgumentError
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
data/lib/bonebed.rb
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "bonebed/version"
|
|
4
|
+
|
|
5
|
+
module Bonebed
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
require_relative "bonebed/doctor"
|
|
10
|
+
require_relative "bonebed/dig"
|
|
11
|
+
require_relative "bonebed/survey"
|
|
12
|
+
require_relative "bonebed/report"
|
metadata
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: bonebed
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Yudai Takada
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: seccomp-notify
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.3'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.3'
|
|
26
|
+
description: Profiles file, network, and process syscalls made while installing or
|
|
27
|
+
requiring Ruby gems.
|
|
28
|
+
email:
|
|
29
|
+
- t.yudai92@gmail.com
|
|
30
|
+
executables:
|
|
31
|
+
- bonebed
|
|
32
|
+
extensions: []
|
|
33
|
+
extra_rdoc_files: []
|
|
34
|
+
files:
|
|
35
|
+
- LICENSE.txt
|
|
36
|
+
- README.md
|
|
37
|
+
- exe/bonebed
|
|
38
|
+
- lib/bonebed.rb
|
|
39
|
+
- lib/bonebed/baseline.rb
|
|
40
|
+
- lib/bonebed/cli.rb
|
|
41
|
+
- lib/bonebed/collector.rb
|
|
42
|
+
- lib/bonebed/decoder/connect.rb
|
|
43
|
+
- lib/bonebed/decoder/execve.rb
|
|
44
|
+
- lib/bonebed/decoder/openat.rb
|
|
45
|
+
- lib/bonebed/difference.rb
|
|
46
|
+
- lib/bonebed/dig.rb
|
|
47
|
+
- lib/bonebed/doctor.rb
|
|
48
|
+
- lib/bonebed/path_normalizer.rb
|
|
49
|
+
- lib/bonebed/report.rb
|
|
50
|
+
- lib/bonebed/session.rb
|
|
51
|
+
- lib/bonebed/survey.rb
|
|
52
|
+
- lib/bonebed/version.rb
|
|
53
|
+
homepage: https://github.com/ydah/bonebed
|
|
54
|
+
licenses:
|
|
55
|
+
- MIT
|
|
56
|
+
metadata:
|
|
57
|
+
allowed_push_host: https://rubygems.org
|
|
58
|
+
source_code_uri: https://github.com/ydah/bonebed
|
|
59
|
+
rubygems_mfa_required: 'true'
|
|
60
|
+
rdoc_options: []
|
|
61
|
+
require_paths:
|
|
62
|
+
- lib
|
|
63
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: 3.2.0
|
|
68
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
69
|
+
requirements:
|
|
70
|
+
- - ">="
|
|
71
|
+
- !ruby/object:Gem::Version
|
|
72
|
+
version: '0'
|
|
73
|
+
requirements: []
|
|
74
|
+
rubygems_version: 4.0.19
|
|
75
|
+
specification_version: 4
|
|
76
|
+
summary: Observe the runtime capabilities of Ruby gems
|
|
77
|
+
test_files: []
|