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