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,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Necropsy
|
|
4
|
+
class DefinitionIndex
|
|
5
|
+
include Enumerable
|
|
6
|
+
|
|
7
|
+
Lookup = Data.define(:identifier, :status, :definitions) do
|
|
8
|
+
def exact?
|
|
9
|
+
status == :exact
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def unique?
|
|
13
|
+
status == :unique
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def ambiguous?
|
|
17
|
+
status == :ambiguous
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def missing?
|
|
21
|
+
status == :missing
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def node
|
|
25
|
+
definitions.first if exact? || unique?
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class AmbiguousDefinitionError < KeyError
|
|
30
|
+
attr_reader :lookup
|
|
31
|
+
|
|
32
|
+
def initialize(lookup)
|
|
33
|
+
@lookup = lookup
|
|
34
|
+
super("#{lookup.identifier.inspect} matches #{lookup.definitions.length} physical definitions")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
UNDEFINED = Object.new.freeze
|
|
39
|
+
private_constant :UNDEFINED
|
|
40
|
+
|
|
41
|
+
def initialize(definitions = [])
|
|
42
|
+
@by_definition_id = {}
|
|
43
|
+
@by_symbol_id = Hash.new { |hash, key| hash[key] = [] }
|
|
44
|
+
definitions.each { |definition| add(definition) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def add(definition)
|
|
48
|
+
existing = @by_definition_id[definition.graph_id]
|
|
49
|
+
return existing if existing == definition
|
|
50
|
+
raise ArgumentError, "duplicate physical definition ID: #{definition.graph_id}" if existing
|
|
51
|
+
|
|
52
|
+
@by_definition_id[definition.graph_id] = definition
|
|
53
|
+
@by_symbol_id[definition.symbol_id] << definition
|
|
54
|
+
definition
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def exact(definition_id)
|
|
58
|
+
@by_definition_id[definition_id]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def definitions_for(symbol_id)
|
|
62
|
+
sorted(@by_symbol_id.fetch(symbol_id, [])).freeze
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def lookup(identifier)
|
|
66
|
+
definitions = definitions_for(identifier)
|
|
67
|
+
unless definitions.empty?
|
|
68
|
+
status = definitions.one? ? :unique : :ambiguous
|
|
69
|
+
return Lookup.new(identifier: identifier, status: status, definitions: definitions)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
exact_match = exact(identifier)
|
|
73
|
+
return Lookup.new(identifier: identifier, status: :exact, definitions: [exact_match].freeze) if exact_match
|
|
74
|
+
|
|
75
|
+
Lookup.new(identifier: identifier, status: :missing, definitions: definitions)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def [](identifier)
|
|
79
|
+
result = lookup(identifier)
|
|
80
|
+
raise AmbiguousDefinitionError, result if result.ambiguous?
|
|
81
|
+
|
|
82
|
+
result.node
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def resolve(identifier)
|
|
86
|
+
lookup(identifier).definitions
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def fetch(identifier, default = UNDEFINED)
|
|
90
|
+
result = lookup(identifier)
|
|
91
|
+
raise AmbiguousDefinitionError, result if result.ambiguous?
|
|
92
|
+
return result.node unless result.missing?
|
|
93
|
+
return yield(identifier) if block_given?
|
|
94
|
+
return default unless default.equal?(UNDEFINED)
|
|
95
|
+
|
|
96
|
+
raise KeyError, "key not found: #{identifier.inspect}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def key?(identifier)
|
|
100
|
+
!lookup(identifier).missing?
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def each
|
|
104
|
+
return enum_for(:each) unless block_given?
|
|
105
|
+
|
|
106
|
+
values.each { |definition| yield(definition.graph_id, definition) }
|
|
107
|
+
self
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def each_key
|
|
111
|
+
return enum_for(:each_key) unless block_given?
|
|
112
|
+
|
|
113
|
+
values.each { |definition| yield definition.graph_id }
|
|
114
|
+
self
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def each_value(&block)
|
|
118
|
+
return enum_for(:each_value) unless block
|
|
119
|
+
|
|
120
|
+
values.each(&block)
|
|
121
|
+
self
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def keys
|
|
125
|
+
values.map(&:graph_id)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def values
|
|
129
|
+
sorted(@by_definition_id.values)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def length
|
|
133
|
+
@by_definition_id.length
|
|
134
|
+
end
|
|
135
|
+
alias size length
|
|
136
|
+
|
|
137
|
+
def empty?
|
|
138
|
+
@by_definition_id.empty?
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def sorted(definitions)
|
|
144
|
+
definitions.sort_by do |definition|
|
|
145
|
+
[definition.symbol_id.to_s, definition.file.to_s, definition.line.to_i, definition.graph_id.to_s]
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Necropsy
|
|
4
|
+
module DynamicEvidenceTracking
|
|
5
|
+
SAMPLE_LIMIT = 5
|
|
6
|
+
RESOLUTION_STATUSES = %w[exact unique ambiguous missing].freeze
|
|
7
|
+
|
|
8
|
+
def dynamic_observation?
|
|
9
|
+
observation.key?('dynamic_evidence')
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def dynamic_evidence_diagnostic
|
|
13
|
+
observation['dynamic_evidence']
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def dynamic_result?(result)
|
|
19
|
+
return true unless result.alive_evidences.empty?
|
|
20
|
+
|
|
21
|
+
dynamic_profiles = profiles.select { |profile| profile.kind == :dynamic }.map { |profile| profile.name.to_s }
|
|
22
|
+
evidence_analyzers = result.edge_evidences.map { |edge| edge.evidence.analyzer.to_s }
|
|
23
|
+
return true if dynamic_profiles.intersect?(evidence_analyzers)
|
|
24
|
+
|
|
25
|
+
observation_keys = result.observation.keys.map(&:to_s)
|
|
26
|
+
observation_keys.intersect?(dynamic_profiles) || observation_keys.intersect?(%w[coverage coverband trace_point])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def record_dynamic_evidence(result, alive_matches, edge_matches)
|
|
30
|
+
diagnostic = observation['dynamic_evidence'] ||= empty_dynamic_evidence_diagnostic
|
|
31
|
+
unmatched_nodes = result.alive_evidences.zip(alive_matches).filter_map do |_alive, resolution|
|
|
32
|
+
dynamic_reference_label(resolution) if missing_dynamic_resolution?(resolution)
|
|
33
|
+
end
|
|
34
|
+
incomplete_nodes = alive_matches.filter_map do |resolution|
|
|
35
|
+
dynamic_reference_label(resolution) unless precise_dynamic_resolution?(resolution)
|
|
36
|
+
end
|
|
37
|
+
incomplete_edges = edge_matches.filter_map { |match| incomplete_edge_sample(match) }
|
|
38
|
+
|
|
39
|
+
update_dynamic_node_counts(diagnostic, alive_matches)
|
|
40
|
+
update_dynamic_edge_counts(diagnostic, edge_matches)
|
|
41
|
+
update_dynamic_resolution_counts(diagnostic, alive_matches, edge_matches)
|
|
42
|
+
append_dynamic_samples(diagnostic, 'nodes', unmatched_nodes)
|
|
43
|
+
append_dynamic_samples(diagnostic, 'edges', incomplete_edges)
|
|
44
|
+
append_resolution_samples(diagnostic, alive_matches, edge_matches)
|
|
45
|
+
warn_unmatched_dynamic_evidence(alive_matches, incomplete_nodes)
|
|
46
|
+
warn_unmatched_dynamic_edges(edge_matches, incomplete_edges)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def empty_dynamic_evidence_diagnostic
|
|
50
|
+
{
|
|
51
|
+
'policy' => 'positive_only',
|
|
52
|
+
'runtime_unobserved' => 'informational_only',
|
|
53
|
+
'attempted' => { 'nodes' => 0, 'edges' => 0 },
|
|
54
|
+
'matched' => { 'nodes' => 0, 'edges' => 0 },
|
|
55
|
+
'partially_matched' => { 'nodes' => 0, 'edges' => 0 },
|
|
56
|
+
'unmatched' => { 'nodes' => 0, 'edges' => 0 },
|
|
57
|
+
'unmatched_samples' => { 'nodes' => [], 'edges' => [] },
|
|
58
|
+
'resolution' => {
|
|
59
|
+
'nodes' => empty_resolution_counts,
|
|
60
|
+
'edge_endpoints' => empty_resolution_counts
|
|
61
|
+
},
|
|
62
|
+
'resolution_samples' => { 'nodes' => [], 'edge_endpoints' => [] }
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def empty_resolution_counts
|
|
67
|
+
RESOLUTION_STATUSES.to_h { |status| [status, 0] }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def update_dynamic_node_counts(diagnostic, resolutions)
|
|
71
|
+
diagnostic['attempted']['nodes'] += resolutions.length
|
|
72
|
+
diagnostic['matched']['nodes'] += resolutions.count { |resolution| precise_dynamic_resolution?(resolution) }
|
|
73
|
+
diagnostic['partially_matched']['nodes'] += resolutions.count { |resolution| ambiguous_dynamic_resolution?(resolution) }
|
|
74
|
+
diagnostic['unmatched']['nodes'] += resolutions.count { |resolution| missing_dynamic_resolution?(resolution) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def update_dynamic_edge_counts(diagnostic, matches)
|
|
78
|
+
diagnostic['attempted']['edges'] += matches.length
|
|
79
|
+
diagnostic['matched']['edges'] += matches.count { |match| fully_matched_edge?(match) }
|
|
80
|
+
diagnostic['partially_matched']['edges'] += matches.count { |match| partially_matched_edge?(match) }
|
|
81
|
+
diagnostic['unmatched']['edges'] += matches.count { |match| unmatched_edge?(match) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def update_dynamic_resolution_counts(diagnostic, nodes, edges)
|
|
85
|
+
nodes.each { |resolution| increment_resolution_count(diagnostic, 'nodes', resolution) }
|
|
86
|
+
edges.each do |match|
|
|
87
|
+
increment_resolution_count(diagnostic, 'edge_endpoints', match.fetch(:caller))
|
|
88
|
+
increment_resolution_count(diagnostic, 'edge_endpoints', match.fetch(:callee))
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def increment_resolution_count(diagnostic, kind, resolution)
|
|
93
|
+
diagnostic['resolution'][kind][resolution.fetch(:status).to_s] += 1
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def fully_matched_edge?(match)
|
|
97
|
+
match.fetch(:materialized)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def partially_matched_edge?(match)
|
|
101
|
+
!fully_matched_edge?(match) && !unmatched_edge?(match)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def unmatched_edge?(match)
|
|
105
|
+
missing_dynamic_resolution?(match.fetch(:caller)) && missing_dynamic_resolution?(match.fetch(:callee))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def ambiguous_dynamic_resolution?(resolution)
|
|
109
|
+
resolution.fetch(:status) == :ambiguous
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def missing_dynamic_resolution?(resolution)
|
|
113
|
+
resolution.fetch(:status) == :missing
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def incomplete_edge_sample(match)
|
|
117
|
+
return if fully_matched_edge?(match)
|
|
118
|
+
|
|
119
|
+
caller = match.fetch(:caller)
|
|
120
|
+
callee = match.fetch(:callee)
|
|
121
|
+
incomplete = []
|
|
122
|
+
missing = dynamic_endpoints_with_status(caller, callee, :missing)
|
|
123
|
+
ambiguous = dynamic_endpoints_with_status(caller, callee, :ambiguous)
|
|
124
|
+
incomplete << "unmatched #{missing.join(', ')}" unless missing.empty?
|
|
125
|
+
incomplete << "ambiguous #{ambiguous.join(', ')}" unless ambiguous.empty?
|
|
126
|
+
"#{dynamic_reference_label(caller)} -> #{dynamic_reference_label(callee)} (#{incomplete.join(', ')})"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def dynamic_endpoints_with_status(caller, callee, status)
|
|
130
|
+
{ 'caller' => caller, 'callee' => callee }.filter_map do |endpoint, resolution|
|
|
131
|
+
"#{endpoint}: #{dynamic_reference_label(resolution)}" if resolution.fetch(:status) == status
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def dynamic_reference_label(resolution)
|
|
136
|
+
reference = resolution.fetch(:reference)
|
|
137
|
+
return reference.fetch('identifier') if reference.key?('identifier')
|
|
138
|
+
|
|
139
|
+
identifier = reference['definition_id'] || reference['symbol_id'] || '<invalid>'
|
|
140
|
+
location = [reference['file'], reference['line']].compact.join(':')
|
|
141
|
+
location.empty? ? identifier : "#{identifier} @ #{location}"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def append_dynamic_samples(diagnostic, kind, samples)
|
|
145
|
+
combined = diagnostic['unmatched_samples'][kind] + samples
|
|
146
|
+
diagnostic['unmatched_samples'][kind] = combined.uniq.sort.first(SAMPLE_LIMIT)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def append_resolution_samples(diagnostic, nodes, edges)
|
|
150
|
+
node_samples = nodes.map { |resolution| resolution_sample(resolution) }
|
|
151
|
+
endpoint_samples = edges.flat_map do |match|
|
|
152
|
+
%i[caller callee].map do |endpoint|
|
|
153
|
+
resolution_sample(match.fetch(endpoint)).merge('endpoint' => endpoint.to_s)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
append_bounded_resolution_samples(diagnostic, 'nodes', node_samples)
|
|
157
|
+
append_bounded_resolution_samples(diagnostic, 'edge_endpoints', endpoint_samples)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def resolution_sample(resolution)
|
|
161
|
+
{
|
|
162
|
+
'reference' => resolution.fetch(:reference),
|
|
163
|
+
'status' => resolution.fetch(:status).to_s,
|
|
164
|
+
'definition_ids' => resolution.fetch(:definitions).map(&:graph_id).sort
|
|
165
|
+
}
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def append_bounded_resolution_samples(diagnostic, kind, samples)
|
|
169
|
+
combined = diagnostic['resolution_samples'][kind] + samples
|
|
170
|
+
diagnostic['resolution_samples'][kind] = combined.uniq.sort_by do |sample|
|
|
171
|
+
canonical_dynamic_sample(sample)
|
|
172
|
+
end.first(SAMPLE_LIMIT)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def canonical_dynamic_sample(value)
|
|
176
|
+
case value
|
|
177
|
+
when Hash
|
|
178
|
+
value.keys.sort.map { |key| [key, canonical_dynamic_sample(value.fetch(key))] }
|
|
179
|
+
when Array
|
|
180
|
+
value.map { |item| canonical_dynamic_sample(item) }
|
|
181
|
+
else
|
|
182
|
+
value.to_s
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def warn_unmatched_dynamic_evidence(matches, samples)
|
|
187
|
+
return if samples.empty?
|
|
188
|
+
|
|
189
|
+
precise = matches.count { |resolution| precise_dynamic_resolution?(resolution) }
|
|
190
|
+
ambiguous = matches.count { |resolution| ambiguous_dynamic_resolution?(resolution) }
|
|
191
|
+
missing = matches.count { |resolution| missing_dynamic_resolution?(resolution) }
|
|
192
|
+
warn "Necropsy matched #{precise} of #{matches.length} dynamic node IDs precisely; ambiguous #{ambiguous}; " \
|
|
193
|
+
"missing #{missing}; unresolved: #{samples.first(SAMPLE_LIMIT).join(', ')}"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def warn_unmatched_dynamic_edges(matches, samples)
|
|
197
|
+
return if samples.empty?
|
|
198
|
+
|
|
199
|
+
full = matches.count { |match| fully_matched_edge?(match) }
|
|
200
|
+
partial = matches.count { |match| partially_matched_edge?(match) }
|
|
201
|
+
unmatched = matches.count { |match| unmatched_edge?(match) }
|
|
202
|
+
warn "Necropsy fully matched #{full} of #{matches.length} dynamic edges; partially matched #{partial}; " \
|
|
203
|
+
"unmatched #{unmatched}; unmatched endpoints: #{samples.first(SAMPLE_LIMIT).join(', ')}"
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
module Necropsy
|
|
6
|
+
module EvidenceStore
|
|
7
|
+
PROJECTIONS = %i[exact conservative observed].freeze
|
|
8
|
+
|
|
9
|
+
def self.normalize_projection(projection)
|
|
10
|
+
normalized = projection.to_sym if projection.respond_to?(:to_sym)
|
|
11
|
+
return normalized if PROJECTIONS.include?(normalized)
|
|
12
|
+
|
|
13
|
+
raise ArgumentError, "projection must be one of: #{PROJECTIONS.join(', ')}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def evidence_records
|
|
17
|
+
@evidence_records_by_id.values.sort_by(&:evidence_id).freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def evidence_record(evidence_id)
|
|
21
|
+
@evidence_records_by_id[evidence_id.to_s]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def evidence_collisions
|
|
25
|
+
@evidence_collision_payloads.keys.sort.map do |evidence_id|
|
|
26
|
+
{
|
|
27
|
+
'evidence_id' => evidence_id,
|
|
28
|
+
'payload_sha256s' => @evidence_collision_payloads.fetch(evidence_id).to_a.sort,
|
|
29
|
+
'domains' => @evidence_domains.fetch(evidence_id, Set.new).map(&:to_s).sort
|
|
30
|
+
}
|
|
31
|
+
end.freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def initialize_evidence_store
|
|
37
|
+
@evidence_records_by_id = {}
|
|
38
|
+
@evidence_payloads_by_id = {}
|
|
39
|
+
@evidence_payloads_by_object = {}.compare_by_identity
|
|
40
|
+
@evidence_domains = Hash.new { |hash, key| hash[key] = Set.new }
|
|
41
|
+
@quarantined_evidence_ids = Set.new
|
|
42
|
+
@evidence_collision_payloads = Hash.new { |hash, key| hash[key] = Set.new }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def register_evidence(evidence, domain: :runtime, canonical_payload: nil)
|
|
46
|
+
record = evidence_with_identity(evidence)
|
|
47
|
+
evidence_id = record.evidence_id
|
|
48
|
+
payload = canonical_payload || canonical_evidence_payload(record)
|
|
49
|
+
@evidence_payloads_by_object[record] = [evidence_id, payload]
|
|
50
|
+
@evidence_domains[evidence_id] << domain.to_sym
|
|
51
|
+
|
|
52
|
+
if @quarantined_evidence_ids.include?(evidence_id)
|
|
53
|
+
@evidence_collision_payloads[evidence_id] << Digest::SHA256.hexdigest(payload)
|
|
54
|
+
rebuild_evidence_collision_state
|
|
55
|
+
return
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
existing_payload = @evidence_payloads_by_id[evidence_id]
|
|
59
|
+
if existing_payload && existing_payload != payload
|
|
60
|
+
@evidence_collision_payloads[evidence_id].merge(
|
|
61
|
+
[Digest::SHA256.hexdigest(existing_payload), Digest::SHA256.hexdigest(payload)]
|
|
62
|
+
)
|
|
63
|
+
quarantine_evidence(evidence_id)
|
|
64
|
+
return
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
@evidence_records_by_id[evidence_id] ||= record
|
|
68
|
+
@evidence_payloads_by_id[evidence_id] ||= payload
|
|
69
|
+
refresh_evidence_store_observation if observation.key?('evidence_store')
|
|
70
|
+
evidence_id
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def evidence_with_identity(evidence)
|
|
74
|
+
return evidence if evidence.evidence_id
|
|
75
|
+
|
|
76
|
+
evidence.with(evidence_id: EvidenceIdentity.generate(evidence.to_h.except('evidence_id')))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def canonical_evidence_payload(evidence)
|
|
80
|
+
BoundedCanonicalizer.dump(evidence.to_h.except('evidence_id'))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def quarantine_evidence(evidence_id)
|
|
84
|
+
@quarantined_evidence_ids << evidence_id
|
|
85
|
+
@evidence_records_by_id.delete(evidence_id)
|
|
86
|
+
@evidence_payloads_by_id.delete(evidence_id)
|
|
87
|
+
remove_evidence_references(evidence_id)
|
|
88
|
+
rebuild_evidence_collision_state
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def remove_evidence_references(evidence_id)
|
|
92
|
+
@edges.each_value do |callees|
|
|
93
|
+
callees.each_value { |evidence_ids| evidence_ids.delete(evidence_id) }
|
|
94
|
+
callees.delete_if { |_callee_id, evidence_ids| evidence_ids.empty? }
|
|
95
|
+
end
|
|
96
|
+
@edges.delete_if { |_caller_id, callees| callees.empty? }
|
|
97
|
+
@dynamic_alive.each_value { |evidence_ids| evidence_ids.delete(evidence_id) }
|
|
98
|
+
@dynamic_alive.delete_if { |_node_id, evidence_ids| evidence_ids.empty? }
|
|
99
|
+
rebuild_incoming_edges
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def rebuild_evidence_collision_state
|
|
103
|
+
remove_blockers_matching do |blocker|
|
|
104
|
+
blocker.kind == :evidence_collision && blocker.source == :evidence_store
|
|
105
|
+
end
|
|
106
|
+
@evidence_collision_payloads.keys.sort.each do |evidence_id|
|
|
107
|
+
@evidence_domains.fetch(evidence_id).to_a.sort.each do |domain|
|
|
108
|
+
add_blocker(evidence_collision_blocker(evidence_id, domain))
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
refresh_evidence_store_observation
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def refresh_evidence_store_observation
|
|
115
|
+
observation['evidence_store'] = {
|
|
116
|
+
'record_count' => @evidence_records_by_id.length,
|
|
117
|
+
'collision_count' => @evidence_collision_payloads.length,
|
|
118
|
+
'collisions' => evidence_collisions.first(5)
|
|
119
|
+
}
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def evidence_collision_blocker(evidence_id, domain)
|
|
123
|
+
Blocker.new(
|
|
124
|
+
kind: :evidence_collision,
|
|
125
|
+
scope_kind: :global,
|
|
126
|
+
scope_value: '*',
|
|
127
|
+
source: :evidence_store,
|
|
128
|
+
reason: 'One evidence ID was supplied with conflicting payloads',
|
|
129
|
+
suggested_action: :fix_analyzer,
|
|
130
|
+
metadata: {
|
|
131
|
+
'caller_domain' => domain.to_s,
|
|
132
|
+
'receiver_kind' => 'implicit',
|
|
133
|
+
'evidence_id' => evidence_id,
|
|
134
|
+
'evidence_store' => true
|
|
135
|
+
}
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def projected_evidence_records(evidence_ids, projection:, scope: nil)
|
|
140
|
+
projection = normalize_projection(projection)
|
|
141
|
+
evidence_ids.filter_map { |evidence_id| evidence_record(evidence_id) }
|
|
142
|
+
.select { |evidence| evidence_in_projection?(evidence, projection, scope) }
|
|
143
|
+
.sort_by(&:evidence_id)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def projected_evidence_ids(evidence_ids, projection:, scope: nil)
|
|
147
|
+
projected_evidence_records(evidence_ids, projection: projection, scope: scope).map(&:evidence_id).freeze
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def normalize_projection(projection)
|
|
151
|
+
EvidenceStore.normalize_projection(projection)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def evidence_payload_registered?(evidence, canonical_payload: nil)
|
|
155
|
+
evidence_id = evidence.evidence_id
|
|
156
|
+
cached = @evidence_payloads_by_object[evidence]
|
|
157
|
+
return true if cached && cached[0] == evidence_id && @evidence_payloads_by_id[evidence_id] == cached[1]
|
|
158
|
+
|
|
159
|
+
payload = canonical_payload || canonical_evidence_payload(evidence)
|
|
160
|
+
return true if @evidence_payloads_by_id[evidence_id] == payload
|
|
161
|
+
return false unless @quarantined_evidence_ids.include?(evidence_id)
|
|
162
|
+
|
|
163
|
+
@evidence_collision_payloads.fetch(evidence_id).include?(Digest::SHA256.hexdigest(payload))
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def evidence_in_projection?(evidence, projection, requested_scope)
|
|
167
|
+
grade = evidence.grade&.to_sym
|
|
168
|
+
case projection
|
|
169
|
+
when :conservative
|
|
170
|
+
true
|
|
171
|
+
when :exact
|
|
172
|
+
grade == :exact || (grade == :observed && verified_observed_scope?(evidence.scope, requested_scope))
|
|
173
|
+
when :observed
|
|
174
|
+
grade == :observed && evidence_scope_matches?(evidence.scope, requested_scope)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def verified_observed_scope?(actual_scope, requested_scope)
|
|
179
|
+
return false unless requested_scope.is_a?(Hash)
|
|
180
|
+
|
|
181
|
+
actual_status = actual_scope.is_a?(Hash) && actual_scope['source_revision_status']
|
|
182
|
+
return false if %w[mismatch stale].include?(actual_status.to_s)
|
|
183
|
+
|
|
184
|
+
revision = requested_scope['revision'] || requested_scope[:revision]
|
|
185
|
+
revision && evidence_scope_matches?(actual_scope, requested_scope)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def evidence_scope_matches?(actual_scope, requested_scope)
|
|
189
|
+
return true if requested_scope.nil?
|
|
190
|
+
|
|
191
|
+
actual_scope = actual_scope.to_h if actual_scope.respond_to?(:to_h)
|
|
192
|
+
requested_scope = requested_scope.to_h if requested_scope.respond_to?(:to_h)
|
|
193
|
+
return canonical_scope(actual_scope) == canonical_scope(requested_scope) unless requested_scope.is_a?(Hash)
|
|
194
|
+
return false unless actual_scope.is_a?(Hash)
|
|
195
|
+
|
|
196
|
+
requested_scope.all? do |key, expected|
|
|
197
|
+
actual_key = [key, key.to_s, key.respond_to?(:to_sym) && key.to_sym].find do |candidate|
|
|
198
|
+
candidate && actual_scope.key?(candidate)
|
|
199
|
+
end
|
|
200
|
+
next false unless actual_key
|
|
201
|
+
|
|
202
|
+
actual = actual_scope.fetch(actual_key)
|
|
203
|
+
evidence_scope_matches?(actual, expected)
|
|
204
|
+
end
|
|
205
|
+
rescue KeyError, NoMethodError, BoundedCanonicalizer::Error
|
|
206
|
+
false
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def canonical_scope(value)
|
|
210
|
+
BoundedCanonicalizer.dump(value)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|