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,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Bootprint
|
|
6
|
+
module Formatters
|
|
7
|
+
class Sarif
|
|
8
|
+
LEVELS = { critical: "error", error: "error", warning: "warning", info: "note" }.freeze
|
|
9
|
+
|
|
10
|
+
def initialize(report) = @report = report
|
|
11
|
+
|
|
12
|
+
def render
|
|
13
|
+
rules = @report.findings.map(&:rule_id).uniq.map do |id|
|
|
14
|
+
finding = @report.findings.find { |candidate| candidate.rule_id == id }
|
|
15
|
+
{
|
|
16
|
+
"id" => id,
|
|
17
|
+
"name" => id.tr("-", "_"),
|
|
18
|
+
"shortDescription" => { "text" => finding.title },
|
|
19
|
+
"help" => { "text" => finding.remediation&.fetch("summary", "Review this environment difference.") }
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
results = @report.findings.map do |finding|
|
|
23
|
+
result = {
|
|
24
|
+
"ruleId" => finding.rule_id,
|
|
25
|
+
"level" => finding.suppressed ? "none" : LEVELS.fetch(finding.severity),
|
|
26
|
+
"message" => { "text" => [finding.summary, finding.impact, finding.remediation&.fetch("summary", nil)].compact.join(" ") },
|
|
27
|
+
"properties" => { "category" => finding.category.to_s, "evidence" => finding.evidence, "suppressed" => finding.suppressed }
|
|
28
|
+
}
|
|
29
|
+
location = physical_location(finding.source_location)
|
|
30
|
+
result["locations"] = [{ "physicalLocation" => location }] if location
|
|
31
|
+
result["suppressions"] = [{ "kind" => "external", "justification" => finding.suppression_reason }] if finding.suppressed
|
|
32
|
+
result
|
|
33
|
+
end
|
|
34
|
+
driver = { "name" => "Bootprint", "version" => VERSION, "rules" => rules }
|
|
35
|
+
run = { "tool" => { "driver" => driver }, "results" => results }
|
|
36
|
+
output = ::JSON.pretty_generate(
|
|
37
|
+
"version" => "2.1.0",
|
|
38
|
+
"$schema" => "https://json.schemastore.org/sarif-2.1.0.json",
|
|
39
|
+
"runs" => [run]
|
|
40
|
+
)
|
|
41
|
+
"#{output}\n"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def physical_location(location)
|
|
47
|
+
return unless location && File.file?(location["path"])
|
|
48
|
+
|
|
49
|
+
region = location["line"] ? { "startLine" => location["line"] } : nil
|
|
50
|
+
{ "artifactLocation" => { "uri" => location["path"] }, "region" => region }.compact
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "formatters/human"
|
|
4
|
+
require_relative "formatters/json"
|
|
5
|
+
require_relative "formatters/sarif"
|
|
6
|
+
require_relative "formatters/markdown"
|
|
7
|
+
|
|
8
|
+
module Bootprint
|
|
9
|
+
module Formatters
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def render(format, report, color: false)
|
|
13
|
+
case format.to_s
|
|
14
|
+
when "human" then Human.new(report, color:).render
|
|
15
|
+
when "json" then JSON.new(report).render
|
|
16
|
+
when "sarif" then Sarif.new(report).render
|
|
17
|
+
when "markdown" then Markdown.new(report).render
|
|
18
|
+
else raise ConfigurationError, "Unknown format #{format.inspect}; use human, json, sarif, or markdown"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Bootprint
|
|
6
|
+
module InitializerProfiler
|
|
7
|
+
NETWORK_METHODS = %i[connect connect_nonblock tcp start open].freeze
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def install!
|
|
12
|
+
return unless Bootprint.configuration.profile_boot
|
|
13
|
+
return unless defined?(Rails::Initializable::Initializer)
|
|
14
|
+
return if Rails::Initializable::Initializer.ancestors.include?(Instrumentation)
|
|
15
|
+
|
|
16
|
+
ENV.singleton_class.prepend(EnvironmentProbe) unless ENV.singleton_class.ancestors.include?(EnvironmentProbe)
|
|
17
|
+
Rails::Initializable::Initializer.prepend(Instrumentation)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def record_missing_environment(name)
|
|
21
|
+
collector = Thread.current[:bootprint_missing_environment]
|
|
22
|
+
collector << name.to_s if collector
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def config_fingerprint
|
|
26
|
+
return {} unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
27
|
+
|
|
28
|
+
config = Rails.application.config
|
|
29
|
+
%i[eager_load cache_classes time_zone cache_store session_store].to_h do |name|
|
|
30
|
+
value = config.public_send(name) if config.respond_to?(name)
|
|
31
|
+
[name.to_s, value.is_a?(Array) ? value.first.to_s : value.to_s]
|
|
32
|
+
rescue StandardError
|
|
33
|
+
[name.to_s, nil]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
module EnvironmentProbe
|
|
38
|
+
def [](name)
|
|
39
|
+
present = key?(name)
|
|
40
|
+
value = super
|
|
41
|
+
Bootprint::InitializerProfiler.record_missing_environment(name) unless present
|
|
42
|
+
value
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def fetch(name, *arguments, &)
|
|
46
|
+
Bootprint::InitializerProfiler.record_missing_environment(name) unless key?(name)
|
|
47
|
+
super
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
module Instrumentation
|
|
52
|
+
def run(*args)
|
|
53
|
+
start_order = Bootprint::RailsState.initializers&.length.to_i + 1
|
|
54
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
55
|
+
constants_before = Object.constants.to_set
|
|
56
|
+
config_before = Bootprint::InitializerProfiler.config_fingerprint
|
|
57
|
+
missing_environment = []
|
|
58
|
+
Thread.current[:bootprint_missing_environment] = missing_environment
|
|
59
|
+
network_operation = false
|
|
60
|
+
tracer = TracePoint.new(:call, :c_call) do |trace|
|
|
61
|
+
owner = trace.defined_class.to_s
|
|
62
|
+
network_operation = true if NETWORK_METHODS.include?(trace.method_id) && owner.match?(/Socket|Net::HTTP|OpenURI/)
|
|
63
|
+
end
|
|
64
|
+
tracer.enable
|
|
65
|
+
exception = nil
|
|
66
|
+
result = super
|
|
67
|
+
result
|
|
68
|
+
rescue Exception => error # rubocop:disable Lint/RescueException -- record boot failures, then re-raise
|
|
69
|
+
exception = { "class" => error.class.name, "message" => Sanitizer.text(error.message) }
|
|
70
|
+
raise
|
|
71
|
+
ensure
|
|
72
|
+
tracer&.disable
|
|
73
|
+
duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1_000
|
|
74
|
+
loaded = (Object.constants.to_set - constants_before).map(&:to_s).sort
|
|
75
|
+
config_after = Bootprint::InitializerProfiler.config_fingerprint
|
|
76
|
+
mutations = config_after.keys.reject { |key| config_before[key] == config_after[key] }
|
|
77
|
+
Bootprint::RailsState.record_initializer(
|
|
78
|
+
"name" => name.to_s,
|
|
79
|
+
"start_order" => start_order,
|
|
80
|
+
"completion_order" => Bootprint::RailsState.initializers&.length.to_i + 1,
|
|
81
|
+
"duration_ms" => duration.round(3),
|
|
82
|
+
"exception" => exception,
|
|
83
|
+
"loaded_constants" => loaded.take(100),
|
|
84
|
+
"missing_environment_variables" => missing_environment.uniq.sort,
|
|
85
|
+
"network_operation_heuristic" => network_operation,
|
|
86
|
+
"configuration_mutations" => mutations,
|
|
87
|
+
"configuration_mutation_heuristic" => !mutations.empty?
|
|
88
|
+
)
|
|
89
|
+
Thread.current[:bootprint_missing_environment] = nil
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bootprint
|
|
4
|
+
module Plugin
|
|
5
|
+
API_VERSION = "1"
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def api_version(value = nil)
|
|
10
|
+
return API_VERSION unless value
|
|
11
|
+
raise PluginError, "Plugin requires API #{value}; Bootprint provides #{API_VERSION}" unless value.to_s == API_VERSION
|
|
12
|
+
|
|
13
|
+
API_VERSION
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
module Plugins
|
|
18
|
+
Definition = Struct.new(:name, :collector_class, :rules_module, :registration_error, keyword_init: true) do
|
|
19
|
+
def collector(value = nil)
|
|
20
|
+
self.collector_class = value if value
|
|
21
|
+
collector_class
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def rules(value = nil)
|
|
25
|
+
self.rules_module = value if value
|
|
26
|
+
rules_module
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
def register(name, &)
|
|
32
|
+
definition = Definition.new(name: name.to_s)
|
|
33
|
+
definition.instance_eval(&)
|
|
34
|
+
install_rules(definition)
|
|
35
|
+
registry.reject! { |plugin| plugin.name == definition.name }
|
|
36
|
+
registry << definition
|
|
37
|
+
definition
|
|
38
|
+
rescue StandardError => error
|
|
39
|
+
definition ||= Definition.new(name: name.to_s)
|
|
40
|
+
definition.registration_error = Sanitizer.text("#{error.class}: #{error.message}")
|
|
41
|
+
registry << definition
|
|
42
|
+
definition
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def capture(warnings:, strict: false)
|
|
46
|
+
registry.each_with_object({}) do |plugin, result|
|
|
47
|
+
if plugin.registration_error
|
|
48
|
+
handle_failure(plugin, plugin.registration_error, warnings, strict)
|
|
49
|
+
next
|
|
50
|
+
end
|
|
51
|
+
next unless plugin.collector_class
|
|
52
|
+
|
|
53
|
+
begin
|
|
54
|
+
value = plugin.collector_class.respond_to?(:capture) ? plugin.collector_class.capture : plugin.collector_class.new.capture
|
|
55
|
+
result[plugin.name] = Sanitizer.recursive(Schema.stringify(value))
|
|
56
|
+
rescue StandardError => error
|
|
57
|
+
handle_failure(plugin, "#{error.class}: #{error.message}", warnings, strict)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def all = registry.dup
|
|
63
|
+
def reset! = @registry = []
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def registry = @registry ||= []
|
|
68
|
+
|
|
69
|
+
def install_rules(definition)
|
|
70
|
+
rules = definition.rules_module
|
|
71
|
+
return unless rules
|
|
72
|
+
|
|
73
|
+
if rules.respond_to?(:install)
|
|
74
|
+
rules.install
|
|
75
|
+
elsif rules.respond_to?(:register)
|
|
76
|
+
rules.register
|
|
77
|
+
else
|
|
78
|
+
raise PluginError, "#{definition.name} rules must respond to install or register"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def handle_failure(plugin, message, warnings, strict)
|
|
83
|
+
sanitized = Sanitizer.text(message)
|
|
84
|
+
raise PluginError, "Plugin #{plugin.name} failed: #{sanitized}" if strict
|
|
85
|
+
|
|
86
|
+
warnings << { "plugin" => plugin.name, "message" => sanitized, "severity" => "warning" }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Bootprint
|
|
6
|
+
class Policy
|
|
7
|
+
MODES = %w[permissive strict].freeze
|
|
8
|
+
SEVERITIES = %w[info warning error critical].freeze
|
|
9
|
+
TOP_LEVEL_KEYS = %w[version minimum_severity fail_on ignore allow rules redaction mode expected_platforms plugins].freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :path, :data
|
|
12
|
+
|
|
13
|
+
def self.load(path = nil)
|
|
14
|
+
return new(nil, {}) unless path
|
|
15
|
+
|
|
16
|
+
absolute = File.expand_path(path)
|
|
17
|
+
raise ConfigurationError, "#{absolute}: file not found" unless File.file?(absolute)
|
|
18
|
+
|
|
19
|
+
parsed = YAML.safe_load_file(absolute, permitted_classes: [], aliases: false) || {}
|
|
20
|
+
new(absolute, parsed)
|
|
21
|
+
rescue Psych::SyntaxError => error
|
|
22
|
+
raise ConfigurationError, "#{absolute}:#{error.line}:#{error.column}: #{error.problem}"
|
|
23
|
+
rescue Psych::Exception => error
|
|
24
|
+
raise ConfigurationError, "#{absolute}: #{error.message}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(path = nil, data = {})
|
|
28
|
+
@path = path
|
|
29
|
+
@data = Schema.stringify(data)
|
|
30
|
+
validate!
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def minimum_severity
|
|
34
|
+
(data["minimum_severity"] || (strict? ? "warning" : "info")).to_sym
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def fail_on
|
|
38
|
+
Array(data["fail_on"] || (strict? ? %w[warning error critical] : %w[error critical])).map(&:to_s)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def expected_platforms
|
|
42
|
+
Array(data["expected_platforms"]).map(&:to_s)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def optional_environment_variables
|
|
46
|
+
allow = data["allow"]
|
|
47
|
+
return [] unless allow.is_a?(Hash)
|
|
48
|
+
|
|
49
|
+
Array(allow["environment_variables"]).map(&:to_s)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def optional_environment_variable?(name)
|
|
53
|
+
optional_environment_variables.include?(name.to_s)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def allowed_paths
|
|
57
|
+
allow = data["allow"]
|
|
58
|
+
if allow.is_a?(Array)
|
|
59
|
+
allow.map(&:to_s)
|
|
60
|
+
else
|
|
61
|
+
Array(allow.is_a?(Hash) ? allow["paths"] : nil).map(&:to_s)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def redaction_patterns
|
|
66
|
+
Array(data.dig("redaction", "patterns")).map(&:to_s)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def redaction_safe_list
|
|
70
|
+
Array(data.dig("redaction", "safe_list")).map(&:to_s)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def plugin_strict?
|
|
74
|
+
strict? || data.dig("plugins", "strict") == true
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def strict? = data.fetch("mode", "permissive") == "strict"
|
|
78
|
+
|
|
79
|
+
def ignored?(rule_id)
|
|
80
|
+
Array(data["ignore"]).map(&:to_s).include?(rule_id.to_s)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def disabled?(rule_id)
|
|
84
|
+
data.dig("rules", rule_id.to_s, "enabled") == false
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def severity_for(rule_id, default)
|
|
88
|
+
(data.dig("rules", rule_id.to_s, "severity") || default).to_sym
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def suppression_reason(rule_id)
|
|
92
|
+
return "ignored by policy" if ignored?(rule_id)
|
|
93
|
+
return "disabled by policy" if disabled?(rule_id)
|
|
94
|
+
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def apply!
|
|
99
|
+
Bootprint.configuration.optional_environment_names |= optional_environment_variables
|
|
100
|
+
Bootprint.configuration.expected_platforms = expected_platforms
|
|
101
|
+
Bootprint.configuration.redaction_patterns |= redaction_patterns
|
|
102
|
+
Bootprint.configuration.redaction_safe_list |= redaction_safe_list
|
|
103
|
+
Bootprint.configuration.plugin_strict = plugin_strict?
|
|
104
|
+
self
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def explain
|
|
108
|
+
lines = []
|
|
109
|
+
lines << "Policy: #{path || '(built-in defaults)'}"
|
|
110
|
+
lines << "Mode: #{strict? ? 'strict' : 'permissive'}"
|
|
111
|
+
lines << "Minimum reported severity: #{minimum_severity}"
|
|
112
|
+
lines << "Blocking severities: #{fail_on.join(', ')}"
|
|
113
|
+
lines << "Ignored rules: #{Array(data['ignore']).join(', ')}" unless Array(data["ignore"]).empty?
|
|
114
|
+
lines << "Optional environment variables: #{optional_environment_variables.join(', ')}" unless optional_environment_variables.empty?
|
|
115
|
+
lines << "Expected platforms: #{expected_platforms.join(', ')}" unless expected_platforms.empty?
|
|
116
|
+
lines << "Rule overrides: #{Array(data['rules']&.keys).join(', ')}" if data["rules"].is_a?(Hash)
|
|
117
|
+
"#{lines.join("\n")}\n"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def validate!
|
|
123
|
+
error("policy root must be a mapping") unless data.is_a?(Hash)
|
|
124
|
+
unknown = data.keys - TOP_LEVEL_KEYS
|
|
125
|
+
error("unknown keys: #{unknown.join(', ')}", unknown.first) unless unknown.empty?
|
|
126
|
+
error("version must equal 1", "version") unless data.fetch("version", 1) == 1
|
|
127
|
+
validate_severity(data["minimum_severity"], "minimum_severity") if data["minimum_severity"]
|
|
128
|
+
unless Array(data["fail_on"]).all? { |severity| SEVERITIES.include?(severity.to_s) }
|
|
129
|
+
error("fail_on contains an invalid severity", "fail_on")
|
|
130
|
+
end
|
|
131
|
+
mode = data.fetch("mode", "permissive")
|
|
132
|
+
error("mode must be strict or permissive", "mode") unless MODES.include?(mode)
|
|
133
|
+
error("ignore must be an array", "ignore") if data.key?("ignore") && !data["ignore"].is_a?(Array)
|
|
134
|
+
error("rules must be a mapping", "rules") if data.key?("rules") && !data["rules"].is_a?(Hash)
|
|
135
|
+
validate_rule_overrides
|
|
136
|
+
validate_allow
|
|
137
|
+
validate_redaction
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def validate_rule_overrides
|
|
141
|
+
return unless data["rules"].is_a?(Hash)
|
|
142
|
+
|
|
143
|
+
data["rules"].each do |id, settings|
|
|
144
|
+
error("rule #{id} must be a mapping", id) unless settings.is_a?(Hash)
|
|
145
|
+
unknown = settings.keys - %w[enabled severity]
|
|
146
|
+
error("rule #{id} has unknown settings: #{unknown.join(', ')}", id) unless unknown.empty?
|
|
147
|
+
validate_severity(settings["severity"], id) if settings["severity"]
|
|
148
|
+
error("rule #{id} enabled must be true or false", id) if settings.key?("enabled") && ![true, false].include?(settings["enabled"])
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def validate_allow
|
|
153
|
+
allow = data["allow"]
|
|
154
|
+
return if allow.nil? || allow.is_a?(Array)
|
|
155
|
+
|
|
156
|
+
error("allow must be a mapping or legacy array", "allow") unless allow.is_a?(Hash)
|
|
157
|
+
unknown = allow.keys - %w[environment_variables paths]
|
|
158
|
+
error("allow has unknown settings: #{unknown.join(', ')}", "allow") unless unknown.empty?
|
|
159
|
+
allow.each { |key, value| error("allow.#{key} must be an array", key) unless value.is_a?(Array) }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def validate_redaction
|
|
163
|
+
redaction = data["redaction"]
|
|
164
|
+
return unless redaction
|
|
165
|
+
|
|
166
|
+
error("redaction must be a mapping", "redaction") unless redaction.is_a?(Hash)
|
|
167
|
+
unknown = redaction.keys - %w[patterns safe_list]
|
|
168
|
+
error("redaction has unknown settings: #{unknown.join(', ')}", "redaction") unless unknown.empty?
|
|
169
|
+
redaction.each do |key, value|
|
|
170
|
+
error("redaction.#{key} must be an array", key) unless value.is_a?(Array) && value.all?(String)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def validate_severity(value, key)
|
|
175
|
+
error("#{key} must be one of #{SEVERITIES.join(', ')}", key) unless SEVERITIES.include?(value.to_s)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def error(message, key = nil)
|
|
179
|
+
location = path || ".bootprint.yml"
|
|
180
|
+
line = key && source_line(key)
|
|
181
|
+
raise ConfigurationError, "#{location}#{":#{line}" if line}: #{message}"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def source_line(key)
|
|
185
|
+
return unless path && File.file?(path)
|
|
186
|
+
|
|
187
|
+
pattern = /^\s*#{Regexp.escape(key.to_s)}\s*:/
|
|
188
|
+
File.readlines(path, encoding: "UTF-8").index { |line| line.match?(pattern) }&.+(1)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bootprint
|
|
4
|
+
module RailsState
|
|
5
|
+
class << self
|
|
6
|
+
attr_reader :initializers
|
|
7
|
+
|
|
8
|
+
def record_initializer(attributes)
|
|
9
|
+
(@initializers ||= []) << Schema.stringify(attributes)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def reset!
|
|
13
|
+
@initializers = []
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
require_relative "rails_state"
|
|
5
|
+
require_relative "initializer_profiler"
|
|
6
|
+
|
|
7
|
+
Bootprint::InitializerProfiler.install!
|
|
8
|
+
|
|
9
|
+
module Bootprint
|
|
10
|
+
class Railtie < Rails::Railtie
|
|
11
|
+
rake_tasks do
|
|
12
|
+
load File.expand_path("../tasks/bootprint.rake", __dir__)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
initializer "bootprint.observe_initializers", before: :load_config_initializers do
|
|
16
|
+
next unless ENV["BOOTPRINT_INSPECT"] == "1" || Bootprint.configuration.profile_boot
|
|
17
|
+
next if Bootprint.configuration.profile_boot
|
|
18
|
+
|
|
19
|
+
Bootprint::RailsState.reset!
|
|
20
|
+
ActiveSupport::Notifications.subscribe("load_config_initializer.railties") do |*args|
|
|
21
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
22
|
+
path = event.payload[:initializer]
|
|
23
|
+
name = path.respond_to?(:to_path) ? path.to_path : path.to_s
|
|
24
|
+
Bootprint::RailsState.record_initializer(
|
|
25
|
+
"name" => name,
|
|
26
|
+
"start_order" => Bootprint::RailsState.initializers.length + 1,
|
|
27
|
+
"completion_order" => Bootprint::RailsState.initializers.length + 1,
|
|
28
|
+
"duration_ms" => event.duration.round(3),
|
|
29
|
+
"exception" => event.payload[:exception] && { "class" => event.payload[:exception].first },
|
|
30
|
+
"loaded_constants" => [],
|
|
31
|
+
"network_operation_heuristic" => false
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|