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 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,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bonebed/cli"
5
+
6
+ exit Bonebed::CLI.start
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bonebed
4
+ VERSION = "0.1.0"
5
+ 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: []