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,266 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../execution_context'
|
|
4
|
+
|
|
5
|
+
module FiberAudit
|
|
6
|
+
module Static
|
|
7
|
+
# Resolves execution context for call sites based on semantic ancestry,
|
|
8
|
+
# path heuristics, and callback DSL patterns.
|
|
9
|
+
#
|
|
10
|
+
# Resolution priority:
|
|
11
|
+
# 1. Semantic inheritance (outranks path)
|
|
12
|
+
# 2. Path-based fallback
|
|
13
|
+
# 3. Callback DSL sniff
|
|
14
|
+
# 4. :unknown
|
|
15
|
+
#
|
|
16
|
+
# Never raises; always returns a Context symbol.
|
|
17
|
+
class ExecutionContextResolver
|
|
18
|
+
# Semantic ancestry signals mapped to contexts
|
|
19
|
+
SEMANTIC_SIGNALS = {
|
|
20
|
+
'ActionController::Base' => Context::REQUEST,
|
|
21
|
+
'ActionController::API' => Context::REQUEST,
|
|
22
|
+
'ActionJob::Base' => Context::JOB, # typo tolerance
|
|
23
|
+
'ActiveJob::Base' => Context::JOB,
|
|
24
|
+
'ActionCable::Channel::Base' => Context::WEBSOCKET,
|
|
25
|
+
'ActionView::Base' => Context::VIEW
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
# Callback model ancestors (ActiveRecord/model ancestry only)
|
|
29
|
+
CALLBACK_ANCESTORS = %w[
|
|
30
|
+
ActiveRecord::Base
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
# @param workspace [Object] responds to ancestors_of(name) OR exposes semantic_index
|
|
34
|
+
def initialize(workspace:)
|
|
35
|
+
@workspace = workspace
|
|
36
|
+
@ancestor_cache = {}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Resolve context for a single call site
|
|
40
|
+
# @param call_site [CallSite] a call site with path, enclosing_symbol, nesting
|
|
41
|
+
# @return [Symbol] one of Context::ALL
|
|
42
|
+
def resolve(call_site:)
|
|
43
|
+
path = call_site.path
|
|
44
|
+
enclosing = call_site.enclosing_symbol
|
|
45
|
+
nesting = Array(call_site.nesting)
|
|
46
|
+
|
|
47
|
+
# Try semantic inheritance first (outranks path)
|
|
48
|
+
semantic = resolve_from_semantics(enclosing, nesting)
|
|
49
|
+
return semantic if semantic
|
|
50
|
+
|
|
51
|
+
# Path-based fallback
|
|
52
|
+
path_ctx = resolve_from_path(path, enclosing)
|
|
53
|
+
return path_ctx if path_ctx
|
|
54
|
+
|
|
55
|
+
# Callback DSL sniff
|
|
56
|
+
callback_ctx = resolve_callback(enclosing, nesting)
|
|
57
|
+
return callback_ctx if callback_ctx
|
|
58
|
+
|
|
59
|
+
Context::UNKNOWN
|
|
60
|
+
rescue StandardError
|
|
61
|
+
Context::UNKNOWN
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Resolve contexts for all call sites, returning new CallSite objects
|
|
65
|
+
# with execution_context populated. Does not mutate originals.
|
|
66
|
+
#
|
|
67
|
+
# @param call_sites [Array<CallSite>]
|
|
68
|
+
# @return [Array<CallSite>] new array in original order
|
|
69
|
+
# Never silently returns an unchanged object when copying fails—
|
|
70
|
+
# unknown is still populated where compatible.
|
|
71
|
+
def resolve_all(call_sites:)
|
|
72
|
+
Array(call_sites).map do |cs|
|
|
73
|
+
ctx = resolve(call_site: cs)
|
|
74
|
+
copy_with_context(cs, ctx)
|
|
75
|
+
end
|
|
76
|
+
rescue StandardError
|
|
77
|
+
# If batch fails, try individually with :unknown.
|
|
78
|
+
# If copy itself fails (not a Data type), let it propagate—
|
|
79
|
+
# never silently return an unchanged object.
|
|
80
|
+
Array(call_sites).map do |cs|
|
|
81
|
+
copy_with_context(cs, Context::UNKNOWN)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
# Extract enclosing class name from enclosing_symbol
|
|
88
|
+
# "ClassName#method" or "ClassName.method" → "ClassName"
|
|
89
|
+
# "Namespace::Class#method" → "Namespace::Class"
|
|
90
|
+
def extract_class(enclosing_symbol)
|
|
91
|
+
return nil if enclosing_symbol.nil? || enclosing_symbol.empty?
|
|
92
|
+
|
|
93
|
+
# Split on # or . and take the class part
|
|
94
|
+
if enclosing_symbol.include?('#')
|
|
95
|
+
enclosing_symbol.split('#', 2).first
|
|
96
|
+
elsif enclosing_symbol.include?('.')
|
|
97
|
+
enclosing_symbol.split('.', 2).first
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Extract method name from enclosing_symbol
|
|
102
|
+
# "ClassName#method" → "method"
|
|
103
|
+
# "ClassName.method" → "method"
|
|
104
|
+
def extract_method(enclosing_symbol)
|
|
105
|
+
return nil if enclosing_symbol.nil? || enclosing_symbol.empty?
|
|
106
|
+
|
|
107
|
+
if enclosing_symbol.include?('#')
|
|
108
|
+
enclosing_symbol.split('#', 2).last
|
|
109
|
+
elsif enclosing_symbol.include?('.')
|
|
110
|
+
enclosing_symbol.split('.', 2).last
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Try to resolve from semantic ancestry with transitive traversal
|
|
115
|
+
def resolve_from_semantics(enclosing_symbol, nesting)
|
|
116
|
+
# Try enclosing class first
|
|
117
|
+
klass = extract_class(enclosing_symbol)
|
|
118
|
+
if klass
|
|
119
|
+
result = check_semantic_ancestors_transitive(klass)
|
|
120
|
+
return result if result
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Try nesting (innermost first)
|
|
124
|
+
nesting.reverse_each do |name|
|
|
125
|
+
result = check_semantic_ancestors_transitive(name)
|
|
126
|
+
return result if result
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Check class name and its transitive ancestors with cycle detection
|
|
133
|
+
# @param class_name [String] the class name to check
|
|
134
|
+
# @param visited [Hash] names already visited during cycle detection
|
|
135
|
+
# @return [Symbol, nil] the context if found, nil otherwise
|
|
136
|
+
def check_semantic_ancestors_transitive(class_name, visited = {})
|
|
137
|
+
return nil unless class_name
|
|
138
|
+
return nil if visited.key?(class_name)
|
|
139
|
+
|
|
140
|
+
visited[class_name] = true
|
|
141
|
+
|
|
142
|
+
# Match the class itself first (known class signal, even if ancestors empty)
|
|
143
|
+
signal = SEMANTIC_SIGNALS[class_name]
|
|
144
|
+
return signal if signal
|
|
145
|
+
|
|
146
|
+
# Get ancestors
|
|
147
|
+
ancestors = safe_ancestors_of(class_name)
|
|
148
|
+
return nil if ancestors.empty?
|
|
149
|
+
|
|
150
|
+
# Check each ancestor and recurse transitively
|
|
151
|
+
ancestors.each do |ancestor_name|
|
|
152
|
+
# Check if this ancestor is a known signal
|
|
153
|
+
ancestor_signal = SEMANTIC_SIGNALS[ancestor_name]
|
|
154
|
+
return ancestor_signal if ancestor_signal
|
|
155
|
+
|
|
156
|
+
# Recurse transitively with cycle detection
|
|
157
|
+
result = check_semantic_ancestors_transitive(ancestor_name, visited)
|
|
158
|
+
return result if result
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Resolve from path segments with contiguous boundary-safe sequences
|
|
165
|
+
def resolve_from_path(path, enclosing_symbol)
|
|
166
|
+
return nil unless path
|
|
167
|
+
|
|
168
|
+
# Normalize Windows backslashes to forward slashes
|
|
169
|
+
normalized = path.tr('\\', '/')
|
|
170
|
+
segments = normalized.split('/')
|
|
171
|
+
|
|
172
|
+
# Contiguous boundary-safe sequences
|
|
173
|
+
# config/initializers → boot
|
|
174
|
+
return Context::BOOT if contiguous_pair?(segments, 'config', 'initializers')
|
|
175
|
+
|
|
176
|
+
# lib/tasks → rake_task
|
|
177
|
+
return Context::RAKE_TASK if contiguous_pair?(segments, 'lib', 'tasks')
|
|
178
|
+
|
|
179
|
+
# basename Rakefile → rake_task
|
|
180
|
+
return Context::RAKE_TASK if File.basename(normalized) == 'Rakefile'
|
|
181
|
+
|
|
182
|
+
# app/views → view
|
|
183
|
+
return Context::VIEW if contiguous_pair?(segments, 'app', 'views')
|
|
184
|
+
|
|
185
|
+
# spec or test segment → test (exact segment match, not substring)
|
|
186
|
+
return Context::TEST if segments.include?('spec') || segments.include?('test')
|
|
187
|
+
|
|
188
|
+
# config.ru requires enclosing instance #call specifically
|
|
189
|
+
# Must be instance method (#call), not class method (.call)
|
|
190
|
+
# Must be exactly 'call', not a substring like 'callback'
|
|
191
|
+
if File.basename(normalized) == 'config.ru'
|
|
192
|
+
method = extract_method(enclosing_symbol)
|
|
193
|
+
return Context::MIDDLEWARE if enclosing_symbol&.include?('#') && method == 'call'
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
nil
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Check if two segments appear contiguously in the path
|
|
200
|
+
# @param segments [Array<String>] path segments
|
|
201
|
+
# @param first [String] first segment
|
|
202
|
+
# @param second [String] second segment
|
|
203
|
+
# @return [Boolean] true when first is immediately followed by second
|
|
204
|
+
def contiguous_pair?(segments, first, second)
|
|
205
|
+
segments.each_cons(2).any? do |left, right|
|
|
206
|
+
left == first && right == second
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Resolve callback DSL
|
|
211
|
+
def resolve_callback(enclosing_symbol, nesting)
|
|
212
|
+
method = extract_method(enclosing_symbol)
|
|
213
|
+
return nil unless method
|
|
214
|
+
|
|
215
|
+
# Method starts with before_/after_/around_
|
|
216
|
+
return nil unless method.start_with?('before_', 'after_', 'around_')
|
|
217
|
+
|
|
218
|
+
# Check if any enclosing class has ActiveRecord/model ancestry
|
|
219
|
+
klass = extract_class(enclosing_symbol)
|
|
220
|
+
candidate_classes = [klass, *nesting].compact
|
|
221
|
+
|
|
222
|
+
candidate_classes.each do |name|
|
|
223
|
+
ancestors = safe_ancestors_of(name)
|
|
224
|
+
return Context::CALLBACK if ancestors.any? { |a| CALLBACK_ANCESTORS.include?(a) }
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
nil
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Get ancestors from workspace safely, never raises
|
|
231
|
+
# Tries workspace.ancestors_of or workspace.semantic_index.ancestors_of
|
|
232
|
+
def safe_ancestors_of(name)
|
|
233
|
+
return [] unless name
|
|
234
|
+
return @ancestor_cache[name] if @ancestor_cache.key?(name)
|
|
235
|
+
|
|
236
|
+
ancestors = if @workspace.respond_to?(:ancestors_of)
|
|
237
|
+
@workspace.ancestors_of(name)
|
|
238
|
+
elsif @workspace.respond_to?(:semantic_index) &&
|
|
239
|
+
@workspace.semantic_index.respond_to?(:ancestors_of)
|
|
240
|
+
@workspace.semantic_index.ancestors_of(name)
|
|
241
|
+
else
|
|
242
|
+
[]
|
|
243
|
+
end
|
|
244
|
+
@ancestor_cache[name] = Array(ancestors).dup.freeze
|
|
245
|
+
rescue StandardError
|
|
246
|
+
@ancestor_cache[name] = [].freeze
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Create a new CallSite with execution_context populated
|
|
250
|
+
# Does not mutate the original
|
|
251
|
+
# Never silently returns the unchanged object
|
|
252
|
+
def copy_with_context(call_site, context)
|
|
253
|
+
klass = call_site.class
|
|
254
|
+
unless klass.respond_to?(:members)
|
|
255
|
+
# Cannot copy immutably - raise instead of silently returning unchanged
|
|
256
|
+
raise TypeError, "Cannot immutably copy #{klass}: not a Data type"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
attrs = {}
|
|
260
|
+
klass.members.each { |m| attrs[m] = call_site.send(m) }
|
|
261
|
+
attrs[:execution_context] = context
|
|
262
|
+
klass.new(**attrs)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../../findings/severity'
|
|
4
|
+
require_relative '../../findings/confidence'
|
|
5
|
+
|
|
6
|
+
module FiberAudit
|
|
7
|
+
module Static
|
|
8
|
+
module Rules
|
|
9
|
+
# Base class for all static analysis rules.
|
|
10
|
+
#
|
|
11
|
+
# Subclasses use the class-level DSL to declare metadata:
|
|
12
|
+
#
|
|
13
|
+
# class BlockingSubprocess < Base
|
|
14
|
+
# id 'FA1001'
|
|
15
|
+
# severity :high
|
|
16
|
+
# confidence :high
|
|
17
|
+
# description 'Blocking subprocess call in the fiber scheduler path'
|
|
18
|
+
#
|
|
19
|
+
# def analyze(call_sites:)
|
|
20
|
+
# # ...
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# Severity resolution flow (monotonic):
|
|
25
|
+
# 1. Start with the rule's default_severity
|
|
26
|
+
# 2. Apply configuration.severity_override (replaces default if present)
|
|
27
|
+
# 3. Apply context ceiling: if the resulting severity is less severe
|
|
28
|
+
# than the ceiling for the execution context, raise to the ceiling.
|
|
29
|
+
# Never lower a severity that is already more severe than the ceiling.
|
|
30
|
+
#
|
|
31
|
+
class Base
|
|
32
|
+
# Context severity ceiling table.
|
|
33
|
+
#
|
|
34
|
+
# For each execution context, the ceiling is the minimum severity
|
|
35
|
+
# (maximum urgency) that a finding must reach. If the rule's base
|
|
36
|
+
# severity is already at or above the ceiling, it is left unchanged.
|
|
37
|
+
# A nil ceiling (unknown context) means no upgrade is applied.
|
|
38
|
+
#
|
|
39
|
+
CONTEXT_CEILING = {
|
|
40
|
+
request: :critical,
|
|
41
|
+
middleware: :critical,
|
|
42
|
+
websocket: :critical,
|
|
43
|
+
callback: :high,
|
|
44
|
+
view: :high,
|
|
45
|
+
job: :high,
|
|
46
|
+
boot: :medium,
|
|
47
|
+
console: :info,
|
|
48
|
+
test: :info,
|
|
49
|
+
rake_task: :low,
|
|
50
|
+
unknown: nil
|
|
51
|
+
}.freeze
|
|
52
|
+
|
|
53
|
+
class << self
|
|
54
|
+
# Get or set the rule identifier.
|
|
55
|
+
# Coerces the value to a frozen String.
|
|
56
|
+
def id(value = nil)
|
|
57
|
+
return @rule_id unless value
|
|
58
|
+
|
|
59
|
+
@rule_id = value.to_s.freeze
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Get or set the default severity.
|
|
63
|
+
# When setting, validates via Severity.coerce.
|
|
64
|
+
# Accepts Symbol or String values; Strings are normalized to Symbols.
|
|
65
|
+
def severity(value = nil)
|
|
66
|
+
return @default_severity unless value
|
|
67
|
+
|
|
68
|
+
normalized = value.is_a?(String) ? value.to_sym : value
|
|
69
|
+
@default_severity = Severity.coerce(normalized)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Alias: getter for the default severity.
|
|
73
|
+
# Also accepts a value for DSL symmetry (delegates to severity).
|
|
74
|
+
def default_severity(value = nil)
|
|
75
|
+
return @default_severity unless value
|
|
76
|
+
|
|
77
|
+
severity(value)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Get or set the default confidence.
|
|
81
|
+
# When setting, validates via Confidence.coerce.
|
|
82
|
+
# Accepts Symbol or String values; Strings are normalized to Symbols.
|
|
83
|
+
def confidence(value = nil)
|
|
84
|
+
return @default_confidence unless value
|
|
85
|
+
|
|
86
|
+
normalized = value.is_a?(String) ? value.to_sym : value
|
|
87
|
+
@default_confidence = Confidence.coerce(normalized)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Alias: getter for the default confidence.
|
|
91
|
+
# Also accepts a value for DSL symmetry (delegates to confidence).
|
|
92
|
+
def default_confidence(value = nil)
|
|
93
|
+
return @default_confidence unless value
|
|
94
|
+
|
|
95
|
+
confidence(value)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Get or set the human-readable description.
|
|
99
|
+
def description(value = nil)
|
|
100
|
+
return @description unless value
|
|
101
|
+
|
|
102
|
+
@description = value.to_s.freeze
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Construct a rule instance with its dependencies.
|
|
107
|
+
#
|
|
108
|
+
# @param workspace [Object] the analysis workspace
|
|
109
|
+
# @param context_resolver [Object] resolves call-site execution contexts
|
|
110
|
+
# @param configuration [FiberAudit::Configuration] audit configuration
|
|
111
|
+
def initialize(workspace:, context_resolver:, configuration:)
|
|
112
|
+
@workspace = workspace
|
|
113
|
+
@context_resolver = context_resolver
|
|
114
|
+
@configuration = configuration
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Analyze call sites and return an array of findings.
|
|
118
|
+
# Subclasses MUST override this method.
|
|
119
|
+
#
|
|
120
|
+
# @param call_sites [Array<FiberAudit::Static::CallSite>]
|
|
121
|
+
# @return [Array<FiberAudit::Finding>]
|
|
122
|
+
# @raise [NotImplementedError]
|
|
123
|
+
def analyze(call_sites:)
|
|
124
|
+
raise NotImplementedError,
|
|
125
|
+
"#{self.class.name}#analyze must be implemented by the subclass"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
protected
|
|
129
|
+
|
|
130
|
+
# @return [Object] the analysis workspace
|
|
131
|
+
attr_reader :workspace
|
|
132
|
+
|
|
133
|
+
# @return [Object] the execution-context resolver
|
|
134
|
+
attr_reader :context_resolver
|
|
135
|
+
|
|
136
|
+
# @return [FiberAudit::Configuration] the audit configuration
|
|
137
|
+
attr_reader :configuration
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
# Compute the final severity for a finding.
|
|
142
|
+
#
|
|
143
|
+
# Resolution order:
|
|
144
|
+
# 1. configuration.severity_override(rule_id) replaces the default
|
|
145
|
+
# 2. Context ceiling monotonically raises (never lowers)
|
|
146
|
+
#
|
|
147
|
+
# @param default_sev [Symbol, String] rule's default severity
|
|
148
|
+
# @param context [Symbol, nil] execution context (nil → :unknown)
|
|
149
|
+
# @return [Symbol] the resolved severity
|
|
150
|
+
def severity_for(default_sev, context)
|
|
151
|
+
# Normalize String to Symbol defensively
|
|
152
|
+
default_sev = default_sev.to_sym if default_sev.is_a?(String)
|
|
153
|
+
|
|
154
|
+
# Step 1: configuration override (replaces default entirely)
|
|
155
|
+
base = configuration.severity_override(self.class.id) || default_sev
|
|
156
|
+
|
|
157
|
+
# Step 2: context ceiling (monotonic raise only)
|
|
158
|
+
ctx = context || :unknown
|
|
159
|
+
ceiling = CONTEXT_CEILING.fetch(ctx) do
|
|
160
|
+
CONTEXT_CEILING[:unknown]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
return base if ceiling.nil?
|
|
164
|
+
|
|
165
|
+
# If the base severity is less severe than the ceiling (higher index),
|
|
166
|
+
# raise it to the ceiling. Otherwise keep it (never lower).
|
|
167
|
+
if Severity.index(base) > Severity.index(ceiling)
|
|
168
|
+
ceiling
|
|
169
|
+
else
|
|
170
|
+
base
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Severity index helper — delegates to Severity.index.
|
|
175
|
+
# Lower index = more severe (0 = :critical, 4 = :info).
|
|
176
|
+
#
|
|
177
|
+
# @param severity [Symbol] a severity level
|
|
178
|
+
# @return [Integer] the numeric index
|
|
179
|
+
def severity_index(severity)
|
|
180
|
+
Severity.index(severity)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
require_relative '../../findings/evidence'
|
|
5
|
+
require_relative '../../correlation/fingerprint'
|
|
6
|
+
require_relative '../../findings/finding'
|
|
7
|
+
|
|
8
|
+
module FiberAudit
|
|
9
|
+
module Static
|
|
10
|
+
module Rules
|
|
11
|
+
class BlockingSubprocess < Base
|
|
12
|
+
id 'FA1001'
|
|
13
|
+
severity :high
|
|
14
|
+
default_confidence :high
|
|
15
|
+
description 'Blocking subprocess call in the fiber scheduler path'
|
|
16
|
+
|
|
17
|
+
TARGETS = {
|
|
18
|
+
'Kernel' => %i[system exec spawn].freeze,
|
|
19
|
+
'Open3' => %i[capture2 capture2e capture3 pipeline].freeze,
|
|
20
|
+
'IO' => %i[popen].freeze,
|
|
21
|
+
'Process' => %i[waitall detach].freeze
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
BARE_KERNEL_METHODS = %i[system exec spawn].freeze
|
|
25
|
+
|
|
26
|
+
MESSAGE = 'Subprocess operation may block the thread running the fiber scheduler.'
|
|
27
|
+
REMEDIATION = 'Move long-running subprocess work outside the request path, or verify scheduler behaviour under load.'
|
|
28
|
+
|
|
29
|
+
def analyze(call_sites:)
|
|
30
|
+
call_sites.filter_map do |site|
|
|
31
|
+
next unless (match = match_call_site(site))
|
|
32
|
+
|
|
33
|
+
build_finding(site, match)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def match_call_site(site)
|
|
40
|
+
receiver = site.receiver_constant
|
|
41
|
+
method = site.method_name
|
|
42
|
+
|
|
43
|
+
if receiver.nil? && site.receiver_source.nil? && BARE_KERNEL_METHODS.include?(method)
|
|
44
|
+
return { constant: 'Kernel', method: method, confidence: :unknown }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
return nil unless receiver && TARGETS.key?(receiver)
|
|
48
|
+
return nil unless TARGETS[receiver].include?(method)
|
|
49
|
+
return nil if shadowed?(receiver, site.nesting)
|
|
50
|
+
|
|
51
|
+
{ constant: receiver, method: method, confidence: site.confidence }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def shadowed?(constant_name, nesting)
|
|
55
|
+
sem = workspace.semantic_index if workspace.respond_to?(:semantic_index)
|
|
56
|
+
sem ||= workspace if workspace.respond_to?(:resolve_constant)
|
|
57
|
+
return false unless sem.respond_to?(:resolve_constant)
|
|
58
|
+
|
|
59
|
+
resolved = sem.resolve_constant(constant_name, nesting: nesting || [])
|
|
60
|
+
!resolved.nil?
|
|
61
|
+
rescue StandardError
|
|
62
|
+
false
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def build_finding(site, match)
|
|
66
|
+
operation = "#{match[:constant]}.#{match[:method]}"
|
|
67
|
+
context = site.execution_context || :unknown
|
|
68
|
+
sev = severity_for(self.class.severity, context)
|
|
69
|
+
|
|
70
|
+
Finding.new(
|
|
71
|
+
rule_id: self.class.id,
|
|
72
|
+
title: 'Blocking subprocess call',
|
|
73
|
+
category: :subprocess,
|
|
74
|
+
severity: sev,
|
|
75
|
+
confidence: match[:confidence],
|
|
76
|
+
location: site.location,
|
|
77
|
+
symbol: site.enclosing_symbol,
|
|
78
|
+
operation: operation,
|
|
79
|
+
execution_context: context,
|
|
80
|
+
message: MESSAGE,
|
|
81
|
+
evidence: [
|
|
82
|
+
Evidence.new(
|
|
83
|
+
source: :static,
|
|
84
|
+
message: "Matched #{operation}",
|
|
85
|
+
details: { receiver: match[:constant], method: match[:method] }
|
|
86
|
+
)
|
|
87
|
+
],
|
|
88
|
+
remediation: REMEDIATION
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'registry'
|
|
4
|
+
require_relative 'blocking_subprocess'
|
|
5
|
+
require_relative 'thread_join'
|
|
6
|
+
require_relative 'synchronization'
|
|
7
|
+
require_relative 'thread_current_state'
|
|
8
|
+
require_relative 'io_select'
|
|
9
|
+
require_relative 'direct_socket'
|
|
10
|
+
require_relative 'net_http_in_request'
|
|
11
|
+
|
|
12
|
+
module FiberAudit
|
|
13
|
+
module Static
|
|
14
|
+
module Rules
|
|
15
|
+
module BuiltIns
|
|
16
|
+
RULES = [
|
|
17
|
+
BlockingSubprocess,
|
|
18
|
+
ThreadJoin,
|
|
19
|
+
Synchronization,
|
|
20
|
+
ThreadCurrentState,
|
|
21
|
+
IOSelect,
|
|
22
|
+
DirectSocket,
|
|
23
|
+
NetHTTPInRequest
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
def registry(workspace: nil, context_resolver: nil)
|
|
29
|
+
Registry.new(workspace: workspace, context_resolver: context_resolver).tap do |registry|
|
|
30
|
+
RULES.each { |rule_class| registry.register(rule_class) }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
require_relative '../../findings/evidence'
|
|
5
|
+
require_relative '../../correlation/fingerprint'
|
|
6
|
+
require_relative '../../findings/finding'
|
|
7
|
+
|
|
8
|
+
module FiberAudit
|
|
9
|
+
module Static
|
|
10
|
+
module Rules
|
|
11
|
+
# FA1006 – Detects direct socket creation that may bypass
|
|
12
|
+
# scheduler-aware networking and block the scheduler thread.
|
|
13
|
+
class DirectSocket < Base
|
|
14
|
+
id 'FA1006'
|
|
15
|
+
severity :medium
|
|
16
|
+
default_confidence :high
|
|
17
|
+
description 'Detects direct socket creation that may bypass scheduler-aware networking.'
|
|
18
|
+
|
|
19
|
+
TITLE = 'Direct socket creation'
|
|
20
|
+
CATEGORY = :network
|
|
21
|
+
|
|
22
|
+
EXACT = %w[
|
|
23
|
+
TCPSocket TCPServer UDPSocket UNIXSocket UNIXServer Socket IPSocket
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
MESSAGE = 'Direct socket use may bypass scheduler-aware networking ' \
|
|
27
|
+
'and block the scheduler thread.'
|
|
28
|
+
REMEDIATION = 'Use scheduler-aware networking APIs or verify the ' \
|
|
29
|
+
'socket operations cooperate with the active Fiber scheduler.'
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
def title = TITLE
|
|
33
|
+
def category = CATEGORY
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def analyze(call_sites:)
|
|
37
|
+
call_sites.filter_map { |site| match(site) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def match(site)
|
|
43
|
+
return unless site.method_name == :new
|
|
44
|
+
|
|
45
|
+
const = site.receiver_constant
|
|
46
|
+
return unless const
|
|
47
|
+
|
|
48
|
+
if EXACT.include?(const)
|
|
49
|
+
return if shadowed?(const, site.nesting)
|
|
50
|
+
|
|
51
|
+
return build_finding(site, const)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
return unless ip_socket_subclass?(const)
|
|
55
|
+
|
|
56
|
+
build_finding(site, const)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def shadowed?(name, nesting)
|
|
60
|
+
sem = semantic_index
|
|
61
|
+
return false unless sem
|
|
62
|
+
|
|
63
|
+
!sem.resolve_constant(name, nesting: nesting || []).nil?
|
|
64
|
+
rescue StandardError
|
|
65
|
+
false
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def ip_socket_subclass?(name)
|
|
69
|
+
sem = semantic_index
|
|
70
|
+
return false unless sem
|
|
71
|
+
|
|
72
|
+
sem.ancestors_of(name).include?('IPSocket')
|
|
73
|
+
rescue StandardError
|
|
74
|
+
false
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def semantic_index
|
|
78
|
+
index = workspace.semantic_index if workspace.respond_to?(:semantic_index)
|
|
79
|
+
index || workspace
|
|
80
|
+
rescue StandardError
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def build_finding(site, const)
|
|
85
|
+
operation = "#{const}.new"
|
|
86
|
+
ctx = site.execution_context || :unknown
|
|
87
|
+
|
|
88
|
+
Finding.new(
|
|
89
|
+
rule_id: self.class.id,
|
|
90
|
+
title: self.class.title,
|
|
91
|
+
category: self.class.category,
|
|
92
|
+
severity: severity_for(:medium, ctx),
|
|
93
|
+
confidence: site.confidence,
|
|
94
|
+
location: site.location,
|
|
95
|
+
symbol: site.enclosing_symbol,
|
|
96
|
+
operation: operation,
|
|
97
|
+
execution_context: ctx,
|
|
98
|
+
message: MESSAGE,
|
|
99
|
+
evidence: [
|
|
100
|
+
Evidence.new(
|
|
101
|
+
source: 'static_analysis',
|
|
102
|
+
message: "Direct socket creation: #{operation}",
|
|
103
|
+
details: { receiver_constant: const, method: :new }
|
|
104
|
+
)
|
|
105
|
+
],
|
|
106
|
+
remediation: REMEDIATION
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|