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.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/.bootprint.yml.example +37 -0
  3. data/ARCHITECTURE.md +46 -0
  4. data/CHANGELOG.md +26 -0
  5. data/CODE_OF_CONDUCT.md +7 -0
  6. data/CONTRIBUTING.md +28 -0
  7. data/LICENSE +21 -0
  8. data/README.md +422 -0
  9. data/RELEASE.md +78 -0
  10. data/ROADMAP.md +15 -0
  11. data/SECURITY.md +47 -0
  12. data/assets/branding/README.md +24 -0
  13. data/assets/branding/bootprint-logo-128.png +0 -0
  14. data/assets/branding/bootprint-logo-512.png +0 -0
  15. data/assets/branding/bootprint-logo-64.png +0 -0
  16. data/assets/branding/bootprint-logo.png +0 -0
  17. data/docs/capturing.md +9 -0
  18. data/docs/ci.md +21 -0
  19. data/docs/comparing.md +22 -0
  20. data/docs/custom-rules.md +7 -0
  21. data/docs/docker.md +7 -0
  22. data/docs/findings.md +7 -0
  23. data/docs/installation.md +7 -0
  24. data/docs/maintainer-setup.md +54 -0
  25. data/docs/plugins.md +7 -0
  26. data/docs/policy.md +9 -0
  27. data/docs/privacy.md +7 -0
  28. data/docs/quick-start.md +9 -0
  29. data/docs/rails.md +13 -0
  30. data/docs/snapshot-schema.md +9 -0
  31. data/docs/troubleshooting.md +8 -0
  32. data/exe/bootprint +6 -0
  33. data/lib/bootprint/analysis.rb +13 -0
  34. data/lib/bootprint/cli.rb +458 -0
  35. data/lib/bootprint/collectors/environment.rb +21 -0
  36. data/lib/bootprint/collectors/filesystem.rb +40 -0
  37. data/lib/bootprint/collectors/gems.rb +96 -0
  38. data/lib/bootprint/collectors/libraries.rb +75 -0
  39. data/lib/bootprint/collectors/operating_system.rb +50 -0
  40. data/lib/bootprint/collectors/rails.rb +97 -0
  41. data/lib/bootprint/collectors/runtime.rb +34 -0
  42. data/lib/bootprint/collectors/toolchain.rb +23 -0
  43. data/lib/bootprint/configuration.rb +38 -0
  44. data/lib/bootprint/diagnosis.rb +95 -0
  45. data/lib/bootprint/diff.rb +47 -0
  46. data/lib/bootprint/docker.rb +149 -0
  47. data/lib/bootprint/doctor.rb +13 -0
  48. data/lib/bootprint/errors.rb +9 -0
  49. data/lib/bootprint/formatters/human.rb +55 -0
  50. data/lib/bootprint/formatters/json.rb +12 -0
  51. data/lib/bootprint/formatters/markdown.rb +27 -0
  52. data/lib/bootprint/formatters/sarif.rb +54 -0
  53. data/lib/bootprint/formatters.rb +22 -0
  54. data/lib/bootprint/initializer_profiler.rb +93 -0
  55. data/lib/bootprint/plugins.rb +90 -0
  56. data/lib/bootprint/policy.rb +191 -0
  57. data/lib/bootprint/rails_state.rb +17 -0
  58. data/lib/bootprint/railtie.rb +36 -0
  59. data/lib/bootprint/rules/builtin.rb +383 -0
  60. data/lib/bootprint/rules/finding.rb +40 -0
  61. data/lib/bootprint/rules/registry.rb +20 -0
  62. data/lib/bootprint/rules/rule.rb +153 -0
  63. data/lib/bootprint/rules.rb +58 -0
  64. data/lib/bootprint/sanitizer.rb +105 -0
  65. data/lib/bootprint/schema.rb +111 -0
  66. data/lib/bootprint/security/auditor.rb +63 -0
  67. data/lib/bootprint/snapshot.rb +122 -0
  68. data/lib/bootprint/version.rb +5 -0
  69. data/lib/bootprint.rb +38 -0
  70. data/lib/tasks/bootprint.rake +18 -0
  71. 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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bootprint
4
+ class Error < StandardError; end
5
+ class InvalidSnapshotError < Error; end
6
+ class ConfigurationError < Error; end
7
+ class DockerError < Error; end
8
+ class PluginError < Error; end
9
+ 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,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Bootprint
6
+ module Formatters
7
+ class JSON
8
+ def initialize(report) = @report = report
9
+ def render = "#{::JSON.pretty_generate(@report.to_h)}\n"
10
+ end
11
+ end
12
+ 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