necropsy 0.1.0 → 0.3.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 +4 -4
- data/.rubocop.yml +33 -0
- data/CHANGELOG.md +44 -0
- data/MEASUREMENTS.md +112 -0
- data/README.md +233 -16
- data/Rakefile +3 -1
- data/bench/README.md +92 -0
- data/bench/audit.rb +113 -0
- data/bench/audits/0.2.1/audit.json +501 -0
- data/bench/audits/0.2.1/audit.md +55 -0
- data/bench/audits/0.2.1/baseline_performance.yml +16 -0
- data/bench/audits/0.2.1/config.yml +42 -0
- data/bench/audits/0.2.1/review.yml +56 -0
- data/bench/audits/0.2.1/review_queue.yml +3651 -0
- data/bench/corpora/v1/README.md +30 -0
- data/bench/corpora/v1/labels.yml +37 -0
- data/bench/corpora/v1/manifest.yml +63 -0
- data/bench/corpora/v1/rubocop.necropsy.yml +7 -0
- data/bench/corpora/v1/self.necropsy.yml +8 -0
- data/bench/corpora/v1/tools/debride.yml +42 -0
- data/bench/corpora/v1/tools/spoom.yml +12 -0
- data/bench/corpora/v1/tools/type_aware.yml +11 -0
- data/bench/golden/v1/candidate_union.json +21918 -0
- data/bench/golden/v1/metadata.json +12 -0
- data/bench/golden/v1/reports/dynamic_evidence.json +146 -0
- data/bench/golden/v1/reports/plain_ruby.json +135 -0
- data/bench/golden/v1/reports/rails.json +176 -0
- data/bench/golden/v1/reports/rubocop_1_75_0.json +26848 -0
- data/bench/golden/v1/reports/self.json +3449 -0
- data/bench/review_queue.rb +35 -0
- data/bench/run.rb +31 -0
- data/bench/schema/candidate-union-v1.schema.json +70 -0
- data/docs/impv_implementation_matrix.md +179 -0
- data/docs/migrations/0.2.1.md +57 -0
- data/docs/migrations/0.3.0.md +207 -0
- data/docs/migrations/0.4.0.md +13 -0
- data/docs/necropsy_performance_adr.md +43 -0
- data/docs/necropsy_scope_decisions.md +58 -0
- data/docs/necropsy_type_facts_adr.md +22 -0
- data/gemfiles/prism_min.gemfile +9 -0
- data/gemfiles/prism_min.gemfile.lock +50 -0
- data/lib/necropsy/analyzer.rb +121 -2
- data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +81 -19
- data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +104 -10
- data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +74 -250
- data/lib/necropsy/analyzers/dynamic/coverband_payload_set.rb +149 -0
- data/lib/necropsy/analyzers/dynamic/observation_policy.rb +96 -0
- data/lib/necropsy/analyzers/dynamic/redis_input_limits.rb +121 -0
- data/lib/necropsy/analyzers/dynamic/redis_nonblocking_io.rb +94 -0
- data/lib/necropsy/analyzers/dynamic/redis_payload_loader.rb +165 -0
- data/lib/necropsy/analyzers/dynamic/redis_transport.rb +217 -0
- data/lib/necropsy/analyzers/dynamic/runtime_reference.rb +96 -0
- data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +225 -20
- data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +3 -1
- data/lib/necropsy/analyzers/legacy_result_adapter.rb +226 -0
- data/lib/necropsy/analyzers/static/cha.rb +33 -69
- data/lib/necropsy/analyzers/static/name_resolution.rb +151 -21
- data/lib/necropsy/analyzers/static/rta.rb +249 -25
- data/lib/necropsy/ast_scanner/call_recording.rb +275 -0
- data/lib/necropsy/ast_scanner/call_site_creation.rb +54 -0
- data/lib/necropsy/ast_scanner/definition_creation.rb +43 -0
- data/lib/necropsy/ast_scanner/dsl_macros.rb +532 -0
- data/lib/necropsy/ast_scanner/method_definitions.rb +358 -0
- data/lib/necropsy/ast_scanner/references.rb +115 -0
- data/lib/necropsy/ast_scanner/ruby_semantics.rb +263 -0
- data/lib/necropsy/ast_scanner/traversal.rb +347 -0
- data/lib/necropsy/ast_scanner/value_definitions.rb +78 -0
- data/lib/necropsy/ast_scanner.rb +89 -564
- data/lib/necropsy/bench/candidate_union.rb +555 -0
- data/lib/necropsy/bench/claim_gate.rb +112 -0
- data/lib/necropsy/bench/evaluator.rb +356 -24
- data/lib/necropsy/bench/finding_facts.rb +152 -0
- data/lib/necropsy/bench/precision_gate.rb +144 -0
- data/lib/necropsy/bench/release_audit/adversarial_runner.rb +56 -0
- data/lib/necropsy/bench/release_audit/artifact_writer.rb +112 -0
- data/lib/necropsy/bench/release_audit/config_validator.rb +112 -0
- data/lib/necropsy/bench/release_audit/git_snapshot.rb +36 -0
- data/lib/necropsy/bench/release_audit/performance_gate.rb +165 -0
- data/lib/necropsy/bench/release_audit/run_provenance.rb +133 -0
- data/lib/necropsy/bench/release_audit.rb +360 -0
- data/lib/necropsy/bench/report_normalizer.rb +140 -0
- data/lib/necropsy/bench/review_queue.rb +154 -0
- data/lib/necropsy/bench/safety_mutation_harness.rb +59 -0
- data/lib/necropsy/bench/seed_runner.rb +408 -0
- data/lib/necropsy/bounded_canonicalizer.rb +218 -0
- data/lib/necropsy/cache/scan_cache.rb +94 -18
- data/lib/necropsy/call_site_identity.rb +54 -0
- data/lib/necropsy/cli.rb +296 -53
- data/lib/necropsy/clock.rb +40 -0
- data/lib/necropsy/confidence/scorer.rb +181 -58
- data/lib/necropsy/configuration.rb +339 -18
- data/lib/necropsy/convention_rules.rb +138 -0
- data/lib/necropsy/definition_identity/canonical_digest.rb +278 -0
- data/lib/necropsy/definition_identity.rb +37 -0
- data/lib/necropsy/diagnostics.rb +342 -0
- data/lib/necropsy/embedded_ruby.rb +55 -0
- data/lib/necropsy/entry_points/plain.rb +125 -7
- data/lib/necropsy/entry_points/rails.rb +422 -105
- data/lib/necropsy/entry_points/test.rb +6 -1
- data/lib/necropsy/flow_interpreter.rb +460 -0
- data/lib/necropsy/graph/blocker_matching.rb +338 -0
- data/lib/necropsy/graph/call_graph.rb +1234 -62
- data/lib/necropsy/graph/definition_index.rb +149 -0
- data/lib/necropsy/graph/dynamic_evidence_tracking.rb +206 -0
- data/lib/necropsy/graph/evidence_store.rb +213 -0
- data/lib/necropsy/graph/resolution_store.rb +497 -0
- data/lib/necropsy/graph_self_check.rb +79 -0
- data/lib/necropsy/guardrail/baseline.rb +359 -13
- data/lib/necropsy/guardrail/diff.rb +5 -2
- data/lib/necropsy/guardrail/quarantine.rb +101 -10
- data/lib/necropsy/load_graph.rb +206 -0
- data/lib/necropsy/models.rb +894 -21
- data/lib/necropsy/performance_profiler.rb +108 -0
- data/lib/necropsy/project.rb +361 -13
- data/lib/necropsy/reachability/engine.rb +80 -15
- data/lib/necropsy/reference_barrier.rb +458 -0
- data/lib/necropsy/report.rb +153 -14
- data/lib/necropsy/reporter.rb +441 -19
- data/lib/necropsy/runner.rb +258 -14
- data/lib/necropsy/runtime_feedback.rb +136 -0
- data/lib/necropsy/semantics_matrix.rb +153 -0
- data/lib/necropsy/trace_point_runtime.rb +19 -0
- data/lib/necropsy/type_facts.rb +53 -0
- data/lib/necropsy/version.rb +1 -1
- data/lib/necropsy/why_not_explanation.rb +436 -0
- data/lib/necropsy/why_not_renderer.rb +197 -0
- data/lib/necropsy/world_policy.rb +90 -0
- data/lib/necropsy.rb +39 -2
- data/schema/necropsy-report-v2.schema.json +366 -0
- data/script/measure.rb +20 -0
- metadata +106 -2
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Necropsy
|
|
4
|
+
module Analyzers
|
|
5
|
+
module Dynamic
|
|
6
|
+
class CoverbandPayloadSet
|
|
7
|
+
PAYLOAD_KEYS = %w[files coverage runtime production nodes executed observation].freeze
|
|
8
|
+
|
|
9
|
+
def initialize(limits:, deadline:)
|
|
10
|
+
@limits = limits
|
|
11
|
+
@deadline = deadline
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def merge(payloads)
|
|
15
|
+
payloads.compact.reduce({}) do |merged, payload|
|
|
16
|
+
validate_payload_hash!(payload)
|
|
17
|
+
deadline.check!
|
|
18
|
+
limits.validate_payload!(merge_payload(merged, payload))
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def from_hash_entries(entries, &)
|
|
23
|
+
domain_error('Malformed Redis hash response') unless entries.is_a?(Array) && entries.length.even?
|
|
24
|
+
|
|
25
|
+
hash = entries.each_slice(2).to_h
|
|
26
|
+
return coverband_hash_payload(hash) if coverband_hash_payload?(hash)
|
|
27
|
+
|
|
28
|
+
generic_hash_payload(hash, &)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def payload_shape?(value)
|
|
32
|
+
value.is_a?(Hash) && value.keys.any? { |key| PAYLOAD_KEYS.include?(key.to_s) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
attr_reader :limits, :deadline
|
|
38
|
+
|
|
39
|
+
def generic_hash_payload(hash)
|
|
40
|
+
payloads = []
|
|
41
|
+
files = {}
|
|
42
|
+
hash.each do |field, value|
|
|
43
|
+
deadline.check!
|
|
44
|
+
parsed = yield(value)
|
|
45
|
+
if payload_shape?(parsed)
|
|
46
|
+
payloads << parsed
|
|
47
|
+
elsif parsed.is_a?(Hash) || parsed.is_a?(Array)
|
|
48
|
+
files[field.to_s] = parsed
|
|
49
|
+
else
|
|
50
|
+
domain_error('Invalid Redis coverage payload value')
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
payloads << { 'files' => files } unless files.empty?
|
|
55
|
+
merge(payloads)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def coverband_hash_payload?(hash)
|
|
59
|
+
hash.key?('file') && hash.key?('file_length')
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def coverband_hash_payload(hash)
|
|
63
|
+
payload = {
|
|
64
|
+
'files' => { hash.fetch('file') => coverband_line_counts(hash) },
|
|
65
|
+
'observation' => coverband_observation(hash)
|
|
66
|
+
}
|
|
67
|
+
limits.validate_payload!(payload)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def coverband_line_counts(hash)
|
|
71
|
+
length = Integer(hash.fetch('file_length'), exception: false)
|
|
72
|
+
domain_error('Invalid Coverband file length') unless length && length >= 0
|
|
73
|
+
domain_error('Coverband file length exceeds configured limit') if length > limits.max_array_elements
|
|
74
|
+
|
|
75
|
+
Array.new(length) do |index|
|
|
76
|
+
deadline.check!
|
|
77
|
+
count = hash[index.to_s]
|
|
78
|
+
count&.to_i
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def coverband_observation(hash)
|
|
83
|
+
%w[first_updated_at last_updated_at file_hash].each_with_object({}) do |key, observation|
|
|
84
|
+
observation[key] = hash[key] if hash[key]
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def validate_payload_hash!(payload)
|
|
89
|
+
domain_error('Redis coverage payload must be a mapping') unless payload.is_a?(Hash)
|
|
90
|
+
|
|
91
|
+
limits.validate_payload!(payload)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def merge_payload(merged, payload)
|
|
95
|
+
merged.merge(payload) do |key, left, right|
|
|
96
|
+
case key.to_s
|
|
97
|
+
when 'files', 'coverage', 'runtime', 'production' then merge_files(left, right)
|
|
98
|
+
when 'nodes', 'executed' then (Array(left) + Array(right)).uniq
|
|
99
|
+
when 'edges' then Array(left) + Array(right)
|
|
100
|
+
when 'observation' then ObservationPolicy.compatible_merge(left, right)
|
|
101
|
+
else right
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def merge_files(left, right)
|
|
107
|
+
return right unless left.is_a?(Hash)
|
|
108
|
+
return left unless right.is_a?(Hash)
|
|
109
|
+
|
|
110
|
+
left.merge(right) do |_file, left_lines, right_lines|
|
|
111
|
+
merge_file_coverage(left_lines, right_lines)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def merge_file_coverage(left, right)
|
|
116
|
+
return merge_count_hashes(left, right) if left.is_a?(Hash) && right.is_a?(Hash)
|
|
117
|
+
return merge_count_arrays(left, right) if count_array?(left) || count_array?(right)
|
|
118
|
+
|
|
119
|
+
(Array(left) + Array(right)).uniq
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def merge_count_hashes(left, right)
|
|
123
|
+
left.merge(right) { |_line, left_count, right_count| left_count.to_i + right_count.to_i }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def merge_count_arrays(left, right)
|
|
127
|
+
length = [Array(left).length, Array(right).length].max
|
|
128
|
+
domain_error('Merged Redis coverage exceeds configured collection size') if length > limits.max_array_elements
|
|
129
|
+
|
|
130
|
+
Array.new(length) do |index|
|
|
131
|
+
left_value = Array(left)[index]
|
|
132
|
+
right_value = Array(right)[index]
|
|
133
|
+
next if left_value.nil? && right_value.nil?
|
|
134
|
+
|
|
135
|
+
left_value.to_i + right_value.to_i
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def count_array?(value)
|
|
140
|
+
value.is_a?(Array) && value.any? { |item| item.nil? || item.to_i.zero? }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def domain_error(message)
|
|
144
|
+
raise Error, message
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Necropsy
|
|
4
|
+
module Analyzers
|
|
5
|
+
module Dynamic
|
|
6
|
+
module ObservationPolicy
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def metadata(payload = nil, expected_revision: nil, **keyword_payload)
|
|
10
|
+
payload ||= keyword_payload
|
|
11
|
+
observation = payload['observation']
|
|
12
|
+
observation = {} unless observation.is_a?(Hash)
|
|
13
|
+
schema_version = Integer(payload.fetch('schema_version', 1), exception: false)
|
|
14
|
+
raise Error, 'Dynamic observation schema version must be 1 or 2' unless [1, 2].include?(schema_version)
|
|
15
|
+
|
|
16
|
+
revision = source_revision(payload, observation)
|
|
17
|
+
|
|
18
|
+
status = source_revision_status(payload, observation, revision, expected_revision)
|
|
19
|
+
normalized = observation.merge(
|
|
20
|
+
'schema_version' => schema_version,
|
|
21
|
+
'positive_evidence_policy' => 'alive_only',
|
|
22
|
+
'source_revision_status' => status,
|
|
23
|
+
'source_revision_policy' => 'accepted_for_liveness_only'
|
|
24
|
+
)
|
|
25
|
+
normalized = normalized.merge('source_revision' => revision) if revision
|
|
26
|
+
normalized = normalized.merge('expected_source_revision' => expected_revision) if expected_revision
|
|
27
|
+
return normalized unless schema_version == 2
|
|
28
|
+
|
|
29
|
+
collector = payload['collector']
|
|
30
|
+
collector = {} unless collector.is_a?(Hash)
|
|
31
|
+
source = payload['source']
|
|
32
|
+
source = {} unless source.is_a?(Hash)
|
|
33
|
+
scope = payload['scope']
|
|
34
|
+
scope = {} unless scope.is_a?(Hash)
|
|
35
|
+
quality = payload['quality']
|
|
36
|
+
quality = {} unless quality.is_a?(Hash)
|
|
37
|
+
normalized.merge(
|
|
38
|
+
'collector_name' => collector['name'] || payload['collector_name'],
|
|
39
|
+
'collector_version' => collector['version'] || payload['collector_version'],
|
|
40
|
+
'source_metadata' => source,
|
|
41
|
+
'environment' => scope['environment'] || observation['environment'],
|
|
42
|
+
'sample_unit' => scope['sample_unit'] || observation['sample_unit'],
|
|
43
|
+
'sample_rate' => scope['sample_rate'] || observation['sample_rate'],
|
|
44
|
+
'quality' => {
|
|
45
|
+
'dropped_events' => quality.fetch('dropped_events', 0),
|
|
46
|
+
'overflowed' => quality.fetch('overflowed', false)
|
|
47
|
+
}
|
|
48
|
+
).compact
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def compatible_merge(left, right)
|
|
52
|
+
return left.merge(right) unless left.is_a?(Hash) && right.is_a?(Hash)
|
|
53
|
+
|
|
54
|
+
%w[source_revision source_revision_status environment collector_name collector_version].each do |key|
|
|
55
|
+
next if left[key].nil? || right[key].nil? || left[key] == right[key]
|
|
56
|
+
|
|
57
|
+
raise Error, "Dynamic observation #{key} is incompatible"
|
|
58
|
+
end
|
|
59
|
+
left.merge(right)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def evidence_scope(observation)
|
|
63
|
+
declared_scope = observation['scope']
|
|
64
|
+
declared_scope = {} unless declared_scope.is_a?(Hash)
|
|
65
|
+
declared_scope = declared_scope.reject do |key, _value|
|
|
66
|
+
%w[revision source_revision].include?(key.to_s)
|
|
67
|
+
end
|
|
68
|
+
selectors = {
|
|
69
|
+
'revision' => observation['source_revision'],
|
|
70
|
+
'source_revision_status' => observation['source_revision_status'],
|
|
71
|
+
'environment' => observation['environment'],
|
|
72
|
+
'collector' => observation['collector']
|
|
73
|
+
}.compact
|
|
74
|
+
declared_scope.merge(selectors)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def source_revision(payload, observation)
|
|
78
|
+
source = payload['source']
|
|
79
|
+
source_revision = source['git_sha'] if source.is_a?(Hash)
|
|
80
|
+
source_revision || payload['source_revision'] || observation['source_revision']
|
|
81
|
+
end
|
|
82
|
+
private_class_method :source_revision
|
|
83
|
+
|
|
84
|
+
def source_revision_status(payload, observation, revision, expected_revision)
|
|
85
|
+
stale = payload['stale'] || observation['stale'] || payload['source_revision_status'] == 'stale'
|
|
86
|
+
return 'stale' if stale
|
|
87
|
+
return 'unknown' unless revision
|
|
88
|
+
return 'provided_unverified' unless expected_revision
|
|
89
|
+
|
|
90
|
+
revision.to_s == expected_revision.to_s ? 'match' : 'mismatch'
|
|
91
|
+
end
|
|
92
|
+
private_class_method :source_revision_status
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module Necropsy
|
|
6
|
+
module Analyzers
|
|
7
|
+
module Dynamic
|
|
8
|
+
class RedisInputLimits
|
|
9
|
+
DEFAULTS = {
|
|
10
|
+
'connect_timeout' => 5.0,
|
|
11
|
+
'read_timeout' => 5.0,
|
|
12
|
+
'total_timeout' => 15.0,
|
|
13
|
+
'max_response_bytes' => 16 * 1024 * 1024,
|
|
14
|
+
'max_bulk_bytes' => 8 * 1024 * 1024,
|
|
15
|
+
'max_array_elements' => 100_000,
|
|
16
|
+
'max_resp_depth' => 16,
|
|
17
|
+
'max_keys' => 1_000,
|
|
18
|
+
'max_payload_depth' => 64
|
|
19
|
+
}.freeze
|
|
20
|
+
TIMEOUT_KEYS = %w[connect_timeout read_timeout total_timeout].freeze
|
|
21
|
+
INTEGER_KEYS = (DEFAULTS.keys - TIMEOUT_KEYS).freeze
|
|
22
|
+
|
|
23
|
+
attr_reader(*DEFAULTS.keys.map(&:to_sym))
|
|
24
|
+
|
|
25
|
+
def initialize(config)
|
|
26
|
+
TIMEOUT_KEYS.each { |key| instance_variable_set("@#{key}", positive_float(config, key)) }
|
|
27
|
+
INTEGER_KEYS.each { |key| instance_variable_set("@#{key}", positive_integer(config, key)) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def validate_payload!(payload)
|
|
31
|
+
stack = [[payload, 0]]
|
|
32
|
+
until stack.empty?
|
|
33
|
+
value, depth = stack.pop
|
|
34
|
+
raise Error, 'Redis coverage payload exceeds maximum nesting depth' if depth > max_payload_depth
|
|
35
|
+
if (value.is_a?(Hash) || value.is_a?(Array)) && value.length > max_array_elements
|
|
36
|
+
raise Error, 'Redis coverage payload exceeds maximum collection size'
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
children = value.is_a?(Hash) ? value.flat_map { |key, child| [key, child] } : Array(value)
|
|
40
|
+
stack.concat(children.map { |child| [child, depth + 1] }) if value.is_a?(Hash) || value.is_a?(Array)
|
|
41
|
+
end
|
|
42
|
+
payload
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def positive_float(config, key)
|
|
48
|
+
value = Float(config.fetch(key, DEFAULTS.fetch(key)))
|
|
49
|
+
return value if value.positive? && value.finite?
|
|
50
|
+
|
|
51
|
+
invalid_limit!(key)
|
|
52
|
+
rescue ArgumentError, TypeError
|
|
53
|
+
invalid_limit!(key)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def positive_integer(config, key)
|
|
57
|
+
value = Integer(config.fetch(key, DEFAULTS.fetch(key)))
|
|
58
|
+
return value if value.positive?
|
|
59
|
+
|
|
60
|
+
invalid_limit!(key)
|
|
61
|
+
rescue ArgumentError, TypeError
|
|
62
|
+
invalid_limit!(key)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def invalid_limit!(key)
|
|
66
|
+
raise Error, "analyzers.dynamic.coverband.#{key} must be a finite positive number"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
class RedisDeadline
|
|
71
|
+
def initialize(seconds)
|
|
72
|
+
@expires_at = monotonic_time + seconds
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def remaining
|
|
76
|
+
seconds = @expires_at - monotonic_time
|
|
77
|
+
raise Error, 'Redis total timeout exceeded' unless seconds.positive?
|
|
78
|
+
|
|
79
|
+
seconds
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def check!
|
|
83
|
+
remaining
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def monotonic_time
|
|
90
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
class RedisCredentialRedactor
|
|
95
|
+
REDACTED = '[REDACTED]'
|
|
96
|
+
|
|
97
|
+
def initialize(source)
|
|
98
|
+
@source = source.to_s
|
|
99
|
+
@safe_source = @source.sub(%r{(?<=://)[^/@\s]+@}, "#{REDACTED}@")
|
|
100
|
+
@secrets = credentials(@source)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def redact(message)
|
|
104
|
+
sanitized = message.to_s.gsub(@source, @safe_source)
|
|
105
|
+
@secrets.reduce(sanitized) { |text, secret| text.gsub(secret, REDACTED) }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def credentials(source)
|
|
111
|
+
uri = URI(source)
|
|
112
|
+
[uri.user, uri.password].compact.flat_map do |credential|
|
|
113
|
+
[credential, URI.decode_www_form_component(credential)]
|
|
114
|
+
end.reject(&:empty?).uniq.sort_by { |value| -value.length }
|
|
115
|
+
rescue URI::Error, ArgumentError
|
|
116
|
+
[]
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'io/wait'
|
|
4
|
+
|
|
5
|
+
module Necropsy
|
|
6
|
+
module Analyzers
|
|
7
|
+
module Dynamic
|
|
8
|
+
module RedisNonblockingIO
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def finish_tls_connect(ssl_socket)
|
|
12
|
+
expires_at = monotonic_time + limits.connect_timeout
|
|
13
|
+
loop do
|
|
14
|
+
status = ssl_socket.connect_nonblock(exception: false)
|
|
15
|
+
return unless wait_status?(status)
|
|
16
|
+
|
|
17
|
+
wait_for_io(status, io: ssl_socket.to_io, expires_at: expires_at,
|
|
18
|
+
label: 'Redis TLS handshake timeout exceeded')
|
|
19
|
+
rescue IO::WaitReadable
|
|
20
|
+
wait_for_io(:wait_readable, io: ssl_socket.to_io, expires_at: expires_at,
|
|
21
|
+
label: 'Redis TLS handshake timeout exceeded')
|
|
22
|
+
rescue IO::WaitWritable
|
|
23
|
+
wait_for_io(:wait_writable, io: ssl_socket.to_io, expires_at: expires_at,
|
|
24
|
+
label: 'Redis TLS handshake timeout exceeded')
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def write_all(value)
|
|
29
|
+
offset = 0
|
|
30
|
+
expires_at = monotonic_time + limits.read_timeout
|
|
31
|
+
while offset < value.bytesize
|
|
32
|
+
check_io_deadline!(expires_at, 'Redis write timeout exceeded')
|
|
33
|
+
begin
|
|
34
|
+
written = socket.write_nonblock(value.byteslice(offset, value.bytesize - offset), exception: false)
|
|
35
|
+
if wait_status?(written)
|
|
36
|
+
wait_for_io(written, io: socket.to_io, expires_at: expires_at, label: 'Redis write timeout exceeded')
|
|
37
|
+
elsif written&.positive?
|
|
38
|
+
offset += written
|
|
39
|
+
else
|
|
40
|
+
domain_error('Redis closed the connection while writing a request')
|
|
41
|
+
end
|
|
42
|
+
rescue IO::WaitReadable
|
|
43
|
+
wait_for_io(:wait_readable, io: socket.to_io, expires_at: expires_at,
|
|
44
|
+
label: 'Redis write timeout exceeded')
|
|
45
|
+
rescue IO::WaitWritable
|
|
46
|
+
wait_for_io(:wait_writable, io: socket.to_io, expires_at: expires_at,
|
|
47
|
+
label: 'Redis write timeout exceeded')
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
check_io_deadline!(expires_at, 'Redis write timeout exceeded')
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def read_nonblock(length)
|
|
54
|
+
expires_at = monotonic_time + limits.read_timeout
|
|
55
|
+
loop do
|
|
56
|
+
value = socket.read_nonblock(length, exception: false)
|
|
57
|
+
return value if value.is_a?(String) && !value.empty?
|
|
58
|
+
|
|
59
|
+
domain_error('Redis closed the connection while reading a response') if value.nil? || value == ''
|
|
60
|
+
wait_for_io(value, io: socket.to_io, expires_at: expires_at, label: 'Redis read timeout exceeded')
|
|
61
|
+
rescue IO::WaitReadable
|
|
62
|
+
wait_for_io(:wait_readable, io: socket.to_io, expires_at: expires_at, label: 'Redis read timeout exceeded')
|
|
63
|
+
rescue IO::WaitWritable
|
|
64
|
+
wait_for_io(:wait_writable, io: socket.to_io, expires_at: expires_at, label: 'Redis read timeout exceeded')
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def wait_status?(value)
|
|
69
|
+
%i[wait_readable wait_writable].include?(value)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def check_io_deadline!(expires_at, label)
|
|
73
|
+
deadline.check!
|
|
74
|
+
domain_error(label) unless (expires_at - monotonic_time).positive?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def wait_for_io(status, io:, expires_at:, label:)
|
|
78
|
+
timeout = [expires_at - monotonic_time, deadline.remaining].min
|
|
79
|
+
domain_error(label) unless timeout.positive?
|
|
80
|
+
|
|
81
|
+
ready = status == :wait_readable ? io.wait_readable(timeout) : io.wait_writable(timeout)
|
|
82
|
+
return if ready
|
|
83
|
+
|
|
84
|
+
deadline.check!
|
|
85
|
+
domain_error(label)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def monotonic_time
|
|
89
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'uri'
|
|
5
|
+
require 'yaml'
|
|
6
|
+
|
|
7
|
+
require_relative 'coverband_payload_set'
|
|
8
|
+
require_relative 'redis_input_limits'
|
|
9
|
+
require_relative 'redis_transport'
|
|
10
|
+
|
|
11
|
+
module Necropsy
|
|
12
|
+
module Analyzers
|
|
13
|
+
module Dynamic
|
|
14
|
+
class RedisPayloadLoader
|
|
15
|
+
DEFAULT_PATTERN = 'coverband*'
|
|
16
|
+
|
|
17
|
+
def initialize(source:, config:)
|
|
18
|
+
@redactor = RedisCredentialRedactor.new(source)
|
|
19
|
+
@uri = parse_uri(source)
|
|
20
|
+
@config = config
|
|
21
|
+
@limits = RedisInputLimits.new(config)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def load
|
|
25
|
+
start_session
|
|
26
|
+
transport.connect
|
|
27
|
+
authenticate
|
|
28
|
+
select_database
|
|
29
|
+
payload_processor.merge(keys.filter_map { |key| payload_for_key(key) })
|
|
30
|
+
rescue RedisCommandError, Error => e
|
|
31
|
+
raise Error, redactor.redact(e.message), cause: nil
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
raise Error, redactor.redact("Could not load Redis coverage: #{e.message}"), cause: nil
|
|
34
|
+
ensure
|
|
35
|
+
transport&.close
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
attr_reader :uri, :config, :limits, :redactor, :transport, :deadline, :payload_processor
|
|
41
|
+
|
|
42
|
+
def parse_uri(source)
|
|
43
|
+
parsed = URI(source)
|
|
44
|
+
return parsed if %w[redis rediss].include?(parsed.scheme) && parsed.host
|
|
45
|
+
|
|
46
|
+
raise Error, 'Invalid Redis source URI'
|
|
47
|
+
rescue URI::Error, ArgumentError
|
|
48
|
+
raise Error, 'Invalid Redis source URI', cause: nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def start_session
|
|
52
|
+
@deadline = RedisDeadline.new(limits.total_timeout)
|
|
53
|
+
@payload_processor = CoverbandPayloadSet.new(limits: limits, deadline: deadline)
|
|
54
|
+
@transport = RedisTransport.new(uri: uri, limits: limits, deadline: deadline, redactor: redactor)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def authenticate
|
|
58
|
+
return unless uri.password
|
|
59
|
+
|
|
60
|
+
password = URI.decode_www_form_component(uri.password)
|
|
61
|
+
return command('AUTH', password) unless uri.user
|
|
62
|
+
|
|
63
|
+
command('AUTH', URI.decode_www_form_component(uri.user), password)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def select_database
|
|
67
|
+
database = uri.path.to_s.delete_prefix('/')
|
|
68
|
+
command('SELECT', database) unless database.empty?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def keys
|
|
72
|
+
configured = Array(config['keys'] || config['key'] || query['key']).compact
|
|
73
|
+
return validate_key_count!(configured) unless configured.empty?
|
|
74
|
+
|
|
75
|
+
scan(pattern: config['pattern'] || query['pattern'] || DEFAULT_PATTERN)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def query
|
|
79
|
+
@query ||= URI.decode_www_form(uri.query.to_s).to_h
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def scan(pattern:)
|
|
83
|
+
cursor = '0'
|
|
84
|
+
found = []
|
|
85
|
+
loop do
|
|
86
|
+
deadline.check!
|
|
87
|
+
cursor, batch = scan_page(cursor, pattern)
|
|
88
|
+
append_keys!(found, batch)
|
|
89
|
+
break if cursor == '0'
|
|
90
|
+
end
|
|
91
|
+
found
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def scan_page(cursor, pattern)
|
|
95
|
+
response = command('SCAN', cursor, 'MATCH', pattern)
|
|
96
|
+
valid = response.is_a?(Array) && response.length == 2 && response.last.is_a?(Array)
|
|
97
|
+
raise Error, 'Malformed Redis SCAN response' unless valid
|
|
98
|
+
|
|
99
|
+
next_cursor = response.first.to_s
|
|
100
|
+
raise Error, 'Malformed Redis SCAN cursor' unless next_cursor.match?(/\A\d+\z/)
|
|
101
|
+
|
|
102
|
+
[next_cursor, response.last]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def append_keys!(found, batch)
|
|
106
|
+
raise Error, 'Redis key count exceeds configured limit' if found.length + batch.length > limits.max_keys
|
|
107
|
+
|
|
108
|
+
found.concat(batch)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def validate_key_count!(keys)
|
|
112
|
+
raise Error, 'Redis key count exceeds configured limit' if keys.length > limits.max_keys
|
|
113
|
+
|
|
114
|
+
keys
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def payload_for_key(key)
|
|
118
|
+
value = command('GET', key)
|
|
119
|
+
return nil if value.nil? || value.empty?
|
|
120
|
+
|
|
121
|
+
payload = parse_payload(value)
|
|
122
|
+
raise Error, 'Redis coverage payload must be a mapping' unless payload.is_a?(Hash)
|
|
123
|
+
|
|
124
|
+
limits.validate_payload!(payload)
|
|
125
|
+
rescue RedisCommandError => e
|
|
126
|
+
raise unless e.message.start_with?('WRONGTYPE')
|
|
127
|
+
|
|
128
|
+
parse_hash_payload(command('HGETALL', key))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def command(*parts)
|
|
132
|
+
transport.command(*parts)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def parse_payload(value)
|
|
136
|
+
return nil if value.nil? || value.empty?
|
|
137
|
+
|
|
138
|
+
parsed = begin
|
|
139
|
+
JSON.parse(value, max_nesting: limits.max_payload_depth)
|
|
140
|
+
rescue JSON::ParserError
|
|
141
|
+
parse_yaml_payload(value)
|
|
142
|
+
end
|
|
143
|
+
limits.validate_payload!(parsed)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def parse_yaml_payload(value)
|
|
147
|
+
YAML.safe_load(value, aliases: false)
|
|
148
|
+
rescue Psych::Exception, ArgumentError, EncodingError, SystemStackError => e
|
|
149
|
+
raise Error, redactor.redact("Invalid Redis coverage payload: #{e.message}"), cause: nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def parse_hash_payload(entries)
|
|
153
|
+
active_payload_processor.from_hash_entries(entries) { |value| parse_payload(value) }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def active_payload_processor
|
|
157
|
+
@active_payload_processor ||= CoverbandPayloadSet.new(
|
|
158
|
+
limits: limits,
|
|
159
|
+
deadline: (@deadline ||= RedisDeadline.new(limits.total_timeout))
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|