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,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
|