fiber_audit 0.1.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 +7 -0
- data/.fiber-audit.example.yml +33 -0
- data/CHANGELOG.md +32 -0
- data/README.md +150 -0
- data/bin/fiber-audit +5 -0
- data/lib/fiber_audit/audit.rb +288 -0
- data/lib/fiber_audit/cli.rb +245 -0
- data/lib/fiber_audit/configuration.rb +236 -0
- data/lib/fiber_audit/correlation/fingerprint.rb +26 -0
- data/lib/fiber_audit/errors.rb +8 -0
- data/lib/fiber_audit/execution_context.rb +47 -0
- data/lib/fiber_audit/findings/collection.rb +58 -0
- data/lib/fiber_audit/findings/confidence.rb +19 -0
- data/lib/fiber_audit/findings/evidence.rb +13 -0
- data/lib/fiber_audit/findings/finding.rb +76 -0
- data/lib/fiber_audit/findings/location.rb +9 -0
- data/lib/fiber_audit/findings/severity.rb +19 -0
- data/lib/fiber_audit/project.rb +96 -0
- data/lib/fiber_audit/reporters/base.rb +12 -0
- data/lib/fiber_audit/reporters/json.rb +34 -0
- data/lib/fiber_audit/reporters/schema.rb +574 -0
- data/lib/fiber_audit/reporters/text.rb +179 -0
- data/lib/fiber_audit/static/call_site.rb +71 -0
- data/lib/fiber_audit/static/call_site_extractor.rb +524 -0
- data/lib/fiber_audit/static/execution_context_resolver.rb +266 -0
- data/lib/fiber_audit/static/rules/base.rb +185 -0
- data/lib/fiber_audit/static/rules/blocking_subprocess.rb +94 -0
- data/lib/fiber_audit/static/rules/built_ins.rb +36 -0
- data/lib/fiber_audit/static/rules/direct_socket.rb +112 -0
- data/lib/fiber_audit/static/rules/io_select.rb +104 -0
- data/lib/fiber_audit/static/rules/net_http_in_request.rb +116 -0
- data/lib/fiber_audit/static/rules/registry.rb +123 -0
- data/lib/fiber_audit/static/rules/synchronization.rb +124 -0
- data/lib/fiber_audit/static/rules/thread_current_state.rb +113 -0
- data/lib/fiber_audit/static/rules/thread_join.rb +96 -0
- data/lib/fiber_audit/static/semantic_index.rb +300 -0
- data/lib/fiber_audit/suppressions/parser.rb +146 -0
- data/lib/fiber_audit/suppressions/store.rb +63 -0
- data/lib/fiber_audit/version.rb +5 -0
- data/lib/fiber_audit.rb +40 -0
- metadata +108 -0
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../errors'
|
|
4
|
+
require_relative '../version'
|
|
5
|
+
require_relative '../findings/severity'
|
|
6
|
+
require_relative '../findings/confidence'
|
|
7
|
+
|
|
8
|
+
module FiberAudit
|
|
9
|
+
module Reporters
|
|
10
|
+
# Schema validates audit results and builds deterministic output hashes.
|
|
11
|
+
# It enforces the publication invariant and structural constraints.
|
|
12
|
+
#
|
|
13
|
+
# Architecture:
|
|
14
|
+
# - build(result): Converts result protocol to normalized primitive report Hash
|
|
15
|
+
# - validate!(report_hash): Validates that Hash and returns it
|
|
16
|
+
# - JSON/Text call build then validate
|
|
17
|
+
# The complete external schema is centralized here so validation cannot
|
|
18
|
+
# drift across reporters.
|
|
19
|
+
# rubocop:disable Metrics/ModuleLength
|
|
20
|
+
module Schema
|
|
21
|
+
SCHEMA_VERSION = '1.0'
|
|
22
|
+
DISCLAIMER = 'This is a static-only audit. PASS cannot be granted without runtime coverage.'
|
|
23
|
+
ALLOWED_STATUSES = %w[FAIL REVIEW PASS_WITH_WARNINGS NO_FINDINGS].freeze
|
|
24
|
+
ALLOWED_SEVERITIES = %w[critical high medium low info].freeze
|
|
25
|
+
ALLOWED_CONFIDENCES = %w[confirmed high medium low unknown].freeze
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# Builds a normalized primitive report hash from result protocol.
|
|
30
|
+
# Normalizes Finding/Evidence/Location objects to JSON primitives.
|
|
31
|
+
def build(result)
|
|
32
|
+
validate_interface!(result)
|
|
33
|
+
|
|
34
|
+
status = validate_status(result.status)
|
|
35
|
+
findings = normalize_findings(result.findings)
|
|
36
|
+
suppressed = normalize_findings(result.suppressed)
|
|
37
|
+
parse_errors = normalize_parse_errors(result.parse_errors)
|
|
38
|
+
coverage = normalize_coverage(result.coverage)
|
|
39
|
+
|
|
40
|
+
summary = build_summary(findings, suppressed)
|
|
41
|
+
|
|
42
|
+
{
|
|
43
|
+
schema_version: SCHEMA_VERSION,
|
|
44
|
+
tool_version: FiberAudit::VERSION,
|
|
45
|
+
status: status,
|
|
46
|
+
disclaimer: DISCLAIMER,
|
|
47
|
+
summary: summary,
|
|
48
|
+
coverage: coverage,
|
|
49
|
+
findings: sort_findings(findings),
|
|
50
|
+
suppressed: sort_findings(suppressed),
|
|
51
|
+
parse_errors: parse_errors
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Validates a report hash and returns it.
|
|
56
|
+
# Raises ReporterError on any validation failure.
|
|
57
|
+
def validate!(report_hash)
|
|
58
|
+
validate_report_structure!(report_hash)
|
|
59
|
+
validate_status_value!(report_hash[:status])
|
|
60
|
+
validate_summary!(report_hash[:summary])
|
|
61
|
+
validate_coverage!(report_hash[:coverage])
|
|
62
|
+
validate_findings_array!(report_hash[:findings], 'findings')
|
|
63
|
+
validate_findings_array!(report_hash[:suppressed], 'suppressed')
|
|
64
|
+
validate_parse_errors!(report_hash[:parse_errors])
|
|
65
|
+
verify_summary_consistency!(report_hash)
|
|
66
|
+
|
|
67
|
+
report_hash.freeze
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def validate_interface!(result)
|
|
71
|
+
%i[findings suppressed parse_errors coverage status].each do |method|
|
|
72
|
+
next if result.respond_to?(method)
|
|
73
|
+
|
|
74
|
+
raise ReporterError, "result must respond to #{method}"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def validate_status(status)
|
|
79
|
+
status_str = status.to_s.upcase
|
|
80
|
+
return status_str if ALLOWED_STATUSES.include?(status_str)
|
|
81
|
+
|
|
82
|
+
raise ReporterError, "invalid status: #{status.inspect}, must be one of #{ALLOWED_STATUSES.join(', ')}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def validate_report_structure!(report_hash)
|
|
86
|
+
required_keys = %i[schema_version tool_version status disclaimer summary coverage findings suppressed parse_errors]
|
|
87
|
+
actual_keys = report_hash.keys
|
|
88
|
+
|
|
89
|
+
missing = required_keys - actual_keys
|
|
90
|
+
raise ReporterError, "report missing required keys: #{missing.join(', ')}" unless missing.empty?
|
|
91
|
+
|
|
92
|
+
unknown = actual_keys - required_keys
|
|
93
|
+
return if unknown.empty?
|
|
94
|
+
|
|
95
|
+
raise ReporterError, "report has unknown keys: #{unknown.join(', ')}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def validate_status_value!(status)
|
|
99
|
+
return if status.is_a?(String) && ALLOWED_STATUSES.include?(status)
|
|
100
|
+
|
|
101
|
+
raise ReporterError, "invalid status: #{status.inspect}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def validate_summary!(summary)
|
|
105
|
+
required_keys = %i[critical high medium low info suppressed total]
|
|
106
|
+
actual_keys = summary.keys
|
|
107
|
+
|
|
108
|
+
missing = required_keys - actual_keys
|
|
109
|
+
raise ReporterError, "summary missing required keys: #{missing.join(', ')}" unless missing.empty?
|
|
110
|
+
|
|
111
|
+
unknown = actual_keys - required_keys
|
|
112
|
+
raise ReporterError, "summary has unknown keys: #{unknown.join(', ')}" unless unknown.empty?
|
|
113
|
+
|
|
114
|
+
required_keys.each do |key|
|
|
115
|
+
value = summary[key]
|
|
116
|
+
raise ReporterError, "summary.#{key} must be a non-negative Integer" unless value.is_a?(Integer) && value >= 0
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def normalize_findings(findings_array)
|
|
121
|
+
collection = findings_array.respond_to?(:to_a) ? findings_array.to_a : Array(findings_array)
|
|
122
|
+
collection.map { |finding| normalize_finding(finding) }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def normalize_finding(finding)
|
|
126
|
+
validate_finding_structure!(finding)
|
|
127
|
+
|
|
128
|
+
rule_id = finding.rule_id.to_s
|
|
129
|
+
message = finding.message.to_s
|
|
130
|
+
fingerprint = finding.fingerprint.to_s
|
|
131
|
+
severity = finding.severity.to_s
|
|
132
|
+
confidence = finding.confidence.to_s
|
|
133
|
+
|
|
134
|
+
validate_finding_strings!(rule_id, message, fingerprint, severity, confidence)
|
|
135
|
+
|
|
136
|
+
location = normalize_location(finding.location)
|
|
137
|
+
evidence = normalize_evidence(finding.evidence, rule_id)
|
|
138
|
+
|
|
139
|
+
{
|
|
140
|
+
rule_id: rule_id,
|
|
141
|
+
title: normalize_optional_field(finding.title, 'title'),
|
|
142
|
+
category: normalize_optional_field(finding.category, 'category'),
|
|
143
|
+
severity: severity,
|
|
144
|
+
confidence: confidence,
|
|
145
|
+
location: location,
|
|
146
|
+
symbol: normalize_optional_field(finding.symbol, 'symbol'),
|
|
147
|
+
operation: normalize_optional_field(finding.operation, 'operation'),
|
|
148
|
+
execution_context: normalize_optional_field(finding.execution_context, 'execution_context'),
|
|
149
|
+
message: message,
|
|
150
|
+
evidence: evidence,
|
|
151
|
+
remediation: normalize_optional_field(finding.remediation, 'remediation'),
|
|
152
|
+
fingerprint: fingerprint
|
|
153
|
+
}
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def validate_finding_structure!(finding)
|
|
157
|
+
%i[rule_id severity confidence message evidence fingerprint].each do |method|
|
|
158
|
+
next if finding.respond_to?(method)
|
|
159
|
+
|
|
160
|
+
raise ReporterError, "finding must respond to #{method}"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def validate_finding_strings!(rule_id, message, fingerprint, severity, confidence)
|
|
165
|
+
raise ReporterError, 'finding rule_id must be non-empty' if rule_id.empty?
|
|
166
|
+
|
|
167
|
+
raise ReporterError, 'finding message must be non-empty' if message.empty?
|
|
168
|
+
|
|
169
|
+
raise ReporterError, 'finding fingerprint must be non-empty' if fingerprint.empty?
|
|
170
|
+
|
|
171
|
+
raise ReporterError, "finding has invalid severity: #{severity.inspect}" unless ALLOWED_SEVERITIES.include?(severity)
|
|
172
|
+
|
|
173
|
+
return if ALLOWED_CONFIDENCES.include?(confidence)
|
|
174
|
+
|
|
175
|
+
raise ReporterError, "finding has invalid confidence: #{confidence.inspect}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def normalize_optional_field(value, field)
|
|
179
|
+
return nil if value.nil?
|
|
180
|
+
return value.to_s if value.is_a?(Symbol)
|
|
181
|
+
return value if json_safe?(value)
|
|
182
|
+
|
|
183
|
+
raise ReporterError, "finding.#{field} is not JSON-safe: #{value.class}"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def normalize_location(location)
|
|
187
|
+
return nil if location.nil?
|
|
188
|
+
return nil unless location.respond_to?(:path)
|
|
189
|
+
|
|
190
|
+
path = location.path.to_s
|
|
191
|
+
line = location.respond_to?(:line) ? location.line : nil
|
|
192
|
+
column = location.respond_to?(:column) ? location.column : nil
|
|
193
|
+
|
|
194
|
+
validate_location!(path, line, column)
|
|
195
|
+
|
|
196
|
+
{ path: path, line: line, column: column }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def validate_location!(path, line, column)
|
|
200
|
+
raise ReporterError, 'location path must be non-empty string' if path.empty?
|
|
201
|
+
|
|
202
|
+
unless line.nil? || (line.is_a?(Integer) && line.positive?)
|
|
203
|
+
raise ReporterError, "location line must be positive Integer or nil, got: #{line.inspect}"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
return if column.nil? || (column.is_a?(Integer) && column >= 0)
|
|
207
|
+
|
|
208
|
+
raise ReporterError, "location column must be non-negative Integer or nil, got: #{column.inspect}"
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def normalize_evidence(evidence_array, rule_id)
|
|
212
|
+
collection = evidence_array.respond_to?(:to_a) ? evidence_array.to_a : Array(evidence_array)
|
|
213
|
+
|
|
214
|
+
raise ReporterError, "Finding (#{rule_id}) must have non-empty evidence (publication invariant)" if collection.empty?
|
|
215
|
+
|
|
216
|
+
collection.map.with_index do |ev, idx|
|
|
217
|
+
normalize_evidence_record(ev, rule_id, idx)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def normalize_evidence_record(evidence, rule_id, idx)
|
|
222
|
+
%i[source message].each do |method|
|
|
223
|
+
next if evidence.respond_to?(method)
|
|
224
|
+
|
|
225
|
+
raise ReporterError, "Finding (#{rule_id}) evidence[#{idx}] must respond to #{method}"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
source = evidence.source.to_s
|
|
229
|
+
message = evidence.message.to_s
|
|
230
|
+
|
|
231
|
+
raise ReporterError, "Finding (#{rule_id}) evidence[#{idx}].source must be non-empty" if source.empty?
|
|
232
|
+
|
|
233
|
+
raise ReporterError, "Finding (#{rule_id}) evidence[#{idx}].message must be non-empty" if message.empty?
|
|
234
|
+
|
|
235
|
+
details = evidence.respond_to?(:details) ? evidence.details : {}
|
|
236
|
+
normalized_details = normalize_json_value(details, rule_id, idx)
|
|
237
|
+
|
|
238
|
+
{ source: source, message: message, details: normalized_details }
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def normalize_json_value(value, rule_id, evidence_index)
|
|
242
|
+
case value
|
|
243
|
+
when Symbol
|
|
244
|
+
value.to_s
|
|
245
|
+
when Hash
|
|
246
|
+
value.each_with_object({}) do |(key, nested), normalized|
|
|
247
|
+
unless key.is_a?(String) || key.is_a?(Symbol)
|
|
248
|
+
raise ReporterError, details_error(rule_id, evidence_index, key.class)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
normalized[key.to_s] = normalize_json_value(nested, rule_id, evidence_index)
|
|
252
|
+
end
|
|
253
|
+
when Array
|
|
254
|
+
value.map { |nested| normalize_json_value(nested, rule_id, evidence_index) }
|
|
255
|
+
else
|
|
256
|
+
return value if json_safe?(value)
|
|
257
|
+
|
|
258
|
+
raise ReporterError, details_error(rule_id, evidence_index, value.class)
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def details_error(rule_id, evidence_index, value_class)
|
|
263
|
+
"Finding (#{rule_id}) evidence[#{evidence_index}].details is not JSON-safe: #{value_class}"
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def json_safe?(value)
|
|
267
|
+
value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false || value.nil?
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def json_safe_recursive?(value)
|
|
271
|
+
case value
|
|
272
|
+
when Hash
|
|
273
|
+
value.all? { |k, v| json_safe?(k) && json_safe_recursive?(v) }
|
|
274
|
+
when Array
|
|
275
|
+
value.all? { |item| json_safe_recursive?(item) }
|
|
276
|
+
else
|
|
277
|
+
json_safe?(value)
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def normalize_parse_errors(parse_errors_array)
|
|
282
|
+
collection = parse_errors_array.respond_to?(:to_a) ? parse_errors_array.to_a : Array(parse_errors_array)
|
|
283
|
+
collection.map { |error| normalize_parse_error(error) }
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def normalize_parse_error(error)
|
|
287
|
+
unless error.respond_to?(:path) && error.respond_to?(:message)
|
|
288
|
+
raise ReporterError, 'parse error must respond to :path and :message'
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
path = error.path.to_s
|
|
292
|
+
message = error.message.to_s
|
|
293
|
+
|
|
294
|
+
raise ReporterError, 'parse error path must be non-empty' if path.empty?
|
|
295
|
+
|
|
296
|
+
raise ReporterError, 'parse error message must be non-empty' if message.empty?
|
|
297
|
+
|
|
298
|
+
line = error.respond_to?(:line) ? error.line : nil
|
|
299
|
+
|
|
300
|
+
unless line.nil? || (line.is_a?(Integer) && line.positive?)
|
|
301
|
+
raise ReporterError, "parse error line must be positive Integer or nil, got: #{line.inspect}"
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
{ path: path, message: message, line: line }
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def normalize_coverage(coverage)
|
|
308
|
+
unless coverage.respond_to?(:analysed_files) &&
|
|
309
|
+
coverage.respond_to?(:total_call_sites) &&
|
|
310
|
+
coverage.respond_to?(:rules_run)
|
|
311
|
+
raise ReporterError, 'coverage must respond to :analysed_files, :total_call_sites, :rules_run'
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
analysed_files = coverage.analysed_files
|
|
315
|
+
total_call_sites = coverage.total_call_sites
|
|
316
|
+
rules_run = coverage.rules_run
|
|
317
|
+
|
|
318
|
+
validate_non_negative_integer!(analysed_files, 'coverage.analysed_files')
|
|
319
|
+
validate_non_negative_integer!(total_call_sites, 'coverage.total_call_sites')
|
|
320
|
+
validate_non_negative_integer!(rules_run, 'coverage.rules_run')
|
|
321
|
+
|
|
322
|
+
{
|
|
323
|
+
analysed_files: analysed_files,
|
|
324
|
+
total_call_sites: total_call_sites,
|
|
325
|
+
rules_run: rules_run
|
|
326
|
+
}
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def validate_coverage!(coverage)
|
|
330
|
+
required_keys = %i[analysed_files total_call_sites rules_run]
|
|
331
|
+
actual_keys = coverage.keys
|
|
332
|
+
|
|
333
|
+
missing = required_keys - actual_keys
|
|
334
|
+
raise ReporterError, "coverage missing required keys: #{missing.join(', ')}" unless missing.empty?
|
|
335
|
+
|
|
336
|
+
unknown = actual_keys - required_keys
|
|
337
|
+
raise ReporterError, "coverage has unknown keys: #{unknown.join(', ')}" unless unknown.empty?
|
|
338
|
+
|
|
339
|
+
required_keys.each do |key|
|
|
340
|
+
value = coverage[key]
|
|
341
|
+
validate_non_negative_integer!(value, "coverage.#{key}")
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def verify_summary_consistency!(report_hash)
|
|
346
|
+
summary = report_hash[:summary]
|
|
347
|
+
findings = report_hash[:findings]
|
|
348
|
+
suppressed = report_hash[:suppressed]
|
|
349
|
+
|
|
350
|
+
counts = Hash.new(0)
|
|
351
|
+
findings.each { |f| counts[f[:severity]] += 1 }
|
|
352
|
+
|
|
353
|
+
%w[critical high medium low info].each do |sev|
|
|
354
|
+
expected = counts[sev]
|
|
355
|
+
actual = summary[sev.to_sym]
|
|
356
|
+
unless actual == expected
|
|
357
|
+
raise ReporterError, "summary.#{sev} count (#{actual}) does not match findings (#{expected})"
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
unless summary[:suppressed] == suppressed.size
|
|
362
|
+
raise ReporterError,
|
|
363
|
+
"summary.suppressed (#{summary[:suppressed]}) does not match suppressed array size (#{suppressed.size})"
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
return if summary[:total] == findings.size
|
|
367
|
+
|
|
368
|
+
raise ReporterError, "summary.total (#{summary[:total]}) does not match findings count (#{findings.size})"
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def validate_non_negative_integer!(value, field_name)
|
|
372
|
+
return if value.is_a?(Integer) && value >= 0
|
|
373
|
+
|
|
374
|
+
raise ReporterError, "#{field_name} must be a non-negative Integer, got: #{value.inspect}"
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def validate_findings_array!(findings_array, context)
|
|
378
|
+
raise ReporterError, "#{context} must be an Array" unless findings_array.is_a?(Array)
|
|
379
|
+
|
|
380
|
+
findings_array.each_with_index do |finding, idx|
|
|
381
|
+
validate_finding_hash!(finding, context, idx)
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# The field-by-field checks intentionally mirror Finding's public shape.
|
|
386
|
+
# rubocop:disable Metrics/AbcSize
|
|
387
|
+
def validate_finding_hash!(finding, context, idx)
|
|
388
|
+
raise ReporterError, "#{context}[#{idx}] must be a Hash" unless finding.is_a?(Hash)
|
|
389
|
+
|
|
390
|
+
required_keys = %i[rule_id title category severity confidence location symbol operation execution_context message
|
|
391
|
+
evidence remediation fingerprint]
|
|
392
|
+
actual_keys = finding.keys
|
|
393
|
+
|
|
394
|
+
missing = required_keys - actual_keys
|
|
395
|
+
raise ReporterError, "#{context}[#{idx}] missing required keys: #{missing.join(', ')}" unless missing.empty?
|
|
396
|
+
|
|
397
|
+
unknown = actual_keys - required_keys
|
|
398
|
+
raise ReporterError, "#{context}[#{idx}] has unknown keys: #{unknown.join(', ')}" unless unknown.empty?
|
|
399
|
+
|
|
400
|
+
%i[rule_id message fingerprint].each do |key|
|
|
401
|
+
value = finding[key]
|
|
402
|
+
unless value.is_a?(String) && !value.empty?
|
|
403
|
+
raise ReporterError, "#{context}[#{idx}].#{key} must be non-empty string"
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
unless finding[:severity].is_a?(String) && ALLOWED_SEVERITIES.include?(finding[:severity])
|
|
408
|
+
raise ReporterError, "#{context}[#{idx}].severity must be known severity string"
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
unless finding[:confidence].is_a?(String) && ALLOWED_CONFIDENCES.include?(finding[:confidence])
|
|
412
|
+
raise ReporterError, "#{context}[#{idx}].confidence must be known confidence string"
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
%i[title category symbol operation execution_context remediation].each do |key|
|
|
416
|
+
value = finding[key]
|
|
417
|
+
next if value.nil?
|
|
418
|
+
next if json_safe?(value)
|
|
419
|
+
|
|
420
|
+
raise ReporterError, "#{context}[#{idx}].#{key} is not JSON-safe"
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
validate_location_hash!(finding[:location], context, idx)
|
|
424
|
+
validate_evidence_array!(finding[:evidence], context, idx)
|
|
425
|
+
end
|
|
426
|
+
# rubocop:enable Metrics/AbcSize
|
|
427
|
+
|
|
428
|
+
def validate_location_hash!(location, context, idx)
|
|
429
|
+
return if location.nil?
|
|
430
|
+
|
|
431
|
+
raise ReporterError, "#{context}[#{idx}].location must be a Hash or nil" unless location.is_a?(Hash)
|
|
432
|
+
|
|
433
|
+
required_keys = %i[path line column]
|
|
434
|
+
actual_keys = location.keys
|
|
435
|
+
|
|
436
|
+
missing = required_keys - actual_keys
|
|
437
|
+
raise ReporterError, "#{context}[#{idx}].location missing required keys: #{missing.join(', ')}" unless missing.empty?
|
|
438
|
+
|
|
439
|
+
unknown = actual_keys - required_keys
|
|
440
|
+
raise ReporterError, "#{context}[#{idx}].location has unknown keys: #{unknown.join(', ')}" unless unknown.empty?
|
|
441
|
+
|
|
442
|
+
path = location[:path]
|
|
443
|
+
line = location[:line]
|
|
444
|
+
column = location[:column]
|
|
445
|
+
|
|
446
|
+
unless path.is_a?(String) && !path.empty?
|
|
447
|
+
raise ReporterError, "#{context}[#{idx}].location.path must be non-empty string"
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
unless line.nil? || (line.is_a?(Integer) && line.positive?)
|
|
451
|
+
raise ReporterError, "#{context}[#{idx}].location.line must be positive Integer or nil"
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
return if column.nil? || (column.is_a?(Integer) && column >= 0)
|
|
455
|
+
|
|
456
|
+
raise ReporterError, "#{context}[#{idx}].location.column must be non-negative Integer or nil"
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def validate_evidence_array!(evidence_array, context, idx)
|
|
460
|
+
unless evidence_array.is_a?(Array) && !evidence_array.empty?
|
|
461
|
+
raise ReporterError, "#{context}[#{idx}].evidence must be non-empty Array"
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
evidence_array.each_with_index do |evidence, evidence_idx|
|
|
465
|
+
validate_evidence_hash!(evidence, context, idx, evidence_idx)
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def validate_evidence_hash!(evidence, context, idx, evidence_idx)
|
|
470
|
+
unless evidence.is_a?(Hash)
|
|
471
|
+
message = "#{context}[#{idx}].evidence[#{evidence_idx}] must be a Hash"
|
|
472
|
+
raise ReporterError, message
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
required_keys = %i[source message details]
|
|
476
|
+
actual_keys = evidence.keys
|
|
477
|
+
|
|
478
|
+
missing = required_keys - actual_keys
|
|
479
|
+
unless missing.empty?
|
|
480
|
+
raise ReporterError, "#{context}[#{idx}].evidence[#{evidence_idx}] missing required keys: #{missing.join(', ')}"
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
unknown = actual_keys - required_keys
|
|
484
|
+
unless unknown.empty?
|
|
485
|
+
raise ReporterError, "#{context}[#{idx}].evidence[#{evidence_idx}] has unknown keys: #{unknown.join(', ')}"
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
source = evidence[:source]
|
|
489
|
+
message = evidence[:message]
|
|
490
|
+
|
|
491
|
+
unless source.is_a?(String) && !source.empty?
|
|
492
|
+
raise ReporterError, "#{context}[#{idx}].evidence[#{evidence_idx}].source must be non-empty string"
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
unless message.is_a?(String) && !message.empty?
|
|
496
|
+
raise ReporterError, "#{context}[#{idx}].evidence[#{evidence_idx}].message must be non-empty string"
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
details = evidence[:details]
|
|
500
|
+
return if details.nil? || json_safe_recursive?(details)
|
|
501
|
+
|
|
502
|
+
raise ReporterError, "#{context}[#{idx}].evidence[#{evidence_idx}].details is not JSON-safe"
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
def validate_parse_errors!(parse_errors)
|
|
506
|
+
raise ReporterError, 'parse_errors must be an Array' unless parse_errors.is_a?(Array)
|
|
507
|
+
|
|
508
|
+
parse_errors.each_with_index do |error, idx|
|
|
509
|
+
validate_parse_error_hash!(error, idx)
|
|
510
|
+
end
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
def validate_parse_error_hash!(error, idx)
|
|
514
|
+
raise ReporterError, "parse_errors[#{idx}] must be a Hash" unless error.is_a?(Hash)
|
|
515
|
+
|
|
516
|
+
required_keys = %i[path message line]
|
|
517
|
+
actual_keys = error.keys
|
|
518
|
+
|
|
519
|
+
missing = required_keys - actual_keys
|
|
520
|
+
raise ReporterError, "parse_errors[#{idx}] missing required keys: #{missing.join(', ')}" unless missing.empty?
|
|
521
|
+
|
|
522
|
+
unknown = actual_keys - required_keys
|
|
523
|
+
raise ReporterError, "parse_errors[#{idx}] has unknown keys: #{unknown.join(', ')}" unless unknown.empty?
|
|
524
|
+
|
|
525
|
+
path = error[:path]
|
|
526
|
+
message = error[:message]
|
|
527
|
+
line = error[:line]
|
|
528
|
+
|
|
529
|
+
raise ReporterError, "parse_errors[#{idx}].path must be non-empty string" unless path.is_a?(String) && !path.empty?
|
|
530
|
+
|
|
531
|
+
unless message.is_a?(String) && !message.empty?
|
|
532
|
+
raise ReporterError, "parse_errors[#{idx}].message must be non-empty string"
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
return if line.nil? || (line.is_a?(Integer) && line.positive?)
|
|
536
|
+
|
|
537
|
+
raise ReporterError, "parse_errors[#{idx}].line must be positive Integer or nil"
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
def build_summary(findings, suppressed)
|
|
541
|
+
counts = {
|
|
542
|
+
critical: 0,
|
|
543
|
+
high: 0,
|
|
544
|
+
medium: 0,
|
|
545
|
+
low: 0,
|
|
546
|
+
info: 0
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
findings.each do |finding|
|
|
550
|
+
severity = finding[:severity].to_sym
|
|
551
|
+
counts[severity] += 1 if counts.key?(severity)
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
counts[:suppressed] = suppressed.size
|
|
555
|
+
counts[:total] = findings.size
|
|
556
|
+
|
|
557
|
+
counts.freeze
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
def sort_findings(findings)
|
|
561
|
+
findings.sort_by do |f|
|
|
562
|
+
[
|
|
563
|
+
Severity.index(f[:severity].to_sym),
|
|
564
|
+
f[:rule_id].to_s,
|
|
565
|
+
f[:location] ? f[:location][:path].to_s : '',
|
|
566
|
+
f[:location] ? (f[:location][:line] || Float::INFINITY) : Float::INFINITY,
|
|
567
|
+
f[:fingerprint].to_s
|
|
568
|
+
]
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
# rubocop:enable Metrics/ModuleLength
|
|
573
|
+
end
|
|
574
|
+
end
|