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
data/lib/necropsy/reporter.rb
CHANGED
|
@@ -2,13 +2,44 @@
|
|
|
2
2
|
|
|
3
3
|
module Necropsy
|
|
4
4
|
class Reporter
|
|
5
|
-
FORMATS = %i[human json yaml yml sarif github annotations].freeze
|
|
5
|
+
FORMATS = %i[human json ndjson yaml yml sarif github annotations].freeze
|
|
6
6
|
DEFAULT_MIN_CONFIDENCE = :medium
|
|
7
|
+
DEFINITION_RESOLUTION_SAMPLE_LIMIT = 5
|
|
7
8
|
|
|
8
9
|
def initialize(report)
|
|
9
10
|
@report = report
|
|
10
11
|
end
|
|
11
12
|
|
|
13
|
+
def self.render_baseline_review(review_report)
|
|
14
|
+
lines = [
|
|
15
|
+
'Baseline migration requires review',
|
|
16
|
+
"Baseline: #{review_report.fetch('baseline_path')}",
|
|
17
|
+
"Schema: v#{review_report.fetch('baseline_schema_version')}",
|
|
18
|
+
"Ambiguous mappings: #{review_report.fetch('ambiguities').length}"
|
|
19
|
+
]
|
|
20
|
+
review_report.fetch('ambiguities').each do |ambiguity|
|
|
21
|
+
baseline = ambiguity.fetch('baseline')
|
|
22
|
+
label = baseline['definition_id'] || baseline['symbol_id'] || baseline['node_id'] || baseline['fingerprint']
|
|
23
|
+
lines << " #{label} via #{ambiguity.fetch('strategy')} (#{ambiguity.fetch('reason')})"
|
|
24
|
+
ambiguity.fetch('candidates').each do |candidate|
|
|
25
|
+
lines << " #{candidate.fetch('symbol_id')} [#{candidate.fetch('definition_id')}] " \
|
|
26
|
+
"#{candidate.fetch('file')}:#{candidate.fetch('line')}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
lines << 'Regenerate the baseline after reviewing every ambiguous physical definition.'
|
|
30
|
+
lines.join("\n")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.render_analysis_health(analysis_health)
|
|
34
|
+
lines = ["Analysis health: #{analysis_health.status}"]
|
|
35
|
+
analysis_health.reasons.each do |reason|
|
|
36
|
+
location = [reason['file'], reason['line']].compact.join(':')
|
|
37
|
+
suffix = location.empty? ? '' : " at #{location}"
|
|
38
|
+
lines << " [#{reason.fetch('severity')}] #{reason.fetch('code')}#{suffix}: #{reason['message']}"
|
|
39
|
+
end
|
|
40
|
+
lines.join("\n")
|
|
41
|
+
end
|
|
42
|
+
|
|
12
43
|
def render(format: :human, min_confidence: DEFAULT_MIN_CONFIDENCE, include_graph: false)
|
|
13
44
|
normalized_format = format.to_sym
|
|
14
45
|
raise Error, "Unknown report format: #{format}" unless FORMATS.include?(normalized_format)
|
|
@@ -16,6 +47,8 @@ module Necropsy
|
|
|
16
47
|
case normalized_format
|
|
17
48
|
when :json
|
|
18
49
|
report.to_json(include_graph: include_graph)
|
|
50
|
+
when :ndjson
|
|
51
|
+
each_ndjson.to_a.join("\n")
|
|
19
52
|
when :sarif
|
|
20
53
|
render_sarif(min_confidence)
|
|
21
54
|
when :github, :annotations
|
|
@@ -27,42 +60,275 @@ module Necropsy
|
|
|
27
60
|
end
|
|
28
61
|
end
|
|
29
62
|
|
|
63
|
+
def each_ndjson
|
|
64
|
+
return enum_for(__method__) unless block_given?
|
|
65
|
+
|
|
66
|
+
yield ndjson_record('report', report.to_h(include_graph: false))
|
|
67
|
+
graph = report.graph
|
|
68
|
+
{
|
|
69
|
+
'node' => graph.nodes.values,
|
|
70
|
+
'call_site' => graph.call_sites,
|
|
71
|
+
'edge' => graph.edges,
|
|
72
|
+
'edge_relation' => graph.edge_relations,
|
|
73
|
+
'evidence' => graph.evidence_records,
|
|
74
|
+
'entry_point' => graph.entry_points,
|
|
75
|
+
'class_info' => graph.class_infos.values,
|
|
76
|
+
'profile' => graph.profiles,
|
|
77
|
+
'resolution' => graph.resolution_records,
|
|
78
|
+
'blocker' => graph.blockers,
|
|
79
|
+
'source_error' => graph.source_errors
|
|
80
|
+
}.each do |record_type, records|
|
|
81
|
+
records.each { |record| yield ndjson_record(record_type, record.to_h) }
|
|
82
|
+
end
|
|
83
|
+
yield ndjson_record('graph_metadata', {
|
|
84
|
+
'edge_projection' => 'conservative',
|
|
85
|
+
'instantiated_classes' => graph.instantiated_classes.to_a.sort,
|
|
86
|
+
'file_statuses' => graph.file_statuses.transform_values(&:to_s),
|
|
87
|
+
'source_domains' => graph.source_domains.transform_values(&:to_s),
|
|
88
|
+
'scope_diagnostics' => graph.scope_diagnostics,
|
|
89
|
+
'observation' => graph.observation
|
|
90
|
+
})
|
|
91
|
+
end
|
|
92
|
+
|
|
30
93
|
private
|
|
31
94
|
|
|
32
95
|
attr_reader :report
|
|
33
96
|
|
|
97
|
+
def ndjson_record(record_type, data)
|
|
98
|
+
JSON.generate('schema' => 'necropsy.graph.ndjson.v1', 'record' => record_type, 'data' => data)
|
|
99
|
+
end
|
|
100
|
+
|
|
34
101
|
def render_human(min_confidence)
|
|
35
|
-
findings = report.dead_methods(min_confidence: min_confidence)
|
|
102
|
+
findings = (report.dead_methods(min_confidence: min_confidence) + report.blocked_methods).uniq
|
|
36
103
|
lines = [
|
|
37
104
|
'Necropsy report',
|
|
38
105
|
"Root: #{report.root}",
|
|
39
106
|
"Nodes: #{report.summary['nodes']}, Edges: #{report.summary['edges']}, Entry points: #{report.summary['entry_points']}",
|
|
107
|
+
"Incomplete source files: #{report.summary['incomplete_files']}",
|
|
108
|
+
"Analysis health: #{report.analysis_health.status} (#{report.analysis_health.reasons.length} issues)",
|
|
40
109
|
"Findings: #{findings.length}"
|
|
41
110
|
]
|
|
111
|
+
report.analysis_health.reasons.each do |reason|
|
|
112
|
+
lines << " health #{reason.fetch('severity')}: #{reason.fetch('code')}"
|
|
113
|
+
end
|
|
114
|
+
append_dynamic_diagnostic(lines)
|
|
115
|
+
append_definition_resolution_diagnostic(lines)
|
|
116
|
+
append_source_diagnostic(lines)
|
|
117
|
+
append_analysis_scope_diagnostic(lines)
|
|
118
|
+
append_reference_barrier_diagnostic(lines)
|
|
42
119
|
|
|
43
120
|
findings.group_by(&:classification).sort_by do |classification, _|
|
|
44
121
|
classification.to_s
|
|
45
122
|
end.each do |classification, group|
|
|
46
123
|
lines << ''
|
|
47
124
|
lines << "#{classification} (#{group.length})"
|
|
48
|
-
group.sort_by
|
|
49
|
-
|
|
125
|
+
group.sort_by do |finding|
|
|
126
|
+
[finding.node.file, finding.node.line, finding.node.id, finding.node.definition_id]
|
|
127
|
+
end.each do |finding|
|
|
128
|
+
lines << " [#{finding.confidence}] #{finding.node.symbol_id} [#{finding.node.definition_id}] " \
|
|
129
|
+
"#{finding.node.file}:#{finding.node.line}"
|
|
130
|
+
append_finding_blockers(lines, finding)
|
|
50
131
|
end
|
|
51
132
|
end
|
|
52
133
|
|
|
53
134
|
lines.join("\n")
|
|
54
135
|
end
|
|
55
136
|
|
|
137
|
+
def append_finding_blockers(lines, finding)
|
|
138
|
+
finding.blockers.each do |blocker|
|
|
139
|
+
metadata = blocker.metadata
|
|
140
|
+
location = [metadata['file'] || metadata[:file], metadata['line'] || metadata[:line]].compact.join(':')
|
|
141
|
+
caller = metadata['caller_id'] || metadata[:caller_id]
|
|
142
|
+
message = metadata['message'] || metadata[:message] || blocker.message
|
|
143
|
+
lines << " blocker #{blocker.kind} at #{location} caller=#{caller}"
|
|
144
|
+
lines << " scope #{blocker.scope_kind}=#{blocker.scope_value.inspect} message=#{message}"
|
|
145
|
+
lines << " reason #{blocker.reason}"
|
|
146
|
+
lines << " match #{metadata['snippet']}" if metadata['snippet']
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def append_dynamic_diagnostic(lines)
|
|
151
|
+
diagnostic = report.diagnostics['dynamic_evidence']
|
|
152
|
+
return unless diagnostic
|
|
153
|
+
|
|
154
|
+
attempted = diagnostic.fetch('attempted')
|
|
155
|
+
matched = diagnostic.fetch('matched')
|
|
156
|
+
partially_matched = diagnostic.fetch('partially_matched')
|
|
157
|
+
unmatched = diagnostic.fetch('unmatched')
|
|
158
|
+
samples = diagnostic.fetch('unmatched_samples').values.flatten
|
|
159
|
+
lines << "Dynamic evidence (positive-only): nodes attempted=#{attempted['nodes']} matched=#{matched['nodes']} " \
|
|
160
|
+
"partial=#{partially_matched['nodes']} unmatched=#{unmatched['nodes']}; " \
|
|
161
|
+
"edges attempted=#{attempted['edges']} matched=#{matched['edges']} " \
|
|
162
|
+
"partial=#{partially_matched['edges']} unmatched=#{unmatched['edges']}"
|
|
163
|
+
lines << "Unmatched dynamic evidence: #{samples.join(', ')}" unless samples.empty?
|
|
164
|
+
append_dynamic_resolution_samples(lines, diagnostic)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def append_dynamic_resolution_samples(lines, diagnostic)
|
|
168
|
+
resolution_samples = diagnostic['resolution_samples'] || diagnostic[:resolution_samples]
|
|
169
|
+
return unless resolution_samples.is_a?(Hash)
|
|
170
|
+
|
|
171
|
+
samples = %w[nodes edge_endpoints].flat_map do |kind|
|
|
172
|
+
Array(resolution_samples[kind] || resolution_samples[kind.to_sym])
|
|
173
|
+
end.select { |sample| (sample['status'] || sample[:status]).to_s == 'ambiguous' }
|
|
174
|
+
resolution = diagnostic['resolution'] || diagnostic[:resolution]
|
|
175
|
+
count = dynamic_ambiguous_resolution_count(resolution, samples)
|
|
176
|
+
return unless count.positive?
|
|
177
|
+
|
|
178
|
+
lines << "Ambiguous runtime references: #{count}"
|
|
179
|
+
rendered_count = [count, DEFINITION_RESOLUTION_SAMPLE_LIMIT].min
|
|
180
|
+
samples.first(rendered_count).each do |sample|
|
|
181
|
+
endpoint = sample['endpoint'] || sample[:endpoint]
|
|
182
|
+
prefix = endpoint ? "#{endpoint} " : ''
|
|
183
|
+
ids = Array(sample['definition_ids'] || sample[:definition_ids])
|
|
184
|
+
lines << " #{prefix}#{definition_resolution_identifier(sample)} -> #{ids.join(', ')}"
|
|
185
|
+
end
|
|
186
|
+
omitted = count - [samples.length, rendered_count].min
|
|
187
|
+
lines << " ... #{omitted} more" if omitted.positive?
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def dynamic_ambiguous_resolution_count(resolution, samples)
|
|
191
|
+
return samples.length unless resolution.is_a?(Hash)
|
|
192
|
+
|
|
193
|
+
%w[nodes edge_endpoints].sum do |kind|
|
|
194
|
+
counts = resolution[kind] || resolution[kind.to_sym]
|
|
195
|
+
counts.is_a?(Hash) ? Integer(counts['ambiguous'] || counts[:ambiguous] || 0) : 0
|
|
196
|
+
end
|
|
197
|
+
rescue ArgumentError, TypeError
|
|
198
|
+
samples.length
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def append_source_diagnostic(lines)
|
|
202
|
+
diagnostic = report.diagnostics['source_incompleteness']
|
|
203
|
+
return unless diagnostic
|
|
204
|
+
|
|
205
|
+
diagnostic.fetch('files').each do |file|
|
|
206
|
+
errors = file.fetch('errors')
|
|
207
|
+
if errors.empty?
|
|
208
|
+
lines << "Incomplete source: #{file['file']}:1 [#{file['status']}]"
|
|
209
|
+
next
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
errors.each do |error|
|
|
213
|
+
lines << "Incomplete source: #{error['file']}:#{error['line']} [#{error['type']}] #{error['message']}"
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def append_analysis_scope_diagnostic(lines)
|
|
219
|
+
diagnostic = report.diagnostics['analysis_scope']
|
|
220
|
+
return unless diagnostic
|
|
221
|
+
|
|
222
|
+
reference_only = Array(diagnostic['reference_only_ruby_files'])
|
|
223
|
+
excluded_callers = diagnostic['potential_callers_outside_reference'] || {}
|
|
224
|
+
lines << "Analysis scope: analyzed Ruby=#{diagnostic.fetch('analyze_file_count')}, " \
|
|
225
|
+
"reference files=#{diagnostic.fetch('reference_file_count')}, " \
|
|
226
|
+
"reference-only Ruby=#{reference_only.length}"
|
|
227
|
+
if excluded_callers.fetch('count', 0).positive?
|
|
228
|
+
lines << "Potential callers outside reference: #{excluded_callers.fetch('count')} " \
|
|
229
|
+
"(runtime=#{excluded_callers.fetch('runtime_count', 0)}): " \
|
|
230
|
+
"#{Array(excluded_callers['samples']).join(', ')}"
|
|
231
|
+
end
|
|
232
|
+
Array(diagnostic['potential_entry_points_outside_analyze']).each do |entry|
|
|
233
|
+
lines << "Potential entry point outside analysis: #{entry.fetch('file')} [#{entry.fetch('reference_status')}]"
|
|
234
|
+
end
|
|
235
|
+
Array(diagnostic['ignored_symlinks']).each do |file|
|
|
236
|
+
lines << "Ignored symlink: #{file}"
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def append_reference_barrier_diagnostic(lines)
|
|
241
|
+
diagnostic = report.diagnostics['non_ruby_reference_barrier']
|
|
242
|
+
return unless diagnostic
|
|
243
|
+
|
|
244
|
+
lines << "Non-Ruby reference barrier: scanned=#{diagnostic.fetch('files_scanned')}/" \
|
|
245
|
+
"#{diagnostic.fetch('files_considered')}, matches=#{diagnostic.fetch('matches')}, " \
|
|
246
|
+
"blocked definitions=#{diagnostic.fetch('matched_definitions')}"
|
|
247
|
+
skipped = diagnostic.fetch('skipped_counts')
|
|
248
|
+
lines << "Skipped non-Ruby references: #{skipped.map { |reason, count| "#{reason}=#{count}" }.join(', ')}" \
|
|
249
|
+
unless skipped.empty?
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def append_definition_resolution_diagnostic(lines)
|
|
253
|
+
diagnostic = report.diagnostics['definition_resolution']
|
|
254
|
+
return unless diagnostic
|
|
255
|
+
|
|
256
|
+
entries = definition_resolution_entries(diagnostic)
|
|
257
|
+
count = definition_resolution_count(diagnostic, entries)
|
|
258
|
+
lines << "Ambiguous definition inputs: #{count}"
|
|
259
|
+
rendered_count = [count, DEFINITION_RESOLUTION_SAMPLE_LIMIT].min
|
|
260
|
+
entries.first(rendered_count).each do |entry|
|
|
261
|
+
if entry.is_a?(Hash)
|
|
262
|
+
kind = entry['kind'] || entry[:kind] || entry['status'] || entry[:status] || 'unknown'
|
|
263
|
+
identifier = definition_resolution_identifier(entry)
|
|
264
|
+
ids = Array(entry['definition_ids'] || entry[:definition_ids])
|
|
265
|
+
lines << " #{kind} #{identifier} -> #{ids.join(', ')}"
|
|
266
|
+
else
|
|
267
|
+
lines << " #{entry}"
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
omitted = count - [entries.length, rendered_count].min
|
|
271
|
+
lines << " ... #{omitted} more" if omitted.positive?
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def definition_resolution_entries(diagnostic)
|
|
275
|
+
return diagnostic if diagnostic.is_a?(Array)
|
|
276
|
+
return [] unless diagnostic.is_a?(Hash)
|
|
277
|
+
|
|
278
|
+
%w[ambiguous_inputs ambiguities samples].each do |key|
|
|
279
|
+
value = diagnostic[key] || diagnostic[key.to_sym]
|
|
280
|
+
return value if value.is_a?(Array)
|
|
281
|
+
end
|
|
282
|
+
[]
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def definition_resolution_identifier(entry)
|
|
286
|
+
direct = entry['identifier'] || entry[:identifier]
|
|
287
|
+
return direct if direct
|
|
288
|
+
|
|
289
|
+
reference = entry['reference'] || entry[:reference]
|
|
290
|
+
return reference unless reference.is_a?(Hash)
|
|
291
|
+
|
|
292
|
+
identifier = reference['identifier'] || reference[:identifier] ||
|
|
293
|
+
reference['definition_id'] || reference[:definition_id] ||
|
|
294
|
+
reference['symbol_id'] || reference[:symbol_id] || 'unknown'
|
|
295
|
+
file = reference['file'] || reference[:file]
|
|
296
|
+
line = reference['line'] || reference[:line]
|
|
297
|
+
location = [file, line].compact.join(':')
|
|
298
|
+
location.empty? ? identifier : "#{identifier} @ #{location}"
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def definition_resolution_count(diagnostic, entries)
|
|
302
|
+
return entries.length unless diagnostic.is_a?(Hash)
|
|
303
|
+
|
|
304
|
+
counts = diagnostic['counts'] || diagnostic[:counts]
|
|
305
|
+
nested_count = counts['ambiguous'] || counts[:ambiguous] if counts.is_a?(Hash)
|
|
306
|
+
value = diagnostic['ambiguous_input_count'] || diagnostic[:ambiguous_input_count] ||
|
|
307
|
+
diagnostic['ambiguous_count'] || diagnostic[:ambiguous_count] ||
|
|
308
|
+
diagnostic['count'] || diagnostic[:count] || nested_count
|
|
309
|
+
Integer(value || entries.length)
|
|
310
|
+
rescue ArgumentError, TypeError
|
|
311
|
+
entries.length
|
|
312
|
+
end
|
|
313
|
+
|
|
56
314
|
def render_github_annotations(min_confidence)
|
|
57
|
-
report.dead_methods(min_confidence: min_confidence).map do |finding|
|
|
58
|
-
message = "#{finding.classification} #{finding.node.
|
|
59
|
-
|
|
60
|
-
"::warning file=#{finding.node.file},line=#{finding.node.line},title=Necropsy #{finding.confidence}
|
|
61
|
-
|
|
315
|
+
finding_annotations = report.dead_methods(min_confidence: min_confidence).map do |finding|
|
|
316
|
+
message = "#{finding.classification} #{finding.node.symbol_id} definition_id=#{finding.node.definition_id} " \
|
|
317
|
+
"confidence=#{finding.confidence}"
|
|
318
|
+
"::warning file=#{finding.node.file},line=#{finding.node.line},title=Necropsy #{finding.confidence}::" \
|
|
319
|
+
"#{escape_annotation(message)}"
|
|
320
|
+
end
|
|
321
|
+
source_annotations = source_diagnostic_entries.map do |entry|
|
|
322
|
+
message = "Incomplete source (#{entry['status']}, #{entry['type']}): #{entry['message']}"
|
|
323
|
+
"::warning file=#{entry['file']},line=#{entry['line']},title=Necropsy incomplete source::" \
|
|
324
|
+
"#{escape_annotation(message)}"
|
|
325
|
+
end
|
|
326
|
+
(finding_annotations + source_annotations + health_annotations).join("\n")
|
|
62
327
|
end
|
|
63
328
|
|
|
64
329
|
def render_sarif(min_confidence)
|
|
65
330
|
findings = report.dead_methods(min_confidence: min_confidence)
|
|
331
|
+
source_entries = source_diagnostic_entries
|
|
66
332
|
{
|
|
67
333
|
'version' => '2.1.0',
|
|
68
334
|
'$schema' => 'https://json.schemastore.org/sarif-2.1.0.json',
|
|
@@ -72,30 +338,47 @@ module Necropsy
|
|
|
72
338
|
'driver' => {
|
|
73
339
|
'name' => 'Necropsy',
|
|
74
340
|
'informationUri' => 'https://github.com/ydah/necropsy',
|
|
75
|
-
'rules' => sarif_rules(findings)
|
|
341
|
+
'rules' => sarif_rules(findings, source_entries)
|
|
76
342
|
}
|
|
77
343
|
},
|
|
78
|
-
'results' => findings.map { |finding| sarif_result(finding) }
|
|
344
|
+
'results' => findings.map { |finding| sarif_result(finding) } + source_entries.map { |entry| sarif_source_result(entry) },
|
|
345
|
+
'properties' => {
|
|
346
|
+
'necropsyFingerprintCompatibility' => Report::FINGERPRINT_COMPATIBILITY,
|
|
347
|
+
'analysisHealth' => report.analysis_health.to_h
|
|
348
|
+
}
|
|
79
349
|
}
|
|
80
350
|
]
|
|
81
351
|
}.to_json
|
|
82
352
|
end
|
|
83
353
|
|
|
84
|
-
def sarif_rules(findings)
|
|
85
|
-
findings.map(&:classification).uniq.map do |classification|
|
|
354
|
+
def sarif_rules(findings, source_entries)
|
|
355
|
+
rules = findings.map(&:classification).uniq.map do |classification|
|
|
86
356
|
{
|
|
87
357
|
'id' => classification.to_s,
|
|
88
358
|
'name' => classification.to_s,
|
|
89
359
|
'shortDescription' => { 'text' => "Necropsy #{classification}" }
|
|
90
360
|
}
|
|
91
361
|
end
|
|
362
|
+
return rules if source_entries.empty?
|
|
363
|
+
|
|
364
|
+
rules << {
|
|
365
|
+
'id' => 'parse_incomplete',
|
|
366
|
+
'name' => 'parse_incomplete',
|
|
367
|
+
'shortDescription' => { 'text' => 'Necropsy incomplete source' }
|
|
368
|
+
}
|
|
92
369
|
end
|
|
93
370
|
|
|
94
371
|
def sarif_result(finding)
|
|
95
|
-
{
|
|
372
|
+
result = {
|
|
96
373
|
'ruleId' => finding.classification.to_s,
|
|
97
374
|
'level' => sarif_level(finding),
|
|
98
375
|
'message' => { 'text' => "#{finding.node.id} is #{finding.classification} (#{finding.confidence})" },
|
|
376
|
+
'properties' => {
|
|
377
|
+
'symbolId' => finding.node.symbol_id,
|
|
378
|
+
'definitionId' => finding.node.definition_id,
|
|
379
|
+
'logicalFingerprint' => finding.logical_fingerprint,
|
|
380
|
+
'physicalFingerprint' => finding.physical_fingerprint
|
|
381
|
+
},
|
|
99
382
|
'locations' => [
|
|
100
383
|
{
|
|
101
384
|
'physicalLocation' => {
|
|
@@ -104,15 +387,148 @@ module Necropsy
|
|
|
104
387
|
}
|
|
105
388
|
}
|
|
106
389
|
],
|
|
107
|
-
'partialFingerprints' => {
|
|
390
|
+
'partialFingerprints' => {
|
|
391
|
+
'necropsy' => finding.logical_fingerprint,
|
|
392
|
+
'necropsyPhysicalDefinition' => finding.physical_fingerprint
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
related_locations = sarif_related_locations(finding)
|
|
396
|
+
result['relatedLocations'] = related_locations unless related_locations.empty?
|
|
397
|
+
code_flows = sarif_code_flows(finding)
|
|
398
|
+
result['codeFlows'] = code_flows unless code_flows.empty?
|
|
399
|
+
result
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def sarif_related_locations(finding)
|
|
403
|
+
finding.blockers.filter_map do |blocker|
|
|
404
|
+
metadata = blocker.metadata
|
|
405
|
+
file = metadata['file'] || metadata[:file]
|
|
406
|
+
line = positive_line(metadata['line'] || metadata[:line])
|
|
407
|
+
next if file.to_s.empty? || line.nil?
|
|
408
|
+
|
|
409
|
+
{
|
|
410
|
+
'physicalLocation' => sarif_physical_location(file, line),
|
|
411
|
+
'message' => { 'text' => "#{blocker.kind}: #{blocker.reason}" },
|
|
412
|
+
'properties' => {
|
|
413
|
+
'blockerKind' => blocker.kind.to_s,
|
|
414
|
+
'blockerSource' => blocker.source.respond_to?(:to_h) ? blocker.source.to_h : blocker.source.to_s
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
end.uniq do |location|
|
|
418
|
+
[
|
|
419
|
+
location.dig('physicalLocation', 'artifactLocation', 'uri'),
|
|
420
|
+
location.dig('physicalLocation', 'region', 'startLine'),
|
|
421
|
+
location.dig('properties', 'blockerKind')
|
|
422
|
+
]
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def sarif_code_flows(finding)
|
|
427
|
+
witness = sarif_witness(finding.node.graph_id)
|
|
428
|
+
return [] unless witness
|
|
429
|
+
|
|
430
|
+
domain, path = witness
|
|
431
|
+
locations = path.each_with_index.filter_map do |definition_id, index|
|
|
432
|
+
node = report.graph.nodes[definition_id]
|
|
433
|
+
next unless node
|
|
434
|
+
|
|
435
|
+
{
|
|
436
|
+
'location' => {
|
|
437
|
+
'physicalLocation' => sarif_physical_location(node.file, node.line),
|
|
438
|
+
'message' => { 'text' => node.symbol_id }
|
|
439
|
+
},
|
|
440
|
+
'executionOrder' => index + 1
|
|
441
|
+
}
|
|
442
|
+
end
|
|
443
|
+
return [] if locations.empty?
|
|
444
|
+
|
|
445
|
+
[{
|
|
446
|
+
'message' => { 'text' => "#{domain} reachability witness" },
|
|
447
|
+
'threadFlows' => [{ 'locations' => locations }],
|
|
448
|
+
'properties' => { 'domain' => domain.to_s }
|
|
449
|
+
}]
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def sarif_witness(definition_id)
|
|
453
|
+
return unless report.reachability
|
|
454
|
+
|
|
455
|
+
%i[runtime external test].each do |domain|
|
|
456
|
+
path = report.reachability.witness(definition_id, kind: domain)
|
|
457
|
+
return [domain, path] if path
|
|
458
|
+
end
|
|
459
|
+
nil
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def sarif_physical_location(file, line)
|
|
463
|
+
{
|
|
464
|
+
'artifactLocation' => { 'uri' => file.to_s },
|
|
465
|
+
'region' => { 'startLine' => line }
|
|
108
466
|
}
|
|
109
467
|
end
|
|
110
468
|
|
|
469
|
+
def positive_line(value)
|
|
470
|
+
line = Integer(value)
|
|
471
|
+
line if line.positive?
|
|
472
|
+
rescue ArgumentError, TypeError
|
|
473
|
+
nil
|
|
474
|
+
end
|
|
475
|
+
|
|
111
476
|
def sarif_level(finding)
|
|
112
477
|
return 'error' if %i[certain high].include?(finding.confidence)
|
|
113
478
|
return 'warning' if finding.confidence == :medium
|
|
114
479
|
|
|
115
480
|
'note'
|
|
116
481
|
end
|
|
482
|
+
|
|
483
|
+
def sarif_source_result(entry)
|
|
484
|
+
{
|
|
485
|
+
'ruleId' => 'parse_incomplete',
|
|
486
|
+
'level' => 'warning',
|
|
487
|
+
'message' => {
|
|
488
|
+
'text' => "Incomplete source (#{entry['status']}, #{entry['type']}): #{entry['message']}"
|
|
489
|
+
},
|
|
490
|
+
'locations' => [
|
|
491
|
+
{
|
|
492
|
+
'physicalLocation' => {
|
|
493
|
+
'artifactLocation' => { 'uri' => entry['file'] },
|
|
494
|
+
'region' => { 'startLine' => entry['line'] }
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
]
|
|
498
|
+
}
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def source_diagnostic_entries
|
|
502
|
+
diagnostic = report.diagnostics['source_incompleteness']
|
|
503
|
+
return [] unless diagnostic
|
|
504
|
+
|
|
505
|
+
diagnostic.fetch('files').flat_map do |file|
|
|
506
|
+
errors = file.fetch('errors')
|
|
507
|
+
if errors.empty?
|
|
508
|
+
next [{ 'file' => file['file'], 'line' => 1, 'type' => file['status'],
|
|
509
|
+
'message' => 'No source diagnostic was available', 'status' => file['status'] }]
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
errors.map { |error| error.merge('status' => file['status']) }
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def health_annotations
|
|
517
|
+
report.analysis_health.reasons.map do |reason|
|
|
518
|
+
level = reason.fetch('severity') == 'invalid' ? 'error' : 'warning'
|
|
519
|
+
title = "Necropsy analysis #{report.analysis_health.status}"
|
|
520
|
+
message = "#{reason.fetch('code')}: #{reason['message']}"
|
|
521
|
+
location = if reason['file']
|
|
522
|
+
" file=#{reason['file']},line=#{positive_line(reason['line']) || 1},"
|
|
523
|
+
else
|
|
524
|
+
' '
|
|
525
|
+
end
|
|
526
|
+
"::#{level}#{location}title=#{title}::#{escape_annotation(message)}"
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def escape_annotation(message)
|
|
531
|
+
message.gsub('%', '%25').gsub("\n", '%0A').gsub("\r", '%0D')
|
|
532
|
+
end
|
|
117
533
|
end
|
|
118
534
|
end
|