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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/.fiber-audit.example.yml +33 -0
  3. data/CHANGELOG.md +32 -0
  4. data/README.md +150 -0
  5. data/bin/fiber-audit +5 -0
  6. data/lib/fiber_audit/audit.rb +288 -0
  7. data/lib/fiber_audit/cli.rb +245 -0
  8. data/lib/fiber_audit/configuration.rb +236 -0
  9. data/lib/fiber_audit/correlation/fingerprint.rb +26 -0
  10. data/lib/fiber_audit/errors.rb +8 -0
  11. data/lib/fiber_audit/execution_context.rb +47 -0
  12. data/lib/fiber_audit/findings/collection.rb +58 -0
  13. data/lib/fiber_audit/findings/confidence.rb +19 -0
  14. data/lib/fiber_audit/findings/evidence.rb +13 -0
  15. data/lib/fiber_audit/findings/finding.rb +76 -0
  16. data/lib/fiber_audit/findings/location.rb +9 -0
  17. data/lib/fiber_audit/findings/severity.rb +19 -0
  18. data/lib/fiber_audit/project.rb +96 -0
  19. data/lib/fiber_audit/reporters/base.rb +12 -0
  20. data/lib/fiber_audit/reporters/json.rb +34 -0
  21. data/lib/fiber_audit/reporters/schema.rb +574 -0
  22. data/lib/fiber_audit/reporters/text.rb +179 -0
  23. data/lib/fiber_audit/static/call_site.rb +71 -0
  24. data/lib/fiber_audit/static/call_site_extractor.rb +524 -0
  25. data/lib/fiber_audit/static/execution_context_resolver.rb +266 -0
  26. data/lib/fiber_audit/static/rules/base.rb +185 -0
  27. data/lib/fiber_audit/static/rules/blocking_subprocess.rb +94 -0
  28. data/lib/fiber_audit/static/rules/built_ins.rb +36 -0
  29. data/lib/fiber_audit/static/rules/direct_socket.rb +112 -0
  30. data/lib/fiber_audit/static/rules/io_select.rb +104 -0
  31. data/lib/fiber_audit/static/rules/net_http_in_request.rb +116 -0
  32. data/lib/fiber_audit/static/rules/registry.rb +123 -0
  33. data/lib/fiber_audit/static/rules/synchronization.rb +124 -0
  34. data/lib/fiber_audit/static/rules/thread_current_state.rb +113 -0
  35. data/lib/fiber_audit/static/rules/thread_join.rb +96 -0
  36. data/lib/fiber_audit/static/semantic_index.rb +300 -0
  37. data/lib/fiber_audit/suppressions/parser.rb +146 -0
  38. data/lib/fiber_audit/suppressions/store.rb +63 -0
  39. data/lib/fiber_audit/version.rb +5 -0
  40. data/lib/fiber_audit.rb +40 -0
  41. metadata +108 -0
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require_relative 'version'
5
+ require_relative 'errors'
6
+ require_relative 'configuration'
7
+ require_relative 'project'
8
+ require_relative 'audit'
9
+ require_relative 'findings/severity'
10
+ require_relative 'reporters/text'
11
+ require_relative 'reporters/json'
12
+ require_relative 'static/rules/built_ins'
13
+
14
+ module FiberAudit
15
+ # rubocop:disable Metrics/ModuleLength
16
+ module CLI
17
+ module_function
18
+
19
+ def start(argv, stdout: $stdout, stderr: $stderr, cwd: Dir.pwd)
20
+ args = argv.dup
21
+ command = args.shift
22
+
23
+ case command
24
+ when 'static'
25
+ run_static(args, stdout: stdout, stderr: stderr, cwd: cwd)
26
+ when 'list-rules'
27
+ reject_arguments!(args)
28
+ list_rules(stdout)
29
+ when 'explain'
30
+ explain_rule(args, stdout: stdout, stderr: stderr)
31
+ when 'version', '--version', '-v'
32
+ reject_arguments!(args)
33
+ stdout.puts "fiber-audit #{FiberAudit::VERSION}"
34
+ 0
35
+ when nil
36
+ print_help(stdout)
37
+ 0
38
+ when 'help', '--help', '-h'
39
+ reject_arguments!(args)
40
+ print_help(stdout)
41
+ 0
42
+ else
43
+ raise OptionParser::InvalidArgument, "unknown command: #{command}"
44
+ end
45
+ rescue StandardError => e
46
+ stderr.puts "fiber-audit: #{e.message}"
47
+ 2
48
+ end
49
+
50
+ def print_help(output = $stdout)
51
+ output.puts <<~HELP
52
+ Usage: fiber-audit <command> [options]
53
+
54
+ Commands:
55
+ static Run static fiber-compatibility analysis
56
+ list-rules List all registered static rules
57
+ explain ID Explain a specific rule
58
+ version Print version
59
+ help Show this help
60
+
61
+ Run `fiber-audit static --help` for analysis options.
62
+ HELP
63
+ end
64
+
65
+ def run_static(argv, stdout:, stderr:, cwd:)
66
+ options, parser = parse_static_options(argv)
67
+ if options[:help]
68
+ stdout.puts parser
69
+ return 0
70
+ end
71
+
72
+ configuration, result = analyze_project(options, cwd, stderr)
73
+ format = options[:format] || default_format(stdout, options[:out])
74
+ report = render_report(format, result, color: color_enabled?(stdout, options))
75
+ publish_report(report, options[:out], stdout, cwd)
76
+
77
+ reportable_findings?(result.findings, configuration.min_severity) ? 1 : 0
78
+ end
79
+
80
+ def analyze_project(options, cwd, stderr)
81
+ project = Project.detect(start_path: cwd)
82
+ stderr.puts unknown_project_note(project) unless project.known?
83
+
84
+ config_path = project.config_path(options[:config])
85
+ if options[:config] && !File.file?(config_path)
86
+ raise ConfigurationError, "configuration file does not exist: #{config_path}"
87
+ end
88
+
89
+ configuration = Configuration.load(config_path)
90
+ configuration = with_min_severity(configuration, options[:min_severity]) if options[:min_severity]
91
+ result = Audit.new(configuration: configuration, root: project.root).call
92
+ [configuration, result]
93
+ end
94
+
95
+ def parse_static_options(argv)
96
+ options = { format: nil, config: nil, out: nil, min_severity: nil, no_color: false, help: false }
97
+ parser = OptionParser.new do |opts|
98
+ opts.banner = 'Usage: fiber-audit static [options]'
99
+ opts.on('--format FORMAT', %w[text json], 'Output format: text or json') { |value| options[:format] = value }
100
+ opts.on('--config PATH', 'Path to configuration file') { |value| options[:config] = value }
101
+ opts.on('--out PATH', 'Write report to a file') { |value| options[:out] = value }
102
+ opts.on('--min-severity SEVERITY', Severity::LEVELS.map(&:to_s), 'Minimum reported severity') do |value|
103
+ options[:min_severity] = value.to_sym
104
+ end
105
+ opts.on('--no-color', 'Disable ANSI colors') { options[:no_color] = true }
106
+ opts.on('-h', '--help', 'Show static options') { options[:help] = true }
107
+ end
108
+ parser.parse!(argv)
109
+ reject_arguments!(argv)
110
+ [options, parser]
111
+ end
112
+
113
+ def with_min_severity(configuration, severity)
114
+ Configuration.new(
115
+ static_include: configuration.static_include,
116
+ static_exclude: configuration.static_exclude,
117
+ rules_config: configuration.rules_config,
118
+ report_formats: configuration.report_formats,
119
+ min_severity: severity,
120
+ suppressions_path: configuration.suppressions_path
121
+ )
122
+ end
123
+
124
+ def default_format(stdout, output_path)
125
+ return 'json' if output_path
126
+
127
+ stdout.respond_to?(:tty?) && stdout.tty? ? 'text' : 'json'
128
+ end
129
+
130
+ def color_enabled?(stdout, options)
131
+ !options[:no_color] && !options[:out] && stdout.respond_to?(:tty?) && stdout.tty?
132
+ end
133
+
134
+ def render_report(format, result, color:)
135
+ case format
136
+ when 'text'
137
+ Reporters::Text.new(color: color).render(result)
138
+ when 'json'
139
+ Reporters::JSON.new(pretty: true).render(result)
140
+ else
141
+ raise OptionParser::InvalidArgument, "unsupported format: #{format}"
142
+ end
143
+ end
144
+
145
+ def publish_report(report, output_path, stdout, cwd)
146
+ unless output_path
147
+ stdout.write(report)
148
+ return
149
+ end
150
+
151
+ resolved_path = File.expand_path(output_path, cwd)
152
+ File.write(resolved_path, report)
153
+ stdout.puts "Report written to #{output_path}"
154
+ end
155
+
156
+ def reportable_findings?(findings, minimum)
157
+ findings.any? do |finding|
158
+ Severity.index(finding.severity) <= Severity.index(minimum)
159
+ end
160
+ end
161
+
162
+ def list_rules(stdout)
163
+ Static::Rules::BuiltIns.registry.each do |rule_class|
164
+ stdout.puts format('%<id>-7s %<severity>-8s %<description>s',
165
+ id: rule_class.id,
166
+ severity: rule_class.default_severity.to_s.upcase,
167
+ description: rule_class.description)
168
+ end
169
+ 0
170
+ end
171
+
172
+ def explain_rule(argv, stdout:, stderr:)
173
+ rule_id = argv.shift
174
+ reject_arguments!(argv)
175
+ unless rule_id
176
+ stderr.puts 'Usage: fiber-audit explain <RULE_ID>'
177
+ return 2
178
+ end
179
+
180
+ rule_class = Static::Rules::BuiltIns.registry.find(rule_id)
181
+ unless rule_class
182
+ stderr.puts "Unknown rule: #{rule_id}"
183
+ return 2
184
+ end
185
+
186
+ stdout.puts "#{rule_class.id} — #{rule_title(rule_class)}"
187
+ stdout.puts "Default severity: #{rule_class.default_severity}"
188
+ stdout.puts "Default confidence: #{rule_class.default_confidence}"
189
+ stdout.puts "Description: #{rule_class.description}"
190
+ stdout.puts 'Targets:'
191
+ rule_targets(rule_class).each { |target| stdout.puts " - #{target}" }
192
+ stdout.puts "Remediation: #{rule_class.const_get(:REMEDIATION)}"
193
+ 0
194
+ end
195
+
196
+ def rule_title(rule_class)
197
+ return 'Blocking subprocess call' if rule_class.id == 'FA1001'
198
+
199
+ %i[TITLE RULE_TITLE].each do |name|
200
+ return rule_class.const_get(name) if rule_class.const_defined?(name, false)
201
+ end
202
+ rule_class.name.split('::').last
203
+ end
204
+
205
+ def rule_targets(rule_class)
206
+ case rule_class.id
207
+ when 'FA1001'
208
+ expand_target_map(rule_class::TARGETS, '.')
209
+ when 'FA1002'
210
+ rule_class::TARGET_METHODS.map { |method| "Thread##{method}" }
211
+ when 'FA1003'
212
+ expand_target_map(rule_class::TARGETS, '#')
213
+ when 'FA1004'
214
+ rule_class::THREAD_VARIABLE_METHODS.map { |method| "Thread##{method}" } +
215
+ rule_class::INDEX_METHODS.map { |method| "Thread.current.#{method}" }
216
+ when 'FA1005'
217
+ expand_target_map(rule_class::TARGETS.transform_values { |method| [method] }, '.')
218
+ when 'FA1006'
219
+ rule_class::EXACT.map { |constant| "#{constant}.new" } + ['IPSocket subclasses']
220
+ when 'FA1007'
221
+ rule_class::NET_HTTP_METHODS.map { |method| "Net::HTTP.#{method}" } +
222
+ rule_class::URI_METHODS.map { |constant, method| "#{constant}.#{method}" }
223
+ else
224
+ []
225
+ end
226
+ end
227
+
228
+ def expand_target_map(targets, separator)
229
+ targets.flat_map do |constant, methods|
230
+ Array(methods).map { |method| "#{constant}#{separator}#{method}" }
231
+ end
232
+ end
233
+
234
+ def reject_arguments!(argv)
235
+ return if argv.empty?
236
+
237
+ raise OptionParser::InvalidArgument, "unexpected arguments: #{argv.join(' ')}"
238
+ end
239
+
240
+ def unknown_project_note(project)
241
+ "Note: project root could not be detected; using #{project.root}"
242
+ end
243
+ end
244
+ # rubocop:enable Metrics/ModuleLength
245
+ end
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'pathname'
5
+ require_relative 'errors'
6
+ require_relative 'findings/severity'
7
+
8
+ module FiberAudit
9
+ class Configuration
10
+ KNOWN_TOP_LEVEL_KEYS = %w[static rules report].freeze
11
+ KNOWN_STATIC_KEYS = %w[include exclude suppressions_path].freeze
12
+ KNOWN_REPORT_KEYS = %w[formats min_severity].freeze
13
+ KNOWN_RULE_KEYS = %w[enabled severity].freeze
14
+ VALID_FORMATS = %w[text json].freeze
15
+
16
+ DEFAULT_STATIC_INCLUDE = %w[
17
+ app/**/*.rb
18
+ lib/**/*.rb
19
+ config/**/*.rb
20
+ config/initializers/**/*.rb
21
+ ].freeze
22
+
23
+ DEFAULT_STATIC_EXCLUDE = %w[
24
+ vendor/**/*
25
+ tmp/**/*
26
+ node_modules/**/*
27
+ db/schema.rb
28
+ ].freeze
29
+
30
+ attr_reader :static_include, :static_exclude, :rules_config,
31
+ :report_formats, :min_severity, :suppressions_path
32
+
33
+ def initialize(
34
+ static_include: DEFAULT_STATIC_INCLUDE,
35
+ static_exclude: DEFAULT_STATIC_EXCLUDE,
36
+ rules_config: {},
37
+ report_formats: %w[text],
38
+ min_severity: :low,
39
+ suppressions_path: nil
40
+ )
41
+ validate_types!(
42
+ static_include, static_exclude, rules_config,
43
+ report_formats, min_severity, suppressions_path
44
+ )
45
+
46
+ @static_include = static_include
47
+ @static_exclude = static_exclude
48
+ @rules_config = rules_config
49
+ @report_formats = report_formats
50
+ @min_severity = coerce_severity(min_severity, 'report.min_severity')
51
+ @suppressions_path = suppressions_path
52
+ end
53
+
54
+ def rule_enabled?(rule_id)
55
+ entry = @rules_config[rule_id] || {}
56
+ entry.fetch('enabled', true)
57
+ end
58
+
59
+ # Returns the overridden severity for a rule as a validated Symbol,
60
+ # or nil when no override is configured.
61
+ def severity_override(rule_id)
62
+ entry = @rules_config[rule_id] || {}
63
+ sev = entry['severity']
64
+ return nil unless sev
65
+
66
+ coerce_severity(sev, "rules.#{rule_id}.severity")
67
+ end
68
+
69
+ class << self
70
+ def load(path = nil)
71
+ return new unless path && File.exist?(path)
72
+
73
+ yaml = YAML.safe_load_file(path) || {}
74
+ validate_yaml_structure!(yaml)
75
+
76
+ static = yaml['static'] || {}
77
+ rules = yaml['rules'] || {}
78
+ report = yaml['report'] || {}
79
+
80
+ new(
81
+ static_include: static['include'] || DEFAULT_STATIC_INCLUDE,
82
+ static_exclude: static['exclude'] || DEFAULT_STATIC_EXCLUDE,
83
+ rules_config: rules,
84
+ report_formats: report['formats'] || %w[text],
85
+ min_severity: report.fetch('min_severity', :low),
86
+ suppressions_path: static['suppressions_path']
87
+ )
88
+ end
89
+
90
+ private
91
+
92
+ def validate_yaml_structure!(yaml)
93
+ unless yaml.is_a?(Hash)
94
+ raise ConfigurationError,
95
+ "configuration must be a YAML mapping, got #{yaml.class}"
96
+ end
97
+
98
+ check_unknown_keys(yaml, KNOWN_TOP_LEVEL_KEYS, 'top level')
99
+
100
+ if yaml.key?('static')
101
+ static = yaml['static']
102
+ unless static.is_a?(Hash)
103
+ raise ConfigurationError,
104
+ "static must be a mapping, got #{static.class}"
105
+ end
106
+ check_unknown_keys(static, KNOWN_STATIC_KEYS, 'static')
107
+ end
108
+
109
+ if yaml.key?('report')
110
+ report = yaml['report']
111
+ unless report.is_a?(Hash)
112
+ raise ConfigurationError,
113
+ "report must be a mapping, got #{report.class}"
114
+ end
115
+ check_unknown_keys(report, KNOWN_REPORT_KEYS, 'report')
116
+ end
117
+
118
+ return unless yaml.key?('rules')
119
+
120
+ rules = yaml['rules']
121
+ return if rules.is_a?(Hash)
122
+
123
+ raise ConfigurationError,
124
+ "rules must be a mapping, got #{rules.class}"
125
+ end
126
+
127
+ def check_unknown_keys(hash, allowed, path)
128
+ unknown = hash.keys - allowed
129
+ return if unknown.empty?
130
+
131
+ sorted_allowed = allowed.sort.join(', ')
132
+ raise ConfigurationError,
133
+ "unknown configuration key '#{unknown.first}' at #{path} " \
134
+ "(valid keys: #{sorted_allowed})"
135
+ end
136
+ end
137
+
138
+ private
139
+
140
+ def validate_types!(
141
+ include_patterns, exclude_patterns, rules,
142
+ formats, _severity, suppressions
143
+ )
144
+ unless include_patterns.is_a?(Array) &&
145
+ include_patterns.all?(String)
146
+ raise ConfigurationError,
147
+ 'static.include must be an Array of Strings'
148
+ end
149
+
150
+ unless exclude_patterns.is_a?(Array) &&
151
+ exclude_patterns.all?(String)
152
+ raise ConfigurationError,
153
+ 'static.exclude must be an Array of Strings'
154
+ end
155
+
156
+ raise ConfigurationError, 'rules must be a Hash' unless rules.is_a?(Hash)
157
+
158
+ validate_rules!(rules)
159
+ validate_report_formats!(formats)
160
+
161
+ return if suppressions.nil? || suppressions.is_a?(String)
162
+
163
+ raise ConfigurationError,
164
+ 'static.suppressions_path must be nil or a String'
165
+ end
166
+
167
+ def validate_report_formats!(formats)
168
+ unless formats.is_a?(Array)
169
+ raise ConfigurationError,
170
+ 'report.formats must be an Array'
171
+ end
172
+
173
+ if formats.empty?
174
+ raise ConfigurationError,
175
+ 'report.formats must not be empty'
176
+ end
177
+
178
+ invalid = formats.reject { |f| VALID_FORMATS.include?(f) }
179
+ return if invalid.empty?
180
+
181
+ raise ConfigurationError,
182
+ "report.formats contains invalid: #{invalid.inspect} " \
183
+ '(valid formats: text, json)'
184
+ end
185
+
186
+ def validate_rules!(rules)
187
+ rules.each do |rule_id, entry|
188
+ unless entry.is_a?(Hash)
189
+ raise ConfigurationError,
190
+ "rules.#{rule_id} must be a Hash, got #{entry.class}"
191
+ end
192
+
193
+ unknown = entry.keys - KNOWN_RULE_KEYS
194
+ unless unknown.empty?
195
+ sorted_allowed = KNOWN_RULE_KEYS.sort.join(', ')
196
+ raise ConfigurationError,
197
+ "unknown configuration key '#{unknown.first}' " \
198
+ "in rules.#{rule_id} " \
199
+ "(valid keys: #{sorted_allowed})"
200
+ end
201
+
202
+ if entry.key?('enabled') &&
203
+ ![true, false].include?(entry['enabled'])
204
+ raise ConfigurationError,
205
+ "rules.#{rule_id}.enabled must be a Boolean"
206
+ end
207
+
208
+ next unless entry.key?('severity')
209
+
210
+ coerce_severity(
211
+ entry['severity'], "rules.#{rule_id}.severity"
212
+ )
213
+ end
214
+ end
215
+
216
+ # Centralized severity coercion.
217
+ # Normalizes String to Symbol before delegating to Severity.coerce.
218
+ # Raises path-anchored ConfigurationError for invalid types or values.
219
+ def coerce_severity(value, path)
220
+ normalized = case value
221
+ when Symbol
222
+ value
223
+ when String
224
+ value.to_sym
225
+ else
226
+ raise ConfigurationError,
227
+ "#{path} must be a String or Symbol, " \
228
+ "got #{value.class}"
229
+ end
230
+ Severity.coerce(normalized)
231
+ rescue ArgumentError
232
+ raise ConfigurationError,
233
+ "#{path} is not a valid severity: #{value.inspect}"
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'pathname'
5
+
6
+ module FiberAudit
7
+ module Correlation
8
+ module Fingerprint
9
+ module_function
10
+
11
+ def call(rule_id:, path:, enclosing_symbol:, operation:)
12
+ Digest::SHA256.hexdigest(
13
+ [rule_id, normalize_path(path), enclosing_symbol, operation].join(':')
14
+ )
15
+ end
16
+
17
+ def normalize_path(path)
18
+ return '' if path.nil?
19
+
20
+ Pathname.new(path).cleanpath.to_s
21
+ rescue ArgumentError
22
+ path.to_s
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiberAudit
4
+ class ConfigurationError < StandardError; end
5
+ class EmptyEvidenceError < StandardError; end
6
+ class ReporterError < StandardError; end
7
+ class ProjectError < StandardError; end
8
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiberAudit
4
+ # Execution context constants for classifying where code runs.
5
+ #
6
+ # These contexts represent the execution environment of a call site:
7
+ # - request: ActionController handling an HTTP request
8
+ # - middleware: Rack middleware in the request chain
9
+ # - callback: ActiveRecord/ActiveSupport callback
10
+ # - view: ActionView template rendering
11
+ # - job: ActiveJob background job
12
+ # - websocket: ActionCable WebSocket handler
13
+ # - boot: Rails initializer or boot sequence
14
+ # - console: Rails console or IRB session
15
+ # - rake_task: Rake task execution
16
+ # - test: Test suite (RSpec, Minitest)
17
+ # - unknown: Cannot determine context
18
+ #
19
+ # The ALL array is frozen and ordered for iteration.
20
+ module Context
21
+ REQUEST = :request
22
+ MIDDLEWARE = :middleware
23
+ CALLBACK = :callback
24
+ VIEW = :view
25
+ JOB = :job
26
+ WEBSOCKET = :websocket
27
+ BOOT = :boot
28
+ CONSOLE = :console
29
+ RAKE_TASK = :rake_task
30
+ TEST = :test
31
+ UNKNOWN = :unknown
32
+
33
+ ALL = [
34
+ REQUEST,
35
+ MIDDLEWARE,
36
+ CALLBACK,
37
+ VIEW,
38
+ JOB,
39
+ WEBSOCKET,
40
+ BOOT,
41
+ CONSOLE,
42
+ RAKE_TASK,
43
+ TEST,
44
+ UNKNOWN
45
+ ].freeze
46
+ end
47
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../errors'
4
+
5
+ module FiberAudit
6
+ class Collection
7
+ include Enumerable
8
+
9
+ def initialize(findings = [])
10
+ @findings = Array(findings).dup
11
+ @findings.each { |f| validate_evidence!(f) }
12
+ end
13
+
14
+ def each(&)
15
+ @findings.each(&)
16
+ end
17
+
18
+ def add(finding)
19
+ validate_evidence!(finding)
20
+ @findings << finding
21
+ self
22
+ end
23
+
24
+ def by_rule(rule_id)
25
+ @findings.select { _1.rule_id == rule_id }
26
+ end
27
+
28
+ def by_severity_min(min_severity)
29
+ min_index = Severity.index(min_severity)
30
+ @findings.select { Severity.index(_1.severity) <= min_index }
31
+ end
32
+
33
+ def size
34
+ @findings.size
35
+ end
36
+
37
+ def empty?
38
+ @findings.empty?
39
+ end
40
+
41
+ def to_a
42
+ @findings.dup
43
+ end
44
+
45
+ def to_h_for_json
46
+ @findings.map(&:to_h_for_json)
47
+ end
48
+
49
+ private
50
+
51
+ def validate_evidence!(finding)
52
+ return if finding.evidence&.any?
53
+
54
+ raise EmptyEvidenceError,
55
+ "Finding #{finding.rule_id} (#{finding.fingerprint[0, 8]}) cannot be published without evidence"
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiberAudit
4
+ module Confidence
5
+ LEVELS = %i[confirmed high medium low unknown].freeze
6
+
7
+ module_function
8
+
9
+ def coerce(value)
10
+ return value if LEVELS.include?(value)
11
+
12
+ raise ArgumentError, "unknown confidence: #{value.inspect}"
13
+ end
14
+
15
+ def index(confidence)
16
+ LEVELS.index(confidence) || LEVELS.size
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiberAudit
4
+ Evidence = Data.define(:source, :message, :details) do
5
+ def initialize(source:, message:, details: {})
6
+ super(source: source, message: message, details: details || {})
7
+ end
8
+
9
+ def to_h_for_json
10
+ { source: source, message: message, details: details }
11
+ end
12
+ end
13
+ end