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
|
@@ -2,46 +2,153 @@
|
|
|
2
2
|
|
|
3
3
|
require 'yaml'
|
|
4
4
|
require 'time'
|
|
5
|
+
require 'tempfile'
|
|
5
6
|
|
|
6
7
|
module Necropsy
|
|
7
8
|
module Guardrail
|
|
8
9
|
class Baseline
|
|
9
|
-
|
|
10
|
+
SCHEMA_VERSION = 2
|
|
11
|
+
MIGRATION_SCHEMA_VERSION = 1
|
|
12
|
+
CLASSIFICATIONS = %w[unreachable unused blocked test_only_reachable].freeze
|
|
13
|
+
|
|
14
|
+
Comparison = Data.define(:matched_findings, :new_findings, :ambiguities, :review_report) do
|
|
15
|
+
def review_required?
|
|
16
|
+
ambiguities.any?
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
attr_reader :path, :fingerprints, :schema_version
|
|
10
21
|
|
|
11
22
|
def self.load(path)
|
|
12
23
|
return new(path: path, findings: []) unless File.exist?(path)
|
|
13
24
|
|
|
14
|
-
payload = YAML.safe_load_file(path, aliases: false)
|
|
15
|
-
|
|
25
|
+
payload = YAML.safe_load_file(path, aliases: false)
|
|
26
|
+
payload = {} if payload.nil?
|
|
27
|
+
raise Error, 'Baseline must contain a YAML mapping' unless payload.is_a?(Hash)
|
|
28
|
+
|
|
29
|
+
schema_version = schema_version_from(payload)
|
|
30
|
+
findings = payload.fetch('findings', [])
|
|
31
|
+
raise Error, 'Baseline findings must be an array' unless findings.is_a?(Array)
|
|
32
|
+
|
|
33
|
+
new(path: path, findings: findings, schema_version: schema_version)
|
|
16
34
|
end
|
|
17
35
|
|
|
18
|
-
def self.
|
|
19
|
-
|
|
36
|
+
def self.schema_version_from(payload)
|
|
37
|
+
explicit = normalize_schema_version(payload['schema_version']) if payload.key?('schema_version')
|
|
38
|
+
legacy = normalize_schema_version(payload['version']) if payload.key?('version')
|
|
39
|
+
raise Error, "Conflicting baseline schema versions: #{explicit} and #{legacy}" if explicit && legacy && explicit != legacy
|
|
40
|
+
|
|
41
|
+
explicit || legacy || 1
|
|
42
|
+
end
|
|
43
|
+
private_class_method :schema_version_from
|
|
44
|
+
|
|
45
|
+
def self.normalize_schema_version(value)
|
|
46
|
+
normalized = if value.is_a?(Integer)
|
|
47
|
+
value
|
|
48
|
+
elsif value.is_a?(String) && value.match?(/\A[0-9]+\z/)
|
|
49
|
+
value.to_i
|
|
50
|
+
end
|
|
51
|
+
raise Error, "Invalid baseline schema version: #{value.inspect}" unless normalized
|
|
52
|
+
|
|
53
|
+
normalized
|
|
54
|
+
end
|
|
55
|
+
private_class_method :normalize_schema_version
|
|
56
|
+
|
|
57
|
+
def self.write(report, path:, clock: Clock.new)
|
|
58
|
+
findings = report.actionable_candidates(min_confidence: :low).map do |finding|
|
|
20
59
|
{
|
|
21
|
-
'fingerprint' => finding.
|
|
60
|
+
'fingerprint' => finding.physical_fingerprint,
|
|
61
|
+
'logical_fingerprint' => finding.logical_fingerprint,
|
|
22
62
|
'classification' => finding.classification.to_s,
|
|
23
63
|
'confidence' => finding.confidence.to_s,
|
|
24
64
|
'node_id' => finding.node.id,
|
|
65
|
+
'symbol_id' => finding.node.symbol_id,
|
|
66
|
+
'definition_id' => finding.node.definition_id,
|
|
67
|
+
'body_digest' => finding.node.body_digest,
|
|
25
68
|
'file' => finding.node.file,
|
|
26
69
|
'line' => finding.node.line
|
|
27
70
|
}
|
|
28
71
|
end
|
|
29
72
|
payload = {
|
|
30
|
-
'
|
|
31
|
-
'
|
|
73
|
+
'schema_version' => SCHEMA_VERSION,
|
|
74
|
+
'version' => SCHEMA_VERSION,
|
|
75
|
+
'identity' => 'physical_definition',
|
|
76
|
+
'generated_at' => clock.time.iso8601,
|
|
32
77
|
'findings' => findings
|
|
33
78
|
}
|
|
34
|
-
|
|
79
|
+
atomic_write(path, payload.to_yaml)
|
|
35
80
|
end
|
|
36
81
|
|
|
37
|
-
def
|
|
82
|
+
def self.atomic_write(path, contents)
|
|
83
|
+
directory = File.dirname(path)
|
|
84
|
+
Tempfile.create([".#{File.basename(path)}", '.tmp'], directory) do |file|
|
|
85
|
+
file.write(contents)
|
|
86
|
+
file.flush
|
|
87
|
+
file.fsync
|
|
88
|
+
file.close
|
|
89
|
+
File.rename(file.path, path)
|
|
90
|
+
end
|
|
91
|
+
File.open(directory, 'rb', &:fsync)
|
|
92
|
+
rescue Errno::EINVAL, Errno::EISDIR
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
private_class_method :atomic_write
|
|
96
|
+
|
|
97
|
+
def initialize(path:, findings:, schema_version: 1)
|
|
38
98
|
@path = path
|
|
39
|
-
@
|
|
40
|
-
|
|
99
|
+
@schema_version = normalize_schema_version(schema_version)
|
|
100
|
+
raise Error, "Unsupported baseline schema version: #{@schema_version}" unless [1, SCHEMA_VERSION].include?(@schema_version)
|
|
101
|
+
|
|
102
|
+
@findings = findings.map do |finding|
|
|
103
|
+
raise Error, 'Baseline findings must be mappings' unless finding.is_a?(Hash)
|
|
104
|
+
|
|
105
|
+
finding.transform_keys(&:to_s).tap { |entry| validate_entry!(entry) }
|
|
106
|
+
end
|
|
107
|
+
validate_duplicate_identities!
|
|
108
|
+
@fingerprints = @findings.filter_map { |finding| finding['fingerprint'] }.to_set
|
|
41
109
|
end
|
|
42
110
|
|
|
43
111
|
def include?(finding)
|
|
44
|
-
|
|
112
|
+
fingerprint = schema_version == SCHEMA_VERSION ? finding.physical_fingerprint : finding.logical_fingerprint
|
|
113
|
+
fingerprints.include?(fingerprint)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def compare(findings, migration: false)
|
|
117
|
+
current = findings.sort_by { |finding| [finding.node.file, finding.node.line, finding.node.definition_id] }
|
|
118
|
+
current_index = build_current_index(current)
|
|
119
|
+
assignments = {}
|
|
120
|
+
assignment_indexes = {}
|
|
121
|
+
ambiguities = []
|
|
122
|
+
|
|
123
|
+
@findings.each_with_index do |entry, index|
|
|
124
|
+
resolution = resolve_entry(entry, current_index, migration: migration)
|
|
125
|
+
if resolution[:review_required]
|
|
126
|
+
ambiguities << ambiguity(entry, index, resolution, assignment_indexes)
|
|
127
|
+
next
|
|
128
|
+
end
|
|
129
|
+
next if resolution[:candidates].empty?
|
|
130
|
+
|
|
131
|
+
candidate = resolution[:candidates].first
|
|
132
|
+
candidate_key = candidate&.physical_fingerprint
|
|
133
|
+
if resolution[:candidates].one? && !assignment_indexes.key?(candidate_key)
|
|
134
|
+
assignments[index] = candidate
|
|
135
|
+
assignment_indexes[candidate_key] = index
|
|
136
|
+
else
|
|
137
|
+
ambiguities << ambiguity(entry, index, resolution, assignment_indexes)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
matched = assignments.values.uniq
|
|
142
|
+
Comparison.new(
|
|
143
|
+
matched_findings: matched,
|
|
144
|
+
new_findings: current - matched,
|
|
145
|
+
ambiguities: ambiguities,
|
|
146
|
+
review_report: migration_review_report(ambiguities)
|
|
147
|
+
)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def migrate(findings)
|
|
151
|
+
compare(findings, migration: true)
|
|
45
152
|
end
|
|
46
153
|
|
|
47
154
|
def count_at_least(confidence)
|
|
@@ -51,6 +158,236 @@ module Necropsy
|
|
|
51
158
|
level && CONFIDENCE_LEVELS.fetch(level, -1) >= threshold
|
|
52
159
|
end
|
|
53
160
|
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
def normalize_schema_version(value)
|
|
165
|
+
self.class.send(:normalize_schema_version, value)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def resolve_entry(entry, current_index, migration:)
|
|
169
|
+
return resolve_exact_entry(entry, current_index) unless migration
|
|
170
|
+
return resolve_v1_entry(entry, current_index) if schema_version == 1
|
|
171
|
+
|
|
172
|
+
strategies(entry).each do |strategy, value|
|
|
173
|
+
next if value.nil? || value.to_s.empty?
|
|
174
|
+
|
|
175
|
+
matches = indexed_matches(current_index, strategy, value, entry)
|
|
176
|
+
return { strategy: strategy, candidates: matches } unless matches.empty?
|
|
177
|
+
end
|
|
178
|
+
{ strategy: 'unmatched', candidates: [] }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def resolve_exact_entry(entry, current_index)
|
|
182
|
+
if schema_version == 1
|
|
183
|
+
resolution = resolve_v1_entry(entry, current_index)
|
|
184
|
+
return resolution.merge(review_required: true, reason: 'legacy_baseline_requires_migration')
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
value = entry['definition_id'] || entry['fingerprint']
|
|
188
|
+
candidates = indexed_matches(current_index, 'exact', value, entry)
|
|
189
|
+
{ strategy: 'exact', candidates: candidates }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def resolve_v1_entry(entry, current_index)
|
|
193
|
+
identity_matches = if present?(symbol_hint(entry))
|
|
194
|
+
indexed_symbol_path(current_index, entry)
|
|
195
|
+
else
|
|
196
|
+
[]
|
|
197
|
+
end
|
|
198
|
+
return { strategy: 'logical_identity', candidates: identity_matches } if identity_matches.length > 1
|
|
199
|
+
|
|
200
|
+
if present?(entry['fingerprint'])
|
|
201
|
+
fingerprint_matches = current_index.fetch(:logical_fingerprint).fetch(entry['fingerprint'], [])
|
|
202
|
+
return { strategy: 'logical_fingerprint', candidates: fingerprint_matches } unless fingerprint_matches.empty?
|
|
203
|
+
|
|
204
|
+
return { strategy: 'unmatched', candidates: [] }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
matches = identity_matches.select { |finding| same_classification?(entry, finding) }
|
|
208
|
+
matches.empty? ? { strategy: 'unmatched', candidates: [] } : { strategy: 'symbol_path_hint', candidates: matches }
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def build_current_index(current)
|
|
212
|
+
{
|
|
213
|
+
definition_id: current.group_by { |finding| finding.node.definition_id },
|
|
214
|
+
physical_fingerprint: current.group_by(&:physical_fingerprint),
|
|
215
|
+
logical_fingerprint: current.group_by(&:logical_fingerprint),
|
|
216
|
+
body_digest: current.group_by { |finding| finding.node.body_digest },
|
|
217
|
+
symbol: current.group_by { |finding| finding.node.symbol_id },
|
|
218
|
+
symbol_path: current.group_by { |finding| symbol_path_key_for(finding) }
|
|
219
|
+
}
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def indexed_matches(current_index, strategy, value, entry)
|
|
223
|
+
candidates = case strategy
|
|
224
|
+
when 'exact'
|
|
225
|
+
by_definition = current_index.fetch(:definition_id).fetch(value, [])
|
|
226
|
+
by_fingerprint = current_index.fetch(:physical_fingerprint).fetch(value, [])
|
|
227
|
+
by_definition + by_fingerprint
|
|
228
|
+
when 'body_digest'
|
|
229
|
+
current_index.fetch(:body_digest).fetch(value, []).select do |finding|
|
|
230
|
+
symbol_path_match?(entry, finding)
|
|
231
|
+
end
|
|
232
|
+
when 'symbol_path_hint'
|
|
233
|
+
indexed_symbol_path(current_index, entry)
|
|
234
|
+
else
|
|
235
|
+
[]
|
|
236
|
+
end
|
|
237
|
+
candidates.uniq.select { |finding| same_classification?(entry, finding) }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def strategies(entry)
|
|
241
|
+
exact = entry['definition_id']
|
|
242
|
+
exact ||= entry['fingerprint'] if schema_version == SCHEMA_VERSION
|
|
243
|
+
[
|
|
244
|
+
['exact', exact],
|
|
245
|
+
['body_digest', entry['body_digest']],
|
|
246
|
+
['symbol_path_hint', symbol_hint(entry)]
|
|
247
|
+
]
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def symbol_path_match?(entry, finding)
|
|
251
|
+
symbol = symbol_hint(entry)
|
|
252
|
+
return false unless symbol == finding.node.symbol_id
|
|
253
|
+
|
|
254
|
+
path = entry['file']
|
|
255
|
+
path.nil? || path.empty? || path == finding.node.file
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def symbol_hint(entry)
|
|
259
|
+
entry['symbol_id'] || entry['node_id']
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def symbol_path_key(entry)
|
|
263
|
+
[symbol_hint(entry), present?(entry['file']) ? entry['file'] : nil]
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def indexed_symbol_path(current_index, entry)
|
|
267
|
+
return current_index.fetch(:symbol).fetch(symbol_hint(entry), []) unless present?(entry['file'])
|
|
268
|
+
|
|
269
|
+
current_index.fetch(:symbol_path).fetch(symbol_path_key(entry), [])
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def symbol_path_key_for(finding)
|
|
273
|
+
[finding.node.symbol_id, finding.node.file]
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def validate_entry!(entry)
|
|
277
|
+
string_fields = %w[
|
|
278
|
+
fingerprint logical_fingerprint classification confidence node_id symbol_id definition_id body_digest file
|
|
279
|
+
]
|
|
280
|
+
string_fields.each do |field|
|
|
281
|
+
value = entry[field]
|
|
282
|
+
raise Error, "Baseline #{field} must be a string" unless value.nil? || value.is_a?(String)
|
|
283
|
+
end
|
|
284
|
+
line = entry['line']
|
|
285
|
+
raise Error, 'Baseline line must be a positive integer' unless line.nil? || (line.is_a?(Integer) && line.positive?)
|
|
286
|
+
|
|
287
|
+
classification = entry['classification']
|
|
288
|
+
raise Error, "Unknown baseline classification: #{classification}" if
|
|
289
|
+
classification && !CLASSIFICATIONS.include?(classification)
|
|
290
|
+
|
|
291
|
+
confidence = entry['confidence']
|
|
292
|
+
raise Error, "Unknown baseline confidence: #{confidence}" if
|
|
293
|
+
confidence && !CONFIDENCE_LEVELS.key?(confidence.to_sym)
|
|
294
|
+
|
|
295
|
+
validate_v1_fingerprint!(entry) if schema_version == 1
|
|
296
|
+
validate_v2_fingerprints!(entry) if schema_version == SCHEMA_VERSION
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def validate_duplicate_identities!
|
|
300
|
+
identities = @findings.filter_map do |entry|
|
|
301
|
+
if schema_version == SCHEMA_VERSION && present?(entry['definition_id']) && present?(entry['classification'])
|
|
302
|
+
physical_fingerprint(entry['classification'], entry['definition_id'])
|
|
303
|
+
elsif present?(entry['fingerprint'])
|
|
304
|
+
entry['fingerprint']
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
duplicate = identities.tally.find { |_identity, count| count > 1 }&.first
|
|
308
|
+
raise Error, "Duplicate baseline physical identity: #{duplicate}" if duplicate
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def validate_v1_fingerprint!(entry)
|
|
312
|
+
return unless present?(entry['fingerprint']) && present?(entry['classification']) && present?(symbol_hint(entry))
|
|
313
|
+
|
|
314
|
+
expected = logical_fingerprint(entry.fetch('classification'), symbol_hint(entry))
|
|
315
|
+
raise Error, 'Baseline v1 fingerprint contradicts its classification or symbol' unless entry['fingerprint'] == expected
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def validate_v2_fingerprints!(entry)
|
|
319
|
+
raise Error, 'Baseline v2 entries require classification' unless present?(entry['classification'])
|
|
320
|
+
|
|
321
|
+
identity_fields = [entry['fingerprint'], entry['definition_id'], entry['body_digest'], symbol_hint(entry)]
|
|
322
|
+
raise Error, 'Baseline v2 entries require a physical identity or migration hint' unless identity_fields.any? { present?(_1) }
|
|
323
|
+
|
|
324
|
+
if present?(entry['fingerprint']) && present?(entry['definition_id'])
|
|
325
|
+
expected = physical_fingerprint(entry.fetch('classification'), entry.fetch('definition_id'))
|
|
326
|
+
raise Error, 'Baseline v2 fingerprint contradicts its classification or definition' unless entry['fingerprint'] == expected
|
|
327
|
+
end
|
|
328
|
+
return unless present?(entry['logical_fingerprint']) && present?(entry['classification']) && present?(symbol_hint(entry))
|
|
329
|
+
|
|
330
|
+
expected = logical_fingerprint(entry.fetch('classification'), symbol_hint(entry))
|
|
331
|
+
raise Error, 'Baseline logical fingerprint contradicts its classification or symbol' unless entry['logical_fingerprint'] == expected
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def logical_fingerprint(classification, symbol_id)
|
|
335
|
+
Digest::SHA256.hexdigest("#{classification}:#{symbol_id}")
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def physical_fingerprint(classification, definition_id)
|
|
339
|
+
Digest::SHA256.hexdigest("#{classification}:#{definition_id}")
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def present?(value)
|
|
343
|
+
value.is_a?(String) && !value.empty?
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def same_classification?(entry, finding)
|
|
347
|
+
classification = entry['classification']
|
|
348
|
+
classification.nil? || classification == finding.classification.to_s
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def ambiguity(entry, index, resolution, assignment_indexes)
|
|
352
|
+
candidates = resolution[:candidates]
|
|
353
|
+
conflicts = candidates.filter_map { |finding| assignment_indexes[finding.physical_fingerprint] }.uniq.sort
|
|
354
|
+
{
|
|
355
|
+
'baseline_index' => index,
|
|
356
|
+
'strategy' => resolution[:strategy],
|
|
357
|
+
'reason' => resolution[:reason] ||
|
|
358
|
+
(conflicts.empty? ? 'multiple_current_definitions' : 'current_definition_already_matched'),
|
|
359
|
+
'conflicting_baseline_indexes' => conflicts,
|
|
360
|
+
'baseline' => baseline_identity(entry),
|
|
361
|
+
'candidates' => candidates.map { |finding| finding_identity(finding) }
|
|
362
|
+
}
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def baseline_identity(entry)
|
|
366
|
+
entry.slice('fingerprint', 'logical_fingerprint', 'classification', 'node_id', 'symbol_id', 'definition_id',
|
|
367
|
+
'body_digest', 'file', 'line')
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def finding_identity(finding)
|
|
371
|
+
{
|
|
372
|
+
'physical_fingerprint' => finding.physical_fingerprint,
|
|
373
|
+
'classification' => finding.classification.to_s,
|
|
374
|
+
'symbol_id' => finding.node.symbol_id,
|
|
375
|
+
'definition_id' => finding.node.definition_id,
|
|
376
|
+
'body_digest' => finding.node.body_digest,
|
|
377
|
+
'file' => finding.node.file,
|
|
378
|
+
'line' => finding.node.line
|
|
379
|
+
}
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def migration_review_report(ambiguities)
|
|
383
|
+
{
|
|
384
|
+
'schema_version' => MIGRATION_SCHEMA_VERSION,
|
|
385
|
+
'baseline_schema_version' => schema_version,
|
|
386
|
+
'baseline_path' => path,
|
|
387
|
+
'review_required' => ambiguities.any?,
|
|
388
|
+
'ambiguities' => ambiguities
|
|
389
|
+
}
|
|
390
|
+
end
|
|
54
391
|
end
|
|
55
392
|
end
|
|
56
393
|
end
|
|
@@ -1,24 +1,37 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'date'
|
|
4
|
+
require 'digest'
|
|
5
|
+
require 'tempfile'
|
|
4
6
|
|
|
5
7
|
module Necropsy
|
|
6
8
|
module Guardrail
|
|
7
9
|
class Quarantine
|
|
8
10
|
ANNOTATION_PREFIX = '# necropsy:quarantine'
|
|
9
11
|
|
|
10
|
-
def initialize(report:, root:)
|
|
12
|
+
def initialize(report:, root:, clock: Clock.new)
|
|
11
13
|
@report = report
|
|
12
14
|
@root = root
|
|
15
|
+
@clock = clock
|
|
13
16
|
end
|
|
14
17
|
|
|
15
18
|
def suggestions(min_confidence: :high)
|
|
16
|
-
report.dead_methods(min_confidence: min_confidence)
|
|
19
|
+
findings = report.dead_methods(min_confidence: min_confidence)
|
|
20
|
+
grouped = findings.group_by { |finding| [finding.node.file, finding.node.line] }
|
|
21
|
+
source_digests = {}
|
|
22
|
+
grouped.sort_by { |(file, line), _entries| [file, line] }.map do |(file, line), entries|
|
|
23
|
+
entries = entries.sort_by { |finding| finding.node.definition_id }
|
|
24
|
+
fingerprints = entries.map(&:physical_fingerprint).uniq
|
|
25
|
+
path = source_path(file)
|
|
26
|
+
source_digests[path] ||= Digest::SHA256.file(path).hexdigest
|
|
17
27
|
{
|
|
18
|
-
finding:
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
28
|
+
finding: entries.first,
|
|
29
|
+
findings: entries.freeze,
|
|
30
|
+
annotation: "#{ANNOTATION_PREFIX} since=#{clock.date.iso8601} #{fingerprint_field(fingerprints)}",
|
|
31
|
+
fingerprints: fingerprints.freeze,
|
|
32
|
+
source_sha256: source_digests.fetch(path),
|
|
33
|
+
path: path,
|
|
34
|
+
line: line
|
|
22
35
|
}
|
|
23
36
|
end
|
|
24
37
|
end
|
|
@@ -27,26 +40,98 @@ module Necropsy
|
|
|
27
40
|
grouped = suggestions(min_confidence: min_confidence).group_by { |suggestion| suggestion[:path] }
|
|
28
41
|
grouped.each do |path, entries|
|
|
29
42
|
source = File.binread(path)
|
|
43
|
+
verify_source_digest!(path, source, entries)
|
|
30
44
|
newline = source[/\r\n|\n/] || "\n"
|
|
31
45
|
trailing_newline = source.end_with?("\r\n", "\n")
|
|
32
46
|
lines = source.split(/\r\n|\n/, -1)
|
|
33
47
|
lines.pop if trailing_newline
|
|
34
48
|
entries.sort_by { |entry| -entry[:line] }.each do |entry|
|
|
35
49
|
index = [entry[:line] - 1, 0].max
|
|
36
|
-
|
|
50
|
+
if index.positive? && lines[index - 1]&.include?(ANNOTATION_PREFIX)
|
|
51
|
+
upgrade_existing_annotation!(lines, index - 1, entry)
|
|
52
|
+
next
|
|
53
|
+
end
|
|
37
54
|
|
|
38
55
|
indent = lines[index][/^\s*/] || ''
|
|
39
56
|
lines.insert(index, "#{indent}#{entry[:annotation]}")
|
|
40
57
|
end
|
|
41
58
|
rewritten = lines.join(newline)
|
|
42
59
|
rewritten << newline if trailing_newline
|
|
43
|
-
|
|
60
|
+
atomic_replace(path, rewritten, expected_sha256: entries.first.fetch(:source_sha256))
|
|
44
61
|
end
|
|
45
62
|
end
|
|
46
63
|
|
|
47
64
|
private
|
|
48
65
|
|
|
49
|
-
attr_reader :report, :root
|
|
66
|
+
attr_reader :report, :root, :clock
|
|
67
|
+
|
|
68
|
+
def source_path(relative)
|
|
69
|
+
expanded_root = File.expand_path(root)
|
|
70
|
+
path = File.expand_path(relative, expanded_root)
|
|
71
|
+
real_root = File.realpath(expanded_root)
|
|
72
|
+
real_path = File.realpath(path)
|
|
73
|
+
inside_root = real_path.start_with?("#{real_root}#{File::SEPARATOR}")
|
|
74
|
+
return path if path.start_with?("#{expanded_root}#{File::SEPARATOR}") && inside_root && File.file?(path)
|
|
75
|
+
|
|
76
|
+
raise Error, "Quarantine source is outside the project or missing: #{relative}"
|
|
77
|
+
rescue SystemCallError
|
|
78
|
+
raise Error, "Quarantine source is outside the project or missing: #{relative}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def fingerprint_field(fingerprints)
|
|
82
|
+
return "fingerprint=#{fingerprints.first}" if fingerprints.one?
|
|
83
|
+
|
|
84
|
+
"fingerprints=#{fingerprints.join(',')}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def verify_source_digest!(path, source, entries)
|
|
88
|
+
expected = entries.map { |entry| entry.fetch(:source_sha256) }.uniq
|
|
89
|
+
current = Digest::SHA256.hexdigest(source)
|
|
90
|
+
return if expected.one? && expected.first == current
|
|
91
|
+
|
|
92
|
+
raise Error, "Quarantine source changed after analysis: #{path}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def upgrade_existing_annotation!(lines, annotation_index, entry)
|
|
96
|
+
annotation = lines.fetch(annotation_index)
|
|
97
|
+
expected = entry.fetch(:fingerprints)
|
|
98
|
+
return if annotation_fingerprints(annotation) == expected.sort
|
|
99
|
+
|
|
100
|
+
raise Error, "Existing quarantine fingerprint does not match #{entry[:path]}:#{entry[:line]}" if
|
|
101
|
+
annotation.match?(/\bfingerprints?=/)
|
|
102
|
+
|
|
103
|
+
lines[annotation_index] = "#{annotation} #{fingerprint_field(expected)}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def annotation_fingerprints(annotation)
|
|
107
|
+
single = annotation[/\bfingerprint=([0-9a-f]{64})(?:\s|$)/, 1]
|
|
108
|
+
multiple = annotation[/\bfingerprints=([0-9a-f]{64}(?:,[0-9a-f]{64})*)(?:\s|$)/, 1]
|
|
109
|
+
Array(single || multiple&.split(',')).sort
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def atomic_replace(path, contents, expected_sha256:)
|
|
113
|
+
directory = File.dirname(path)
|
|
114
|
+
mode = File.stat(path).mode & 0o7777
|
|
115
|
+
Tempfile.create([".#{File.basename(path)}", '.tmp'], directory) do |file|
|
|
116
|
+
file.binmode
|
|
117
|
+
file.write(contents)
|
|
118
|
+
file.flush
|
|
119
|
+
file.fsync
|
|
120
|
+
file.chmod(mode)
|
|
121
|
+
file.close
|
|
122
|
+
current = Digest::SHA256.file(path).hexdigest
|
|
123
|
+
raise Error, "Quarantine source changed while writing: #{path}" unless current == expected_sha256
|
|
124
|
+
|
|
125
|
+
File.rename(file.path, path)
|
|
126
|
+
end
|
|
127
|
+
fsync_directory(directory)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def fsync_directory(directory)
|
|
131
|
+
File.open(directory, 'rb', &:fsync)
|
|
132
|
+
rescue Errno::EINVAL, Errno::EISDIR
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
50
135
|
end
|
|
51
136
|
end
|
|
52
137
|
end
|