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,458 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "json"
5
+ require_relative "../bootprint"
6
+ require_relative "formatters"
7
+
8
+ module Bootprint
9
+ class CLI
10
+ EXIT_OK = 0
11
+ EXIT_POLICY = 1
12
+ EXIT_CONFIG = 2
13
+ EXIT_SNAPSHOT = 3
14
+ EXIT_INTERNAL = 4
15
+
16
+ def self.start(argv = ARGV, out: $stdout, err: $stderr)
17
+ new(argv, out:, err:).run
18
+ end
19
+
20
+ def initialize(argv, out:, err:)
21
+ @argv = argv.dup
22
+ @out = out
23
+ @err = err
24
+ end
25
+
26
+ def run
27
+ command = @argv.shift
28
+ case command
29
+ when "capture" then capture
30
+ when "diff" then diff
31
+ when "diagnose" then diagnose
32
+ when "doctor" then doctor
33
+ when "verify" then verify
34
+ when "fix" then fix
35
+ when "policy" then policy_command
36
+ when "snapshot" then snapshot_command
37
+ when "docker" then docker_command
38
+ when "ci" then ci_command
39
+ when "security" then security_command
40
+ when "version", "--version", "-v" then version
41
+ when "help", "--help", "-h", nil then help(EXIT_OK)
42
+ else
43
+ @err.puts "Unknown command: #{command}"
44
+ help(EXIT_CONFIG)
45
+ end
46
+ rescue InvalidSnapshotError => error
47
+ @err.puts "bootprint: #{error.message}"
48
+ EXIT_SNAPSHOT
49
+ rescue OptionParser::ParseError, ConfigurationError => error
50
+ @err.puts "bootprint: #{error.message}"
51
+ EXIT_CONFIG
52
+ rescue DockerError, PluginError, Errno::EACCES, Errno::ENOENT => error
53
+ @err.puts "bootprint: #{error.message}"
54
+ EXIT_INTERNAL
55
+ rescue StandardError => error
56
+ @err.puts "bootprint: internal error: #{error.class}: #{Sanitizer.text(error.message)}"
57
+ @err.puts error.backtrace if ENV["BOOTPRINT_DEBUG"] == "1"
58
+ EXIT_INTERNAL
59
+ end
60
+
61
+ private
62
+
63
+ def capture
64
+ options = { env_names: [], required_env: [], privacy: "standard" }
65
+ parser = OptionParser.new do |opts|
66
+ opts.banner = "Usage: bootprint capture [NAME] [options]"
67
+ opts.on("-o", "--output PATH", "Write to PATH") { |value| options[:output] = value }
68
+ opts.on("--env NAME", "Include an environment-variable name") { |value| options[:env_names] << value }
69
+ opts.on("--required-env NAME", "Declare a required environment variable") { |value| options[:required_env] << value }
70
+ opts.on("--all-env", "Record every environment-variable name; never values") { options[:all_env] = true }
71
+ opts.on("--privacy MODE", %w[standard strict], "standard or strict") { |value| options[:privacy] = value }
72
+ opts.on("--policy PATH", "Apply capture policy") { |value| options[:policy] = value }
73
+ end
74
+ return EXIT_OK unless parse_options(parser)
75
+
76
+ label = @argv.shift
77
+ reject_extra_arguments!
78
+ validate_label!(label)
79
+ policy = load_policy(options[:policy])
80
+ policy.apply!
81
+ Bootprint.configuration.environment_names |= options[:env_names]
82
+ Bootprint.configuration.required_environment_names |= options[:required_env]
83
+ Bootprint.configuration.environment_patterns = [/.*/] if options[:all_env]
84
+ output = options[:output] || snapshot_path(label)
85
+ Snapshot.capture(label:, privacy: options[:privacy]).write(output)
86
+ @out.puts "Captured sanitized schema-v#{Schema::CURRENT_VERSION} runtime fingerprint to #{output}"
87
+ EXIT_OK
88
+ end
89
+
90
+ def diff
91
+ options = report_options
92
+ parser = report_parser("Usage: bootprint diff SOURCE TARGET [options]", options)
93
+ return EXIT_OK unless parse_options(parser)
94
+ raise OptionParser::MissingArgument, "SOURCE and TARGET are required" unless @argv.length == 2
95
+
96
+ source = load_snapshot(@argv.shift)
97
+ target = load_snapshot(@argv.shift)
98
+ policy = load_policy(options[:policy])
99
+ changes = Diff.new(source, target, allowed_paths: policy.allowed_paths + options[:allows]).changes
100
+ findings = changes.map { |change| raw_change_finding(change) }
101
+ report = Report.new(source:, target:, findings:, policy:, duration_ms: 0.0)
102
+ render(report, options[:format])
103
+ EXIT_OK
104
+ end
105
+
106
+ def diagnose
107
+ options = report_options.merge(against: nil)
108
+ parser = report_parser("Usage: bootprint diagnose SOURCE TARGET [options]", options)
109
+ parser.on("--against SNAPSHOT", "Compare SNAPSHOT with the current environment") { |value| options[:against] = value }
110
+ return EXIT_OK unless parse_options(parser)
111
+
112
+ source, target = diagnosis_pair(options)
113
+ report = build_diagnosis(source, target, options)
114
+ render(report, options[:format])
115
+ report.blocking? ? EXIT_POLICY : EXIT_OK
116
+ end
117
+
118
+ def doctor
119
+ options = report_options.merge(against: nil)
120
+ parser = report_parser("Usage: bootprint doctor [options]", options)
121
+ parser.on("--against SNAPSHOT", "Include drift diagnosis from SNAPSHOT") { |value| options[:against] = value }
122
+ return EXIT_OK unless parse_options(parser)
123
+
124
+ reject_extra_arguments!
125
+ policy = load_policy(options[:policy]).apply!
126
+ current = Snapshot.capture(label: "current")
127
+ source = options[:against] ? load_snapshot(options[:against]) : current
128
+ report = Diagnosis.new(source, current, policy:, only: options[:only], minimum_severity: options[:minimum]).run
129
+ render(report, options[:format])
130
+ report.blocking? ? EXIT_POLICY : EXIT_OK
131
+ end
132
+
133
+ def verify
134
+ options = report_options.merge(against: "bootprint.lock")
135
+ parser = report_parser("Usage: bootprint verify [options]", options)
136
+ parser.on("--against SNAPSHOT", "Reference snapshot (default: bootprint.lock)") { |value| options[:against] = value }
137
+ return EXIT_OK unless parse_options(parser)
138
+
139
+ reject_extra_arguments!
140
+ source = load_snapshot(options[:against])
141
+ policy = load_policy(options[:policy]).apply!
142
+ target = Snapshot.capture(label: "current")
143
+ report = Diagnosis.new(source, target, policy:, only: options[:only], minimum_severity: options[:minimum]).run
144
+ render(report, options[:format])
145
+ report.blocking? ? EXIT_POLICY : EXIT_OK
146
+ end
147
+
148
+ def fix
149
+ options = report_options.merge(against: "bootprint.lock", dry_run: false)
150
+ parser = report_parser("Usage: bootprint fix --dry-run [options]", options)
151
+ parser.on("--against SNAPSHOT", "Reference snapshot") { |value| options[:against] = value }
152
+ parser.on("--dry-run", "Preview remediation without modifying files") { options[:dry_run] = true }
153
+ return EXIT_OK unless parse_options(parser)
154
+ raise OptionParser::MissingArgument, "--dry-run is required; automatic repair is not supported" unless options[:dry_run]
155
+
156
+ reject_extra_arguments!
157
+ source = load_snapshot(options[:against])
158
+ policy = load_policy(options[:policy]).apply!
159
+ report = Diagnosis.new(source, Snapshot.capture(label: "current"), policy:).run
160
+ @out.puts "Bootprint remediation preview (no commands executed, no files modified)"
161
+ report.findings.reject(&:suppressed).each do |finding|
162
+ remediation = finding.remediation || {}
163
+ next if remediation.empty?
164
+
165
+ @out.puts "\n#{finding.severity.to_s.upcase} #{finding.title}"
166
+ @out.puts " #{remediation['summary']}" if remediation["summary"]
167
+ Array(remediation["commands"]).each { |command| @out.puts " would run: #{command}" }
168
+ Array(remediation["files"]).each { |file| @out.puts " may update: #{file}" }
169
+ end
170
+ EXIT_OK
171
+ end
172
+
173
+ def policy_command
174
+ subcommand = @argv.shift
175
+ options = { path: default_policy_path }
176
+ parser = OptionParser.new do |opts|
177
+ opts.banner = "Usage: bootprint policy #{subcommand || 'COMMAND'} [--file PATH]"
178
+ opts.on("--file PATH", "Policy file (default: .bootprint.yml)") { |value| options[:path] = value }
179
+ end
180
+ return EXIT_OK unless parse_options(parser)
181
+
182
+ reject_extra_arguments!
183
+ policy = Policy.load(options[:path])
184
+ case subcommand
185
+ when "validate"
186
+ @out.puts "Policy is valid: #{policy.path}"
187
+ when "explain"
188
+ @out.write policy.explain
189
+ else
190
+ raise OptionParser::InvalidArgument, "policy command must be validate or explain"
191
+ end
192
+ EXIT_OK
193
+ end
194
+
195
+ def snapshot_command
196
+ subcommand = @argv.shift
197
+ options = {}
198
+ parser = OptionParser.new do |opts|
199
+ opts.banner = "Usage: bootprint snapshot #{subcommand || 'COMMAND'} SNAPSHOT [options]"
200
+ opts.on("-o", "--output PATH", "Migration output path") { |value| options[:output] = value }
201
+ end
202
+ return EXIT_OK unless parse_options(parser)
203
+
204
+ reference = @argv.shift or raise OptionParser::MissingArgument, "SNAPSHOT is required"
205
+ reject_extra_arguments!
206
+ path = resolve_snapshot(reference)
207
+ snapshot = Snapshot.load(path)
208
+ case subcommand
209
+ when "inspect"
210
+ @out.puts JSON.pretty_generate(snapshot_inspection(snapshot, path))
211
+ when "validate"
212
+ @out.puts "Snapshot is valid: #{File.expand_path(path)} (schema #{snapshot.data['schema_version']})"
213
+ when "migrate"
214
+ output = options[:output] || "#{path}.v#{Schema::CURRENT_VERSION}.json"
215
+ snapshot.write(output)
216
+ @out.puts "Migrated snapshot to #{output}"
217
+ else
218
+ raise OptionParser::InvalidArgument, "snapshot command must be inspect, migrate, or validate"
219
+ end
220
+ EXIT_OK
221
+ end
222
+
223
+ def docker_command
224
+ require_relative "docker"
225
+ subcommand = @argv.shift
226
+ options = report_options.merge(against: nil, privacy: "standard")
227
+ parser = report_parser("Usage: bootprint docker #{subcommand || 'COMMAND'} IMAGE [options]", options)
228
+ parser.on("--against SNAPSHOT", "Reference snapshot") { |value| options[:against] = value }
229
+ parser.on("-o", "--output PATH", "Capture output path") { |value| options[:output] = value }
230
+ parser.on("--privacy MODE", %w[standard strict], "standard or strict") { |value| options[:privacy] = value }
231
+ return EXIT_OK unless parse_options(parser)
232
+
233
+ image = @argv.shift or raise OptionParser::MissingArgument, "IMAGE is required"
234
+ reject_extra_arguments!
235
+ target = Docker::Client.new.capture(image, privacy: options[:privacy])
236
+ case subcommand
237
+ when "capture"
238
+ output = options[:output] || File.join(".bootprint", "#{safe_name(image)}.json")
239
+ target.write(output)
240
+ @out.puts "Captured Docker image #{image} to #{output}"
241
+ EXIT_OK
242
+ when "compare", "diagnose"
243
+ source = load_snapshot(options[:against] || "bootprint.lock")
244
+ report = build_diagnosis(source, target, options)
245
+ render(report, options[:format])
246
+ report.blocking? ? EXIT_POLICY : EXIT_OK
247
+ else
248
+ raise OptionParser::InvalidArgument, "docker command must be capture, compare, or diagnose"
249
+ end
250
+ end
251
+
252
+ def ci_command
253
+ subcommand = @argv.shift
254
+ raise OptionParser::InvalidArgument, "ci command must be verify" unless subcommand == "verify"
255
+
256
+ options = report_options.merge(against: "bootprint.lock")
257
+ parser = report_parser("Usage: bootprint ci verify [options]", options)
258
+ parser.on("--against SNAPSHOT", "Reference snapshot") { |value| options[:against] = value }
259
+ return EXIT_OK unless parse_options(parser)
260
+
261
+ reject_extra_arguments!
262
+ source = load_snapshot(options[:against])
263
+ policy = load_policy(options[:policy]).apply!
264
+ target = Snapshot.capture(label: ci_provider)
265
+ report = Diagnosis.new(source, target, policy:, only: options[:only], minimum_severity: options[:minimum]).run
266
+ render(report, options[:format])
267
+ emit_ci_annotations(report)
268
+ report.blocking? ? EXIT_POLICY : EXIT_OK
269
+ end
270
+
271
+ def security_command
272
+ subcommand = @argv.shift
273
+ raise OptionParser::InvalidArgument, "security command must be audit" unless subcommand == "audit"
274
+
275
+ options = { format: "human" }
276
+ parser = OptionParser.new do |opts|
277
+ opts.banner = "Usage: bootprint security audit SNAPSHOT [--format human|json]"
278
+ opts.on("--format FORMAT", %w[human json]) { |value| options[:format] = value }
279
+ end
280
+ return EXIT_OK unless parse_options(parser)
281
+
282
+ reference = @argv.shift or raise OptionParser::MissingArgument, "SNAPSHOT is required"
283
+ reject_extra_arguments!
284
+ require_relative "security/auditor"
285
+ issues = Security::Auditor.new(load_snapshot(reference)).audit
286
+ if options[:format] == "json"
287
+ @out.puts JSON.pretty_generate("schema_version" => 1, "issues" => issues.map(&:to_h))
288
+ elsif issues.empty?
289
+ @out.puts "Security audit passed: no likely sensitive values detected."
290
+ else
291
+ @out.puts "Security audit found #{issues.length} potential exposure(s):"
292
+ issues.each { |issue| @out.puts "WARNING #{issue.path}: #{issue.message}" }
293
+ end
294
+ issues.empty? ? EXIT_OK : EXIT_POLICY
295
+ end
296
+
297
+ def report_options
298
+ { format: "human", allows: [], policy: default_policy_path, only: nil, minimum: nil }
299
+ end
300
+
301
+ def report_parser(banner, options)
302
+ OptionParser.new do |opts|
303
+ opts.banner = banner
304
+ opts.on("--format FORMAT", %w[human json sarif markdown], "human, json, sarif, or markdown") { |value| options[:format] = value }
305
+ opts.on("--only CATEGORIES", "Comma-separated rule categories") { |value| options[:only] = value.split(",").map(&:strip) }
306
+ opts.on("--minimum-severity LEVEL", Rules::Rule::SEVERITIES.map(&:to_s)) { |value| options[:minimum] = value }
307
+ opts.on("--allow PATH", "Allow a dotted raw-diff path") { |value| options[:allows] << value }
308
+ opts.on("--policy PATH", "Policy file") { |value| options[:policy] = value }
309
+ end
310
+ end
311
+
312
+ def diagnosis_pair(options)
313
+ if options[:against]
314
+ reject_extra_arguments!
315
+ load_policy(options[:policy]).apply!
316
+ [load_snapshot(options[:against]), Snapshot.capture(label: "current", privacy: Bootprint.configuration.privacy)]
317
+ else
318
+ raise OptionParser::MissingArgument, "SOURCE and TARGET are required unless --against is used" unless @argv.length == 2
319
+
320
+ [load_snapshot(@argv.shift), load_snapshot(@argv.shift)]
321
+ end
322
+ end
323
+
324
+ def build_diagnosis(source, target, options)
325
+ policy = load_policy(options[:policy]).apply!
326
+ Diagnosis.new(source, target, policy:, only: options[:only], minimum_severity: options[:minimum]).run
327
+ end
328
+
329
+ def raw_change_finding(change)
330
+ Rules::Finding.new(
331
+ rule_id: "raw-environment-difference", title: change.path, category: :difference,
332
+ severity: :info, summary: "#{change.path} differs between snapshots.",
333
+ evidence: { "source" => change.local, "target" => change.target },
334
+ remediation: { "summary" => "Run bootprint diagnose for compatibility guidance.", "commands" => [], "files" => [] },
335
+ references: [], metadata: {}, suppressed: false
336
+ )
337
+ end
338
+
339
+ def render(report, format)
340
+ @out.write Formatters.render(format, report, color: color?)
341
+ end
342
+
343
+ def emit_ci_annotations(report)
344
+ provider = ci_provider
345
+ if provider == "github"
346
+ report.findings.reject(&:suppressed).each do |finding|
347
+ level = %i[critical error].include?(finding.severity) ? "error" : "warning"
348
+ @out.puts "::#{level} title=#{escape_annotation(finding.title)}::#{escape_annotation(finding.summary)}"
349
+ end
350
+ if ENV["GITHUB_STEP_SUMMARY"] && !ENV["GITHUB_STEP_SUMMARY"].empty?
351
+ File.open(ENV["GITHUB_STEP_SUMMARY"], "a", encoding: "UTF-8") do |file|
352
+ file.write Formatters::Markdown.new(report).render
353
+ end
354
+ end
355
+ elsif provider == "gitlab"
356
+ @out.puts "Bootprint GitLab CI: #{report.blocking? ? 'policy violation' : 'compatible'}"
357
+ end
358
+ end
359
+
360
+ def ci_provider
361
+ return "github" if ENV["GITHUB_ACTIONS"] == "true"
362
+ return "gitlab" if ENV["GITLAB_CI"] == "true"
363
+ return "circleci" if ENV["CIRCLECI"] == "true"
364
+
365
+ ENV.key?("CI") ? "generic" : "local"
366
+ end
367
+
368
+ def escape_annotation(value)
369
+ value.to_s.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A")
370
+ end
371
+
372
+ def snapshot_inspection(snapshot, path)
373
+ {
374
+ "path" => File.expand_path(path),
375
+ "schema_version" => snapshot.data["schema_version"],
376
+ "generated_at" => snapshot.data["generated_at"],
377
+ "bootprint_version" => snapshot.data["bootprint_version"],
378
+ "environment_name" => snapshot.name,
379
+ "sections" => snapshot.environment.keys.sort,
380
+ "capture" => snapshot.data["capture"]
381
+ }
382
+ end
383
+
384
+ def load_policy(path)
385
+ Policy.load(path)
386
+ end
387
+
388
+ def default_policy_path
389
+ File.file?(".bootprint.yml") ? ".bootprint.yml" : nil
390
+ end
391
+
392
+ def load_snapshot(reference) = Snapshot.load(resolve_snapshot(reference))
393
+
394
+ def resolve_snapshot(reference)
395
+ return reference if File.file?(reference)
396
+
397
+ named = File.join(".bootprint", "#{reference}.json")
398
+ File.file?(named) ? named : reference
399
+ end
400
+
401
+ def snapshot_path(label) = label ? File.join(".bootprint", "#{label}.json") : "bootprint.lock"
402
+
403
+ def validate_label!(label)
404
+ return unless label
405
+
406
+ valid = label.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]*\z/) && !%w[. ..].include?(label)
407
+ raise OptionParser::InvalidArgument, "NAME may contain only letters, numbers, dots, underscores, and hyphens" unless valid
408
+ end
409
+
410
+ def safe_name(value) = value.to_s.gsub(/[^a-zA-Z0-9_.-]+/, "-").gsub(/\A-+|-+\z/, "")
411
+
412
+ def reject_extra_arguments!
413
+ raise OptionParser::ParseError, "unexpected arguments: #{@argv.join(' ')}" unless @argv.empty?
414
+ end
415
+
416
+ def parse_options(parser)
417
+ parser.on_tail("-h", "--help", "Show help") do
418
+ @out.puts(parser)
419
+ throw :bootprint_help
420
+ end
421
+ catch(:bootprint_help) do
422
+ parser.parse!(@argv)
423
+ return true
424
+ end
425
+ false
426
+ end
427
+
428
+ def color? = @out.respond_to?(:tty?) && @out.tty? && !ENV.key?("NO_COLOR")
429
+
430
+ def version
431
+ @out.puts "bootprint #{VERSION}"
432
+ EXIT_OK
433
+ end
434
+
435
+ def help(exit_code)
436
+ @out.puts <<~HELP
437
+ Bootprint #{VERSION} — Reproduce the environment, not just the dependencies.
438
+
439
+ Usage: bootprint COMMAND [options]
440
+
441
+ capture [NAME] Capture a sanitized schema-v2 snapshot
442
+ diff SOURCE TARGET Show every raw environment difference
443
+ diagnose SOURCE TARGET Explain compatibility risks and fixes
444
+ doctor Diagnose the current runtime
445
+ verify Enforce policy against bootprint.lock
446
+ fix --dry-run Preview remediation without modifying files
447
+ policy COMMAND Validate or explain .bootprint.yml
448
+ snapshot COMMAND Inspect, migrate, or validate snapshots
449
+ docker COMMAND Capture, compare, or diagnose a local Docker image
450
+ ci verify Verify with CI-native annotations
451
+ security audit Audit a snapshot for sensitive data
452
+
453
+ Run `bootprint COMMAND --help` for command-specific options.
454
+ HELP
455
+ exit_code
456
+ end
457
+ end
458
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bootprint
4
+ module Collectors
5
+ module Environment
6
+ module_function
7
+
8
+ def key = "environment"
9
+
10
+ def capture
11
+ names = ENV.keys.select { |name| Bootprint.configuration.capture_environment?(name) }
12
+ names |= Bootprint.configuration.required_environment_names
13
+ names |= Bootprint.configuration.optional_environment_names
14
+ {
15
+ "variables" => names.sort.to_h { |name| [name, ENV.key?(name)] },
16
+ "redaction" => "Names and presence only; values are never captured."
17
+ }
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+ require "rbconfig"
5
+
6
+ module Bootprint
7
+ module Collectors
8
+ module Filesystem
9
+ module_function
10
+
11
+ def key = "filesystem"
12
+
13
+ def capture
14
+ {
15
+ "path_separator" => File::ALT_SEPARATOR || File::SEPARATOR,
16
+ "case_sensitive" => case_sensitive?,
17
+ "symlinks_supported" => File.respond_to?(:symlink),
18
+ "temporary_directory" => directory_state(Dir.tmpdir),
19
+ "required_directories" => {
20
+ "current" => directory_state(Dir.pwd),
21
+ "log" => directory_state(File.join(Dir.pwd, "log")),
22
+ "tmp" => directory_state(File.join(Dir.pwd, "tmp"))
23
+ }
24
+ }
25
+ end
26
+
27
+ def directory_state(path)
28
+ {
29
+ "present" => File.directory?(path),
30
+ "writable" => File.directory?(path) && File.writable?(path),
31
+ "path" => Sanitizer.path(path)
32
+ }
33
+ end
34
+
35
+ def case_sensitive?
36
+ !Gem.win_platform? && !RbConfig::CONFIG["host_os"].to_s.include?("darwin")
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "rubygems"
5
+
6
+ module Bootprint
7
+ module Collectors
8
+ module Gems
9
+ module_function
10
+
11
+ def key = "gems"
12
+
13
+ def capture
14
+ lockfile = File.file?("Gemfile.lock") ? "Gemfile.lock" : nil
15
+ checksums = parse_checksums(lockfile)
16
+ specs = resolved_specs
17
+ lock_metadata = parse_lockfile(lockfile)
18
+
19
+ {
20
+ "lockfile_sha256" => lockfile && Digest::SHA256.file(lockfile).hexdigest,
21
+ "platforms" => lock_metadata["platforms"],
22
+ "ruby_version" => lock_metadata["ruby_version"],
23
+ "bundled_with" => lock_metadata["bundled_with"],
24
+ "git_sources" => lock_metadata["git_sources"],
25
+ "path_sources" => lock_metadata["path_sources"],
26
+ "resolved" => specs.sort_by(&:name).to_h do |spec|
27
+ [spec.name, {
28
+ "version" => spec.version.to_s,
29
+ "platform" => spec.platform.to_s,
30
+ "checksum" => checksums["#{spec.name} (#{spec.version})"],
31
+ "native_extensions" => !spec.extensions.empty?,
32
+ "missing_extensions" => spec.respond_to?(:missing_extensions?) && spec.missing_extensions?,
33
+ "prerelease" => spec.version.prerelease?
34
+ }.compact]
35
+ end
36
+ }
37
+ end
38
+
39
+ def resolved_specs
40
+ return Bundler.load.specs.to_a if defined?(Bundler) && Bundler.respond_to?(:load)
41
+
42
+ Gem.loaded_specs.values
43
+ rescue StandardError
44
+ Gem.loaded_specs.values
45
+ end
46
+
47
+ def parse_checksums(lockfile)
48
+ return {} unless lockfile
49
+
50
+ lines = File.readlines(lockfile, chomp: true)
51
+ start = lines.index("CHECKSUMS")
52
+ return {} unless start
53
+
54
+ lines.drop(start + 1).take_while { |line| line.start_with?(" ") }.to_h do |line|
55
+ name, checksum = line.strip.split(" sha256=", 2)
56
+ [name, checksum]
57
+ end
58
+ end
59
+
60
+ def parse_lockfile(lockfile)
61
+ empty = { "platforms" => [], "git_sources" => [], "path_sources" => [] }
62
+ return empty unless lockfile
63
+
64
+ lines = File.readlines(lockfile, chomp: true)
65
+ empty.merge(
66
+ "platforms" => section_values(lines, "PLATFORMS"),
67
+ "ruby_version" => single_section_value(lines, "RUBY VERSION")&.sub(/\Aruby\s+/, ""),
68
+ "bundled_with" => single_section_value(lines, "BUNDLED WITH"),
69
+ "git_sources" => source_values(lines, "GIT", "remote:"),
70
+ "path_sources" => source_values(lines, "PATH", "remote:")
71
+ )
72
+ end
73
+
74
+ def section_values(lines, heading)
75
+ start = lines.index(heading)
76
+ return [] unless start
77
+
78
+ section = lines.drop(start + 1).take_while { |line| line.empty? || line.start_with?(" ") }
79
+ section.filter_map { |line| line.strip unless line.strip.empty? }
80
+ end
81
+
82
+ def single_section_value(lines, heading)
83
+ section_values(lines, heading).first
84
+ end
85
+
86
+ def source_values(lines, heading, prefix)
87
+ indexes = lines.each_index.select { |index| lines[index] == heading }
88
+ indexes.filter_map do |index|
89
+ block = lines.drop(index + 1).take_while { |line| line.empty? || line.start_with?(" ") }
90
+ remote = block.find { |line| line.strip.start_with?(prefix) }
91
+ remote&.strip&.delete_prefix(prefix)&.strip
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "psych"
5
+ require "rbconfig"
6
+
7
+ module Bootprint
8
+ module Collectors
9
+ module Libraries
10
+ module_function
11
+
12
+ def key = "libraries"
13
+
14
+ def capture
15
+ result = {
16
+ "openssl" => {
17
+ "compiled" => OpenSSL::OPENSSL_VERSION,
18
+ "runtime" => OpenSSL.const_defined?(:OPENSSL_LIBRARY_VERSION) ? OpenSSL::OPENSSL_LIBRARY_VERSION : OpenSSL::OPENSSL_VERSION
19
+ },
20
+ "libyaml" => Psych.libyaml_version.join("."),
21
+ "psych" => Psych::VERSION
22
+ }
23
+ result["sqlite"] = sqlite_info if Gem.loaded_specs.key?("sqlite3")
24
+ result["postgresql"] = postgresql_info if Gem.loaded_specs.key?("pg")
25
+ result["mysql"] = mysql_info if Gem.loaded_specs.key?("mysql2")
26
+ result["libc"] = libc_info
27
+ result
28
+ end
29
+
30
+ def sqlite_info
31
+ require "sqlite3"
32
+ { "gem" => SQLite3::VERSION, "runtime" => SQLite3::SQLITE_VERSION }
33
+ rescue LoadError, StandardError => error
34
+ { "capture_error" => Sanitizer.text("#{error.class}: #{error.message}") }
35
+ end
36
+
37
+ def postgresql_info
38
+ require "pg"
39
+ version = PG.library_version
40
+ { "gem" => PG::VERSION, "client" => format_pg_version(version) }
41
+ rescue LoadError, StandardError => error
42
+ { "capture_error" => Sanitizer.text("#{error.class}: #{error.message}") }
43
+ end
44
+
45
+ def format_pg_version(version)
46
+ major = version / 10_000
47
+ minor = (version / 100) % 100
48
+ patch = version % 100
49
+ major >= 10 ? "#{major}.#{patch}" : "#{major}.#{minor}.#{patch}"
50
+ end
51
+
52
+ def mysql_info
53
+ require "mysql2"
54
+ info = Mysql2::Client.info
55
+ { "gem" => Mysql2::VERSION, "client" => info[:version] || info["version"] }
56
+ rescue LoadError, StandardError => error
57
+ { "capture_error" => Sanitizer.text("#{error.class}: #{error.message}") }
58
+ end
59
+
60
+ def libc_info
61
+ host = RbConfig::CONFIG["host_os"].to_s
62
+ family = if host.include?("linux")
63
+ RbConfig::CONFIG["CC"].to_s.include?("musl") ? "musl" : "glibc-or-compatible"
64
+ elsif host.match?(/mswin|mingw/)
65
+ "windows-crt"
66
+ elsif host.include?("darwin")
67
+ "libSystem"
68
+ else
69
+ "unknown"
70
+ end
71
+ { "family" => family }
72
+ end
73
+ end
74
+ end
75
+ end