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,383 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubygems"
4
+
5
+ module Bootprint
6
+ module Rules
7
+ module Builtin
8
+ module_function
9
+
10
+ def install
11
+ runtime_rules
12
+ dependency_rules
13
+ native_library_rules
14
+ configuration_rules
15
+ filesystem_rules
16
+ initializer_rules
17
+ end
18
+
19
+ def runtime_rules
20
+ rule("ruby-version-drift", "Ruby version mismatch", :runtime, :error,
21
+ cause: "The environments select different Ruby releases.",
22
+ impact: "Language behavior, standard libraries, and native-extension ABIs may differ.",
23
+ fix: "Pin the same Ruby version in local, container, and CI configuration.",
24
+ commands: ["ruby --version"], files: [".ruby-version"], location: ".ruby-version") do |source, target|
25
+ difference(source, target, "runtime.ruby_version")
26
+ end
27
+ rule("ruby-engine-drift", "Ruby engine mismatch", :runtime, :critical,
28
+ impact: "MRI, JRuby, and TruffleRuby have different runtime and extension compatibility.",
29
+ fix: "Use the same Ruby engine in every environment.", files: [".ruby-version"], location: ".ruby-version") do |source, target|
30
+ difference(source, target, "runtime.engine")
31
+ end
32
+ rule("ruby-patch-level-drift", "Ruby patch-level drift", :runtime, :warning,
33
+ impact: "Patch releases can include security, parser, and native-runtime changes.",
34
+ fix: "Update the older environment to the pinned Ruby patch release.", files: [".ruby-version"], location: ".ruby-version") do |source, target|
35
+ difference(source, target, "runtime.patchlevel")
36
+ end
37
+ rule("architecture-mismatch", "CPU architecture mismatch", :runtime, :critical,
38
+ impact: "Compiled gems cannot be reused across incompatible CPU architectures.",
39
+ fix: "Build gems on the deployment architecture or use a matching multi-architecture image.") do |source, target|
40
+ difference(source, target, "runtime.architecture")
41
+ end
42
+ rule("operating-system-mismatch", "Operating-system mismatch", :runtime, :warning,
43
+ impact: "System libraries, path behavior, and available native packages can differ.",
44
+ fix: "Develop in a container or VM matching the deployment operating system.") do |source, target|
45
+ difference(source, target, "operating_system.name")
46
+ end
47
+ rule("unsupported-ruby-version", "Unsupported Ruby version", :runtime, :critical,
48
+ cause: "The target Ruby predates Bootprint's supported runtime floor.",
49
+ impact: "The runtime may lack security fixes and required language APIs.",
50
+ fix: "Upgrade the target to a maintained Ruby release.", references: ["https://www.ruby-lang.org/en/downloads/branches/"]) do |_source, target|
51
+ value = dig(target, "runtime.ruby_version")
52
+ value && Gem::Version.new(value) < Gem::Version.new("3.1.0") && { "target" => value, "minimum" => "3.1.0" }
53
+ rescue ArgumentError
54
+ false
55
+ end
56
+ rule("debug-ruby-build", "Debug Ruby build detected", :runtime, :warning,
57
+ impact: "Debug runtimes can be substantially slower and differ in assertion behavior.",
58
+ fix: "Use a standard release build for production.") do |_source, target|
59
+ dig(target, "runtime.debug_build") == true && { "description" => dig(target, "runtime.description") }
60
+ end
61
+ end
62
+
63
+ def dependency_rules
64
+ rule("bundler-version-drift", "Bundler version mismatch", :dependencies, :warning,
65
+ impact: "Dependency resolution and lockfile serialization may change.",
66
+ fix: "Install and invoke the Bundler version recorded in Gemfile.lock.",
67
+ commands: ["gem install bundler -v <locked-version>", "bundle _<locked-version>_ install"], files: ["Gemfile.lock"], location: "Gemfile.lock") do |source, target|
68
+ difference(source, target, "dependencies.toolchain.bundler_version")
69
+ end
70
+ rule("rubygems-version-drift", "RubyGems version mismatch", :dependencies, :warning,
71
+ impact: "Platform selection and gem installation behavior may differ.",
72
+ fix: "Align RubyGems versions or use the Ruby distribution's supported version.") do |source, target|
73
+ difference(source, target, "dependencies.toolchain.rubygems_version")
74
+ end
75
+ rule("lockfile-platform-drift", "Lockfile platform sets differ", :dependencies, :error,
76
+ impact: "Bundler may resolve different gem variants in deployment.",
77
+ fix: "Add every deployment platform and regenerate the lockfile.",
78
+ commands: ["bundle lock --add-platform <platform>", "bundle install"], files: ["Gemfile.lock"], location: "Gemfile.lock") do |source, target|
79
+ difference(source, target, "dependencies.lockfile.platforms")
80
+ end
81
+ rule("missing-lockfile-platform", "Deployment platform is absent from Gemfile.lock", :dependencies, :error,
82
+ impact: "Bundler may resolve dependencies during deployment or reject the bundle.",
83
+ fix: "Add the deployment platform to the lockfile and rebuild the bundle.",
84
+ commands: ["bundle lock --add-platform <required-platform>", "bundle install"], files: ["Gemfile.lock"], location: "Gemfile.lock") do |_source, target, policy|
85
+ platforms = Array(dig(target, "dependencies.lockfile.platforms"))
86
+ required = policy.expected_platforms
87
+ required = [dig(target, "runtime.platform")].compact if required.empty?
88
+ missing = required.reject { |platform| platform_covered?(platforms, platform) }
89
+ !missing.empty? && { "current_platforms" => platforms, "required_platforms" => missing }
90
+ end
91
+ rule("gem-version-drift", "Gem version drift", :dependencies, :warning,
92
+ impact: "Application behavior can differ even when Ruby itself matches.",
93
+ fix: "Use the same committed Gemfile.lock and run bundle install in deployment.", files: ["Gemfile.lock"], location: "Gemfile.lock") do |source, target|
94
+ source_gems = dig(source, "dependencies.gems") || {}
95
+ target_gems = dig(target, "dependencies.gems") || {}
96
+ changes = (source_gems.keys | target_gems.keys).filter_map do |name|
97
+ left = source_gems.dig(name, "version")
98
+ right = target_gems.dig(name, "version")
99
+ { "gem" => name, "source" => left, "target" => right } if left != right
100
+ end
101
+ !changes.empty? && { "changes" => changes }
102
+ end
103
+ rule("git-dependency-drift", "Git-sourced dependency drift", :dependencies, :error,
104
+ impact: "A branch or moving Git reference can resolve different code over time.",
105
+ fix: "Pin Git dependencies to immutable commit SHAs and commit Gemfile.lock.", files: %w[Gemfile Gemfile.lock], location: "Gemfile.lock") do |source, target|
106
+ difference(source, target, "dependencies.lockfile.git_sources")
107
+ end
108
+ rule("path-dependency-in-deployment", "Path dependency detected in deployment", :dependencies, :error,
109
+ impact: "The referenced local path may not exist in CI or production.",
110
+ fix: "Publish the dependency, use a Git source, or vendor it into a stable deployment path.", files: %w[Gemfile Gemfile.lock], location: "Gemfile") do |_source, target|
111
+ paths = Array(dig(target, "dependencies.lockfile.path_sources"))
112
+ deployment = production?(target) || target.key?("container") || dig(target, "operating_system.ci")
113
+ deployment && !paths.empty? && { "paths" => paths.map { |path| Sanitizer.path(path) } }
114
+ end
115
+ rule("prerelease-gem-in-production", "Prerelease gem used in production", :dependencies, :warning,
116
+ impact: "Prerelease gems may change without normal compatibility guarantees.",
117
+ fix: "Pin a stable gem release or explicitly document the production exception.", files: ["Gemfile.lock"], location: "Gemfile.lock") do |_source, target|
118
+ next false unless production?(target)
119
+
120
+ gems = selected_gems(target) { |_name, spec| spec["prerelease"] == true }
121
+ !gems.empty? && { "gems" => gems }
122
+ end
123
+ rule("yanked-gem-version", "Yanked gem version detected", :dependencies, :critical,
124
+ impact: "Fresh installations may fail because the exact artifact is no longer served.",
125
+ fix: "Upgrade to an available release and regenerate Gemfile.lock.", files: ["Gemfile.lock"], location: "Gemfile.lock") do |_source, target|
126
+ gems = selected_gems(target) { |_name, spec| spec["yanked"] == true || spec["available"] == false }
127
+ !gems.empty? && { "gems" => gems, "source" => "captured metadata or plugin" }
128
+ end
129
+ rule("missing-native-extension", "Missing native extension", :dependencies, :critical,
130
+ impact: "The gem cannot load its compiled code in the target environment.",
131
+ fix: "Rebuild the extension in the target environment.", commands: ["gem pristine <gem>", "bundle pristine <gem>"], files: ["Gemfile.lock"], location: "Gemfile.lock") do |_source, target|
132
+ gems = selected_gems(target) { |_name, spec| spec["missing_extensions"] == true }
133
+ !gems.empty? && { "gems" => gems }
134
+ end
135
+ end
136
+
137
+ def native_library_rules
138
+ library_rule("openssl-incompatibility", "OpenSSL incompatibility", "openssl", :critical,
139
+ "Use Ruby builds linked to a compatible OpenSSL family in every environment.")
140
+ library_rule("libyaml-mismatch", "libyaml mismatch", "libyaml", :error,
141
+ "Align libyaml packages and rebuild Ruby or Psych.")
142
+ library_rule("sqlite-version-mismatch", "SQLite version mismatch", "sqlite.runtime", :error,
143
+ "Install the same SQLite client library and rebuild the sqlite3 gem.")
144
+ library_rule("postgresql-client-mismatch", "PostgreSQL client mismatch", "postgresql.client", :error,
145
+ "Install the target PostgreSQL client development package and rebuild pg.")
146
+ library_rule("mysql-client-mismatch", "MySQL client mismatch", "mysql.client", :error,
147
+ "Install the matching MySQL client development package and rebuild mysql2.")
148
+ library_rule("libc-mismatch", "C library mismatch", "libc.family", :critical,
149
+ "Build native gems against the target libc or use a matching base image.")
150
+ rule("missing-compiler-toolchain", "Compiler toolchain is unavailable", :native, :error,
151
+ impact: "Native gems without a precompiled variant cannot be installed.",
152
+ fix: "Install a C compiler and build tools in the image build stage.") do |_source, target|
153
+ native = selected_gems(target) { |_name, spec| spec["native_extensions"] == true }
154
+ capabilities = dig(target, "operating_system.capabilities") || {}
155
+ missing = %w[make gcc].reject { |tool| capabilities[tool] }
156
+ !native.empty? && !missing.empty? && { "native_gems" => native, "missing_tools" => missing }
157
+ end
158
+ rule("missing-system-headers", "Ruby system headers are unavailable", :native, :error,
159
+ impact: "Native extension compilation may fail before linking.",
160
+ fix: "Install the Ruby development/header package for the target runtime.") do |_source, target|
161
+ headers = dig(target, "operating_system.ruby_headers") || {}
162
+ headers["present"] == false && { "headers" => headers }
163
+ end
164
+ rule("architecture-specific-gem-incompatibility", "Native extension platform mismatch", :native, :critical,
165
+ impact: "The selected gem binary cannot execute on the target platform.",
166
+ fix: "Add the target platform to Gemfile.lock and rebuild gems on that platform.",
167
+ commands: ["bundle lock --add-platform <target-platform>", "bundle install"], files: ["Gemfile.lock"], location: "Gemfile.lock") do |_source, target|
168
+ runtime = dig(target, "runtime.platform").to_s
169
+ gems = selected_gems(target) do |_name, spec|
170
+ platform = spec["platform"].to_s
171
+ spec["native_extensions"] && platform != "ruby" && !platform_covered?([platform], runtime)
172
+ end
173
+ !gems.empty? && { "runtime_platform" => runtime, "gems" => gems }
174
+ end
175
+ end
176
+
177
+ def configuration_rules
178
+ rule("missing-environment-variable", "Required environment variable is missing", :configuration, :error,
179
+ impact: "The target application may fail during boot or when the integration is used.",
180
+ fix: "Configure the variable in the target secret/configuration store; do not commit its value.") do |source, target, policy|
181
+ dig(source, "configuration.environment_variables") || {}
182
+ target_vars = dig(target, "configuration.environment_variables") || {}
183
+ required = required_environment_names(source, target)
184
+ missing = required.select { |name| !target_vars[name] && !policy.optional_environment_variable?(name) }
185
+ !missing.empty? && { "variables" => missing }
186
+ end
187
+ rule("environment-variable-local-only", "Environment variable present only locally", :configuration, :warning,
188
+ impact: "Local behavior may rely on configuration unavailable in deployment.",
189
+ fix: "Declare the variable optional in .bootprint.yml or configure it in the target.", location: ".bootprint.yml") do |source, target, policy|
190
+ source_vars = dig(source, "configuration.environment_variables") || {}
191
+ target_vars = dig(target, "configuration.environment_variables") || {}
192
+ required = required_environment_names(source, target)
193
+ names = source_vars.filter_map do |name, value|
194
+ name if value && !target_vars[name] && !required.include?(name) && !policy.optional_environment_variable?(name)
195
+ end
196
+ !names.empty? && { "variables" => names }
197
+ end
198
+ rule("conflicting-environment-variables", "Conflicting environment-variable names", :configuration, :warning,
199
+ impact: "Libraries may select different configuration depending on precedence.",
200
+ fix: "Choose one canonical configuration convention and remove the duplicate.") do |_source, target|
201
+ vars = dig(target, "configuration.environment_variables") || {}
202
+ pairs = [%w[DATABASE_URL DB_HOST], %w[REDIS_URL REDIS_HOST], %w[RAILS_ENV RACK_ENV]]
203
+ conflicts = pairs.select { |left, right| vars[left] && vars[right] }
204
+ !conflicts.empty? && { "conflicts" => conflicts }
205
+ end
206
+ rails_difference("rails-environment-mismatch", "Rails environment mismatch", "environment", :error,
207
+ "Run both environments with the intended RAILS_ENV and RACK_ENV.")
208
+ rule("debug-mode-in-production", "Debug mode enabled in production", :configuration, :critical,
209
+ impact: "Debug behavior can expose sensitive errors and disable production optimizations.",
210
+ fix: "Disable debug/development settings and use production Rails configuration.") do |_source, target|
211
+ rails = dig(target, "configuration.rails") || {}
212
+ production?(target) && (rails["cache_classes"] == false || rails["eager_load"] == false) && {
213
+ "eager_load" => rails["eager_load"], "cache_classes" => rails["cache_classes"]
214
+ }
215
+ end
216
+ rule("secret-key-configuration-missing", "Rails secret-key configuration is missing", :configuration, :critical,
217
+ impact: "Rails cannot safely verify encrypted cookies and signed messages.",
218
+ fix: "Provide SECRET_KEY_BASE through the deployment secret store.") do |_source, target|
219
+ secret = dig(target, "configuration.rails.secret_key_base") || {}
220
+ production?(target) && secret["present"] == false && { "present" => false, "value_captured" => false }
221
+ end
222
+ rails_difference("database-adapter-mismatch", "Database adapter mismatch", "adapters.database", :error,
223
+ "Use the same database adapter or test against the production database.")
224
+ rails_difference("cache-adapter-mismatch", "Cache adapter mismatch", "adapters.cache", :warning,
225
+ "Configure the same cache store or document the intentional difference.")
226
+ rails_difference("queue-adapter-mismatch", "Queue adapter mismatch", "adapters.active_job", :error,
227
+ "Configure the production Active Job adapter in every relevant environment.")
228
+ rails_difference("session-store-mismatch", "Session store mismatch", "adapters.session", :warning,
229
+ "Align session-store configuration and migration requirements.")
230
+ end
231
+
232
+ def filesystem_rules
233
+ rule("runtime-directory-read-only", "Required runtime directory is read-only", :filesystem, :error,
234
+ impact: "The process may fail when writing caches, sockets, uploads, or runtime state.",
235
+ fix: "Mount a writable directory or redirect runtime files to an approved writable location.") do |_source, target|
236
+ directories = dig(target, "filesystem.required_directories") || {}
237
+ blocked = directories.select { |_name, state| state["present"] && state["writable"] == false }
238
+ !blocked.empty? && { "directories" => blocked }
239
+ end
240
+ rule("temporary-directory-missing", "Temporary directory is unavailable", :filesystem, :critical,
241
+ impact: "Ruby and gems cannot safely create temporary files.",
242
+ fix: "Create a writable temporary directory and configure TMPDIR when necessary.") do |_source, target|
243
+ state = dig(target, "filesystem.temporary_directory") || {}
244
+ (!state["present"] || !state["writable"]) && { "temporary_directory" => state }
245
+ end
246
+ rule("log-directory-not-writable", "Rails log directory is not writable", :filesystem, :error,
247
+ impact: "File logging can fail during application boot or request processing.",
248
+ fix: "Create a writable log directory or log to standard output.") do |_source, target|
249
+ state = dig(target, "filesystem.required_directories.log") || {}
250
+ state["present"] && state["writable"] == false && { "log_directory" => state }
251
+ end
252
+ rule("filesystem-case-sensitivity-mismatch", "Filesystem case sensitivity differs", :filesystem, :warning,
253
+ impact: "Incorrect filename casing can work locally but fail in Linux deployment.",
254
+ fix: "Correct import and require paths to match exact on-disk casing.") do |source, target|
255
+ difference(source, target, "filesystem.case_sensitive")
256
+ end
257
+ rule("path-separator-incompatibility", "Path separator differs", :filesystem, :warning,
258
+ impact: "Hard-coded path separators may create invalid paths on the target OS.",
259
+ fix: "Build paths with File.join and Pathname instead of string concatenation.") do |source, target|
260
+ difference(source, target, "filesystem.path_separator")
261
+ end
262
+ rule("symlink-behavior-mismatch", "Symlink behavior differs", :filesystem, :warning,
263
+ impact: "Deployments or asset pipelines relying on symlinks may fail.",
264
+ fix: "Avoid required symlinks or verify target permissions and filesystem support.") do |source, target|
265
+ difference(source, target, "filesystem.symlinks_supported")
266
+ end
267
+ end
268
+
269
+ def initializer_rules
270
+ rule("slow-rails-initializer", "Slow Rails initializer", :rails, :warning,
271
+ impact: "Slow initializers increase deploy, worker, console, and test startup time.",
272
+ fix: "Defer external setup and expensive work until the integration is first used.") do |_source, target|
273
+ threshold = Bootprint.configuration.slow_initializer_threshold_ms
274
+ initializers = Array(dig(target, "configuration.rails.initializers"))
275
+ slow = initializers.select { |item| item["duration_ms"].to_f > threshold }
276
+ !slow.empty? && { "threshold_ms" => threshold, "initializers" => slow }
277
+ end
278
+ rule("unstable-initializer-order", "Rails initializer order changed", :rails, :warning,
279
+ impact: "Configuration may be read before another initializer makes it available.",
280
+ fix: "Declare explicit initializer before/after dependencies or consolidate coupled setup.") do |source, target|
281
+ left = Array(dig(source, "configuration.rails.initializers")).map { |item| item["name"] }
282
+ right = Array(dig(target, "configuration.rails.initializers")).map { |item| item["name"] }
283
+ !left.empty? && !right.empty? && left != right && { "source_order" => left, "target_order" => right }
284
+ end
285
+ rule("initializer-exception", "Rails initializer raised an exception", :rails, :critical,
286
+ impact: "The application cannot complete a reliable boot.",
287
+ fix: "Resolve the recorded exception and avoid swallowing initializer failures.") do |_source, target|
288
+ failed = Array(dig(target, "configuration.rails.initializers")).select { |item| item["exception"] }
289
+ !failed.empty? && { "initializers" => failed }
290
+ end
291
+ rule("initializer-network-operation", "Initializer may perform network operations", :rails, :warning,
292
+ impact: "Network setup during boot makes startup slow and dependent on remote availability.",
293
+ fix: "Defer connection establishment until first use. This finding is heuristic.") do |_source, target|
294
+ networked = Array(dig(target, "configuration.rails.initializers")).select { |item| item["network_operation_heuristic"] }
295
+ !networked.empty? && { "heuristic" => true, "initializers" => networked }
296
+ end
297
+ rule("initializer-missing-environment-variable", "Initializer accessed an unavailable environment variable", :rails, :error,
298
+ impact: "The initializer may configure an integration with a nil or missing value.",
299
+ fix: "Declare the variable required or handle its absence before using the integration.") do |_source, target|
300
+ affected = Array(dig(target, "configuration.rails.initializers")).filter_map do |initializer|
301
+ names = Array(initializer["missing_environment_variables"])
302
+ { "name" => initializer["name"], "variables" => names } unless names.empty?
303
+ end
304
+ !affected.empty? && { "initializers" => affected, "values_captured" => false }
305
+ end
306
+ rule("initializer-global-configuration-mutation", "Initializer mutated global Rails configuration", :rails, :warning,
307
+ impact: "Late global changes can produce ordering-dependent behavior. This finding is heuristic.",
308
+ fix: "Move the setting to environment configuration or declare an explicit initializer dependency.") do |_source, target|
309
+ affected = Array(dig(target, "configuration.rails.initializers")).select do |initializer|
310
+ initializer["configuration_mutation_heuristic"] == true
311
+ end
312
+ !affected.empty? && { "heuristic" => true, "initializers" => affected }
313
+ end
314
+ end
315
+
316
+ def rule(id, title, category, severity, impact:, fix:, cause: "The captured environments differ in a compatibility-relevant way.",
317
+ commands: [], files: [], references: [], location: nil, &detector)
318
+ Rules.define id do
319
+ name title
320
+ category category
321
+ severity severity
322
+ detect(&detector)
323
+ explain do |_source, _target, evidence|
324
+ { "title" => title, "summary" => title, "cause" => cause, "impact" => impact, "evidence" => evidence }
325
+ end
326
+ remediate fix, commands: commands, files: files
327
+ references(*references)
328
+ source_location(location) if location
329
+ metadata "built_in" => true, "since" => "0.2.0"
330
+ end
331
+ end
332
+
333
+ def library_rule(id, title, path, severity, fix)
334
+ rule(id, title, :native, severity,
335
+ impact: "Native client behavior or binary compatibility may differ.", fix:) do |source, target|
336
+ difference(source, target, "native_libraries.#{path}")
337
+ end
338
+ end
339
+
340
+ def rails_difference(id, title, path, severity, fix)
341
+ rule(id, title, :configuration, severity,
342
+ impact: "Rails behavior differs between the source and target environments.", fix:) do |source, target|
343
+ difference(source, target, "configuration.rails.#{path}")
344
+ end
345
+ end
346
+
347
+ def difference(source, target, path)
348
+ left = dig(source, path)
349
+ right = dig(target, path)
350
+ !left.nil? && !right.nil? && left != right && { "source" => left, "target" => right }
351
+ end
352
+
353
+ def dig(value, path)
354
+ path.split(".").reduce(value) { |current, key| current.is_a?(Hash) ? current[key] : nil }
355
+ end
356
+
357
+ def selected_gems(environment, &block)
358
+ gems = dig(environment, "dependencies.gems") || {}
359
+ gems.select(&block).map { |name, spec| { "name" => name, "version" => spec["version"], "platform" => spec["platform"] }.compact }
360
+ end
361
+
362
+ def production?(environment)
363
+ dig(environment, "configuration.rails.environment") == "production" || environment["name"] == "production"
364
+ end
365
+
366
+ def required_environment_names(source, target)
367
+ explicit = Array(dig(source, "configuration.required_environment_variables")) |
368
+ Array(dig(target, "configuration.required_environment_variables"))
369
+ source_vars = dig(source, "configuration.environment_variables") || {}
370
+ conventional = %w[DATABASE_URL REDIS_URL SECRET_KEY_BASE RAILS_MASTER_KEY].select { |name| source_vars[name] }
371
+ explicit | conventional
372
+ end
373
+
374
+ def platform_covered?(platforms, required)
375
+ return false if required.to_s.empty?
376
+
377
+ platforms.any? do |platform|
378
+ platform == required || platform == "ruby" || required.include?(platform) || platform.include?(required)
379
+ end
380
+ end
381
+ end
382
+ end
383
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bootprint
4
+ module Rules
5
+ Finding = Struct.new(
6
+ :rule_id, :title, :category, :severity, :summary, :cause, :impact,
7
+ :evidence, :remediation, :references, :metadata, :source_location,
8
+ :suppressed, :suppression_reason, keyword_init: true
9
+ ) do
10
+ def to_h
11
+ {
12
+ "rule_id" => rule_id,
13
+ "title" => title,
14
+ "category" => category.to_s,
15
+ "severity" => severity.to_s,
16
+ "summary" => summary,
17
+ "cause" => cause,
18
+ "impact" => impact,
19
+ "evidence" => evidence || {},
20
+ "remediation" => remediation || {},
21
+ "references" => references || [],
22
+ "metadata" => metadata || {},
23
+ "source_location" => source_location,
24
+ "suppressed" => !suppressed.nil?,
25
+ "suppression_reason" => suppression_reason
26
+ }.compact
27
+ end
28
+
29
+ def blocking?(policy)
30
+ !suppressed && policy.fail_on.include?(severity.to_s)
31
+ end
32
+
33
+ # Compatibility readers for 0.1 integrations.
34
+ def path = metadata&.fetch("path", nil) || source_location&.fetch("path", nil)
35
+ def local = evidence&.fetch("source", nil)
36
+ def target = evidence&.fetch("target", nil)
37
+ def message = summary
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bootprint
4
+ module Rules
5
+ module Registry
6
+ module_function
7
+
8
+ def add(rule)
9
+ rules.reject! { |existing| existing.id == rule.id }
10
+ rules << rule
11
+ rule
12
+ end
13
+
14
+ def fetch(id) = rules.find { |rule| rule.id == id.to_s }
15
+ def all = rules.dup
16
+ def reset! = @rules = []
17
+ def rules = @rules ||= []
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bootprint
4
+ module Rules
5
+ class Rule
6
+ SEVERITIES = %i[info warning error critical].freeze
7
+ PATH_ALIASES = {
8
+ "ruby_version" => "runtime.ruby_version",
9
+ "ruby_platform" => "runtime.platform",
10
+ "bundler_version" => "dependencies.toolchain.bundler_version",
11
+ "rubygems_version" => "dependencies.toolchain.rubygems_version",
12
+ "openssl_version" => "native_libraries.openssl.runtime"
13
+ }.freeze
14
+
15
+ attr_reader :id, :human_name, :category_name, :severity_name, :metadata_value,
16
+ :reference_values, :source_location_value
17
+
18
+ def initialize(id, severity: nil)
19
+ @id = id.to_s
20
+ @human_name = id.to_s.split("-").map(&:capitalize).join(" ")
21
+ @category_name = :general
22
+ @severity_name = (severity || :warning).to_sym
23
+ @metadata_value = {}
24
+ @reference_values = []
25
+ end
26
+
27
+ def name(value = nil)
28
+ @human_name = value.to_s if value
29
+ @human_name
30
+ end
31
+
32
+ def category(value = nil)
33
+ @category_name = value.to_sym if value
34
+ @category_name
35
+ end
36
+
37
+ def severity(value = nil)
38
+ if value
39
+ candidate = value.to_sym
40
+ raise ConfigurationError, "Invalid severity #{value.inspect} for #{id}" unless SEVERITIES.include?(candidate)
41
+
42
+ @severity_name = candidate
43
+ end
44
+ @severity_name
45
+ end
46
+
47
+ def detect(&block)
48
+ @detector = block if block
49
+ @detector
50
+ end
51
+
52
+ def explain(message = nil, &block)
53
+ @explainer = block || ->(_source, _target, evidence = nil) { { "summary" => message.to_s, "evidence" => evidence } }
54
+ end
55
+
56
+ def remediate(summary = nil, commands: [], files: [], &block)
57
+ @remediator = block || lambda do |_source, _target, _evidence = nil|
58
+ { "summary" => summary.to_s, "commands" => commands, "files" => files }
59
+ end
60
+ end
61
+
62
+ def metadata(value = nil, **pairs)
63
+ @metadata_value.merge!(Schema.stringify(value || {}).merge(Schema.stringify(pairs)))
64
+ end
65
+
66
+ def references(*values)
67
+ @reference_values.concat(values.flatten.map(&:to_s))
68
+ end
69
+
70
+ def source_location(path = nil, line: nil)
71
+ @source_location_value = { "path" => path.to_s, "line" => line }.compact if path
72
+ @source_location_value
73
+ end
74
+
75
+ # Compatibility with the 0.1 single-path rule API.
76
+ def compare(path)
77
+ @compare_path = PATH_ALIASES.fetch(path.to_s, path.to_s)
78
+ detect { |source, target| dig(source, @compare_path) != dig(target, @compare_path) }
79
+ end
80
+
81
+ def condition(&block)
82
+ @condition = block
83
+ end
84
+
85
+ def evaluate(source_snapshot, target_snapshot, policy:)
86
+ source = environment(source_snapshot)
87
+ target = environment(target_snapshot)
88
+ evidence = detection_result(source, target, policy)
89
+ return unless evidence
90
+
91
+ explanation_arguments = if @compare_path
92
+ [dig(source, @compare_path), dig(target, @compare_path), evidence]
93
+ else
94
+ [source, target, evidence]
95
+ end
96
+ details = Schema.stringify(invoke(@explainer, *explanation_arguments) || {})
97
+ details = { "summary" => details.to_s, "evidence" => evidence } unless details.is_a?(Hash)
98
+ remediation = Schema.stringify(invoke(@remediator, source, target, evidence) || {})
99
+ effective_severity = policy.severity_for(id, severity_name)
100
+ suppressed = policy.ignored?(id) || policy.disabled?(id)
101
+ Finding.new(
102
+ rule_id: id,
103
+ title: details["title"] || human_name,
104
+ category: category_name,
105
+ severity: effective_severity,
106
+ summary: details["summary"] || "#{human_name} was detected.",
107
+ cause: details["cause"],
108
+ impact: details["impact"],
109
+ evidence: details["evidence"] || normalize_evidence(evidence),
110
+ remediation: remediation,
111
+ references: reference_values,
112
+ metadata: metadata_value.merge("path" => @compare_path).compact,
113
+ source_location: details["source_location"] || source_location_value,
114
+ suppressed:,
115
+ suppression_reason: suppressed ? policy.suppression_reason(id) : nil
116
+ )
117
+ end
118
+
119
+ private
120
+
121
+ def detection_result(source, target, policy)
122
+ raise ConfigurationError, "Rule #{id} does not define detection logic" unless @detector
123
+
124
+ if @compare_path && @condition
125
+ local = dig(source, @compare_path)
126
+ remote = dig(target, @compare_path)
127
+ matched = @condition.call(local, remote, source, target)
128
+ return matched && { "source" => local, "target" => remote }
129
+ end
130
+ result = invoke(@detector, source, target, policy)
131
+ result == true ? {} : result
132
+ end
133
+
134
+ def environment(snapshot)
135
+ snapshot.respond_to?(:environment) ? snapshot.environment : snapshot.fetch("environment", snapshot)
136
+ end
137
+
138
+ def invoke(callable, *arguments)
139
+ return {} unless callable
140
+
141
+ callable.call(*arguments.take(callable.arity.negative? ? arguments.length : callable.arity))
142
+ end
143
+
144
+ def normalize_evidence(value)
145
+ value.is_a?(Hash) ? Schema.stringify(value) : { "detected" => value }
146
+ end
147
+
148
+ def dig(hash, path)
149
+ path.split(".").reduce(hash) { |value, key| value.is_a?(Hash) ? value[key] : nil }
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rules/finding"
4
+ require_relative "rules/rule"
5
+ require_relative "rules/registry"
6
+
7
+ module Bootprint
8
+ module Rules
9
+ SEVERITY_ORDER = { info: 0, warning: 1, error: 2, critical: 3 }.freeze
10
+ class << self
11
+ def define(id, severity: nil, &block)
12
+ rule = Rule.new(id, severity:)
13
+ rule.instance_eval(&block)
14
+ Registry.add(rule)
15
+ end
16
+
17
+ def register(name_or_rule)
18
+ return Registry.add(name_or_rule) if name_or_rule.is_a?(Rule)
19
+
20
+ require "bootprint/rules/#{name_or_rule}"
21
+ rescue LoadError => error
22
+ raise ConfigurationError, "Could not load Bootprint rules for #{name_or_rule}: #{error.message}"
23
+ end
24
+
25
+ def evaluate(source, target, policy: Policy.new, only: nil, minimum_severity: nil)
26
+ categories = Array(only).map(&:to_sym)
27
+ minimum = (minimum_severity || policy.minimum_severity).to_sym
28
+ Registry.all.filter_map do |rule|
29
+ next if !categories.empty? && !categories.include?(rule.category)
30
+
31
+ finding = begin
32
+ rule.evaluate(source, target, policy:)
33
+ rescue StandardError => error
34
+ Finding.new(
35
+ rule_id: "#{rule.id}-evaluation-failure",
36
+ title: "Diagnostic rule failed",
37
+ category: :internal,
38
+ severity: policy.plugin_strict? ? :error : :warning,
39
+ summary: "Rule #{rule.id} could not evaluate this snapshot.",
40
+ cause: Sanitizer.text("#{error.class}: #{error.message}"),
41
+ impact: "This rule's diagnosis is incomplete; other rules continued.",
42
+ evidence: { "rule_id" => rule.id },
43
+ remediation: { "summary" => "Update the rule provider or Bootprint.", "commands" => [], "files" => [] },
44
+ references: [], metadata: {}, suppressed: false
45
+ )
46
+ end
47
+ next unless finding
48
+ next if SEVERITY_ORDER.fetch(finding.severity) < SEVERITY_ORDER.fetch(minimum)
49
+
50
+ finding
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ require_relative "rules/builtin"
58
+ Bootprint::Rules::Builtin.install