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