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