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,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bootprint
|
|
4
|
+
module Sanitizer
|
|
5
|
+
REDACTED = "[REDACTED]"
|
|
6
|
+
JWT_PATTERN = /\AeyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\z/
|
|
7
|
+
PRIVATE_KEY_PATTERN = /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/
|
|
8
|
+
URL_CREDENTIAL_PATTERN = %r{\A([a-z][a-z0-9+.-]*://)([^/@\s]+)@}i
|
|
9
|
+
HIGH_ENTROPY_PATTERN = %r{\A[A-Za-z0-9+/_=-]{32,}\z}
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def text(value)
|
|
14
|
+
sanitized = value.to_s.dup
|
|
15
|
+
replacements.each do |path, marker|
|
|
16
|
+
path_variants(path).each do |variant|
|
|
17
|
+
sanitized.gsub!(variant, marker)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
sanitized.gsub(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/, "?")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def path(value)
|
|
24
|
+
text(value)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def recursive(value, patterns: Bootprint.configuration.redaction_patterns, privacy: Bootprint.configuration.privacy)
|
|
28
|
+
case value
|
|
29
|
+
when Hash
|
|
30
|
+
value.each_with_object({}) do |(key, nested), result|
|
|
31
|
+
name = key.to_s
|
|
32
|
+
result[name] = if secret_name?(name, patterns) && !boolean?(nested)
|
|
33
|
+
redaction_metadata(nested)
|
|
34
|
+
elsif safe_digest_name?(name) && !nested.is_a?(Hash) && !nested.is_a?(Array)
|
|
35
|
+
nested.to_s
|
|
36
|
+
else
|
|
37
|
+
recursive(nested, patterns:, privacy:)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
when Array
|
|
41
|
+
value.map { |nested| recursive(nested, patterns:, privacy:) }
|
|
42
|
+
when String
|
|
43
|
+
sanitize_value(value, privacy:)
|
|
44
|
+
else
|
|
45
|
+
value
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def secret_name?(name, patterns = Bootprint.configuration.redaction_patterns)
|
|
50
|
+
return false if Bootprint.configuration.redaction_safe_list.any? { |entry| File.fnmatch?(entry, name, File::FNM_CASEFOLD) }
|
|
51
|
+
|
|
52
|
+
patterns.any? { |pattern| name.upcase.include?(pattern.to_s.upcase) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def safe_digest_name?(name)
|
|
56
|
+
name.match?(/(?:checksum|sha256|digest)\z/i)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def boolean?(value)
|
|
60
|
+
[true, false].include?(value)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def sensitive_value?(value)
|
|
64
|
+
string = value.to_s
|
|
65
|
+
PRIVATE_KEY_PATTERN.match?(string) || JWT_PATTERN.match?(string) ||
|
|
66
|
+
URL_CREDENTIAL_PATTERN.match?(string) || high_entropy?(string)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def sanitize_value(value, privacy: :standard)
|
|
70
|
+
result = text(value)
|
|
71
|
+
result = result.gsub(URL_CREDENTIAL_PATTERN, "\\1#{REDACTED}@")
|
|
72
|
+
result = REDACTED if PRIVATE_KEY_PATTERN.match?(result) || JWT_PATTERN.match?(result) || high_entropy?(result)
|
|
73
|
+
result = "<HOST>" if privacy.to_sym == :strict && hostname_like?(result)
|
|
74
|
+
result
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def high_entropy?(value)
|
|
78
|
+
return false unless HIGH_ENTROPY_PATTERN.match?(value)
|
|
79
|
+
|
|
80
|
+
value.chars.uniq.length >= 16
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def hostname_like?(value)
|
|
84
|
+
value.match?(/\A(?=.{1,253}\z)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}\z/i)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def redaction_metadata(value)
|
|
88
|
+
{ "present" => !value.nil?, "redacted" => true }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def replacements
|
|
92
|
+
app_root = File.expand_path(Dir.pwd)
|
|
93
|
+
home = begin
|
|
94
|
+
File.expand_path(Dir.home)
|
|
95
|
+
rescue ArgumentError
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
[[app_root, "<APP_ROOT>"], [home, "<HOME>"]].reject { |path, _marker| path.nil? || path.empty? }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def path_variants(path)
|
|
102
|
+
[path, path.tr("\\", "/"), path.tr("/", "\\")].uniq.sort_by { |variant| -variant.length }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bootprint
|
|
4
|
+
module Schema
|
|
5
|
+
CURRENT_VERSION = 2
|
|
6
|
+
ENVIRONMENT_SECTIONS = %w[runtime dependencies native_libraries configuration filesystem operating_system].freeze
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def migrate(data)
|
|
11
|
+
raise InvalidSnapshotError, "Snapshot root must be a JSON object" unless data.is_a?(Hash)
|
|
12
|
+
|
|
13
|
+
version = data.is_a?(Hash) ? data["schema_version"] || data[:schema_version] : nil
|
|
14
|
+
case version
|
|
15
|
+
when CURRENT_VERSION then stringify(data)
|
|
16
|
+
when 1 then migrate_v1(stringify(data))
|
|
17
|
+
when nil then raise InvalidSnapshotError, "Snapshot does not declare schema_version"
|
|
18
|
+
else
|
|
19
|
+
if version.is_a?(Integer) && version > CURRENT_VERSION
|
|
20
|
+
raise InvalidSnapshotError, "Snapshot schema #{version} is newer than supported schema #{CURRENT_VERSION}; upgrade Bootprint"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
raise InvalidSnapshotError, "Unsupported snapshot schema #{version.inspect}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def validate!(data)
|
|
28
|
+
raise InvalidSnapshotError, "Snapshot root must be a JSON object" unless data.is_a?(Hash)
|
|
29
|
+
raise InvalidSnapshotError, "schema_version must equal #{CURRENT_VERSION}" unless data["schema_version"] == CURRENT_VERSION
|
|
30
|
+
raise InvalidSnapshotError, "generated_at must be an ISO-8601 string" unless data["generated_at"].is_a?(String)
|
|
31
|
+
raise InvalidSnapshotError, "bootprint_version must be a string" unless data["bootprint_version"].is_a?(String)
|
|
32
|
+
|
|
33
|
+
environment = data["environment"]
|
|
34
|
+
raise InvalidSnapshotError, "environment must be an object" unless environment.is_a?(Hash)
|
|
35
|
+
|
|
36
|
+
missing = ENVIRONMENT_SECTIONS - environment.keys
|
|
37
|
+
raise InvalidSnapshotError, "environment is missing sections: #{missing.join(', ')}" unless missing.empty?
|
|
38
|
+
|
|
39
|
+
invalid = ENVIRONMENT_SECTIONS.reject { |key| environment[key].is_a?(Hash) }
|
|
40
|
+
raise InvalidSnapshotError, "environment sections must be objects: #{invalid.join(', ')}" unless invalid.empty?
|
|
41
|
+
|
|
42
|
+
variables = environment.dig("configuration", "environment_variables")
|
|
43
|
+
unless variables.is_a?(Hash) && variables.values.all? { |value| [true, false].include?(value) }
|
|
44
|
+
raise InvalidSnapshotError, "configuration.environment_variables must map names to booleans"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
true
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def migrate_v1(old)
|
|
51
|
+
known = %w[schema_version metadata runtime toolchain gems libraries environment operating_system rails]
|
|
52
|
+
unknown = old.except(*known)
|
|
53
|
+
rails = old["rails"] || {}
|
|
54
|
+
{
|
|
55
|
+
"schema_version" => CURRENT_VERSION,
|
|
56
|
+
"generated_at" => old.dig("metadata", "captured_at") || Time.now.utc.iso8601,
|
|
57
|
+
"bootprint_version" => VERSION,
|
|
58
|
+
"environment" => {
|
|
59
|
+
"name" => old.dig("metadata", "label"),
|
|
60
|
+
"runtime" => old["runtime"] || {},
|
|
61
|
+
"dependencies" => {
|
|
62
|
+
"toolchain" => old["toolchain"] || {},
|
|
63
|
+
"gems" => old.dig("gems", "resolved") || {},
|
|
64
|
+
"lockfile" => { "sha256" => old.dig("gems", "lockfile_sha256"), "platforms" => [] }.compact
|
|
65
|
+
},
|
|
66
|
+
"native_libraries" => old["libraries"] || {},
|
|
67
|
+
"configuration" => {
|
|
68
|
+
"environment_variables" => old.dig("environment", "variables") || {},
|
|
69
|
+
"required_environment_variables" => [],
|
|
70
|
+
"optional_environment_variables" => [],
|
|
71
|
+
"rails" => rails
|
|
72
|
+
},
|
|
73
|
+
"filesystem" => default_filesystem,
|
|
74
|
+
"operating_system" => old["operating_system"] || {}
|
|
75
|
+
}.compact,
|
|
76
|
+
"capture" => { "migrated_from" => 1 },
|
|
77
|
+
"extensions" => unknown
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def default_filesystem
|
|
82
|
+
{
|
|
83
|
+
"path_separator" => File::ALT_SEPARATOR || File::SEPARATOR,
|
|
84
|
+
"temporary_directory" => { "present" => true, "writable" => true },
|
|
85
|
+
"required_directories" => {}
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def stringify(value)
|
|
90
|
+
case value
|
|
91
|
+
when Hash then value.to_h { |key, nested| [key.to_s, stringify(nested)] }
|
|
92
|
+
when Array then value.map { |nested| stringify(nested) }
|
|
93
|
+
else value
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def deterministic(value)
|
|
98
|
+
case value
|
|
99
|
+
when Hash then value.keys.sort.to_h { |key| [key, deterministic(value[key])] }
|
|
100
|
+
when Array
|
|
101
|
+
normalized = value.map { |nested| deterministic(nested) }
|
|
102
|
+
normalized.all? { |item| scalar?(item) } ? normalized.sort_by(&:to_s) : normalized
|
|
103
|
+
else value
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def scalar?(value)
|
|
108
|
+
value.nil? || value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bootprint
|
|
4
|
+
module Security
|
|
5
|
+
class Auditor
|
|
6
|
+
Issue = Struct.new(:path, :kind, :message, keyword_init: true) do
|
|
7
|
+
def to_h = { "path" => path, "kind" => kind, "message" => message }
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def initialize(snapshot)
|
|
11
|
+
@snapshot = snapshot
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def audit
|
|
15
|
+
issues = []
|
|
16
|
+
walk(@snapshot.data, [], issues)
|
|
17
|
+
issues
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def walk(value, path, issues)
|
|
23
|
+
case value
|
|
24
|
+
when Hash
|
|
25
|
+
value.each do |key, nested|
|
|
26
|
+
current = path + [key]
|
|
27
|
+
if Sanitizer.secret_name?(key) && !presence_boolean?(nested) && !redacted_metadata?(nested)
|
|
28
|
+
issues << Issue.new(path: current.join("."), kind: "secret-name",
|
|
29
|
+
message: "Secret-like field is not represented by redaction metadata.")
|
|
30
|
+
end
|
|
31
|
+
walk(nested, current, issues)
|
|
32
|
+
end
|
|
33
|
+
when Array
|
|
34
|
+
value.each_with_index { |nested, index| walk(nested, path + [index], issues) }
|
|
35
|
+
when String
|
|
36
|
+
return if Sanitizer.safe_digest_name?(path.last.to_s)
|
|
37
|
+
|
|
38
|
+
if Sanitizer.sensitive_value?(value)
|
|
39
|
+
issues << Issue.new(path: path.join("."), kind: "sensitive-value",
|
|
40
|
+
message: "Value resembles a credential, token, private key, or credential-bearing URL.")
|
|
41
|
+
elsif absolute_home_path?(value)
|
|
42
|
+
issues << Issue.new(path: path.join("."), kind: "home-path", message: "Absolute user-home path was not normalized.")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def redacted_metadata?(value)
|
|
48
|
+
value.is_a?(Hash) && value["redacted"] == true && value.keys.all? { |key| %w[present source redacted].include?(key) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def presence_boolean?(value)
|
|
52
|
+
[true, false].include?(value)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def absolute_home_path?(value)
|
|
56
|
+
home = File.expand_path(Dir.home)
|
|
57
|
+
value.include?(home) || value.include?(home.tr("\\", "/"))
|
|
58
|
+
rescue ArgumentError
|
|
59
|
+
false
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require_relative "collectors/runtime"
|
|
7
|
+
require_relative "collectors/toolchain"
|
|
8
|
+
require_relative "collectors/gems"
|
|
9
|
+
require_relative "collectors/libraries"
|
|
10
|
+
require_relative "collectors/environment"
|
|
11
|
+
require_relative "collectors/filesystem"
|
|
12
|
+
require_relative "collectors/operating_system"
|
|
13
|
+
require_relative "collectors/rails"
|
|
14
|
+
|
|
15
|
+
module Bootprint
|
|
16
|
+
class Snapshot
|
|
17
|
+
SCHEMA_VERSION = Schema::CURRENT_VERSION
|
|
18
|
+
NON_SEMANTIC_PATHS = %w[generated_at capture.duration_ms capture.plugins].freeze
|
|
19
|
+
|
|
20
|
+
attr_reader :data
|
|
21
|
+
|
|
22
|
+
def self.capture(label: nil, privacy: Bootprint.configuration.privacy)
|
|
23
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
24
|
+
Bootprint.configuration.privacy = privacy.to_sym
|
|
25
|
+
warnings = []
|
|
26
|
+
environment = {
|
|
27
|
+
"name" => label,
|
|
28
|
+
"runtime" => safely_collect(Collectors::Runtime, warnings),
|
|
29
|
+
"dependencies" => dependency_data(warnings),
|
|
30
|
+
"native_libraries" => safely_collect(Collectors::Libraries, warnings),
|
|
31
|
+
"configuration" => configuration_data(warnings),
|
|
32
|
+
"filesystem" => safely_collect(Collectors::Filesystem, warnings),
|
|
33
|
+
"operating_system" => safely_collect(Collectors::OperatingSystem, warnings)
|
|
34
|
+
}.compact
|
|
35
|
+
|
|
36
|
+
plugin_data = if defined?(Bootprint::Plugins)
|
|
37
|
+
Bootprint::Plugins.capture(warnings:, strict: Bootprint.configuration.plugin_strict)
|
|
38
|
+
else
|
|
39
|
+
{}
|
|
40
|
+
end
|
|
41
|
+
environment["plugins"] = plugin_data unless plugin_data.empty?
|
|
42
|
+
duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1_000
|
|
43
|
+
new({
|
|
44
|
+
"schema_version" => SCHEMA_VERSION,
|
|
45
|
+
"generated_at" => Time.now.utc.iso8601,
|
|
46
|
+
"bootprint_version" => VERSION,
|
|
47
|
+
"environment" => Sanitizer.recursive(environment, privacy:),
|
|
48
|
+
"capture" => {
|
|
49
|
+
"duration_ms" => duration.round(3),
|
|
50
|
+
"privacy" => privacy.to_s,
|
|
51
|
+
"warnings" => warnings
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.load(path)
|
|
57
|
+
parsed = JSON.parse(File.read(path, encoding: "UTF-8"))
|
|
58
|
+
new(parsed)
|
|
59
|
+
rescue JSON::ParserError => error
|
|
60
|
+
raise InvalidSnapshotError, "#{File.expand_path(path)} is not valid JSON: #{error.message}"
|
|
61
|
+
rescue Errno::ENOENT
|
|
62
|
+
raise InvalidSnapshotError, "Snapshot not found: #{File.expand_path(path)}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.safely_collect(collector, warnings)
|
|
66
|
+
collector.capture
|
|
67
|
+
rescue StandardError => error
|
|
68
|
+
warnings << {
|
|
69
|
+
"collector" => collector.key,
|
|
70
|
+
"message" => Sanitizer.text("#{error.class}: #{error.message}"),
|
|
71
|
+
"severity" => "warning"
|
|
72
|
+
}
|
|
73
|
+
{ "capture_error" => Sanitizer.text("#{error.class}: #{error.message}") }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.dependency_data(warnings)
|
|
77
|
+
gems = safely_collect(Collectors::Gems, warnings)
|
|
78
|
+
{
|
|
79
|
+
"toolchain" => safely_collect(Collectors::Toolchain, warnings),
|
|
80
|
+
"gems" => gems["resolved"] || {},
|
|
81
|
+
"lockfile" => gems.except("resolved")
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.configuration_data(warnings)
|
|
86
|
+
environment = safely_collect(Collectors::Environment, warnings)
|
|
87
|
+
rails = safely_collect(Collectors::Rails, warnings)
|
|
88
|
+
{
|
|
89
|
+
"environment_variables" => environment["variables"] || {},
|
|
90
|
+
"required_environment_variables" => Bootprint.configuration.required_environment_names.sort,
|
|
91
|
+
"optional_environment_variables" => Bootprint.configuration.optional_environment_names.sort,
|
|
92
|
+
"rails" => rails || {}
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def initialize(data)
|
|
97
|
+
migrated = Schema.migrate(Schema.stringify(data))
|
|
98
|
+
Schema.validate!(migrated)
|
|
99
|
+
@data = Schema.deterministic(migrated)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def write(path)
|
|
103
|
+
directory = File.dirname(File.expand_path(path))
|
|
104
|
+
FileUtils.mkdir_p(directory)
|
|
105
|
+
File.write(path, "#{JSON.pretty_generate(data)}\n")
|
|
106
|
+
path
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def [](key) = data[key.to_s]
|
|
110
|
+
def environment = data["environment"]
|
|
111
|
+
def name = environment["name"] || "unnamed"
|
|
112
|
+
|
|
113
|
+
def semantic_data
|
|
114
|
+
Marshal.load(Marshal.dump(data)).tap do |copy|
|
|
115
|
+
copy.delete("generated_at")
|
|
116
|
+
copy.delete("bootprint_version")
|
|
117
|
+
copy["capture"]&.delete("duration_ms")
|
|
118
|
+
copy["capture"]&.delete("warnings")
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
data/lib/bootprint.rb
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "bootprint/version"
|
|
4
|
+
require_relative "bootprint/errors"
|
|
5
|
+
require_relative "bootprint/configuration"
|
|
6
|
+
require_relative "bootprint/sanitizer"
|
|
7
|
+
require_relative "bootprint/schema"
|
|
8
|
+
require_relative "bootprint/plugins"
|
|
9
|
+
require_relative "bootprint/snapshot"
|
|
10
|
+
require_relative "bootprint/diff"
|
|
11
|
+
require_relative "bootprint/doctor"
|
|
12
|
+
require_relative "bootprint/policy"
|
|
13
|
+
|
|
14
|
+
module Bootprint
|
|
15
|
+
class << self
|
|
16
|
+
def configuration
|
|
17
|
+
@configuration ||= Configuration.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def configure
|
|
21
|
+
yield(configuration)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def rule(name, severity: nil, &block)
|
|
25
|
+
Rules.define(name, severity: severity, &block)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def capture(label: nil, **options)
|
|
29
|
+
Snapshot.capture(label:, **options)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
require_relative "bootprint/rules"
|
|
35
|
+
require_relative "bootprint/diagnosis"
|
|
36
|
+
require_relative "bootprint/analysis"
|
|
37
|
+
|
|
38
|
+
require_relative "bootprint/railtie" if defined?(Rails::Railtie)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :bootprint do
|
|
4
|
+
desc "Capture a sanitized Rails runtime fingerprint"
|
|
5
|
+
task capture: :environment do
|
|
6
|
+
require "bootprint"
|
|
7
|
+
path = ENV.fetch("BOOTPRINT_OUTPUT", "bootprint.lock")
|
|
8
|
+
Bootprint.capture(label: Rails.env.to_s).write(path)
|
|
9
|
+
puts "Captured Bootprint snapshot to #{path}"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
desc "Run Bootprint Rails runtime diagnostics"
|
|
13
|
+
task doctor: :environment do
|
|
14
|
+
require "bootprint/cli"
|
|
15
|
+
exit_code = Bootprint::CLI.start(["doctor"])
|
|
16
|
+
abort "Bootprint doctor found blocking issues" unless exit_code.zero?
|
|
17
|
+
end
|
|
18
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: bootprint
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Magnexis
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Bootprint captures sanitized Ruby and Rails runtime fingerprints, diagnoses
|
|
13
|
+
dangerous environment drift, and provides policy-aware remediation for CI, Docker,
|
|
14
|
+
staging, and production.
|
|
15
|
+
email:
|
|
16
|
+
- hello@magnexis.com
|
|
17
|
+
executables:
|
|
18
|
+
- bootprint
|
|
19
|
+
extensions: []
|
|
20
|
+
extra_rdoc_files: []
|
|
21
|
+
files:
|
|
22
|
+
- ".bootprint.yml.example"
|
|
23
|
+
- ARCHITECTURE.md
|
|
24
|
+
- CHANGELOG.md
|
|
25
|
+
- CODE_OF_CONDUCT.md
|
|
26
|
+
- CONTRIBUTING.md
|
|
27
|
+
- LICENSE
|
|
28
|
+
- README.md
|
|
29
|
+
- RELEASE.md
|
|
30
|
+
- ROADMAP.md
|
|
31
|
+
- SECURITY.md
|
|
32
|
+
- assets/branding/README.md
|
|
33
|
+
- assets/branding/bootprint-logo-128.png
|
|
34
|
+
- assets/branding/bootprint-logo-512.png
|
|
35
|
+
- assets/branding/bootprint-logo-64.png
|
|
36
|
+
- assets/branding/bootprint-logo.png
|
|
37
|
+
- docs/capturing.md
|
|
38
|
+
- docs/ci.md
|
|
39
|
+
- docs/comparing.md
|
|
40
|
+
- docs/custom-rules.md
|
|
41
|
+
- docs/docker.md
|
|
42
|
+
- docs/findings.md
|
|
43
|
+
- docs/installation.md
|
|
44
|
+
- docs/maintainer-setup.md
|
|
45
|
+
- docs/plugins.md
|
|
46
|
+
- docs/policy.md
|
|
47
|
+
- docs/privacy.md
|
|
48
|
+
- docs/quick-start.md
|
|
49
|
+
- docs/rails.md
|
|
50
|
+
- docs/snapshot-schema.md
|
|
51
|
+
- docs/troubleshooting.md
|
|
52
|
+
- exe/bootprint
|
|
53
|
+
- lib/bootprint.rb
|
|
54
|
+
- lib/bootprint/analysis.rb
|
|
55
|
+
- lib/bootprint/cli.rb
|
|
56
|
+
- lib/bootprint/collectors/environment.rb
|
|
57
|
+
- lib/bootprint/collectors/filesystem.rb
|
|
58
|
+
- lib/bootprint/collectors/gems.rb
|
|
59
|
+
- lib/bootprint/collectors/libraries.rb
|
|
60
|
+
- lib/bootprint/collectors/operating_system.rb
|
|
61
|
+
- lib/bootprint/collectors/rails.rb
|
|
62
|
+
- lib/bootprint/collectors/runtime.rb
|
|
63
|
+
- lib/bootprint/collectors/toolchain.rb
|
|
64
|
+
- lib/bootprint/configuration.rb
|
|
65
|
+
- lib/bootprint/diagnosis.rb
|
|
66
|
+
- lib/bootprint/diff.rb
|
|
67
|
+
- lib/bootprint/docker.rb
|
|
68
|
+
- lib/bootprint/doctor.rb
|
|
69
|
+
- lib/bootprint/errors.rb
|
|
70
|
+
- lib/bootprint/formatters.rb
|
|
71
|
+
- lib/bootprint/formatters/human.rb
|
|
72
|
+
- lib/bootprint/formatters/json.rb
|
|
73
|
+
- lib/bootprint/formatters/markdown.rb
|
|
74
|
+
- lib/bootprint/formatters/sarif.rb
|
|
75
|
+
- lib/bootprint/initializer_profiler.rb
|
|
76
|
+
- lib/bootprint/plugins.rb
|
|
77
|
+
- lib/bootprint/policy.rb
|
|
78
|
+
- lib/bootprint/rails_state.rb
|
|
79
|
+
- lib/bootprint/railtie.rb
|
|
80
|
+
- lib/bootprint/rules.rb
|
|
81
|
+
- lib/bootprint/rules/builtin.rb
|
|
82
|
+
- lib/bootprint/rules/finding.rb
|
|
83
|
+
- lib/bootprint/rules/registry.rb
|
|
84
|
+
- lib/bootprint/rules/rule.rb
|
|
85
|
+
- lib/bootprint/sanitizer.rb
|
|
86
|
+
- lib/bootprint/schema.rb
|
|
87
|
+
- lib/bootprint/security/auditor.rb
|
|
88
|
+
- lib/bootprint/snapshot.rb
|
|
89
|
+
- lib/bootprint/version.rb
|
|
90
|
+
- lib/tasks/bootprint.rake
|
|
91
|
+
homepage: https://github.com/theworker02/bootprint
|
|
92
|
+
licenses:
|
|
93
|
+
- MIT
|
|
94
|
+
metadata:
|
|
95
|
+
source_code_uri: https://github.com/theworker02/bootprint/tree/main
|
|
96
|
+
homepage_uri: https://github.com/theworker02/bootprint
|
|
97
|
+
documentation_uri: https://github.com/theworker02/bootprint/tree/main/docs
|
|
98
|
+
changelog_uri: https://github.com/theworker02/bootprint/blob/main/CHANGELOG.md
|
|
99
|
+
bug_tracker_uri: https://github.com/theworker02/bootprint/issues
|
|
100
|
+
funding_uri: https://github.com/sponsors/theworker02
|
|
101
|
+
allowed_push_host: https://rubygems.org
|
|
102
|
+
rubygems_mfa_required: 'true'
|
|
103
|
+
rdoc_options: []
|
|
104
|
+
require_paths:
|
|
105
|
+
- lib
|
|
106
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
107
|
+
requirements:
|
|
108
|
+
- - ">="
|
|
109
|
+
- !ruby/object:Gem::Version
|
|
110
|
+
version: '3.1'
|
|
111
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
112
|
+
requirements:
|
|
113
|
+
- - ">="
|
|
114
|
+
- !ruby/object:Gem::Version
|
|
115
|
+
version: 3.3.0
|
|
116
|
+
requirements: []
|
|
117
|
+
rubygems_version: 3.6.9
|
|
118
|
+
specification_version: 4
|
|
119
|
+
summary: Diagnose runtime-environment drift in Ruby applications
|
|
120
|
+
test_files: []
|