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,104 @@
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
+ # FA1005: Detects explicit IO.select calls that may bypass
12
+ # scheduler-aware I/O and block the scheduler thread.
13
+ class IOSelect < Base
14
+ id 'FA1005'
15
+ severity :medium
16
+ default_confidence :high
17
+ description 'Explicit IO.select call that may bypass scheduler-aware I/O'
18
+
19
+ TITLE = 'Explicit IO.select call'
20
+ CATEGORY = :blocking_io
21
+
22
+ MESSAGE = 'IO.select may bypass scheduler-aware I/O and block the thread running the fiber scheduler.'
23
+ REMEDIATION = 'Use scheduler-aware I/O APIs or allow the active Fiber scheduler to manage readiness.'
24
+
25
+ TARGETS = {
26
+ 'IO' => :select,
27
+ 'Kernel' => :select
28
+ }.freeze
29
+
30
+ class << self
31
+ def title = TITLE
32
+ def category = CATEGORY
33
+ end
34
+
35
+ def analyze(call_sites:)
36
+ call_sites.filter_map { |site| match(site) }
37
+ end
38
+
39
+ private
40
+
41
+ def match(site)
42
+ return unless site.method_name == :select
43
+
44
+ receiver = site.receiver_constant
45
+
46
+ if receiver.nil? && site.receiver_source.nil?
47
+ # Bare select() call — treat as Kernel.select with unknown confidence
48
+ return build_finding(site, 'Kernel', :unknown)
49
+ end
50
+
51
+ canonical = TARGETS[receiver]
52
+ return unless canonical == :select
53
+ return if shadowed?(receiver, site.nesting)
54
+
55
+ build_finding(site, receiver, site.confidence)
56
+ end
57
+
58
+ def shadowed?(constant_name, nesting)
59
+ sem = semantic_index
60
+ return false unless sem
61
+
62
+ !sem.resolve_constant(constant_name, nesting: nesting || []).nil?
63
+ rescue StandardError
64
+ false
65
+ end
66
+
67
+ def semantic_index
68
+ index = workspace.semantic_index if workspace.respond_to?(:semantic_index)
69
+ return index if index
70
+
71
+ workspace if workspace.respond_to?(:resolve_constant)
72
+ rescue StandardError
73
+ nil
74
+ end
75
+
76
+ def build_finding(site, constant, confidence)
77
+ operation = "#{constant}.select"
78
+ context = site.execution_context || :unknown
79
+
80
+ Finding.new(
81
+ rule_id: self.class.id,
82
+ title: TITLE,
83
+ category: CATEGORY,
84
+ severity: severity_for(:medium, context),
85
+ confidence: confidence,
86
+ location: site.location,
87
+ symbol: site.enclosing_symbol,
88
+ operation: operation,
89
+ execution_context: context,
90
+ message: MESSAGE,
91
+ evidence: [
92
+ Evidence.new(
93
+ source: 'static_analysis',
94
+ message: "Explicit IO.select: #{operation}",
95
+ details: { receiver: constant, method: :select }
96
+ )
97
+ ],
98
+ remediation: REMEDIATION
99
+ )
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,116 @@
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
+ # FA1007: Detects blocking HTTP calls in request-like contexts.
12
+ # Targets Net::HTTP.{get,get_response,start,request}, URI.open, OpenURI.open_uri.
13
+ # Excludes Net::HTTP.get_print. Emits only for request/middleware/websocket/callback.
14
+ class NetHTTPInRequest < Base
15
+ id 'FA1007'
16
+ severity :high
17
+ default_confidence :high
18
+ description 'Blocking HTTP call in request path'
19
+
20
+ TITLE = 'Blocking HTTP call in request path'
21
+ CATEGORY = :network
22
+ MESSAGE = 'Synchronous HTTP activity in a request-like context may block the thread running the fiber scheduler.'
23
+ REMEDIATION = 'Use a scheduler-aware HTTP client, or move outbound HTTP work outside the request path.'
24
+
25
+ NET_HTTP_METHODS = %i[get get_response start request].freeze
26
+ URI_METHODS = {
27
+ 'URI' => :open,
28
+ 'OpenURI' => :open_uri
29
+ }.freeze
30
+ EMIT_CONTEXTS = %i[request middleware websocket callback].freeze
31
+
32
+ def analyze(call_sites:)
33
+ call_sites.filter_map { |site| analyze_call_site(site) }
34
+ rescue StandardError
35
+ []
36
+ end
37
+
38
+ private
39
+
40
+ def analyze_call_site(site)
41
+ return unless eligible_context?(site) && (match = match_call_site(site))
42
+
43
+ build_finding(site, match)
44
+ end
45
+
46
+ def eligible_context?(site)
47
+ EMIT_CONTEXTS.include?(site.execution_context)
48
+ end
49
+
50
+ def match_call_site(site)
51
+ receiver = site.receiver_constant
52
+ method = site.method_name
53
+ return unless receiver && method
54
+
55
+ if receiver == 'Net::HTTP' && NET_HTTP_METHODS.include?(method)
56
+ return if shadowed?(receiver, site.nesting)
57
+
58
+ { type: :net_http, operation: "Net::HTTP.#{method}" }
59
+ elsif URI_METHODS[receiver] == method
60
+ return if shadowed?(receiver, site.nesting) || non_http_literal?(site.arguments&.first)
61
+
62
+ { type: :uri, operation: "#{receiver}.#{method}" }
63
+ end
64
+ end
65
+
66
+ def shadowed?(const_name, nesting)
67
+ index = workspace.semantic_index if workspace.respond_to?(:semantic_index)
68
+ index ||= workspace if workspace.respond_to?(:resolve_constant)
69
+ return false unless index.respond_to?(:resolve_constant)
70
+
71
+ !index.resolve_constant(const_name, nesting: nesting || []).nil?
72
+ rescue StandardError
73
+ false
74
+ end
75
+
76
+ def non_http_literal?(argument)
77
+ match = argument&.match(/\A(["'])(.*)\1\z/m)
78
+ return false unless match
79
+
80
+ scheme = match[2][/\A([a-z][a-z0-9+.-]*):/i, 1]
81
+ scheme && !%w[http https].include?(scheme.downcase)
82
+ end
83
+
84
+ def build_finding(site, match)
85
+ context = site.execution_context
86
+ sev, conf = if match[:type] == :net_http
87
+ [severity_for(:high, context), site.confidence]
88
+ else
89
+ %i[medium low]
90
+ end
91
+
92
+ FiberAudit::Finding.new(
93
+ rule_id: self.class.id,
94
+ title: TITLE,
95
+ category: CATEGORY,
96
+ severity: sev,
97
+ confidence: conf,
98
+ location: site.location,
99
+ symbol: site.enclosing_symbol,
100
+ operation: match[:operation],
101
+ execution_context: context,
102
+ message: MESSAGE,
103
+ evidence: [
104
+ FiberAudit::Evidence.new(
105
+ source: 'static_analysis',
106
+ message: "Blocking HTTP call detected: #{match[:operation]}",
107
+ details: { receiver: site.receiver_constant, method: site.method_name, context: context }
108
+ )
109
+ ],
110
+ remediation: REMEDIATION
111
+ )
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module FiberAudit
6
+ module Static
7
+ module Rules
8
+ # Registry for managing rule classes.
9
+ #
10
+ # The registry holds rule classes (not instances) and can instantiate them
11
+ # with the appropriate dependencies for a given configuration.
12
+ #
13
+ # Usage:
14
+ # registry = Registry.new(workspace: ws, context_resolver: resolver)
15
+ # registry.register(BlockingSubprocess)
16
+ # registry.register(ThreadJoin)
17
+ #
18
+ # # Find a rule class by ID
19
+ # rule_class = registry['FA1001']
20
+ #
21
+ # # Get all enabled rule instances for a configuration
22
+ # instances = registry.enabled_for(configuration)
23
+ # instances.each do |rule|
24
+ # findings = rule.analyze(call_sites: call_sites)
25
+ # end
26
+ #
27
+ class Registry
28
+ include Enumerable
29
+
30
+ # @param workspace [Object, nil] the analysis workspace
31
+ # @param context_resolver [Object, nil] resolves execution contexts
32
+ def initialize(workspace: nil, context_resolver: nil)
33
+ @workspace = workspace
34
+ @context_resolver = context_resolver
35
+ @rules = []
36
+ end
37
+
38
+ # Register a rule class.
39
+ #
40
+ # @param rule_class [Class] must be a subclass of Base with an id set
41
+ # @return [self] for chaining
42
+ # @raise [ArgumentError] if rule_class is not a Base subclass, has no id, or id is duplicate
43
+ def register(rule_class)
44
+ validate_rule_class!(rule_class)
45
+
46
+ rule_id = rule_class.id
47
+ if @rules.any? { |r| r.id == rule_id }
48
+ raise ArgumentError,
49
+ "Rule with id '#{rule_id}' is already registered"
50
+ end
51
+
52
+ @rules << rule_class
53
+ self
54
+ end
55
+
56
+ # Find a rule class by ID (string or symbol).
57
+ #
58
+ # @param id [String, Symbol] the rule identifier
59
+ # @return [Class, nil] the rule class, or nil if not found
60
+ def [](id)
61
+ id_str = id.to_s
62
+ @rules.find { |r| r.id == id_str }
63
+ end
64
+
65
+ # Alias for []
66
+ alias find []
67
+
68
+ # Return all registered rule classes in insertion order.
69
+ #
70
+ # @return [Array<Class>] array of rule classes
71
+ def list
72
+ @rules.dup
73
+ end
74
+
75
+ # Iterate over registered rule classes.
76
+ #
77
+ # @yieldparam rule_class [Class] each registered rule class
78
+ # @return [Enumerator] if no block given
79
+ def each(&)
80
+ @rules.each(&)
81
+ end
82
+
83
+ # Instantiate all enabled rules for the given configuration.
84
+ #
85
+ # Filters rules by configuration.rule_enabled?(id) and returns instances
86
+ # initialized with the registry's workspace, context_resolver, and the
87
+ # provided configuration.
88
+ #
89
+ # @param configuration [FiberAudit::Configuration] audit configuration
90
+ # @return [Array<Base>] array of instantiated rule objects
91
+ def enabled_for(configuration)
92
+ @rules.filter_map do |rule_class|
93
+ next unless configuration.rule_enabled?(rule_class.id)
94
+
95
+ rule_class.new(
96
+ workspace: @workspace,
97
+ context_resolver: @context_resolver,
98
+ configuration: configuration
99
+ )
100
+ end
101
+ end
102
+
103
+ private
104
+
105
+ # Validate that a rule class meets the requirements for registration.
106
+ #
107
+ # @param rule_class [Class] the class to validate
108
+ # @raise [ArgumentError] if validation fails
109
+ def validate_rule_class!(rule_class)
110
+ unless rule_class.is_a?(Class) && rule_class < Base
111
+ raise ArgumentError,
112
+ "rule_class must be a subclass of Base, got #{rule_class.inspect}"
113
+ end
114
+
115
+ return unless rule_class.id.nil? || rule_class.id.empty?
116
+
117
+ raise ArgumentError,
118
+ 'rule_class must have an id set via the DSL'
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,124 @@
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 Synchronization < Base
12
+ id 'FA1003'
13
+ severity :medium
14
+ default_confidence :high
15
+ description 'Thread synchronization primitives that may block the fiber scheduler thread'
16
+
17
+ RULE_TITLE = 'Thread synchronization'
18
+ RULE_CATEGORY = :synchronization
19
+ TARGETS = {
20
+ 'Mutex' => %i[lock synchronize try_lock],
21
+ 'ConditionVariable' => %i[wait],
22
+ 'Monitor' => %i[synchronize],
23
+ 'MonitorMixin' => %i[synchronize]
24
+ }.freeze
25
+ TRY_LOCK_MSG = 'Mutex.try_lock is non-blocking but may indicate ' \
26
+ 'thread-oriented synchronization in fiber-scheduled code.'
27
+ NORMAL_MSG = 'Synchronization operation may block the thread running the fiber scheduler.'
28
+ REMEDIATION = 'Use scheduler-aware synchronization primitives, or verify ' \
29
+ 'contention and scheduler behaviour under load.'
30
+
31
+ def analyze(call_sites:)
32
+ explicit_monitor_mixins = explicit_monitor_mixin_classes(call_sites)
33
+ call_sites.filter_map { |site| check(site, explicit_monitor_mixins) }
34
+ rescue StandardError
35
+ []
36
+ end
37
+
38
+ private
39
+
40
+ def check(site, explicit_monitor_mixins)
41
+ if site.receiver_source.nil? && site.receiver_constant.nil? && site.method_name == :synchronize
42
+ klass = extract_class_name(site.enclosing_symbol)
43
+ semantic_match = klass && monitor_mixin_ancestor?(klass)
44
+ return unless semantic_match || explicit_monitor_mixins.include?(klass)
45
+ return if workspace_shadow?('MonitorMixin', site.nesting)
46
+
47
+ return build_finding(site, 'MonitorMixin', :synchronize)
48
+ end
49
+
50
+ target = site.receiver_constant
51
+ return unless target && TARGETS.fetch(target, []).include?(site.method_name)
52
+ return if workspace_shadow?(target, site.nesting) || site.receiver_source == target
53
+
54
+ build_finding(site, target, site.method_name)
55
+ rescue StandardError
56
+ nil
57
+ end
58
+
59
+ def workspace_shadow?(target, nesting)
60
+ index = workspace_index
61
+ return false unless index.respond_to?(:resolve_constant)
62
+
63
+ !index.resolve_constant(target, nesting: nesting || []).nil?
64
+ rescue StandardError
65
+ false
66
+ end
67
+
68
+ def workspace_index
69
+ index = workspace.semantic_index if workspace.respond_to?(:semantic_index)
70
+ index || (workspace if workspace.respond_to?(:resolve_constant))
71
+ rescue StandardError
72
+ nil
73
+ end
74
+
75
+ def extract_class_name(symbol)
76
+ return unless symbol
77
+
78
+ separator = symbol.include?('#') ? '#' : '.'
79
+ symbol.split(separator, 2).first if symbol.include?(separator)
80
+ end
81
+
82
+ def monitor_mixin_ancestor?(class_name)
83
+ adapters = [workspace]
84
+ adapters << workspace.semantic_index if workspace.respond_to?(:semantic_index)
85
+ adapters.compact.uniq.any? do |adapter|
86
+ adapter.respond_to?(:ancestors_of) &&
87
+ Array(adapter.ancestors_of(class_name)).include?('MonitorMixin')
88
+ rescue StandardError
89
+ false
90
+ end
91
+ end
92
+
93
+ def explicit_monitor_mixin_classes(call_sites)
94
+ call_sites.filter_map do |site|
95
+ next unless site.receiver_source.nil? && site.method_name == :include
96
+ next unless Array(site.arguments).any? { |argument| argument.to_s.delete_prefix('::') == 'MonitorMixin' }
97
+
98
+ Array(site.nesting).last
99
+ end.uniq
100
+ end
101
+
102
+ def build_finding(site, target, method)
103
+ try_lock = target == 'Mutex' && method == :try_lock
104
+ operation = "#{target}##{method}"
105
+ severity = try_lock ? :info : severity_for(:medium, site.execution_context)
106
+ confidence = try_lock ? :high : site.confidence
107
+ message = try_lock ? TRY_LOCK_MSG : NORMAL_MSG
108
+ evidence = Evidence.new(source: operation, message: message,
109
+ details: { receiver: site.receiver_source, method: method.to_s, constant: target })
110
+
111
+ Finding.new(
112
+ rule_id: self.class.id, title: RULE_TITLE, category: RULE_CATEGORY,
113
+ severity: severity, confidence: confidence, location: site.location,
114
+ symbol: site.enclosing_symbol, operation: operation,
115
+ execution_context: site.execution_context, message: message,
116
+ evidence: [evidence], remediation: REMEDIATION
117
+ )
118
+ rescue StandardError
119
+ nil
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,113 @@
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
+ # Detect thread-variable and Thread.current index state.
12
+ class ThreadCurrentState < Base
13
+ id 'FA1004'
14
+ severity :high
15
+ confidence :high
16
+ description 'Thread-local state in fiber code may be shared across fibers and leak request-local data'
17
+
18
+ TITLE = 'Thread-local state in fiber code'
19
+ CATEGORY = :thread_local
20
+ MESSAGE = 'Thread-local state may be shared across fibers and leak request-local data.'
21
+ REMEDIATION = 'Use fiber-local or framework-provided request-local state instead of Thread thread variables.'
22
+
23
+ THREAD_VARIABLE_METHODS = %i[thread_variable_get thread_variable_set].freeze
24
+ INDEX_METHODS = %i[[] []=].freeze
25
+
26
+ def analyze(call_sites:)
27
+ findings = []
28
+ call_sites.each do |site|
29
+ next if skip?(site)
30
+
31
+ finding = match_thread_variable(site) || match_index_op(site)
32
+ findings << finding if finding
33
+ end
34
+ findings
35
+ rescue StandardError
36
+ []
37
+ end
38
+
39
+ private
40
+
41
+ def skip?(site)
42
+ defer_current_attributes?(site) ||
43
+ direct_thread_class?(site) ||
44
+ workspace_thread_shadowed?(site)
45
+ end
46
+
47
+ def defer_current_attributes?(site)
48
+ site.receiver_source.to_s.include?('CurrentAttributes')
49
+ end
50
+
51
+ def direct_thread_class?(site)
52
+ site.receiver_source == 'Thread'
53
+ end
54
+
55
+ def workspace_thread_shadowed?(site)
56
+ index = workspace.semantic_index if workspace.respond_to?(:semantic_index)
57
+ index ||= workspace if workspace.respond_to?(:resolve_constant)
58
+ return false unless index.respond_to?(:resolve_constant)
59
+
60
+ !index.resolve_constant('Thread', nesting: site.nesting || []).nil?
61
+ rescue StandardError
62
+ false
63
+ end
64
+
65
+ def match_thread_variable(site)
66
+ return unless THREAD_VARIABLE_METHODS.include?(site.method_name)
67
+
68
+ instance_match = site.receiver_constant == 'Thread' && site.receiver_source != 'Thread'
69
+ current_match = site.receiver_source == 'Thread.current'
70
+
71
+ return unless instance_match || current_match
72
+
73
+ build_finding(site, :high, :high, operation(site))
74
+ end
75
+
76
+ def match_index_op(site)
77
+ return unless INDEX_METHODS.include?(site.method_name)
78
+ return unless site.receiver_source == 'Thread.current'
79
+
80
+ build_finding(site, :medium, :high, operation(site))
81
+ end
82
+
83
+ def operation(site)
84
+ if INDEX_METHODS.include?(site.method_name)
85
+ "Thread.current.#{site.method_name}"
86
+ else
87
+ "Thread.#{site.method_name}"
88
+ end
89
+ end
90
+
91
+ def build_finding(site, default_sev, conf, operation)
92
+ context = site.execution_context
93
+ severity = severity_for(default_sev, context)
94
+
95
+ Finding.new(
96
+ rule_id: self.class.id,
97
+ title: TITLE,
98
+ category: CATEGORY,
99
+ severity: severity,
100
+ confidence: conf,
101
+ location: site.location,
102
+ symbol: site.enclosing_symbol,
103
+ operation: operation,
104
+ execution_context: context,
105
+ message: MESSAGE,
106
+ evidence: [Evidence.new(source: site.receiver_source, message: "Thread-local access via #{site.method_name}")],
107
+ remediation: REMEDIATION
108
+ )
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,96 @@
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
+ # FA1002: Thread#join / Thread#value may block the fiber-scheduler thread.
12
+ #
13
+ # Matches join/value on Thread instances whose receiver_constant is
14
+ # 'Thread' but whose receiver_source is not the bare literal 'Thread'.
15
+ # Covers direct constructor chains (Thread.new.join), assigned receivers
16
+ # (t = Thread.new; t.join), and the exact syntactic Thread.current form.
17
+ #
18
+ # Skips direct Thread.join/value, arbitrary worker.join, wrong methods,
19
+ # and workspace-shadowed Thread (checked via workspace or
20
+ # workspace.semantic_index resolve_constant seam). Adapter errors from
21
+ # those seams are swallowed — they never raise.
22
+ class ThreadJoin < Base
23
+ id 'FA1002'
24
+ severity :high
25
+ confidence :high
26
+ description 'Waiting for a thread may block the thread running the fiber scheduler.'
27
+
28
+ TITLE = 'Thread wait'
29
+ CATEGORY = :synchronization
30
+ MESSAGE = 'Waiting for a thread may block the thread running the fiber scheduler.'
31
+ REMEDIATION = 'Replace thread waits with scheduler-aware coordination, ' \
32
+ 'or move the work outside the fiber-scheduled path.'
33
+ TARGET_METHODS = %i[join value].freeze
34
+ CANONICAL_OPS = %w[Thread.join Thread.value].freeze
35
+ DIRECT_CLASS_SOURCE = 'Thread'
36
+
37
+ def analyze(call_sites:)
38
+ call_sites.filter_map { |site| match(site) }
39
+ end
40
+
41
+ private
42
+
43
+ def match(site)
44
+ return unless TARGET_METHODS.include?(site.method_name)
45
+ return unless thread_instance?(site)
46
+ return if shadowed_thread?(site)
47
+
48
+ build_finding(site)
49
+ end
50
+
51
+ # Receiver must resolve to Thread but not be the bare literal.
52
+ def thread_instance?(site)
53
+ return true if site.receiver_source == 'Thread.current'
54
+
55
+ site.receiver_constant == 'Thread' && site.receiver_source != DIRECT_CLASS_SOURCE
56
+ rescue StandardError
57
+ false
58
+ end
59
+
60
+ # Check workspace / semantic_index resolve_constant seam.
61
+ # Adapter errors are swallowed.
62
+ def shadowed_thread?(site)
63
+ index = workspace.semantic_index if workspace.respond_to?(:semantic_index)
64
+ index ||= workspace if workspace.respond_to?(:resolve_constant)
65
+ return false unless index.respond_to?(:resolve_constant)
66
+
67
+ !index.resolve_constant('Thread', nesting: site.nesting || []).nil?
68
+ rescue StandardError
69
+ false
70
+ end
71
+
72
+ def build_finding(site)
73
+ loc = site.location
74
+ op = "Thread.#{site.method_name}"
75
+ sev = severity_for(self.class.severity, site.execution_context)
76
+ conf = site.receiver_source == 'Thread.current' ? :high : site.confidence
77
+
78
+ Finding.new(
79
+ rule_id: self.class.id, title: TITLE, category: CATEGORY,
80
+ severity: sev, confidence: conf, location: loc,
81
+ symbol: site.enclosing_symbol, operation: op,
82
+ execution_context: site.execution_context,
83
+ message: MESSAGE,
84
+ evidence: [Evidence.new(
85
+ source: 'static_analysis',
86
+ message: "#{op} on #{site.receiver_source}",
87
+ details: { operation: op, receiver: site.receiver_source,
88
+ canonical_operations: CANONICAL_OPS }
89
+ )],
90
+ remediation: REMEDIATION
91
+ )
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end