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,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,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
|