bootprint 0.2.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/.bootprint.yml.example +37 -0
- data/ARCHITECTURE.md +46 -0
- data/CHANGELOG.md +26 -0
- data/CODE_OF_CONDUCT.md +7 -0
- data/CONTRIBUTING.md +28 -0
- data/LICENSE +21 -0
- data/README.md +422 -0
- data/RELEASE.md +78 -0
- data/ROADMAP.md +15 -0
- data/SECURITY.md +47 -0
- data/assets/branding/README.md +24 -0
- data/assets/branding/bootprint-logo-128.png +0 -0
- data/assets/branding/bootprint-logo-512.png +0 -0
- data/assets/branding/bootprint-logo-64.png +0 -0
- data/assets/branding/bootprint-logo.png +0 -0
- data/docs/capturing.md +9 -0
- data/docs/ci.md +21 -0
- data/docs/comparing.md +22 -0
- data/docs/custom-rules.md +7 -0
- data/docs/docker.md +7 -0
- data/docs/findings.md +7 -0
- data/docs/installation.md +7 -0
- data/docs/maintainer-setup.md +54 -0
- data/docs/plugins.md +7 -0
- data/docs/policy.md +9 -0
- data/docs/privacy.md +7 -0
- data/docs/quick-start.md +9 -0
- data/docs/rails.md +13 -0
- data/docs/snapshot-schema.md +9 -0
- data/docs/troubleshooting.md +8 -0
- data/exe/bootprint +6 -0
- data/lib/bootprint/analysis.rb +13 -0
- data/lib/bootprint/cli.rb +458 -0
- data/lib/bootprint/collectors/environment.rb +21 -0
- data/lib/bootprint/collectors/filesystem.rb +40 -0
- data/lib/bootprint/collectors/gems.rb +96 -0
- data/lib/bootprint/collectors/libraries.rb +75 -0
- data/lib/bootprint/collectors/operating_system.rb +50 -0
- data/lib/bootprint/collectors/rails.rb +97 -0
- data/lib/bootprint/collectors/runtime.rb +34 -0
- data/lib/bootprint/collectors/toolchain.rb +23 -0
- data/lib/bootprint/configuration.rb +38 -0
- data/lib/bootprint/diagnosis.rb +95 -0
- data/lib/bootprint/diff.rb +47 -0
- data/lib/bootprint/docker.rb +149 -0
- data/lib/bootprint/doctor.rb +13 -0
- data/lib/bootprint/errors.rb +9 -0
- data/lib/bootprint/formatters/human.rb +55 -0
- data/lib/bootprint/formatters/json.rb +12 -0
- data/lib/bootprint/formatters/markdown.rb +27 -0
- data/lib/bootprint/formatters/sarif.rb +54 -0
- data/lib/bootprint/formatters.rb +22 -0
- data/lib/bootprint/initializer_profiler.rb +93 -0
- data/lib/bootprint/plugins.rb +90 -0
- data/lib/bootprint/policy.rb +191 -0
- data/lib/bootprint/rails_state.rb +17 -0
- data/lib/bootprint/railtie.rb +36 -0
- data/lib/bootprint/rules/builtin.rb +383 -0
- data/lib/bootprint/rules/finding.rb +40 -0
- data/lib/bootprint/rules/registry.rb +20 -0
- data/lib/bootprint/rules/rule.rb +153 -0
- data/lib/bootprint/rules.rb +58 -0
- data/lib/bootprint/sanitizer.rb +105 -0
- data/lib/bootprint/schema.rb +111 -0
- data/lib/bootprint/security/auditor.rb +63 -0
- data/lib/bootprint/snapshot.rb +122 -0
- data/lib/bootprint/version.rb +5 -0
- data/lib/bootprint.rb +38 -0
- data/lib/tasks/bootprint.rake +18 -0
- metadata +120 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
|
|
6
|
+
module Bootprint
|
|
7
|
+
module Collectors
|
|
8
|
+
module OperatingSystem
|
|
9
|
+
TOOLS = %w[git make gcc clang cmake pkg-config docker podman].freeze
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def key = "operating_system"
|
|
14
|
+
|
|
15
|
+
def capture
|
|
16
|
+
{
|
|
17
|
+
"name" => RbConfig::CONFIG["host_os"],
|
|
18
|
+
"cpu" => RbConfig::CONFIG["host_cpu"],
|
|
19
|
+
"processors" => Etc.nprocessors,
|
|
20
|
+
"capabilities" => TOOLS.to_h { |tool| [tool, executable?(tool)] },
|
|
21
|
+
"ruby_headers" => header_state,
|
|
22
|
+
"ci" => ci_provider
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def executable?(name)
|
|
27
|
+
extensions = Gem.win_platform? ? ENV.fetch("PATHEXT", ".EXE;.BAT;.CMD").split(";") : [""]
|
|
28
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |directory|
|
|
29
|
+
extensions.any? do |extension|
|
|
30
|
+
File.executable?(File.join(directory, "#{name}#{extension}"))
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def header_state
|
|
36
|
+
path = RbConfig::CONFIG["rubyhdrdir"]
|
|
37
|
+
{ "present" => path && File.directory?(path), "path" => path && Sanitizer.path(path) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def ci_provider
|
|
41
|
+
return "github" if ENV["GITHUB_ACTIONS"] == "true"
|
|
42
|
+
return "gitlab" if ENV["GITLAB_CI"] == "true"
|
|
43
|
+
return "circleci" if ENV["CIRCLECI"] == "true"
|
|
44
|
+
return "generic" if ENV.key?("CI")
|
|
45
|
+
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bootprint
|
|
4
|
+
module Collectors
|
|
5
|
+
module Rails
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def key = "rails"
|
|
9
|
+
|
|
10
|
+
def capture
|
|
11
|
+
return {} unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
|
|
12
|
+
|
|
13
|
+
application = ::Rails.application
|
|
14
|
+
config = application.config
|
|
15
|
+
{
|
|
16
|
+
"version" => ::Rails.version,
|
|
17
|
+
"environment" => ::Rails.env.to_s,
|
|
18
|
+
"framework_defaults" => value(config, :loaded_config_version)&.to_s,
|
|
19
|
+
"eager_load" => value(config, :eager_load),
|
|
20
|
+
"cache_classes" => value(config, :cache_classes),
|
|
21
|
+
"autoload_paths" => paths(value(config, :autoload_paths)),
|
|
22
|
+
"eager_load_paths" => paths(value(config, :eager_load_paths)),
|
|
23
|
+
"adapters" => adapters(config),
|
|
24
|
+
"active_storage_service" => nested_value(config, :active_storage, :service)&.to_s,
|
|
25
|
+
"action_cable_adapter" => action_cable_adapter,
|
|
26
|
+
"mail_delivery_method" => nested_value(config, :action_mailer, :delivery_method)&.to_s,
|
|
27
|
+
"time_zone" => value(config, :time_zone)&.to_s,
|
|
28
|
+
"logging" => logging(config),
|
|
29
|
+
"public_file_server_enabled" => nested_value(config, :public_file_server, :enabled),
|
|
30
|
+
"assets_compile" => nested_value(config, :assets, :compile),
|
|
31
|
+
"secret_key_base" => secret_metadata(application),
|
|
32
|
+
"initializers" => sanitized_initializers
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def adapters(config)
|
|
37
|
+
{
|
|
38
|
+
"active_job" => nested_value(config, :active_job, :queue_adapter)&.to_s,
|
|
39
|
+
"cache" => Array(value(config, :cache_store)).first&.to_s,
|
|
40
|
+
"session" => value(config, :session_store)&.to_s,
|
|
41
|
+
"database" => database_adapter
|
|
42
|
+
}.compact
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def database_adapter
|
|
46
|
+
return unless defined?(ActiveRecord::Base)
|
|
47
|
+
|
|
48
|
+
ActiveRecord::Base.connection_db_config.adapter
|
|
49
|
+
rescue StandardError
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def action_cable_adapter
|
|
54
|
+
return unless defined?(ActionCable) && ActionCable.respond_to?(:server)
|
|
55
|
+
|
|
56
|
+
ActionCable.server.config.cable.fetch("adapter", nil)
|
|
57
|
+
rescue StandardError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def logging(config)
|
|
62
|
+
logger = defined?(::Rails.logger) ? ::Rails.logger : nil
|
|
63
|
+
{ "level" => logger&.level, "class" => logger&.class&.name, "log_tags" => Array(value(config, :log_tags)).map(&:to_s) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def secret_metadata(application)
|
|
67
|
+
present = !application.secret_key_base.to_s.empty?
|
|
68
|
+
source = ENV.key?("SECRET_KEY_BASE") ? "environment" : "credentials_or_configuration"
|
|
69
|
+
{ "present" => present, "source" => source, "redacted" => true }
|
|
70
|
+
rescue StandardError
|
|
71
|
+
{ "present" => false, "source" => "unavailable", "redacted" => true }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def sanitized_initializers
|
|
75
|
+
return [] unless defined?(Bootprint::RailsState)
|
|
76
|
+
|
|
77
|
+
Array(Bootprint::RailsState.initializers).map do |initializer|
|
|
78
|
+
initializer.merge("name" => Sanitizer.path(initializer["name"]))
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def paths(value)
|
|
83
|
+
Array(value).map { |path| Sanitizer.path(path) }.sort
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def value(object, method)
|
|
87
|
+
object.public_send(method) if object.respond_to?(method)
|
|
88
|
+
rescue StandardError
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def nested_value(object, *methods)
|
|
93
|
+
methods.reduce(object) { |current, method| current && value(current, method) }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
module Bootprint
|
|
6
|
+
module Collectors
|
|
7
|
+
module Runtime
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def key = "runtime"
|
|
11
|
+
|
|
12
|
+
def capture
|
|
13
|
+
{
|
|
14
|
+
"engine" => defined?(RUBY_ENGINE) ? RUBY_ENGINE : "ruby",
|
|
15
|
+
"engine_version" => defined?(RUBY_ENGINE_VERSION) ? RUBY_ENGINE_VERSION : RUBY_VERSION,
|
|
16
|
+
"ruby_version" => RUBY_VERSION,
|
|
17
|
+
"patchlevel" => RUBY_PATCHLEVEL,
|
|
18
|
+
"platform" => RUBY_PLATFORM,
|
|
19
|
+
"architecture" => RbConfig::CONFIG["arch"],
|
|
20
|
+
"host_os" => RbConfig::CONFIG["host_os"],
|
|
21
|
+
"host_cpu" => RbConfig::CONFIG["host_cpu"],
|
|
22
|
+
"description" => RUBY_DESCRIPTION,
|
|
23
|
+
"debug_build" => debug_build?,
|
|
24
|
+
"supported" => Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.1.0")
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def debug_build?
|
|
29
|
+
args = RbConfig::CONFIG["configure_args"].to_s
|
|
30
|
+
args.include?("--enable-debug") || args.include?("--with-assertions")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubygems"
|
|
4
|
+
|
|
5
|
+
module Bootprint
|
|
6
|
+
module Collectors
|
|
7
|
+
module Toolchain
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def key = "toolchain"
|
|
11
|
+
|
|
12
|
+
def capture
|
|
13
|
+
bundler_version = if defined?(Bundler::VERSION)
|
|
14
|
+
Bundler::VERSION
|
|
15
|
+
else
|
|
16
|
+
Gem.loaded_specs["bundler"]&.version&.to_s ||
|
|
17
|
+
Gem::Specification.find_all_by_name("bundler").map(&:version).max&.to_s
|
|
18
|
+
end
|
|
19
|
+
{ "rubygems_version" => Gem::VERSION, "bundler_version" => bundler_version }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bootprint
|
|
4
|
+
class Configuration
|
|
5
|
+
DEFAULT_ENV_PATTERNS = [
|
|
6
|
+
/\A(?:DATABASE|REDIS|RAILS|RACK|BUNDLE|SECRET_KEY_BASE|RAILS_MASTER_KEY)(?:_|\z)/,
|
|
7
|
+
/_URL\z/,
|
|
8
|
+
/_HOST\z/,
|
|
9
|
+
/_PORT\z/
|
|
10
|
+
].freeze
|
|
11
|
+
|
|
12
|
+
attr_accessor :environment_patterns, :environment_names, :ignored_environment_names,
|
|
13
|
+
:required_environment_names, :optional_environment_names, :privacy,
|
|
14
|
+
:slow_initializer_threshold_ms, :profile_boot, :plugin_strict,
|
|
15
|
+
:redaction_patterns, :redaction_safe_list, :expected_platforms
|
|
16
|
+
|
|
17
|
+
def initialize
|
|
18
|
+
@environment_patterns = DEFAULT_ENV_PATTERNS.dup
|
|
19
|
+
@environment_names = []
|
|
20
|
+
@ignored_environment_names = []
|
|
21
|
+
@required_environment_names = []
|
|
22
|
+
@optional_environment_names = []
|
|
23
|
+
@privacy = :standard
|
|
24
|
+
@slow_initializer_threshold_ms = 500.0
|
|
25
|
+
@profile_boot = ENV["BOOTPRINT_PROFILE_BOOT"] == "1"
|
|
26
|
+
@plugin_strict = false
|
|
27
|
+
@redaction_patterns = %w[TOKEN SECRET PASSWORD PRIVATE_KEY AUTHORIZATION COOKIE]
|
|
28
|
+
@redaction_safe_list = %w[checksum sha256 digest]
|
|
29
|
+
@expected_platforms = []
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def capture_environment?(name)
|
|
33
|
+
return false if ignored_environment_names.include?(name)
|
|
34
|
+
|
|
35
|
+
environment_names.include?(name) || environment_patterns.any? { |pattern| pattern.match?(name) }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bootprint
|
|
4
|
+
class Diagnosis
|
|
5
|
+
REPORT_SCHEMA_VERSION = 1
|
|
6
|
+
attr_reader :source, :target, :policy, :only, :minimum_severity
|
|
7
|
+
|
|
8
|
+
def initialize(source, target, policy: Policy.new, only: nil, minimum_severity: nil)
|
|
9
|
+
@source = source
|
|
10
|
+
@target = target
|
|
11
|
+
@policy = policy
|
|
12
|
+
@only = only
|
|
13
|
+
@minimum_severity = minimum_severity
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run
|
|
17
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
18
|
+
findings = Rules.evaluate(source, target, policy:, only:, minimum_severity:)
|
|
19
|
+
findings.concat(plugin_failure_findings(target))
|
|
20
|
+
duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1_000
|
|
21
|
+
Report.new(source:, target:, findings:, policy:, duration_ms: duration.round(3))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def plugin_failure_findings(snapshot)
|
|
27
|
+
warnings = snapshot.data.dig("capture", "warnings") || []
|
|
28
|
+
warnings.filter_map do |warning|
|
|
29
|
+
next unless warning["plugin"]
|
|
30
|
+
|
|
31
|
+
severity = policy.plugin_strict? ? :error : :warning
|
|
32
|
+
Rules::Finding.new(
|
|
33
|
+
rule_id: "plugin-capture-failure",
|
|
34
|
+
title: "Bootprint plugin failed",
|
|
35
|
+
category: :plugins,
|
|
36
|
+
severity:,
|
|
37
|
+
summary: "Plugin #{warning['plugin']} could not capture its data.",
|
|
38
|
+
cause: warning["message"],
|
|
39
|
+
impact: "Plugin-specific diagnostics are incomplete; the core snapshot remains valid.",
|
|
40
|
+
evidence: warning,
|
|
41
|
+
remediation: { "summary" => "Update, reconfigure, or disable the failing plugin.", "commands" => [], "files" => [] },
|
|
42
|
+
references: [], metadata: { "built_in" => true }, suppressed: false
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class Report
|
|
49
|
+
attr_reader :source, :target, :findings, :policy, :duration_ms
|
|
50
|
+
|
|
51
|
+
def initialize(source:, target:, findings:, policy:, duration_ms:)
|
|
52
|
+
@source = source
|
|
53
|
+
@target = target
|
|
54
|
+
@findings = findings.sort_by { |finding| [-Rules::SEVERITY_ORDER.fetch(finding.severity), finding.rule_id] }
|
|
55
|
+
@policy = policy
|
|
56
|
+
@duration_ms = duration_ms
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def blocking? = findings.any? { |finding| finding.blocking?(policy) }
|
|
60
|
+
|
|
61
|
+
def counts
|
|
62
|
+
Rules::Rule::SEVERITIES.to_h do |severity|
|
|
63
|
+
count = findings.count { |finding| finding.severity == severity && !finding.suppressed }
|
|
64
|
+
[severity.to_s, count]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def to_h
|
|
69
|
+
{
|
|
70
|
+
"schema_version" => Diagnosis::REPORT_SCHEMA_VERSION,
|
|
71
|
+
"source" => snapshot_metadata(source),
|
|
72
|
+
"target" => snapshot_metadata(target),
|
|
73
|
+
"findings" => findings.map(&:to_h),
|
|
74
|
+
"summary" => counts.merge("blocking" => blocking?),
|
|
75
|
+
"execution" => {
|
|
76
|
+
"bootprint_version" => VERSION,
|
|
77
|
+
"duration_ms" => duration_ms,
|
|
78
|
+
"network_requests" => 0,
|
|
79
|
+
"policy" => policy.path
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def snapshot_metadata(snapshot)
|
|
87
|
+
{
|
|
88
|
+
"name" => snapshot.name,
|
|
89
|
+
"schema_version" => snapshot.data["schema_version"],
|
|
90
|
+
"generated_at" => snapshot.data["generated_at"],
|
|
91
|
+
"bootprint_version" => snapshot.data["bootprint_version"]
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bootprint
|
|
4
|
+
class Diff
|
|
5
|
+
Change = Struct.new(:path, :local, :target, keyword_init: true) do
|
|
6
|
+
def to_h
|
|
7
|
+
{ "path" => path, "local" => local, "target" => target }
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
IGNORED_PATHS = %w[generated_at bootprint_version environment.name].freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :local, :target, :allowed_paths
|
|
14
|
+
|
|
15
|
+
def initialize(local, target, allowed_paths: [])
|
|
16
|
+
@local = local.is_a?(Snapshot) ? local.data : local
|
|
17
|
+
@target = target.is_a?(Snapshot) ? target.data : target
|
|
18
|
+
@allowed_paths = allowed_paths
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def changes
|
|
22
|
+
@changes ||= compare(local, target).reject { |change| ignored?(change.path) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def allowed?(path)
|
|
26
|
+
allowed_paths.any? { |pattern| File.fnmatch?(pattern, path, File::FNM_PATHNAME | File::FNM_EXTGLOB) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def compare(left, right, path = nil)
|
|
32
|
+
if left.is_a?(Hash) && right.is_a?(Hash)
|
|
33
|
+
(left.keys | right.keys).sort.flat_map do |key|
|
|
34
|
+
compare(left[key], right[key], [path, key].compact.join("."))
|
|
35
|
+
end
|
|
36
|
+
elsif left != right
|
|
37
|
+
[Change.new(path:, local: left, target: right)]
|
|
38
|
+
else
|
|
39
|
+
[]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def ignored?(path)
|
|
44
|
+
IGNORED_PATHS.include?(path) || path == "capture" || path.start_with?("capture.") || allowed?(path)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module Bootprint
|
|
8
|
+
module Docker
|
|
9
|
+
class Client
|
|
10
|
+
CAPTURE_SCRIPT = <<~'RUBY'
|
|
11
|
+
require "json"
|
|
12
|
+
require "rbconfig"
|
|
13
|
+
require "rubygems"
|
|
14
|
+
require "openssl"
|
|
15
|
+
require "psych"
|
|
16
|
+
packages = []
|
|
17
|
+
if File.file?("/var/lib/dpkg/status")
|
|
18
|
+
packages = File.read("/var/lib/dpkg/status").scan(/^Package:\s*(\S+)/).flatten
|
|
19
|
+
elsif File.file?("/lib/apk/db/installed")
|
|
20
|
+
packages = File.read("/lib/apk/db/installed").scan(/^P:(\S+)/).flatten
|
|
21
|
+
end
|
|
22
|
+
gems = Gem::Specification.map do |spec|
|
|
23
|
+
[spec.name, {
|
|
24
|
+
"version" => spec.version.to_s,
|
|
25
|
+
"platform" => spec.platform.to_s,
|
|
26
|
+
"native_extensions" => !spec.extensions.empty?,
|
|
27
|
+
"missing_extensions" => spec.respond_to?(:missing_extensions?) && spec.missing_extensions?,
|
|
28
|
+
"prerelease" => spec.version.prerelease?
|
|
29
|
+
}]
|
|
30
|
+
end.sort.to_h
|
|
31
|
+
output = {
|
|
32
|
+
"runtime" => {
|
|
33
|
+
"engine" => defined?(RUBY_ENGINE) ? RUBY_ENGINE : "ruby",
|
|
34
|
+
"engine_version" => defined?(RUBY_ENGINE_VERSION) ? RUBY_ENGINE_VERSION : RUBY_VERSION,
|
|
35
|
+
"ruby_version" => RUBY_VERSION,
|
|
36
|
+
"patchlevel" => RUBY_PATCHLEVEL,
|
|
37
|
+
"platform" => RUBY_PLATFORM,
|
|
38
|
+
"architecture" => RbConfig::CONFIG["arch"],
|
|
39
|
+
"host_os" => RbConfig::CONFIG["host_os"],
|
|
40
|
+
"host_cpu" => RbConfig::CONFIG["host_cpu"],
|
|
41
|
+
"description" => RUBY_DESCRIPTION,
|
|
42
|
+
"debug_build" => RbConfig::CONFIG["configure_args"].to_s.include?("--enable-debug"),
|
|
43
|
+
"supported" => Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.1.0")
|
|
44
|
+
},
|
|
45
|
+
"dependencies" => {
|
|
46
|
+
"toolchain" => {
|
|
47
|
+
"rubygems_version" => Gem::VERSION,
|
|
48
|
+
"bundler_version" => Gem.loaded_specs["bundler"]&.version&.to_s
|
|
49
|
+
},
|
|
50
|
+
"gems" => gems,
|
|
51
|
+
"lockfile" => { "platforms" => [] }
|
|
52
|
+
},
|
|
53
|
+
"native_libraries" => {
|
|
54
|
+
"openssl" => {
|
|
55
|
+
"compiled" => OpenSSL::OPENSSL_VERSION,
|
|
56
|
+
"runtime" => OpenSSL.const_defined?(:OPENSSL_LIBRARY_VERSION) ? OpenSSL::OPENSSL_LIBRARY_VERSION : OpenSSL::OPENSSL_VERSION
|
|
57
|
+
},
|
|
58
|
+
"libyaml" => Psych.libyaml_version.join("."),
|
|
59
|
+
"psych" => Psych::VERSION
|
|
60
|
+
},
|
|
61
|
+
"configuration" => {
|
|
62
|
+
"environment_variables" => ENV.keys.sort.to_h { |name| [name, true] },
|
|
63
|
+
"required_environment_variables" => [],
|
|
64
|
+
"optional_environment_variables" => [],
|
|
65
|
+
"rails" => {}
|
|
66
|
+
},
|
|
67
|
+
"filesystem" => {
|
|
68
|
+
"path_separator" => File::SEPARATOR,
|
|
69
|
+
"case_sensitive" => true,
|
|
70
|
+
"symlinks_supported" => File.respond_to?(:symlink),
|
|
71
|
+
"temporary_directory" => { "present" => File.directory?("/tmp"), "writable" => File.writable?("/tmp"), "path" => "/tmp" },
|
|
72
|
+
"required_directories" => { "current" => { "present" => File.directory?(Dir.pwd), "writable" => File.writable?(Dir.pwd), "path" => Dir.pwd } }
|
|
73
|
+
},
|
|
74
|
+
"operating_system" => {
|
|
75
|
+
"name" => RbConfig::CONFIG["host_os"],
|
|
76
|
+
"cpu" => RbConfig::CONFIG["host_cpu"],
|
|
77
|
+
"capabilities" => {
|
|
78
|
+
"make" => ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, "make")) },
|
|
79
|
+
"gcc" => ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, "gcc")) }
|
|
80
|
+
},
|
|
81
|
+
"ruby_headers" => { "present" => File.directory?(RbConfig::CONFIG["rubyhdrdir"].to_s) },
|
|
82
|
+
"system_packages" => packages.sort
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
STDOUT.write(JSON.generate(output))
|
|
86
|
+
RUBY
|
|
87
|
+
|
|
88
|
+
def available?
|
|
89
|
+
_out, _error, status = Open3.capture3("docker", "version", "--format", "{{.Client.Version}}")
|
|
90
|
+
status.success?
|
|
91
|
+
rescue Errno::ENOENT
|
|
92
|
+
false
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def capture(image, label: nil, privacy: :standard)
|
|
96
|
+
raise DockerError, "Docker is unavailable; install Docker and ensure its daemon is running" unless available?
|
|
97
|
+
|
|
98
|
+
metadata = image_metadata(image)
|
|
99
|
+
container_name = "bootprint-#{SecureRandom.hex(8)}"
|
|
100
|
+
stdout, stderr, status = Open3.capture3(
|
|
101
|
+
"docker", "run", "--name", container_name, "--rm", "--network", "none", "--read-only", "--entrypoint", "ruby",
|
|
102
|
+
image.to_s, "-e", CAPTURE_SCRIPT
|
|
103
|
+
)
|
|
104
|
+
raise DockerError, "Could not inspect image #{image}: #{Sanitizer.text(stderr.strip)}" unless status.success?
|
|
105
|
+
|
|
106
|
+
environment = JSON.parse(stdout)
|
|
107
|
+
environment["name"] = label || image.to_s
|
|
108
|
+
environment["container"] = metadata
|
|
109
|
+
Snapshot.new(
|
|
110
|
+
"schema_version" => Schema::CURRENT_VERSION,
|
|
111
|
+
"generated_at" => Time.now.utc.iso8601,
|
|
112
|
+
"bootprint_version" => VERSION,
|
|
113
|
+
"environment" => Sanitizer.recursive(environment, privacy:),
|
|
114
|
+
"capture" => { "privacy" => privacy.to_s, "source" => "docker", "network" => "disabled" }
|
|
115
|
+
)
|
|
116
|
+
rescue JSON::ParserError => error
|
|
117
|
+
raise DockerError, "Image #{image} returned invalid inspection data: #{error.message}"
|
|
118
|
+
ensure
|
|
119
|
+
cleanup_container(container_name) if container_name
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def image_metadata(image)
|
|
125
|
+
stdout, stderr, status = Open3.capture3("docker", "image", "inspect", image.to_s)
|
|
126
|
+
raise DockerError, "Docker image #{image} is not available locally: #{Sanitizer.text(stderr.strip)}" unless status.success?
|
|
127
|
+
|
|
128
|
+
details = JSON.parse(stdout).first || {}
|
|
129
|
+
config = details["Config"] || {}
|
|
130
|
+
{
|
|
131
|
+
"image" => image.to_s,
|
|
132
|
+
"image_id" => details["Id"],
|
|
133
|
+
"architecture" => details["Architecture"],
|
|
134
|
+
"os" => details["Os"],
|
|
135
|
+
"working_directory" => config["WorkingDir"],
|
|
136
|
+
"entrypoint" => Array(config["Entrypoint"]),
|
|
137
|
+
"command" => Array(config["Cmd"]),
|
|
138
|
+
"declared_environment_variables" => Array(config["Env"]).map { |entry| entry.split("=", 2).first }.sort
|
|
139
|
+
}
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def cleanup_container(name)
|
|
143
|
+
Open3.capture3("docker", "rm", "--force", name.to_s)
|
|
144
|
+
rescue Errno::ENOENT
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bootprint
|
|
4
|
+
class Doctor
|
|
5
|
+
def initialize(policy: Policy.new)
|
|
6
|
+
@policy = policy
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def run(snapshot = Snapshot.capture(label: "current"))
|
|
10
|
+
Diagnosis.new(snapshot, snapshot, policy: @policy).run
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bootprint
|
|
4
|
+
module Formatters
|
|
5
|
+
class Human
|
|
6
|
+
COLORS = { critical: 31, error: 31, warning: 33, info: 36 }.freeze
|
|
7
|
+
|
|
8
|
+
def initialize(report, color: false)
|
|
9
|
+
@report = report
|
|
10
|
+
@color = color && !ENV.key?("NO_COLOR")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def render
|
|
14
|
+
output = "Bootprint Diagnosis\nSource: #{@report.source.name}\nTarget: #{@report.target.name}\n"
|
|
15
|
+
active = @report.findings
|
|
16
|
+
if active.empty?
|
|
17
|
+
output << "\nNo findings at the selected severity.\n"
|
|
18
|
+
else
|
|
19
|
+
active.each { |finding| output << render_finding(finding) }
|
|
20
|
+
end
|
|
21
|
+
output << "\nSummary:\n"
|
|
22
|
+
@report.counts.each { |severity, count| output << format(" %-8s %d\n", severity, count) if count.positive? }
|
|
23
|
+
output << " blocking #{@report.blocking? ? 'yes' : 'no'}\n"
|
|
24
|
+
output
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def render_finding(finding)
|
|
30
|
+
label = finding.suppressed ? "SUPPRESSED" : finding.severity.to_s.upcase
|
|
31
|
+
heading = format("%-10s %s", colorize(label, finding.severity), finding.title)
|
|
32
|
+
body = "\n\n#{heading}\n #{finding.summary}\n"
|
|
33
|
+
body << "\n Why: #{finding.cause}\n" if finding.cause
|
|
34
|
+
body << " Impact: #{finding.impact}\n" if finding.impact
|
|
35
|
+
evidence = finding.evidence || {}
|
|
36
|
+
body << " Evidence: #{compact(evidence)}\n" unless evidence.empty?
|
|
37
|
+
remediation = finding.remediation || {}
|
|
38
|
+
if remediation["summary"]
|
|
39
|
+
body << "\n Recommended fix:\n #{remediation['summary']}\n"
|
|
40
|
+
Array(remediation["commands"]).each { |command| body << " $ #{command}\n" }
|
|
41
|
+
end
|
|
42
|
+
body << " Suppression: #{finding.suppression_reason}\n" if finding.suppressed
|
|
43
|
+
body
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def compact(value)
|
|
47
|
+
value.inspect.gsub(/\s+/, " ")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def colorize(text, severity)
|
|
51
|
+
@color ? "\e[#{COLORS.fetch(severity)}m#{text}\e[0m" : text
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bootprint
|
|
4
|
+
module Formatters
|
|
5
|
+
class Markdown
|
|
6
|
+
def initialize(report) = @report = report
|
|
7
|
+
|
|
8
|
+
def render
|
|
9
|
+
output = +"# Bootprint Diagnosis\n\n"
|
|
10
|
+
output << "| Source | Target | Blocking |\n|---|---|---|\n"
|
|
11
|
+
output << "| #{@report.source.name} | #{@report.target.name} | #{@report.blocking? ? 'Yes' : 'No'} |\n\n"
|
|
12
|
+
@report.findings.each do |finding|
|
|
13
|
+
output << "## #{finding.severity.to_s.upcase}: #{finding.title}\n\n"
|
|
14
|
+
output << "#{finding.summary}\n\n"
|
|
15
|
+
output << "**Impact:** #{finding.impact}\n\n" if finding.impact
|
|
16
|
+
if finding.remediation&.fetch("summary", nil)
|
|
17
|
+
output << "**Recommended fix:** #{finding.remediation['summary']}\n\n"
|
|
18
|
+
Array(finding.remediation["commands"]).each { |command| output << "```console\n#{command}\n```\n\n" }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
output << "## Summary\n\n"
|
|
22
|
+
output << @report.counts.map { |severity, count| "- #{severity}: #{count}" }.join("\n") << "\n"
|
|
23
|
+
output
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|