necropsy 0.2.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/CHANGELOG.md +26 -0
- data/MEASUREMENTS.md +27 -0
- data/README.md +182 -17
- 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 +69 -17
- data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +99 -9
- data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +69 -299
- 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 +133 -23
- 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 -73
- data/lib/necropsy/analyzers/static/name_resolution.rb +133 -12
- data/lib/necropsy/analyzers/static/rta.rb +237 -29
- data/lib/necropsy/ast_scanner/call_recording.rb +149 -30
- 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 +431 -41
- data/lib/necropsy/ast_scanner/method_definitions.rb +232 -50
- data/lib/necropsy/ast_scanner/references.rb +43 -9
- data/lib/necropsy/ast_scanner/ruby_semantics.rb +101 -15
- data/lib/necropsy/ast_scanner/traversal.rb +234 -71
- data/lib/necropsy/ast_scanner/value_definitions.rb +23 -13
- data/lib/necropsy/ast_scanner.rb +67 -6
- data/lib/necropsy/bench/candidate_union.rb +555 -0
- data/lib/necropsy/bench/claim_gate.rb +112 -0
- data/lib/necropsy/bench/evaluator.rb +329 -16
- 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 +85 -17
- data/lib/necropsy/call_site_identity.rb +54 -0
- data/lib/necropsy/cli.rb +220 -33
- data/lib/necropsy/clock.rb +40 -0
- data/lib/necropsy/confidence/scorer.rb +103 -58
- data/lib/necropsy/configuration.rb +224 -21
- 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 +176 -36
- data/lib/necropsy/embedded_ruby.rb +55 -0
- data/lib/necropsy/entry_points/plain.rb +111 -10
- data/lib/necropsy/entry_points/rails.rb +322 -41
- 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 +1099 -109
- 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 +350 -13
- data/lib/necropsy/guardrail/quarantine.rb +94 -9
- data/lib/necropsy/load_graph.rb +206 -0
- data/lib/necropsy/models.rb +878 -13
- data/lib/necropsy/performance_profiler.rb +108 -0
- data/lib/necropsy/project.rb +327 -25
- data/lib/necropsy/reachability/engine.rb +54 -13
- data/lib/necropsy/reference_barrier.rb +458 -0
- data/lib/necropsy/report.rb +113 -4
- data/lib/necropsy/reporter.rb +431 -15
- data/lib/necropsy/runner.rb +233 -18
- data/lib/necropsy/runtime_feedback.rb +136 -0
- data/lib/necropsy/semantics_matrix.rb +153 -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 +35 -2
- data/schema/necropsy-report-v2.schema.json +366 -0
- metadata +85 -1
|
@@ -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
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
require 'socket'
|
|
5
|
+
require 'timeout'
|
|
6
|
+
|
|
7
|
+
require_relative 'redis_nonblocking_io'
|
|
8
|
+
|
|
9
|
+
module Necropsy
|
|
10
|
+
module Analyzers
|
|
11
|
+
module Dynamic
|
|
12
|
+
class RedisCommandError < StandardError; end
|
|
13
|
+
|
|
14
|
+
class RedisTransport
|
|
15
|
+
include RedisNonblockingIO
|
|
16
|
+
|
|
17
|
+
CRLF = "\r\n"
|
|
18
|
+
|
|
19
|
+
def initialize(uri:, limits:, deadline:, redactor:)
|
|
20
|
+
@uri = uri
|
|
21
|
+
@limits = limits
|
|
22
|
+
@deadline = deadline
|
|
23
|
+
@redactor = redactor
|
|
24
|
+
@response_bytes = 0
|
|
25
|
+
@read_buffer = +''
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def connect
|
|
29
|
+
@tcp_socket = connect_tcp
|
|
30
|
+
@socket = uri.scheme == 'rediss' ? connect_tls : tcp_socket
|
|
31
|
+
deadline.check!
|
|
32
|
+
self
|
|
33
|
+
rescue Error
|
|
34
|
+
close
|
|
35
|
+
raise
|
|
36
|
+
rescue SystemCallError, SocketError, IO::TimeoutError, OpenSSL::SSL::SSLError => e
|
|
37
|
+
close
|
|
38
|
+
domain_error("Could not connect securely to Redis at #{target}: #{e.message}")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def close
|
|
42
|
+
socket&.close
|
|
43
|
+
tcp_socket&.close unless socket.equal?(tcp_socket)
|
|
44
|
+
rescue IOError, SystemCallError
|
|
45
|
+
nil
|
|
46
|
+
ensure
|
|
47
|
+
@socket = nil
|
|
48
|
+
@tcp_socket = nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def command(*parts)
|
|
52
|
+
deadline.check!
|
|
53
|
+
write_all(redis_command(parts))
|
|
54
|
+
response = read_response
|
|
55
|
+
deadline.check!
|
|
56
|
+
response
|
|
57
|
+
rescue RedisCommandError
|
|
58
|
+
raise
|
|
59
|
+
rescue IOError, SystemCallError, OpenSSL::SSL::SSLError => e
|
|
60
|
+
domain_error("Redis connection failed: #{e.message}")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
attr_reader :uri, :limits, :deadline, :redactor, :socket, :tcp_socket
|
|
66
|
+
|
|
67
|
+
def connect_tcp
|
|
68
|
+
expires_at = monotonic_time + limits.connect_timeout
|
|
69
|
+
last_error = nil
|
|
70
|
+
addresses(expires_at).each do |address|
|
|
71
|
+
candidate = Socket.new(address.afamily, address.socktype, address.protocol)
|
|
72
|
+
begin
|
|
73
|
+
finish_tcp_connect(candidate, address, expires_at)
|
|
74
|
+
return candidate
|
|
75
|
+
rescue SystemCallError, SocketError => e
|
|
76
|
+
candidate.close
|
|
77
|
+
last_error = e
|
|
78
|
+
rescue Error
|
|
79
|
+
candidate.close
|
|
80
|
+
raise
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
raise(last_error || SocketError.new("No Redis address found for #{uri.host}"))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def addresses(expires_at)
|
|
87
|
+
timeout = [expires_at - monotonic_time, deadline.remaining].min
|
|
88
|
+
domain_error('Redis address resolution timeout exceeded') unless timeout.positive?
|
|
89
|
+
|
|
90
|
+
# Limit asynchronous interruption to pure resolution, before any socket resource exists.
|
|
91
|
+
resolved = Timeout.timeout(timeout) do
|
|
92
|
+
Addrinfo.getaddrinfo(uri.host, uri.port || 6379, nil, :STREAM)
|
|
93
|
+
end
|
|
94
|
+
deadline.check!
|
|
95
|
+
domain_error('Redis address resolution timeout exceeded') unless (expires_at - monotonic_time).positive?
|
|
96
|
+
resolved
|
|
97
|
+
rescue Timeout::Error
|
|
98
|
+
deadline.check!
|
|
99
|
+
domain_error('Redis address resolution timeout exceeded')
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def finish_tcp_connect(candidate, address, expires_at)
|
|
103
|
+
status = candidate.connect_nonblock(address, exception: false)
|
|
104
|
+
return unless wait_status?(status)
|
|
105
|
+
|
|
106
|
+
wait_for_io(status, io: candidate, expires_at: expires_at, label: 'Redis connect timeout exceeded')
|
|
107
|
+
error = candidate.getsockopt(Socket::SOL_SOCKET, Socket::SO_ERROR).int
|
|
108
|
+
raise SystemCallError.new("connect(2) for #{target}", error) unless error.zero?
|
|
109
|
+
rescue Errno::EISCONN
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def connect_tls
|
|
114
|
+
context = OpenSSL::SSL::SSLContext.new
|
|
115
|
+
store = OpenSSL::X509::Store.new
|
|
116
|
+
store.set_default_paths
|
|
117
|
+
context.cert_store = store
|
|
118
|
+
context.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
119
|
+
context.verify_hostname = true if context.respond_to?(:verify_hostname=)
|
|
120
|
+
|
|
121
|
+
ssl_socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, context)
|
|
122
|
+
ssl_socket.sync_close = true
|
|
123
|
+
ssl_socket.hostname = uri.host
|
|
124
|
+
finish_tls_connect(ssl_socket)
|
|
125
|
+
ssl_socket.post_connection_check(uri.host)
|
|
126
|
+
ssl_socket
|
|
127
|
+
rescue StandardError
|
|
128
|
+
ssl_socket&.close
|
|
129
|
+
raise
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def target
|
|
133
|
+
"#{uri.host}:#{uri.port || 6379}"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def redis_command(parts)
|
|
137
|
+
encoded = parts.flat_map { |part| ["$#{part.to_s.bytesize}", part.to_s] }
|
|
138
|
+
(["*#{parts.length}"] + encoded).join(CRLF) + CRLF
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def read_response(depth = 0)
|
|
142
|
+
prefix = read_exact(1)
|
|
143
|
+
case prefix
|
|
144
|
+
when '+' then read_line
|
|
145
|
+
when '-' then raise RedisCommandError, redactor.redact(read_line)
|
|
146
|
+
when ':' then read_integer('integer')
|
|
147
|
+
when '$' then read_bulk_string
|
|
148
|
+
when '*' then read_array(depth)
|
|
149
|
+
else domain_error("Malformed Redis response prefix #{prefix.inspect}")
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def read_bulk_string
|
|
154
|
+
length = read_integer('bulk length', nullable: true)
|
|
155
|
+
return nil if length == -1
|
|
156
|
+
|
|
157
|
+
domain_error('Invalid negative Redis bulk length') if length.negative?
|
|
158
|
+
domain_error('Redis bulk response exceeds configured limit') if length > limits.max_bulk_bytes
|
|
159
|
+
|
|
160
|
+
value = read_exact(length)
|
|
161
|
+
domain_error('Malformed Redis bulk response terminator') unless read_exact(2) == CRLF
|
|
162
|
+
value
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def read_array(depth)
|
|
166
|
+
length = read_integer('array length', nullable: true)
|
|
167
|
+
return nil if length == -1
|
|
168
|
+
|
|
169
|
+
domain_error('Invalid negative Redis array length') if length.negative?
|
|
170
|
+
domain_error('Redis array response exceeds configured element limit') if length > limits.max_array_elements
|
|
171
|
+
|
|
172
|
+
child_depth = depth + 1
|
|
173
|
+
domain_error('Redis response exceeds configured nesting depth') if child_depth > limits.max_resp_depth
|
|
174
|
+
Array.new(length) { read_response(child_depth) }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def read_integer(label, nullable: false)
|
|
178
|
+
text = read_line
|
|
179
|
+
domain_error("Malformed Redis #{label}") unless text.match?(/\A-?\d+\z/)
|
|
180
|
+
|
|
181
|
+
value = Integer(text, 10)
|
|
182
|
+
domain_error("Invalid negative Redis #{label}") if value.negative? && (!nullable || value < -1)
|
|
183
|
+
value
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def read_line
|
|
187
|
+
loop do
|
|
188
|
+
if (terminator = @read_buffer.index(CRLF))
|
|
189
|
+
return @read_buffer.slice!(0, terminator + CRLF.bytesize).delete_suffix(CRLF)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
fill_buffer
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def read_exact(length)
|
|
197
|
+
fill_buffer while @read_buffer.bytesize < length
|
|
198
|
+
@read_buffer.slice!(0, length)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def fill_buffer
|
|
202
|
+
deadline.check!
|
|
203
|
+
remaining = limits.max_response_bytes - @response_bytes
|
|
204
|
+
domain_error('Redis response exceeds configured byte limit') unless remaining.positive?
|
|
205
|
+
|
|
206
|
+
chunk = read_nonblock([4096, remaining].min)
|
|
207
|
+
@response_bytes += chunk.bytesize
|
|
208
|
+
@read_buffer << chunk
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def domain_error(message)
|
|
212
|
+
raise Error, redactor.redact(message), cause: nil
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Necropsy
|
|
4
|
+
module Analyzers
|
|
5
|
+
module Dynamic
|
|
6
|
+
module RuntimeReference
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def build(symbol_id:, file: nil, line: nil, definition_id: nil)
|
|
10
|
+
{
|
|
11
|
+
'definition_id' => definition_id,
|
|
12
|
+
'symbol_id' => symbol_id,
|
|
13
|
+
'file' => file,
|
|
14
|
+
'line' => line
|
|
15
|
+
}.compact
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def normalize(value)
|
|
19
|
+
return value if value.is_a?(String)
|
|
20
|
+
return unless value.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
data = value.to_h { |key, item| [key.to_s, item] }
|
|
23
|
+
symbol_id = data['symbol_id']
|
|
24
|
+
return unless symbol_id.is_a?(String) && !symbol_id.empty?
|
|
25
|
+
return unless valid_optional_string?(data, 'definition_id') && valid_optional_string?(data, 'file')
|
|
26
|
+
|
|
27
|
+
line = normalize_line(data['line'])
|
|
28
|
+
return if data.key?('line') && !data['line'].nil? && line.nil?
|
|
29
|
+
|
|
30
|
+
build(
|
|
31
|
+
definition_id: optional_string(data['definition_id']),
|
|
32
|
+
symbol_id: symbol_id,
|
|
33
|
+
file: optional_string(data['file']),
|
|
34
|
+
line: line
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def sort_key(reference)
|
|
39
|
+
normalized = normalize(reference)
|
|
40
|
+
return ['', reference.to_s, '', 0] unless normalized.is_a?(Hash)
|
|
41
|
+
|
|
42
|
+
[
|
|
43
|
+
normalized.fetch('definition_id', ''),
|
|
44
|
+
normalized.fetch('symbol_id'),
|
|
45
|
+
normalized.fetch('file', ''),
|
|
46
|
+
normalized.fetch('line', 0)
|
|
47
|
+
]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def key(reference)
|
|
51
|
+
sort_key(reference)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def preferred(structured:, legacy:)
|
|
55
|
+
return Array(legacy) if structured.nil?
|
|
56
|
+
|
|
57
|
+
structured_values = Array(structured)
|
|
58
|
+
structured_symbols = structured_values.filter_map do |reference|
|
|
59
|
+
normalize(reference).then { |normalized| normalized['symbol_id'] if normalized.is_a?(Hash) }
|
|
60
|
+
end.to_set
|
|
61
|
+
structured_values + Array(legacy).reject { |symbol_id| structured_symbols.include?(symbol_id) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def malformed?(reference)
|
|
65
|
+
!reference.is_a?(String) && normalize(reference).nil?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def relative_file(root, path)
|
|
69
|
+
expanded = File.expand_path(path)
|
|
70
|
+
prefix = "#{File.expand_path(root)}/"
|
|
71
|
+
return unless expanded.start_with?(prefix)
|
|
72
|
+
|
|
73
|
+
expanded.delete_prefix(prefix)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def optional_string(value)
|
|
77
|
+
value if value.is_a?(String) && !value.empty?
|
|
78
|
+
end
|
|
79
|
+
private_class_method :optional_string
|
|
80
|
+
|
|
81
|
+
def valid_optional_string?(data, key)
|
|
82
|
+
!data.key?(key) || data[key].nil? || (data[key].is_a?(String) && !data[key].empty?)
|
|
83
|
+
end
|
|
84
|
+
private_class_method :valid_optional_string?
|
|
85
|
+
|
|
86
|
+
def normalize_line(value)
|
|
87
|
+
return if value.nil?
|
|
88
|
+
|
|
89
|
+
line = Integer(value, exception: false)
|
|
90
|
+
line if line&.positive?
|
|
91
|
+
end
|
|
92
|
+
private_class_method :normalize_line
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|