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,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Bootprint
6
+ module Formatters
7
+ class Sarif
8
+ LEVELS = { critical: "error", error: "error", warning: "warning", info: "note" }.freeze
9
+
10
+ def initialize(report) = @report = report
11
+
12
+ def render
13
+ rules = @report.findings.map(&:rule_id).uniq.map do |id|
14
+ finding = @report.findings.find { |candidate| candidate.rule_id == id }
15
+ {
16
+ "id" => id,
17
+ "name" => id.tr("-", "_"),
18
+ "shortDescription" => { "text" => finding.title },
19
+ "help" => { "text" => finding.remediation&.fetch("summary", "Review this environment difference.") }
20
+ }
21
+ end
22
+ results = @report.findings.map do |finding|
23
+ result = {
24
+ "ruleId" => finding.rule_id,
25
+ "level" => finding.suppressed ? "none" : LEVELS.fetch(finding.severity),
26
+ "message" => { "text" => [finding.summary, finding.impact, finding.remediation&.fetch("summary", nil)].compact.join(" ") },
27
+ "properties" => { "category" => finding.category.to_s, "evidence" => finding.evidence, "suppressed" => finding.suppressed }
28
+ }
29
+ location = physical_location(finding.source_location)
30
+ result["locations"] = [{ "physicalLocation" => location }] if location
31
+ result["suppressions"] = [{ "kind" => "external", "justification" => finding.suppression_reason }] if finding.suppressed
32
+ result
33
+ end
34
+ driver = { "name" => "Bootprint", "version" => VERSION, "rules" => rules }
35
+ run = { "tool" => { "driver" => driver }, "results" => results }
36
+ output = ::JSON.pretty_generate(
37
+ "version" => "2.1.0",
38
+ "$schema" => "https://json.schemastore.org/sarif-2.1.0.json",
39
+ "runs" => [run]
40
+ )
41
+ "#{output}\n"
42
+ end
43
+
44
+ private
45
+
46
+ def physical_location(location)
47
+ return unless location && File.file?(location["path"])
48
+
49
+ region = location["line"] ? { "startLine" => location["line"] } : nil
50
+ { "artifactLocation" => { "uri" => location["path"] }, "region" => region }.compact
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "formatters/human"
4
+ require_relative "formatters/json"
5
+ require_relative "formatters/sarif"
6
+ require_relative "formatters/markdown"
7
+
8
+ module Bootprint
9
+ module Formatters
10
+ module_function
11
+
12
+ def render(format, report, color: false)
13
+ case format.to_s
14
+ when "human" then Human.new(report, color:).render
15
+ when "json" then JSON.new(report).render
16
+ when "sarif" then Sarif.new(report).render
17
+ when "markdown" then Markdown.new(report).render
18
+ else raise ConfigurationError, "Unknown format #{format.inspect}; use human, json, sarif, or markdown"
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Bootprint
6
+ module InitializerProfiler
7
+ NETWORK_METHODS = %i[connect connect_nonblock tcp start open].freeze
8
+
9
+ module_function
10
+
11
+ def install!
12
+ return unless Bootprint.configuration.profile_boot
13
+ return unless defined?(Rails::Initializable::Initializer)
14
+ return if Rails::Initializable::Initializer.ancestors.include?(Instrumentation)
15
+
16
+ ENV.singleton_class.prepend(EnvironmentProbe) unless ENV.singleton_class.ancestors.include?(EnvironmentProbe)
17
+ Rails::Initializable::Initializer.prepend(Instrumentation)
18
+ end
19
+
20
+ def record_missing_environment(name)
21
+ collector = Thread.current[:bootprint_missing_environment]
22
+ collector << name.to_s if collector
23
+ end
24
+
25
+ def config_fingerprint
26
+ return {} unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
27
+
28
+ config = Rails.application.config
29
+ %i[eager_load cache_classes time_zone cache_store session_store].to_h do |name|
30
+ value = config.public_send(name) if config.respond_to?(name)
31
+ [name.to_s, value.is_a?(Array) ? value.first.to_s : value.to_s]
32
+ rescue StandardError
33
+ [name.to_s, nil]
34
+ end
35
+ end
36
+
37
+ module EnvironmentProbe
38
+ def [](name)
39
+ present = key?(name)
40
+ value = super
41
+ Bootprint::InitializerProfiler.record_missing_environment(name) unless present
42
+ value
43
+ end
44
+
45
+ def fetch(name, *arguments, &)
46
+ Bootprint::InitializerProfiler.record_missing_environment(name) unless key?(name)
47
+ super
48
+ end
49
+ end
50
+
51
+ module Instrumentation
52
+ def run(*args)
53
+ start_order = Bootprint::RailsState.initializers&.length.to_i + 1
54
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
55
+ constants_before = Object.constants.to_set
56
+ config_before = Bootprint::InitializerProfiler.config_fingerprint
57
+ missing_environment = []
58
+ Thread.current[:bootprint_missing_environment] = missing_environment
59
+ network_operation = false
60
+ tracer = TracePoint.new(:call, :c_call) do |trace|
61
+ owner = trace.defined_class.to_s
62
+ network_operation = true if NETWORK_METHODS.include?(trace.method_id) && owner.match?(/Socket|Net::HTTP|OpenURI/)
63
+ end
64
+ tracer.enable
65
+ exception = nil
66
+ result = super
67
+ result
68
+ rescue Exception => error # rubocop:disable Lint/RescueException -- record boot failures, then re-raise
69
+ exception = { "class" => error.class.name, "message" => Sanitizer.text(error.message) }
70
+ raise
71
+ ensure
72
+ tracer&.disable
73
+ duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1_000
74
+ loaded = (Object.constants.to_set - constants_before).map(&:to_s).sort
75
+ config_after = Bootprint::InitializerProfiler.config_fingerprint
76
+ mutations = config_after.keys.reject { |key| config_before[key] == config_after[key] }
77
+ Bootprint::RailsState.record_initializer(
78
+ "name" => name.to_s,
79
+ "start_order" => start_order,
80
+ "completion_order" => Bootprint::RailsState.initializers&.length.to_i + 1,
81
+ "duration_ms" => duration.round(3),
82
+ "exception" => exception,
83
+ "loaded_constants" => loaded.take(100),
84
+ "missing_environment_variables" => missing_environment.uniq.sort,
85
+ "network_operation_heuristic" => network_operation,
86
+ "configuration_mutations" => mutations,
87
+ "configuration_mutation_heuristic" => !mutations.empty?
88
+ )
89
+ Thread.current[:bootprint_missing_environment] = nil
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bootprint
4
+ module Plugin
5
+ API_VERSION = "1"
6
+
7
+ module_function
8
+
9
+ def api_version(value = nil)
10
+ return API_VERSION unless value
11
+ raise PluginError, "Plugin requires API #{value}; Bootprint provides #{API_VERSION}" unless value.to_s == API_VERSION
12
+
13
+ API_VERSION
14
+ end
15
+ end
16
+
17
+ module Plugins
18
+ Definition = Struct.new(:name, :collector_class, :rules_module, :registration_error, keyword_init: true) do
19
+ def collector(value = nil)
20
+ self.collector_class = value if value
21
+ collector_class
22
+ end
23
+
24
+ def rules(value = nil)
25
+ self.rules_module = value if value
26
+ rules_module
27
+ end
28
+ end
29
+
30
+ class << self
31
+ def register(name, &)
32
+ definition = Definition.new(name: name.to_s)
33
+ definition.instance_eval(&)
34
+ install_rules(definition)
35
+ registry.reject! { |plugin| plugin.name == definition.name }
36
+ registry << definition
37
+ definition
38
+ rescue StandardError => error
39
+ definition ||= Definition.new(name: name.to_s)
40
+ definition.registration_error = Sanitizer.text("#{error.class}: #{error.message}")
41
+ registry << definition
42
+ definition
43
+ end
44
+
45
+ def capture(warnings:, strict: false)
46
+ registry.each_with_object({}) do |plugin, result|
47
+ if plugin.registration_error
48
+ handle_failure(plugin, plugin.registration_error, warnings, strict)
49
+ next
50
+ end
51
+ next unless plugin.collector_class
52
+
53
+ begin
54
+ value = plugin.collector_class.respond_to?(:capture) ? plugin.collector_class.capture : plugin.collector_class.new.capture
55
+ result[plugin.name] = Sanitizer.recursive(Schema.stringify(value))
56
+ rescue StandardError => error
57
+ handle_failure(plugin, "#{error.class}: #{error.message}", warnings, strict)
58
+ end
59
+ end
60
+ end
61
+
62
+ def all = registry.dup
63
+ def reset! = @registry = []
64
+
65
+ private
66
+
67
+ def registry = @registry ||= []
68
+
69
+ def install_rules(definition)
70
+ rules = definition.rules_module
71
+ return unless rules
72
+
73
+ if rules.respond_to?(:install)
74
+ rules.install
75
+ elsif rules.respond_to?(:register)
76
+ rules.register
77
+ else
78
+ raise PluginError, "#{definition.name} rules must respond to install or register"
79
+ end
80
+ end
81
+
82
+ def handle_failure(plugin, message, warnings, strict)
83
+ sanitized = Sanitizer.text(message)
84
+ raise PluginError, "Plugin #{plugin.name} failed: #{sanitized}" if strict
85
+
86
+ warnings << { "plugin" => plugin.name, "message" => sanitized, "severity" => "warning" }
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Bootprint
6
+ class Policy
7
+ MODES = %w[permissive strict].freeze
8
+ SEVERITIES = %w[info warning error critical].freeze
9
+ TOP_LEVEL_KEYS = %w[version minimum_severity fail_on ignore allow rules redaction mode expected_platforms plugins].freeze
10
+
11
+ attr_reader :path, :data
12
+
13
+ def self.load(path = nil)
14
+ return new(nil, {}) unless path
15
+
16
+ absolute = File.expand_path(path)
17
+ raise ConfigurationError, "#{absolute}: file not found" unless File.file?(absolute)
18
+
19
+ parsed = YAML.safe_load_file(absolute, permitted_classes: [], aliases: false) || {}
20
+ new(absolute, parsed)
21
+ rescue Psych::SyntaxError => error
22
+ raise ConfigurationError, "#{absolute}:#{error.line}:#{error.column}: #{error.problem}"
23
+ rescue Psych::Exception => error
24
+ raise ConfigurationError, "#{absolute}: #{error.message}"
25
+ end
26
+
27
+ def initialize(path = nil, data = {})
28
+ @path = path
29
+ @data = Schema.stringify(data)
30
+ validate!
31
+ end
32
+
33
+ def minimum_severity
34
+ (data["minimum_severity"] || (strict? ? "warning" : "info")).to_sym
35
+ end
36
+
37
+ def fail_on
38
+ Array(data["fail_on"] || (strict? ? %w[warning error critical] : %w[error critical])).map(&:to_s)
39
+ end
40
+
41
+ def expected_platforms
42
+ Array(data["expected_platforms"]).map(&:to_s)
43
+ end
44
+
45
+ def optional_environment_variables
46
+ allow = data["allow"]
47
+ return [] unless allow.is_a?(Hash)
48
+
49
+ Array(allow["environment_variables"]).map(&:to_s)
50
+ end
51
+
52
+ def optional_environment_variable?(name)
53
+ optional_environment_variables.include?(name.to_s)
54
+ end
55
+
56
+ def allowed_paths
57
+ allow = data["allow"]
58
+ if allow.is_a?(Array)
59
+ allow.map(&:to_s)
60
+ else
61
+ Array(allow.is_a?(Hash) ? allow["paths"] : nil).map(&:to_s)
62
+ end
63
+ end
64
+
65
+ def redaction_patterns
66
+ Array(data.dig("redaction", "patterns")).map(&:to_s)
67
+ end
68
+
69
+ def redaction_safe_list
70
+ Array(data.dig("redaction", "safe_list")).map(&:to_s)
71
+ end
72
+
73
+ def plugin_strict?
74
+ strict? || data.dig("plugins", "strict") == true
75
+ end
76
+
77
+ def strict? = data.fetch("mode", "permissive") == "strict"
78
+
79
+ def ignored?(rule_id)
80
+ Array(data["ignore"]).map(&:to_s).include?(rule_id.to_s)
81
+ end
82
+
83
+ def disabled?(rule_id)
84
+ data.dig("rules", rule_id.to_s, "enabled") == false
85
+ end
86
+
87
+ def severity_for(rule_id, default)
88
+ (data.dig("rules", rule_id.to_s, "severity") || default).to_sym
89
+ end
90
+
91
+ def suppression_reason(rule_id)
92
+ return "ignored by policy" if ignored?(rule_id)
93
+ return "disabled by policy" if disabled?(rule_id)
94
+
95
+ nil
96
+ end
97
+
98
+ def apply!
99
+ Bootprint.configuration.optional_environment_names |= optional_environment_variables
100
+ Bootprint.configuration.expected_platforms = expected_platforms
101
+ Bootprint.configuration.redaction_patterns |= redaction_patterns
102
+ Bootprint.configuration.redaction_safe_list |= redaction_safe_list
103
+ Bootprint.configuration.plugin_strict = plugin_strict?
104
+ self
105
+ end
106
+
107
+ def explain
108
+ lines = []
109
+ lines << "Policy: #{path || '(built-in defaults)'}"
110
+ lines << "Mode: #{strict? ? 'strict' : 'permissive'}"
111
+ lines << "Minimum reported severity: #{minimum_severity}"
112
+ lines << "Blocking severities: #{fail_on.join(', ')}"
113
+ lines << "Ignored rules: #{Array(data['ignore']).join(', ')}" unless Array(data["ignore"]).empty?
114
+ lines << "Optional environment variables: #{optional_environment_variables.join(', ')}" unless optional_environment_variables.empty?
115
+ lines << "Expected platforms: #{expected_platforms.join(', ')}" unless expected_platforms.empty?
116
+ lines << "Rule overrides: #{Array(data['rules']&.keys).join(', ')}" if data["rules"].is_a?(Hash)
117
+ "#{lines.join("\n")}\n"
118
+ end
119
+
120
+ private
121
+
122
+ def validate!
123
+ error("policy root must be a mapping") unless data.is_a?(Hash)
124
+ unknown = data.keys - TOP_LEVEL_KEYS
125
+ error("unknown keys: #{unknown.join(', ')}", unknown.first) unless unknown.empty?
126
+ error("version must equal 1", "version") unless data.fetch("version", 1) == 1
127
+ validate_severity(data["minimum_severity"], "minimum_severity") if data["minimum_severity"]
128
+ unless Array(data["fail_on"]).all? { |severity| SEVERITIES.include?(severity.to_s) }
129
+ error("fail_on contains an invalid severity", "fail_on")
130
+ end
131
+ mode = data.fetch("mode", "permissive")
132
+ error("mode must be strict or permissive", "mode") unless MODES.include?(mode)
133
+ error("ignore must be an array", "ignore") if data.key?("ignore") && !data["ignore"].is_a?(Array)
134
+ error("rules must be a mapping", "rules") if data.key?("rules") && !data["rules"].is_a?(Hash)
135
+ validate_rule_overrides
136
+ validate_allow
137
+ validate_redaction
138
+ end
139
+
140
+ def validate_rule_overrides
141
+ return unless data["rules"].is_a?(Hash)
142
+
143
+ data["rules"].each do |id, settings|
144
+ error("rule #{id} must be a mapping", id) unless settings.is_a?(Hash)
145
+ unknown = settings.keys - %w[enabled severity]
146
+ error("rule #{id} has unknown settings: #{unknown.join(', ')}", id) unless unknown.empty?
147
+ validate_severity(settings["severity"], id) if settings["severity"]
148
+ error("rule #{id} enabled must be true or false", id) if settings.key?("enabled") && ![true, false].include?(settings["enabled"])
149
+ end
150
+ end
151
+
152
+ def validate_allow
153
+ allow = data["allow"]
154
+ return if allow.nil? || allow.is_a?(Array)
155
+
156
+ error("allow must be a mapping or legacy array", "allow") unless allow.is_a?(Hash)
157
+ unknown = allow.keys - %w[environment_variables paths]
158
+ error("allow has unknown settings: #{unknown.join(', ')}", "allow") unless unknown.empty?
159
+ allow.each { |key, value| error("allow.#{key} must be an array", key) unless value.is_a?(Array) }
160
+ end
161
+
162
+ def validate_redaction
163
+ redaction = data["redaction"]
164
+ return unless redaction
165
+
166
+ error("redaction must be a mapping", "redaction") unless redaction.is_a?(Hash)
167
+ unknown = redaction.keys - %w[patterns safe_list]
168
+ error("redaction has unknown settings: #{unknown.join(', ')}", "redaction") unless unknown.empty?
169
+ redaction.each do |key, value|
170
+ error("redaction.#{key} must be an array", key) unless value.is_a?(Array) && value.all?(String)
171
+ end
172
+ end
173
+
174
+ def validate_severity(value, key)
175
+ error("#{key} must be one of #{SEVERITIES.join(', ')}", key) unless SEVERITIES.include?(value.to_s)
176
+ end
177
+
178
+ def error(message, key = nil)
179
+ location = path || ".bootprint.yml"
180
+ line = key && source_line(key)
181
+ raise ConfigurationError, "#{location}#{":#{line}" if line}: #{message}"
182
+ end
183
+
184
+ def source_line(key)
185
+ return unless path && File.file?(path)
186
+
187
+ pattern = /^\s*#{Regexp.escape(key.to_s)}\s*:/
188
+ File.readlines(path, encoding: "UTF-8").index { |line| line.match?(pattern) }&.+(1)
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bootprint
4
+ module RailsState
5
+ class << self
6
+ attr_reader :initializers
7
+
8
+ def record_initializer(attributes)
9
+ (@initializers ||= []) << Schema.stringify(attributes)
10
+ end
11
+
12
+ def reset!
13
+ @initializers = []
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+ require_relative "rails_state"
5
+ require_relative "initializer_profiler"
6
+
7
+ Bootprint::InitializerProfiler.install!
8
+
9
+ module Bootprint
10
+ class Railtie < Rails::Railtie
11
+ rake_tasks do
12
+ load File.expand_path("../tasks/bootprint.rake", __dir__)
13
+ end
14
+
15
+ initializer "bootprint.observe_initializers", before: :load_config_initializers do
16
+ next unless ENV["BOOTPRINT_INSPECT"] == "1" || Bootprint.configuration.profile_boot
17
+ next if Bootprint.configuration.profile_boot
18
+
19
+ Bootprint::RailsState.reset!
20
+ ActiveSupport::Notifications.subscribe("load_config_initializer.railties") do |*args|
21
+ event = ActiveSupport::Notifications::Event.new(*args)
22
+ path = event.payload[:initializer]
23
+ name = path.respond_to?(:to_path) ? path.to_path : path.to_s
24
+ Bootprint::RailsState.record_initializer(
25
+ "name" => name,
26
+ "start_order" => Bootprint::RailsState.initializers.length + 1,
27
+ "completion_order" => Bootprint::RailsState.initializers.length + 1,
28
+ "duration_ms" => event.duration.round(3),
29
+ "exception" => event.payload[:exception] && { "class" => event.payload[:exception].first },
30
+ "loaded_constants" => [],
31
+ "network_operation_heuristic" => false
32
+ )
33
+ end
34
+ end
35
+ end
36
+ end